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 ( Account, Customer, Invoice, InvoiceItem, LedgerEntry, LedgerInstallment, Product, StockMovement, Transaction, ) from apps.bizapps.api.accounting import ( ACCOUNT_CASH, ACCOUNT_PAYABLE, ACCOUNT_RECEIVABLE, ACCOUNT_SALES, ChartOfAccountsService, ExcelExportService, SubsidiaryLedgerService, ) 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_percent=data.get("discount_percent", 0), extra_discount_label=data.get("extra_discount_label", ""), extra_discount_percent=data.get("extra_discount_percent", 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}", ) if invoice.discount_percent: invoice.discount_amount = subtotal * invoice.discount_percent / 100 else: invoice.discount_amount = data.get("discount_amount", 0) after_discount = subtotal - invoice.discount_amount if invoice.extra_discount_percent: invoice.extra_discount_amount = after_discount * invoice.extra_discount_percent / 100 else: invoice.extra_discount_amount = data.get("extra_discount_amount", 0) 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 - invoice.extra_discount_amount + tax_amount ) invoice.save( update_fields=[ "total_amount", "discount_amount", "extra_discount_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_entry_transaction(user: User, entry: LedgerEntry) -> None: """سندِ دوطرفه‌ی یک ثبتِ ساده (غیرِ فاکتوری) — قبلا فقط فاکتورها و اقساط سند می‌خوردند، پس ثبتِ مستقیمِ درآمد/هزینه (که کارِ اصلیِ بیشترِ کاربرهاست) هیچ‌وقت وارد تراز حساب‌ها/دفتر معین نمی‌شد. اینجا سندِ قبلیِ همین ردیف (اگر بود) پاک و دوباره ساخته می‌شود — ساده‌تر و کم‌خطاتر از آپدیتِ جزئی. فاکتورهای فروش (linked_invoice) را عمدا رد می‌کند — سندشان از قبل، موقعِ ساختِ فاکتور، ساخته شده؛ دوباره‌سندزدن یعنی دوبار حساب کردن. """ Transaction.objects.filter(reference_ledger_entry=entry).delete() if entry.linked_invoice_id: return account = entry.account or LedgerService.account_for_category(user, entry.category) if account is None: return # دسته‌ی ناشناخته یا خالی — چیزی برای سند زدن نیست. is_debt = entry.category in LedgerEntry.DEBT_CATEGORIES amount = (entry.total_amount or entry.amount) if is_debt else entry.amount if not amount: return if entry.category == LedgerEntry.Category.SALE_CREDIT: debit_ref = ChartOfAccountsService.get(user, ACCOUNT_RECEIVABLE) credit_ref = account elif entry.category == LedgerEntry.Category.PURCHASE_CREDIT: debit_ref = account credit_ref = ChartOfAccountsService.get(user, ACCOUNT_PAYABLE) elif entry.entry_type == LedgerEntry.EntryType.INCOME: debit_ref = ChartOfAccountsService.get(user, ACCOUNT_CASH) credit_ref = account else: debit_ref = account credit_ref = ChartOfAccountsService.get(user, ACCOUNT_CASH) transaction_type = ( Transaction.TransactionType.CREDIT_SALE if entry.category == LedgerEntry.Category.SALE_CREDIT else Transaction.TransactionType.PURCHASE if entry.category == LedgerEntry.Category.PURCHASE_CREDIT else Transaction.TransactionType.CASH_SALE if entry.entry_type == LedgerEntry.EntryType.INCOME else Transaction.TransactionType.EXPENSE ) number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "LED") naive_dt = timezone.datetime.combine(entry.entry_date, timezone.datetime.min.time()) Transaction.objects.create( id=str(uuid.uuid4()), user=user, transaction_number=number, transaction_date=timezone.make_aware(naive_dt) if timezone.is_naive(naive_dt) else naive_dt, reference_ledger_entry=entry, transaction_type=transaction_type, debit_account=debit_ref.code if debit_ref else "", credit_account=credit_ref.code if credit_ref else "", debit_account_ref=debit_ref, credit_account_ref=credit_ref, amount=amount, description=entry.title, ) @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 به‌شکلِ واقعیِ دفتر معین — به‌ازای هر حسابِ معینِ دارای گردش: سربرگِ حساب، مانده‌ی ابتدای دوره، سندهای آن حساب به‌ترتیبِ تاریخ (با بدهکار/بستانکار/مانده‌ی در جریان)، و مانده‌ی پایانِ دوره — دقیقا همان ساختاری که در خروجیِ اکسلِ چندبرگی هم هست، فقط سبک‌تر و تک‌برگی. قبلا این خروجی فقط یک دامپِ خامِ ردیف‌های دفترِ ساده بود (بدونِ حساب/بدهکار/بستانکار/مانده) که هیچ شباهتی به دفتر معین نداشت. """ buf = io.StringIO() buf.write("") # BOM writer = csv.writer(buf) writer.writerow(["تاریخ", "شماره سند", "نوع", "شرح", "بدهکار", "بستانکار", "مانده"]) accounts = Account.objects.filter(user=user, level=Account.Level.MOIN).order_by("code") wrote_any_row = False for account in accounts: statement = SubsidiaryLedgerService.statement(user, account, start, end) if not statement["rows"]: continue wrote_any_row = True writer.writerow([f"{account.code} — {account.name}", "", "", "", "", "", ""]) writer.writerow( ["مانده‌ی ابتدای دوره", "", "", "", "", "", f"{statement['opening_balance']:.0f}"] ) for row in statement["rows"]: row_date = row["date"] writer.writerow( [ row_date.date().isoformat() if hasattr(row_date, "date") else row_date, row["number"], row["type"], row["description"], f"{row['debit']:.0f}" if row["debit"] else "", f"{row['credit']:.0f}" if row["credit"] else "", f"{row['balance']:.0f}", ] ) writer.writerow( ["مانده‌ی پایانِ دوره", "", "", "", "", "", f"{statement['closing_balance']:.0f}"] ) writer.writerow([]) if not wrote_any_row: writer.writerow(["در این بازه هیچ حسابی گردش نداشته.", "", "", "", "", "", ""]) return buf.getvalue()