hesabixArc/hesabixAPI/app/services/receipt_payment_service.py
2025-10-16 20:52:59 +03:30

1198 lines
52 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
سرویس دریافت و پرداخت (Receipt & Payment)
این سرویس برای ثبت اسناد دریافت و پرداخت استفاده می‌شود که شامل:
- دریافت وجه از اشخاص (مشتریان)
- پرداخت به اشخاص (تامین‌کنندگان)
"""
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_, func
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.person import Person
from adapters.db.models.currency import Currency
from adapters.db.models.user import User
from adapters.db.models.fiscal_year import FiscalYear
from app.core.responses import ApiError
import jdatetime
# تنظیم لاگر
logger = logging.getLogger(__name__)
# نوع‌های سند
DOCUMENT_TYPE_RECEIPT = "receipt" # دریافت
DOCUMENT_TYPE_PAYMENT = "payment" # پرداخت
# نوع‌های حساب (از migration)
ACCOUNT_TYPE_RECEIVABLE = "person" # حساب دریافتنی
ACCOUNT_TYPE_PAYABLE = "person" # حساب پرداختنی
ACCOUNT_TYPE_CASH = "cash_register" # صندوق
ACCOUNT_TYPE_BANK = "bank" # بانک
ACCOUNT_TYPE_CHECK_RECEIVED = "check" # اسناد دریافتنی (چک دریافتی)
ACCOUNT_TYPE_CHECK_PAYABLE = "check" # اسناد پرداختنی (چک پرداختی)
def _parse_iso_date(dt: str | datetime | date) -> date:
"""تبدیل تاریخ به فرمت date - پشتیبانی از تاریخ‌های شمسی و میلادی"""
if isinstance(dt, date):
return dt
if isinstance(dt, datetime):
return dt.date()
dt_str = str(dt).strip()
try:
# ابتدا سعی کن ISO8601 را پردازش کنی
dt_str_clean = dt_str.replace('Z', '+00:00')
parsed = datetime.fromisoformat(dt_str_clean)
return parsed.date()
except Exception:
pass
try:
# بررسی فرمت YYYY-MM-DD (میلادی)
if len(dt_str) == 10 and dt_str.count('-') == 2:
return datetime.strptime(dt_str, '%Y-%m-%d').date()
except Exception:
pass
try:
# بررسی فرمت YYYY/MM/DD (ممکن است شمسی باشد)
if len(dt_str) == 10 and dt_str.count('/') == 2:
parts = dt_str.split('/')
if len(parts) == 3:
year, month, day = parts
try:
year_int = int(year)
month_int = int(month)
day_int = int(day)
# اگر سال بزرگتر از 1500 باشد، احتمالاً شمسی است
if year_int > 1500:
# تبدیل شمسی به میلادی
jalali_date = jdatetime.date(year_int, month_int, day_int)
gregorian_date = jalali_date.togregorian()
return gregorian_date
else:
# احتمالاً میلادی است
return datetime.strptime(dt_str, '%Y/%m/%d').date()
except (ValueError, jdatetime.JalaliDateError):
# اگر تبدیل شمسی ناموفق بود، سعی کن میلادی کنی
return datetime.strptime(dt_str, '%Y/%m/%d').date()
except Exception:
pass
# اگر هیچ فرمتی کار نکرد، خطا بده
raise ApiError("INVALID_DATE", f"Invalid date format: {dt}", http_status=400)
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
"""دریافت سال مالی فعلی برای کسب‌وکار"""
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if not fiscal_year:
raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400)
return fiscal_year
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
"""
دریافت حساب ثابت بر اساس کد
Args:
db: Session پایگاه داده
account_code: کد حساب (مثل 10201, 10202, 10203)
Returns:
Account: حساب ثابت
"""
account = db.query(Account).filter(
and_(
Account.business_id == None, # حساب‌های عمومی
Account.code == account_code
)
).first()
if not account:
raise ApiError(
"ACCOUNT_NOT_FOUND",
f"Account with code {account_code} not found",
http_status=500
)
return account
def _get_person_account(
db: Session,
business_id: int,
person_id: int,
is_receivable: bool
) -> Account:
"""
دریافت حساب شخص (حساب دریافتنی یا پرداختنی عمومی)
Args:
business_id: شناسه کسب‌وکار
person_id: شناسه شخص
is_receivable: اگر True باشد، حساب دریافتنی و اگر False باشد حساب پرداختنی
Returns:
Account: حساب شخص عمومی
"""
person = db.query(Person).filter(
and_(Person.id == person_id, Person.business_id == business_id)
).first()
if not person:
raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404)
# کد حساب عمومی (بدون ایجاد حساب جداگانه)
account_code = "10401" if is_receivable else "20201"
# استفاده از تابع کمکی
return _get_fixed_account_by_code(db, account_code)
def create_receipt_payment(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any]
) -> Dict[str, Any]:
"""
ایجاد سند دریافت یا پرداخت
Args:
business_id: شناسه کسب‌وکار
user_id: شناسه کاربر ایجادکننده
data: اطلاعات سند شامل:
- document_type: "receipt" یا "payment"
- document_date: تاریخ سند
- currency_id: شناسه ارز
- person_lines: لیست تراکنش‌های اشخاص [{"person_id": int, "amount": float, "description": str?}, ...]
- account_lines: لیست تراکنش‌های حساب‌ها [{"account_id": int, "amount": float, "description": str?}, ...]
- extra_info: اطلاعات اضافی (اختیاری)
Returns:
Dict: اطلاعات سند ایجاد شده
"""
logger.info(f"=== شروع ایجاد سند دریافت/پرداخت ===")
logger.info(f"business_id: {business_id}, user_id: {user_id}")
logger.info(f"داده‌های ورودی: {data}")
# اعتبارسنجی نوع سند
document_type = str(data.get("document_type", "")).lower()
logger.info(f"نوع سند: {document_type}")
if document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'receipt' or 'payment'", http_status=400)
is_receipt = (document_type == DOCUMENT_TYPE_RECEIPT)
logger.info(f"آیا دریافت است: {is_receipt}")
# اعتبارسنجی تاریخ
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
# اعتبارسنجی ارز
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)
# دریافت سال مالی فعلی
logger.info(f"دریافت سال مالی فعلی برای business_id={business_id}")
fiscal_year = _get_current_fiscal_year(db, business_id)
logger.info(f"سال مالی فعلی: id={fiscal_year.id}, title={fiscal_year.title}")
# اعتبارسنجی خطوط اشخاص
person_lines = data.get("person_lines", [])
logger.info(f"تعداد خطوط اشخاص: {len(person_lines)}")
logger.info(f"خطوط اشخاص: {person_lines}")
if not person_lines or not isinstance(person_lines, list):
raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400)
# اعتبارسنجی خطوط حساب‌ها
account_lines = data.get("account_lines", [])
logger.info(f"تعداد خطوط حساب‌ها: {len(account_lines)}")
logger.info(f"خطوط حساب‌ها: {account_lines}")
if not account_lines or not isinstance(account_lines, list):
raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400)
# محاسبه مجموع مبالغ
person_total = sum(float(line.get("amount", 0)) for line in person_lines)
account_total = sum(float(line.get("amount", 0)) for line in account_lines)
# بررسی تعادل مبالغ
if abs(person_total - account_total) > 0.01: # tolerance برای خطای ممیز شناور
raise ApiError(
"UNBALANCED_AMOUNTS",
f"Person total ({person_total}) must equal account total ({account_total})",
http_status=400
)
# تولید کد سند
# فرمت: RP-YYYYMMDD-NNNN (RP = Receipt/Payment)
today = datetime.now().date()
prefix = f"{'RC' if is_receipt else 'PY'}-{today.strftime('%Y%m%d')}"
last_doc = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.code.like(f"{prefix}-%")
)
).order_by(Document.code.desc()).first()
if last_doc:
try:
last_num = int(last_doc.code.split("-")[-1])
next_num = last_num + 1
except Exception:
next_num = 1
else:
next_num = 1
doc_code = f"{prefix}-{next_num:04d}"
# ایجاد سند
document = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code,
document_type=document_type,
document_date=document_date,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=datetime.utcnow(),
is_proforma=False,
description=data.get("description"),
extra_info=data.get("extra_info"),
)
db.add(document)
db.flush() # برای دریافت document.id
# ایجاد خطوط سند برای اشخاص
logger.info(f"=== شروع ایجاد خطوط اشخاص ===")
for i, person_line in enumerate(person_lines):
logger.info(f"پردازش خط شخص {i+1}: {person_line}")
person_id = person_line.get("person_id")
logger.info(f"person_id: {person_id}")
if not person_id:
logger.warning(f"خط شخص {i+1}: person_id موجود نیست، رد می‌شود")
continue
amount = Decimal(str(person_line.get("amount", 0)))
logger.info(f"مبلغ: {amount}")
if amount <= 0:
logger.warning(f"خط شخص {i+1}: مبلغ صفر یا منفی، رد می‌شود")
continue
description = person_line.get("description", "").strip() or None
logger.info(f"توضیحات: {description}")
# دریافت حساب شخص عمومی
# در دریافت: حساب دریافتنی (receivable) - کد 10401
# در پرداخت: حساب پرداختنی (payable) - کد 20201
logger.info(f"دریافت حساب شخص برای person_id={person_id}, is_receivable={is_receipt}")
person_account = _get_person_account(
db,
business_id,
int(person_id),
is_receivable=is_receipt
)
logger.info(f"حساب شخص پیدا شد: id={person_account.id}, code={person_account.code}, name={person_account.name}")
# ایجاد خط سند برای شخص
# در دریافت: شخص بستانکار (credit)
# در پرداخت: شخص بدهکار (debit)
debit_amount = amount if not is_receipt else Decimal(0)
credit_amount = amount if is_receipt else Decimal(0)
logger.info(f"مقادیر بدهکار/بستانکار: debit={debit_amount}, credit={credit_amount}")
line = DocumentLine(
document_id=document.id,
account_id=person_account.id,
person_id=int(person_id),
quantity=person_line.get("quantity"),
debit=debit_amount,
credit=credit_amount,
description=description,
extra_info={
"person_id": int(person_id),
"person_name": person_line.get("person_name"),
}
)
logger.info(f"خط سند شخص ایجاد شد: {line}")
db.add(line)
# ایجاد خطوط سند برای حساب‌ها
logger.info(f"=== شروع ایجاد خطوط حساب‌ها ===")
total_commission = Decimal(0) # مجموع کارمزدها
for i, account_line in enumerate(account_lines):
logger.info(f"پردازش خط حساب {i+1}: {account_line}")
account_id = account_line.get("account_id")
logger.info(f"account_id: {account_id}")
if not account_id:
logger.info(f"خط حساب {i+1}: account_id موجود نیست، ادامه می‌دهد")
amount = Decimal(str(account_line.get("amount", 0)))
logger.info(f"مبلغ: {amount}")
if amount <= 0:
logger.warning(f"خط حساب {i+1}: مبلغ صفر یا منفی، رد می‌شود")
continue
description = account_line.get("description", "").strip() or None
transaction_type = account_line.get("transaction_type")
transaction_date = account_line.get("transaction_date")
commission = account_line.get("commission")
logger.info(f"نوع تراکنش: {transaction_type}")
logger.info(f"تاریخ تراکنش: {transaction_date}")
logger.info(f"کمیسیون: {commission}")
# اضافه کردن کارمزد به مجموع
if commission:
commission_amount = Decimal(str(commission))
total_commission += commission_amount
logger.info(f"کارمزد اضافه شد: {commission_amount}, مجموع: {total_commission}")
# تعیین حساب بر اساس transaction_type
account = None
if transaction_type == "bank":
# برای بانک، از حساب بانک استفاده کن
account_code = "10203" # بانک
logger.info(f"انتخاب حساب بانک با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif transaction_type == "cash_register":
# برای صندوق، از حساب صندوق استفاده کن
account_code = "10202" # صندوق
logger.info(f"انتخاب حساب صندوق با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif transaction_type == "petty_cash":
# برای تنخواهگردان، از حساب تنخواهگردان استفاده کن
account_code = "10201" # تنخواه گردان
logger.info(f"انتخاب حساب تنخواهگردان با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif transaction_type == "check":
# برای چک، بر اساس نوع سند از کد مناسب استفاده کن
if is_receipt:
account_code = "10403" # اسناد دریافتنی (چک دریافتی)
else:
account_code = "20202" # اسناد پرداختنی (چک پرداختی)
logger.info(f"انتخاب حساب چک با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif transaction_type == "person":
# برای شخص، از حساب شخص عمومی استفاده کن
account_code = "20201" # حساب‌های پرداختنی
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif account_id:
# اگر account_id مشخص باشد، از آن استفاده کن
logger.info(f"استفاده از account_id مشخص: {account_id}")
account = db.query(Account).filter(
and_(
Account.id == int(account_id),
or_(
Account.business_id == business_id,
Account.business_id == None # حساب‌های عمومی
)
)
).first()
if not account:
logger.error(f"خط حساب {i+1}: حساب پیدا نشد برای transaction_type: {transaction_type}")
raise ApiError(
"ACCOUNT_NOT_FOUND",
f"Account not found for transaction_type: {transaction_type}",
http_status=404
)
logger.info(f"حساب پیدا شد: id={account.id}, code={account.code}, name={account.name}")
# ایجاد اطلاعات اضافی برای خط سند
extra_info = {}
if transaction_type:
extra_info["transaction_type"] = transaction_type
if transaction_date:
extra_info["transaction_date"] = transaction_date
if commission:
extra_info["commission"] = float(commission)
# اطلاعات اضافی بر اساس نوع تراکنش
if transaction_type == "bank":
if account_line.get("bank_id"):
extra_info["bank_id"] = account_line.get("bank_id")
if account_line.get("bank_name"):
extra_info["bank_name"] = account_line.get("bank_name")
elif transaction_type == "cash_register":
if account_line.get("cash_register_id"):
extra_info["cash_register_id"] = account_line.get("cash_register_id")
if account_line.get("cash_register_name"):
extra_info["cash_register_name"] = account_line.get("cash_register_name")
elif transaction_type == "petty_cash":
if account_line.get("petty_cash_id"):
extra_info["petty_cash_id"] = account_line.get("petty_cash_id")
if account_line.get("petty_cash_name"):
extra_info["petty_cash_name"] = account_line.get("petty_cash_name")
elif transaction_type == "check":
if account_line.get("check_id"):
extra_info["check_id"] = account_line.get("check_id")
if account_line.get("check_number"):
extra_info["check_number"] = account_line.get("check_number")
# ایجاد خط سند برای حساب
# در دریافت: حساب بدهکار (debit) - دارایی افزایش می‌یابد
# در پرداخت: حساب بستانکار (credit) - دارایی کاهش می‌یابد
debit_amount = amount if is_receipt else Decimal(0)
credit_amount = amount if not is_receipt else Decimal(0)
logger.info(f"مقادیر بدهکار/بستانکار برای حساب: debit={debit_amount}, credit={credit_amount}")
# تنظیم bank_account_id بر اساس bank_id ارسالی
bank_account_id = None
if transaction_type == "bank" and account_line.get("bank_id"):
try:
bank_account_id = int(account_line.get("bank_id"))
logger.info(f"bank_account_id تنظیم شد: {bank_account_id}")
except (ValueError, TypeError):
logger.warning(f"خطا در تبدیل bank_id: {account_line.get('bank_id')}")
# تنظیم person_id برای transaction_type="person"
person_id_for_line = None
if transaction_type == "person" and account_line.get("person_id"):
try:
person_id_for_line = int(account_line.get("person_id"))
logger.info(f"person_id تنظیم شد: {person_id_for_line}")
except (ValueError, TypeError):
logger.warning(f"خطا در تبدیل person_id: {account_line.get('person_id')}")
line = DocumentLine(
document_id=document.id,
account_id=account.id,
person_id=person_id_for_line,
bank_account_id=bank_account_id,
cash_register_id=account_line.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id"),
check_id=account_line.get("check_id"),
quantity=account_line.get("quantity"),
debit=debit_amount,
credit=credit_amount,
description=description,
extra_info=extra_info if extra_info else None,
)
logger.info(f"خط سند حساب ایجاد شد: {line}")
db.add(line)
# ایجاد خطوط کارمزد اگر کارمزدی وجود دارد
if total_commission > 0:
logger.info(f"=== ایجاد خطوط کارمزد ===")
logger.info(f"مجموع کارمزد: {total_commission}")
# ایجاد خط کارمزد برای هر تراکنش که کارمزد دارد
for i, account_line in enumerate(account_lines):
commission = account_line.get("commission")
if not commission or Decimal(str(commission)) <= 0:
continue
commission_amount = Decimal(str(commission))
transaction_type = account_line.get("transaction_type")
logger.info(f"ایجاد خط کارمزد برای تراکنش {i+1}: مبلغ={commission_amount}, نوع={transaction_type}")
# تعیین حساب کارمزد بر اساس نوع تراکنش
commission_account = None
commission_account_code = None
if transaction_type == "bank":
commission_account_code = "10203" # بانک
elif transaction_type == "cash_register":
commission_account_code = "10202" # صندوق
elif transaction_type == "petty_cash":
commission_account_code = "10201" # تنخواه گردان
elif transaction_type == "check":
if is_receipt:
commission_account_code = "10403" # اسناد دریافتنی
else:
commission_account_code = "20202" # اسناد پرداختنی
elif transaction_type == "person":
commission_account_code = "20201" # حساب‌های پرداختنی
if commission_account_code:
commission_account = _get_fixed_account_by_code(db, commission_account_code)
logger.info(f"حساب کارمزد پیدا شد: id={commission_account.id}, code={commission_account.code}, name={commission_account.name}")
# ایجاد خط کارمزد برای حساب (بانک/صندوق/تنخواهگردان)
# در دریافت: کارمزد از حساب کم می‌شود (credit)
# در پرداخت: کارمزد به حساب اضافه می‌شود (debit)
commission_debit = commission_amount if not is_receipt else Decimal(0)
commission_credit = commission_amount if is_receipt else Decimal(0)
commission_line = DocumentLine(
document_id=document.id,
account_id=commission_account.id,
bank_account_id=account_line.get("bank_id"),
cash_register_id=account_line.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id"),
check_id=account_line.get("check_id"),
debit=commission_debit,
credit=commission_credit,
description=f"کارمزد تراکنش {transaction_type}",
extra_info={
"transaction_type": transaction_type,
"commission": float(commission_amount),
"is_commission_line": True,
"original_transaction_index": i,
}
)
logger.info(f"خط کارمزد حساب ایجاد شد: {commission_line}")
db.add(commission_line)
# ایجاد خط کارمزد برای حساب کارمزد خدمات بانکی (کد 70902)
# در دریافت: کارمزد به حساب کارمزد اضافه می‌شود (debit)
# در پرداخت: کارمزد از حساب کارمزد کم می‌شود (credit)
logger.info(f"ایجاد خط کارمزد برای حساب کارمزد خدمات بانکی")
# دریافت حساب کارمزد خدمات بانکی
commission_service_account = _get_fixed_account_by_code(db, "70902")
logger.info(f"حساب کارمزد خدمات بانکی پیدا شد: id={commission_service_account.id}, code={commission_service_account.code}, name={commission_service_account.name}")
commission_service_debit = commission_amount if is_receipt else Decimal(0)
commission_service_credit = commission_amount if not is_receipt else Decimal(0)
commission_service_line = DocumentLine(
document_id=document.id,
account_id=commission_service_account.id,
debit=commission_service_debit,
credit=commission_service_credit,
description=f"کارمزد خدمات بانکی",
extra_info={
"commission": float(commission_amount),
"is_commission_line": True,
"original_transaction_index": i,
"commission_type": "banking_service",
}
)
logger.info(f"خط کارمزد خدمات بانکی ایجاد شد: {commission_service_line}")
db.add(commission_service_line)
# ذخیره تغییرات
logger.info(f"=== ذخیره تغییرات ===")
db.commit()
db.refresh(document)
logger.info(f"سند با موفقیت ایجاد شد: id={document.id}, code={document.code}")
return document_to_dict(db, document)
def get_receipt_payment(db: Session, document_id: int) -> Optional[Dict[str, Any]]:
"""دریافت جزئیات یک سند دریافت/پرداخت"""
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
return None
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
return None
return document_to_dict(db, document)
def list_receipts_payments(
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_RECEIPT, DOCUMENT_TYPE_PAYMENT])
)
)
# فیلتر بر اساس سال مالی (از query یا پیش فرض سال جاری)
fiscal_year_id = query.get("fiscal_year_id")
if fiscal_year_id is not None:
try:
fiscal_year_id = int(fiscal_year_id)
except (TypeError, ValueError):
fiscal_year_id = None
if fiscal_year_id is None:
try:
fiscal_year = _get_current_fiscal_year(db, business_id)
fiscal_year_id = fiscal_year.id
except Exception:
fiscal_year_id = None
if fiscal_year_id is not None:
q = q.filter(Document.fiscal_year_id == fiscal_year_id)
# فیلتر بر اساس نوع
doc_type = query.get("document_type")
logger.info(f"فیلتر نوع سند: {doc_type}")
if doc_type:
q = q.filter(Document.document_type == doc_type)
logger.info(f"فیلتر نوع سند اعمال شد: {doc_type}")
else:
logger.info("فیلتر نوع سند اعمال نشد - نمایش همه انواع")
# فیلتر بر اساس تاریخ
from_date = query.get("from_date")
to_date = query.get("to_date")
if from_date:
try:
from_dt = _parse_iso_date(from_date)
q = q.filter(Document.document_date >= from_dt)
logger.info(f"فیلتر تاریخ از: {from_date} -> {from_dt}")
except Exception as e:
logger.warning(f"خطا در پردازش تاریخ از: {from_date}, خطا: {e}")
pass
if to_date:
try:
to_dt = _parse_iso_date(to_date)
q = q.filter(Document.document_date <= to_dt)
logger.info(f"فیلتر تاریخ تا: {to_date} -> {to_dt}")
except Exception as e:
logger.warning(f"خطا در پردازش تاریخ تا: {to_date}, خطا: {e}")
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 = query.get("sort_desc", True)
# بررسی اینکه sort_by معتبر است
if sort_by and 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()
items = q.offset(skip).limit(take).all()
return {
"items": [document_to_dict(db, doc) for doc in items],
"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,
}
def delete_receipt_payment(db: Session, document_id: int) -> bool:
"""حذف سند دریافت/پرداخت"""
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
return False
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
return False
# 1) جلوگیری از حذف در سال مالی غیر جاری
try:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first()
if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True:
raise ApiError(
"FISCAL_YEAR_LOCKED",
"سند متعلق به سال مالی جاری نیست و قابل حذف نمی‌باشد",
http_status=409,
)
except ApiError:
# عبور خطای آگاهانه
raise
except Exception:
# اگر به هر دلیل نتوانستیم وضعیت سال مالی را بررسی کنیم، حذف را متوقف نکن
pass
# 2) جلوگیری از حذف در صورت قفل بودن سند (براساس extra_info یا developer_settings)
try:
locked_flags = []
if isinstance(document.extra_info, dict):
locked_flags.append(bool(document.extra_info.get("locked")))
locked_flags.append(bool(document.extra_info.get("is_locked")))
if isinstance(document.developer_settings, dict):
locked_flags.append(bool(document.developer_settings.get("locked")))
locked_flags.append(bool(document.developer_settings.get("is_locked")))
if any(locked_flags):
raise ApiError(
"DOCUMENT_LOCKED",
"این سند قفل است و قابل حذف نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
# 3) جلوگیری از حذف اگر خطوط سند به چک مرتبط باشند
try:
has_related_checks = db.query(DocumentLine).filter(
and_(
DocumentLine.document_id == document.id,
DocumentLine.check_id.isnot(None),
)
).first() is not None
if has_related_checks:
raise ApiError(
"DOCUMENT_REFERENCED",
"این سند دارای اقلام مرتبط با چک است و قابل حذف نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
db.delete(document)
db.commit()
return True
def update_receipt_payment(
db: Session,
document_id: int,
user_id: int,
data: Dict[str, Any]
) -> Dict[str, Any]:
"""به‌روزرسانی سند دریافت/پرداخت - استراتژی Full-Replace خطوط"""
document = db.query(Document).filter(Document.id == document_id).first()
if document is None:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
raise ApiError("INVALID_DOCUMENT_TYPE", "Invalid document type", http_status=400)
# 1) محدودیت‌های سال مالی/قفل/وابستگی مشابه حذف
try:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first()
if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True:
raise ApiError(
"FISCAL_YEAR_LOCKED",
"سند متعلق به سال مالی جاری نیست و قابل ویرایش نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
try:
locked_flags = []
if isinstance(document.extra_info, dict):
locked_flags.append(bool(document.extra_info.get("locked")))
locked_flags.append(bool(document.extra_info.get("is_locked")))
if isinstance(document.developer_settings, dict):
locked_flags.append(bool(document.developer_settings.get("locked")))
locked_flags.append(bool(document.developer_settings.get("is_locked")))
if any(locked_flags):
raise ApiError(
"DOCUMENT_LOCKED",
"این سند قفل است و قابل ویرایش نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
try:
has_related_checks = db.query(DocumentLine).filter(
and_(
DocumentLine.document_id == document.id,
DocumentLine.check_id.isnot(None),
)
).first() is not None
if has_related_checks:
raise ApiError(
"DOCUMENT_REFERENCED",
"این سند دارای اقلام مرتبط با چک است و قابل ویرایش نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
# 2) اعتبارسنجی ورودی‌ها (مشابه create)
document_date = _parse_iso_date(data.get("document_date", document.document_date))
currency_id = data.get("currency_id", document.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)
person_lines = data.get("person_lines", [])
account_lines = data.get("account_lines", [])
if not isinstance(person_lines, list) or not person_lines:
raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400)
if not isinstance(account_lines, list) or not account_lines:
raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400)
person_total = sum(float(line.get("amount", 0)) for line in person_lines)
account_total = sum(float(line.get("amount", 0)) for line in account_lines)
if abs(person_total - account_total) > 0.01:
raise ApiError("UNBALANCED_AMOUNTS", "Totals must be balanced", http_status=400)
# 3) اعمال تغییرات در سند (بدون تغییر code و document_type)
document.document_date = document_date
document.currency_id = int(currency_id)
if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None:
document.extra_info = data.get("extra_info")
if isinstance(data.get("description"), str) or data.get("description") is None:
document.description = data.get("description")
# تعیین نوع دریافت/پرداخت برای محاسبات بدهکار/بستانکار
is_receipt = (document.document_type == DOCUMENT_TYPE_RECEIPT)
# حذف خطوط فعلی و ایجاد مجدد
db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False)
# خطوط شخص
for person_line in person_lines:
person_id = person_line.get("person_id")
if not person_id:
continue
amount = Decimal(str(person_line.get("amount", 0)))
if amount <= 0:
continue
description = (person_line.get("description") or "").strip() or None
person_account = _get_person_account(db, document.business_id, int(person_id), is_receivable=is_receipt)
debit_amount = amount if not is_receipt else Decimal(0)
credit_amount = amount if is_receipt else Decimal(0)
line = DocumentLine(
document_id=document.id,
account_id=person_account.id,
person_id=int(person_id),
quantity=person_line.get("quantity"),
debit=debit_amount,
credit=credit_amount,
description=description,
extra_info={
"person_id": int(person_id),
"person_name": person_line.get("person_name"),
},
)
db.add(line)
# خطوط حساب‌ها + کارمزدها
total_commission = Decimal(0)
for i, account_line in enumerate(account_lines):
amount = Decimal(str(account_line.get("amount", 0)))
if amount <= 0:
continue
description = (account_line.get("description") or "").strip() or None
transaction_type = account_line.get("transaction_type")
transaction_date = account_line.get("transaction_date")
commission = account_line.get("commission")
if commission:
total_commission += Decimal(str(commission))
# انتخاب حساب بر اساس transaction_type یا account_id
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_receipt else "20202")
elif transaction_type == "person":
account = _get_fixed_account_by_code(db, "20201")
elif account_line.get("account_id"):
account = db.query(Account).filter(
and_(
Account.id == int(account_line.get("account_id")),
or_(Account.business_id == document.business_id, Account.business_id == None),
)
).first()
if not account:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found for transaction_type", http_status=404)
extra_info: Dict[str, Any] = {}
if transaction_type:
extra_info["transaction_type"] = transaction_type
if transaction_date:
extra_info["transaction_date"] = transaction_date
if commission:
extra_info["commission"] = float(commission)
if transaction_type == "bank":
if account_line.get("bank_id"):
extra_info["bank_id"] = account_line.get("bank_id")
if account_line.get("bank_name"):
extra_info["bank_name"] = account_line.get("bank_name")
elif transaction_type == "cash_register":
if account_line.get("cash_register_id"):
extra_info["cash_register_id"] = account_line.get("cash_register_id")
if account_line.get("cash_register_name"):
extra_info["cash_register_name"] = account_line.get("cash_register_name")
elif transaction_type == "petty_cash":
if account_line.get("petty_cash_id"):
extra_info["petty_cash_id"] = account_line.get("petty_cash_id")
if account_line.get("petty_cash_name"):
extra_info["petty_cash_name"] = account_line.get("petty_cash_name")
elif transaction_type == "check":
if account_line.get("check_id"):
extra_info["check_id"] = account_line.get("check_id")
if account_line.get("check_number"):
extra_info["check_number"] = account_line.get("check_number")
debit_amount = amount if is_receipt else Decimal(0)
credit_amount = amount if not is_receipt else Decimal(0)
bank_account_id = None
if transaction_type == "bank" and account_line.get("bank_id"):
try:
bank_account_id = int(account_line.get("bank_id"))
except Exception:
bank_account_id = None
person_id_for_line = None
if transaction_type == "person" and account_line.get("person_id"):
try:
person_id_for_line = int(account_line.get("person_id"))
except Exception:
person_id_for_line = None
line = DocumentLine(
document_id=document.id,
account_id=account.id,
person_id=person_id_for_line,
bank_account_id=bank_account_id,
cash_register_id=account_line.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id"),
check_id=account_line.get("check_id"),
quantity=account_line.get("quantity"),
debit=debit_amount,
credit=credit_amount,
description=description,
extra_info=extra_info if extra_info else None,
)
db.add(line)
# خطوط کارمزد
if total_commission > 0:
for i, account_line in enumerate(account_lines):
commission = account_line.get("commission")
if not commission or Decimal(str(commission)) <= 0:
continue
commission_amount = Decimal(str(commission))
transaction_type = account_line.get("transaction_type")
commission_account_code = None
if transaction_type == "bank":
commission_account_code = "10203"
elif transaction_type == "cash_register":
commission_account_code = "10202"
elif transaction_type == "petty_cash":
commission_account_code = "10201"
elif transaction_type == "check":
commission_account_code = "10403" if is_receipt else "20202"
elif transaction_type == "person":
commission_account_code = "20201"
if commission_account_code:
commission_account = _get_fixed_account_by_code(db, commission_account_code)
commission_debit = commission_amount if not is_receipt else Decimal(0)
commission_credit = commission_amount if is_receipt else Decimal(0)
db.add(DocumentLine(
document_id=document.id,
account_id=commission_account.id,
bank_account_id=account_line.get("bank_id"),
cash_register_id=account_line.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id"),
check_id=account_line.get("check_id"),
debit=commission_debit,
credit=commission_credit,
description=f"کارمزد تراکنش {transaction_type}",
extra_info={
"transaction_type": transaction_type,
"commission": float(commission_amount),
"is_commission_line": True,
"original_transaction_index": i,
},
))
commission_service_account = _get_fixed_account_by_code(db, "70902")
commission_service_debit = commission_amount if is_receipt else Decimal(0)
commission_service_credit = commission_amount if not is_receipt else Decimal(0)
db.add(DocumentLine(
document_id=document.id,
account_id=commission_service_account.id,
debit=commission_service_debit,
credit=commission_service_credit,
description="کارمزد خدمات بانکی",
extra_info={
"commission": float(commission_amount),
"is_commission_line": True,
"original_transaction_index": i,
"commission_type": "banking_service",
},
))
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()
# جداسازی خطوط اشخاص و حساب‌ها
person_lines = []
account_lines = []
for line in lines:
account = db.query(Account).filter(Account.id == line.account_id).first()
if not account:
continue
line_dict = {
"id": line.id,
"account_id": line.account_id,
"person_id": line.person_id,
"product_id": line.product_id,
"bank_account_id": line.bank_account_id,
"cash_register_id": line.cash_register_id,
"petty_cash_id": line.petty_cash_id,
"check_id": line.check_id,
"quantity": float(line.quantity) if line.quantity else None,
"account_name": account.name,
"account_code": account.code,
"account_type": account.account_type,
"debit": float(line.debit),
"credit": float(line.credit),
"amount": float(line.debit if line.debit > 0 else line.credit),
"description": line.description,
"extra_info": line.extra_info,
}
# اضافه کردن اطلاعات اضافی از extra_info
if line.extra_info:
if "transaction_type" in line.extra_info:
line_dict["transaction_type"] = line.extra_info["transaction_type"]
if "transaction_date" in line.extra_info:
line_dict["transaction_date"] = line.extra_info["transaction_date"]
if "commission" in line.extra_info:
line_dict["commission"] = line.extra_info["commission"]
if "bank_id" in line.extra_info:
line_dict["bank_id"] = line.extra_info["bank_id"]
if "bank_name" in line.extra_info:
line_dict["bank_name"] = line.extra_info["bank_name"]
if "cash_register_id" in line.extra_info:
line_dict["cash_register_id"] = line.extra_info["cash_register_id"]
if "cash_register_name" in line.extra_info:
line_dict["cash_register_name"] = line.extra_info["cash_register_name"]
if "petty_cash_id" in line.extra_info:
line_dict["petty_cash_id"] = line.extra_info["petty_cash_id"]
if "petty_cash_name" in line.extra_info:
line_dict["petty_cash_name"] = line.extra_info["petty_cash_name"]
if "check_id" in line.extra_info:
line_dict["check_id"] = line.extra_info["check_id"]
if "check_number" in line.extra_info:
line_dict["check_number"] = line.extra_info["check_number"]
if "person_name" in line.extra_info:
line_dict["person_name"] = line.extra_info["person_name"]
# اگر person_id موجود است، نام شخص را از دیتابیس دریافت کن
if line.person_id and "person_name" not in line_dict:
person = db.query(Person).filter(Person.id == line.person_id).first()
if person:
line_dict["person_name"] = person.alias_name or f"{person.first_name} {person.last_name}".strip()
else:
line_dict["person_name"] = "نامشخص"
# تشخیص اینکه آیا این خط مربوط به شخص است یا حساب
# خطوط کارمزد را جداگانه تشخیص می‌دهیم
is_commission_line = line.extra_info and line.extra_info.get("is_commission_line", False)
if is_commission_line:
# خط کارمزد - همیشه در account_lines قرار می‌گیرد
account_lines.append(line_dict)
elif line.extra_info and line.extra_info.get("person_id"):
person_lines.append(line_dict)
else:
account_lines.append(line_dict)
# دریافت اطلاعات کاربر ایجادکننده
created_by = db.query(User).filter(User.id == document.created_by_user_id).first()
created_by_name = f"{created_by.first_name} {created_by.last_name}".strip() if created_by else None
# دریافت اطلاعات ارز
currency = db.query(Currency).filter(Currency.id == document.currency_id).first()
currency_code = currency.code if currency else None
# محاسبه مبلغ کل و تعداد خطوط
total_amount = sum(line.get("amount", 0) for line in person_lines)
person_lines_count = len(person_lines)
account_lines_count = len(account_lines)
# ایجاد لیست نام اشخاص برای نمایش
person_names = []
for line in person_lines:
person_name = line.get("person_name")
if person_name and person_name not in person_names:
person_names.append(person_name)
person_names_str = ", ".join(person_names) if person_names else "نامشخص"
# تعیین نام نوع سند
document_type_name = "دریافت" if document.document_type == DOCUMENT_TYPE_RECEIPT else "پرداخت"
return {
"id": document.id,
"code": document.code,
"business_id": document.business_id,
"document_type": document.document_type,
"document_type_name": document_type_name,
"document_date": document.document_date.isoformat(),
"registered_at": document.registered_at.isoformat(),
"currency_id": document.currency_id,
"currency_code": currency_code,
"created_by_user_id": document.created_by_user_id,
"created_by_name": created_by_name,
"is_proforma": document.is_proforma,
"description": document.description,
"extra_info": document.extra_info,
"person_lines": person_lines,
"account_lines": account_lines,
"total_amount": total_amount,
"person_lines_count": person_lines_count,
"account_lines_count": account_lines_count,
"person_names": person_names_str,
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
}