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()