Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/check_service.py

1645 lines
67 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.

from __future__ import annotations
from typing import Any, Dict, List, Optional
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from adapters.db.models.check import Check, CheckType, CheckStatus, HolderType
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.fiscal_year import FiscalYear
from adapters.db.models.currency import Currency
from adapters.db.models.person import Person
from app.core.responses import ApiError
from app.services.document_monetization_service import ensure_document_policy_allows_creation
def _parse_iso(dt: str) -> datetime:
try:
return datetime.fromisoformat(dt.replace('Z', '+00:00'))
except Exception:
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
def _parse_iso_date_only(dt: str | datetime | date) -> date:
if isinstance(dt, date) and not isinstance(dt, datetime):
return dt
if isinstance(dt, datetime):
return dt.date()
try:
return datetime.fromisoformat(str(dt)).date()
except Exception:
return datetime.utcnow().date()
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
account = db.query(Account).filter(Account.code == str(account_code)).first()
if not account:
from app.core.responses import ApiError
raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=404)
return account
def _get_business_fiscal_year(db: Session, business_id: int) -> FiscalYear:
from sqlalchemy import and_ # local import to avoid unused import if not used elsewhere
fy = (
db.query(FiscalYear)
.filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True, # noqa: E712
)
)
.order_by(FiscalYear.start_date.desc())
.first()
)
if not fy:
from app.core.responses import ApiError
raise ApiError("FISCAL_YEAR_NOT_FOUND", "Active fiscal year not found", http_status=404)
return fy
def create_check(db: Session, business_id: int, user_id: int, data: Dict[str, Any], *, commit: bool = True) -> Dict[str, Any]:
try:
ctype = str(data.get('type', '')).lower()
if ctype not in ("received", "transferred"):
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
person_id = data.get('person_id')
if ctype == "received" and not person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400)
issue_date = _parse_iso(str(data.get('issue_date')))
due_date = _parse_iso(str(data.get('due_date')))
if due_date < issue_date:
raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400)
sayad = data.get('sayad_code')
if sayad is not None:
s = str(sayad).strip()
if s and (len(s) != 16 or not s.isdigit()):
raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400)
amount = data.get('amount')
try:
amount_val = float(amount)
except Exception:
raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400)
if amount_val <= 0:
raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400)
check_number = str(data.get('check_number', '')).strip()
if not check_number:
raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400)
# یونیک بودن در سطح کسب‌وکار
exists = db.query(Check).filter(and_(Check.business_id == business_id, Check.check_number == check_number)).first()
if exists is not None:
raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400)
if sayad:
exists_sayad = db.query(Check).filter(and_(Check.business_id == business_id, Check.sayad_code == sayad)).first()
if exists_sayad is not None:
raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400)
obj = Check(
business_id=business_id,
type=CheckType[ctype.upper()],
person_id=int(person_id) if person_id else None,
issue_date=issue_date,
due_date=due_date,
check_number=check_number,
sayad_code=str(sayad).strip() if sayad else None,
bank_name=(str(data.get('bank_name')).strip() if data.get('bank_name') else None),
branch_name=(str(data.get('branch_name')).strip() if data.get('branch_name') else None),
amount=amount_val,
currency_id=int(data.get('currency_id')),
)
# تعیین وضعیت اولیه
if ctype == "received":
obj.status = CheckStatus.RECEIVED_ON_HAND
obj.current_holder_type = HolderType.BUSINESS
obj.current_holder_id = None
else:
obj.status = CheckStatus.TRANSFERRED_ISSUED
obj.current_holder_type = HolderType.PERSON if person_id else HolderType.BUSINESS
obj.current_holder_id = int(person_id) if person_id else None
db.add(obj)
db.flush()
# ایجاد سند حسابداری (الزامی)
document_date: date = _parse_iso_date_only(data.get("document_date") or issue_date)
# تعیین حساب‌ها و سطرها
amount_dec = Decimal(str(amount_val))
lines: List[Dict[str, Any]] = []
description = (str(data.get("document_description")).strip() or None) if data.get("document_description") is not None else None
if ctype == "received":
# بدهکار: اسناد دریافتنی 10403
acc_notes_recv = _get_fixed_account_by_code(db, "10403")
lines.append({
"account_id": acc_notes_recv.id,
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "ثبت چک دریافتی",
"check_id": obj.id,
})
# بستانکار: حساب دریافتنی شخص 10401
acc_ar = _get_fixed_account_by_code(db, "10401")
lines.append({
"account_id": acc_ar.id,
"person_id": int(person_id) if person_id else None,
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "ثبت چک دریافتی",
"check_id": obj.id,
})
else: # transferred
# برای چک واگذار شده، person_id الزامی است
if not person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required for transferred checks", http_status=400)
# بدهکار: حساب پرداختنی شخص 20201
acc_ap = _get_fixed_account_by_code(db, "20201")
lines.append({
"account_id": acc_ap.id,
"person_id": int(person_id),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "ثبت چک واگذار شده",
"check_id": obj.id,
})
# بستانکار: اسناد پرداختنی 20202
acc_notes_pay = _get_fixed_account_by_code(db, "20202")
lines.append({
"account_id": acc_notes_pay.id,
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "ثبت چک واگذار شده",
"check_id": obj.id,
})
# بررسی تراز سند
debit_total = Decimal(0)
credit_total = Decimal(0)
for line in lines:
debit_total += Decimal(str(line.get("debit") or 0))
credit_total += Decimal(str(line.get("credit") or 0))
if debit_total != credit_total:
raise ApiError("UNBALANCED_DOCUMENT", f"Document is not balanced: debit={debit_total}, credit={credit_total}", http_status=400)
# ایجاد سند
amount_for_policy = _calculate_document_amount_from_lines(lines)
ensure_document_policy_allows_creation(
db,
business_id=business_id,
document_type="check",
document_date=document_date,
amount=amount_for_policy,
)
created_document_id = _create_document_for_check_action(
db,
business_id=business_id,
user_id=user_id,
currency_id=int(data.get("currency_id")),
document_date=document_date,
description=description,
lines=lines,
extra_info={
"source": "check_create",
"check_id": obj.id,
"check_type": ctype,
**(data.get("extra_info") if isinstance(data.get("extra_info"), dict) else {}),
},
)
obj.last_action_document_id = created_document_id
if commit:
try:
db.commit()
except Exception:
db.rollback()
raise
db.refresh(obj)
else:
db.flush()
result = check_to_dict(db, obj)
result["document_id"] = created_document_id
return result
except Exception:
if commit:
db.rollback()
raise
def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
obj = db.query(Check).filter(Check.id == check_id).first()
return check_to_dict(db, obj) if obj else None
# =====================
# Action helpers
# =====================
def _create_document_for_check_action(
db: Session,
*,
business_id: int,
user_id: int,
currency_id: int,
document_date: date,
description: Optional[str],
lines: List[Dict[str, Any]],
extra_info: Dict[str, Any],
) -> int:
amount = _calculate_document_amount_from_lines(lines)
ensure_document_policy_allows_creation(
db,
business_id=business_id,
document_type="check",
document_date=document_date,
amount=amount,
)
document = Document(
code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
business_id=business_id,
fiscal_year_id=_get_business_fiscal_year(db, business_id).id,
currency_id=int(currency_id),
created_by_user_id=int(user_id),
document_date=document_date,
document_type="check",
is_proforma=False,
description=description,
extra_info=extra_info,
)
db.add(document)
db.flush()
for line in lines:
db.add(DocumentLine(document_id=document.id, **line))
return document.id
def _calculate_document_amount_from_lines(lines: List[Dict[str, Any]]) -> Decimal:
debit_total = Decimal(0)
credit_total = Decimal(0)
for line in lines:
debit_total += Decimal(str(line.get("debit") or 0))
credit_total += Decimal(str(line.get("credit") or 0))
return max(debit_total, credit_total)
def _ensure_account(db: Session, code: str) -> int:
return _get_fixed_account_by_code(db, code).id
def _parse_optional_date(d: Any, fallback: date) -> date:
return _parse_iso_date_only(d) if d else fallback
def _load_check_or_404(db: Session, check_id: int) -> Check:
obj = db.query(Check).filter(Check.id == check_id).first()
if not obj:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
return obj
def _person_display_name(db: Session, person_id: Optional[int]) -> Optional[str]:
if not person_id:
return None
p = db.query(Person).filter(Person.id == int(person_id)).first()
return getattr(p, "alias_name", None) if p else None
def _unwrap_check_from_deposit(
db: Session,
obj: Check,
user_id: int,
*,
purpose: str,
) -> None:
"""اگر چک سپرده شده باشد، قبل از واگذاری/عودت به RECEIVED_ON_HAND برمی‌گرداند."""
if obj.status != CheckStatus.DEPOSITED:
return
amount_dec = Decimal(str(obj.amount))
purpose_labels = {
"endorse": ("برگشت از سپرده برای واگذاری", "deposit_return_for_endorse"),
"return": ("برگشت از سپرده برای عودت", "deposit_return_for_return"),
}
label, action_key = purpose_labels.get(purpose, purpose_labels["return"])
deposit_return_lines: List[Dict[str, Any]] = [
{
"account_id": _ensure_account(db, "10403"),
"debit": amount_dec,
"credit": Decimal(0),
"description": label,
"check_id": obj.id,
},
{
"account_id": _ensure_account(db, "10404"),
"debit": Decimal(0),
"credit": amount_dec,
"description": label,
"check_id": obj.id,
},
]
_create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=obj.due_date.date(),
description=label,
lines=deposit_return_lines,
extra_info={"source": "check_action", "action": action_key, "check_id": obj.id},
)
obj.status = CheckStatus.RECEIVED_ON_HAND
obj.status_at = datetime.utcnow()
obj.current_holder_type = HolderType.BUSINESS
obj.current_holder_id = None
def _resolve_return_type(obj: Check, data: Dict[str, Any]) -> str:
explicit = (data.get("return_type") or "").strip().lower()
if explicit in ("from_endorsee", "to_drawer"):
return explicit
if obj.type == CheckType.RECEIVED and obj.status == CheckStatus.ENDORSED:
return "from_endorsee"
return "to_drawer"
def endorse_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
if obj.type != CheckType.RECEIVED:
raise ApiError("INVALID_ACTION", "Only received checks can be endorsed", http_status=400)
_unwrap_check_from_deposit(db, obj, user_id, purpose="endorse")
# اجازه واگذاری از وضعیت‌های RECEIVED_ON_HAND, RETURNED, BOUNCED یا وضعیت خالی (None)
if obj.status is not None and obj.status not in (CheckStatus.RECEIVED_ON_HAND, CheckStatus.RETURNED, CheckStatus.BOUNCED):
raise ApiError("INVALID_STATE", f"Cannot endorse from status {obj.status}", http_status=400)
target_person_id = int(data.get("target_person_id"))
document_date = _parse_optional_date(data.get("document_date"), obj.issue_date.date())
description = (data.get("description") or None)
lines: List[Dict[str, Any]] = []
amount_dec = Decimal(str(obj.amount))
# Dr 20201 (target person AP), Cr 10403
lines.append({
"account_id": _ensure_account(db, "20201"),
"person_id": target_person_id,
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "واگذاری چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10403"),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "واگذاری چک",
"check_id": obj.id,
})
# ایجاد سند (الزامی)
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info={
"source": "check_action",
"action": "endorse",
"check_id": obj.id,
"target_person_id": target_person_id,
},
)
# Update state
obj.status = CheckStatus.ENDORSED
obj.status_at = datetime.utcnow()
obj.current_holder_type = HolderType.PERSON
obj.current_holder_id = target_person_id
obj.last_action_document_id = document_id
try:
db.commit()
except Exception:
db.rollback()
raise
db.refresh(obj)
res = check_to_dict(db, obj)
res["document_id"] = document_id
return res
def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
description = (data.get("description") or None)
amount_dec = Decimal(str(obj.amount))
lines: List[Dict[str, Any]] = []
if obj.type == CheckType.RECEIVED:
if obj.status not in (CheckStatus.RECEIVED_ON_HAND, CheckStatus.DEPOSITED):
raise ApiError("INVALID_STATE", f"Cannot clear received check from status {obj.status}", http_status=400)
# Dr 10203 (bank), Cr 10403 یا 10404 بسته به وضعیت
credit_code = "10404" if obj.status == CheckStatus.DEPOSITED else "10403"
lines.append({
"account_id": _ensure_account(db, "10203"),
"bank_account_id": int(data.get("bank_account_id")),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "وصول چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, credit_code),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "وصول چک",
"check_id": obj.id,
})
else:
if obj.status not in (CheckStatus.TRANSFERRED_ISSUED, CheckStatus.BOUNCED):
raise ApiError("INVALID_STATE", f"Cannot pay transferred check from status {obj.status}", http_status=400)
# transferred/pay: Dr 20202, Cr 10203
lines.append({
"account_id": _ensure_account(db, "20202"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "پرداخت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10203"),
"bank_account_id": int(data.get("bank_account_id")),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "پرداخت چک",
"check_id": obj.id,
})
# ایجاد سند (الزامی)
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info={"source": "check_action", "action": "clear", "check_id": obj.id},
)
obj.status = CheckStatus.CLEARED
obj.status_at = datetime.utcnow()
obj.current_holder_type = HolderType.BANK
obj.current_holder_id = int(data.get("bank_account_id"))
obj.last_action_document_id = document_id
try:
db.commit()
except Exception:
db.rollback()
raise
db.refresh(obj)
res = check_to_dict(db, obj)
res["document_id"] = document_id
return res
def pay_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
# alias to clear_check for transferred
obj = _load_check_or_404(db, check_id)
if obj.type != CheckType.TRANSFERRED:
raise ApiError("INVALID_ACTION", "Only transferred checks can be paid", http_status=400)
return clear_check(db, check_id, user_id, data)
def return_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
if obj.status == CheckStatus.CLEARED:
raise ApiError("INVALID_STATE", "Cannot return a cleared check", http_status=400)
if obj.status == CheckStatus.CANCELLED:
raise ApiError("INVALID_STATE", "Cannot return a cancelled check", http_status=400)
document_date = _parse_optional_date(data.get("document_date"), obj.issue_date.date())
description = (data.get("description") or None)
amount_dec = Decimal(str(obj.amount))
lines: List[Dict[str, Any]] = []
doc_extra: Dict[str, Any] = {"source": "check_action", "check_id": obj.id}
if obj.type == CheckType.TRANSFERRED:
if obj.status == CheckStatus.RETURNED:
raise ApiError("INVALID_STATE", "Check is already returned", http_status=400)
if not obj.person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required on transferred check to return", http_status=400)
lines.extend([
{
"account_id": _ensure_account(db, "20202"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "عودت چک پرداختنی",
"check_id": obj.id,
},
{
"account_id": _ensure_account(db, "20201"),
"person_id": int(obj.person_id),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "عودت چک پرداختنی",
"check_id": obj.id,
},
])
doc_extra["action"] = "return_transferred"
obj.status = CheckStatus.RETURNED
obj.current_holder_type = HolderType.BUSINESS
obj.current_holder_id = None
else:
_unwrap_check_from_deposit(db, obj, user_id, purpose="return")
return_type = _resolve_return_type(obj, data)
if return_type == "from_endorsee":
if obj.status != CheckStatus.ENDORSED:
raise ApiError(
"INVALID_STATE",
"Return from endorsee is only allowed when check status is ENDORSED",
http_status=400,
)
endorsee_id = data.get("target_person_id") or obj.current_holder_id
if not endorsee_id:
raise ApiError("PERSON_REQUIRED", "target_person_id or current_holder_id required", http_status=400)
endorsee_id = int(endorsee_id)
if (
obj.current_holder_type == HolderType.PERSON
and obj.current_holder_id
and int(obj.current_holder_id) != endorsee_id
):
raise ApiError(
"INVALID_PERSON",
"target_person_id does not match current endorsee",
http_status=400,
)
desc = description or "برگشت چک از واگذارشونده"
lines.extend([
{
"account_id": _ensure_account(db, "10403"),
"debit": amount_dec,
"credit": Decimal(0),
"description": desc,
"check_id": obj.id,
},
{
"account_id": _ensure_account(db, "20201"),
"person_id": endorsee_id,
"debit": Decimal(0),
"credit": amount_dec,
"description": desc,
"check_id": obj.id,
},
])
doc_extra["action"] = "return_from_endorsee"
doc_extra["target_person_id"] = endorsee_id
obj.status = CheckStatus.RECEIVED_ON_HAND
obj.current_holder_type = HolderType.BUSINESS
obj.current_holder_id = None
else:
# to_drawer — عودت به صادرکننده
if obj.status == CheckStatus.RETURNED:
raise ApiError("INVALID_STATE", "Check is already returned to drawer", http_status=400)
if not obj.person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required on received check to return", http_status=400)
drawer_id = int(obj.person_id)
desc_drawer = description or "عودت چک به صادرکننده"
if obj.status == CheckStatus.ENDORSED:
endorsee_id = obj.current_holder_id
if not endorsee_id:
raise ApiError("PERSON_REQUIRED", "current_holder_id required for endorsed check", http_status=400)
endorsee_id = int(endorsee_id)
desc_unwind = description or "لغو واگذاری و عودت به صادرکننده"
lines.extend([
{
"account_id": _ensure_account(db, "10403"),
"debit": amount_dec,
"credit": Decimal(0),
"description": desc_unwind,
"check_id": obj.id,
},
{
"account_id": _ensure_account(db, "20201"),
"person_id": endorsee_id,
"debit": Decimal(0),
"credit": amount_dec,
"description": desc_unwind,
"check_id": obj.id,
},
{
"account_id": _ensure_account(db, "10401"),
"person_id": drawer_id,
"debit": amount_dec,
"credit": Decimal(0),
"description": desc_drawer,
"check_id": obj.id,
},
{
"account_id": _ensure_account(db, "10403"),
"debit": Decimal(0),
"credit": amount_dec,
"description": desc_drawer,
"check_id": obj.id,
},
])
doc_extra["action"] = "return_to_drawer_after_endorse"
doc_extra["target_person_id"] = endorsee_id
doc_extra["drawer_person_id"] = drawer_id
else:
allowed = (
CheckStatus.RECEIVED_ON_HAND,
CheckStatus.BOUNCED,
None,
)
if obj.status not in allowed:
raise ApiError(
"INVALID_STATE",
f"Cannot return to drawer from status {obj.status}",
http_status=400,
)
lines.extend([
{
"account_id": _ensure_account(db, "10401"),
"person_id": drawer_id,
"debit": amount_dec,
"credit": Decimal(0),
"description": desc_drawer,
"check_id": obj.id,
},
{
"account_id": _ensure_account(db, "10403"),
"debit": Decimal(0),
"credit": amount_dec,
"description": desc_drawer,
"check_id": obj.id,
},
])
doc_extra["action"] = "return_to_drawer"
doc_extra["drawer_person_id"] = drawer_id
obj.status = CheckStatus.RETURNED
obj.current_holder_type = HolderType.PERSON
obj.current_holder_id = drawer_id
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info=doc_extra,
)
obj.status_at = datetime.utcnow()
obj.last_action_document_id = document_id
try:
db.commit()
except Exception:
db.rollback()
raise
db.refresh(obj)
res = check_to_dict(db, obj)
res["document_id"] = document_id
res["return_type"] = doc_extra.get("action")
return res
def bounce_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
description = (data.get("description") or None)
amount_dec = Decimal(str(obj.amount))
lines: List[Dict[str, Any]] = []
if obj.type == CheckType.RECEIVED:
# فقط از وضعیت های DEPOSITED یا CLEARED اجازه برگشت
if obj.status not in (CheckStatus.DEPOSITED, CheckStatus.CLEARED):
raise ApiError("INVALID_STATE", f"Cannot bounce from status {obj.status}", http_status=400)
bank_account_id = data.get("bank_account_id")
if obj.status == CheckStatus.DEPOSITED:
# Dr 10403, Cr 10404
lines.append({
"account_id": _ensure_account(db, "10403"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "برگشت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10404"),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "برگشت چک",
"check_id": obj.id,
})
else:
# CLEARED: Dr 10403, Cr 10203 (نیازمند bank_account_id)
if not bank_account_id:
raise ApiError("BANK_ACCOUNT_REQUIRED", "bank_account_id is required to bounce a cleared check", http_status=400)
lines.append({
"account_id": _ensure_account(db, "10403"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "برگشت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10203"),
"bank_account_id": int(bank_account_id),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "برگشت چک",
"check_id": obj.id,
})
else:
if obj.status != CheckStatus.CLEARED:
raise ApiError("INVALID_STATE", f"Cannot bounce transferred check from status {obj.status}", http_status=400)
# transferred: Dr 20202, Cr 20201(person) (increase AP again)
if not obj.person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required on transferred check to bounce", http_status=400)
lines.append({
"account_id": _ensure_account(db, "20202"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "برگشت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "20201"),
"person_id": int(obj.person_id),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "برگشت چک",
"check_id": obj.id,
})
# Optional expense fee
expense_amount = data.get("expense_amount")
expense_account_id = data.get("expense_account_id")
bank_account_id = data.get("bank_account_id")
if expense_amount and expense_account_id and float(expense_amount) > 0:
lines.append({
"account_id": int(expense_account_id),
"debit": Decimal(str(expense_amount)),
"credit": Decimal(0),
"description": description or "هزینه برگشت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10203"),
**({"bank_account_id": int(bank_account_id)} if bank_account_id else {}),
"debit": Decimal(0),
"credit": Decimal(str(expense_amount)),
"description": description or "هزینه برگشت چک",
"check_id": obj.id,
})
# ایجاد سند (الزامی)
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info={"source": "check_action", "action": "bounce", "check_id": obj.id},
)
obj.status = CheckStatus.BOUNCED
obj.status_at = datetime.utcnow()
obj.current_holder_type = HolderType.BUSINESS
obj.current_holder_id = None
obj.last_action_document_id = document_id
try:
db.commit()
except Exception:
db.rollback()
raise
db.refresh(obj)
res = check_to_dict(db, obj)
res["document_id"] = document_id
return res
def deposit_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
if obj.type != CheckType.RECEIVED:
raise ApiError("INVALID_ACTION", "Only received checks can be deposited", http_status=400)
# فقط از وضعیت RECEIVED_ON_HAND یا وضعیت خالی (None) می‌توان سپرده کرد
if obj.status is not None and obj.status != CheckStatus.RECEIVED_ON_HAND:
raise ApiError("INVALID_STATE", f"Cannot deposit from status {obj.status}", http_status=400)
# bank_account_id در schema اجباری است اما در منطق استفاده نمی‌شود
# برای سازگاری با schema، آن را می‌پذیریم اما استفاده نمی‌کنیم
document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
description = (data.get("description") or None)
amount_dec = Decimal(str(obj.amount))
# Requires account 10404 to exist
in_collection = _get_fixed_account_by_code(db, "10404") # may raise 404
lines: List[Dict[str, Any]] = [
{
"account_id": in_collection.id,
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "سپرده چک به بانک",
"check_id": obj.id,
},
{
"account_id": _ensure_account(db, "10403"),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "سپرده چک به بانک",
"check_id": obj.id,
},
]
# ایجاد سند (الزامی)
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info={"source": "check_action", "action": "deposit", "check_id": obj.id},
)
obj.status = CheckStatus.DEPOSITED
obj.status_at = datetime.utcnow()
obj.current_holder_type = HolderType.BANK
obj.last_action_document_id = document_id
try:
db.commit()
except Exception:
db.rollback()
raise
db.refresh(obj)
res = check_to_dict(db, obj)
res["document_id"] = document_id
return res
def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
obj = db.query(Check).filter(Check.id == check_id).first()
if obj is None:
return None
if 'type' in data and data['type'] is not None:
ctype = str(data['type']).lower()
if ctype not in ("received", "transferred"):
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
obj.type = CheckType[ctype.upper()]
if 'person_id' in data:
obj.person_id = int(data['person_id']) if data['person_id'] is not None else None
if 'issue_date' in data and data['issue_date'] is not None:
obj.issue_date = _parse_iso(str(data['issue_date']))
if 'due_date' in data and data['due_date'] is not None:
obj.due_date = _parse_iso(str(data['due_date']))
if obj.due_date < obj.issue_date:
raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400)
if 'check_number' in data and data['check_number'] is not None:
new_num = str(data['check_number']).strip()
if not new_num:
raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400)
exists = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.check_number == new_num, Check.id != obj.id)).first()
if exists is not None:
raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400)
obj.check_number = new_num
if 'sayad_code' in data:
s = data['sayad_code']
if s is not None:
s = str(s).strip()
if s and (len(s) != 16 or not s.isdigit()):
raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400)
if s:
exists_sayad = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.sayad_code == s, Check.id != obj.id)).first()
if exists_sayad is not None:
raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400)
obj.sayad_code = s if s else None
for field in ["bank_name", "branch_name"]:
if field in data:
setattr(obj, field, (str(data[field]).strip() if data[field] is not None else None))
if 'amount' in data and data['amount'] is not None:
try:
amount_val = float(data['amount'])
except Exception:
raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400)
if amount_val <= 0:
raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400)
obj.amount = amount_val
if 'currency_id' in data and data['currency_id'] is not None:
obj.currency_id = int(data['currency_id'])
try:
db.commit()
except Exception:
db.rollback()
raise
db.refresh(obj)
return check_to_dict(db, obj)
def delete_check(db: Session, check_id: int, user_id: Optional[int] = None) -> bool:
"""
حذف چک با بررسی کامل اسناد حسابداری و لینک‌ها.
برای چک پاس‌شده (CLEARED): در صورت وجود سال مالی جاری، اگر تاریخ صدور چک در
بازهٔ همان سال باشد و همهٔ اسناد دارای خط با این check_id نیز متعلق به همان سال
مالی باشند، حذف فیزیکی چک و اسناد نوع check مجاز است؛ در غیر این صورت خطا برمی‌گردد.
چک سپرده‌شده (DEPOSITED): همچنان حذف مستقیم مجاز نیست.
Args:
db: جلسه دیتابیس
check_id: شناسه چک
user_id: شناسه کاربر (اختیاری، برای لاگ)
Returns:
True در صورت موفقیت
Raises:
ApiError: در صورت عدم امکان حذف
"""
import logging
logger = logging.getLogger(__name__)
# 1. بررسی وجود چک
obj = db.query(Check).filter(Check.id == check_id).first()
if obj is None:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
business_id = obj.business_id
# 2. چک سپرده‌شده هنوز قابل حذف مستقیم نیست
if obj.status == CheckStatus.DEPOSITED:
raise ApiError(
"CHECK_DEPOSITED",
"Cannot delete a deposited check. Please return the check from deposit first.",
http_status=400
)
# 3. سال مالی جاری (برای چک پاس‌شده الزامی است)
current_fiscal_year = None
try:
current_fiscal_year = _get_business_fiscal_year(db, business_id)
except Exception:
current_fiscal_year = None
cleared_delete_allowed = False
if obj.status == CheckStatus.CLEARED:
if current_fiscal_year is None:
raise ApiError(
"FISCAL_YEAR_NOT_FOUND",
"برای حذف چک پاس‌شده، سال مالی جاری باید وجود داشته باشد.",
http_status=404,
)
issue_d = obj.issue_date.date() if isinstance(obj.issue_date, datetime) else obj.issue_date
if issue_d < current_fiscal_year.start_date or issue_d > current_fiscal_year.end_date:
raise ApiError(
"CHECK_NOT_IN_CURRENT_FISCAL_YEAR",
"تاریخ صدور چک در سال مالی جاری نیست؛ حذف چک پاس‌شده مجاز نیست.",
http_status=400,
)
cleared_delete_allowed = True
# 4. شناسایی اسناد حسابداری مرتبط
related_documents: List[Document] = []
# 4.1. اسناد ایجاد شده برای چک (از طریق extra_info)
# ابتدا تمام اسناد با source مناسب را پیدا می‌کنیم
try:
documents_from_extra_info = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.extra_info.isnot(None),
or_(
Document.extra_info['source'].astext == 'check_create',
Document.extra_info['source'].astext == 'check_action'
)
)
).all()
except Exception:
# در صورت خطا در query JSONB، از روش جایگزین استفاده می‌کنیم
documents_from_extra_info = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.extra_info.isnot(None)
)
).all()
# فیلتر در Python
documents_from_extra_info = [
doc for doc in documents_from_extra_info
if doc.extra_info and doc.extra_info.get("source") in ("check_create", "check_action")
]
for doc in documents_from_extra_info:
extra_info = doc.extra_info or {}
check_id_in_doc = extra_info.get("check_id")
if check_id_in_doc == check_id:
related_documents.append(doc)
# 4.2. اسناد دارای DocumentLine با check_id
document_lines_with_check = db.query(DocumentLine).filter(
DocumentLine.check_id == check_id
).all()
document_ids_from_lines = {line.document_id for line in document_lines_with_check}
if document_ids_from_lines:
documents_from_lines = db.query(Document).filter(
Document.id.in_(document_ids_from_lines)
).all()
for doc in documents_from_lines:
if doc not in related_documents:
related_documents.append(doc)
# 4.3. اسناد مرتبط از طریق last_action_document_id
if obj.last_action_document_id:
last_action_doc = db.query(Document).filter(
Document.id == obj.last_action_document_id
).first()
if last_action_doc and last_action_doc not in related_documents:
related_documents.append(last_action_doc)
# 4.4. چک پاس‌شده: هر سندی که خطی با این check_id دارد باید در سال مالی جاری باشد
if cleared_delete_allowed:
if current_fiscal_year is None:
raise ApiError(
"FISCAL_YEAR_NOT_FOUND",
"برای حذف چک پاس‌شده، سال مالی جاری باید وجود داشته باشد.",
http_status=404,
)
line_doc_ids = {ln.document_id for ln in document_lines_with_check}
for did in line_doc_ids:
fd = db.query(Document).filter(Document.id == did).first()
if fd is None:
continue
if fd.fiscal_year_id != current_fiscal_year.id:
raise ApiError(
"FISCAL_YEAR_CLOSED",
f"سند حسابداری مرتبط با این چک (شناسه {fd.id}) متعلق به سال مالی جاری نیست؛ حذف مجاز نیست.",
http_status=400,
)
if obj.last_action_document_id:
lad = db.query(Document).filter(Document.id == obj.last_action_document_id).first()
if lad is not None and lad.fiscal_year_id != current_fiscal_year.id:
raise ApiError(
"FISCAL_YEAR_CLOSED",
f"آخرین سند عملیات چک (شناسه {lad.id}) متعلق به سال مالی جاری نیست؛ حذف مجاز نیست.",
http_status=400,
)
# 5. بررسی وجود استفاده حسابداری قطعی در اسناد غیرچکی
# حذف فیزیکی چک فقط زمانی مجاز است که رد آن صرفا در اسناد نوع check باشد.
# اگر چک در اسناد قطعی غیرچکی استفاده شده باشد، حذف باید متوقف شود.
document_lines_with_check_final = db.query(DocumentLine).join(
Document, DocumentLine.document_id == Document.id
).filter(
DocumentLine.check_id == check_id,
Document.is_proforma == False
).all()
if document_lines_with_check_final:
# دریافت انواع اسناد مرتبط
document_types = db.query(Document.document_type).join(
DocumentLine, Document.id == DocumentLine.document_id
).filter(
DocumentLine.check_id == check_id,
Document.is_proforma == False
).distinct().all()
types_list = [doc_type[0] for doc_type in document_types if doc_type[0]]
blocking_types = [t for t in types_list if t != "check"]
if not blocking_types:
blocking_types = []
# تبدیل انواع اسناد به نام‌های فارسی
type_names = []
type_mapping = {
"invoice_sales": "فاکتور فروش",
"invoice_sales_return": "برگشت از فروش",
"invoice_purchase": "فاکتور خرید",
"invoice_purchase_return": "برگشت از خرید",
"invoice_direct_consumption": "مصرف مستقیم",
"invoice_production": "تولید",
"invoice_waste": "ضایعات",
"receipt": "دریافت",
"payment": "پرداخت",
"expense": "هزینه",
"income": "درآمد",
"transfer": "انتقال",
"check": "چک",
}
for doc_type in blocking_types:
type_name = type_mapping.get(doc_type, doc_type)
if type_name not in type_names:
type_names.append(type_name)
if type_names:
types_str = "، ".join(type_names)
raise ApiError(
"CHECK_HAS_ACCOUNTING_DOCUMENTS",
f"امکان حذف این چک وجود ندارد زیرا در اسناد قطعی غیرچکی استفاده شده است. انواع اسناد: {types_str}",
http_status=400
)
# 6. بررسی سال مالی اسناد (برای سایر وضعیت‌ها اگر سال جاری شناخته نشد، این بخش رد می‌شود)
if current_fiscal_year:
for doc in related_documents:
if doc.fiscal_year_id != current_fiscal_year.id:
raise ApiError(
"FISCAL_YEAR_CLOSED",
f"سند مرتبط با این چک (شناسه {doc.id}) متعلق به سال مالی جاری نیست؛ حذف مجاز نیست.",
http_status=400,
)
# 7. بررسی وضعیت اسناد (فقط اسناد قطعی قابل بررسی)
for doc in related_documents:
if doc.is_proforma:
# اسناد پیش‌نویس قابل حذف هستند
continue
# اسناد قطعی غیرچکی باید حذف را متوقف کنند
if doc.document_type != "check":
# اگر سند از نوع دیگری است (مثلاً invoice)، فقط لینک را حذف می‌کنیم
raise ApiError(
"CHECK_HAS_ACCOUNTING_DOCUMENTS",
"امکان حذف چک وجود ندارد چون در سند قطعی غیرچکی استفاده شده است",
http_status=400
)
# 8. حذف لینک‌ها از DocumentLine (فقط برای اسناد غیرچکی و پیش‌نویس)
for line in document_lines_with_check:
# بررسی اینکه آیا سند مربوطه پیش‌نویس است یا نه
doc = db.query(Document).filter(Document.id == line.document_id).first()
if doc and doc.is_proforma and doc.document_type != "check":
line.check_id = None
db.flush()
# 9. حذف لینک‌ها از extra_info اسناد
for doc in related_documents:
if doc.extra_info:
extra_info = dict(doc.extra_info)
links = extra_info.get("links", {})
# حذف check_id از links
if isinstance(links, dict):
if "check_ids" in links:
check_ids = links.get("check_ids", [])
if isinstance(check_ids, list):
check_ids = [cid for cid in check_ids if cid != check_id]
links["check_ids"] = check_ids
# حذف از payment_document_ids اگر سند مربوط به چک است
if doc.extra_info.get("source") in ("check_create", "check_action"):
# این سند خودش مربوط به چک است، باید حذف شود
pass
else:
# حذف شناسه اسناد مرتبط با چک از payment_document_ids
payment_doc_ids = links.get("payment_document_ids", [])
if isinstance(payment_doc_ids, list):
# پیدا کردن شناسه اسناد مرتبط با چک
related_doc_ids = [d.id for d in related_documents if d.document_type == "check"]
payment_doc_ids = [pid for pid in payment_doc_ids if pid not in related_doc_ids]
links["payment_document_ids"] = payment_doc_ids
extra_info["links"] = links
doc.extra_info = extra_info
db.flush()
# 10. حذف لینک‌ها از فاکتورها (در صورت وجود)
# بررسی فاکتورهایی که DocumentLine آنها به چک لینک شده است
invoice_document_ids = {line.document_id for line in document_lines_with_check}
if invoice_document_ids:
invoices_with_check = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.id.in_(invoice_document_ids),
Document.document_type.in_(["invoice", "receipt_payment"]),
Document.extra_info.isnot(None)
)
).all()
for invoice_doc in invoices_with_check:
if not invoice_doc.extra_info:
continue
extra_info = dict(invoice_doc.extra_info)
links = extra_info.get("links", {})
if isinstance(links, dict):
updated = False
# حذف check_ids
if "check_ids" in links:
check_ids = links.get("check_ids", [])
if isinstance(check_ids, list) and check_id in check_ids:
check_ids = [cid for cid in check_ids if cid != check_id]
links["check_ids"] = check_ids
updated = True
# حذف payment_document_ids مربوط به اسناد چک
payment_doc_ids = links.get("payment_document_ids", [])
if isinstance(payment_doc_ids, list):
related_doc_ids = [d.id for d in related_documents if d.document_type == "check"]
original_count = len(payment_doc_ids)
payment_doc_ids = [pid for pid in payment_doc_ids if pid not in related_doc_ids]
if len(payment_doc_ids) != original_count:
links["payment_document_ids"] = payment_doc_ids
updated = True
if updated:
extra_info["links"] = links
invoice_doc.extra_info = extra_info
db.flush()
# 11. حذف اسناد حسابداری مرتبط (همه اسناد از نوع check)
for doc in related_documents:
if doc.document_type == "check":
# حذف DocumentLine ها (به صورت خودکار با cascade)
# حذف Document
try:
db.delete(doc)
db.flush()
logger.info(f"Deleted document {doc.id} related to check {check_id}")
except Exception as e:
logger.error(f"Error deleting document {doc.id}: {e}")
raise ApiError(
"DOCUMENT_DELETE_FAILED",
f"Failed to delete related document {doc.id}: {str(e)}",
http_status=500
)
# 12. حذف چک
try:
db.delete(obj)
db.commit()
logger.info(f"Check {check_id} deleted successfully by user {user_id}")
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting check {check_id}: {e}")
raise ApiError(
"CHECK_DELETE_FAILED",
f"Failed to delete check: {str(e)}",
http_status=500
)
def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
q = db.query(Check).filter(Check.business_id == business_id)
# جستجو
if query.get("search") and query.get("search_fields"):
term = f"%{query['search']}%"
conditions = []
for f in query["search_fields"]:
if f == "check_number":
conditions.append(Check.check_number.ilike(term))
elif f == "sayad_code":
conditions.append(Check.sayad_code.ilike(term))
elif f == "bank_name":
conditions.append(Check.bank_name.ilike(term))
elif f == "branch_name":
conditions.append(Check.branch_name.ilike(term))
elif f == "person_name":
# join به persons
q = q.join(Person, Check.person_id == Person.id, isouter=True)
conditions.append(Person.alias_name.ilike(term))
if conditions:
from sqlalchemy import or_
q = q.filter(or_(*conditions))
# فیلترها
if query.get("filters"):
from app.core.calendar import CalendarConverter
for flt in query["filters"]:
prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property')
op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator')
val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value')
if not prop or not op:
continue
if prop == 'type' and op == '=':
try:
enum_val = CheckType[str(val).upper()]
q = q.filter(Check.type == enum_val)
except Exception:
pass
elif prop == 'status':
try:
if op == '=' and isinstance(val, str) and val:
enum_val = CheckStatus[val]
q = q.filter(Check.status == enum_val)
elif op == 'in' and isinstance(val, list) and val:
enum_vals = []
for v in val:
try:
enum_vals.append(CheckStatus[str(v)])
except Exception:
pass
if enum_vals:
q = q.filter(Check.status.in_(enum_vals))
except Exception:
pass
elif prop == 'currency' and op == '=':
try:
q = q.filter(Check.currency_id == int(val))
except Exception:
pass
elif prop == 'person_id' and op == '=':
try:
q = q.filter(Check.person_id == int(val))
except Exception:
pass
elif prop in ('issue_date', 'due_date'):
# انتظار: فیلترهای بازه با اپراتورهای ">=" و "<=" از DataTable
try:
if isinstance(val, str) and val:
# ورودی تاریخ ممکن است بر اساس هدر تقویم باشد؛ در این لایه فرض بر ISO است (از فرانت ارسال می‌شود)
dt = _parse_iso(val)
col = getattr(Check, prop)
if op == ">=":
q = q.filter(col >= dt)
elif op == "<=":
q = q.filter(col <= dt)
except Exception:
pass
# additional params: person_id — صادرکننده/طرف ثبت یا واگذارشونده فعلی
person_param = query.get('person_id')
if person_param:
try:
pid = int(person_param)
q = q.filter(
or_(
Check.person_id == pid,
and_(
Check.current_holder_type == HolderType.PERSON,
Check.current_holder_id == pid,
),
)
)
except Exception:
pass
# مرتب‌سازی
from app.services.sqlalchemy_sort_from_query import apply_sqlalchemy_order_from_query_dict
q = apply_sqlalchemy_order_from_query_dict(
q,
Check,
query,
allowed_columns=None,
fallback_column="created_at",
default_sort_desc=bool(query.get("sort_desc", True)),
)
# صفحه‌بندی
skip = int(query.get("skip", 0))
take = int(query.get("take", 20))
total = q.count()
items = q.offset(skip).limit(take).all()
return {
"items": [check_to_dict(db, i) for i 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 check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]:
if obj is None:
return None
person_name = _person_display_name(db, obj.person_id)
current_holder_name = None
if obj.current_holder_type == HolderType.PERSON and obj.current_holder_id:
current_holder_name = _person_display_name(db, obj.current_holder_id)
currency_title = None
try:
c = db.query(Currency).filter(Currency.id == obj.currency_id).first()
currency_title = c.title or c.code if c else None
except Exception:
pass
endorsed_to_person_id = None
endorsed_to_person_name = None
if obj.status == CheckStatus.ENDORSED and obj.current_holder_id:
endorsed_to_person_id = obj.current_holder_id
endorsed_to_person_name = current_holder_name
return {
"id": obj.id,
"business_id": obj.business_id,
"type": obj.type.name.lower(),
"person_id": obj.person_id,
"person_name": person_name,
"drawer_person_id": obj.person_id,
"drawer_person_name": person_name,
"issue_date": obj.issue_date.isoformat(),
"due_date": obj.due_date.isoformat(),
"check_number": obj.check_number,
"sayad_code": obj.sayad_code,
"bank_name": obj.bank_name,
"branch_name": obj.branch_name,
"amount": float(obj.amount),
"currency_id": obj.currency_id,
"currency": currency_title,
"status": (obj.status.name if obj.status else None),
"status_at": (obj.status_at.isoformat() if obj.status_at else None),
"current_holder_type": (obj.current_holder_type.name if obj.current_holder_type else None),
"current_holder_id": obj.current_holder_id,
"current_holder_name": current_holder_name,
"endorsed_to_person_id": endorsed_to_person_id,
"endorsed_to_person_name": endorsed_to_person_name,
"last_action_document_id": obj.last_action_document_id,
"created_at": obj.created_at.isoformat(),
"updated_at": obj.updated_at.isoformat(),
}
def _history_action_label(db: Session, action_type: str, extra_info: Dict[str, Any]) -> str:
action_names = {
"endorse": "واگذاری",
"clear": "وصول/پاس",
"return": "عودت",
"return_from_endorsee": "برگشت از واگذارشونده",
"return_to_drawer": "عودت به صادرکننده",
"return_to_drawer_after_endorse": "عودت به صادرکننده (پس از لغو واگذاری)",
"return_transferred": "عودت چک پرداختنی",
"bounce": "برگشت از بانک",
"pay": "پرداخت",
"deposit": "سپرده",
"deposit_return_for_endorse": "برگشت از سپرده برای واگذاری",
"deposit_return_for_return": "برگشت از سپرده برای عودت",
}
base = action_names.get(action_type, "عملیات")
if action_type == "endorse":
name = _person_display_name(db, extra_info.get("target_person_id"))
if name:
return f"واگذاری به {name}"
if action_type == "return_from_endorsee":
name = _person_display_name(db, extra_info.get("target_person_id"))
if name:
return f"برگشت از واگذارشونده ({name})"
if action_type in ("return_to_drawer", "return_to_drawer_after_endorse"):
name = _person_display_name(db, extra_info.get("drawer_person_id"))
if name:
return f"عودت به صادرکننده ({name})"
return base
def get_check_history_and_documents(db: Session, check_id: int) -> Dict[str, Any]:
"""
دریافت سوابق چک و اسناد حسابداری مرتبط با چک
Args:
db: جلسه دیتابیس
check_id: شناسه چک
Returns:
دیکشنری حاوی:
- history: لیست سوابق چک (تغییرات وضعیت)
- documents: لیست اسناد حسابداری مرتبط
"""
# بررسی وجود چک
check = _load_check_or_404(db, check_id)
business_id = check.business_id
# 1. دریافت اسناد حسابداری مرتبط
related_documents: List[Document] = []
# 1.1. اسناد ایجاد شده برای چک (از طریق extra_info)
try:
documents_from_extra_info = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.extra_info.isnot(None),
or_(
Document.extra_info['source'].astext == 'check_create',
Document.extra_info['source'].astext == 'check_action'
)
)
).all()
except Exception:
documents_from_extra_info = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.extra_info.isnot(None)
)
).all()
documents_from_extra_info = [
doc for doc in documents_from_extra_info
if doc.extra_info and doc.extra_info.get("source") in ("check_create", "check_action")
]
for doc in documents_from_extra_info:
extra_info = doc.extra_info or {}
check_id_in_doc = extra_info.get("check_id")
if check_id_in_doc == check_id:
related_documents.append(doc)
# 1.2. اسناد دارای DocumentLine با check_id
document_lines_with_check = db.query(DocumentLine).filter(
DocumentLine.check_id == check_id
).all()
document_ids_from_lines = {line.document_id for line in document_lines_with_check}
if document_ids_from_lines:
documents_from_lines = db.query(Document).filter(
Document.id.in_(document_ids_from_lines)
).all()
for doc in documents_from_lines:
if doc not in related_documents:
related_documents.append(doc)
# 1.3. اسناد مرتبط از طریق last_action_document_id
if check.last_action_document_id:
last_action_doc = db.query(Document).filter(
Document.id == check.last_action_document_id
).first()
if last_action_doc and last_action_doc not in related_documents:
related_documents.append(last_action_doc)
# تبدیل اسناد به دیکشنری
documents_list = []
for doc in related_documents:
# دریافت اطلاعات کامل سند
from adapters.db.repositories.document_repository import DocumentRepository
repo = DocumentRepository(db)
doc_dict = repo.get_document_details(doc.id)
if doc_dict:
documents_list.append(doc_dict)
# 2. ساخت سوابق چک از اسناد
history = []
# سند ایجاد چک
for doc in related_documents:
extra_info = doc.extra_info or {}
if extra_info.get("source") == "check_create":
history.append({
"action": "ایجاد چک",
"action_type": "create",
"date": doc.document_date.isoformat() if doc.document_date else None,
"document_id": doc.id,
"document_code": doc.code,
"description": doc.description or "ایجاد چک",
})
# سوابق عملیات‌ها
for doc in related_documents:
extra_info = doc.extra_info or {}
if extra_info.get("source") == "check_action":
action_type = extra_info.get("action_type") or extra_info.get("action") or "unknown"
action_name = _history_action_label(db, action_type, extra_info)
history.append({
"action": action_name,
"action_type": action_type,
"date": doc.document_date.isoformat() if doc.document_date else None,
"document_id": doc.id,
"document_code": doc.code,
"description": doc.description or action_name,
"target_person_id": extra_info.get("target_person_id"),
"target_person_name": _person_display_name(db, extra_info.get("target_person_id")),
"drawer_person_id": extra_info.get("drawer_person_id"),
"drawer_person_name": _person_display_name(db, extra_info.get("drawer_person_id")),
})
# مرتب‌سازی سوابق بر اساس تاریخ
history.sort(key=lambda x: x.get("date") or "", reverse=True)
return {
"history": history,
"documents": documents_list,
}