399 lines
15 KiB
Python
399 lines
15 KiB
Python
|
|
"""
|
|||
|
|
سرویس هزینه و درآمد (Expense & Income)
|
|||
|
|
|
|||
|
|
این سرویس ثبت اسناد «هزینه/درآمد» را با چند سطر حساب و چند سطر طرفحساب پشتیبانی میکند.
|
|||
|
|
الگوی پیادهسازی بر اساس سرویس دریافت/پرداخت است.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from typing import Any, Dict, List, Optional
|
|||
|
|
from datetime import datetime, date
|
|||
|
|
from decimal import Decimal
|
|||
|
|
import logging
|
|||
|
|
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
from sqlalchemy import and_, or_
|
|||
|
|
|
|||
|
|
from adapters.db.models.document import Document
|
|||
|
|
from adapters.db.models.document_line import DocumentLine
|
|||
|
|
from adapters.db.models.account import Account
|
|||
|
|
from adapters.db.models.currency import Currency
|
|||
|
|
from adapters.db.models.fiscal_year import FiscalYear
|
|||
|
|
from adapters.db.models.user import User
|
|||
|
|
from app.core.responses import ApiError
|
|||
|
|
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# نوعهای سند
|
|||
|
|
DOCUMENT_TYPE_EXPENSE = "expense"
|
|||
|
|
DOCUMENT_TYPE_INCOME = "income"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_iso_date(dt: str | datetime | date) -> date:
|
|||
|
|
if isinstance(dt, date) and not isinstance(dt, datetime):
|
|||
|
|
return dt
|
|||
|
|
if isinstance(dt, datetime):
|
|||
|
|
return dt.date()
|
|||
|
|
try:
|
|||
|
|
return datetime.fromisoformat(str(dt)).date()
|
|||
|
|
except Exception:
|
|||
|
|
return datetime.utcnow().date()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
|
|||
|
|
account = db.query(Account).filter(Account.code == str(account_code)).first()
|
|||
|
|
if not account:
|
|||
|
|
raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=404)
|
|||
|
|
return account
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_business_fiscal_year(db: Session, business_id: int) -> FiscalYear:
|
|||
|
|
fy = db.query(FiscalYear).filter(
|
|||
|
|
and_(FiscalYear.business_id == business_id, FiscalYear.is_closed == False) # noqa: E712
|
|||
|
|
).order_by(FiscalYear.start_date.desc()).first()
|
|||
|
|
if not fy:
|
|||
|
|
raise ApiError("FISCAL_YEAR_NOT_FOUND", "Active fiscal year not found", http_status=404)
|
|||
|
|
return fy
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_expense_income(
|
|||
|
|
db: Session,
|
|||
|
|
business_id: int,
|
|||
|
|
user_id: int,
|
|||
|
|
data: Dict[str, Any],
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
ایجاد سند هزینه/درآمد با چند سطر حساب و چند سطر طرفحساب
|
|||
|
|
|
|||
|
|
data = {
|
|||
|
|
"document_type": "expense" | "income",
|
|||
|
|
"document_date": "2025-10-20",
|
|||
|
|
"currency_id": 1,
|
|||
|
|
"description": str?,
|
|||
|
|
"item_lines": [ # سطرهای حسابهای هزینه/درآمد
|
|||
|
|
{"account_id": 123, "amount": 100000, "description": str?},
|
|||
|
|
],
|
|||
|
|
"counterparty_lines": [ # سطرهای طرفحساب (بانک/صندوق/شخص/چک ...)
|
|||
|
|
{
|
|||
|
|
"transaction_type": "bank" | "cash_register" | "petty_cash" | "check" | "person",
|
|||
|
|
"amount": 100000,
|
|||
|
|
"transaction_date": "2025-10-20T10:00:00",
|
|||
|
|
"description": str?,
|
|||
|
|
"commission": float?, # اختیاری
|
|||
|
|
# فیلدهای اختیاری متناسب با نوع
|
|||
|
|
"bank_id": int?, "bank_name": str?,
|
|||
|
|
"cash_register_id": int?, "cash_register_name": str?,
|
|||
|
|
"petty_cash_id": int?, "petty_cash_name": str?,
|
|||
|
|
"check_id": int?, "check_number": str?,
|
|||
|
|
"person_id": int?, "person_name": str?,
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
document_type = str(data.get("document_type", "")).lower()
|
|||
|
|
if document_type not in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME):
|
|||
|
|
raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'expense' or 'income'", http_status=400)
|
|||
|
|
|
|||
|
|
is_income = document_type == DOCUMENT_TYPE_INCOME
|
|||
|
|
|
|||
|
|
# تاریخ
|
|||
|
|
document_date = _parse_iso_date(data.get("document_date", datetime.utcnow()))
|
|||
|
|
|
|||
|
|
# ارز
|
|||
|
|
currency_id = data.get("currency_id")
|
|||
|
|
if not currency_id:
|
|||
|
|
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
|
|||
|
|
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
|
|||
|
|
if not currency:
|
|||
|
|
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
|
|||
|
|
|
|||
|
|
# سال مالی فعال
|
|||
|
|
fiscal_year = _get_business_fiscal_year(db, business_id)
|
|||
|
|
|
|||
|
|
# اعتبارسنجی خطوط
|
|||
|
|
item_lines: List[Dict[str, Any]] = list(data.get("item_lines") or [])
|
|||
|
|
counterparty_lines: List[Dict[str, Any]] = list(data.get("counterparty_lines") or [])
|
|||
|
|
if not item_lines:
|
|||
|
|
raise ApiError("LINES_REQUIRED", "item_lines is required", http_status=400)
|
|||
|
|
if not counterparty_lines:
|
|||
|
|
raise ApiError("LINES_REQUIRED", "counterparty_lines is required", http_status=400)
|
|||
|
|
|
|||
|
|
sum_items = Decimal(0)
|
|||
|
|
for idx, line in enumerate(item_lines):
|
|||
|
|
if not line.get("account_id"):
|
|||
|
|
raise ApiError("ACCOUNT_REQUIRED", f"item_lines[{idx}].account_id is required", http_status=400)
|
|||
|
|
amount = Decimal(str(line.get("amount", 0)))
|
|||
|
|
if amount <= 0:
|
|||
|
|
raise ApiError("AMOUNT_INVALID", f"item_lines[{idx}].amount must be > 0", http_status=400)
|
|||
|
|
sum_items += amount
|
|||
|
|
|
|||
|
|
sum_counterparties = Decimal(0)
|
|||
|
|
for idx, line in enumerate(counterparty_lines):
|
|||
|
|
amount = Decimal(str(line.get("amount", 0)))
|
|||
|
|
if amount <= 0:
|
|||
|
|
raise ApiError("AMOUNT_INVALID", f"counterparty_lines[{idx}].amount must be > 0", http_status=400)
|
|||
|
|
sum_counterparties += amount
|
|||
|
|
|
|||
|
|
if sum_items != sum_counterparties:
|
|||
|
|
raise ApiError("LINES_NOT_BALANCED", "Sum of items and counterparties must be equal", http_status=400)
|
|||
|
|
|
|||
|
|
# ایجاد سند
|
|||
|
|
user = db.query(User).filter(User.id == int(user_id)).first()
|
|||
|
|
if not user:
|
|||
|
|
raise ApiError("USER_NOT_FOUND", "User not found", http_status=404)
|
|||
|
|
|
|||
|
|
# کد سند ساده: EI-YYYYMMDD-<rand>
|
|||
|
|
code = f"EI-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}"
|
|||
|
|
|
|||
|
|
document = Document(
|
|||
|
|
code=code,
|
|||
|
|
business_id=business_id,
|
|||
|
|
fiscal_year_id=fiscal_year.id,
|
|||
|
|
currency_id=int(currency_id),
|
|||
|
|
created_by_user_id=int(user_id),
|
|||
|
|
document_date=document_date,
|
|||
|
|
document_type=document_type,
|
|||
|
|
is_proforma=False,
|
|||
|
|
description=(data.get("description") or None),
|
|||
|
|
extra_info=(data.get("extra_info") if isinstance(data.get("extra_info"), dict) else None),
|
|||
|
|
)
|
|||
|
|
db.add(document)
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
# سطرهای حسابهای هزینه/درآمد
|
|||
|
|
for line in item_lines:
|
|||
|
|
account = db.query(Account).filter(
|
|||
|
|
and_(
|
|||
|
|
Account.id == int(line.get("account_id")),
|
|||
|
|
or_(Account.business_id == business_id, Account.business_id == None), # noqa: E711
|
|||
|
|
)
|
|||
|
|
).first()
|
|||
|
|
if not account:
|
|||
|
|
raise ApiError("ACCOUNT_NOT_FOUND", "Item account not found", http_status=404)
|
|||
|
|
|
|||
|
|
amount = Decimal(str(line.get("amount", 0)))
|
|||
|
|
description = (line.get("description") or "").strip() or None
|
|||
|
|
|
|||
|
|
debit_amount = amount if not is_income else Decimal(0)
|
|||
|
|
credit_amount = amount if is_income else Decimal(0)
|
|||
|
|
|
|||
|
|
db.add(DocumentLine(
|
|||
|
|
document_id=document.id,
|
|||
|
|
account_id=account.id,
|
|||
|
|
debit=debit_amount,
|
|||
|
|
credit=credit_amount,
|
|||
|
|
description=description,
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# سطرهای طرفحساب (بانک/صندوق/شخص/چک/تنخواه)
|
|||
|
|
for line in counterparty_lines:
|
|||
|
|
amount = Decimal(str(line.get("amount", 0)))
|
|||
|
|
description = (line.get("description") or "").strip() or None
|
|||
|
|
transaction_type: Optional[str] = line.get("transaction_type")
|
|||
|
|
|
|||
|
|
# انتخاب حساب طرفحساب
|
|||
|
|
account: Optional[Account] = None
|
|||
|
|
if transaction_type == "bank":
|
|||
|
|
account = _get_fixed_account_by_code(db, "10203")
|
|||
|
|
elif transaction_type == "cash_register":
|
|||
|
|
account = _get_fixed_account_by_code(db, "10202")
|
|||
|
|
elif transaction_type == "petty_cash":
|
|||
|
|
account = _get_fixed_account_by_code(db, "10201")
|
|||
|
|
elif transaction_type == "check":
|
|||
|
|
# برای چکها از کدهای اسناد دریافتنی/پرداختنی استفاده شود
|
|||
|
|
account = _get_fixed_account_by_code(db, "10403" if is_income else "20202")
|
|||
|
|
elif transaction_type == "person":
|
|||
|
|
# پرداخت/دریافت با شخص عمومی پرداختنی
|
|||
|
|
account = _get_fixed_account_by_code(db, "20201")
|
|||
|
|
elif line.get("account_id"):
|
|||
|
|
account = db.query(Account).filter(
|
|||
|
|
and_(
|
|||
|
|
Account.id == int(line.get("account_id")),
|
|||
|
|
or_(Account.business_id == business_id, Account.business_id == None), # noqa: E711
|
|||
|
|
)
|
|||
|
|
).first()
|
|||
|
|
if not account:
|
|||
|
|
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found for counterparty line", http_status=404)
|
|||
|
|
|
|||
|
|
extra_info: Dict[str, Any] = {}
|
|||
|
|
if transaction_type:
|
|||
|
|
extra_info["transaction_type"] = transaction_type
|
|||
|
|
if line.get("transaction_date"):
|
|||
|
|
extra_info["transaction_date"] = line.get("transaction_date")
|
|||
|
|
if line.get("commission"):
|
|||
|
|
extra_info["commission"] = float(line.get("commission"))
|
|||
|
|
if transaction_type == "bank":
|
|||
|
|
if line.get("bank_id"):
|
|||
|
|
extra_info["bank_id"] = line.get("bank_id")
|
|||
|
|
if line.get("bank_name"):
|
|||
|
|
extra_info["bank_name"] = line.get("bank_name")
|
|||
|
|
elif transaction_type == "cash_register":
|
|||
|
|
if line.get("cash_register_id"):
|
|||
|
|
extra_info["cash_register_id"] = line.get("cash_register_id")
|
|||
|
|
if line.get("cash_register_name"):
|
|||
|
|
extra_info["cash_register_name"] = line.get("cash_register_name")
|
|||
|
|
elif transaction_type == "petty_cash":
|
|||
|
|
if line.get("petty_cash_id"):
|
|||
|
|
extra_info["petty_cash_id"] = line.get("petty_cash_id")
|
|||
|
|
if line.get("petty_cash_name"):
|
|||
|
|
extra_info["petty_cash_name"] = line.get("petty_cash_name")
|
|||
|
|
elif transaction_type == "check":
|
|||
|
|
if line.get("check_id"):
|
|||
|
|
extra_info["check_id"] = line.get("check_id")
|
|||
|
|
if line.get("check_number"):
|
|||
|
|
extra_info["check_number"] = line.get("check_number")
|
|||
|
|
elif transaction_type == "person":
|
|||
|
|
if line.get("person_id"):
|
|||
|
|
extra_info["person_id"] = line.get("person_id")
|
|||
|
|
if line.get("person_name"):
|
|||
|
|
extra_info["person_name"] = line.get("person_name")
|
|||
|
|
|
|||
|
|
debit_amount = amount if is_income else Decimal(0)
|
|||
|
|
credit_amount = amount if not is_income else Decimal(0)
|
|||
|
|
|
|||
|
|
db.add(DocumentLine(
|
|||
|
|
document_id=document.id,
|
|||
|
|
account_id=account.id,
|
|||
|
|
person_id=(int(line["person_id"]) if transaction_type == "person" and line.get("person_id") else None),
|
|||
|
|
bank_account_id=(int(line["bank_id"]) if transaction_type == "bank" and line.get("bank_id") else None),
|
|||
|
|
cash_register_id=line.get("cash_register_id"),
|
|||
|
|
petty_cash_id=line.get("petty_cash_id"),
|
|||
|
|
check_id=line.get("check_id"),
|
|||
|
|
debit=debit_amount,
|
|||
|
|
credit=credit_amount,
|
|||
|
|
description=description,
|
|||
|
|
extra_info=extra_info or None,
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
# توجه: خطوط کارمزد در این نسخه پیادهسازی نمیشود (میتوان مشابه سرویس دریافت/پرداخت اضافه کرد)
|
|||
|
|
|
|||
|
|
db.commit()
|
|||
|
|
db.refresh(document)
|
|||
|
|
return document_to_dict(db, document)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
|||
|
|
lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all()
|
|||
|
|
items: List[Dict[str, Any]] = []
|
|||
|
|
counterparties: List[Dict[str, Any]] = []
|
|||
|
|
for ln in lines:
|
|||
|
|
account = db.query(Account).filter(Account.id == ln.account_id).first()
|
|||
|
|
row = {
|
|||
|
|
"id": ln.id,
|
|||
|
|
"account_id": ln.account_id,
|
|||
|
|
"account_name": account.name if account else None,
|
|||
|
|
"debit": float(ln.debit or 0),
|
|||
|
|
"credit": float(ln.credit or 0),
|
|||
|
|
"description": ln.description,
|
|||
|
|
"extra_info": ln.extra_info,
|
|||
|
|
"person_id": ln.person_id,
|
|||
|
|
"bank_account_id": ln.bank_account_id,
|
|||
|
|
"cash_register_id": ln.cash_register_id,
|
|||
|
|
"petty_cash_id": ln.petty_cash_id,
|
|||
|
|
"check_id": ln.check_id,
|
|||
|
|
}
|
|||
|
|
# ساده: بر اساس وجود transaction_type در extra_info، به عنوان طرفحساب تلقی میشود
|
|||
|
|
if ln.extra_info and ln.extra_info.get("transaction_type"):
|
|||
|
|
counterparties.append(row)
|
|||
|
|
else:
|
|||
|
|
items.append(row)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"id": document.id,
|
|||
|
|
"code": document.code,
|
|||
|
|
"business_id": document.business_id,
|
|||
|
|
"fiscal_year_id": document.fiscal_year_id,
|
|||
|
|
"currency_id": document.currency_id,
|
|||
|
|
"document_type": document.document_type,
|
|||
|
|
"document_date": document.document_date.isoformat(),
|
|||
|
|
"description": document.description,
|
|||
|
|
"items": items,
|
|||
|
|
"counterparties": counterparties,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_expense_income(
|
|||
|
|
db: Session,
|
|||
|
|
business_id: int,
|
|||
|
|
query: Dict[str, Any],
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""لیست اسناد هزینه و درآمد با فیلتر، جستوجو و صفحهبندی"""
|
|||
|
|
q = db.query(Document).filter(
|
|||
|
|
and_(
|
|||
|
|
Document.business_id == business_id,
|
|||
|
|
Document.document_type.in_([DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME]),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# سال مالی
|
|||
|
|
fiscal_year_id = query.get("fiscal_year_id")
|
|||
|
|
try:
|
|||
|
|
fiscal_year_id_int = int(fiscal_year_id) if fiscal_year_id is not None else None
|
|||
|
|
except Exception:
|
|||
|
|
fiscal_year_id_int = None
|
|||
|
|
if fiscal_year_id_int is None:
|
|||
|
|
try:
|
|||
|
|
fy = _get_business_fiscal_year(db, business_id)
|
|||
|
|
fiscal_year_id_int = fy.id
|
|||
|
|
except Exception:
|
|||
|
|
fiscal_year_id_int = None
|
|||
|
|
if fiscal_year_id_int is not None:
|
|||
|
|
q = q.filter(Document.fiscal_year_id == fiscal_year_id_int)
|
|||
|
|
|
|||
|
|
# نوع سند
|
|||
|
|
doc_type = query.get("document_type")
|
|||
|
|
if doc_type in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME):
|
|||
|
|
q = q.filter(Document.document_type == doc_type)
|
|||
|
|
|
|||
|
|
# فیلتر تاریخ
|
|||
|
|
from_date = query.get("from_date")
|
|||
|
|
to_date = query.get("to_date")
|
|||
|
|
if from_date:
|
|||
|
|
try:
|
|||
|
|
q = q.filter(Document.document_date >= _parse_iso_date(from_date))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
if to_date:
|
|||
|
|
try:
|
|||
|
|
q = q.filter(Document.document_date <= _parse_iso_date(to_date))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# جستوجو در کد سند
|
|||
|
|
search = query.get("search")
|
|||
|
|
if search:
|
|||
|
|
q = q.filter(Document.code.ilike(f"%{search}%"))
|
|||
|
|
|
|||
|
|
# مرتبسازی
|
|||
|
|
sort_by = query.get("sort_by", "document_date")
|
|||
|
|
sort_desc = bool(query.get("sort_desc", True))
|
|||
|
|
if isinstance(sort_by, str) and hasattr(Document, sort_by):
|
|||
|
|
col = getattr(Document, sort_by)
|
|||
|
|
q = q.order_by(col.desc() if sort_desc else col.asc())
|
|||
|
|
else:
|
|||
|
|
q = q.order_by(Document.document_date.desc())
|
|||
|
|
|
|||
|
|
# صفحهبندی
|
|||
|
|
skip = int(query.get("skip", 0))
|
|||
|
|
take = int(query.get("take", 20))
|
|||
|
|
total = q.count()
|
|||
|
|
docs = q.offset(skip).limit(take).all()
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"items": [document_to_dict(db, d) for d in docs],
|
|||
|
|
"pagination": {
|
|||
|
|
"total": total,
|
|||
|
|
"page": (skip // take) + 1,
|
|||
|
|
"per_page": take,
|
|||
|
|
"total_pages": (total + take - 1) // take,
|
|||
|
|
"has_next": skip + take < total,
|
|||
|
|
"has_prev": skip > 0,
|
|||
|
|
},
|
|||
|
|
"query_info": query,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|