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

2634 lines
99 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 typing import List, Optional, Dict, Any
from decimal import Decimal
from datetime import datetime, time, timezone, date
import json
from sqlalchemy.exc import IntegrityError
from app.core.responses import ApiError
from sqlalchemy.orm import Session, joinedload, selectinload
from sqlalchemy import and_, or_, func, String
from adapters.db.models.person import Person, PersonBankAccount, PersonSocialContact, PersonType
from adapters.db.models.person_group import PersonGroup
from adapters.db.models.business import Business
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.api.v1.schema_models.person import (
PersonCreateRequest,
PersonUpdateRequest,
PersonBankAccountCreateRequest,
PersonSocialContactInput,
)
from app.core.responses import success_response
from app.core.cache import get_cache
from app.services.person_group_service import (
assert_assignable_person_group,
merge_person_create_with_group_defaults,
)
import logging
logger = logging.getLogger(__name__)
def invalidate_persons_cache(business_id: int, fiscal_year_id: Optional[int] = None):
"""
حذف تمام کش‌های مربوط به لیست اشخاص یک کسب‌وکار
این تابع از چند روش استفاده می‌کند:
1. Tag-based invalidation با set ردیس: حذف انتخابی بر اساس business_id و fiscal_year_id (بهینه‌تر)
2. Pattern-based invalidation: حذف تمام کلیدهای persons_list:* (fallback برای اطمینان)
3. Redis Pub/Sub: انتشار پیام invalidation برای تمام instanceها
Args:
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
- اگر None باشد، تمام کش‌های مربوط به business_id حذف می‌شوند
- اگر مشخص باشد، فقط کش‌های مربوط به آن fiscal_year_id حذف می‌شوند
"""
cache = get_cache()
if not cache.enabled:
return
try:
# روش 1: استفاده از invalidate_by_business (بهینه‌ترین روش)
# این متد از set ردیس برای نگهداری کلیدها استفاده می‌کند
deleted_count = cache.invalidate_by_business(business_id, fiscal_year_id)
if deleted_count > 0:
logger.info(f"Invalidated {deleted_count} cache keys for business_id {business_id}, fiscal_year_id {fiscal_year_id}")
# روش 2: حذف تمام کلیدهای persons_list:* (fallback برای اطمینان کامل)
# این کار برای اطمینان از حذف کامل کش انجام می‌شود
# (در صورت وجود کلیدهای قدیمی که با tag-based ذخیره نشده‌اند)
pattern = "persons_list:*"
deleted_pattern = cache.delete_pattern(pattern)
if deleted_pattern > 0:
logger.info(f"Invalidated {deleted_pattern} cache keys using pattern: {pattern}")
# روش 3: انتشار پیام invalidation از طریق Redis Pub/Sub
# این کار باعث می‌شود که تمام instanceهای برنامه کش را invalidate کنند
invalidation_message = {
"type": "persons_cache_invalidation",
"business_id": business_id,
"fiscal_year_id": fiscal_year_id,
"timestamp": None
}
try:
import time
invalidation_message["timestamp"] = time.time()
cache.publish_invalidation("cache_invalidation", invalidation_message)
logger.info(f"Published invalidation message for business_id {business_id}, fiscal_year_id {fiscal_year_id}")
except Exception as pub_error:
logger.warning(f"Error publishing invalidation message: {pub_error}")
except Exception as e:
# خطا در invalidate نباید مانع عملیات اصلی شود
logger.warning(f"Error invalidating persons cache for business_id {business_id}: {e}")
def create_person(
db: Session,
business_id: int,
person_data: PersonCreateRequest,
*,
defer_cache_invalidation: bool = False,
) -> Dict[str, Any]:
"""ایجاد شخص جدید"""
person_data = merge_person_create_with_group_defaults(db, business_id, person_data)
# محاسبه/اعتبارسنجی کد یکتا
code: Optional[int] = getattr(person_data, 'code', None)
if code is not None:
exists = db.query(Person).filter(
and_(Person.business_id == business_id, Person.code == code)
).first()
if exists:
raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400)
else:
# تولید خودکار کد: بیشینه فعلی + 1 (نسبت به همان کسب و کار)
max_code = db.query(func.max(Person.code)).filter(Person.business_id == business_id).scalar()
code = (max_code or 0) + 1
# آماده‌سازی person_types (چندانتخابی) و سازگاری person_type تکی
types_list: List[str] = []
if getattr(person_data, 'person_types', None):
types_list = [t.value if hasattr(t, 'value') else str(t) for t in person_data.person_types] # type: ignore[attr-defined]
elif getattr(person_data, 'person_type', None):
t = person_data.person_type
types_list = [t.value if hasattr(t, 'value') else str(t)]
# حداقل یک نوع شخص الزامی است
if not types_list:
raise ApiError(
"PERSON_TYPE_REQUIRED",
"نوع شخص الزامی است",
http_status=400,
)
# نوع تکی برای استفاده‌های بعدی (قبل از هر استفاده تعریف شود)
incoming_single_type = getattr(person_data, 'person_type', None)
# اعتبارسنجی سهام برای سهامدار
is_shareholder = False
if types_list:
is_shareholder = 'سهامدار' in types_list
if not is_shareholder and incoming_single_type is not None:
try:
is_shareholder = (getattr(incoming_single_type, 'value', str(incoming_single_type)) == 'سهامدار')
except Exception:
is_shareholder = False
if is_shareholder:
sc_val = getattr(person_data, 'share_count', None)
if sc_val is None or not isinstance(sc_val, int) or sc_val <= 0:
raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
# ایجاد شخص
# نگاشت person_type دریافتی از اسکیما به Enum مدل
mapped_single_type = None
if incoming_single_type is not None:
try:
# incoming_single_type.value مقدار فارسی مانند "سهامدار"
mapped_single_type = PersonType(getattr(incoming_single_type, 'value', str(incoming_single_type)))
except Exception:
mapped_single_type = None
# بارگذاری تنظیمات اعتبار پیش‌فرض کسب‌وکار
business_defaults = db.query(Business).filter(Business.id == business_id).first()
default_credit_limit = getattr(business_defaults, "default_credit_limit", None) if business_defaults else None
default_check_enabled = bool(getattr(business_defaults, "check_credit_enabled_by_default", False)) if business_defaults else False
person = Person(
business_id=business_id,
person_group_id=getattr(person_data, "person_group_id", None),
code=code,
alias_name=person_data.alias_name,
first_name=person_data.first_name,
last_name=person_data.last_name,
# ذخیره مقدار Enum با مقدار فارسی (values_callable در مدل مقادیر فارسی را می‌نویسد)
# person_types نباید None باشد (nullable=False در مدل)
person_types=json.dumps(types_list, ensure_ascii=False) if types_list else "[]",
company_name=person_data.company_name,
name_prefix=getattr(person_data, "name_prefix", None),
legal_entity_type=getattr(person_data, "legal_entity_type", None) or "natural",
payment_id=person_data.payment_id,
national_id=person_data.national_id,
registration_number=person_data.registration_number,
economic_id=person_data.economic_id,
country=person_data.country,
province=person_data.province,
city=person_data.city,
address=person_data.address,
postal_code=person_data.postal_code,
phone=person_data.phone,
mobile=person_data.mobile,
mobile_2=person_data.mobile_2,
mobile_3=person_data.mobile_3,
fax=person_data.fax,
email=person_data.email,
website=person_data.website,
share_count=getattr(person_data, 'share_count', None),
commission_sale_percent=getattr(person_data, 'commission_sale_percent', None),
commission_sales_return_percent=getattr(person_data, 'commission_sales_return_percent', None),
commission_sales_amount=getattr(person_data, 'commission_sales_amount', None),
commission_sales_return_amount=getattr(person_data, 'commission_sales_return_amount', None),
commission_exclude_discounts=bool(getattr(person_data, 'commission_exclude_discounts', False)),
commission_exclude_additions_deductions=bool(getattr(person_data, 'commission_exclude_additions_deductions', False)),
commission_post_in_invoice_document=bool(getattr(person_data, 'commission_post_in_invoice_document', False)),
# اعتبار: اگر کاربر مقدار نداد، از تنظیمات کسب‌وکار استفاده شود
credit_limit=(getattr(person_data, 'credit_limit', None) if getattr(person_data, 'credit_limit', None) is not None else default_credit_limit),
credit_check_enabled=(getattr(person_data, 'credit_check_enabled', None) if getattr(person_data, 'credit_check_enabled', None) is not None else default_check_enabled),
)
db.add(person)
db.flush() # برای دریافت ID
# ایجاد حساب‌های بانکی
if person_data.bank_accounts:
for bank_account_data in person_data.bank_accounts:
bank_account = PersonBankAccount(
person_id=person.id,
bank_name=bank_account_data.bank_name,
account_number=bank_account_data.account_number,
card_number=bank_account_data.card_number,
sheba_number=bank_account_data.sheba_number,
)
db.add(bank_account)
_socials = getattr(person_data, "social_contacts", None) or []
for i, sc in enumerate(_socials):
db.add(
PersonSocialContact(
person_id=person.id,
platform_key=sc.platform_key,
custom_label=sc.custom_label,
value=sc.value,
sort_order=i,
)
)
try:
db.commit()
except IntegrityError:
db.rollback()
raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400)
db.refresh(person)
# Invalidate کش لیست اشخاص (در bulk با defer از بیرون یک‌بار invalidate می‌شود)
if not defer_cache_invalidation:
invalidate_persons_cache(business_id, fiscal_year_id=None)
# فراخوانی workflow triggers
try:
from app.services.workflow.workflow_trigger_service import trigger_person_created
person_types_list = json.loads(person.person_types) if person.person_types else []
trigger_person_created(
db=db,
business_id=business_id,
person_id=person.id,
person_types=person_types_list,
user_id=None # می‌توان user_id را از context دریافت کرد
)
except Exception as e:
# عدم موفقیت در trigger نباید مانع بازگشت شخص شود
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to trigger workflows for person {person.id}: {e}")
return success_response(
message="شخص با موفقیت ایجاد شد",
data=_person_to_dict(person)
)
def get_person_by_id(
db: Session,
person_id: int,
business_id: int,
fiscal_year_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
"""دریافت شخص بر اساس شناسه (همراه با تراز و وضعیت مالی در سال مالی انتخاب‌شده یا جاری)"""
person = (
db.query(Person)
.options(
joinedload(Person.person_group),
selectinload(Person.bank_accounts),
selectinload(Person.social_contacts),
)
.filter(and_(Person.id == person_id, Person.business_id == business_id))
.first()
)
if not person:
return None
data = _person_to_dict(person)
fy_id = fiscal_year_id
if not fy_id:
fiscal_year = db.query(FiscalYear).filter(
and_(FiscalYear.business_id == business_id, FiscalYear.is_last == True)
).first()
fy_id = fiscal_year.id if fiscal_year else None
breakdown = calculate_person_balance_breakdown(db, person_id, fiscal_year_id=fy_id)
data["balance"] = breakdown["balance"]
data["status"] = breakdown["status"]
data["total_debit"] = breakdown["total_debit"]
data["total_credit"] = breakdown["total_credit"]
return data
def _person_sort_needs_balance_materialization(query_info: Dict[str, Any]) -> bool:
if query_info.get("sort_by") in ("balance", "status"):
return True
raw = query_info.get("sort")
if not isinstance(raw, list):
return False
for it in raw:
by = it.get("by") if isinstance(it, dict) else None
if by in ("balance", "status"):
return True
return False
def get_persons_by_business(
db: Session,
business_id: int,
query_info: Dict[str, Any],
fiscal_year_id: Optional[int] = None
) -> Dict[str, Any]:
"""دریافت لیست اشخاص با جستجو و فیلتر"""
query = (
db.query(Person)
.options(
joinedload(Person.person_group),
selectinload(Person.bank_accounts),
selectinload(Person.social_contacts),
)
.filter(Person.business_id == business_id)
)
# بررسی نیاز به محاسبه تراز قبل از pagination
# (برای فیلتر یا مرتب‌سازی بر اساس تراز/وضعیت)
needs_balance_before_pagination = _person_sort_needs_balance_materialization(query_info)
sort_by = query_info.get('sort_by', 'created_at')
# بررسی فیلترها برای balance و status
if query_info.get('filters'):
for filter_item in query_info['filters']:
if isinstance(filter_item, dict):
field = filter_item.get('property')
else:
field = getattr(filter_item, 'property', None)
if field in ['balance', 'status']:
needs_balance_before_pagination = True
break
# اعمال جستجو
if query_info.get('search') and query_info.get('search_fields'):
search_term = f"%{query_info['search']}%"
search_conditions = []
for field in query_info['search_fields']:
if field == 'code':
# تبدیل به رشته برای جستجو مانند LIKE
try:
code_int = int(query_info['search']) # type: ignore[arg-type]
search_conditions.append(Person.code == code_int)
except Exception:
pass
if field == 'alias_name':
search_conditions.append(Person.alias_name.ilike(search_term))
elif field == 'first_name':
search_conditions.append(Person.first_name.ilike(search_term))
elif field == 'last_name':
search_conditions.append(Person.last_name.ilike(search_term))
elif field == 'company_name':
search_conditions.append(Person.company_name.ilike(search_term))
elif field == 'mobile':
search_conditions.append(
or_(
Person.mobile.ilike(search_term),
Person.mobile_2.ilike(search_term),
Person.mobile_3.ilike(search_term),
)
)
elif field == 'email':
search_conditions.append(Person.email.ilike(search_term))
elif field == 'national_id':
search_conditions.append(Person.national_id.ilike(search_term))
elif field == "social_value":
search_conditions.append(
Person.id.in_(
db.query(PersonSocialContact.person_id).filter(
PersonSocialContact.value.ilike(search_term)
)
)
)
if search_conditions:
query = query.filter(or_(*search_conditions))
def apply_text_filter(base_query, column, operator: str, value):
q = base_query
if operator == '=':
q = q.filter(column == value)
elif operator in ('like', '*'):
q = q.filter(column.ilike(f"%{value}%"))
elif operator == '*?': # starts with
q = q.filter(column.ilike(f"{value}%"))
elif operator == '?*': # ends with
q = q.filter(column.ilike(f"%{value}"))
elif operator == 'in' and isinstance(value, list):
vals = [v for v in value if v is not None and str(v).strip() != ""]
if vals:
q = q.filter(column.in_(vals))
return q
def _to_int_or_none(v):
try:
return int(v)
except Exception:
return None
column_aliases = {
# UI ستون نوع شخص را با person_type می‌فرستد ولی در DB/سرویس person_types است.
'person_type': 'person_types',
}
text_columns = {
'alias_name': Person.alias_name,
'first_name': Person.first_name,
'last_name': Person.last_name,
'company_name': Person.company_name,
'name_prefix': Person.name_prefix,
'legal_entity_type': Person.legal_entity_type,
'mobile': Person.mobile,
'mobile_2': Person.mobile_2,
'mobile_3': Person.mobile_3,
'phone': Person.phone,
'fax': Person.fax,
'email': Person.email,
'website': Person.website,
'payment_id': Person.payment_id,
'national_id': Person.national_id,
'registration_number': Person.registration_number,
'economic_id': Person.economic_id,
'country': Person.country,
'province': Person.province,
'city': Person.city,
'address': Person.address,
'postal_code': Person.postal_code,
}
number_columns = {
'code': Person.code,
'person_group_id': Person.person_group_id,
'share_count': Person.share_count,
'commission_sale_percent': Person.commission_sale_percent,
'commission_sales_return_percent': Person.commission_sales_return_percent,
'commission_sales_amount': Person.commission_sales_amount,
'commission_sales_return_amount': Person.commission_sales_return_amount,
}
# اعمال فیلترها
if query_info.get('filters'):
for filter_item in query_info['filters']:
# پشتیبانی از هر دو حالت: دیکشنری یا شیء Pydantic
if isinstance(filter_item, dict):
field = filter_item.get('property')
operator = filter_item.get('operator')
value = filter_item.get('value')
else:
field = getattr(filter_item, 'property', None)
operator = getattr(filter_item, 'operator', None)
value = getattr(filter_item, 'value', None)
if not field or not operator:
continue
field = column_aliases.get(field, field)
# فیلترهای عددی
if field in number_columns:
col = number_columns[field]
if operator == '=':
if field in ('code', 'person_group_id', 'share_count'):
iv = _to_int_or_none(value)
if iv is not None:
query = query.filter(col == iv)
else:
query = query.filter(col == value)
elif operator == 'in' and isinstance(value, list):
if field in ('code', 'person_group_id', 'share_count'):
ivals = [iv for iv in (_to_int_or_none(v) for v in value) if iv is not None]
if ivals:
query = query.filter(col.in_(ivals))
else:
query = query.filter(col.in_(value))
continue
# انواع شخص چندانتخابی (رشته JSON)
if field == 'person_types':
if operator == '=' and isinstance(value, str):
query = query.filter(Person.person_types.ilike(f'%"{value}"%'))
elif operator == 'in' and isinstance(value, list):
sub_filters = [Person.person_types.ilike(f'%"{v}"%') for v in value if v is not None and str(v).strip() != ""]
if sub_filters:
query = query.filter(or_(*sub_filters))
continue
# ستون نمایشی گروه اشخاص در UI (person_group_name)
if field == 'person_group_name':
if operator == '=':
query = query.filter(Person.person_group.has(PersonGroup.name == value))
elif operator in ('like', '*'):
query = query.filter(Person.person_group.has(PersonGroup.name.ilike(f"%{value}%")))
elif operator == '*?':
query = query.filter(Person.person_group.has(PersonGroup.name.ilike(f"{value}%")))
elif operator == '?*':
query = query.filter(Person.person_group.has(PersonGroup.name.ilike(f"%{value}")))
elif operator == 'in' and isinstance(value, list):
vals = [v for v in value if v is not None and str(v).strip() != ""]
if vals:
query = query.filter(Person.person_group.has(PersonGroup.name.in_(vals)))
continue
# فیلترهای متنی عمومی
if field in text_columns:
query = apply_text_filter(query, text_columns[field], operator, value)
continue
# شمارش کل رکوردها
total = query.count()
# اعمال مرتب‌سازی (فقط برای فیلدهای دیتابیس؛ چندستونه sort در اولویت)
sort_desc = query_info.get('sort_desc', True)
if not needs_balance_before_pagination:
from app.services.sort_resolution import effective_sort_specs as _eff_specs
from app.services.sort_resolution import query_info_for_sort as _qi_for_sort
_sql_allowed = frozenset({"code", "alias_name", "first_name", "last_name", "created_at", "updated_at"})
_qi = _qi_for_sort(query_info, default_sort_desc=bool(sort_desc))
_specs = _eff_specs(_qi, allowed=_sql_allowed, default_when_empty=("created_at", True))
_parts = []
for _n, _d in _specs:
if not hasattr(Person, _n):
continue
_c = getattr(Person, _n)
_parts.append(_c.desc() if _d else _c.asc())
if _parts:
query = query.order_by(*_parts)
else:
query = query.order_by(Person.created_at.desc())
skip = max(int(query_info.get('skip', 0) or 0), 0)
take = int(query_info.get('take', 20) or 20)
take = min(max(take, 1), 10000)
# اگر نیاز به محاسبه تراز قبل از pagination است
if needs_balance_before_pagination:
# دریافت همه persons
all_persons = query.all()
# تبدیل به دیکشنری و محاسبه تراز
all_items = []
person_ids = [p.id for p in all_persons]
balances = calculate_persons_balances_bulk(db, person_ids, fiscal_year_id)
fx_codes = calculate_persons_foreign_currency_codes_bulk(
db, person_ids, business_id, fiscal_year_id
)
for person in all_persons:
item = _person_to_dict(person)
balance, status = balances.get(person.id, (0.0, "بدون تراکنش"))
item['balance'] = balance
item['status'] = status
item['foreign_currency_codes'] = fx_codes.get(person.id, [])
all_items.append(item)
# اعمال فیلتر balance و status
if query_info.get('filters'):
for filter_item in query_info['filters']:
if isinstance(filter_item, dict):
field = filter_item.get('property')
operator = filter_item.get('operator')
value = filter_item.get('value')
else:
field = getattr(filter_item, 'property', None)
operator = getattr(filter_item, 'operator', None)
value = getattr(filter_item, 'value', None)
if field == 'balance':
if operator == '=':
all_items = [item for item in all_items if item['balance'] == value]
elif operator == '>':
all_items = [item for item in all_items if item['balance'] > value]
elif operator == '>=':
all_items = [item for item in all_items if item['balance'] >= value]
elif operator == '<':
all_items = [item for item in all_items if item['balance'] < value]
elif operator == '<=':
all_items = [item for item in all_items if item['balance'] <= value]
elif field == 'status':
if operator == '=' and isinstance(value, str):
all_items = [item for item in all_items if item['status'] == value]
elif operator == 'in' and isinstance(value, list):
all_items = [item for item in all_items if item['status'] in value]
# مرتب‌سازی
if sort_by == 'balance':
all_items.sort(key=lambda x: x['balance'], reverse=sort_desc)
elif sort_by == 'status':
all_items.sort(key=lambda x: x['status'], reverse=sort_desc)
# محاسبه total بعد از فیلتر
total = len(all_items)
# اعمال pagination
items = all_items[skip:skip + take]
else:
# روش معمولی: ابتدا pagination، سپس محاسبه تراز
persons = query.offset(skip).limit(take).all()
# تبدیل به دیکشنری
items = [_person_to_dict(person) for person in persons]
# محاسبه تراز برای persons فعلی
person_ids = [p.id for p in persons]
balances = calculate_persons_balances_bulk(db, person_ids, fiscal_year_id)
fx_codes = calculate_persons_foreign_currency_codes_bulk(
db, person_ids, business_id, fiscal_year_id
)
for item in items:
person_id = item['id']
balance, status = balances.get(person_id, (0.0, "بدون تراکنش"))
item['balance'] = balance
item['status'] = status
item['foreign_currency_codes'] = fx_codes.get(person_id, [])
# محاسبه اطلاعات صفحه‌بندی
total_pages = (total + take - 1) // take if take > 0 else 0
current_page = (skip // take) + 1 if take > 0 else 1
pagination = {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1
}
return {
'items': items,
'pagination': pagination,
'query_info': query_info
}
def update_person(
db: Session,
person_id: int,
business_id: int,
person_data: PersonUpdateRequest,
*,
defer_cache_invalidation: bool = False,
) -> Optional[Dict[str, Any]]:
"""ویرایش شخص"""
person = db.query(Person).filter(
and_(Person.id == person_id, Person.business_id == business_id)
).first()
if not person:
return None
# به‌روزرسانی فیلدها
update_data = (
person_data.model_dump(exclude_unset=True)
if hasattr(person_data, "model_dump")
else person_data.dict(exclude_unset=True)
)
sync_social: Optional[List[Any]] = None
if "social_contacts" in update_data:
sync_social = update_data.pop("social_contacts")
sync_banks: Optional[List[Any]] = None
if "bank_accounts" in update_data:
sync_banks = update_data.pop("bank_accounts")
# مانده افتتاحیه روی persons ذخیره نمی‌شود؛ در person_opening_balance_service مدیریت می‌شود
update_data.pop("opening_balance", None)
# مدیریت کد یکتا (شامل پاک کردن صریح با null)
if 'code' in update_data:
desired_code = update_data['code']
if desired_code is not None:
exists = db.query(Person).filter(
and_(Person.business_id == business_id, Person.code == desired_code, Person.id != person_id)
).first()
if exists:
raise ValueError("کد شخص تکراری است")
person.code = desired_code
else:
person.code = None
# مدیریت انواع شخص چندگانه
types_list: Optional[List[str]] = None
if 'person_types' in update_data and update_data['person_types'] is not None:
incoming = update_data['person_types'] or []
types_list = [t.value if hasattr(t, 'value') else str(t) for t in incoming]
person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
# همگام کردن person_type تکی برای سازگاری
# person_type handling removed - only person_types is used now
if 'person_group_id' in update_data:
pgid = update_data.pop('person_group_id', None)
if pgid is None:
person.person_group_id = None
else:
assert_assignable_person_group(db, business_id, int(pgid))
person.person_group_id = int(pgid)
# اگر شخص سهامدار شد، share_count معتبر باشد
resulting_types: List[str] = []
if person.person_types:
try:
tmp = json.loads(person.person_types)
if isinstance(tmp, list):
resulting_types = [str(x) for x in tmp]
except Exception:
resulting_types = []
if 'سهامدار' in resulting_types:
sc_val2 = update_data.get('share_count', person.share_count)
if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
# سایر فیلدها
for field in list(update_data.keys()):
if field in {'code', 'person_types', 'person_type', 'bank_accounts', 'social_contacts'}:
continue
if field == 'legal_entity_type' and update_data[field] is None:
continue
setattr(person, field, update_data[field])
if sync_banks is not None:
db.query(PersonBankAccount).filter(PersonBankAccount.person_id == person_id).delete(
synchronize_session=False
)
for bank_account_data in sync_banks:
p = (
bank_account_data
if isinstance(bank_account_data, PersonBankAccountCreateRequest)
else PersonBankAccountCreateRequest.model_validate(bank_account_data)
)
bank_name = (p.bank_name or "").strip()
if not bank_name:
continue
db.add(
PersonBankAccount(
person_id=person_id,
bank_name=bank_name,
account_number=p.account_number,
card_number=p.card_number,
sheba_number=p.sheba_number,
)
)
if sync_social is not None:
db.query(PersonSocialContact).filter(PersonSocialContact.person_id == person_id).delete(
synchronize_session=False
)
for i, sc in enumerate(sync_social):
p = sc if isinstance(sc, PersonSocialContactInput) else PersonSocialContactInput.model_validate(sc)
db.add(
PersonSocialContact(
person_id=person_id,
platform_key=p.platform_key,
custom_label=p.custom_label,
value=p.value,
sort_order=i,
)
)
db.commit()
db.refresh(person)
person = (
db.query(Person)
.options(selectinload(Person.bank_accounts), selectinload(Person.social_contacts))
.filter(Person.id == person_id)
.first()
) or person
if not defer_cache_invalidation:
invalidate_persons_cache(business_id, fiscal_year_id=None)
# فراخوانی workflow
try:
from app.services.workflow.workflow_trigger_service import trigger_person_updated
types_list: list = []
if person.person_types:
try:
tmp = json.loads(person.person_types)
if isinstance(tmp, list):
types_list = [str(x) for x in tmp]
except Exception:
pass
trigger_person_updated(
db=db,
business_id=business_id,
person_id=int(person.id),
person_types=types_list,
user_id=None,
)
except Exception as e:
logger.warning("person.updated workflow trigger failed: %s", e, exc_info=True)
return success_response(
message="شخص با موفقیت ویرایش شد",
data=_person_to_dict(person)
)
def check_person_has_accounting_documents(db: Session, person_id: int) -> tuple[bool, list[str]]:
"""
بررسی وجود اسناد حسابداری مرتبط با شخص
Returns:
tuple: (has_documents, document_types)
- has_documents: True اگر سند مرتبطی وجود داشته باشد
- document_types: لیست انواع اسناد مرتبط
"""
from sqlalchemy import func
# بررسی وجود خطوط سند با person_id در اسناد قطعی (غیر پیش‌نویس)
document_lines_count = db.query(func.count(DocumentLine.id)).join(
Document, DocumentLine.document_id == Document.id
).filter(
DocumentLine.person_id == person_id,
Document.is_proforma == False
).scalar()
if document_lines_count and document_lines_count > 0:
# دریافت انواع اسناد مرتبط
document_types = db.query(Document.document_type).join(
DocumentLine, Document.id == DocumentLine.document_id
).filter(
DocumentLine.person_id == person_id,
Document.is_proforma == False
).distinct().all()
types_list = [doc_type[0] for doc_type in document_types if doc_type[0]]
# تبدیل انواع اسناد به نام‌های فارسی
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": "انتقال",
"manual": "سند دستی",
"check": "چک",
}
for doc_type in types_list:
type_name = type_mapping.get(doc_type, doc_type)
if type_name not in type_names:
type_names.append(type_name)
return True, type_names
return False, []
def delete_person(db: Session, person_id: int, business_id: int) -> tuple[bool, str | None]:
"""
حذف شخص
Returns:
tuple: (success, error_message)
- success: True اگر حذف موفق باشد
- error_message: پیام خطا در صورت عدم موفقیت
"""
person = db.query(Person).filter(
and_(Person.id == person_id, Person.business_id == business_id)
).first()
if not person:
return False, "شخص یافت نشد"
# بررسی وجود اسناد حسابداری مرتبط
has_documents, document_types = check_person_has_accounting_documents(db, person_id)
if has_documents:
types_str = "، ".join(document_types)
error_msg = f"امکان حذف این شخص وجود ندارد زیرا دارای اسناد حسابداری مرتبط است. انواع اسناد: {types_str}"
return False, error_msg
try:
db.delete(person)
db.commit()
# Invalidate کش لیست اشخاص
invalidate_persons_cache(business_id, fiscal_year_id=None)
return True, None
except Exception as e:
db.rollback()
return False, f"خطا در حذف شخص: {str(e)}"
def get_person_summary(db: Session, business_id: int) -> Dict[str, Any]:
"""دریافت خلاصه اشخاص"""
# تعداد کل اشخاص
total_persons = db.query(Person).filter(Person.business_id == business_id).count()
# حذف مفهوم فعال/غیرفعال
active_persons = 0
inactive_persons = total_persons
# تعداد بر اساس نوع
by_type = {}
for person_type in PersonType:
count = db.query(Person).filter(
and_(Person.business_id == business_id, Person.person_types.ilike(f'%"{person_type.value}"%'))
).count()
by_type[person_type.value] = count
return {
'total_persons': total_persons,
'by_type': by_type,
'active_persons': active_persons,
'inactive_persons': inactive_persons
}
def _person_to_dict(person: Person) -> Dict[str, Any]:
"""تبدیل مدل Person به دیکشنری"""
# Parse person_types JSON to list
types_list: List[str] = []
if person.person_types:
try:
types = json.loads(person.person_types)
if isinstance(types, list):
types_list = [str(x) for x in types]
except Exception:
types_list = []
pg = getattr(person, "person_group", None)
return {
'id': person.id,
'business_id': person.business_id,
'person_group_id': getattr(person, "person_group_id", None),
'person_group_name': pg.name if pg else None,
'code': person.code,
'alias_name': person.alias_name,
'first_name': person.first_name,
'last_name': person.last_name,
'person_types': types_list,
'company_name': person.company_name,
'name_prefix': getattr(person, "name_prefix", None),
'legal_entity_type': getattr(person, "legal_entity_type", None) or "natural",
'payment_id': person.payment_id,
'share_count': person.share_count,
'commission_sale_percent': float(person.commission_sale_percent) if getattr(person, 'commission_sale_percent', None) is not None else None,
'commission_sales_return_percent': float(person.commission_sales_return_percent) if getattr(person, 'commission_sales_return_percent', None) is not None else None,
'commission_sales_amount': float(person.commission_sales_amount) if getattr(person, 'commission_sales_amount', None) is not None else None,
'commission_sales_return_amount': float(person.commission_sales_return_amount) if getattr(person, 'commission_sales_return_amount', None) is not None else None,
'commission_exclude_discounts': bool(person.commission_exclude_discounts),
'commission_exclude_additions_deductions': bool(person.commission_exclude_additions_deductions),
'commission_post_in_invoice_document': bool(person.commission_post_in_invoice_document),
'national_id': person.national_id,
'registration_number': person.registration_number,
'economic_id': person.economic_id,
'country': person.country,
'province': person.province,
'city': person.city,
'address': person.address,
'postal_code': person.postal_code,
'phone': person.phone,
'mobile': person.mobile,
'mobile_2': person.mobile_2,
'mobile_3': person.mobile_3,
'fax': person.fax,
'email': person.email,
'website': person.website,
'credit_limit': float(person.credit_limit) if getattr(person, 'credit_limit', None) is not None else None,
'credit_check_enabled': getattr(person, 'credit_check_enabled', None),
'created_at': person.created_at.isoformat(),
'updated_at': person.updated_at.isoformat(),
'bank_accounts': [
{
'id': ba.id,
'person_id': ba.person_id,
'bank_name': ba.bank_name,
'account_number': ba.account_number,
'card_number': ba.card_number,
'sheba_number': ba.sheba_number,
'created_at': ba.created_at.isoformat(),
'updated_at': ba.updated_at.isoformat(),
}
for ba in person.bank_accounts
],
'social_contacts': [
{
'id': sc.id,
'person_id': sc.person_id,
'platform_key': sc.platform_key,
'custom_label': sc.custom_label,
'value': sc.value,
'sort_order': sc.sort_order,
'created_at': sc.created_at.isoformat(),
'updated_at': sc.updated_at.isoformat(),
}
for sc in sorted(
getattr(person, "social_contacts", []) or [],
key=lambda x: (x.sort_order, x.id),
)
],
}
def search_persons(db: Session, business_id: int, search_query: Optional[str] = None,
page: int = 1, limit: int = 20) -> List[Person]:
"""جست‌وجو در اشخاص"""
query = db.query(Person).filter(Person.business_id == business_id)
if search_query:
# جست‌وجو در نام، نام خانوادگی، نام مستعار، کد، تلفن و ایمیل
search_filter = or_(
Person.alias_name.ilike(f"%{search_query}%"),
Person.first_name.ilike(f"%{search_query}%"),
Person.last_name.ilike(f"%{search_query}%"),
Person.company_name.ilike(f"%{search_query}%"),
Person.phone.ilike(f"%{search_query}%"),
or_(
Person.mobile.ilike(f"%{search_query}%"),
Person.mobile_2.ilike(f"%{search_query}%"),
Person.mobile_3.ilike(f"%{search_query}%"),
),
Person.email.ilike(f"%{search_query}%"),
Person.code == int(search_query) if search_query.isdigit() else False
)
query = query.filter(search_filter)
# مرتب‌سازی بر اساس نام مستعار
query = query.order_by(Person.alias_name)
# صفحه‌بندی
offset = (page - 1) * limit
return query.offset(offset).limit(limit).all()
def count_persons(db: Session, business_id: int, search_query: Optional[str] = None) -> int:
"""شمارش تعداد اشخاص"""
query = db.query(Person).filter(Person.business_id == business_id)
if search_query:
# جست‌وجو در نام، نام خانوادگی، نام مستعار، کد، تلفن و ایمیل
search_filter = or_(
Person.alias_name.ilike(f"%{search_query}%"),
Person.first_name.ilike(f"%{search_query}%"),
Person.last_name.ilike(f"%{search_query}%"),
Person.company_name.ilike(f"%{search_query}%"),
Person.phone.ilike(f"%{search_query}%"),
or_(
Person.mobile.ilike(f"%{search_query}%"),
Person.mobile_2.ilike(f"%{search_query}%"),
Person.mobile_3.ilike(f"%{search_query}%"),
),
Person.email.ilike(f"%{search_query}%"),
Person.code == int(search_query) if search_query.isdigit() else False
)
query = query.filter(search_filter)
return query.count()
def _person_balance_status_from_totals(
total_credit: Decimal, total_debit: Decimal, balance: Decimal
) -> str:
if total_credit == 0 and total_debit == 0:
return "بدون تراکنش"
if balance > 0:
return "بستانکار"
if balance < 0:
return "بدهکار"
return "بالانس"
def _document_to_base_currency_rate(
db: Session,
business_id: int,
base_currency_id: int,
document: Document,
*,
rate_cache: Dict[int, Decimal],
) -> Decimal:
"""
نرخ تبدیل ۱ واحد ارز سند به ارز پایه (هم‌معنا با resolve_rate_to_base).
اولویت: extra_info.fx.rate ثبت‌شده روی سند، وگرنه نرخ تاریخ سند.
"""
doc_id = int(document.id)
if doc_id in rate_cache:
return rate_cache[doc_id]
dc = int(document.currency_id or 0)
if dc == int(base_currency_id):
rate_cache[doc_id] = Decimal(1)
return Decimal(1)
extra = document.extra_info or {}
fx = extra.get("fx") if isinstance(extra, dict) else None
if isinstance(fx, dict) and not fx.get("skipped") and fx.get("rate") is not None:
try:
rate = Decimal(str(fx["rate"]))
if rate > 0:
rate_cache[doc_id] = rate
return rate
except Exception:
pass
from app.services.business_currency_rate_service import resolve_rate_to_base, _to_utc_aware
from app.services.invoice_fx_revaluation import compute_fx_as_of_utc, get_fx_revaluation_policy
b = db.get(Business, int(business_id))
if not b:
logger.warning(
"person balance: business %s not found, using rate=1 for document %s",
business_id,
doc_id,
)
rate_cache[doc_id] = Decimal(1)
return Decimal(1)
policy = get_fx_revaluation_policy(b)
reg = document.registered_at or datetime.now(timezone.utc)
if reg.tzinfo is None:
reg = reg.replace(tzinfo=timezone.utc)
reg = _to_utc_aware(reg)
as_of = compute_fx_as_of_utc(document.document_date, reg, policy)
try:
res = resolve_rate_to_base(db, int(business_id), dc, as_of)
rate = res["rate"] if isinstance(res["rate"], Decimal) else Decimal(str(res["rate"]))
if rate <= 0:
rate = Decimal(1)
rate_cache[doc_id] = rate
return rate
except ApiError:
pass
try:
fallback_as_of = datetime.combine(
document.document_date, time(23, 59, 59, 999999), tzinfo=timezone.utc
)
res = resolve_rate_to_base(db, int(business_id), dc, fallback_as_of)
rate = res["rate"] if isinstance(res["rate"], Decimal) else Decimal(str(res["rate"]))
if rate <= 0:
rate = Decimal(1)
rate_cache[doc_id] = rate
return rate
except ApiError as e:
logger.warning(
"person balance: no FX rate business=%s document=%s currency=%s: %s",
business_id,
doc_id,
dc,
e,
)
rate_cache[doc_id] = Decimal(1)
return Decimal(1)
def _person_line_amount_to_base(
db: Session,
document: Document,
amount: Any,
*,
rate_cache: Dict[int, Decimal],
base_currency_by_business: Dict[int, Optional[int]],
line: Optional["DocumentLine"] = None,
side: Optional[str] = None,
) -> Decimal:
"""
تبدیل مبلغ به ارز پایه.
اگر line و side داده شود، اولویت با debit_base/credit_base ذخیره‌شده روی خط است (P2.5).
"""
try:
amt = Decimal(str(amount or 0))
except Exception:
return Decimal(0)
if amt == 0:
return Decimal(0)
if line is not None and side in ("debit", "credit"):
from app.services.document_line_fx_service import line_side_amount_to_base
return line_side_amount_to_base(
db,
document,
line,
amt,
side=side,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
)
bid = int(document.business_id)
if bid not in base_currency_by_business:
bz = db.get(Business, bid)
base_currency_by_business[bid] = (
int(bz.default_currency_id) if bz and bz.default_currency_id else None
)
base_id = base_currency_by_business[bid]
if base_id is None:
return amt
rate = _document_to_base_currency_rate(db, bid, int(base_id), document, rate_cache=rate_cache)
return amt * rate
def amount_in_document_currency_to_base(
db: Session,
document: Document,
amount: Any,
*,
rate_cache: Dict[int, Decimal],
base_currency_by_business: Dict[int, Optional[int]],
line: Optional["DocumentLine"] = None,
side: Optional[str] = None,
) -> Decimal:
"""تبدیل مبلغ به ارز پایه با همان منطق خط سند (fx ذخیره‌شده یا resolve_rate؛ در نبود نرخ ۱:۱)."""
return _person_line_amount_to_base(
db,
document,
amount,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side=side,
)
def _filtered_person_balances_in_base(
db: Session,
person_ids: List[int],
*,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from_obj: Optional[date] = None,
date_to_obj: Optional[date] = None,
) -> Dict[int, Dict[str, Any]]:
"""تراز اشخاص با فیلتر سال مالی/ارز/بازه تاریخ؛ همه مقادیر به ارز پایه."""
if not person_ids:
return {}
q = (
db.query(DocumentLine, Document)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
DocumentLine.person_id.in_(person_ids),
Document.is_proforma == False, # noqa: E712
)
)
if fiscal_year_id:
q = q.filter(Document.fiscal_year_id == fiscal_year_id)
if currency_id:
q = q.filter(Document.currency_id == currency_id)
if date_from_obj is not None:
q = q.filter(Document.document_date >= date_from_obj)
if date_to_obj is not None:
q = q.filter(Document.document_date <= date_to_obj)
rows = q.all()
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
credit_by_person: Dict[int, Decimal] = {int(pid): Decimal(0) for pid in person_ids}
debit_by_person: Dict[int, Decimal] = {int(pid): Decimal(0) for pid in person_ids}
last_date: Dict[int, Optional[date]] = {int(pid): None for pid in person_ids}
for line, doc in rows:
pid = int(line.person_id)
if pid not in debit_by_person:
continue
debit_by_person[pid] += _person_line_amount_to_base(
db,
doc,
line.debit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="debit",
)
credit_by_person[pid] += _person_line_amount_to_base(
db,
doc,
line.credit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="credit",
)
dd = doc.document_date
if dd is not None:
cur = last_date.get(pid)
if cur is None or dd > cur:
last_date[pid] = dd
out: Dict[int, Dict[str, Any]] = {}
for pid in person_ids:
pid = int(pid)
tc = credit_by_person[pid]
td = debit_by_person[pid]
bal = tc - td
ld = last_date.get(pid)
out[pid] = {
"balance": float(bal),
"total_credit": float(tc),
"total_debit": float(td),
"last_transaction_date": ld.isoformat() if ld else None,
}
return out
def calculate_person_balances_by_currency(
db: Session,
person_id: int,
fiscal_year_id: Optional[int] = None,
) -> Dict[str, Any]:
"""
مانده شخص به تفکیک ارز سند (مبالغ بومی) + معادل ارز پایه.
قواعد حسابداری:
- تجمیع عادی بر اساس Document.currency_id و debit/credit بومی
- اگر خط دارای extra_info.fx_settlement باشد (پرداخت بین‌ارزی):
ماندهٔ ارز تسویه (settles_currency) با settles_amount جابه‌جا می‌شود
و مبلغ خط به ارز پرداخت در ماندهٔ شخص لحاظ نمی‌شود (تا AR ارزی دوبار شمرده نشود)
- base_equivalent از debit_base/credit_base یا تبدیل نرخ
"""
from adapters.db.models.currency import Currency
from app.services.fx_rate_provider_service import business_is_multi_currency
pers = db.query(Person).filter(Person.id == person_id).first()
if not pers:
return {
"person_id": person_id,
"is_multi_currency": False,
"base_currency_id": None,
"balances": [],
"total_base": 0.0,
"status": "بدون تراکنش",
}
business_id = int(pers.business_id)
biz = db.get(Business, business_id)
base_currency_id = int(biz.default_currency_id) if biz and biz.default_currency_id else None
is_mc = business_is_multi_currency(db, business_id)
line_query = (
db.query(DocumentLine, Document)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
DocumentLine.person_id == person_id,
Document.is_proforma == False, # noqa: E712
)
)
if fiscal_year_id:
line_query = line_query.filter(Document.fiscal_year_id == fiscal_year_id)
rows = line_query.all()
# currency_id -> {debit, credit, debit_base, credit_base}
buckets: Dict[int, Dict[str, Decimal]] = {}
def _bucket(cid: int) -> Dict[str, Decimal]:
if cid not in buckets:
buckets[cid] = {
"debit": Decimal(0),
"credit": Decimal(0),
"debit_base": Decimal(0),
"credit_base": Decimal(0),
}
return buckets[cid]
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
for line, doc in rows:
extra = line.extra_info if isinstance(line.extra_info, dict) else {}
fx_set = extra.get("fx_settlement") if isinstance(extra, dict) else None
debit_base = _person_line_amount_to_base(
db,
doc,
line.debit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="debit",
)
credit_base = _person_line_amount_to_base(
db,
doc,
line.credit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="credit",
)
if isinstance(fx_set, dict) and fx_set.get("settles_amount") is not None and fx_set.get("settles_currency_id") is not None:
try:
settles_cid = int(fx_set["settles_currency_id"])
settles_amt = Decimal(str(fx_set["settles_amount"]))
except Exception:
settles_cid = int(doc.currency_id)
settles_amt = Decimal(0)
if settles_amt < 0:
settles_amt = Decimal(0)
b = _bucket(settles_cid)
# دریافت: کاهش بدهی شخص → credit ؛ پرداخت: کاهش بستانکاری → debit
# جهت از علامت خطوط شخص سند: اگر credit>0 دریافت/تسویه بدهکار است
if Decimal(str(line.credit or 0)) > 0:
b["credit"] += settles_amt
b["credit_base"] += credit_base
elif Decimal(str(line.debit or 0)) > 0:
b["debit"] += settles_amt
b["debit_base"] += debit_base
else:
# fallback: credit
b["credit"] += settles_amt
b["credit_base"] += credit_base
continue
# تسعیر پایان دوره: فقط ارزش پایه ارز هدف؛ مانده بومی تغییر نمی‌کند
if extra.get("fx_period_revaluation") and extra.get("account_currency_id") is not None:
try:
rev_cid = int(extra["account_currency_id"])
except Exception:
continue
b = _bucket(rev_cid)
b["debit_base"] += debit_base
b["credit_base"] += credit_base
continue
cid = int(doc.currency_id or 0)
if cid <= 0:
continue
b = _bucket(cid)
b["debit"] += Decimal(str(line.debit or 0))
b["credit"] += Decimal(str(line.credit or 0))
b["debit_base"] += debit_base
b["credit_base"] += credit_base
if not buckets:
return {
"person_id": person_id,
"is_multi_currency": is_mc,
"base_currency_id": base_currency_id,
"balances": [],
"total_base": 0.0,
"status": "بدون تراکنش",
}
currency_ids = list(buckets.keys())
currencies = {
int(c.id): c
for c in db.query(Currency).filter(Currency.id.in_(currency_ids)).all()
}
balances_out: List[Dict[str, Any]] = []
total_debit_base = Decimal(0)
total_credit_base = Decimal(0)
for cid, tot in sorted(buckets.items(), key=lambda x: x[0]):
bal = tot["credit"] - tot["debit"]
bal_base = tot["credit_base"] - tot["debit_base"]
total_debit_base += tot["debit_base"]
total_credit_base += tot["credit_base"]
cur = currencies.get(cid)
status = _person_balance_status_from_totals(tot["credit"], tot["debit"], bal)
balances_out.append(
{
"currency_id": cid,
"currency_code": getattr(cur, "code", None) if cur else None,
"currency_title": getattr(cur, "title", None) if cur else None,
"currency_symbol": getattr(cur, "symbol", None) if cur else None,
"decimal_places": int(getattr(cur, "decimal_places", 2) or 2) if cur else 2,
"total_debit": float(tot["debit"]),
"total_credit": float(tot["credit"]),
"balance": float(bal),
"base_equivalent": float(bal_base),
"status": status,
"is_base_currency": base_currency_id is not None and cid == int(base_currency_id),
}
)
total_base = total_credit_base - total_debit_base
overall_status = _person_balance_status_from_totals(
total_credit_base, total_debit_base, total_base
)
return {
"person_id": person_id,
"is_multi_currency": is_mc,
"base_currency_id": base_currency_id,
"balances": balances_out,
"total_base": float(total_base),
"total_debit_base": float(total_debit_base),
"total_credit_base": float(total_credit_base),
"status": overall_status,
}
def calculate_person_balance_breakdown(
db: Session,
person_id: int,
fiscal_year_id: Optional[int] = None,
) -> Dict[str, Any]:
"""
مانده شخص به ارز پایه: جمع بدهکار، جمع بستانکار، تراز و وضعیت.
تراز = بستانکار − بدهکار (منفی = بدهکار، مثبت = بستانکار).
"""
empty = {
"balance": 0.0,
"status": "بدون تراکنش",
"total_debit": 0.0,
"total_credit": 0.0,
}
pers = db.query(Person).filter(Person.id == person_id).first()
if not pers:
return empty
line_query = (
db.query(DocumentLine, Document)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
DocumentLine.person_id == person_id,
Document.is_proforma == False, # noqa: E712
)
)
if fiscal_year_id:
line_query = line_query.filter(Document.fiscal_year_id == fiscal_year_id)
rows = line_query.all()
if not rows:
return empty
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
total_credit_base = Decimal(0)
total_debit_base = Decimal(0)
for line, doc in rows:
total_debit_base += _person_line_amount_to_base(
db,
doc,
line.debit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="debit",
)
total_credit_base += _person_line_amount_to_base(
db,
doc,
line.credit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="credit",
)
balance = total_credit_base - total_debit_base
status = _person_balance_status_from_totals(total_credit_base, total_debit_base, balance)
return {
"balance": float(balance),
"status": status,
"total_debit": float(total_debit_base),
"total_credit": float(total_credit_base),
}
def calculate_person_balance(
db: Session,
person_id: int,
fiscal_year_id: Optional[int] = None,
) -> tuple[float, str]:
"""
محاسبه تراز و وضعیت مالی یک شخص (به ارز پایهٔ کسب‌وکار).
مبالغ خطوط سند که به ارز غیر پایه هستند با نرخ ذخیره‌شده در extra_info.fx
(در صورت وجود) یا نرخ تاریخ همان سند به ارز پایه تبدیل می‌شوند.
Returns:
tuple: (تراز, وضعیت)
- تراز: بستانکار - بدهکار به ارز پایه
- وضعیت: "بستانکار" | "بدهکار" | "بالانس" | "بدون تراکنش"
"""
breakdown = calculate_person_balance_breakdown(
db, person_id, fiscal_year_id=fiscal_year_id
)
return breakdown["balance"], breakdown["status"]
def calculate_persons_balances_bulk(
db: Session,
person_ids: List[int],
fiscal_year_id: Optional[int] = None
) -> Dict[int, tuple[float, str]]:
"""
محاسبه تراز و وضعیت چندین شخص به صورت دسته‌جمعی (به ارز پایهٔ هر کسب‌وکار).
فرض: person_ids معمولاً همگی متعلق به یک business_id هستند (مثل لیست اشخاص یک کسب‌وکار).
Args:
db: نشست پایگاه داده
person_ids: لیست شناسه‌های اشخاص
fiscal_year_id: شناسه سال مالی (اختیاری)
Returns:
dict: {person_id: (balance, status)}
"""
if not person_ids:
return {}
balances: Dict[int, tuple[float, str]] = {
int(pid): (0.0, "بدون تراکنش") for pid in person_ids
}
q = (
db.query(DocumentLine, Document)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
DocumentLine.person_id.in_(person_ids),
Document.is_proforma == False,
)
)
if fiscal_year_id:
q = q.filter(Document.fiscal_year_id == fiscal_year_id)
rows = q.all()
if not rows:
return balances
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
credit_by_person: Dict[int, Decimal] = {int(pid): Decimal(0) for pid in person_ids}
debit_by_person: Dict[int, Decimal] = {int(pid): Decimal(0) for pid in person_ids}
for line, doc in rows:
pid = int(line.person_id)
if pid not in debit_by_person:
continue
debit_by_person[pid] += _person_line_amount_to_base(
db,
doc,
line.debit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="debit",
)
credit_by_person[pid] += _person_line_amount_to_base(
db,
doc,
line.credit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="credit",
)
for pid in person_ids:
pid = int(pid)
tc = credit_by_person[pid]
td = debit_by_person[pid]
bal = tc - td
status = _person_balance_status_from_totals(tc, td, bal)
balances[pid] = (float(bal), status)
return balances
def calculate_persons_foreign_currency_codes_bulk(
db: Session,
person_ids: List[int],
business_id: int,
fiscal_year_id: Optional[int] = None,
) -> Dict[int, List[str]]:
"""کد ارزهای غیرپایه که شخص در آن‌ها گردش دارد (کشف‌پذیری لیست؛ سبک)."""
from adapters.db.models.currency import Currency
from app.services.fx_rate_provider_service import business_is_multi_currency
out: Dict[int, List[str]] = {int(pid): [] for pid in person_ids}
if not person_ids or not business_is_multi_currency(db, int(business_id)):
return out
biz = db.get(Business, int(business_id))
base_id = int(biz.default_currency_id) if biz and biz.default_currency_id else None
if not base_id:
return out
q = (
db.query(DocumentLine.person_id, Document.currency_id)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
DocumentLine.person_id.in_(person_ids),
Document.is_proforma == False, # noqa: E712
Document.currency_id.isnot(None),
Document.currency_id != int(base_id),
)
.distinct()
)
if fiscal_year_id:
q = q.filter(Document.fiscal_year_id == int(fiscal_year_id))
pairs = q.all()
# همچنین خطوط تسعیر پایان‌دوره که ارز هدف غیرپایه دارند
rev_q = (
db.query(DocumentLine.person_id, DocumentLine.extra_info)
.join(Document, DocumentLine.document_id == Document.id)
.filter(
DocumentLine.person_id.in_(person_ids),
Document.is_proforma == False, # noqa: E712
)
)
if fiscal_year_id:
rev_q = rev_q.filter(Document.fiscal_year_id == int(fiscal_year_id))
currency_ids: set[int] = set()
person_cur: Dict[int, set[int]] = {int(pid): set() for pid in person_ids}
for pid, cid in pairs:
if pid is None or cid is None:
continue
person_cur[int(pid)].add(int(cid))
currency_ids.add(int(cid))
for pid, extra in rev_q.all():
if pid is None or not isinstance(extra, dict):
continue
if not extra.get("fx_period_revaluation") and not extra.get("fx_settlement"):
continue
cid = None
if extra.get("fx_period_revaluation") and extra.get("account_currency_id") is not None:
try:
cid = int(extra["account_currency_id"])
except Exception:
cid = None
fx_set = extra.get("fx_settlement")
if cid is None and isinstance(fx_set, dict) and fx_set.get("settles_currency_id") is not None:
try:
cid = int(fx_set["settles_currency_id"])
except Exception:
cid = None
if cid is None or cid == int(base_id):
continue
person_cur[int(pid)].add(cid)
currency_ids.add(cid)
code_map: Dict[int, str] = {}
if currency_ids:
for c in db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all():
code_map[int(c.id)] = str(c.code or c.id)
for pid, cids in person_cur.items():
out[pid] = sorted({code_map.get(cid, str(cid)) for cid in cids if cid})
return out
def get_debtors_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
min_balance: Optional[float] = None,
person_ids: Optional[List[int]] = None,
search: Optional[str] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش بدهکاران
Args:
db: نشست پایگاه داده
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
date_from: از تاریخ (اختیاری، فرمت YYYY-MM-DD)
date_to: تا تاریخ (اختیاری، فرمت YYYY-MM-DD)
min_balance: حداقل بدهی (فقط اشخاص با balance <= -min_balance)
person_ids: لیست شناسه‌های اشخاص برای فیلتر (اختیاری)
search: جستجو در نام/کد (اختیاری)
skip: تعداد رکوردهای رد شده برای pagination
take: تعداد رکوردهای برگشتی
Returns:
dict: {
'items': لیست بدهکاران,
'summary': خلاصه آمار,
'pagination': اطلاعات pagination
}
"""
from datetime import datetime
from sqlalchemy import case
# Query پایه برای اشخاص
query = db.query(Person).filter(Person.business_id == business_id)
# فیلتر بر اساس person_ids
if person_ids:
query = query.filter(Person.id.in_(person_ids))
# فیلتر جستجو
if search and search.strip():
search_filter = or_(
Person.alias_name.ilike(f'%{search}%'),
Person.company_name.ilike(f'%{search}%'),
Person.first_name.ilike(f'%{search}%'),
Person.last_name.ilike(f'%{search}%'),
func.cast(Person.code, String).ilike(f'%{search}%'),
)
query = query.filter(search_filter)
# دریافت همه اشخاص
all_persons = query.all()
if not all_persons:
return {
'items': [],
'summary': {
'total_count': 0,
'total_debt': 0.0,
'average_debt': 0.0,
},
'pagination': {
'total': 0,
'page': 1,
'per_page': take,
'total_pages': 0,
'has_next': False,
'has_prev': False,
}
}
person_ids_list = [p.id for p in all_persons]
date_from_d: Optional[date] = None
date_to_d: Optional[date] = None
if date_from:
try:
date_from_d = datetime.strptime(date_from, "%Y-%m-%d").date()
except ValueError:
pass
if date_to:
try:
date_to_d = datetime.strptime(date_to, "%Y-%m-%d").date()
except ValueError:
pass
balances_raw = _filtered_person_balances_in_base(
db,
person_ids_list,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from_obj=date_from_d,
date_to_obj=date_to_d,
)
balances_dict: Dict[int, Dict[str, Any]] = {
pid: balances_raw.get(
int(pid),
{
"balance": 0.0,
"total_credit": 0.0,
"total_debit": 0.0,
"last_transaction_date": None,
},
)
for pid in person_ids_list
}
# فیلتر فقط بدهکاران (balance < 0)
debtors = []
for person in all_persons:
balance_info = balances_dict[person.id]
balance = balance_info['balance']
# فقط بدهکاران (balance < 0)
if balance < 0:
# فیلتر بر اساس min_balance
if min_balance is not None:
if abs(balance) < min_balance:
continue
person_dict = _person_to_dict(person)
person_dict.update(balance_info)
# تعیین وضعیت
if balance < 0:
person_dict['status'] = 'بدهکار'
else:
person_dict['status'] = 'بالانس'
debtors.append(person_dict)
# مرتب‌سازی بر اساس balance (از بیشترین بدهی به کمترین)
debtors.sort(key=lambda x: x['balance'])
# محاسبه خلاصه
total_debt = sum(abs(d['balance']) for d in debtors)
total_count = len(debtors)
average_debt = total_debt / total_count if total_count > 0 else 0.0
# اعمال pagination
total = len(debtors)
paginated_debtors = debtors[skip:skip + take]
total_pages = (total + take - 1) // take if take > 0 else 0
current_page = (skip // take) + 1 if take > 0 else 1
return {
'items': paginated_debtors,
'summary': {
'total_count': total_count,
'total_debt': total_debt,
'average_debt': average_debt,
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
}
}
def get_creditors_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
min_balance: Optional[float] = None,
person_ids: Optional[List[int]] = None,
search: Optional[str] = None,
skip: int = 0,
take: int = 50,
) -> Dict[str, Any]:
"""
گزارش بستانکاران
Args:
db: نشست پایگاه داده
business_id: شناسه کسب‌وکار
fiscal_year_id: شناسه سال مالی (اختیاری)
currency_id: شناسه ارز (اختیاری)
date_from: از تاریخ (اختیاری، فرمت YYYY-MM-DD)
date_to: تا تاریخ (اختیاری، فرمت YYYY-MM-DD)
min_balance: حداقل بستانکاری (فقط اشخاص با balance >= min_balance)
person_ids: لیست شناسه‌های اشخاص برای فیلتر (اختیاری)
search: جستجو در نام/کد (اختیاری)
skip: تعداد رکوردهای رد شده برای pagination
take: تعداد رکوردهای برگشتی
Returns:
dict: {
'items': لیست بستانکاران,
'summary': خلاصه آمار,
'pagination': اطلاعات pagination
}
"""
from datetime import datetime
from sqlalchemy import case
# Query پایه برای اشخاص
query = db.query(Person).filter(Person.business_id == business_id)
# فیلتر بر اساس person_ids
if person_ids:
query = query.filter(Person.id.in_(person_ids))
# فیلتر جستجو
if search and search.strip():
search_filter = or_(
Person.alias_name.ilike(f'%{search}%'),
Person.company_name.ilike(f'%{search}%'),
Person.first_name.ilike(f'%{search}%'),
Person.last_name.ilike(f'%{search}%'),
func.cast(Person.code, String).ilike(f'%{search}%'),
)
query = query.filter(search_filter)
# دریافت همه اشخاص
all_persons = query.all()
if not all_persons:
return {
'items': [],
'summary': {
'total_count': 0,
'total_credit': 0.0,
'average_credit': 0.0,
},
'pagination': {
'total': 0,
'page': 1,
'per_page': take,
'total_pages': 0,
'has_next': False,
'has_prev': False,
}
}
person_ids_list = [p.id for p in all_persons]
date_from_d: Optional[date] = None
date_to_d: Optional[date] = None
if date_from:
try:
date_from_d = datetime.strptime(date_from, "%Y-%m-%d").date()
except ValueError:
pass
if date_to:
try:
date_to_d = datetime.strptime(date_to, "%Y-%m-%d").date()
except ValueError:
pass
balances_raw = _filtered_person_balances_in_base(
db,
person_ids_list,
fiscal_year_id=fiscal_year_id,
currency_id=currency_id,
date_from_obj=date_from_d,
date_to_obj=date_to_d,
)
balances_dict: Dict[int, Dict[str, Any]] = {
pid: balances_raw.get(
int(pid),
{
"balance": 0.0,
"total_credit": 0.0,
"total_debit": 0.0,
"last_transaction_date": None,
},
)
for pid in person_ids_list
}
# فیلتر فقط بستانکاران (balance > 0)
creditors = []
for person in all_persons:
balance_info = balances_dict[person.id]
balance = balance_info['balance']
# فقط بستانکاران (balance > 0)
if balance > 0:
# فیلتر بر اساس min_balance
if min_balance is not None:
if balance < min_balance:
continue
person_dict = _person_to_dict(person)
person_dict.update(balance_info)
# تعیین وضعیت
if balance > 0:
person_dict['status'] = 'بستانکار'
elif balance == 0:
person_dict['status'] = 'بالانس'
else:
person_dict['status'] = 'بدون تراکنش'
creditors.append(person_dict)
# مرتب‌سازی بر اساس balance (از بیشترین بستانکاری به کمترین)
creditors.sort(key=lambda x: x['balance'], reverse=True)
# محاسبه خلاصه
total_credit = sum(d['balance'] for d in creditors)
total_count = len(creditors)
average_credit = total_credit / total_count if total_count > 0 else 0.0
# اعمال pagination
total = len(creditors)
paginated_creditors = creditors[skip:skip + take]
total_pages = (total + take - 1) // take if take > 0 else 0
current_page = (skip // take) + 1 if take > 0 else 1
return {
'items': paginated_creditors,
'summary': {
'total_count': total_count,
'total_credit': total_credit,
'average_credit': average_credit,
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
}
}
# انواع فاکتوری که در حالت «جامع» به ریز اقلام کالا/خدمت گسترش می‌یابند
_PEOPLE_TX_INVOICE_EXPAND_TYPES = frozenset({
"invoice_sales",
"invoice_sales_return",
"invoice_purchase",
"invoice_purchase_return",
})
def _people_tx_document_type_name(doc_type: str | None) -> str:
"""تبدیل document_type به نام فارسی"""
if not doc_type:
return ""
doc_type = doc_type.strip()
mapping = {
"invoice_sales": "فروش",
"invoice_sales_return": "برگشت از فروش",
"invoice_purchase": "خرید",
"invoice_purchase_return": "برگشت از خرید",
"invoice_direct_consumption": "مصرف مستقیم",
"invoice_production": "تولید",
"invoice_waste": "ضایعات",
"inventory_transfer": "انتقال موجودی",
"production": "تولید",
"opening_balance": "موجودی اولیه",
"expense": "هزینه",
"income": "درآمد",
"receipt": "دریافت",
"payment": "پرداخت",
"transfer": "انتقال",
"manual": "سند دستی",
"invoice": "فاکتور",
"check": "چک",
}
return mapping.get(doc_type, doc_type)
def _invoice_item_line_amount(quantity: Any, extra_info: Optional[Dict[str, Any]]) -> float:
"""مبلغ ردیف اقلام فاکتور (با احتساب تخفیف و مالیات خط)."""
info = extra_info or {}
qty = Decimal(str(quantity or 0))
if info.get("line_total") is not None:
try:
return float(Decimal(str(info.get("line_total") or 0)))
except Exception:
pass
unit_price = Decimal(str(info.get("unit_price", 0) or 0))
line_discount = Decimal(str(info.get("line_discount", 0) or 0))
tax_amount = Decimal(str(info.get("tax_amount", 0) or 0))
return float((qty * unit_price) - line_discount + tax_amount)
def _normalize_people_tx_detail_level(detail_level: Optional[str]) -> str:
value = (detail_level or "summary").strip().lower()
if value in ("comprehensive", "detailed", "items", "invoice_lines"):
return "comprehensive"
return "summary"
def _expand_people_tx_with_invoice_items(
db: Session,
business_id: int,
base_items: List[Dict[str, Any]],
*,
amounts_in_base: bool,
) -> List[Dict[str, Any]]:
"""
گسترش ردیف‌های فاکتور خرید/فروش به ریز اقلام کالا.
دریافت/پرداخت و سایر اسناد بدون تغییر می‌مانند.
هر فاکتور فقط یک‌بار (روی خط اصلی شخص، نه سود اقساط) گسترش می‌یابد تا مبلغ تراز حفظ شود.
"""
from collections import defaultdict
from adapters.db.models.invoice_item_line import InvoiceItemLine
from adapters.db.models.product import Product
invoice_doc_ids = sorted({
int(item["document_id"])
for item in base_items
if item.get("document_type") in _PEOPLE_TX_INVOICE_EXPAND_TYPES
and item.get("document_id") is not None
})
if not invoice_doc_ids:
return base_items
inv_lines = (
db.query(InvoiceItemLine)
.filter(InvoiceItemLine.document_id.in_(invoice_doc_ids))
.order_by(InvoiceItemLine.document_id.asc(), InvoiceItemLine.id.asc())
.all()
)
lines_by_doc: Dict[int, List[Any]] = defaultdict(list)
product_ids: set[int] = set()
for inv_line in inv_lines:
lines_by_doc[int(inv_line.document_id)].append(inv_line)
if inv_line.product_id is not None:
product_ids.add(int(inv_line.product_id))
products_by_id: Dict[int, Any] = {}
if product_ids:
for product in (
db.query(Product)
.filter(
Product.business_id == business_id,
Product.id.in_(list(product_ids)),
)
.all()
):
products_by_id[int(product.id)] = product
expanded: List[Dict[str, Any]] = []
expanded_docs: set[int] = set()
for item in base_items:
doc_id = item.get("document_id")
doc_type = item.get("document_type")
line_extra = item.get("line_extra_info") or {}
is_installment_person_line = bool(
isinstance(line_extra, dict) and line_extra.get("installment")
)
can_expand = (
doc_type in _PEOPLE_TX_INVOICE_EXPAND_TYPES
and doc_id is not None
and int(doc_id) not in expanded_docs
and not is_installment_person_line
and int(doc_id) in lines_by_doc
and len(lines_by_doc[int(doc_id)]) > 0
)
if not can_expand:
row = dict(item)
row.setdefault("row_kind", "document")
expanded.append(row)
continue
doc_id_int = int(doc_id)
expanded_docs.add(doc_id_int)
person_debit_native = float(item.get("debit_native") or 0)
person_credit_native = float(item.get("credit_native") or 0)
person_debit_base = float(item.get("debit_base") or 0)
person_credit_base = float(item.get("credit_base") or 0)
use_debit_side = person_debit_native >= person_credit_native
person_native_amount = (
person_debit_native if use_debit_side else person_credit_native
)
person_base_amount = (
person_debit_base if use_debit_side else person_credit_base
)
item_amounts: List[float] = []
for inv_line in lines_by_doc[doc_id_int]:
item_amounts.append(
_invoice_item_line_amount(inv_line.quantity, inv_line.extra_info)
)
items_sum = float(sum(Decimal(str(a)) for a in item_amounts))
for inv_line, amount in zip(lines_by_doc[doc_id_int], item_amounts):
info = inv_line.extra_info or {}
product = products_by_id.get(int(inv_line.product_id)) if inv_line.product_id else None
product_name = product.name if product is not None else None
product_code = product.code if product is not None else None
qty = float(inv_line.quantity or 0) if inv_line.quantity is not None else None
unit_price = None
try:
if info.get("unit_price") is not None:
unit_price = float(Decimal(str(info.get("unit_price") or 0)))
except Exception:
unit_price = None
line_discount = None
try:
if info.get("line_discount") is not None:
line_discount = float(Decimal(str(info.get("line_discount") or 0)))
except Exception:
line_discount = None
tax_amount = None
try:
if info.get("tax_amount") is not None:
tax_amount = float(Decimal(str(info.get("tax_amount") or 0)))
except Exception:
tax_amount = None
desc_parts = []
if product_name:
desc_parts.append(product_name)
if inv_line.description:
desc_parts.append(str(inv_line.description))
description = " — ".join(desc_parts) if desc_parts else (item.get("description") or "")
# Invoice item amounts are native amounts. Allocate their base
# counterpart from the already persisted base person-line amount so
# the expanded rows retain the report's aggregate balance exactly.
base_amount = (
float(amount) * person_base_amount / items_sum
if items_sum else 0.0
)
debit_native = float(amount) if use_debit_side else 0.0
credit_native = 0.0 if use_debit_side else float(amount)
debit_base = base_amount if use_debit_side else 0.0
credit_base = 0.0 if use_debit_side else base_amount
row = dict(item)
row.update({
"row_kind": "invoice_item",
"line_id": item.get("line_id"),
"parent_line_id": item.get("line_id"),
"invoice_item_line_id": inv_line.id,
"product_id": int(inv_line.product_id) if inv_line.product_id is not None else None,
"product_code": product_code,
"product_name": product_name,
"quantity": qty,
"unit_price": unit_price,
"line_discount": line_discount,
"tax_amount": tax_amount,
"line_amount": base_amount if amounts_in_base else float(amount),
"debit_native": debit_native,
"credit_native": credit_native,
"debit_base": debit_base,
"credit_base": credit_base,
"debit": debit_base if amounts_in_base else debit_native,
"credit": credit_base if amounts_in_base else credit_native,
"description": description,
})
expanded.append(row)
native_remainder = float(
Decimal(str(person_native_amount)) - Decimal(str(items_sum))
)
base_items_sum = sum(
float(amount) * person_base_amount / items_sum if items_sum else 0.0
for amount in item_amounts
)
base_remainder = person_base_amount - base_items_sum
if abs(native_remainder) >= 0.01 or abs(base_remainder) >= 0.01:
if use_debit_side:
debit_native = native_remainder if native_remainder > 0 else 0.0
credit_native = -native_remainder if native_remainder < 0 else 0.0
debit_base = base_remainder if base_remainder > 0 else 0.0
credit_base = -base_remainder if base_remainder < 0 else 0.0
else:
credit_native = native_remainder if native_remainder > 0 else 0.0
debit_native = -native_remainder if native_remainder < 0 else 0.0
credit_base = base_remainder if base_remainder > 0 else 0.0
debit_base = -base_remainder if base_remainder < 0 else 0.0
row = dict(item)
row.update({
"row_kind": "invoice_remainder",
"line_id": item.get("line_id"),
"parent_line_id": item.get("line_id"),
"invoice_item_line_id": None,
"product_id": None,
"product_code": None,
"product_name": None,
"quantity": None,
"unit_price": None,
"line_discount": None,
"tax_amount": None,
"line_amount": base_remainder if amounts_in_base else native_remainder,
"debit_native": debit_native,
"credit_native": credit_native,
"debit_base": debit_base,
"credit_base": credit_base,
"debit": debit_base if amounts_in_base else debit_native,
"credit": credit_base if amounts_in_base else credit_native,
"description": "سایر / مالیات و تعدیلات فاکتور",
})
expanded.append(row)
return expanded
def get_people_transactions_report(
db: Session,
business_id: int,
fiscal_year_id: Optional[int] = None,
currency_id: Optional[int] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
person_ids: Optional[List[int]] = None,
document_type: Optional[str] = None, # receipt, payment, or None for all
search: Optional[str] = None,
skip: int = 0,
take: int = 50,
detail_level: Optional[str] = "summary",
) -> Dict[str, Any]:
"""
گزارش تراکنش‌های اشخاص / معین طرف‌حساب
detail_level:
- summary: یک ردیف به‌ازای هر خط حسابداری شخص (مبلغ کل فاکتور)
- comprehensive: ریز اقلام خرید/فروش + دریافت/پرداخت در یک گردش واحد
"""
from datetime import datetime
detail_level_norm = _normalize_people_tx_detail_level(detail_level)
amounts_in_base = currency_id is None
# Query پایه: DocumentLine join Document و Person
query = db.query(
DocumentLine,
Document,
Person
).join(
Document, DocumentLine.document_id == Document.id
).outerjoin(
Person, DocumentLine.person_id == Person.id
).filter(
Document.business_id == business_id,
Document.is_proforma == False, # فقط اسناد قطعی
DocumentLine.person_id.isnot(None) # فقط خطوط با person_id
)
# فیلتر سال مالی
if fiscal_year_id:
query = query.filter(Document.fiscal_year_id == fiscal_year_id)
# فیلتر ارز
if currency_id:
query = query.filter(Document.currency_id == currency_id)
# فیلتر تاریخ
if date_from:
try:
date_from_obj = datetime.strptime(date_from, '%Y-%m-%d').date()
query = query.filter(Document.document_date >= date_from_obj)
except ValueError:
pass
if date_to:
try:
date_to_obj = datetime.strptime(date_to, '%Y-%m-%d').date()
query = query.filter(Document.document_date <= date_to_obj)
except ValueError:
pass
# فیلتر اشخاص
if person_ids:
query = query.filter(DocumentLine.person_id.in_(person_ids))
# فیلتر نوع سند - پشتیبانی از همه انواع اسناد
if document_type:
query = query.filter(Document.document_type == document_type)
# فیلتر جستجو
if search and search.strip():
search_filter = or_(
Document.code.ilike(f'%{search}%'),
Person.alias_name.ilike(f'%{search}%'),
Person.company_name.ilike(f'%{search}%'),
Person.first_name.ilike(f'%{search}%'),
Person.last_name.ilike(f'%{search}%'),
)
query = query.filter(search_filter)
# مرتب‌سازی: تاریخ سند، کد سند، شناسه خط
query = query.order_by(
Document.document_date.asc(),
Document.id.asc(),
DocumentLine.id.asc()
)
all_results = query.all()
if not all_results:
return {
'items': [],
'summary': {
'total_count': 0,
'total_debit': 0.0,
'total_credit': 0.0,
'detail_level': detail_level_norm,
},
'pagination': {
'total': 0,
'page': 1,
'per_page': take,
'total_pages': 0,
'has_next': False,
'has_prev': False,
},
'meta': {
'currency_id': currency_id,
'amounts_in_base': amounts_in_base,
},
}
from adapters.db.models.currency import Currency
currency_ids = {
int(doc.currency_id)
for _, doc, _ in all_results
if getattr(doc, "currency_id", None)
}
currencies_by_id: Dict[int, Any] = {}
if currency_ids:
currencies_by_id = {
int(c.id): c
for c in db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
}
rate_cache: Dict[int, Decimal] = {}
base_currency_by_business: Dict[int, Optional[int]] = {}
base_items: List[Dict[str, Any]] = []
for line, doc, person in all_results:
debit_native = float(line.debit or 0)
credit_native = float(line.credit or 0)
# همان منطق مانده شخص: debit_base ذخیره‌شده، وگرنه تبدیل نرخ (نه مبلغ بومی خام)
debit_base = float(
_person_line_amount_to_base(
db,
doc,
line.debit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="debit",
)
)
credit_base = float(
_person_line_amount_to_base(
db,
doc,
line.credit,
rate_cache=rate_cache,
base_currency_by_business=base_currency_by_business,
line=line,
side="credit",
)
)
debit = debit_base if amounts_in_base else debit_native
credit = credit_base if amounts_in_base else credit_native
person_name = None
if person:
person_name = (
person.alias_name or
person.company_name or
f"{person.first_name or ''} {person.last_name or ''}".strip()
)
document_type_name = _people_tx_document_type_name(doc.document_type)
cur = currencies_by_id.get(int(doc.currency_id)) if doc.currency_id else None
item_dict = _person_to_dict(person) if person else {}
item_dict.update({
'row_kind': 'document',
'line_id': line.id,
'document_id': doc.id,
'document_code': doc.code,
# date object تا format_datetime_fields بتواند جلالی/میلادی کند
'document_date': doc.document_date,
'document_type': doc.document_type,
'document_type_name': document_type_name,
'person_id': line.person_id,
'person_name': person_name,
'debit': debit,
'credit': credit,
'debit_native': debit_native,
'credit_native': credit_native,
'debit_base': debit_base,
'credit_base': credit_base,
'exchange_rate': (
float(line.exchange_rate) if line.exchange_rate is not None else None
),
'document_currency_id': doc.currency_id,
'currency_code': getattr(cur, "code", None) if cur else None,
'currency_symbol': getattr(cur, "symbol", None) if cur else None,
'currency_decimal_places': (
int(getattr(cur, "decimal_places", 2) or 2) if cur else 2
),
'description': line.description,
'line_extra_info': line.extra_info or {},
'product_id': None,
'product_code': None,
'product_name': None,
'quantity': None,
'unit_price': None,
'line_discount': None,
'tax_amount': None,
'line_amount': None,
'invoice_item_line_id': None,
'parent_line_id': None,
})
base_items.append(item_dict)
if detail_level_norm == "comprehensive":
items = _expand_people_tx_with_invoice_items(
db, business_id, base_items, amounts_in_base=amounts_in_base
)
else:
items = base_items
running_balance = 0.0
for item in items:
debit = float(item.get('debit') or 0)
credit = float(item.get('credit') or 0)
balance_change = credit - debit
running_balance += balance_change
item['balance_change'] = balance_change
item['running_balance'] = running_balance
# فیلد داخلی؛ برای کلاینت لازم نیست
item.pop('line_extra_info', None)
total_count = len(items)
total_debit = sum(float(item.get('debit') or 0) for item in items)
total_credit = sum(float(item.get('credit') or 0) for item in items)
total = len(items)
paginated_items = items[skip:skip + take]
total_pages = (total + take - 1) // take if take > 0 else 0
current_page = (skip // take) + 1 if take > 0 else 1
return {
'items': paginated_items,
'summary': {
'total_count': total_count,
'total_debit': total_debit,
'total_credit': total_credit,
'detail_level': detail_level_norm,
},
'pagination': {
'total': total,
'page': current_page,
'per_page': take,
'total_pages': total_pages,
'has_next': current_page < total_pages,
'has_prev': current_page > 1,
},
'meta': {
'currency_id': currency_id,
'amounts_in_base': amounts_in_base,
},
}