This commit is contained in:
2026-09-04 13:05:54 +03:30
parent 463b812e3e
commit 17082a5ea9
43 changed files with 5663 additions and 285 deletions
+59 -17
View File
@@ -1,40 +1,82 @@
from django.contrib import admin
from .models import Appointment, Customer, LedgerEntry, Product, StockMovement
from .models import (
Account,
Customer,
Invoice,
InvoiceItem,
LedgerEntry,
LedgerInstallment,
Product,
StockMovement,
Transaction,
)
@admin.register(Account)
class AccountAdmin(admin.ModelAdmin):
list_display = ("code", "name", "level", "account_type", "parent", "is_system", "is_active")
list_filter = ("level", "account_type", "is_system", "is_active")
search_fields = ("code", "name", "user__phone")
autocomplete_fields = ["parent"]
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = ("name", "phone", "user", "updated_at")
search_fields = ("name", "phone", "email", "user__phone")
list_display = ("name", "phone", "outstanding_balance", "total_purchases_count", "last_purchase_date")
search_fields = ("name", "phone", "user__phone")
readonly_fields = (
"total_purchases_count", "total_purchase_amount", "last_purchase_date", "outstanding_balance",
)
@admin.register(LedgerEntry)
class LedgerEntryAdmin(admin.ModelAdmin):
list_display = ("title", "entry_type", "amount", "entry_date", "user")
list_display = ("title", "entry_type", "category", "amount", "entry_date", "counterparty_name")
list_filter = ("entry_type", "category")
search_fields = ("title", "user__phone")
search_fields = ("title", "counterparty_name", "counterparty_phone", "user__phone")
@admin.register(LedgerInstallment)
class LedgerInstallmentAdmin(admin.ModelAdmin):
list_display = ("ledger_entry", "amount", "paid_date", "reference")
search_fields = ("reference", "ledger_entry__title")
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "sku", "category", "quantity", "unit_price", "user")
list_display = ("name", "sku", "category", "quantity", "unit", "unit_price", "total_purchase_value")
list_filter = ("category",)
search_fields = ("name", "sku", "user__phone")
readonly_fields = ("total_purchase_value",)
@admin.register(StockMovement)
class StockMovementAdmin(admin.ModelAdmin):
list_display = ("product", "delta", "quantity_after", "created_at")
search_fields = ("product__name", "product__user__phone")
readonly_fields = ("product", "delta", "quantity_after", "note", "created_at")
def has_add_permission(self, request):
return False
list_display = ("product", "delta", "quantity_after", "note", "created_at")
search_fields = ("product__name",)
@admin.register(Appointment)
class AppointmentAdmin(admin.ModelAdmin):
list_display = ("customer_name", "title", "start_at", "status", "user")
list_filter = ("status",)
search_fields = ("customer_name", "customer_phone", "user__phone")
class InvoiceItemInline(admin.TabularInline):
model = InvoiceItem
extra = 0
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
list_display = (
"invoice_number", "buyer_name", "final_amount", "payment_type", "payment_status", "order_status",
"invoice_date",
)
list_filter = ("payment_type", "payment_status", "order_status")
search_fields = ("invoice_number", "buyer_name", "buyer_phone", "user__phone")
inlines = [InvoiceItemInline]
@admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin):
list_display = (
"transaction_number", "transaction_type", "debit_account", "credit_account", "amount", "transaction_date",
)
list_filter = ("transaction_type",)
search_fields = ("transaction_number", "description", "user__phone")
+627
View File
@@ -0,0 +1,627 @@
"""
لایه‌ی حسابداریِ حرفه‌ای — درختِ حساب‌ها (کل/معین/تفصیلی)، ترازِ حساب‌ها، و
خروجیِ اکسلِ چندبرگی.
کاربرِ اپ هیچ‌وقت مجبور نیست این لایه را ببیند؛ او فقط یک دسته‌ی آشنا
(«فروش قسطی»، «اجاره»…) انتخاب می‌کند. این ماژول همان انتخاب را به یک
حسابِ معینِ واقعی نگاشت می‌کند تا گزارش‌ها، ترازِ حساب‌ها و خروجیِ اکسل
از یک ساختارِ درستِ حسابداری ساخته شوند.
"""
import io
import uuid
from datetime import date
from django.db import transaction as db_transaction
from django.db.models import Q, Sum
from django.utils import timezone
from apps.account.models import User
from apps.bizapps.models import (
Account,
Customer,
Invoice,
LedgerBook,
LedgerEntry,
Product,
Transaction,
)
# درختِ پیش‌فرضِ حساب‌ها برای یک کسب‌وکار خرد — عمداً کوچک نگه داشته شده
# (پنج حساب کل، چند معین زیر هرکدام) چون مخاطبِ این اپ مغازه‌دار است نه
# شرکتِ حسابداری. کاربر می‌تواند از پنل خودش حسابِ تازه اضافه کند.
DEFAULT_CHART = [
# (code, name, level, type, parent_code)
("1000", "دارایی‌ها", 1, Account.AccountType.ASSET, None),
("1001", "صندوق", 2, Account.AccountType.ASSET, "1000"),
("1002", "حساب بانکی", 2, Account.AccountType.ASSET, "1000"),
("1100", "حساب‌های دریافتنی", 2, Account.AccountType.ASSET, "1000"),
("1200", "موجودی انبار", 2, Account.AccountType.ASSET, "1000"),
("2000", "بدهی‌ها", 1, Account.AccountType.LIABILITY, None),
("2100", "حساب‌های پرداختنی", 2, Account.AccountType.LIABILITY, "2000"),
("3000", "سرمایه", 1, Account.AccountType.EQUITY, None),
("3001", "سرمایه‌ی مالک", 2, Account.AccountType.EQUITY, "3000"),
("4000", "درآمدها", 1, Account.AccountType.INCOME, None),
("4001", "فروش کالا", 2, Account.AccountType.INCOME, "4000"),
("4002", "درآمد خدمات", 2, Account.AccountType.INCOME, "4000"),
("4003", "سایر درآمدها", 2, Account.AccountType.INCOME, "4000"),
("5000", "هزینه‌ها", 1, Account.AccountType.EXPENSE, None),
("5001", "بهای تمام‌شده‌ی کالای فروش‌رفته", 2, Account.AccountType.EXPENSE, "5000"),
("5002", "اجاره", 2, Account.AccountType.EXPENSE, "5000"),
("5003", "حقوق و دستمزد", 2, Account.AccountType.EXPENSE, "5000"),
("5004", "قبض و آب‌وبرق", 2, Account.AccountType.EXPENSE, "5000"),
("5005", "حمل‌ونقل", 2, Account.AccountType.EXPENSE, "5000"),
("5006", "سایر هزینه‌ها", 2, Account.AccountType.EXPENSE, "5000"),
]
ACCOUNT_CASH = "1001"
ACCOUNT_RECEIVABLE = "1100"
ACCOUNT_PAYABLE = "2100"
ACCOUNT_SALES = "4001"
ACCOUNT_INVENTORY = "1200"
class ChartOfAccountsService:
@staticmethod
def ensure_chart(user: User) -> None:
"""درختِ پیش‌فرض را برای کاربر می‌سازد اگر هنوز ساخته نشده باشد.
idempotent است — اجرای دوباره چیزی را دوباره نمی‌سازد."""
existing = set(Account.objects.filter(user=user).values_list("code", flat=True))
if len(existing) >= len(DEFAULT_CHART):
return
by_code: dict[str, Account] = {
a.code: a for a in Account.objects.filter(user=user)
}
# دو پاس: اول حساب‌های کل (بدون والد)، بعد معین‌ها که به والد نیاز دارند.
for level_pass in (1, 2, 3):
for code, name, level, acc_type, parent_code in DEFAULT_CHART:
if level != level_pass or code in by_code:
continue
account = Account.objects.create(
id=str(uuid.uuid4()),
user=user,
code=code,
name=name,
level=level,
account_type=acc_type,
parent=by_code.get(parent_code) if parent_code else None,
is_system=True,
)
by_code[code] = account
@staticmethod
def get(user: User, code: str) -> Account | None:
"""حسابِ معین را با کدش برمی‌گرداند؛ اگر درخت هنوز ساخته نشده،
اول می‌سازدش."""
account = Account.objects.filter(user=user, code=code).first()
if account is None:
ChartOfAccountsService.ensure_chart(user)
account = Account.objects.filter(user=user, code=code).first()
return account
@staticmethod
def ensure_customer_account(user: User, customer: Customer) -> Account:
"""برای هر مشتری یک حسابِ تفصیلی زیرِ «حساب‌های دریافتنی» می‌سازد —
همان چیزی که بدهیِ او را در درختِ حساب‌ها قابل‌ردیابی می‌کند."""
if customer.account_id:
return customer.account
parent = ChartOfAccountsService.get(user, ACCOUNT_RECEIVABLE)
# کدِ تفصیلی: 1100-0001, 1100-0002, …
used = Account.objects.filter(user=user, parent=parent).count()
code = f"{ACCOUNT_RECEIVABLE}-{used + 1:04d}"
account = Account.objects.create(
id=str(uuid.uuid4()),
user=user,
code=code,
name=customer.name,
level=Account.Level.TAFSILI,
account_type=Account.AccountType.ASSET,
parent=parent,
is_system=False,
)
customer.account = account
customer.save(update_fields=["account", "updated_at"])
return account
@staticmethod
def trial_balance(user: User, start: date | None = None, end: date | None = None) -> list[dict]:
"""ترازِ حساب‌ها — برای هر حسابِ معین، جمعِ بدهکار و بستانکار و
مانده (با در نظر گرفتنِ ماهیتِ طبیعیِ حساب)."""
ChartOfAccountsService.ensure_chart(user)
qs = Transaction.objects.filter(user=user)
if start:
qs = qs.filter(transaction_date__date__gte=start)
if end:
qs = qs.filter(transaction_date__date__lte=end)
debit = dict(
qs.exclude(debit_account_ref=None)
.values_list("debit_account_ref")
.annotate(total=Sum("amount"))
)
credit = dict(
qs.exclude(credit_account_ref=None)
.values_list("credit_account_ref")
.annotate(total=Sum("amount"))
)
rows = []
for account in Account.objects.filter(user=user, level=Account.Level.MOIN).order_by("code"):
d = debit.get(account.id, 0) or 0
c = credit.get(account.id, 0) or 0
if d == 0 and c == 0:
continue
balance = (d - c) if account.is_debit_nature else (c - d)
rows.append(
{
"code": account.code,
"name": account.name,
"path": account.full_path,
"account_type": account.get_account_type_display(),
"debit": d,
"credit": c,
"balance": balance,
}
)
return rows
class CustomerStatementService:
"""صورت‌حسابِ یک مشتری — فاکتورها، بدهی‌های باز، و اقساطِ پرداختی، همه
در یک جا. همان چیزی که «اتصالِ مشتری به فاکتور و حسابداری» را برای
کاربر ملموس می‌کند."""
@staticmethod
def build(user: User, customer: Customer) -> dict:
invoices = (
Invoice.objects.filter(user=user)
.filter(Q(customer=customer) | Q(buyer_phone=customer.phone))
.exclude(buyer_phone="")
.order_by("-invoice_date")
if customer.phone
else Invoice.objects.filter(user=user, customer=customer).order_by("-invoice_date")
)
debts = (
LedgerEntry.objects.filter(
user=user,
category=LedgerEntry.Category.SALE_CREDIT,
counterparty_phone=customer.phone,
).prefetch_related("installments")
if customer.phone
else LedgerEntry.objects.none()
)
debt_rows = []
for entry in debts:
debt_rows.append(
{
"id": entry.id,
"title": entry.title,
"entry_date": entry.entry_date,
"total_amount": entry.total_amount,
"paid_amount": entry.paid_amount,
"remaining_amount": entry.remaining_amount,
"installments": [
{"amount": i.amount, "paid_date": i.paid_date, "reference": i.reference}
for i in entry.installments.all()
],
}
)
return {
"customer": {
"id": customer.id,
"name": customer.name,
"phone": customer.phone,
"account_code": customer.account.code if customer.account_id else "",
"outstanding_balance": customer.outstanding_balance,
"total_purchases_count": customer.total_purchases_count,
"total_purchase_amount": customer.total_purchase_amount,
"last_purchase_date": customer.last_purchase_date,
},
"invoices": [
{
"id": inv.id,
"invoice_number": inv.invoice_number,
"invoice_date": inv.invoice_date,
"final_amount": inv.final_amount,
"payment_type": inv.get_payment_type_display(),
"payment_status": inv.get_payment_status_display(),
"order_status": inv.get_order_status_display(),
}
for inv in invoices
],
"debts": debt_rows,
}
class ExcelExportService:
"""خروجیِ اکسلِ واقعی (.xlsx) و چندبرگی — به‌جای یک CSV تک‌برگیِ خام.
برگه‌ها: خلاصه، دفتر روزنامه، تراز حساب‌ها، بدهکاران، فاکتورها، انبار.
هدفِ صریح: «به دردِ مشتری بخورد» — یعنی همان چیزی که یک مغازه‌دار
می‌تواند مستقیم برای حسابدارش بفرستد، با ستون‌های فارسیِ راست‌چین،
عددهای هزارگان‌دار، و جمعِ ستون‌ها."""
HEADER_FILL = "FF2D2A55" # سرمه‌ای برند
MONEY_FORMAT = "#,##0"
@staticmethod
def _style_sheet(ws, header_row: int = 1) -> None:
from openpyxl.styles import Alignment, Font, PatternFill
ws.sheet_view.rightToLeft = True
fill = PatternFill("solid", fgColor=ExcelExportService.HEADER_FILL)
for cell in ws[header_row]:
if cell.value is None:
continue
cell.font = Font(bold=True, color="FFFFFFFF")
cell.fill = fill
cell.alignment = Alignment(horizontal="center", vertical="center")
ws.freeze_panes = ws.cell(row=header_row + 1, column=1)
@staticmethod
def _autosize(ws) -> None:
for column_cells in ws.columns:
longest = 0
letter = None
for cell in column_cells:
letter = letter or cell.column_letter
value = cell.value
if value is not None:
longest = max(longest, len(str(value)))
if letter:
ws.column_dimensions[letter].width = min(max(longest + 4, 12), 45)
@staticmethod
def _write(ws, headers: list[str], rows: list[list], money_columns: set[int] | None = None) -> None:
ws.append(headers)
for row in rows:
ws.append(row)
if money_columns:
for row_cells in ws.iter_rows(min_row=2):
for idx in money_columns:
if idx < len(row_cells):
row_cells[idx].number_format = ExcelExportService.MONEY_FORMAT
ExcelExportService._style_sheet(ws)
ExcelExportService._autosize(ws)
@staticmethod
def build(user: User, start: date | None = None, end: date | None = None) -> bytes:
from openpyxl import Workbook
ChartOfAccountsService.ensure_chart(user)
wb = Workbook()
entries = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related(
"installments", "account"
)
invoices = Invoice.objects.filter(user=user).order_by("invoice_date")
if start:
entries = entries.filter(entry_date__gte=start)
invoices = invoices.filter(invoice_date__date__gte=start)
if end:
entries = entries.filter(entry_date__lte=end)
invoices = invoices.filter(invoice_date__date__lte=end)
entries = list(entries)
invoices = list(invoices)
# --- برگه ۱: خلاصه ---
ws = wb.active
ws.title = "خلاصه"
total_income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME)
total_expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE)
open_receivable = sum(
e.remaining_amount for e in entries if e.category == LedgerEntry.Category.SALE_CREDIT
)
open_payable = sum(
e.remaining_amount for e in entries if e.category == LedgerEntry.Category.PURCHASE_CREDIT
)
inventory_value = (
Product.objects.filter(user=user).aggregate(v=Sum("total_purchase_value"))["v"] or 0
)
ExcelExportService._write(
ws,
["عنوان", "مبلغ"],
[
["بازه‌ی گزارش", f"{start or 'ابتدا'} تا {end or 'امروز'}"],
["مجموع درآمد", total_income],
["مجموع هزینه", total_expense],
["سود/زیان خالص", total_income - total_expense],
["مانده‌ی مطالبات (طلب از مشتریان)", open_receivable],
["مانده‌ی بدهی‌ها (به تامین‌کنندگان)", open_payable],
["ارزش موجودی انبار", inventory_value],
["تعداد فاکتورها", len(invoices)],
],
money_columns={1},
)
# --- برگه ۲: دفتر روزنامه ---
ws = wb.create_sheet("دفتر روزنامه")
ExcelExportService._write(
ws,
[
"تاریخ", "نوع", "دسته", "حساب معین", "عنوان", "مبلغ",
"طرف حساب", "شماره طرف حساب", "مبلغ کل تعهد", "پرداخت‌شده", "مانده", "یادداشت",
],
[
[
e.entry_date.isoformat(),
e.get_entry_type_display(),
e.get_category_display() if e.category else "",
e.account.name if e.account_id else "",
e.title,
e.amount,
e.counterparty_name,
e.counterparty_phone,
e.total_amount if e.is_debt else None,
e.paid_amount if e.is_debt else None,
e.remaining_amount if e.is_debt else None,
e.note,
]
for e in entries
],
money_columns={5, 8, 9, 10},
)
# --- برگه ۳: تراز حساب‌ها ---
ws = wb.create_sheet("تراز حساب‌ها")
ExcelExportService._write(
ws,
["کد حساب", "نام حساب", "مسیر", "نوع", "بدهکار", "بستانکار", "مانده"],
[
[r["code"], r["name"], r["path"], r["account_type"], r["debit"], r["credit"], r["balance"]]
for r in ChartOfAccountsService.trial_balance(user, start, end)
],
money_columns={4, 5, 6},
)
# --- برگه ۴: بدهکاران ---
ws = wb.create_sheet("بدهکاران")
debtors = [
c for c in Customer.objects.filter(user=user).order_by("-outstanding_balance")
if c.outstanding_balance
]
ExcelExportService._write(
ws,
["نام مشتری", "شماره تماس", "کد حساب تفصیلی", "مانده بدهی", "تعداد خرید", "مجموع خرید", "آخرین خرید"],
[
[
c.name,
c.phone,
c.account.code if c.account_id else "",
c.outstanding_balance,
c.total_purchases_count,
c.total_purchase_amount,
c.last_purchase_date.date().isoformat() if c.last_purchase_date else "",
]
for c in debtors
],
money_columns={3, 5},
)
# --- برگه ۵: فاکتورها ---
ws = wb.create_sheet("فاکتورها")
ExcelExportService._write(
ws,
["شماره فاکتور", "تاریخ", "خریدار", "شماره خریدار", "جمع کل", "تخفیف", "مبلغ نهایی", "نوع پرداخت", "وضعیت پرداخت", "وضعیت سفارش"],
[
[
inv.invoice_number,
inv.invoice_date.date().isoformat(),
inv.buyer_name,
inv.buyer_phone,
inv.total_amount,
inv.discount_amount,
inv.final_amount,
inv.get_payment_type_display(),
inv.get_payment_status_display(),
inv.get_order_status_display(),
]
for inv in invoices
],
money_columns={4, 5, 6},
)
# --- برگه ۶: انبار ---
ws = wb.create_sheet("انبار")
ExcelExportService._write(
ws,
["نام کالا", "کد کالا", "دسته", "واحد", "موجودی", "قیمت خرید", "قیمت فروش", "ارزش موجودی", "وضعیت"],
[
[
p.name,
p.sku,
p.category,
p.unit,
p.quantity,
p.purchase_price,
p.unit_price,
p.total_purchase_value,
"کمبود موجودی" if p.is_low_stock else "",
]
for p in Product.objects.filter(user=user).order_by("category", "name")
],
money_columns={5, 6, 7},
)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
class LedgerBookService:
"""مدیریت «فایل»های دفتر — باز کردن فایل تازه، بستن فایل، و تضمینِ
این‌که همیشه دقیقا یک فایلِ جاری وجود دارد."""
@staticmethod
def ensure_active_book(user: User) -> "LedgerBook":
"""فایلِ جاری را برمی‌گرداند؛ اگر کاربر هنوز هیچ فایلی ندارد، یک
فایلِ پیش‌فرض می‌سازد تا هیچ‌وقت حالتِ «هیچ فایلی نیست» پیش نیاید."""
from apps.bizapps.models import LedgerBook
book = LedgerBook.objects.filter(user=user, is_active=True).first()
if book is not None:
return book
today = timezone.localdate()
book = LedgerBook.objects.create(
id=str(uuid.uuid4()),
user=user,
name=f"دفتر {today.year}",
start_date=today,
is_active=True,
)
# ثبت‌های قدیمیِ بی‌فایل به همین فایلِ اول وصل می‌شوند تا از
# گزارش‌ها ناپدید نشوند.
LedgerEntry.objects.filter(user=user, book__isnull=True).update(book=book)
return book
@staticmethod
@db_transaction.atomic
def open_new_book(
user: User,
name: str,
start_date: date,
end_date: date | None = None,
carry_balance: bool = True,
) -> "LedgerBook":
"""فایل تازه باز می‌کند: فایلِ جاریِ قبلی بسته و فقط-خواندنی می‌شود،
و اگر خواسته شود مانده‌ی پایانیِ آن به‌عنوان مانده‌ی ابتدای فایلِ
جدید منتقل می‌گردد (کارِ استانداردِ «انتقال مانده» سرِ سال مالی)."""
from apps.bizapps.models import LedgerBook
previous = LedgerBook.objects.select_for_update().filter(user=user, is_active=True).first()
opening = 0.0
if previous is not None:
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"])
return LedgerBook.objects.create(
id=str(uuid.uuid4()),
user=user,
name=name,
start_date=start_date,
end_date=end_date,
opening_balance=opening,
is_active=True,
)
@staticmethod
def closing_balance(book: "LedgerBook") -> float:
"""مانده‌ی پایانیِ یک فایل = مانده‌ی ابتدا + درآمدها − هزینه‌ها."""
entries = book.entries.all()
income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME)
expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE)
return (book.opening_balance or 0) + income - expense
@staticmethod
def summary(book: "LedgerBook") -> dict:
entries = list(book.entries.prefetch_related("installments"))
income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME)
expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE)
return {
"entry_count": len(entries),
"total_income": income,
"total_expense": expense,
"net": income - expense,
"opening_balance": book.opening_balance or 0,
"closing_balance": (book.opening_balance or 0) + income - expense,
"open_receivable": sum(
e.remaining_amount for e in entries if e.category == LedgerEntry.Category.SALE_CREDIT
),
"open_payable": sum(
e.remaining_amount for e in entries if e.category == LedgerEntry.Category.PURCHASE_CREDIT
),
}
class SubsidiaryLedgerService:
"""دفتر معین — برای یک حسابِ معینِ مشخص، همه‌ی گردشِ آن حساب را به‌ترتیبِ
تاریخ با «مانده‌ی در جریان» (running balance) برمی‌گرداند.
این دقیقا همان چیزی است که حسابدار انتظار دارد ببیند: تاریخ، شرح،
بدهکار، بستانکار، مانده — و مانده بعد از هر ردیف به‌روز می‌شود."""
@staticmethod
def statement(
user: User,
account: Account,
start: date | None = None,
end: date | None = None,
) -> dict:
qs = (
Transaction.objects.filter(user=user)
.filter(Q(debit_account_ref=account) | Q(credit_account_ref=account))
.order_by("transaction_date", "created_at")
)
if start:
qs = qs.filter(transaction_date__date__gte=start)
if end:
qs = qs.filter(transaction_date__date__lte=end)
# مانده‌ی ابتدای دوره: اثرِ همه‌ی اسنادِ پیش از بازه.
opening = 0.0
if start:
for t in (
Transaction.objects.filter(user=user)
.filter(Q(debit_account_ref=account) | Q(credit_account_ref=account))
.filter(transaction_date__date__lt=start)
):
opening += SubsidiaryLedgerService._effect(account, t)
rows = []
balance = opening
total_debit = 0.0
total_credit = 0.0
for t in qs:
debit = t.amount if t.debit_account_ref_id == account.id else 0
credit = t.amount if t.credit_account_ref_id == account.id else 0
balance += SubsidiaryLedgerService._effect(account, t)
total_debit += debit
total_credit += credit
rows.append(
{
"id": t.id,
"date": t.transaction_date,
"number": t.transaction_number,
"description": t.description,
"type": t.get_transaction_type_display(),
"invoice_id": t.reference_invoice_id,
"debit": debit,
"credit": credit,
"balance": balance,
}
)
return {
"account": {
"id": account.id,
"code": account.code,
"name": account.name,
"path": account.full_path,
"account_type": account.get_account_type_display(),
"is_debit_nature": account.is_debit_nature,
},
"opening_balance": opening,
"total_debit": total_debit,
"total_credit": total_credit,
"closing_balance": balance,
"rows": rows,
}
@staticmethod
def _effect(account: Account, t: Transaction) -> float:
"""اثرِ یک سند روی مانده‌ی این حساب، با در نظر گرفتنِ ماهیتِ طبیعیِ
حساب — برای حسابِ بدهکار (دارایی/هزینه) بدهکار مثبت است، برای
حسابِ بستانکار (درآمد/بدهی/سرمایه) برعکس."""
debit = t.amount if t.debit_account_ref_id == account.id else 0
credit = t.amount if t.credit_account_ref_id == account.id else 0
return (debit - credit) if account.is_debit_nature else (credit - debit)
+216 -19
View File
@@ -1,37 +1,120 @@
from rest_framework import serializers
from apps.bizapps.models import Appointment, Customer, LedgerEntry, Product, StockMovement
from apps.bizapps.models import (
Account,
Customer,
Invoice,
InvoiceItem,
LedgerBook,
LedgerEntry,
LedgerInstallment,
Product,
StockMovement,
Transaction,
)
class AccountSerializer(serializers.ModelSerializer):
full_path = serializers.CharField(read_only=True)
level_display = serializers.CharField(source="get_level_display", read_only=True)
account_type_display = serializers.CharField(source="get_account_type_display", read_only=True)
class Meta:
model = Account
fields = [
"id", "code", "name", "level", "level_display", "account_type",
"account_type_display", "parent", "full_path", "is_system", "is_active",
]
read_only_fields = ["is_system"]
class CustomerSerializer(serializers.ModelSerializer):
account_code = serializers.CharField(source="account.code", read_only=True, default="")
class Meta:
model = Customer
fields = ["id", "name", "phone", "email", "address", "notes", "updated_at"]
read_only_fields = ["updated_at"]
fields = [
"id", "name", "phone", "social_platform", "social_handle",
"email", "address", "notes",
"total_purchases_count", "total_purchase_amount", "last_purchase_date",
"outstanding_balance", "account_code", "updated_at",
]
# این‌ها را سرویس‌ها محاسبه می‌کنند، نه کلاینت — وگرنه مانده‌ی بدهی
# از سمت اپ قابل‌دستکاری می‌شد.
read_only_fields = [
"total_purchases_count", "total_purchase_amount", "last_purchase_date",
"outstanding_balance", "account_code", "updated_at",
]
class LedgerInstallmentSerializer(serializers.ModelSerializer):
class Meta:
model = LedgerInstallment
fields = ["id", "amount", "paid_date", "reference", "created_at"]
# برخلافِ بقیه‌ی مدل‌ها (که UUID را خودِ اپ فلاتر می‌سازد و می‌فرستد)،
# id قسط را ویو تولید می‌کند — پس نباید از کلاینت الزامی باشد.
read_only_fields = ["id", "created_at"]
def validate_amount(self, value):
if value <= 0:
raise serializers.ValidationError("مبلغ قسط باید بزرگ‌تر از صفر باشد.")
return value
def validate(self, attrs):
entry: LedgerEntry = self.context["ledger_entry"]
if not entry.is_debt:
raise serializers.ValidationError("فقط برای ردیف‌های بدهی‌دار می‌توان قسط ثبت کرد.")
amount = attrs.get("amount", 0)
# epsilon کوچک تا خطای گرد‌کردنِ اعشارِ شناور باعثِ ردِ یک قسطِ
# تسویه‌ی دقیق نشود.
if amount > entry.remaining_amount + 1:
raise serializers.ValidationError("مبلغ قسط از مانده‌ی بدهی بیشتر است.")
return attrs
class LedgerEntrySerializer(serializers.ModelSerializer):
installments = LedgerInstallmentSerializer(many=True, read_only=True)
is_debt = serializers.BooleanField(read_only=True)
professional_type = serializers.CharField(read_only=True)
paid_amount = serializers.FloatField(read_only=True)
remaining_amount = serializers.FloatField(read_only=True)
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="")
class Meta:
model = LedgerEntry
fields = ["id", "entry_type", "title", "amount", "category", "entry_date", "note", "updated_at"]
read_only_fields = ["updated_at"]
fields = [
"id", "entry_type", "title", "amount", "category", "category_display",
"entry_date", "note", "total_amount", "counterparty_name", "counterparty_phone",
"linked_invoice", "account_name", "account_code",
"is_debt", "professional_type", "paid_amount", "remaining_amount",
"installments", "updated_at",
]
read_only_fields = ["linked_invoice", "account_name", "account_code", "updated_at"]
def validate(self, attrs):
category = attrs.get("category", getattr(self.instance, "category", ""))
if category in LedgerEntry.DEBT_CATEGORIES:
total = attrs.get("total_amount", getattr(self.instance, "total_amount", 0))
if not total or total <= 0:
raise serializers.ValidationError(
{"total_amount": "برای فروش قسطی/خرید نسیه، مبلغ کل تعهد الزامی است."}
)
return attrs
class ProductSerializer(serializers.ModelSerializer):
is_low_stock = serializers.BooleanField(read_only=True)
class Meta:
model = Product
fields = [
"id",
"name",
"sku",
"category",
"quantity",
"unit_price",
"low_stock_threshold",
"notes",
"updated_at",
"id", "name", "sku", "category", "unit", "quantity",
"unit_price", "purchase_price", "total_purchase_value",
"low_stock_threshold", "is_low_stock", "notes", "updated_at",
]
read_only_fields = ["updated_at"]
read_only_fields = ["total_purchase_value", "is_low_stock", "updated_at"]
class StockMovementSerializer(serializers.ModelSerializer):
@@ -51,8 +134,122 @@ class AdjustStockSerializer(serializers.Serializer):
return value
class AppointmentSerializer(serializers.ModelSerializer):
class InvoiceItemSerializer(serializers.ModelSerializer):
class Meta:
model = Appointment
fields = ["id", "customer_name", "customer_phone", "title", "start_at", "status", "notes", "updated_at"]
read_only_fields = ["updated_at"]
model = InvoiceItem
fields = ["id", "product", "description", "quantity", "unit_price", "discount_percent", "total_price"]
read_only_fields = ["total_price"]
class InvoiceSerializer(serializers.ModelSerializer):
items = InvoiceItemSerializer(many=True, read_only=True)
payment_type_display = serializers.CharField(source="get_payment_type_display", read_only=True)
payment_status_display = serializers.CharField(source="get_payment_status_display", read_only=True)
order_status_display = serializers.CharField(source="get_order_status_display", read_only=True)
class Meta:
model = Invoice
fields = [
"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",
"payment_type", "payment_type_display",
"payment_status", "payment_status_display",
"order_status", "order_status_display",
"shipping_method", "tracking_code", "notes", "paid_at",
"items", "created_at", "updated_at",
]
read_only_fields = [
"invoice_number", "total_amount", "final_amount", "paid_at", "created_at", "updated_at",
]
class CreateInvoiceItemSerializer(serializers.Serializer):
product = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all(), required=False, allow_null=True)
description = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
quantity = serializers.FloatField(min_value=0.0001)
unit_price = serializers.FloatField(required=False, default=0)
discount_percent = serializers.FloatField(required=False, default=0, min_value=0, max_value=100)
def validate(self, attrs):
if attrs.get("product") is None and not attrs.get("description"):
raise serializers.ValidationError("برای ردیف بدون کالا، شرح الزامی است.")
return attrs
class CreateInvoiceSerializer(serializers.Serializer):
items = CreateInvoiceItemSerializer(many=True)
customer = serializers.PrimaryKeyRelatedField(queryset=Customer.objects.all(), required=False, allow_null=True)
invoice_date = serializers.DateTimeField(required=False)
buyer_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
buyer_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, default="")
buyer_address = serializers.CharField(max_length=255, required=False, allow_blank=True, default="")
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_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)
tracking_code = serializers.CharField(max_length=100, required=False, allow_blank=True, default="")
notes = serializers.CharField(required=False, allow_blank=True, default="")
send_sms = serializers.BooleanField(required=False, default=False)
def validate_items(self, value):
if not value:
raise serializers.ValidationError("فاکتور باید حداقل یک قلم کالا داشته باشد.")
return value
class UpdateInvoiceStatusSerializer(serializers.Serializer):
order_status = serializers.ChoiceField(choices=Invoice.OrderStatus.choices, required=False)
payment_status = serializers.ChoiceField(choices=Invoice.PaymentStatus.choices, required=False)
tracking_code = serializers.CharField(max_length=100, required=False, allow_blank=True)
def validate(self, attrs):
if not attrs:
raise serializers.ValidationError("حداقل یکی از فیلدهای وضعیت باید ارسال شود.")
return attrs
class TransactionSerializer(serializers.ModelSerializer):
transaction_type_display = serializers.CharField(source="get_transaction_type_display", read_only=True)
debit_account_name = serializers.CharField(source="debit_account_ref.name", read_only=True, default="")
credit_account_name = serializers.CharField(source="credit_account_ref.name", read_only=True, default="")
class Meta:
model = Transaction
fields = [
"id", "transaction_number", "transaction_date", "reference_invoice",
"transaction_type", "transaction_type_display",
"debit_account", "credit_account", "debit_account_name", "credit_account_name",
"amount", "description", "created_at",
]
read_only_fields = fields
class LedgerBookSerializer(serializers.ModelSerializer):
class Meta:
model = LedgerBook
fields = [
"id", "name", "start_date", "end_date", "opening_balance",
"is_active", "is_closed", "closed_at", "created_at",
]
read_only_fields = ["id", "is_active", "is_closed", "closed_at", "created_at"]
class OpenLedgerBookSerializer(serializers.Serializer):
name = serializers.CharField(max_length=120)
start_date = serializers.DateField()
end_date = serializers.DateField(required=False, allow_null=True)
# پیش‌فرض true: انتقالِ مانده سرِ سال مالی کارِ استاندارد است.
carry_balance = serializers.BooleanField(required=False, default=True)
def validate(self, attrs):
end = attrs.get("end_date")
if end and end <= attrs["start_date"]:
raise serializers.ValidationError(
{"end_date": "تاریخ پایان باید بعد از تاریخ شروع باشد."}
)
return attrs
+88 -7
View File
@@ -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)
+430 -71
View File
@@ -1,130 +1,489 @@
from django.db import transaction
import uuid
from django.db.models import Sum
from django.http import HttpResponse
from django.utils.dateparse import parse_date
from rest_framework import generics, status
from rest_framework.exceptions import NotFound
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.billing.permissions import HasActiveFeature
from apps.bizapps.models import Appointment, Customer, LedgerEntry, Product, StockMovement
from .serializers import (
AdjustStockSerializer,
AppointmentSerializer,
CustomerSerializer,
LedgerEntrySerializer,
ProductSerializer,
StockMovementSerializer,
from apps.bizapps.models import (
Account,
Customer,
Invoice,
LedgerBook,
LedgerEntry,
LedgerInstallment,
Product,
StockMovement,
Transaction,
)
# همه‌ی این ریزاپ‌ها با جم فعال می‌شوند (یا دوره‌ی آزمایشی) — `feature_key`
# روی هر ویو مشخص می‌کند HasActiveFeature باید چه چیزی را چک کند.
# permission_classes اینجا Base است تا زیرکلاس‌ها فراموش نکنند.
from .accounting import (
ChartOfAccountsService,
CustomerStatementService,
LedgerBookService,
SubsidiaryLedgerService,
)
from .serializers import (
AccountSerializer,
AdjustStockSerializer,
CreateInvoiceSerializer,
CustomerSerializer,
InvoiceSerializer,
LedgerBookSerializer,
LedgerEntrySerializer,
LedgerInstallmentSerializer,
OpenLedgerBookSerializer,
ProductSerializer,
StockMovementSerializer,
TransactionSerializer,
UpdateInvoiceStatusSerializer,
)
from .services import InsufficientStockError, InvoiceService, LedgerService
class _UserScopedListCreateView(generics.ListCreateAPIView):
permission_classes = [IsAuthenticated, HasActiveFeature]
class _OwnedMixin:
"""همه‌ی منابع این اپ فقط متعلق به کاربرِ درخواست‌دهنده‌اند."""
permission_classes = [IsAuthenticated]
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
# ---------------- مشتریان ----------------
class CustomerListCreateView(_OwnedMixin, generics.ListCreateAPIView):
model = Customer
serializer_class = CustomerSerializer
def perform_create(self, serializer):
serializer.save(user=self.request.user)
class _UserScopedDetailView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [IsAuthenticated, HasActiveFeature]
class CustomerDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
model = Customer
serializer_class = CustomerSerializer
class CustomerStatementView(APIView):
"""صورت‌حسابِ کاملِ یک مشتری — فاکتورها + بدهی‌های باز + اقساط، یک‌جا.
همان اتصالِ «مشتری ↔ فاکتور ↔ حسابداری» به‌شکلِ یک پاسخِ واحد."""
permission_classes = [IsAuthenticated]
def get(self, request, pk):
customer = Customer.objects.filter(user=request.user, pk=pk).first()
if customer is None:
raise NotFound("مشتری یافت نشد.")
return Response(CustomerStatementService.build(request.user, customer))
# ---------------- دفتر درآمد و هزینه ----------------
class LedgerEntryListCreateView(_OwnedMixin, generics.ListCreateAPIView):
model = LedgerEntry
serializer_class = LedgerEntrySerializer
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
qs = super().get_queryset().prefetch_related("installments").select_related("account")
# پیش‌فرض فقط فایلِ جاری؟ نه — book=<id> صریح، وگرنه همه؛ تا
# گزارش‌های چنددوره‌ای هم ممکن بماند.
book_id = self.request.query_params.get("book")
if book_id:
qs = qs.filter(book_id=book_id)
start = parse_date(self.request.query_params.get("start", "") or "")
end = parse_date(self.request.query_params.get("end", "") or "")
if start:
qs = qs.filter(entry_date__gte=start)
if end:
qs = qs.filter(entry_date__lte=end)
return qs
def perform_create(self, serializer):
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)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
class CustomerListCreateView(_UserScopedListCreateView):
model = Customer
serializer_class = CustomerSerializer
feature_key = "customers"
class CustomerDetailView(_UserScopedDetailView):
model = Customer
serializer_class = CustomerSerializer
feature_key = "customers"
class LedgerEntryListCreateView(_UserScopedListCreateView):
class LedgerEntryDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
model = LedgerEntry
serializer_class = LedgerEntrySerializer
feature_key = "ledger"
def get_queryset(self):
return super().get_queryset().prefetch_related("installments").select_related("account")
def perform_update(self, serializer):
entry = serializer.save()
LedgerService.sync_entry_account(self.request.user, entry)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
def perform_destroy(self, instance):
phone = instance.counterparty_phone
instance.delete()
if phone:
LedgerService.recompute_customer_balance(self.request.user, phone)
class LedgerEntryDetailView(_UserScopedDetailView):
model = LedgerEntry
serializer_class = LedgerEntrySerializer
feature_key = "ledger"
class LedgerInstallmentListCreateView(generics.ListCreateAPIView):
serializer_class = LedgerInstallmentSerializer
permission_classes = [IsAuthenticated]
def _entry(self) -> LedgerEntry:
entry = LedgerEntry.objects.filter(
user=self.request.user, pk=self.kwargs["entry_id"]
).prefetch_related("installments").first()
if entry is None:
raise NotFound("ردیف دفتر یافت نشد.")
return entry
def get_serializer_context(self):
context = super().get_serializer_context()
context["ledger_entry"] = self._entry()
return context
def get_queryset(self):
return self._entry().installments.all()
def perform_create(self, serializer):
entry = self._entry()
installment = serializer.save(id=str(uuid.uuid4()), ledger_entry=entry)
# هر قسط، هم مانده‌ی مشتری را به‌روز می‌کند هم یک سندِ حسابداری
# دوطرفه می‌سازد — همان اتصالِ دفتر به درختِ حساب‌ها.
LedgerService.post_installment_transaction(self.request.user, installment)
if entry.counterparty_phone:
LedgerService.recompute_customer_balance(self.request.user, entry.counterparty_phone)
class ProductListCreateView(_UserScopedListCreateView):
class LedgerInstallmentDetailView(generics.DestroyAPIView):
serializer_class = LedgerInstallmentSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return LedgerInstallment.objects.filter(ledger_entry__user=self.request.user)
def perform_destroy(self, instance):
phone = instance.ledger_entry.counterparty_phone
instance.delete()
if phone:
LedgerService.recompute_customer_balance(self.request.user, phone)
class LedgerExportView(APIView):
"""خروجیِ دفتر — پیش‌فرض اکسلِ چندبرگی (?type=csv برای نسخه‌ی ساده).
عمداً `type` و نه `format`: پارامترِ `format` را خودِ DRF برای مذاکره‌ی
محتوا رزرو کرده و `?format=csv` را به‌عنوان درخواستِ رندررِ ثبت‌نشده‌ی
csv تفسیر و ۴۰۴ می‌دهد."""
permission_classes = [IsAuthenticated]
def get(self, request):
start = parse_date(request.query_params.get("start", "") or "")
end = parse_date(request.query_params.get("end", "") or "")
if request.query_params.get("type") == "csv":
content = LedgerService.export_csv(request.user, start, end)
response = HttpResponse(content, content_type="text/csv; charset=utf-8-sig")
response["Content-Disposition"] = 'attachment; filename="ledger.csv"'
return response
content = LedgerService.export_xlsx(request.user, start, end)
response = HttpResponse(
content,
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
response["Content-Disposition"] = 'attachment; filename="shopship-report.xlsx"'
return response
# ---------------- درخت حساب‌ها ----------------
class AccountListView(generics.ListCreateAPIView):
serializer_class = AccountSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
ChartOfAccountsService.ensure_chart(self.request.user)
qs = Account.objects.filter(user=self.request.user).select_related("parent")
level = self.request.query_params.get("level")
if level:
qs = qs.filter(level=level)
return qs
def perform_create(self, serializer):
serializer.save(id=str(uuid.uuid4()), user=self.request.user, is_system=False)
class AccountDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = AccountSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Account.objects.filter(user=self.request.user)
def perform_destroy(self, instance):
# حساب‌های سیستمی پایه‌ی سندهای خودکارند؛ حذفشان گزارش‌ها را می‌شکند.
if instance.is_system:
raise ValidationError({"detail": "حساب‌های سیستمی قابل حذف نیستند."})
instance.delete()
class TrialBalanceView(APIView):
"""ترازِ حساب‌ها — جمعِ بدهکار/بستانکار و مانده‌ی هر حسابِ معین."""
permission_classes = [IsAuthenticated]
def get(self, request):
start = parse_date(request.query_params.get("start", "") or "")
end = parse_date(request.query_params.get("end", "") or "")
return Response(ChartOfAccountsService.trial_balance(request.user, start, end))
class TransactionListView(generics.ListAPIView):
serializer_class = TransactionSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Transaction.objects.filter(user=self.request.user).select_related(
"debit_account_ref", "credit_account_ref"
)
# ---------------- انبار ----------------
class ProductListCreateView(_OwnedMixin, generics.ListCreateAPIView):
model = Product
serializer_class = ProductSerializer
feature_key = "inventory"
def perform_create(self, serializer):
product = serializer.save(user=self.request.user)
product.recompute_purchase_value()
product.save(update_fields=["total_purchase_value", "updated_at"])
class ProductDetailView(_UserScopedDetailView):
class ProductDetailView(_OwnedMixin, generics.RetrieveUpdateDestroyAPIView):
model = Product
serializer_class = ProductSerializer
feature_key = "inventory"
def perform_update(self, serializer):
product = serializer.save()
product.recompute_purchase_value()
product.save(update_fields=["total_purchase_value", "updated_at"])
class ProductAdjustStockView(APIView):
"""تعدیل اتمیک موجودی (+/-) + یک ردیف تاریخچه — به‌جای این‌که کلاینت
مستقیم quantity جدید را PATCH کند (که هیچ تاریخچه‌ای نمی‌سازد و اگر دو
درخواست هم‌زمان بیایند می‌تواند یکی را گم کند)."""
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "inventory"
@transaction.atomic
def post(self, request, pk):
try:
product = Product.objects.select_for_update().get(pk=pk, user=request.user)
except Product.DoesNotExist:
product = Product.objects.filter(user=request.user, pk=pk).first()
if product is None:
raise NotFound("کالا یافت نشد.")
serializer = AdjustStockSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
delta = serializer.validated_data["delta"]
product.quantity += delta
product.save(update_fields=["quantity", "updated_at"])
product.quantity += serializer.validated_data["delta"]
product.recompute_purchase_value()
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
StockMovement.objects.create(
product=product,
delta=delta,
delta=serializer.validated_data["delta"],
quantity_after=product.quantity,
note=serializer.validated_data.get("note", ""),
)
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)
return Response(ProductSerializer(product).data)
class ProductMovementsView(generics.ListAPIView):
"""تاریخچه‌ی تغییرات موجودی یک کالا — جدیدترین اول."""
serializer_class = StockMovementSerializer
permission_classes = [IsAuthenticated, HasActiveFeature]
feature_key = "inventory"
permission_classes = [IsAuthenticated]
def get_queryset(self):
return StockMovement.objects.filter(
product_id=self.kwargs["pk"], product__user=self.request.user
product__user=self.request.user, product_id=self.kwargs["pk"]
)
class AppointmentListCreateView(_UserScopedListCreateView):
model = Appointment
serializer_class = AppointmentSerializer
feature_key = "booking"
class InventorySummaryView(APIView):
"""خلاصه‌ی انبار — ارزشِ کل، تعداد اقلام، و کالاهای روی آستانه‌ی کمبود."""
permission_classes = [IsAuthenticated]
def get(self, request):
products = Product.objects.filter(user=request.user)
low = [p for p in products if p.is_low_stock]
return Response(
{
"total_products": products.count(),
"total_value": products.aggregate(v=Sum("total_purchase_value"))["v"] or 0,
"low_stock_count": len(low),
"low_stock": ProductSerializer(low, many=True).data,
}
)
class AppointmentDetailView(_UserScopedDetailView):
model = Appointment
serializer_class = AppointmentSerializer
feature_key = "booking"
# ---------------- فاکتور ----------------
class InvoiceListCreateView(_OwnedMixin, generics.ListCreateAPIView):
model = Invoice
serializer_class = InvoiceSerializer
def get_queryset(self):
return super().get_queryset().prefetch_related("items")
def create(self, request, *args, **kwargs):
serializer = CreateInvoiceSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = dict(serializer.validated_data)
send_sms = data.pop("send_sms", False)
try:
invoice = InvoiceService.create_invoice(request.user, data, send_sms=send_sms)
except InsufficientStockError as exc:
raise ValidationError({"detail": str(exc)})
return Response(InvoiceSerializer(invoice).data, status=status.HTTP_201_CREATED)
class InvoiceDetailView(_OwnedMixin, generics.RetrieveAPIView):
model = Invoice
serializer_class = InvoiceSerializer
def get_queryset(self):
return super().get_queryset().prefetch_related("items")
class InvoiceStatusView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, pk):
serializer = UpdateInvoiceStatusSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
invoice = InvoiceService.update_status(request.user, pk, **serializer.validated_data)
except Invoice.DoesNotExist:
raise NotFound("فاکتور یافت نشد.")
return Response(InvoiceSerializer(invoice).data)
class InvoiceCancelView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, pk):
try:
invoice = InvoiceService.cancel_invoice(request.user, pk)
except Invoice.DoesNotExist:
raise NotFound("فاکتور یافت نشد.")
except ValueError as exc:
raise ValidationError({"detail": str(exc)})
return Response(InvoiceSerializer(invoice).data)
class BusinessDashboardView(APIView):
"""داشبوردِ یکپارچه — همان «اتصالِ بخش‌ها» به‌شکلِ یک پاسخ: فروش، بدهی،
انبار و فاکتورها کنار هم، به‌جای چهار درخواستِ جدا از چهار صفحه."""
permission_classes = [IsAuthenticated]
def get(self, request):
user = request.user
entries = LedgerEntry.objects.filter(user=user).prefetch_related("installments")
products = Product.objects.filter(user=user)
invoices = Invoice.objects.filter(user=user)
income = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.INCOME)
expense = sum(e.amount for e in entries if e.entry_type == LedgerEntry.EntryType.EXPENSE)
receivable = sum(
e.remaining_amount for e in entries if e.category == LedgerEntry.Category.SALE_CREDIT
)
payable = sum(
e.remaining_amount for e in entries if e.category == LedgerEntry.Category.PURCHASE_CREDIT
)
return Response(
{
"total_income": income,
"total_expense": expense,
"net_profit": income - expense,
"open_receivable": receivable,
"open_payable": payable,
"inventory_value": products.aggregate(v=Sum("total_purchase_value"))["v"] or 0,
"low_stock_count": sum(1 for p in products if p.is_low_stock),
"invoice_count": invoices.count(),
"unpaid_invoice_count": invoices.filter(
payment_status=Invoice.PaymentStatus.UNPAID
).exclude(order_status=Invoice.OrderStatus.CANCELLED).count(),
"customer_count": Customer.objects.filter(user=user).count(),
"debtor_count": Customer.objects.filter(user=user, outstanding_balance__gt=0).count(),
}
)
# ---------------- فایل‌های دفتر و دفتر معین ----------------
class LedgerBookListView(generics.ListAPIView):
"""فهرست فایل‌های دفتر — همیشه حداقل یک فایلِ جاری وجود دارد."""
serializer_class = LedgerBookSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
LedgerBookService.ensure_active_book(self.request.user)
return LedgerBook.objects.filter(user=self.request.user)
class OpenLedgerBookView(APIView):
"""باز کردن فایل تازه — فایلِ جاری بسته می‌شود و (در صورت انتخاب) مانده
به فایل جدید منتقل می‌گردد."""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = OpenLedgerBookSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
book = LedgerBookService.open_new_book(
request.user,
name=serializer.validated_data["name"],
start_date=serializer.validated_data["start_date"],
end_date=serializer.validated_data.get("end_date"),
carry_balance=serializer.validated_data.get("carry_balance", True),
)
return Response(LedgerBookSerializer(book).data, status=status.HTTP_201_CREATED)
class LedgerBookSummaryView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, pk):
book = LedgerBook.objects.filter(user=request.user, pk=pk).first()
if book is None:
raise NotFound("فایل دفتر یافت نشد.")
return Response(
{"book": LedgerBookSerializer(book).data, **LedgerBookService.summary(book)}
)
class SubsidiaryLedgerView(APIView):
"""دفتر معین یک حساب — گردشِ حساب با مانده‌ی در جریان."""
permission_classes = [IsAuthenticated]
def get(self, request, pk):
account = Account.objects.filter(user=request.user, pk=pk).first()
if account is None:
raise NotFound("حساب یافت نشد.")
start = parse_date(request.query_params.get("start", "") or "")
end = parse_date(request.query_params.get("end", "") or "")
return Response(
SubsidiaryLedgerService.statement(request.user, account, start, end)
)
+246
View File
@@ -0,0 +1,246 @@
# Generated by Django 5.1.15 on 2026-09-02 00:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('code', models.CharField(max_length=20, verbose_name='کد حساب')),
('name', models.CharField(max_length=150, verbose_name='نام حساب')),
('level', models.PositiveSmallIntegerField(choices=[(1, 'حساب کل'), (2, 'حساب معین'), (3, 'حساب تفصیلی')], verbose_name='سطح')),
('account_type', models.CharField(choices=[('asset', 'دارایی'), ('liability', 'بدهی'), ('equity', 'سرمایه'), ('income', 'درآمد'), ('expense', 'هزینه')], max_length=15, verbose_name='نوع حساب')),
('is_system', models.BooleanField(default=False, verbose_name='حساب سیستمی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='bizapps.account', verbose_name='حساب والد')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accounts', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'حساب',
'verbose_name_plural': 'درخت حساب\u200cها',
'ordering': ['code'],
},
),
migrations.CreateModel(
name='Customer',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام')),
('phone', models.CharField(blank=True, max_length=20, verbose_name='شماره تماس')),
('email', models.CharField(blank=True, max_length=150, verbose_name='ایمیل')),
('address', models.CharField(blank=True, max_length=255, verbose_name='آدرس')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('total_purchases_count', models.PositiveIntegerField(default=0, verbose_name='تعداد خرید')),
('total_purchase_amount', models.FloatField(default=0, verbose_name='مجموع خرید')),
('last_purchase_date', models.DateTimeField(blank=True, null=True, verbose_name='آخرین خرید')),
('outstanding_balance', models.FloatField(default=0, verbose_name='مانده بدهی')),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customer', to='bizapps.account', verbose_name='حساب تفصیلی')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'مشتری',
'verbose_name_plural': 'مشتریان',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('invoice_number', models.CharField(max_length=30, verbose_name='شماره فاکتور')),
('invoice_date', models.DateTimeField(verbose_name='تاریخ فاکتور')),
('buyer_name', models.CharField(blank=True, max_length=150, verbose_name='نام خریدار')),
('buyer_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره خریدار')),
('buyer_address', models.CharField(blank=True, max_length=255, verbose_name='آدرس خریدار')),
('seller_name', models.CharField(blank=True, max_length=150, verbose_name='نام فروشنده')),
('seller_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره فروشنده')),
('seller_address', models.CharField(blank=True, max_length=255, verbose_name='آدرس فروشنده')),
('total_amount', models.FloatField(default=0, verbose_name='جمع کل')),
('discount_amount', models.FloatField(default=0, verbose_name='تخفیف')),
('tax_lines', models.JSONField(blank=True, default=list, verbose_name='ردیف\u200cهای مالیات')),
('final_amount', models.FloatField(default=0, verbose_name='مبلغ نهایی')),
('payment_type', models.CharField(choices=[('cash', 'نقدی'), ('credit', 'قسطی/نسیه')], default='cash', max_length=10)),
('payment_status', models.CharField(choices=[('unpaid', 'پرداخت\u200cنشده'), ('paid', 'پرداخت\u200cشده')], default='unpaid', max_length=10)),
('order_status', models.CharField(choices=[('pending_payment', 'در انتظار پرداخت'), ('ready_to_ship', 'آماده\u200cی ارسال'), ('shipped', 'ارسال\u200cشده'), ('delivered', 'تحویل\u200cشده'), ('cancelled', 'لغوشده')], default='ready_to_ship', max_length=20)),
('shipping_method', models.CharField(choices=[('pickup', 'تحویل حضوری'), ('post', 'پست'), ('courier', 'پیک')], default='pickup', max_length=10)),
('tracking_code', models.CharField(blank=True, max_length=100, verbose_name='کد رهگیری')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='زمان پرداخت')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='bizapps.customer')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'فاکتور',
'verbose_name_plural': 'فاکتورها',
'ordering': ['-invoice_date'],
},
),
migrations.CreateModel(
name='LedgerEntry',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('entry_type', models.CharField(choices=[('income', 'درآمد'), ('expense', 'هزینه')], max_length=10, verbose_name='نوع')),
('title', models.CharField(max_length=150, verbose_name='عنوان')),
('amount', models.FloatField(verbose_name='مبلغ')),
('category', models.CharField(blank=True, choices=[('sale_cash', 'فروش نقدی'), ('sale_credit', 'فروش قسطی/نسیه'), ('service_income', 'درآمد خدمات'), ('other_income', 'سایر درآمدها'), ('purchase_cash', 'خرید نقدی'), ('purchase_credit', 'خرید نسیه'), ('rent', 'اجاره'), ('salary', 'حقوق'), ('utility', 'قبض و آب\u200cوبرق'), ('transport', 'حمل\u200cونقل'), ('other_expense', 'سایر هزینه\u200cها')], max_length=100, verbose_name='دسته')),
('entry_date', models.DateField(verbose_name='تاریخ')),
('note', models.TextField(blank=True, verbose_name='یادداشت')),
('total_amount', models.FloatField(default=0, verbose_name='مبلغ کل تعهد')),
('counterparty_name', models.CharField(blank=True, max_length=150, verbose_name='طرف حساب')),
('counterparty_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره طرف حساب')),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ledger_entries', to='bizapps.account', verbose_name='حساب معین')),
('linked_invoice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ledger_entries', to='bizapps.invoice')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ledger_entries', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'ردیف دفتر درآمد/هزینه',
'verbose_name_plural': 'دفتر درآمد و هزینه',
'ordering': ['-entry_date'],
},
),
migrations.CreateModel(
name='LedgerInstallment',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('amount', models.FloatField(verbose_name='مبلغ قسط')),
('paid_date', models.DateField(verbose_name='تاریخ پرداخت')),
('reference', models.CharField(blank=True, max_length=150, verbose_name='شماره مرجع')),
('created_at', models.DateTimeField(auto_now_add=True)),
('ledger_entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='installments', to='bizapps.ledgerentry')),
],
options={
'verbose_name': 'قسط',
'verbose_name_plural': 'اقساط',
'ordering': ['paid_date'],
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام کالا')),
('sku', models.CharField(blank=True, max_length=100, verbose_name='کد کالا')),
('category', models.CharField(blank=True, max_length=100, verbose_name='دسته')),
('unit', models.CharField(blank=True, default='عدد', max_length=30, verbose_name='واحد شمارش')),
('quantity', models.FloatField(default=0, verbose_name='موجودی')),
('unit_price', models.FloatField(default=0, verbose_name='قیمت فروش')),
('purchase_price', models.FloatField(default=0, verbose_name='قیمت خرید')),
('total_purchase_value', models.FloatField(default=0, verbose_name='ارزش موجودی')),
('low_stock_threshold', models.FloatField(default=0, verbose_name='آستانه\u200cی هشدار موجودی کم')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'کالا',
'verbose_name_plural': 'انبار',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='InvoiceItem',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('description', models.CharField(max_length=255, verbose_name='شرح')),
('quantity', models.FloatField(verbose_name='تعداد')),
('unit_price', models.FloatField(verbose_name='قیمت واحد')),
('discount_percent', models.FloatField(default=0, verbose_name='درصد تخفیف')),
('total_price', models.FloatField(default=0, verbose_name='جمع')),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='bizapps.invoice')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='bizapps.product')),
],
options={
'verbose_name': 'قلم فاکتور',
'verbose_name_plural': 'اقلام فاکتور',
},
),
migrations.CreateModel(
name='StockMovement',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('delta', models.FloatField(verbose_name='تغییر')),
('quantity_after', models.FloatField(verbose_name='موجودی بعد از تغییر')),
('note', models.CharField(blank=True, max_length=255, verbose_name='یادداشت')),
('created_at', models.DateTimeField(auto_now_add=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movements', to='bizapps.product')),
],
options={
'verbose_name': 'تغییر موجودی',
'verbose_name_plural': 'تاریخچه\u200cی موجودی',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('transaction_number', models.CharField(max_length=30, verbose_name='شماره سند')),
('transaction_date', models.DateTimeField(verbose_name='تاریخ سند')),
('transaction_type', models.CharField(choices=[('cash_sale', 'فروش نقدی'), ('credit_sale', 'فروش نسیه'), ('installment', 'دریافت قسط'), ('purchase', 'خرید'), ('expense', 'هزینه'), ('adjustment', 'تعدیل')], max_length=20)),
('debit_account', models.CharField(max_length=20, verbose_name='کد حساب بدهکار')),
('credit_account', models.CharField(max_length=20, verbose_name='کد حساب بستانکار')),
('amount', models.FloatField(verbose_name='مبلغ')),
('description', models.CharField(blank=True, max_length=255, verbose_name='شرح')),
('created_at', models.DateTimeField(auto_now_add=True)),
('credit_account_ref', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='credit_transactions', to='bizapps.account', verbose_name='حساب بستانکار')),
('debit_account_ref', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='debit_transactions', to='bizapps.account', verbose_name='حساب بدهکار')),
('reference_invoice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='bizapps.invoice')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'سند حسابداری',
'verbose_name_plural': 'اسناد حسابداری',
'ordering': ['-transaction_date'],
},
),
migrations.AddIndex(
model_name='account',
index=models.Index(fields=['user', 'level'], name='bizapps_acc_user_id_e1d510_idx'),
),
migrations.AddConstraint(
model_name='account',
constraint=models.UniqueConstraint(fields=('user', 'code'), name='unique_user_account_code'),
),
migrations.AddIndex(
model_name='customer',
index=models.Index(fields=['user', 'phone'], name='bizapps_cus_user_id_886a45_idx'),
),
migrations.AddIndex(
model_name='invoice',
index=models.Index(fields=['user', 'invoice_date'], name='bizapps_inv_user_id_89a273_idx'),
),
migrations.AddConstraint(
model_name='invoice',
constraint=models.UniqueConstraint(fields=('user', 'invoice_number'), name='unique_user_invoice_number'),
),
migrations.AddIndex(
model_name='ledgerentry',
index=models.Index(fields=['user', 'entry_date'], name='bizapps_led_user_id_795e45_idx'),
),
migrations.AddIndex(
model_name='product',
index=models.Index(fields=['user', 'category'], name='bizapps_pro_user_id_adf41e_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['user', 'transaction_date'], name='bizapps_tra_user_id_7cbd25_idx'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.15 on 2026-09-02 00:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='customer',
name='social_handle',
field=models.CharField(blank=True, max_length=150, verbose_name='آیدی/نام کاربری'),
),
migrations.AddField(
model_name='customer',
name='social_platform',
field=models.CharField(blank=True, choices=[('instagram', 'اینستاگرام'), ('telegram', 'تلگرام'), ('whatsapp', 'واتساپ'), ('bale', 'بله'), ('eitaa', 'ایتا'), ('other', 'سایر')], max_length=15, verbose_name='پلتفرم ارتباطی'),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.1.15 on 2026-09-02 06:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0002_customer_social_handle_customer_social_platform'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='LedgerBook',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=120, verbose_name='نام فایل')),
('start_date', models.DateField(verbose_name='از تاریخ')),
('end_date', models.DateField(blank=True, null=True, verbose_name='تا تاریخ')),
('opening_balance', models.FloatField(default=0, verbose_name='مانده ابتدای دوره')),
('is_active', models.BooleanField(default=True, verbose_name='فایل جاری')),
('is_closed', models.BooleanField(default=False, verbose_name='بسته\u200cشده')),
('closed_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ledger_books', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'فایل دفتر',
'verbose_name_plural': 'فایل\u200cهای دفتر',
'ordering': ['-start_date', '-created_at'],
},
),
migrations.AddField(
model_name='ledgerentry',
name='book',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='bizapps.ledgerbook', verbose_name='فایل دفتر'),
),
migrations.AddIndex(
model_name='ledgerbook',
index=models.Index(fields=['user', 'is_active'], name='bizapps_led_user_id_0ad2b0_idx'),
),
]
+408 -44
View File
@@ -2,62 +2,184 @@ from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# ریزاپ‌های ساده‌ی کسب‌وکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار،
# نوبت‌دهی. دقیقا هم‌الگو با apps.banker: id همان UUID تولیدشده در خودِ اپ
# فلاتر است (نه auto-increment)، هر رکورد به user صاحبش محدود می‌شود.
# ریزاپ‌های کسب‌وکار کوچک — مشتریان، دفتر درآمد/هزینه، انبار، فاکتور.
# دقیقا هم‌الگو با apps.banker: id همان UUID تولیدشده در خودِ اپ فلاتر است
# (نه auto-increment)، هر رکورد به user صاحبش محدود می‌شود.
#
# ساختار حسابداری دو لایه است:
# لایه‌ی «عادی» — کاربر فقط یک دسته‌ی آشنا انتخاب می‌کند (فروش نقدی،
# فروش قسطی، اجاره، حقوق و…).
# لایه‌ی «حرفه‌ای» — پشتِ صحنه همان دسته به یک حسابِ معینِ واقعی از
# Account (کل → معین → تفصیلی) نگاشت می‌شود و سندِ
# دوطرفه در Transaction ثبت می‌گردد.
# کاربر هیچ‌وقت مجبور نیست لایه‌ی دوم را ببیند، ولی گزارش/خروجی اکسل و
# ترازِ حساب‌ها از همان لایه ساخته می‌شود.
class Account(models.Model):
"""درختِ حساب‌ها (فایل‌بندیِ حسابداری) — سه سطحِ استانداردِ ایرانی:
حساب کل (level=1) مثلا «دارایی‌های جاری»
└─ حساب معین (level=2) مثلا «صندوق»، «حساب‌های دریافتنی»
└─ حساب تفصیلی (level=3) مثلا یک مشتریِ خاص
هر کاربر درختِ خودش را دارد (با یک درختِ پیش‌فرضِ آماده که موقعِ اولین
استفاده ساخته می‌شود) تا کسب‌وکارهای مختلف بتوانند حساب‌های دلخواهِ
خودشان را هم اضافه کنند بدون این‌که روی هم اثر بگذارند."""
class Level(models.IntegerChoices):
KOL = 1, _("حساب کل")
MOIN = 2, _("حساب معین")
TAFSILI = 3, _("حساب تفصیلی")
class AccountType(models.TextChoices):
ASSET = "asset", _("دارایی")
LIABILITY = "liability", _("بدهی")
EQUITY = "equity", _("سرمایه")
INCOME = "income", _("درآمد")
EXPENSE = "expense", _("هزینه")
# ماهیتِ طبیعیِ حساب — تعیین می‌کند در ترازِ حساب‌ها، مانده‌ی بدهکار
# نشانه‌ی افزایش است یا بستانکار. برای دارایی/هزینه بدهکار، برای
# بدهی/سرمایه/درآمد بستانکار.
DEBIT_NATURE_TYPES = {AccountType.ASSET, AccountType.EXPENSE}
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="accounts")
code = models.CharField(max_length=20, verbose_name=_("کد حساب"))
name = models.CharField(max_length=150, verbose_name=_("نام حساب"))
level = models.PositiveSmallIntegerField(choices=Level.choices, verbose_name=_("سطح"))
account_type = models.CharField(max_length=15, choices=AccountType.choices, verbose_name=_("نوع حساب"))
parent = models.ForeignKey(
"self", null=True, blank=True, on_delete=models.CASCADE, related_name="children", verbose_name=_("حساب والد")
)
# حساب‌های سیستمی (بخشی از درختِ پیش‌فرض) نباید حذف شوند، چون سرویس‌ها
# با کدشان به آن‌ها ارجاع می‌دهند.
is_system = models.BooleanField(default=False, verbose_name=_("حساب سیستمی"))
is_active = models.BooleanField(default=True, verbose_name=_("فعال"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("حساب")
verbose_name_plural = _("درخت حساب‌ها")
ordering = ["code"]
constraints = [models.UniqueConstraint(fields=["user", "code"], name="unique_user_account_code")]
indexes = [models.Index(fields=["user", "level"])]
def __str__(self):
return f"{self.code} — {self.name}"
@property
def is_debit_nature(self) -> bool:
return self.account_type in self.DEBIT_NATURE_TYPES
@property
def full_path(self) -> str:
"""مسیرِ کاملِ حساب در درخت — برای نمایش در گزارش/اکسل."""
parts = [self.name]
node = self.parent
while node is not None:
parts.append(node.name)
node = node.parent
return " / ".join(reversed(parts))
class LedgerBook(models.Model):
"""«فایل» دفتر درآمد و هزینه — معادلِ فایلِ مالیِ یک دوره (معمولا یک سال
مالی) در نرم‌افزارهای حسابداری.
چرا لازم است: بدون آن، همه‌ی ثبت‌های چند سال در یک لیستِ بی‌انتها قاطی
می‌شوند و «سود امسال» عملا قابل‌محاسبه نیست. با فایل، کاربر سرِ سال
مالیِ تازه یک فایل جدید باز می‌کند، فایل قبلی بسته و فقط-خواندنی می‌شود
و گزارش‌های هر دوره تمیز از هم جدا می‌مانند.
"""
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_books")
name = models.CharField(max_length=120, verbose_name=_("نام فایل"))
start_date = models.DateField(verbose_name=_("از تاریخ"))
end_date = models.DateField(null=True, blank=True, verbose_name=_("تا تاریخ"))
# مانده‌ی نقدیِ ابتدای دوره — معمولا مانده‌ی پایانِ فایلِ قبلی.
opening_balance = models.FloatField(default=0, verbose_name=_("مانده ابتدای دوره"))
# فقط یک فایل در هر لحظه «جاری» است؛ ثبت‌های تازه به همان می‌روند.
is_active = models.BooleanField(default=True, verbose_name=_("فایل جاری"))
# فایلِ بسته‌شده فقط-خواندنی است تا گزارشِ دوره‌ی قبل عوض نشود.
is_closed = models.BooleanField(default=False, verbose_name=_("بسته‌شده"))
closed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("فایل دفتر")
verbose_name_plural = _("فایل‌های دفتر")
ordering = ["-start_date", "-created_at"]
indexes = [models.Index(fields=["user", "is_active"])]
def __str__(self):
return self.name
class Customer(models.Model):
class SocialPlatform(models.TextChoices):
INSTAGRAM = "instagram", _("اینستاگرام")
TELEGRAM = "telegram", _("تلگرام")
WHATSAPP = "whatsapp", _("واتساپ")
BALE = "bale", _("بله")
EITAA = "eitaa", _("ایتا")
OTHER = "other", _("سایر")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="customers")
name = models.CharField(max_length=150, verbose_name=_("نام"))
phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره تماس"))
# مخاطبِ اصلیِ این اپ کسب‌وکارهای اینستاگرامی/تلگرامی‌اند؛ برای خیلی از
# آن‌ها آیدیِ شبکه‌ی اجتماعی راهِ ارتباطیِ اصلی است، نه ایمیل.
social_platform = models.CharField(
max_length=15, choices=SocialPlatform.choices, blank=True, verbose_name=_("پلتفرم ارتباطی")
)
social_handle = models.CharField(max_length=150, blank=True, verbose_name=_("آیدی/نام کاربری"))
email = models.CharField(max_length=150, blank=True, verbose_name=_("ایمیل"))
address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
# این سه فیلد cache هستند — منبعِ حقیقت، فاکتورها و ردیف‌های دفترند؛
# سرویس‌ها بعد از هر تغییر بازمحاسبه‌شان می‌کنند.
total_purchases_count = models.PositiveIntegerField(default=0, verbose_name=_("تعداد خرید"))
total_purchase_amount = models.FloatField(default=0, verbose_name=_("مجموع خرید"))
last_purchase_date = models.DateTimeField(null=True, blank=True, verbose_name=_("آخرین خرید"))
outstanding_balance = models.FloatField(default=0, verbose_name=_("مانده بدهی"))
# حسابِ تفصیلیِ اختصاصیِ این مشتری زیرِ «حساب‌های دریافتنی» — همان
# چیزی که فروشِ قسطیِ او را در درختِ حساب‌ها قابل‌ردیابی می‌کند.
account = models.OneToOneField(
Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="customer",
verbose_name=_("حساب تفصیلی"),
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("مشتری")
verbose_name_plural = _("مشتریان")
ordering = ["name"]
indexes = [models.Index(fields=["user", "phone"])]
def __str__(self):
return self.name
class LedgerEntry(models.Model):
class EntryType(models.TextChoices):
INCOME = "income", _("درآمد")
EXPENSE = "expense", _("هزینه")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_entries")
entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع"))
title = models.CharField(max_length=150, verbose_name=_("عنوان"))
amount = models.FloatField(verbose_name=_("مبلغ"))
category = models.CharField(max_length=100, blank=True, verbose_name=_("دسته"))
entry_date = models.DateField(verbose_name=_("تاریخ"))
note = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("ردیف دفتر درآمد/هزینه")
verbose_name_plural = _("دفتر درآمد و هزینه")
ordering = ["-entry_date"]
def __str__(self):
return f"{self.title} ({self.get_entry_type_display()})"
class Product(models.Model):
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="products")
name = models.CharField(max_length=150, verbose_name=_("نام کالا"))
sku = models.CharField(max_length=100, blank=True, verbose_name=_("کد کالا"))
category = models.CharField(max_length=100, blank=True, verbose_name=_("دسته"))
unit = models.CharField(max_length=30, blank=True, default="عدد", verbose_name=_("واحد شمارش"))
quantity = models.FloatField(default=0, verbose_name=_("موجودی"))
unit_price = models.FloatField(default=0, verbose_name=_("قیمت واحد"))
unit_price = models.FloatField(default=0, verbose_name=_("قیمت فروش"))
purchase_price = models.FloatField(default=0, verbose_name=_("قیمت خرید"))
# ارزشِ ریالیِ موجودیِ فعلی (quantity × purchase_price) — cache شده تا
# «ارزش کل انبار» بدون پیمایشِ همه‌ی کالاها قابل‌جمع‌زدن باشد.
total_purchase_value = models.FloatField(default=0, verbose_name=_("ارزش موجودی"))
low_stock_threshold = models.FloatField(default=0, verbose_name=_("آستانه‌ی هشدار موجودی کم"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
updated_at = models.DateTimeField(auto_now=True)
@@ -66,14 +188,23 @@ class Product(models.Model):
verbose_name = _("کالا")
verbose_name_plural = _("انبار")
ordering = ["name"]
indexes = [models.Index(fields=["user", "category"])]
def __str__(self):
return self.name
def recompute_purchase_value(self) -> None:
self.total_purchase_value = (self.quantity or 0) * (self.purchase_price or 0)
@property
def is_low_stock(self) -> bool:
return self.low_stock_threshold > 0 and self.quantity <= self.low_stock_threshold
class StockMovement(models.Model):
"""تاریخچه‌ی تغییرات موجودی — هر تعدیل (دستیِ +/- یا ویرایش مستقیم فرم)
یک ردیف اینجا ثبت می‌کند تا معلوم باشد موجودی کِی و چقدر تغییر کرده."""
"""تاریخچه‌ی تغییرات موجودی — هر تعدیل (دستیِ +/-، ویرایش مستقیم فرم، یا
فروش/لغوِ فاکتور) یک ردیف اینجا ثبت می‌کند تا معلوم باشد موجودی کِی،
چقدر و به چه دلیل تغییر کرده."""
id = models.AutoField(primary_key=True)
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="movements")
@@ -91,26 +222,259 @@ class StockMovement(models.Model):
return f"{self.product.name} — {self.delta:+g} — {self.created_at:%Y-%m-%d %H:%M}"
class Appointment(models.Model):
class Status(models.TextChoices):
SCHEDULED = "scheduled", _("زمان‌بندی‌شده")
DONE = "done", _("انجام‌شده")
class Invoice(models.Model):
class PaymentType(models.TextChoices):
CASH = "cash", _("نقدی")
CREDIT = "credit", _("قسطی/نسیه")
class PaymentStatus(models.TextChoices):
UNPAID = "unpaid", _("پرداخت‌نشده")
PAID = "paid", _("پرداخت‌شده")
class OrderStatus(models.TextChoices):
PENDING_PAYMENT = "pending_payment", _("در انتظار پرداخت")
READY_TO_SHIP = "ready_to_ship", _("آماده‌ی ارسال")
SHIPPED = "shipped", _("ارسال‌شده")
DELIVERED = "delivered", _("تحویل‌شده")
CANCELLED = "cancelled", _("لغوشده")
class ShippingMethod(models.TextChoices):
PICKUP = "pickup", _("تحویل حضوری")
POST = "post", _("پست")
COURIER = "courier", _("پیک")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="appointments")
customer_name = models.CharField(max_length=150, verbose_name=_("نام مشتری"))
customer_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره مشتری"))
title = models.CharField(max_length=150, verbose_name=_("عنوان/خدمت"))
start_at = models.DateTimeField(verbose_name=_("زمان نوبت"))
status = models.CharField(max_length=10, choices=Status.choices, default=Status.SCHEDULED)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="invoices")
invoice_number = models.CharField(max_length=30, verbose_name=_("شماره فاکتور"))
invoice_date = models.DateTimeField(verbose_name=_("تاریخ فاکتور"))
customer = models.ForeignKey(
Customer, null=True, blank=True, on_delete=models.SET_NULL, related_name="invoices"
)
buyer_name = models.CharField(max_length=150, blank=True, verbose_name=_("نام خریدار"))
buyer_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره خریدار"))
buyer_address = models.CharField(max_length=255, blank=True, verbose_name=_("آدرس خریدار"))
seller_name = models.CharField(max_length=150, blank=True, verbose_name=_("نام فروشنده"))
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=_("جمع کل"))
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=_("مبلغ نهایی"))
payment_type = models.CharField(max_length=10, choices=PaymentType.choices, default=PaymentType.CASH)
payment_status = models.CharField(max_length=10, choices=PaymentStatus.choices, default=PaymentStatus.UNPAID)
order_status = models.CharField(max_length=20, choices=OrderStatus.choices, default=OrderStatus.READY_TO_SHIP)
shipping_method = models.CharField(max_length=10, choices=ShippingMethod.choices, default=ShippingMethod.PICKUP)
tracking_code = models.CharField(max_length=100, blank=True, verbose_name=_("کد رهگیری"))
notes = models.TextField(blank=True, verbose_name=_("یادداشت"))
paid_at = models.DateTimeField(null=True, blank=True, verbose_name=_("زمان پرداخت"))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("نوبت")
verbose_name_plural = _("نوبت‌دهی")
ordering = ["start_at"]
verbose_name = _("فاکتور")
verbose_name_plural = _("فاکتورها")
ordering = ["-invoice_date"]
constraints = [
models.UniqueConstraint(fields=["user", "invoice_number"], name="unique_user_invoice_number")
]
indexes = [models.Index(fields=["user", "invoice_date"])]
def __str__(self):
return f"{self.customer_name} — {self.title}"
return f"{self.invoice_number} — {self.buyer_name}"
class InvoiceItem(models.Model):
id = models.CharField(max_length=36, primary_key=True)
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, null=True, blank=True, on_delete=models.SET_NULL, related_name="+")
description = models.CharField(max_length=255, verbose_name=_("شرح"))
quantity = models.FloatField(verbose_name=_("تعداد"))
unit_price = models.FloatField(verbose_name=_("قیمت واحد"))
discount_percent = models.FloatField(default=0, verbose_name=_("درصد تخفیف"))
total_price = models.FloatField(default=0, verbose_name=_("جمع"))
class Meta:
verbose_name = _("قلم فاکتور")
verbose_name_plural = _("اقلام فاکتور")
def __str__(self):
return f"{self.description} × {self.quantity:g}"
def compute_total(self) -> None:
gross = (self.quantity or 0) * (self.unit_price or 0)
self.total_price = gross * (1 - (self.discount_percent or 0) / 100)
class Transaction(models.Model):
"""سندِ حسابداریِ دوطرفه — هر رویدادِ مالی (فروش، لغو، دریافتِ قسط)
یک ردیف اینجا می‌سازد با حسابِ بدهکار و حسابِ بستانکار. ترازِ حساب‌ها و
خروجیِ اکسل از روی همین جدول ساخته می‌شوند."""
class TransactionType(models.TextChoices):
CASH_SALE = "cash_sale", _("فروش نقدی")
CREDIT_SALE = "credit_sale", _("فروش نسیه")
INSTALLMENT = "installment", _("دریافت قسط")
PURCHASE = "purchase", _("خرید")
EXPENSE = "expense", _("هزینه")
ADJUSTMENT = "adjustment", _("تعدیل")
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions")
transaction_number = models.CharField(max_length=30, verbose_name=_("شماره سند"))
transaction_date = models.DateTimeField(verbose_name=_("تاریخ سند"))
reference_invoice = models.ForeignKey(
Invoice, null=True, blank=True, on_delete=models.SET_NULL, related_name="transactions"
)
transaction_type = models.CharField(max_length=20, choices=TransactionType.choices)
# کدِ حسابِ متنی برای سازگاری با داده‌ی قدیمی نگه داشته شده؛ debit/credit
# به‌صورتِ FK به Account همان چیزی است که گزارش‌های جدید از آن می‌خوانند.
debit_account = models.CharField(max_length=20, verbose_name=_("کد حساب بدهکار"))
credit_account = models.CharField(max_length=20, verbose_name=_("کد حساب بستانکار"))
debit_account_ref = models.ForeignKey(
Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="debit_transactions",
verbose_name=_("حساب بدهکار"),
)
credit_account_ref = models.ForeignKey(
Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="credit_transactions",
verbose_name=_("حساب بستانکار"),
)
amount = models.FloatField(verbose_name=_("مبلغ"))
description = models.CharField(max_length=255, blank=True, verbose_name=_("شرح"))
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("سند حسابداری")
verbose_name_plural = _("اسناد حسابداری")
ordering = ["-transaction_date"]
indexes = [models.Index(fields=["user", "transaction_date"])]
def __str__(self):
return f"{self.transaction_number} — {self.amount:,.0f}"
class LedgerEntry(models.Model):
class EntryType(models.TextChoices):
INCOME = "income", _("درآمد")
EXPENSE = "expense", _("هزینه")
class Category(models.TextChoices):
# درآمدها
SALE_CASH = "sale_cash", _("فروش نقدی")
SALE_CREDIT = "sale_credit", _("فروش قسطی/نسیه")
SERVICE_INCOME = "service_income", _("درآمد خدمات")
OTHER_INCOME = "other_income", _("سایر درآمدها")
# هزینه‌ها
PURCHASE_CASH = "purchase_cash", _("خرید نقدی")
PURCHASE_CREDIT = "purchase_credit", _("خرید نسیه")
RENT = "rent", _("اجاره")
SALARY = "salary", _("حقوق")
UTILITY = "utility", _("قبض و آب‌وبرق")
TRANSPORT = "transport", _("حمل‌ونقل")
OTHER_EXPENSE = "other_expense", _("سایر هزینه‌ها")
# فقط این دو دسته «بدهی‌دار»ند — یعنی مبلغِ کل، طرفِ حساب و اقساط دارند.
# بقیه یک ثبتِ ساده‌ی نقدی‌اند.
DEBT_CATEGORIES = {Category.SALE_CREDIT, Category.PURCHASE_CREDIT}
# نگاشتِ لایه‌ی «عادی» به حسابِ معینِ لایه‌ی «حرفه‌ای» — کلیدِ اتصالِ
# دسته‌بندیِ ساده‌ی کاربر به درختِ حساب‌ها.
CATEGORY_ACCOUNT_CODES = {
Category.SALE_CASH: "4001",
Category.SALE_CREDIT: "4001",
Category.SERVICE_INCOME: "4002",
Category.OTHER_INCOME: "4003",
Category.PURCHASE_CASH: "5001",
Category.PURCHASE_CREDIT: "5001",
Category.RENT: "5002",
Category.SALARY: "5003",
Category.UTILITY: "5004",
Category.TRANSPORT: "5005",
Category.OTHER_EXPENSE: "5006",
}
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ledger_entries")
# null مجاز است تا ثبت‌های قدیمیِ پیش از افزودنِ «فایل» بی‌صاحب نمانند؛
# سرویس‌ها هنگام ساخت، همیشه فایلِ جاری را ست می‌کنند.
book = models.ForeignKey(
LedgerBook, null=True, blank=True, on_delete=models.CASCADE,
related_name="entries", verbose_name=_("فایل دفتر"),
)
entry_type = models.CharField(max_length=10, choices=EntryType.choices, verbose_name=_("نوع"))
title = models.CharField(max_length=150, verbose_name=_("عنوان"))
amount = models.FloatField(verbose_name=_("مبلغ"))
# max_length=100 (نه تنگ‌تر) عمداً — این فیلد قبلا آزاد بود و داده‌ی
# واقعیِ قدیمی با متنِ فارسیِ دلخواه دارد؛ تنگ‌کردنش موقعِ مهاجرت روی
# پستگرس می‌تواند «value too long» بدهد.
category = models.CharField(
max_length=100, choices=Category.choices, blank=True, verbose_name=_("دسته")
)
entry_date = models.DateField(verbose_name=_("تاریخ"))
note = models.TextField(blank=True, verbose_name=_("یادداشت"))
# فیلدهای بدهی‌دار — فقط وقتی category در DEBT_CATEGORIES باشد معنی دارند.
total_amount = models.FloatField(default=0, verbose_name=_("مبلغ کل تعهد"))
counterparty_name = models.CharField(max_length=150, blank=True, verbose_name=_("طرف حساب"))
counterparty_phone = models.CharField(max_length=20, blank=True, verbose_name=_("شماره طرف حساب"))
linked_invoice = models.ForeignKey(
Invoice, null=True, blank=True, on_delete=models.SET_NULL, related_name="ledger_entries"
)
account = models.ForeignKey(
Account, null=True, blank=True, on_delete=models.SET_NULL, related_name="ledger_entries",
verbose_name=_("حساب معین"),
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("ردیف دفتر درآمد/هزینه")
verbose_name_plural = _("دفتر درآمد و هزینه")
ordering = ["-entry_date"]
indexes = [models.Index(fields=["user", "entry_date"])]
def __str__(self):
return f"{self.title} ({self.get_entry_type_display()})"
@property
def is_debt(self) -> bool:
return self.category in self.DEBT_CATEGORIES
@property
def professional_type(self) -> str:
"""ترجمه‌ی دسته‌ی «عادی» به اصطلاحِ حسابداریِ حرفه‌ای."""
if self.category == self.Category.SALE_CREDIT:
return "receivable"
if self.category == self.Category.PURCHASE_CREDIT:
return "payable"
return "simple"
@property
def paid_amount(self) -> float:
if not self.is_debt:
return 0
return sum(i.amount for i in self.installments.all())
@property
def remaining_amount(self) -> float:
if not self.is_debt:
return 0
return (self.total_amount or 0) - self.paid_amount
class LedgerInstallment(models.Model):
"""هر قسطِ پرداخت‌شده روی یک ردیفِ بدهی‌دار. مانده‌ی هر بدهی همیشه از
اینجا محاسبه می‌شود (نه ذخیره‌ی مستقیم) تا هیچ‌وقت با واقعیت فاصله نگیرد."""
id = models.CharField(max_length=36, primary_key=True)
ledger_entry = models.ForeignKey(LedgerEntry, on_delete=models.CASCADE, related_name="installments")
amount = models.FloatField(verbose_name=_("مبلغ قسط"))
paid_date = models.DateField(verbose_name=_("تاریخ پرداخت"))
reference = models.CharField(max_length=150, blank=True, verbose_name=_("شماره مرجع"))
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("قسط")
verbose_name_plural = _("اقساط")
ordering = ["paid_date"]
def __str__(self):
return f"{self.amount:,.0f} — {self.paid_date}"
+740 -2
View File
@@ -1,3 +1,4 @@
import io
import uuid
from datetime import date
@@ -7,8 +8,28 @@ from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import User
from apps.bizapps.api.accounting import (
ACCOUNT_CASH,
ACCOUNT_RECEIVABLE,
ACCOUNT_SALES,
DEFAULT_CHART,
ChartOfAccountsService,
CustomerStatementService,
ExcelExportService,
LedgerBookService,
SubsidiaryLedgerService,
)
from apps.bizapps.api.services import InvoiceService, LedgerService
from apps.bizapps.models import Customer, LedgerEntry, LedgerInstallment
from apps.bizapps.models import (
Account,
Customer,
Invoice,
LedgerBook,
LedgerEntry,
LedgerInstallment,
Product,
Transaction,
)
# این ماژول لایه‌ی «هماهنگیِ عادی/حرفه‌ای»ِ دفتر درآمد و هزینه را پوشش می‌دهد —
# دقیقا همان بخشی که کاربر صریحا خواسته «تست‌نویسی برایش خیلی مهم است»، چون
@@ -290,7 +311,724 @@ class LedgerApiTests(TestCase):
)
self.assertEqual(response.status_code, 400)
def test_export_returns_csv(self):
def test_export_defaults_to_xlsx(self):
"""پیش‌فرضِ خروجی، اکسلِ چندبرگی است — چون همان چیزی است که مستقیم
به‌دردِ حسابدارِ کاربر می‌خورد، نه CSV خام."""
response = self.client.get(reverse("bizapps:ledger-export"))
self.assertEqual(response.status_code, 200)
self.assertIn("spreadsheetml.sheet", response["Content-Type"])
self.assertIn(".xlsx", response["Content-Disposition"])
# امضای فایلِ zip — یک xlsx واقعی، نه متنِ خالی با هدرِ اشتباه.
self.assertEqual(response.content[:2], b"PK")
def test_export_csv_still_available_via_query_param(self):
response = self.client.get(reverse("bizapps:ledger-export"), {"type": "csv"})
self.assertEqual(response.status_code, 200)
self.assertIn("text/csv", response["Content-Type"])
# ---------------------------------------------------------------------------
# لایه‌ی حسابداریِ حرفه‌ای — درختِ حساب‌ها (کل/معین/تفصیلی)، ترازِ حساب‌ها،
# صورت‌حسابِ مشتری، و خروجیِ اکسلِ چندبرگی.
# ---------------------------------------------------------------------------
class ChartOfAccountsTests(TestCase):
def setUp(self):
self.user = _make_user()
def test_ensure_chart_creates_default_tree(self):
ChartOfAccountsService.ensure_chart(self.user)
accounts = Account.objects.filter(user=self.user)
self.assertEqual(accounts.count(), len(DEFAULT_CHART))
self.assertTrue(accounts.filter(level=Account.Level.KOL).exists())
self.assertTrue(accounts.filter(level=Account.Level.MOIN).exists())
def test_ensure_chart_is_idempotent(self):
ChartOfAccountsService.ensure_chart(self.user)
first = Account.objects.filter(user=self.user).count()
ChartOfAccountsService.ensure_chart(self.user)
self.assertEqual(Account.objects.filter(user=self.user).count(), first)
def test_moin_accounts_are_linked_to_their_kol_parent(self):
ChartOfAccountsService.ensure_chart(self.user)
cash = Account.objects.get(user=self.user, code=ACCOUNT_CASH)
self.assertEqual(cash.level, Account.Level.MOIN)
self.assertIsNotNone(cash.parent)
self.assertEqual(cash.parent.level, Account.Level.KOL)
self.assertIn("دارایی", cash.full_path)
def test_chart_is_per_user_not_shared(self):
other = _make_user(phone="09129999999")
ChartOfAccountsService.ensure_chart(self.user)
ChartOfAccountsService.ensure_chart(other)
self.assertEqual(Account.objects.filter(user=self.user).count(), len(DEFAULT_CHART))
self.assertEqual(Account.objects.filter(user=other).count(), len(DEFAULT_CHART))
def test_customer_gets_tafsili_account_under_receivables(self):
customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
account = ChartOfAccountsService.ensure_customer_account(self.user, customer)
self.assertEqual(account.level, Account.Level.TAFSILI)
self.assertEqual(account.parent.code, ACCOUNT_RECEIVABLE)
self.assertTrue(account.code.startswith(f"{ACCOUNT_RECEIVABLE}-"))
customer.refresh_from_db()
self.assertEqual(customer.account_id, account.id)
def test_customer_account_is_not_duplicated(self):
customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
first = ChartOfAccountsService.ensure_customer_account(self.user, customer)
second = ChartOfAccountsService.ensure_customer_account(self.user, customer)
self.assertEqual(first.id, second.id)
def test_debit_nature_matches_account_type(self):
ChartOfAccountsService.ensure_chart(self.user)
self.assertTrue(Account.objects.get(user=self.user, code=ACCOUNT_CASH).is_debit_nature)
self.assertFalse(Account.objects.get(user=self.user, code=ACCOUNT_SALES).is_debit_nature)
class LedgerAccountMappingTests(TestCase):
"""هر دسته‌ی «عادی» باید به حسابِ معینِ درست نگاشت شود — این پلِ بینِ
لایه‌ی ساده و لایه‌ی حرفه‌ای است و اگر بشکند، ترازِ حساب‌ها بی‌معنی می‌شود."""
def setUp(self):
self.user = _make_user()
def test_every_category_maps_to_an_existing_account(self):
ChartOfAccountsService.ensure_chart(self.user)
for category in LedgerEntry.Category.values:
account = LedgerService.account_for_category(self.user, category)
self.assertIsNotNone(account, f"دسته‌ی {category} به هیچ حسابی نگاشت نشد")
self.assertEqual(account.level, Account.Level.MOIN)
def test_income_categories_map_to_income_accounts(self):
ChartOfAccountsService.ensure_chart(self.user)
account = LedgerService.account_for_category(self.user, LedgerEntry.Category.SALE_CASH)
self.assertEqual(account.account_type, Account.AccountType.INCOME)
def test_expense_categories_map_to_expense_accounts(self):
ChartOfAccountsService.ensure_chart(self.user)
account = LedgerService.account_for_category(self.user, LedgerEntry.Category.RENT)
self.assertEqual(account.account_type, Account.AccountType.EXPENSE)
class TrialBalanceTests(TestCase):
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=10, unit_price=100, purchase_price=60
)
def test_cash_sale_posts_to_cash_and_sales(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"payment_type": Invoice.PaymentType.CASH,
},
)
rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)}
self.assertIn(ACCOUNT_CASH, rows)
self.assertIn(ACCOUNT_SALES, rows)
self.assertEqual(rows[ACCOUNT_CASH]["debit"], 100)
self.assertEqual(rows[ACCOUNT_SALES]["credit"], 100)
def test_credit_sale_posts_to_receivable_not_cash(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"payment_type": Invoice.PaymentType.CREDIT,
},
)
rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)}
self.assertIn(ACCOUNT_RECEIVABLE, rows)
self.assertEqual(rows[ACCOUNT_RECEIVABLE]["debit"], 100)
self.assertNotIn(ACCOUNT_CASH, rows)
def test_balance_respects_account_nature(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"payment_type": Invoice.PaymentType.CASH,
},
)
rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)}
# صندوق (دارایی، ماهیت بدهکار) و فروش (درآمد، ماهیت بستانکار) هر دو
# باید مانده‌ی مثبت نشان دهند، نه یکی منفی.
self.assertEqual(rows[ACCOUNT_CASH]["balance"], 100)
self.assertEqual(rows[ACCOUNT_SALES]["balance"], 100)
def test_empty_accounts_are_omitted(self):
rows = ChartOfAccountsService.trial_balance(self.user)
self.assertEqual(rows, [])
class CustomerStatementTests(TestCase):
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=10, unit_price=100, purchase_price=60
)
def test_statement_gathers_invoices_and_open_debts(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT,
},
)
self.customer.refresh_from_db()
statement = CustomerStatementService.build(self.user, self.customer)
self.assertEqual(len(statement["invoices"]), 1)
self.assertEqual(len(statement["debts"]), 1)
self.assertEqual(statement["debts"][0]["remaining_amount"], 100)
self.assertEqual(statement["customer"]["outstanding_balance"], 100)
# حسابِ تفصیلیِ مشتری هم باید در صورت‌حساب بیاید.
self.assertTrue(statement["customer"]["account_code"])
def test_statement_reflects_installment_payments(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT,
},
)
entry = LedgerEntry.objects.get(user=self.user, category=LedgerEntry.Category.SALE_CREDIT)
LedgerInstallment.objects.create(
id=str(uuid.uuid4()), ledger_entry=entry, amount=40, paid_date=date.today()
)
LedgerService.recompute_customer_balance(self.user, "09121112222")
self.customer.refresh_from_db()
statement = CustomerStatementService.build(self.user, self.customer)
self.assertEqual(statement["debts"][0]["paid_amount"], 40)
self.assertEqual(statement["debts"][0]["remaining_amount"], 60)
self.assertEqual(len(statement["debts"][0]["installments"]), 1)
class ExcelExportTests(TestCase):
def setUp(self):
self.user = _make_user()
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()),
user=self.user,
name="کالا",
quantity=10,
unit_price=100,
purchase_price=60,
total_purchase_value=600,
)
def _workbook(self):
from openpyxl import load_workbook
content = ExcelExportService.build(self.user)
return load_workbook(io.BytesIO(content))
def test_workbook_has_all_expected_sheets(self):
wb = self._workbook()
self.assertEqual(
wb.sheetnames,
["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "انبار"],
)
def test_sheets_are_right_to_left(self):
wb = self._workbook()
for name in wb.sheetnames:
self.assertTrue(wb[name].sheet_view.rightToLeft, f"برگه‌ی {name} راست‌چین نیست")
def test_inventory_sheet_lists_products(self):
wb = self._workbook()
rows = list(wb["انبار"].iter_rows(min_row=2, values_only=True))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0][0], "کالا")
def test_summary_reports_inventory_value(self):
wb = self._workbook()
summary = {r[0]: r[1] for r in wb["خلاصه"].iter_rows(min_row=2, values_only=True)}
self.assertEqual(summary["ارزش موجودی انبار"], 600)
def test_debtors_sheet_lists_only_customers_with_balance(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT,
},
)
wb = self._workbook()
rows = list(wb["بدهکاران"].iter_rows(min_row=2, values_only=True))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0][0], "علی")
self.assertEqual(rows[0][3], 100)
def test_invoice_sheet_lists_invoices(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 2, "unit_price": 100}],
"customer": self.customer,
"payment_type": Invoice.PaymentType.CASH,
},
)
wb = self._workbook()
rows = list(wb["فاکتورها"].iter_rows(min_row=2, values_only=True))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0][6], 200) # مبلغ نهایی
class InstallmentPostsTransactionTests(TestCase):
"""هر قسط باید علاوه بر به‌روزکردنِ مانده، یک سندِ حسابداریِ واقعی هم
بسازد — وگرنه ترازِ حساب‌ها بعد از دریافتِ قسط غلط می‌ماند."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
self.entry = LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=self.user,
entry_type=LedgerEntry.EntryType.INCOME,
title="فروش قسطی",
amount=1000,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=date.today(),
total_amount=1000,
counterparty_phone="09121112222",
)
def test_installment_creates_balanced_transaction(self):
response = self.client.post(
reverse("bizapps:ledger-installment-list", kwargs={"entry_id": self.entry.id}),
{"amount": 400, "paid_date": date.today().isoformat()},
format="json",
)
self.assertEqual(response.status_code, 201)
txn = Transaction.objects.get(user=self.user, transaction_type=Transaction.TransactionType.INSTALLMENT)
self.assertEqual(txn.amount, 400)
# دریافتِ قسط: صندوق بدهکار، حساب دریافتنی بستانکار.
self.assertEqual(txn.debit_account, ACCOUNT_CASH)
self.assertEqual(txn.credit_account, ACCOUNT_RECEIVABLE)
self.assertIsNotNone(txn.debit_account_ref)
self.assertIsNotNone(txn.credit_account_ref)
def test_installment_shows_up_in_trial_balance(self):
self.client.post(
reverse("bizapps:ledger-installment-list", kwargs={"entry_id": self.entry.id}),
{"amount": 400, "paid_date": date.today().isoformat()},
format="json",
)
rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)}
self.assertEqual(rows[ACCOUNT_CASH]["debit"], 400)
class BusinessDashboardTests(TestCase):
"""داشبورد، همان «اتصالِ بخش‌ها» را در یک پاسخ جمع می‌کند."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()),
user=self.user,
name="کالا",
quantity=2,
unit_price=100,
purchase_price=60,
total_purchase_value=120,
low_stock_threshold=5,
)
def test_dashboard_aggregates_across_modules(self):
InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": 1, "unit_price": 100}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT,
},
)
response = self.client.get(reverse("bizapps:dashboard"))
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data["open_receivable"], 100)
self.assertEqual(data["invoice_count"], 1)
self.assertEqual(data["customer_count"], 1)
self.assertEqual(data["debtor_count"], 1)
# موجودی از ۲ به ۱ رسیده و زیرِ آستانه‌ی ۵ است.
self.assertEqual(data["low_stock_count"], 1)
class NegativeStockGuardTests(TestCase):
"""سیاستِ صریحِ موجودی: فروشِ بیش از موجودی رد می‌شود و هیچ اثری
(نه فاکتور، نه سند، نه کسرِ موجودی) باقی نمی‌گذارد."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
self.product = Product.objects.create(
id=str(uuid.uuid4()), user=self.user, name="کالا", unit="عدد",
quantity=2, unit_price=100, purchase_price=60,
)
def test_selling_more_than_stock_is_rejected(self):
from apps.bizapps.api.services import InsufficientStockError
with self.assertRaises(InsufficientStockError):
InvoiceService.create_invoice(
self.user,
{"items": [{"product": self.product, "quantity": 5, "unit_price": 100}]},
)
def test_rejected_sale_leaves_no_partial_data(self):
from apps.bizapps.api.services import InsufficientStockError
with self.assertRaises(InsufficientStockError):
InvoiceService.create_invoice(
self.user,
{"items": [{"product": self.product, "quantity": 5, "unit_price": 100}]},
)
self.product.refresh_from_db()
self.assertEqual(self.product.quantity, 2)
self.assertEqual(Invoice.objects.filter(user=self.user).count(), 0)
self.assertEqual(Transaction.objects.filter(user=self.user).count(), 0)
def test_selling_exactly_the_remaining_stock_is_allowed(self):
InvoiceService.create_invoice(
self.user,
{"items": [{"product": self.product, "quantity": 2, "unit_price": 100}]},
)
self.product.refresh_from_db()
self.assertEqual(self.product.quantity, 0)
def test_api_returns_readable_400_not_500(self):
response = self.client.post(
reverse("bizapps:invoice-list"),
{"items": [{"product": self.product.id, "quantity": 5, "unit_price": 100}]},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("موجودی", str(response.data))
class CustomerSocialFieldTests(TestCase):
"""راهِ ارتباطیِ شبکه‌ی اجتماعی — برای مخاطبِ اصلیِ این اپ (کسب‌وکارهای
اینستاگرامی/تلگرامی) کاربردی‌تر از ایمیل است."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_customer_can_store_platform_and_handle(self):
response = self.client.post(
reverse("bizapps:customer-list"),
{
"id": str(uuid.uuid4()),
"name": "فروشگاه گلها",
"phone": "09121112222",
"social_platform": Customer.SocialPlatform.INSTAGRAM,
"social_handle": "@golha.shop",
},
format="json",
)
self.assertEqual(response.status_code, 201)
customer = Customer.objects.get(user=self.user)
self.assertEqual(customer.social_platform, "instagram")
self.assertEqual(customer.social_handle, "@golha.shop")
def test_social_fields_are_optional(self):
response = self.client.post(
reverse("bizapps:customer-list"),
{"id": str(uuid.uuid4()), "name": "بدون شبکه اجتماعی"},
format="json",
)
self.assertEqual(response.status_code, 201)
def test_invalid_platform_is_rejected(self):
response = self.client.post(
reverse("bizapps:customer-list"),
{
"id": str(uuid.uuid4()),
"name": "تست",
"social_platform": "myspace",
},
format="json",
)
self.assertEqual(response.status_code, 400)
class LedgerBookTests(TestCase):
"""«فایل» دفتر — باز کردن فایل تازه، بستن فایل قبلی، انتقال مانده."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
def _entry(self, amount, entry_type=LedgerEntry.EntryType.INCOME, book=None):
return LedgerEntry.objects.create(
id=str(uuid.uuid4()), user=self.user, book=book,
entry_type=entry_type, title="ثبت", amount=amount,
category=LedgerEntry.Category.SALE_CASH, entry_date=date.today(),
)
def test_active_book_is_created_on_demand(self):
book = LedgerBookService.ensure_active_book(self.user)
self.assertTrue(book.is_active)
self.assertFalse(book.is_closed)
self.assertEqual(LedgerBook.objects.filter(user=self.user).count(), 1)
def test_ensure_active_book_is_idempotent(self):
first = LedgerBookService.ensure_active_book(self.user)
second = LedgerBookService.ensure_active_book(self.user)
self.assertEqual(first.id, second.id)
def test_orphan_entries_are_adopted_by_the_first_book(self):
orphan = self._entry(500)
self.assertIsNone(orphan.book_id)
book = LedgerBookService.ensure_active_book(self.user)
orphan.refresh_from_db()
self.assertEqual(orphan.book_id, book.id)
def test_opening_a_new_book_closes_the_previous_one(self):
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.assertTrue(new.is_active)
# همیشه دقیقا یک فایل جاری
self.assertEqual(LedgerBook.objects.filter(user=self.user, is_active=True).count(), 1)
def test_balance_is_carried_into_the_new_book(self):
old = LedgerBookService.ensure_active_book(self.user)
self._entry(1000, book=old)
self._entry(300, entry_type=LedgerEntry.EntryType.EXPENSE, book=old)
new = LedgerBookService.open_new_book(
self.user, name="دوره بعد", start_date=date(2027, 3, 21), carry_balance=True
)
self.assertEqual(new.opening_balance, 700)
def test_balance_is_not_carried_when_not_requested(self):
old = LedgerBookService.ensure_active_book(self.user)
self._entry(1000, book=old)
new = LedgerBookService.open_new_book(
self.user, name="دوره بعد", start_date=date(2027, 3, 21), carry_balance=False
)
self.assertEqual(new.opening_balance, 0)
def test_summary_includes_opening_and_closing(self):
book = LedgerBookService.ensure_active_book(self.user)
book.opening_balance = 500
book.save()
self._entry(1000, book=book)
self._entry(200, entry_type=LedgerEntry.EntryType.EXPENSE, book=book)
summary = LedgerBookService.summary(book)
self.assertEqual(summary["opening_balance"], 500)
self.assertEqual(summary["total_income"], 1000)
self.assertEqual(summary["total_expense"], 200)
self.assertEqual(summary["net"], 800)
self.assertEqual(summary["closing_balance"], 1300)
def test_new_entries_go_into_the_active_book(self):
book = LedgerBookService.ensure_active_book(self.user)
response = self.client.post(
reverse("bizapps:ledger-list"),
{
"id": str(uuid.uuid4()),
"entry_type": LedgerEntry.EntryType.INCOME,
"title": "فروش",
"amount": 400,
"category": LedgerEntry.Category.SALE_CASH,
"entry_date": date.today().isoformat(),
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(LedgerEntry.objects.get(user=self.user).book_id, book.id)
def test_entry_list_can_be_scoped_to_one_book(self):
"""قراردادی که اپ به آن تکیه می‌کند: ?book=<id> فقط ثبت‌های همان
دوره را برمی‌گرداند.
بدون این فیلتر، صفحه‌ی دفتر ثبت‌های همه‌ی دوره‌ها را با هم نشان
می‌داد و جمعِ درآمد/هزینه‌ی بالای صفحه با مانده‌ی فایلِ جاری
نمی‌خواند."""
old = LedgerBookService.ensure_active_book(self.user)
self._entry(1000, book=old)
new_book = LedgerBookService.open_new_book(
self.user, name="دوره بعد", start_date=date(2027, 3, 21)
)
self._entry(250, book=new_book)
url = reverse("bizapps:ledger-list")
scoped = self.client.get(url, {"book": new_book.id})
self.assertEqual(scoped.status_code, 200)
self.assertEqual([e["amount"] for e in scoped.data], [250])
# بدون پارامتر، همه‌ی دوره‌ها — برای گزارش‌های چنددوره‌ای.
everything = self.client.get(url)
self.assertEqual(len(everything.data), 2)
def test_scoped_summary_ignores_the_other_book(self):
old = LedgerBookService.ensure_active_book(self.user)
self._entry(1000, book=old)
new_book = LedgerBookService.open_new_book(
self.user, name="دوره بعد", start_date=date(2027, 3, 21)
)
self._entry(250, book=new_book)
response = self.client.get(
reverse("bizapps:ledger-book-summary", kwargs={"pk": new_book.id})
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["total_income"], 250)
# مانده‌ی ابتدا از فایل قبل منتقل شده، پس مانده‌ی پایانی ۱۲۵۰ است.
self.assertEqual(response.data["opening_balance"], 1000)
self.assertEqual(response.data["closing_balance"], 1250)
def test_open_book_endpoint_rejects_end_before_start(self):
response = self.client.post(
reverse("bizapps:ledger-book-open"),
{"name": "بد", "start_date": "2027-03-21", "end_date": "2026-01-01"},
format="json",
)
self.assertEqual(response.status_code, 400)
def test_book_list_always_returns_at_least_one(self):
response = self.client.get(reverse("bizapps:ledger-book-list"))
self.assertEqual(response.status_code, 200)
self.assertGreaterEqual(len(response.data), 1)
class SubsidiaryLedgerTests(TestCase):
"""دفتر معین — گردشِ یک حساب با مانده‌ی در جریان، همان چیزی که حسابدار
انتظار دارد ببیند."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
self.client.force_authenticate(self.user)
self.customer = Customer.objects.create(
id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222"
)
self.product = Product.objects.create(
id=str(uuid.uuid4()), user=self.user, name="کالا",
quantity=100, unit_price=100, purchase_price=60,
)
ChartOfAccountsService.ensure_chart(self.user)
self.cash = Account.objects.get(user=self.user, code=ACCOUNT_CASH)
self.sales = Account.objects.get(user=self.user, code=ACCOUNT_SALES)
def _sale(self, qty=1, price=100, credit=False):
return InvoiceService.create_invoice(
self.user,
{
"items": [{"product": self.product, "quantity": qty, "unit_price": price}],
"customer": self.customer,
"buyer_phone": "09121112222",
"payment_type": Invoice.PaymentType.CREDIT if credit else Invoice.PaymentType.CASH,
},
)
def test_statement_lists_rows_with_running_balance(self):
# قیمتِ واحد عمدا از خودِ کالا برداشته می‌شود (ضدِ دستکاری از سمت
# کلاینت)، پس هر فروشِ یک‌عددی ۱۰۰ است — مانده باید تجمعی جلو برود.
self._sale()
self._sale()
statement = SubsidiaryLedgerService.statement(self.user, self.cash)
balances = [r["balance"] for r in statement["rows"]]
self.assertEqual(len(statement["rows"]), 2)
self.assertEqual(balances, [100, 200])
self.assertEqual(statement["closing_balance"], 200)
def test_debit_and_credit_columns_are_separated(self):
self._sale(price=100)
cash_stmt = SubsidiaryLedgerService.statement(self.user, self.cash)
sales_stmt = SubsidiaryLedgerService.statement(self.user, self.sales)
self.assertEqual(cash_stmt["rows"][0]["debit"], 100)
self.assertEqual(cash_stmt["rows"][0]["credit"], 0)
self.assertEqual(sales_stmt["rows"][0]["credit"], 100)
self.assertEqual(sales_stmt["rows"][0]["debit"], 0)
def test_credit_nature_account_balance_grows_on_credit(self):
self._sale(price=100)
sales_stmt = SubsidiaryLedgerService.statement(self.user, self.sales)
# فروش ماهیت بستانکار دارد؛ مانده باید مثبت باشد نه منفی.
self.assertEqual(sales_stmt["closing_balance"], 100)
self.assertFalse(sales_stmt["account"]["is_debit_nature"])
def test_totals_match_rows(self):
self._sale()
self._sale()
statement = SubsidiaryLedgerService.statement(self.user, self.cash)
self.assertEqual(statement["total_debit"], 200)
self.assertEqual(statement["total_credit"], 0)
self.assertEqual(
statement["total_debit"], sum(r["debit"] for r in statement["rows"])
)
def test_empty_account_returns_empty_statement(self):
statement = SubsidiaryLedgerService.statement(self.user, self.cash)
self.assertEqual(statement["rows"], [])
self.assertEqual(statement["closing_balance"], 0)
def test_api_endpoint_returns_statement(self):
self._sale(price=100)
response = self.client.get(
reverse("bizapps:account-subsidiary-ledger", kwargs={"pk": self.cash.id})
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["account"]["code"], ACCOUNT_CASH)
self.assertEqual(len(response.data["rows"]), 1)
def test_other_users_account_is_not_reachable(self):
other = _make_user(phone="09129998888")
ChartOfAccountsService.ensure_chart(other)
foreign = Account.objects.get(user=other, code=ACCOUNT_CASH)
response = self.client.get(
reverse("bizapps:account-subsidiary-ledger", kwargs={"pk": foreign.id})
)
self.assertEqual(response.status_code, 404)
+52 -5
View File
@@ -1,28 +1,75 @@
from django.urls import path
from .api.views import (
AppointmentDetailView,
AppointmentListCreateView,
AccountDetailView,
AccountListView,
BusinessDashboardView,
CustomerDetailView,
CustomerListCreateView,
CustomerStatementView,
InventorySummaryView,
InvoiceCancelView,
InvoiceDetailView,
InvoiceListCreateView,
InvoiceStatusView,
LedgerEntryDetailView,
LedgerEntryListCreateView,
LedgerBookListView,
LedgerBookSummaryView,
LedgerExportView,
LedgerInstallmentDetailView,
LedgerInstallmentListCreateView,
OpenLedgerBookView,
ProductAdjustStockView,
ProductDetailView,
ProductListCreateView,
ProductMovementsView,
SubsidiaryLedgerView,
TransactionListView,
TrialBalanceView,
)
app_name = "bizapps"
urlpatterns = [
path("dashboard/", BusinessDashboardView.as_view(), name="dashboard"),
path("customers/", CustomerListCreateView.as_view(), name="customer-list"),
# قبل از <str:pk> نیاید، وگرنه "statement" به‌عنوان pk تفسیر می‌شود.
path("customers/<str:pk>/statement/", CustomerStatementView.as_view(), name="customer-statement"),
path("customers/<str:pk>/", CustomerDetailView.as_view(), name="customer-detail"),
# export قبل از <str:pk> ثبت شده تا مسیرها با هم تداخل نکنند.
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/export/", LedgerExportView.as_view(), name="ledger-export"),
path("ledger/", LedgerEntryListCreateView.as_view(), name="ledger-list"),
path(
"ledger/<str:entry_id>/installments/",
LedgerInstallmentListCreateView.as_view(),
name="ledger-installment-list",
),
path("ledger/<str:pk>/", LedgerEntryDetailView.as_view(), name="ledger-detail"),
path(
"ledger-installments/<str:pk>/",
LedgerInstallmentDetailView.as_view(),
name="ledger-installment-detail",
),
path("accounts/", AccountListView.as_view(), name="account-list"),
path("accounts/<str:pk>/ledger/", SubsidiaryLedgerView.as_view(), name="account-subsidiary-ledger"),
path("accounts/<str:pk>/", AccountDetailView.as_view(), name="account-detail"),
path("trial-balance/", TrialBalanceView.as_view(), name="trial-balance"),
path("transactions/", TransactionListView.as_view(), name="transaction-list"),
path("products/summary/", InventorySummaryView.as_view(), name="product-summary"),
path("products/", ProductListCreateView.as_view(), name="product-list"),
path("products/<str:pk>/", ProductDetailView.as_view(), name="product-detail"),
path("products/<str:pk>/adjust/", ProductAdjustStockView.as_view(), name="product-adjust"),
path("products/<str:pk>/movements/", ProductMovementsView.as_view(), name="product-movements"),
path("appointments/", AppointmentListCreateView.as_view(), name="appointment-list"),
path("appointments/<str:pk>/", AppointmentDetailView.as_view(), name="appointment-detail"),
path("products/<str:pk>/", ProductDetailView.as_view(), name="product-detail"),
path("invoices/", InvoiceListCreateView.as_view(), name="invoice-list"),
path("invoices/<str:pk>/status/", InvoiceStatusView.as_view(), name="invoice-status"),
path("invoices/<str:pk>/cancel/", InvoiceCancelView.as_view(), name="invoice-cancel"),
path("invoices/<str:pk>/", InvoiceDetailView.as_view(), name="invoice-detail"),
]