Files

676 lines
30 KiB
Python
Raw Permalink Normal View History

2026-09-04 13:05:54 +03:30
"""
لایه‌ی حسابداریِ حرفه‌ای — درختِ حساب‌ها (کل/معین/تفصیلی)، ترازِ حساب‌ها، و
خروجیِ اکسلِ چندبرگی.
کاربرِ اپ هیچ‌وقت مجبور نیست این لایه را ببیند؛ او فقط یک دسته‌ی آشنا
(«فروش قسطی»، «اجاره»…) انتخاب می‌کند. این ماژول همان انتخاب را به یک
حسابِ معینِ واقعی نگاشت می‌کند تا گزارش‌ها، ترازِ حساب‌ها و خروجیِ اکسل
از یک ساختارِ درستِ حسابداری ساخته شوند.
"""
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},
)
# --- برگه ۷: انبار ---
2026-09-04 13:05:54 +03:30
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 مراجعه کن)."""
2026-09-04 13:05:54 +03:30
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"])
2026-09-04 13:05:54 +03:30
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
2026-09-04 13:05:54 +03:30
@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)