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

1827 lines
72 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import select, and_, func, or_
import logging
from adapters.db.repositories.business_repo import BusinessRepository
from adapters.db.repositories.fiscal_year_repo import FiscalYearRepository
from adapters.db.models.currency import Currency, BusinessCurrency
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
from adapters.db.models.business import Business, BusinessType, BusinessField
from adapters.db.models.business_print_settings import BusinessPrintSettings
from adapters.db.models.document import Document
from adapters.db.models.person import Person
from adapters.db.models.product import Product
# Lazy import to avoid circular dependency
# from adapters.api.v1.schemas import (
# BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse,
# BusinessListResponse, BusinessSummaryResponse, PaginationInfo
# )
from app.core.responses import format_datetime_fields, ApiError
from app.services.system_settings_service import get_wallet_settings
logger = logging.getLogger(__name__)
def invalidate_user_businesses_list_cache() -> None:
"""پاک‌سازی کش لیست کسب‌وکارهای کاربر پس از حذف/بازیابی."""
from app.core.cache import get_cache
cache = get_cache()
if not cache.enabled:
return
try:
cache.delete_pattern("user_businesses:*")
except Exception as e:
logger.warning("Failed to invalidate user_businesses cache: %s", e)
def _business_is_restorable(business: Business, *, now: datetime | None = None) -> bool:
"""کسب‌وکار soft-delete شده هنوز در مهلت بازیابی است."""
if getattr(business, "deleted_at", None) is None:
return False
auto_delete_at = getattr(business, "auto_delete_at", None)
if auto_delete_at is None:
return True
ref = now or datetime.utcnow()
return ref < auto_delete_at
def _normalize_invoice_warehouse_release_mode(value) -> str:
if value is None:
return "draft"
s = str(value).strip().lower()
if s in ("none", "off", "no", "disabled"):
return "none"
if s in ("posted", "final", "confirmed"):
return "posted"
if s == "draft":
return "draft"
return "draft"
def _normalize_purchase_accounting_mode_for_response(value) -> str:
from app.services.purchase_accounting_service import normalize_purchase_accounting_mode
return normalize_purchase_accounting_mode(value)
def ensure_business_default_document_policies(
db: Session,
business_id: int,
user_id: int | None = None,
*,
commit: bool = True,
) -> None:
"""اعمال idempotent سیاست‌های پیش‌فرض درآمدزایی اسناد برای یک کسب‌وکار."""
try:
# Lazy import to avoid circular imports (document_monetization_service <-> wallet_service <-> business_service)
from app.services.document_monetization_service import apply_default_policies_to_business
apply_default_policies_to_business(
db,
int(business_id),
user_id=user_id,
commit=commit,
)
except Exception:
logger.exception(
"failed_to_apply_default_policies business_id=%s",
business_id,
)
def ensure_wallet_currency_in_business(db: Session, business_id: int) -> bool:
"""
بررسی و اضافه کردن ارز کیف پول به لیست ارزهای کسب و کار در صورت عدم وجود
Args:
db: Database session
business_id: شناسه کسب‌وکار
Returns:
True اگر ارز اضافه شد، False اگر قبلاً وجود داشت
"""
from app.core.responses import ApiError
# دریافت کسب و کار
business = db.query(Business).filter(Business.id == business_id).first()
if not business:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
# دریافت ارز کیف پول
wallet_settings = get_wallet_settings(db)
wallet_currency_id = wallet_settings.get("wallet_base_currency_id")
if not wallet_currency_id:
# اگر ارز کیف پول تنظیم نشده باشد، کاری نمی‌کنیم
logger.warning("wallet_currency_not_set", business_id=business_id)
return False
wallet_currency_id = int(wallet_currency_id)
# بررسی اینکه آیا ارز کیف پول همان ارز پیشفرض کسب و کار است
if business.default_currency_id == wallet_currency_id:
# ارز پیشفرض به صورت خودکار در لیست ارزها قرار دارد
return False
# بررسی اینکه آیا ارز کیف پول در لیست ارزهای جانبی وجود دارد
existing = (
db.query(BusinessCurrency)
.filter(
and_(
BusinessCurrency.business_id == business_id,
BusinessCurrency.currency_id == wallet_currency_id
)
)
.first()
)
if existing:
# ارز قبلاً اضافه شده است
return False
# بررسی وجود ارز در سیستم
currency = db.query(Currency).filter(Currency.id == wallet_currency_id).first()
if not currency:
logger.warning("wallet_currency_not_found", business_id=business_id, currency_id=wallet_currency_id)
return False
# اضافه کردن ارز کیف پول به لیست ارزهای جانبی
business_currency = BusinessCurrency(
business_id=business_id,
currency_id=wallet_currency_id
)
db.add(business_currency)
db.flush()
# Invalidate cache
from app.core.cache import get_cache
cache = get_cache()
if cache.enabled:
cache.delete(f"business_currencies:{business_id}")
logger.info(
"wallet_currency_added_to_business",
business_id=business_id,
currency_id=wallet_currency_id,
currency_code=currency.code
)
return True
def check_business_creation_permission(db: Session, user_id: int) -> tuple[bool, str | None]:
"""
بررسی اینکه آیا کاربر می‌تواند کسب و کار ایجاد کند یا نه
Args:
db: Database session
user_id: شناسه کاربر
Returns:
Tuple[bool, str | None]:
- (True, None): اجازه ایجاد دارد
- (False, "پیام خطا"): اجازه ایجاد ندارد
"""
from app.services.system_settings_service import get_business_creation_verification_requirement
from adapters.db.models.user import User
requirement = get_business_creation_verification_requirement(db)
# بدون محدودیت
if requirement == "none":
return True, None
# دریافت اطلاعات کاربر
user = db.get(User, user_id)
if not user:
return False, "کاربر یافت نشد"
email_verified = getattr(user, "email_verified", False)
mobile_verified = getattr(user, "mobile_verified", False)
# بررسی بر اساس requirement
if requirement == "email_only":
if not email_verified:
return False, "برای ایجاد کسب و کار، شما باید ایمیل خود را تایید کنید. لطفاً به بخش 'تایید شماره موبایل و ایمیل' در تنظیمات حساب کاربری بروید."
return True, None
elif requirement == "mobile_only":
if not mobile_verified:
return False, "برای ایجاد کسب و کار، شما باید شماره موبایل خود را تایید کنید. لطفاً به بخش 'تایید شماره موبایل و ایمیل' در تنظیمات حساب کاربری بروید."
return True, None
elif requirement == "both":
if not email_verified or not mobile_verified:
missing = []
if not email_verified:
missing.append("ایمیل")
if not mobile_verified:
missing.append("شماره موبایل")
return False, f"برای ایجاد کسب و کار، شما باید {' و '.join(missing)} خود را تایید کنید. لطفاً به بخش 'تایید شماره موبایل و ایمیل' در تنظیمات حساب کاربری بروید."
return True, None
elif requirement == "either":
if not email_verified and not mobile_verified:
return False, "برای ایجاد کسب و کار، شما باید حداقل ایمیل یا شماره موبایل خود را تایید کنید. لطفاً به بخش 'تایید شماره موبایل و ایمیل' در تنظیمات حساب کاربری بروید."
return True, None
# حالت پیش‌فرض: بدون محدودیت
return True, None
def create_business(
db: Session,
business_data,
owner_id: int,
*,
defer_commit: bool = False,
) -> Dict[str, Any]:
"""ایجاد کسب و کار جدید.
defer_commit: اگر True باشد، هیچ commit انجام نمی‌شود (برای بازیابی در یک تراکنش واحد).
در این حالت hookهایی که بعد از commit به داده نیاز دارند اجرا نمی‌شوند.
"""
# Lazy import to avoid circular dependency
from adapters.api.v1.schemas import BusinessCreateRequest
from app.core.responses import ApiError
# بررسی دسترسی ایجاد کسب و کار
can_create, error_message = check_business_creation_permission(db, owner_id)
if not can_create:
raise ApiError(
"BUSINESS_CREATION_NOT_ALLOWED",
error_message or "شما اجازه ایجاد کسب و کار را ندارید",
http_status=403
)
business_repo = BusinessRepository(db)
fiscal_repo = FiscalYearRepository(db)
# تبدیل enum values به مقادیر فارسی
# business_data.business_type و business_data.business_field قبلاً مقادیر فارسی هستند
business_type_enum = business_data.business_type
business_field_enum = business_data.business_field
# ذخیره در دیتابیس
created_business = business_repo.create_business(
name=business_data.name,
business_type=business_type_enum,
business_field=business_field_enum,
owner_id=owner_id,
default_currency_id=getattr(business_data, "default_currency_id", None),
address=business_data.address,
phone=business_data.phone,
mobile=business_data.mobile,
national_id=business_data.national_id,
registration_number=business_data.registration_number,
economic_id=business_data.economic_id,
country=business_data.country,
province=business_data.province,
city=business_data.city,
postal_code=business_data.postal_code,
commit=not defer_commit,
)
# ایجاد سال‌های مالی اولیه (در صورت ارسال)
if getattr(business_data, "fiscal_years", None):
# فقط یک سال با is_last=True نگه داریم (آخرین مورد True باشد)
last_true_index = None
for idx, fy in enumerate(business_data.fiscal_years or []):
if fy.is_last:
last_true_index = idx
for idx, fy in enumerate(business_data.fiscal_years or []):
fiscal_repo.create_fiscal_year(
business_id=created_business.id,
title=fy.title,
start_date=fy.start_date,
end_date=fy.end_date,
is_last=(idx == last_true_index) if last_true_index is not None else (idx == len(business_data.fiscal_years) - 1),
commit=not defer_commit,
)
# مدیریت ارزها
currency_ids: list[int] = []
if getattr(business_data, "currency_ids", None):
currency_ids = list(dict.fromkeys(business_data.currency_ids)) # unique
default_currency_id = getattr(business_data, "default_currency_id", None)
if default_currency_id:
if default_currency_id not in currency_ids:
currency_ids.insert(0, default_currency_id)
# اعتبارسنجی وجود ارزها
if currency_ids:
existing_ids = [cid for (cid,) in db.query(Currency.id).filter(Currency.id.in_(currency_ids)).all()]
if set(existing_ids) != set(currency_ids):
missing = set(currency_ids) - set(existing_ids)
raise ValueError(f"Invalid currency ids: {sorted(list(missing))}")
# درج ارتباطات در business_currencies
for cid in currency_ids:
bc = BusinessCurrency(business_id=created_business.id, currency_id=cid)
db.add(bc)
if defer_commit:
db.flush()
else:
db.commit()
# بررسی و اضافه کردن ارز کیف پول در صورت نیاز
# اگر ارز پیشفرض کسب و کار با ارز کیف پول متفاوت باشد، ارز کیف پول را به ارزهای جانبی اضافه می‌کنیم
if not defer_commit:
try:
ensure_wallet_currency_in_business(db, created_business.id)
except Exception as e:
# در صورت خطا، لاگ می‌کنیم اما ایجاد کسب‌وکار را متوقف نمی‌کنیم
logger.warning("failed_to_add_wallet_currency", business_id=created_business.id, error=str(e))
db.refresh(created_business)
# اعمال خودکار سیاست‌های پیش‌فرض درآمدزایی اسناد
if not defer_commit:
ensure_business_default_document_policies(
db,
created_business.id,
user_id=owner_id,
commit=True,
)
# تبدیل به response format (+ دادهٔ نمونه در صورت درخواست)
result = _business_to_dict(created_business)
want_sample = bool(getattr(business_data, "include_sample_data", False))
if want_sample and not defer_commit:
try:
from app.services.business_sample_data_service import seed_sample_data_for_new_business
seed_sample_data_for_new_business(db, created_business.id, owner_id)
result["sample_data_seeded"] = True
except Exception as e:
logger.exception(
"sample_data_seed_failed",
business_id=created_business.id,
error=str(e),
)
result["sample_data_seeded"] = False
result["sample_data_error"] = str(e)
return result
def get_business_by_id(db: Session, business_id: int, user_id: int) -> Optional[Dict[str, Any]]:
"""دریافت کسب و کار برای مالک یا عضو با دسترسی join فعال."""
from app.core.auth_dependency import _user_can_access_business
business_repo = BusinessRepository(db)
business = business_repo.get_by_id(business_id)
if not business or getattr(business, "deleted_at", None) is not None:
return None
if not _user_can_access_business(db, user_id, business_id):
return None
return _business_to_dict(business)
def get_businesses_by_owner(db: Session, owner_id: int, query_info: Dict[str, Any]) -> Dict[str, Any]:
"""دریافت لیست کسب و کارهای یک مالک"""
business_repo = BusinessRepository(db)
# دریافت کسب و کارها
businesses = business_repo.get_by_owner_id(owner_id)
# اعمال فیلترها
if query_info.get('search'):
search_term = query_info['search']
businesses = [b for b in businesses if search_term.lower() in b.name.lower()]
# اعمال مرتب‌سازی
sort_by = query_info.get('sort_by', 'created_at')
sort_desc = query_info.get('sort_desc', True)
if sort_by == 'name':
businesses.sort(key=lambda x: x.name, reverse=sort_desc)
elif sort_by == 'business_type':
businesses.sort(key=lambda x: x.business_type.value, reverse=sort_desc)
elif sort_by == 'created_at':
businesses.sort(key=lambda x: x.created_at, reverse=sort_desc)
# صفحه‌بندی
total = len(businesses)
skip = query_info.get('skip', 0)
take = query_info.get('take', 10)
start_idx = skip
end_idx = skip + take
paginated_businesses = businesses[start_idx:end_idx]
# محاسبه اطلاعات صفحه‌بندی
total_pages = (total + take - 1) // take
current_page = (skip // take) + 1
# Lazy import to avoid circular dependency
from adapters.api.v1.schemas import PaginationInfo
pagination = PaginationInfo(
total=total,
page=current_page,
per_page=take,
total_pages=total_pages,
has_next=current_page < total_pages,
has_prev=current_page > 1
)
# تبدیل به response format
items = [_business_to_dict(business) for business in paginated_businesses]
return {
"items": items,
"pagination": pagination.dict(),
"query_info": query_info
}
def get_user_businesses(db: Session, user_id: int, query_info: Dict[str, Any], include_deleted_for_owner: bool = True) -> Dict[str, Any]:
"""
دریافت لیست کسب و کارهای کاربر (مالک + عضو)
قوانین نمایش:
- برای مالک: کسب و کارهای حذف شده را نشان می‌دهد (اگر auto_delete_at نگذشته باشد) - قابل کنترل با include_deleted_for_owner
- برای سایر کاربران: کسب و کارهای حذف شده را نشان نمی‌دهد
Args:
db: Session دیتابیس
user_id: شناسه کاربر
query_info: اطلاعات جستجو و صفحه‌بندی
include_deleted_for_owner: آیا کسب‌وکارهای حذف‌شده برای مالک نمایش داده شوند؟ (پیش‌فرض True)
"""
business_repo = BusinessRepository(db)
permission_repo = BusinessPermissionRepository(db)
# دریافت کسب و کارهای مالک (شامل حذف شده‌ها که هنوز مهلت دارند - بسته به پارامتر)
owned_businesses = business_repo.get_by_owner_id(user_id, include_deleted=include_deleted_for_owner)
# دریافت کسب و کارهای عضو (فقط غیرحذف شده‌ها)
member_permissions = permission_repo.get_user_member_businesses(user_id)
member_business_ids = [perm.business_id for perm in member_permissions]
member_businesses = []
for business_id in member_business_ids:
business = business_repo.get_by_id(business_id)
if business:
# فیلتر کردن حذف شده‌ها برای اعضا
if business.deleted_at is None:
member_businesses.append(business)
# یا اگر حذف شده اما auto_delete_at نگذشته (فقط برای مالک - که قبلاً در owned_businesses است)
# ترکیب لیست‌ها
all_businesses = []
# اضافه کردن کسب و کارهای مالک با نقش owner
for business in owned_businesses:
business_dict = _business_to_dict(business)
business_dict['is_owner'] = True
business_dict['role'] = 'مالک'
business_dict['permissions'] = {}
all_businesses.append(business_dict)
# اضافه کردن کسب و کارهای عضو با نقش member
for business in member_businesses:
# اگر قبلاً به عنوان مالک اضافه شده، نادیده بگیر
if business.id not in [b['id'] for b in all_businesses]:
business_dict = _business_to_dict(business)
business_dict['is_owner'] = False
business_dict['role'] = 'عضو'
# دریافت دسترسی‌های کاربر برای این کسب و کار
permission_obj = permission_repo.get_by_user_and_business(user_id, business.id)
if permission_obj and permission_obj.business_permissions:
perms = permission_obj.business_permissions
# Normalize to dict to avoid legacy list format
if isinstance(perms, dict):
business_dict['permissions'] = perms
elif isinstance(perms, list):
try:
if all(isinstance(item, list) and len(item) == 2 for item in perms):
business_dict['permissions'] = {k: v for k, v in perms if isinstance(k, str)}
elif all(isinstance(item, dict) for item in perms):
merged = {}
for it in perms:
merged.update({k: v for k, v in it.items()})
business_dict['permissions'] = merged
else:
business_dict['permissions'] = {}
except Exception:
business_dict['permissions'] = {}
else:
business_dict['permissions'] = {}
else:
business_dict['permissions'] = {}
all_businesses.append(business_dict)
# اعمال فیلترها
if query_info.get('search'):
search_term = query_info['search']
all_businesses = [b for b in all_businesses if search_term.lower() in b['name'].lower()]
# اعمال مرتب‌سازی
sort_by = query_info.get('sort_by', 'created_at')
sort_desc = query_info.get('sort_desc', True)
if sort_by == 'name':
all_businesses.sort(key=lambda x: x['name'], reverse=sort_desc)
elif sort_by == 'business_type':
all_businesses.sort(key=lambda x: x['business_type'], reverse=sort_desc)
elif sort_by == 'created_at':
all_businesses.sort(key=lambda x: x['created_at'], reverse=sort_desc)
# صفحه‌بندی
total = len(all_businesses)
skip = query_info.get('skip', 0)
take = query_info.get('take', 10)
start_idx = skip
end_idx = skip + take
paginated_businesses = all_businesses[start_idx:end_idx]
# محاسبه اطلاعات صفحه‌بندی
total_pages = (total + take - 1) // take
current_page = (skip // take) + 1
# Lazy import to avoid circular dependency
from adapters.api.v1.schemas import PaginationInfo
pagination = PaginationInfo(
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": paginated_businesses,
"pagination": pagination.dict(),
"query_info": query_info
}
def update_business(db: Session, business_id: int, business_data, owner_id: int) -> Optional[Dict[str, Any]]:
"""ویرایش کسب و کار"""
# Lazy import to avoid circular dependency
from adapters.api.v1.schemas import BusinessUpdateRequest
from app.core.responses import ApiError
from adapters.db.models.currency import Currency, BusinessCurrency
business_repo = BusinessRepository(db)
business = business_repo.get_by_id(business_id)
if not business or business.owner_id != owner_id:
return None
# به‌روزرسانی فیلدها
update_data = business_data.dict(exclude_unset=True)
# مدیریت ارز پیش‌فرض
if "default_currency_id" in update_data:
new_default_currency_id = update_data.pop("default_currency_id")
# بررسی اینکه آیا کسب‌وکار قبلاً ارز پیش‌فرض تنظیم کرده است
if business.default_currency_id is not None:
raise ApiError(
"CANNOT_CHANGE_DEFAULT_CURRENCY",
"کسب‌وکار شما قبلاً ارز پیش‌فرض تنظیم کرده است و امکان تغییر آن وجود ندارد",
http_status=400
)
# V2-P7 دفاع: حتی برای اولین تنظیم، پس از وجود سند قفل
has_docs = (
db.query(Document.id)
.filter(Document.business_id == business_id)
.limit(1)
.first()
)
if has_docs is not None:
raise ApiError(
"CANNOT_SET_DEFAULT_CURRENCY_AFTER_DOCUMENTS",
"پس از ثبت سند حسابداری امکان تنظیم ارز پایه وجود ندارد",
http_status=400,
)
# بررسی وجود ارز
if new_default_currency_id is not None:
currency = db.query(Currency).filter(Currency.id == int(new_default_currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "ارز یافت نشد", http_status=404)
# تنظیم ارز پیش‌فرض
business.default_currency_id = int(new_default_currency_id)
# اضافه کردن به business_currencies اگر وجود نداشته باشد
existing_bc = (
db.query(BusinessCurrency)
.filter(
and_(
BusinessCurrency.business_id == business_id,
BusinessCurrency.currency_id == int(new_default_currency_id)
)
)
.first()
)
if not existing_bc:
bc = BusinessCurrency(business_id=business_id, currency_id=int(new_default_currency_id))
db.add(bc)
db.flush()
# به‌روزرسانی سایر فیلدها
if "display_timezone" in update_data:
from app.services.business_timezone_service import (
invalidate_business_display_timezone_cache,
normalize_business_display_timezone,
)
update_data["display_timezone"] = normalize_business_display_timezone(
update_data.get("display_timezone")
)
for field, value in update_data.items():
setattr(business, field, value)
# ذخیره تغییرات
updated_business = business_repo.update(business)
if "display_timezone" in update_data:
from app.services.business_timezone_service import invalidate_business_display_timezone_cache
invalidate_business_display_timezone_cache(business_id)
return _business_to_dict(updated_business)
def check_currency_usage_in_documents(db: Session, business_id: int, currency_id: int) -> int:
"""
تعداد اسناد با currency_id مستقیم.
برای بررسی کامل حذف ارز از get_business_currency_usage استفاده کنید.
"""
count = (
db.query(func.count(Document.id))
.filter(
and_(
Document.business_id == business_id,
Document.currency_id == currency_id
)
)
.scalar()
)
return count or 0
def get_business_currency_usage(db: Session, business_id: int, currency_id: int) -> Dict[str, Any]:
"""
V2-P7 / D6: شمارش استفاده ارز در اسناد، خطوط (account_currency در extra)،
حساب‌های بانکی/صندوق/تنخواه، چک، قیمت ارزی کالا و اقلام لیست قیمت.
"""
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 adapters.db.models.check import Check
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.price_list import PriceList, PriceItem
from sqlalchemy import cast, String, or_
bid = int(business_id)
cid = int(currency_id)
cid_str = str(cid)
def _cnt(q) -> int:
n = q.scalar()
return int(n or 0)
documents = _cnt(
db.query(func.count(Document.id)).filter(
Document.business_id == bid,
Document.currency_id == cid,
)
)
# اسناد بازنویسی‌شده به پایه با settles_currency_id در extra_info
documents_settles = 0
try:
documents_settles = _cnt(
db.query(func.count(Document.id)).filter(
Document.business_id == bid,
Document.currency_id != cid,
or_(
cast(Document.extra_info["settles_currency_id"], String) == cid_str,
cast(Document.extra_info["fx_settlement"]["settles_currency_id"], String) == cid_str,
),
)
)
except Exception:
documents_settles = 0
# خطوط با account_currency_id بومی
document_lines_native = 0
try:
document_lines_native = _cnt(
db.query(func.count(DocumentLine.id))
.join(Document, Document.id == DocumentLine.document_id)
.filter(
Document.business_id == bid,
cast(DocumentLine.extra_info["account_currency_id"], String) == cid_str,
)
)
except Exception:
document_lines_native = 0
bank_accounts = _cnt(
db.query(func.count(BankAccount.id)).filter(
BankAccount.business_id == bid,
BankAccount.currency_id == cid,
)
)
cash_registers = _cnt(
db.query(func.count(CashRegister.id)).filter(
CashRegister.business_id == bid,
CashRegister.currency_id == cid,
)
)
petty_cashes = _cnt(
db.query(func.count(PettyCash.id)).filter(
PettyCash.business_id == bid,
PettyCash.currency_id == cid,
)
)
checks = _cnt(
db.query(func.count(Check.id)).filter(
Check.business_id == bid,
Check.currency_id == cid,
)
)
products_fx = _cnt(
db.query(func.count(Product.id)).filter(
Product.business_id == bid,
Product.price_fx_currency_id == cid,
)
)
price_items = _cnt(
db.query(func.count(PriceItem.id))
.join(PriceList, PriceList.id == PriceItem.price_list_id)
.filter(
PriceList.business_id == bid,
PriceItem.currency_id == cid,
)
)
breakdown = {
"documents": documents,
"documents_settles_extra": documents_settles,
"document_lines_native": document_lines_native,
"bank_accounts": bank_accounts,
"cash_registers": cash_registers,
"petty_cashes": petty_cashes,
"checks": checks,
"products_fx": products_fx,
"price_items": price_items,
}
total = sum(int(v) for v in breakdown.values())
# تعداد ارزهای فرعی فعال کسب‌وکار
secondary_count = _cnt(
db.query(func.count(BusinessCurrency.id)).filter(
BusinessCurrency.business_id == bid,
)
)
is_last_secondary = secondary_count <= 1
blockers: List[str] = []
labels = {
"documents": "اسناد حسابداری",
"documents_settles_extra": "اسناد با تسویه این ارز",
"document_lines_native": "خطوط سند با مبلغ بومی این ارز",
"bank_accounts": "حساب‌های بانکی",
"cash_registers": "صندوق‌ها",
"petty_cashes": "تنخواه‌ها",
"checks": "چک‌ها",
"products_fx": "کالاها با قیمت ارزی",
"price_items": "اقلام لیست قیمت",
}
for key, n in breakdown.items():
if n > 0:
blockers.append(f"{labels.get(key, key)} ({n} مورد)")
if is_last_secondary and total > 0:
blockers.append("آخرین ارز فرعی کسب‌وکار در حضور اسناد/حساب‌های ارزی قابل حذف نیست")
return {
"breakdown": breakdown,
"total": total,
"is_used": total > 0,
"document_count": documents + documents_settles, # سازگاری با UI قبلی
"can_delete": total == 0,
"is_last_secondary": is_last_secondary,
"secondary_count": secondary_count,
"blockers": blockers,
}
def add_business_currency(db: Session, business_id: int, currency_id: int, owner_id: int) -> Dict[str, Any]:
"""
اضافه کردن ارز جانبی به کسب‌وکار
Args:
db: Database session
business_id: شناسه کسب‌وکار
currency_id: شناسه ارز
owner_id: شناسه مالک کسب‌وکار
Returns:
اطلاعات ارز اضافه شده
Raises:
ApiError: در صورت خطا
"""
from app.core.responses import ApiError
# بررسی وجود کسب‌وکار و مالکیت
business = db.query(Business).filter(Business.id == business_id).first()
if not business:
raise ApiError("NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
if business.owner_id != owner_id:
raise ApiError("FORBIDDEN", "شما دسترسی به این کسب‌وکار را ندارید", http_status=403)
# بررسی وجود ارز
currency = db.query(Currency).filter(Currency.id == currency_id).first()
if not currency:
raise ApiError("NOT_FOUND", "ارز یافت نشد", http_status=404)
# بررسی اینکه ارز پیش‌فرض نیست
if business.default_currency_id == currency_id:
raise ApiError(
"CANNOT_ADD_DEFAULT_AS_SECONDARY",
"ارز پیش‌فرض به صورت خودکار در لیست ارزها قرار دارد",
http_status=400
)
# بررسی تکراری نبودن
existing = (
db.query(BusinessCurrency)
.filter(
and_(
BusinessCurrency.business_id == business_id,
BusinessCurrency.currency_id == currency_id
)
)
.first()
)
if existing:
raise ApiError(
"CURRENCY_ALREADY_ADDED",
"این ارز قبلاً به کسب‌وکار اضافه شده است",
http_status=400
)
# اضافه کردن ارز
business_currency = BusinessCurrency(
business_id=business_id,
currency_id=currency_id
)
db.add(business_currency)
db.commit()
db.refresh(business_currency)
# Invalidate cache
from app.core.cache import get_cache
cache = get_cache()
if cache.enabled:
cache.delete(f"business_currencies:{business_id}")
return {
"id": currency.id,
"name": currency.name,
"title": currency.title,
"symbol": currency.symbol,
"code": currency.code,
"is_default": False,
}
def remove_business_currency(db: Session, business_id: int, currency_id: int, owner_id: int) -> None:
"""
حذف ارز جانبی از کسب‌وکار
Args:
db: Database session
business_id: شناسه کسب‌وکار
currency_id: شناسه ارز
owner_id: شناسه مالک کسب‌وکار
Raises:
ApiError: در صورت خطا
"""
from app.core.responses import ApiError
# بررسی وجود کسب‌وکار و مالکیت
business = db.query(Business).filter(Business.id == business_id).first()
if not business:
raise ApiError("NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
if business.owner_id != owner_id:
raise ApiError("FORBIDDEN", "شما دسترسی به این کسب‌وکار را ندارید", http_status=403)
# بررسی اینکه ارز پیش‌فرض نیست
if business.default_currency_id == currency_id:
raise ApiError(
"CANNOT_DELETE_DEFAULT_CURRENCY",
"ارز پیش‌فرض قابل حذف نیست",
http_status=400
)
# بررسی استفاده در اسناد / حساب‌ها / کالا (V2-P7 D6)
usage = get_business_currency_usage(db, business_id, currency_id)
if usage.get("is_used"):
blockers = usage.get("blockers") or []
detail = "؛ ".join(blockers) if blockers else f"{usage.get('total')} مورد"
raise ApiError(
"CURRENCY_IN_USE",
f"این ارز قابل حذف نیست: {detail}",
http_status=400,
)
# حذف ارز
business_currency = (
db.query(BusinessCurrency)
.filter(
and_(
BusinessCurrency.business_id == business_id,
BusinessCurrency.currency_id == currency_id
)
)
.first()
)
if not business_currency:
raise ApiError("NOT_FOUND", "این ارز در لیست ارزهای کسب‌وکار یافت نشد", http_status=404)
db.delete(business_currency)
db.commit()
# Invalidate cache
from app.core.cache import get_cache
cache = get_cache()
if cache.enabled:
cache.delete(f"business_currencies:{business_id}")
def delete_business(db: Session, business_id: int, owner_id: int) -> bool:
"""حذف کسب و کار (قدیمی - برای سازگاری با backward compatibility)"""
return delete_business_soft(db, business_id, owner_id, None, owner_id) is not None
def _check_deletion_restrictions(db: Session, business_id: int) -> Dict[str, Any]:
"""بررسی محدودیت‌های حذف کسب و کار"""
restrictions = {
"can_delete": True,
"has_finalized_invoices": False,
"finalized_invoices_count": 0,
"has_tax_workspace_invoices": False,
"tax_workspace_invoices_count": 0,
"has_locked_documents": False,
"locked_documents_count": 0,
}
# بررسی فاکتورهای نهایی شده
finalized_docs = db.query(Document).filter(
Document.business_id == business_id,
Document.document_type.in_(['invoice', 'invoice_return', 'proforma'])
).all()
finalized_count = 0
for doc in finalized_docs:
extra_info = doc.extra_info or {}
if isinstance(extra_info, dict):
tax_status = extra_info.get("tax_status", "")
if isinstance(tax_status, str) and tax_status.strip() == "finalized":
finalized_count += 1
restrictions["finalized_invoices_count"] = finalized_count
restrictions["has_finalized_invoices"] = finalized_count > 0
# بررسی فاکتورهای در کارپوشه مودیان
tax_workspace_count = 0
for doc in finalized_docs:
extra_info = doc.extra_info or {}
if isinstance(extra_info, dict):
tax_workspace = extra_info.get("tax_workspace", False)
if tax_workspace:
tax_workspace_count += 1
restrictions["tax_workspace_invoices_count"] = tax_workspace_count
restrictions["has_tax_workspace_invoices"] = tax_workspace_count > 0
# بررسی اسناد قفل شده
locked_count = 0
all_docs = db.query(Document).filter(Document.business_id == business_id).all()
for doc in all_docs:
extra_info = doc.extra_info or {}
developer_settings = doc.developer_settings or {}
if isinstance(extra_info, dict):
if extra_info.get("locked") or extra_info.get("is_locked"):
locked_count += 1
elif isinstance(developer_settings, dict):
if developer_settings.get("locked") or developer_settings.get("is_locked"):
locked_count += 1
restrictions["locked_documents_count"] = locked_count
restrictions["has_locked_documents"] = locked_count > 0
# بررسی نهایی
restrictions["can_delete"] = (
not restrictions["has_finalized_invoices"] and
not restrictions["has_tax_workspace_invoices"] and
not restrictions["has_locked_documents"]
)
return restrictions
def _format_restriction_error(restrictions: Dict[str, Any]) -> str:
"""فرمت کردن پیام خطای محدودیت‌ها"""
errors = []
if restrictions["has_finalized_invoices"]:
errors.append(f"{restrictions['finalized_invoices_count']} فاکتور نهایی شده وجود دارد")
if restrictions["has_tax_workspace_invoices"]:
errors.append(f"{restrictions['tax_workspace_invoices_count']} فاکتور در کارپوشه مودیان وجود دارد")
if restrictions["has_locked_documents"]:
errors.append(f"{restrictions['locked_documents_count']} سند قفل شده وجود دارد")
return "نمی‌توان کسب و کار را حذف کرد: " + "، ".join(errors)
def get_business_delete_info(db: Session, business_id: int, owner_id: int) -> Dict[str, Any]:
"""دریافت اطلاعات مرتبط با حذف کسب و کار"""
business = db.query(Business).filter(Business.id == business_id).first()
if not business:
raise ApiError("BUSINESS_NOT_FOUND", "کسب و کار یافت نشد", http_status=404)
# بررسی مالکیت
if business.owner_id != owner_id:
raise ApiError("FORBIDDEN", "فقط مالک کسب و کار می‌تواند آن را حذف کند", http_status=403)
# بررسی محدودیت‌ها
restrictions = _check_deletion_restrictions(db, business_id)
# آمار کلی
total_documents = db.query(Document).filter(Document.business_id == business_id).count()
total_persons = db.query(Person).filter(Person.business_id == business_id).count()
total_products = db.query(Product).filter(Product.business_id == business_id).count()
return {
"business": {
"id": business.id,
"name": business.name,
},
"restrictions": restrictions,
"statistics": {
"total_documents": total_documents,
"total_persons": total_persons,
"total_products": total_products,
}
}
def delete_business_soft(
db: Session,
business_id: int,
owner_id: int,
deletion_reason: str | None = None,
requested_by: int | None = None,
skip_restore_period: bool = False,
) -> Dict[str, Any] | None:
"""
حذف نرم کسب و کار با بررسی‌های امنیتی و ایجاد بکاپ خودکار.
اگر skip_restore_period=True باشد، auto_delete_at بلافاصله تنظیم می‌شود
و مهلت ۳۰ روزه بازیابی اعمال نمی‌شود (حذف سریع soft delete).
"""
business_repo = BusinessRepository(db)
business = business_repo.get_by_id(business_id)
if not business or business.owner_id != owner_id:
raise ApiError("BUSINESS_NOT_FOUND", "کسب و کار یافت نشد", http_status=404)
# بررسی اینکه قبلاً حذف نشده باشد
if business.deleted_at is not None:
raise ApiError("ALREADY_DELETED", "کسب و کار قبلاً حذف شده است", http_status=400)
# بررسی محدودیت‌ها
restrictions = _check_deletion_restrictions(db, business_id)
if not restrictions["can_delete"]:
error_msg = _format_restriction_error(restrictions)
raise ApiError("CANNOT_DELETE_BUSINESS", error_msg, http_status=409)
# ایجاد بکاپ خودکار قبل از حذف
backup_result = None
try:
from adapters.api.v1.business_backups import _perform_backup
# ساخت AuthContext موقت برای بکاپ
class TempAuthContext:
def get_user_id(self):
return owner_id
temp_ctx = TempAuthContext()
backup_data = _perform_backup(db, temp_ctx, business_id)
# ذخیره بکاپ
from app.services.file_storage_service import FileStorageService
storage = FileStorageService(db)
import anyio
from fastapi import UploadFile
import io
async def _upload_backup():
filename = f"business_{business_id}_auto_backup_before_deletion_{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}.hbx"
faux_upload = UploadFile(
filename=filename,
file=io.BytesIO(backup_data["zip_bytes"])
)
saved = await storage.upload_file(
faux_upload,
user_id=owner_id,
module_context="business_auto_backup",
developer_data={
"business_id": business_id,
"backup_type": "auto_before_deletion",
"schema_version": backup_data["metadata"]["schema_version"],
},
is_temporary=False,
expires_in_days=3650, # 10 سال
business_id=business_id,
check_storage_limit=False, # بکاپ خودکار محدودیت ندارد
)
return saved
backup_result = anyio.run(_upload_backup)
logger.info(f"Auto backup created for business {business_id}: {backup_result.get('id')}")
except Exception as e:
logger.error(f"Failed to create auto backup for business {business_id}: {e}")
# اگر بکاپ ناموفق بود، حذف را ادامه می‌دهیم اما هشدار می‌دهیم
# انجام Soft Delete
now = datetime.utcnow()
restore_deadline_days = 0 if skip_restore_period else 30
business.deleted_at = now
business.deletion_requested_at = now
business.deletion_requested_by = requested_by or owner_id
business.deletion_reason = deletion_reason
business.auto_delete_at = now if skip_restore_period else now + timedelta(days=30)
db.commit()
db.refresh(business)
invalidate_user_businesses_list_cache()
# لاگ عملیات
logger.info(
f"Business {business_id} soft deleted by user {owner_id}",
extra={
"business_id": business_id,
"deleted_by": owner_id,
"deleted_at": now.isoformat(),
"auto_delete_at": business.auto_delete_at.isoformat(),
"backup_created": backup_result is not None,
"skip_restore_period": skip_restore_period,
}
)
return {
"business_id": business_id,
"deleted_at": business.deleted_at.isoformat(),
"auto_delete_at": business.auto_delete_at.isoformat(),
"restore_deadline_days": restore_deadline_days,
"skip_restore_period": skip_restore_period,
"backup_created": backup_result is not None,
"backup_id": backup_result.get("id") if backup_result else None,
}
def restore_business(db: Session, business_id: int, owner_id: int) -> Dict[str, Any]:
"""بازیابی کسب و کار حذف شده (فقط در 30 روز اول)"""
business = db.query(Business).filter(Business.id == business_id).first()
if not business:
raise ApiError("BUSINESS_NOT_FOUND", "کسب و کار یافت نشد", http_status=404)
# بررسی مالکیت
if business.owner_id != owner_id:
raise ApiError("FORBIDDEN", "فقط مالک می‌تواند کسب و کار را بازیابی کند", http_status=403)
# بررسی اینکه حذف شده باشد
if business.deleted_at is None:
raise ApiError("NOT_DELETED", "کسب و کار حذف نشده است", http_status=400)
# بررسی مهلت بازیابی
if business.auto_delete_at and datetime.utcnow() > business.auto_delete_at:
raise ApiError(
"RESTORE_EXPIRED",
"مهلت بازیابی کسب و کار به پایان رسیده است. امکان بازیابی وجود ندارد.",
http_status=410
)
# بازیابی
business.deleted_at = None
business.deletion_requested_at = None
business.deletion_requested_by = None
business.deletion_reason = None
business.auto_delete_at = None
db.commit()
db.refresh(business)
invalidate_user_businesses_list_cache()
logger.info(f"Business {business_id} restored by user {owner_id}")
return {
"business_id": business_id,
"restored_at": datetime.utcnow().isoformat()
}
def get_business_summary(db: Session, owner_id: int) -> Dict[str, Any]:
"""دریافت خلاصه آمار کسب و کارها"""
business_repo = BusinessRepository(db)
businesses = business_repo.get_by_owner_id(owner_id)
# شمارش بر اساس نوع
by_type = {}
for business_type in BusinessType:
by_type[business_type.value] = len([b for b in businesses if b.business_type == business_type])
# شمارش بر اساس زمینه فعالیت
by_field = {}
for business_field in BusinessField:
by_field[business_field.value] = len([b for b in businesses if b.business_field == business_field])
return {
"total_businesses": len(businesses),
"by_type": by_type,
"by_field": by_field
}
def get_business_print_settings(db: Session, business_id: int) -> Dict[str, Any]:
"""
دریافت تنظیمات چاپ فاکتورهای یک کسب‌وکار.
- رکوردی با document_type = 'all' به عنوان تنظیمات عمومی (پیش‌فرض) استفاده می‌شود.
- در صورت وجود رکورد برای نوع سند خاص، همان برای آن نوع استفاده می‌شود.
- اگر هیچ رکوردی وجود نداشته باشد، مقادیر پیش‌فرض (همه روشن، بدون متن پاورقی) برگردانده می‌شود.
"""
from app.services.print_stamp_scale import (
STAMP_SCALE_DEFAULT,
clamp_scale_percent,
)
from app.services.print_tax_discount_display import (
DEFAULT_TAX_DISCOUNT_DISPLAY_SETTINGS,
normalize_display_mode,
)
rows = (
db.query(BusinessPrintSettings)
.filter(BusinessPrintSettings.business_id == business_id)
.all()
)
def _row_to_dict(row: BusinessPrintSettings) -> Dict[str, Any]:
return {
"document_type": row.document_type,
"show_logo": bool(getattr(row, "show_logo", True)),
"show_stamp": bool(getattr(row, "show_stamp", True)),
"show_payments": bool(getattr(row, "show_payments", True)),
"show_installment_plan": bool(getattr(row, "show_installment_plan", True)),
"show_share_qr": bool(getattr(row, "show_share_qr", False)),
"show_footer_print_time": bool(getattr(row, "show_footer_print_time", True)),
"show_footer_preparer": bool(getattr(row, "show_footer_preparer", True)),
"footer_note": getattr(row, "footer_note", None),
"show_customer_balance": bool(getattr(row, "show_customer_balance", True)),
"show_seller_signature_area": bool(getattr(row, "show_seller_signature_area", True)),
"show_buyer_signature_area": bool(getattr(row, "show_buyer_signature_area", True)),
"stamp_scale_percent": clamp_scale_percent(
getattr(row, "stamp_scale_percent", STAMP_SCALE_DEFAULT)
),
"signature_scale_percent": clamp_scale_percent(
getattr(row, "signature_scale_percent", STAMP_SCALE_DEFAULT)
),
**{
key: normalize_display_mode(getattr(row, key, None))
for key in DEFAULT_TAX_DISCOUNT_DISPLAY_SETTINGS
},
}
default_settings: Dict[str, Any] = {
"document_type": "all",
"show_logo": True,
"show_stamp": True,
"show_payments": True,
"show_installment_plan": True,
"show_share_qr": False,
"show_footer_print_time": True,
"show_footer_preparer": True,
"footer_note": None,
"show_customer_balance": True,
"show_seller_signature_area": True,
"show_buyer_signature_area": True,
"stamp_scale_percent": STAMP_SCALE_DEFAULT,
"signature_scale_percent": STAMP_SCALE_DEFAULT,
**DEFAULT_TAX_DISCOUNT_DISPLAY_SETTINGS,
}
per_type: Dict[str, Any] = {}
for row in rows:
data = _row_to_dict(row)
if row.document_type == "all":
default_settings = data
else:
per_type[row.document_type] = data
return {
"default": default_settings,
"per_type": per_type,
}
def update_business_print_settings(
db: Session,
business_id: int,
settings_payload: Dict[str, Any],
) -> Dict[str, Any]:
"""
به‌روزرسانی تنظیمات چاپ فاکتورهای یک کسب‌وکار.
ورودی انتظار دارد ساختاری شبیه:
{
"default": { ... },
"per_type": {
"invoice_sales": { ... },
"invoice_purchase": { ... },
...
}
}
"""
from app.services.print_stamp_scale import (
STAMP_SCALE_DEFAULT,
clamp_scale_percent,
)
from app.services.print_tax_discount_display import (
DEFAULT_TAX_DISCOUNT_DISPLAY_SETTINGS,
normalize_display_mode,
)
default_data = (settings_payload or {}).get("default") or {}
per_type_data: Dict[str, Any] = (settings_payload or {}).get("per_type") or {}
# کمک‌کننده برای گرفتن مقدار بولین با پیش‌فرض
def _get_bool(d: Dict[str, Any], key: str, default: bool) -> bool:
val = d.get(key)
if isinstance(val, bool):
return val
if isinstance(val, (int, float)):
return bool(val)
if isinstance(val, str):
s = val.strip().lower()
if s in {"true", "1", "yes", "on"}:
return True
if s in {"false", "0", "no", "off"}:
return False
return default
def _apply_print_cfg_to_row(row: BusinessPrintSettings, cfg: Dict[str, Any]) -> None:
row.show_logo = _get_bool(cfg, "show_logo", True)
row.show_stamp = _get_bool(cfg, "show_stamp", True)
row.show_payments = _get_bool(cfg, "show_payments", True)
row.show_installment_plan = _get_bool(cfg, "show_installment_plan", True)
row.show_share_qr = _get_bool(cfg, "show_share_qr", False)
row.show_footer_print_time = _get_bool(cfg, "show_footer_print_time", True)
row.show_footer_preparer = _get_bool(cfg, "show_footer_preparer", True)
row.show_customer_balance = _get_bool(cfg, "show_customer_balance", True)
row.show_seller_signature_area = _get_bool(
cfg, "show_seller_signature_area", True
)
row.show_buyer_signature_area = _get_bool(
cfg, "show_buyer_signature_area", True
)
row.stamp_scale_percent = clamp_scale_percent(
cfg.get("stamp_scale_percent"), STAMP_SCALE_DEFAULT
)
row.signature_scale_percent = clamp_scale_percent(
cfg.get("signature_scale_percent"), STAMP_SCALE_DEFAULT
)
row.footer_note = (
(cfg.get("footer_note") or None)
if isinstance(cfg.get("footer_note"), str)
else cfg.get("footer_note")
)
for key in DEFAULT_TAX_DISCOUNT_DISPLAY_SETTINGS:
setattr(row, key, normalize_display_mode(cfg.get(key)))
# ابتدا رکورد تنظیمات عمومی (all) را به‌روزرسانی یا ایجاد می‌کنیم
default_row = (
db.query(BusinessPrintSettings)
.filter(
BusinessPrintSettings.business_id == business_id,
BusinessPrintSettings.document_type == "all",
)
.first()
)
if default_data:
if not default_row:
default_row = BusinessPrintSettings(
business_id=business_id,
document_type="all",
)
db.add(default_row)
_apply_print_cfg_to_row(default_row, default_data)
# سپس تنظیمات اختصاصی هر نوع سند را به‌روزرسانی / ایجاد می‌کنیم
# document_type فقط برای انواعی نگهداری می‌شود که در per_type ارسال شده‌اند.
existing_rows = (
db.query(BusinessPrintSettings)
.filter(
BusinessPrintSettings.business_id == business_id,
BusinessPrintSettings.document_type != "all",
)
.all()
)
existing_map: Dict[str, BusinessPrintSettings] = {
r.document_type: r for r in existing_rows
}
# انواعی که باید نگه داشته شوند
keep_types = set()
for doc_type, cfg in per_type_data.items():
if not isinstance(cfg, dict):
continue
doc_type_str = str(doc_type).strip()
if not doc_type_str:
continue
keep_types.add(doc_type_str)
row = existing_map.get(doc_type_str)
if not row:
row = BusinessPrintSettings(
business_id=business_id,
document_type=doc_type_str,
)
db.add(row)
_apply_print_cfg_to_row(row, cfg)
# سایر رکوردهای موجود که دیگر در per_type نیستند حذف می‌شوند
for doc_type, row in existing_map.items():
if doc_type not in keep_types:
db.delete(row)
db.commit()
return get_business_print_settings(db, business_id)
def get_all_businesses_admin(db: Session, query_info: Dict[str, Any]) -> Dict[str, Any]:
"""دریافت لیست همه کسب و کارها برای ادمین (فقط سوپر ادمین)"""
from adapters.db.models.user import User
from adapters.api.v1.schemas import PaginationInfo
from sqlalchemy import or_, func
business_repo = BusinessRepository(db)
# ساخت کوئری پایه
query = db.query(Business)
# اعمال فیلتر جستجو
if query_info.get('search'):
search_term = query_info['search'].lower()
search_conditions = [
func.lower(Business.name).contains(search_term)
]
# اضافه کردن شرایط جستجو برای فیلدهای اختیاری (که ممکن است null باشند)
search_conditions.append(func.lower(Business.phone).contains(search_term))
search_conditions.append(func.lower(Business.mobile).contains(search_term))
search_conditions.append(func.lower(Business.national_id).contains(search_term))
search_conditions.append(func.lower(Business.economic_id).contains(search_term))
query = query.filter(or_(*search_conditions))
# فیلتر بر اساس نوع کسب و کار
if query_info.get('business_type'):
from adapters.db.models.business import BusinessType
try:
business_type = BusinessType(query_info['business_type'])
query = query.filter(Business.business_type == business_type)
except (ValueError, KeyError):
pass
# فیلتر بر اساس زمینه فعالیت
if query_info.get('business_field'):
from adapters.db.models.business import BusinessField
try:
business_field = BusinessField(query_info['business_field'])
query = query.filter(Business.business_field == business_field)
except (ValueError, KeyError):
pass
# فیلتر بر اساس استان
if query_info.get('province'):
query = query.filter(Business.province == query_info['province'])
# فیلتر بر اساس شهر
if query_info.get('city'):
query = query.filter(Business.city == query_info['city'])
# شمارش کل
total = query.count()
# اعمال مرتب‌سازی
sort_by = query_info.get('sort_by', 'created_at')
sort_desc = query_info.get('sort_desc', True)
if sort_by == 'name':
order_by = Business.name.desc() if sort_desc else Business.name.asc()
elif sort_by == 'business_type':
order_by = Business.business_type.desc() if sort_desc else Business.business_type.asc()
elif sort_by == 'owner_id':
order_by = Business.owner_id.desc() if sort_desc else Business.owner_id.asc()
else: # created_at (پیش‌فرض)
order_by = Business.created_at.desc() if sort_desc else Business.created_at.asc()
query = query.order_by(order_by)
# اعمال صفحه‌بندی
skip = query_info.get('skip', 0)
take = query_info.get('take', 10)
businesses = query.offset(skip).limit(take).all()
# محاسبه اطلاعات صفحه‌بندی
total_pages = (total + take - 1) // take if take > 0 else 1
current_page = (skip // take) + 1 if take > 0 else 1
pagination = PaginationInfo(
total=total,
page=current_page,
per_page=take,
total_pages=total_pages,
has_next=current_page < total_pages,
has_prev=current_page > 1
)
# تبدیل کسب و کارها به dictionary و افزودن اطلاعات مالک
items = []
for business in businesses:
business_dict = _business_to_dict(business)
# افزودن اطلاعات مالک
owner = db.get(User, business.owner_id)
if owner:
business_dict['owner'] = {
'id': owner.id,
'email': owner.email,
'mobile': owner.mobile,
'first_name': owner.first_name,
'last_name': owner.last_name,
'full_name': f"{owner.first_name or ''} {owner.last_name or ''}".strip() or owner.email or owner.mobile
}
else:
business_dict['owner'] = None
items.append(business_dict)
return {
"items": items,
"pagination": pagination.dict(),
"query_info": query_info
}
def _business_to_dict(business: Business) -> Dict[str, Any]:
"""تبدیل مدل کسب و کار به dictionary"""
data = {
"id": business.id,
"name": business.name,
"business_type": business.business_type.value,
"business_field": business.business_field.value,
"owner_id": business.owner_id,
"address": business.address,
"phone": business.phone,
"mobile": business.mobile,
"national_id": business.national_id,
"registration_number": business.registration_number,
"economic_id": business.economic_id,
"country": business.country,
"province": business.province,
"city": business.city,
"postal_code": business.postal_code,
# فایل‌های گرافیکی مرتبط با کسب‌وکار
"logo_file_id": getattr(business, "logo_file_id", None),
"stamp_file_id": getattr(business, "stamp_file_id", None),
# تنظیمات اعتبار
"default_credit_limit": float(business.default_credit_limit) if getattr(business, "default_credit_limit", None) is not None else None,
"check_credit_enabled_by_default": bool(getattr(business, "check_credit_enabled_by_default", False)),
"public_catalog_show_contact": bool(getattr(business, "public_catalog_show_contact", False)),
"public_catalog_show_base_sales_price": bool(getattr(business, "public_catalog_show_base_sales_price", True)),
"display_timezone": getattr(business, "display_timezone", None),
# تنظیمات محاسبه سود فاکتور
"invoice_profit_calculation_method": getattr(business, "invoice_profit_calculation_method", None),
"invoice_profit_calculation_basis": getattr(business, "invoice_profit_calculation_basis", None),
"invoice_profit_include_overhead": bool(getattr(business, "invoice_profit_include_overhead", False)),
"invoice_profit_overhead_type": getattr(business, "invoice_profit_overhead_type", None),
"invoice_profit_overhead_percent": float(business.invoice_profit_overhead_percent) if getattr(business, "invoice_profit_overhead_percent", None) is not None else None,
"invoice_profit_calculation_type": getattr(business, "invoice_profit_calculation_type", None),
"invoice_profit_ledger_recognition_basis": getattr(
business, "invoice_profit_ledger_recognition_basis", None
),
"invoice_profit_fifo_shortage_mode": str(
getattr(business, "invoice_profit_fifo_shortage_mode", None) or "perpetual_mixed",
),
"invoice_sync_update_sales_price_enabled": bool(getattr(business, "invoice_sync_update_sales_price_enabled", False)),
"invoice_sync_update_purchase_price_enabled": bool(getattr(business, "invoice_sync_update_purchase_price_enabled", False)),
"invoice_sync_sales_price_basis": getattr(business, "invoice_sync_sales_price_basis", None),
"invoice_sync_purchase_price_basis": getattr(business, "invoice_sync_purchase_price_basis", None),
"invoice_warehouse_release_mode": _normalize_invoice_warehouse_release_mode(
getattr(business, "invoice_warehouse_release_mode", None),
),
"invoice_purchase_accounting_mode": _normalize_purchase_accounting_mode_for_response(
getattr(business, "invoice_purchase_accounting_mode", None)
),
"invoice_missing_line_warehouse_policy": str(
getattr(business, "invoice_missing_line_warehouse_policy", None) or "reject",
),
"invoice_default_warehouse_id": getattr(business, "invoice_default_warehouse_id", None),
"invoice_default_warehouse_fill_document_header": bool(
getattr(business, "invoice_default_warehouse_fill_document_header", True),
),
"allow_negative_inventory_for_bulk": bool(getattr(business, "allow_negative_inventory_for_bulk", False)),
"allow_negative_inventory_for_unique": bool(getattr(business, "allow_negative_inventory_for_unique", False)),
"warehouse_transfer_require_positive_stock": bool(
getattr(business, "warehouse_transfer_require_positive_stock", True),
),
"goods_expense_income_workflow_mode": str(
getattr(business, "goods_expense_income_workflow_mode", None) or "simple",
),
"goods_expense_income_auto_post_in_simple_mode": bool(
getattr(business, "goods_expense_income_auto_post_in_simple_mode", True),
),
"goods_expense_income_default_expense_account_code": str(
getattr(business, "goods_expense_income_default_expense_account_code", None) or "70407",
),
"goods_expense_income_default_income_account_code": str(
getattr(business, "goods_expense_income_default_income_account_code", None) or "60103",
),
"goods_expense_income_stock_count_mode": str(
getattr(business, "goods_expense_income_stock_count_mode", None) or "goods_docs",
),
"goods_expense_income_allow_manual_unit_cost": bool(
getattr(business, "goods_expense_income_allow_manual_unit_cost", False),
),
"goods_expense_income_require_person": bool(
getattr(business, "goods_expense_income_require_person", False),
),
"invoice_global_discount_percent_basis": str(
getattr(business, "invoice_global_discount_percent_basis", None)
or "subtotal_after_line_discount",
),
"invoice_global_discount_tax_mode": str(
getattr(business, "invoice_global_discount_tax_mode", None)
or "recalculate_tax_proportional",
),
"invoice_global_discount_max_percent": (
float(business.invoice_global_discount_max_percent)
if getattr(business, "invoice_global_discount_max_percent", None) is not None
else None
),
"invoice_global_discount_max_amount": (
float(business.invoice_global_discount_max_amount)
if getattr(business, "invoice_global_discount_max_amount", None) is not None
else None
),
"fx_revaluation_policy": getattr(business, "fx_revaluation_policy", None),
"created_at": business.created_at, # datetime object بماند
"updated_at": business.updated_at, # datetime object بماند
# Soft Delete fields
"deleted_at": business.deleted_at.isoformat() if getattr(business, "deleted_at", None) else None,
"deletion_requested_at": business.deletion_requested_at.isoformat() if getattr(business, "deletion_requested_at", None) else None,
"auto_delete_at": business.auto_delete_at.isoformat() if getattr(business, "auto_delete_at", None) else None,
"is_deleted": getattr(business, "deleted_at", None) is not None,
"is_deletion_pending": _business_is_restorable(business),
}
# ارز پیشفرض
if getattr(business, "default_currency", None):
c = business.default_currency
data["default_currency"] = {
"id": c.id,
"code": c.code,
"title": c.title,
"symbol": c.symbol,
"decimal_places": int(getattr(c, "decimal_places", None) or 0),
}
else:
data["default_currency"] = None
# ارزهای فعال کسب‌وکار
if getattr(business, "currencies", None):
data["currencies"] = [
{"id": c.id, "code": c.code, "title": c.title, "symbol": c.symbol, "decimal_places": int(getattr(c, "decimal_places", None) or 0)}
for c in business.currencies
]
else:
data["currencies"] = []
# چندارزی: حداقل یک ارز فرعی غیر از ارز اصلی
default_id = getattr(business, "default_currency_id", None)
currencies_list = data.get("currencies") or []
if default_id is None:
data["is_multi_currency"] = False
else:
data["is_multi_currency"] = any(
int(c.get("id")) != int(default_id) for c in currencies_list if c.get("id") is not None
)
return data
def get_business_invoice_share_settings(db: Session, business_id: int) -> Dict[str, Any]:
business = db.query(Business).filter(Business.id == int(business_id)).first()
if not business:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
raw = (
business.invoice_share_settings
if isinstance(getattr(business, "invoice_share_settings", None), dict)
else {}
)
gw = raw.get("default_online_payment_gateway_id")
try:
gw_int = int(gw) if gw is not None else None
except Exception:
gw_int = None
return {
"default_online_payment_enabled": bool(raw.get("default_online_payment_enabled") or False),
"default_online_payment_gateway_id": gw_int,
}
def update_business_invoice_share_settings(
db: Session,
business_id: int,
payload: Dict[str, Any],
) -> Dict[str, Any]:
from app.services.document_share_link_service import _assert_gateway_allowed_for_business
business = db.query(Business).filter(Business.id == int(business_id)).first()
if not business:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
body = payload or {}
raw: Dict[str, Any] = {}
if isinstance(getattr(business, "invoice_share_settings", None), dict):
raw = dict(business.invoice_share_settings or {})
if "default_online_payment_enabled" in body:
raw["default_online_payment_enabled"] = bool(body.get("default_online_payment_enabled"))
if "default_online_payment_gateway_id" in body:
gw_val = body.get("default_online_payment_gateway_id")
if gw_val in (None, "", 0, "0"):
raw.pop("default_online_payment_gateway_id", None)
else:
try:
raw["default_online_payment_gateway_id"] = int(gw_val)
except Exception as exc:
raise ApiError(
"INVALID_GATEWAY_ID",
"شناسه درگاه نامعتبر است",
http_status=400,
) from exc
eff_en = bool(raw.get("default_online_payment_enabled") or False)
eff_gw = raw.get("default_online_payment_gateway_id")
try:
eff_gw_int = int(eff_gw) if eff_gw is not None else None
except Exception:
eff_gw_int = None
if eff_en:
if not eff_gw_int:
raise ApiError(
"GATEWAY_NOT_CONFIGURED",
"برای فعال بودن پرداخت آنلاین، انتخاب درگاه الزامی است",
http_status=400,
)
_assert_gateway_allowed_for_business(db, int(business_id), int(eff_gw_int))
business.invoice_share_settings = raw if raw else None
db.add(business)
db.commit()
db.refresh(business)
return get_business_invoice_share_settings(db, business_id)