adding
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
"""
|
||||
لایهی حسابداریِ حرفهای — درختِ حسابها (کل/معین/تفصیلی)، ترازِ حسابها، و
|
||||
خروجیِ اکسلِ چندبرگی.
|
||||
|
||||
کاربرِ اپ هیچوقت مجبور نیست این لایه را ببیند؛ او فقط یک دستهی آشنا
|
||||
(«فروش قسطی»، «اجاره»…) انتخاب میکند. این ماژول همان انتخاب را به یک
|
||||
حسابِ معینِ واقعی نگاشت میکند تا گزارشها، ترازِ حسابها و خروجیِ اکسل
|
||||
از یک ساختارِ درستِ حسابداری ساخته شوند.
|
||||
"""
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user