forked from hesabix/arc
1428 lines
58 KiB
Python
Executable file
1428 lines
58 KiB
Python
Executable file
# Removed __future__ annotations to fix OpenAPI schema generation
|
|
|
|
from typing import Dict, Any, List
|
|
from datetime import datetime, date
|
|
from decimal import Decimal
|
|
import json
|
|
import zipfile
|
|
import io
|
|
|
|
from fastapi import APIRouter, Depends, Request, Query, HTTPException, UploadFile, File, Body, BackgroundTasks
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text, inspect
|
|
from uuid import UUID
|
|
|
|
from adapters.db.session import get_db
|
|
from adapters.api.v1.schemas import (
|
|
BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse,
|
|
BusinessListResponse, BusinessSummaryResponse, SuccessResponse,
|
|
BusinessType, BusinessField
|
|
)
|
|
from app.core.responses import success_response, format_datetime_fields, ApiError
|
|
from app.core.auth_dependency import get_current_user, AuthContext
|
|
from app.core.permissions import (
|
|
require_business_management,
|
|
require_business_access,
|
|
require_business_permission_dep,
|
|
require_business_access_dep,
|
|
)
|
|
from app.core.cache import get_cache
|
|
from app.services.business_service import (
|
|
create_business,
|
|
get_businesses_by_owner,
|
|
get_user_businesses,
|
|
update_business,
|
|
delete_business,
|
|
delete_business_soft,
|
|
get_business_delete_info,
|
|
restore_business,
|
|
get_business_summary,
|
|
get_business_print_settings,
|
|
update_business_print_settings,
|
|
get_business_invoice_share_settings,
|
|
update_business_invoice_share_settings,
|
|
add_business_currency,
|
|
remove_business_currency,
|
|
get_business_currency_usage,
|
|
)
|
|
from app.services.file_storage_service import FileStorageService
|
|
from adapters.db.models.business import Business
|
|
from starlette.responses import StreamingResponse
|
|
from app.services.job_manager import JobManager
|
|
from adapters.api.v1.schema_models.legacy_import import (
|
|
LegacyImportExecuteRequest,
|
|
LegacyImportPreviewRequest,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/businesses", tags=["کسبوکارها"])
|
|
|
|
|
|
@router.post("",
|
|
summary="ایجاد کسب و کار جدید",
|
|
description="ایجاد کسب و کار جدید برای کاربر جاری",
|
|
response_model=SuccessResponse,
|
|
responses={
|
|
200: {
|
|
"description": "کسب و کار با موفقیت ایجاد شد",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": True,
|
|
"message": "کسب و کار با موفقیت ایجاد شد",
|
|
"data": {
|
|
"id": 1,
|
|
"name": "شرکت نمونه",
|
|
"business_type": "شرکت",
|
|
"business_field": "تولیدی",
|
|
"owner_id": 1,
|
|
"created_at": "2024-01-01T00:00:00Z"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
400: {
|
|
"description": "خطا در اعتبارسنجی دادهها"
|
|
},
|
|
401: {
|
|
"description": "کاربر احراز هویت نشده است"
|
|
}
|
|
}
|
|
)
|
|
def create_new_business(
|
|
request: Request,
|
|
business_data: BusinessCreateRequest,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""ایجاد کسب و کار جدید"""
|
|
owner_id = ctx.get_user_id()
|
|
business = create_business(db, business_data, owner_id)
|
|
formatted_data = format_datetime_fields(business, request)
|
|
return success_response(formatted_data, request)
|
|
|
|
|
|
def _normalize_business_type(value: Any) -> BusinessType:
|
|
"""
|
|
تبدیل business_type از بکاپ به BusinessType enum.
|
|
پشتیبانی از enum name (مثل 'SHOP') و مقدار فارسی (مثل 'مغازه').
|
|
"""
|
|
if value is None:
|
|
return BusinessType.COMPANY
|
|
|
|
value_str = str(value).strip()
|
|
|
|
# اگر مقدار فارسی است، مستقیماً استفاده میکنیم
|
|
persian_values = {
|
|
"شرکت": BusinessType.COMPANY,
|
|
"مغازه": BusinessType.SHOP,
|
|
"فروشگاه": BusinessType.STORE,
|
|
"اتحادیه": BusinessType.UNION,
|
|
"باشگاه": BusinessType.CLUB,
|
|
"موسسه": BusinessType.INSTITUTE,
|
|
"شخصی": BusinessType.INDIVIDUAL,
|
|
}
|
|
if value_str in persian_values:
|
|
return persian_values[value_str]
|
|
|
|
# اگر enum name است (مثل 'SHOP', 'COMPANY')
|
|
enum_name_to_type = {
|
|
"COMPANY": BusinessType.COMPANY,
|
|
"SHOP": BusinessType.SHOP,
|
|
"STORE": BusinessType.STORE,
|
|
"UNION": BusinessType.UNION,
|
|
"CLUB": BusinessType.CLUB,
|
|
"INSTITUTE": BusinessType.INSTITUTE,
|
|
"INDIVIDUAL": BusinessType.INDIVIDUAL,
|
|
}
|
|
if value_str.upper() in enum_name_to_type:
|
|
return enum_name_to_type[value_str.upper()]
|
|
|
|
# تلاش برای پیدا کردن در enum
|
|
try:
|
|
return BusinessType(value_str)
|
|
except ValueError:
|
|
# اگر پیدا نشد، پیشفرض شرکت
|
|
return BusinessType.COMPANY
|
|
|
|
|
|
def _normalize_business_field(value: Any) -> BusinessField:
|
|
"""
|
|
تبدیل business_field از بکاپ به BusinessField enum.
|
|
پشتیبانی از enum name (مثل 'MANUFACTURING', 'COMMERCIAL', 'TRADING') و مقدار فارسی (مثل 'تولیدی').
|
|
"""
|
|
if value is None:
|
|
return BusinessField.OTHER
|
|
|
|
value_str = str(value).strip()
|
|
|
|
# اگر مقدار فارسی است، مستقیماً استفاده میکنیم
|
|
persian_values = {
|
|
"تولیدی": BusinessField.MANUFACTURING,
|
|
"بازرگانی": BusinessField.COMMERCIAL,
|
|
"خدماتی": BusinessField.SERVICE,
|
|
"سایر": BusinessField.OTHER,
|
|
}
|
|
if value_str in persian_values:
|
|
return persian_values[value_str]
|
|
|
|
# اگر enum name است
|
|
enum_name_to_field = {
|
|
"MANUFACTURING": BusinessField.MANUFACTURING,
|
|
"COMMERCIAL": BusinessField.COMMERCIAL,
|
|
"TRADING": BusinessField.COMMERCIAL, # تبدیل TRADING به COMMERCIAL (برای سازگاری)
|
|
"SERVICE": BusinessField.SERVICE,
|
|
"OTHER": BusinessField.OTHER,
|
|
}
|
|
if value_str.upper() in enum_name_to_field:
|
|
return enum_name_to_field[value_str.upper()]
|
|
|
|
# تلاش برای پیدا کردن در enum
|
|
try:
|
|
return BusinessField(value_str)
|
|
except ValueError:
|
|
# اگر پیدا نشد، پیشفرض سایر
|
|
return BusinessField.OTHER
|
|
|
|
|
|
@router.post("/import-from-backup",
|
|
summary="ایجاد کسب و کار جدید از فایل پشتیبان",
|
|
description="ایجاد کسب و کار جدید از فایل پشتیبان (.hbx). فایلهای .hs60 در حال حاضر پشتیبانی نمیشوند.",
|
|
response_model=SuccessResponse,
|
|
responses={
|
|
200: {
|
|
"description": "کسب و کار با موفقیت از فایل پشتیبان ایجاد شد",
|
|
},
|
|
400: {
|
|
"description": "خطا در فایل پشتیبان یا فرمت نامعتبر"
|
|
},
|
|
401: {
|
|
"description": "کاربر احراز هویت نشده است"
|
|
}
|
|
}
|
|
)
|
|
async def import_business_from_backup(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
async_mode: bool = True,
|
|
background: BackgroundTasks = None,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""
|
|
ایجاد کسب و کار جدید از فایل پشتیبان (.hbx)
|
|
|
|
- پشتیبانی از فایلهای .hbx (فرمت ZIP با metadata.json و tables/*.jsonl)
|
|
- فایلهای .hs60 در حال حاضر پشتیبانی نمیشوند
|
|
"""
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# بررسی پسوند فایل
|
|
filename = file.filename or ""
|
|
file_ext = filename.lower().split('.')[-1] if '.' in filename else ""
|
|
|
|
# بررسی فایل .hs60 - نمایش خطا
|
|
if file_ext == "hs60":
|
|
raise ApiError(
|
|
"HS60_NOT_SUPPORTED",
|
|
"فرمت فایل .hs60 در حال حاضر پشتیبانی نمیشود. این قابلیت در آینده اضافه خواهد شد.",
|
|
http_status=400
|
|
)
|
|
|
|
# بررسی فایل .hbx
|
|
if file_ext != "hbx":
|
|
raise ApiError(
|
|
"INVALID_FILE_FORMAT",
|
|
"فرمت فایل معتبر نیست. فقط فایلهای .hbx پشتیبانی میشوند.",
|
|
http_status=400
|
|
)
|
|
|
|
if async_mode:
|
|
jm = JobManager.instance()
|
|
job_id = jm.create("Import business from backup queued")
|
|
|
|
# خواندن محتوای فایل قبل از شروع background task
|
|
file_bytes: bytes
|
|
try:
|
|
file_bytes = await file.read()
|
|
except Exception as e:
|
|
logger.error(f"Error reading file: {e}")
|
|
raise ApiError("FILE_READ_ERROR", f"خطا در خواندن فایل: {str(e)}", http_status=400)
|
|
|
|
def task():
|
|
# استفاده از get_db_session برای background task
|
|
from adapters.db.session import get_db_session
|
|
with get_db_session() as db:
|
|
try:
|
|
jm.start(job_id, "Starting import")
|
|
jm.update(job_id, 10, "Loading backup file")
|
|
|
|
# بررسی و خواندن فایل ZIP
|
|
buf = io.BytesIO(file_bytes)
|
|
try:
|
|
zf = zipfile.ZipFile(buf, mode="r")
|
|
except zipfile.BadZipFile:
|
|
raise ApiError("INVALID_BACKUP", "فایل ZIP معتبر نیست", http_status=400)
|
|
|
|
# خواندن metadata.json
|
|
try:
|
|
metadata = json.loads(zf.read("metadata.json").decode("utf-8"))
|
|
except KeyError:
|
|
raise ApiError("INVALID_BACKUP", "metadata.json در فایل پشتیبان یافت نشد", http_status=400)
|
|
|
|
jm.update(job_id, 20, "Reading business data")
|
|
|
|
# خواندن اطلاعات کسبوکار
|
|
try:
|
|
business_rows = []
|
|
with zf.open("tables/businesses.jsonl", "r") as f:
|
|
for raw in f:
|
|
if not raw:
|
|
continue
|
|
business_rows.append(json.loads(raw.decode("utf-8")))
|
|
|
|
if not business_rows:
|
|
raise ApiError("INVALID_BACKUP", "اطلاعات کسبوکار در فایل پشتیبان یافت نشد", http_status=400)
|
|
|
|
original_business = business_rows[0]
|
|
except KeyError:
|
|
raise ApiError("INVALID_BACKUP", "tables/businesses.jsonl در فایل پشتیبان یافت نشد", http_status=400)
|
|
|
|
from app.services.business_backup_financial_policy import (
|
|
compute_backup_checksum,
|
|
finalize_financial_state_after_restore,
|
|
guard_new_business_import,
|
|
register_backup_import,
|
|
validate_backup_owner,
|
|
)
|
|
|
|
validate_backup_owner(
|
|
metadata,
|
|
ctx.get_user_id(),
|
|
db=db,
|
|
backup_business_row=original_business,
|
|
)
|
|
backup_checksum = compute_backup_checksum(file_bytes)
|
|
guard_new_business_import(
|
|
db,
|
|
user_id=ctx.get_user_id(),
|
|
backup_checksum=backup_checksum,
|
|
import_mode="new_business",
|
|
)
|
|
|
|
jm.update(job_id, 30, "Creating new business")
|
|
|
|
# تبدیل به BusinessCreateRequest
|
|
business_create_data = BusinessCreateRequest(
|
|
name=original_business.get('name', 'کسبوکار جدید'),
|
|
business_type=_normalize_business_type(original_business.get('business_type')),
|
|
business_field=_normalize_business_field(original_business.get('business_field')),
|
|
address=original_business.get('address'),
|
|
phone=original_business.get('phone'),
|
|
mobile=original_business.get('mobile'),
|
|
national_id=original_business.get('national_id'),
|
|
registration_number=original_business.get('registration_number'),
|
|
economic_id=original_business.get('economic_id'),
|
|
country=original_business.get('country'),
|
|
province=original_business.get('province'),
|
|
city=original_business.get('city'),
|
|
postal_code=original_business.get('postal_code'),
|
|
default_currency_id=original_business.get('default_currency_id'),
|
|
default_credit_limit=float(original_business.get('default_credit_limit')) if original_business.get('default_credit_limit') else None,
|
|
check_credit_enabled_by_default=bool(original_business.get('check_credit_enabled_by_default', False)),
|
|
)
|
|
|
|
# ایجاد کسبوکار + ایمپورت جداول در یک تراکنش (مثل /backups/restore)
|
|
new_business = create_business(
|
|
db,
|
|
business_create_data,
|
|
ctx.get_user_id(),
|
|
defer_commit=True,
|
|
)
|
|
new_business_id = new_business["id"]
|
|
|
|
jm.update(job_id, 40, f"Business created (ID: {new_business_id})")
|
|
|
|
from sqlalchemy import inspect as sa_inspect
|
|
|
|
from adapters.api.v1.business_backups import (
|
|
_discover_scoped_tables,
|
|
_sort_tables_for_insert_by_fks,
|
|
_try_set_session_replication_role_replica,
|
|
_reset_session_replication_role,
|
|
_insert_table_from_backup_zip,
|
|
is_backup_excluded_table,
|
|
)
|
|
from app.services.business_backup_schema_compat import (
|
|
analyze_schema_diff,
|
|
load_table_column_meta,
|
|
)
|
|
|
|
tables_info = _discover_scoped_tables(db)
|
|
target_tables = [
|
|
t
|
|
for t in tables_info.keys()
|
|
if t != "businesses" and not is_backup_excluded_table(t)
|
|
]
|
|
target_tables = _sort_tables_for_insert_by_fks(db.get_bind(), target_tables)
|
|
|
|
conn = db.connection()
|
|
replica_role_ok = _try_set_session_replication_role_replica(conn)
|
|
schema_inspector = sa_inspect(db.get_bind())
|
|
json_cols_cache: dict[str, set[str]] = {}
|
|
column_meta_cache: dict[str, dict] = {}
|
|
|
|
try:
|
|
jm.update(job_id, 50, "Importing data")
|
|
for _t in target_tables:
|
|
if _t not in column_meta_cache:
|
|
column_meta_cache[_t] = load_table_column_meta(
|
|
schema_inspector, _t, conn
|
|
)
|
|
analyze_schema_diff(metadata, tables_info, target_tables, column_meta_cache)
|
|
|
|
for table in target_tables:
|
|
try:
|
|
zf.getinfo(f"tables/{table}.jsonl")
|
|
except KeyError:
|
|
continue
|
|
_insert_table_from_backup_zip(
|
|
conn,
|
|
zf,
|
|
table=table,
|
|
metadata=metadata,
|
|
tables_info=tables_info,
|
|
schema_inspector=schema_inspector,
|
|
new_business_id=new_business_id,
|
|
mode="new_business",
|
|
json_cols_cache=json_cols_cache,
|
|
column_meta_cache=column_meta_cache,
|
|
)
|
|
|
|
_reset_session_replication_role(conn, replica_role_ok)
|
|
|
|
jm.update(job_id, 90, "Resetting financial state")
|
|
finalize_financial_state_after_restore(db, new_business_id)
|
|
register_backup_import(
|
|
db,
|
|
user_id=ctx.get_user_id(),
|
|
backup_checksum=backup_checksum,
|
|
import_mode="new_business",
|
|
source_business_id=int(metadata.get("business_id"))
|
|
if metadata.get("business_id")
|
|
else None,
|
|
target_business_id=new_business_id,
|
|
)
|
|
from app.services.business_service import ensure_business_default_document_policies
|
|
|
|
ensure_business_default_document_policies(
|
|
db,
|
|
new_business_id,
|
|
user_id=ctx.get_user_id(),
|
|
commit=True,
|
|
)
|
|
except Exception:
|
|
db.rollback()
|
|
_reset_session_replication_role(conn, replica_role_ok)
|
|
raise
|
|
|
|
zf.close()
|
|
# commit نهایی توسط get_db_session() پس از خروج موفق از task
|
|
|
|
result_data = {
|
|
"business_id": new_business_id,
|
|
"business": new_business,
|
|
}
|
|
jm.succeed(job_id, result_data, "Import completed")
|
|
except Exception as e:
|
|
# استخراج error message به صورت مناسب
|
|
error_msg = None
|
|
error_code = None
|
|
error_details = None
|
|
|
|
# بررسی ApiError یا HTTPException
|
|
from fastapi import HTTPException
|
|
if isinstance(e, (ApiError, HTTPException)):
|
|
if hasattr(e, 'detail') and isinstance(e.detail, dict):
|
|
error_info = e.detail.get("error", {})
|
|
if isinstance(error_info, dict):
|
|
error_code = error_info.get("code")
|
|
error_msg = error_info.get("message")
|
|
error_details = error_info.get("details")
|
|
elif isinstance(e.detail, str):
|
|
error_msg = e.detail
|
|
|
|
# اگر error message پیدا نشد، از str(e) استفاده کن
|
|
if not error_msg:
|
|
error_msg = str(e) if e else "Unknown error"
|
|
|
|
# ساخت error message مناسب برای frontend
|
|
if error_code:
|
|
final_error = f"{error_code}: {error_msg}"
|
|
if error_details:
|
|
final_error += f" | Details: {error_details}"
|
|
else:
|
|
final_error = error_msg
|
|
|
|
jm.fail(job_id, final_error, "Import failed")
|
|
raise
|
|
|
|
background.add_task(task)
|
|
return success_response({"job_id": job_id}, request=request, message="ایمپورت کسبوکار شروع شد")
|
|
else:
|
|
# مسیر همزمان (برای تست - توصیه نمیشود برای فایلهای بزرگ)
|
|
raise ApiError("SYNC_DISABLED", "لطفاً از async_mode=true استفاده کنید", http_status=400)
|
|
|
|
|
|
@router.post(
|
|
"/import-from-legacy-api/preview",
|
|
summary="پیشنمایش انتقال از نسخه قدیم (API)",
|
|
description="اعتبارسنجی کلید API و نمایش حجم دادههای قابل انتقال از حسابیکس نسخه قدیم",
|
|
response_model=SuccessResponse,
|
|
)
|
|
def preview_import_from_legacy_api(
|
|
request: Request,
|
|
payload: LegacyImportPreviewRequest,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
from app.services.business_service import check_business_creation_permission
|
|
from app.services.legacy_import.preview_service import preview_legacy_import
|
|
from app.services.system_settings_service import assert_legacy_api_import_allowed
|
|
assert_legacy_api_import_allowed(db)
|
|
can_create, error_message = check_business_creation_permission(db, ctx.get_user_id())
|
|
if not can_create:
|
|
raise ApiError(
|
|
"BUSINESS_CREATION_NOT_ALLOWED",
|
|
error_message or "شما اجازه ایجاد کسب و کار را ندارید",
|
|
http_status=403,
|
|
)
|
|
data = preview_legacy_import(payload.server_url, payload.api_key)
|
|
return success_response(format_datetime_fields(data, request), request, message="پیشنمایش انتقال")
|
|
|
|
|
|
@router.post(
|
|
"/import-from-legacy-api",
|
|
summary="ایجاد کسب و کار از نسخه قدیم (API)",
|
|
description="دانلود آرشیو از سرور نسخه قدیم و ایجاد کسبوکار جدید در پنل کاربر",
|
|
response_model=SuccessResponse,
|
|
)
|
|
async def import_business_from_legacy_api(
|
|
request: Request,
|
|
payload: LegacyImportExecuteRequest,
|
|
async_mode: bool = True,
|
|
background: BackgroundTasks = None,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
from app.services.business_service import check_business_creation_permission
|
|
from app.services.legacy_import.importer import LegacyBusinessImporter
|
|
from app.services.legacy_import.preview_service import LegacyImportOptions
|
|
from app.services.system_settings_service import assert_legacy_api_import_allowed
|
|
assert_legacy_api_import_allowed(db)
|
|
can_create, error_message = check_business_creation_permission(db, ctx.get_user_id())
|
|
if not can_create:
|
|
raise ApiError(
|
|
"BUSINESS_CREATION_NOT_ALLOWED",
|
|
error_message or "شما اجازه ایجاد کسب و کار را ندارید",
|
|
http_status=403,
|
|
)
|
|
|
|
if not async_mode:
|
|
raise ApiError(
|
|
"SYNC_DISABLED",
|
|
"لطفاً از async_mode=true استفاده کنید",
|
|
http_status=400,
|
|
)
|
|
|
|
opts_schema = payload.options
|
|
options = LegacyImportOptions(
|
|
business_name_override=opts_schema.business_name_override if opts_schema else None,
|
|
business_name_suffix=(opts_schema.business_name_suffix if opts_schema else "") or "",
|
|
import_persons=opts_schema.import_persons if opts_schema else True,
|
|
import_products=opts_schema.import_products if opts_schema else True,
|
|
import_banks=opts_schema.import_banks if opts_schema else True,
|
|
import_warehouses=opts_schema.import_warehouses if opts_schema else True,
|
|
import_documents=opts_schema.import_documents if opts_schema else True,
|
|
import_files=opts_schema.import_files if opts_schema else True,
|
|
)
|
|
|
|
jm = JobManager.instance()
|
|
job_id = jm.create("Legacy API import queued")
|
|
owner_id = ctx.get_user_id()
|
|
server_url = payload.server_url
|
|
api_key = payload.api_key
|
|
|
|
def task():
|
|
from adapters.db.session import get_db_session
|
|
|
|
with get_db_session() as task_db:
|
|
try:
|
|
jm.start(job_id, "شروع انتقال از نسخه قدیم")
|
|
|
|
def on_progress(pct: int, msg: str) -> None:
|
|
jm.update(job_id, pct, msg)
|
|
|
|
importer = LegacyBusinessImporter(
|
|
task_db,
|
|
owner_id,
|
|
server_url,
|
|
api_key,
|
|
options=options,
|
|
progress_callback=on_progress,
|
|
)
|
|
result = importer.run()
|
|
jm.succeed(job_id, result, "انتقال از نسخه قدیم با موفقیت انجام شد")
|
|
except Exception as e:
|
|
from app.services.legacy_import.errors import format_legacy_import_exception
|
|
|
|
error_msg = format_legacy_import_exception(e)
|
|
# پیام واضح برای کاربر؛ از متن عمومی انگلیسی استفاده نمیشود
|
|
jm.fail(job_id, error_msg, error_msg)
|
|
# خطا قبلاً در وضعیت job ثبت شده؛ از پرتاب مجدد برای جلوگیری از
|
|
# لاگ «Unhandled exception» گمراهکننده خودداری میکنیم.
|
|
import logging
|
|
|
|
logging.getLogger(__name__).exception(
|
|
"Legacy API import job %s failed: %s", job_id, error_msg
|
|
)
|
|
|
|
background.add_task(task)
|
|
return success_response(
|
|
{"job_id": job_id},
|
|
request=request,
|
|
message="انتقال از نسخه قدیم شروع شد",
|
|
)
|
|
|
|
|
|
@router.post("/list",
|
|
summary="لیست کسب و کارهای کاربر",
|
|
description="دریافت لیست کسب و کارهای کاربر جاری با قابلیت فیلتر و جستجو",
|
|
response_model=SuccessResponse,
|
|
responses={
|
|
200: {
|
|
"description": "لیست کسب و کارها با موفقیت دریافت شد",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": True,
|
|
"message": "لیست کسب و کارها دریافت شد",
|
|
"data": {
|
|
"items": [
|
|
{
|
|
"id": 1,
|
|
"name": "شرکت نمونه",
|
|
"business_type": "شرکت",
|
|
"business_field": "تولیدی",
|
|
"owner_id": 1,
|
|
"created_at": "1403/01/01 00:00:00"
|
|
}
|
|
],
|
|
"pagination": {
|
|
"total": 1,
|
|
"page": 1,
|
|
"per_page": 10,
|
|
"total_pages": 1,
|
|
"has_next": False,
|
|
"has_prev": False
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
401: {
|
|
"description": "کاربر احراز هویت نشده است"
|
|
}
|
|
}
|
|
)
|
|
def list_user_businesses(
|
|
request: Request,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
take: int = 10,
|
|
skip: int = 0,
|
|
sort_by: str = "created_at",
|
|
sort_desc: bool = True,
|
|
search: str = None
|
|
) -> dict:
|
|
"""لیست کسب و کارهای کاربر (مالک + عضو)"""
|
|
user_id = ctx.get_user_id()
|
|
query_dict = {
|
|
"take": take,
|
|
"skip": skip,
|
|
"sort_by": sort_by,
|
|
"sort_desc": sort_desc,
|
|
"search": search
|
|
}
|
|
|
|
# کش لیست کسبوکارهای کاربر
|
|
cache = get_cache()
|
|
cache_key = None
|
|
|
|
if cache.enabled:
|
|
import json, hashlib
|
|
key_payload = {
|
|
"user_id": user_id,
|
|
"query": query_dict,
|
|
}
|
|
key_str = json.dumps(key_payload, sort_keys=True, ensure_ascii=False)
|
|
key_hash = hashlib.sha256(key_str.encode("utf-8")).hexdigest()[:16]
|
|
cache_key = f"user_businesses:{key_hash}"
|
|
cached = cache.get(cache_key)
|
|
if cached is not None:
|
|
return success_response(cached, request)
|
|
|
|
businesses = get_user_businesses(db, user_id, query_dict)
|
|
formatted_data = format_datetime_fields(businesses, request)
|
|
|
|
if cache.enabled and cache_key:
|
|
cache.set(cache_key, formatted_data, ttl=60)
|
|
|
|
return success_response(formatted_data, request)
|
|
|
|
|
|
@router.get(
|
|
"/{business_id}/print-settings",
|
|
summary="تنظیمات چاپ فاکتورهای کسبوکار",
|
|
description="دریافت تنظیمات چاپ فاکتور (لوگو، مهر، مقیاس مهر/امضا، پرداختها، اقساط و متن انتهایی) بهصورت پیشفرض و به تفکیک نوع فاکتور.",
|
|
response_model=SuccessResponse,
|
|
)
|
|
async def get_business_print_settings_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "print")),
|
|
) -> dict:
|
|
"""دریافت تنظیمات چاپ فاکتورهای یک کسبوکار."""
|
|
# بررسی وجود کسبوکار (require_business_permission_dep دسترسی را چک کرده است)
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if not business:
|
|
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
|
|
|
|
data = get_business_print_settings(db, business_id)
|
|
return success_response(data, request)
|
|
|
|
|
|
@router.put(
|
|
"/{business_id}/print-settings",
|
|
summary="ویرایش تنظیمات چاپ فاکتورهای کسبوکار",
|
|
description="ذخیره تنظیمات چاپ فاکتور (لوگو، مهر، مقیاس مهر/امضا، پرداختها، اقساط و متن انتهایی) بهصورت پیشفرض و به تفکیک نوع فاکتور.",
|
|
response_model=SuccessResponse,
|
|
)
|
|
async def update_business_print_settings_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
payload: Dict[str, Any],
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "print")),
|
|
) -> dict:
|
|
"""ویرایش تنظیمات چاپ فاکتورهای یک کسبوکار."""
|
|
# بررسی وجود کسبوکار (require_business_permission_dep دسترسی را چک کرده است)
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if not business:
|
|
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
|
|
|
|
updated = update_business_print_settings(db, business_id, payload or {})
|
|
return success_response(updated, request, "تنظیمات چاپ با موفقیت ذخیره شد")
|
|
|
|
|
|
@router.get(
|
|
"/{business_id}/invoice-share-settings",
|
|
summary="تنظیمات پیشفرض پرداخت آنلاین لینک عمومی فاکتور",
|
|
description="مقادیر پیشفرض برای فعالسازی پرداخت آنلاین هنگام ایجاد لینک اشتراک فاکتور جدید.",
|
|
response_model=SuccessResponse,
|
|
)
|
|
async def get_business_invoice_share_settings_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
_ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_access_dep),
|
|
) -> dict:
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if not business:
|
|
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
|
|
data = get_business_invoice_share_settings(db, business_id)
|
|
return success_response(data, request)
|
|
|
|
|
|
@router.put(
|
|
"/{business_id}/invoice-share-settings",
|
|
summary="ذخیرهٔ تنظیمات پیشفرض پرداخت آنلاین لینک عمومی فاکتور",
|
|
description="بهروزرسانی پیشفرضهای پرداخت آنلاین برای لینکهای اشتراک فاکتور.",
|
|
response_model=SuccessResponse,
|
|
)
|
|
async def update_business_invoice_share_settings_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
payload: Dict[str, Any] = Body(...),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "business")),
|
|
) -> dict:
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if not business:
|
|
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
|
|
updated = update_business_invoice_share_settings(db, business_id, payload or {})
|
|
return success_response(updated, request, message="INVOICE_SHARE_SETTINGS_UPDATED")
|
|
|
|
|
|
@router.get("/{business_id}",
|
|
summary="جزئیات کسب و کار",
|
|
description="دریافت جزئیات یک کسب و کار خاص",
|
|
response_model=SuccessResponse,
|
|
responses={
|
|
200: {
|
|
"description": "جزئیات کسب و کار با موفقیت دریافت شد",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": True,
|
|
"message": "جزئیات کسب و کار دریافت شد",
|
|
"data": {
|
|
"id": 1,
|
|
"name": "شرکت نمونه",
|
|
"business_type": "شرکت",
|
|
"business_field": "تولیدی",
|
|
"owner_id": 1,
|
|
"address": "تهران، خیابان ولیعصر",
|
|
"phone": "02112345678",
|
|
"created_at": "1403/01/01 00:00:00"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
401: {
|
|
"description": "کاربر احراز هویت نشده است"
|
|
},
|
|
403: {
|
|
"description": "دسترسی غیرمجاز به کسب و کار"
|
|
},
|
|
404: {
|
|
"description": "کسب و کار یافت نشد"
|
|
}
|
|
}
|
|
)
|
|
@router.post("/{business_id}/details",
|
|
summary="جزئیات کسب و کار",
|
|
description="دریافت جزئیات یک کسب و کار خاص",
|
|
response_model=SuccessResponse,
|
|
responses={
|
|
200: {
|
|
"description": "جزئیات کسب و کار با موفقیت دریافت شد",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": True,
|
|
"message": "جزئیات کسب و کار دریافت شد",
|
|
"data": {
|
|
"id": 1,
|
|
"name": "شرکت نمونه",
|
|
"business_type": "شرکت",
|
|
"business_field": "تولیدی",
|
|
"owner_id": 1,
|
|
"address": "تهران، خیابان ولیعصر",
|
|
"phone": "02112345678",
|
|
"created_at": "1403/01/01 00:00:00"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
401: {
|
|
"description": "کاربر احراز هویت نشده است"
|
|
},
|
|
403: {
|
|
"description": "دسترسی غیرمجاز به کسب و کار"
|
|
},
|
|
404: {
|
|
"description": "کسب و کار یافت نشد"
|
|
}
|
|
}
|
|
)
|
|
def get_business(
|
|
request: Request,
|
|
business_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""دریافت جزئیات کسب و کار"""
|
|
from adapters.db.repositories.business_repo import BusinessRepository
|
|
from app.services.business_service import _business_to_dict
|
|
|
|
business_repo = BusinessRepository(db)
|
|
business = business_repo.get_by_id(business_id)
|
|
if not business or getattr(business, "deleted_at", None) is not None:
|
|
raise ApiError("NOT_FOUND", "کسب و کار یافت نشد", http_status=404)
|
|
if not ctx.can_access_business(business_id):
|
|
raise ApiError("FORBIDDEN", "دسترسی غیرمجاز به این کسب و کار", http_status=403)
|
|
|
|
formatted_data = format_datetime_fields(_business_to_dict(business), request)
|
|
return success_response(formatted_data, request)
|
|
|
|
|
|
@router.put("/{business_id}",
|
|
summary="ویرایش کسب و کار",
|
|
description="ویرایش اطلاعات یک کسب و کار",
|
|
response_model=SuccessResponse,
|
|
responses={
|
|
200: {
|
|
"description": "کسب و کار با موفقیت ویرایش شد",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": True,
|
|
"message": "کسب و کار با موفقیت ویرایش شد",
|
|
"data": {
|
|
"id": 1,
|
|
"name": "شرکت نمونه ویرایش شده",
|
|
"business_type": "شرکت",
|
|
"business_field": "تولیدی",
|
|
"owner_id": 1,
|
|
"updated_at": "2024-01-01T12:00:00Z"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
400: {
|
|
"description": "خطا در اعتبارسنجی دادهها"
|
|
},
|
|
401: {
|
|
"description": "کاربر احراز هویت نشده است"
|
|
},
|
|
404: {
|
|
"description": "کسب و کار یافت نشد"
|
|
}
|
|
}
|
|
)
|
|
@require_business_access("business_id")
|
|
def update_business_info(
|
|
request: Request,
|
|
business_id: int,
|
|
business_data: BusinessUpdateRequest,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "business")),
|
|
) -> dict:
|
|
"""ویرایش کسب و کار"""
|
|
owner_id = ctx.get_user_id()
|
|
business = update_business(db, business_id, business_data, owner_id)
|
|
|
|
if not business:
|
|
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
|
|
|
|
formatted_data = format_datetime_fields(business, request)
|
|
return success_response(formatted_data, request, "کسب و کار با موفقیت ویرایش شد")
|
|
|
|
|
|
@router.get("/{business_id}/delete-info",
|
|
summary="دریافت اطلاعات حذف کسب و کار",
|
|
description="بررسی و دریافت اطلاعات مرتبط با حذف کسب و کار",
|
|
response_model=SuccessResponse,
|
|
)
|
|
@require_business_access("business_id")
|
|
def get_business_delete_info_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""دریافت اطلاعات مرتبط با حذف کسب و کار"""
|
|
owner_id = ctx.get_user_id()
|
|
info = get_business_delete_info(db, business_id, owner_id)
|
|
return success_response(info, request, "اطلاعات حذف کسب و کار")
|
|
|
|
|
|
@router.delete("/{business_id}",
|
|
summary="حذف کسب و کار",
|
|
description="حذف نرم یک کسب و کار (30 روز قابل بازیابی)",
|
|
response_model=SuccessResponse,
|
|
responses={
|
|
200: {
|
|
"description": "کسب و کار با موفقیت حذف شد",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": True,
|
|
"message": "کسب و کار با موفقیت حذف شد. شما 30 روز فرصت دارید آن را بازیابی کنید.",
|
|
"data": {
|
|
"business_id": 1,
|
|
"deleted_at": "2024-01-01T12:00:00Z",
|
|
"auto_delete_at": "2024-01-31T12:00:00Z",
|
|
"restore_deadline_days": 30,
|
|
"backup_created": True
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
401: {
|
|
"description": "کاربر احراز هویت نشده است"
|
|
},
|
|
403: {
|
|
"description": "فقط مالک کسب و کار میتواند آن را حذف کند"
|
|
},
|
|
404: {
|
|
"description": "کسب و کار یافت نشد"
|
|
},
|
|
409: {
|
|
"description": "نمیتوان کسب و کار را حذف کرد (محدودیتها وجود دارد)"
|
|
}
|
|
}
|
|
)
|
|
@require_business_access("business_id")
|
|
def delete_business_info(
|
|
request: Request,
|
|
business_id: int,
|
|
deletion_reason: str | None = Body(None, embed=True),
|
|
skip_restore_period: bool = Body(False, embed=True),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "business")),
|
|
) -> dict:
|
|
"""
|
|
حذف نرم کسب و کار (Soft Delete)
|
|
- فقط مالک میتواند حذف کند
|
|
- قبل از حذف، بکاپ خودکار ایجاد میشود
|
|
- اطلاعرسانی به مالک ارسال میشود
|
|
- بهطور پیشفرض کسب و کار 30 روز قابل بازیابی است
|
|
- با skip_restore_period=true مهلت بازیابی اعمال نمیشود (حذف سریع)
|
|
"""
|
|
from app.services.notification_service import NotificationService
|
|
|
|
owner_id = ctx.get_user_id()
|
|
result = delete_business_soft(
|
|
db=db,
|
|
business_id=business_id,
|
|
owner_id=owner_id,
|
|
deletion_reason=deletion_reason,
|
|
requested_by=owner_id,
|
|
skip_restore_period=skip_restore_period,
|
|
)
|
|
|
|
restore_days = int(result.get("restore_deadline_days") or 0)
|
|
if skip_restore_period:
|
|
success_message = "کسب و کار با موفقیت حذف شد. مهلت بازیابی فعال نیست."
|
|
else:
|
|
success_message = "کسب و کار با موفقیت حذف شد. شما 30 روز فرصت دارید آن را بازیابی کنید."
|
|
|
|
# ارسال اطلاعرسانی
|
|
try:
|
|
notification_service = NotificationService(db)
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if business:
|
|
notification_service.send(
|
|
user_id=owner_id,
|
|
event_key="business.deleted",
|
|
context={
|
|
"business_name": business.name,
|
|
"business_id": business_id,
|
|
"deletion_date": result["deleted_at"],
|
|
"restore_deadline": result["auto_delete_at"],
|
|
"restore_days": restore_days,
|
|
"skip_restore_period": skip_restore_period,
|
|
},
|
|
preferred_channels=["email", "telegram", "inapp"]
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning(f"Failed to send deletion notification: {e}")
|
|
|
|
return success_response(
|
|
result,
|
|
request,
|
|
success_message,
|
|
)
|
|
|
|
|
|
@router.post("/{business_id}/restore",
|
|
summary="بازیابی کسب و کار",
|
|
description="بازیابی کسب و کار حذف شده (فقط در 30 روز اول)",
|
|
response_model=SuccessResponse,
|
|
responses={
|
|
200: {
|
|
"description": "کسب و کار با موفقیت بازیابی شد"
|
|
},
|
|
400: {
|
|
"description": "کسب و کار حذف نشده است"
|
|
},
|
|
403: {
|
|
"description": "فقط مالک میتواند کسب و کار را بازیابی کند"
|
|
},
|
|
404: {
|
|
"description": "کسب و کار یافت نشد"
|
|
},
|
|
410: {
|
|
"description": "مهلت بازیابی به پایان رسیده است"
|
|
}
|
|
}
|
|
)
|
|
def restore_business_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""بازیابی کسب و کار حذف شده (فقط در 30 روز اول)"""
|
|
from app.services.notification_service import NotificationService
|
|
|
|
owner_id = ctx.get_user_id()
|
|
result = restore_business(db, business_id, owner_id)
|
|
|
|
# اطلاعرسانی
|
|
try:
|
|
notification_service = NotificationService(db)
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if business:
|
|
notification_service.send(
|
|
user_id=owner_id,
|
|
event_key="business.restored",
|
|
context={
|
|
"business_name": business.name,
|
|
"business_id": business_id,
|
|
},
|
|
preferred_channels=["email", "telegram", "inapp"]
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning(f"Failed to send restoration notification: {e}")
|
|
|
|
return success_response(
|
|
result,
|
|
request,
|
|
"کسب و کار با موفقیت بازیابی شد"
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{business_id}/logo",
|
|
summary="آپلود لوگوی کسبوکار",
|
|
description="آپلود تصویر لوگوی کسبوکار و ذخیره شناسه فایل روی رکورد Business.",
|
|
response_model=SuccessResponse,
|
|
)
|
|
@require_business_access("business_id")
|
|
async def upload_business_logo(
|
|
request: Request,
|
|
business_id: int,
|
|
file: UploadFile = File(...),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "print")),
|
|
) -> dict:
|
|
# بررسی وجود کسبوکار
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if not business:
|
|
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
|
|
|
|
storage = FileStorageService(db)
|
|
|
|
# حذف فایل قدیمی لوگو اگر وجود داشته باشد
|
|
if business.logo_file_id:
|
|
try:
|
|
old_file_id = UUID(str(business.logo_file_id))
|
|
await storage.delete_file(old_file_id)
|
|
except Exception:
|
|
pass # اگر فایل قدیمی وجود نداشت یا خطا رخ داد، ادامه میدهیم
|
|
|
|
try:
|
|
saved = await storage.upload_file(
|
|
file=file,
|
|
user_id=ctx.get_user_id(),
|
|
module_context="business_logo",
|
|
context_id=None,
|
|
developer_data={"business_id": business_id, "type": "logo"},
|
|
is_temporary=False,
|
|
expires_in_days=3650,
|
|
business_id=business_id,
|
|
check_storage_limit=True,
|
|
)
|
|
except HTTPException as e:
|
|
# اگر خطای محدودیت ذخیرهسازی باشد، جزئیات را برمیگردانیم
|
|
if e.status_code == 400 and isinstance(e.detail, dict) and e.detail.get("error") == "STORAGE_LIMIT_EXCEEDED":
|
|
error_detail = {
|
|
"success": False,
|
|
"error": {
|
|
"code": "STORAGE_LIMIT_EXCEEDED",
|
|
"message": e.detail.get("message", "حجم فایل از محدودیت ذخیرهسازی تجاوز میکند"),
|
|
"total_limit_gb": e.detail.get("total_limit_gb"),
|
|
"current_usage_gb": e.detail.get("current_usage_gb"),
|
|
"available_gb": e.detail.get("available_gb"),
|
|
"required_gb": e.detail.get("required_gb"),
|
|
"over_usage_gb": e.detail.get("over_usage_gb"),
|
|
}
|
|
}
|
|
raise HTTPException(status_code=400, detail=error_detail)
|
|
raise
|
|
|
|
business.logo_file_id = saved.get("file_id")
|
|
db.commit()
|
|
|
|
return success_response(
|
|
{
|
|
"logo_file_id": business.logo_file_id,
|
|
"file": saved,
|
|
},
|
|
request,
|
|
"لوگوی کسبوکار با موفقیت ذخیره شد",
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{business_id}/logo",
|
|
summary="دریافت لوگوی کسبوکار",
|
|
description="بازگرداندن تصویر لوگوی کسبوکار بهصورت فایل برای نمایش در UI یا فاکتور.",
|
|
)
|
|
@require_business_access("business_id")
|
|
async def get_business_logo(
|
|
request: Request,
|
|
business_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "print")),
|
|
):
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if not business or not getattr(business, "logo_file_id", None):
|
|
raise HTTPException(status_code=404, detail="لوگوی کسب و کار تنظیم نشده است")
|
|
|
|
storage = FileStorageService(db)
|
|
file_data = await storage.download_file(UUID(str(business.logo_file_id)))
|
|
|
|
filename = file_data.get("filename") or "logo"
|
|
return StreamingResponse(
|
|
io.BytesIO(file_data["content"]),
|
|
media_type=file_data.get("mime_type") or "image/png",
|
|
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{business_id}/stamp",
|
|
summary="آپلود مهر/امضای کسبوکار",
|
|
description="آپلود تصویر مهر یا امضای رسمی کسبوکار و ذخیره شناسه فایل روی رکورد Business.",
|
|
response_model=SuccessResponse,
|
|
)
|
|
@require_business_access("business_id")
|
|
async def upload_business_stamp(
|
|
request: Request,
|
|
business_id: int,
|
|
file: UploadFile = File(...),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "print")),
|
|
) -> dict:
|
|
# بررسی وجود کسبوکار
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if not business:
|
|
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
|
|
|
|
storage = FileStorageService(db)
|
|
|
|
# حذف فایل قدیمی مهر اگر وجود داشته باشد
|
|
if business.stamp_file_id:
|
|
try:
|
|
old_file_id = UUID(str(business.stamp_file_id))
|
|
await storage.delete_file(old_file_id)
|
|
except Exception:
|
|
pass # اگر فایل قدیمی وجود نداشت یا خطا رخ داد، ادامه میدهیم
|
|
|
|
try:
|
|
saved = await storage.upload_file(
|
|
file=file,
|
|
user_id=ctx.get_user_id(),
|
|
module_context="business_stamp",
|
|
context_id=None,
|
|
developer_data={"business_id": business_id, "type": "stamp"},
|
|
is_temporary=False,
|
|
expires_in_days=3650,
|
|
business_id=business_id,
|
|
check_storage_limit=True,
|
|
)
|
|
except HTTPException as e:
|
|
# اگر خطای محدودیت ذخیرهسازی باشد، جزئیات را برمیگردانیم
|
|
if e.status_code == 400 and isinstance(e.detail, dict) and e.detail.get("error") == "STORAGE_LIMIT_EXCEEDED":
|
|
error_detail = {
|
|
"success": False,
|
|
"error": {
|
|
"code": "STORAGE_LIMIT_EXCEEDED",
|
|
"message": e.detail.get("message", "حجم فایل از محدودیت ذخیرهسازی تجاوز میکند"),
|
|
"total_limit_gb": e.detail.get("total_limit_gb"),
|
|
"current_usage_gb": e.detail.get("current_usage_gb"),
|
|
"available_gb": e.detail.get("available_gb"),
|
|
"required_gb": e.detail.get("required_gb"),
|
|
"over_usage_gb": e.detail.get("over_usage_gb"),
|
|
}
|
|
}
|
|
raise HTTPException(status_code=400, detail=error_detail)
|
|
raise
|
|
|
|
business.stamp_file_id = saved.get("file_id")
|
|
db.commit()
|
|
|
|
return success_response(
|
|
{
|
|
"stamp_file_id": business.stamp_file_id,
|
|
"file": saved,
|
|
},
|
|
request,
|
|
"مهر/امضای کسبوکار با موفقیت ذخیره شد",
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{business_id}/stamp",
|
|
summary="دریافت مهر/امضای کسبوکار",
|
|
description="بازگرداندن تصویر مهر یا امضای کسبوکار بهصورت فایل برای نمایش در UI یا فاکتور.",
|
|
)
|
|
@require_business_access("business_id")
|
|
async def get_business_stamp(
|
|
request: Request,
|
|
business_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "print")),
|
|
):
|
|
business = db.query(Business).filter(Business.id == business_id).first()
|
|
if not business or not getattr(business, "stamp_file_id", None):
|
|
raise HTTPException(status_code=404, detail="مهر/امضای کسب و کار تنظیم نشده است")
|
|
|
|
storage = FileStorageService(db)
|
|
file_data = await storage.download_file(UUID(str(business.stamp_file_id)))
|
|
|
|
filename = file_data.get("filename") or "stamp"
|
|
return StreamingResponse(
|
|
io.BytesIO(file_data["content"]),
|
|
media_type=file_data.get("mime_type") or "image/png",
|
|
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.post("/stats",
|
|
summary="آمار کسب و کارها",
|
|
description="دریافت آمار کلی کسب و کارهای کاربر",
|
|
response_model=SuccessResponse,
|
|
responses={
|
|
200: {
|
|
"description": "آمار کسب و کارها با موفقیت دریافت شد",
|
|
"content": {
|
|
"application/json": {
|
|
"example": {
|
|
"success": True,
|
|
"message": "آمار کسب و کارها دریافت شد",
|
|
"data": {
|
|
"total_businesses": 5,
|
|
"by_type": {
|
|
"شرکت": 2,
|
|
"مغازه": 1,
|
|
"فروشگاه": 2
|
|
},
|
|
"by_field": {
|
|
"تولیدی": 3,
|
|
"خدماتی": 2
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
401: {
|
|
"description": "کاربر احراز هویت نشده است"
|
|
}
|
|
}
|
|
)
|
|
def get_business_stats(
|
|
request: Request,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""آمار کسب و کارها"""
|
|
owner_id = ctx.get_user_id()
|
|
stats = get_business_summary(db, owner_id)
|
|
return success_response(stats, request)
|
|
|
|
|
|
@router.post(
|
|
"/{business_id}/currencies",
|
|
summary="اضافه کردن ارز جانبی به کسبوکار",
|
|
description="اضافه کردن یک ارز به لیست ارزهای قابل استفاده در کسبوکار",
|
|
response_model=SuccessResponse,
|
|
)
|
|
@require_business_access("business_id")
|
|
def add_business_currency_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
body: Dict[str, Any] = Body(...),
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "business")),
|
|
) -> dict:
|
|
"""اضافه کردن ارز جانبی به کسبوکار"""
|
|
currency_id = body.get("currency_id")
|
|
if not currency_id:
|
|
raise HTTPException(status_code=400, detail="currency_id الزامی است")
|
|
|
|
try:
|
|
currency_id = int(currency_id)
|
|
except (ValueError, TypeError):
|
|
raise HTTPException(status_code=400, detail="currency_id باید یک عدد صحیح باشد")
|
|
|
|
owner_id = ctx.get_user_id()
|
|
currency_data = add_business_currency(db, business_id, currency_id, owner_id)
|
|
return success_response(currency_data, request, "ارز با موفقیت اضافه شد")
|
|
|
|
|
|
@router.delete(
|
|
"/{business_id}/currencies/{currency_id}",
|
|
summary="حذف ارز جانبی از کسبوکار",
|
|
description="حذف یک ارز از لیست ارزهای قابل استفاده در کسبوکار (در صورت عدم استفاده در اسناد)",
|
|
response_model=SuccessResponse,
|
|
)
|
|
@require_business_access("business_id")
|
|
def remove_business_currency_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
currency_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
_: None = Depends(require_business_permission_dep("settings", "business")),
|
|
) -> dict:
|
|
"""حذف ارز جانبی از کسبوکار"""
|
|
owner_id = ctx.get_user_id()
|
|
remove_business_currency(db, business_id, currency_id, owner_id)
|
|
return success_response({"ok": True}, request, "ارز با موفقیت حذف شد")
|
|
|
|
|
|
@router.get(
|
|
"/{business_id}/currencies/{currency_id}/usage-check",
|
|
summary="بررسی استفاده ارز در اسناد",
|
|
description="بررسی اینکه آیا یک ارز در اسناد حسابداری استفاده شده است یا نه",
|
|
response_model=SuccessResponse,
|
|
)
|
|
@require_business_access("business_id")
|
|
def check_currency_usage_endpoint(
|
|
request: Request,
|
|
business_id: int,
|
|
currency_id: int,
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""بررسی استفاده ارز در اسناد، حسابها و کالاها (V2-P7 D6)"""
|
|
usage = get_business_currency_usage(db, business_id, currency_id)
|
|
return success_response({
|
|
"is_used": usage["is_used"],
|
|
"document_count": usage["document_count"],
|
|
"total": usage["total"],
|
|
"can_delete": usage["can_delete"],
|
|
"is_last_secondary": usage["is_last_secondary"],
|
|
"secondary_count": usage["secondary_count"],
|
|
"breakdown": usage["breakdown"],
|
|
"blockers": usage["blockers"],
|
|
}, request)
|