arc/hesabixAPI/app/services/receipt_payment_service.py

2835 lines
139 KiB
Python
Executable file
Raw Permalink 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.

This file contains Unicode characters that might be confused with other characters. 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, Tuple
from datetime import datetime, date
from decimal import Decimal
import logging
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy import and_, or_, func, exists
from sqlalchemy.exc import IntegrityError
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.check import Check, CheckType, CheckStatus
from adapters.db.models.user import User
from adapters.db.models.fiscal_year import FiscalYear
from app.core.responses import ApiError
from app.services.document_monetization_service import ensure_document_policy_allows_creation
from app.services.document_numbering_service import generate_document_code
import jdatetime
# تنظیم لاگر
logger = logging.getLogger(__name__)
def _invoice_account_detail_ids(db: Session, business_id: int, account_line: Dict[str, Any]) -> Dict[str, Optional[int]]:
"""Resolve and tenant-check optional details attached to an invoice account payment."""
from adapters.db.models.product import Product
from adapters.db.models.bank_account import BankAccount
from adapters.db.models.cash_register import CashRegister
from adapters.db.models.petty_cash import PettyCash
models = {
"person_id": Person,
"product_id": Product,
"bank_account_id": BankAccount,
"cash_register_id": CashRegister,
"petty_cash_id": PettyCash,
"check_id": Check,
}
resolved: Dict[str, Optional[int]] = {key: None for key in models}
supplied = [(key, account_line.get(key)) for key in models if account_line.get(key) is not None]
if len(supplied) > 1:
raise ApiError("INVALID_ACCOUNT_DETAIL", "Only one account detail may be selected", http_status=400)
for key, raw_id in supplied:
try:
detail_id = int(raw_id)
except (TypeError, ValueError):
raise ApiError("INVALID_ACCOUNT_DETAIL", "Invalid account detail ID", http_status=400)
detail = db.query(models[key]).filter(
models[key].id == detail_id,
models[key].business_id == business_id,
).first()
if detail is None:
raise ApiError("INVALID_ACCOUNT_DETAIL", "Account detail was not found for this business", http_status=400)
resolved[key] = detail_id
return resolved
# نوع‌های سند
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" # اسناد پرداختنی (چک پرداختی)
ALLOWED_RECEIPT_CHECK_STATUSES = {CheckStatus.RECEIVED_ON_HAND}
ALLOWED_PAYMENT_CHECK_STATUSES = {CheckStatus.TRANSFERRED_ISSUED}
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 resolve_receipt_document_date_from_account_lines(
default_date: date,
account_lines: List[Dict[str, Any]],
) -> date:
"""تاریخ سند دریافت/پرداخت: بیشینهٔ تاریخ پیش‌فرض و transaction_date خطوط حساب."""
dates: List[date] = [default_date]
for line in account_lines or []:
raw = line.get("transaction_date")
if raw is None:
continue
try:
dates.append(_parse_iso_date(raw))
except ApiError:
continue
return max(dates)
def _link_receipt_payment_to_invoices(
db: Session,
receipt_payment_document_id: int,
person_lines: List[Dict[str, Any]],
extra_info: Optional[Dict[str, Any]] = None,
) -> None:
"""لینک دوطرفه سند دریافت/پرداخت به فاکتور(ها) از person_lines، extra_info و settlements."""
from app.services.legacy_import.invoice_settlement import append_receipt_payment_to_invoice
invoice_ids: set[int] = set()
for pl in person_lines or []:
pl_extra = pl.get("extra_info") if isinstance(pl.get("extra_info"), dict) else {}
inv_id = pl_extra.get("invoice_id")
if inv_id is None:
continue
try:
invoice_ids.add(int(inv_id))
except (TypeError, ValueError):
continue
if isinstance(extra_info, dict):
doc_inv = extra_info.get("invoice_id")
if doc_inv is not None:
try:
invoice_ids.add(int(doc_inv))
except (TypeError, ValueError):
pass
for st in extra_info.get("settlements") or []:
if not isinstance(st, dict):
continue
st_inv = st.get("invoice_id")
if st_inv is None:
continue
try:
invoice_ids.add(int(st_inv))
except (TypeError, ValueError):
continue
for inv_id in invoice_ids:
append_receipt_payment_to_invoice(db, inv_id, int(receipt_payment_document_id))
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 _validate_check_transaction_line(
db: Session,
*,
business_id: int,
account_line: Dict[str, Any],
is_receipt: bool,
document_currency_id: int,
amount_decimal: Decimal,
exclude_document_id: Optional[int] = None,
) -> Tuple[Check, int]:
"""اعتبارسنجی الزامات انتخاب چک برای سطر دریافت/پرداخت"""
check_id_raw = account_line.get("check_id")
if not check_id_raw:
raise ApiError(
"CHECK_ID_REQUIRED",
"برای تراکنش از نوع چک، انتخاب چک الزامی است",
http_status=400,
)
try:
check_id = int(check_id_raw)
except Exception:
raise ApiError("CHECK_ID_INVALID", "شناسه چک نامعتبر است", http_status=400)
check = (
db.query(Check)
.filter(
and_(
Check.id == check_id,
Check.business_id == business_id,
)
)
.first()
)
if not check:
raise ApiError("CHECK_NOT_FOUND", "چک انتخاب‌شده یافت نشد", http_status=404)
expected_type = CheckType.RECEIVED if is_receipt else CheckType.TRANSFERRED
if check.type != expected_type:
raise ApiError(
"CHECK_TYPE_MISMATCH",
"نوع چک با نوع سند هم‌خوانی ندارد",
http_status=400,
)
allowed_statuses = ALLOWED_RECEIPT_CHECK_STATUSES if is_receipt else ALLOWED_PAYMENT_CHECK_STATUSES
if check.status not in allowed_statuses:
raise ApiError(
"CHECK_STATUS_INVALID",
"وضعیت فعلی چک اجازه استفاده در این سند را نمی‌دهد",
http_status=409,
)
if int(check.currency_id) != int(document_currency_id):
# بین‌ارزی (E2): سند پایه است؛ ارز بومی چک در account_currency_id می‌آید
native_cur = account_line.get("account_currency_id")
try:
native_cur_i = int(native_cur) if native_cur is not None else None
except Exception:
native_cur_i = None
if native_cur_i is None or int(check.currency_id) != native_cur_i:
raise ApiError(
"CHECK_CURRENCY_MISMATCH",
"ارز چک با ارز سند دریافت/پرداخت یکسان نیست",
http_status=400,
)
compare_amount = amount_decimal
native_amt = account_line.get("account_currency_amount")
if native_amt is not None:
try:
compare_amount = Decimal(str(native_amt))
except Exception:
compare_amount = amount_decimal
if compare_amount != Decimal(str(check.amount)):
raise ApiError(
"CHECK_AMOUNT_MISMATCH",
"مبلغ تراکنش با مبلغ ثبت‌شده چک برابر نیست",
http_status=400,
)
existing_q = (
db.query(DocumentLine)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
DocumentLine.check_id == check_id,
Document.document_type.in_([DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT]),
)
)
if exclude_document_id:
existing_q = existing_q.filter(Document.id != exclude_document_id)
if existing_q.first():
raise ApiError(
"CHECK_ALREADY_LINKED",
"این چک قبلاً در سند دریافت/پرداخت دیگری استفاده شده است",
http_status=409,
)
return check, check_id
def create_receipt_payment(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
*,
commit: bool = True,
) -> 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: اطلاعات اضافی (اختیاری)
commit: اگر False باشد، فقط flush می‌شود (مثلاً داخل تراکنش بزرگ‌تر مانند ویرایش فاکتور)
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}")
# V2-P4: بازنویسی بین‌ارزی مستقل به ارز پایه (اگر لازم)
try:
from app.services.cross_currency_settlement_service import (
prepare_standalone_receipt_payment_cross_currency,
)
data = prepare_standalone_receipt_payment_cross_currency(
db, business_id, data, is_receipt=is_receipt
)
except ApiError:
raise
except Exception:
logger.exception("prepare standalone cross-currency failed")
# امکان override نوع حساب طرف‌شخص (دریافتنی/پرداختنی) برای سناریوهایی مثل برگشت فاکتور
extra_info_all = data.get("extra_info") or {}
person_is_receivable_override = extra_info_all.get("person_is_receivable")
if isinstance(person_is_receivable_override, bool):
is_person_receivable = person_is_receivable_override
else:
# پیش‌فرض: دریافت → دریافتنی، پرداخت → پرداختنی
is_person_receivable = is_receipt
# اعتبارسنجی تاریخ (پس از خواندن account_lines)
parsed_doc_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)
currency_id = int(currency_id)
# دریافت سال مالی فعلی
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)
document_date = resolve_receipt_document_date_from_account_lines(parsed_doc_date, account_lines)
# محاسبه مجموع مبالغ
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)
fx_adjustment_lines = data.get("fx_adjustment_lines") or []
fx_adj_debit = sum(
float(x.get("amount", 0) or 0)
for x in fx_adjustment_lines
if str(x.get("side") or "").lower() == "debit"
)
fx_adj_credit = sum(
float(x.get("amount", 0) or 0)
for x in fx_adjustment_lines
if str(x.get("side") or "").lower() == "credit"
)
# تعادل: مجموع بدهکار = مجموع بستانکار
# دریافت: شخص credit، حساب debit، FX debit/credit
# پرداخت: شخص debit، حساب credit، FX debit/credit
if is_receipt:
left = account_total + fx_adj_debit
right = person_total + fx_adj_credit
else:
left = person_total + fx_adj_debit
right = account_total + fx_adj_credit
if abs(left - right) > 0.01:
raise ApiError(
"UNBALANCED_AMOUNTS",
f"مبالغ سند نامتوازن است (چپ={left}, راست={right})",
http_status=400,
)
amount_decimal = Decimal(str(person_total))
ensure_document_policy_allows_creation(
db,
business_id,
document_type=document_type,
document_date=document_date,
amount=amount_decimal,
)
# دریافت project_id (اختیاری)
project_id = data.get("project_id")
if project_id:
# اعتبارسنجی پروژه
from adapters.db.models.project import Project
project = db.query(Project).filter(
and_(Project.id == project_id, Project.business_id == business_id, Project.is_active == True)
).first()
if not project:
raise ApiError("PROJECT_NOT_FOUND", "پروژه یافت نشد یا غیرفعال است", http_status=404)
# ایجاد سند (شماره بر اساس تنظیمات شماره‌گذاری کسب‌وکار)
document: Optional[Document] = None
max_code_attempts = 5
for _attempt in range(max_code_attempts):
doc_code = generate_document_code(db, business_id, document_type, document_date)
candidate = 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"),
project_id=project_id,
)
try:
with db.begin_nested():
db.add(candidate)
db.flush()
except IntegrityError as exc:
msg = str(getattr(exc.orig, "args", exc))
if "uq_documents_business_code" in msg or "Duplicate entry" in msg:
continue
raise
else:
document = candidate
break
if not document:
raise ApiError(
"DOCUMENT_CODE_RACE",
"تولید شماره سند پس از چند تلاش ناموفق بود. لطفاً دوباره تلاش کنید.",
http_status=409,
)
# --- اعتبارسنجی اقساط قبل از اعمال (سختگیرانه) ---
extra_info_all = data.get("extra_info") or {}
settlements_input = extra_info_all.get("settlements") or []
if settlements_input:
# فقط روی دریافت مجاز است
if not is_receipt:
raise ApiError(
"INSTALLMENT_NOT_ALLOWED_FOR_PAYMENT",
"تخصیص اقساط فقط در اسناد دریافت مجاز است",
http_status=400,
)
# نقشه مبلغ خطوط هر شخص
person_amount_by_id: Dict[int, Decimal] = {}
for pl in person_lines:
try:
pid = int(pl.get("person_id"))
amt = Decimal(str(pl.get("amount", 0) or 0))
if pid and amt > 0:
person_amount_by_id[pid] = person_amount_by_id.get(pid, Decimal(0)) + amt
except Exception:
continue
# مجموع تخصیص اقساط به ازای هر شخص
allocated_by_person: Dict[int, Decimal] = {}
for st in settlements_input:
# الزام person_id در هر settlement برای تطبیق با خطوط شخص
try:
st_person_id = int(st.get("person_id"))
except Exception:
raise ApiError(
"INSTALLMENT_PERSON_REQUIRED",
"برای هر تخصیص قسط، person_id الزامی است",
http_status=400,
)
if st_person_id not in person_amount_by_id:
raise ApiError(
"INSTALLMENT_PERSON_MISMATCH",
"شخص تخصیص قسط باید در خطوط اشخاص سند وجود داشته باشد",
http_status=400,
)
# اعتبارسنجی فاکتور و ارز
try:
invoice_id = int(st.get("invoice_id"))
except Exception:
raise ApiError("INSTALLMENT_INVOICE_INVALID", "شناسه فاکتور اقساط نامعتبر است", http_status=400)
invoice_doc = db.query(Document).filter(Document.id == invoice_id).first()
if not invoice_doc:
raise ApiError("INSTALLMENT_INVOICE_NOT_FOUND", "فاکتور اقساط پیدا نشد", http_status=404)
if int(invoice_doc.currency_id) != int(currency_id):
# V2-P4: پس از بازنویسی بین‌ارزی، ارز سند = پایه؛ ارز تسویه در extra_info است
settles_cur = None
if isinstance(extra_info_all, dict):
try:
settles_cur = int(extra_info_all.get("settles_currency_id")) if extra_info_all.get("settles_currency_id") is not None else None
except Exception:
settles_cur = None
if settles_cur is None or int(invoice_doc.currency_id) != int(settles_cur):
raise ApiError(
"INSTALLMENT_CURRENCY_MISMATCH",
"ارز فاکتور اقساط با ارز سند دریافت یکسان نیست",
http_status=400,
)
inv_extra = invoice_doc.extra_info or {}
plan = inv_extra.get("installment_plan")
schedule = (plan or {}).get("schedule")
if not (isinstance(plan, dict) and isinstance(schedule, list) and schedule):
raise ApiError(
"INSTALLMENT_PLAN_NOT_FOUND",
"برای فاکتور انتخاب‌شده، طرح اقساط ثبت نشده است",
http_status=404,
)
# بررسی عدم بیش‌پرداخت در هر قسط
remaining_by_seq: Dict[int, Decimal] = {}
for item in schedule:
try:
seq_i = int(item.get("seq") or 0)
total_i = Decimal(str(item.get("total", 0) or 0))
paid_i = Decimal(str(item.get("paid_amount", 0) or 0))
remaining_by_seq[seq_i] = max(total_i - paid_i, Decimal(0))
except Exception:
continue
allocations = st.get("allocations") or []
if not isinstance(allocations, list) or not allocations:
raise ApiError("INSTALLMENT_ALLOCATIONS_REQUIRED", "لیست تخصیص اقساط خالی است", http_status=400)
for al in allocations:
try:
seq = int(al.get("seq"))
amt = Decimal(str(al.get("amount", 0) or 0))
except Exception:
raise ApiError("INSTALLMENT_ALLOCATION_INVALID", "تخصیص قسط نامعتبر است", http_status=400)
if seq <= 0 or amt <= 0:
raise ApiError("INSTALLMENT_ALLOCATION_INVALID", "تخصیص قسط نامعتبر است", http_status=400)
remain = remaining_by_seq.get(seq, None)
if remain is None:
raise ApiError("INSTALLMENT_SEQ_NOT_FOUND", "شماره قسط در طرح اقساط یافت نشد", http_status=404)
if amt > remain:
raise ApiError(
"INSTALLMENT_OVERPAY_PER_SEQ",
"مبلغ تخصیص از مانده همان قسط بیشتر است",
http_status=400,
)
allocated_by_person[st_person_id] = allocated_by_person.get(st_person_id, Decimal(0)) + amt
# کنترل سقف مجموع تخصیص هر شخص نسبت به مبلغ خطوط همان شخص
for pid, alloc_sum in allocated_by_person.items():
if alloc_sum > person_amount_by_id.get(pid, Decimal(0)):
raise ApiError(
"INSTALLMENT_OVER_PERSON_LINES",
"جمع تخصیص اقساط یک شخص از مجموع مبلغ خطوط همان شخص بیشتر است",
http_status=400,
)
# ایجاد خطوط سند برای اشخاص
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 که ممکن است None باشد
description_raw = person_line.get("description")
description = None
if description_raw:
if isinstance(description_raw, str):
description = description_raw.strip() or None
else:
description = str(description_raw).strip() or None
logger.info(f"توضیحات: {description}")
# دریافت حساب شخص عمومی
# در حالت عادی: دریافت → حساب دریافتنی (10401)، پرداخت → حساب پرداختنی (20201)
# در صورت وجود person_is_receivable در extra_info، از همان تبعیت می‌کند
logger.info(f"دریافت حساب شخص برای person_id={person_id}, is_receivable={is_person_receivable}")
person_account = _get_person_account(
db,
business_id,
int(person_id),
is_receivable=is_person_receivable,
)
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}")
# ساخت extra_info برای خط شخص
line_extra_info = {
"person_id": int(person_id),
"person_name": person_line.get("person_name"),
}
# اضافه کردن اطلاعات فاکتور از extra_info شخص
person_line_extra = person_line.get("extra_info")
if isinstance(person_line_extra, dict):
if person_line_extra.get("invoice_id"):
line_extra_info["invoice_id"] = person_line_extra["invoice_id"]
if person_line_extra.get("invoice_code"):
line_extra_info["invoice_code"] = person_line_extra["invoice_code"]
if person_line_extra.get("link_to_invoice"):
line_extra_info["link_to_invoice"] = person_line_extra["link_to_invoice"]
if person_line_extra.get("fx_settlement"):
line_extra_info["fx_settlement"] = person_line_extra["fx_settlement"]
if person_line_extra.get("side"):
line_extra_info["side"] = person_line_extra["side"]
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=line_extra_info
)
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}")
amount = Decimal(str(account_line.get("amount", 0)))
logger.info(f"مبلغ: {amount}")
if amount <= 0:
logger.warning(f"خط حساب {i+1}: مبلغ صفر یا منفی، رد می‌شود")
continue
# مدیریت description که ممکن است None باشد
description_raw = account_line.get("description")
description = None
if description_raw:
if isinstance(description_raw, str):
description = description_raw.strip() or None
else:
description = str(description_raw).strip() or None
transaction_type = account_line.get("transaction_type")
normalized_transaction_type = transaction_type
if transaction_type == "check_expense":
normalized_transaction_type = "check"
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
check_id_val: Optional[int] = None
if normalized_transaction_type == "check":
check_obj, check_id_val = _validate_check_transaction_line(
db,
business_id=business_id,
account_line=account_line,
is_receipt=is_receipt,
document_currency_id=currency_id,
amount_decimal=amount,
)
account_line["check_id"] = check_id_val
if not account_line.get("check_number"):
account_line["check_number"] = check_obj.check_number
if normalized_transaction_type == "bank":
# برای بانک، از حساب بانک استفاده کن
account_code = "10203" # بانک
logger.info(f"انتخاب حساب بانک با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif normalized_transaction_type == "cash_register":
# برای صندوق، از حساب صندوق استفاده کن
account_code = "10202" # صندوق
logger.info(f"انتخاب حساب صندوق با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif normalized_transaction_type == "petty_cash":
# برای تنخواهگردان، از حساب تنخواهگردان استفاده کن
account_code = "10201" # تنخواه گردان
logger.info(f"انتخاب حساب تنخواهگردان با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif normalized_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 normalized_transaction_type == "person":
# برای شخص، از حساب شخص عمومی استفاده کن
account_code = "20201" # حساب‌های پرداختنی
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif normalized_transaction_type == "wallet":
# کیف‌پول (حساب نزد پرداخت‌یار)
account_code = "10204"
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:
# اگر transaction_type موجود نباشد، از account_id استفاده کن
if not transaction_type and account_id:
logger.warning(f"خط حساب {i+1}: transaction_type موجود نیست اما account_id موجود است: {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}, account_id: {account_id}")
raise ApiError(
"ACCOUNT_NOT_FOUND",
f"Account not found for transaction_type: {transaction_type}, account_id: {account_id}",
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 normalized_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 normalized_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 normalized_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 normalized_transaction_type == "check":
if account_line.get("check_id"):
extra_info["check_id"] = int(account_line.get("check_id"))
if account_line.get("check_number"):
extra_info["check_number"] = account_line.get("check_number")
# مانده بومی حساب (بین‌ارزی E2: مبلغ خط = پایه)
if account_line.get("account_currency_amount") is not None:
extra_info["account_currency_amount"] = str(account_line.get("account_currency_amount"))
if account_line.get("account_currency_id") is not None:
try:
extra_info["account_currency_id"] = int(account_line.get("account_currency_id"))
except Exception:
pass
# برای چک: منطق حسابداری متفاوت است
# در دریافت با چک دریافتی: چک از 10403 خارج می‌شود → باید بستانکار شود
# در پرداخت با چک پرداختی: چک از 20202 خارج می‌شود → باید بدهکار شود
if normalized_transaction_type == "check":
# برای چک دریافتی در سند دریافت: حساب 10403 باید بستانکار شود (چک خارج شد)
# برای چک پرداختی در سند پرداخت: حساب 20202 باید بدهکار شود (چک پرداخت شد)
if is_receipt:
# دریافت با چک دریافتی: حساب 10403 بستانکار می‌شود
debit_amount = Decimal(0)
credit_amount = amount
else:
# پرداخت با چک پرداختی: حساب 20202 بدهکار می‌شود
debit_amount = amount
credit_amount = Decimal(0)
else:
# برای سایر حساب‌ها (بانک، صندوق، ...): منطق عادی
# در دریافت: حساب بدهکار (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')}")
resolved_check_id = check_id_val
if resolved_check_id is None and account_line.get("check_id"):
try:
resolved_check_id = int(account_line.get("check_id"))
except Exception:
resolved_check_id = None
detail_ids = _invoice_account_detail_ids(db, business_id, account_line) if transaction_type == "account" else {}
if transaction_type == "account" and account_line.get("detail_name"):
extra_info["detail_name"] = str(account_line["detail_name"])
if transaction_type == "account" and account_line.get("detail_type"):
extra_info["detail_type"] = str(account_line["detail_type"])
line = DocumentLine(
document_id=document.id,
account_id=account.id,
person_id=person_id_for_line or detail_ids.get("person_id"),
product_id=detail_ids.get("product_id"),
bank_account_id=bank_account_id or detail_ids.get("bank_account_id"),
cash_register_id=account_line.get("cash_register_id") or detail_ids.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id") or detail_ids.get("petty_cash_id"),
check_id=resolved_check_id or detail_ids.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 normalized_transaction_type == "check" and is_receipt:
# چک دریافتی در سند دریافت: چک از 10403 خارج می‌شود و وجه به بانک/صندوق سپرده می‌شود
# ایجاد خط اضافی برای بانک/صندوق (بدهکار)
# بررسی اینکه چک به چه حسابی سپرده شد
target_account_type = account_line.get("target_account_type") # bank, cash_register, petty_cash
target_bank_id = account_line.get("bank_id")
target_cash_register_id = account_line.get("cash_register_id")
target_petty_cash_id = account_line.get("petty_cash_id")
# تعیین حساب مقصد
target_account = None
target_account_code = None
target_bank_account_id = None
if target_account_type == "bank" or target_bank_id:
target_account_code = "10203" # بانک
target_account = _get_fixed_account_by_code(db, target_account_code)
if target_bank_id:
target_bank_account_id = int(target_bank_id)
elif target_account_type == "cash_register" or target_cash_register_id:
target_account_code = "10202" # صندوق
target_account = _get_fixed_account_by_code(db, target_account_code)
elif target_account_type == "petty_cash" or target_petty_cash_id:
target_account_code = "10201" # تنخواه گردان
target_account = _get_fixed_account_by_code(db, target_account_code)
else:
# پیش‌فرض: بانک (اگر مشخص نشده باشد)
target_account_code = "10203" # بانک
target_account = _get_fixed_account_by_code(db, target_account_code)
if target_account:
# ایجاد خط برای بانک/صندوق (بدهکار)
target_line_extra_info = {
"transaction_type": "bank" if target_account_code == "10203" else ("cash_register" if target_account_code == "10202" else "petty_cash"),
"check_id": resolved_check_id,
"is_check_target_account": True,
}
if target_bank_id:
target_line_extra_info["bank_id"] = target_bank_id
target_line = DocumentLine(
document_id=document.id,
account_id=target_account.id,
bank_account_id=target_bank_account_id,
check_id=resolved_check_id,
debit=amount, # بانک/صندوق بدهکار می‌شود (وجه دریافت شد)
credit=Decimal(0),
description=description or f"سپرده چک به {target_account.name}",
extra_info=target_line_extra_info,
)
logger.info(f"خط اضافی برای حساب مقصد چک ایجاد شد: {target_line}")
db.add(target_line)
elif normalized_transaction_type == "check" and not is_receipt:
# چک پرداختی در سند پرداخت: چک از 20202 خارج می‌شود و وجه از بانک/صندوق پرداخت می‌شود
# ایجاد خط اضافی برای بانک/صندوق (بستانکار)
# بررسی اینکه چک از چه حسابی پرداخت شد
source_account_type = account_line.get("source_account_type") # bank, cash_register, petty_cash
source_bank_id = account_line.get("bank_id")
source_cash_register_id = account_line.get("cash_register_id")
source_petty_cash_id = account_line.get("petty_cash_id")
# تعیین حساب مبدا
source_account = None
source_account_code = None
source_bank_account_id = None
if source_account_type == "bank" or source_bank_id:
source_account_code = "10203" # بانک
source_account = _get_fixed_account_by_code(db, source_account_code)
if source_bank_id:
source_bank_account_id = int(source_bank_id)
elif source_account_type == "cash_register" or source_cash_register_id:
source_account_code = "10202" # صندوق
source_account = _get_fixed_account_by_code(db, source_account_code)
elif source_account_type == "petty_cash" or source_petty_cash_id:
source_account_code = "10201" # تنخواه گردان
source_account = _get_fixed_account_by_code(db, source_account_code)
else:
# پیش‌فرض: بانک (اگر مشخص نشده باشد)
source_account_code = "10203" # بانک
source_account = _get_fixed_account_by_code(db, source_account_code)
if source_account:
# ایجاد خط برای بانک/صندوق (بستانکار)
source_line_extra_info = {
"transaction_type": "bank" if source_account_code == "10203" else ("cash_register" if source_account_code == "10202" else "petty_cash"),
"check_id": resolved_check_id,
"is_check_source_account": True,
}
if source_bank_id:
source_line_extra_info["bank_id"] = source_bank_id
source_line = DocumentLine(
document_id=document.id,
account_id=source_account.id,
bank_account_id=source_bank_account_id,
check_id=resolved_check_id,
debit=Decimal(0),
credit=amount, # بانک/صندوق بستانکار می‌شود (وجه پرداخت شد)
description=description or f"پرداخت چک از {source_account.name}",
extra_info=source_line_extra_info,
)
logger.info(f"خط اضافی برای حساب مبدا چک ایجاد شد: {source_line}")
db.add(source_line)
# خطوط تعدیل تسعیر ارز (پرداخت بین‌ارزی)
for fx_line in fx_adjustment_lines:
try:
fx_amt = Decimal(str(fx_line.get("amount", 0) or 0))
except Exception:
continue
if fx_amt <= 0:
continue
code = str(fx_line.get("account_code") or "").strip()
if not code:
continue
side = str(fx_line.get("side") or "").strip().lower()
fx_account = _get_fixed_account_by_code(db, code)
db.add(
DocumentLine(
document_id=document.id,
account_id=fx_account.id,
debit=fx_amt if side == "debit" else Decimal(0),
credit=fx_amt if side == "credit" else Decimal(0),
description=fx_line.get("description") or "تسعیر ارز",
extra_info={"fx_adjustment": True, "side": side, "account_code": code},
)
)
# ایجاد خطوط کارمزد اگر کارمزدی وجود دارد
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")
normalized_transaction_type = transaction_type
if transaction_type == "check_expense":
normalized_transaction_type = "check"
logger.info(f"ایجاد خط کارمزد برای تراکنش {i+1}: مبلغ={commission_amount}, نوع={transaction_type}")
# تعیین حساب کارمزد بر اساس نوع تراکنش
commission_account = None
commission_account_code = None
if normalized_transaction_type == "bank":
commission_account_code = "10203" # بانک
elif normalized_transaction_type == "cash_register":
commission_account_code = "10202" # صندوق
elif normalized_transaction_type == "petty_cash":
commission_account_code = "10201" # تنخواه گردان
elif normalized_transaction_type == "check":
if is_receipt:
commission_account_code = "10403" # اسناد دریافتنی
else:
commission_account_code = "20202" # اسناد پرداختنی
elif normalized_transaction_type == "person":
commission_account_code = "20201" # حساب‌های پرداختنی
elif normalized_transaction_type == "wallet":
commission_account_code = "10204" # کیف‌پول / نزد پرداخت‌یار
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_check_id = None
if account_line.get("check_id"):
try:
commission_check_id = int(account_line.get("check_id"))
except Exception:
commission_check_id = None
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=commission_check_id,
debit=commission_debit,
credit=commission_credit,
description=f"کارمزد تراکنش {transaction_type}",
extra_info={
# بدون transaction_type تا به‌عنوان تراکنش پرداخت مشتری بازخوانی نشود
"is_commission_line": True,
"original_transaction_index": i,
"commission_type": "settlement_contra",
}
)
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={
"is_commission_line": True,
"original_transaction_index": i,
"commission_type": "banking_service",
}
)
logger.info(f"خط کارمزد خدمات بانکی ایجاد شد: {commission_service_line}")
db.add(commission_service_line)
# --- فروش اقساطی: تخصیص به اقساط و شناسایی سود (بدون try/except ساکت) ---
extra_info_all = data.get("extra_info") or {}
settlements = extra_info_all.get("settlements") or []
total_interest_to_recognize = Decimal(0)
updated_invoice_ids: List[int] = []
if is_receipt and isinstance(settlements, list) and settlements:
for st in settlements:
invoice_id = int(st.get("invoice_id"))
invoice_doc = db.query(Document).filter(Document.id == invoice_id).first()
if not invoice_doc:
raise ApiError("INSTALLMENT_INVOICE_NOT_FOUND", "فاکتور اقساط پیدا نشد", http_status=404)
inv_extra = invoice_doc.extra_info or {}
plan = inv_extra.get("installment_plan")
schedule = (plan or {}).get("schedule")
if not (isinstance(plan, dict) and isinstance(schedule, list) and schedule):
raise ApiError("INSTALLMENT_PLAN_NOT_FOUND", "طرح اقساط برای فاکتور یافت نشد", http_status=404)
allocations = st.get("allocations") or []
if not isinstance(allocations, list) or not allocations:
raise ApiError("INSTALLMENT_ALLOCATIONS_REQUIRED", "لیست تخصیص اقساط خالی است", http_status=400)
seq_to_amount: Dict[int, Decimal] = {}
for al in allocations:
seq = int(al.get("seq"))
amt = Decimal(str(al.get("amount", 0) or 0))
if seq <= 0 or amt <= 0:
raise ApiError("INSTALLMENT_ALLOCATION_INVALID", "تخصیص قسط نامعتبر است", http_status=400)
seq_to_amount[seq] = seq_to_amount.get(seq, Decimal(0)) + amt
# کنترل مانده هر قسط (با اتکا به اعتبارسنجی بالادستی remaining)
remaining_by_seq: Dict[int, Decimal] = {}
for item in schedule:
seq_i = int(item.get("seq") or 0)
total_i = Decimal(str(item.get("total", 0) or 0))
paid_i = Decimal(str(item.get("paid_amount", 0) or 0))
remaining_by_seq[seq_i] = max(total_i - paid_i, Decimal(0))
for seq, alloc in seq_to_amount.items():
remain = remaining_by_seq.get(seq)
if remain is None:
raise ApiError("INSTALLMENT_SEQ_NOT_FOUND", "شماره قسط یافت نشد", http_status=404)
if alloc > remain:
raise ApiError("INSTALLMENT_OVERPAY_PER_SEQ", "مبلغ تخصیص از مانده قسط بیشتر است", http_status=400)
# اعمال و محاسبه سود
interest_delta_for_invoice = Decimal(0)
new_schedule: List[Dict[str, Any]] = []
for item in schedule:
seq = int(item.get("seq") or 0)
paid_before = Decimal(str(item.get("paid_amount", 0) or 0))
interest_part = Decimal(str(item.get("interest", 0) or 0))
total_part = Decimal(str(item.get("total", 0) or 0))
alloc_amt = seq_to_amount.get(seq, Decimal(0))
paid_after = paid_before + alloc_amt
prev_interest_recognized = min(paid_before, interest_part)
new_interest_recognized = min(paid_after, interest_part)
delta = new_interest_recognized - prev_interest_recognized
if delta > 0:
interest_delta_for_invoice += delta
# محاسبه status با tolerance برای خطای ممیز شناور
tolerance = Decimal("0.01")
if total_part > 0 and (paid_after >= total_part or (total_part - paid_after) <= tolerance):
new_status = "paid"
elif paid_after > tolerance:
new_status = "partial"
else:
new_status = "pending"
logger.info(f"قسط #{seq}: total={total_part}, paid_before={paid_before}, alloc={alloc_amt}, paid_after={paid_after}, status={new_status}")
new_item = dict(item)
new_item["paid_amount"] = float(paid_after)
new_item["status"] = new_status
pay_list = list(new_item.get("payments") or [])
if alloc_amt > Decimal(0):
pay_list.append({
"document_id": int(document.id),
"document_code": getattr(document, "code", None),
"document_date": document.document_date.isoformat() if getattr(document, "document_date", None) else None,
"seq": seq,
"amount": float(alloc_amt),
})
new_item["payments"] = pay_list
new_schedule.append(new_item)
new_plan = dict(plan or {})
new_plan["schedule"] = new_schedule
inv_extra["installment_plan"] = new_plan
invoice_doc.extra_info = inv_extra
# flag کردن تغییرات در JSON field برای SQLAlchemy
flag_modified(invoice_doc, "extra_info")
updated_invoice_ids.append(invoice_doc.id)
total_interest_to_recognize += interest_delta_for_invoice
if total_interest_to_recognize > 0:
# عدم ساکت کردن خطا؛ اگر حساب ثابت نبود، خطا می‌دهیم تا وضعیت شفاف باشد
unearned = _get_fixed_account_by_code(db, "10405")
earned = _get_fixed_account_by_code(db, "60205")
db.add(DocumentLine(
document_id=document.id,
account_id=unearned.id,
debit=total_interest_to_recognize,
credit=Decimal(0),
description="انتقال سود اقساط از سود تحقق‌نیافته",
extra_info={"installment": True, "reclassification": True},
))
db.add(DocumentLine(
document_id=document.id,
account_id=earned.id,
debit=Decimal(0),
credit=total_interest_to_recognize,
description="شناسایی سود فروش اقساطی",
extra_info={"installment": True, "reclassification": True},
))
# لینک دوطرفه به فاکتور(ها) — person_line.invoice_id، extra_info.invoice_id، settlements
_link_receipt_payment_to_invoices(
db,
int(document.id),
person_lines,
extra_info_all if isinstance(extra_info_all, dict) else None,
)
# ذخیره تغییرات
logger.info(f"=== ذخیره تغییرات ===")
# flush کردن تغییرات قبل از commit برای اطمینان از ذخیره شدن
db.flush()
try:
from app.services.document_line_fx_service import stamp_document_lines_fx_base
stamp_document_lines_fx_base(db, document, only_missing=False, allow_infer=True)
db.flush()
except Exception as fx_stamp_ex:
logger.warning("receipt_payment fx base stamp failed doc_id=%s err=%s", document.id, fx_stamp_ex)
# تغییر وضعیت چک‌های استفاده شده
logger.info(f"=== تغییر وضعیت چک‌های استفاده شده ===")
for account_line in account_lines:
check_id = account_line.get("check_id")
if not check_id:
continue
try:
check_obj = db.query(Check).filter(Check.id == int(check_id)).first()
if not check_obj:
logger.warning(f"چک با شناسه {check_id} یافت نشد")
continue
normalized_transaction_type = account_line.get("transaction_type")
if normalized_transaction_type == "check_expense":
normalized_transaction_type = "check"
if normalized_transaction_type == "check":
logger.info(f"تغییر وضعیت چک {check_obj.check_number} (id={check_obj.id})")
logger.info(f"وضعیت قبلی: {check_obj.status}, نوع چک: {check_obj.type}, نوع سند: {document_type}")
if check_obj.type == CheckType.RECEIVED:
# چک دریافتی
if is_receipt:
# در سند دریافت، چک استفاده می‌شود (مثلاً به بانک سپرده می‌شود)
# بسته به نوع تراکنش حساب (بانک/صندوق/چک) وضعیت را تعیین می‌کنیم
if account_line.get("transaction_type") in ("bank", "cash_register"):
# اگر به بانک/صندوق سپرده شد، وضعیت را DEPOSITED می‌کنیم
check_obj.status = CheckStatus.DEPOSITED
else:
# اگر در جای دیگری استفاده شد، وضعیت را CLEARED می‌کنیم
check_obj.status = CheckStatus.CLEARED
logger.info(f"وضعیت جدید: {check_obj.status}")
# در سند پرداخت، چک دریافتی نمی‌تواند استفاده شود (validation قبلاً این را چک می‌کند)
elif check_obj.type == CheckType.TRANSFERRED:
# چک پرداختی
if not is_receipt:
# در سند پرداخت، چک پرداخته می‌شود
check_obj.status = CheckStatus.CLEARED
logger.info(f"وضعیت جدید: {check_obj.status}")
# در سند دریافت، چک پرداختی نمی‌تواند استفاده شود (validation قبلاً این را چک می‌کند)
check_obj.status_at = datetime.utcnow()
check_obj.last_action_document_id = document.id
logger.info(f"وضعیت چک {check_obj.check_number} به {check_obj.status} تغییر یافت")
except Exception as e:
logger.error(f"خطا در تغییر وضعیت چک {check_id}: {e}", exc_info=True)
# خطا را لاگ می‌کنیم اما به فرآیند ادامه می‌دهیم
if commit:
db.commit()
else:
db.flush()
db.refresh(document)
# refresh کردن فاکتورهای اقساطی که به‌روزرسانی شدند
for invoice_id in updated_invoice_ids:
invoice_doc = db.query(Document).filter(Document.id == invoice_id).first()
if invoice_doc:
db.refresh(invoice_doc)
logger.info(f"فاکتور اقساطی refresh شد: id={invoice_id}")
# بررسی محتوای extra_info بعد از refresh
if invoice_doc.extra_info:
plan = invoice_doc.extra_info.get("installment_plan")
if plan:
schedule = plan.get("schedule", [])
logger.info(f" تعداد اقساط در فاکتور: {len(schedule)}")
for item in schedule:
seq = item.get("seq")
status = item.get("status")
paid = item.get("paid_amount", 0)
total = item.get("total", 0)
logger.info(f" قسط #{seq}: status={status}, paid={paid}, total={total}")
logger.info(f"سند با موفقیت ایجاد شد: id={document.id}, code={document.code}")
result = document_to_dict(db, document)
if commit:
# Invalidate cache بعد از ایجاد موفق سند دریافت/پرداخت
from app.services.document_service import invalidate_documents_cache
invalidate_documents_cache(
business_id=business_id,
fiscal_year_id=document.fiscal_year_id,
document_id=document.id,
document_type=document_type
)
# فراخوانی workflow triggers
try:
from app.services.workflow.workflow_trigger_service import trigger_receipt_payment_created
# محاسبه مجموع مبلغ
total_amount = sum(
float(line.get("debit", 0) or 0) + float(line.get("credit", 0) or 0)
for line in result.get("lines", [])
)
trigger_receipt_payment_created(
db=db,
business_id=business_id,
receipt_payment_id=document.id,
type="receipt" if is_receipt else "payment",
amount=total_amount,
user_id=user_id
)
except Exception as e:
# عدم موفقیت در trigger نباید مانع بازگشت سند شود
logger.warning(f"Failed to trigger workflows for receipt/payment {document.id}: {e}")
return result
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
result = document_to_dict(db, document)
return result
def _apply_receipt_payment_extra_filters(db: Session, q, query: Dict[str, Any]):
"""فیلترهای پیشرفته ستون‌ها (مثل چندانتخابی پروژه در جدول)."""
filters_raw = query.get("filters") or []
if not filters_raw:
return q
for raw in filters_raw:
if not isinstance(raw, dict):
continue
prop = raw.get("property")
operator = str(raw.get("operator") or "").strip().lower()
val = raw.get("value")
if prop == "project_name" and operator == "in" and val:
ids: List[int] = []
iterable = val if isinstance(val, list) else [val]
for x in iterable:
try:
ids.append(int(x))
except (TypeError, ValueError):
continue
if ids:
q = q.filter(Document.project_id.in_(ids))
elif prop == "project_name" and operator == "=" and val not in (None, ""):
try:
q = q.filter(Document.project_id == int(val))
except (TypeError, ValueError):
pass
return q
def _apply_receipt_payment_search(db: Session, q, query: Dict[str, Any]):
"""جستجو بر اساس کد، شرح یا نام ایجادکننده وقتی search_fields ارسال شده باشد."""
search = query.get("search")
if not search:
return q
pattern = f"%{search}%"
search_fields = query.get("search_fields")
if not search_fields or not isinstance(search_fields, list):
return q.filter(Document.code.ilike(pattern))
sf_set = {str(x) for x in search_fields}
parts = []
if "code" in sf_set:
parts.append(Document.code.ilike(pattern))
if "description" in sf_set:
parts.append(Document.description.ilike(pattern))
if "created_by_name" in sf_set:
uid_rows = db.query(User.id).filter(
or_(
func.concat(User.first_name, " ", User.last_name).ilike(pattern),
User.first_name.ilike(pattern),
User.last_name.ilike(pattern),
)
).all()
uid_list = [row[0] for row in uid_rows if row[0] is not None]
if uid_list:
parts.append(Document.created_by_user_id.in_(uid_list))
else:
parts.append(Document.id == -1)
if not parts:
return q.filter(Document.code.ilike(pattern))
return q.filter(or_(*parts))
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
# فیلتر پروژه (صفحه یا پارامتر صریح)
pid = query.get("project_id")
if pid is not None:
try:
q = q.filter(Document.project_id == int(pid))
except (TypeError, ValueError):
pass
# فیلتر شخص — وجود حداقل یک خط سند با person_id
pers_id = query.get("person_id")
if pers_id is not None:
try:
pid_person = int(pers_id)
q = q.filter(
exists().where(
and_(
DocumentLine.document_id == Document.id,
DocumentLine.person_id == pid_person,
)
)
)
except (TypeError, ValueError):
pass
q = _apply_receipt_payment_extra_filters(db, q, query)
# جستجو (پشتیبانی از چند فیلد)
q = _apply_receipt_payment_search(db, q, query)
# مرتب‌سازی (sort چندستونه در اولویت، وگرنه sort_by/sort_desc)
from app.services.document_list_sort import apply_document_dynamic_ordering_from_dict
q = apply_document_dynamic_ordering_from_dict(q, query)
# صفحه‌بندی
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, *, commit: bool = True) -> 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
# 4) جلوگیری از حذف اگر سند به تراکنش‌های کیف پول مرتبط باشد
try:
from app.services.wallet_service import check_document_has_wallet_transactions
wallet_check = check_document_has_wallet_transactions(db, document_id)
if wallet_check["has_wallet_transactions"] and wallet_check.get("has_protected_transactions", False):
raise ApiError(
"DOCUMENT_HAS_WALLET_TRANSACTIONS",
wallet_check["message"],
http_status=409,
)
except ApiError:
raise
except Exception:
pass
# حذف لینک این سند از فاکتورهای مرتبط (قبل از حذف سند)
try:
from adapters.db.models.document_line import DocumentLine
# پیدا کردن تمام invoice_id های مرتبط از طریق DocumentLine ها
related_lines = db.query(DocumentLine).filter(
DocumentLine.document_id == document_id
).all()
invoice_ids_set = set()
for line in related_lines:
line_extra = line.extra_info or {}
invoice_id = line_extra.get("invoice_id")
if invoice_id:
try:
invoice_ids_set.add(int(invoice_id))
except (ValueError, TypeError):
pass
# حذف این document_id از links فاکتورها
if invoice_ids_set:
for invoice_id in invoice_ids_set:
invoice_doc = db.query(Document).filter(Document.id == invoice_id).first()
if invoice_doc:
extra_info = dict(invoice_doc.extra_info or {})
links = dict(extra_info.get("links", {}))
receipt_payment_ids = list(links.get("receipt_payment_document_ids", []) or [])
if document_id in receipt_payment_ids:
receipt_payment_ids.remove(document_id)
links["receipt_payment_document_ids"] = receipt_payment_ids
extra_info["links"] = links
invoice_doc.extra_info = extra_info
flag_modified(invoice_doc, "extra_info")
db.add(invoice_doc)
logger.info(f"لینک سند {document_id} از فاکتور {invoice_id} حذف شد")
except Exception as ex:
logger.warning(f"خطا در حذف لینک از فاکتورها هنگام حذف سند {document_id}: {ex}")
# ادامه می‌دهیم حتی اگر خطا رخ داد
# دریافت اطلاعات قبل از حذف برای invalidation
business_id = document.business_id
fiscal_year_id = document.fiscal_year_id
document_type = document.document_type
db.delete(document)
if commit:
db.commit()
else:
db.flush()
if commit:
from app.services.document_service import invalidate_documents_cache
invalidate_documents_cache(
business_id=business_id,
fiscal_year_id=fiscal_year_id,
document_id=document_id,
document_type=document_type
)
return True
def update_receipt_payment(
db: Session,
document_id: int,
user_id: int,
data: Dict[str, Any]
) -> Dict[str, Any]:
"""به‌روزرسانی سند دریافت/پرداخت - استراتژی Full-Replace خطوط"""
logger.info(f"[UPDATE_RECEIPT_PAYMENT] شروع ویرایش سند {document_id}")
logger.info(f"[UPDATE_RECEIPT_PAYMENT] data دریافتی: {data}")
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)
logger.info(f"[UPDATE_RECEIPT_PAYMENT] document.extra_info قبل از تغییر: {document.extra_info}")
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)
parsed_doc_date = _parse_iso_date(data.get("document_date", document.document_date))
is_receipt = (document.document_type == DOCUMENT_TYPE_RECEIPT)
try:
from app.services.cross_currency_settlement_service import (
prepare_standalone_receipt_payment_cross_currency,
)
data = prepare_standalone_receipt_payment_cross_currency(
db, int(document.business_id), data, is_receipt=is_receipt
)
except ApiError:
raise
except Exception:
logger.exception("prepare standalone cross-currency on update failed")
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)
fx_adjustment_lines = data.get("fx_adjustment_lines") or []
fx_adj_debit = sum(
float(x.get("amount", 0) or 0)
for x in fx_adjustment_lines
if str(x.get("side") or "").lower() == "debit"
)
fx_adj_credit = sum(
float(x.get("amount", 0) or 0)
for x in fx_adjustment_lines
if str(x.get("side") or "").lower() == "credit"
)
if is_receipt:
left = account_total + fx_adj_debit
right = person_total + fx_adj_credit
else:
left = person_total + fx_adj_debit
right = account_total + fx_adj_credit
if abs(left - right) > 0.01:
raise ApiError(
"UNBALANCED_AMOUNTS",
f"مبالغ سند نامتوازن است (چپ={left}, راست={right})",
http_status=400,
)
document_date = resolve_receipt_document_date_from_account_lines(parsed_doc_date, account_lines)
# 3) اعمال تغییرات در سند (بدون تغییر code و document_type)
document.document_date = document_date
document.currency_id = int(currency_id)
if "extra_info" in data and (
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 (
"description" in data and data.get("description") is None
):
document.description = data.get("description")
if "project_id" in data:
project_id = data.get("project_id")
if project_id:
from adapters.db.models.project import Project
project = db.query(Project).filter(
and_(
Project.id == int(project_id),
Project.business_id == document.business_id,
Project.is_active == True,
)
).first()
if not project:
raise ApiError("PROJECT_NOT_FOUND", "پروژه یافت نشد یا غیرفعال است", http_status=404)
document.project_id = int(project_id)
else:
document.project_id = None
# تعیین نوع دریافت/پرداخت برای محاسبات بدهکار/بستانکار
is_receipt = (document.document_type == DOCUMENT_TYPE_RECEIPT)
logger.info(f"[UPDATE_RECEIPT_PAYMENT] document.document_type={document.document_type}, is_receipt={is_receipt}")
# امکان override نوع حساب طرف‌شخص (دریافتنی/پرداختنی) از extra_info در ویرایش
extra_info_all = data.get("extra_info") or document.extra_info or {}
logger.info(f"[UPDATE_RECEIPT_PAYMENT] extra_info_all: {extra_info_all}")
person_is_receivable_override = extra_info_all.get("person_is_receivable")
if isinstance(person_is_receivable_override, bool):
is_person_receivable = person_is_receivable_override
else:
is_person_receivable = is_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_person_receivable)
debit_amount = amount if not is_receipt else Decimal(0)
credit_amount = amount if is_receipt else Decimal(0)
# ساخت extra_info برای خط شخص
line_extra_info = {
"person_id": int(person_id),
"person_name": person_line.get("person_name"),
}
# اضافه کردن اطلاعات فاکتور از extra_info شخص
person_line_extra = person_line.get("extra_info")
if isinstance(person_line_extra, dict):
if person_line_extra.get("invoice_id"):
line_extra_info["invoice_id"] = person_line_extra["invoice_id"]
if person_line_extra.get("invoice_code"):
line_extra_info["invoice_code"] = person_line_extra["invoice_code"]
if person_line_extra.get("link_to_invoice"):
line_extra_info["link_to_invoice"] = person_line_extra["link_to_invoice"]
if person_line_extra.get("fx_settlement"):
line_extra_info["fx_settlement"] = person_line_extra["fx_settlement"]
if person_line_extra.get("side"):
line_extra_info["side"] = person_line_extra["side"]
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=line_extra_info,
)
db.add(line)
# --- اعتبارسنجی اقساط در ویرایش (هم‌راستا با ایجاد) ---
extra_info_all = data.get("extra_info") or {}
settlements_input = extra_info_all.get("settlements") or []
logger.info(f"[UPDATE_RECEIPT_PAYMENT] settlements_input برای validation: {settlements_input}")
if settlements_input:
# فقط روی دریافت مجاز است
if not is_receipt:
logger.error(f"[UPDATE_RECEIPT_PAYMENT] خطا: settlements برای payment مجاز نیست")
raise ApiError(
"INSTALLMENT_NOT_ALLOWED_FOR_PAYMENT",
"تخصیص اقساط فقط در اسناد دریافت مجاز است",
http_status=400,
)
person_amount_by_id: Dict[int, Decimal] = {}
for pl in person_lines:
try:
pid = int(pl.get("person_id"))
amt = Decimal(str(pl.get("amount", 0) or 0))
if pid and amt > 0:
person_amount_by_id[pid] = person_amount_by_id.get(pid, Decimal(0)) + amt
except Exception:
continue
logger.info(f"[UPDATE_RECEIPT_PAYMENT] person_amount_by_id: {person_amount_by_id}")
# یافتن allocations قبلی این سند برای کم کردن از paid_amount در validation
old_settlements = (document.extra_info or {}).get("settlements") or []
logger.info(f"[UPDATE_RECEIPT_PAYMENT] old_settlements برای validation: {old_settlements}")
allocated_by_person: Dict[int, Decimal] = {}
for st in settlements_input:
try:
st_person_id = int(st.get("person_id"))
except Exception:
raise ApiError("INSTALLMENT_PERSON_REQUIRED", "برای هر تخصیص قسط، person_id الزامی است", http_status=400)
if st_person_id not in person_amount_by_id:
raise ApiError("INSTALLMENT_PERSON_MISMATCH", "شخص تخصیص قسط باید در خطوط اشخاص سند وجود داشته باشد", http_status=400)
try:
invoice_id = int(st.get("invoice_id"))
except Exception:
raise ApiError("INSTALLMENT_INVOICE_INVALID", "شناسه فاکتور اقساط نامعتبر است", http_status=400)
invoice_doc = db.query(Document).filter(Document.id == invoice_id).first()
if not invoice_doc:
raise ApiError("INSTALLMENT_INVOICE_NOT_FOUND", "فاکتور اقساط پیدا نشد", http_status=404)
if int(invoice_doc.currency_id) != int(currency_id):
settles_cur = None
if isinstance(extra_info_all, dict):
try:
settles_cur = int(extra_info_all.get("settles_currency_id")) if extra_info_all.get("settles_currency_id") is not None else None
except Exception:
settles_cur = None
if settles_cur is None or int(invoice_doc.currency_id) != int(settles_cur):
raise ApiError("INSTALLMENT_CURRENCY_MISMATCH", "ارز فاکتور اقساط با ارز سند دریافت یکسان نیست", http_status=400)
inv_extra = invoice_doc.extra_info or {}
plan = inv_extra.get("installment_plan")
schedule = (plan or {}).get("schedule")
if not (isinstance(plan, dict) and isinstance(schedule, list) and schedule):
raise ApiError("INSTALLMENT_PLAN_NOT_FOUND", "برای فاکتور انتخاب‌شده، طرح اقساط ثبت نشده است", http_status=404)
# یافتن allocations قبلی این سند برای این فاکتور
old_allocations_by_seq_for_validation: Dict[int, Decimal] = {}
for old_st in old_settlements:
if int(old_st.get("invoice_id", 0)) == invoice_id:
old_allocs = old_st.get("allocations") or []
for old_al in old_allocs:
old_seq = int(old_al.get("seq", 0))
old_amt = Decimal(str(old_al.get("amount", 0) or 0))
if old_seq > 0 and old_amt > 0:
old_allocations_by_seq_for_validation[old_seq] = old_allocations_by_seq_for_validation.get(old_seq, Decimal(0)) + old_amt
logger.info(f"[UPDATE_RECEIPT_PAYMENT] old_allocations_by_seq_for_validation برای فاکتور {invoice_id}: {old_allocations_by_seq_for_validation}")
remaining_by_seq: Dict[int, Decimal] = {}
for item in schedule:
try:
seq_i = int(item.get("seq") or 0)
total_i = Decimal(str(item.get("total", 0) or 0))
paid_i = Decimal(str(item.get("paid_amount", 0) or 0))
# کم کردن allocations قبلی این سند از paid_amount
old_alloc_for_seq = old_allocations_by_seq_for_validation.get(seq_i, Decimal(0))
paid_i_adjusted = max(paid_i - old_alloc_for_seq, Decimal(0))
remaining_by_seq[seq_i] = max(total_i - paid_i_adjusted, Decimal(0))
logger.info(f"[UPDATE_RECEIPT_PAYMENT] validation قسط {seq_i}: total={total_i}, paid_i={paid_i}, old_alloc={old_alloc_for_seq}, paid_i_adjusted={paid_i_adjusted}, remaining={remaining_by_seq[seq_i]}")
except Exception:
continue
allocations = st.get("allocations") or []
if not isinstance(allocations, list) or not allocations:
raise ApiError("INSTALLMENT_ALLOCATIONS_REQUIRED", "لیست تخصیص اقساط خالی است", http_status=400)
for al in allocations:
try:
seq = int(al.get("seq"))
amt = Decimal(str(al.get("amount", 0) or 0))
except Exception:
raise ApiError("INSTALLMENT_ALLOCATION_INVALID", "تخصیص قسط نامعتبر است", http_status=400)
if seq <= 0 or amt <= 0:
raise ApiError("INSTALLMENT_ALLOCATION_INVALID", "تخصیص قسط نامعتبر است", http_status=400)
remain = remaining_by_seq.get(seq, None)
if remain is None:
raise ApiError("INSTALLMENT_SEQ_NOT_FOUND", "شماره قسط در طرح اقساط یافت نشد", http_status=404)
logger.info(f"[UPDATE_RECEIPT_PAYMENT] validation قسط {seq}: alloc={amt}, remain={remain}")
if amt > remain:
logger.error(f"[UPDATE_RECEIPT_PAYMENT] خطا در validation قسط {seq}: alloc={amt} > remain={remain}")
raise ApiError("INSTALLMENT_OVERPAY_PER_SEQ", "مبلغ تخصیص از مانده همان قسط بیشتر است", http_status=400)
allocated_by_person[st_person_id] = allocated_by_person.get(st_person_id, Decimal(0)) + amt
for pid, alloc_sum in allocated_by_person.items():
if alloc_sum > person_amount_by_id.get(pid, Decimal(0)):
raise ApiError("INSTALLMENT_OVER_PERSON_LINES", "جمع تخصیص اقساط یک شخص از مجموع مبلغ خطوط همان شخص بیشتر است", http_status=400)
# خطوط حساب‌ها + کارمزدها
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")
normalized_transaction_type = transaction_type
if transaction_type == "check_expense":
normalized_transaction_type = "check"
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
check_id_val: Optional[int] = None
if normalized_transaction_type == "check":
check_obj, check_id_val = _validate_check_transaction_line(
db,
business_id=document.business_id,
account_line=account_line,
is_receipt=is_receipt,
document_currency_id=currency_id,
amount_decimal=amount,
exclude_document_id=document.id,
)
account_line["check_id"] = check_id_val
if not account_line.get("check_number"):
account_line["check_number"] = check_obj.check_number
if normalized_transaction_type == "bank":
account = _get_fixed_account_by_code(db, "10203")
elif normalized_transaction_type == "cash_register":
account = _get_fixed_account_by_code(db, "10202")
elif normalized_transaction_type == "petty_cash":
account = _get_fixed_account_by_code(db, "10201")
elif normalized_transaction_type == "check":
account = _get_fixed_account_by_code(db, "10403" if is_receipt else "20202")
elif normalized_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 normalized_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 normalized_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 normalized_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 normalized_transaction_type == "check":
if account_line.get("check_id"):
extra_info["check_id"] = int(account_line.get("check_id"))
if account_line.get("check_number"):
extra_info["check_number"] = account_line.get("check_number")
if account_line.get("account_currency_amount") is not None:
extra_info["account_currency_amount"] = str(account_line.get("account_currency_amount"))
if account_line.get("account_currency_id") is not None:
try:
extra_info["account_currency_id"] = int(account_line.get("account_currency_id"))
except Exception:
pass
# ایجاد خط سند برای حساب
# برای چک: منطق حسابداری متفاوت است
if normalized_transaction_type == "check":
if is_receipt:
debit_amount = Decimal(0)
credit_amount = amount
else:
# پرداخت با چک پرداختی: حساب 20202 بدهکار می‌شود
debit_amount = amount
credit_amount = Decimal(0)
else:
# برای سایر حساب‌ها (بانک، صندوق، ...): منطق عادی
# در دریافت: حساب بدهکار (debit) - دارایی افزایش می‌یابد
# در پرداخت: حساب بستانکار (credit) - دارایی کاهش می‌یابد
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
resolved_check_id = check_id_val
if resolved_check_id is None and account_line.get("check_id"):
try:
resolved_check_id = int(account_line.get("check_id"))
except Exception:
resolved_check_id = None
detail_ids = _invoice_account_detail_ids(db, business_id, account_line) if transaction_type == "account" else {}
if transaction_type == "account" and account_line.get("detail_name"):
extra_info["detail_name"] = str(account_line["detail_name"])
if transaction_type == "account" and account_line.get("detail_type"):
extra_info["detail_type"] = str(account_line["detail_type"])
line = DocumentLine(
document_id=document.id,
account_id=account.id,
person_id=person_id_for_line or detail_ids.get("person_id"),
product_id=detail_ids.get("product_id"),
bank_account_id=bank_account_id or detail_ids.get("bank_account_id"),
cash_register_id=account_line.get("cash_register_id") or detail_ids.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id") or detail_ids.get("petty_cash_id"),
check_id=resolved_check_id or detail_ids.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)
# خطوط تعدیل تسعیر ارز (V2-P4)
for fx_line in fx_adjustment_lines:
try:
fx_amt = Decimal(str(fx_line.get("amount", 0) or 0))
except Exception:
continue
if fx_amt <= 0:
continue
code = str(fx_line.get("account_code") or "").strip()
if not code:
continue
side = str(fx_line.get("side") or "").strip().lower()
fx_account = _get_fixed_account_by_code(db, code)
db.add(
DocumentLine(
document_id=document.id,
account_id=fx_account.id,
debit=fx_amt if side == "debit" else Decimal(0),
credit=fx_amt if side == "credit" else Decimal(0),
description=fx_line.get("description") or "تسعیر ارز",
extra_info={"fx_adjustment": True, "side": side, "account_code": code},
)
)
# خطوط کارمزد
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")
normalized_transaction_type = transaction_type
if transaction_type == "check_expense":
normalized_transaction_type = "check"
commission_account_code = None
if normalized_transaction_type == "bank":
commission_account_code = "10203"
elif normalized_transaction_type == "cash_register":
commission_account_code = "10202"
elif normalized_transaction_type == "petty_cash":
commission_account_code = "10201"
elif normalized_transaction_type == "check":
commission_account_code = "10403" if is_receipt else "20202"
elif normalized_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)
commission_check_id = None
if account_line.get("check_id"):
try:
commission_check_id = int(account_line.get("check_id"))
except Exception:
commission_check_id = None
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=commission_check_id,
debit=commission_debit,
credit=commission_credit,
description=f"کارمزد تراکنش {transaction_type}",
extra_info={
# بدون transaction_type تا به‌عنوان تراکنش پرداخت مشتری بازخوانی نشود
"is_commission_line": True,
"original_transaction_index": i,
"commission_type": "settlement_contra",
},
))
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={
"is_commission_line": True,
"original_transaction_index": i,
"commission_type": "banking_service",
},
))
# --- اعمال اقساط در ویرایش، مشابه ایجاد ---
settlements = (data.get("extra_info") or {}).get("settlements") or []
logger.info(f"[UPDATE_RECEIPT_PAYMENT] settlements جدید از data: {settlements}")
total_interest_to_recognize = Decimal(0)
updated_invoice_ids: List[int] = []
if is_receipt and isinstance(settlements, list) and settlements:
logger.info(f"[UPDATE_RECEIPT_PAYMENT] تعداد settlements: {len(settlements)}")
for st in settlements:
invoice_id = int(st.get("invoice_id"))
invoice_doc = db.query(Document).filter(Document.id == invoice_id).first()
if not invoice_doc:
raise ApiError("INSTALLMENT_INVOICE_NOT_FOUND", "فاکتور اقساط پیدا نشد", http_status=404)
inv_extra = invoice_doc.extra_info or {}
plan = inv_extra.get("installment_plan")
schedule = (plan or {}).get("schedule")
logger.info(f"[INSTALLMENT] فاکتور {invoice_id}: plan={plan}")
logger.info(f"[INSTALLMENT] فاکتور {invoice_id}: schedule={schedule}")
if not (isinstance(plan, dict) and isinstance(schedule, list) and schedule):
raise ApiError("INSTALLMENT_PLAN_NOT_FOUND", "طرح اقساط برای فاکتور یافت نشد", http_status=404)
allocations = st.get("allocations") or []
logger.info(f"[INSTALLMENT] شروع پردازش allocations برای فاکتور {invoice_id} (سند {document.id})")
# یافتن allocations قبلی این سند برای کم کردن از paid_amount
old_settlements = (document.extra_info or {}).get("settlements") or []
old_allocations_by_seq: Dict[int, Decimal] = {}
logger.info(f"[INSTALLMENT] old_settlements برای فاکتور {invoice_id}: {old_settlements}")
for old_st in old_settlements:
if int(old_st.get("invoice_id", 0)) == invoice_id:
old_allocs = old_st.get("allocations") or []
logger.info(f"[INSTALLMENT] old_allocs برای فاکتور {invoice_id}: {old_allocs}")
for old_al in old_allocs:
old_seq = int(old_al.get("seq", 0))
old_amt = Decimal(str(old_al.get("amount", 0) or 0))
if old_seq > 0 and old_amt > 0:
old_allocations_by_seq[old_seq] = old_allocations_by_seq.get(old_seq, Decimal(0)) + old_amt
logger.info(f"[INSTALLMENT] old_allocations_by_seq برای فاکتور {invoice_id}: {old_allocations_by_seq}")
seq_to_amount: Dict[int, Decimal] = {}
logger.info(f"[INSTALLMENT] allocations جدید برای فاکتور {invoice_id}: {allocations}")
for al in allocations:
seq = int(al.get("seq"))
amt = Decimal(str(al.get("amount", 0) or 0))
if seq <= 0 or amt <= 0:
raise ApiError("INSTALLMENT_ALLOCATION_INVALID", "تخصیص قسط نامعتبر است", http_status=400)
seq_to_amount[seq] = seq_to_amount.get(seq, Decimal(0)) + amt
logger.info(f"[INSTALLMENT] seq_to_amount برای فاکتور {invoice_id}: {seq_to_amount}")
# بررسی اینکه آیا allocations جدید با allocations قبلی یکسان هستند
# باید هم seq ها و هم amount ها را بررسی کنیم
allocations_changed = False
old_seqs = set(old_allocations_by_seq.keys())
new_seqs = set(seq_to_amount.keys())
# بررسی تغییرات در seq ها
if old_seqs != new_seqs:
allocations_changed = True
logger.info(f"[INSTALLMENT] seq های allocations تغییر کرده: old_seqs={old_seqs}, new_seqs={new_seqs}")
else:
# اگر seq ها یکسان باشند، amount ها را بررسی می‌کنیم
for seq in new_seqs:
old_amt = old_allocations_by_seq.get(seq, Decimal(0))
new_amt = seq_to_amount.get(seq, Decimal(0))
diff = abs(new_amt - old_amt)
logger.info(f"[INSTALLMENT] مقایسه قسط {seq}: old={old_amt}, new={new_amt}, diff={diff}")
if diff > Decimal("0.01"): # tolerance برای خطای ممیز شناور
allocations_changed = True
logger.info(f"[INSTALLMENT] قسط {seq} تغییر کرده: diff={diff} > 0.01")
break
logger.info(f"[INSTALLMENT] allocations_changed برای فاکتور {invoice_id}: {allocations_changed}")
remaining_by_seq: Dict[int, Decimal] = {}
logger.info(f"[INSTALLMENT] محاسبه remaining_by_seq برای فاکتور {invoice_id}")
for item in schedule:
seq_i = int(item.get("seq") or 0)
total_i = Decimal(str(item.get("total", 0) or 0))
paid_i = Decimal(str(item.get("paid_amount", 0) or 0))
# کم کردن allocations قبلی این سند از paid_amount
old_alloc_for_seq = old_allocations_by_seq.get(seq_i, Decimal(0))
paid_i_adjusted = max(paid_i - old_alloc_for_seq, Decimal(0))
remaining = max(total_i - paid_i_adjusted, Decimal(0))
remaining_by_seq[seq_i] = remaining
logger.info(f"[INSTALLMENT] قسط {seq_i}: total={total_i}, paid_i={paid_i}, old_alloc={old_alloc_for_seq}, paid_i_adjusted={paid_i_adjusted}, remaining={remaining}")
# اعتبارسنجی فقط اگر allocations تغییر کرده باشند
if allocations_changed:
logger.info(f"[INSTALLMENT] اعتبارسنجی allocations برای فاکتور {invoice_id}")
for seq, alloc in seq_to_amount.items():
remain = remaining_by_seq.get(seq)
if remain is None:
logger.error(f"[INSTALLMENT] قسط {seq} در schedule یافت نشد")
raise ApiError("INSTALLMENT_SEQ_NOT_FOUND", "شماره قسط یافت نشد", http_status=404)
logger.info(f"[INSTALLMENT] اعتبارسنجی قسط {seq}: alloc={alloc}, remain={remain}")
if alloc > remain:
logger.error(f"[INSTALLMENT] خطا در قسط {seq}: alloc={alloc} > remain={remain}")
logger.error(f"[INSTALLMENT] جزئیات قسط {seq}: total={schedule[seq-1].get('total') if seq <= len(schedule) else 'N/A'}, paid_i={schedule[seq-1].get('paid_amount') if seq <= len(schedule) else 'N/A'}, old_alloc={old_allocations_by_seq.get(seq, 0)}")
raise ApiError("INSTALLMENT_OVERPAY_PER_SEQ", "مبلغ تخصیص از مانده قسط بیشتر است", http_status=400)
logger.info(f"[INSTALLMENT] اعتبارسنجی موفق برای فاکتور {invoice_id}")
else:
# اگر allocations تغییر نکرده باشند، از اعمال مجدد صرف نظر می‌کنیم
logger.info(f"[INSTALLMENT] allocations برای فاکتور {invoice_id} تغییر نکرده است، از اعمال مجدد صرف نظر می‌کنیم")
continue
interest_delta_for_invoice = Decimal(0)
new_schedule: List[Dict[str, Any]] = []
logger.info(f"[INSTALLMENT] شروع اعمال allocations برای فاکتور {invoice_id}")
for item in schedule:
seq = int(item.get("seq") or 0)
paid_before_raw = Decimal(str(item.get("paid_amount", 0) or 0))
# کم کردن allocations قبلی این سند از paid_before (فقط در حالت ویرایش)
old_alloc_for_seq = old_allocations_by_seq.get(seq, Decimal(0))
paid_before = max(paid_before_raw - old_alloc_for_seq, Decimal(0))
interest_part = Decimal(str(item.get("interest", 0) or 0))
total_part = Decimal(str(item.get("total", 0) or 0))
alloc_amt = seq_to_amount.get(seq, Decimal(0))
paid_after = paid_before + alloc_amt
logger.info(f"[INSTALLMENT] قسط {seq}: paid_before_raw={paid_before_raw}, old_alloc={old_alloc_for_seq}, paid_before={paid_before}, alloc_amt={alloc_amt}, paid_after={paid_after}")
# محاسبه interest_delta: فقط برای allocations جدید (نه allocations قبلی)
# اگر allocations قبلی وجود داشته باشد، interest_delta قبلی را rollback می‌کنیم
# interest_delta قبلی = interest_delta که با allocations قبلی ایجاد شده بود
# interest_delta جدید = interest_delta که با allocations جدید ایجاد می‌شود
# interest_delta کل = interest_delta جدید - interest_delta قبلی
prev_interest_recognized_with_old = min(paid_before_raw, interest_part)
prev_interest_recognized_without_old = min(paid_before, interest_part)
new_interest_recognized = min(paid_after, interest_part)
# interest_delta قبلی (که باید rollback شود)
old_interest_delta = prev_interest_recognized_with_old - prev_interest_recognized_without_old
# interest_delta جدید (با allocations جدید)
new_interest_delta = new_interest_recognized - prev_interest_recognized_without_old
# interest_delta کل = جدید - قبلی
delta = new_interest_delta - old_interest_delta
logger.info(f"[INSTALLMENT] قسط {seq} interest_delta: interest_part={interest_part}, prev_with_old={prev_interest_recognized_with_old}, prev_without_old={prev_interest_recognized_without_old}, new={new_interest_recognized}, old_delta={old_interest_delta}, new_delta={new_interest_delta}, delta={delta}")
if delta > 0:
interest_delta_for_invoice += delta
elif delta < 0:
# اگر allocations جدید کمتر از allocations قبلی باشد، interest_delta منفی می‌شود
interest_delta_for_invoice += delta
# محاسبه status با tolerance برای خطای ممیز شناور
tolerance = Decimal("0.01")
if total_part > 0 and (paid_after >= total_part or (total_part - paid_after) <= tolerance):
new_status = "paid"
elif paid_after > tolerance:
new_status = "partial"
else:
new_status = "pending"
logger.info(f"[INSTALLMENT] قسط {seq}: total={total_part}, status={new_status}")
new_item = dict(item)
pay_list = [
p for p in (item.get("payments") or [])
if not (isinstance(p, dict) and int(p.get("document_id") or 0) == int(document.id))
]
if alloc_amt > Decimal(0):
pay_list.append({
"document_id": int(document.id),
"document_code": getattr(document, "code", None),
"document_date": document.document_date.isoformat() if getattr(document, "document_date", None) else None,
"seq": seq,
"amount": float(alloc_amt),
})
new_item["payments"] = pay_list
new_item["paid_amount"] = float(paid_after)
new_item["status"] = new_status
new_schedule.append(new_item)
new_plan = dict(plan or {})
new_plan["schedule"] = new_schedule
inv_extra["installment_plan"] = new_plan
invoice_doc.extra_info = inv_extra
# flag کردن تغییرات در JSON field برای SQLAlchemy
flag_modified(invoice_doc, "extra_info")
updated_invoice_ids.append(invoice_doc.id)
total_interest_to_recognize += interest_delta_for_invoice
if total_interest_to_recognize > 0:
unearned = _get_fixed_account_by_code(db, "10405")
earned = _get_fixed_account_by_code(db, "60205")
db.add(DocumentLine(
document_id=document.id,
account_id=unearned.id,
debit=total_interest_to_recognize,
credit=Decimal(0),
description="انتقال سود اقساط از سود تحقق‌نیافته",
extra_info={"installment": True, "reclassification": True},
))
db.add(DocumentLine(
document_id=document.id,
account_id=earned.id,
debit=Decimal(0),
credit=total_interest_to_recognize,
description="شناسایی سود فروش اقساطی",
extra_info={"installment": True, "reclassification": True},
))
# flush کردن تغییرات قبل از commit برای اطمینان از ذخیره شدن
db.flush()
# تغییر وضعیت چک‌های استفاده شده (در update)
logger.info(f"[UPDATE_RECEIPT_PAYMENT] === تغییر وضعیت چک‌های استفاده شده ===")
for account_line in account_lines:
check_id = account_line.get("check_id")
if not check_id:
continue
try:
check_obj = db.query(Check).filter(Check.id == int(check_id)).first()
if not check_obj:
logger.warning(f"[UPDATE_RECEIPT_PAYMENT] چک با شناسه {check_id} یافت نشد")
continue
normalized_transaction_type = account_line.get("transaction_type")
if normalized_transaction_type == "check_expense":
normalized_transaction_type = "check"
if normalized_transaction_type == "check":
logger.info(f"[UPDATE_RECEIPT_PAYMENT] تغییر وضعیت چک {check_obj.check_number} (id={check_obj.id})")
logger.info(f"[UPDATE_RECEIPT_PAYMENT] وضعیت قبلی: {check_obj.status}, نوع چک: {check_obj.type}, نوع سند: {document.document_type}")
if check_obj.type == CheckType.RECEIVED:
# چک دریافتی
if is_receipt:
# در سند دریافت، چک استفاده می‌شود
if account_line.get("transaction_type") in ("bank", "cash_register"):
check_obj.status = CheckStatus.DEPOSITED
else:
check_obj.status = CheckStatus.CLEARED
logger.info(f"[UPDATE_RECEIPT_PAYMENT] وضعیت جدید: {check_obj.status}")
elif check_obj.type == CheckType.TRANSFERRED:
# چک پرداختی
if not is_receipt:
# در سند پرداخت، چک پرداخته می‌شود
check_obj.status = CheckStatus.CLEARED
logger.info(f"[UPDATE_RECEIPT_PAYMENT] وضعیت جدید: {check_obj.status}")
check_obj.status_at = datetime.utcnow()
check_obj.last_action_document_id = document.id
logger.info(f"[UPDATE_RECEIPT_PAYMENT] وضعیت چک {check_obj.check_number} به {check_obj.status} تغییر یافت")
except Exception as e:
logger.error(f"[UPDATE_RECEIPT_PAYMENT] خطا در تغییر وضعیت چک {check_id}: {e}", exc_info=True)
_link_receipt_payment_to_invoices(
db,
int(document.id),
person_lines,
extra_info_all if isinstance(extra_info_all, dict) else None,
)
db.commit()
db.refresh(document)
# refresh کردن فاکتورهای اقساطی که به‌روزرسانی شدند
for invoice_id in updated_invoice_ids:
invoice_doc = db.query(Document).filter(Document.id == invoice_id).first()
if invoice_doc:
db.refresh(invoice_doc)
logger.info(f"فاکتور اقساطی refresh شد: id={invoice_id}")
# بررسی محتوای extra_info بعد از refresh
if invoice_doc.extra_info:
plan = invoice_doc.extra_info.get("installment_plan")
if plan:
schedule = plan.get("schedule", [])
logger.info(f" تعداد اقساط در فاکتور: {len(schedule)}")
for item in schedule:
seq = item.get("seq")
status = item.get("status")
paid = item.get("paid_amount", 0)
total = item.get("total", 0)
logger.info(f" قسط #{seq}: status={status}, paid={paid}, total={total}")
result = document_to_dict(db, document)
# دریافت اطلاعات قبل از به‌روزرسانی برای invalidation
business_id = document.business_id
old_fiscal_year_id = document.fiscal_year_id
document_type = document.document_type
# Invalidate cache بعد از به‌روزرسانی موفق سند دریافت/پرداخت
from app.services.document_service import invalidate_documents_cache
invalidate_documents_cache(
business_id=business_id,
fiscal_year_id=old_fiscal_year_id,
document_id=document.id,
document_type=document_type
)
# فراخوانی workflow triggers (ویرایش، نه ایجاد)
try:
from app.services.workflow.workflow_trigger_service import trigger_receipt_payment_updated
# محاسبه مجموع مبلغ
total_amount = sum(
float(line.get("debit", 0) or 0) + float(line.get("credit", 0) or 0)
for line in result.get("lines", [])
)
is_receipt = document.document_type == DOCUMENT_TYPE_RECEIPT
trigger_receipt_payment_updated(
db=db,
business_id=document.business_id,
receipt_payment_id=document.id,
type="receipt" if is_receipt else "payment",
amount=total_amount,
user_id=user_id
)
except Exception as e:
# عدم موفقیت در trigger نباید مانع بازگشت سند شود
logger.warning(f"Failed to trigger workflows for receipt/payment {document.id}: {e}")
return result
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:
c_raw = line.extra_info.get("commission")
# فقط مقدار عددی برگردان؛ بول/رشتهٔ غیرعددی باعث خطای نوع در کلاینت (double?) می‌شود
if isinstance(c_raw, bool):
pass
elif isinstance(c_raw, (int, float)):
try:
line_dict["commission"] = float(c_raw)
except (TypeError, ValueError):
pass
elif isinstance(c_raw, str) and c_raw.strip():
try:
line_dict["commission"] = float(c_raw.replace(",", ""))
except (TypeError, ValueError):
pass
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"]
# فقط وقتی نام در extra_info مقدار داشته باشد استفاده کن؛ وگرنه بعداً از جدول Person خوانده می‌شود
if line.extra_info.get("person_name"):
line_dict["person_name"] = line.extra_info["person_name"]
# اضافه کردن اطلاعات فاکتور از extra_info
if "invoice_id" in line.extra_info:
line_dict["invoice_id"] = line.extra_info["invoice_id"]
if "invoice_code" in line.extra_info:
line_dict["invoice_code"] = line.extra_info["invoice_code"]
if "link_to_invoice" in line.extra_info:
line_dict["link_to_invoice"] = line.extra_info["link_to_invoice"]
if "fx_settlement" in line.extra_info:
line_dict["fx_settlement"] = line.extra_info["fx_settlement"]
# اگر 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)
is_installment_line = line.extra_info and (line.extra_info.get("installment", False) or line.extra_info.get("reclassification", False))
# خطوط اقساط را از نمایش حذف می‌کنیم (فقط در پشت صحنه ثبت می‌شوند)
if is_installment_line:
continue # این خط را نادیده بگیر و به لیست اضافه نکن
if is_commission_line:
# خط کارمزد - در account_lines قرار می‌گیرد
account_lines.append(line_dict)
elif (
not (
line.extra_info
and line.extra_info.get("transaction_type") == "account"
and line.extra_info.get("detail_type")
)
and (
(line.extra_info and line.extra_info.get("person_id"))
or (line.person_id and account.account_type == "person")
)
):
# خط شخص: یا extra_info دارای person_id است، یا ستون person_id پر است و حساب از نوع شخص (دریافتنی/پرداختنی)
# برای داده‌های قدیمی که فقط person_id در ستون دارند، بدون نیاز به extra_info هم درست نمایش داده می‌شود
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 "پرداخت"
# دریافت نام پروژه
project_name = None
if document.project_id:
from adapters.db.models.project import Project
project = db.query(Project).filter(Project.id == document.project_id).first()
if project:
project_name = project.name
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,
"project_id": document.project_id,
"project_name": project_name,
"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(),
}