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

1001 lines
39 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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, timezone
from decimal import Decimal
import logging
from sqlalchemy.orm import Session
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.currency import Currency
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.user import User
from adapters.db.models.bank_account import BankAccount
from adapters.db.models.cash_register import CashRegister
from adapters.db.models.petty_cash import PettyCash
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__)
DOCUMENT_TYPE_TRANSFER = "transfer"
def _parse_iso_date(dt: str | datetime | date) -> date:
if isinstance(dt, date):
return dt
if isinstance(dt, datetime):
return dt.date()
dt_str = str(dt).strip()
try:
dt_str_clean = dt_str.replace('Z', '+00:00')
parsed = datetime.fromisoformat(dt_str_clean)
return parsed.date()
except Exception:
pass
try:
if len(dt_str) == 10 and dt_str.count('-') == 2:
return datetime.strptime(dt_str, '%Y-%m-%d').date()
except Exception:
pass
try:
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)
# سال‌های شمسی معمول حدود ۱۲۰۰–۱۶۰۰؛ سال‌های میلادی معمولاً >= ۱۷۰۰
if 1200 <= year_int <= 1600:
jalali_date = jdatetime.date(year_int, month_int, day_int)
return jalali_date.togregorian()
return datetime.strptime(dt_str, '%Y/%m/%d').date()
except (ValueError, jdatetime.JalaliDateError):
return datetime.strptime(dt_str, '%Y/%m/%d').date()
except Exception:
pass
raise ApiError("INVALID_DATE", f"Invalid date format: {dt}", http_status=400)
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True,
)
).first()
if not fiscal_year:
raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400)
return fiscal_year
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
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 _account_code_for_type(account_type: str) -> str:
if account_type == "bank":
return "10203"
if account_type == "cash_register":
return "10202"
if account_type == "petty_cash":
return "10201"
raise ApiError("INVALID_ACCOUNT_TYPE", f"Invalid account type: {account_type}", http_status=400)
def create_transfer(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
*,
commit: bool = True,
) -> Dict[str, Any]:
logger.info("=== شروع ایجاد سند انتقال ===")
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
currency_id = data.get("currency_id")
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
fiscal_year = _get_current_fiscal_year(db, business_id)
source = data.get("source") or {}
destination = data.get("destination") or {}
amount = Decimal(str(data.get("amount", 0)))
commission = Decimal(str(data.get("commission", 0))) if data.get("commission") is not None else Decimal(0)
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "amount must be greater than 0", http_status=400)
if commission < 0:
raise ApiError("INVALID_COMMISSION", "commission must be >= 0", http_status=400)
src_type = str(source.get("type") or "").strip()
dst_type = str(destination.get("type") or "").strip()
src_id = source.get("id")
dst_id = destination.get("id")
if src_type not in ("bank", "cash_register", "petty_cash"):
raise ApiError("INVALID_SOURCE", "source.type must be bank|cash_register|petty_cash", http_status=400)
if dst_type not in ("bank", "cash_register", "petty_cash"):
raise ApiError("INVALID_DESTINATION", "destination.type must be bank|cash_register|petty_cash", http_status=400)
if src_type == dst_type and src_id and dst_id and str(src_id) == str(dst_id):
raise ApiError("SAME_SOURCE_DESTINATION", "source and destination cannot be the same", http_status=400)
from app.services.cross_currency_settlement_service import (
FX_GAIN_ACCOUNT_CODE,
FX_LOSS_ACCOUNT_CODE,
resolve_cross_currency_transfer_plan,
)
dest_amount_raw = data.get("destination_amount")
fx_rate_raw = data.get("fx_rate") or data.get("exchange_rate")
src_rate_raw = data.get("source_rate_to_base")
dst_rate_raw = data.get("destination_rate_to_base")
xplan = resolve_cross_currency_transfer_plan(
db,
business_id=business_id,
source_type=src_type,
source_id=src_id,
destination_type=dst_type,
destination_id=dst_id,
source_amount=amount,
destination_amount=Decimal(str(dest_amount_raw)) if dest_amount_raw is not None else None,
fx_rate=Decimal(str(fx_rate_raw)) if fx_rate_raw is not None else None,
source_rate_to_base=Decimal(str(src_rate_raw)) if src_rate_raw is not None else None,
destination_rate_to_base=Decimal(str(dst_rate_raw)) if dst_rate_raw is not None else None,
as_of=(
datetime(document_date.year, document_date.month, document_date.day, tzinfo=timezone.utc)
if isinstance(document_date, date)
else None
),
)
if xplan is not None:
currency_id = int(xplan["document_currency_id"])
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)
# Resolve accounts by fixed codes
src_account = _get_fixed_account_by_code(db, _account_code_for_type(src_type))
dst_account = _get_fixed_account_by_code(db, _account_code_for_type(dst_type))
# Resolve names for auto description if needed
def _resolve_name(tp: str, _id: Any) -> str | None:
try:
if tp == "bank" and _id is not None:
ba = db.query(BankAccount).filter(BankAccount.id == int(_id)).first()
return ba.name if ba else None
if tp == "cash_register" and _id is not None:
cr = db.query(CashRegister).filter(CashRegister.id == int(_id)).first()
return cr.name if cr else None
if tp == "petty_cash" and _id is not None:
pc = db.query(PettyCash).filter(PettyCash.id == int(_id)).first()
return pc.name if pc else None
except Exception:
return None
return None
auto_description = None
if not data.get("description"):
src_name = _resolve_name(src_type, src_id) or "مبدأ"
dst_name = _resolve_name(dst_type, dst_id) or "مقصد"
# human readable types
def _type_name(tp: str) -> str:
return "حساب بانکی" if tp == "bank" else ("صندوق" if tp == "cash_register" else "تنخواه")
auto_description = f"انتقال از {_type_name(src_type)} {src_name} به {_type_name(dst_type)} {dst_name}"
ensure_document_policy_allows_creation(
db,
business_id,
document_type=DOCUMENT_TYPE_TRANSFER,
document_date=document_date,
amount=amount,
)
document: Document | None = None
max_code_attempts = 5
for _attempt in range(max_code_attempts):
doc_code = generate_document_code(db, business_id, DOCUMENT_TYPE_TRANSFER, document_date)
candidate = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code,
document_type=DOCUMENT_TYPE_TRANSFER,
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") or auto_description,
extra_info=data.get("extra_info"),
)
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,
)
# Destination line (Debit)
dest_kwargs: Dict[str, Any] = {}
if dst_type == "bank" and dst_id is not None:
try:
dest_kwargs["bank_account_id"] = int(dst_id)
except Exception:
pass
elif dst_type == "cash_register" and dst_id is not None:
dest_kwargs["cash_register_id"] = dst_id
elif dst_type == "petty_cash" and dst_id is not None:
dest_kwargs["petty_cash_id"] = dst_id
dest_line = DocumentLine(
document_id=document.id,
account_id=dst_account.id,
debit=(xplan["destination_base"] if xplan else amount),
credit=Decimal(0),
description=data.get("destination_description") or data.get("description"),
extra_info={
"side": "destination",
"destination_type": dst_type,
"destination_id": dst_id,
**({
"account_currency_amount": str(xplan["destination_amount"]),
"account_currency_id": int(xplan["destination_currency_id"]),
"cross_currency_transfer": True,
} if xplan else {}),
},
**dest_kwargs,
)
db.add(dest_line)
# Source line (Credit)
src_kwargs: Dict[str, Any] = {}
if src_type == "bank" and src_id is not None:
try:
src_kwargs["bank_account_id"] = int(src_id)
except Exception:
pass
elif src_type == "cash_register" and src_id is not None:
src_kwargs["cash_register_id"] = src_id
elif src_type == "petty_cash" and src_id is not None:
src_kwargs["petty_cash_id"] = src_id
src_line = DocumentLine(
document_id=document.id,
account_id=src_account.id,
debit=Decimal(0),
credit=(xplan["source_base"] if xplan else amount),
description=data.get("source_description") or data.get("description"),
extra_info={
"side": "source",
"source_type": src_type,
"source_id": src_id,
**({
"account_currency_amount": str(xplan["source_amount"]),
"account_currency_id": int(xplan["source_currency_id"]),
"cross_currency_transfer": True,
} if xplan else {}),
},
**src_kwargs,
)
db.add(src_line)
# تسعیر اختلاف مبدأ/مقصد در انتقال بین‌ارزی
if xplan is not None:
fx_diff = Decimal(str(xplan["fx_diff"]))
if abs(fx_diff) > 0:
if fx_diff > 0:
# منبع پایه بیشتر از مقصد → زیان
fx_acc = _get_fixed_account_by_code(db, FX_LOSS_ACCOUNT_CODE)
db.add(DocumentLine(
document_id=document.id,
account_id=fx_acc.id,
debit=fx_diff,
credit=Decimal(0),
description="زیان تسعیر ارز انتقال",
extra_info={"fx_adjustment": True, "cross_currency_transfer": True},
))
else:
fx_acc = _get_fixed_account_by_code(db, FX_GAIN_ACCOUNT_CODE)
db.add(DocumentLine(
document_id=document.id,
account_id=fx_acc.id,
debit=Decimal(0),
credit=(-fx_diff),
description="سود تسعیر ارز انتقال",
extra_info={"fx_adjustment": True, "cross_currency_transfer": True},
))
# برای تراز: اگر fx_diff اعمال شد، مبدأ/مقصد ممکن است نیاز به تنظیم داشته باشند
# با تعریف fx_diff = source_base - destination_base:
# Dr dest (dest_base) + Dr loss(fx) = Cr src (src_base) وقتی fx>0
# Dr dest (dest_base) = Cr src (src_base) + Cr gain(-fx) وقتی fx<0
if commission > 0 and xplan is None:
# Debit commission expense 70902
commission_service_account = _get_fixed_account_by_code(db, "70902")
commission_expense_line = DocumentLine(
document_id=document.id,
account_id=commission_service_account.id,
debit=commission,
credit=Decimal(0),
description="کارمزد خدمات بانکی",
extra_info={
"side": "commission",
"is_commission_line": True,
},
**src_kwargs,
)
db.add(commission_expense_line)
# Credit commission to source account (increase credit of source)
commission_credit_line = DocumentLine(
document_id=document.id,
account_id=src_account.id,
debit=Decimal(0),
credit=commission,
description="کارمزد انتقال (ثبت در مبدأ)",
extra_info={
"side": "commission",
"is_commission_line": True,
"source_type": src_type,
"source_id": src_id,
},
**src_kwargs,
)
db.add(commission_credit_line)
if xplan is not None:
extra = dict(document.extra_info or {}) if isinstance(document.extra_info, dict) else {}
extra["cross_currency_transfer"] = {
"source_currency_id": int(xplan["source_currency_id"]),
"destination_currency_id": int(xplan["destination_currency_id"]),
"source_amount": str(xplan["source_amount"]),
"destination_amount": str(xplan["destination_amount"]),
"rate": str(xplan["rate"]),
"fx_diff": str(xplan["fx_diff"]),
}
document.extra_info = extra
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)
except Exception:
logger.exception("transfer fx base stamp failed")
if commit:
db.commit()
db.refresh(document)
else:
db.flush()
result = transfer_document_to_dict(db, document)
if not commit:
return result
# 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_TRANSFER
)
return result
def get_transfer(db: Session, document_id: int) -> Optional[Dict[str, Any]]:
document = db.query(Document).filter(Document.id == document_id).first()
if not document or document.document_type != DOCUMENT_TYPE_TRANSFER:
return None
return transfer_document_to_dict(db, document)
def _apply_transfer_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_transfers(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 == DOCUMENT_TYPE_TRANSFER,
)
)
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)
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)
except Exception:
pass
if to_date:
try:
to_dt = _parse_iso_date(to_date)
q = q.filter(Document.document_date <= to_dt)
except Exception:
pass
project_id_raw = query.get("project_id")
if project_id_raw is not None:
try:
q = q.filter(Document.project_id == int(project_id_raw))
except (TypeError, ValueError):
pass
ba_f = query.get("bank_account_id")
if ba_f is not None:
try:
bid = int(ba_f)
q = q.filter(
exists().where(
and_(
DocumentLine.document_id == Document.id,
DocumentLine.bank_account_id == bid,
)
)
)
except (TypeError, ValueError):
pass
cr_f = query.get("cash_register_id")
if cr_f is not None:
try:
cid = int(cr_f)
q = q.filter(
exists().where(
and_(
DocumentLine.document_id == Document.id,
DocumentLine.cash_register_id == cid,
)
)
)
except (TypeError, ValueError):
pass
pc_f = query.get("petty_cash_id")
if pc_f is not None:
try:
pid_pc = int(pc_f)
q = q.filter(
exists().where(
and_(
DocumentLine.document_id == Document.id,
DocumentLine.petty_cash_id == pid_pc,
)
)
)
except (TypeError, ValueError):
pass
# Apply advanced filters (e.g., DataTable date range filters)
filters = query.get("filters")
if filters and isinstance(filters, (list, tuple)):
for flt in filters:
try:
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 == 'document_date':
if isinstance(val, str) and val:
try:
dt = _parse_iso_date(val)
col = getattr(Document, prop)
if op == ">=":
q = q.filter(col >= dt)
elif op == "<=":
q = q.filter(col <= dt)
except Exception:
pass
elif prop == "project_name" and str(op).strip().lower() == "in" and val:
ids: List[int] = []
for x in (val if isinstance(val, list) else [val]):
try:
ids.append(int(x))
except (TypeError, ValueError):
continue
if ids:
q = q.filter(Document.project_id.in_(ids))
elif prop == "project_name" and str(op).strip().lower() == "=" and val not in (None, ""):
try:
q = q.filter(Document.project_id == int(val))
except (TypeError, ValueError):
pass
except Exception:
pass
q = _apply_transfer_search(db, q, query)
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": [transfer_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_transfer(db: Session, document_id: int, *, commit: bool = True) -> bool:
document = db.query(Document).filter(Document.id == document_id).first()
if not document or document.document_type != DOCUMENT_TYPE_TRANSFER:
return False
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:
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
# دریافت اطلاعات قبل از حذف برای invalidation
business_id = document.business_id
fiscal_year_id = document.fiscal_year_id
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_TRANSFER
)
return True
def update_transfer(
db: Session,
document_id: int,
user_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
document = db.query(Document).filter(Document.id == document_id).first()
if document is None or document.document_type != DOCUMENT_TYPE_TRANSFER:
raise ApiError("DOCUMENT_NOT_FOUND", "Transfer document not found", http_status=404)
# دریافت اطلاعات قبل از به‌روزرسانی برای invalidation
old_fiscal_year_id = document.fiscal_year_id
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
document_date = _parse_iso_date(data.get("document_date", document.document_date))
currency_id = data.get("currency_id", document.currency_id)
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
source = data.get("source") or {}
destination = data.get("destination") or {}
amount = Decimal(str(data.get("amount", 0)))
commission = Decimal(str(data.get("commission", 0))) if data.get("commission") is not None else Decimal(0)
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "amount must be greater than 0", http_status=400)
if commission < 0:
raise ApiError("INVALID_COMMISSION", "commission must be >= 0", http_status=400)
src_type = str(source.get("type") or "").strip()
dst_type = str(destination.get("type") or "").strip()
src_id = source.get("id")
dst_id = destination.get("id")
if src_type not in ("bank", "cash_register", "petty_cash"):
raise ApiError("INVALID_SOURCE", "source.type must be bank|cash_register|petty_cash", http_status=400)
if dst_type not in ("bank", "cash_register", "petty_cash"):
raise ApiError("INVALID_DESTINATION", "destination.type must be bank|cash_register|petty_cash", http_status=400)
if src_type == dst_type and src_id and dst_id and str(src_id) == str(dst_id):
raise ApiError("SAME_SOURCE_DESTINATION", "source and destination cannot be the same", http_status=400)
# Update document fields
document.document_date = document_date
document.currency_id = int(currency_id)
if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None:
document.extra_info = data.get("extra_info")
if isinstance(data.get("description"), str) or data.get("description") is None:
if data.get("description"):
document.description = data.get("description")
else:
# regenerate auto description
def _resolve_name(tp: str, _id: Any) -> str | None:
try:
if tp == "bank" and _id is not None:
ba = db.query(BankAccount).filter(BankAccount.id == int(_id)).first()
return ba.name if ba else None
if tp == "cash_register" and _id is not None:
cr = db.query(CashRegister).filter(CashRegister.id == int(_id)).first()
return cr.name if cr else None
if tp == "petty_cash" and _id is not None:
pc = db.query(PettyCash).filter(PettyCash.id == int(_id)).first()
return pc.name if pc else None
except Exception:
return None
return None
def _type_name(tp: str) -> str:
return "حساب بانکی" if tp == "bank" else ("صندوق" if tp == "cash_register" else "تنخواه")
src_name = _resolve_name(src_type, src_id) or "مبدأ"
dst_name = _resolve_name(dst_type, dst_id) or "مقصد"
document.description = f"انتقال از {_type_name(src_type)} {src_name} به {_type_name(dst_type)} {dst_name}"
# Remove old lines and recreate
db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False)
src_account = _get_fixed_account_by_code(db, _account_code_for_type(src_type))
dst_account = _get_fixed_account_by_code(db, _account_code_for_type(dst_type))
dest_kwargs: Dict[str, Any] = {}
if dst_type == "bank" and dst_id is not None:
try:
dest_kwargs["bank_account_id"] = int(dst_id)
except Exception:
pass
elif dst_type == "cash_register" and dst_id is not None:
dest_kwargs["cash_register_id"] = dst_id
elif dst_type == "petty_cash" and dst_id is not None:
dest_kwargs["petty_cash_id"] = dst_id
db.add(DocumentLine(
document_id=document.id,
account_id=dst_account.id,
debit=amount,
credit=Decimal(0),
description=data.get("destination_description") or data.get("description"),
extra_info={
"side": "destination",
"destination_type": dst_type,
"destination_id": dst_id,
},
**dest_kwargs,
))
src_kwargs: Dict[str, Any] = {}
if src_type == "bank" and src_id is not None:
try:
src_kwargs["bank_account_id"] = int(src_id)
except Exception:
pass
elif src_type == "cash_register" and src_id is not None:
src_kwargs["cash_register_id"] = src_id
elif src_type == "petty_cash" and src_id is not None:
src_kwargs["petty_cash_id"] = src_id
db.add(DocumentLine(
document_id=document.id,
account_id=src_account.id,
debit=Decimal(0),
credit=amount,
description=data.get("source_description") or data.get("description"),
extra_info={
"side": "source",
"source_type": src_type,
"source_id": src_id,
},
**src_kwargs,
))
if commission > 0:
commission_service_account = _get_fixed_account_by_code(db, "70902")
db.add(DocumentLine(
document_id=document.id,
account_id=commission_service_account.id,
debit=commission,
credit=Decimal(0),
description="کارمزد خدمات بانکی",
extra_info={
"side": "commission",
"is_commission_line": True,
},
**src_kwargs,
))
db.add(DocumentLine(
document_id=document.id,
account_id=src_account.id,
debit=Decimal(0),
credit=commission,
description="کارمزد انتقال (ثبت در مبدأ)",
extra_info={
"side": "commission",
"is_commission_line": True,
"source_type": src_type,
"source_id": src_id,
},
**src_kwargs,
))
db.commit()
db.refresh(document)
result = transfer_document_to_dict(db, document)
# Invalidate cache بعد از به‌روزرسانی موفق سند انتقال
from app.services.document_service import invalidate_documents_cache
invalidate_documents_cache(
business_id=document.business_id,
fiscal_year_id=document.fiscal_year_id,
document_id=document.id,
document_type=DOCUMENT_TYPE_TRANSFER
)
return result
def transfer_document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all()
account_lines = []
source_name = None
destination_name = None
source_type = None
destination_type = None
for line in lines:
account = db.query(Account).filter(Account.id == line.account_id).first()
if not account:
continue
line_dict: Dict[str, Any] = {
"id": line.id,
"account_id": line.account_id,
"bank_account_id": line.bank_account_id,
"cash_register_id": line.cash_register_id,
"petty_cash_id": line.petty_cash_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,
}
if line.extra_info:
if "side" in line.extra_info:
line_dict["side"] = line.extra_info["side"]
if "source_type" in line.extra_info:
line_dict["source_type"] = line.extra_info["source_type"]
# Only assign source_type from source lines
if line_dict.get("side") == "source":
source_type = source_type or line.extra_info["source_type"]
if "destination_type" in line.extra_info:
line_dict["destination_type"] = line.extra_info["destination_type"]
# Only assign destination_type from destination lines
if line_dict.get("side") == "destination":
destination_type = destination_type or line.extra_info["destination_type"]
if "is_commission_line" in line.extra_info:
line_dict["is_commission_line"] = line.extra_info["is_commission_line"]
# capture source/destination names from linked entities
try:
if line_dict.get("side") == "source":
if line_dict.get("bank_account_id"):
ba = db.query(BankAccount).filter(BankAccount.id == int(line_dict["bank_account_id"])) .first()
source_name = ba.name if ba else source_name
elif line_dict.get("cash_register_id"):
cr = db.query(CashRegister).filter(CashRegister.id == int(line_dict["cash_register_id"])) .first()
source_name = cr.name if cr else source_name
elif line_dict.get("petty_cash_id"):
pc = db.query(PettyCash).filter(PettyCash.id == int(line_dict["petty_cash_id"])) .first()
source_name = pc.name if pc else source_name
elif line_dict.get("side") == "destination":
if line_dict.get("bank_account_id"):
ba = db.query(BankAccount).filter(BankAccount.id == int(line_dict["bank_account_id"])) .first()
destination_name = ba.name if ba else destination_name
elif line_dict.get("cash_register_id"):
cr = db.query(CashRegister).filter(CashRegister.id == int(line_dict["cash_register_id"])) .first()
destination_name = cr.name if cr else destination_name
elif line_dict.get("petty_cash_id"):
pc = db.query(PettyCash).filter(PettyCash.id == int(line_dict["petty_cash_id"])) .first()
destination_name = pc.name if pc else destination_name
except Exception:
pass
account_lines.append(line_dict)
# Compute total as sum of debits of non-commission lines (destination line amount)
total_amount = sum(l.get("debit", 0) for l in account_lines if not l.get("is_commission_line"))
# Compute commission from commission expense lines
commission = sum(l.get("debit", 0) for l in account_lines if l.get("is_commission_line") and l.get("side") == "commission")
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
# Helper function to get type name in Persian
def _get_type_name(tp: str) -> str:
if tp == "bank":
return "حساب بانکی"
elif tp == "cash_register":
return "صندوق"
elif tp == "petty_cash":
return "تنخواه"
return tp or ""
source_type_name = _get_type_name(source_type) if source_type else ""
destination_type_name = _get_type_name(destination_type) if destination_type else ""
# Convert dates to datetime for proper formatting
document_date_dt = datetime.combine(document.document_date, datetime.min.time()) if document.document_date else None
registered_at_dt = document.registered_at if document.registered_at else None
# دریافت نام پروژه
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_date": document_date_dt, # Keep as datetime for format_datetime_fields
"registered_at": registered_at_dt, # Keep as datetime for format_datetime_fields
"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,
"source_type": source_type,
"source_type_name": source_type_name,
"source_name": source_name,
"destination_type": destination_type,
"destination_type_name": destination_type_name,
"destination_name": destination_name,
"commission": float(commission),
"extra_info": document.extra_info,
"person_lines": [],
"account_lines": account_lines,
"total_amount": float(total_amount),
"person_lines_count": 0,
"account_lines_count": len(account_lines),
"created_at": document.created_at if hasattr(document, 'created_at') and document.created_at else None,
"updated_at": document.updated_at if hasattr(document, 'updated_at') and document.updated_at else None,
}