adding
This commit is contained in:
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user