Merge branch 'claude/app-50-bugs-fixes-1531ef' into main

This commit is contained in:
2026-09-15 23:21:02 +03:30
32 changed files with 1036 additions and 77 deletions
@@ -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),
]