Files
shopship/apps/bizapps/api/services.py
T

581 lines
28 KiB
Python
Raw Normal View History

2026-08-31 15:11:52 +03:30
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,
2026-08-31 15:11:52 +03:30
Customer,
Invoice,
InvoiceItem,
LedgerEntry,
LedgerInstallment,
Product,
StockMovement,
Transaction,
)
2026-09-04 13:05:54 +03:30
from apps.bizapps.api.accounting import (
ACCOUNT_CASH,
ACCOUNT_PAYABLE,
ACCOUNT_RECEIVABLE,
ACCOUNT_SALES,
ChartOfAccountsService,
ExcelExportService,
SubsidiaryLedgerService,
2026-09-04 13:05:54 +03:30
)
2026-08-31 15:11:52 +03:30
logger = logging.getLogger(__name__)
2026-09-04 13:05:54 +03:30
class InsufficientStockError(Exception):
"""فروشِ بیش از موجودیِ انبار — عمدا یک استثنای جدا (نه ValueError عمومی)
تا ویو بتواند دقیقا همین حالت را به پیامِ ۴۰۰ قابل‌فهم تبدیل کند."""
2026-08-31 15:11:52 +03:30
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),
2026-08-31 15:11:52 +03:30
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)
2026-09-04 13:05:54 +03:30
# سیاستِ صریح: فروشِ بیش از موجودی جلویش گرفته می‌شود، نه
# این‌که موجودی بی‌سروصدا منفی شود. اگر کاربر واقعا جنس را
# دارد ولی در انبار ثبت نکرده، اول باید موجودی را اصلاح کند —
# وگرنه ارزشِ انبار و بهای تمام‌شده هر دو غلط می‌مانند.
if quantity > product.quantity:
raise InsufficientStockError(
f"موجودی «{product.name}» کافی نیست — "
f"موجودی فعلی: {product.quantity:g} {product.unit or ''}، "
f"درخواستی: {quantity:g}."
)
2026-08-31 15:11:52 +03:30
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)
2026-08-31 15:11:52 +03:30
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",
]
)
2026-08-31 15:11:52 +03:30
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
is_credit = payment_type == Invoice.PaymentType.CREDIT
2026-09-04 13:05:54 +03:30
debit_code = ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH
2026-08-31 15:11:52 +03:30
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
),
2026-09-04 13:05:54 +03:30
debit_account=debit_code,
2026-08-31 15:11:52 +03:30
credit_account=ACCOUNT_SALES,
2026-09-04 13:05:54 +03:30
debit_account_ref=ChartOfAccountsService.get(user, debit_code),
credit_account_ref=ChartOfAccountsService.get(user, ACCOUNT_SALES),
2026-08-31 15:11:52 +03:30
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"]
)
2026-09-04 13:05:54 +03:30
# حسابِ تفصیلیِ اختصاصیِ مشتری زیرِ «حساب‌های دریافتنی» — از همین
# اولین فاکتور ساخته می‌شود تا بدهی‌اش در درختِ حساب‌ها ردیابی شود.
ChartOfAccountsService.ensure_customer_account(user, customer)
2026-08-31 15:11:52 +03:30
# طبق درخواست: فروشِ قسطی/نسیه باید خودکار به دفتر درآمد/هزینه به‌عنوان
# یک ردیفِ بدهی‌دار (نه صرفا یک عدد در لیست فاکتورها) وصل شود — همان
# مثالِ «۱۰۰ تومن فروش قسطی از نظر عادی درآمد، از نظر حرفه‌ای بدهکاریه».
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,
2026-09-04 13:05:54 +03:30
account=LedgerService.account_for_category(user, LedgerEntry.Category.SALE_CREDIT),
2026-08-31 15:11:52 +03:30
)
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,
2026-09-04 13:05:54 +03:30
# سندِ معکوس: جای بدهکار و بستانکارِ سندِ اصلی عوض می‌شود.
debit_account_ref=original.credit_account_ref if original else None,
credit_account_ref=original.debit_account_ref if original else None,
2026-08-31 15:11:52 +03:30
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)
همیشه با جمعِ واقعیِ ردیف‌های «فروش قسطی»ِ همان شماره‌تماس هم‌خوان بماند."""
2026-09-04 13:05:54 +03:30
@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,
)
2026-09-04 13:05:54 +03:30
@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}",
)
2026-08-31 15:11:52 +03:30
@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"])
2026-09-04 13:05:54 +03:30
@staticmethod
def export_xlsx(user: User, start=None, end=None) -> bytes:
"""خروجیِ اکسلِ واقعیِ چندبرگی (خلاصه، دفتر روزنامه، ترازِ حساب‌ها،
بدهکاران، فاکتورها، انبار) — همان چیزی که مستقیم به‌دردِ حسابدارِ
کاربر می‌خورد، برخلافِ CSV تک‌برگیِ خام."""
return ExcelExportService.build(user, start, end)
2026-08-31 15:11:52 +03:30
@staticmethod
def export_csv(user: User, start=None, end=None) -> str:
"""خروجیِ CSV به‌شکلِ واقعیِ دفتر معین — به‌ازای هر حسابِ معینِ
دارای گردش: سربرگِ حساب، مانده‌ی ابتدای دوره، سندهای آن حساب
به‌ترتیبِ تاریخ (با بدهکار/بستانکار/مانده‌ی در جریان)، و مانده‌ی
پایانِ دوره — دقیقا همان ساختاری که در خروجیِ اکسلِ چندبرگی هم
هست، فقط سبک‌تر و تک‌برگی. قبلا این خروجی فقط یک دامپِ خامِ
ردیف‌های دفترِ ساده بود (بدونِ حساب/بدهکار/بستانکار/مانده) که هیچ
شباهتی به دفتر معین نداشت.
"""
2026-08-31 15:11:52 +03:30
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}", "", "", "", "", "", ""])
2026-08-31 15:11:52 +03:30
writer.writerow(
["مانده‌ی ابتدای دوره", "", "", "", "", "", f"{statement['opening_balance']:.0f}"]
2026-08-31 15:11:52 +03:30
)
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(["در این بازه هیچ حسابی گردش نداشته.", "", "", "", "", "", ""])
2026-08-31 15:11:52 +03:30
return buf.getvalue()