831e656fa3
- Add MessageTemplate.runtime_field_labels so mobile's per-field execution-time labels are actually persisted (were silently dropped) - Fix AdvanceOrderStageView reading the wrong extra-input key, and be honest about SMS send failures instead of always claiming success - Add admin gift-gem SMS notification via Kavenegar - Post real double-entry Transactions for plain (non-invoice) ledger entries so trial balance, the معین screen, and CSV/XLSX exports actually include everyday income/expense entries; backfill existing historical entries via data migration - Rewrite CSV ledger export to produce an actual دفتر معین structure (per-account running balance) instead of a flat row dump - Add invoice percent/promotional discounts, tax presets, ledger discount-percent handling, product SKU/category/expiry fields, and manual ledger-book archiving (replacing auto-archive on new book) - Add reviews app (models, API, admin) for the comments/reviews feature - Update tests for the above; fix two stale assertions left over from earlier work in this branch (معین sheet name, book auto-close)
676 lines
30 KiB
Python
676 lines
30 KiB
Python
"""
|
|
لایهی حسابداریِ حرفهای — درختِ حسابها (کل/معین/تفصیلی)، ترازِ حسابها، و
|
|
خروجیِ اکسلِ چندبرگی.
|
|
|
|
کاربرِ اپ هیچوقت مجبور نیست این لایه را ببیند؛ او فقط یک دستهی آشنا
|
|
(«فروش قسطی»، «اجاره»…) انتخاب میکند. این ماژول همان انتخاب را به یک
|
|
حسابِ معینِ واقعی نگاشت میکند تا گزارشها، ترازِ حسابها و خروجیِ اکسل
|
|
از یک ساختارِ درستِ حسابداری ساخته شوند.
|
|
"""
|
|
|
|
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("دفتر معین")
|
|
subsidiary_rows: list[list] = []
|
|
for account in Account.objects.filter(user=user, level=Account.Level.MOIN).order_by("code"):
|
|
statement = SubsidiaryLedgerService.statement(user, account, start, end)
|
|
if not statement["rows"]:
|
|
continue
|
|
subsidiary_rows.append([f"{account.code} — {account.name}", "", "", "", "", "", ""])
|
|
subsidiary_rows.append(["ماندهی ابتدای دوره", "", "", "", "", "", statement["opening_balance"]])
|
|
for row in statement["rows"]:
|
|
subsidiary_rows.append(
|
|
[
|
|
row["date"].date().isoformat() if hasattr(row["date"], "date") else row["date"],
|
|
row["number"],
|
|
row["type"],
|
|
row["description"],
|
|
row["debit"],
|
|
row["credit"],
|
|
row["balance"],
|
|
]
|
|
)
|
|
subsidiary_rows.append(["ماندهی پایانِ دوره", "", "", "", "", "", statement["closing_balance"]])
|
|
subsidiary_rows.append([])
|
|
ExcelExportService._write(
|
|
ws,
|
|
["تاریخ", "شماره سند", "نوع", "شرح", "بدهکار", "بستانکار", "مانده"],
|
|
subsidiary_rows,
|
|
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":
|
|
"""فایل تازه باز میکند و آن را فایلِ جاری میکند؛ اگر خواسته شود
|
|
ماندهی پایانیِ فایلِ قبلی بهعنوان ماندهی ابتدای فایلِ جدید منتقل
|
|
میشود (کارِ استانداردِ «انتقال مانده» سرِ سال مالی).
|
|
|
|
برخلافِ قبل، فایلِ قبلی خودکار بایگانی (بسته) نمیشود — فقط از حالتِ
|
|
«جاری» خارج میشود و همچنان در فهرست و قابلویرایش باقی میماند؛
|
|
بایگانیکردنش یک اقدامِ جداگانه و دستی است (به LedgerBookService.
|
|
archive_book مراجعه کن)."""
|
|
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.save(update_fields=["is_active", "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 archive_book(book: "LedgerBook") -> "LedgerBook":
|
|
"""بایگانیکردنِ دستیِ یک فایل — از این پس فقط-خواندنی میشود.
|
|
فایلِ جاری هم قابلبایگانی است (کاربر آگاهانه انتخاب کرده)، فقط بعد
|
|
از آن یک فایلِ جاریِ تازه لازم است (اولین فراخوانیِ بعدیِ
|
|
ensure_active_book خودش میسازد)."""
|
|
book.is_active = False
|
|
book.is_closed = True
|
|
book.closed_at = timezone.now()
|
|
book.save(update_fields=["is_active", "is_closed", "closed_at", "updated_at"])
|
|
return book
|
|
|
|
@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)
|