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)
581 lines
28 KiB
Python
581 lines
28 KiB
Python
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,
|
|
Customer,
|
|
Invoice,
|
|
InvoiceItem,
|
|
LedgerEntry,
|
|
LedgerInstallment,
|
|
Product,
|
|
StockMovement,
|
|
Transaction,
|
|
)
|
|
|
|
from apps.bizapps.api.accounting import (
|
|
ACCOUNT_CASH,
|
|
ACCOUNT_PAYABLE,
|
|
ACCOUNT_RECEIVABLE,
|
|
ACCOUNT_SALES,
|
|
ChartOfAccountsService,
|
|
ExcelExportService,
|
|
SubsidiaryLedgerService,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class InsufficientStockError(Exception):
|
|
"""فروشِ بیش از موجودیِ انبار — عمدا یک استثنای جدا (نه ValueError عمومی)
|
|
تا ویو بتواند دقیقا همین حالت را به پیامِ ۴۰۰ قابلفهم تبدیل کند."""
|
|
|
|
|
|
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),
|
|
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)
|
|
# سیاستِ صریح: فروشِ بیش از موجودی جلویش گرفته میشود، نه
|
|
# اینکه موجودی بیسروصدا منفی شود. اگر کاربر واقعا جنس را
|
|
# دارد ولی در انبار ثبت نکرده، اول باید موجودی را اصلاح کند —
|
|
# وگرنه ارزشِ انبار و بهای تمامشده هر دو غلط میمانند.
|
|
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"])
|
|
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)
|
|
|
|
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",
|
|
]
|
|
)
|
|
|
|
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,
|
|
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
|
|
),
|
|
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}",
|
|
)
|
|
|
|
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"]
|
|
)
|
|
# حسابِ تفصیلیِ اختصاصیِ مشتری زیرِ «حسابهای دریافتنی» — از همین
|
|
# اولین فاکتور ساخته میشود تا بدهیاش در درختِ حسابها ردیابی شود.
|
|
ChartOfAccountsService.ensure_customer_account(user, customer)
|
|
|
|
# طبق درخواست: فروشِ قسطی/نسیه باید خودکار به دفتر درآمد/هزینه بهعنوان
|
|
# یک ردیفِ بدهیدار (نه صرفا یک عدد در لیست فاکتورها) وصل شود — همان
|
|
# مثالِ «۱۰۰ تومن فروش قسطی از نظر عادی درآمد، از نظر حرفهای بدهکاریه».
|
|
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,
|
|
account=LedgerService.account_for_category(user, LedgerEntry.Category.SALE_CREDIT),
|
|
)
|
|
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,
|
|
# سندِ معکوس: جای بدهکار و بستانکارِ سندِ اصلی عوض میشود.
|
|
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}",
|
|
)
|
|
|
|
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)
|
|
همیشه با جمعِ واقعیِ ردیفهای «فروش قسطی»ِ همان شمارهتماس همخوان بماند."""
|
|
|
|
@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,
|
|
)
|
|
|
|
@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()
|
|
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"])
|
|
|
|
@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 بهشکلِ واقعیِ دفتر معین — بهازای هر حسابِ معینِ
|
|
دارای گردش: سربرگِ حساب، ماندهی ابتدای دوره، سندهای آن حساب
|
|
بهترتیبِ تاریخ (با بدهکار/بستانکار/ماندهی در جریان)، و ماندهی
|
|
پایانِ دوره — دقیقا همان ساختاری که در خروجیِ اکسلِ چندبرگی هم
|
|
هست، فقط سبکتر و تکبرگی. قبلا این خروجی فقط یک دامپِ خامِ
|
|
ردیفهای دفترِ ساده بود (بدونِ حساب/بدهکار/بستانکار/مانده) که هیچ
|
|
شباهتی به دفتر معین نداشت.
|
|
"""
|
|
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}", "", "", "", "", "", ""])
|
|
writer.writerow(
|
|
["ماندهی ابتدای دوره", "", "", "", "", "", f"{statement['opening_balance']:.0f}"]
|
|
)
|
|
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(["در این بازه هیچ حسابی گردش نداشته.", "", "", "", "", "", ""])
|
|
return buf.getvalue()
|