""" لایه‌ی حسابداریِ حرفه‌ای — درختِ حساب‌ها (کل/معین/تفصیلی)، ترازِ حساب‌ها، و خروجیِ اکسلِ چندبرگی. کاربرِ اپ هیچ‌وقت مجبور نیست این لایه را ببیند؛ او فقط یک دسته‌ی آشنا («فروش قسطی»، «اجاره»…) انتخاب می‌کند. این ماژول همان انتخاب را به یک حسابِ معینِ واقعی نگاشت می‌کند تا گزارش‌ها، ترازِ حساب‌ها و خروجیِ اکسل از یک ساختارِ درستِ حسابداری ساخته شوند. """ import io import uuid from datetime import date from django.db import transaction as db_transaction from django.db.models import Q, Sum from django.utils import timezone from apps.account.models import User from apps.bizapps.models import ( Account, Customer, Invoice, LedgerBook, LedgerEntry, Product, Transaction, ) # درختِ پیش‌فرضِ حساب‌ها برای یک کسب‌وکار خرد — عمداً کوچک نگه داشته شده # (پنج حساب کل، چند معین زیر هرکدام) چون مخاطبِ این اپ مغازه‌دار است نه # شرکتِ حسابداری. کاربر می‌تواند از پنل خودش حسابِ تازه اضافه کند. DEFAULT_CHART = [ # (code, name, level, type, parent_code) ("1000", "دارایی‌ها", 1, Account.AccountType.ASSET, None), ("1001", "صندوق", 2, Account.AccountType.ASSET, "1000"), ("1002", "حساب بانکی", 2, Account.AccountType.ASSET, "1000"), ("1100", "حساب‌های دریافتنی", 2, Account.AccountType.ASSET, "1000"), ("1200", "موجودی انبار", 2, Account.AccountType.ASSET, "1000"), ("2000", "بدهی‌ها", 1, Account.AccountType.LIABILITY, None), ("2100", "حساب‌های پرداختنی", 2, Account.AccountType.LIABILITY, "2000"), ("3000", "سرمایه", 1, Account.AccountType.EQUITY, None), ("3001", "سرمایه‌ی مالک", 2, Account.AccountType.EQUITY, "3000"), ("4000", "درآمدها", 1, Account.AccountType.INCOME, None), ("4001", "فروش کالا", 2, Account.AccountType.INCOME, "4000"), ("4002", "درآمد خدمات", 2, Account.AccountType.INCOME, "4000"), ("4003", "سایر درآمدها", 2, Account.AccountType.INCOME, "4000"), ("5000", "هزینه‌ها", 1, Account.AccountType.EXPENSE, None), ("5001", "بهای تمام‌شده‌ی کالای فروش‌رفته", 2, Account.AccountType.EXPENSE, "5000"), ("5002", "اجاره", 2, Account.AccountType.EXPENSE, "5000"), ("5003", "حقوق و دستمزد", 2, Account.AccountType.EXPENSE, "5000"), ("5004", "قبض و آب‌وبرق", 2, Account.AccountType.EXPENSE, "5000"), ("5005", "حمل‌ونقل", 2, Account.AccountType.EXPENSE, "5000"), ("5006", "سایر هزینه‌ها", 2, Account.AccountType.EXPENSE, "5000"), ] ACCOUNT_CASH = "1001" ACCOUNT_RECEIVABLE = "1100" ACCOUNT_PAYABLE = "2100" ACCOUNT_SALES = "4001" ACCOUNT_INVENTORY = "1200" class ChartOfAccountsService: @staticmethod def ensure_chart(user: User) -> None: """درختِ پیش‌فرض را برای کاربر می‌سازد اگر هنوز ساخته نشده باشد. idempotent است — اجرای دوباره چیزی را دوباره نمی‌سازد.""" existing = set(Account.objects.filter(user=user).values_list("code", flat=True)) if len(existing) >= len(DEFAULT_CHART): return by_code: dict[str, Account] = { a.code: a for a in Account.objects.filter(user=user) } # دو پاس: اول حساب‌های کل (بدون والد)، بعد معین‌ها که به والد نیاز دارند. for level_pass in (1, 2, 3): for code, name, level, acc_type, parent_code in DEFAULT_CHART: if level != level_pass or code in by_code: continue account = Account.objects.create( id=str(uuid.uuid4()), user=user, code=code, name=name, level=level, account_type=acc_type, parent=by_code.get(parent_code) if parent_code else None, is_system=True, ) by_code[code] = account @staticmethod def get(user: User, code: str) -> Account | None: """حسابِ معین را با کدش برمی‌گرداند؛ اگر درخت هنوز ساخته نشده، اول می‌سازدش.""" account = Account.objects.filter(user=user, code=code).first() if account is None: ChartOfAccountsService.ensure_chart(user) account = Account.objects.filter(user=user, code=code).first() return account @staticmethod def ensure_customer_account(user: User, customer: Customer) -> Account: """برای هر مشتری یک حسابِ تفصیلی زیرِ «حساب‌های دریافتنی» می‌سازد — همان چیزی که بدهیِ او را در درختِ حساب‌ها قابل‌ردیابی می‌کند.""" if customer.account_id: return customer.account parent = ChartOfAccountsService.get(user, ACCOUNT_RECEIVABLE) # کدِ تفصیلی: 1100-0001, 1100-0002, … used = Account.objects.filter(user=user, parent=parent).count() code = f"{ACCOUNT_RECEIVABLE}-{used + 1:04d}" account = Account.objects.create( id=str(uuid.uuid4()), user=user, code=code, name=customer.name, level=Account.Level.TAFSILI, account_type=Account.AccountType.ASSET, parent=parent, is_system=False, ) customer.account = account customer.save(update_fields=["account", "updated_at"]) return account @staticmethod def trial_balance(user: User, start: date | None = None, end: date | None = None) -> list[dict]: """ترازِ حساب‌ها — برای هر حسابِ معین، جمعِ بدهکار و بستانکار و مانده (با در نظر گرفتنِ ماهیتِ طبیعیِ حساب).""" ChartOfAccountsService.ensure_chart(user) qs = Transaction.objects.filter(user=user) if start: qs = qs.filter(transaction_date__date__gte=start) if end: qs = qs.filter(transaction_date__date__lte=end) debit = dict( qs.exclude(debit_account_ref=None) .values_list("debit_account_ref") .annotate(total=Sum("amount")) ) credit = dict( qs.exclude(credit_account_ref=None) .values_list("credit_account_ref") .annotate(total=Sum("amount")) ) rows = [] for account in Account.objects.filter(user=user, level=Account.Level.MOIN).order_by("code"): d = debit.get(account.id, 0) or 0 c = credit.get(account.id, 0) or 0 if d == 0 and c == 0: continue balance = (d - c) if account.is_debit_nature else (c - d) rows.append( { "code": account.code, "name": account.name, "path": account.full_path, "account_type": account.get_account_type_display(), "debit": d, "credit": c, "balance": balance, } ) return rows class CustomerStatementService: """صورت‌حسابِ یک مشتری — فاکتورها، بدهی‌های باز، و اقساطِ پرداختی، همه در یک جا. همان چیزی که «اتصالِ مشتری به فاکتور و حسابداری» را برای کاربر ملموس می‌کند.""" @staticmethod def build(user: User, customer: Customer) -> dict: invoices = ( Invoice.objects.filter(user=user) .filter(Q(customer=customer) | Q(buyer_phone=customer.phone)) .exclude(buyer_phone="") .order_by("-invoice_date") if customer.phone else Invoice.objects.filter(user=user, customer=customer).order_by("-invoice_date") ) debts = ( LedgerEntry.objects.filter( user=user, category=LedgerEntry.Category.SALE_CREDIT, counterparty_phone=customer.phone, ).prefetch_related("installments") if customer.phone else LedgerEntry.objects.none() ) debt_rows = [] for entry in debts: debt_rows.append( { "id": entry.id, "title": entry.title, "entry_date": entry.entry_date, "total_amount": entry.total_amount, "paid_amount": entry.paid_amount, "remaining_amount": entry.remaining_amount, "installments": [ {"amount": i.amount, "paid_date": i.paid_date, "reference": i.reference} for i in entry.installments.all() ], } ) return { "customer": { "id": customer.id, "name": customer.name, "phone": customer.phone, "account_code": customer.account.code if customer.account_id else "", "outstanding_balance": customer.outstanding_balance, "total_purchases_count": customer.total_purchases_count, "total_purchase_amount": customer.total_purchase_amount, "last_purchase_date": customer.last_purchase_date, }, "invoices": [ { "id": inv.id, "invoice_number": inv.invoice_number, "invoice_date": inv.invoice_date, "final_amount": inv.final_amount, "payment_type": inv.get_payment_type_display(), "payment_status": inv.get_payment_status_display(), "order_status": inv.get_order_status_display(), } for inv in invoices ], "debts": debt_rows, } class ExcelExportService: """خروجیِ اکسلِ واقعی (.xlsx) و چندبرگی — به‌جای یک CSV تک‌برگیِ خام. برگه‌ها: خلاصه، دفتر روزنامه، تراز حساب‌ها، بدهکاران، فاکتورها، انبار. هدفِ صریح: «به دردِ مشتری بخورد» — یعنی همان چیزی که یک مغازه‌دار می‌تواند مستقیم برای حسابدارش بفرستد، با ستون‌های فارسیِ راست‌چین، عددهای هزارگان‌دار، و جمعِ ستون‌ها.""" HEADER_FILL = "FF2D2A55" # سرمه‌ای برند MONEY_FORMAT = "#,##0" @staticmethod def _style_sheet(ws, header_row: int = 1) -> None: from openpyxl.styles import Alignment, Font, PatternFill ws.sheet_view.rightToLeft = True fill = PatternFill("solid", fgColor=ExcelExportService.HEADER_FILL) for cell in ws[header_row]: if cell.value is None: continue cell.font = Font(bold=True, color="FFFFFFFF") cell.fill = fill cell.alignment = Alignment(horizontal="center", vertical="center") ws.freeze_panes = ws.cell(row=header_row + 1, column=1) @staticmethod def _autosize(ws) -> None: for column_cells in ws.columns: longest = 0 letter = None for cell in column_cells: letter = letter or cell.column_letter value = cell.value if value is not None: longest = max(longest, len(str(value))) if letter: ws.column_dimensions[letter].width = min(max(longest + 4, 12), 45) @staticmethod def _write(ws, headers: list[str], rows: list[list], money_columns: set[int] | None = None) -> None: ws.append(headers) for row in rows: ws.append(row) if money_columns: for row_cells in ws.iter_rows(min_row=2): for idx in money_columns: if idx < len(row_cells): row_cells[idx].number_format = ExcelExportService.MONEY_FORMAT ExcelExportService._style_sheet(ws) ExcelExportService._autosize(ws) @staticmethod def build(user: User, start: date | None = None, end: date | None = None) -> bytes: from openpyxl import Workbook ChartOfAccountsService.ensure_chart(user) wb = Workbook() entries = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related( "installments", "account" ) invoices = Invoice.objects.filter(user=user).order_by("invoice_date") if start: entries = entries.filter(entry_date__gte=start) invoices = invoices.filter(invoice_date__date__gte=start) if end: entries = entries.filter(entry_date__lte=end) invoices = invoices.filter(invoice_date__date__lte=end) entries = list(entries) invoices = list(invoices) # --- برگه ۱: خلاصه --- ws = wb.active ws.title = "خلاصه" total_income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME) total_expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE) open_receivable = sum( e.remaining_amount for e in entries if e.category == LedgerEntry.Category.SALE_CREDIT ) open_payable = sum( e.remaining_amount for e in entries if e.category == LedgerEntry.Category.PURCHASE_CREDIT ) inventory_value = ( Product.objects.filter(user=user).aggregate(v=Sum("total_purchase_value"))["v"] or 0 ) ExcelExportService._write( ws, ["عنوان", "مبلغ"], [ ["بازه‌ی گزارش", f"{start or 'ابتدا'} تا {end or 'امروز'}"], ["مجموع درآمد", total_income], ["مجموع هزینه", total_expense], ["سود/زیان خالص", total_income - total_expense], ["مانده‌ی مطالبات (طلب از مشتریان)", open_receivable], ["مانده‌ی بدهی‌ها (به تامین‌کنندگان)", open_payable], ["ارزش موجودی انبار", inventory_value], ["تعداد فاکتورها", len(invoices)], ], money_columns={1}, ) # --- برگه ۲: دفتر روزنامه --- ws = wb.create_sheet("دفتر روزنامه") ExcelExportService._write( ws, [ "تاریخ", "نوع", "دسته", "حساب معین", "عنوان", "مبلغ", "طرف حساب", "شماره طرف حساب", "مبلغ کل تعهد", "پرداخت‌شده", "مانده", "یادداشت", ], [ [ e.entry_date.isoformat(), e.get_entry_type_display(), e.get_category_display() if e.category else "", e.account.name if e.account_id else "", e.title, e.amount, e.counterparty_name, e.counterparty_phone, e.total_amount if e.is_debt else None, e.paid_amount if e.is_debt else None, e.remaining_amount if e.is_debt else None, e.note, ] for e in entries ], money_columns={5, 8, 9, 10}, ) # --- برگه ۳: تراز حساب‌ها --- ws = wb.create_sheet("تراز حساب‌ها") ExcelExportService._write( ws, ["کد حساب", "نام حساب", "مسیر", "نوع", "بدهکار", "بستانکار", "مانده"], [ [r["code"], r["name"], r["path"], r["account_type"], r["debit"], r["credit"], r["balance"]] for r in ChartOfAccountsService.trial_balance(user, start, end) ], money_columns={4, 5, 6}, ) # --- برگه ۴: بدهکاران --- ws = wb.create_sheet("بدهکاران") debtors = [ c for c in Customer.objects.filter(user=user).order_by("-outstanding_balance") if c.outstanding_balance ] ExcelExportService._write( ws, ["نام مشتری", "شماره تماس", "کد حساب تفصیلی", "مانده بدهی", "تعداد خرید", "مجموع خرید", "آخرین خرید"], [ [ c.name, c.phone, c.account.code if c.account_id else "", c.outstanding_balance, c.total_purchases_count, c.total_purchase_amount, c.last_purchase_date.date().isoformat() if c.last_purchase_date else "", ] for c in debtors ], money_columns={3, 5}, ) # --- برگه ۵: فاکتورها --- ws = wb.create_sheet("فاکتورها") ExcelExportService._write( ws, ["شماره فاکتور", "تاریخ", "خریدار", "شماره خریدار", "جمع کل", "تخفیف", "مبلغ نهایی", "نوع پرداخت", "وضعیت پرداخت", "وضعیت سفارش"], [ [ inv.invoice_number, inv.invoice_date.date().isoformat(), inv.buyer_name, inv.buyer_phone, inv.total_amount, inv.discount_amount, inv.final_amount, inv.get_payment_type_display(), inv.get_payment_status_display(), inv.get_order_status_display(), ] for inv in invoices ], money_columns={4, 5, 6}, ) # --- برگه ۶: انبار --- ws = wb.create_sheet("انبار") ExcelExportService._write( ws, ["نام کالا", "کد کالا", "دسته", "واحد", "موجودی", "قیمت خرید", "قیمت فروش", "ارزش موجودی", "وضعیت"], [ [ p.name, p.sku, p.category, p.unit, p.quantity, p.purchase_price, p.unit_price, p.total_purchase_value, "کمبود موجودی" if p.is_low_stock else "", ] for p in Product.objects.filter(user=user).order_by("category", "name") ], money_columns={5, 6, 7}, ) buf = io.BytesIO() wb.save(buf) return buf.getvalue() class LedgerBookService: """مدیریت «فایل»های دفتر — باز کردن فایل تازه، بستن فایل، و تضمینِ این‌که همیشه دقیقا یک فایلِ جاری وجود دارد.""" @staticmethod def ensure_active_book(user: User) -> "LedgerBook": """فایلِ جاری را برمی‌گرداند؛ اگر کاربر هنوز هیچ فایلی ندارد، یک فایلِ پیش‌فرض می‌سازد تا هیچ‌وقت حالتِ «هیچ فایلی نیست» پیش نیاید.""" from apps.bizapps.models import LedgerBook book = LedgerBook.objects.filter(user=user, is_active=True).first() if book is not None: return book today = timezone.localdate() book = LedgerBook.objects.create( id=str(uuid.uuid4()), user=user, name=f"دفتر {today.year}", start_date=today, is_active=True, ) # ثبت‌های قدیمیِ بی‌فایل به همین فایلِ اول وصل می‌شوند تا از # گزارش‌ها ناپدید نشوند. LedgerEntry.objects.filter(user=user, book__isnull=True).update(book=book) return book @staticmethod @db_transaction.atomic def open_new_book( user: User, name: str, start_date: date, end_date: date | None = None, carry_balance: bool = True, ) -> "LedgerBook": """فایل تازه باز می‌کند: فایلِ جاریِ قبلی بسته و فقط-خواندنی می‌شود، و اگر خواسته شود مانده‌ی پایانیِ آن به‌عنوان مانده‌ی ابتدای فایلِ جدید منتقل می‌گردد (کارِ استانداردِ «انتقال مانده» سرِ سال مالی).""" from apps.bizapps.models import LedgerBook previous = LedgerBook.objects.select_for_update().filter(user=user, is_active=True).first() opening = 0.0 if previous is not None: if carry_balance: opening = LedgerBookService.closing_balance(previous) previous.is_active = False previous.is_closed = True previous.closed_at = timezone.now() previous.save(update_fields=["is_active", "is_closed", "closed_at", "updated_at"]) return LedgerBook.objects.create( id=str(uuid.uuid4()), user=user, name=name, start_date=start_date, end_date=end_date, opening_balance=opening, is_active=True, ) @staticmethod def closing_balance(book: "LedgerBook") -> float: """مانده‌ی پایانیِ یک فایل = مانده‌ی ابتدا + درآمدها − هزینه‌ها.""" entries = book.entries.all() income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME) expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE) return (book.opening_balance or 0) + income - expense @staticmethod def summary(book: "LedgerBook") -> dict: entries = list(book.entries.prefetch_related("installments")) income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME) expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE) return { "entry_count": len(entries), "total_income": income, "total_expense": expense, "net": income - expense, "opening_balance": book.opening_balance or 0, "closing_balance": (book.opening_balance or 0) + income - expense, "open_receivable": sum( e.remaining_amount for e in entries if e.category == LedgerEntry.Category.SALE_CREDIT ), "open_payable": sum( e.remaining_amount for e in entries if e.category == LedgerEntry.Category.PURCHASE_CREDIT ), } class SubsidiaryLedgerService: """دفتر معین — برای یک حسابِ معینِ مشخص، همه‌ی گردشِ آن حساب را به‌ترتیبِ تاریخ با «مانده‌ی در جریان» (running balance) برمی‌گرداند. این دقیقا همان چیزی است که حسابدار انتظار دارد ببیند: تاریخ، شرح، بدهکار، بستانکار، مانده — و مانده بعد از هر ردیف به‌روز می‌شود.""" @staticmethod def statement( user: User, account: Account, start: date | None = None, end: date | None = None, ) -> dict: qs = ( Transaction.objects.filter(user=user) .filter(Q(debit_account_ref=account) | Q(credit_account_ref=account)) .order_by("transaction_date", "created_at") ) if start: qs = qs.filter(transaction_date__date__gte=start) if end: qs = qs.filter(transaction_date__date__lte=end) # مانده‌ی ابتدای دوره: اثرِ همه‌ی اسنادِ پیش از بازه. opening = 0.0 if start: for t in ( Transaction.objects.filter(user=user) .filter(Q(debit_account_ref=account) | Q(credit_account_ref=account)) .filter(transaction_date__date__lt=start) ): opening += SubsidiaryLedgerService._effect(account, t) rows = [] balance = opening total_debit = 0.0 total_credit = 0.0 for t in qs: debit = t.amount if t.debit_account_ref_id == account.id else 0 credit = t.amount if t.credit_account_ref_id == account.id else 0 balance += SubsidiaryLedgerService._effect(account, t) total_debit += debit total_credit += credit rows.append( { "id": t.id, "date": t.transaction_date, "number": t.transaction_number, "description": t.description, "type": t.get_transaction_type_display(), "invoice_id": t.reference_invoice_id, "debit": debit, "credit": credit, "balance": balance, } ) return { "account": { "id": account.id, "code": account.code, "name": account.name, "path": account.full_path, "account_type": account.get_account_type_display(), "is_debit_nature": account.is_debit_nature, }, "opening_balance": opening, "total_debit": total_debit, "total_credit": total_credit, "closing_balance": balance, "rows": rows, } @staticmethod def _effect(account: Account, t: Transaction) -> float: """اثرِ یک سند روی مانده‌ی این حساب، با در نظر گرفتنِ ماهیتِ طبیعیِ حساب — برای حسابِ بدهکار (دارایی/هزینه) بدهکار مثبت است، برای حسابِ بستانکار (درآمد/بدهی/سرمایه) برعکس.""" debit = t.amount if t.debit_account_ref_id == account.id else 0 credit = t.amount if t.credit_account_ref_id == account.id else 0 return (debit - credit) if account.is_debit_nature else (credit - debit)