forked from hesabix/arc
1001 lines
39 KiB
Python
Executable file
1001 lines
39 KiB
Python
Executable file
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,
|
||
}
|
||
|
||
|