adding
This commit is contained in:
+59
-17
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user