2025-10-21 11:30:01 +03:30
|
|
|
|
"""
|
|
|
|
|
|
سرویس هزینه و درآمد (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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-10-27 22:17:45 +03:30
|
|
|
|
def get_expense_income(db: Session, document_id: int) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
"""دریافت جزئیات یک سند هزینه/درآمد"""
|
|
|
|
|
|
document = db.query(Document).filter(Document.id == document_id).first()
|
|
|
|
|
|
if not document:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
return document_to_dict(db, document)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_expense_income(
|
|
|
|
|
|
db: Session,
|
|
|
|
|
|
document_id: int,
|
|
|
|
|
|
user_id: int,
|
|
|
|
|
|
data: Dict[str, Any]
|
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
"""ویرایش سند هزینه/درآمد"""
|
|
|
|
|
|
document = db.query(Document).filter(Document.id == document_id).first()
|
|
|
|
|
|
if not document:
|
|
|
|
|
|
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
|
|
|
|
|
|
|
|
|
|
|
|
# بررسی نوع سند
|
|
|
|
|
|
if document.document_type not in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME):
|
|
|
|
|
|
raise ApiError("INVALID_DOCUMENT_TYPE", "Document is not expense/income", http_status=400)
|
|
|
|
|
|
|
|
|
|
|
|
is_income = document.document_type == DOCUMENT_TYPE_INCOME
|
|
|
|
|
|
|
|
|
|
|
|
# تاریخ
|
|
|
|
|
|
document_date = _parse_iso_date(data.get("document_date", document.document_date))
|
|
|
|
|
|
|
|
|
|
|
|
# ارز
|
|
|
|
|
|
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, document.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)
|
|
|
|
|
|
|
|
|
|
|
|
# حذف خطوط قبلی
|
|
|
|
|
|
db.query(DocumentLine).filter(DocumentLine.document_id == document_id).delete()
|
|
|
|
|
|
|
|
|
|
|
|
# بهروزرسانی اطلاعات سند
|
|
|
|
|
|
document.document_date = document_date
|
|
|
|
|
|
document.currency_id = int(currency_id)
|
|
|
|
|
|
document.fiscal_year_id = fiscal_year.id
|
|
|
|
|
|
document.description = (data.get("description") or "").strip() or None
|
|
|
|
|
|
document.extra_info = data.get("extra_info") if isinstance(data.get("extra_info"), dict) else None
|
|
|
|
|
|
|
|
|
|
|
|
# سطرهای حسابهای هزینه/درآمد
|
|
|
|
|
|
for line in item_lines:
|
|
|
|
|
|
account = db.query(Account).filter(
|
|
|
|
|
|
and_(
|
|
|
|
|
|
Account.id == int(line.get("account_id")),
|
|
|
|
|
|
or_(Account.business_id == document.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
|
|
|
|
|
|
commission = Decimal(str(line.get("commission", 0))) if line.get("commission") else Decimal(0)
|
|
|
|
|
|
|
|
|
|
|
|
# تعیین نوع تراکنش و حساب مربوطه
|
|
|
|
|
|
transaction_type = line.get("transaction_type", "bank")
|
|
|
|
|
|
account = None
|
|
|
|
|
|
|
|
|
|
|
|
if transaction_type == "bank":
|
|
|
|
|
|
bank_account_id = line.get("bank_account_id")
|
|
|
|
|
|
if bank_account_id:
|
|
|
|
|
|
from adapters.db.models.bank_account import BankAccount
|
|
|
|
|
|
bank_account = db.query(BankAccount).filter(BankAccount.id == int(bank_account_id)).first()
|
|
|
|
|
|
if bank_account:
|
|
|
|
|
|
account = bank_account.account
|
|
|
|
|
|
elif transaction_type == "cash_register":
|
|
|
|
|
|
cash_register_id = line.get("cash_register_id")
|
|
|
|
|
|
if cash_register_id:
|
|
|
|
|
|
from adapters.db.models.cash_register import CashRegister
|
|
|
|
|
|
cash_register = db.query(CashRegister).filter(CashRegister.id == int(cash_register_id)).first()
|
|
|
|
|
|
if cash_register:
|
|
|
|
|
|
account = cash_register.account
|
|
|
|
|
|
elif transaction_type == "petty_cash":
|
|
|
|
|
|
petty_cash_id = line.get("petty_cash_id")
|
|
|
|
|
|
if petty_cash_id:
|
|
|
|
|
|
from adapters.db.models.petty_cash import PettyCash
|
|
|
|
|
|
petty_cash = db.query(PettyCash).filter(PettyCash.id == int(petty_cash_id)).first()
|
|
|
|
|
|
if petty_cash:
|
|
|
|
|
|
account = petty_cash.account
|
|
|
|
|
|
elif transaction_type == "check":
|
|
|
|
|
|
check_id = line.get("check_id")
|
|
|
|
|
|
if check_id:
|
|
|
|
|
|
from adapters.db.models.check import Check
|
|
|
|
|
|
check = db.query(Check).filter(Check.id == int(check_id)).first()
|
|
|
|
|
|
if check:
|
|
|
|
|
|
account = check.account
|
|
|
|
|
|
elif transaction_type == "person":
|
|
|
|
|
|
person_id = line.get("person_id")
|
|
|
|
|
|
if person_id:
|
|
|
|
|
|
from adapters.db.models.person import Person
|
|
|
|
|
|
person = db.query(Person).filter(Person.id == int(person_id)).first()
|
|
|
|
|
|
if person:
|
|
|
|
|
|
# حساب شخص بر اساس نوع (دریافتنی/پرداختنی)
|
|
|
|
|
|
account = _get_person_account(db, document.business_id, int(person_id), is_income)
|
|
|
|
|
|
|
|
|
|
|
|
if not account:
|
|
|
|
|
|
# اگر حساب مشخص نشده، از حساب پیشفرض استفاده کن
|
|
|
|
|
|
account_code = "1111" if is_income else "2111" # نقد یا بانک
|
|
|
|
|
|
account = _get_fixed_account_by_code(db, account_code)
|
|
|
|
|
|
|
|
|
|
|
|
# مبلغ اصلی
|
|
|
|
|
|
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,
|
|
|
|
|
|
debit=debit_amount,
|
|
|
|
|
|
credit=credit_amount,
|
|
|
|
|
|
description=description,
|
|
|
|
|
|
extra_info={
|
|
|
|
|
|
"transaction_type": transaction_type,
|
|
|
|
|
|
"transaction_date": line.get("transaction_date"),
|
|
|
|
|
|
"commission": float(commission),
|
|
|
|
|
|
**{k: v for k, v in line.items() if k not in ["amount", "description", "commission", "transaction_type", "transaction_date"]}
|
|
|
|
|
|
}
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
# اگر کارمزد وجود دارد، خط کارمزد اضافه کن
|
|
|
|
|
|
if commission > 0:
|
|
|
|
|
|
commission_account = _get_fixed_account_by_code(db, "5111") # کارمزد
|
|
|
|
|
|
db.add(DocumentLine(
|
|
|
|
|
|
document_id=document.id,
|
|
|
|
|
|
account_id=commission_account.id,
|
|
|
|
|
|
debit=commission if is_income else Decimal(0),
|
|
|
|
|
|
credit=commission if not is_income else Decimal(0),
|
|
|
|
|
|
description=f"کارمزد {description or ''}",
|
|
|
|
|
|
extra_info={"is_commission_line": True}
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(document)
|
|
|
|
|
|
|
|
|
|
|
|
return document_to_dict(db, document)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_expense_income(db: Session, document_id: int) -> bool:
|
|
|
|
|
|
"""حذف یک سند هزینه/درآمد"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
document = db.query(Document).filter(Document.id == document_id).first()
|
|
|
|
|
|
if not document:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# بررسی نوع سند
|
|
|
|
|
|
if document.document_type not in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# حذف خطوط سند
|
|
|
|
|
|
db.query(DocumentLine).filter(DocumentLine.document_id == document_id).delete()
|
|
|
|
|
|
|
|
|
|
|
|
# حذف سند
|
|
|
|
|
|
db.delete(document)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error deleting expense/income document {document_id}: {e}")
|
|
|
|
|
|
db.rollback()
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_multiple_expense_income(db: Session, document_ids: List[int]) -> bool:
|
|
|
|
|
|
"""حذف چندین سند هزینه/درآمد"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
documents = db.query(Document).filter(
|
|
|
|
|
|
and_(
|
|
|
|
|
|
Document.id.in_(document_ids),
|
|
|
|
|
|
Document.document_type.in_([DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME])
|
|
|
|
|
|
)
|
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
|
|
if not documents:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# حذف خطوط اسناد
|
|
|
|
|
|
db.query(DocumentLine).filter(DocumentLine.document_id.in_(document_ids)).delete()
|
|
|
|
|
|
|
|
|
|
|
|
# حذف اسناد
|
|
|
|
|
|
for document in documents:
|
|
|
|
|
|
db.delete(document)
|
|
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error deleting multiple expense/income documents: {e}")
|
|
|
|
|
|
db.rollback()
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def export_expense_income_excel(db: Session, business_id: int, query: Dict[str, Any]) -> bytes:
|
|
|
|
|
|
"""خروجی Excel اسناد هزینه/درآمد"""
|
|
|
|
|
|
# این تابع باید پیادهسازی شود
|
|
|
|
|
|
# فعلاً یک فایل Excel خالی برمیگرداند
|
|
|
|
|
|
import io
|
|
|
|
|
|
from openpyxl import Workbook
|
|
|
|
|
|
|
|
|
|
|
|
wb = Workbook()
|
|
|
|
|
|
ws = wb.active
|
|
|
|
|
|
ws.title = "هزینه و درآمد"
|
|
|
|
|
|
|
|
|
|
|
|
# هدرها
|
|
|
|
|
|
headers = ["کد سند", "نوع", "تاریخ سند", "مبلغ کل", "توضیحات", "ایجادکننده", "تاریخ ثبت"]
|
|
|
|
|
|
for col, header in enumerate(headers, 1):
|
|
|
|
|
|
ws.cell(row=1, column=col, value=header)
|
|
|
|
|
|
|
|
|
|
|
|
# دریافت دادهها
|
|
|
|
|
|
result = list_expense_income(db, business_id, query)
|
|
|
|
|
|
items = result.get("items", [])
|
|
|
|
|
|
|
|
|
|
|
|
# اضافه کردن دادهها
|
|
|
|
|
|
for row, item in enumerate(items, 2):
|
|
|
|
|
|
ws.cell(row=row, column=1, value=item.get("code", ""))
|
|
|
|
|
|
ws.cell(row=row, column=2, value=item.get("document_type_name", ""))
|
|
|
|
|
|
ws.cell(row=row, column=3, value=item.get("document_date", ""))
|
|
|
|
|
|
ws.cell(row=row, column=4, value=item.get("total_amount", 0))
|
|
|
|
|
|
ws.cell(row=row, column=5, value=item.get("description", ""))
|
|
|
|
|
|
ws.cell(row=row, column=6, value=item.get("created_by_name", ""))
|
|
|
|
|
|
ws.cell(row=row, column=7, value=item.get("registered_at", ""))
|
|
|
|
|
|
|
|
|
|
|
|
# ذخیره در بایت
|
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
|
wb.save(output)
|
|
|
|
|
|
return output.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def export_expense_income_pdf(db: Session, business_id: int, query: Dict[str, Any]) -> bytes:
|
|
|
|
|
|
"""خروجی PDF اسناد هزینه/درآمد"""
|
|
|
|
|
|
# این تابع باید پیادهسازی شود
|
|
|
|
|
|
# فعلاً یک فایل PDF خالی برمیگرداند
|
|
|
|
|
|
from reportlab.pdfgen import canvas
|
|
|
|
|
|
from reportlab.lib.pagesizes import letter
|
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
|
c = canvas.Canvas(output, pagesize=letter)
|
|
|
|
|
|
|
|
|
|
|
|
# عنوان
|
|
|
|
|
|
c.setFont("Helvetica-Bold", 16)
|
|
|
|
|
|
c.drawString(100, 750, "گزارش هزینه و درآمد")
|
|
|
|
|
|
|
|
|
|
|
|
# دریافت دادهها
|
|
|
|
|
|
result = list_expense_income(db, business_id, query)
|
|
|
|
|
|
items = result.get("items", [])
|
|
|
|
|
|
|
|
|
|
|
|
# اضافه کردن دادهها
|
|
|
|
|
|
y = 700
|
|
|
|
|
|
c.setFont("Helvetica", 12)
|
|
|
|
|
|
for item in items[:20]: # حداکثر 20 آیتم
|
|
|
|
|
|
c.drawString(100, y, f"{item.get('code', '')} - {item.get('document_type_name', '')} - {item.get('total_amount', 0)}")
|
|
|
|
|
|
y -= 20
|
|
|
|
|
|
if y < 100:
|
|
|
|
|
|
c.showPage()
|
|
|
|
|
|
y = 750
|
|
|
|
|
|
|
|
|
|
|
|
c.save()
|
|
|
|
|
|
return output.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_expense_income_pdf(db: Session, document_id: int) -> bytes:
|
|
|
|
|
|
"""تولید PDF یک سند هزینه/درآمد"""
|
|
|
|
|
|
# این تابع باید پیادهسازی شود
|
|
|
|
|
|
# فعلاً یک فایل PDF خالی برمیگرداند
|
|
|
|
|
|
from reportlab.pdfgen import canvas
|
|
|
|
|
|
from reportlab.lib.pagesizes import letter
|
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
|
c = canvas.Canvas(output, pagesize=letter)
|
|
|
|
|
|
|
|
|
|
|
|
# دریافت سند
|
|
|
|
|
|
document = db.query(Document).filter(Document.id == document_id).first()
|
|
|
|
|
|
if not document:
|
|
|
|
|
|
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
|
|
|
|
|
|
|
|
|
|
|
|
# عنوان
|
|
|
|
|
|
c.setFont("Helvetica-Bold", 16)
|
|
|
|
|
|
c.drawString(100, 750, f"سند {document.document_type_name}")
|
|
|
|
|
|
c.drawString(100, 730, f"کد: {document.code}")
|
|
|
|
|
|
c.drawString(100, 710, f"تاریخ: {document.document_date}")
|
|
|
|
|
|
|
|
|
|
|
|
c.save()
|
|
|
|
|
|
return output.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_person_account(
|
|
|
|
|
|
db: Session,
|
|
|
|
|
|
business_id: int,
|
|
|
|
|
|
person_id: int,
|
|
|
|
|
|
is_receivable: bool
|
|
|
|
|
|
) -> Account:
|
|
|
|
|
|
"""دریافت حساب شخص (دریافتنی یا پرداختنی)"""
|
|
|
|
|
|
from adapters.db.models.person import Person
|
|
|
|
|
|
person = db.query(Person).filter(Person.id == person_id).first()
|
|
|
|
|
|
if not person:
|
|
|
|
|
|
raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404)
|
|
|
|
|
|
|
|
|
|
|
|
# تعیین کد حساب بر اساس نوع
|
|
|
|
|
|
if is_receivable:
|
|
|
|
|
|
account_code = "1211" # دریافتنیها
|
|
|
|
|
|
else:
|
|
|
|
|
|
account_code = "2211" # پرداختنیها
|
|
|
|
|
|
|
|
|
|
|
|
return _get_fixed_account_by_code(db, account_code)
|
|
|
|
|
|
|
|
|
|
|
|
|