adding
This commit is contained in:
@@ -19,13 +19,21 @@ from apps.bizapps.models import (
|
||||
Transaction,
|
||||
)
|
||||
|
||||
from apps.bizapps.api.accounting import (
|
||||
ACCOUNT_CASH,
|
||||
ACCOUNT_PAYABLE,
|
||||
ACCOUNT_RECEIVABLE,
|
||||
ACCOUNT_SALES,
|
||||
ChartOfAccountsService,
|
||||
ExcelExportService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# کدهای حساب — دفترکل ساده طبق سند فنی (بدون نیاز به جدول Chart of Accounts
|
||||
# جدا، چون این اپ برای کسبوکارهای خرد است نه حسابداری شرکتی کامل).
|
||||
ACCOUNT_CASH = "1000" # صندوق
|
||||
ACCOUNT_RECEIVABLE = "1100" # مطالبات (فروش اعتباری — نسخهی آتی)
|
||||
ACCOUNT_SALES = "4000" # فروش
|
||||
|
||||
class InsufficientStockError(Exception):
|
||||
"""فروشِ بیش از موجودیِ انبار — عمدا یک استثنای جدا (نه ValueError عمومی)
|
||||
تا ویو بتواند دقیقا همین حالت را به پیامِ ۴۰۰ قابلفهم تبدیل کند."""
|
||||
|
||||
|
||||
class InvoiceService:
|
||||
@@ -118,6 +126,16 @@ class InvoiceService:
|
||||
|
||||
if product is not None:
|
||||
product = Product.objects.select_for_update().get(pk=product.pk)
|
||||
# سیاستِ صریح: فروشِ بیش از موجودی جلویش گرفته میشود، نه
|
||||
# اینکه موجودی بیسروصدا منفی شود. اگر کاربر واقعا جنس را
|
||||
# دارد ولی در انبار ثبت نکرده، اول باید موجودی را اصلاح کند —
|
||||
# وگرنه ارزشِ انبار و بهای تمامشده هر دو غلط میمانند.
|
||||
if quantity > product.quantity:
|
||||
raise InsufficientStockError(
|
||||
f"موجودی «{product.name}» کافی نیست — "
|
||||
f"موجودی فعلی: {product.quantity:g} {product.unit or ''}، "
|
||||
f"درخواستی: {quantity:g}."
|
||||
)
|
||||
product.quantity -= quantity
|
||||
product.recompute_purchase_value()
|
||||
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
|
||||
@@ -135,6 +153,7 @@ class InvoiceService:
|
||||
|
||||
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
|
||||
is_credit = payment_type == Invoice.PaymentType.CREDIT
|
||||
debit_code = ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH
|
||||
Transaction.objects.create(
|
||||
id=str(uuid.uuid4()),
|
||||
user=user,
|
||||
@@ -144,8 +163,10 @@ class InvoiceService:
|
||||
transaction_type=(
|
||||
Transaction.TransactionType.CREDIT_SALE if is_credit else Transaction.TransactionType.CASH_SALE
|
||||
),
|
||||
debit_account=ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH,
|
||||
debit_account=debit_code,
|
||||
credit_account=ACCOUNT_SALES,
|
||||
debit_account_ref=ChartOfAccountsService.get(user, debit_code),
|
||||
credit_account_ref=ChartOfAccountsService.get(user, ACCOUNT_SALES),
|
||||
amount=invoice.final_amount,
|
||||
description=f"فروش فاکتور {invoice_number}",
|
||||
)
|
||||
@@ -158,6 +179,9 @@ class InvoiceService:
|
||||
customer.save(
|
||||
update_fields=["total_purchases_count", "total_purchase_amount", "last_purchase_date", "updated_at"]
|
||||
)
|
||||
# حسابِ تفصیلیِ اختصاصیِ مشتری زیرِ «حسابهای دریافتنی» — از همین
|
||||
# اولین فاکتور ساخته میشود تا بدهیاش در درختِ حسابها ردیابی شود.
|
||||
ChartOfAccountsService.ensure_customer_account(user, customer)
|
||||
|
||||
# طبق درخواست: فروشِ قسطی/نسیه باید خودکار به دفتر درآمد/هزینه بهعنوان
|
||||
# یک ردیفِ بدهیدار (نه صرفا یک عدد در لیست فاکتورها) وصل شود — همان
|
||||
@@ -175,6 +199,7 @@ class InvoiceService:
|
||||
counterparty_name=invoice.buyer_name,
|
||||
counterparty_phone=invoice.buyer_phone,
|
||||
linked_invoice=invoice,
|
||||
account=LedgerService.account_for_category(user, LedgerEntry.Category.SALE_CREDIT),
|
||||
)
|
||||
if invoice.buyer_phone:
|
||||
LedgerService.recompute_customer_balance(user, invoice.buyer_phone)
|
||||
@@ -252,6 +277,9 @@ class InvoiceService:
|
||||
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,
|
||||
# سندِ معکوس: جای بدهکار و بستانکارِ سندِ اصلی عوض میشود.
|
||||
debit_account_ref=original.credit_account_ref if original else None,
|
||||
credit_account_ref=original.debit_account_ref if original else None,
|
||||
amount=-(original.amount if original else invoice.final_amount),
|
||||
description=f"لغو فاکتور {invoice.invoice_number}",
|
||||
)
|
||||
@@ -350,6 +378,51 @@ class LedgerService:
|
||||
این سرویس تضمین میکند ماندهی بدهیِ هر مشتری (Customer.outstanding_balance)
|
||||
همیشه با جمعِ واقعیِ ردیفهای «فروش قسطی»ِ همان شمارهتماس همخوان بماند."""
|
||||
|
||||
@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_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}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def recompute_customer_balance(user: User, phone: str) -> None:
|
||||
phone = (phone or "").strip()
|
||||
@@ -366,9 +439,17 @@ class LedgerService:
|
||||
customer.outstanding_balance = balance
|
||||
customer.save(update_fields=["outstanding_balance", "updated_at"])
|
||||
|
||||
@staticmethod
|
||||
def export_xlsx(user: User, start=None, end=None) -> bytes:
|
||||
"""خروجیِ اکسلِ واقعیِ چندبرگی (خلاصه، دفتر روزنامه، ترازِ حسابها،
|
||||
بدهکاران، فاکتورها، انبار) — همان چیزی که مستقیم بهدردِ حسابدارِ
|
||||
کاربر میخورد، برخلافِ CSV تکبرگیِ خام."""
|
||||
return ExcelExportService.build(user, start, end)
|
||||
|
||||
@staticmethod
|
||||
def export_csv(user: User, start=None, end=None) -> str:
|
||||
"""خروجیِ CSV حرفهای — UTF-8 BOM برای بازشدنِ درستِ فارسی در اکسل."""
|
||||
"""خروجیِ CSV ساده — برای وقتی کاربر فقط یک فایلِ سبکِ تکبرگی
|
||||
میخواهد. نسخهی کاملِ چندبرگی در export_xlsx است."""
|
||||
qs = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related("installments")
|
||||
if start:
|
||||
qs = qs.filter(entry_date__gte=start)
|
||||
|
||||
Reference in New Issue
Block a user