hesabixArc/hesabixAPI/app/services/check_service.py
2025-11-02 09:41:38 +00:00

291 lines
11 KiB
Python

from __future__ import annotations
from typing import Any, Dict, List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from adapters.db.models.check import Check, CheckType
from adapters.db.models.person import Person
from adapters.db.models.currency import Currency
from app.core.responses import ApiError
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 create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
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')),
)
db.add(obj)
db.commit()
db.refresh(obj)
return check_to_dict(db, obj)
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
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'])
db.commit()
db.refresh(obj)
return check_to_dict(db, obj)
def delete_check(db: Session, check_id: int) -> bool:
obj = db.query(Check).filter(Check.id == check_id).first()
if obj is None:
return False
db.delete(obj)
db.commit()
return True
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 == '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:
q = q.filter(Check.person_id == int(person_param))
except Exception:
pass
# مرتب‌سازی
sort_by = query.get("sort_by") or "created_at"
sort_desc = bool(query.get("sort_desc", True))
col = getattr(Check, sort_by, Check.created_at)
q = q.order_by(col.desc() if sort_desc else col.asc())
# صفحه‌بندی
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 = None
if obj.person_id:
p = db.query(Person).filter(Person.id == obj.person_id).first()
person_name = getattr(p, 'alias_name', None)
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
return {
"id": obj.id,
"business_id": obj.business_id,
"type": obj.type.name.lower(),
"person_id": obj.person_id,
"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,
"created_at": obj.created_at.isoformat(),
"updated_at": obj.updated_at.isoformat(),
}