Files
shopship/apps/bizapps/api/services.py
T
2026-09-04 13:05:54 +03:30

494 lines
24 KiB
Python

import csv
import io
import logging
import uuid
from django.db import transaction
from django.utils import timezone
from apps.account.models import User
from apps.banker.models import Contact
from apps.bizapps.models import (
Customer,
Invoice,
InvoiceItem,
LedgerEntry,
LedgerInstallment,
Product,
StockMovement,
Transaction,
)
from apps.bizapps.api.accounting import (
ACCOUNT_CASH,
ACCOUNT_PAYABLE,
ACCOUNT_RECEIVABLE,
ACCOUNT_SALES,
ChartOfAccountsService,
ExcelExportService,
)
logger = logging.getLogger(__name__)
class InsufficientStockError(Exception):
"""فروشِ بیش از موجودیِ انبار — عمدا یک استثنای جدا (نه ValueError عمومی)
تا ویو بتواند دقیقا همین حالت را به پیامِ ۴۰۰ قابل‌فهم تبدیل کند."""
class InvoiceService:
@staticmethod
def _next_sequenced_number(user: User, model, field_name: str, prefix: str) -> str:
"""فرمت PREFIX-YYMM-NNNN با سکانسِ ماهانه‌ی جداگانه برای هر کاربر —
سطر آخرِ همان ماه را قفل می‌کند (select_for_update) تا دو درخواستِ
هم‌زمان شماره‌ی تکراری نگیرند."""
now = timezone.now()
month_prefix = f"{prefix}-{now:%y%m}-"
last = (
model.objects.select_for_update()
.filter(user=user, **{f"{field_name}__startswith": month_prefix})
.order_by(f"-{field_name}")
.first()
)
if last:
last_seq = int(getattr(last, field_name).rsplit("-", 1)[-1])
else:
last_seq = 0
return f"{month_prefix}{last_seq + 1:04d}"
@staticmethod
def _upsert_customer_contact(user: User, phone: str, name: str) -> Contact | None:
phone = (phone or "").strip()
if not phone:
return None
contact = Contact.objects.filter(user=user, phone=phone).first()
if contact:
return contact
return Contact.objects.create(id=str(uuid.uuid4()), user=user, name=name or phone, phone=phone)
@staticmethod
@transaction.atomic
def create_invoice(user: User, data: dict, send_sms: bool = False) -> Invoice:
items_data = data["items"]
invoice_number = InvoiceService._next_sequenced_number(user, Invoice, "invoice_number", "INV")
customer: Customer | None = data.get("customer")
payment_type = data.get("payment_type", Invoice.PaymentType.CASH)
order_status = (
Invoice.OrderStatus.PENDING_PAYMENT
if payment_type == Invoice.PaymentType.CREDIT
else Invoice.OrderStatus.READY_TO_SHIP
)
invoice = Invoice.objects.create(
id=str(uuid.uuid4()),
user=user,
invoice_number=invoice_number,
invoice_date=data.get("invoice_date") or timezone.now(),
customer=customer,
buyer_name=data.get("buyer_name", "") or (customer.name if customer else ""),
buyer_phone=data.get("buyer_phone", "") or (customer.phone if customer else ""),
buyer_address=data.get("buyer_address", "") or (customer.address if customer else ""),
seller_name=data.get("seller_name", ""),
seller_phone=data.get("seller_phone", ""),
seller_address=data.get("seller_address", ""),
discount_amount=data.get("discount_amount", 0),
tax_lines=data.get("tax_lines", []),
payment_type=payment_type,
shipping_method=data.get("shipping_method", Invoice.ShippingMethod.PICKUP),
order_status=order_status,
tracking_code=data.get("tracking_code", ""),
notes=data.get("notes", ""),
)
subtotal = 0.0
for item_data in items_data:
product: Product | None = item_data.get("product")
quantity = item_data["quantity"]
# طبق سند: قیمت واحد در زمان فروش از SellingPrice کالا کپی می‌شود
# (سمت کلاینت قابل‌دستکاری نیست) — فقط برای ردیف‌های آزاد (بدون
# کالا) قیمتِ ارسالی کلاینت به‌عنوان قیمت واحد پذیرفته می‌شود.
unit_price = product.unit_price if product else item_data["unit_price"]
description = item_data.get("description") or (product.name if product else "")
item = InvoiceItem(
id=str(uuid.uuid4()),
invoice=invoice,
product=product,
description=description,
quantity=quantity,
unit_price=unit_price,
discount_percent=item_data.get("discount_percent", 0),
)
item.compute_total()
item.save()
subtotal += item.total_price
if product is not None:
product = Product.objects.select_for_update().get(pk=product.pk)
# سیاستِ صریح: فروشِ بیش از موجودی جلویش گرفته می‌شود، نه
# این‌که موجودی بی‌سروصدا منفی شود. اگر کاربر واقعا جنس را
# دارد ولی در انبار ثبت نکرده، اول باید موجودی را اصلاح کند —
# وگرنه ارزشِ انبار و بهای تمام‌شده هر دو غلط می‌مانند.
if quantity > product.quantity:
raise InsufficientStockError(
f"موجودی «{product.name}» کافی نیست — "
f"موجودی فعلی: {product.quantity:g} {product.unit or ''}، "
f"درخواستی: {quantity:g}."
)
product.quantity -= quantity
product.recompute_purchase_value()
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
StockMovement.objects.create(
product=product,
delta=-quantity,
quantity_after=product.quantity,
note=f"فروش فاکتور {invoice_number}",
)
tax_amount = sum(subtotal * float(line.get("percent", 0)) / 100 for line in invoice.tax_lines)
invoice.total_amount = subtotal
invoice.final_amount = subtotal - invoice.discount_amount + tax_amount
invoice.save(update_fields=["total_amount", "final_amount"])
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
is_credit = payment_type == Invoice.PaymentType.CREDIT
debit_code = ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
transaction_number=transaction_number,
transaction_date=invoice.invoice_date,
reference_invoice=invoice,
transaction_type=(
Transaction.TransactionType.CREDIT_SALE if is_credit else Transaction.TransactionType.CASH_SALE
),
debit_account=debit_code,
credit_account=ACCOUNT_SALES,
debit_account_ref=ChartOfAccountsService.get(user, debit_code),
credit_account_ref=ChartOfAccountsService.get(user, ACCOUNT_SALES),
amount=invoice.final_amount,
description=f"فروش فاکتور {invoice_number}",
)
if customer is not None:
customer = Customer.objects.select_for_update().get(pk=customer.pk)
customer.total_purchases_count += 1
customer.total_purchase_amount += invoice.final_amount
customer.last_purchase_date = invoice.invoice_date
customer.save(
update_fields=["total_purchases_count", "total_purchase_amount", "last_purchase_date", "updated_at"]
)
# حسابِ تفصیلیِ اختصاصیِ مشتری زیرِ «حساب‌های دریافتنی» — از همین
# اولین فاکتور ساخته می‌شود تا بدهی‌اش در درختِ حساب‌ها ردیابی شود.
ChartOfAccountsService.ensure_customer_account(user, customer)
# طبق درخواست: فروشِ قسطی/نسیه باید خودکار به دفتر درآمد/هزینه به‌عنوان
# یک ردیفِ بدهی‌دار (نه صرفا یک عدد در لیست فاکتورها) وصل شود — همان
# مثالِ «۱۰۰ تومن فروش قسطی از نظر عادی درآمد، از نظر حرفه‌ای بدهکاریه».
if is_credit:
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=user,
entry_type=LedgerEntry.EntryType.INCOME,
title=f"فاکتور {invoice_number}",
amount=invoice.final_amount,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=invoice.invoice_date.date(),
total_amount=invoice.final_amount,
counterparty_name=invoice.buyer_name,
counterparty_phone=invoice.buyer_phone,
linked_invoice=invoice,
account=LedgerService.account_for_category(user, LedgerEntry.Category.SALE_CREDIT),
)
if invoice.buyer_phone:
LedgerService.recompute_customer_balance(user, invoice.buyer_phone)
if send_sms and invoice.buyer_phone:
InvoiceService._send_invoice_sms(user, invoice)
logger.info(
"invoice created: user=%s invoice=%s final_amount=%s transaction=%s",
user.phone,
invoice_number,
invoice.final_amount,
transaction_number,
)
return invoice
@staticmethod
def _send_invoice_sms(user: User, invoice: Invoice) -> None:
# ایمپورت دیرهنگام برای جلوگیری از وابستگی چرخه‌ای در import-time
# بین اپ‌های bizapps و messaging.
from apps.messaging.api.services import BulkMessageService
from apps.messaging.models import Channel
contact = InvoiceService._upsert_customer_contact(user, invoice.buyer_phone, invoice.buyer_name)
if contact is None:
return
body = f"فاکتور {invoice.invoice_number} به مبلغ {invoice.final_amount:,.0f} تومان ثبت شد."
BulkMessageService.create_and_dispatch(
user=user,
channels=[Channel.SMS],
contact_ids=[contact.id],
body_override=body,
)
@staticmethod
@transaction.atomic
def cancel_invoice(user: User, invoice_id: str) -> Invoice:
invoice = Invoice.objects.select_for_update().get(pk=invoice_id, user=user)
if invoice.order_status == Invoice.OrderStatus.CANCELLED:
raise ValueError("این فاکتور قبلا لغو شده است.")
for item in invoice.items.select_related("product"):
if item.product is None:
continue
product = Product.objects.select_for_update().get(pk=item.product_id)
product.quantity += item.quantity
product.recompute_purchase_value()
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
StockMovement.objects.create(
product=product,
delta=item.quantity,
quantity_after=product.quantity,
note=f"لغو فاکتور {invoice.invoice_number}",
)
# ردیفِ دفترِ بدهی‌دارِ متصل به این فاکتور: اگر هنوز هیچ قسطی روی آن
# ثبت نشده، با لغوِ فاکتور آن بدهی هم دیگر معنی ندارد — حذفش می‌کنیم.
# اگر قسطی ثبت شده (یعنی پولی واقعا رد و بدل شده)، تاریخچه‌ی مالی را
# پاک نمی‌کنیم؛ همان‌جا برای رسیدگیِ دستیِ کاربر می‌ماند.
linked_entry = invoice.ledger_entries.first()
if linked_entry is not None and not linked_entry.installments.exists():
phone = linked_entry.counterparty_phone
linked_entry.delete()
if phone:
LedgerService.recompute_customer_balance(user, phone)
original = invoice.transactions.order_by("created_at").first()
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
transaction_number=transaction_number,
transaction_date=timezone.now(),
reference_invoice=invoice,
transaction_type=original.transaction_type if original else Transaction.TransactionType.CASH_SALE,
debit_account=original.credit_account if original else ACCOUNT_SALES,
credit_account=original.debit_account if original else ACCOUNT_CASH,
# سندِ معکوس: جای بدهکار و بستانکارِ سندِ اصلی عوض می‌شود.
debit_account_ref=original.credit_account_ref if original else None,
credit_account_ref=original.debit_account_ref if original else None,
amount=-(original.amount if original else invoice.final_amount),
description=f"لغو فاکتور {invoice.invoice_number}",
)
invoice.order_status = Invoice.OrderStatus.CANCELLED
invoice.save(update_fields=["order_status", "updated_at"])
logger.info(
"invoice cancelled: user=%s invoice=%s reversal_transaction=%s",
user.phone,
invoice.invoice_number,
transaction_number,
)
return invoice
@staticmethod
@transaction.atomic
def update_status(
user: User,
invoice_id: str,
order_status: str | None = None,
payment_status: str | None = None,
tracking_code: str | None = None,
) -> Invoice:
invoice = Invoice.objects.select_for_update().get(pk=invoice_id, user=user)
update_fields = ["updated_at"]
status_changed = order_status is not None and order_status != invoice.order_status
if order_status is not None:
invoice.order_status = order_status
update_fields.append("order_status")
if payment_status is not None:
invoice.payment_status = payment_status
if payment_status == Invoice.PaymentStatus.PAID and invoice.paid_at is None:
invoice.paid_at = timezone.now()
update_fields.append("paid_at")
update_fields.append("payment_status")
if tracking_code is not None:
invoice.tracking_code = tracking_code
update_fields.append("tracking_code")
invoice.save(update_fields=update_fields)
logger.info(
"invoice status updated: user=%s invoice=%s order_status=%s payment_status=%s",
user.phone,
invoice.invoice_number,
invoice.order_status,
invoice.payment_status,
)
# اگر فاکتور به «پرداخت‌شده» رفت و به یک ردیفِ بدهی‌دار وصل بود، همان
# مانده را به‌عنوان یک قسطِ تسویه‌ی کامل ثبت می‌کنیم — کاربر مجبور
# نیست دستی برود توی دفتر و قسط بزند.
if payment_status == Invoice.PaymentStatus.PAID:
linked_entry = invoice.ledger_entries.first()
if linked_entry is not None and linked_entry.remaining_amount > 0:
LedgerInstallment.objects.create(
id=str(uuid.uuid4()),
ledger_entry=linked_entry,
amount=linked_entry.remaining_amount,
paid_date=timezone.now().date(),
reference=f"تسویه‌ی کامل فاکتور {invoice.invoice_number}",
)
if linked_entry.counterparty_phone:
LedgerService.recompute_customer_balance(user, linked_entry.counterparty_phone)
# طبق سند: «پس از هر تغییر وضعیت سفارش، یک پیامک ارسال شود» — برخلاف
# پیامکِ ثبت فاکتور (که opt-in است)، این یکی خودکار است، چون همان
# رفتاری‌ست که apps.orders از قبل برای مراحل پیگیری سفارش دارد و
# کاربر با آن آشناست.
if status_changed and invoice.buyer_phone:
InvoiceService._send_status_sms(user, invoice)
return invoice
@staticmethod
def _send_status_sms(user: User, invoice: Invoice) -> None:
from apps.messaging.api.services import BulkMessageService
from apps.messaging.models import Channel
contact = InvoiceService._upsert_customer_contact(user, invoice.buyer_phone, invoice.buyer_name)
if contact is None:
return
status_label = Invoice.OrderStatus(invoice.order_status).label
body = f"وضعیت سفارش فاکتور {invoice.invoice_number}: {status_label}"
BulkMessageService.create_and_dispatch(
user=user,
channels=[Channel.SMS],
contact_ids=[contact.id],
body_override=body,
)
class LedgerService:
"""هماهنگیِ لایه‌ی «عادی» (دسته‌بندی ساده) با لایه‌ی «حرفه‌ای» (بدهکاری/
بستانکاری واقعی) — طبق درخواست: کاربرِ معمولی فقط دسته را انتخاب می‌کند،
این سرویس تضمین می‌کند مانده‌ی بدهیِ هر مشتری (Customer.outstanding_balance)
همیشه با جمعِ واقعیِ ردیف‌های «فروش قسطی»ِ همان شماره‌تماس هم‌خوان بماند."""
@staticmethod
def account_for_category(user: User, category: str):
"""دسته‌ی «عادی» را به حسابِ معینِ متناظرش در درختِ حساب‌ها نگاشت
می‌کند — همان پلی که لایه‌ی ساده را به لایه‌ی حرفه‌ای وصل می‌کند."""
code = LedgerEntry.CATEGORY_ACCOUNT_CODES.get(category)
if not code:
return None
return ChartOfAccountsService.get(user, code)
@staticmethod
def sync_entry_account(user: User, entry: LedgerEntry) -> None:
"""حسابِ معینِ یک ردیف را با دسته‌اش هم‌خوان می‌کند — بعد از هر
ساخت/ویرایش صدا زده می‌شود تا دسته و حساب هیچ‌وقت واگرا نشوند."""
account = LedgerService.account_for_category(user, entry.category)
if entry.account_id != (account.id if account else None):
entry.account = account
entry.save(update_fields=["account", "updated_at"])
@staticmethod
@transaction.atomic
def post_installment_transaction(user: User, installment: LedgerInstallment) -> None:
"""هر قسطِ دریافتی/پرداختی یک سندِ دوطرفه هم می‌سازد: صندوق بدهکار،
حساب دریافتنی بستانکار (و برای بدهی‌های خودمان، برعکس)."""
entry = installment.ledger_entry
is_receivable = entry.category == LedgerEntry.Category.SALE_CREDIT
cash = ChartOfAccountsService.get(user, ACCOUNT_CASH)
counter_code = ACCOUNT_RECEIVABLE if is_receivable else ACCOUNT_PAYABLE
counter = ChartOfAccountsService.get(user, counter_code)
number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
transaction_number=number,
transaction_date=timezone.now(),
reference_invoice=entry.linked_invoice,
transaction_type=Transaction.TransactionType.INSTALLMENT,
debit_account=ACCOUNT_CASH if is_receivable else counter_code,
credit_account=counter_code if is_receivable else ACCOUNT_CASH,
debit_account_ref=cash if is_receivable else counter,
credit_account_ref=counter if is_receivable else cash,
amount=installment.amount,
description=f"دریافت قسط — {entry.title}" if is_receivable else f"پرداخت قسط — {entry.title}",
)
@staticmethod
def recompute_customer_balance(user: User, phone: str) -> None:
phone = (phone or "").strip()
if not phone:
return
customer = Customer.objects.filter(user=user, phone=phone).first()
if customer is None:
return
receivables = LedgerEntry.objects.filter(
user=user, category=LedgerEntry.Category.SALE_CREDIT, counterparty_phone=phone
).prefetch_related("installments")
balance = sum(e.remaining_amount for e in receivables)
if customer.outstanding_balance != balance:
customer.outstanding_balance = balance
customer.save(update_fields=["outstanding_balance", "updated_at"])
@staticmethod
def export_xlsx(user: User, start=None, end=None) -> bytes:
"""خروجیِ اکسلِ واقعیِ چندبرگی (خلاصه، دفتر روزنامه، ترازِ حساب‌ها،
بدهکاران، فاکتورها، انبار) — همان چیزی که مستقیم به‌دردِ حسابدارِ
کاربر می‌خورد، برخلافِ CSV تک‌برگیِ خام."""
return ExcelExportService.build(user, start, end)
@staticmethod
def export_csv(user: User, start=None, end=None) -> str:
"""خروجیِ CSV ساده — برای وقتی کاربر فقط یک فایلِ سبکِ تک‌برگی
می‌خواهد. نسخه‌ی کاملِ چندبرگی در export_xlsx است."""
qs = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related("installments")
if start:
qs = qs.filter(entry_date__gte=start)
if end:
qs = qs.filter(entry_date__lte=end)
buf = io.StringIO()
buf.write("") # BOM
writer = csv.writer(buf)
writer.writerow(
[
"تاریخ",
"نوع",
"دسته",
"عنوان",
"مبلغ",
"طرف حساب",
"شماره طرف حساب",
"مبلغ کل تعهد",
"پرداخت‌شده",
"مانده",
"یادداشت",
]
)
for e in qs:
writer.writerow(
[
e.entry_date.isoformat(),
e.get_entry_type_display(),
e.get_category_display() if e.category else "",
e.title,
f"{e.amount:.0f}",
e.counterparty_name,
e.counterparty_phone,
f"{e.total_amount:.0f}" if e.is_debt else "",
f"{e.paid_amount:.0f}" if e.is_debt else "",
f"{e.remaining_amount:.0f}" if e.is_debt else "",
e.note,
]
)
return buf.getvalue()