Fix OTP/SMS, ledger accounting, order tracking, and add reviews app

- 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)
This commit is contained in:
2026-09-15 23:18:05 +03:30
parent 61b7c91b02
commit 831e656fa3
32 changed files with 1036 additions and 77 deletions
+55 -7
View File
@@ -426,7 +426,40 @@ class ExcelExportService:
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},
)
# --- برگه ۷: انبار ---
ws = wb.create_sheet("انبار")
ExcelExportService._write(
ws,
@@ -489,9 +522,14 @@ class LedgerBookService:
end_date: date | None = None,
carry_balance: bool = True,
) -> "LedgerBook":
"""فایل تازه باز می‌کند: فایلِ جاریِ قبلی بسته و فقط-خواندنی می‌شود،
و اگر خواسته شود مانده‌ی پایانیِ آن به‌عنوان مانده‌ی ابتدای فایلِ
جدید منتقل می‌گردد (کارِ استانداردِ «انتقال مانده» سرِ سال مالی)."""
"""فایل تازه باز می‌کند و آن را فایلِ جاری می‌کند؛ اگر خواسته شود
مانده‌ی پایانیِ فایلِ قبلی به‌عنوان مانده‌ی ابتدای فایلِ جدید منتقل
می‌شود (کارِ استانداردِ «انتقال مانده» سرِ سال مالی).
برخلافِ قبل، فایلِ قبلی خودکار بایگانی (بسته) نمی‌شود — فقط از حالتِ
«جاری» خارج می‌شود و همچنان در فهرست و قابل‌ویرایش باقی می‌ماند؛
بایگانی‌کردنش یک اقدامِ جداگانه و دستی است (به LedgerBookService.
archive_book مراجعه کن)."""
from apps.bizapps.models import LedgerBook
previous = LedgerBook.objects.select_for_update().filter(user=user, is_active=True).first()
@@ -500,9 +538,7 @@ class LedgerBookService:
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"])
previous.save(update_fields=["is_active", "updated_at"])
return LedgerBook.objects.create(
id=str(uuid.uuid4()),
@@ -514,6 +550,18 @@ class LedgerBookService:
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
@staticmethod
def closing_balance(book: "LedgerBook") -> float:
"""مانده‌ی پایانیِ یک فایل = مانده‌ی ابتدا + درآمدها − هزینه‌ها."""
+20 -2
View File
@@ -81,6 +81,9 @@ class LedgerEntrySerializer(serializers.ModelSerializer):
category_display = serializers.CharField(source="get_category_display", read_only=True)
account_name = serializers.CharField(source="account.name", read_only=True, default="")
account_code = serializers.CharField(source="account.code", read_only=True, default="")
discount_amount = serializers.FloatField(read_only=True)
tax_amount = serializers.FloatField(read_only=True)
computed_amount = serializers.FloatField(read_only=True)
class Meta:
model = LedgerEntry
@@ -88,6 +91,8 @@ class LedgerEntrySerializer(serializers.ModelSerializer):
"id", "entry_type", "title", "amount", "category", "category_display",
"entry_date", "note", "total_amount", "counterparty_name", "counterparty_phone",
"linked_invoice", "account_name", "account_code",
"base_amount", "discount_percent", "tax_percent",
"discount_amount", "tax_amount", "computed_amount",
"is_debt", "professional_type", "paid_amount", "remaining_amount",
"installments", "updated_at",
]
@@ -112,7 +117,8 @@ class ProductSerializer(serializers.ModelSerializer):
fields = [
"id", "name", "sku", "category", "unit", "quantity",
"unit_price", "purchase_price", "total_purchase_value",
"low_stock_threshold", "is_low_stock", "notes", "updated_at",
"low_stock_threshold", "is_low_stock", "is_active",
"production_date", "expiry_date", "notes", "updated_at",
]
read_only_fields = ["total_purchase_value", "is_low_stock", "updated_at"]
@@ -153,7 +159,9 @@ class InvoiceSerializer(serializers.ModelSerializer):
"id", "invoice_number", "invoice_date", "customer",
"buyer_name", "buyer_phone", "buyer_address",
"seller_name", "seller_phone", "seller_address",
"total_amount", "discount_amount", "tax_lines", "final_amount",
"total_amount", "discount_percent", "discount_amount",
"extra_discount_label", "extra_discount_percent", "extra_discount_amount",
"tax_lines", "final_amount",
"payment_type", "payment_type_display",
"payment_status", "payment_status_display",
"order_status", "order_status_display",
@@ -188,7 +196,17 @@ class CreateInvoiceSerializer(serializers.Serializer):
seller_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
seller_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, default="")
seller_address = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
# درصد اولویت دارد: اگر discount_percent مثبت باشد، discount_amount از
# روی همان محاسبه می‌شود (هم‌الگو با تخفیفِ درصدیِ هر قلم فاکتور)؛
# discount_amount خامْ فقط برای سازگاری با کلاینت‌های قدیمی نگه داشته
# شده — اگر درصد صفر باشد همان مقدار مستقیم استفاده می‌شود.
discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100)
discount_amount = serializers.FloatField(required=False, default=0, min_value=0)
# تخفیفِ ویژه/دوره‌ای — جدا از تخفیفِ بالا، روی مبلغِ بعد از آن اعمال
# می‌شود. یا با درصد یا با مبلغِ ثابت پر می‌شود.
extra_discount_label = serializers.CharField(max_length=100, required=False, allow_blank=True, default="")
extra_discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100)
extra_discount_amount = serializers.FloatField(required=False, default=0, min_value=0)
tax_lines = serializers.ListField(child=serializers.DictField(), required=False, default=list)
payment_type = serializers.ChoiceField(choices=Invoice.PaymentType.choices, required=False)
shipping_method = serializers.ChoiceField(choices=Invoice.ShippingMethod.choices, required=False)
+127 -40
View File
@@ -9,6 +9,7 @@ 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,
@@ -26,6 +27,7 @@ from apps.bizapps.api.accounting import (
ACCOUNT_SALES,
ChartOfAccountsService,
ExcelExportService,
SubsidiaryLedgerService,
)
logger = logging.getLogger(__name__)
@@ -92,7 +94,9 @@ class InvoiceService:
seller_name=data.get("seller_name", ""),
seller_phone=data.get("seller_phone", ""),
seller_address=data.get("seller_address", ""),
discount_amount=data.get("discount_amount", 0),
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),
@@ -146,10 +150,27 @@ class InvoiceService:
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 + tax_amount
invoice.save(update_fields=["total_amount", "final_amount"])
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
@@ -396,6 +417,70 @@ class LedgerService:
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:
@@ -448,46 +533,48 @@ class LedgerService:
@staticmethod
def export_csv(user: User, start=None, end=None) -> str:
"""خروجیِ 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)
if end:
qs = qs.filter(entry_date__lte=end)
"""خروجیِ CSV به‌شکلِ واقعیِ دفتر معین — به‌ازای هر حسابِ معینِ
دارای گردش: سربرگِ حساب، مانده‌ی ابتدای دوره، سندهای آن حساب
به‌ترتیبِ تاریخ (با بدهکار/بستانکار/مانده‌ی در جریان)، و مانده‌ی
پایانِ دوره — دقیقا همان ساختاری که در خروجیِ اکسلِ چندبرگی هم
هست، فقط سبک‌تر و تک‌برگی. قبلا این خروجی فقط یک دامپِ خامِ
ردیف‌های دفترِ ساده بود (بدونِ حساب/بدهکار/بستانکار/مانده) که هیچ
شباهتی به دفتر معین نداشت.
"""
buf = io.StringIO()
buf.write("") # BOM
writer = csv.writer(buf)
writer.writerow(
[
"تاریخ",
"نوع",
"دسته",
"عنوان",
"مبلغ",
"طرف حساب",
"شماره طرف حساب",
"مبلغ کل تعهد",
"پرداخت‌شده",
"مانده",
"یادداشت",
]
)
for e in qs:
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(
[
e.entry_date.isoformat(),
e.get_entry_type_display(),
e.get_category_display() if e.category else "",
e.title,
f"{e.amount:.0f}",
e.counterparty_name,
e.counterparty_phone,
f"{e.total_amount:.0f}" if e.is_debt else "",
f"{e.paid_amount:.0f}" if e.is_debt else "",
f"{e.remaining_amount:.0f}" if e.is_debt else "",
e.note,
]
["مانده‌ی ابتدای دوره", "", "", "", "", "", 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()
+44
View File
@@ -109,6 +109,7 @@ class LedgerEntryListCreateView(_OwnedMixin, generics.ListCreateAPIView):
book = LedgerBookService.ensure_active_book(self.request.user)
entry = serializer.save(user=self.request.user, book=book)
LedgerService.sync_entry_account(self.request.user, entry)
LedgerService.post_entry_transaction(self.request.user, entry)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
@@ -123,6 +124,7 @@ class LedgerEntryDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
def perform_update(self, serializer):
entry = serializer.save()
LedgerService.sync_entry_account(self.request.user, entry)
LedgerService.post_entry_transaction(self.request.user, entry)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
@@ -473,6 +475,48 @@ class LedgerBookSummaryView(APIView):
)
class LedgerBookDetailView(generics.RetrieveUpdateDestroyAPIView):
"""ویرایش/حذفِ یک فایل دفتر. فایلِ بایگانی‌شده فقط-خواندنی است — نه
ویرایش و نه حذف. حذف فقط برای فایلِ خالی (بدون هیچ ثبتی) مجاز است تا
داده‌ی مالی با حذفِ فایل به‌طور آبشاری از بین نرود."""
serializer_class = LedgerBookSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return LedgerBook.objects.filter(user=self.request.user)
def _guard_editable(self, book: LedgerBook):
if book.is_closed:
raise ValidationError("فایلِ بایگانی‌شده فقط-خواندنی است.")
def perform_update(self, serializer):
self._guard_editable(serializer.instance)
serializer.save()
def perform_destroy(self, instance):
self._guard_editable(instance)
if instance.entries.exists():
raise ValidationError("فقط فایلِ خالی (بدون هیچ ثبتی) قابل‌حذف است.")
instance.delete()
class LedgerBookArchiveView(APIView):
"""بایگانی‌کردنِ دستیِ یک فایل دفتر — از این پس فقط-خواندنی می‌شود و در
انتخابِ فایلِ دفتر برای فاکتورهای تازه نشان داده نمی‌شود."""
permission_classes = [IsAuthenticated]
def post(self, request, pk):
book = LedgerBook.objects.filter(user=request.user, pk=pk).first()
if book is None:
raise NotFound("فایل دفتر یافت نشد.")
if book.is_closed:
raise ValidationError("این فایل قبلا بایگانی شده.")
book = LedgerBookService.archive_book(book)
return Response(LedgerBookSerializer(book).data)
class SubsidiaryLedgerView(APIView):
"""دفتر معین یک حساب — گردشِ حساب با مانده‌ی در جریان."""
@@ -0,0 +1,63 @@
# Generated by Django 5.1.15 on 2026-09-14 23:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0003_ledgerbook_ledgerentry_book_and_more'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='discount_percent',
field=models.FloatField(default=0, verbose_name='درصد تخفیف'),
),
migrations.AddField(
model_name='invoice',
name='extra_discount_amount',
field=models.FloatField(default=0, verbose_name='مبلغ تخفیف ویژه'),
),
migrations.AddField(
model_name='invoice',
name='extra_discount_label',
field=models.CharField(blank=True, help_text='مثلا: فروش فوق\u200cالعاده، تخفیف دوره\u200cای', max_length=100, verbose_name='عنوان تخفیف ویژه'),
),
migrations.AddField(
model_name='invoice',
name='extra_discount_percent',
field=models.FloatField(default=0, verbose_name='درصد تخفیف ویژه'),
),
migrations.AddField(
model_name='ledgerentry',
name='base_amount',
field=models.FloatField(blank=True, default=0, verbose_name='مبلغ پایه (پیش از تخفیف/مالیات)'),
),
migrations.AddField(
model_name='ledgerentry',
name='discount_percent',
field=models.FloatField(blank=True, default=0, verbose_name='درصد تخفیف (پیش از مالیات)'),
),
migrations.AddField(
model_name='ledgerentry',
name='tax_percent',
field=models.FloatField(blank=True, default=0, verbose_name='درصد مالیات/ارزش\u200cافزوده'),
),
migrations.AddField(
model_name='product',
name='expiry_date',
field=models.DateField(blank=True, null=True, verbose_name='تاریخ انقضا'),
),
migrations.AddField(
model_name='product',
name='is_active',
field=models.BooleanField(default=True, verbose_name='فعال'),
),
migrations.AddField(
model_name='product',
name='production_date',
field=models.DateField(blank=True, null=True, verbose_name='تاریخ تولید'),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.15 on 2026-09-15 10:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0004_invoice_discount_percent_and_more'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='reference_ledger_entry',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='bizapps.ledgerentry'),
),
]
@@ -0,0 +1,168 @@
"""داده‌ی قدیمی: ردیف‌های سادهٔ دفتر (غیرِ فاکتوری) که قبل از این نسخه ثبت
شده‌اند، هیچ‌وقت سندِ دوطرفه نگرفته بودند — یعنی در ترازِ حساب‌ها/دفتر
معین/خروجیِ CSV دیده نمی‌شدند. این مهاجرت همان سندهایی را که از این پس
به‌صورت خودکار موقعِ ساخت/ویرایشِ هر ردیف ساخته می‌شوند، برای داده‌ی
قدیمی هم می‌سازد.
عمداً کدِ حسابداریِ فعلی (apps.bizapps.api.accounting/services) را ایمپورت
نمی‌کند — مهاجرت‌ها باید با مدلِ همان لحظه (historical model) کار کنند،
نه با کدِ همیشه‌درحال‌تغییرِ اپ؛ به همین خاطر ثابت‌ها و منطقِ لازم همین‌جا
تکرار شده‌اند.
"""
import uuid
from django.db import migrations
from django.utils import timezone
CATEGORY_ACCOUNT_CODES = {
"sale_cash": "4001",
"sale_credit": "4001",
"service_income": "4002",
"other_income": "4003",
"purchase_cash": "5001",
"purchase_credit": "5001",
"rent": "5002",
"salary": "5003",
"utility": "5004",
"transport": "5005",
"other_expense": "5006",
}
DEBT_CATEGORIES = {"sale_credit", "purchase_credit"}
ACCOUNT_CASH = "1001"
ACCOUNT_RECEIVABLE = "1100"
ACCOUNT_PAYABLE = "2100"
# (code, name, level, account_type, parent_code) — فقط زیرمجموعه‌ای که این
# بک‌فیل واقعاً لازم دارد؛ اگر حسابِ کاربر از قبل ساخته شده باشد، دوباره
# ساخته نمی‌شود (get_or_create).
NEEDED_ACCOUNTS = [
("1000", "دارایی‌ها", 1, "asset", None),
("1001", "صندوق", 2, "asset", "1000"),
("1100", "حساب‌های دریافتنی", 2, "asset", "1000"),
("2000", "بدهی‌ها", 1, "liability", None),
("2100", "حساب‌های پرداختنی", 2, "liability", "2000"),
("4000", "درآمدها", 1, "income", None),
("4001", "فروش کالا", 2, "income", "4000"),
("4002", "درآمد خدمات", 2, "income", "4000"),
("4003", "سایر درآمدها", 2, "income", "4000"),
("5000", "هزینه‌ها", 1, "expense", None),
("5001", "بهای تمام‌شده‌ی کالای فروش‌رفته", 2, "expense", "5000"),
("5002", "اجاره", 2, "expense", "5000"),
("5003", "حقوق و دستمزد", 2, "expense", "5000"),
("5004", "قبض و آب‌وبرق", 2, "expense", "5000"),
("5005", "حمل‌ونقل", 2, "expense", "5000"),
("5006", "سایر هزینه‌ها", 2, "expense", "5000"),
]
def _ensure_account(Account, user_id, code, by_code):
if code in by_code:
return by_code[code]
existing = Account.objects.filter(user_id=user_id, code=code).first()
if existing is not None:
by_code[code] = existing
return existing
spec = next((a for a in NEEDED_ACCOUNTS if a[0] == code), None)
if spec is None:
return None
_, name, level, account_type, parent_code = spec
parent = _ensure_account(Account, user_id, parent_code, by_code) if parent_code else None
account = Account.objects.create(
id=str(uuid.uuid4()),
user_id=user_id,
code=code,
name=name,
level=level,
account_type=account_type,
parent=parent,
is_system=True,
is_active=True,
)
by_code[code] = account
return account
def backfill_transactions(apps, schema_editor):
LedgerEntry = apps.get_model("bizapps", "LedgerEntry")
Account = apps.get_model("bizapps", "Account")
Transaction = apps.get_model("bizapps", "Transaction")
entries = (
LedgerEntry.objects.filter(linked_invoice__isnull=True)
.exclude(transactions__isnull=False)
.order_by("user_id", "entry_date")
)
accounts_by_user: dict[str, dict[str, object]] = {}
counters: dict[str, int] = {}
for entry in entries:
code = CATEGORY_ACCOUNT_CODES.get(entry.category)
if not code:
continue
is_debt = entry.category in DEBT_CATEGORIES
amount = (entry.total_amount or entry.amount) if is_debt else entry.amount
if not amount:
continue
by_code = accounts_by_user.setdefault(entry.user_id, {})
account = _ensure_account(Account, entry.user_id, code, by_code)
if account is None:
continue
if entry.category == "sale_credit":
debit = _ensure_account(Account, entry.user_id, ACCOUNT_RECEIVABLE, by_code)
credit = account
ttype = "credit_sale"
elif entry.category == "purchase_credit":
debit = account
credit = _ensure_account(Account, entry.user_id, ACCOUNT_PAYABLE, by_code)
ttype = "purchase"
elif entry.entry_type == "income":
debit = _ensure_account(Account, entry.user_id, ACCOUNT_CASH, by_code)
credit = account
ttype = "cash_sale"
else:
debit = account
credit = _ensure_account(Account, entry.user_id, ACCOUNT_CASH, by_code)
ttype = "expense"
if debit is None or credit is None:
continue
counters[entry.user_id] = counters.get(entry.user_id, 0) + 1
number = f"LED-BKFL-{counters[entry.user_id]:06d}"
naive_dt = timezone.datetime.combine(entry.entry_date, timezone.datetime.min.time())
aware_dt = timezone.make_aware(naive_dt) if timezone.is_naive(naive_dt) else naive_dt
Transaction.objects.create(
id=str(uuid.uuid4()),
user_id=entry.user_id,
transaction_number=number,
transaction_date=aware_dt,
reference_ledger_entry_id=entry.id,
transaction_type=ttype,
debit_account=debit.code,
credit_account=credit.code,
debit_account_ref_id=debit.id,
credit_account_ref_id=credit.id,
amount=amount,
description=entry.title,
)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("bizapps", "0005_transaction_reference_ledger_entry"),
]
operations = [
migrations.RunPython(backfill_transactions, noop),
]
+48
View File
@@ -181,6 +181,9 @@ class Product(models.Model):
# «ارزش کل انبار» بدون پیمایشِ همه‌ی کالاها قابل‌جمع‌زدن باشد.
total_purchase_value = models.FloatField(default=0, verbose_name=_("ارزش موجودی"))
low_stock_threshold = models.FloatField(default=0, verbose_name=_("آستانه‌ی هشدار موجودی کم"))
is_active = models.BooleanField(default=True, verbose_name=_("فعال"))
production_date = models.DateField(null=True, blank=True, verbose_name=_("تاریخ تولید"))
expiry_date = models.DateField(null=True, blank=True, verbose_name=_("تاریخ انقضا"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
@@ -257,7 +260,20 @@ class Invoice(models.Model):
seller_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره فروشنده"))
seller_address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس فروشنده"))
total_amount = models.FloatField(default=0, verbose_name=_("جمع کل"))
# درصدِ تخفیفِ کلیِ فاکتور — منبعِ حقیقت همین درصد است (هم‌الگو با
# InvoiceItem.discount_percent)؛ discount_amount فقط cache شده تا در
# گزارش/اکسل بدون محاسبه‌ی دوباره قابل‌نمایش باشد.
discount_percent = models.FloatField(default=0, verbose_name=_("درصد تخفیف"))
discount_amount = models.FloatField(default=0, verbose_name=_("تخفیف"))
# تخفیفِ ویژه/دوره‌ای (مثلا «فروش فوق‌العاده») — جدا از تخفیفِ معمولیِ
# بالا تا هر دو در خروجی فاکتور با عنوانِ خودشان دیده شوند. یا با درصد
# (روی مبلغ بعد از تخفیف معمولی) یا با مبلغ ثابت پر می‌شود، نه هر دو.
extra_discount_label = models.CharField(
max_length=100, blank=True, verbose_name=_("عنوان تخفیف ویژه"),
help_text=_("مثلا: فروش فوق‌العاده، تخفیف دوره‌ای"),
)
extra_discount_percent = models.FloatField(default=0, verbose_name=_("درصد تخفیف ویژه"))
extra_discount_amount = models.FloatField(default=0, verbose_name=_("مبلغ تخفیف ویژه"))
# هر ردیف: {"title": "مالیات بر ارزش افزوده", "percent": 9}
tax_lines = models.JSONField(default=list, blank=True, verbose_name=_("ردیف‌های مالیات"))
final_amount = models.FloatField(default=0, verbose_name=_("مبلغ نهایی"))
@@ -326,6 +342,12 @@ class Transaction(models.Model):
reference_invoice = models.ForeignKey(
Invoice, null=True, blank=True, on_delete=models.SET_NULL, related_name="transactions"
)
# سندِ خودکارِ یک ردیفِ دفترِ ساده (غیرِ فاکتوری) — CASCADE عمدی است: اگر
# خودِ ردیف حذف شود، سندش هم باید حذف شود، وگرنه تراز حساب‌ها یک سندِ
# یتیم و اشتباه نگه می‌دارد.
reference_ledger_entry = models.ForeignKey(
"LedgerEntry", null=True, blank=True, on_delete=models.CASCADE, related_name="transactions"
)
transaction_type = models.CharField(max_length=20, choices=TransactionType.choices)
# کدِ حسابِ متنی برای سازگاری با داده‌ی قدیمی نگه داشته شده؛ debit/credit
# به‌صورتِ FK به Account همان چیزی است که گزارش‌های جدید از آن می‌خوانند.
@@ -403,6 +425,14 @@ class LedgerEntry(models.Model):
)
entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع"))
title = models.CharField(max_length=150, verbose_name=_("عنوان"))
# amount همیشه مبلغِ نهاییِ ثبت‌شده است (همان چیزی که در گزارش/جمعِ
# درآمد-هزینه استفاده می‌شود) — سه فیلدِ زیر فقط یک ماشین‌حسابِ اختیاری
# برای رسیدن به آن هستند، تا کاربر مجبور نباشد خودش دستی حساب کند:
# تخفیف روی مبلغِ پایه (پیش از مالیات) اعمال می‌شود، بعد مالیات روی
# مبلغِ تخفیف‌خورده — یعنی دقیقا «تخفیف خارج از مالیات و ارزش‌افزوده».
base_amount = models.FloatField(default=0, blank=True, verbose_name=_("مبلغ پایه (پیش از تخفیف/مالیات)"))
discount_percent = models.FloatField(default=0, blank=True, verbose_name=_("درصد تخفیف (پیش از مالیات)"))
tax_percent = models.FloatField(default=0, blank=True, verbose_name=_("درصد مالیات/ارزش‌افزوده"))
amount = models.FloatField(verbose_name=_("مبلغ"))
# max_length=100 (نه تنگ‌تر) عمداً — این فیلد قبلا آزاد بود و داده‌ی
# واقعیِ قدیمی با متنِ فارسیِ دلخواه دارد؛ تنگ‌کردنش موقعِ مهاجرت روی
@@ -438,6 +468,24 @@ class LedgerEntry(models.Model):
def is_debt(self) -> bool:
return self.category in self.DEBT_CATEGORIES
@property
def discount_amount(self) -> float:
return (self.base_amount or 0) * (self.discount_percent or 0) / 100
@property
def net_before_tax(self) -> float:
return (self.base_amount or 0) - self.discount_amount
@property
def tax_amount(self) -> float:
return self.net_before_tax * (self.tax_percent or 0) / 100
@property
def computed_amount(self) -> float:
"""مبلغِ نهایی که از روی مبلغِ پایه، تخفیف و مالیات محاسبه می‌شود —
برای پیش‌نمایش سمتِ کلاینت؛ مقدارِ واقعا ذخیره‌شده همیشه amount است."""
return self.net_before_tax + self.tax_amount
@property
def professional_type(self) -> str:
"""ترجمه‌ی دسته‌ی «عادی» به اصطلاحِ حسابداریِ حرفه‌ای."""
+43 -10
View File
@@ -177,19 +177,24 @@ class LedgerServiceTests(TestCase):
self.customer.refresh_from_db()
self.assertEqual(self.customer.outstanding_balance, 0)
def _create_and_post(self, **kwargs):
"""معادلِ دستیِ کاری که ویوی API انجام می‌دهد: بعد از ساختِ ردیف،
حساب را هماهنگ و سندِ دوطرفه‌اش را می‌زند — بدونِ این سند، ردیف در
هیچ گزارشِ حرفه‌ای (تراز/دفتر معین/CSV) دیده نمی‌شود."""
entry = LedgerEntry.objects.create(id=str(uuid.uuid4()), user=self.user, **kwargs)
LedgerService.sync_entry_account(self.user, entry)
LedgerService.post_entry_transaction(self.user, entry)
return entry
def test_export_csv_filters_by_date_range(self):
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
self._create_and_post(
entry_type=LedgerEntry.EntryType.INCOME,
title="داخل بازه",
amount=100,
category=LedgerEntry.Category.SALE_CASH,
entry_date=date(2026, 1, 15),
)
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
self._create_and_post(
entry_type=LedgerEntry.EntryType.EXPENSE,
title="خارج از بازه",
amount=50,
@@ -200,6 +205,31 @@ class LedgerServiceTests(TestCase):
self.assertIn("داخل بازه", csv_text)
self.assertNotIn("خارج از بازه", csv_text)
def test_post_entry_transaction_covers_plain_income_and_expense(self):
"""قبلا فقط فاکتور/قسط سند می‌خورد؛ یک ثبتِ سادهِ «فروش نقدی» یا
«اجاره» (که کارِ روزمرهِ اکثرِ کاربرهاست) اصلا در ترازِ حساب‌ها
دیده نمی‌شد. این تست دقیقا همان رگرسیون را قفل می‌کند."""
self._create_and_post(
entry_type=LedgerEntry.EntryType.INCOME,
title="فروش نقدی امروز",
amount=200,
category=LedgerEntry.Category.SALE_CASH,
entry_date=date.today(),
)
self._create_and_post(
entry_type=LedgerEntry.EntryType.EXPENSE,
title="اجاره مغازه",
amount=80,
category=LedgerEntry.Category.RENT,
entry_date=date.today(),
)
rows = ChartOfAccountsService.trial_balance(self.user)
by_code = {r["code"]: r for r in rows}
self.assertIn(ACCOUNT_CASH, by_code)
# فروشِ نقدی صندوق را بدهکار می‌کند، اجاره بستانکار — مانده‌ی خالص
# صندوق باید ۲۰۰ منهای ۸۰ باشد.
self.assertEqual(by_code[ACCOUNT_CASH]["balance"], 120)
class InvoiceLedgerIntegrationTests(TestCase):
"""اتصالِ فاکتور <-> دفتر: طبق درخواست، فقط فروشِ قسطی باید خودکار یک
@@ -553,7 +583,7 @@ class ExcelExportTests(TestCase):
wb = self._workbook()
self.assertEqual(
wb.sheetnames,
["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "انبار"],
["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "دفتر معین", "انبار"],
)
def test_sheets_are_right_to_left(self):
@@ -825,15 +855,18 @@ class LedgerBookTests(TestCase):
orphan.refresh_from_db()
self.assertEqual(orphan.book_id, book.id)
def test_opening_a_new_book_closes_the_previous_one(self):
def test_opening_a_new_book_deactivates_but_does_not_archive_the_previous_one(self):
"""بایگانی‌کردن یک اقدامِ دستیِ جداگانه است (LedgerBookService.
archive_book) — باز کردنِ فایلِ تازه فقط فایلِ قبلی را از حالتِ
«جاری» خارج می‌کند، نه این‌که خودکار بایگانی‌اش کند."""
old = LedgerBookService.ensure_active_book(self.user)
new = LedgerBookService.open_new_book(
self.user, name="سال ۱۴۰۶", start_date=date(2027, 3, 21)
)
old.refresh_from_db()
self.assertFalse(old.is_active)
self.assertTrue(old.is_closed)
self.assertIsNotNone(old.closed_at)
self.assertFalse(old.is_closed)
self.assertIsNone(old.closed_at)
self.assertTrue(new.is_active)
# همیشه دقیقا یک فایل جاری
self.assertEqual(LedgerBook.objects.filter(user=self.user, is_active=True).count(), 1)
+4
View File
@@ -14,6 +14,8 @@ from .api.views import (
InvoiceStatusView,
LedgerEntryDetailView,
LedgerEntryListCreateView,
LedgerBookArchiveView,
LedgerBookDetailView,
LedgerBookListView,
LedgerBookSummaryView,
LedgerExportView,
@@ -42,6 +44,8 @@ urlpatterns = [
path("ledger/books/", LedgerBookListView.as_view(), name="ledger-book-list"),
path("ledger/books/open/", OpenLedgerBookView.as_view(), name="ledger-book-open"),
path("ledger/books/<str:pk>/summary/", LedgerBookSummaryView.as_view(), name="ledger-book-summary"),
path("ledger/books/<str:pk>/archive/", LedgerBookArchiveView.as_view(), name="ledger-book-archive"),
path("ledger/books/<str:pk>/", LedgerBookDetailView.as_view(), name="ledger-book-detail"),
path("ledger/export/", LedgerExportView.as_view(), name="ledger-export"),
path("ledger/", LedgerEntryListCreateView.as_view(), name="ledger-list"),
path(