diff --git a/hesabixAPI/adapters/api/v1/categories.py b/hesabixAPI/adapters/api/v1/categories.py
index 6290dbc..ddfec4b 100644
--- a/hesabixAPI/adapters/api/v1/categories.py
+++ b/hesabixAPI/adapters/api/v1/categories.py
@@ -146,3 +146,39 @@ def delete_category(
return success_response({"deleted": ok}, request)
+# Server-side search categories with breadcrumb path
+@router.post("/business/{business_id}/search")
+@require_business_access("business_id")
+def search_categories(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] | None = None,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("categories"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.view", http_status=403)
+ q = (body or {}).get("query") if isinstance(body, dict) else None
+ limit = (body or {}).get("limit") if isinstance(body, dict) else None
+ if not isinstance(q, str) or not q.strip():
+ return success_response({"items": []}, request)
+ try:
+ limit_int = int(limit) if isinstance(limit, int) or (isinstance(limit, str) and str(limit).isdigit()) else 50
+ limit_int = max(1, min(limit_int, 200))
+ except Exception:
+ limit_int = 50
+ repo = CategoryRepository(db)
+ items = repo.search_with_paths(business_id=business_id, query=q.strip(), limit=limit_int)
+ # map label consistently
+ mapped = [
+ {
+ "id": it.get("id"),
+ "parent_id": it.get("parent_id"),
+ "label": it.get("title") or "",
+ "translations": it.get("translations") or {},
+ "path": it.get("path") or [],
+ }
+ for it in items
+ ]
+ return success_response({"items": mapped}, request)
+
diff --git a/hesabixAPI/adapters/api/v1/currencies.py b/hesabixAPI/adapters/api/v1/currencies.py
index 244babd..0429b23 100644
--- a/hesabixAPI/adapters/api/v1/currencies.py
+++ b/hesabixAPI/adapters/api/v1/currencies.py
@@ -1,9 +1,13 @@
from fastapi import APIRouter, Depends, Request
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, joinedload
from adapters.db.session import get_db
from adapters.db.models.currency import Currency
from app.core.responses import success_response
+from app.core.responses import ApiError
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from adapters.db.models.business import Business
router = APIRouter(prefix="/currencies", tags=["currencies"])
@@ -28,3 +32,60 @@ def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict:
return success_response(items, request)
+@router.get(
+ "/business/{business_id}",
+ summary="فهرست ارزهای کسبوکار",
+ description="دریافت ارز پیشفرض کسبوکار بهعلاوه ارزهای فعال آن کسبوکار (بدون تکرار)",
+)
+@require_business_access()
+def list_business_currencies(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ business = (
+ db.query(Business)
+ .options(
+ joinedload(Business.default_currency),
+ joinedload(Business.currencies),
+ )
+ .filter(Business.id == business_id)
+ .first()
+ )
+ if not business:
+ raise ApiError("NOT_FOUND", "کسبوکار یافت نشد", http_status=404)
+
+ seen_ids = set()
+ result = []
+
+ # Add default currency first if exists
+ if business.default_currency:
+ c = business.default_currency
+ result.append({
+ "id": c.id,
+ "name": c.name,
+ "title": c.title,
+ "symbol": c.symbol,
+ "code": c.code,
+ "is_default": True,
+ })
+ seen_ids.add(c.id)
+
+ # Add active business currencies (excluding duplicates)
+ for c in business.currencies or []:
+ if c.id in seen_ids:
+ continue
+ result.append({
+ "id": c.id,
+ "name": c.name,
+ "title": c.title,
+ "symbol": c.symbol,
+ "code": c.code,
+ "is_default": False,
+ })
+ seen_ids.add(c.id)
+
+ # If nothing found, return empty list
+ return success_response(result, request)
+
diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py
index ac23487..2185f0c 100644
--- a/hesabixAPI/adapters/api/v1/persons.py
+++ b/hesabixAPI/adapters/api/v1/persons.py
@@ -12,6 +12,7 @@ from adapters.api.v1.schemas import QueryInfo, SuccessResponse
from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_management_dep
+from app.core.i18n import negotiate_locale
from app.services.person_service import (
create_person, get_person_by_id, get_persons_by_business,
update_person, delete_person, get_person_summary
@@ -207,6 +208,14 @@ async def export_persons_excel(
ws = wb.active
ws.title = "Persons"
+ # Locale and RTL/LTR handling
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ if locale == 'fa':
+ try:
+ ws.sheet_view.rightToLeft = True
+ except Exception:
+ pass
+
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
@@ -226,7 +235,10 @@ async def export_persons_excel(
value = item.get(key, "")
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
- ws.cell(row=row_idx, column=col_idx, value=value).border = border
+ cell = ws.cell(row=row_idx, column=col_idx, value=value)
+ cell.border = border
+ if locale == 'fa':
+ cell.alignment = Alignment(horizontal="right")
# Auto-width columns
for column in ws.columns:
@@ -344,7 +356,12 @@ async def export_persons_pdf(
except Exception:
business_name = ""
- # Styled HTML (A4 landscape, RTL)
+ # Styled HTML with dynamic direction/locale
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ is_fa = (locale == 'fa')
+ html_lang = 'fa' if is_fa else 'en'
+ html_dir = 'rtl' if is_fa else 'ltr'
+
def escape(s: Any) -> str:
try:
return str(s).replace('&', '&').replace('<', '<').replace('>', '>')
@@ -373,16 +390,24 @@ async def export_persons_pdf(
now = formatted_now.get('formatted', formatted_now.get('date_time', ''))
except Exception:
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
+
+ title_text = "گزارش لیست اشخاص" if is_fa else "Persons List Report"
+ label_biz = "نام کسبوکار" if is_fa else "Business Name"
+ label_date = "تاریخ گزارش" if is_fa else "Report Date"
+ footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
+ page_label_left = "صفحه " if is_fa else "Page "
+ page_label_of = " از " if is_fa else " of "
+
table_html = f"""
-
+
@@ -459,7 +484,7 @@ async def export_persons_pdf(
-
+
"""
diff --git a/hesabixAPI/adapters/api/v1/price_lists.py b/hesabixAPI/adapters/api/v1/price_lists.py
index 958331b..8cfa5df 100644
--- a/hesabixAPI/adapters/api/v1/price_lists.py
+++ b/hesabixAPI/adapters/api/v1/price_lists.py
@@ -137,12 +137,14 @@ def list_price_items_endpoint(
request: Request,
business_id: int,
price_list_id: int,
+ product_id: int | None = None,
+ currency_id: int | None = None,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
- result = list_price_items(db, business_id, price_list_id)
+ result = list_price_items(db, business_id, price_list_id, product_id=product_id, currency_id=currency_id)
return success_response(data=format_datetime_fields(result, request), request=request)
diff --git a/hesabixAPI/adapters/api/v1/products.py b/hesabixAPI/adapters/api/v1/products.py
index 79d9fc7..d4ceda1 100644
--- a/hesabixAPI/adapters/api/v1/products.py
+++ b/hesabixAPI/adapters/api/v1/products.py
@@ -12,6 +12,8 @@ from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.schema_models.product import (
ProductCreateRequest,
ProductUpdateRequest,
+ BulkPriceUpdateRequest,
+ BulkPriceUpdatePreviewResponse,
)
from app.services.product_service import (
create_product,
@@ -20,6 +22,13 @@ from app.services.product_service import (
update_product,
delete_product,
)
+from app.services.bulk_price_update_service import (
+ preview_bulk_price_update,
+ apply_bulk_price_update,
+)
+from adapters.db.models.business import Business
+from app.core.i18n import negotiate_locale
+from fastapi import UploadFile, File, Form
router = APIRouter(prefix="/products", tags=["products"])
@@ -125,6 +134,8 @@ async def export_products_excel(
db: Session = Depends(get_db),
):
import io
+ import re
+ import datetime
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
@@ -145,6 +156,22 @@ async def export_products_excel(
items = result.get("items", []) if isinstance(result, dict) else result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
+ # Apply selected indices filter if requested
+ selected_only = bool(body.get('selected_only', False))
+ selected_indices = body.get('selected_indices')
+ if selected_only and selected_indices is not None and isinstance(items, list):
+ indices = None
+ if isinstance(selected_indices, str):
+ try:
+ import json as _json
+ indices = _json.loads(selected_indices)
+ except Exception:
+ indices = None
+ elif isinstance(selected_indices, list):
+ indices = selected_indices
+ if isinstance(indices, list):
+ items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
+
export_columns = body.get("export_columns")
if export_columns and isinstance(export_columns, list):
headers = [col.get("label") or col.get("key") for col in export_columns]
@@ -169,6 +196,14 @@ async def export_products_excel(
ws = wb.active
ws.title = "Products"
+ # Locale and RTL/LTR handling for Excel
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ if locale == 'fa':
+ try:
+ ws.sheet_view.rightToLeft = True
+ except Exception:
+ pass
+
# Header style
header_font = Font(bold=True)
header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid")
@@ -188,21 +223,336 @@ async def export_products_excel(
ws.append(row)
for cell in ws[ws.max_row]:
cell.border = thin_border
+ # Align data cells based on locale
+ if locale == 'fa':
+ cell.alignment = Alignment(horizontal="right")
+
+ # Auto width columns
+ try:
+ for column in ws.columns:
+ max_length = 0
+ column_letter = column[0].column_letter
+ for cell in column:
+ try:
+ if cell.value is not None and len(str(cell.value)) > max_length:
+ max_length = len(str(cell.value))
+ except Exception:
+ pass
+ ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
+ except Exception:
+ pass
output = io.BytesIO()
wb.save(output)
data = output.getvalue()
+ # Build meaningful filename
+ biz_name = ""
+ try:
+ b = db.query(Business).filter(Business.id == business_id).first()
+ if b is not None:
+ biz_name = b.name or ""
+ except Exception:
+ biz_name = ""
+ def slugify(text: str) -> str:
+ return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
+ base = "products"
+ if biz_name:
+ base += f"_{slugify(biz_name)}"
+ if selected_only:
+ base += "_selected"
+ filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
- "Content-Disposition": "attachment; filename=products.xlsx",
+ "Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(data)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
},
)
+@router.post("/business/{business_id}/import/template",
+ summary="دانلود تمپلیت ایمپورت محصولات",
+ description="فایل Excel تمپلیت برای ایمپورت کالا/خدمت را برمیگرداند",
+)
+@require_business_access("business_id")
+async def download_products_import_template(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ import datetime
+ from fastapi.responses import Response
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment
+
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Template"
+
+ headers = [
+ "code","name","item_type","description","category_id",
+ "main_unit_id","secondary_unit_id","unit_conversion_factor",
+ "base_sales_price","base_purchase_price","track_inventory",
+ "reorder_point","min_order_qty","lead_time_days",
+ "is_sales_taxable","is_purchase_taxable","sales_tax_rate","purchase_tax_rate",
+ "tax_type_id","tax_code","tax_unit_id",
+ # attribute_ids can be comma-separated ids
+ "attribute_ids",
+ ]
+ for col, header in enumerate(headers, 1):
+ cell = ws.cell(row=1, column=col, value=header)
+ cell.font = Font(bold=True)
+ cell.alignment = Alignment(horizontal="center")
+
+ sample = [
+ "P1001","نمونه کالا","کالا","توضیح اختیاری", "",
+ "", "", "",
+ "150000", "120000", "TRUE",
+ "0", "0", "",
+ "FALSE", "FALSE", "", "",
+ "", "", "",
+ "1,2,3",
+ ]
+ for col, val in enumerate(sample, 1):
+ ws.cell(row=2, column=col, value=val)
+
+ # Auto width
+ for column in ws.columns:
+ try:
+ letter = column[0].column_letter
+ max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column)
+ ws.column_dimensions[letter].width = min(max_len + 2, 50)
+ except Exception:
+ pass
+
+ buf = io.BytesIO()
+ wb.save(buf)
+ buf.seek(0)
+
+ filename = f"products_import_template_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+ return Response(
+ content=buf.getvalue(),
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
+@router.post("/business/{business_id}/import/excel",
+ summary="ایمپورت محصولات از فایل Excel",
+ description="فایل اکسل را دریافت میکند و بهصورت dry-run یا واقعی پردازش میکند",
+)
+@require_business_access("business_id")
+async def import_products_excel(
+ request: Request,
+ business_id: int,
+ file: UploadFile = File(...),
+ dry_run: str = Form(default="true"),
+ match_by: str = Form(default="code"),
+ conflict_policy: str = Form(default="upsert"),
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ import json
+ import logging
+ import re
+ import zipfile
+ from decimal import Decimal
+ from typing import Optional
+ from openpyxl import load_workbook
+
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+
+ logger = logging.getLogger(__name__)
+
+ def _validate_excel_signature(content: bytes) -> bool:
+ try:
+ if not content.startswith(b'PK'):
+ return False
+ with zipfile.ZipFile(io.BytesIO(content), 'r') as zf:
+ return any(n.startswith('xl/') for n in zf.namelist())
+ except Exception:
+ return False
+
+ try:
+ is_dry_run = str(dry_run).lower() in ("true","1","yes","on")
+
+ if not file.filename or not file.filename.lower().endswith('.xlsx'):
+ raise ApiError("INVALID_FILE", "فرمت فایل معتبر نیست. تنها xlsx پشتیبانی میشود", http_status=400)
+
+ content = await file.read()
+ if len(content) < 100 or not _validate_excel_signature(content):
+ raise ApiError("INVALID_FILE", "فایل Excel معتبر نیست یا خالی است", http_status=400)
+
+ try:
+ wb = load_workbook(filename=io.BytesIO(content), data_only=True)
+ except zipfile.BadZipFile:
+ raise ApiError("INVALID_FILE", "فایل Excel خراب است یا فرمت آن معتبر نیست", http_status=400)
+
+ ws = wb.active
+ rows = list(ws.iter_rows(values_only=True))
+ if not rows:
+ return success_response(data={"summary": {"total": 0}}, request=request, message="EMPTY_FILE")
+
+ headers = [str(h).strip() if h is not None else "" for h in rows[0]]
+ data_rows = rows[1:]
+
+ def _parse_bool(v: object) -> Optional[bool]:
+ if v is None: return None
+ s = str(v).strip().lower()
+ if s in ("true","1","yes","on","بله","هست"):
+ return True
+ if s in ("false","0","no","off","خیر","نیست"):
+ return False
+ return None
+
+ def _parse_decimal(v: object) -> Optional[Decimal]:
+ if v is None or str(v).strip() == "":
+ return None
+ try:
+ return Decimal(str(v).replace(",",""))
+ except Exception:
+ return None
+
+ def _parse_int(v: object) -> Optional[int]:
+ if v is None or str(v).strip() == "":
+ return None
+ try:
+ return int(str(v).split(".")[0])
+ except Exception:
+ return None
+
+ def _normalize_item_type(v: object) -> Optional[str]:
+ if v is None: return None
+ s = str(v).strip()
+ mapping = {"product": "کالا", "service": "خدمت"}
+ low = s.lower()
+ if low in mapping: return mapping[low]
+ if s in ("کالا","خدمت"): return s
+ return None
+
+ errors: list[dict] = []
+ valid_items: list[dict] = []
+
+ for idx, row in enumerate(data_rows, start=2):
+ item: dict[str, Any] = {}
+ row_errors: list[str] = []
+
+ for ci, key in enumerate(headers):
+ if not key:
+ continue
+ val = row[ci] if ci < len(row) else None
+ if isinstance(val, str):
+ val = val.strip()
+ item[key] = val
+
+ # normalize & cast
+ if 'item_type' in item:
+ item['item_type'] = _normalize_item_type(item.get('item_type')) or 'کالا'
+ for k in ['base_sales_price','base_purchase_price','sales_tax_rate','purchase_tax_rate','unit_conversion_factor']:
+ if k in item:
+ item[k] = _parse_decimal(item.get(k))
+ for k in ['reorder_point','min_order_qty','lead_time_days','category_id','main_unit_id','secondary_unit_id','tax_type_id','tax_unit_id']:
+ if k in item:
+ item[k] = _parse_int(item.get(k))
+ for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']:
+ if k in item:
+ item[k] = _parse_bool(item.get(k)) if item.get(k) is not None else None
+
+ # attribute_ids: comma-separated
+ if 'attribute_ids' in item and item['attribute_ids']:
+ try:
+ parts = [p.strip() for p in str(item['attribute_ids']).split(',') if p and p.strip()]
+ item['attribute_ids'] = [int(p) for p in parts if p.isdigit()]
+ except Exception:
+ item['attribute_ids'] = []
+
+ # validations
+ name = item.get('name')
+ if not name or str(name).strip() == "":
+ row_errors.append('name الزامی است')
+
+ # if code is empty, it will be auto-generated in service
+ code = item.get('code')
+ if code is not None and str(code).strip() == "":
+ item['code'] = None
+
+ if row_errors:
+ errors.append({"row": idx, "errors": row_errors})
+ continue
+
+ valid_items.append(item)
+
+ inserted = 0
+ updated = 0
+ skipped = 0
+
+ if not is_dry_run and valid_items:
+ from sqlalchemy import and_ as _and
+ from adapters.db.models.product import Product
+ from adapters.api.v1.schema_models.product import ProductCreateRequest, ProductUpdateRequest
+ from app.services.product_service import create_product, update_product
+
+ def _find_existing(session: Session, data: dict) -> Optional[Product]:
+ if match_by == 'code' and data.get('code'):
+ return session.query(Product).filter(_and(Product.business_id == business_id, Product.code == str(data['code']).strip())).first()
+ if match_by == 'name' and data.get('name'):
+ return session.query(Product).filter(_and(Product.business_id == business_id, Product.name == str(data['name']).strip())).first()
+ return None
+
+ for data in valid_items:
+ existing = _find_existing(db, data)
+ if existing is None:
+ try:
+ create_product(db, business_id, ProductCreateRequest(**data))
+ inserted += 1
+ except Exception as e:
+ logger.error(f"Create product failed: {e}")
+ skipped += 1
+ else:
+ if conflict_policy == 'insert':
+ skipped += 1
+ elif conflict_policy in ('update','upsert'):
+ try:
+ update_product(db, existing.id, business_id, ProductUpdateRequest(**data))
+ updated += 1
+ except Exception as e:
+ logger.error(f"Update product failed: {e}")
+ skipped += 1
+
+ summary = {
+ "total": len(data_rows),
+ "valid": len(valid_items),
+ "invalid": len(errors),
+ "inserted": inserted,
+ "updated": updated,
+ "skipped": skipped,
+ "dry_run": is_dry_run,
+ }
+
+ return success_response(
+ data={"summary": summary, "errors": errors},
+ request=request,
+ message="PRODUCTS_IMPORT_RESULT",
+ )
+ except ApiError:
+ raise
+ except Exception as e:
+ logger.error(f"Import error: {e}", exc_info=True)
+ raise ApiError("IMPORT_ERROR", f"خطا در پردازش فایل: {e}", http_status=500)
@router.post("/business/{business_id}/export/pdf",
summary="خروجی PDF لیست محصولات",
description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستونها",
@@ -215,8 +565,9 @@ async def export_products_pdf(
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
- import io
+ import json
import datetime
+ import re
from fastapi.responses import Response
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
@@ -237,6 +588,21 @@ async def export_products_pdf(
items = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
+ # Apply selected indices filter if requested
+ selected_only = bool(body.get('selected_only', False))
+ selected_indices = body.get('selected_indices')
+ if selected_only and selected_indices is not None:
+ indices = None
+ if isinstance(selected_indices, str):
+ try:
+ indices = json.loads(selected_indices)
+ except (json.JSONDecodeError, TypeError):
+ indices = None
+ elif isinstance(selected_indices, list):
+ indices = selected_indices
+ if isinstance(indices, list):
+ items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
+
export_columns = body.get("export_columns")
if export_columns and isinstance(export_columns, list):
headers = [col.get("label") or col.get("key") for col in export_columns]
@@ -257,44 +623,211 @@ async def export_products_pdf(
keys = [k for k, _ in default_cols]
headers = [v for _, v in default_cols]
- # Build simple HTML table
- head_html = """
-
- """
- title = "گزارش فهرست محصولات"
- now = datetime.datetime.utcnow().isoformat()
- header_row = "".join([f"{h} | " for h in headers])
- body_rows = "".join([
- "" + "".join([f"| {(it.get(k) if it.get(k) is not None else '')} | " for k in keys]) + "
"
- for it in items
- ])
- html = f"""
- {head_html}
- {title}
- زمان تولید: {now}
-
- {header_row}
- {body_rows}
-
-
+ # Locale and direction
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ is_fa = (locale == 'fa')
+ html_lang = 'fa' if is_fa else 'en'
+ html_dir = 'rtl' if is_fa else 'ltr'
+
+ # Load business info for header
+ business_name = ""
+ try:
+ biz = db.query(Business).filter(Business.id == business_id).first()
+ if biz is not None:
+ business_name = biz.name or ""
+ except Exception:
+ business_name = ""
+
+ # Escape helper
+ def escape(s: Any) -> str:
+ try:
+ return str(s).replace('&', '&').replace('<', '<').replace('>', '>')
+ except Exception:
+ return str(s)
+
+ # Build rows
+ rows_html = []
+ for item in items:
+ tds = []
+ for key in keys:
+ value = item.get(key)
+ if value is None:
+ value = ""
+ elif isinstance(value, list):
+ value = ", ".join(str(v) for v in value)
+ tds.append(f"{escape(value)} | ")
+ rows_html.append(f"{''.join(tds)}
")
+
+ headers_html = ''.join(f"{escape(h)} | " for h in headers)
+
+ # Format report datetime based on X-Calendar-Type header
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
+ try:
+ from app.core.calendar import CalendarConverter
+ formatted_now = CalendarConverter.format_datetime(datetime.datetime.now(),
+ "jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian")
+ now_str = formatted_now.get('formatted', formatted_now.get('date_time', ''))
+ except Exception:
+ now_str = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
+
+ title_text = "گزارش فهرست محصولات" if is_fa else "Products List Report"
+ label_biz = "نام کسبوکار" if is_fa else "Business Name"
+ label_date = "تاریخ گزارش" if is_fa else "Report Date"
+ footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
+ page_label_left = "صفحه " if is_fa else "Page "
+ page_label_of = " از " if is_fa else " of "
+
+ table_html = f"""
+
+
+
+
+
+
+
+
+
+
+ {headers_html}
+
+
+ {''.join(rows_html)}
+
+
+
+
+
+
"""
font_config = FontConfiguration()
- pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 landscape; margin: 10mm; }")], font_config=font_config)
+ pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
+
+ # Build meaningful filename
+ biz_name = business_name
+ def slugify(text: str) -> str:
+ return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
+ base = "products"
+ if biz_name:
+ base += f"_{slugify(biz_name)}"
+ if selected_only:
+ base += "_selected"
+ filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
- "Content-Disposition": "attachment; filename=products.pdf",
+ "Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
},
)
+@router.post("/business/{business_id}/bulk-price-update/preview",
+ summary="پیشنمایش تغییر قیمتهای گروهی",
+ description="پیشنمایش تغییرات قیمت قبل از اعمال",
+)
+@require_business_access("business_id")
+def preview_bulk_price_update_endpoint(
+ request: Request,
+ business_id: int,
+ payload: BulkPriceUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+
+ result = preview_bulk_price_update(db, business_id, payload)
+ return success_response(data=result.dict(), request=request)
+
+
+@router.post("/business/{business_id}/bulk-price-update/apply",
+ summary="اعمال تغییر قیمتهای گروهی",
+ description="اعمال تغییرات قیمت بر روی کالاهای انتخاب شده",
+)
+@require_business_access("business_id")
+def apply_bulk_price_update_endpoint(
+ request: Request,
+ business_id: int,
+ payload: BulkPriceUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+
+ result = apply_bulk_price_update(db, business_id, payload)
+ return success_response(data=result, request=request)
+
+
diff --git a/hesabixAPI/adapters/api/v1/schema_models/price_list.py b/hesabixAPI/adapters/api/v1/schema_models/price_list.py
index f89a40e..7849770 100644
--- a/hesabixAPI/adapters/api/v1/schema_models/price_list.py
+++ b/hesabixAPI/adapters/api/v1/schema_models/price_list.py
@@ -7,23 +7,19 @@ from pydantic import BaseModel, Field
class PriceListCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
- currency_id: Optional[int] = None
- default_unit_id: Optional[int] = None
is_active: bool = True
class PriceListUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
- currency_id: Optional[int] = None
- default_unit_id: Optional[int] = None
is_active: Optional[bool] = None
class PriceItemUpsertRequest(BaseModel):
product_id: int
unit_id: Optional[int] = None
- currency_id: Optional[int] = None
- tier_name: str = Field(..., min_length=1, max_length=64)
+ currency_id: int
+ tier_name: Optional[str] = Field(default=None, min_length=1, max_length=64)
min_qty: Decimal = Field(default=0)
price: Decimal
@@ -32,8 +28,6 @@ class PriceListResponse(BaseModel):
id: int
business_id: int
name: str
- currency_id: Optional[int] = None
- default_unit_id: Optional[int] = None
is_active: bool
created_at: str
updated_at: str
@@ -47,7 +41,7 @@ class PriceItemResponse(BaseModel):
price_list_id: int
product_id: int
unit_id: Optional[int] = None
- currency_id: Optional[int] = None
+ currency_id: int
tier_name: str
min_qty: Decimal
price: Decimal
diff --git a/hesabixAPI/adapters/api/v1/schema_models/product.py b/hesabixAPI/adapters/api/v1/schema_models/product.py
index 129bf46..80535a7 100644
--- a/hesabixAPI/adapters/api/v1/schema_models/product.py
+++ b/hesabixAPI/adapters/api/v1/schema_models/product.py
@@ -108,3 +108,59 @@ class ProductResponse(BaseModel):
from_attributes = True
+class BulkPriceUpdateType(str, Enum):
+ PERCENTAGE = "percentage"
+ AMOUNT = "amount"
+
+
+class BulkPriceUpdateDirection(str, Enum):
+ INCREASE = "increase"
+ DECREASE = "decrease"
+
+
+class BulkPriceUpdateTarget(str, Enum):
+ SALES_PRICE = "sales_price"
+ PURCHASE_PRICE = "purchase_price"
+ BOTH = "both"
+
+
+class BulkPriceUpdateRequest(BaseModel):
+ """درخواست تغییر قیمتهای گروهی"""
+ update_type: BulkPriceUpdateType = Field(..., description="نوع تغییر: درصدی یا مقداری")
+ direction: BulkPriceUpdateDirection = Field(default=BulkPriceUpdateDirection.INCREASE, description="جهت تغییر: افزایش یا کاهش")
+ target: BulkPriceUpdateTarget = Field(..., description="هدف تغییر: قیمت فروش، خرید یا هر دو")
+ value: Decimal = Field(..., description="مقدار تغییر (درصد یا مبلغ)")
+
+ # فیلترهای انتخاب کالاها
+ category_ids: Optional[List[int]] = Field(default=None, description="شناسههای دستهبندی")
+ currency_ids: Optional[List[int]] = Field(default=None, description="شناسههای ارز")
+ price_list_ids: Optional[List[int]] = Field(default=None, description="شناسههای لیست قیمت")
+ item_types: Optional[List[ProductItemType]] = Field(default=None, description="نوع آیتمها")
+ product_ids: Optional[List[int]] = Field(default=None, description="شناسههای کالاهای خاص")
+
+ # گزینههای اضافی
+ only_products_with_inventory: Optional[bool] = Field(default=None, description="فقط کالاهای با موجودی")
+ only_products_with_base_price: Optional[bool] = Field(default=True, description="فقط کالاهای با قیمت پایه")
+
+
+class BulkPriceUpdatePreview(BaseModel):
+ """پیشنمایش تغییرات قیمت"""
+ product_id: int
+ product_name: str
+ product_code: str
+ category_name: Optional[str] = None
+ current_sales_price: Optional[Decimal] = None
+ current_purchase_price: Optional[Decimal] = None
+ new_sales_price: Optional[Decimal] = None
+ new_purchase_price: Optional[Decimal] = None
+ sales_price_change: Optional[Decimal] = None
+ purchase_price_change: Optional[Decimal] = None
+
+
+class BulkPriceUpdatePreviewResponse(BaseModel):
+ """پاسخ پیشنمایش تغییرات قیمت"""
+ total_products: int
+ affected_products: List[BulkPriceUpdatePreview]
+ summary: dict = Field(..., description="خلاصه تغییرات")
+
+
diff --git a/hesabixAPI/adapters/api/v1/tax_units.py b/hesabixAPI/adapters/api/v1/tax_units.py
index 56dceb7..77392cc 100644
--- a/hesabixAPI/adapters/api/v1/tax_units.py
+++ b/hesabixAPI/adapters/api/v1/tax_units.py
@@ -13,6 +13,7 @@ from pydantic import BaseModel, Field
router = APIRouter(prefix="/tax-units", tags=["tax-units"])
+alias_router = APIRouter(prefix="/units", tags=["units"])
class TaxUnitCreateRequest(BaseModel):
@@ -86,6 +87,7 @@ class TaxUnitResponse(BaseModel):
}
}
)
+@alias_router.get("/business/{business_id}")
@require_business_access()
def get_tax_units(
request: Request,
@@ -160,6 +162,7 @@ def get_tax_units(
}
}
)
+@alias_router.post("/business/{business_id}")
@require_business_access()
def create_tax_unit(
request: Request,
@@ -255,6 +258,7 @@ def create_tax_unit(
}
}
)
+@alias_router.put("/{tax_unit_id}")
@require_business_access()
def update_tax_unit(
request: Request,
@@ -345,6 +349,7 @@ def update_tax_unit(
}
}
)
+@alias_router.delete("/{tax_unit_id}")
@require_business_access()
def delete_tax_unit(
request: Request,
diff --git a/hesabixAPI/adapters/db/models/price_list.py b/hesabixAPI/adapters/db/models/price_list.py
index 88944b7..7f95dd4 100644
--- a/hesabixAPI/adapters/db/models/price_list.py
+++ b/hesabixAPI/adapters/db/models/price_list.py
@@ -26,8 +26,6 @@ class PriceList(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
- currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True)
- default_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -36,14 +34,14 @@ class PriceList(Base):
class PriceItem(Base):
__tablename__ = "price_items"
__table_args__ = (
- UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", name="uq_price_items_unique_tier"),
+ UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", "currency_id", name="uq_price_items_unique_tier_currency"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
price_list_id: Mapped[int] = mapped_column(Integer, ForeignKey("price_lists.id", ondelete="CASCADE"), nullable=False, index=True)
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
- currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True)
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
tier_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="نام پله قیمت (تکی/عمده/همکار/...)" )
min_qty: Mapped[Decimal] = mapped_column(Numeric(18, 3), nullable=False, default=0)
price: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
diff --git a/hesabixAPI/adapters/db/repositories/business_permission_repo.py b/hesabixAPI/adapters/db/repositories/business_permission_repo.py
index f4ac3e9..b364fa2 100644
--- a/hesabixAPI/adapters/db/repositories/business_permission_repo.py
+++ b/hesabixAPI/adapters/db/repositories/business_permission_repo.py
@@ -89,7 +89,32 @@ class BusinessPermissionRepository(BaseRepository[BusinessPermission]):
# سپس فیلتر میکنیم
member_permissions = []
for perm in all_permissions:
- if perm.business_permissions and perm.business_permissions.get('join') == True:
+ # Normalize legacy/non-dict JSON values to dict before access
+ raw = perm.business_permissions
+ normalized = {}
+ if isinstance(raw, dict):
+ normalized = raw
+ elif isinstance(raw, list):
+ # If legacy stored as list, try to coerce to dict if it looks like key-value pairs
+ try:
+ # e.g., [["join", true], ["sales", {"read": true}]] or [{"join": true}, ...]
+ if all(isinstance(item, list) and len(item) == 2 for item in raw):
+ normalized = {k: v for k, v in raw if isinstance(k, str)}
+ elif all(isinstance(item, dict) for item in raw):
+ # Merge list of dicts
+ merged: dict = {}
+ for item in raw:
+ merged.update({k: v for k, v in item.items()})
+ normalized = merged
+ except Exception:
+ normalized = {}
+ elif raw is None:
+ normalized = {}
+ else:
+ # Unsupported type, skip safely
+ normalized = {}
+
+ if normalized.get('join') == True:
member_permissions.append(perm)
return member_permissions
\ No newline at end of file
diff --git a/hesabixAPI/adapters/db/repositories/category_repository.py b/hesabixAPI/adapters/db/repositories/category_repository.py
index 43ad61e..a84d944 100644
--- a/hesabixAPI/adapters/db/repositories/category_repository.py
+++ b/hesabixAPI/adapters/db/repositories/category_repository.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import List, Dict, Any
from sqlalchemy.orm import Session
-from sqlalchemy import select, and_, or_
+from sqlalchemy import select, and_, or_, func
from .base_repo import BaseRepository
from ..models.category import BusinessCategory
@@ -90,3 +90,55 @@ class CategoryRepository(BaseRepository[BusinessCategory]):
return True
+ def search_with_paths(self, *, business_id: int, query: str, limit: int = 50) -> list[Dict[str, Any]]:
+ q = (query or "").strip()
+ if not q:
+ return []
+ # Basic ILIKE search over fa/en translations by JSON string casting
+ # Note: For performance, consider a materialized path or FTS in future
+ stmt = (
+ select(BusinessCategory)
+ .where(BusinessCategory.business_id == business_id)
+ )
+ rows = list(self.db.execute(stmt).scalars().all())
+ # Build in-memory tree index
+ by_id: dict[int, BusinessCategory] = {r.id: r for r in rows}
+ def get_title(r: BusinessCategory) -> str:
+ trans = r.title_translations or {}
+ return (trans.get("fa") or trans.get("en") or "").strip()
+ # Filter by query
+ q_lower = q.lower()
+ matched: list[BusinessCategory] = []
+ for r in rows:
+ if q_lower in get_title(r).lower():
+ matched.append(r)
+ matched = matched[: max(1, min(limit, 200))]
+ # Build path for each match
+ def build_path(r: BusinessCategory) -> list[Dict[str, Any]]:
+ path: list[Dict[str, Any]] = []
+ current = r
+ seen: set[int] = set()
+ while current is not None and current.id not in seen:
+ seen.add(current.id)
+ title = get_title(current)
+ path.append({
+ "id": current.id,
+ "parent_id": current.parent_id,
+ "title": title,
+ "translations": current.title_translations or {},
+ })
+ pid = current.parent_id
+ current = by_id.get(pid) if pid else None
+ path.reverse()
+ return path
+ result: list[Dict[str, Any]] = []
+ for r in matched:
+ result.append({
+ "id": r.id,
+ "parent_id": r.parent_id,
+ "title": get_title(r),
+ "translations": r.title_translations or {},
+ "path": build_path(r),
+ })
+ return result
+
diff --git a/hesabixAPI/adapters/db/repositories/price_list_repository.py b/hesabixAPI/adapters/db/repositories/price_list_repository.py
index 0c11600..6d7c500 100644
--- a/hesabixAPI/adapters/db/repositories/price_list_repository.py
+++ b/hesabixAPI/adapters/db/repositories/price_list_repository.py
@@ -69,8 +69,6 @@ class PriceListRepository(BaseRepository[PriceList]):
"id": pl.id,
"business_id": pl.business_id,
"name": pl.name,
- "currency_id": pl.currency_id,
- "default_unit_id": pl.default_unit_id,
"is_active": pl.is_active,
"created_at": pl.created_at,
"updated_at": pl.updated_at,
@@ -81,8 +79,12 @@ class PriceItemRepository(BaseRepository[PriceItem]):
def __init__(self, db: Session) -> None:
super().__init__(db, PriceItem)
- def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0) -> dict[str, Any]:
+ def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> dict[str, Any]:
stmt = select(PriceItem).where(PriceItem.price_list_id == price_list_id)
+ if product_id is not None:
+ stmt = stmt.where(PriceItem.product_id == product_id)
+ if currency_id is not None:
+ stmt = stmt.where(PriceItem.currency_id == currency_id)
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all())
items = [self._to_dict(pi) for pi in rows]
@@ -98,22 +100,22 @@ class PriceItemRepository(BaseRepository[PriceItem]):
},
}
- def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int | None, tier_name: str, min_qty, price) -> PriceItem:
+ def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int, tier_name: str | None, min_qty, price) -> PriceItem:
# Try find existing unique combination
stmt = select(PriceItem).where(
and_(
PriceItem.price_list_id == price_list_id,
PriceItem.product_id == product_id,
PriceItem.unit_id.is_(unit_id) if unit_id is None else PriceItem.unit_id == unit_id,
- PriceItem.tier_name == tier_name,
+ PriceItem.tier_name == (tier_name or 'پیشفرض'),
PriceItem.min_qty == min_qty,
+ PriceItem.currency_id == currency_id,
)
)
existing = self.db.execute(stmt).scalars().first()
if existing:
existing.price = price
- if currency_id is not None:
- existing.currency_id = currency_id
+ existing.currency_id = currency_id
self.db.commit()
self.db.refresh(existing)
return existing
@@ -122,7 +124,7 @@ class PriceItemRepository(BaseRepository[PriceItem]):
product_id=product_id,
unit_id=unit_id,
currency_id=currency_id,
- tier_name=tier_name,
+ tier_name=(tier_name or 'پیشفرض'),
min_qty=min_qty,
price=price,
)
diff --git a/hesabixAPI/adapters/db/repositories/product_repository.py b/hesabixAPI/adapters/db/repositories/product_repository.py
index db63dc2..e28deba 100644
--- a/hesabixAPI/adapters/db/repositories/product_repository.py
+++ b/hesabixAPI/adapters/db/repositories/product_repository.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
-from sqlalchemy import select, and_, func
+from sqlalchemy import select, and_, or_, func
from .base_repo import BaseRepository
from ..models.product import Product
@@ -25,6 +25,38 @@ class ProductRepository(BaseRepository[Product]):
)
)
+ # Apply filters (supports minimal set used by clients)
+ if filters:
+ for f in filters:
+ # Support both dict and pydantic-like objects
+ if isinstance(f, dict):
+ field = f.get("property")
+ operator = f.get("operator")
+ value = f.get("value")
+ else:
+ field = getattr(f, "property", None)
+ operator = getattr(f, "operator", None)
+ value = getattr(f, "value", None)
+
+ if not field or not operator:
+ continue
+
+ # Code filters
+ if field == "code":
+ if operator == "=":
+ stmt = stmt.where(Product.code == value)
+ elif operator == "in" and isinstance(value, (list, tuple)):
+ stmt = stmt.where(Product.code.in_(list(value)))
+ continue
+
+ # Name contains
+ if field == "name":
+ if operator in {"contains", "ilike"} and isinstance(value, str):
+ stmt = stmt.where(Product.name.ilike(f"%{value}%"))
+ elif operator == "=":
+ stmt = stmt.where(Product.name == value)
+ continue
+
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
# Sorting
diff --git a/hesabixAPI/app/core/auth_dependency.py b/hesabixAPI/app/core/auth_dependency.py
index ed68fc4..c38b5ac 100644
--- a/hesabixAPI/app/core/auth_dependency.py
+++ b/hesabixAPI/app/core/auth_dependency.py
@@ -45,6 +45,26 @@ class AuthContext:
# ایجاد translator برای زبان تشخیص داده شده
self._translator = Translator(language)
+ @staticmethod
+ def _normalize_permissions_value(value) -> dict:
+ """نرمالسازی مقدار JSON دسترسیها به dict برای سازگاری با دادههای legacy"""
+ if isinstance(value, dict):
+ return value
+ if isinstance(value, list):
+ try:
+ # لیست جفتها مانند [["join", true], ["sales", {..}]]
+ if all(isinstance(item, list) and len(item) == 2 for item in value):
+ return {k: v for k, v in value if isinstance(k, str)}
+ # لیست دیکشنریها مانند [{"join": true}, {"sales": {...}}]
+ if all(isinstance(item, dict) for item in value):
+ merged = {}
+ for item in value:
+ merged.update({k: v for k, v in item.items()})
+ return merged
+ except Exception:
+ return {}
+ return {}
+
def get_translator(self) -> Translator:
"""دریافت translator برای ترجمه"""
return self._translator
@@ -89,7 +109,7 @@ class AuthContext:
permission_obj = repo.get_by_user_and_business(self.user.id, self.business_id)
if permission_obj and permission_obj.business_permissions:
- return permission_obj.business_permissions
+ return AuthContext._normalize_permissions_value(permission_obj.business_permissions)
return {}
# بررسی دسترسیهای اپلیکیشن
@@ -278,7 +298,7 @@ class AuthContext:
return False
# بررسی دسترسی join
- business_perms = permission_obj.business_permissions or {}
+ business_perms = AuthContext._normalize_permissions_value(permission_obj.business_permissions)
has_join_access = business_perms.get('join', False)
logger.info(f"Business membership check: user {self.user.id} join access to business {business_id}: {has_join_access}")
return has_join_access
diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py
index 1805506..ce5d65f 100644
--- a/hesabixAPI/app/core/permissions.py
+++ b/hesabixAPI/app/core/permissions.py
@@ -74,7 +74,7 @@ def require_business_access(business_id_param: str = "business_id"):
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
- def wrapper(*args, **kwargs) -> Any:
+ async def wrapper(*args, **kwargs) -> Any:
import logging
from fastapi import Request
logger = logging.getLogger(__name__)
@@ -108,7 +108,11 @@ def require_business_access(business_id_param: str = "business_id"):
logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}")
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
- return func(*args, **kwargs)
+ # فراخوانی تابع اصلی و await در صورت نیاز
+ result = func(*args, **kwargs)
+ if inspect.isawaitable(result):
+ result = await result
+ return result
# Preserve original signature so FastAPI sees correct parameters (including Request)
wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
return wrapper
diff --git a/hesabixAPI/app/core/responses.py b/hesabixAPI/app/core/responses.py
index c44d1f1..2c2730d 100644
--- a/hesabixAPI/app/core/responses.py
+++ b/hesabixAPI/app/core/responses.py
@@ -14,9 +14,15 @@ def success_response(data: Any, request: Request = None, message: str = None) ->
if data is not None:
response["data"] = data
- # Add message if provided
+ # Add message if provided (translate if translator exists)
if message is not None:
- response["message"] = message
+ translated = message
+ try:
+ if request is not None and hasattr(request.state, 'translator') and request.state.translator is not None:
+ translated = request.state.translator.t(message, default=message)
+ except Exception:
+ translated = message
+ response["message"] = translated
# Add calendar type information if request is available
if request and hasattr(request.state, 'calendar_type'):
diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py
index c706a6a..b478cab 100644
--- a/hesabixAPI/app/main.py
+++ b/hesabixAPI/app/main.py
@@ -17,6 +17,7 @@ from adapters.api.v1.products import router as products_router
from adapters.api.v1.price_lists import router as price_lists_router
from adapters.api.v1.persons import router as persons_router
from adapters.api.v1.tax_units import router as tax_units_router
+from adapters.api.v1.tax_units import alias_router as units_alias_router
from adapters.api.v1.tax_types import router as tax_types_router
from adapters.api.v1.support.tickets import router as support_tickets_router
from adapters.api.v1.support.operator import router as support_operator_router
@@ -292,6 +293,7 @@ def create_app() -> FastAPI:
application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
application.include_router(persons_router, prefix=settings.api_v1_prefix)
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
+ application.include_router(units_alias_router, prefix=settings.api_v1_prefix)
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
# Support endpoints
diff --git a/hesabixAPI/app/services/bulk_price_update_service.py b/hesabixAPI/app/services/bulk_price_update_service.py
new file mode 100644
index 0000000..056a8dd
--- /dev/null
+++ b/hesabixAPI/app/services/bulk_price_update_service.py
@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+from typing import Dict, Any, List, Optional
+from decimal import Decimal
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, or_
+
+from adapters.db.models.product import Product
+from adapters.db.models.price_list import PriceItem
+from adapters.db.models.category import BusinessCategory
+from adapters.db.models.currency import Currency
+from adapters.api.v1.schema_models.product import (
+ BulkPriceUpdateRequest,
+ BulkPriceUpdatePreview,
+ BulkPriceUpdatePreviewResponse,
+ BulkPriceUpdateType,
+ BulkPriceUpdateTarget,
+ BulkPriceUpdateDirection,
+ ProductItemType
+)
+
+
+def _quantize_non_negative_integer(value: Decimal) -> Decimal:
+ """رُند کردن به عدد صحیح غیرمنفی (بدون اعشار)."""
+ # حذف اعشار: round-half-up به نزدیکترین عدد صحیح
+ quantized = value.quantize(Decimal('1'))
+ if quantized < 0:
+ return Decimal('0')
+ return quantized
+
+def _quantize_integer_keep_sign(value: Decimal) -> Decimal:
+ """رُند کردن به عدد صحیح با حفظ علامت (بدون اعشار)."""
+ return value.quantize(Decimal('1'))
+
+
+def calculate_new_price(current_price: Optional[Decimal], update_type: BulkPriceUpdateType, direction: BulkPriceUpdateDirection, value: Decimal) -> Optional[Decimal]:
+ """محاسبه قیمت جدید بر اساس نوع تغییر با جهت، سپس رُند و کلَمپ به صفر"""
+ if current_price is None:
+ return None
+
+ delta = Decimal('0')
+ if update_type == BulkPriceUpdateType.PERCENTAGE:
+ sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1')
+ multiplier = Decimal('1') + (sign * (value / Decimal('100')))
+ new_value = current_price * multiplier
+ else:
+ sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1')
+ delta = sign * value
+ new_value = current_price + delta
+
+ # رُند به عدد صحیح و کلَمپ به صفر
+ return _quantize_non_negative_integer(new_value)
+
+
+def get_filtered_products(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> List[Product]:
+ """دریافت کالاهای فیلتر شده بر اساس معیارهای درخواست"""
+ query = db.query(Product).filter(Product.business_id == business_id)
+
+ # فیلتر بر اساس دستهبندی
+ if request.category_ids:
+ query = query.filter(Product.category_id.in_(request.category_ids))
+
+ # فیلتر بر اساس نوع آیتم
+ if request.item_types:
+ query = query.filter(Product.item_type.in_([t.value for t in request.item_types]))
+
+ # فیلتر بر اساس ارز: محصولی که قیمتهای لیست مرتبط با ارزهای انتخابی دارد
+ if request.currency_ids:
+ query = query.filter(
+ db.query(PriceItem.id)
+ .filter(
+ PriceItem.product_id == Product.id,
+ PriceItem.currency_id.in_(request.currency_ids)
+ ).exists()
+ )
+
+ # فیلتر بر اساس لیست قیمت: محصولی که در هر یک از لیستهای انتخابی آیتم قیمت دارد
+ if request.price_list_ids:
+ query = query.filter(
+ db.query(PriceItem.id)
+ .filter(
+ PriceItem.product_id == Product.id,
+ PriceItem.price_list_id.in_(request.price_list_ids)
+ ).exists()
+ )
+
+ # فیلتر بر اساس شناسههای کالاهای خاص
+ if request.product_ids:
+ query = query.filter(Product.id.in_(request.product_ids))
+
+ # فیلتر بر اساس موجودی
+ if request.only_products_with_inventory is not None:
+ if request.only_products_with_inventory:
+ query = query.filter(Product.track_inventory == True)
+ else:
+ query = query.filter(Product.track_inventory == False)
+
+ # فیلتر بر اساس وجود قیمت پایه
+ if request.only_products_with_base_price:
+ if request.target == BulkPriceUpdateTarget.SALES_PRICE:
+ query = query.filter(Product.base_sales_price.isnot(None))
+ elif request.target == BulkPriceUpdateTarget.PURCHASE_PRICE:
+ query = query.filter(Product.base_purchase_price.isnot(None))
+ else:
+ # در حالت هر دو، حداقل یکی موجود باشد
+ query = query.filter(or_(Product.base_sales_price.isnot(None), Product.base_purchase_price.isnot(None)))
+
+ return query.all()
+
+
+def preview_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> BulkPriceUpdatePreviewResponse:
+ """پیشنمایش تغییرات قیمت گروهی"""
+ products = get_filtered_products(db, business_id, request)
+
+ # کش نام دستهها برای کاهش کوئری
+ category_titles: Dict[int, str] = {}
+ def _resolve_category_name(cid: Optional[int]) -> Optional[str]:
+ if cid is None:
+ return None
+ if cid in category_titles:
+ return category_titles[cid]
+ try:
+ cat = db.query(BusinessCategory).filter(BusinessCategory.id == cid, BusinessCategory.business_id == business_id).first()
+ if cat and isinstance(cat.title_translations, dict):
+ title = cat.title_translations.get('fa') or cat.title_translations.get('default') or ''
+ category_titles[cid] = title
+ return title
+ except Exception:
+ return None
+ return None
+
+ affected_products = []
+ total_sales_change = Decimal('0')
+ total_purchase_change = Decimal('0')
+ products_with_sales_change = 0
+ products_with_purchase_change = 0
+
+ for product in products:
+ preview = BulkPriceUpdatePreview(
+ product_id=product.id,
+ product_name=product.name or "بدون نام",
+ product_code=product.code or "بدون کد",
+ category_name=_resolve_category_name(product.category_id),
+ current_sales_price=product.base_sales_price,
+ current_purchase_price=product.base_purchase_price,
+ new_sales_price=None,
+ new_purchase_price=None,
+ sales_price_change=None,
+ purchase_price_change=None
+ )
+
+ # محاسبه تغییرات قیمت فروش
+ if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None:
+ new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value)
+ preview.new_sales_price = new_sales_price
+ preview.sales_price_change = (new_sales_price - product.base_sales_price) if new_sales_price is not None else None
+ total_sales_change += (preview.sales_price_change or Decimal('0'))
+ products_with_sales_change += 1
+
+ # محاسبه تغییرات قیمت خرید
+ if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None:
+ new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value)
+ preview.new_purchase_price = new_purchase_price
+ preview.purchase_price_change = (new_purchase_price - product.base_purchase_price) if new_purchase_price is not None else None
+ total_purchase_change += (preview.purchase_price_change or Decimal('0'))
+ products_with_purchase_change += 1
+
+ affected_products.append(preview)
+
+ summary = {
+ "total_products": len(products),
+ "affected_products": len(affected_products),
+ "products_with_sales_change": products_with_sales_change,
+ "products_with_purchase_change": products_with_purchase_change,
+ "total_sales_change": float(_quantize_integer_keep_sign(total_sales_change)),
+ "total_purchase_change": float(_quantize_integer_keep_sign(total_purchase_change)),
+ "update_type": request.update_type.value,
+ "direction": request.direction.value,
+ "target": request.target.value,
+ "value": float(_quantize_non_negative_integer(request.value)) if request.update_type == BulkPriceUpdateType.AMOUNT else float(request.value)
+ }
+
+ return BulkPriceUpdatePreviewResponse(
+ total_products=len(products),
+ affected_products=affected_products,
+ summary=summary
+ )
+
+
+def apply_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> Dict[str, Any]:
+ """اعمال تغییرات قیمت گروهی"""
+ products = get_filtered_products(db, business_id, request)
+
+ updated_count = 0
+ errors = []
+
+ # اگر price_list_ids مشخص شده باشد، هم قیمت پایه و هم PriceItemها باید بهروزرسانی شوند
+ for product in products:
+ try:
+ # بروزرسانی قیمت فروش
+ if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None:
+ new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value)
+ product.base_sales_price = new_sales_price
+
+ # بروزرسانی قیمت خرید
+ if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None:
+ new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value)
+ product.base_purchase_price = new_purchase_price
+
+ # بروزرسانی آیتمهای لیست قیمت مرتبط (در صورت مشخص بودن فیلترها)
+ q = db.query(PriceItem).filter(PriceItem.product_id == product.id)
+ if request.currency_ids:
+ q = q.filter(PriceItem.currency_id.in_(request.currency_ids))
+ if request.price_list_ids:
+ q = q.filter(PriceItem.price_list_id.in_(request.price_list_ids))
+ # اگر هدف فقط فروش/خرید نیست چون PriceItem فقط یک فیلد price دارد، همان price را تغییر میدهیم
+ for pi in q.all():
+ new_pi_price = calculate_new_price(Decimal(pi.price), request.update_type, request.direction, request.value)
+ pi.price = new_pi_price
+
+ updated_count += 1
+
+ except Exception as e:
+ errors.append(f"خطا در بروزرسانی کالای {product.name}: {str(e)}")
+
+ db.commit()
+
+ return {
+ "message": f"تغییرات قیمت برای {updated_count} کالا اعمال شد",
+ "updated_count": updated_count,
+ "total_products": len(products),
+ "errors": errors
+ }
diff --git a/hesabixAPI/app/services/business_service.py b/hesabixAPI/app/services/business_service.py
index a8be166..2ff1927 100644
--- a/hesabixAPI/app/services/business_service.py
+++ b/hesabixAPI/app/services/business_service.py
@@ -192,7 +192,28 @@ def get_user_businesses(db: Session, user_id: int, query_info: Dict[str, Any]) -
business_dict['role'] = 'عضو'
# دریافت دسترسیهای کاربر برای این کسب و کار
permission_obj = permission_repo.get_by_user_and_business(user_id, business.id)
- business_dict['permissions'] = permission_obj.business_permissions if permission_obj else {}
+ 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)
# اعمال فیلترها
diff --git a/hesabixAPI/app/services/price_list_service.py b/hesabixAPI/app/services/price_list_service.py
index 805d50a..0a40588 100644
--- a/hesabixAPI/app/services/price_list_service.py
+++ b/hesabixAPI/app/services/price_list_service.py
@@ -20,11 +20,9 @@ def create_price_list(db: Session, business_id: int, payload: PriceListCreateReq
obj = repo.create(
business_id=business_id,
name=payload.name.strip(),
- currency_id=payload.currency_id,
- default_unit_id=payload.default_unit_id,
is_active=payload.is_active,
)
- return {"message": "لیست قیمت ایجاد شد", "data": _pl_to_dict(obj)}
+ return {"message": "PRICE_LIST_CREATED", "data": _pl_to_dict(obj)}
def list_price_lists(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
@@ -53,10 +51,10 @@ def update_price_list(db: Session, business_id: int, id: int, payload: PriceList
dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip(), PriceList.id != id)).first()
if dup:
raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400)
- updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, currency_id=payload.currency_id, default_unit_id=payload.default_unit_id, is_active=payload.is_active)
+ updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, is_active=payload.is_active)
if not updated:
return None
- return {"message": "لیست قیمت بروزرسانی شد", "data": _pl_to_dict(updated)}
+ return {"message": "PRICE_LIST_UPDATED", "data": _pl_to_dict(updated)}
def delete_price_list(db: Session, business_id: int, id: int) -> bool:
@@ -67,13 +65,13 @@ def delete_price_list(db: Session, business_id: int, id: int) -> bool:
return repo.delete(id)
-def list_price_items(db: Session, business_id: int, price_list_id: int, take: int = 50, skip: int = 0) -> Dict[str, Any]:
+def list_price_items(db: Session, business_id: int, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> Dict[str, Any]:
# مالکیت را از روی price_list بررسی میکنیم
pl = db.get(PriceList, price_list_id)
if not pl or pl.business_id != business_id:
raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404)
repo = PriceItemRepository(db)
- return repo.list_for_price_list(price_list_id=price_list_id, take=take, skip=skip)
+ return repo.list_for_price_list(price_list_id=price_list_id, take=take, skip=skip, product_id=product_id, currency_id=currency_id)
def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload: PriceItemUpsertRequest) -> Dict[str, Any]:
@@ -93,12 +91,12 @@ def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload
price_list_id=price_list_id,
product_id=payload.product_id,
unit_id=payload.unit_id,
- currency_id=payload.currency_id or pl.currency_id,
- tier_name=payload.tier_name.strip(),
+ currency_id=payload.currency_id,
+ tier_name=(payload.tier_name.strip() if isinstance(payload.tier_name, str) and payload.tier_name.strip() else 'پیشفرض'),
min_qty=payload.min_qty,
price=payload.price,
)
- return {"message": "قیمت ثبت شد", "data": _pi_to_dict(obj)}
+ return {"message": "PRICE_ITEM_UPSERTED", "data": _pi_to_dict(obj)}
def delete_price_item(db: Session, business_id: int, id: int) -> bool:
@@ -118,8 +116,6 @@ def _pl_to_dict(obj: PriceList) -> Dict[str, Any]:
"id": obj.id,
"business_id": obj.business_id,
"name": obj.name,
- "currency_id": obj.currency_id,
- "default_unit_id": obj.default_unit_id,
"is_active": obj.is_active,
"created_at": obj.created_at,
"updated_at": obj.updated_at,
diff --git a/hesabixAPI/app/services/product_service.py b/hesabixAPI/app/services/product_service.py
index 71f7666..6d09190 100644
--- a/hesabixAPI/app/services/product_service.py
+++ b/hesabixAPI/app/services/product_service.py
@@ -103,7 +103,7 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest)
_upsert_attributes(db, obj.id, business_id, payload.attribute_ids)
- return {"message": "آیتم با موفقیت ایجاد شد", "data": _to_dict(obj)}
+ return {"message": "PRODUCT_CREATED", "data": _to_dict(obj)}
def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
@@ -178,7 +178,7 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod
return None
_upsert_attributes(db, product_id, business_id, payload.attribute_ids)
- return {"message": "آیتم با موفقیت ویرایش شد", "data": _to_dict(updated)}
+ return {"message": "PRODUCT_UPDATED", "data": _to_dict(updated)}
def delete_product(db: Session, product_id: int, business_id: int) -> bool:
diff --git a/hesabixAPI/build/lib/adapters/__init__.py b/hesabixAPI/build/lib/adapters/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hesabixAPI/build/lib/adapters/api/__init__.py b/hesabixAPI/build/lib/adapters/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hesabixAPI/build/lib/adapters/api/v1/__init__.py b/hesabixAPI/build/lib/adapters/api/v1/__init__.py
new file mode 100644
index 0000000..d8d30c8
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/__init__.py
@@ -0,0 +1,5 @@
+from .health import router as health # noqa: F401
+from .categories import router as categories # noqa: F401
+from .products import router as products # noqa: F401
+from .price_lists import router as price_lists # noqa: F401
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/accounts.py b/hesabixAPI/build/lib/adapters/api/v1/accounts.py
new file mode 100644
index 0000000..4f8f62e
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/accounts.py
@@ -0,0 +1,57 @@
+from typing import List, Dict, Any
+
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.api.v1.schemas import SuccessResponse
+from adapters.api.v1.schema_models.account import AccountTreeNode
+from app.core.responses import success_response
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from adapters.db.models.account import Account
+
+
+router = APIRouter(prefix="/accounts", tags=["accounts"])
+
+
+def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]:
+ by_id: dict[int, AccountTreeNode] = {}
+ roots: list[AccountTreeNode] = []
+ for n in nodes:
+ node = AccountTreeNode(
+ id=n['id'], code=n['code'], name=n['name'], account_type=n.get('account_type'), parent_id=n.get('parent_id')
+ )
+ by_id[node.id] = node
+ for node in list(by_id.values()):
+ pid = node.parent_id
+ if pid and pid in by_id:
+ by_id[pid].children.append(node)
+ else:
+ roots.append(node)
+ return roots
+
+
+@router.get("/business/{business_id}/tree",
+ summary="دریافت درخت حسابها برای یک کسب و کار",
+ description="لیست حسابهای عمومی و حسابهای اختصاصی کسب و کار به صورت درختی",
+)
+@require_business_access("business_id")
+def get_accounts_tree(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ # دریافت حسابهای عمومی (business_id IS NULL) و حسابهای مختص این کسب و کار
+ rows = db.query(Account).filter(
+ (Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
+ ).order_by(Account.code.asc()).all()
+ flat = [
+ {"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id}
+ for r in rows
+ ]
+ tree = _build_tree(flat)
+ return success_response({"items": [n.model_dump() for n in tree]}, request)
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/admin/email_config.py b/hesabixAPI/build/lib/adapters/api/v1/admin/email_config.py
new file mode 100644
index 0000000..fe79901
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/admin/email_config.py
@@ -0,0 +1,349 @@
+from fastapi import APIRouter, Depends, HTTPException, Request
+from sqlalchemy.orm import Session
+from typing import List
+
+from adapters.db.session import get_db
+from adapters.db.models.email_config import EmailConfig
+from adapters.db.repositories.email_config_repository import EmailConfigRepository
+from adapters.api.v1.schema_models.email import (
+ EmailConfigCreate,
+ EmailConfigUpdate,
+ EmailConfigResponse,
+ SendEmailRequest,
+ TestConnectionRequest
+)
+from adapters.api.v1.schemas import SuccessResponse
+from app.core.responses import success_response, format_datetime_fields
+from app.core.permissions import require_app_permission
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.i18n import gettext, negotiate_locale
+
+router = APIRouter(prefix="/admin/email", tags=["Email Configuration"])
+
+
+@router.get("/configs", response_model=SuccessResponse)
+@require_app_permission("superadmin")
+async def get_email_configs(
+ request: Request,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user)
+):
+ """Get all email configurations"""
+ try:
+ email_repo = EmailConfigRepository(db)
+ configs = email_repo.get_all_configs()
+
+ config_responses = [
+ EmailConfigResponse.model_validate(config) for config in configs
+ ]
+
+ # Format datetime fields based on calendar type
+ formatted_data = format_datetime_fields(config_responses, request)
+
+ return success_response(
+ data=formatted_data,
+ request=request
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/configs/{config_id}", response_model=SuccessResponse)
+@require_app_permission("superadmin")
+async def get_email_config(
+ config_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user)
+):
+ """Get specific email configuration"""
+ try:
+ email_repo = EmailConfigRepository(db)
+ config = email_repo.get_by_id(config_id)
+
+ if not config:
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
+
+ config_response = EmailConfigResponse.model_validate(config)
+
+ # Format datetime fields based on calendar type
+ formatted_data = format_datetime_fields(config_response.model_dump(), request)
+
+ return success_response(
+ data=formatted_data,
+ request=request
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/configs", response_model=SuccessResponse)
+@require_app_permission("superadmin")
+async def create_email_config(
+ request_data: EmailConfigCreate,
+ request: Request,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user)
+):
+ """Create new email configuration"""
+ try:
+ email_repo = EmailConfigRepository(db)
+
+ # Get locale from request
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+
+ # Check if name already exists
+ existing_config = email_repo.get_by_name(request_data.name)
+ if existing_config:
+ raise HTTPException(status_code=400, detail=gettext("Configuration name already exists", locale))
+
+ # Create new config
+ config = EmailConfig(**request_data.model_dump())
+ email_repo.db.add(config)
+ email_repo.db.commit()
+ email_repo.db.refresh(config)
+
+ # If this is the first config, set it as default
+ if not email_repo.get_default_config():
+ email_repo.set_default_config(config.id)
+
+ config_response = EmailConfigResponse.model_validate(config)
+
+ # Format datetime fields based on calendar type
+ formatted_data = format_datetime_fields(config_response.model_dump(), request)
+
+ return success_response(
+ data=formatted_data,
+ request=request
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/configs/{config_id}", response_model=SuccessResponse)
+@require_app_permission("superadmin")
+async def update_email_config(
+ config_id: int,
+ request_data: EmailConfigUpdate,
+ request: Request,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user)
+):
+ """Update email configuration"""
+ try:
+ email_repo = EmailConfigRepository(db)
+ config = email_repo.get_by_id(config_id)
+
+ # Get locale from request
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+
+ if not config:
+ raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
+
+ # Check name uniqueness if name is being updated
+ if request_data.name and request_data.name != config.name:
+ existing_config = email_repo.get_by_name(request_data.name)
+ if existing_config:
+ raise HTTPException(status_code=400, detail=gettext("Configuration name already exists", locale))
+
+ # Update config
+ update_data = request_data.model_dump(exclude_unset=True)
+
+ # Prevent changing is_default through update - use set-default endpoint instead
+ if 'is_default' in update_data:
+ del update_data['is_default']
+
+ for field, value in update_data.items():
+ setattr(config, field, value)
+
+ email_repo.update(config)
+
+ config_response = EmailConfigResponse.model_validate(config)
+
+ # Format datetime fields based on calendar type
+ formatted_data = format_datetime_fields(config_response.model_dump(), request)
+
+ return success_response(
+ data=formatted_data,
+ request=request
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/configs/{config_id}", response_model=SuccessResponse)
+@require_app_permission("superadmin")
+async def delete_email_config(
+ config_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user)
+):
+ """Delete email configuration"""
+ try:
+ email_repo = EmailConfigRepository(db)
+ config = email_repo.get_by_id(config_id)
+
+ # Get locale from request
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+
+ if not config:
+ raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
+
+ # Prevent deletion of default config
+ if config.is_default:
+ raise HTTPException(status_code=400, detail=gettext("Cannot delete default configuration", locale))
+
+ email_repo.delete(config)
+
+ return success_response(
+ data=None,
+ request=request
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/configs/{config_id}/test", response_model=SuccessResponse)
+@require_app_permission("superadmin")
+async def test_email_config(
+ config_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user)
+):
+ """Test email configuration connection"""
+ try:
+ email_repo = EmailConfigRepository(db)
+ config = email_repo.get_by_id(config_id)
+
+ # Get locale from request
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+
+ if not config:
+ raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
+
+ is_connected = email_repo.test_connection(config)
+
+ return success_response(
+ data={"connected": is_connected},
+ request=request
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/configs/{config_id}/activate", response_model=SuccessResponse)
+@require_app_permission("superadmin")
+async def activate_email_config(
+ config_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user)
+):
+ """Activate email configuration"""
+ try:
+ email_repo = EmailConfigRepository(db)
+ config = email_repo.get_by_id(config_id)
+
+ # Get locale from request
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+
+ if not config:
+ raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
+
+ success = email_repo.set_active_config(config_id)
+
+ if not success:
+ raise HTTPException(status_code=500, detail=gettext("Failed to activate configuration", locale))
+
+ return success_response(
+ data=None,
+ request=request
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/configs/{config_id}/set-default", response_model=SuccessResponse)
+@require_app_permission("superadmin")
+async def set_default_email_config(
+ config_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user)
+):
+ """Set email configuration as default"""
+ try:
+ email_repo = EmailConfigRepository(db)
+ config = email_repo.get_by_id(config_id)
+
+ # Get locale from request
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+
+ if not config:
+ raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
+
+ success = email_repo.set_default_config(config_id)
+
+ if not success:
+ raise HTTPException(status_code=500, detail=gettext("Failed to set default configuration", locale))
+
+ return success_response(
+ data=None,
+ request=request
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/send", response_model=SuccessResponse)
+@require_app_permission("superadmin")
+async def send_email(
+ request_data: SendEmailRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user)
+):
+ """Send email using configured SMTP"""
+ try:
+ from app.services.email_service import EmailService
+
+ email_service = EmailService(db)
+ success = email_service.send_email(
+ to=request_data.to,
+ subject=request_data.subject,
+ body=request_data.body,
+ html_body=request_data.html_body,
+ config_id=request_data.config_id
+ )
+
+ # Get locale from request
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+
+ if not success:
+ raise HTTPException(status_code=500, detail=gettext("Failed to send email", locale))
+
+ return success_response(
+ data={"sent": True},
+ request=request
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/hesabixAPI/build/lib/adapters/api/v1/admin/file_storage.py b/hesabixAPI/build/lib/adapters/api/v1/admin/file_storage.py
new file mode 100644
index 0000000..906242c
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/admin/file_storage.py
@@ -0,0 +1,725 @@
+from typing import List, Optional
+from uuid import UUID
+from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Request
+from sqlalchemy.orm import Session
+from sqlalchemy import and_
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_app_permission
+from app.core.responses import success_response
+from app.core.responses import ApiError
+from app.core.i18n import locale_dependency
+from app.services.file_storage_service import FileStorageService
+from adapters.db.repositories.file_storage_repository import StorageConfigRepository, FileStorageRepository
+from adapters.db.models.user import User
+from adapters.db.models.file_storage import StorageConfig, FileStorage
+from adapters.api.v1.schema_models.file_storage import (
+ StorageConfigCreateRequest,
+ StorageConfigUpdateRequest,
+ FileUploadRequest,
+ FileVerificationRequest,
+ FileInfo,
+ FileUploadResponse,
+ StorageConfigResponse,
+ FileStatisticsResponse,
+ CleanupResponse
+)
+
+router = APIRouter(prefix="/admin/files", tags=["Admin File Management"])
+
+
+@router.get("/", response_model=dict)
+@require_app_permission("superadmin")
+async def list_all_files(
+ request: Request,
+ page: int = Query(1, ge=1),
+ size: int = Query(50, ge=1, le=100),
+ module_context: Optional[str] = Query(None),
+ is_temporary: Optional[bool] = Query(None),
+ is_verified: Optional[bool] = Query(None),
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """لیست تمام فایلها با فیلتر"""
+ try:
+ file_repo = FileStorageRepository(db)
+
+ # محاسبه offset برای pagination
+ offset = (page - 1) * size
+
+ # ساخت فیلترها
+ filters = []
+ if module_context:
+ filters.append(FileStorage.module_context == module_context)
+ if is_temporary is not None:
+ filters.append(FileStorage.is_temporary == is_temporary)
+ if is_verified is not None:
+ filters.append(FileStorage.is_verified == is_verified)
+
+ # اضافه کردن فیلتر حذف نشده
+ filters.append(FileStorage.deleted_at.is_(None))
+
+ # دریافت فایلها با فیلتر و pagination
+ files_query = db.query(FileStorage).filter(and_(*filters))
+ total_count = files_query.count()
+
+ files = files_query.order_by(FileStorage.created_at.desc()).offset(offset).limit(size).all()
+
+ # تبدیل به فرمت مناسب
+ files_data = []
+ for file in files:
+ files_data.append({
+ "id": str(file.id),
+ "original_name": file.original_name,
+ "stored_name": file.stored_name,
+ "file_size": file.file_size,
+ "mime_type": file.mime_type,
+ "storage_type": file.storage_type,
+ "module_context": file.module_context,
+ "context_id": str(file.context_id) if file.context_id else None,
+ "is_temporary": file.is_temporary,
+ "is_verified": file.is_verified,
+ "is_active": file.is_active,
+ "created_at": file.created_at.isoformat(),
+ "updated_at": file.updated_at.isoformat(),
+ "expires_at": file.expires_at.isoformat() if file.expires_at else None,
+ "uploaded_by": file.uploaded_by,
+ "checksum": file.checksum
+ })
+
+ # محاسبه pagination info
+ total_pages = (total_count + size - 1) // size
+ has_next = page < total_pages
+ has_prev = page > 1
+
+ data = {
+ "files": files_data,
+ "pagination": {
+ "page": page,
+ "size": size,
+ "total_count": total_count,
+ "total_pages": total_pages,
+ "has_next": has_next,
+ "has_prev": has_prev
+ },
+ "filters": {
+ "module_context": module_context,
+ "is_temporary": is_temporary,
+ "is_verified": is_verified
+ }
+ }
+
+ return success_response(data, request)
+ except Exception as e:
+ raise ApiError(
+ code="FILE_LIST_ERROR",
+ message=translator.t("FILE_LIST_ERROR", f"خطا در دریافت لیست فایلها: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+@router.get("/unverified", response_model=dict)
+@require_app_permission("superadmin")
+async def get_unverified_files(
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """فایلهای تایید نشده"""
+ try:
+ file_service = FileStorageService(db)
+ unverified_files = await file_service.file_repo.get_unverified_temporary_files()
+
+ data = {
+ "unverified_files": [
+ {
+ "file_id": str(file.id),
+ "original_name": file.original_name,
+ "file_size": file.file_size,
+ "module_context": file.module_context,
+ "created_at": file.created_at.isoformat(),
+ "expires_at": file.expires_at.isoformat() if file.expires_at else None
+ }
+ for file in unverified_files
+ ],
+ "count": len(unverified_files)
+ }
+
+ return success_response(data, request)
+ except Exception as e:
+ raise ApiError(
+ code="UNVERIFIED_FILES_ERROR",
+ message=translator.t("UNVERIFIED_FILES_ERROR", f"خطا در دریافت فایلهای تایید نشده: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+@router.post("/cleanup-temporary", response_model=dict)
+@require_app_permission("superadmin")
+async def cleanup_temporary_files(
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """پاکسازی فایلهای موقت"""
+ try:
+ file_service = FileStorageService(db)
+ cleanup_result = await file_service.cleanup_unverified_files()
+
+ data = {
+ "message": translator.t("CLEANUP_COMPLETED", "Temporary files cleanup completed"),
+ "result": cleanup_result
+ }
+
+ return success_response(data, request)
+ except Exception as e:
+ raise ApiError(
+ code="CLEANUP_ERROR",
+ message=translator.t("CLEANUP_ERROR", f"خطا در پاکسازی فایلهای موقت: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+@router.delete("/{file_id}", response_model=dict)
+@require_app_permission("superadmin")
+async def force_delete_file(
+ file_id: UUID,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """حذف اجباری فایل"""
+ try:
+ file_service = FileStorageService(db)
+ success = await file_service.delete_file(file_id)
+
+ if not success:
+ raise ApiError(
+ code="FILE_NOT_FOUND",
+ message=translator.t("FILE_NOT_FOUND", "فایل یافت نشد"),
+ http_status=404,
+ translator=translator
+ )
+
+ data = {"message": translator.t("FILE_DELETED_SUCCESS", "File deleted successfully")}
+ return success_response(data, request)
+ except ApiError:
+ raise
+ except Exception as e:
+ raise ApiError(
+ code="DELETE_FILE_ERROR",
+ message=translator.t("DELETE_FILE_ERROR", f"خطا در حذف فایل: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+@router.put("/{file_id}/restore", response_model=dict)
+@require_app_permission("superadmin")
+async def restore_file(
+ file_id: UUID,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """بازیابی فایل حذف شده"""
+ try:
+ file_repo = FileStorageRepository(db)
+ success = await file_repo.restore_file(file_id)
+
+ if not success:
+ raise ApiError(
+ code="FILE_NOT_FOUND",
+ message=translator.t("FILE_NOT_FOUND", "فایل یافت نشد"),
+ http_status=404,
+ translator=translator
+ )
+
+ data = {"message": translator.t("FILE_RESTORED_SUCCESS", "File restored successfully")}
+ return success_response(data, request)
+ except ApiError:
+ raise
+ except Exception as e:
+ raise ApiError(
+ code="RESTORE_FILE_ERROR",
+ message=translator.t("RESTORE_FILE_ERROR", f"خطا در بازیابی فایل: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+@router.get("/statistics", response_model=dict)
+@require_app_permission("superadmin")
+async def get_file_statistics(
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """آمار استفاده از فضای ذخیرهسازی"""
+ try:
+ file_service = FileStorageService(db)
+ statistics = await file_service.get_storage_statistics()
+
+ return success_response(statistics, request)
+ except Exception as e:
+ raise ApiError(
+ code="STATISTICS_ERROR",
+ message=translator.t("STATISTICS_ERROR", f"خطا در دریافت آمار: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+# Storage Configuration Management
+@router.get("/storage-configs/", response_model=dict)
+@require_app_permission("superadmin")
+async def get_storage_configs(
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """لیست تنظیمات ذخیرهسازی"""
+ try:
+ config_repo = StorageConfigRepository(db)
+ configs = config_repo.get_all_configs()
+
+ data = {
+ "configs": [
+ {
+ "id": str(config.id),
+ "name": config.name,
+ "storage_type": config.storage_type,
+ "is_default": config.is_default,
+ "is_active": config.is_active,
+ "config_data": config.config_data,
+ "created_at": config.created_at.isoformat()
+ }
+ for config in configs
+ ]
+ }
+
+ return success_response(data, request)
+ except Exception as e:
+ raise ApiError(
+ code="STORAGE_CONFIGS_ERROR",
+ message=translator.t("STORAGE_CONFIGS_ERROR", f"خطا در دریافت تنظیمات ذخیرهسازی: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+@router.post("/storage-configs/", response_model=dict)
+@require_app_permission("superadmin")
+async def create_storage_config(
+ request: Request,
+ config_request: StorageConfigCreateRequest,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """ایجاد تنظیمات ذخیرهسازی جدید"""
+ try:
+ config_repo = StorageConfigRepository(db)
+
+ config = await config_repo.create_config(
+ name=config_request.name,
+ storage_type=config_request.storage_type,
+ config_data=config_request.config_data,
+ created_by=current_user.get_user_id(),
+ is_default=config_request.is_default,
+ is_active=config_request.is_active
+ )
+
+ data = {
+ "message": translator.t("STORAGE_CONFIG_CREATED", "Storage configuration created successfully"),
+ "config_id": str(config.id)
+ }
+
+ return success_response(data, request)
+ except Exception as e:
+ raise ApiError(
+ code="CREATE_STORAGE_CONFIG_ERROR",
+ message=translator.t("CREATE_STORAGE_CONFIG_ERROR", f"خطا در ایجاد تنظیمات ذخیرهسازی: {str(e)}"),
+ http_status=400,
+ translator=translator
+ )
+
+
+@router.put("/storage-configs/{config_id}", response_model=dict)
+@require_app_permission("superadmin")
+async def update_storage_config(
+ config_id: UUID,
+ request: Request,
+ config_request: StorageConfigUpdateRequest,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """بروزرسانی تنظیمات ذخیرهسازی"""
+ try:
+ config_repo = StorageConfigRepository(db)
+
+ # TODO: پیادهسازی بروزرسانی
+ data = {"message": translator.t("STORAGE_CONFIG_UPDATE_NOT_IMPLEMENTED", "Storage configuration update - to be implemented")}
+ return success_response(data, request)
+ except Exception as e:
+ raise ApiError(
+ code="UPDATE_STORAGE_CONFIG_ERROR",
+ message=translator.t("UPDATE_STORAGE_CONFIG_ERROR", f"خطا در بروزرسانی تنظیمات ذخیرهسازی: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+@router.put("/storage-configs/{config_id}/set-default", response_model=dict)
+@require_app_permission("superadmin")
+async def set_default_storage_config(
+ config_id: UUID,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """تنظیم به عنوان پیشفرض"""
+ try:
+ config_repo = StorageConfigRepository(db)
+ success = await config_repo.set_default_config(config_id)
+
+ if not success:
+ raise ApiError(
+ code="STORAGE_CONFIG_NOT_FOUND",
+ message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیرهسازی یافت نشد"),
+ http_status=404,
+ translator=translator
+ )
+
+ data = {"message": translator.t("DEFAULT_STORAGE_CONFIG_UPDATED", "Default storage configuration updated successfully")}
+ return success_response(data, request)
+ except ApiError:
+ raise
+ except Exception as e:
+ raise ApiError(
+ code="SET_DEFAULT_STORAGE_CONFIG_ERROR",
+ message=translator.t("SET_DEFAULT_STORAGE_CONFIG_ERROR", f"خطا در تنظیم پیشفرض: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+@router.delete("/storage-configs/{config_id}", response_model=dict)
+@require_app_permission("superadmin")
+async def delete_storage_config(
+ config_id: str,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """حذف تنظیمات ذخیرهسازی"""
+ try:
+ config_repo = StorageConfigRepository(db)
+
+ # بررسی وجود فایلها قبل از حذف
+ file_count = config_repo.count_files_by_storage_config(config_id)
+ if file_count > 0:
+ raise ApiError(
+ code="STORAGE_CONFIG_HAS_FILES",
+ message=translator.t("STORAGE_CONFIG_HAS_FILES", f"این تنظیمات ذخیرهسازی دارای {file_count} فایل است و قابل حذف نیست"),
+ http_status=400,
+ translator=translator
+ )
+
+ success = config_repo.delete_config(config_id)
+
+ if not success:
+ raise ApiError(
+ code="STORAGE_CONFIG_NOT_FOUND",
+ message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیرهسازی یافت نشد"),
+ http_status=404,
+ translator=translator
+ )
+
+ data = {"message": translator.t("STORAGE_CONFIG_DELETED", "Storage configuration deleted successfully")}
+ return success_response(data, request)
+ except ApiError:
+ raise
+ except Exception as e:
+ raise ApiError(
+ code="DELETE_STORAGE_CONFIG_ERROR",
+ message=translator.t("DELETE_STORAGE_CONFIG_ERROR", f"خطا در حذف تنظیمات ذخیرهسازی: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+@router.post("/storage-configs/{config_id}/test", response_model=dict)
+@require_app_permission("superadmin")
+async def test_storage_config(
+ config_id: str,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: AuthContext = Depends(get_current_user),
+ translator = Depends(locale_dependency)
+):
+ """تست اتصال به storage"""
+ try:
+ config_repo = StorageConfigRepository(db)
+ config = db.query(StorageConfig).filter(StorageConfig.id == config_id).first()
+
+ if not config:
+ raise ApiError(
+ code="STORAGE_CONFIG_NOT_FOUND",
+ message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیرهسازی یافت نشد"),
+ http_status=404,
+ translator=translator
+ )
+
+ # تست اتصال بر اساس نوع storage
+ test_result = await _test_storage_connection(config)
+
+ if test_result["success"]:
+ data = {
+ "message": translator.t("STORAGE_CONNECTION_SUCCESS", "اتصال به storage موفقیتآمیز بود"),
+ "test_result": test_result
+ }
+ else:
+ data = {
+ "message": translator.t("STORAGE_CONNECTION_FAILED", "اتصال به storage ناموفق بود"),
+ "test_result": test_result
+ }
+
+ return success_response(data, request)
+ except ApiError:
+ raise
+ except Exception as e:
+ raise ApiError(
+ code="TEST_STORAGE_CONFIG_ERROR",
+ message=translator.t("TEST_STORAGE_CONFIG_ERROR", f"خطا در تست اتصال: {str(e)}"),
+ http_status=500,
+ translator=translator
+ )
+
+
+# Helper function for testing storage connections
+async def _test_storage_connection(config: StorageConfig) -> dict:
+ """تست اتصال به storage بر اساس نوع آن"""
+ import os
+ import tempfile
+ from datetime import datetime
+
+ try:
+ if config.storage_type == "local":
+ return await _test_local_storage(config)
+ elif config.storage_type == "ftp":
+ return await _test_ftp_storage(config)
+ else:
+ return {
+ "success": False,
+ "error": f"نوع storage پشتیبانی نشده: {config.storage_type}",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "error": str(e),
+ "tested_at": datetime.utcnow().isoformat()
+ }
+
+
+async def _test_local_storage(config: StorageConfig) -> dict:
+ """تست اتصال به local storage"""
+ import os
+ from datetime import datetime
+
+ try:
+ base_path = config.config_data.get("base_path", "/tmp/hesabix_files")
+
+ # بررسی وجود مسیر
+ if not os.path.exists(base_path):
+ # تلاش برای ایجاد مسیر
+ os.makedirs(base_path, exist_ok=True)
+
+ # بررسی دسترسی نوشتن
+ test_file_path = os.path.join(base_path, f"test_connection_{datetime.utcnow().timestamp()}.txt")
+
+ # نوشتن فایل تست
+ with open(test_file_path, "w") as f:
+ f.write("Test connection file")
+
+ # خواندن فایل تست
+ with open(test_file_path, "r") as f:
+ content = f.read()
+
+ # حذف فایل تست
+ os.remove(test_file_path)
+
+ if content == "Test connection file":
+ return {
+ "success": True,
+ "message": "اتصال به local storage موفقیتآمیز بود",
+ "storage_type": "local",
+ "base_path": base_path,
+ "tested_at": datetime.utcnow().isoformat()
+ }
+ else:
+ return {
+ "success": False,
+ "error": "خطا در خواندن فایل تست",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+
+ except PermissionError:
+ return {
+ "success": False,
+ "error": "دسترسی به مسیر ذخیرهسازی وجود ندارد",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "error": f"خطا در تست local storage: {str(e)}",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+
+
+async def _test_ftp_storage(config: StorageConfig) -> dict:
+ """تست اتصال به FTP storage"""
+ import ftplib
+ import tempfile
+ import os
+ from datetime import datetime
+
+ try:
+ # دریافت تنظیمات FTP
+ config_data = config.config_data
+ host = config_data.get("host")
+ port = int(config_data.get("port", 21))
+ username = config_data.get("username")
+ password = config_data.get("password")
+ directory = config_data.get("directory", "/")
+ use_tls = config_data.get("use_tls", False)
+
+ # بررسی وجود پارامترهای ضروری
+ if not all([host, username, password]):
+ return {
+ "success": False,
+ "error": "پارامترهای ضروری FTP (host, username, password) موجود نیست",
+ "storage_type": "ftp",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+
+ # اتصال به FTP
+ if use_tls:
+ ftp = ftplib.FTP_TLS()
+ else:
+ ftp = ftplib.FTP()
+
+ # تنظیم timeout
+ ftp.connect(host, port, timeout=10)
+ ftp.login(username, password)
+
+ # تغییر به دایرکتوری مورد نظر
+ if directory and directory != "/":
+ try:
+ ftp.cwd(directory)
+ except ftplib.error_perm:
+ return {
+ "success": False,
+ "error": f"دسترسی به دایرکتوری {directory} وجود ندارد",
+ "storage_type": "ftp",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+
+ # تست نوشتن فایل
+ test_filename = f"test_connection_{datetime.utcnow().timestamp()}.txt"
+ test_content = "Test FTP connection file"
+
+ # ایجاد فایل موقت
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as temp_file:
+ temp_file.write(test_content)
+ temp_file_path = temp_file.name
+
+ try:
+ # آپلود فایل
+ with open(temp_file_path, 'rb') as file:
+ ftp.storbinary(f'STOR {test_filename}', file)
+
+ # بررسی وجود فایل
+ file_list = []
+ ftp.retrlines('LIST', file_list.append)
+ file_exists = any(test_filename in line for line in file_list)
+
+ if not file_exists:
+ return {
+ "success": False,
+ "error": "فایل تست آپلود نشد",
+ "storage_type": "ftp",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+
+ # حذف فایل تست
+ try:
+ ftp.delete(test_filename)
+ except ftplib.error_perm:
+ pass # اگر نتوانست حذف کند، مهم نیست
+
+ # بستن اتصال
+ ftp.quit()
+
+ return {
+ "success": True,
+ "message": "اتصال به FTP server موفقیتآمیز بود",
+ "storage_type": "ftp",
+ "host": host,
+ "port": port,
+ "directory": directory,
+ "use_tls": use_tls,
+ "tested_at": datetime.utcnow().isoformat()
+ }
+
+ finally:
+ # حذف فایل موقت
+ try:
+ os.unlink(temp_file_path)
+ except:
+ pass
+
+ except ftplib.error_perm as e:
+ return {
+ "success": False,
+ "error": f"خطا در احراز هویت FTP: {str(e)}",
+ "storage_type": "ftp",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+ except ftplib.error_temp as e:
+ return {
+ "success": False,
+ "error": f"خطای موقت FTP: {str(e)}",
+ "storage_type": "ftp",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+ except ConnectionRefusedError:
+ return {
+ "success": False,
+ "error": "اتصال به سرور FTP رد شد. بررسی کنید که سرور در حال اجرا باشد",
+ "storage_type": "ftp",
+ "tested_at": datetime.utcnow().isoformat()
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "error": f"خطا در تست FTP storage: {str(e)}",
+ "storage_type": "ftp",
+ "tested_at": datetime.utcnow().isoformat()
+ }
diff --git a/hesabixAPI/build/lib/adapters/api/v1/auth.py b/hesabixAPI/build/lib/adapters/api/v1/auth.py
new file mode 100644
index 0000000..2854aca
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/auth.py
@@ -0,0 +1,936 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+import datetime
+from fastapi import APIRouter, Depends, Request, Query
+from fastapi.responses import Response
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.responses import success_response, format_datetime_fields
+from app.services.captcha_service import create_captcha
+from app.services.auth_service import register_user, login_user, create_password_reset, reset_password, change_password, referral_stats
+from app.services.pdf import PDFService
+from .schemas import (
+ RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest,
+ ChangePasswordRequest, CreateApiKeyRequest, QueryInfo, FilterItem,
+ SuccessResponse, CaptchaResponse, LoginResponse, ApiKeyResponse,
+ ReferralStatsResponse, UserResponse
+)
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.services.api_key_service import list_personal_keys, create_personal_key, revoke_key
+
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+
+@router.post("/captcha",
+ summary="تولید کپچای عددی",
+ description="تولید کپچای عددی برای تأیید هویت در عملیات حساس",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "کپچا با موفقیت تولید شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "کپچا تولید شد",
+ "data": {
+ "captcha_id": "abc123def456",
+ "image_base64": "iVBORw0KGgoAAAANSUhEUgAA...",
+ "ttl_seconds": 180
+ }
+ }
+ }
+ }
+ }
+ }
+)
+def generate_captcha(db: Session = Depends(get_db)) -> dict:
+ captcha_id, image_base64, ttl = create_captcha(db)
+ return success_response({
+ "captcha_id": captcha_id,
+ "image_base64": image_base64,
+ "ttl_seconds": ttl,
+ })
+
+
+@router.get("/me",
+ summary="دریافت اطلاعات کاربر کنونی",
+ description="دریافت اطلاعات کامل کاربری که در حال حاضر وارد سیستم شده است",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "اطلاعات کاربر با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "اطلاعات کاربر دریافت شد",
+ "data": {
+ "id": 1,
+ "email": "user@example.com",
+ "mobile": "09123456789",
+ "first_name": "احمد",
+ "last_name": "احمدی",
+ "is_active": True,
+ "referral_code": "ABC123",
+ "referred_by_user_id": None,
+ "app_permissions": {"admin": True},
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "احراز هویت مورد نیاز است",
+ "error_code": "UNAUTHORIZED"
+ }
+ }
+ }
+ }
+ }
+)
+def get_current_user_info(
+ request: Request,
+ ctx: AuthContext = Depends(get_current_user)
+) -> dict:
+ """دریافت اطلاعات کاربر کنونی"""
+ return success_response(ctx.to_dict(), request)
+
+
+@router.post("/register",
+ summary="ثبتنام کاربر جدید",
+ description="ثبتنام کاربر جدید در سیستم با تأیید کپچا",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "کاربر با موفقیت ثبتنام شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "ثبتنام با موفقیت انجام شد",
+ "data": {
+ "api_key": "sk_1234567890abcdef",
+ "expires_at": None,
+ "user": {
+ "id": 1,
+ "first_name": "احمد",
+ "last_name": "احمدی",
+ "email": "ahmad@example.com",
+ "mobile": "09123456789",
+ "referral_code": "ABC123",
+ "app_permissions": None
+ }
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "کپچا نامعتبر است",
+ "error_code": "INVALID_CAPTCHA"
+ }
+ }
+ }
+ },
+ 409: {
+ "description": "کاربر با این ایمیل یا موبایل قبلاً ثبتنام کرده است",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "کاربر با این ایمیل قبلاً ثبتنام کرده است",
+ "error_code": "USER_EXISTS"
+ }
+ }
+ }
+ }
+ }
+)
+def register(request: Request, payload: RegisterRequest, db: Session = Depends(get_db)) -> dict:
+ user_id = register_user(
+ db=db,
+ first_name=payload.first_name,
+ last_name=payload.last_name,
+ email=payload.email,
+ mobile=payload.mobile,
+ password=payload.password,
+ captcha_id=payload.captcha_id,
+ captcha_code=payload.captcha_code,
+ referrer_code=payload.referrer_code,
+ )
+ # Create a session api key similar to login
+ user_agent = request.headers.get("User-Agent")
+ ip = request.client.host if request.client else None
+ from app.core.security import generate_api_key
+ from adapters.db.repositories.api_key_repo import ApiKeyRepository
+ api_key, key_hash = generate_api_key()
+ api_repo = ApiKeyRepository(db)
+ api_repo.create_session_key(user_id=user_id, key_hash=key_hash, device_id=payload.device_id, user_agent=user_agent, ip=ip, expires_at=None)
+ from adapters.db.models.user import User
+ user_obj = db.get(User, user_id)
+ user = {"id": user_id, "first_name": payload.first_name, "last_name": payload.last_name, "email": payload.email, "mobile": payload.mobile, "referral_code": getattr(user_obj, "referral_code", None), "app_permissions": getattr(user_obj, "app_permissions", None)}
+ response_data = {"api_key": api_key, "expires_at": None, "user": user}
+ formatted_data = format_datetime_fields(response_data, request)
+ return success_response(formatted_data, request)
+
+
+@router.post("/login",
+ summary="ورود با ایمیل یا موبایل",
+ description="ورود کاربر به سیستم با استفاده از ایمیل یا شماره موبایل و رمز عبور",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "ورود با موفقیت انجام شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "ورود با موفقیت انجام شد",
+ "data": {
+ "api_key": "sk_1234567890abcdef",
+ "expires_at": "2024-01-02T00:00:00Z",
+ "user": {
+ "id": 1,
+ "first_name": "احمد",
+ "last_name": "احمدی",
+ "email": "ahmad@example.com",
+ "mobile": "09123456789",
+ "referral_code": "ABC123",
+ "app_permissions": {"admin": True}
+ }
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "کپچا نامعتبر است",
+ "error_code": "INVALID_CAPTCHA"
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "اطلاعات ورود نامعتبر است",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "ایمیل یا رمز عبور اشتباه است",
+ "error_code": "INVALID_CREDENTIALS"
+ }
+ }
+ }
+ }
+ }
+)
+def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
+ user_agent = request.headers.get("User-Agent")
+ ip = request.client.host if request.client else None
+ api_key, expires_at, user = login_user(
+ db=db,
+ identifier=payload.identifier,
+ password=payload.password,
+ captcha_id=payload.captcha_id,
+ captcha_code=payload.captcha_code,
+ device_id=payload.device_id,
+ user_agent=user_agent,
+ ip=ip,
+ )
+ # Ensure referral_code is included
+ from adapters.db.repositories.user_repo import UserRepository
+ repo = UserRepository(db)
+ from adapters.db.models.user import User
+ user_obj = None
+ if 'id' in user and user['id']:
+ user_obj = repo.db.get(User, user['id'])
+ if user_obj is not None:
+ user["referral_code"] = getattr(user_obj, "referral_code", None)
+ response_data = {"api_key": api_key, "expires_at": expires_at, "user": user}
+ formatted_data = format_datetime_fields(response_data, request)
+ return success_response(formatted_data, request)
+
+
+@router.post("/forgot-password",
+ summary="ایجاد توکن بازنشانی رمز عبور",
+ description="ایجاد توکن برای بازنشانی رمز عبور کاربر",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "توکن بازنشانی با موفقیت ایجاد شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "توکن بازنشانی ارسال شد",
+ "data": {
+ "ok": True,
+ "token": "reset_token_1234567890abcdef"
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "کپچا نامعتبر است",
+ "error_code": "INVALID_CAPTCHA"
+ }
+ }
+ }
+ },
+ 404: {
+ "description": "کاربر یافت نشد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "کاربر با این ایمیل یا موبایل یافت نشد",
+ "error_code": "USER_NOT_FOUND"
+ }
+ }
+ }
+ }
+ }
+)
+def forgot_password(payload: ForgotPasswordRequest, db: Session = Depends(get_db)) -> dict:
+ # In production do not return token; send via email/SMS. Here we return for dev/testing.
+ token = create_password_reset(db=db, identifier=payload.identifier, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code)
+ return success_response({"ok": True, "token": token if token else None})
+
+
+@router.post("/reset-password",
+ summary="بازنشانی رمز عبور با توکن",
+ description="بازنشانی رمز عبور کاربر با استفاده از توکن دریافتی",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "رمز عبور با موفقیت بازنشانی شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "رمز عبور با موفقیت تغییر کرد",
+ "data": {
+ "ok": True
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "کپچا نامعتبر است",
+ "error_code": "INVALID_CAPTCHA"
+ }
+ }
+ }
+ },
+ 404: {
+ "description": "توکن نامعتبر یا منقضی شده است",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "توکن نامعتبر یا منقضی شده است",
+ "error_code": "INVALID_TOKEN"
+ }
+ }
+ }
+ }
+ }
+)
+def reset_password_endpoint(payload: ResetPasswordRequest, db: Session = Depends(get_db)) -> dict:
+ reset_password(db=db, token=payload.token, new_password=payload.new_password, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code)
+ return success_response({"ok": True})
+
+
+@router.get("/api-keys",
+ summary="لیست کلیدهای API شخصی",
+ description="دریافت لیست کلیدهای API شخصی کاربر",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "لیست کلیدهای API با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "لیست کلیدهای API دریافت شد",
+ "data": [
+ {
+ "id": 1,
+ "name": "کلید اصلی",
+ "scopes": "read,write",
+ "device_id": "device123",
+ "user_agent": "Mozilla/5.0...",
+ "ip": "192.168.1.1",
+ "expires_at": None,
+ "last_used_at": "2024-01-01T12:00:00Z",
+ "created_at": "2024-01-01T00:00:00Z"
+ }
+ ]
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ }
+ }
+)
+def list_keys(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
+ items = list_personal_keys(db, ctx.user.id)
+ return success_response(items)
+
+
+@router.post("/api-keys",
+ summary="ایجاد کلید API شخصی",
+ description="ایجاد کلید API جدید برای کاربر",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "کلید API با موفقیت ایجاد شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "کلید API ایجاد شد",
+ "data": {
+ "id": 1,
+ "api_key": "sk_1234567890abcdef"
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ }
+ }
+)
+def create_key(request: Request, payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
+ id_, api_key = create_personal_key(db, ctx.user.id, payload.name, payload.scopes, None)
+ return success_response({"id": id_, "api_key": api_key})
+
+
+@router.post("/change-password",
+ summary="تغییر رمز عبور",
+ description="تغییر رمز عبور کاربر با تأیید رمز عبور فعلی",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "رمز عبور با موفقیت تغییر کرد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "رمز عبور با موفقیت تغییر کرد",
+ "data": {
+ "ok": True
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "رمز عبور فعلی اشتباه است",
+ "error_code": "INVALID_CURRENT_PASSWORD"
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ }
+ }
+)
+def change_password_endpoint(request: Request, payload: ChangePasswordRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
+ # دریافت translator از request state
+ translator = getattr(request.state, "translator", None)
+
+ change_password(
+ db=db,
+ user_id=ctx.user.id,
+ current_password=payload.current_password,
+ new_password=payload.new_password,
+ confirm_password=payload.confirm_password,
+ translator=translator
+ )
+ return success_response({"ok": True})
+
+
+@router.delete("/api-keys/{key_id}",
+ summary="حذف کلید API",
+ description="حذف کلید API مشخص شده",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "کلید API با موفقیت حذف شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "کلید API حذف شد",
+ "data": {
+ "ok": True
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 404: {
+ "description": "کلید API یافت نشد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "کلید API یافت نشد",
+ "error_code": "API_KEY_NOT_FOUND"
+ }
+ }
+ }
+ }
+ }
+)
+def delete_key(request: Request, key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
+ revoke_key(db, ctx.user.id, key_id)
+ return success_response({"ok": True})
+
+
+@router.get("/referrals/stats",
+ summary="آمار معرفیها",
+ description="دریافت آمار معرفیهای کاربر فعلی",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "آمار معرفیها با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "آمار معرفیها دریافت شد",
+ "data": {
+ "total_referrals": 25,
+ "active_referrals": 20,
+ "recent_referrals": 5,
+ "referral_rate": 0.8
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ }
+ }
+)
+def get_referral_stats(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), start: str = Query(None, description="تاریخ شروع (ISO format)"), end: str = Query(None, description="تاریخ پایان (ISO format)")):
+ from datetime import datetime
+ start_dt = datetime.fromisoformat(start) if start else None
+ end_dt = datetime.fromisoformat(end) if end else None
+ stats = referral_stats(db=db, user_id=ctx.user.id, start=start_dt, end=end_dt)
+ return success_response(stats)
+
+
+@router.post("/referrals/list",
+ summary="لیست معرفیها با فیلتر پیشرفته",
+ description="دریافت لیست معرفیها با قابلیت فیلتر، جستجو، مرتبسازی و صفحهبندی",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "لیست معرفیها با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "لیست معرفیها دریافت شد",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "first_name": "علی",
+ "last_name": "احمدی",
+ "email": "ali@example.com",
+ "mobile": "09123456789",
+ "created_at": "2024-01-01T00:00:00Z"
+ }
+ ],
+ "total": 1,
+ "page": 1,
+ "limit": 10,
+ "total_pages": 1,
+ "has_next": False,
+ "has_prev": False
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ }
+ }
+)
+def get_referral_list_advanced(
+ request: Request,
+ query_info: QueryInfo,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """
+ دریافت لیست معرفیها با قابلیت فیلتر پیشرفته
+
+ پارامترهای QueryInfo:
+ - sort_by: فیلد مرتبسازی (مثال: created_at, first_name, last_name, email)
+ - sort_desc: ترتیب نزولی (true/false)
+ - take: تعداد رکورد در هر صفحه (پیشفرض: 10)
+ - skip: تعداد رکورد صرفنظر شده (پیشفرض: 0)
+ - search: عبارت جستجو
+ - search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"])
+ - filters: آرایه فیلترها با ساختار:
+ [
+ {
+ "property": "created_at",
+ "operator": ">=",
+ "value": "2024-01-01T00:00:00"
+ },
+ {
+ "property": "first_name",
+ "operator": "*",
+ "value": "احمد"
+ }
+ ]
+ """
+ from adapters.db.repositories.user_repo import UserRepository
+ from adapters.db.models.user import User
+ from datetime import datetime
+
+ # Create a custom query for referrals
+ repo = UserRepository(db)
+
+ # Add filter for referrals only (users with referred_by_user_id = current user)
+ referral_filter = FilterItem(
+ property="referred_by_user_id",
+ operator="=",
+ value=ctx.user.id
+ )
+
+ # Add referral filter to existing filters
+ if query_info.filters is None:
+ query_info.filters = [referral_filter]
+ else:
+ query_info.filters.append(referral_filter)
+
+ # Set default search fields for referrals
+ if query_info.search_fields is None:
+ query_info.search_fields = ["first_name", "last_name", "email"]
+
+ # Execute query with filters
+ referrals, total = repo.query_with_filters(query_info)
+
+ # Convert to dictionary format
+ referral_dicts = [repo.to_dict(referral) for referral in referrals]
+
+ # Format datetime fields
+ formatted_referrals = format_datetime_fields(referral_dicts, request)
+
+ # Calculate pagination info
+ page = (query_info.skip // query_info.take) + 1
+ total_pages = (total + query_info.take - 1) // query_info.take
+
+ return success_response({
+ "items": formatted_referrals,
+ "total": total,
+ "page": page,
+ "limit": query_info.take,
+ "total_pages": total_pages,
+ "has_next": page < total_pages,
+ "has_prev": page > 1
+ }, request)
+
+
+@router.post("/referrals/export/pdf",
+ summary="خروجی PDF لیست معرفیها",
+ description="خروجی PDF لیست معرفیها با قابلیت فیلتر و انتخاب سطرهای خاص",
+ responses={
+ 200: {
+ "description": "فایل PDF با موفقیت تولید شد",
+ "content": {
+ "application/pdf": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ }
+ }
+)
+def export_referrals_pdf(
+ request: Request,
+ query_info: QueryInfo,
+ selected_only: bool = False,
+ selected_indices: str | None = None,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> Response:
+ """
+ خروجی PDF لیست معرفیها
+
+ پارامترها:
+ - selected_only: آیا فقط سطرهای انتخاب شده export شوند
+ - selected_indices: لیست ایندکسهای انتخاب شده (JSON string)
+ - سایر پارامترهای QueryInfo برای فیلتر
+ """
+ from app.services.pdf import PDFService
+ from app.services.auth_service import referral_stats
+ import json
+
+ # Parse selected indices if provided
+ indices = None
+ if selected_only and selected_indices:
+ try:
+ indices = json.loads(selected_indices)
+ except (json.JSONDecodeError, TypeError):
+ indices = None
+
+ # Get stats for the report
+ stats = None
+ try:
+ # Extract date range from filters if available
+ start_date = None
+ end_date = None
+ if query_info.filters:
+ for filter_item in query_info.filters:
+ if filter_item.property == 'created_at':
+ if filter_item.operator == '>=':
+ start_date = filter_item.value
+ elif filter_item.operator == '<':
+ end_date = filter_item.value
+
+ stats = referral_stats(
+ db=db,
+ user_id=ctx.user.id,
+ start=start_date,
+ end=end_date
+ )
+ except Exception:
+ pass # Continue without stats
+
+ # Get calendar type from request headers
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali")
+ calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian"
+
+ # Generate PDF using new modular service
+ pdf_service = PDFService()
+
+ # Get locale from request headers
+ locale_header = request.headers.get("Accept-Language", "fa")
+ locale = "fa" if locale_header.startswith("fa") else "en"
+
+ pdf_bytes = pdf_service.generate_pdf(
+ module_name='marketing',
+ data={}, # Empty data - module will fetch its own data
+ calendar_type=calendar_type,
+ locale=locale,
+ db=db,
+ user_id=ctx.user.id,
+ query_info=query_info,
+ selected_indices=indices,
+ stats=stats
+ )
+
+ # Return PDF response
+ from fastapi.responses import Response
+ import datetime
+
+ filename = f"referrals_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
+
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Content-Length": str(len(pdf_bytes))
+ }
+ )
+
+
+@router.post("/referrals/export/excel",
+ summary="خروجی Excel لیست معرفیها",
+ description="خروجی Excel لیست معرفیها با قابلیت فیلتر و انتخاب سطرهای خاص",
+ responses={
+ 200: {
+ "description": "فایل Excel با موفقیت تولید شد",
+ "content": {
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ }
+ }
+)
+def export_referrals_excel(
+ request: Request,
+ query_info: QueryInfo,
+ selected_only: bool = False,
+ selected_indices: str | None = None,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> Response:
+ """
+ خروجی Excel لیست معرفیها (فایل Excel واقعی برای دانلود)
+
+ پارامترها:
+ - selected_only: آیا فقط سطرهای انتخاب شده export شوند
+ - selected_indices: لیست ایندکسهای انتخاب شده (JSON string)
+ - سایر پارامترهای QueryInfo برای فیلتر
+ """
+ from app.services.pdf import PDFService
+ import json
+ import io
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+
+ # Parse selected indices if provided
+ indices = None
+ if selected_only and selected_indices:
+ try:
+ indices = json.loads(selected_indices)
+ except (json.JSONDecodeError, TypeError):
+ indices = None
+
+ # Get calendar type from request headers
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali")
+ calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian"
+
+ # Generate Excel data using new modular service
+ pdf_service = PDFService()
+
+ # Get locale from request headers
+ locale_header = request.headers.get("Accept-Language", "fa")
+ locale = "fa" if locale_header.startswith("fa") else "en"
+
+ excel_data = pdf_service.generate_excel_data(
+ module_name='marketing',
+ data={}, # Empty data - module will fetch its own data
+ calendar_type=calendar_type,
+ locale=locale,
+ db=db,
+ user_id=ctx.user.id,
+ query_info=query_info,
+ selected_indices=indices
+ )
+
+ # Create Excel workbook
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Referrals"
+
+ # Define styles
+ header_font = Font(bold=True, color="FFFFFF")
+ header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
+ header_alignment = Alignment(horizontal="center", vertical="center")
+ border = Border(
+ left=Side(style='thin'),
+ right=Side(style='thin'),
+ top=Side(style='thin'),
+ bottom=Side(style='thin')
+ )
+
+ # Add headers
+ if excel_data:
+ headers = list(excel_data[0].keys())
+ for col, header in enumerate(headers, 1):
+ cell = ws.cell(row=1, column=col, value=header)
+ cell.font = header_font
+ cell.fill = header_fill
+ cell.alignment = header_alignment
+ cell.border = border
+
+ # Add data rows
+ for row, data in enumerate(excel_data, 2):
+ for col, header in enumerate(headers, 1):
+ cell = ws.cell(row=row, column=col, value=data.get(header, ""))
+ cell.border = border
+ # Center align for numbers and dates
+ if header in ["ردیف", "Row", "تاریخ ثبت", "Registration Date"]:
+ cell.alignment = Alignment(horizontal="center")
+
+ # Auto-adjust column widths
+ for column in ws.columns:
+ max_length = 0
+ column_letter = column[0].column_letter
+ for cell in column:
+ try:
+ if len(str(cell.value)) > max_length:
+ max_length = len(str(cell.value))
+ except:
+ pass
+ adjusted_width = min(max_length + 2, 50)
+ ws.column_dimensions[column_letter].width = adjusted_width
+
+ # Save to BytesIO
+ excel_buffer = io.BytesIO()
+ wb.save(excel_buffer)
+ excel_buffer.seek(0)
+
+ # Generate filename
+ filename = f"referrals_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+
+ # Return Excel file as response
+ return Response(
+ content=excel_buffer.getvalue(),
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ }
+ )
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py b/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py
new file mode 100644
index 0000000..da722d2
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py
@@ -0,0 +1,293 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from fastapi import APIRouter, Depends, Request, HTTPException
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.api.v1.schemas import SuccessResponse
+from app.core.responses import success_response, format_datetime_fields
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from app.services.business_dashboard_service import (
+ get_business_dashboard_data, get_business_members, get_business_statistics
+)
+
+router = APIRouter(prefix="/business", tags=["business-dashboard"])
+
+
+@router.post("/{business_id}/dashboard",
+ summary="دریافت داشبورد کسب و کار",
+ description="دریافت اطلاعات کلی و آمار کسب و کار",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "داشبورد کسب و کار با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "داشبورد کسب و کار دریافت شد",
+ "data": {
+ "business_info": {
+ "id": 1,
+ "name": "شرکت نمونه",
+ "business_type": "شرکت",
+ "business_field": "تولیدی",
+ "owner_id": 1,
+ "created_at": "1403/01/01 00:00:00",
+ "member_count": 5
+ },
+ "statistics": {
+ "total_sales": 1000000.0,
+ "total_purchases": 500000.0,
+ "active_members": 5,
+ "recent_transactions": 25
+ },
+ "recent_activities": [
+ {
+ "id": 1,
+ "title": "فروش جدید",
+ "description": "فروش محصول A به مبلغ 100,000 تومان",
+ "icon": "sell",
+ "time_ago": "2 ساعت پیش"
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسب و کار"
+ },
+ 404: {
+ "description": "کسب و کار یافت نشد"
+ }
+ }
+)
+@require_business_access("business_id")
+def get_business_dashboard(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """دریافت داشبورد کسب و کار"""
+ dashboard_data = get_business_dashboard_data(db, business_id, ctx)
+ formatted_data = format_datetime_fields(dashboard_data, request)
+ return success_response(formatted_data, request)
+
+
+@router.post("/{business_id}/members",
+ summary="لیست اعضای کسب و کار",
+ description="دریافت لیست اعضای کسب و کار",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "لیست اعضا با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "لیست اعضا دریافت شد",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "user_id": 2,
+ "first_name": "احمد",
+ "last_name": "احمدی",
+ "email": "ahmad@example.com",
+ "role": "مدیر فروش",
+ "permissions": {
+ "sales": {"write": True, "delete": True},
+ "reports": {"export": True}
+ },
+ "joined_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": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسب و کار"
+ }
+ }
+)
+@require_business_access("business_id")
+def get_business_members(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """لیست اعضای کسب و کار"""
+ members_data = get_business_members(db, business_id, ctx)
+ formatted_data = format_datetime_fields(members_data, request)
+ return success_response(formatted_data, request)
+
+
+@router.post("/{business_id}/statistics",
+ summary="آمار کسب و کار",
+ description="دریافت آمار تفصیلی کسب و کار",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "آمار با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "آمار دریافت شد",
+ "data": {
+ "sales_by_month": [
+ {"month": "1403/01", "amount": 500000},
+ {"month": "1403/02", "amount": 750000}
+ ],
+ "top_products": [
+ {"name": "محصول A", "sales_count": 100, "revenue": 500000}
+ ],
+ "member_activity": {
+ "active_today": 3,
+ "active_this_week": 5,
+ "total_members": 8
+ }
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسب و کار"
+ }
+ }
+)
+@require_business_access("business_id")
+def get_business_statistics(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """آمار کسب و کار"""
+ stats_data = get_business_statistics(db, business_id, ctx)
+ formatted_data = format_datetime_fields(stats_data, request)
+ return success_response(formatted_data, request)
+
+
+@router.post("/{business_id}/info-with-permissions",
+ summary="دریافت اطلاعات کسب و کار و دسترسیها",
+ description="دریافت اطلاعات کسب و کار همراه با دسترسیهای کاربر",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "اطلاعات کسب و کار و دسترسیها با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "اطلاعات کسب و کار و دسترسیها دریافت شد",
+ "data": {
+ "business_info": {
+ "id": 1,
+ "name": "شرکت نمونه",
+ "business_type": "شرکت",
+ "business_field": "تولیدی",
+ "owner_id": 1,
+ "address": "تهران، خیابان ولیعصر",
+ "phone": "02112345678",
+ "mobile": "09123456789",
+ "created_at": "1403/01/01 00:00:00"
+ },
+ "user_permissions": {
+ "people": {"add": True, "view": True, "edit": True, "delete": False},
+ "products": {"add": True, "view": True, "edit": False, "delete": False},
+ "invoices": {"add": True, "view": True, "edit": True, "delete": True}
+ },
+ "is_owner": False,
+ "role": "عضو",
+ "has_access": True
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسب و کار"
+ },
+ 404: {
+ "description": "کسب و کار یافت نشد"
+ }
+ }
+)
+@require_business_access("business_id")
+def get_business_info_with_permissions(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """دریافت اطلاعات کسب و کار همراه با دسترسیهای کاربر"""
+ from adapters.db.models.business import Business
+ from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
+
+ # دریافت اطلاعات کسب و کار
+ business = db.get(Business, business_id)
+ if not business:
+ from app.core.responses import ApiError
+ raise ApiError("NOT_FOUND", "Business not found", http_status=404)
+
+ # دریافت دسترسیهای کاربر
+ permissions = {}
+ if not ctx.is_superadmin() and not ctx.is_business_owner(business_id):
+ # دریافت دسترسیهای کسب و کار از business_permissions
+ permission_repo = BusinessPermissionRepository(db)
+ # ترتیب آرگومانها: (user_id, business_id)
+ business_permission = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id)
+ if business_permission:
+ permissions = business_permission.business_permissions or {}
+
+ business_info = {
+ "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,
+ "created_at": business.created_at.isoformat(),
+ }
+
+ response_data = {
+ "business_info": business_info,
+ "user_permissions": permissions,
+ "is_owner": ctx.is_business_owner(business_id),
+ "role": "مالک" if ctx.is_business_owner(business_id) else "عضو",
+ "has_access": ctx.can_access_business(business_id)
+ }
+
+ formatted_data = format_datetime_fields(response_data, request)
+ return success_response(formatted_data, request)
diff --git a/hesabixAPI/build/lib/adapters/api/v1/business_users.py b/hesabixAPI/build/lib/adapters/api/v1/business_users.py
new file mode 100644
index 0000000..dfc9482
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/business_users.py
@@ -0,0 +1,564 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from fastapi import APIRouter, Depends, Request, HTTPException
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.api.v1.schemas import (
+ BusinessUsersListResponse, AddUserRequest, AddUserResponse,
+ UpdatePermissionsRequest, UpdatePermissionsResponse, RemoveUserResponse
+)
+from app.core.responses import success_response, format_datetime_fields
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
+from adapters.db.models.user import User
+from adapters.db.models.business import Business
+
+router = APIRouter(prefix="/business", tags=["business-users"])
+
+
+@router.get("/{business_id}/users/{user_id}",
+ summary="دریافت جزئیات کاربر",
+ description="دریافت جزئیات کاربر و دسترسیهایش در کسب و کار",
+ responses={
+ 200: {
+ "description": "جزئیات کاربر با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "جزئیات کاربر دریافت شد",
+ "user": {
+ "id": 1,
+ "business_id": 1,
+ "user_id": 2,
+ "user_name": "علی احمدی",
+ "user_email": "ali@example.com",
+ "user_phone": "09123456789",
+ "role": "member",
+ "status": "active",
+ "added_at": "2024-01-01T00:00:00Z",
+ "last_active": "2024-01-01T12:00:00Z",
+ "permissions": {
+ "people": {
+ "add": True,
+ "view": True,
+ "edit": False,
+ "delete": False
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسب و کار"
+ },
+ 404: {
+ "description": "کاربر یافت نشد"
+ }
+ }
+)
+@require_business_access("business_id")
+def get_user_details(
+ request: Request,
+ business_id: int,
+ user_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """دریافت جزئیات کاربر و دسترسیهایش"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ current_user_id = ctx.get_user_id()
+ logger.info(f"Getting user details for user {user_id} in business {business_id}, current user: {current_user_id}")
+
+ # Check if user is business owner or has permission to manage users
+ business = db.get(Business, business_id)
+ if not business:
+ logger.error(f"Business {business_id} not found")
+ raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
+
+ is_owner = business.owner_id == current_user_id
+ can_manage = ctx.can_manage_business_users()
+
+ logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}")
+
+ if not is_owner and not can_manage:
+ logger.warning(f"User {current_user_id} does not have permission to view user details for business {business_id}")
+ raise HTTPException(status_code=403, detail="شما مجوز مشاهده جزئیات کاربران ندارید")
+
+ # Get user details
+ user = db.get(User, user_id)
+ if not user:
+ logger.warning(f"User {user_id} not found")
+ raise HTTPException(status_code=404, detail="کاربر یافت نشد")
+
+ # Get user permissions for this business
+ permission_repo = BusinessPermissionRepository(db)
+ permission_obj = permission_repo.get_by_user_and_business(user_id, business_id)
+
+ # Determine role and permissions
+ if business.owner_id == user_id:
+ role = "owner"
+ permissions = {} # Owner has all permissions
+ else:
+ role = "member"
+ permissions = permission_obj.business_permissions if permission_obj else {}
+
+ # Format user data
+ user_data = {
+ "id": permission_obj.id if permission_obj else user_id,
+ "business_id": business_id,
+ "user_id": user_id,
+ "user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(),
+ "user_email": user.email or "",
+ "user_phone": user.mobile,
+ "role": role,
+ "status": "active",
+ "added_at": permission_obj.created_at if permission_obj else business.created_at,
+ "last_active": permission_obj.updated_at if permission_obj else business.updated_at,
+ "permissions": permissions,
+ }
+
+ logger.info(f"Returning user data: {user_data}")
+
+ # Format datetime fields based on calendar type
+ formatted_user_data = format_datetime_fields(user_data, request)
+
+ return success_response(
+ data={"user": formatted_user_data},
+ request=request,
+ message="جزئیات کاربر دریافت شد"
+ )
+
+
+@router.get("/{business_id}/users",
+ summary="لیست کاربران کسب و کار",
+ description="دریافت لیست کاربران یک کسب و کار",
+ response_model=BusinessUsersListResponse,
+ responses={
+ 200: {
+ "description": "لیست کاربران با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "لیست کاربران دریافت شد",
+ "users": [
+ {
+ "id": 1,
+ "business_id": 1,
+ "user_id": 2,
+ "user_name": "علی احمدی",
+ "user_email": "ali@example.com",
+ "user_phone": "09123456789",
+ "role": "member",
+ "status": "active",
+ "added_at": "2024-01-01T00:00:00Z",
+ "last_active": "2024-01-01T12:00:00Z",
+ "permissions": {
+ "sales": {
+ "read": True,
+ "write": True,
+ "delete": False
+ },
+ "reports": {
+ "read": True,
+ "export": True
+ }
+ }
+ }
+ ],
+ "total_count": 1
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسب و کار"
+ }
+ }
+)
+@require_business_access("business_id")
+def get_users(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """دریافت لیست کاربران کسب و کار"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ current_user_id = ctx.get_user_id()
+ logger.info(f"Getting users for business {business_id}, current user: {current_user_id}")
+
+ # Check if user is business owner or has permission to manage users
+ business = db.get(Business, business_id)
+ if not business:
+ logger.error(f"Business {business_id} not found")
+ raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
+
+ is_owner = business.owner_id == current_user_id
+ can_manage = ctx.can_manage_business_users()
+
+ logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}")
+
+ if not is_owner and not can_manage:
+ logger.warning(f"User {current_user_id} does not have permission to manage users for business {business_id}")
+ raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
+
+ # Get business permissions for this business
+ permission_repo = BusinessPermissionRepository(db)
+ business_permissions = permission_repo.get_business_users(business_id)
+ logger.info(f"Found {len(business_permissions)} business permissions for business {business_id}")
+
+ # Format users data
+ formatted_users = []
+
+ # Add business owner first
+ owner = db.get(User, business.owner_id)
+ if owner:
+ logger.info(f"Adding business owner: {owner.id} - {owner.email}")
+ owner_data = {
+ "id": business.owner_id, # Use owner_id as id
+ "business_id": business_id,
+ "user_id": business.owner_id,
+ "user_name": f"{owner.first_name or ''} {owner.last_name or ''}".strip(),
+ "user_email": owner.email or "",
+ "user_phone": owner.mobile,
+ "role": "owner",
+ "status": "active",
+ "added_at": business.created_at,
+ "last_active": business.updated_at,
+ "permissions": {}, # Owner has all permissions
+ }
+ formatted_users.append(owner_data)
+ else:
+ logger.warning(f"Business owner {business.owner_id} not found in users table")
+
+ # Add other users with permissions
+ for perm in business_permissions:
+ # Skip if this is the owner (already added)
+ if perm.user_id == business.owner_id:
+ logger.info(f"Skipping owner user {perm.user_id} as already added")
+ continue
+
+ user = db.get(User, perm.user_id)
+ if user:
+ logger.info(f"Adding user with permissions: {user.id} - {user.email}")
+ user_data = {
+ "id": perm.id,
+ "business_id": perm.business_id,
+ "user_id": perm.user_id,
+ "user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(),
+ "user_email": user.email or "",
+ "user_phone": user.mobile,
+ "role": "member",
+ "status": "active",
+ "added_at": perm.created_at,
+ "last_active": perm.updated_at,
+ "permissions": perm.business_permissions or {},
+ }
+ formatted_users.append(user_data)
+ else:
+ logger.warning(f"User {perm.user_id} not found in users table")
+
+ logger.info(f"Returning {len(formatted_users)} users for business {business_id}")
+
+ # Format datetime fields based on calendar type
+ formatted_users = format_datetime_fields(formatted_users, request)
+
+ return success_response(
+ data={
+ "users": formatted_users,
+ "total_count": len(formatted_users)
+ },
+ request=request,
+ message="لیست کاربران دریافت شد"
+ )
+
+
+@router.post("/{business_id}/users",
+ summary="افزودن کاربر به کسب و کار",
+ description="افزودن کاربر جدید به کسب و کار با ایمیل یا شماره تلفن",
+ response_model=AddUserResponse,
+ responses={
+ 200: {
+ "description": "کاربر با موفقیت اضافه شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "کاربر با موفقیت اضافه شد",
+ "user": {
+ "id": 1,
+ "business_id": 1,
+ "user_id": 2,
+ "user_name": "علی احمدی",
+ "user_email": "ali@example.com",
+ "user_phone": "09123456789",
+ "role": "member",
+ "status": "active",
+ "added_at": "2024-01-01T00:00:00Z",
+ "last_active": None,
+ "permissions": {}
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها"
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز یا مجوز کافی نیست"
+ },
+ 404: {
+ "description": "کاربر یافت نشد"
+ }
+ }
+)
+@require_business_access("business_id")
+def add_user(
+ request: Request,
+ business_id: int,
+ add_request: AddUserRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """افزودن کاربر به کسب و کار"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ current_user_id = ctx.get_user_id()
+ logger.info(f"Adding user to business {business_id}, current user: {current_user_id}")
+ logger.info(f"Add request: {add_request.email_or_phone}")
+
+ # Check if user is business owner or has permission to manage users
+ business = db.get(Business, business_id)
+ if not business:
+ logger.error(f"Business {business_id} not found")
+ raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
+
+ is_owner = business.owner_id == current_user_id
+ can_manage = ctx.can_manage_business_users(business_id)
+
+ logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}")
+ logger.info(f"User {current_user_id} business_id from context: {ctx.business_id}")
+ logger.info(f"User {current_user_id} is superadmin: {ctx.is_superadmin()}")
+
+ if not is_owner and not can_manage:
+ logger.warning(f"User {current_user_id} does not have permission to add users to business {business_id}")
+ raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
+
+ # Find user by email or phone
+ logger.info(f"Searching for user with email/phone: {add_request.email_or_phone}")
+ user = db.query(User).filter(
+ (User.email == add_request.email_or_phone) |
+ (User.mobile == add_request.email_or_phone)
+ ).first()
+
+ if not user:
+ logger.warning(f"User not found with email/phone: {add_request.email_or_phone}")
+ raise HTTPException(status_code=404, detail="کاربر یافت نشد")
+
+ logger.info(f"Found user: {user.id} - {user.email}")
+
+ # Check if user is already added to this business
+ permission_repo = BusinessPermissionRepository(db)
+ existing_permission = permission_repo.get_by_user_and_business(user.id, business_id)
+
+ if existing_permission:
+ logger.warning(f"User {user.id} already exists in business {business_id}")
+ raise HTTPException(status_code=400, detail="کاربر قبلاً به این کسب و کار اضافه شده است")
+
+ # Add user to business with default permissions
+ logger.info(f"Adding user {user.id} to business {business_id}")
+ permission_obj = permission_repo.create_or_update(
+ user_id=user.id,
+ business_id=business_id,
+ permissions={'join': True} # Default permissions with join access
+ )
+
+ logger.info(f"Created permission object: {permission_obj.id}")
+
+ # Format user data
+ user_data = {
+ "id": permission_obj.id,
+ "business_id": permission_obj.business_id,
+ "user_id": permission_obj.user_id,
+ "user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(),
+ "user_email": user.email or "",
+ "user_phone": user.mobile,
+ "role": "member",
+ "status": "active",
+ "added_at": permission_obj.created_at,
+ "last_active": None,
+ "permissions": permission_obj.business_permissions or {},
+ }
+
+ logger.info(f"Returning user data: {user_data}")
+
+ # Format datetime fields based on calendar type
+ formatted_user_data = format_datetime_fields(user_data, request)
+
+ return success_response(
+ data={"user": formatted_user_data},
+ request=request,
+ message="کاربر با موفقیت اضافه شد"
+ )
+
+
+@router.put("/{business_id}/users/{user_id}/permissions",
+ summary="بهروزرسانی دسترسیهای کاربر",
+ description="بهروزرسانی دسترسیهای یک کاربر در کسب و کار",
+ response_model=UpdatePermissionsResponse,
+ responses={
+ 200: {
+ "description": "دسترسیها با موفقیت بهروزرسانی شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "دسترسیها با موفقیت بهروزرسانی شد"
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها"
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز یا مجوز کافی نیست"
+ },
+ 404: {
+ "description": "کاربر یافت نشد"
+ }
+ }
+)
+@require_business_access("business_id")
+def update_permissions(
+ request: Request,
+ business_id: int,
+ user_id: int,
+ update_request: UpdatePermissionsRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """بهروزرسانی دسترسیهای کاربر"""
+ current_user_id = ctx.get_user_id()
+
+ # Check if user is business owner or has permission to manage users
+ business = db.get(Business, business_id)
+ if not business:
+ raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
+
+ is_owner = business.owner_id == current_user_id
+ can_manage = ctx.can_manage_business_users()
+
+ if not is_owner and not can_manage:
+ raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
+
+ # Check if target user exists
+ target_user = db.get(User, user_id)
+ if not target_user:
+ raise HTTPException(status_code=404, detail="کاربر یافت نشد")
+
+ # Update permissions
+ permission_repo = BusinessPermissionRepository(db)
+ permission_obj = permission_repo.create_or_update(
+ user_id=user_id,
+ business_id=business_id,
+ permissions=update_request.permissions
+ )
+
+ return success_response(
+ data={},
+ request=request,
+ message="دسترسیها با موفقیت بهروزرسانی شد"
+ )
+
+
+@router.delete("/{business_id}/users/{user_id}",
+ summary="حذف کاربر از کسب و کار",
+ description="حذف کاربر از کسب و کار",
+ response_model=RemoveUserResponse,
+ responses={
+ 200: {
+ "description": "کاربر با موفقیت حذف شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "کاربر با موفقیت حذف شد"
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز یا مجوز کافی نیست"
+ },
+ 404: {
+ "description": "کاربر یافت نشد"
+ }
+ }
+)
+@require_business_access("business_id")
+def remove_user(
+ request: Request,
+ business_id: int,
+ user_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """حذف کاربر از کسب و کار"""
+ current_user_id = ctx.get_user_id()
+
+ # Check if user is business owner or has permission to manage users
+ business = db.get(Business, business_id)
+ if not business:
+ raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
+
+ is_owner = business.owner_id == current_user_id
+ can_manage = ctx.can_manage_business_users()
+
+ if not is_owner and not can_manage:
+ raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
+
+ # Check if target user is business owner
+ business = db.get(Business, business_id)
+ if business and business.owner_id == user_id:
+ raise HTTPException(status_code=400, detail="نمیتوان مالک کسب و کار را حذف کرد")
+
+ # Remove user permissions
+ permission_repo = BusinessPermissionRepository(db)
+ success = permission_repo.delete_by_user_and_business(user_id, business_id)
+
+ if not success:
+ raise HTTPException(status_code=404, detail="کاربر یافت نشد")
+
+ return success_response(
+ data={},
+ request=request,
+ message="کاربر با موفقیت حذف شد"
+ )
diff --git a/hesabixAPI/build/lib/adapters/api/v1/businesses.py b/hesabixAPI/build/lib/adapters/api/v1/businesses.py
new file mode 100644
index 0000000..8d93b81
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/businesses.py
@@ -0,0 +1,320 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from fastapi import APIRouter, Depends, Request, Query, HTTPException
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.api.v1.schemas import (
+ BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse,
+ BusinessListResponse, BusinessSummaryResponse, SuccessResponse
+)
+from app.core.responses import success_response, format_datetime_fields
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_management
+from app.services.business_service import (
+ create_business, get_business_by_id, get_businesses_by_owner, get_user_businesses,
+ update_business, delete_business, get_business_summary
+)
+
+
+router = APIRouter(prefix="/businesses", tags=["businesses"])
+
+
+@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)
+
+
+@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
+ }
+ businesses = get_user_businesses(db, user_id, query_dict)
+ formatted_data = format_datetime_fields(businesses, request)
+
+ return success_response(formatted_data, request)
+
+
+@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": "کاربر احراز هویت نشده است"
+ },
+ 404: {
+ "description": "کسب و کار یافت نشد"
+ }
+ }
+)
+def get_business(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """دریافت جزئیات کسب و کار"""
+ owner_id = ctx.get_user_id()
+ business = get_business_by_id(db, business_id, 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.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": "کسب و کار یافت نشد"
+ }
+ }
+)
+def update_business_info(
+ request: Request,
+ business_id: int,
+ business_data: BusinessUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> 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.delete("/{business_id}",
+ summary="حذف کسب و کار",
+ description="حذف یک کسب و کار",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "کسب و کار با موفقیت حذف شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "کسب و کار با موفقیت حذف شد",
+ "data": {"ok": True}
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 404: {
+ "description": "کسب و کار یافت نشد"
+ }
+ }
+)
+def delete_business_info(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """حذف کسب و کار"""
+ owner_id = ctx.get_user_id()
+ success = delete_business(db, business_id, owner_id)
+
+ if not success:
+ raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
+
+ return success_response({"ok": True}, request, "کسب و کار با موفقیت حذف شد")
+
+
+@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)
diff --git a/hesabixAPI/build/lib/adapters/api/v1/categories.py b/hesabixAPI/build/lib/adapters/api/v1/categories.py
new file mode 100644
index 0000000..6290dbc
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/categories.py
@@ -0,0 +1,148 @@
+from typing import Any, Dict
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from app.core.responses import success_response, ApiError
+from adapters.db.repositories.category_repository import CategoryRepository
+
+
+router = APIRouter(prefix="/categories", tags=["categories"])
+
+
+@router.post("/business/{business_id}/tree")
+@require_business_access("business_id")
+def get_categories_tree(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] | None = None,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ # اجازه مشاهده نیاز به view روی سکشن categories دارد
+ if not ctx.can_read_section("categories"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.view", http_status=403)
+ repo = CategoryRepository(db)
+ # درخت سراسری: بدون فیلتر نوع
+ tree = repo.get_tree(business_id, None)
+ # تبدیل کلید title به label به صورت بازگشتی
+ def _map_label(nodes: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
+ mapped: list[Dict[str, Any]] = []
+ for n in nodes:
+ children = n.get("children") or []
+ mapped.append({
+ "id": n.get("id"),
+ "parent_id": n.get("parent_id"),
+ "label": n.get("title", ""),
+ "translations": n.get("translations", {}),
+ "children": _map_label(children) if isinstance(children, list) else [],
+ })
+ return mapped
+ items = _map_label(tree)
+ return success_response({"items": items}, request)
+
+
+@router.post("/business/{business_id}")
+@require_business_access("business_id")
+def create_category(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any],
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("categories", "add"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.add", http_status=403)
+ parent_id = body.get("parent_id")
+ label: str = (body.get("label") or "").strip()
+ # ساخت ترجمهها از روی برچسب واحد
+ translations: Dict[str, str] = {"fa": label, "en": label} if label else {}
+ repo = CategoryRepository(db)
+ obj = repo.create_category(business_id=business_id, parent_id=parent_id, translations=translations)
+ item = {
+ "id": obj.id,
+ "parent_id": obj.parent_id,
+ "label": (obj.title_translations or {}).get(ctx.language)
+ or (obj.title_translations or {}).get("fa")
+ or (obj.title_translations or {}).get("en"),
+ "translations": obj.title_translations,
+ }
+ return success_response({"item": item}, request)
+
+
+@router.post("/business/{business_id}/update")
+@require_business_access("business_id")
+def update_category(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any],
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("categories", "edit"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.edit", http_status=403)
+ category_id = body.get("category_id")
+ label = body.get("label")
+ translations = {"fa": label, "en": label} if isinstance(label, str) and label.strip() else None
+ repo = CategoryRepository(db)
+ obj = repo.update_category(category_id=category_id, translations=translations)
+ if not obj:
+ raise ApiError("NOT_FOUND", "Category not found", http_status=404)
+ item = {
+ "id": obj.id,
+ "parent_id": obj.parent_id,
+ "label": (obj.title_translations or {}).get(ctx.language)
+ or (obj.title_translations or {}).get("fa")
+ or (obj.title_translations or {}).get("en"),
+ "translations": obj.title_translations,
+ }
+ return success_response({"item": item}, request)
+
+
+@router.post("/business/{business_id}/move")
+@require_business_access("business_id")
+def move_category(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any],
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("categories", "edit"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.edit", http_status=403)
+ category_id = body.get("category_id")
+ new_parent_id = body.get("new_parent_id")
+ repo = CategoryRepository(db)
+ obj = repo.move_category(category_id=category_id, new_parent_id=new_parent_id)
+ if not obj:
+ raise ApiError("NOT_FOUND", "Category not found", http_status=404)
+ item = {
+ "id": obj.id,
+ "parent_id": obj.parent_id,
+ "label": (obj.title_translations or {}).get(ctx.language)
+ or (obj.title_translations or {}).get("fa")
+ or (obj.title_translations or {}).get("en"),
+ "translations": obj.title_translations,
+ }
+ return success_response({"item": item}, request)
+
+
+@router.post("/business/{business_id}/delete")
+@require_business_access("business_id")
+def delete_category(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any],
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("categories", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.delete", http_status=403)
+ repo = CategoryRepository(db)
+ category_id = body.get("category_id")
+ ok = repo.delete_category(category_id=category_id)
+ return success_response({"deleted": ok}, request)
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/currencies.py b/hesabixAPI/build/lib/adapters/api/v1/currencies.py
new file mode 100644
index 0000000..244babd
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/currencies.py
@@ -0,0 +1,30 @@
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.db.models.currency import Currency
+from app.core.responses import success_response
+
+
+router = APIRouter(prefix="/currencies", tags=["currencies"])
+
+
+@router.get(
+ "",
+ summary="فهرست ارزها",
+ description="دریافت فهرست ارزهای قابل استفاده",
+)
+def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict:
+ items = [
+ {
+ "id": c.id,
+ "name": c.name,
+ "title": c.title,
+ "symbol": c.symbol,
+ "code": c.code,
+ }
+ for c in db.query(Currency).order_by(Currency.title.asc()).all()
+ ]
+ return success_response(items, request)
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/health.py b/hesabixAPI/build/lib/adapters/api/v1/health.py
new file mode 100644
index 0000000..fc9c8c8
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/health.py
@@ -0,0 +1,30 @@
+from fastapi import APIRouter
+from adapters.api.v1.schemas import SuccessResponse
+
+router = APIRouter(prefix="/health", tags=["health"])
+
+
+@router.get("",
+ summary="بررسی وضعیت سرویس",
+ description="بررسی وضعیت کلی سرویس و در دسترس بودن آن",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "سرویس در دسترس است",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "سرویس در دسترس است",
+ "data": {
+ "status": "ok",
+ "timestamp": "2024-01-01T00:00:00Z"
+ }
+ }
+ }
+ }
+ }
+ }
+)
+def health() -> dict[str, str]:
+ return {"status": "ok"}
diff --git a/hesabixAPI/build/lib/adapters/api/v1/persons.py b/hesabixAPI/build/lib/adapters/api/v1/persons.py
new file mode 100644
index 0000000..2185f0c
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/persons.py
@@ -0,0 +1,945 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body, Form
+from fastapi import UploadFile, File
+from sqlalchemy.orm import Session
+from typing import Dict, Any, List, Optional
+
+from adapters.db.session import get_db
+from adapters.api.v1.schema_models.person import (
+ PersonCreateRequest, PersonUpdateRequest, PersonResponse,
+ PersonListResponse, PersonSummaryResponse, PersonBankAccountCreateRequest
+)
+from adapters.api.v1.schemas import QueryInfo, SuccessResponse
+from app.core.responses import success_response, format_datetime_fields
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_management_dep
+from app.core.i18n import negotiate_locale
+from app.services.person_service import (
+ create_person, get_person_by_id, get_persons_by_business,
+ update_person, delete_person, get_person_summary
+)
+from adapters.db.models.person import Person
+from adapters.db.models.business import Business
+
+router = APIRouter(prefix="/persons", tags=["persons"])
+
+
+@router.post("/businesses/{business_id}/persons/create",
+ summary="ایجاد شخص جدید",
+ description="ایجاد شخص جدید برای کسب و کار مشخص",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "شخص با موفقیت ایجاد شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "شخص با موفقیت ایجاد شد",
+ "data": {
+ "id": 1,
+ "business_id": 1,
+ "alias_name": "علی احمدی",
+ "person_type": "مشتری",
+ "created_at": "2024-01-01T00:00:00Z"
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها"
+ },
+ 401: {
+ "description": "عدم احراز هویت"
+ },
+ 403: {
+ "description": "عدم دسترسی به کسب و کار"
+ }
+ }
+)
+async def create_person_endpoint(
+ request: Request,
+ business_id: int,
+ person_data: PersonCreateRequest,
+ db: Session = Depends(get_db),
+ auth_context: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ """ایجاد شخص جدید برای کسب و کار"""
+ result = create_person(db, business_id, person_data)
+ return success_response(
+ data=format_datetime_fields(result['data'], request),
+ request=request,
+ message=result['message'],
+ )
+
+
+@router.post("/businesses/{business_id}/persons",
+ summary="لیست اشخاص کسب و کار",
+ description="دریافت لیست اشخاص یک کسب و کار با امکان جستجو و فیلتر",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "لیست اشخاص با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "لیست اشخاص با موفقیت دریافت شد",
+ "data": {
+ "items": [],
+ "pagination": {
+ "total": 0,
+ "page": 1,
+ "per_page": 20,
+ "total_pages": 0,
+ "has_next": False,
+ "has_prev": False
+ },
+ "query_info": {}
+ }
+ }
+ }
+ }
+ }
+ }
+)
+async def get_persons_endpoint(
+ request: Request,
+ business_id: int,
+ query_info: QueryInfo,
+ db: Session = Depends(get_db),
+ auth_context: AuthContext = Depends(get_current_user),
+):
+ """دریافت لیست اشخاص کسب و کار"""
+ query_dict = {
+ "take": query_info.take,
+ "skip": query_info.skip,
+ "sort_by": query_info.sort_by,
+ "sort_desc": query_info.sort_desc,
+ "search": query_info.search,
+ "search_fields": query_info.search_fields,
+ "filters": query_info.filters,
+ }
+ result = get_persons_by_business(db, business_id, query_dict)
+
+ # فرمت کردن تاریخها
+ result['items'] = [
+ format_datetime_fields(item, request) for item in result['items']
+ ]
+
+ return success_response(
+ data=result,
+ request=request,
+ message="لیست اشخاص با موفقیت دریافت شد",
+ )
+
+
+@router.post("/businesses/{business_id}/persons/export/excel",
+ summary="خروجی Excel لیست اشخاص",
+ description="خروجی Excel لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
+)
+async def export_persons_excel(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ auth_context: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ import json
+ import datetime
+ import re
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+ from fastapi.responses import Response
+
+ # Build query dict similar to list endpoint from flat body
+ query_dict = {
+ "take": int(body.get("take", 20)),
+ "skip": int(body.get("skip", 0)),
+ "sort_by": body.get("sort_by"),
+ "sort_desc": bool(body.get("sort_desc", False)),
+ "search": body.get("search"),
+ "search_fields": body.get("search_fields"),
+ "filters": body.get("filters"),
+ }
+
+ result = get_persons_by_business(db, business_id, query_dict)
+
+ items = result.get('items', [])
+ # Format date/time fields using existing helper
+ items = [format_datetime_fields(item, request) for item in items]
+
+ # Apply selected indices filter if requested
+ selected_only = bool(body.get('selected_only', False))
+ selected_indices = body.get('selected_indices')
+ if selected_only and selected_indices is not None:
+ indices = None
+ if isinstance(selected_indices, str):
+ try:
+ indices = json.loads(selected_indices)
+ except (json.JSONDecodeError, TypeError):
+ indices = None
+ elif isinstance(selected_indices, list):
+ indices = selected_indices
+ if isinstance(indices, list):
+ items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
+
+ # Prepare headers based on export_columns (order + visibility)
+ headers: List[str] = []
+ keys: List[str] = []
+ export_columns = body.get('export_columns')
+ if export_columns:
+ for col in export_columns:
+ key = col.get('key')
+ label = col.get('label', key)
+ if key:
+ keys.append(str(key))
+ headers.append(str(label))
+ else:
+ # Fallback to item keys if no columns provided
+ if items:
+ keys = list(items[0].keys())
+ headers = keys
+
+ # Create workbook
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Persons"
+
+ # Locale and RTL/LTR handling
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ if locale == 'fa':
+ try:
+ ws.sheet_view.rightToLeft = True
+ except Exception:
+ pass
+
+ header_font = Font(bold=True, color="FFFFFF")
+ header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
+ header_alignment = Alignment(horizontal="center", vertical="center")
+ border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
+
+ # Write header row
+ for col_idx, header in enumerate(headers, 1):
+ cell = ws.cell(row=1, column=col_idx, value=header)
+ cell.font = header_font
+ cell.fill = header_fill
+ cell.alignment = header_alignment
+ cell.border = border
+
+ # Write data rows
+ for row_idx, item in enumerate(items, 2):
+ for col_idx, key in enumerate(keys, 1):
+ value = item.get(key, "")
+ if isinstance(value, list):
+ value = ", ".join(str(v) for v in value)
+ cell = ws.cell(row=row_idx, column=col_idx, value=value)
+ cell.border = border
+ if locale == 'fa':
+ cell.alignment = Alignment(horizontal="right")
+
+ # Auto-width columns
+ for column in ws.columns:
+ max_length = 0
+ column_letter = column[0].column_letter
+ for cell in column:
+ try:
+ if len(str(cell.value)) > max_length:
+ max_length = len(str(cell.value))
+ except Exception:
+ pass
+ ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
+
+ # Save to bytes
+ buffer = io.BytesIO()
+ wb.save(buffer)
+ buffer.seek(0)
+
+ # Build meaningful filename
+ biz_name = ""
+ try:
+ b = db.query(Business).filter(Business.id == business_id).first()
+ if b is not None:
+ biz_name = b.name or ""
+ except Exception:
+ biz_name = ""
+ def slugify(text: str) -> str:
+ return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
+ base = "persons"
+ if biz_name:
+ base += f"_{slugify(biz_name)}"
+ if selected_only:
+ base += "_selected"
+ filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+ content = buffer.getvalue()
+ return Response(
+ content=content,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Content-Length": str(len(content)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
+@router.post("/businesses/{business_id}/persons/export/pdf",
+ summary="خروجی PDF لیست اشخاص",
+ description="خروجی PDF لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
+)
+async def export_persons_pdf(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ auth_context: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import json
+ import datetime
+ import re
+ from fastapi.responses import Response
+ from weasyprint import HTML, CSS
+ from weasyprint.text.fonts import FontConfiguration
+
+ # Build query dict from flat body
+ query_dict = {
+ "take": int(body.get("take", 20)),
+ "skip": int(body.get("skip", 0)),
+ "sort_by": body.get("sort_by"),
+ "sort_desc": bool(body.get("sort_desc", False)),
+ "search": body.get("search"),
+ "search_fields": body.get("search_fields"),
+ "filters": body.get("filters"),
+ }
+
+ result = get_persons_by_business(db, business_id, query_dict)
+ items = result.get('items', [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ selected_only = bool(body.get('selected_only', False))
+ selected_indices = body.get('selected_indices')
+ if selected_only and selected_indices is not None:
+ indices = None
+ if isinstance(selected_indices, str):
+ try:
+ indices = json.loads(selected_indices)
+ except (json.JSONDecodeError, TypeError):
+ indices = None
+ elif isinstance(selected_indices, list):
+ indices = selected_indices
+ if isinstance(indices, list):
+ items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
+
+ headers: List[str] = []
+ keys: List[str] = []
+ export_columns = body.get('export_columns')
+ if export_columns:
+ for col in export_columns:
+ key = col.get('key')
+ label = col.get('label', key)
+ if key:
+ keys.append(str(key))
+ headers.append(str(label))
+ else:
+ if items:
+ keys = list(items[0].keys())
+ headers = keys
+
+ # Load business info for header
+ business_name = ""
+ try:
+ biz = db.query(Business).filter(Business.id == business_id).first()
+ if biz is not None:
+ business_name = biz.name
+ except Exception:
+ business_name = ""
+
+ # Styled HTML with dynamic direction/locale
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ is_fa = (locale == 'fa')
+ html_lang = 'fa' if is_fa else 'en'
+ html_dir = 'rtl' if is_fa else 'ltr'
+
+ def escape(s: Any) -> str:
+ try:
+ return str(s).replace('&', '&').replace('<', '<').replace('>', '>')
+ except Exception:
+ return str(s)
+
+ rows_html = []
+ for item in items:
+ tds = []
+ for key in keys:
+ value = item.get(key)
+ if value is None:
+ value = ""
+ elif isinstance(value, list):
+ value = ", ".join(str(v) for v in value)
+ tds.append(f"{escape(value)} | ")
+ rows_html.append(f"{''.join(tds)}
")
+
+ headers_html = ''.join(f"{escape(h)} | " for h in headers)
+ # Format report datetime based on X-Calendar-Type header
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
+ try:
+ from app.core.calendar import CalendarConverter
+ formatted_now = CalendarConverter.format_datetime(datetime.datetime.now(),
+ "jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian")
+ now = formatted_now.get('formatted', formatted_now.get('date_time', ''))
+ except Exception:
+ now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
+
+ title_text = "گزارش لیست اشخاص" if is_fa else "Persons List Report"
+ label_biz = "نام کسبوکار" if is_fa else "Business Name"
+ label_date = "تاریخ گزارش" if is_fa else "Report Date"
+ footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
+ page_label_left = "صفحه " if is_fa else "Page "
+ page_label_of = " از " if is_fa else " of "
+
+ table_html = f"""
+
+
+
+
+
+
+
+
+
+
+ {headers_html}
+
+
+ {''.join(rows_html)}
+
+
+
+
+
+
+ """
+
+ font_config = FontConfiguration()
+ pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
+
+ # Build meaningful filename
+ biz_name = ""
+ try:
+ b = db.query(Business).filter(Business.id == business_id).first()
+ if b is not None:
+ biz_name = b.name or ""
+ except Exception:
+ biz_name = ""
+ def slugify(text: str) -> str:
+ return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
+ base = "persons"
+ if biz_name:
+ base += f"_{slugify(biz_name)}"
+ if selected_only:
+ base += "_selected"
+ filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Content-Length": str(len(pdf_bytes)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
+@router.post("/businesses/{business_id}/persons/import/template",
+ summary="دانلود تمپلیت ایمپورت اشخاص",
+ description="فایل Excel تمپلیت برای ایمپورت اشخاص را برمیگرداند",
+)
+async def download_persons_import_template(
+ business_id: int,
+ request: Request,
+ auth_context: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ import datetime
+ from fastapi.responses import Response
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Template"
+
+ headers = [
+ 'code','alias_name','first_name','last_name','person_type','person_types','company_name','payment_id',
+ 'national_id','registration_number','economic_id','country','province','city','address','postal_code',
+ 'phone','mobile','fax','email','website','share_count','commission_sale_percent','commission_sales_return_percent',
+ 'commission_sales_amount','commission_sales_return_amount'
+ ]
+ for col, header in enumerate(headers, 1):
+ cell = ws.cell(row=1, column=col, value=header)
+ cell.font = Font(bold=True)
+ cell.alignment = Alignment(horizontal="center")
+
+ # Sample row
+ sample = [
+ '', 'نمونه نام مستعار', 'علی', 'احمدی', 'مشتری', 'مشتری, فروشنده', 'نمونه شرکت', 'PID123',
+ '0012345678', '12345', 'ECO-1', 'ایران', 'تهران', 'تهران', 'خیابان مثال ۱', '1234567890',
+ '02112345678', '09120000000', '', 'test@example.com', 'example.com', '', '5', '0', '0', '0'
+ ]
+ for col, val in enumerate(sample, 1):
+ ws.cell(row=2, column=col, value=val)
+
+ # Auto width
+ for column in ws.columns:
+ try:
+ letter = column[0].column_letter
+ max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column)
+ ws.column_dimensions[letter].width = min(max_len + 2, 50)
+ except Exception:
+ pass
+
+ buf = io.BytesIO()
+ wb.save(buf)
+ buf.seek(0)
+
+ filename = f"persons_import_template_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+ return Response(
+ content=buf.getvalue(),
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
+@router.get("/persons/{person_id}",
+ summary="جزئیات شخص",
+ description="دریافت جزئیات یک شخص",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "جزئیات شخص با موفقیت دریافت شد"
+ },
+ 404: {
+ "description": "شخص یافت نشد"
+ }
+ }
+)
+async def get_person_endpoint(
+ request: Request,
+ person_id: int,
+ db: Session = Depends(get_db),
+ auth_context: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ """دریافت جزئیات شخص"""
+ # ابتدا باید business_id را از person دریافت کنیم
+ person = db.query(Person).filter(Person.id == person_id).first()
+ if not person:
+ raise HTTPException(status_code=404, detail="شخص یافت نشد")
+
+ result = get_person_by_id(db, person_id, person.business_id)
+ if not result:
+ raise HTTPException(status_code=404, detail="شخص یافت نشد")
+
+ return success_response(
+ data=format_datetime_fields(result, request),
+ request=request,
+ message="جزئیات شخص با موفقیت دریافت شد",
+ )
+
+
+@router.put("/persons/{person_id}",
+ summary="ویرایش شخص",
+ description="ویرایش اطلاعات یک شخص",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "شخص با موفقیت ویرایش شد"
+ },
+ 404: {
+ "description": "شخص یافت نشد"
+ }
+ }
+)
+async def update_person_endpoint(
+ request: Request,
+ person_id: int,
+ person_data: PersonUpdateRequest,
+ db: Session = Depends(get_db),
+ auth_context: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ """ویرایش شخص"""
+ # ابتدا باید business_id را از person دریافت کنیم
+ person = db.query(Person).filter(Person.id == person_id).first()
+ if not person:
+ raise HTTPException(status_code=404, detail="شخص یافت نشد")
+
+ result = update_person(db, person_id, person.business_id, person_data)
+ if not result:
+ raise HTTPException(status_code=404, detail="شخص یافت نشد")
+
+ return success_response(
+ data=format_datetime_fields(result['data'], request),
+ request=request,
+ message=result['message'],
+ )
+
+
+@router.delete("/persons/{person_id}",
+ summary="حذف شخص",
+ description="حذف یک شخص",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "شخص با موفقیت حذف شد"
+ },
+ 404: {
+ "description": "شخص یافت نشد"
+ }
+ }
+)
+async def delete_person_endpoint(
+ request: Request,
+ person_id: int,
+ db: Session = Depends(get_db),
+ auth_context: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ """حذف شخص"""
+ # ابتدا باید business_id را از person دریافت کنیم
+ person = db.query(Person).filter(Person.id == person_id).first()
+ if not person:
+ raise HTTPException(status_code=404, detail="شخص یافت نشد")
+
+ success = delete_person(db, person_id, person.business_id)
+ if not success:
+ raise HTTPException(status_code=404, detail="شخص یافت نشد")
+
+ return success_response(message="شخص با موفقیت حذف شد", request=request)
+
+
+@router.get("/businesses/{business_id}/persons/summary",
+ summary="خلاصه اشخاص کسب و کار",
+ description="دریافت خلاصه آماری اشخاص یک کسب و کار",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "خلاصه اشخاص با موفقیت دریافت شد"
+ }
+ }
+)
+async def get_persons_summary_endpoint(
+ request: Request,
+ business_id: int,
+ db: Session = Depends(get_db),
+ auth_context: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ """دریافت خلاصه اشخاص کسب و کار"""
+ result = get_person_summary(db, business_id)
+
+ return success_response(
+ data=result,
+ request=request,
+ message="خلاصه اشخاص با موفقیت دریافت شد",
+ )
+
+
+@router.post("/businesses/{business_id}/persons/import/excel",
+ summary="ایمپورت اشخاص از فایل Excel",
+ description="فایل اکسل را دریافت میکند و بهصورت dry-run یا واقعی پردازش میکند",
+)
+async def import_persons_excel(
+ business_id: int,
+ request: Request,
+ file: UploadFile = File(...),
+ dry_run: str = Form(default="true"),
+ match_by: str = Form(default="code"),
+ conflict_policy: str = Form(default="upsert"),
+ auth_context: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ import json
+ import re
+ from openpyxl import load_workbook
+ from fastapi import HTTPException
+ import logging
+ import zipfile
+
+ logger = logging.getLogger(__name__)
+
+ def validate_excel_file(content: bytes) -> bool:
+ """
+ Validate if the content is a valid Excel file
+ """
+ try:
+ # Check if it starts with PK signature (zip file)
+ if not content.startswith(b'PK'):
+ return False
+
+ # Try to open as zip file
+ with zipfile.ZipFile(io.BytesIO(content), 'r') as zip_file:
+ file_list = zip_file.namelist()
+ # Check for Excel structure (xl/ folder for .xlsx files)
+ excel_structure = any(f.startswith('xl/') for f in file_list)
+ if excel_structure:
+ return True
+
+ # Check for older Excel format (.xls) - this would be a different structure
+ # But since we only support .xlsx, we'll return False for .xls
+ return False
+ except zipfile.BadZipFile:
+ logger.error("File is not a valid zip file")
+ return False
+ except Exception as e:
+ logger.error(f"Error validating Excel file: {str(e)}")
+ return False
+
+ try:
+ # Convert dry_run string to boolean
+ dry_run_bool = dry_run.lower() in ('true', '1', 'yes', 'on')
+
+ logger.info(f"Import request: business_id={business_id}, dry_run={dry_run_bool}, match_by={match_by}, conflict_policy={conflict_policy}")
+ logger.info(f"File info: filename={file.filename}, content_type={file.content_type}")
+
+ if not file.filename or not file.filename.lower().endswith('.xlsx'):
+ logger.error(f"Invalid file format: {file.filename}")
+ raise HTTPException(status_code=400, detail="فرمت فایل معتبر نیست. تنها xlsx پشتیبانی میشود")
+
+ content = await file.read()
+ logger.info(f"File content size: {len(content)} bytes")
+
+ # Log first few bytes for debugging
+ logger.info(f"File header (first 20 bytes): {content[:20].hex()}")
+ logger.info(f"File header (first 20 bytes as text): {content[:20]}")
+
+ # Check if content is empty or too small
+ if len(content) < 100:
+ logger.error(f"File too small: {len(content)} bytes")
+ raise HTTPException(status_code=400, detail="فایل خیلی کوچک است یا خالی است")
+
+ # Validate Excel file format
+ if not validate_excel_file(content):
+ logger.error("File is not a valid Excel file")
+ raise HTTPException(status_code=400, detail="فرمت فایل معتبر نیست. فایل Excel معتبر نیست")
+
+ try:
+ # Try to load the workbook with additional error handling
+ wb = load_workbook(filename=io.BytesIO(content), data_only=True)
+ logger.info(f"Successfully loaded workbook with {len(wb.worksheets)} worksheets")
+ except zipfile.BadZipFile as e:
+ logger.error(f"Bad zip file error: {str(e)}")
+ raise HTTPException(status_code=400, detail="فایل Excel خراب است یا فرمت آن معتبر نیست")
+ except Exception as e:
+ logger.error(f"Error loading workbook: {str(e)}")
+ raise HTTPException(status_code=400, detail=f"امکان خواندن فایل وجود ندارد: {str(e)}")
+
+ ws = wb.active
+ rows = list(ws.iter_rows(values_only=True))
+ if not rows:
+ return success_response(data={"summary": {"total": 0}}, request=request, message="فایل خالی است")
+
+ headers = [str(h).strip() if h is not None else "" for h in rows[0]]
+ data_rows = rows[1:]
+
+ # helper to map enum strings (fa/en) to internal value
+ def normalize_person_type(value: str) -> Optional[str]:
+ if not value:
+ return None
+ value = str(value).strip()
+ mapping = {
+ 'customer': 'مشتری', 'marketer': 'بازاریاب', 'employee': 'کارمند', 'supplier': 'تامینکننده',
+ 'partner': 'همکار', 'seller': 'فروشنده', 'shareholder': 'سهامدار'
+ }
+ for en, fa in mapping.items():
+ if value.lower() == en or value == fa:
+ return fa
+ return value # assume already fa
+
+ errors: list[dict] = []
+ valid_items: list[dict] = []
+
+ for idx, row in enumerate(data_rows, start=2):
+ item: dict[str, Any] = {}
+ row_errors: list[str] = []
+ for ci, key in enumerate(headers):
+ if not key:
+ continue
+ val = row[ci] if ci < len(row) else None
+ if isinstance(val, str):
+ val = val.strip()
+ item[key] = val
+ # normalize types
+ if 'person_type' in item and item['person_type']:
+ item['person_type'] = normalize_person_type(item['person_type'])
+ if 'person_types' in item and item['person_types']:
+ # split by comma
+ parts = [normalize_person_type(p.strip()) for p in str(item['person_types']).split(',') if str(p).strip()]
+ item['person_types'] = parts
+
+ # alias_name required
+ if not item.get('alias_name'):
+ row_errors.append('alias_name الزامی است')
+
+ # shareholder rule
+ if (item.get('person_type') == 'سهامدار') or (isinstance(item.get('person_types'), list) and 'سهامدار' in item.get('person_types', [])):
+ sc = item.get('share_count')
+ try:
+ sc_val = int(sc) if sc is not None and str(sc).strip() != '' else None
+ except Exception:
+ sc_val = None
+ if sc_val is None or sc_val <= 0:
+ row_errors.append('برای سهامدار share_count باید > 0 باشد')
+ else:
+ item['share_count'] = sc_val
+
+ if row_errors:
+ errors.append({"row": idx, "errors": row_errors})
+ continue
+
+ valid_items.append(item)
+
+ inserted = 0
+ updated = 0
+ skipped = 0
+
+ if not dry_run_bool and valid_items:
+ # apply import with conflict policy
+ from adapters.db.models.person import Person
+ from sqlalchemy import and_
+
+ def find_existing(session: Session, data: dict) -> Optional[Person]:
+ if match_by == 'national_id' and data.get('national_id'):
+ return session.query(Person).filter(and_(Person.business_id == business_id, Person.national_id == data['national_id'])).first()
+ if match_by == 'email' and data.get('email'):
+ return session.query(Person).filter(and_(Person.business_id == business_id, Person.email == data['email'])).first()
+ if match_by == 'code' and data.get('code'):
+ try:
+ code_int = int(data['code'])
+ return session.query(Person).filter(and_(Person.business_id == business_id, Person.code == code_int)).first()
+ except Exception:
+ return None
+ return None
+
+ for data in valid_items:
+ existing = find_existing(db, data)
+ match_value = None
+ try:
+ match_value = data.get(match_by)
+ except Exception:
+ match_value = None
+ if existing is None:
+ # create
+ try:
+ create_person(db, business_id, PersonCreateRequest(**data))
+ inserted += 1
+ except Exception as e:
+ logger.error(f"Create person failed for data={data}: {str(e)}")
+ skipped += 1
+ else:
+ if conflict_policy == 'insert':
+ logger.info(f"Skipping existing person (match_by={match_by}, value={match_value}) due to conflict_policy=insert")
+ skipped += 1
+ elif conflict_policy in ('update', 'upsert'):
+ try:
+ update_person(db, existing.id, business_id, PersonUpdateRequest(**data))
+ updated += 1
+ except Exception as e:
+ logger.error(f"Update person failed for id={existing.id}, data={data}: {str(e)}")
+ skipped += 1
+
+ summary = {
+ "total": len(data_rows),
+ "valid": len(valid_items),
+ "invalid": len(errors),
+ "inserted": inserted,
+ "updated": updated,
+ "skipped": skipped,
+ "dry_run": dry_run_bool,
+ }
+
+ return success_response(
+ data={
+ "summary": summary,
+ "errors": errors,
+ },
+ request=request,
+ message="نتیجه ایمپورت اشخاص",
+ )
+ except Exception as e:
+ logger.error(f"Import error: {str(e)}", exc_info=True)
+ raise HTTPException(status_code=500, detail=f"خطا در پردازش فایل: {str(e)}")
diff --git a/hesabixAPI/build/lib/adapters/api/v1/price_lists.py b/hesabixAPI/build/lib/adapters/api/v1/price_lists.py
new file mode 100644
index 0000000..8cfa5df
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/price_lists.py
@@ -0,0 +1,165 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from typing import Dict, Any
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from app.core.responses import success_response, ApiError, format_datetime_fields
+from adapters.api.v1.schemas import QueryInfo
+from adapters.api.v1.schema_models.price_list import (
+ PriceListCreateRequest,
+ PriceListUpdateRequest,
+ PriceItemUpsertRequest,
+)
+from app.services.price_list_service import (
+ create_price_list,
+ list_price_lists,
+ get_price_list,
+ update_price_list,
+ delete_price_list,
+ list_price_items,
+ upsert_price_item,
+ delete_price_item,
+)
+
+
+router = APIRouter(prefix="/price-lists", tags=["price-lists"])
+
+
+@router.post("/business/{business_id}")
+@require_business_access("business_id")
+def create_price_list_endpoint(
+ request: Request,
+ business_id: int,
+ payload: PriceListCreateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = create_price_list(db, business_id, payload)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.post("/business/{business_id}/search")
+@require_business_access("business_id")
+def search_price_lists_endpoint(
+ request: Request,
+ business_id: int,
+ query: QueryInfo,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ result = list_price_lists(db, business_id, {
+ "take": query.take,
+ "skip": query.skip,
+ "sort_by": query.sort_by,
+ "sort_desc": query.sort_desc,
+ "search": query.search,
+ })
+ return success_response(data=format_datetime_fields(result, request), request=request)
+
+
+@router.get("/business/{business_id}/{price_list_id}")
+@require_business_access("business_id")
+def get_price_list_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ item = get_price_list(db, business_id, price_list_id)
+ if not item:
+ raise ApiError("NOT_FOUND", "Price list not found", http_status=404)
+ return success_response(data=format_datetime_fields({"item": item}, request), request=request)
+
+
+@router.put("/business/{business_id}/{price_list_id}")
+@require_business_access("business_id")
+def update_price_list_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ payload: PriceListUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = update_price_list(db, business_id, price_list_id, payload)
+ if not result:
+ raise ApiError("NOT_FOUND", "Price list not found", http_status=404)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.delete("/business/{business_id}/{price_list_id}")
+@require_business_access("business_id")
+def delete_price_list_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
+ ok = delete_price_list(db, business_id, price_list_id)
+ return success_response({"deleted": ok}, request)
+
+
+@router.post("/business/{business_id}/{price_list_id}/items")
+@require_business_access("business_id")
+def upsert_price_item_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ payload: PriceItemUpsertRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = upsert_price_item(db, business_id, price_list_id, payload)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.get("/business/{business_id}/{price_list_id}/items")
+@require_business_access("business_id")
+def list_price_items_endpoint(
+ request: Request,
+ business_id: int,
+ price_list_id: int,
+ product_id: int | None = None,
+ currency_id: int | None = None,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ result = list_price_items(db, business_id, price_list_id, product_id=product_id, currency_id=currency_id)
+ return success_response(data=format_datetime_fields(result, request), request=request)
+
+
+@router.delete("/business/{business_id}/items/{item_id}")
+@require_business_access("business_id")
+def delete_price_item_endpoint(
+ request: Request,
+ business_id: int,
+ item_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
+ ok = delete_price_item(db, business_id, item_id)
+ return success_response({"deleted": ok}, request)
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/product_attributes.py b/hesabixAPI/build/lib/adapters/api/v1/product_attributes.py
new file mode 100644
index 0000000..c87b9f8
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/product_attributes.py
@@ -0,0 +1,124 @@
+from typing import Any, Dict
+
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from app.core.responses import success_response, ApiError, format_datetime_fields
+from adapters.api.v1.schemas import QueryInfo
+from adapters.api.v1.schema_models.product_attribute import (
+ ProductAttributeCreateRequest,
+ ProductAttributeUpdateRequest,
+)
+from app.services.product_attribute_service import (
+ create_attribute,
+ list_attributes,
+ get_attribute,
+ update_attribute,
+ delete_attribute,
+)
+
+
+router = APIRouter(prefix="/product-attributes", tags=["product-attributes"])
+
+
+@router.post("/business/{business_id}")
+@require_business_access("business_id")
+def create_product_attribute(
+ request: Request,
+ business_id: int,
+ payload: ProductAttributeCreateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("product_attributes", "add"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.add", http_status=403)
+ result = create_attribute(db, business_id, payload)
+ return success_response(
+ data=format_datetime_fields(result["data"], request),
+ request=request,
+ message=result.get("message"),
+ )
+
+
+@router.post("/business/{business_id}/search")
+@require_business_access("business_id")
+def search_product_attributes(
+ request: Request,
+ business_id: int,
+ query: QueryInfo,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("product_attributes"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.view", http_status=403)
+
+ result = list_attributes(db, business_id, {
+ "take": query.take,
+ "skip": query.skip,
+ "sort_by": query.sort_by,
+ "sort_desc": query.sort_desc,
+ "search": query.search,
+ "filters": query.filters,
+ })
+ # Format all datetime fields in items/pagination
+ formatted = format_datetime_fields(result, request)
+ return success_response(data=formatted, request=request)
+
+
+@router.get("/business/{business_id}/{attribute_id}")
+@require_business_access("business_id")
+def get_product_attribute(
+ request: Request,
+ business_id: int,
+ attribute_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("product_attributes"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.view", http_status=403)
+ item = get_attribute(db, attribute_id, business_id)
+ if not item:
+ raise ApiError("NOT_FOUND", "Attribute not found", http_status=404)
+ return success_response(data=format_datetime_fields({"item": item}, request), request=request)
+
+
+@router.put("/business/{business_id}/{attribute_id}")
+@require_business_access("business_id")
+def update_product_attribute(
+ request: Request,
+ business_id: int,
+ attribute_id: int,
+ payload: ProductAttributeUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("product_attributes", "edit"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.edit", http_status=403)
+ result = update_attribute(db, attribute_id, business_id, payload)
+ if not result:
+ raise ApiError("NOT_FOUND", "Attribute not found", http_status=404)
+ return success_response(
+ data=format_datetime_fields(result["data"], request),
+ request=request,
+ message=result.get("message"),
+ )
+
+
+@router.delete("/business/{business_id}/{attribute_id}")
+@require_business_access("business_id")
+def delete_product_attribute(
+ request: Request,
+ business_id: int,
+ attribute_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("product_attributes", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.delete", http_status=403)
+ ok = delete_attribute(db, attribute_id, business_id)
+ return success_response({"deleted": ok}, request)
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/products.py b/hesabixAPI/build/lib/adapters/api/v1/products.py
new file mode 100644
index 0000000..a76bfdb
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/products.py
@@ -0,0 +1,509 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from typing import Dict, Any
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from app.core.responses import success_response, ApiError, format_datetime_fields
+from adapters.api.v1.schemas import QueryInfo
+from adapters.api.v1.schema_models.product import (
+ ProductCreateRequest,
+ ProductUpdateRequest,
+)
+from app.services.product_service import (
+ create_product,
+ list_products,
+ get_product,
+ update_product,
+ delete_product,
+)
+from adapters.db.models.business import Business
+from app.core.i18n import negotiate_locale
+
+
+router = APIRouter(prefix="/products", tags=["products"])
+
+
+@router.post("/business/{business_id}")
+@require_business_access("business_id")
+def create_product_endpoint(
+ request: Request,
+ business_id: int,
+ payload: ProductCreateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = create_product(db, business_id, payload)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.post("/business/{business_id}/search")
+@require_business_access("business_id")
+def search_products_endpoint(
+ request: Request,
+ business_id: int,
+ query_info: QueryInfo,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ result = list_products(db, business_id, {
+ "take": query_info.take,
+ "skip": query_info.skip,
+ "sort_by": query_info.sort_by,
+ "sort_desc": query_info.sort_desc,
+ "search": query_info.search,
+ "filters": query_info.filters,
+ })
+ return success_response(data=format_datetime_fields(result, request), request=request)
+
+
+@router.get("/business/{business_id}/{product_id}")
+@require_business_access("business_id")
+def get_product_endpoint(
+ request: Request,
+ business_id: int,
+ product_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+ item = get_product(db, product_id, business_id)
+ if not item:
+ raise ApiError("NOT_FOUND", "Product not found", http_status=404)
+ return success_response(data=format_datetime_fields({"item": item}, request), request=request)
+
+
+@router.put("/business/{business_id}/{product_id}")
+@require_business_access("business_id")
+def update_product_endpoint(
+ request: Request,
+ business_id: int,
+ product_id: int,
+ payload: ProductUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+ result = update_product(db, product_id, business_id, payload)
+ if not result:
+ raise ApiError("NOT_FOUND", "Product not found", http_status=404)
+ return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
+
+
+@router.delete("/business/{business_id}/{product_id}")
+@require_business_access("business_id")
+def delete_product_endpoint(
+ request: Request,
+ business_id: int,
+ product_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
+ ok = delete_product(db, product_id, business_id)
+ return success_response({"deleted": ok}, request)
+
+
+@router.post("/business/{business_id}/export/excel",
+ summary="خروجی Excel لیست محصولات",
+ description="خروجی Excel لیست محصولات با قابلیت فیلتر، انتخاب ستونها و ترتیب آنها",
+)
+@require_business_access("business_id")
+async def export_products_excel(
+ request: Request,
+ business_id: int,
+ body: dict,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ import re
+ import datetime
+ from fastapi.responses import Response
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+
+ query_dict = {
+ "take": int(body.get("take", 1000)),
+ "skip": int(body.get("skip", 0)),
+ "sort_by": body.get("sort_by"),
+ "sort_desc": bool(body.get("sort_desc", False)),
+ "search": body.get("search"),
+ "search_fields": body.get("search_fields"),
+ "filters": body.get("filters"),
+ }
+ result = list_products(db, business_id, query_dict)
+ items = result.get("items", []) if isinstance(result, dict) else result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ # Apply selected indices filter if requested
+ selected_only = bool(body.get('selected_only', False))
+ selected_indices = body.get('selected_indices')
+ if selected_only and selected_indices is not None and isinstance(items, list):
+ indices = None
+ if isinstance(selected_indices, str):
+ try:
+ import json as _json
+ indices = _json.loads(selected_indices)
+ except Exception:
+ indices = None
+ elif isinstance(selected_indices, list):
+ indices = selected_indices
+ if isinstance(indices, list):
+ items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
+
+ export_columns = body.get("export_columns")
+ if export_columns and isinstance(export_columns, list):
+ headers = [col.get("label") or col.get("key") for col in export_columns]
+ keys = [col.get("key") for col in export_columns]
+ else:
+ default_cols = [
+ ("code", "کد"),
+ ("name", "نام"),
+ ("item_type", "نوع"),
+ ("category_id", "دسته"),
+ ("base_sales_price", "قیمت فروش"),
+ ("base_purchase_price", "قیمت خرید"),
+ ("main_unit_id", "واحد اصلی"),
+ ("secondary_unit_id", "واحد فرعی"),
+ ("track_inventory", "کنترل موجودی"),
+ ("created_at_formatted", "ایجاد"),
+ ]
+ keys = [k for k, _ in default_cols]
+ headers = [v for _, v in default_cols]
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Products"
+
+ # Locale and RTL/LTR handling for Excel
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ if locale == 'fa':
+ try:
+ ws.sheet_view.rightToLeft = True
+ except Exception:
+ pass
+
+ # Header style
+ header_font = Font(bold=True)
+ header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid")
+ thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
+
+ ws.append(headers)
+ for cell in ws[1]:
+ cell.font = header_font
+ cell.fill = header_fill
+ cell.alignment = Alignment(horizontal="center")
+ cell.border = thin_border
+
+ for it in items:
+ row = []
+ for k in keys:
+ row.append(it.get(k))
+ ws.append(row)
+ for cell in ws[ws.max_row]:
+ cell.border = thin_border
+ # Align data cells based on locale
+ if locale == 'fa':
+ cell.alignment = Alignment(horizontal="right")
+
+ # Auto width columns
+ try:
+ for column in ws.columns:
+ max_length = 0
+ column_letter = column[0].column_letter
+ for cell in column:
+ try:
+ if cell.value is not None and len(str(cell.value)) > max_length:
+ max_length = len(str(cell.value))
+ except Exception:
+ pass
+ ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
+ except Exception:
+ pass
+
+ output = io.BytesIO()
+ wb.save(output)
+ data = output.getvalue()
+
+ # Build meaningful filename
+ biz_name = ""
+ try:
+ b = db.query(Business).filter(Business.id == business_id).first()
+ if b is not None:
+ biz_name = b.name or ""
+ except Exception:
+ biz_name = ""
+ def slugify(text: str) -> str:
+ return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
+ base = "products"
+ if biz_name:
+ base += f"_{slugify(biz_name)}"
+ if selected_only:
+ base += "_selected"
+ filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+
+ return Response(
+ content=data,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Content-Length": str(len(data)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
+@router.post("/business/{business_id}/export/pdf",
+ summary="خروجی PDF لیست محصولات",
+ description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستونها",
+)
+@require_business_access("business_id")
+async def export_products_pdf(
+ request: Request,
+ business_id: int,
+ body: dict,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import json
+ import datetime
+ import re
+ from fastapi.responses import Response
+ from weasyprint import HTML, CSS
+ from weasyprint.text.fonts import FontConfiguration
+
+ if not ctx.can_read_section("inventory"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
+
+ query_dict = {
+ "take": int(body.get("take", 100)),
+ "skip": int(body.get("skip", 0)),
+ "sort_by": body.get("sort_by"),
+ "sort_desc": bool(body.get("sort_desc", False)),
+ "search": body.get("search"),
+ "search_fields": body.get("search_fields"),
+ "filters": body.get("filters"),
+ }
+ result = list_products(db, business_id, query_dict)
+ items = result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ # Apply selected indices filter if requested
+ selected_only = bool(body.get('selected_only', False))
+ selected_indices = body.get('selected_indices')
+ if selected_only and selected_indices is not None:
+ indices = None
+ if isinstance(selected_indices, str):
+ try:
+ indices = json.loads(selected_indices)
+ except (json.JSONDecodeError, TypeError):
+ indices = None
+ elif isinstance(selected_indices, list):
+ indices = selected_indices
+ if isinstance(indices, list):
+ items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
+
+ export_columns = body.get("export_columns")
+ if export_columns and isinstance(export_columns, list):
+ headers = [col.get("label") or col.get("key") for col in export_columns]
+ keys = [col.get("key") for col in export_columns]
+ else:
+ default_cols = [
+ ("code", "کد"),
+ ("name", "نام"),
+ ("item_type", "نوع"),
+ ("category_id", "دسته"),
+ ("base_sales_price", "قیمت فروش"),
+ ("base_purchase_price", "قیمت خرید"),
+ ("main_unit_id", "واحد اصلی"),
+ ("secondary_unit_id", "واحد فرعی"),
+ ("track_inventory", "کنترل موجودی"),
+ ("created_at_formatted", "ایجاد"),
+ ]
+ keys = [k for k, _ in default_cols]
+ headers = [v for _, v in default_cols]
+
+ # Locale and direction
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ is_fa = (locale == 'fa')
+ html_lang = 'fa' if is_fa else 'en'
+ html_dir = 'rtl' if is_fa else 'ltr'
+
+ # Load business info for header
+ business_name = ""
+ try:
+ biz = db.query(Business).filter(Business.id == business_id).first()
+ if biz is not None:
+ business_name = biz.name or ""
+ except Exception:
+ business_name = ""
+
+ # Escape helper
+ def escape(s: Any) -> str:
+ try:
+ return str(s).replace('&', '&').replace('<', '<').replace('>', '>')
+ except Exception:
+ return str(s)
+
+ # Build rows
+ rows_html = []
+ for item in items:
+ tds = []
+ for key in keys:
+ value = item.get(key)
+ if value is None:
+ value = ""
+ elif isinstance(value, list):
+ value = ", ".join(str(v) for v in value)
+ tds.append(f"{escape(value)} | ")
+ rows_html.append(f"{''.join(tds)}
")
+
+ headers_html = ''.join(f"{escape(h)} | " for h in headers)
+
+ # Format report datetime based on X-Calendar-Type header
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
+ try:
+ from app.core.calendar import CalendarConverter
+ formatted_now = CalendarConverter.format_datetime(datetime.datetime.now(),
+ "jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian")
+ now_str = formatted_now.get('formatted', formatted_now.get('date_time', ''))
+ except Exception:
+ now_str = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
+
+ title_text = "گزارش فهرست محصولات" if is_fa else "Products List Report"
+ label_biz = "نام کسبوکار" if is_fa else "Business Name"
+ label_date = "تاریخ گزارش" if is_fa else "Report Date"
+ footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
+ page_label_left = "صفحه " if is_fa else "Page "
+ page_label_of = " از " if is_fa else " of "
+
+ table_html = f"""
+
+
+
+
+
+
+
+
+
+
+ {headers_html}
+
+
+ {''.join(rows_html)}
+
+
+
+
+
+
+ """
+
+ font_config = FontConfiguration()
+ pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
+
+ # Build meaningful filename
+ biz_name = business_name
+ def slugify(text: str) -> str:
+ return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
+ base = "products"
+ if biz_name:
+ base += f"_{slugify(biz_name)}"
+ if selected_only:
+ base += "_selected"
+ filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
+
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Content-Length": str(len(pdf_bytes)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/__init__.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/__init__.py
new file mode 100644
index 0000000..03f73ed
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/__init__.py
@@ -0,0 +1,10 @@
+# This file makes the directory a Python package
+
+# Import from file_storage module
+from .file_storage import *
+
+# Re-export from parent schemas module
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
+from schemas import *
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/account.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/account.py
new file mode 100644
index 0000000..581b091
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/account.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from typing import List, Optional
+from pydantic import BaseModel, Field
+
+
+class AccountTreeNode(BaseModel):
+ id: int = Field(..., description="ID حساب")
+ code: str = Field(..., description="کد حساب")
+ name: str = Field(..., description="نام حساب")
+ account_type: Optional[str] = Field(default=None, description="نوع حساب")
+ parent_id: Optional[int] = Field(default=None, description="شناسه والد")
+ level: Optional[int] = Field(default=None, description="سطح حساب در درخت")
+ children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان")
+
+ class Config:
+ from_attributes = True
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/email.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/email.py
new file mode 100644
index 0000000..633f6e7
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/email.py
@@ -0,0 +1,59 @@
+from pydantic import BaseModel, EmailStr, Field
+from typing import Optional
+from datetime import datetime
+
+
+class EmailConfigBase(BaseModel):
+ name: str = Field(..., min_length=1, max_length=100, description="Configuration name")
+ smtp_host: str = Field(..., min_length=1, max_length=255, description="SMTP host")
+ smtp_port: int = Field(..., ge=1, le=65535, description="SMTP port")
+ smtp_username: str = Field(..., min_length=1, max_length=255, description="SMTP username")
+ smtp_password: str = Field(..., min_length=1, max_length=255, description="SMTP password")
+ use_tls: bool = Field(default=True, description="Use TLS encryption")
+ use_ssl: bool = Field(default=False, description="Use SSL encryption")
+ from_email: EmailStr = Field(..., description="From email address")
+ from_name: str = Field(..., min_length=1, max_length=100, description="From name")
+ is_active: bool = Field(default=True, description="Is this configuration active")
+ is_default: bool = Field(default=False, description="Is this the default configuration")
+
+
+class EmailConfigCreate(EmailConfigBase):
+ pass
+
+
+class EmailConfigUpdate(BaseModel):
+ name: Optional[str] = Field(None, min_length=1, max_length=100)
+ smtp_host: Optional[str] = Field(None, min_length=1, max_length=255)
+ smtp_port: Optional[int] = Field(None, ge=1, le=65535)
+ smtp_username: Optional[str] = Field(None, min_length=1, max_length=255)
+ smtp_password: Optional[str] = Field(None, min_length=1, max_length=255)
+ use_tls: Optional[bool] = None
+ use_ssl: Optional[bool] = None
+ from_email: Optional[EmailStr] = None
+ from_name: Optional[str] = Field(None, min_length=1, max_length=100)
+ is_active: Optional[bool] = None
+ is_default: Optional[bool] = None
+
+
+class EmailConfigResponse(EmailConfigBase):
+ id: int
+ created_at: datetime
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class SendEmailRequest(BaseModel):
+ to: EmailStr = Field(..., description="Recipient email address")
+ subject: str = Field(..., min_length=1, max_length=255, description="Email subject")
+ body: str = Field(..., min_length=1, description="Email body (plain text)")
+ html_body: Optional[str] = Field(None, description="Email body (HTML)")
+ config_id: Optional[int] = Field(None, description="Specific config ID to use")
+
+
+class TestConnectionRequest(BaseModel):
+ config_id: int = Field(..., description="Configuration ID to test")
+
+
+# These response models are no longer needed as we use SuccessResponse from schemas.py
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/file_storage.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/file_storage.py
new file mode 100644
index 0000000..ab6dc56
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/file_storage.py
@@ -0,0 +1,80 @@
+from typing import Optional, Dict, Any, List
+from uuid import UUID
+from pydantic import BaseModel, Field
+from datetime import datetime
+
+
+# Request Models
+class StorageConfigCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=100, description="نام پیکربندی")
+ storage_type: str = Field(..., description="نوع ذخیرهسازی")
+ config_data: Dict[str, Any] = Field(..., description="دادههای پیکربندی")
+ is_default: bool = Field(default=False, description="آیا پیشفرض است")
+ is_active: bool = Field(default=True, description="آیا فعال است")
+
+
+class StorageConfigUpdateRequest(BaseModel):
+ name: Optional[str] = Field(default=None, min_length=1, max_length=100, description="نام پیکربندی")
+ config_data: Optional[Dict[str, Any]] = Field(default=None, description="دادههای پیکربندی")
+ is_active: Optional[bool] = Field(default=None, description="آیا فعال است")
+
+
+class FileUploadRequest(BaseModel):
+ module_context: str = Field(..., description="زمینه ماژول")
+ context_id: Optional[UUID] = Field(default=None, description="شناسه زمینه")
+ developer_data: Optional[Dict[str, Any]] = Field(default=None, description="دادههای توسعهدهنده")
+ is_temporary: bool = Field(default=False, description="آیا فایل موقت است")
+ expires_in_days: int = Field(default=30, ge=1, le=365, description="تعداد روزهای انقضا")
+
+
+class FileVerificationRequest(BaseModel):
+ verification_data: Dict[str, Any] = Field(..., description="دادههای تایید")
+
+
+# Response Models
+class FileInfo(BaseModel):
+ file_id: str = Field(..., description="شناسه فایل")
+ original_name: str = Field(..., description="نام اصلی فایل")
+ file_size: int = Field(..., description="حجم فایل")
+ mime_type: str = Field(..., description="نوع فایل")
+ is_temporary: bool = Field(..., description="آیا موقت است")
+ is_verified: bool = Field(..., description="آیا تایید شده است")
+ created_at: str = Field(..., description="تاریخ ایجاد")
+ expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
+
+ class Config:
+ from_attributes = True
+
+
+class FileUploadResponse(BaseModel):
+ file_id: str = Field(..., description="شناسه فایل")
+ original_name: str = Field(..., description="نام اصلی فایل")
+ file_size: int = Field(..., description="حجم فایل")
+ mime_type: str = Field(..., description="نوع فایل")
+ is_temporary: bool = Field(..., description="آیا موقت است")
+ verification_token: Optional[str] = Field(default=None, description="توکن تایید")
+ expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
+
+
+class StorageConfigResponse(BaseModel):
+ id: str = Field(..., description="شناسه پیکربندی")
+ name: str = Field(..., description="نام پیکربندی")
+ storage_type: str = Field(..., description="نوع ذخیرهسازی")
+ is_default: bool = Field(..., description="آیا پیشفرض است")
+ is_active: bool = Field(..., description="آیا فعال است")
+ created_at: str = Field(..., description="تاریخ ایجاد")
+
+ class Config:
+ from_attributes = True
+
+
+class FileStatisticsResponse(BaseModel):
+ total_files: int = Field(..., description="کل فایلها")
+ total_size: int = Field(..., description="حجم کل")
+ temporary_files: int = Field(..., description="فایلهای موقت")
+ unverified_files: int = Field(..., description="فایلهای تایید نشده")
+
+
+class CleanupResponse(BaseModel):
+ cleaned_files: int = Field(..., description="تعداد فایلهای پاکسازی شده")
+ total_unverified: int = Field(..., description="کل فایلهای تایید نشده")
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/person.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/person.py
new file mode 100644
index 0000000..3772a4b
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/person.py
@@ -0,0 +1,242 @@
+from typing import List, Optional
+from pydantic import BaseModel, Field
+from enum import Enum
+from datetime import datetime
+
+
+class PersonType(str, Enum):
+ """نوع شخص"""
+ CUSTOMER = "مشتری"
+ MARKETER = "بازاریاب"
+ EMPLOYEE = "کارمند"
+ SUPPLIER = "تامینکننده"
+ PARTNER = "همکار"
+ SELLER = "فروشنده"
+ SHAREHOLDER = "سهامدار"
+
+
+class PersonBankAccountCreateRequest(BaseModel):
+ """درخواست ایجاد حساب بانکی شخص"""
+ bank_name: str = Field(..., min_length=1, max_length=255, description="نام بانک")
+ account_number: Optional[str] = Field(default=None, max_length=50, description="شماره حساب")
+ card_number: Optional[str] = Field(default=None, max_length=20, description="شماره کارت")
+ sheba_number: Optional[str] = Field(default=None, max_length=30, description="شماره شبا")
+
+
+class PersonBankAccountUpdateRequest(BaseModel):
+ """درخواست ویرایش حساب بانکی شخص"""
+ bank_name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام بانک")
+ account_number: Optional[str] = Field(default=None, max_length=50, description="شماره حساب")
+ card_number: Optional[str] = Field(default=None, max_length=20, description="شماره کارت")
+ sheba_number: Optional[str] = Field(default=None, max_length=30, description="شماره شبا")
+
+
+class PersonBankAccountResponse(BaseModel):
+ """پاسخ اطلاعات حساب بانکی شخص"""
+ id: int = Field(..., description="شناسه حساب بانکی")
+ person_id: int = Field(..., description="شناسه شخص")
+ bank_name: str = Field(..., description="نام بانک")
+ account_number: Optional[str] = Field(default=None, description="شماره حساب")
+ card_number: Optional[str] = Field(default=None, description="شماره کارت")
+ sheba_number: Optional[str] = Field(default=None, description="شماره شبا")
+ created_at: str = Field(..., description="تاریخ ایجاد")
+ updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
+
+ class Config:
+ from_attributes = True
+
+
+class PersonCreateRequest(BaseModel):
+ """درخواست ایجاد شخص جدید"""
+ # اطلاعات پایه
+ code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار (در صورت عدم ارسال، خودکار تولید میشود)")
+ alias_name: str = Field(..., min_length=1, max_length=255, description="نام مستعار (الزامی)")
+ first_name: Optional[str] = Field(default=None, max_length=100, description="نام")
+ last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی")
+ person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)")
+ person_types: Optional[List[PersonType]] = Field(default=None, description="انواع شخص (چندانتخابی)")
+ company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت")
+ payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت")
+
+ # اطلاعات اقتصادی
+ national_id: Optional[str] = Field(default=None, max_length=20, description="شناسه ملی")
+ registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
+ economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
+
+ # اطلاعات تماس
+ country: Optional[str] = Field(default=None, max_length=100, description="کشور")
+ province: Optional[str] = Field(default=None, max_length=100, description="استان")
+ city: Optional[str] = Field(default=None, max_length=100, description="شهرستان")
+ address: Optional[str] = Field(default=None, description="آدرس")
+ postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
+ phone: Optional[str] = Field(default=None, max_length=20, description="تلفن")
+ mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
+ fax: Optional[str] = Field(default=None, max_length=20, description="فکس")
+ email: Optional[str] = Field(default=None, max_length=255, description="پست الکترونیکی")
+ website: Optional[str] = Field(default=None, max_length=255, description="وبسایت")
+
+ # حسابهای بانکی
+ bank_accounts: Optional[List[PersonBankAccountCreateRequest]] = Field(default=[], description="حسابهای بانکی")
+ # سهام
+ share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار، اجباری و حداقل 1)")
+ # پورسانت (برای بازاریاب/فروشنده)
+ commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش")
+ commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش")
+ commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا")
+ commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا")
+ commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف")
+ commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات")
+ commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور")
+
+ @classmethod
+ def __get_validators__(cls):
+ yield from super().__get_validators__()
+
+ @staticmethod
+ def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool:
+ if person_type == PersonType.SHAREHOLDER:
+ return True
+ if person_types:
+ return PersonType.SHAREHOLDER in person_types
+ return False
+
+ @classmethod
+ def validate(cls, value): # type: ignore[override]
+ obj = super().validate(value)
+ # اعتبارسنجی شرطی سهامدار
+ if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)):
+ sc = getattr(obj, 'share_count', None)
+ if sc is None or (isinstance(sc, int) and sc <= 0):
+ raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد")
+ return obj
+
+
+class PersonUpdateRequest(BaseModel):
+ """درخواست ویرایش شخص"""
+ # اطلاعات پایه
+ code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار")
+ alias_name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام مستعار")
+ first_name: Optional[str] = Field(default=None, max_length=100, description="نام")
+ last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی")
+ person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)")
+ person_types: Optional[List[PersonType]] = Field(default=None, description="انواع شخص (چندانتخابی)")
+ company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت")
+ payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت")
+
+ # اطلاعات اقتصادی
+ national_id: Optional[str] = Field(default=None, max_length=20, description="شناسه ملی")
+ registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
+ economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
+
+ # اطلاعات تماس
+ country: Optional[str] = Field(default=None, max_length=100, description="کشور")
+ province: Optional[str] = Field(default=None, max_length=100, description="استان")
+ city: Optional[str] = Field(default=None, max_length=100, description="شهرستان")
+ address: Optional[str] = Field(default=None, description="آدرس")
+ postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
+ phone: Optional[str] = Field(default=None, max_length=20, description="تلفن")
+ mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
+ fax: Optional[str] = Field(default=None, max_length=20, description="فکس")
+ email: Optional[str] = Field(default=None, max_length=255, description="پست الکترونیکی")
+ website: Optional[str] = Field(default=None, max_length=255, description="وبسایت")
+
+ # سهام
+ share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار)")
+ # پورسانت
+ commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش")
+ commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش")
+ commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا")
+ commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا")
+ commission_exclude_discounts: Optional[bool] = Field(default=None, description="عدم محاسبه تخفیف")
+ commission_exclude_additions_deductions: Optional[bool] = Field(default=None, description="عدم محاسبه اضافات و کسورات")
+ commission_post_in_invoice_document: Optional[bool] = Field(default=None, description="ثبت پورسانت در سند فاکتور")
+
+ @classmethod
+ def __get_validators__(cls):
+ yield from super().__get_validators__()
+
+ @staticmethod
+ def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool:
+ if person_type == PersonType.SHAREHOLDER:
+ return True
+ if person_types:
+ return PersonType.SHAREHOLDER in person_types
+ return False
+
+ @classmethod
+ def validate(cls, value): # type: ignore[override]
+ obj = super().validate(value)
+ # اگر ورودیها مشخصاً به سهامدار اشاره دارند، share_count باید معتبر باشد
+ if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)):
+ sc = getattr(obj, 'share_count', None)
+ if sc is None or (isinstance(sc, int) and sc <= 0):
+ raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد")
+ return obj
+
+
+class PersonResponse(BaseModel):
+ """پاسخ اطلاعات شخص"""
+ id: int = Field(..., description="شناسه شخص")
+ business_id: int = Field(..., description="شناسه کسب و کار")
+
+ # اطلاعات پایه
+ code: Optional[int] = Field(default=None, description="کد یکتا")
+ alias_name: str = Field(..., description="نام مستعار")
+ first_name: Optional[str] = Field(default=None, description="نام")
+ last_name: Optional[str] = Field(default=None, description="نام خانوادگی")
+ person_type: str = Field(..., description="نوع شخص")
+ person_types: List[str] = Field(default_factory=list, description="انواع شخص")
+ company_name: Optional[str] = Field(default=None, description="نام شرکت")
+ payment_id: Optional[str] = Field(default=None, description="شناسه پرداخت")
+
+ # اطلاعات اقتصادی
+ national_id: Optional[str] = Field(default=None, description="شناسه ملی")
+ registration_number: Optional[str] = Field(default=None, description="شماره ثبت")
+ economic_id: Optional[str] = Field(default=None, description="شناسه اقتصادی")
+
+ # اطلاعات تماس
+ country: Optional[str] = Field(default=None, description="کشور")
+ province: Optional[str] = Field(default=None, description="استان")
+ city: Optional[str] = Field(default=None, description="شهرستان")
+ address: Optional[str] = Field(default=None, description="آدرس")
+ postal_code: Optional[str] = Field(default=None, description="کد پستی")
+ phone: Optional[str] = Field(default=None, description="تلفن")
+ mobile: Optional[str] = Field(default=None, description="موبایل")
+ fax: Optional[str] = Field(default=None, description="فکس")
+ email: Optional[str] = Field(default=None, description="پست الکترونیکی")
+ website: Optional[str] = Field(default=None, description="وبسایت")
+
+ # زمانبندی
+ created_at: str = Field(..., description="تاریخ ایجاد")
+ updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
+
+ # حسابهای بانکی
+ bank_accounts: List[PersonBankAccountResponse] = Field(default=[], description="حسابهای بانکی")
+ # سهام
+ share_count: Optional[int] = Field(default=None, description="تعداد سهام")
+ # پورسانت
+ commission_sale_percent: Optional[float] = Field(default=None, description="درصد پورسانت از فروش")
+ commission_sales_return_percent: Optional[float] = Field(default=None, description="درصد پورسانت از برگشت از فروش")
+ commission_sales_amount: Optional[float] = Field(default=None, description="مبلغ فروش مبنا")
+ commission_sales_return_amount: Optional[float] = Field(default=None, description="مبلغ برگشت از فروش مبنا")
+ commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف")
+ commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات")
+ commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور")
+
+ class Config:
+ from_attributes = True
+
+
+class PersonListResponse(BaseModel):
+ """پاسخ لیست اشخاص"""
+ items: List[PersonResponse] = Field(..., description="لیست اشخاص")
+ pagination: dict = Field(..., description="اطلاعات صفحهبندی")
+ query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر")
+
+
+class PersonSummaryResponse(BaseModel):
+ """پاسخ خلاصه اشخاص"""
+ total_persons: int = Field(..., description="تعداد کل اشخاص")
+ by_type: dict = Field(..., description="تعداد بر اساس نوع")
+ active_persons: int = Field(..., description="تعداد اشخاص فعال")
+ inactive_persons: int = Field(..., description="تعداد اشخاص غیرفعال")
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/price_list.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/price_list.py
new file mode 100644
index 0000000..7849770
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/price_list.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from typing import Optional, List
+from decimal import Decimal
+from pydantic import BaseModel, Field
+
+
+class PriceListCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255)
+ is_active: bool = True
+
+
+class PriceListUpdateRequest(BaseModel):
+ name: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ is_active: Optional[bool] = None
+
+
+class PriceItemUpsertRequest(BaseModel):
+ product_id: int
+ unit_id: Optional[int] = None
+ currency_id: int
+ tier_name: Optional[str] = Field(default=None, min_length=1, max_length=64)
+ min_qty: Decimal = Field(default=0)
+ price: Decimal
+
+
+class PriceListResponse(BaseModel):
+ id: int
+ business_id: int
+ name: str
+ is_active: bool
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
+class PriceItemResponse(BaseModel):
+ id: int
+ price_list_id: int
+ product_id: int
+ unit_id: Optional[int] = None
+ currency_id: int
+ tier_name: str
+ min_qty: Decimal
+ price: Decimal
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py
new file mode 100644
index 0000000..129bf46
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from typing import Optional, List
+from decimal import Decimal
+from pydantic import BaseModel, Field
+from enum import Enum
+
+
+class ProductItemType(str, Enum):
+ PRODUCT = "کالا"
+ SERVICE = "خدمت"
+
+
+class ProductCreateRequest(BaseModel):
+ item_type: ProductItemType = Field(default=ProductItemType.PRODUCT)
+ code: Optional[str] = Field(default=None, max_length=64)
+ name: str = Field(..., min_length=1, max_length=255)
+ description: Optional[str] = Field(default=None, max_length=2000)
+ category_id: Optional[int] = None
+
+ main_unit_id: Optional[int] = None
+ secondary_unit_id: Optional[int] = None
+ unit_conversion_factor: Optional[Decimal] = None
+
+ base_sales_price: Optional[Decimal] = None
+ base_sales_note: Optional[str] = None
+ base_purchase_price: Optional[Decimal] = None
+ base_purchase_note: Optional[str] = None
+
+ track_inventory: bool = Field(default=False)
+ reorder_point: Optional[int] = None
+ min_order_qty: Optional[int] = None
+ lead_time_days: Optional[int] = None
+
+ is_sales_taxable: bool = Field(default=False)
+ is_purchase_taxable: bool = Field(default=False)
+ sales_tax_rate: Optional[Decimal] = None
+ purchase_tax_rate: Optional[Decimal] = None
+ tax_type_id: Optional[int] = None
+ tax_code: Optional[str] = Field(default=None, max_length=100)
+ tax_unit_id: Optional[int] = None
+
+ attribute_ids: Optional[List[int]] = Field(default=None, description="ویژگیهای انتخابی برای لینک شدن")
+
+
+class ProductUpdateRequest(BaseModel):
+ item_type: Optional[ProductItemType] = None
+ code: Optional[str] = Field(default=None, max_length=64)
+ name: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ description: Optional[str] = Field(default=None, max_length=2000)
+ category_id: Optional[int] = None
+
+ main_unit_id: Optional[int] = None
+ secondary_unit_id: Optional[int] = None
+ unit_conversion_factor: Optional[Decimal] = None
+
+ base_sales_price: Optional[Decimal] = None
+ base_sales_note: Optional[str] = None
+ base_purchase_price: Optional[Decimal] = None
+ base_purchase_note: Optional[str] = None
+
+ track_inventory: Optional[bool] = None
+ reorder_point: Optional[int] = None
+ min_order_qty: Optional[int] = None
+ lead_time_days: Optional[int] = None
+
+ is_sales_taxable: Optional[bool] = None
+ is_purchase_taxable: Optional[bool] = None
+ sales_tax_rate: Optional[Decimal] = None
+ purchase_tax_rate: Optional[Decimal] = None
+ tax_type_id: Optional[int] = None
+ tax_code: Optional[str] = Field(default=None, max_length=100)
+ tax_unit_id: Optional[int] = None
+
+ attribute_ids: Optional[List[int]] = None
+
+
+class ProductResponse(BaseModel):
+ id: int
+ business_id: int
+ item_type: str
+ code: str
+ name: str
+ description: Optional[str] = None
+ category_id: Optional[int] = None
+ main_unit_id: Optional[int] = None
+ secondary_unit_id: Optional[int] = None
+ unit_conversion_factor: Optional[Decimal] = None
+ base_sales_price: Optional[Decimal] = None
+ base_sales_note: Optional[str] = None
+ base_purchase_price: Optional[Decimal] = None
+ base_purchase_note: Optional[str] = None
+ track_inventory: bool
+ reorder_point: Optional[int] = None
+ min_order_qty: Optional[int] = None
+ lead_time_days: Optional[int] = None
+ is_sales_taxable: bool
+ is_purchase_taxable: bool
+ sales_tax_rate: Optional[Decimal] = None
+ purchase_tax_rate: Optional[Decimal] = None
+ tax_type_id: Optional[int] = None
+ tax_code: Optional[str] = None
+ tax_unit_id: Optional[int] = None
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/product_attribute.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product_attribute.py
new file mode 100644
index 0000000..1af0a88
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product_attribute.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import Optional, List
+from pydantic import BaseModel, Field
+
+
+class ProductAttributeCreateRequest(BaseModel):
+ title: str = Field(..., min_length=1, max_length=255, description="عنوان ویژگی")
+ description: Optional[str] = Field(default=None, description="توضیحات ویژگی")
+
+
+class ProductAttributeUpdateRequest(BaseModel):
+ title: Optional[str] = Field(default=None, min_length=1, max_length=255, description="عنوان ویژگی")
+ description: Optional[str] = Field(default=None, description="توضیحات ویژگی")
+
+
+class ProductAttributeResponse(BaseModel):
+ id: int
+ business_id: int
+ title: str
+ description: Optional[str] = None
+ created_at: str
+ updated_at: str
+
+
+class ProductAttributeListResponse(BaseModel):
+ items: list[ProductAttributeResponse]
+ pagination: dict
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schemas.py b/hesabixAPI/build/lib/adapters/api/v1/schemas.py
new file mode 100644
index 0000000..e7fdf86
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schemas.py
@@ -0,0 +1,339 @@
+from typing import Any, List, Optional, Union, Generic, TypeVar
+from pydantic import BaseModel, EmailStr, Field
+from enum import Enum
+from datetime import datetime, date
+
+T = TypeVar('T')
+
+
+class FilterItem(BaseModel):
+ property: str = Field(..., description="نام فیلد مورد نظر برای اعمال فیلتر")
+ operator: str = Field(..., description="نوع عملگر: =, >, >=, <, <=, !=, *, ?*, *?, in")
+ value: Any = Field(..., description="مقدار مورد نظر")
+
+
+class QueryInfo(BaseModel):
+ sort_by: Optional[str] = Field(default=None, description="نام فیلد مورد نظر برای مرتب سازی")
+ sort_desc: bool = Field(default=False, description="false = مرتب سازی صعودی، true = مرتب سازی نزولی")
+ take: int = Field(default=10, ge=1, le=1000, description="حداکثر تعداد رکورد بازگشتی")
+ skip: int = Field(default=0, ge=0, description="تعداد رکوردی که از ابتدای لیست صرف نظر می شود")
+ search: Optional[str] = Field(default=None, description="عبارت جستجو")
+ search_fields: Optional[List[str]] = Field(default=None, description="آرایه ای از فیلدهایی که جستجو در آن انجام می گیرد")
+ filters: Optional[List[FilterItem]] = Field(default=None, description="آرایه ای از اشیا برای اعمال فیلتر بر روی لیست")
+
+
+class CaptchaSolve(BaseModel):
+ captcha_id: str = Field(..., min_length=8)
+ captcha_code: str = Field(..., min_length=3, max_length=8)
+
+
+class RegisterRequest(CaptchaSolve):
+ first_name: Optional[str] = Field(default=None, max_length=100)
+ last_name: Optional[str] = Field(default=None, max_length=100)
+ email: Optional[EmailStr] = None
+ mobile: Optional[str] = Field(default=None, max_length=32)
+ password: str = Field(..., min_length=8, max_length=128)
+ device_id: Optional[str] = Field(default=None, max_length=100)
+ referrer_code: Optional[str] = Field(default=None, min_length=4, max_length=32)
+
+
+class LoginRequest(CaptchaSolve):
+ identifier: str = Field(..., min_length=3, max_length=255)
+ password: str = Field(..., min_length=8, max_length=128)
+ device_id: Optional[str] = Field(default=None, max_length=100)
+
+
+class ForgotPasswordRequest(CaptchaSolve):
+ identifier: str = Field(..., min_length=3, max_length=255)
+
+
+class ResetPasswordRequest(CaptchaSolve):
+ token: str = Field(..., min_length=16)
+ new_password: str = Field(..., min_length=8, max_length=128)
+
+
+class ChangePasswordRequest(BaseModel):
+ current_password: str = Field(..., min_length=8, max_length=128)
+ new_password: str = Field(..., min_length=8, max_length=128)
+ confirm_password: str = Field(..., min_length=8, max_length=128)
+
+
+class CreateApiKeyRequest(BaseModel):
+ name: Optional[str] = Field(default=None, max_length=100)
+ scopes: Optional[str] = Field(default=None, max_length=500)
+ expires_at: Optional[str] = None # ISO string; parse server-side if provided
+
+
+# Response Models
+class SuccessResponse(BaseModel):
+ success: bool = Field(default=True, description="وضعیت موفقیت عملیات")
+ message: Optional[str] = Field(default=None, description="پیام توضیحی")
+ data: Optional[Union[dict, list]] = Field(default=None, description="دادههای بازگشتی")
+
+
+class ErrorResponse(BaseModel):
+ success: bool = Field(default=False, description="وضعیت موفقیت عملیات")
+ message: str = Field(..., description="پیام خطا")
+ error_code: Optional[str] = Field(default=None, description="کد خطا")
+ details: Optional[dict] = Field(default=None, description="جزئیات خطا")
+
+
+class UserResponse(BaseModel):
+ id: int = Field(..., description="شناسه کاربر")
+ email: Optional[str] = Field(default=None, description="ایمیل کاربر")
+ mobile: Optional[str] = Field(default=None, description="شماره موبایل")
+ first_name: Optional[str] = Field(default=None, description="نام")
+ last_name: Optional[str] = Field(default=None, description="نام خانوادگی")
+ is_active: bool = Field(..., description="وضعیت فعال بودن")
+ referral_code: str = Field(..., description="کد معرفی")
+ referred_by_user_id: Optional[int] = Field(default=None, description="شناسه کاربر معرف")
+ app_permissions: Optional[dict] = Field(default=None, description="مجوزهای اپلیکیشن")
+ created_at: str = Field(..., description="تاریخ ایجاد")
+ updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
+
+
+class CaptchaResponse(BaseModel):
+ captcha_id: str = Field(..., description="شناسه کپچا")
+ image_base64: str = Field(..., description="تصویر کپچا به صورت base64")
+ ttl_seconds: int = Field(..., description="زمان انقضا به ثانیه")
+
+
+class LoginResponse(BaseModel):
+ api_key: str = Field(..., description="کلید API")
+ expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
+ user: UserResponse = Field(..., description="اطلاعات کاربر")
+
+
+class ApiKeyResponse(BaseModel):
+ id: int = Field(..., description="شناسه کلید")
+ name: Optional[str] = Field(default=None, description="نام کلید")
+ scopes: Optional[str] = Field(default=None, description="محدوده دسترسی")
+ device_id: Optional[str] = Field(default=None, description="شناسه دستگاه")
+ user_agent: Optional[str] = Field(default=None, description="اطلاعات مرورگر")
+ ip: Optional[str] = Field(default=None, description="آدرس IP")
+ expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
+ last_used_at: Optional[str] = Field(default=None, description="آخرین استفاده")
+ created_at: str = Field(..., description="تاریخ ایجاد")
+
+
+class ReferralStatsResponse(BaseModel):
+ total_referrals: int = Field(..., description="تعداد کل معرفیها")
+ active_referrals: int = Field(..., description="تعداد معرفیهای فعال")
+ recent_referrals: int = Field(..., description="تعداد معرفیهای اخیر")
+ referral_rate: float = Field(..., description="نرخ معرفی")
+
+
+class PaginationInfo(BaseModel):
+ total: int = Field(..., description="تعداد کل رکوردها")
+ page: int = Field(..., description="شماره صفحه فعلی")
+ per_page: int = Field(..., description="تعداد رکورد در هر صفحه")
+ total_pages: int = Field(..., description="تعداد کل صفحات")
+ has_next: bool = Field(..., description="آیا صفحه بعدی وجود دارد")
+ has_prev: bool = Field(..., description="آیا صفحه قبلی وجود دارد")
+
+
+class UsersListResponse(BaseModel):
+ items: List[UserResponse] = Field(..., description="لیست کاربران")
+ pagination: PaginationInfo = Field(..., description="اطلاعات صفحهبندی")
+ query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر")
+
+
+class UsersSummaryResponse(BaseModel):
+ total_users: int = Field(..., description="تعداد کل کاربران")
+ active_users: int = Field(..., description="تعداد کاربران فعال")
+ inactive_users: int = Field(..., description="تعداد کاربران غیرفعال")
+ active_percentage: float = Field(..., description="درصد کاربران فعال")
+
+
+# Business Schemas
+class BusinessType(str, Enum):
+ COMPANY = "شرکت"
+ SHOP = "مغازه"
+ STORE = "فروشگاه"
+ UNION = "اتحادیه"
+ CLUB = "باشگاه"
+ INSTITUTE = "موسسه"
+ INDIVIDUAL = "شخصی"
+
+
+class BusinessField(str, Enum):
+ MANUFACTURING = "تولیدی"
+ COMMERCIAL = "بازرگانی"
+ SERVICE = "خدماتی"
+ OTHER = "سایر"
+
+
+class BusinessCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255, description="نام کسب و کار")
+ business_type: BusinessType = Field(..., description="نوع کسب و کار")
+ business_field: BusinessField = Field(..., description="زمینه فعالیت")
+ address: Optional[str] = Field(default=None, max_length=1000, description="آدرس")
+ phone: Optional[str] = Field(default=None, max_length=20, description="تلفن ثابت")
+ mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
+ national_id: Optional[str] = Field(default=None, max_length=20, description="کد ملی")
+ registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
+ economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
+ country: Optional[str] = Field(default=None, max_length=100, description="کشور")
+ province: Optional[str] = Field(default=None, max_length=100, description="استان")
+ city: Optional[str] = Field(default=None, max_length=100, description="شهر")
+ postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
+ fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سالهای مالی برای ایجاد اولیه")
+ default_currency_id: Optional[int] = Field(default=None, description="شناسه ارز پیشفرض")
+ currency_ids: Optional[List[int]] = Field(default=None, description="لیست شناسه ارزهای قابل استفاده")
+
+
+class BusinessUpdateRequest(BaseModel):
+ name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام کسب و کار")
+ business_type: Optional[BusinessType] = Field(default=None, description="نوع کسب و کار")
+ business_field: Optional[BusinessField] = Field(default=None, description="زمینه فعالیت")
+ address: Optional[str] = Field(default=None, max_length=1000, description="آدرس")
+ phone: Optional[str] = Field(default=None, max_length=20, description="تلفن ثابت")
+ mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
+ national_id: Optional[str] = Field(default=None, max_length=20, description="کد ملی")
+ registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
+ economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
+ country: Optional[str] = Field(default=None, max_length=100, description="کشور")
+ province: Optional[str] = Field(default=None, max_length=100, description="استان")
+ city: Optional[str] = Field(default=None, max_length=100, description="شهر")
+ postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
+
+
+class BusinessResponse(BaseModel):
+ id: int = Field(..., description="شناسه کسب و کار")
+ name: str = Field(..., description="نام کسب و کار")
+ business_type: str = Field(..., description="نوع کسب و کار")
+ business_field: str = Field(..., description="زمینه فعالیت")
+ owner_id: int = Field(..., description="شناسه مالک")
+ address: Optional[str] = Field(default=None, description="آدرس")
+ phone: Optional[str] = Field(default=None, description="تلفن ثابت")
+ mobile: Optional[str] = Field(default=None, description="موبایل")
+ national_id: Optional[str] = Field(default=None, description="کد ملی")
+ registration_number: Optional[str] = Field(default=None, description="شماره ثبت")
+ economic_id: Optional[str] = Field(default=None, description="شناسه اقتصادی")
+ country: Optional[str] = Field(default=None, description="کشور")
+ province: Optional[str] = Field(default=None, description="استان")
+ city: Optional[str] = Field(default=None, description="شهر")
+ postal_code: Optional[str] = Field(default=None, description="کد پستی")
+ created_at: str = Field(..., description="تاریخ ایجاد")
+ updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
+ default_currency: Optional[dict] = Field(default=None, description="ارز پیشفرض")
+ currencies: Optional[List[dict]] = Field(default=None, description="ارزهای فعال کسبوکار")
+
+
+class BusinessListResponse(BaseModel):
+ items: List[BusinessResponse] = Field(..., description="لیست کسب و کارها")
+ pagination: PaginationInfo = Field(..., description="اطلاعات صفحهبندی")
+ query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر")
+
+
+class BusinessSummaryResponse(BaseModel):
+ total_businesses: int = Field(..., description="تعداد کل کسب و کارها")
+ by_type: dict = Field(..., description="تعداد بر اساس نوع")
+ by_field: dict = Field(..., description="تعداد بر اساس زمینه فعالیت")
+
+
+class PaginatedResponse(BaseModel, Generic[T]):
+ """پاسخ صفحهبندی شده برای لیستها"""
+ items: List[T] = Field(..., description="آیتمهای صفحه")
+ total: int = Field(..., description="تعداد کل آیتمها")
+ page: int = Field(..., description="شماره صفحه فعلی")
+ limit: int = Field(..., description="تعداد آیتم در هر صفحه")
+ total_pages: int = Field(..., description="تعداد کل صفحات")
+
+ @classmethod
+ def create(cls, items: List[T], total: int, page: int, limit: int) -> 'PaginatedResponse[T]':
+ """ایجاد پاسخ صفحهبندی شده"""
+ total_pages = (total + limit - 1) // limit
+ return cls(
+ items=items,
+ total=total,
+ page=page,
+ limit=limit,
+ total_pages=total_pages
+ )
+
+
+# Fiscal Year Schemas
+class FiscalYearCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=255, description="عنوان سال مالی")
+ start_date: date = Field(..., description="تاریخ شروع سال مالی")
+ end_date: date = Field(..., description="تاریخ پایان سال مالی")
+ is_last: bool = Field(default=True, description="آیا آخرین سال مالی فعال است؟")
+
+
+# Business User Schemas
+class BusinessUserSchema(BaseModel):
+ id: int
+ business_id: int
+ user_id: int
+ user_name: str
+ user_email: str
+ user_phone: Optional[str] = None
+ role: str
+ status: str
+ added_at: datetime
+ last_active: Optional[datetime] = None
+ permissions: dict
+
+ class Config:
+ from_attributes = True
+
+
+class AddUserRequest(BaseModel):
+ email_or_phone: str
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "email_or_phone": "user@example.com"
+ }
+ }
+
+
+class AddUserResponse(BaseModel):
+ success: bool
+ message: str
+ user: Optional[BusinessUserSchema] = None
+
+
+class UpdatePermissionsRequest(BaseModel):
+ permissions: dict
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "permissions": {
+ "sales": {
+ "read": True,
+ "write": True,
+ "delete": False
+ },
+ "reports": {
+ "read": True,
+ "export": True
+ },
+ "settings": {
+ "manage_users": True
+ }
+ }
+ }
+ }
+
+
+class UpdatePermissionsResponse(BaseModel):
+ success: bool
+ message: str
+
+
+class RemoveUserResponse(BaseModel):
+ success: bool
+ message: str
+
+
+class BusinessUsersListResponse(BaseModel):
+ success: bool
+ message: str
+ data: dict
+ calendar_type: Optional[str] = None
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/__init__.py b/hesabixAPI/build/lib/adapters/api/v1/support/__init__.py
new file mode 100644
index 0000000..3c6872d
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/support/__init__.py
@@ -0,0 +1 @@
+# Support API endpoints
diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/categories.py b/hesabixAPI/build/lib/adapters/api/v1/support/categories.py
new file mode 100644
index 0000000..47d4dcb
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/support/categories.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from typing import List
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.db.repositories.support.category_repository import CategoryRepository
+from adapters.api.v1.support.schemas import CategoryResponse
+from adapters.api.v1.schemas import SuccessResponse
+from app.core.responses import success_response, format_datetime_fields
+
+router = APIRouter()
+
+
+@router.get("", response_model=SuccessResponse)
+async def get_categories(
+ request: Request,
+ db: Session = Depends(get_db)
+):
+ """دریافت لیست دستهبندیهای فعال"""
+ category_repo = CategoryRepository(db)
+ categories = category_repo.get_active_categories()
+
+ # Convert to dict and format datetime fields
+ categories_data = [CategoryResponse.from_orm(category).dict() for category in categories]
+ formatted_data = format_datetime_fields(categories_data, request)
+
+ return success_response(formatted_data, request)
diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/operator.py b/hesabixAPI/build/lib/adapters/api/v1/support/operator.py
new file mode 100644
index 0000000..45ade04
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/support/operator.py
@@ -0,0 +1,296 @@
+from typing import List
+from fastapi import APIRouter, Depends, HTTPException, status, Request, Body
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.db.repositories.support.ticket_repository import TicketRepository
+from adapters.db.repositories.support.message_repository import MessageRepository
+from adapters.api.v1.schemas import QueryInfo, PaginatedResponse, SuccessResponse
+from adapters.api.v1.support.schemas import (
+ CreateMessageRequest,
+ UpdateStatusRequest,
+ AssignTicketRequest,
+ TicketResponse,
+ MessageResponse
+)
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_app_permission
+from app.core.responses import success_response, format_datetime_fields
+
+router = APIRouter()
+
+
+@router.post("/tickets/search", response_model=SuccessResponse)
+@require_app_permission("support_operator")
+async def search_operator_tickets(
+ request: Request,
+ query_info: QueryInfo = Body(...),
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """جستجو در تمام تیکتها برای اپراتور"""
+ ticket_repo = TicketRepository(db)
+
+ # تنظیم فیلدهای قابل جستجو
+ if not query_info.search_fields:
+ query_info.search_fields = ["title", "description", "user_email", "user_name"]
+
+ tickets, total = ticket_repo.get_operator_tickets(query_info)
+
+ # تبدیل به dict
+ ticket_dicts = []
+ for ticket in tickets:
+ ticket_dict = {
+ "id": ticket.id,
+ "title": ticket.title,
+ "description": ticket.description,
+ "user_id": ticket.user_id,
+ "category_id": ticket.category_id,
+ "priority_id": ticket.priority_id,
+ "status_id": ticket.status_id,
+ "assigned_operator_id": ticket.assigned_operator_id,
+ "is_internal": ticket.is_internal,
+ "closed_at": ticket.closed_at,
+ "created_at": ticket.created_at,
+ "updated_at": ticket.updated_at,
+ "user": {
+ "id": ticket.user.id,
+ "first_name": ticket.user.first_name,
+ "last_name": ticket.user.last_name,
+ "email": ticket.user.email
+ } if ticket.user else None,
+ "assigned_operator": {
+ "id": ticket.assigned_operator.id,
+ "first_name": ticket.assigned_operator.first_name,
+ "last_name": ticket.assigned_operator.last_name,
+ "email": ticket.assigned_operator.email
+ } if ticket.assigned_operator else None,
+ "category": {
+ "id": ticket.category.id,
+ "name": ticket.category.name,
+ "description": ticket.category.description,
+ "is_active": ticket.category.is_active,
+ "created_at": ticket.category.created_at,
+ "updated_at": ticket.category.updated_at
+ } if ticket.category else None,
+ "priority": {
+ "id": ticket.priority.id,
+ "name": ticket.priority.name,
+ "description": ticket.priority.description,
+ "color": ticket.priority.color,
+ "order": ticket.priority.order,
+ "created_at": ticket.priority.created_at,
+ "updated_at": ticket.priority.updated_at
+ } if ticket.priority else None,
+ "status": {
+ "id": ticket.status.id,
+ "name": ticket.status.name,
+ "description": ticket.status.description,
+ "color": ticket.status.color,
+ "is_final": ticket.status.is_final,
+ "created_at": ticket.status.created_at,
+ "updated_at": ticket.status.updated_at
+ } if ticket.status else None
+ }
+ ticket_dicts.append(ticket_dict)
+
+ paginated_data = PaginatedResponse.create(
+ items=ticket_dicts,
+ total=total,
+ page=(query_info.skip // query_info.take) + 1,
+ limit=query_info.take
+ )
+
+ # Format datetime fields based on calendar type
+ formatted_data = format_datetime_fields(paginated_data.dict(), request)
+
+ return success_response(formatted_data, request)
+
+
+@router.get("/tickets/{ticket_id}", response_model=SuccessResponse)
+@require_app_permission("support_operator")
+async def get_operator_ticket(
+ request: Request,
+ ticket_id: int,
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """مشاهده تیکت برای اپراتور"""
+ ticket_repo = TicketRepository(db)
+
+ ticket = ticket_repo.get_operator_ticket_with_details(ticket_id)
+ if not ticket:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="تیکت یافت نشد"
+ )
+
+ # Format datetime fields based on calendar type
+ ticket_data = TicketResponse.from_orm(ticket).dict()
+ formatted_data = format_datetime_fields(ticket_data, request)
+
+ return success_response(formatted_data, request)
+
+
+@router.put("/tickets/{ticket_id}/status", response_model=SuccessResponse)
+@require_app_permission("support_operator")
+async def update_ticket_status(
+ request: Request,
+ ticket_id: int,
+ status_request: UpdateStatusRequest,
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """تغییر وضعیت تیکت"""
+ ticket_repo = TicketRepository(db)
+
+ ticket = ticket_repo.update_ticket_status(
+ ticket_id=ticket_id,
+ status_id=status_request.status_id,
+ operator_id=status_request.assigned_operator_id or current_user.get_user_id()
+ )
+
+ if not ticket:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="تیکت یافت نشد"
+ )
+
+ # دریافت تیکت با جزئیات
+ ticket_with_details = ticket_repo.get_operator_ticket_with_details(ticket_id)
+
+ # Format datetime fields based on calendar type
+ ticket_data = TicketResponse.from_orm(ticket_with_details).dict()
+ formatted_data = format_datetime_fields(ticket_data, request)
+
+ return success_response(formatted_data, request)
+
+
+@router.post("/tickets/{ticket_id}/assign", response_model=SuccessResponse)
+@require_app_permission("support_operator")
+async def assign_ticket(
+ request: Request,
+ ticket_id: int,
+ assign_request: AssignTicketRequest,
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """تخصیص تیکت به اپراتور"""
+ ticket_repo = TicketRepository(db)
+
+ ticket = ticket_repo.assign_ticket(ticket_id, assign_request.operator_id)
+ if not ticket:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="تیکت یافت نشد"
+ )
+
+ # دریافت تیکت با جزئیات
+ ticket_with_details = ticket_repo.get_operator_ticket_with_details(ticket_id)
+
+ # Format datetime fields based on calendar type
+ ticket_data = TicketResponse.from_orm(ticket_with_details).dict()
+ formatted_data = format_datetime_fields(ticket_data, request)
+
+ return success_response(formatted_data, request)
+
+
+@router.post("/tickets/{ticket_id}/messages", response_model=SuccessResponse)
+@require_app_permission("support_operator")
+async def send_operator_message(
+ request: Request,
+ ticket_id: int,
+ message_request: CreateMessageRequest,
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """ارسال پیام اپراتور به تیکت"""
+ ticket_repo = TicketRepository(db)
+ message_repo = MessageRepository(db)
+
+ # بررسی وجود تیکت
+ ticket = ticket_repo.get_operator_ticket_with_details(ticket_id)
+ if not ticket:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="تیکت یافت نشد"
+ )
+
+ # ایجاد پیام
+ message = message_repo.create_message(
+ ticket_id=ticket_id,
+ sender_id=current_user.get_user_id(),
+ sender_type="operator",
+ content=message_request.content,
+ is_internal=message_request.is_internal
+ )
+
+ # اگر تیکت هنوز به اپراتور تخصیص نشده، آن را تخصیص ده
+ if not ticket.assigned_operator_id:
+ ticket_repo.assign_ticket(ticket_id, current_user.get_user_id())
+
+ # Format datetime fields based on calendar type
+ message_data = MessageResponse.from_orm(message).dict()
+ formatted_data = format_datetime_fields(message_data, request)
+
+ return success_response(formatted_data, request)
+
+
+@router.post("/tickets/{ticket_id}/messages/search", response_model=SuccessResponse)
+@require_app_permission("support_operator")
+async def search_operator_ticket_messages(
+ request: Request,
+ ticket_id: int,
+ query_info: QueryInfo = Body(...),
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """جستجو در پیامهای تیکت برای اپراتور"""
+ ticket_repo = TicketRepository(db)
+ message_repo = MessageRepository(db)
+
+ # بررسی وجود تیکت
+ ticket = ticket_repo.get_operator_ticket_with_details(ticket_id)
+ if not ticket:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="تیکت یافت نشد"
+ )
+
+ # تنظیم فیلدهای قابل جستجو
+ if not query_info.search_fields:
+ query_info.search_fields = ["content"]
+
+ messages, total = message_repo.get_ticket_messages(ticket_id, query_info)
+
+ # تبدیل به dict
+ message_dicts = []
+ for message in messages:
+ message_dict = {
+ "id": message.id,
+ "ticket_id": message.ticket_id,
+ "sender_id": message.sender_id,
+ "sender_type": message.sender_type,
+ "content": message.content,
+ "is_internal": message.is_internal,
+ "created_at": message.created_at,
+ "sender": {
+ "id": message.sender.id,
+ "first_name": message.sender.first_name,
+ "last_name": message.sender.last_name,
+ "email": message.sender.email
+ } if message.sender else None
+ }
+ message_dicts.append(message_dict)
+
+ paginated_data = PaginatedResponse.create(
+ items=message_dicts,
+ total=total,
+ page=(query_info.skip // query_info.take) + 1,
+ limit=query_info.take
+ )
+
+ # Format datetime fields based on calendar type
+ formatted_data = format_datetime_fields(paginated_data.dict(), request)
+
+ return success_response(formatted_data, request)
diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/priorities.py b/hesabixAPI/build/lib/adapters/api/v1/support/priorities.py
new file mode 100644
index 0000000..47442c7
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/support/priorities.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from typing import List
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.db.repositories.support.priority_repository import PriorityRepository
+from adapters.api.v1.support.schemas import PriorityResponse
+from adapters.api.v1.schemas import SuccessResponse
+from app.core.responses import success_response, format_datetime_fields
+
+router = APIRouter()
+
+
+@router.get("", response_model=SuccessResponse)
+async def get_priorities(
+ request: Request,
+ db: Session = Depends(get_db)
+):
+ """دریافت لیست اولویتها"""
+ priority_repo = PriorityRepository(db)
+ priorities = priority_repo.get_priorities_ordered()
+
+ # Convert to dict and format datetime fields
+ priorities_data = [PriorityResponse.from_orm(priority).dict() for priority in priorities]
+ formatted_data = format_datetime_fields(priorities_data, request)
+
+ return success_response(formatted_data, request)
diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/schemas.py b/hesabixAPI/build/lib/adapters/api/v1/support/schemas.py
new file mode 100644
index 0000000..0f4bcc9
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/support/schemas.py
@@ -0,0 +1,134 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from datetime import datetime
+from typing import Optional, List
+from pydantic import BaseModel, Field
+
+from adapters.db.models.support.message import SenderType
+from adapters.api.v1.schemas import PaginatedResponse
+
+
+# Base schemas
+class CategoryBase(BaseModel):
+ name: str = Field(..., min_length=1, max_length=100)
+ description: Optional[str] = None
+ is_active: bool = True
+
+
+class PriorityBase(BaseModel):
+ name: str = Field(..., min_length=1, max_length=50)
+ description: Optional[str] = None
+ color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
+ order: int = 0
+
+
+class StatusBase(BaseModel):
+ name: str = Field(..., min_length=1, max_length=50)
+ description: Optional[str] = None
+ color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
+ is_final: bool = False
+
+
+class TicketBase(BaseModel):
+ title: str = Field(..., min_length=1, max_length=255)
+ description: str = Field(..., min_length=1)
+ category_id: int
+ priority_id: int
+
+
+class MessageBase(BaseModel):
+ content: str = Field(..., min_length=1)
+ is_internal: bool = False
+
+
+# Response schemas
+class CategoryResponse(CategoryBase):
+ id: int
+ created_at: datetime
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class PriorityResponse(PriorityBase):
+ id: int
+ created_at: datetime
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class StatusResponse(StatusBase):
+ id: int
+ created_at: datetime
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class UserInfo(BaseModel):
+ id: int
+ first_name: Optional[str] = None
+ last_name: Optional[str] = None
+ email: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class MessageResponse(MessageBase):
+ id: int
+ ticket_id: int
+ sender_id: int
+ sender_type: SenderType
+ sender: Optional[UserInfo] = None
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class TicketResponse(TicketBase):
+ id: int
+ user_id: int
+ status_id: int
+ assigned_operator_id: Optional[int] = None
+ is_internal: bool = False
+ closed_at: Optional[datetime] = None
+ created_at: datetime
+ updated_at: datetime
+
+ # Related objects
+ user: Optional[UserInfo] = None
+ assigned_operator: Optional[UserInfo] = None
+ category: Optional[CategoryResponse] = None
+ priority: Optional[PriorityResponse] = None
+ status: Optional[StatusResponse] = None
+ messages: Optional[List[MessageResponse]] = None
+
+ class Config:
+ from_attributes = True
+
+
+# Request schemas
+class CreateTicketRequest(TicketBase):
+ pass
+
+
+class CreateMessageRequest(MessageBase):
+ pass
+
+
+class UpdateStatusRequest(BaseModel):
+ status_id: int
+ assigned_operator_id: Optional[int] = None
+
+
+class AssignTicketRequest(BaseModel):
+ operator_id: int
+
+
+# PaginatedResponse is now imported from adapters.api.v1.schemas
diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/statuses.py b/hesabixAPI/build/lib/adapters/api/v1/support/statuses.py
new file mode 100644
index 0000000..e9a292b
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/support/statuses.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from typing import List
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.db.repositories.support.status_repository import StatusRepository
+from adapters.api.v1.support.schemas import StatusResponse
+from adapters.api.v1.schemas import SuccessResponse
+from app.core.responses import success_response, format_datetime_fields
+
+router = APIRouter()
+
+
+@router.get("", response_model=SuccessResponse)
+async def get_statuses(
+ request: Request,
+ db: Session = Depends(get_db)
+):
+ """دریافت لیست وضعیتها"""
+ status_repo = StatusRepository(db)
+ statuses = status_repo.get_all_statuses()
+
+ # Convert to dict and format datetime fields
+ statuses_data = [StatusResponse.from_orm(status).dict() for status in statuses]
+ formatted_data = format_datetime_fields(statuses_data, request)
+
+ return success_response(formatted_data, request)
diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/tickets.py b/hesabixAPI/build/lib/adapters/api/v1/support/tickets.py
new file mode 100644
index 0000000..8a98e21
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/support/tickets.py
@@ -0,0 +1,256 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from typing import List
+from fastapi import APIRouter, Depends, HTTPException, status, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.db.repositories.support.ticket_repository import TicketRepository
+from adapters.db.repositories.support.message_repository import MessageRepository
+from adapters.api.v1.schemas import QueryInfo, PaginatedResponse, SuccessResponse
+from adapters.api.v1.support.schemas import (
+ CreateTicketRequest,
+ CreateMessageRequest,
+ TicketResponse,
+ MessageResponse
+)
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.responses import success_response, format_datetime_fields
+
+router = APIRouter()
+
+
+@router.post("/search", response_model=SuccessResponse)
+async def search_user_tickets(
+ request: Request,
+ query_info: QueryInfo,
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """جستجو در تیکتهای کاربر"""
+ ticket_repo = TicketRepository(db)
+
+ # تنظیم فیلدهای قابل جستجو
+ if not query_info.search_fields:
+ query_info.search_fields = ["title", "description"]
+
+ tickets, total = ticket_repo.get_user_tickets(current_user.get_user_id(), query_info)
+
+ # تبدیل به dict
+ ticket_dicts = []
+ for ticket in tickets:
+ ticket_dict = {
+ "id": ticket.id,
+ "title": ticket.title,
+ "description": ticket.description,
+ "user_id": ticket.user_id,
+ "category_id": ticket.category_id,
+ "priority_id": ticket.priority_id,
+ "status_id": ticket.status_id,
+ "assigned_operator_id": ticket.assigned_operator_id,
+ "is_internal": ticket.is_internal,
+ "closed_at": ticket.closed_at,
+ "created_at": ticket.created_at,
+ "updated_at": ticket.updated_at,
+ "category": {
+ "id": ticket.category.id,
+ "name": ticket.category.name,
+ "description": ticket.category.description,
+ "is_active": ticket.category.is_active,
+ "created_at": ticket.category.created_at,
+ "updated_at": ticket.category.updated_at
+ } if ticket.category else None,
+ "priority": {
+ "id": ticket.priority.id,
+ "name": ticket.priority.name,
+ "description": ticket.priority.description,
+ "color": ticket.priority.color,
+ "order": ticket.priority.order,
+ "created_at": ticket.priority.created_at,
+ "updated_at": ticket.priority.updated_at
+ } if ticket.priority else None,
+ "status": {
+ "id": ticket.status.id,
+ "name": ticket.status.name,
+ "description": ticket.status.description,
+ "color": ticket.status.color,
+ "is_final": ticket.status.is_final,
+ "created_at": ticket.status.created_at,
+ "updated_at": ticket.status.updated_at
+ } if ticket.status else None
+ }
+ ticket_dicts.append(ticket_dict)
+
+ paginated_data = PaginatedResponse.create(
+ items=ticket_dicts,
+ total=total,
+ page=(query_info.skip // query_info.take) + 1,
+ limit=query_info.take
+ )
+
+ # Format datetime fields based on calendar type
+ formatted_data = format_datetime_fields(paginated_data.dict(), request)
+
+ return success_response(formatted_data, request)
+
+
+@router.post("", response_model=SuccessResponse)
+async def create_ticket(
+ request: Request,
+ ticket_request: CreateTicketRequest,
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """ایجاد تیکت جدید"""
+ ticket_repo = TicketRepository(db)
+
+ # ایجاد تیکت
+ ticket_data = {
+ "title": ticket_request.title,
+ "description": ticket_request.description,
+ "user_id": current_user.get_user_id(),
+ "category_id": ticket_request.category_id,
+ "priority_id": ticket_request.priority_id,
+ "status_id": 1, # وضعیت پیشفرض: باز
+ "is_internal": False
+ }
+
+ ticket = ticket_repo.create(ticket_data)
+
+ # ایجاد پیام اولیه
+ message_repo = MessageRepository(db)
+ message_repo.create_message(
+ ticket_id=ticket.id,
+ sender_id=current_user.get_user_id(),
+ sender_type="user",
+ content=ticket_request.description,
+ is_internal=False
+ )
+
+ # دریافت تیکت با جزئیات
+ ticket_with_details = ticket_repo.get_ticket_with_details(ticket.id, current_user.get_user_id())
+
+ # Format datetime fields based on calendar type
+ ticket_data = TicketResponse.from_orm(ticket_with_details).dict()
+ formatted_data = format_datetime_fields(ticket_data, request)
+
+ return success_response(formatted_data, request)
+
+
+@router.get("/{ticket_id}", response_model=SuccessResponse)
+async def get_ticket(
+ request: Request,
+ ticket_id: int,
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """مشاهده تیکت"""
+ ticket_repo = TicketRepository(db)
+
+ ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id())
+ if not ticket:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="تیکت یافت نشد"
+ )
+
+ # Format datetime fields based on calendar type
+ ticket_data = TicketResponse.from_orm(ticket).dict()
+ formatted_data = format_datetime_fields(ticket_data, request)
+
+ return success_response(formatted_data, request)
+
+
+@router.post("/{ticket_id}/messages", response_model=SuccessResponse)
+async def send_message(
+ request: Request,
+ ticket_id: int,
+ message_request: CreateMessageRequest,
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """ارسال پیام به تیکت"""
+ ticket_repo = TicketRepository(db)
+ message_repo = MessageRepository(db)
+
+ # بررسی وجود تیکت
+ ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id())
+ if not ticket:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="تیکت یافت نشد"
+ )
+
+ # ایجاد پیام
+ message = message_repo.create_message(
+ ticket_id=ticket_id,
+ sender_id=current_user.get_user_id(),
+ sender_type="user",
+ content=message_request.content,
+ is_internal=message_request.is_internal
+ )
+
+ # Format datetime fields based on calendar type
+ message_data = MessageResponse.from_orm(message).dict()
+ formatted_data = format_datetime_fields(message_data, request)
+
+ return success_response(formatted_data, request)
+
+
+@router.post("/{ticket_id}/messages/search", response_model=SuccessResponse)
+async def search_ticket_messages(
+ request: Request,
+ ticket_id: int,
+ query_info: QueryInfo,
+ current_user: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """جستجو در پیامهای تیکت"""
+ ticket_repo = TicketRepository(db)
+ message_repo = MessageRepository(db)
+
+ # بررسی وجود تیکت
+ ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id())
+ if not ticket:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="تیکت یافت نشد"
+ )
+
+ # تنظیم فیلدهای قابل جستجو
+ if not query_info.search_fields:
+ query_info.search_fields = ["content"]
+
+ messages, total = message_repo.get_ticket_messages(ticket_id, query_info)
+
+ # تبدیل به dict
+ message_dicts = []
+ for message in messages:
+ message_dict = {
+ "id": message.id,
+ "ticket_id": message.ticket_id,
+ "sender_id": message.sender_id,
+ "sender_type": message.sender_type,
+ "content": message.content,
+ "is_internal": message.is_internal,
+ "created_at": message.created_at,
+ "sender": {
+ "id": message.sender.id,
+ "first_name": message.sender.first_name,
+ "last_name": message.sender.last_name,
+ "email": message.sender.email
+ } if message.sender else None
+ }
+ message_dicts.append(message_dict)
+
+ paginated_data = PaginatedResponse.create(
+ items=message_dicts,
+ total=total,
+ page=(query_info.skip // query_info.take) + 1,
+ limit=query_info.take
+ )
+
+ # Format datetime fields based on calendar type
+ formatted_data = format_datetime_fields(paginated_data.dict(), request)
+
+ return success_response(formatted_data, request)
diff --git a/hesabixAPI/build/lib/adapters/api/v1/tax_types.py b/hesabixAPI/build/lib/adapters/api/v1/tax_types.py
new file mode 100644
index 0000000..5360905
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/tax_types.py
@@ -0,0 +1,49 @@
+from typing import Dict, Any, List
+from fastapi import APIRouter, Depends, Request
+
+from adapters.api.v1.schemas import SuccessResponse
+from adapters.db.session import get_db # noqa: F401 (kept for consistency/future use)
+from app.core.responses import success_response
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from sqlalchemy.orm import Session # noqa: F401
+
+
+router = APIRouter(prefix="/tax-types", tags=["tax-types"])
+
+
+def _static_tax_types() -> List[Dict[str, Any]]:
+ titles = [
+ "دارو",
+ "دخانیات",
+ "موبایل",
+ "لوازم خانگی برقی",
+ "قطعات مصرفی و یدکی وسایل نقلیه",
+ "فراورده ها و مشتقات نفتی و گازی و پتروشیمیایی",
+ "طلا اعم از شمش، مسکوکات و مصنوعات زینتی",
+ "منسوجات و پوشاک",
+ "اسباب بازی",
+ "دام زنده، گوشت سفید و قرمز",
+ "محصولات اساسی کشاورزی",
+ "سایر کالا ها",
+ ]
+ return [{"id": idx + 1, "title": t} for idx, t in enumerate(titles)]
+
+
+@router.get(
+ "/business/{business_id}",
+ summary="لیست نوعهای مالیات",
+ description="دریافت لیست نوعهای مالیات (ثابت)",
+ response_model=SuccessResponse,
+)
+@require_business_access()
+def list_tax_types(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+) -> Dict[str, Any]:
+ # Currently returns a static list; later can be sourced from DB if needed
+ items = _static_tax_types()
+ return success_response(items, request)
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/tax_units.py b/hesabixAPI/build/lib/adapters/api/v1/tax_units.py
new file mode 100644
index 0000000..77392cc
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/tax_units.py
@@ -0,0 +1,387 @@
+from fastapi import APIRouter, Depends, Request, HTTPException
+from sqlalchemy.orm import Session
+from typing import List, Optional
+from decimal import Decimal
+
+from adapters.db.session import get_db
+from adapters.db.models.tax_unit import TaxUnit
+from adapters.api.v1.schemas import SuccessResponse
+from app.core.responses import success_response, format_datetime_fields
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from pydantic import BaseModel, Field
+
+
+router = APIRouter(prefix="/tax-units", tags=["tax-units"])
+alias_router = APIRouter(prefix="/units", tags=["units"])
+
+
+class TaxUnitCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255, description="نام واحد مالیاتی")
+ code: str = Field(..., min_length=1, max_length=64, description="کد واحد مالیاتی")
+ description: Optional[str] = Field(default=None, description="توضیحات")
+ tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
+ is_active: bool = Field(default=True, description="وضعیت فعال/غیرفعال")
+
+
+class TaxUnitUpdateRequest(BaseModel):
+ name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام واحد مالیاتی")
+ code: Optional[str] = Field(default=None, min_length=1, max_length=64, description="کد واحد مالیاتی")
+ description: Optional[str] = Field(default=None, description="توضیحات")
+ tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
+ is_active: Optional[bool] = Field(default=None, description="وضعیت فعال/غیرفعال")
+
+
+class TaxUnitResponse(BaseModel):
+ id: int
+ business_id: int
+ name: str
+ code: str
+ description: Optional[str] = None
+ tax_rate: Optional[Decimal] = None
+ is_active: bool
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
+@router.get("/business/{business_id}",
+ summary="لیست واحدهای مالیاتی کسبوکار",
+ description="دریافت لیست واحدهای مالیاتی یک کسبوکار",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "لیست واحدهای مالیاتی با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "لیست واحدهای مالیاتی دریافت شد",
+ "data": [
+ {
+ "id": 1,
+ "business_id": 1,
+ "name": "مالیات بر ارزش افزوده",
+ "code": "VAT",
+ "description": "مالیات بر ارزش افزوده 9 درصد",
+ "tax_rate": 9.0,
+ "is_active": True,
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ }
+ ]
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسبوکار"
+ },
+ 404: {
+ "description": "کسبوکار یافت نشد"
+ }
+ }
+)
+@alias_router.get("/business/{business_id}")
+@require_business_access()
+def get_tax_units(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """دریافت لیست واحدهای مالیاتی یک کسبوکار"""
+
+ # Query tax units for the business
+ tax_units = db.query(TaxUnit).filter(
+ TaxUnit.business_id == business_id
+ ).order_by(TaxUnit.name).all()
+
+ # Convert to response format
+ tax_unit_dicts = []
+ for tax_unit in tax_units:
+ tax_unit_dict = {
+ "id": tax_unit.id,
+ "business_id": tax_unit.business_id,
+ "name": tax_unit.name,
+ "code": tax_unit.code,
+ "description": tax_unit.description,
+ "tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
+ "is_active": tax_unit.is_active,
+ "created_at": tax_unit.created_at.isoformat(),
+ "updated_at": tax_unit.updated_at.isoformat()
+ }
+ tax_unit_dicts.append(format_datetime_fields(tax_unit_dict, request))
+
+ return success_response(tax_unit_dicts, request)
+
+
+@router.post("/business/{business_id}",
+ summary="ایجاد واحد مالیاتی جدید",
+ description="ایجاد یک واحد مالیاتی جدید برای کسبوکار",
+ response_model=SuccessResponse,
+ responses={
+ 201: {
+ "description": "واحد مالیاتی با موفقیت ایجاد شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "واحد مالیاتی با موفقیت ایجاد شد",
+ "data": {
+ "id": 1,
+ "business_id": 1,
+ "name": "مالیات بر ارزش افزوده",
+ "code": "VAT",
+ "description": "مالیات بر ارزش افزوده 9 درصد",
+ "tax_rate": 9.0,
+ "is_active": True,
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها"
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسبوکار"
+ },
+ 404: {
+ "description": "کسبوکار یافت نشد"
+ }
+ }
+)
+@alias_router.post("/business/{business_id}")
+@require_business_access()
+def create_tax_unit(
+ request: Request,
+ business_id: int,
+ tax_unit_data: TaxUnitCreateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """ایجاد واحد مالیاتی جدید"""
+
+ # Check if code already exists for this business
+ existing_tax_unit = db.query(TaxUnit).filter(
+ TaxUnit.business_id == business_id,
+ TaxUnit.code == tax_unit_data.code
+ ).first()
+
+ if existing_tax_unit:
+ raise HTTPException(
+ status_code=400,
+ detail="کد واحد مالیاتی قبلاً استفاده شده است"
+ )
+
+ # Create new tax unit
+ tax_unit = TaxUnit(
+ business_id=business_id,
+ name=tax_unit_data.name,
+ code=tax_unit_data.code,
+ description=tax_unit_data.description,
+ tax_rate=tax_unit_data.tax_rate,
+ is_active=tax_unit_data.is_active
+ )
+
+ db.add(tax_unit)
+ db.commit()
+ db.refresh(tax_unit)
+
+ # Convert to response format
+ tax_unit_dict = {
+ "id": tax_unit.id,
+ "business_id": tax_unit.business_id,
+ "name": tax_unit.name,
+ "code": tax_unit.code,
+ "description": tax_unit.description,
+ "tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
+ "is_active": tax_unit.is_active,
+ "created_at": tax_unit.created_at.isoformat(),
+ "updated_at": tax_unit.updated_at.isoformat()
+ }
+
+ formatted_response = format_datetime_fields(tax_unit_dict, request)
+
+ return success_response(formatted_response, request)
+
+
+@router.put("/{tax_unit_id}",
+ summary="بهروزرسانی واحد مالیاتی",
+ description="بهروزرسانی اطلاعات یک واحد مالیاتی",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "واحد مالیاتی با موفقیت بهروزرسانی شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "واحد مالیاتی با موفقیت بهروزرسانی شد",
+ "data": {
+ "id": 1,
+ "business_id": 1,
+ "name": "مالیات بر ارزش افزوده",
+ "code": "VAT",
+ "description": "مالیات بر ارزش افزوده 9 درصد",
+ "tax_rate": 9.0,
+ "is_active": True,
+ "created_at": "2024-01-01T00:00:00Z",
+ "updated_at": "2024-01-01T00:00:00Z"
+ }
+ }
+ }
+ }
+ },
+ 400: {
+ "description": "خطا در اعتبارسنجی دادهها"
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسبوکار"
+ },
+ 404: {
+ "description": "واحد مالیاتی یافت نشد"
+ }
+ }
+)
+@alias_router.put("/{tax_unit_id}")
+@require_business_access()
+def update_tax_unit(
+ request: Request,
+ tax_unit_id: int,
+ tax_unit_data: TaxUnitUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """بهروزرسانی واحد مالیاتی"""
+
+ # Find the tax unit
+ tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first()
+ if not tax_unit:
+ raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد")
+
+ # Check business access
+ if tax_unit.business_id not in ctx.business_ids:
+ raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسبوکار")
+
+ # Check if new code conflicts with existing ones
+ if tax_unit_data.code and tax_unit_data.code != tax_unit.code:
+ existing_tax_unit = db.query(TaxUnit).filter(
+ TaxUnit.business_id == tax_unit.business_id,
+ TaxUnit.code == tax_unit_data.code,
+ TaxUnit.id != tax_unit_id
+ ).first()
+
+ if existing_tax_unit:
+ raise HTTPException(
+ status_code=400,
+ detail="کد واحد مالیاتی قبلاً استفاده شده است"
+ )
+
+ # Update fields
+ update_data = tax_unit_data.dict(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(tax_unit, field, value)
+
+ db.commit()
+ db.refresh(tax_unit)
+
+ # Convert to response format
+ tax_unit_dict = {
+ "id": tax_unit.id,
+ "business_id": tax_unit.business_id,
+ "name": tax_unit.name,
+ "code": tax_unit.code,
+ "description": tax_unit.description,
+ "tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
+ "is_active": tax_unit.is_active,
+ "created_at": tax_unit.created_at.isoformat(),
+ "updated_at": tax_unit.updated_at.isoformat()
+ }
+
+ formatted_response = format_datetime_fields(tax_unit_dict, request)
+
+ return success_response(formatted_response, request)
+
+
+@router.delete("/{tax_unit_id}",
+ summary="حذف واحد مالیاتی",
+ description="حذف یک واحد مالیاتی",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "واحد مالیاتی با موفقیت حذف شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "واحد مالیاتی با موفقیت حذف شد",
+ "data": None
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز به کسبوکار"
+ },
+ 404: {
+ "description": "واحد مالیاتی یافت نشد"
+ },
+ 409: {
+ "description": "امکان حذف واحد مالیاتی به دلیل استفاده در محصولات وجود ندارد"
+ }
+ }
+)
+@alias_router.delete("/{tax_unit_id}")
+@require_business_access()
+def delete_tax_unit(
+ request: Request,
+ tax_unit_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ """حذف واحد مالیاتی"""
+
+ # Find the tax unit
+ tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first()
+ if not tax_unit:
+ raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد")
+
+ # Check business access
+ if tax_unit.business_id not in ctx.business_ids:
+ raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسبوکار")
+
+ # Check if tax unit is used in products
+ from adapters.db.models.product import Product
+ products_using_tax_unit = db.query(Product).filter(
+ Product.tax_unit_id == tax_unit_id
+ ).count()
+
+ if products_using_tax_unit > 0:
+ raise HTTPException(
+ status_code=409,
+ detail=f"امکان حذف واحد مالیاتی به دلیل استفاده در {products_using_tax_unit} محصول وجود ندارد"
+ )
+
+ # Delete the tax unit
+ db.delete(tax_unit)
+ db.commit()
+
+ return success_response(None, request)
diff --git a/hesabixAPI/build/lib/adapters/api/v1/users.py b/hesabixAPI/build/lib/adapters/api/v1/users.py
new file mode 100644
index 0000000..06b9742
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/users.py
@@ -0,0 +1,362 @@
+# Removed __future__ annotations to fix OpenAPI schema generation
+
+from fastapi import APIRouter, Depends, Request, Query
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.db.repositories.user_repo import UserRepository
+from adapters.api.v1.schemas import QueryInfo, SuccessResponse, UsersListResponse, UsersSummaryResponse, UserResponse
+from app.core.responses import success_response, format_datetime_fields
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_user_management
+
+
+router = APIRouter(prefix="/users", tags=["users"])
+
+
+@router.post("/search",
+ summary="لیست کاربران با فیلتر پیشرفته",
+ description="دریافت لیست کاربران با قابلیت فیلتر، جستجو، مرتبسازی و صفحهبندی. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "لیست کاربران با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "لیست کاربران دریافت شد",
+ "data": {
+ "items": [
+ {
+ "id": 1,
+ "email": "user@example.com",
+ "mobile": "09123456789",
+ "first_name": "احمد",
+ "last_name": "احمدی",
+ "is_active": True,
+ "referral_code": "ABC123",
+ "created_at": "2024-01-01T00:00:00Z"
+ }
+ ],
+ "pagination": {
+ "total": 1,
+ "page": 1,
+ "per_page": 10,
+ "total_pages": 1,
+ "has_next": False,
+ "has_prev": False
+ }
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز - نیاز به مجوز usermanager",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "Missing app permission: user_management",
+ "error_code": "FORBIDDEN"
+ }
+ }
+ }
+ }
+ }
+)
+@require_user_management()
+def list_users(
+ request: Request,
+ query_info: QueryInfo,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """
+ دریافت لیست کاربران با قابلیت فیلتر، جستجو، مرتبسازی و صفحهبندی
+
+ پارامترهای QueryInfo:
+ - sort_by: فیلد مرتبسازی (مثال: created_at, first_name)
+ - sort_desc: ترتیب نزولی (true/false)
+ - take: تعداد رکورد در هر صفحه (پیشفرض: 10)
+ - skip: تعداد رکورد صرفنظر شده (پیشفرض: 0)
+ - search: عبارت جستجو
+ - search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"])
+ - filters: آرایه فیلترها با ساختار:
+ [
+ {
+ "property": "is_active",
+ "operator": "=",
+ "value": true
+ },
+ {
+ "property": "first_name",
+ "operator": "*",
+ "value": "احمد"
+ }
+ ]
+
+ عملگرهای پشتیبانی شده:
+ - = : برابر
+ - > : بزرگتر از
+ - >= : بزرگتر یا مساوی
+ - < : کوچکتر از
+ - <= : کوچکتر یا مساوی
+ - != : نامساوی
+ - * : شامل (contains)
+ - ?* : خاتمه یابد (ends with)
+ - *? : شروع شود (starts with)
+ - in : در بین مقادیر آرایه
+ """
+ repo = UserRepository(db)
+ users, total = repo.query_with_filters(query_info)
+
+ # تبدیل User objects به dictionary
+ user_dicts = [repo.to_dict(user) for user in users]
+
+ # فرمت کردن تاریخها
+ formatted_users = [format_datetime_fields(user_dict, request) for user_dict in user_dicts]
+
+ # محاسبه اطلاعات صفحهبندی
+ page = (query_info.skip // query_info.take) + 1
+ total_pages = (total + query_info.take - 1) // query_info.take
+
+ response_data = {
+ "items": formatted_users,
+ "pagination": {
+ "total": total,
+ "page": page,
+ "per_page": query_info.take,
+ "total_pages": total_pages,
+ "has_next": page < total_pages,
+ "has_prev": page > 1
+ },
+ "query_info": {
+ "sort_by": query_info.sort_by,
+ "sort_desc": query_info.sort_desc,
+ "search": query_info.search,
+ "search_fields": query_info.search_fields,
+ "filters": [{"property": f.property, "operator": f.operator, "value": f.value} for f in (query_info.filters or [])]
+ }
+ }
+
+ return success_response(response_data, request)
+
+
+@router.get("",
+ summary="لیست ساده کاربران",
+ description="دریافت لیست ساده کاربران. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "لیست کاربران با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "لیست کاربران دریافت شد",
+ "data": [
+ {
+ "id": 1,
+ "email": "user@example.com",
+ "mobile": "09123456789",
+ "first_name": "احمد",
+ "last_name": "احمدی",
+ "is_active": True,
+ "referral_code": "ABC123",
+ "created_at": "2024-01-01T00:00:00Z"
+ }
+ ]
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز - نیاز به مجوز usermanager",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "Missing app permission: user_management",
+ "error_code": "FORBIDDEN"
+ }
+ }
+ }
+ }
+ }
+)
+@require_user_management()
+def list_users_simple(
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+ limit: int = Query(10, ge=1, le=100, description="تعداد رکورد در هر صفحه"),
+ offset: int = Query(0, ge=0, description="تعداد رکورد صرفنظر شده")
+):
+ """دریافت لیست ساده کاربران"""
+ repo = UserRepository(db)
+
+ # Create basic query info
+ query_info = QueryInfo(take=limit, skip=offset)
+ users, total = repo.query_with_filters(query_info)
+
+ # تبدیل User objects به dictionary
+ user_dicts = [repo.to_dict(user) for user in users]
+
+ # فرمت کردن تاریخها
+ formatted_users = [format_datetime_fields(user_dict, None) for user_dict in user_dicts]
+
+ return success_response(formatted_users, None)
+
+
+@router.get("/{user_id}",
+ summary="دریافت اطلاعات یک کاربر",
+ description="دریافت اطلاعات کامل یک کاربر بر اساس شناسه. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "اطلاعات کاربر با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "اطلاعات کاربر دریافت شد",
+ "data": {
+ "id": 1,
+ "email": "user@example.com",
+ "mobile": "09123456789",
+ "first_name": "احمد",
+ "last_name": "احمدی",
+ "is_active": True,
+ "referral_code": "ABC123",
+ "created_at": "2024-01-01T00:00:00Z"
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز - نیاز به مجوز usermanager",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "Missing app permission: user_management",
+ "error_code": "FORBIDDEN"
+ }
+ }
+ }
+ },
+ 404: {
+ "description": "کاربر یافت نشد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "کاربر یافت نشد",
+ "error_code": "USER_NOT_FOUND"
+ }
+ }
+ }
+ }
+ }
+)
+@require_user_management()
+def get_user(
+ user_id: int,
+ request: Request,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """دریافت اطلاعات یک کاربر بر اساس ID"""
+ repo = UserRepository(db)
+ user = repo.get_by_id(user_id)
+
+ if not user:
+ from fastapi import HTTPException
+ raise HTTPException(status_code=404, detail="کاربر یافت نشد")
+
+ user_dict = repo.to_dict(user)
+ formatted_user = format_datetime_fields(user_dict, request)
+
+ return success_response(formatted_user, request)
+
+
+@router.get("/stats/summary",
+ summary="آمار کلی کاربران",
+ description="دریافت آمار کلی کاربران شامل تعداد کل، فعال و غیرفعال. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.",
+ response_model=SuccessResponse,
+ responses={
+ 200: {
+ "description": "آمار کاربران با موفقیت دریافت شد",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": True,
+ "message": "آمار کاربران دریافت شد",
+ "data": {
+ "total_users": 100,
+ "active_users": 85,
+ "inactive_users": 15,
+ "active_percentage": 85.0
+ }
+ }
+ }
+ }
+ },
+ 401: {
+ "description": "کاربر احراز هویت نشده است"
+ },
+ 403: {
+ "description": "دسترسی غیرمجاز - نیاز به مجوز usermanager",
+ "content": {
+ "application/json": {
+ "example": {
+ "success": False,
+ "message": "Missing app permission: user_management",
+ "error_code": "FORBIDDEN"
+ }
+ }
+ }
+ }
+ }
+)
+@require_user_management()
+def get_users_summary(
+ request: Request,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """دریافت آمار کلی کاربران"""
+ repo = UserRepository(db)
+
+ # تعداد کل کاربران
+ total_users = repo.count_all()
+
+ # تعداد کاربران فعال
+ active_users = repo.query_with_filters(QueryInfo(
+ filters=[{"property": "is_active", "operator": "=", "value": True}]
+ ))[1]
+
+ # تعداد کاربران غیرفعال
+ inactive_users = total_users - active_users
+
+ response_data = {
+ "total_users": total_users,
+ "active_users": active_users,
+ "inactive_users": inactive_users,
+ "active_percentage": round((active_users / total_users * 100), 2) if total_users > 0 else 0
+ }
+
+ return success_response(response_data, request)
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/__init__.py b/hesabixAPI/build/lib/adapters/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hesabixAPI/build/lib/adapters/db/models/__init__.py b/hesabixAPI/build/lib/adapters/db/models/__init__.py
new file mode 100644
index 0000000..a8d4d3c
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/__init__.py
@@ -0,0 +1,38 @@
+from adapters.db.session import Base # re-export Base for Alembic
+
+# Import models to register with SQLAlchemy metadata
+from .user import User # noqa: F401
+from .api_key import ApiKey # noqa: F401
+from .captcha import Captcha # noqa: F401
+from .password_reset import PasswordReset # noqa: F401
+from .business import Business # noqa: F401
+from .business_permission import BusinessPermission # noqa: F401
+from .person import Person, PersonBankAccount # noqa: F401
+# Business user models removed - using business_permissions instead
+
+# Import support models
+from .support import * # noqa: F401, F403
+
+# Import file storage models
+from .file_storage import *
+
+# Import email config models
+from .email_config import EmailConfig # noqa: F401, F403
+
+
+# Accounting / Fiscal models
+from .fiscal_year import FiscalYear # noqa: F401
+
+# Currency models
+from .currency import Currency, BusinessCurrency # noqa: F401
+
+# Documents
+from .document import Document # noqa: F401
+from .document_line import DocumentLine # noqa: F401
+from .account import Account # noqa: F401
+from .category import BusinessCategory # noqa: F401
+from .product_attribute import ProductAttribute # noqa: F401
+from .product import Product # noqa: F401
+from .price_list import PriceList, PriceItem # noqa: F401
+from .product_attribute_link import ProductAttributeLink # noqa: F401
+from .tax_unit import TaxUnit # noqa: F401
diff --git a/hesabixAPI/build/lib/adapters/db/models/account.py b/hesabixAPI/build/lib/adapters/db/models/account.py
new file mode 100644
index 0000000..a5e38a6
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/account.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Account(Base):
+ __tablename__ = "accounts"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'code', name='uq_accounts_business_code'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ business_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=True, index=True)
+ account_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ code: Mapped[str] = mapped_column(String(50), nullable=False)
+ parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ business = relationship("Business", back_populates="accounts")
+ parent = relationship("Account", remote_side="Account.id", back_populates="children")
+ children = relationship("Account", back_populates="parent", cascade="all, delete-orphan")
+ document_lines = relationship("DocumentLine", back_populates="account")
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/api_key.py b/hesabixAPI/build/lib/adapters/db/models/api_key.py
new file mode 100644
index 0000000..991131d
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/api_key.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, DateTime, ForeignKey
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class ApiKey(Base):
+ __tablename__ = "api_keys"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
+ key_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
+ key_type: Mapped[str] = mapped_column(String(16), nullable=False) # "session" | "personal"
+ name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ scopes: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ device_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/business.py b/hesabixAPI/build/lib/adapters/db/models/business.py
new file mode 100644
index 0000000..85253d9
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/business.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from datetime import datetime
+from enum import Enum
+
+from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class BusinessType(str, Enum):
+ """نوع کسب و کار"""
+ COMPANY = "شرکت" # شرکت
+ SHOP = "مغازه" # مغازه
+ STORE = "فروشگاه" # فروشگاه
+ UNION = "اتحادیه" # اتحادیه
+ CLUB = "باشگاه" # باشگاه
+ INSTITUTE = "موسسه" # موسسه
+ INDIVIDUAL = "شخصی" # شخصی
+
+
+class BusinessField(str, Enum):
+ """زمینه فعالیت کسب و کار"""
+ MANUFACTURING = "تولیدی" # تولیدی
+ TRADING = "بازرگانی" # بازرگانی
+ SERVICE = "خدماتی" # خدماتی
+ OTHER = "سایر" # سایر
+
+
+class Business(Base):
+ __tablename__ = "businesses"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ business_type: Mapped[BusinessType] = mapped_column(SQLEnum(BusinessType), nullable=False)
+ business_field: Mapped[BusinessField] = mapped_column(SQLEnum(BusinessField), nullable=False)
+ owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ default_currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True)
+
+ # فیلدهای جدید
+ address: Mapped[str | None] = mapped_column(Text, nullable=True)
+ phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ national_id: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
+ registration_number: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
+ economic_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
+
+ # فیلدهای جغرافیایی
+ country: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ province: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ city: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
+
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan")
+ fiscal_years = relationship("FiscalYear", back_populates="business", cascade="all, delete-orphan")
+ currencies = relationship("Currency", secondary="business_currencies", back_populates="businesses")
+ default_currency = relationship("Currency", foreign_keys="[Business.default_currency_id]", uselist=False)
+ documents = relationship("Document", back_populates="business", cascade="all, delete-orphan")
+ accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan")
diff --git a/hesabixAPI/build/lib/adapters/db/models/business_permission.py b/hesabixAPI/build/lib/adapters/db/models/business_permission.py
new file mode 100644
index 0000000..fcac273
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/business_permission.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import Integer, ForeignKey, JSON, DateTime
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class BusinessPermission(Base):
+ __tablename__ = "business_permissions"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ business_permissions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
diff --git a/hesabixAPI/build/lib/adapters/db/models/captcha.py b/hesabixAPI/build/lib/adapters/db/models/captcha.py
new file mode 100644
index 0000000..cec072a
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/captcha.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, DateTime
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class Captcha(Base):
+ __tablename__ = "captchas"
+
+ id: Mapped[str] = mapped_column(String(40), primary_key=True)
+ code_hash: Mapped[str] = mapped_column(String(128), nullable=False)
+ expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+ attempts: Mapped[int] = mapped_column(default=0, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/category.py b/hesabixAPI/build/lib/adapters/db/models/category.py
new file mode 100644
index 0000000..e9f4e73
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/category.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class BusinessCategory(Base):
+ """
+ دستهبندیهای کالا/خدمت برای هر کسبوکار با ساختار درختی
+ - عناوین چندزبانه در فیلد JSON `title_translations` نگهداری میشود
+ - نوع دستهبندی: product | service
+ """
+ __tablename__ = "categories"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+ parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True)
+ # فیلد type حذف شده است (در مهاجرت بعدی)
+ title_translations: Mapped[dict] = mapped_column(JSON, nullable=False, default={})
+ sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ parent = relationship("BusinessCategory", remote_side=[id], backref="children")
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/currency.py b/hesabixAPI/build/lib/adapters/db/models/currency.py
new file mode 100644
index 0000000..776fd05
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/currency.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Currency(Base):
+ __tablename__ = "currencies"
+ __table_args__ = (
+ UniqueConstraint('name', name='uq_currencies_name'),
+ UniqueConstraint('code', name='uq_currencies_code'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
+ title: Mapped[str] = mapped_column(String(100), nullable=False)
+ symbol: Mapped[str] = mapped_column(String(16), nullable=False)
+ code: Mapped[str] = mapped_column(String(16), nullable=False) # نام کوتاه
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ businesses = relationship("Business", secondary="business_currencies", back_populates="currencies")
+ documents = relationship("Document", back_populates="currency")
+
+
+class BusinessCurrency(Base):
+ __tablename__ = "business_currencies"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'currency_id', name='uq_business_currencies_business_currency'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="CASCADE"), nullable=False, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/document.py b/hesabixAPI/build/lib/adapters/db/models/document.py
new file mode 100644
index 0000000..6d290cd
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/document.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON, Date, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Document(Base):
+ __tablename__ = "documents"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'code', name='uq_documents_business_code'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
+ created_by_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
+ registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ document_date: Mapped[date] = mapped_column(Date, nullable=False)
+ document_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ is_proforma: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ developer_settings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ business = relationship("Business", back_populates="documents")
+ currency = relationship("Currency", back_populates="documents")
+ created_by = relationship("User", foreign_keys=[created_by_user_id])
+ lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan")
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/document_line.py b/hesabixAPI/build/lib/adapters/db/models/document_line.py
new file mode 100644
index 0000000..494012b
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/document_line.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import Integer, DateTime, ForeignKey, JSON, Text, Numeric
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class DocumentLine(Base):
+ __tablename__ = "document_lines"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
+ account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True)
+ debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
+ credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ developer_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ document = relationship("Document", back_populates="lines")
+ account = relationship("Account", back_populates="document_lines")
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/email_config.py b/hesabixAPI/build/lib/adapters/db/models/email_config.py
new file mode 100644
index 0000000..17aa98f
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/email_config.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, DateTime, Boolean, Integer, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class EmailConfig(Base):
+ __tablename__ = "email_configs"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
+ smtp_host: Mapped[str] = mapped_column(String(255), nullable=False)
+ smtp_port: Mapped[int] = mapped_column(Integer, nullable=False)
+ smtp_username: Mapped[str] = mapped_column(String(255), nullable=False)
+ smtp_password: Mapped[str] = mapped_column(String(255), nullable=False) # Should be encrypted
+ use_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ use_ssl: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ from_email: Mapped[str] = mapped_column(String(255), nullable=False)
+ from_name: Mapped[str] = mapped_column(String(100), nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
diff --git a/hesabixAPI/build/lib/adapters/db/models/file_storage.py b/hesabixAPI/build/lib/adapters/db/models/file_storage.py
new file mode 100644
index 0000000..e52ef85
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/file_storage.py
@@ -0,0 +1,71 @@
+from sqlalchemy import Column, String, Integer, DateTime, Boolean, Text, ForeignKey, JSON, BigInteger
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+import uuid
+
+from adapters.db.session import Base
+
+
+class FileStorage(Base):
+ __tablename__ = "file_storage"
+
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ original_name = Column(String(255), nullable=False)
+ stored_name = Column(String(255), nullable=False)
+ file_path = Column(String(500), nullable=False)
+ file_size = Column(Integer, nullable=False)
+ mime_type = Column(String(100), nullable=False)
+ storage_type = Column(String(20), nullable=False) # local, ftp
+ storage_config_id = Column(String(36), ForeignKey("storage_configs.id"), nullable=True)
+ uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
+ module_context = Column(String(50), nullable=False) # tickets, accounting, business_logo, etc.
+ context_id = Column(String(36), nullable=True) # ticket_id, document_id, etc.
+ developer_data = Column(JSON, nullable=True)
+ checksum = Column(String(64), nullable=True)
+ is_active = Column(Boolean, default=True, nullable=False)
+ is_temporary = Column(Boolean, default=False, nullable=False)
+ is_verified = Column(Boolean, default=False, nullable=False)
+ verification_token = Column(String(100), nullable=True)
+ last_verified_at = Column(DateTime(timezone=True), nullable=True)
+ expires_at = Column(DateTime(timezone=True), nullable=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+ deleted_at = Column(DateTime(timezone=True), nullable=True)
+
+ # Relationships
+ uploader = relationship("User", foreign_keys=[uploaded_by])
+ storage_config = relationship("StorageConfig", foreign_keys=[storage_config_id])
+
+
+class StorageConfig(Base):
+ __tablename__ = "storage_configs"
+
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ name = Column(String(100), nullable=False)
+ storage_type = Column(String(20), nullable=False) # local, ftp
+ is_default = Column(Boolean, default=False, nullable=False)
+ is_active = Column(Boolean, default=True, nullable=False)
+ config_data = Column(JSON, nullable=False)
+ created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+ # Relationships
+ creator = relationship("User", foreign_keys=[created_by])
+
+
+class FileVerification(Base):
+ __tablename__ = "file_verifications"
+
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ file_id = Column(String(36), ForeignKey("file_storage.id"), nullable=False)
+ module_name = Column(String(50), nullable=False)
+ verification_token = Column(String(100), nullable=False)
+ verified_at = Column(DateTime(timezone=True), nullable=True)
+ verified_by = Column(Integer, ForeignKey("users.id"), nullable=True)
+ verification_data = Column(JSON, nullable=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ # Relationships
+ file = relationship("FileStorage", foreign_keys=[file_id])
+ verifier = relationship("User", foreign_keys=[verified_by])
diff --git a/hesabixAPI/build/lib/adapters/db/models/fiscal_year.py b/hesabixAPI/build/lib/adapters/db/models/fiscal_year.py
new file mode 100644
index 0000000..a3026b7
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/fiscal_year.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Integer, Boolean, ForeignKey
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class FiscalYear(Base):
+ __tablename__ = "fiscal_years"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
+ start_date: Mapped[date] = mapped_column(Date, nullable=False)
+ end_date: Mapped[date] = mapped_column(Date, nullable=False)
+ is_last: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ business = relationship("Business", back_populates="fiscal_years")
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/password_reset.py b/hesabixAPI/build/lib/adapters/db/models/password_reset.py
new file mode 100644
index 0000000..d752ccb
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/password_reset.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, DateTime, ForeignKey
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class PasswordReset(Base):
+ __tablename__ = "password_resets"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
+ token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
+ expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+ used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/person.py b/hesabixAPI/build/lib/adapters/db/models/person.py
new file mode 100644
index 0000000..f130c07
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/person.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from datetime import datetime
+from enum import Enum
+
+from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint, Numeric, Boolean
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class PersonType(str, Enum):
+ """نوع شخص"""
+ CUSTOMER = "مشتری" # مشتری
+ MARKETER = "بازاریاب" # بازاریاب
+ EMPLOYEE = "کارمند" # کارمند
+ SUPPLIER = "تامینکننده" # تامینکننده
+ PARTNER = "همکار" # همکار
+ SELLER = "فروشنده" # فروشنده
+ SHAREHOLDER = "سهامدار" # سهامدار
+
+
+class Person(Base):
+ __tablename__ = "persons"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'code', name='uq_persons_business_code'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+
+ # اطلاعات پایه
+ code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="کد یکتا در هر کسب و کار")
+ alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)")
+ first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
+ last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی")
+ person_type: Mapped[PersonType] = mapped_column(
+ SQLEnum(PersonType, values_callable=lambda obj: [e.value for e in obj], name="person_type_enum"),
+ nullable=False,
+ comment="نوع شخص"
+ )
+ person_types: Mapped[str | None] = mapped_column(Text, nullable=True, comment="لیست انواع شخص به صورت JSON")
+ company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
+ payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
+ # سهام
+ share_count: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="تعداد سهام (فقط برای سهامدار)")
+
+ # تنظیمات پورسانت برای بازاریاب/فروشنده
+ commission_sale_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از فروش")
+ commission_sales_return_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از برگشت از فروش")
+ commission_sales_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ فروش مبنا برای پورسانت")
+ commission_sales_return_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ برگشت از فروش مبنا برای پورسانت")
+ commission_exclude_discounts: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه تخفیف در پورسانت")
+ commission_exclude_additions_deductions: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه اضافات و کسورات فاکتور در پورسانت")
+ commission_post_in_invoice_document: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="ثبت پورسانت در سند حسابداری فاکتور")
+
+ # اطلاعات اقتصادی
+ national_id: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True, comment="شناسه ملی")
+ registration_number: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شماره ثبت")
+ economic_id: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شناسه اقتصادی")
+
+ # اطلاعات تماس
+ country: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="کشور")
+ province: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="استان")
+ city: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شهرستان")
+ address: Mapped[str | None] = mapped_column(Text, nullable=True, comment="آدرس")
+ postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="کد پستی")
+ phone: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="تلفن")
+ mobile: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="موبایل")
+ fax: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="فکس")
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="پست الکترونیکی")
+ website: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="وبسایت")
+
+ # زمانبندی
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ business: Mapped["Business"] = relationship("Business", back_populates="persons")
+ bank_accounts: Mapped[list["PersonBankAccount"]] = relationship("PersonBankAccount", back_populates="person", cascade="all, delete-orphan")
+
+
+class PersonBankAccount(Base):
+ __tablename__ = "person_bank_accounts"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ person_id: Mapped[int] = mapped_column(Integer, ForeignKey("persons.id", ondelete="CASCADE"), nullable=False, index=True)
+
+ # اطلاعات حساب بانکی
+ bank_name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام بانک")
+ account_number: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شماره حساب")
+ card_number: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="شماره کارت")
+ sheba_number: Mapped[str | None] = mapped_column(String(30), nullable=True, comment="شماره شبا")
+
+ # زمانبندی
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ person: Mapped["Person"] = relationship("Person", back_populates="bank_accounts")
diff --git a/hesabixAPI/build/lib/adapters/db/models/price_list.py b/hesabixAPI/build/lib/adapters/db/models/price_list.py
new file mode 100644
index 0000000..7f95dd4
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/price_list.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import (
+ String,
+ Integer,
+ DateTime,
+ ForeignKey,
+ UniqueConstraint,
+ Boolean,
+ Numeric,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class PriceList(Base):
+ __tablename__ = "price_lists"
+ __table_args__ = (
+ UniqueConstraint("business_id", "name", name="uq_price_lists_business_name"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
+class PriceItem(Base):
+ __tablename__ = "price_items"
+ __table_args__ = (
+ UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", "currency_id", name="uq_price_items_unique_tier_currency"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ price_list_id: Mapped[int] = mapped_column(Integer, ForeignKey("price_lists.id", ondelete="CASCADE"), nullable=False, index=True)
+ product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
+ unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
+ tier_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="نام پله قیمت (تکی/عمده/همکار/...)" )
+ min_qty: Mapped[Decimal] = mapped_column(Numeric(18, 3), nullable=False, default=0)
+ price: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/product.py b/hesabixAPI/build/lib/adapters/db/models/product.py
new file mode 100644
index 0000000..6188a74
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/product.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+from enum import Enum
+
+from sqlalchemy import (
+ String,
+ Integer,
+ DateTime,
+ Text,
+ ForeignKey,
+ UniqueConstraint,
+ Boolean,
+ Numeric,
+ Enum as SQLEnum,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class ProductItemType(str, Enum):
+ PRODUCT = "کالا"
+ SERVICE = "خدمت"
+
+
+class Product(Base):
+ """
+ موجودیت کالا/خدمت در سطح هر کسبوکار
+ - کد دستی/اتوماتیک یکتا در هر کسبوکار
+ - پشتیبانی از مالیات فروش/خرید، کنترل موجودی و واحدها
+ - اتصال به دستهبندیها و ویژگیها (ویژگیها از طریق جدول لینک)
+ """
+
+ __tablename__ = "products"
+ __table_args__ = (
+ UniqueConstraint("business_id", "code", name="uq_products_business_code"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+
+ item_type: Mapped[ProductItemType] = mapped_column(
+ SQLEnum(ProductItemType, values_callable=lambda obj: [e.value for e in obj], name="product_item_type_enum"),
+ nullable=False,
+ default=ProductItemType.PRODUCT,
+ comment="نوع آیتم (کالا/خدمت)",
+ )
+
+ code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد یکتا در هر کسبوکار")
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ # دستهبندی (اختیاری)
+ category_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True)
+
+ # واحدها
+ main_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ secondary_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ unit_conversion_factor: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True)
+
+ # قیمتهای پایه (نمایشی)
+ base_sales_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
+ base_sales_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+ base_purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
+ base_purchase_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ # کنترل موجودی
+ track_inventory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ reorder_point: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ min_order_qty: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ lead_time_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
+ # مالیات
+ is_sales_taxable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ is_purchase_taxable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ sales_tax_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
+ purchase_tax_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
+ tax_type_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ tax_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ tax_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/product_attribute.py b/hesabixAPI/build/lib/adapters/db/models/product_attribute.py
new file mode 100644
index 0000000..2fec70d
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/product_attribute.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, Text, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class ProductAttribute(Base):
+ """
+ ویژگیهای کالا/خدمت در سطح هر کسبوکار
+ - عنوان و توضیحات ساده (بدون چندزبانه)
+ - هر عنوان در هر کسبوکار یکتا باشد
+ """
+ __tablename__ = "product_attributes"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'title', name='uq_product_attributes_business_title'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+
+ title: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/product_attribute_link.py b/hesabixAPI/build/lib/adapters/db/models/product_attribute_link.py
new file mode 100644
index 0000000..9d42847
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/product_attribute_link.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import Integer, DateTime, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adapters.db.session import Base
+
+
+class ProductAttributeLink(Base):
+ """لینک بین محصول و ویژگیها (چندبهچند)"""
+ __tablename__ = "product_attribute_links"
+ __table_args__ = (
+ UniqueConstraint("product_id", "attribute_id", name="uq_product_attribute_links_unique"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
+ attribute_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_attributes.id", ondelete="CASCADE"), nullable=False, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/support/__init__.py b/hesabixAPI/build/lib/adapters/db/models/support/__init__.py
new file mode 100644
index 0000000..d73b820
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/support/__init__.py
@@ -0,0 +1,8 @@
+from adapters.db.session import Base # re-export Base for Alembic
+
+# Import support models to register with SQLAlchemy metadata
+from .category import Category # noqa: F401
+from .priority import Priority # noqa: F401
+from .status import Status # noqa: F401
+from .ticket import Ticket # noqa: F401
+from .message import Message # noqa: F401
diff --git a/hesabixAPI/build/lib/adapters/db/models/support/category.py b/hesabixAPI/build/lib/adapters/db/models/support/category.py
new file mode 100644
index 0000000..ee1c947
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/support/category.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, DateTime, Boolean, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Category(Base):
+ """دستهبندی تیکتهای پشتیبانی"""
+ __tablename__ = "support_categories"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ tickets = relationship("Ticket", back_populates="category")
diff --git a/hesabixAPI/build/lib/adapters/db/models/support/message.py b/hesabixAPI/build/lib/adapters/db/models/support/message.py
new file mode 100644
index 0000000..799b09a
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/support/message.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from datetime import datetime
+from enum import Enum
+
+from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Boolean, Enum as SQLEnum
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class SenderType(str, Enum):
+ """نوع فرستنده پیام"""
+ USER = "user"
+ OPERATOR = "operator"
+ SYSTEM = "system"
+
+
+class Message(Base):
+ """پیامهای تیکتهای پشتیبانی"""
+ __tablename__ = "support_messages"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False, index=True)
+ sender_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ sender_type: Mapped[SenderType] = mapped_column(SQLEnum(SenderType), nullable=False, index=True)
+ content: Mapped[str] = mapped_column(Text, nullable=False)
+ is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا پیام داخلی است؟
+
+ # Timestamps
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+
+ # Relationships
+ ticket = relationship("Ticket", back_populates="messages")
+ sender = relationship("User")
diff --git a/hesabixAPI/build/lib/adapters/db/models/support/priority.py b/hesabixAPI/build/lib/adapters/db/models/support/priority.py
new file mode 100644
index 0000000..cbb0e7b
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/support/priority.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, DateTime, Integer, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Priority(Base):
+ """اولویت تیکتهای پشتیبانی"""
+ __tablename__ = "support_priorities"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color code
+ order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ tickets = relationship("Ticket", back_populates="priority")
diff --git a/hesabixAPI/build/lib/adapters/db/models/support/status.py b/hesabixAPI/build/lib/adapters/db/models/support/status.py
new file mode 100644
index 0000000..d216749
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/support/status.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, DateTime, Boolean, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Status(Base):
+ """وضعیت تیکتهای پشتیبانی"""
+ __tablename__ = "support_statuses"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color code
+ is_final: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا وضعیت نهایی است؟
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ tickets = relationship("Ticket", back_populates="status")
diff --git a/hesabixAPI/build/lib/adapters/db/models/support/ticket.py b/hesabixAPI/build/lib/adapters/db/models/support/ticket.py
new file mode 100644
index 0000000..70155e9
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/support/ticket.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Boolean
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Ticket(Base):
+ """تیکتهای پشتیبانی"""
+ __tablename__ = "support_tickets"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ title: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ description: Mapped[str] = mapped_column(Text, nullable=False)
+
+ # Foreign Keys
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
+ category_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_categories.id", ondelete="RESTRICT"), nullable=False, index=True)
+ priority_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_priorities.id", ondelete="RESTRICT"), nullable=False, index=True)
+ status_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_statuses.id", ondelete="RESTRICT"), nullable=False, index=True)
+ assigned_operator_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
+
+ # Additional fields
+ is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا تیکت داخلی است؟
+ closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+ # Timestamps
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ user = relationship("User", foreign_keys=[user_id], back_populates="tickets")
+ assigned_operator = relationship("User", foreign_keys=[assigned_operator_id])
+ category = relationship("Category", back_populates="tickets")
+ priority = relationship("Priority", back_populates="tickets")
+ status = relationship("Status", back_populates="tickets")
+ messages = relationship("Message", back_populates="ticket", cascade="all, delete-orphan")
diff --git a/hesabixAPI/build/lib/adapters/db/models/tax_unit.py b/hesabixAPI/build/lib/adapters/db/models/tax_unit.py
new file mode 100644
index 0000000..56be926
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/tax_unit.py
@@ -0,0 +1,24 @@
+from datetime import datetime
+from sqlalchemy import String, Integer, Boolean, DateTime, Text, Numeric
+from sqlalchemy.orm import Mapped, mapped_column
+from adapters.db.session import Base
+
+
+class TaxUnit(Base):
+ """
+ موجودیت واحد مالیاتی
+ - مدیریت واحدهای مالیاتی مختلف برای کسبوکارها
+ - پشتیبانی از انواع مختلف مالیات (فروش، خرید، ارزش افزوده و...)
+ """
+
+ __tablename__ = "tax_units"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True, comment="شناسه کسبوکار")
+ name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام واحد مالیاتی")
+ code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی")
+ description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="توضیحات")
+ tax_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="نرخ مالیات (درصد)")
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال/غیرفعال")
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
diff --git a/hesabixAPI/build/lib/adapters/db/models/user.py b/hesabixAPI/build/lib/adapters/db/models/user.py
new file mode 100644
index 0000000..2e119be
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/user.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey, JSON
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
+ mobile: Mapped[str | None] = mapped_column(String(32), unique=True, index=True, nullable=True)
+ first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ # Marketing/Referral fields
+ referral_code: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
+ referred_by_user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ # App permissions
+ app_permissions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Support relationships
+ tickets = relationship("Ticket", foreign_keys="Ticket.user_id", back_populates="user")
+
+ # Business relationships - using business_permissions instead
+ # businesses = relationship("BusinessUser", back_populates="user", cascade="all, delete-orphan")
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/api_key_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/api_key_repo.py
new file mode 100644
index 0000000..9c6aa0c
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/api_key_repo.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from adapters.db.models.api_key import ApiKey
+
+
+class ApiKeyRepository:
+ def __init__(self, db: Session) -> None:
+ self.db = db
+
+ def create_session_key(self, *, user_id: int, key_hash: str, device_id: str | None, user_agent: str | None, ip: str | None, expires_at: datetime | None) -> ApiKey:
+ obj = ApiKey(user_id=user_id, key_hash=key_hash, key_type="session", name=None, scopes=None, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get_by_hash(self, key_hash: str) -> Optional[ApiKey]:
+ stmt = select(ApiKey).where(ApiKey.key_hash == key_hash)
+ return self.db.execute(stmt).scalars().first()
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/base_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/base_repo.py
new file mode 100644
index 0000000..ffb52b6
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/base_repo.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from typing import Type, TypeVar, Generic, Any
+from sqlalchemy.orm import Session
+from sqlalchemy import select, func
+
+from app.services.query_service import QueryService
+from adapters.api.v1.schemas import QueryInfo
+
+T = TypeVar('T')
+
+
+class BaseRepository(Generic[T]):
+ """کلاس پایه برای Repository ها با قابلیت فیلتر پیشرفته"""
+
+ def __init__(self, db: Session, model_class: Type[T]) -> None:
+ self.db = db
+ self.model_class = model_class
+
+ def query_with_filters(self, query_info: QueryInfo) -> tuple[list[T], int]:
+ """
+ اجرای کوئری با فیلتر و بازگرداندن نتایج و تعداد کل
+
+ Args:
+ query_info: اطلاعات کوئری شامل فیلترها، مرتبسازی و صفحهبندی
+
+ Returns:
+ tuple: (لیست نتایج, تعداد کل رکوردها)
+ """
+ return QueryService.query_with_filters(self.model_class, self.db, query_info)
+
+ def get_by_id(self, id: int) -> T | None:
+ """دریافت رکورد بر اساس ID"""
+ stmt = select(self.model_class).where(self.model_class.id == id)
+ return self.db.execute(stmt).scalars().first()
+
+ def get_all(self, limit: int = 100, offset: int = 0) -> list[T]:
+ """دریافت تمام رکوردها با محدودیت"""
+ stmt = select(self.model_class).offset(offset).limit(limit)
+ return list(self.db.execute(stmt).scalars().all())
+
+ def count_all(self) -> int:
+ """شمارش تمام رکوردها"""
+ stmt = select(func.count()).select_from(self.model_class)
+ return int(self.db.execute(stmt).scalar() or 0)
+
+ def exists(self, **filters) -> bool:
+ """بررسی وجود رکورد بر اساس فیلترهای مشخص شده"""
+ stmt = select(self.model_class)
+ for field, value in filters.items():
+ if hasattr(self.model_class, field):
+ column = getattr(self.model_class, field)
+ stmt = stmt.where(column == value)
+
+ return self.db.execute(stmt).scalars().first() is not None
+
+ def delete(self, obj: T) -> None:
+ """حذف رکورد از دیتابیس"""
+ self.db.delete(obj)
+ self.db.commit()
+
+ def update(self, obj: T) -> None:
+ """بروزرسانی رکورد در دیتابیس"""
+ self.db.commit()
\ No newline at end of file
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py
new file mode 100644
index 0000000..b364fa2
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py
@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from sqlalchemy import select, and_, text
+from sqlalchemy.orm import Session
+
+from adapters.db.models.business_permission import BusinessPermission
+from adapters.db.repositories.base_repo import BaseRepository
+
+
+class BusinessPermissionRepository(BaseRepository[BusinessPermission]):
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, BusinessPermission)
+
+ def get_by_user_and_business(self, user_id: int, business_id: int) -> Optional[BusinessPermission]:
+ """دریافت دسترسیهای کاربر برای کسب و کار خاص"""
+ stmt = select(BusinessPermission).where(
+ and_(
+ BusinessPermission.user_id == user_id,
+ BusinessPermission.business_id == business_id
+ )
+ )
+ return self.db.execute(stmt).scalars().first()
+
+ def create_or_update(self, user_id: int, business_id: int, permissions: dict) -> BusinessPermission:
+ """ایجاد یا بهروزرسانی دسترسیهای کاربر برای کسب و کار"""
+ existing = self.get_by_user_and_business(user_id, business_id)
+
+ if existing:
+ # Preserve existing permissions and enforce join=True
+ existing_permissions = existing.business_permissions or {}
+
+ # Always ignore incoming 'join' field from clients
+ incoming_permissions = dict(permissions or {})
+ if 'join' in incoming_permissions:
+ incoming_permissions.pop('join', None)
+
+ # Merge and enforce join flag
+ merged_permissions = dict(existing_permissions)
+ merged_permissions.update(incoming_permissions)
+ merged_permissions['join'] = True
+
+ existing.business_permissions = merged_permissions
+ self.db.commit()
+ self.db.refresh(existing)
+ return existing
+ else:
+ # On creation, ensure join=True exists by default
+ base_permissions = {'join': True}
+ incoming_permissions = dict(permissions or {})
+ if 'join' in incoming_permissions:
+ incoming_permissions.pop('join', None)
+
+ new_permission = BusinessPermission(
+ user_id=user_id,
+ business_id=business_id,
+ business_permissions={**base_permissions, **incoming_permissions}
+ )
+ self.db.add(new_permission)
+ self.db.commit()
+ self.db.refresh(new_permission)
+ return new_permission
+
+ def delete_by_user_and_business(self, user_id: int, business_id: int) -> bool:
+ """حذف دسترسیهای کاربر برای کسب و کار"""
+ existing = self.get_by_user_and_business(user_id, business_id)
+ if existing:
+ self.db.delete(existing)
+ self.db.commit()
+ return True
+ return False
+
+ def get_user_businesses(self, user_id: int) -> list[BusinessPermission]:
+ """دریافت تمام کسب و کارهایی که کاربر دسترسی دارد"""
+ stmt = select(BusinessPermission).where(BusinessPermission.user_id == user_id)
+ return self.db.execute(stmt).scalars().all()
+
+ def get_business_users(self, business_id: int) -> list[BusinessPermission]:
+ """دریافت تمام کاربرانی که دسترسی به کسب و کار دارند"""
+ stmt = select(BusinessPermission).where(BusinessPermission.business_id == business_id)
+ return self.db.execute(stmt).scalars().all()
+
+ def get_user_member_businesses(self, user_id: int) -> list[BusinessPermission]:
+ """دریافت تمام کسب و کارهایی که کاربر عضو آنها است (دسترسی join)"""
+ # ابتدا تمام دسترسیهای کاربر را دریافت میکنیم
+ all_permissions = self.get_user_businesses(user_id)
+
+ # سپس فیلتر میکنیم
+ member_permissions = []
+ for perm in all_permissions:
+ # Normalize legacy/non-dict JSON values to dict before access
+ raw = perm.business_permissions
+ normalized = {}
+ if isinstance(raw, dict):
+ normalized = raw
+ elif isinstance(raw, list):
+ # If legacy stored as list, try to coerce to dict if it looks like key-value pairs
+ try:
+ # e.g., [["join", true], ["sales", {"read": true}]] or [{"join": true}, ...]
+ if all(isinstance(item, list) and len(item) == 2 for item in raw):
+ normalized = {k: v for k, v in raw if isinstance(k, str)}
+ elif all(isinstance(item, dict) for item in raw):
+ # Merge list of dicts
+ merged: dict = {}
+ for item in raw:
+ merged.update({k: v for k, v in item.items()})
+ normalized = merged
+ except Exception:
+ normalized = {}
+ elif raw is None:
+ normalized = {}
+ else:
+ # Unsupported type, skip safely
+ normalized = {}
+
+ if normalized.get('join') == True:
+ member_permissions.append(perm)
+
+ return member_permissions
\ No newline at end of file
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/business_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/business_repo.py
new file mode 100644
index 0000000..50c9af2
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/business_repo.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+from typing import List, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_
+
+from .base_repo import BaseRepository
+from ..models.business import Business, BusinessType, BusinessField
+
+
+class BusinessRepository(BaseRepository[Business]):
+ """Repository برای مدیریت کسب و کارها"""
+
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, Business)
+
+ def get_by_owner_id(self, owner_id: int) -> List[Business]:
+ """دریافت تمام کسب و کارهای یک مالک"""
+ stmt = select(Business).where(Business.owner_id == owner_id)
+ return list(self.db.execute(stmt).scalars().all())
+
+ def get_by_business_type(self, business_type: BusinessType) -> List[Business]:
+ """دریافت کسب و کارها بر اساس نوع"""
+ stmt = select(Business).where(Business.business_type == business_type)
+ return list(self.db.execute(stmt).scalars().all())
+
+ def get_by_business_field(self, business_field: BusinessField) -> List[Business]:
+ """دریافت کسب و کارها بر اساس زمینه فعالیت"""
+ stmt = select(Business).where(Business.business_field == business_field)
+ return list(self.db.execute(stmt).scalars().all())
+
+ def get_by_owner_and_type(self, owner_id: int, business_type: BusinessType) -> List[Business]:
+ """دریافت کسب و کارهای یک مالک بر اساس نوع"""
+ stmt = select(Business).where(
+ and_(
+ Business.owner_id == owner_id,
+ Business.business_type == business_type
+ )
+ )
+ return list(self.db.execute(stmt).scalars().all())
+
+ def search_by_name(self, name: str) -> List[Business]:
+ """جستجوی کسب و کارها بر اساس نام (case-insensitive)"""
+ stmt = select(Business).where(Business.name.ilike(f"%{name}%"))
+ return list(self.db.execute(stmt).scalars().all())
+
+ def create_business(
+ self,
+ name: str,
+ business_type: BusinessType,
+ business_field: BusinessField,
+ owner_id: int,
+ default_currency_id: int | None = None,
+ address: str | None = None,
+ phone: str | None = None,
+ mobile: str | None = None,
+ national_id: str | None = None,
+ registration_number: str | None = None,
+ economic_id: str | None = None,
+ country: str | None = None,
+ province: str | None = None,
+ city: str | None = None,
+ postal_code: str | None = None
+ ) -> Business:
+ """ایجاد کسب و کار جدید"""
+ business = Business(
+ name=name,
+ business_type=business_type,
+ business_field=business_field,
+ owner_id=owner_id,
+ default_currency_id=default_currency_id,
+ address=address,
+ phone=phone,
+ mobile=mobile,
+ national_id=national_id,
+ registration_number=registration_number,
+ economic_id=economic_id,
+ country=country,
+ province=province,
+ city=city,
+ postal_code=postal_code
+ )
+ self.db.add(business)
+ self.db.commit()
+ self.db.refresh(business)
+ return business
+
+ def get_by_national_id(self, national_id: str) -> Business | None:
+ """دریافت کسب و کار بر اساس شناسه ملی"""
+ stmt = select(Business).where(Business.national_id == national_id)
+ return self.db.execute(stmt).scalars().first()
+
+ def get_by_registration_number(self, registration_number: str) -> Business | None:
+ """دریافت کسب و کار بر اساس شماره ثبت"""
+ stmt = select(Business).where(Business.registration_number == registration_number)
+ return self.db.execute(stmt).scalars().first()
+
+ def get_by_economic_id(self, economic_id: str) -> Business | None:
+ """دریافت کسب و کار بر اساس شناسه اقتصادی"""
+ stmt = select(Business).where(Business.economic_id == economic_id)
+ return self.db.execute(stmt).scalars().first()
+
+ def search_by_phone(self, phone: str) -> List[Business]:
+ """جستجوی کسب و کارها بر اساس شماره تلفن"""
+ stmt = select(Business).where(
+ (Business.phone == phone) | (Business.mobile == phone)
+ )
+ return list(self.db.execute(stmt).scalars().all())
+
+ def get_by_country(self, country: str) -> List[Business]:
+ """دریافت کسب و کارها بر اساس کشور"""
+ stmt = select(Business).where(Business.country == country)
+ return list(self.db.execute(stmt).scalars().all())
+
+ def get_by_province(self, province: str) -> List[Business]:
+ """دریافت کسب و کارها بر اساس استان"""
+ stmt = select(Business).where(Business.province == province)
+ return list(self.db.execute(stmt).scalars().all())
+
+ def get_by_city(self, city: str) -> List[Business]:
+ """دریافت کسب و کارها بر اساس شهرستان"""
+ stmt = select(Business).where(Business.city == city)
+ return list(self.db.execute(stmt).scalars().all())
+
+ def get_by_postal_code(self, postal_code: str) -> List[Business]:
+ """دریافت کسب و کارها بر اساس کد پستی"""
+ stmt = select(Business).where(Business.postal_code == postal_code)
+ return list(self.db.execute(stmt).scalars().all())
+
+ def get_by_location(self, country: str | None = None, province: str | None = None, city: str | None = None) -> List[Business]:
+ """دریافت کسب و کارها بر اساس موقعیت جغرافیایی"""
+ stmt = select(Business)
+ conditions = []
+
+ if country:
+ conditions.append(Business.country == country)
+ if province:
+ conditions.append(Business.province == province)
+ if city:
+ conditions.append(Business.city == city)
+
+ if conditions:
+ stmt = stmt.where(and_(*conditions))
+
+ return list(self.db.execute(stmt).scalars().all())
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py
new file mode 100644
index 0000000..43ad61e
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+from typing import List, Dict, Any
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, or_
+
+from .base_repo import BaseRepository
+from ..models.category import BusinessCategory
+
+
+class CategoryRepository(BaseRepository[BusinessCategory]):
+ def __init__(self, db: Session):
+ super().__init__(db, BusinessCategory)
+
+ def get_tree(self, business_id: int, type_: str | None = None) -> list[Dict[str, Any]]:
+ stmt = select(BusinessCategory).where(BusinessCategory.business_id == business_id)
+ # درخت سراسری: نوع نادیده گرفته میشود (همه رکوردها)
+ stmt = stmt.order_by(BusinessCategory.sort_order.asc(), BusinessCategory.id.asc())
+ rows = list(self.db.execute(stmt).scalars().all())
+ flat = [
+ {
+ "id": r.id,
+ "parent_id": r.parent_id,
+ "translations": r.title_translations or {},
+ # برچسب واحد بر اساس زبان پیشفرض: ابتدا fa سپس en
+ "title": (r.title_translations or {}).get("fa")
+ or (r.title_translations or {}).get("en")
+ or "",
+ }
+ for r in rows
+ ]
+ return self._build_tree(flat)
+
+ def _build_tree(self, nodes: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
+ by_id: dict[int, Dict[str, Any]] = {}
+ roots: list[Dict[str, Any]] = []
+ for n in nodes:
+ item = {
+ "id": n["id"],
+ "parent_id": n.get("parent_id"),
+ "title": n.get("title", ""),
+ "translations": n.get("translations", {}),
+ "children": [],
+ }
+ by_id[item["id"]] = item
+ for item in list(by_id.values()):
+ pid = item.get("parent_id")
+ if pid and pid in by_id:
+ by_id[pid]["children"].append(item)
+ else:
+ roots.append(item)
+ return roots
+
+ def create_category(self, *, business_id: int, parent_id: int | None, translations: dict[str, str]) -> BusinessCategory:
+ obj = BusinessCategory(
+ business_id=business_id,
+ parent_id=parent_id,
+ title_translations=translations or {},
+ )
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def update_category(self, *, category_id: int, translations: dict[str, str] | None = None) -> BusinessCategory | None:
+ obj = self.db.get(BusinessCategory, category_id)
+ if not obj:
+ return None
+ if translations:
+ obj.title_translations = {**(obj.title_translations or {}), **translations}
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def move_category(self, *, category_id: int, new_parent_id: int | None) -> BusinessCategory | None:
+ obj = self.db.get(BusinessCategory, category_id)
+ if not obj:
+ return None
+ obj.parent_id = new_parent_id
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete_category(self, *, category_id: int) -> bool:
+ obj = self.db.get(BusinessCategory, category_id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/email_config_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/email_config_repository.py
new file mode 100644
index 0000000..834c467
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/email_config_repository.py
@@ -0,0 +1,85 @@
+from typing import Optional, List
+from sqlalchemy.orm import Session
+from sqlalchemy import and_
+
+from adapters.db.models.email_config import EmailConfig
+from adapters.db.repositories.base_repo import BaseRepository
+
+
+class EmailConfigRepository(BaseRepository[EmailConfig]):
+ def __init__(self, db: Session):
+ super().__init__(db, EmailConfig)
+
+ def get_active_config(self) -> Optional[EmailConfig]:
+ """Get the currently active email configuration"""
+ return self.db.query(self.model_class).filter(self.model_class.is_active == True).first()
+
+ def get_default_config(self) -> Optional[EmailConfig]:
+ """Get the default email configuration"""
+ return self.db.query(self.model_class).filter(self.model_class.is_default == True).first()
+
+ def set_default_config(self, config_id: int) -> bool:
+ """Set a configuration as default (removes default from others)"""
+ try:
+ # First check if the config exists
+ config = self.get_by_id(config_id)
+ if not config:
+ return False
+
+ # Remove default from all configs
+ self.db.query(self.model_class).update({self.model_class.is_default: False})
+
+ # Set the specified config as default
+ config.is_default = True
+ self.db.commit()
+ return True
+ except Exception as e:
+ self.db.rollback()
+ print(f"Error in set_default_config: {e}") # Debug log
+ return False
+
+ def get_by_name(self, name: str) -> Optional[EmailConfig]:
+ """Get email configuration by name"""
+ return self.db.query(self.model_class).filter(self.model_class.name == name).first()
+
+ def get_all_configs(self) -> List[EmailConfig]:
+ """Get all email configurations"""
+ return self.db.query(self.model_class).order_by(self.model_class.created_at.desc()).all()
+
+ def set_active_config(self, config_id: int) -> bool:
+ """Set a specific configuration as active and deactivate others"""
+ try:
+ # Deactivate all configs
+ self.db.query(self.model_class).update({self.model_class.is_active: False})
+
+ # Activate the specified config
+ config = self.get_by_id(config_id)
+ if config:
+ config.is_active = True
+ self.db.commit()
+ return True
+ return False
+ except Exception:
+ self.db.rollback()
+ return False
+
+ def test_connection(self, config: EmailConfig) -> bool:
+ """Test SMTP connection for a configuration"""
+ try:
+ import smtplib
+ from email.mime.text import MIMEText
+
+ # Create SMTP connection
+ if config.use_ssl:
+ server = smtplib.SMTP_SSL(config.smtp_host, config.smtp_port)
+ else:
+ server = smtplib.SMTP(config.smtp_host, config.smtp_port)
+ if config.use_tls:
+ server.starttls()
+
+ # Login
+ server.login(config.smtp_username, config.smtp_password)
+ server.quit()
+ return True
+ except Exception:
+ return False
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/file_storage_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/file_storage_repository.py
new file mode 100644
index 0000000..a3ba233
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/file_storage_repository.py
@@ -0,0 +1,301 @@
+from typing import List, Optional, Dict, Any
+from uuid import UUID
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, or_, desc, func
+from datetime import datetime, timedelta
+
+from adapters.db.models.file_storage import FileStorage, StorageConfig, FileVerification
+from adapters.db.repositories.base_repo import BaseRepository
+
+
+class FileStorageRepository(BaseRepository[FileStorage]):
+ def __init__(self, db: Session):
+ super().__init__(db, FileStorage)
+
+ async def create_file(
+ self,
+ original_name: str,
+ stored_name: str,
+ file_path: str,
+ file_size: int,
+ mime_type: str,
+ storage_type: str,
+ uploaded_by: UUID,
+ module_context: str,
+ context_id: Optional[UUID] = None,
+ developer_data: Optional[Dict] = None,
+ checksum: Optional[str] = None,
+ is_temporary: bool = False,
+ expires_in_days: int = 30,
+ storage_config_id: Optional[UUID] = None
+ ) -> FileStorage:
+ expires_at = None
+ if is_temporary:
+ expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
+
+ file_storage = FileStorage(
+ original_name=original_name,
+ stored_name=stored_name,
+ file_path=file_path,
+ file_size=file_size,
+ mime_type=mime_type,
+ storage_type=storage_type,
+ storage_config_id=storage_config_id,
+ uploaded_by=uploaded_by,
+ module_context=module_context,
+ context_id=context_id,
+ developer_data=developer_data,
+ checksum=checksum,
+ is_temporary=is_temporary,
+ expires_at=expires_at
+ )
+
+ self.db.add(file_storage)
+ self.db.commit()
+ self.db.refresh(file_storage)
+ return file_storage
+
+ async def get_file_by_id(self, file_id: UUID) -> Optional[FileStorage]:
+ return self.db.query(FileStorage).filter(
+ and_(
+ FileStorage.id == file_id,
+ FileStorage.deleted_at.is_(None)
+ )
+ ).first()
+
+ async def get_files_by_context(
+ self,
+ module_context: str,
+ context_id: UUID
+ ) -> List[FileStorage]:
+ return self.db.query(FileStorage).filter(
+ and_(
+ FileStorage.module_context == module_context,
+ FileStorage.context_id == context_id,
+ FileStorage.deleted_at.is_(None),
+ FileStorage.is_active == True
+ )
+ ).order_by(desc(FileStorage.created_at)).all()
+
+ async def get_user_files(
+ self,
+ user_id: UUID,
+ limit: int = 50,
+ offset: int = 0
+ ) -> List[FileStorage]:
+ return self.db.query(FileStorage).filter(
+ and_(
+ FileStorage.uploaded_by == user_id,
+ FileStorage.deleted_at.is_(None)
+ )
+ ).order_by(desc(FileStorage.created_at)).offset(offset).limit(limit).all()
+
+ async def get_unverified_temporary_files(self) -> List[FileStorage]:
+ return self.db.query(FileStorage).filter(
+ and_(
+ FileStorage.is_temporary == True,
+ FileStorage.is_verified == False,
+ FileStorage.deleted_at.is_(None),
+ FileStorage.is_active == True
+ )
+ ).all()
+
+ async def get_expired_temporary_files(self) -> List[FileStorage]:
+ return self.db.query(FileStorage).filter(
+ and_(
+ FileStorage.is_temporary == True,
+ FileStorage.expires_at < datetime.utcnow(),
+ FileStorage.deleted_at.is_(None)
+ )
+ ).all()
+
+ async def verify_file(self, file_id: UUID, verification_data: Dict) -> bool:
+ file_storage = await self.get_file_by_id(file_id)
+ if not file_storage:
+ return False
+
+ file_storage.is_verified = True
+ file_storage.last_verified_at = datetime.utcnow()
+ file_storage.developer_data = {**(file_storage.developer_data or {}), **verification_data}
+
+ self.db.commit()
+ return True
+
+ async def soft_delete_file(self, file_id: UUID) -> bool:
+ file_storage = await self.get_file_by_id(file_id)
+ if not file_storage:
+ return False
+
+ file_storage.deleted_at = datetime.utcnow()
+ file_storage.is_active = False
+
+ self.db.commit()
+ return True
+
+ async def restore_file(self, file_id: UUID) -> bool:
+ file_storage = self.db.query(FileStorage).filter(FileStorage.id == file_id).first()
+ if not file_storage:
+ return False
+
+ file_storage.deleted_at = None
+ file_storage.is_active = True
+
+ self.db.commit()
+ return True
+
+ async def get_storage_statistics(self) -> Dict[str, Any]:
+ total_files = self.db.query(FileStorage).filter(
+ FileStorage.deleted_at.is_(None)
+ ).count()
+
+ total_size = self.db.query(func.sum(FileStorage.file_size)).filter(
+ FileStorage.deleted_at.is_(None)
+ ).scalar() or 0
+
+ temporary_files = self.db.query(FileStorage).filter(
+ and_(
+ FileStorage.is_temporary == True,
+ FileStorage.deleted_at.is_(None)
+ )
+ ).count()
+
+ unverified_files = self.db.query(FileStorage).filter(
+ and_(
+ FileStorage.is_temporary == True,
+ FileStorage.is_verified == False,
+ FileStorage.deleted_at.is_(None)
+ )
+ ).count()
+
+ return {
+ "total_files": total_files,
+ "total_size": total_size,
+ "temporary_files": temporary_files,
+ "unverified_files": unverified_files
+ }
+
+
+class StorageConfigRepository(BaseRepository[StorageConfig]):
+ def __init__(self, db: Session):
+ super().__init__(db, StorageConfig)
+
+ async def create_config(
+ self,
+ name: str,
+ storage_type: str,
+ config_data: Dict,
+ created_by: int,
+ is_default: bool = False,
+ is_active: bool = True
+ ) -> StorageConfig:
+ # اگر این config به عنوان پیشفرض تنظیم میشود، بقیه را غیرفعال کن
+ if is_default:
+ await self.clear_default_configs()
+
+ storage_config = StorageConfig(
+ name=name,
+ storage_type=storage_type,
+ config_data=config_data,
+ created_by=created_by,
+ is_default=is_default,
+ is_active=is_active
+ )
+
+ self.db.add(storage_config)
+ self.db.commit()
+ self.db.refresh(storage_config)
+ return storage_config
+
+ async def get_default_config(self) -> Optional[StorageConfig]:
+ return self.db.query(StorageConfig).filter(
+ and_(
+ StorageConfig.is_default == True,
+ StorageConfig.is_active == True
+ )
+ ).first()
+
+ def get_all_configs(self) -> List[StorageConfig]:
+ return self.db.query(StorageConfig).filter(
+ StorageConfig.is_active == True
+ ).order_by(desc(StorageConfig.created_at)).all()
+
+ async def set_default_config(self, config_id: UUID) -> bool:
+ # ابتدا همه config ها را غیرپیشفرض کن
+ await self.clear_default_configs()
+
+ # config مورد نظر را پیشفرض کن
+ config = self.db.query(StorageConfig).filter(StorageConfig.id == config_id).first()
+ if not config:
+ return False
+
+ config.is_default = True
+ self.db.commit()
+ return True
+
+ async def clear_default_configs(self):
+ self.db.query(StorageConfig).update({"is_default": False})
+ self.db.commit()
+
+ def count_files_by_storage_config(self, config_id: str) -> int:
+ """شمارش تعداد فایلهای مربوط به یک storage config"""
+ return self.db.query(FileStorage).filter(
+ FileStorage.storage_config_id == config_id,
+ FileStorage.is_active == True,
+ FileStorage.deleted_at.is_(None)
+ ).count()
+
+ def delete_config(self, config_id: str) -> bool:
+ config = self.db.query(StorageConfig).filter(StorageConfig.id == config_id).first()
+ if not config:
+ return False
+
+ config.is_active = False
+ self.db.commit()
+ return True
+
+
+class FileVerificationRepository(BaseRepository[FileVerification]):
+ def __init__(self, db: Session):
+ super().__init__(FileVerification, db)
+
+ async def create_verification(
+ self,
+ file_id: UUID,
+ module_name: str,
+ verification_token: str,
+ verification_data: Optional[Dict] = None
+ ) -> FileVerification:
+ verification = FileVerification(
+ file_id=file_id,
+ module_name=module_name,
+ verification_token=verification_token,
+ verification_data=verification_data
+ )
+
+ self.db.add(verification)
+ self.db.commit()
+ self.db.refresh(verification)
+ return verification
+
+ async def verify_file(
+ self,
+ file_id: UUID,
+ verification_token: str,
+ verified_by: UUID
+ ) -> bool:
+ verification = self.db.query(FileVerification).filter(
+ and_(
+ FileVerification.file_id == file_id,
+ FileVerification.verification_token == verification_token,
+ FileVerification.verified_at.is_(None)
+ )
+ ).first()
+
+ if not verification:
+ return False
+
+ verification.verified_at = datetime.utcnow()
+ verification.verified_by = verified_by
+
+ self.db.commit()
+ return True
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/fiscal_year_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/fiscal_year_repo.py
new file mode 100644
index 0000000..b4cf059
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/fiscal_year_repo.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from datetime import date
+from sqlalchemy.orm import Session
+
+from .base_repo import BaseRepository
+from ..models.fiscal_year import FiscalYear
+
+
+class FiscalYearRepository(BaseRepository[FiscalYear]):
+ """Repository برای مدیریت سالهای مالی"""
+
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, FiscalYear)
+
+ def create_fiscal_year(
+ self,
+ *,
+ business_id: int,
+ title: str,
+ start_date: date,
+ end_date: date,
+ is_last: bool = True,
+ ) -> FiscalYear:
+ fiscal_year = FiscalYear(
+ business_id=business_id,
+ title=title,
+ start_date=start_date,
+ end_date=end_date,
+ is_last=is_last,
+ )
+ self.db.add(fiscal_year)
+ self.db.commit()
+ self.db.refresh(fiscal_year)
+ return fiscal_year
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/password_reset_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/password_reset_repo.py
new file mode 100644
index 0000000..4b60837
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/password_reset_repo.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from adapters.db.models.password_reset import PasswordReset
+
+
+class PasswordResetRepository:
+ def __init__(self, db: Session) -> None:
+ self.db = db
+
+ def create(self, *, user_id: int, token_hash: str, expires_at: datetime) -> PasswordReset:
+ obj = PasswordReset(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def get_by_hash(self, token_hash: str) -> Optional[PasswordReset]:
+ stmt = select(PasswordReset).where(PasswordReset.token_hash == token_hash)
+ return self.db.execute(stmt).scalars().first()
+
+ def mark_used(self, pr: PasswordReset) -> None:
+ pr.used_at = datetime.utcnow()
+ self.db.add(pr)
+ self.db.commit()
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/price_list_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/price_list_repository.py
new file mode 100644
index 0000000..6d7c500
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/price_list_repository.py
@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+from typing import Any, Dict, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, func, and_, or_
+
+from .base_repo import BaseRepository
+from ..models.price_list import PriceList, PriceItem
+
+
+class PriceListRepository(BaseRepository[PriceList]):
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, PriceList)
+
+ def search(self, *, business_id: int, take: int = 20, skip: int = 0, sort_by: str | None = None, sort_desc: bool = True, search: str | None = None) -> dict[str, Any]:
+ stmt = select(PriceList).where(PriceList.business_id == business_id)
+ if search:
+ stmt = stmt.where(PriceList.name.ilike(f"%{search}%"))
+
+ total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
+ if sort_by in {"name", "created_at"}:
+ col = getattr(PriceList, sort_by)
+ stmt = stmt.order_by(col.desc() if sort_desc else col.asc())
+ else:
+ stmt = stmt.order_by(PriceList.id.desc() if sort_desc else PriceList.id.asc())
+
+ rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all())
+ items = [self._to_dict_list(pl) for pl in rows]
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1 if take else 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take if take else 1,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ }
+
+ def create(self, **data: Any) -> PriceList:
+ obj = PriceList(**data)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def update(self, id: int, **data: Any) -> Optional[PriceList]:
+ obj = self.db.get(PriceList, id)
+ if not obj:
+ return None
+ for k, v in data.items():
+ if hasattr(obj, k) and v is not None:
+ setattr(obj, k, v)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, id: int) -> bool:
+ obj = self.db.get(PriceList, id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+ def _to_dict_list(self, pl: PriceList) -> dict[str, Any]:
+ return {
+ "id": pl.id,
+ "business_id": pl.business_id,
+ "name": pl.name,
+ "is_active": pl.is_active,
+ "created_at": pl.created_at,
+ "updated_at": pl.updated_at,
+ }
+
+
+class PriceItemRepository(BaseRepository[PriceItem]):
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, PriceItem)
+
+ def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> dict[str, Any]:
+ stmt = select(PriceItem).where(PriceItem.price_list_id == price_list_id)
+ if product_id is not None:
+ stmt = stmt.where(PriceItem.product_id == product_id)
+ if currency_id is not None:
+ stmt = stmt.where(PriceItem.currency_id == currency_id)
+ total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
+ rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all())
+ items = [self._to_dict(pi) for pi in rows]
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1 if take else 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take if take else 1,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ }
+
+ def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int, tier_name: str | None, min_qty, price) -> PriceItem:
+ # Try find existing unique combination
+ stmt = select(PriceItem).where(
+ and_(
+ PriceItem.price_list_id == price_list_id,
+ PriceItem.product_id == product_id,
+ PriceItem.unit_id.is_(unit_id) if unit_id is None else PriceItem.unit_id == unit_id,
+ PriceItem.tier_name == (tier_name or 'پیشفرض'),
+ PriceItem.min_qty == min_qty,
+ PriceItem.currency_id == currency_id,
+ )
+ )
+ existing = self.db.execute(stmt).scalars().first()
+ if existing:
+ existing.price = price
+ existing.currency_id = currency_id
+ self.db.commit()
+ self.db.refresh(existing)
+ return existing
+ obj = PriceItem(
+ price_list_id=price_list_id,
+ product_id=product_id,
+ unit_id=unit_id,
+ currency_id=currency_id,
+ tier_name=(tier_name or 'پیشفرض'),
+ min_qty=min_qty,
+ price=price,
+ )
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, id: int) -> bool:
+ obj = self.db.get(PriceItem, id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+ def _to_dict(self, pi: PriceItem) -> dict[str, Any]:
+ return {
+ "id": pi.id,
+ "price_list_id": pi.price_list_id,
+ "product_id": pi.product_id,
+ "unit_id": pi.unit_id,
+ "currency_id": pi.currency_id,
+ "tier_name": pi.tier_name,
+ "min_qty": pi.min_qty,
+ "price": pi.price,
+ "created_at": pi.created_at,
+ "updated_at": pi.updated_at,
+ }
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/product_attribute_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/product_attribute_repository.py
new file mode 100644
index 0000000..b61846e
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/product_attribute_repository.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+from typing import Dict, Any, List, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, func
+
+from .base_repo import BaseRepository
+from ..models.product_attribute import ProductAttribute
+
+
+class ProductAttributeRepository(BaseRepository[ProductAttribute]):
+ def __init__(self, db: Session):
+ super().__init__(db, ProductAttribute)
+
+ def search(
+ self,
+ *,
+ business_id: int,
+ take: int = 20,
+ skip: int = 0,
+ sort_by: str | None = None,
+ sort_desc: bool = True,
+ search: str | None = None,
+ filters: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ stmt = select(ProductAttribute).where(ProductAttribute.business_id == business_id)
+
+ if search:
+ stmt = stmt.where(ProductAttribute.title.ilike(f"%{search}%"))
+
+ total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
+
+ # Sorting
+ if sort_by == 'title':
+ order_col = ProductAttribute.title.desc() if sort_desc else ProductAttribute.title.asc()
+ stmt = stmt.order_by(order_col)
+ else:
+ order_col = ProductAttribute.id.desc() if sort_desc else ProductAttribute.id.asc()
+ stmt = stmt.order_by(order_col)
+
+ # Paging
+ stmt = stmt.offset(skip).limit(take)
+ rows = list(self.db.execute(stmt).scalars().all())
+
+ items: list[dict[str, Any]] = [
+ {
+ "id": r.id,
+ "business_id": r.business_id,
+ "title": r.title,
+ "description": r.description,
+ "created_at": r.created_at,
+ "updated_at": r.updated_at,
+ }
+ for r in rows
+ ]
+
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1 if take else 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take if take else 1,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ }
+
+ def create(self, *, business_id: int, title: str, description: str | None) -> ProductAttribute:
+ obj = ProductAttribute(business_id=business_id, title=title, description=description)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def update(self, *, attribute_id: int, title: str | None, description: str | None) -> Optional[ProductAttribute]:
+ obj = self.db.get(ProductAttribute, attribute_id)
+ if not obj:
+ return None
+ if title is not None:
+ obj.title = title
+ if description is not None:
+ obj.description = description
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, *, attribute_id: int) -> bool:
+ obj = self.db.get(ProductAttribute, attribute_id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py
new file mode 100644
index 0000000..db63dc2
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from typing import Any, Dict, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, func
+
+from .base_repo import BaseRepository
+from ..models.product import Product
+
+
+class ProductRepository(BaseRepository[Product]):
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, Product)
+
+ def search(self, *, business_id: int, take: int = 20, skip: int = 0, sort_by: str | None = None, sort_desc: bool = True, search: str | None = None, filters: dict[str, Any] | None = None) -> dict[str, Any]:
+ stmt = select(Product).where(Product.business_id == business_id)
+
+ if search:
+ like = f"%{search}%"
+ stmt = stmt.where(
+ or_(
+ Product.name.ilike(like),
+ Product.code.ilike(like),
+ Product.description.ilike(like),
+ )
+ )
+
+ total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
+
+ # Sorting
+ if sort_by in {"name", "code", "created_at"}:
+ col = getattr(Product, sort_by)
+ stmt = stmt.order_by(col.desc() if sort_desc else col.asc())
+ else:
+ stmt = stmt.order_by(Product.id.desc() if sort_desc else Product.id.asc())
+
+ stmt = stmt.offset(skip).limit(take)
+ rows = list(self.db.execute(stmt).scalars().all())
+
+ def _to_dict(p: Product) -> dict[str, Any]:
+ return {
+ "id": p.id,
+ "business_id": p.business_id,
+ "item_type": p.item_type.value if hasattr(p.item_type, 'value') else str(p.item_type),
+ "code": p.code,
+ "name": p.name,
+ "description": p.description,
+ "category_id": p.category_id,
+ "main_unit_id": p.main_unit_id,
+ "secondary_unit_id": p.secondary_unit_id,
+ "unit_conversion_factor": p.unit_conversion_factor,
+ "base_sales_price": p.base_sales_price,
+ "base_sales_note": p.base_sales_note,
+ "base_purchase_price": p.base_purchase_price,
+ "base_purchase_note": p.base_purchase_note,
+ "track_inventory": p.track_inventory,
+ "reorder_point": p.reorder_point,
+ "min_order_qty": p.min_order_qty,
+ "lead_time_days": p.lead_time_days,
+ "is_sales_taxable": p.is_sales_taxable,
+ "is_purchase_taxable": p.is_purchase_taxable,
+ "sales_tax_rate": p.sales_tax_rate,
+ "purchase_tax_rate": p.purchase_tax_rate,
+ "tax_type_id": p.tax_type_id,
+ "tax_code": p.tax_code,
+ "tax_unit_id": p.tax_unit_id,
+ "created_at": p.created_at,
+ "updated_at": p.updated_at,
+ }
+
+ items = [_to_dict(r) for r in rows]
+
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1 if take else 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take if take else 1,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ }
+
+ def create(self, **data: Any) -> Product:
+ obj = Product(**data)
+ self.db.add(obj)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def update(self, product_id: int, **data: Any) -> Optional[Product]:
+ obj = self.db.get(Product, product_id)
+ if not obj:
+ return None
+ for k, v in data.items():
+ if hasattr(obj, k) and v is not None:
+ setattr(obj, k, v)
+ self.db.commit()
+ self.db.refresh(obj)
+ return obj
+
+ def delete(self, product_id: int) -> bool:
+ obj = self.db.get(Product, product_id)
+ if not obj:
+ return False
+ self.db.delete(obj)
+ self.db.commit()
+ return True
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/__init__.py b/hesabixAPI/build/lib/adapters/db/repositories/support/__init__.py
new file mode 100644
index 0000000..b496a54
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/support/__init__.py
@@ -0,0 +1 @@
+# Support repositories
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/category_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/category_repository.py
new file mode 100644
index 0000000..0b43003
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/support/category_repository.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from typing import List
+from sqlalchemy.orm import Session
+
+from adapters.db.repositories.base_repo import BaseRepository
+from adapters.db.models.support.category import Category
+
+
+class CategoryRepository(BaseRepository[Category]):
+ def __init__(self, db: Session):
+ super().__init__(db, Category)
+
+ def get_active_categories(self) -> List[Category]:
+ """دریافت دستهبندیهای فعال"""
+ return self.db.query(Category)\
+ .filter(Category.is_active == True)\
+ .order_by(Category.name)\
+ .all()
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/message_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/message_repository.py
new file mode 100644
index 0000000..11c7e82
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/support/message_repository.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from typing import Optional, List
+from sqlalchemy.orm import Session, joinedload
+from sqlalchemy import select, func, and_, or_
+
+from adapters.db.repositories.base_repo import BaseRepository
+from adapters.db.models.support.message import Message, SenderType
+from adapters.api.v1.schemas import QueryInfo
+
+
+class MessageRepository(BaseRepository[Message]):
+ def __init__(self, db: Session):
+ super().__init__(db, Message)
+
+ def get_ticket_messages(self, ticket_id: int, query_info: QueryInfo) -> tuple[List[Message], int]:
+ """دریافت پیامهای تیکت با فیلتر و صفحهبندی"""
+ query = self.db.query(Message)\
+ .options(joinedload(Message.sender))\
+ .filter(Message.ticket_id == ticket_id)
+
+ # اعمال جستجو
+ if query_info.search and query_info.search_fields:
+ search_conditions = []
+ for field in query_info.search_fields:
+ if hasattr(Message, field):
+ search_conditions.append(getattr(Message, field).ilike(f"%{query_info.search}%"))
+ if search_conditions:
+ query = query.filter(or_(*search_conditions))
+
+ # شمارش کل
+ total = query.count()
+
+ # اعمال مرتبسازی
+ if query_info.sort_by and hasattr(Message, query_info.sort_by):
+ sort_column = getattr(Message, query_info.sort_by)
+ if query_info.sort_desc:
+ query = query.order_by(sort_column.desc())
+ else:
+ query = query.order_by(sort_column.asc())
+ else:
+ query = query.order_by(Message.created_at.asc())
+
+ # اعمال صفحهبندی
+ query = query.offset(query_info.skip).limit(query_info.take)
+
+ return query.all(), total
+
+ def create_message(
+ self,
+ ticket_id: int,
+ sender_id: int,
+ sender_type: SenderType,
+ content: str,
+ is_internal: bool = False
+ ) -> Message:
+ """ایجاد پیام جدید"""
+ from datetime import datetime
+ from adapters.db.models.support.ticket import Ticket
+
+ message = Message(
+ ticket_id=ticket_id,
+ sender_id=sender_id,
+ sender_type=sender_type,
+ content=content,
+ is_internal=is_internal
+ )
+
+ self.db.add(message)
+
+ # Update ticket's updated_at field
+ ticket = self.db.query(Ticket).filter(Ticket.id == ticket_id).first()
+ if ticket:
+ ticket.updated_at = datetime.utcnow()
+
+ self.db.commit()
+ self.db.refresh(message)
+ return message
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/priority_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/priority_repository.py
new file mode 100644
index 0000000..12d7a15
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/support/priority_repository.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from typing import List
+from sqlalchemy.orm import Session
+
+from adapters.db.repositories.base_repo import BaseRepository
+from adapters.db.models.support.priority import Priority
+
+
+class PriorityRepository(BaseRepository[Priority]):
+ def __init__(self, db: Session):
+ super().__init__(db, Priority)
+
+ def get_priorities_ordered(self) -> List[Priority]:
+ """دریافت اولویتها به ترتیب"""
+ return self.db.query(Priority)\
+ .order_by(Priority.order, Priority.name)\
+ .all()
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/status_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/status_repository.py
new file mode 100644
index 0000000..32e75c7
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/support/status_repository.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from typing import List
+from sqlalchemy.orm import Session
+
+from adapters.db.repositories.base_repo import BaseRepository
+from adapters.db.models.support.status import Status
+
+
+class StatusRepository(BaseRepository[Status]):
+ def __init__(self, db: Session):
+ super().__init__(db, Status)
+
+ def get_all_statuses(self) -> List[Status]:
+ """دریافت تمام وضعیتها"""
+ return self.db.query(Status)\
+ .order_by(Status.name)\
+ .all()
+
+ def get_final_statuses(self) -> List[Status]:
+ """دریافت وضعیتهای نهایی"""
+ return self.db.query(Status)\
+ .filter(Status.is_final == True)\
+ .order_by(Status.name)\
+ .all()
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/ticket_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/ticket_repository.py
new file mode 100644
index 0000000..a44601b
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/support/ticket_repository.py
@@ -0,0 +1,262 @@
+from __future__ import annotations
+
+from typing import Optional, List, Dict, Any
+from sqlalchemy.orm import Session, joinedload
+from sqlalchemy import select, func, and_, or_
+
+from adapters.db.repositories.base_repo import BaseRepository
+from adapters.db.models.support.ticket import Ticket
+from adapters.db.models.support.message import Message
+from adapters.api.v1.schemas import QueryInfo
+
+
+class TicketRepository(BaseRepository[Ticket]):
+ def __init__(self, db: Session):
+ super().__init__(db, Ticket)
+
+ def create(self, ticket_data: Dict[str, Any]) -> Ticket:
+ """ایجاد تیکت جدید"""
+ ticket = Ticket(**ticket_data)
+ self.db.add(ticket)
+ self.db.commit()
+ self.db.refresh(ticket)
+ return ticket
+
+ def get_ticket_with_details(self, ticket_id: int, user_id: int) -> Optional[Ticket]:
+ """دریافت تیکت با جزئیات کامل"""
+ return self.db.query(Ticket)\
+ .options(
+ joinedload(Ticket.user),
+ joinedload(Ticket.assigned_operator),
+ joinedload(Ticket.category),
+ joinedload(Ticket.priority),
+ joinedload(Ticket.status),
+ joinedload(Ticket.messages).joinedload(Message.sender)
+ )\
+ .filter(Ticket.id == ticket_id, Ticket.user_id == user_id)\
+ .first()
+
+ def get_operator_ticket_with_details(self, ticket_id: int) -> Optional[Ticket]:
+ """دریافت تیکت برای اپراتور با جزئیات کامل"""
+ return self.db.query(Ticket)\
+ .options(
+ joinedload(Ticket.user),
+ joinedload(Ticket.assigned_operator),
+ joinedload(Ticket.category),
+ joinedload(Ticket.priority),
+ joinedload(Ticket.status),
+ joinedload(Ticket.messages).joinedload(Message.sender)
+ )\
+ .filter(Ticket.id == ticket_id)\
+ .first()
+
+ def get_user_tickets(self, user_id: int, query_info: QueryInfo) -> tuple[List[Ticket], int]:
+ """دریافت تیکتهای کاربر با فیلتر و صفحهبندی"""
+ query = self.db.query(Ticket)\
+ .options(
+ joinedload(Ticket.category),
+ joinedload(Ticket.priority),
+ joinedload(Ticket.status)
+ )\
+ .filter(Ticket.user_id == user_id)
+
+ # اعمال فیلترها
+ if query_info.filters:
+ for filter_item in query_info.filters:
+ if filter_item.property == "title" and hasattr(Ticket, "title"):
+ if filter_item.operator == "*":
+ query = query.filter(Ticket.title.ilike(f"%{filter_item.value}%"))
+ elif filter_item.operator == "*?":
+ query = query.filter(Ticket.title.ilike(f"{filter_item.value}%"))
+ elif filter_item.operator == "?*":
+ query = query.filter(Ticket.title.ilike(f"%{filter_item.value}"))
+ elif filter_item.operator == "=":
+ query = query.filter(Ticket.title == filter_item.value)
+ elif filter_item.property == "category.name":
+ query = query.join(Ticket.category)
+ if filter_item.operator == "in":
+ from adapters.db.models.support.category import Category
+ query = query.filter(Category.name.in_(filter_item.value))
+ else:
+ query = query.filter(Ticket.category.has(name=filter_item.value))
+ elif filter_item.property == "priority.name":
+ query = query.join(Ticket.priority)
+ if filter_item.operator == "in":
+ from adapters.db.models.support.priority import Priority
+ query = query.filter(Priority.name.in_(filter_item.value))
+ else:
+ query = query.filter(Ticket.priority.has(name=filter_item.value))
+ elif filter_item.property == "status.name":
+ query = query.join(Ticket.status)
+ if filter_item.operator == "in":
+ from adapters.db.models.support.status import Status
+ query = query.filter(Status.name.in_(filter_item.value))
+ else:
+ query = query.filter(Ticket.status.has(name=filter_item.value))
+ elif filter_item.property == "description" and hasattr(Ticket, "description"):
+ if filter_item.operator == "*":
+ query = query.filter(Ticket.description.ilike(f"%{filter_item.value}%"))
+ elif filter_item.operator == "*?":
+ query = query.filter(Ticket.description.ilike(f"{filter_item.value}%"))
+ elif filter_item.operator == "?*":
+ query = query.filter(Ticket.description.ilike(f"%{filter_item.value}"))
+ elif filter_item.operator == "=":
+ query = query.filter(Ticket.description == filter_item.value)
+
+ # اعمال جستجو
+ if query_info.search and query_info.search_fields:
+ search_conditions = []
+ for field in query_info.search_fields:
+ if hasattr(Ticket, field):
+ search_conditions.append(getattr(Ticket, field).ilike(f"%{query_info.search}%"))
+ if search_conditions:
+ query = query.filter(or_(*search_conditions))
+
+ # شمارش کل
+ total = query.count()
+
+ # اعمال مرتبسازی
+ if query_info.sort_by and hasattr(Ticket, query_info.sort_by):
+ sort_column = getattr(Ticket, query_info.sort_by)
+ if query_info.sort_desc:
+ query = query.order_by(sort_column.desc())
+ else:
+ query = query.order_by(sort_column.asc())
+ else:
+ query = query.order_by(Ticket.created_at.desc())
+
+ # اعمال صفحهبندی
+ query = query.offset(query_info.skip).limit(query_info.take)
+
+ return query.all(), total
+
+ def get_operator_tickets(self, query_info: QueryInfo) -> tuple[List[Ticket], int]:
+ """دریافت تمام تیکتها برای اپراتور با فیلتر و صفحهبندی"""
+ query = self.db.query(Ticket)\
+ .options(
+ joinedload(Ticket.user),
+ joinedload(Ticket.assigned_operator),
+ joinedload(Ticket.category),
+ joinedload(Ticket.priority),
+ joinedload(Ticket.status)
+ )
+
+ # اعمال فیلترها
+ if query_info.filters:
+ for filter_item in query_info.filters:
+ if filter_item.property == "title" and hasattr(Ticket, "title"):
+ if filter_item.operator == "*":
+ query = query.filter(Ticket.title.ilike(f"%{filter_item.value}%"))
+ elif filter_item.operator == "*?":
+ query = query.filter(Ticket.title.ilike(f"{filter_item.value}%"))
+ elif filter_item.operator == "?*":
+ query = query.filter(Ticket.title.ilike(f"%{filter_item.value}"))
+ elif filter_item.operator == "=":
+ query = query.filter(Ticket.title == filter_item.value)
+ elif filter_item.property == "category.name":
+ query = query.join(Ticket.category)
+ if filter_item.operator == "in":
+ from adapters.db.models.support.category import Category
+ query = query.filter(Category.name.in_(filter_item.value))
+ else:
+ query = query.filter(Ticket.category.has(name=filter_item.value))
+ elif filter_item.property == "priority.name":
+ query = query.join(Ticket.priority)
+ if filter_item.operator == "in":
+ from adapters.db.models.support.priority import Priority
+ query = query.filter(Priority.name.in_(filter_item.value))
+ else:
+ query = query.filter(Ticket.priority.has(name=filter_item.value))
+ elif filter_item.property == "status.name":
+ query = query.join(Ticket.status)
+ if filter_item.operator == "in":
+ from adapters.db.models.support.status import Status
+ query = query.filter(Status.name.in_(filter_item.value))
+ else:
+ query = query.filter(Ticket.status.has(name=filter_item.value))
+ elif filter_item.property == "description" and hasattr(Ticket, "description"):
+ if filter_item.operator == "*":
+ query = query.filter(Ticket.description.ilike(f"%{filter_item.value}%"))
+ elif filter_item.operator == "*?":
+ query = query.filter(Ticket.description.ilike(f"{filter_item.value}%"))
+ elif filter_item.operator == "?*":
+ query = query.filter(Ticket.description.ilike(f"%{filter_item.value}"))
+ elif filter_item.operator == "=":
+ query = query.filter(Ticket.description == filter_item.value)
+ elif filter_item.property == "user_email":
+ query = query.join(Ticket.user).filter(Ticket.user.has(email=filter_item.value))
+ elif filter_item.property == "user_name":
+ query = query.join(Ticket.user).filter(
+ or_(
+ Ticket.user.has(first_name=filter_item.value),
+ Ticket.user.has(last_name=filter_item.value)
+ )
+ )
+
+ # اعمال جستجو
+ if query_info.search and query_info.search_fields:
+ search_conditions = []
+ for field in query_info.search_fields:
+ if hasattr(Ticket, field):
+ search_conditions.append(getattr(Ticket, field).ilike(f"%{query_info.search}%"))
+ elif field == "user_email" and hasattr(Ticket.user, "email"):
+ search_conditions.append(Ticket.user.email.ilike(f"%{query_info.search}%"))
+ elif field == "user_name":
+ search_conditions.append(
+ or_(
+ Ticket.user.first_name.ilike(f"%{query_info.search}%"),
+ Ticket.user.last_name.ilike(f"%{query_info.search}%")
+ )
+ )
+ if search_conditions:
+ query = query.filter(or_(*search_conditions))
+
+ # شمارش کل
+ total = query.count()
+
+ # اعمال مرتبسازی
+ if query_info.sort_by and hasattr(Ticket, query_info.sort_by):
+ sort_column = getattr(Ticket, query_info.sort_by)
+ if query_info.sort_desc:
+ query = query.order_by(sort_column.desc())
+ else:
+ query = query.order_by(sort_column.asc())
+ else:
+ query = query.order_by(Ticket.created_at.desc())
+
+ # اعمال صفحهبندی
+ query = query.offset(query_info.skip).limit(query_info.take)
+
+ return query.all(), total
+
+ def update_ticket_status(self, ticket_id: int, status_id: int, operator_id: Optional[int] = None) -> Optional[Ticket]:
+ """تغییر وضعیت تیکت"""
+ ticket = self.get_by_id(ticket_id)
+ if not ticket:
+ return None
+
+ ticket.status_id = status_id
+ if operator_id:
+ ticket.assigned_operator_id = operator_id
+
+ # اگر وضعیت نهایی است، تاریخ بسته شدن را تنظیم کن
+ from adapters.db.models.support.status import Status
+ status = self.db.query(Status).filter(Status.id == status_id).first()
+ if status and status.is_final:
+ from datetime import datetime
+ ticket.closed_at = datetime.utcnow()
+
+ self.db.commit()
+ self.db.refresh(ticket)
+ return ticket
+
+ def assign_ticket(self, ticket_id: int, operator_id: int) -> Optional[Ticket]:
+ """تخصیص تیکت به اپراتور"""
+ ticket = self.get_by_id(ticket_id)
+ if not ticket:
+ return None
+
+ ticket.assigned_operator_id = operator_id
+ self.db.commit()
+ self.db.refresh(ticket)
+ return ticket
\ No newline at end of file
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/user_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/user_repo.py
new file mode 100644
index 0000000..0b8ec1a
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/user_repo.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from sqlalchemy import select, func, and_, or_
+from sqlalchemy.orm import Session
+
+from adapters.db.models.user import User
+from adapters.db.repositories.base_repo import BaseRepository
+from adapters.api.v1.schemas import QueryInfo
+
+
+class UserRepository(BaseRepository[User]):
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, User)
+
+ def get_by_email(self, email: str) -> Optional[User]:
+ stmt = select(User).where(User.email == email)
+ return self.db.execute(stmt).scalars().first()
+
+ def get_by_mobile(self, mobile: str) -> Optional[User]:
+ stmt = select(User).where(User.mobile == mobile)
+ return self.db.execute(stmt).scalars().first()
+
+ def get_by_referral_code(self, referral_code: str) -> Optional[User]:
+ stmt = select(User).where(User.referral_code == referral_code)
+ return self.db.execute(stmt).scalars().first()
+
+ def is_first_user(self) -> bool:
+ """بررسی اینکه آیا این اولین کاربر سیستم است یا نه"""
+ stmt = select(func.count()).select_from(User)
+ count = self.db.execute(stmt).scalar() or 0
+ return count == 0
+
+ def create(self, *, email: str | None, mobile: str | None, password_hash: str, first_name: str | None, last_name: str | None, referral_code: str, referred_by_user_id: int | None = None) -> User:
+ # تعیین دسترسیهای برنامه بر اساس اینکه آیا کاربر اول است یا نه
+ app_permissions = {"superadmin": True} if self.is_first_user() else {}
+
+ user = User(
+ email=email,
+ mobile=mobile,
+ password_hash=password_hash,
+ first_name=first_name,
+ last_name=last_name,
+ referral_code=referral_code,
+ referred_by_user_id=referred_by_user_id,
+ app_permissions=app_permissions
+ )
+ self.db.add(user)
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def count_referred(self, referrer_user_id: int, start: str | None = None, end: str | None = None) -> int:
+ stmt = select(func.count()).select_from(User).where(User.referred_by_user_id == referrer_user_id)
+ if start is not None:
+ stmt = stmt.where(User.created_at >= func.cast(start, User.created_at.type))
+ if end is not None:
+ stmt = stmt.where(User.created_at < func.cast(end, User.created_at.type))
+ return int(self.db.execute(stmt).scalar() or 0)
+
+ def count_referred_between(self, referrer_user_id: int, start_dt, end_dt) -> int:
+ stmt = select(func.count()).select_from(User).where(
+ and_(
+ User.referred_by_user_id == referrer_user_id,
+ User.created_at >= start_dt,
+ User.created_at < end_dt,
+ )
+ )
+ return int(self.db.execute(stmt).scalar() or 0)
+
+ def count_referred_filtered(self, referrer_user_id: int, start_dt=None, end_dt=None, search: str | None = None) -> int:
+ stmt = select(func.count()).select_from(User).where(User.referred_by_user_id == referrer_user_id)
+ if start_dt is not None:
+ stmt = stmt.where(User.created_at >= start_dt)
+ if end_dt is not None:
+ stmt = stmt.where(User.created_at < end_dt)
+ if search:
+ like = f"%{search}%"
+ stmt = stmt.where(or_(User.first_name.ilike(like), User.last_name.ilike(like), User.email.ilike(like)))
+ return int(self.db.execute(stmt).scalar() or 0)
+
+ def list_referred(self, referrer_user_id: int, start_dt=None, end_dt=None, search: str | None = None, offset: int = 0, limit: int = 20):
+ stmt = select(User).where(User.referred_by_user_id == referrer_user_id)
+ if start_dt is not None:
+ stmt = stmt.where(User.created_at >= start_dt)
+ if end_dt is not None:
+ stmt = stmt.where(User.created_at < end_dt)
+ if search:
+ like = f"%{search}%"
+ stmt = stmt.where(or_(User.first_name.ilike(like), User.last_name.ilike(like), User.email.ilike(like)))
+ stmt = stmt.order_by(User.created_at.desc()).offset(offset).limit(limit)
+ return self.db.execute(stmt).scalars().all()
+
+ def to_dict(self, user: User) -> dict:
+ """تبدیل User object به dictionary برای API response"""
+ return {
+ "id": user.id,
+ "email": user.email,
+ "mobile": user.mobile,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ "is_active": user.is_active,
+ "referral_code": user.referral_code,
+ "referred_by_user_id": user.referred_by_user_id,
+ "app_permissions": user.app_permissions,
+ "created_at": user.created_at,
+ "updated_at": user.updated_at,
+ }
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/session.py b/hesabixAPI/build/lib/adapters/db/session.py
new file mode 100644
index 0000000..066d8d3
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/session.py
@@ -0,0 +1,23 @@
+from collections.abc import Generator
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, DeclarativeBase, Session
+
+from app.core.settings import get_settings
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+settings = get_settings()
+engine = create_engine(settings.mysql_dsn, echo=settings.sqlalchemy_echo, pool_pre_ping=True, pool_recycle=3600)
+SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
+
+
+def get_db() -> Generator[Session, None, None]:
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/hesabixAPI/build/lib/app/__init__.py b/hesabixAPI/build/lib/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hesabixAPI/build/lib/app/core/__init__.py b/hesabixAPI/build/lib/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hesabixAPI/build/lib/app/core/auth_dependency.py b/hesabixAPI/build/lib/app/core/auth_dependency.py
new file mode 100644
index 0000000..c38b5ac
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/auth_dependency.py
@@ -0,0 +1,431 @@
+from __future__ import annotations
+
+from typing import Optional
+from fastapi import Depends, Header, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.db.repositories.api_key_repo import ApiKeyRepository
+from adapters.db.models.user import User
+from app.core.security import hash_api_key
+from app.core.responses import ApiError
+from app.core.i18n import negotiate_locale, Translator
+from app.core.calendar import get_calendar_type_from_header, CalendarType
+
+
+class AuthContext:
+ """کلاس مرکزی برای نگهداری اطلاعات کاربر کنونی و تنظیمات"""
+
+ def __init__(
+ self,
+ user: User,
+ api_key_id: int,
+ language: str = "fa",
+ calendar_type: CalendarType = "jalali",
+ timezone: Optional[str] = None,
+ business_id: Optional[int] = None,
+ fiscal_year_id: Optional[int] = None,
+ db: Optional[Session] = None
+ ) -> None:
+ self.user = user
+ self.api_key_id = api_key_id
+ self.language = language
+ self.calendar_type = calendar_type
+ self.timezone = timezone
+ self.business_id = business_id
+ self.fiscal_year_id = fiscal_year_id
+ self.db = db
+
+ # دسترسیهای اپلیکیشن
+ self.app_permissions = user.app_permissions or {}
+
+ # دسترسیهای کسب و کار (در صورت وجود business_id)
+ self.business_permissions = self._get_business_permissions() if business_id and db else {}
+
+ # ایجاد translator برای زبان تشخیص داده شده
+ self._translator = Translator(language)
+
+ @staticmethod
+ def _normalize_permissions_value(value) -> dict:
+ """نرمالسازی مقدار JSON دسترسیها به dict برای سازگاری با دادههای legacy"""
+ if isinstance(value, dict):
+ return value
+ if isinstance(value, list):
+ try:
+ # لیست جفتها مانند [["join", true], ["sales", {..}]]
+ if all(isinstance(item, list) and len(item) == 2 for item in value):
+ return {k: v for k, v in value if isinstance(k, str)}
+ # لیست دیکشنریها مانند [{"join": true}, {"sales": {...}}]
+ if all(isinstance(item, dict) for item in value):
+ merged = {}
+ for item in value:
+ merged.update({k: v for k, v in item.items()})
+ return merged
+ except Exception:
+ return {}
+ return {}
+
+ def get_translator(self) -> Translator:
+ """دریافت translator برای ترجمه"""
+ return self._translator
+
+ def get_calendar_type(self) -> CalendarType:
+ """دریافت نوع تقویم"""
+ return self.calendar_type
+
+ def get_user_id(self) -> int:
+ """دریافت ID کاربر"""
+ return self.user.id
+
+ def get_user_email(self) -> Optional[str]:
+ """دریافت ایمیل کاربر"""
+ return self.user.email
+
+ def get_user_mobile(self) -> Optional[str]:
+ """دریافت شماره موبایل کاربر"""
+ return self.user.mobile
+
+ def get_user_name(self) -> str:
+ """دریافت نام کامل کاربر"""
+ first_name = self.user.first_name or ""
+ last_name = self.user.last_name or ""
+ return f"{first_name} {last_name}".strip()
+
+ def get_referral_code(self) -> Optional[str]:
+ """دریافت کد معرف کاربر"""
+ return getattr(self.user, "referral_code", None)
+
+ def is_user_active(self) -> bool:
+ """بررسی فعال بودن کاربر"""
+ return self.user.is_active
+
+ def _get_business_permissions(self) -> dict:
+ """دریافت دسترسیهای کسب و کار از دیتابیس"""
+ if not self.business_id or not self.db:
+ return {}
+
+ from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
+ repo = BusinessPermissionRepository(self.db)
+ permission_obj = repo.get_by_user_and_business(self.user.id, self.business_id)
+
+ if permission_obj and permission_obj.business_permissions:
+ return AuthContext._normalize_permissions_value(permission_obj.business_permissions)
+ return {}
+
+ # بررسی دسترسیهای اپلیکیشن
+ def has_app_permission(self, permission: str) -> bool:
+ """بررسی دسترسی در سطح اپلیکیشن"""
+ # SuperAdmin تمام دسترسیهای اپلیکیشن را دارد
+ if self.app_permissions.get("superadmin", False):
+ return True
+
+ return self.app_permissions.get(permission, False)
+
+ def is_superadmin(self) -> bool:
+ """بررسی superadmin بودن"""
+ return self.has_app_permission("superadmin")
+
+ def can_manage_users(self) -> bool:
+ """بررسی دسترسی مدیریت کاربران در سطح اپلیکیشن"""
+ return self.has_app_permission("user_management")
+
+ def can_manage_businesses(self) -> bool:
+ """بررسی دسترسی مدیریت کسب و کارها"""
+ return self.has_app_permission("business_management")
+
+ def can_access_system_settings(self) -> bool:
+ """بررسی دسترسی به تنظیمات سیستم"""
+ return self.has_app_permission("system_settings")
+
+ def can_access_support_operator(self) -> bool:
+ """بررسی دسترسی به پنل اپراتور پشتیبانی"""
+ return self.has_app_permission("support_operator")
+
+ def is_business_owner(self, business_id: int = None) -> bool:
+ """بررسی اینکه آیا کاربر مالک کسب و کار است یا نه"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ target_business_id = business_id or self.business_id
+ if not target_business_id or not self.db:
+ logger.info(f"is_business_owner: no business_id ({target_business_id}) or db ({self.db is not None})")
+ return False
+
+ from adapters.db.models.business import Business
+ business = self.db.get(Business, target_business_id)
+ is_owner = business and business.owner_id == self.user.id
+ logger.info(f"is_business_owner: business_id={target_business_id}, business={business}, owner_id={business.owner_id if business else None}, user_id={self.user.id}, is_owner={is_owner}")
+ return is_owner
+
+ # بررسی دسترسیهای کسب و کار
+ def has_business_permission(self, section: str, action: str) -> bool:
+ """بررسی دسترسی در سطح کسب و کار"""
+ if not self.business_id:
+ return False
+
+ # SuperAdmin تمام دسترسیها را دارد
+ if self.is_superadmin():
+ return True
+
+ # مالک کسب و کار تمام دسترسیها را دارد
+ if self.is_business_owner():
+ return True
+
+ # بررسی دسترسیهای عادی
+ if not self.business_permissions:
+ return False
+
+ # بررسی وجود بخش
+ if section not in self.business_permissions:
+ return False
+
+ section_perms = self.business_permissions[section]
+
+ # اگر بخش خالی است، فقط خواندن
+ if not section_perms:
+ return action == "read"
+
+ # بررسی دسترسی خاص
+ return section_perms.get(action, False)
+
+ def can_read_section(self, section: str) -> bool:
+ """بررسی دسترسی خواندن بخش در کسب و کار"""
+ if not self.business_id:
+ return False
+
+ # SuperAdmin و مالک کسب و کار دسترسی کامل دارند
+ if self.is_superadmin() or self.is_business_owner():
+ return True
+
+ return section in self.business_permissions
+
+ def can_write_section(self, section: str) -> bool:
+ """بررسی دسترسی نوشتن در بخش"""
+ return self.has_business_permission(section, "write")
+
+ def can_delete_section(self, section: str) -> bool:
+ """بررسی دسترسی حذف در بخش"""
+ return self.has_business_permission(section, "delete")
+
+ def can_approve_section(self, section: str) -> bool:
+ """بررسی دسترسی تأیید در بخش"""
+ return self.has_business_permission(section, "approve")
+
+ def can_export_section(self, section: str) -> bool:
+ """بررسی دسترسی صادرات در بخش"""
+ return self.has_business_permission(section, "export")
+
+ def can_manage_business_users(self, business_id: int = None) -> bool:
+ """بررسی دسترسی مدیریت کاربران کسب و کار"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ # SuperAdmin دسترسی کامل دارد
+ if self.is_superadmin():
+ logger.info(f"can_manage_business_users: user {self.user.id} is superadmin")
+ return True
+
+ # مالک کسب و کار دسترسی کامل دارد
+ if self.is_business_owner(business_id):
+ logger.info(f"can_manage_business_users: user {self.user.id} is business owner")
+ return True
+
+ # بررسی دسترسی در سطح کسب و کار
+ has_permission = self.has_business_permission("settings", "manage_users")
+ logger.info(f"can_manage_business_users: user {self.user.id} has permission: {has_permission}")
+ return has_permission
+
+ # ترکیب دسترسیها
+ def has_any_permission(self, section: str, action: str) -> bool:
+ """بررسی دسترسی در هر دو سطح"""
+ # SuperAdmin دسترسی کامل دارد
+ if self.is_superadmin():
+ return True
+
+ # بررسی دسترسی کسب و کار
+ return self.has_business_permission(section, action)
+
+ def can_access_business(self, business_id: int) -> bool:
+ """بررسی دسترسی به کسب و کار خاص"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ logger.info(f"Checking business access: user {self.user.id}, business {business_id}, context business_id {self.business_id}")
+
+ # SuperAdmin دسترسی به همه کسب و کارها دارد
+ if self.is_superadmin():
+ logger.info(f"User {self.user.id} is superadmin, granting access to business {business_id}")
+ return True
+
+ # اگر مالک کسب و کار است، دسترسی دارد
+ if self.is_business_owner() and business_id == self.business_id:
+ logger.info(f"User {self.user.id} is business owner of {business_id}, granting access")
+ return True
+
+ # بررسی دسترسیهای کسب و کار
+ has_access = business_id == self.business_id
+ logger.info(f"Business access check: {business_id} == {self.business_id} = {has_access}")
+ return has_access
+
+ def is_business_member(self, business_id: int) -> bool:
+ """بررسی اینکه آیا کاربر عضو کسب و کار است یا نه (دسترسی join)"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ logger.info(f"Checking business membership: user {self.user.id}, business {business_id}")
+
+ # SuperAdmin عضو همه کسب و کارها محسوب میشود
+ if self.is_superadmin():
+ logger.info(f"User {self.user.id} is superadmin, is member of all businesses")
+ return True
+
+ # اگر مالک کسب و کار است، عضو محسوب میشود
+ if self.is_business_owner() and business_id == self.business_id:
+ logger.info(f"User {self.user.id} is business owner of {business_id}, is member")
+ return True
+
+ # بررسی دسترسی join در business_permissions
+ if not self.db:
+ logger.info(f"No database session available")
+ return False
+
+ from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
+ repo = BusinessPermissionRepository(self.db)
+ permission_obj = repo.get_by_user_and_business(self.user.id, business_id)
+
+ if not permission_obj:
+ logger.info(f"No business permission found for user {self.user.id} and business {business_id}")
+ return False
+
+ # بررسی دسترسی join
+ business_perms = AuthContext._normalize_permissions_value(permission_obj.business_permissions)
+ has_join_access = business_perms.get('join', False)
+ logger.info(f"Business membership check: user {self.user.id} join access to business {business_id}: {has_join_access}")
+ return has_join_access
+
+ def to_dict(self) -> dict:
+ """تبدیل به dictionary برای استفاده در API"""
+ return {
+ "user": {
+ "id": self.user.id,
+ "first_name": self.user.first_name,
+ "last_name": self.user.last_name,
+ "email": self.user.email,
+ "mobile": self.user.mobile,
+ "referral_code": getattr(self.user, "referral_code", None),
+ "is_active": self.user.is_active,
+ "app_permissions": self.app_permissions,
+ "created_at": self.user.created_at.isoformat() if self.user.created_at else None,
+ "updated_at": self.user.updated_at.isoformat() if self.user.updated_at else None,
+ },
+ "api_key_id": self.api_key_id,
+ "permissions": {
+ "app_permissions": self.app_permissions,
+ "business_permissions": self.business_permissions,
+ "is_superadmin": self.is_superadmin(),
+ "is_business_owner": self.is_business_owner(),
+ },
+ "settings": {
+ "language": self.language,
+ "calendar_type": self.calendar_type,
+ "timezone": self.timezone,
+ "business_id": self.business_id,
+ "fiscal_year_id": self.fiscal_year_id,
+ }
+ }
+
+
+def get_current_user(
+ request: Request,
+ db: Session = Depends(get_db)
+) -> AuthContext:
+ """دریافت اطلاعات کامل کاربر کنونی و تنظیمات از درخواست"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ # Get authorization from request headers
+ auth_header = request.headers.get("Authorization")
+ logger.info(f"Auth header: {auth_header}")
+
+ if not auth_header or not auth_header.startswith("ApiKey "):
+ logger.warning(f"Invalid auth header: {auth_header}")
+ raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401)
+
+ api_key = auth_header[len("ApiKey ") :].strip()
+ key_hash = hash_api_key(api_key)
+ repo = ApiKeyRepository(db)
+ obj = repo.get_by_hash(key_hash)
+ if not obj or obj.revoked_at is not None:
+ raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401)
+
+ from adapters.db.models.user import User
+ user = db.get(User, obj.user_id)
+ if not user or not user.is_active:
+ raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401)
+
+ # تشخیص زبان از هدر Accept-Language
+ language = _detect_language(request)
+
+ # تشخیص نوع تقویم از هدر X-Calendar-Type
+ calendar_type = _detect_calendar_type(request)
+
+ # تشخیص منطقه زمانی از هدر X-Timezone (اختیاری)
+ timezone = _detect_timezone(request)
+
+ # تشخیص کسب و کار از هدر X-Business-ID (آینده)
+ business_id = _detect_business_id(request)
+
+ # تشخیص سال مالی از هدر X-Fiscal-Year-ID (آینده)
+ fiscal_year_id = _detect_fiscal_year_id(request)
+
+ return AuthContext(
+ user=user,
+ api_key_id=obj.id,
+ language=language,
+ calendar_type=calendar_type,
+ timezone=timezone,
+ business_id=business_id,
+ fiscal_year_id=fiscal_year_id,
+ db=db
+ )
+
+
+def _detect_language(request: Request) -> str:
+ """تشخیص زبان از هدر Accept-Language"""
+ accept_language = request.headers.get("Accept-Language")
+ return negotiate_locale(accept_language)
+
+
+def _detect_calendar_type(request: Request) -> CalendarType:
+ """تشخیص نوع تقویم از هدر X-Calendar-Type"""
+ calendar_header = request.headers.get("X-Calendar-Type")
+ return get_calendar_type_from_header(calendar_header)
+
+
+def _detect_timezone(request: Request) -> Optional[str]:
+ """تشخیص منطقه زمانی از هدر X-Timezone"""
+ return request.headers.get("X-Timezone")
+
+
+def _detect_business_id(request: Request) -> Optional[int]:
+ """تشخیص ID کسب و کار از هدر X-Business-ID (آینده)"""
+ business_id_str = request.headers.get("X-Business-ID")
+ if business_id_str:
+ try:
+ return int(business_id_str)
+ except ValueError:
+ pass
+ return None
+
+
+def _detect_fiscal_year_id(request: Request) -> Optional[int]:
+ """تشخیص ID سال مالی از هدر X-Fiscal-Year-ID (آینده)"""
+ fiscal_year_id_str = request.headers.get("X-Fiscal-Year-ID")
+ if fiscal_year_id_str:
+ try:
+ return int(fiscal_year_id_str)
+ except ValueError:
+ pass
+ return None
+
+
diff --git a/hesabixAPI/build/lib/app/core/calendar.py b/hesabixAPI/build/lib/app/core/calendar.py
new file mode 100644
index 0000000..27c10f1
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/calendar.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal, Optional
+import jdatetime
+
+CalendarType = Literal["gregorian", "jalali"]
+
+
+class CalendarConverter:
+ """Utility class for converting dates between Gregorian and Jalali calendars"""
+
+ @staticmethod
+ def to_jalali(dt: datetime) -> dict:
+ """Convert Gregorian datetime to Jalali format"""
+ if dt is None:
+ return None
+
+ jalali = jdatetime.datetime.fromgregorian(datetime=dt)
+ # نام ماههای شمسی
+ jalali_month_names = [
+ 'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
+ 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'
+ ]
+ # نام روزهای هفته شمسی
+ jalali_weekday_names = [
+ 'شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'
+ ]
+
+ return {
+ "year": jalali.year,
+ "month": jalali.month,
+ "day": jalali.day,
+ "hour": jalali.hour,
+ "minute": jalali.minute,
+ "second": jalali.second,
+ "weekday": jalali.weekday(),
+ "month_name": jalali_month_names[jalali.month - 1],
+ "weekday_name": jalali_weekday_names[jalali.weekday()],
+ "formatted": jalali.strftime("%Y/%m/%d %H:%M:%S"),
+ "date_only": jalali.strftime("%Y/%m/%d"),
+ "time_only": jalali.strftime("%H:%M:%S"),
+ "is_leap_year": jalali.isleap(),
+ "month_days": jdatetime.j_days_in_month[jalali.month - 1],
+ }
+
+ @staticmethod
+ def to_gregorian(dt: datetime) -> dict:
+ """Convert Gregorian datetime to standard format"""
+ if dt is None:
+ return None
+
+ return {
+ "year": dt.year,
+ "month": dt.month,
+ "day": dt.day,
+ "hour": dt.hour,
+ "minute": dt.minute,
+ "second": dt.second,
+ "weekday": dt.weekday(),
+ "month_name": dt.strftime("%B"),
+ "weekday_name": dt.strftime("%A"),
+ "formatted": dt.strftime("%Y-%m-%d %H:%M:%S"),
+ "date_only": dt.strftime("%Y-%m-%d"),
+ "time_only": dt.strftime("%H:%M:%S"),
+ }
+
+ @staticmethod
+ def format_datetime(dt: datetime, calendar_type: CalendarType) -> dict:
+ """Format datetime based on calendar type"""
+ if calendar_type == "jalali":
+ return CalendarConverter.to_jalali(dt)
+ else:
+ return CalendarConverter.to_gregorian(dt)
+
+ @staticmethod
+ def format_datetime_list(dt_list: list[datetime], calendar_type: CalendarType) -> list[dict]:
+ """Format list of datetimes based on calendar type"""
+ return [CalendarConverter.format_datetime(dt, calendar_type) for dt in dt_list if dt is not None]
+
+
+def get_calendar_type_from_header(calendar_header: Optional[str]) -> CalendarType:
+ """Extract calendar type from X-Calendar-Type header"""
+ if not calendar_header:
+ return "gregorian"
+
+ calendar_type = calendar_header.lower().strip()
+ if calendar_type in ["jalali", "persian", "shamsi"]:
+ return "jalali"
+ else:
+ return "gregorian"
diff --git a/hesabixAPI/build/lib/app/core/calendar_middleware.py b/hesabixAPI/build/lib/app/core/calendar_middleware.py
new file mode 100644
index 0000000..675ea2a
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/calendar_middleware.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from fastapi import Request
+from .calendar import get_calendar_type_from_header, CalendarType
+
+
+async def add_calendar_type(request: Request, call_next):
+ """Middleware to add calendar type to request state"""
+ calendar_header = request.headers.get("X-Calendar-Type")
+ calendar_type = get_calendar_type_from_header(calendar_header)
+ request.state.calendar_type = calendar_type
+
+ response = await call_next(request)
+ return response
diff --git a/hesabixAPI/build/lib/app/core/error_handlers.py b/hesabixAPI/build/lib/app/core/error_handlers.py
new file mode 100644
index 0000000..9685746
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/error_handlers.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import FastAPI, Request, HTTPException
+from fastapi.exceptions import RequestValidationError
+from starlette.responses import JSONResponse
+
+
+def _translate_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
+ translator = getattr(request.state, "translator", None)
+ if translator is None:
+ # fallback
+ return JSONResponse(
+ status_code=422,
+ content={"success": False, "error": {"code": "VALIDATION_ERROR", "message": "Validation error", "details": exc.errors()}},
+ )
+
+ # translated details
+ details: list[dict[str, Any]] = []
+ for err in exc.errors():
+ type_ = err.get("type")
+ loc = err.get("loc", [])
+ ctx = err.get("ctx", {}) or {}
+ msg = err.get("msg", "")
+
+ # extract field name (skip body/query/path)
+ field_name = None
+ if isinstance(loc, (list, tuple)):
+ for part in loc:
+ if str(part) not in ("body", "query", "path"):
+ field_name = str(part)
+
+ if type_ == "string_too_short":
+ # Check if it's a password field
+ if field_name and "password" in field_name.lower():
+ msg = translator.t("PASSWORD_MIN_LENGTH")
+ else:
+ msg = translator.t("STRING_TOO_SHORT")
+ min_len = ctx.get("min_length")
+ if min_len is not None:
+ msg = f"{msg} (حداقل {min_len})"
+ elif type_ == "string_too_long":
+ msg = translator.t("STRING_TOO_LONG")
+ max_len = ctx.get("max_length")
+ if max_len is not None:
+ msg = f"{msg} (حداکثر {max_len})"
+ elif type_ in {"missing", "value_error.missing"}:
+ msg = translator.t("FIELD_REQUIRED")
+ # broader email detection
+ elif (
+ type_ in {"value_error.email", "email"}
+ or (field_name == "email" and isinstance(type_, str) and type_.startswith("value_error"))
+ or (isinstance(msg, str) and "email address" in msg.lower())
+ ):
+ msg = translator.t("INVALID_EMAIL")
+
+ details.append({"loc": loc, "msg": msg, "type": type_})
+
+ return JSONResponse(
+ status_code=422,
+ content={
+ "success": False,
+ "error": {
+ "code": "VALIDATION_ERROR",
+ "message": translator.t("VALIDATION_ERROR"),
+ "details": details,
+ },
+ },
+ )
+
+
+def _translate_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
+ translator = getattr(request.state, "translator", None)
+ detail = exc.detail
+ status_code = exc.status_code or 400
+ if isinstance(detail, dict) and isinstance(detail.get("error"), dict):
+ error = detail["error"]
+ code = error.get("code")
+ message = error.get("message")
+ if translator is not None and isinstance(code, str):
+ localized = translator.t(code, default=message if isinstance(message, str) else None)
+ detail["error"]["message"] = localized
+ return JSONResponse(status_code=status_code, content=detail)
+ # fallback generic shape
+ message = ""
+ if isinstance(detail, str):
+ message = detail
+ elif isinstance(detail, dict) and "detail" in detail:
+ message = str(detail["detail"])
+ if translator is not None:
+ message = translator.t("HTTP_ERROR", default=message)
+ return JSONResponse(status_code=status_code, content={"success": False, "error": {"code": "HTTP_ERROR", "message": message}})
+
+
+def register_error_handlers(app: FastAPI) -> None:
+ app.add_exception_handler(RequestValidationError, _translate_validation_error)
+ app.add_exception_handler(HTTPException, _translate_http_exception)
+
+
diff --git a/hesabixAPI/build/lib/app/core/i18n.py b/hesabixAPI/build/lib/app/core/i18n.py
new file mode 100644
index 0000000..574ca12
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/i18n.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+from typing import Any, Callable
+
+from fastapi import Request
+from .i18n_catalog import get_gettext_translation
+
+
+SUPPORTED_LOCALES: tuple[str, ...] = ("fa", "en")
+DEFAULT_LOCALE: str = "en"
+
+
+def negotiate_locale(accept_language: str | None) -> str:
+ if not accept_language:
+ return DEFAULT_LOCALE
+ parts = [p.strip() for p in accept_language.split(",") if p.strip()]
+ for part in parts:
+ lang = part.split(";")[0].strip().lower()
+ base = lang.split("-")[0]
+ if lang in SUPPORTED_LOCALES:
+ return lang
+ if base in SUPPORTED_LOCALES:
+ return base
+ return DEFAULT_LOCALE
+
+
+class Translator:
+ def __init__(self, locale: str) -> None:
+ self.locale = locale if locale in SUPPORTED_LOCALES else DEFAULT_LOCALE
+ self._gt = get_gettext_translation(self.locale)
+
+ def t(self, key: str, default: str | None = None) -> str:
+ """Translate a key using gettext. Falls back to default or key if not found."""
+ try:
+ if self._gt is not None:
+ msg = self._gt.gettext(key)
+ if msg and msg != key:
+ return msg
+ except Exception:
+ pass
+ return default or key
+
+
+async def locale_dependency(request: Request) -> Translator:
+ lang = negotiate_locale(request.headers.get("Accept-Language"))
+ return Translator(lang)
+
+
+def get_translator(locale: str = "fa") -> Translator:
+ """Get translator for the given locale"""
+ return Translator(locale)
+
+
+def gettext(key: str, locale: str = "fa") -> str:
+ """Get translation for a key using gettext"""
+ translator = get_translator(locale)
+ return translator.t(key)
+
+
diff --git a/hesabixAPI/build/lib/app/core/i18n_catalog.py b/hesabixAPI/build/lib/app/core/i18n_catalog.py
new file mode 100644
index 0000000..052bbc6
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/i18n_catalog.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+import gettext
+import os
+from functools import lru_cache
+from typing import Optional
+
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
+LOCALES_DIR = os.path.join(BASE_DIR, 'locales')
+
+
+@lru_cache(maxsize=128)
+def get_gettext_translation(locale: str, domain: str = 'messages') -> Optional[gettext.NullTranslations]:
+ try:
+ return gettext.translation(domain=domain, localedir=LOCALES_DIR, languages=[locale], fallback=True)
+ except Exception:
+ return None
diff --git a/hesabixAPI/build/lib/app/core/logging.py b/hesabixAPI/build/lib/app/core/logging.py
new file mode 100644
index 0000000..e2fd43f
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/logging.py
@@ -0,0 +1,29 @@
+import logging
+import sys
+from typing import Any
+
+import structlog
+
+
+def configure_logging(settings: Any) -> None:
+ shared_processors = [
+ structlog.processors.TimeStamper(fmt="iso"),
+ structlog.processors.add_log_level,
+ structlog.processors.StackInfoRenderer(),
+ structlog.processors.format_exc_info,
+ ]
+
+ structlog.configure(
+ processors=[
+ *shared_processors,
+ structlog.processors.JSONRenderer(),
+ ],
+ wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, settings.log_level, logging.INFO)),
+ cache_logger_on_first_use=True,
+ )
+
+ logging.basicConfig(
+ format="%(message)s",
+ stream=sys.stdout,
+ level=getattr(logging, settings.log_level, logging.INFO),
+ )
diff --git a/hesabixAPI/build/lib/app/core/permissions.py b/hesabixAPI/build/lib/app/core/permissions.py
new file mode 100644
index 0000000..ce5d65f
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/permissions.py
@@ -0,0 +1,201 @@
+from functools import wraps
+from typing import Callable, Any
+import inspect
+
+from fastapi import Depends
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.responses import ApiError
+
+
+def require_app_permission(permission: str):
+ """Decorator برای بررسی دسترسی در سطح اپلیکیشن"""
+ def decorator(func: Callable) -> Callable:
+ @wraps(func)
+ async def wrapper(*args, **kwargs) -> Any:
+ # پیدا کردن AuthContext در kwargs
+ ctx = None
+ for key, value in kwargs.items():
+ if isinstance(value, AuthContext):
+ ctx = value
+ break
+
+ if not ctx:
+ raise ApiError("UNAUTHORIZED", "Authentication required", http_status=401)
+
+ if not ctx.has_app_permission(permission):
+ raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403)
+ return await func(*args, **kwargs)
+ return wrapper
+ return decorator
+
+
+def require_business_permission(section: str, action: str):
+ """Decorator برای بررسی دسترسی در سطح کسب و کار"""
+ def decorator(func: Callable) -> Callable:
+ @wraps(func)
+ def wrapper(*args, **kwargs) -> Any:
+ ctx = get_current_user()
+ if not ctx.has_business_permission(section, action):
+ raise ApiError("FORBIDDEN", f"Missing business permission: {section}.{action}", http_status=403)
+ return func(*args, **kwargs)
+ return wrapper
+ return decorator
+
+
+def require_any_permission(section: str, action: str):
+ """Decorator برای بررسی دسترسی در هر دو سطح (app یا business)"""
+ def decorator(func: Callable) -> Callable:
+ @wraps(func)
+ def wrapper(*args, **kwargs) -> Any:
+ ctx = get_current_user()
+ if not ctx.has_any_permission(section, action):
+ raise ApiError("FORBIDDEN", f"Missing permission: {section}.{action}", http_status=403)
+ return func(*args, **kwargs)
+ return wrapper
+ return decorator
+
+
+def require_superadmin():
+ """Decorator برای بررسی superadmin بودن"""
+ def decorator(func: Callable) -> Callable:
+ @wraps(func)
+ def wrapper(*args, **kwargs) -> Any:
+ ctx = get_current_user()
+ if not ctx.is_superadmin():
+ raise ApiError("FORBIDDEN", "Superadmin access required", http_status=403)
+ return func(*args, **kwargs)
+ return wrapper
+ return decorator
+
+
+def require_business_access(business_id_param: str = "business_id"):
+ """Decorator برای بررسی دسترسی به کسب و کار خاص.
+ امضای اصلی endpoint حفظ میشود و Request از آرگومانها استخراج میگردد.
+ """
+ def decorator(func: Callable) -> Callable:
+ @wraps(func)
+ async def wrapper(*args, **kwargs) -> Any:
+ import logging
+ from fastapi import Request
+ logger = logging.getLogger(__name__)
+
+ # یافتن Request در args/kwargs
+ request = None
+ for arg in args:
+ if isinstance(arg, Request):
+ request = arg
+ break
+ if request is None:
+ request = kwargs.get('request')
+ if request is None:
+ logger.error("Request not found in function arguments")
+ raise ApiError("INTERNAL_ERROR", "Request not found", http_status=500)
+
+ # دسترسی به DB و کاربر
+ from adapters.db.session import get_db
+ db = next(get_db())
+ ctx = get_current_user(request, db)
+
+ # استخراج business_id از kwargs یا path params
+ business_id = kwargs.get(business_id_param)
+ if business_id is None:
+ try:
+ business_id = request.path_params.get(business_id_param)
+ except Exception:
+ business_id = None
+
+ if business_id and not ctx.can_access_business(int(business_id)):
+ logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}")
+ raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
+
+ # فراخوانی تابع اصلی و await در صورت نیاز
+ result = func(*args, **kwargs)
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+ # Preserve original signature so FastAPI sees correct parameters (including Request)
+ wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
+ return wrapper
+ return decorator
+
+
+# Decorator های ترکیبی برای استفاده آسان
+def require_sales_write():
+ """دسترسی نوشتن در بخش فروش"""
+ return require_any_permission("sales", "write")
+
+
+def require_sales_delete():
+ """دسترسی حذف در بخش فروش"""
+ return require_any_permission("sales", "delete")
+
+
+def require_sales_approve():
+ """دسترسی تأیید در بخش فروش"""
+ return require_any_permission("sales", "approve")
+
+
+def require_purchases_write():
+ """دسترسی نوشتن در بخش خرید"""
+ return require_any_permission("purchases", "write")
+
+
+def require_accounting_write():
+ """دسترسی نوشتن در بخش حسابداری"""
+ return require_any_permission("accounting", "write")
+
+
+def require_inventory_write():
+ """دسترسی نوشتن در بخش موجودی"""
+ return require_any_permission("inventory", "write")
+
+
+def require_reports_export():
+ """دسترسی صادرات گزارش"""
+ return require_any_permission("reports", "export")
+
+
+def require_settings_manage_users():
+ """دسترسی مدیریت کاربران کسب و کار"""
+ return require_any_permission("settings", "manage_users")
+
+
+def require_user_management():
+ """دسترسی مدیریت کاربران در سطح اپلیکیشن"""
+ return require_app_permission("user_management")
+
+
+def require_business_management():
+ """دسترسی مدیریت کسب و کارها"""
+ return require_app_permission("business_management")
+
+
+def require_system_settings():
+ """دسترسی تنظیمات سیستم"""
+ return require_app_permission("system_settings")
+
+
+def require_permission(permission: str):
+ """Decorator عمومی برای بررسی دسترسی - wrapper برای require_app_permission"""
+ return require_app_permission(permission)
+
+
+# =========================
+# FastAPI Dependencies (for Depends)
+# =========================
+def require_app_permission_dep(permission: str):
+ """FastAPI dependency جهت بررسی دسترسی در سطح اپلیکیشن.
+
+ استفاده:
+ _: None = Depends(require_app_permission_dep("business_management"))
+ """
+ def _dependency(auth_context: AuthContext = Depends(get_current_user)) -> None:
+ if not auth_context.has_app_permission(permission):
+ raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403)
+ return _dependency
+
+
+def require_business_management_dep(auth_context: AuthContext = Depends(get_current_user)) -> None:
+ """FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها."""
+ if not auth_context.has_app_permission("business_management"):
+ raise ApiError("FORBIDDEN", "Missing app permission: business_management", http_status=403)
diff --git a/hesabixAPI/build/lib/app/core/responses.py b/hesabixAPI/build/lib/app/core/responses.py
new file mode 100644
index 0000000..c44d1f1
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/responses.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from typing import Any
+from datetime import datetime
+
+from fastapi import HTTPException, status, Request
+from .calendar import CalendarConverter, CalendarType
+
+
+def success_response(data: Any, request: Request = None, message: str = None) -> dict[str, Any]:
+ response = {"success": True}
+
+ # Add data if provided
+ if data is not None:
+ response["data"] = data
+
+ # Add message if provided
+ if message is not None:
+ response["message"] = message
+
+ # Add calendar type information if request is available
+ if request and hasattr(request.state, 'calendar_type'):
+ response["calendar_type"] = request.state.calendar_type
+
+ return response
+
+
+def format_datetime_fields(data: Any, request: Request) -> Any:
+ """Recursively format datetime fields based on calendar type"""
+ if not request or not hasattr(request.state, 'calendar_type'):
+ return data
+
+ calendar_type = request.state.calendar_type
+
+ if isinstance(data, dict):
+ formatted_data = {}
+ for key, value in data.items():
+ if value is None:
+ formatted_data[key] = None
+ elif isinstance(value, datetime):
+ # Format the main date field based on calendar type
+ if calendar_type == "jalali":
+ formatted_data[key] = CalendarConverter.to_jalali(value)["formatted"]
+ else:
+ formatted_data[key] = value.isoformat()
+
+ # Add formatted date as additional field
+ formatted_data[f"{key}_formatted"] = CalendarConverter.format_datetime(value, calendar_type)
+ # Convert raw date to the same calendar type as the formatted date
+ if calendar_type == "jalali":
+ formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"]
+ else:
+ formatted_data[f"{key}_raw"] = value.isoformat()
+ elif isinstance(value, (dict, list)):
+ formatted_data[key] = format_datetime_fields(value, request)
+ else:
+ formatted_data[key] = value
+ return formatted_data
+
+ elif isinstance(data, list):
+ return [format_datetime_fields(item, request) for item in data]
+
+ else:
+ return data
+
+
+class ApiError(HTTPException):
+ def __init__(self, code: str, message: str, http_status: int = status.HTTP_400_BAD_REQUEST, translator=None) -> None:
+ # اگر translator موجود است، پیام را ترجمه کن
+ if translator:
+ translated_message = translator.t(code) if hasattr(translator, 't') else message
+ else:
+ translated_message = message
+
+ super().__init__(status_code=http_status, detail={"success": False, "error": {"code": code, "message": translated_message}})
+
+
diff --git a/hesabixAPI/build/lib/app/core/security.py b/hesabixAPI/build/lib/app/core/security.py
new file mode 100644
index 0000000..1d3f469
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/security.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import os
+import secrets
+from datetime import datetime, timedelta
+
+from argon2 import PasswordHasher
+
+from app.core.settings import get_settings
+
+
+_ph = PasswordHasher()
+
+
+def hash_password(password: str) -> str:
+ return _ph.hash(password)
+
+
+def verify_password(password: str, password_hash: str) -> bool:
+ try:
+ _ph.verify(password_hash, password)
+ return True
+ except Exception:
+ return False
+
+
+def generate_api_key(prefix: str = "ak_live_", length: int = 32) -> tuple[str, str]:
+ """Return (public_key, key_hash). Store only key_hash in DB."""
+ secret = secrets.token_urlsafe(length)
+ api_key = f"{prefix}{secret}"
+ settings = get_settings()
+ key_hash = hashlib.sha256(f"{settings.captcha_secret}:{api_key}".encode("utf-8")).hexdigest()
+ return api_key, key_hash
+
+
+def consteq(a: str, b: str) -> bool:
+ return hmac.compare_digest(a, b)
+
+
+def hash_api_key(api_key: str) -> str:
+ settings = get_settings()
+ return hashlib.sha256(f"{settings.captcha_secret}:{api_key}".encode("utf-8")).hexdigest()
+
+
diff --git a/hesabixAPI/build/lib/app/core/settings.py b/hesabixAPI/build/lib/app/core/settings.py
new file mode 100644
index 0000000..54215a2
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/settings.py
@@ -0,0 +1,48 @@
+from functools import lru_cache
+from pydantic import BaseModel
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+
+ app_name: str = "Hesabix API"
+ app_version: str = "0.1.0"
+ api_v1_prefix: str = "/api/v1"
+ environment: str = "development"
+ debug: bool = True
+
+ # Database
+ db_user: str = "hesabix"
+ db_password: str = "change_me"
+ db_host: str = "localhost"
+ db_port: int = 3306
+ db_name: str = "hesabix"
+ sqlalchemy_echo: bool = False
+
+ # Logging
+ log_level: str = "INFO"
+
+ # Captcha / Security
+ captcha_length: int = 5
+ captcha_ttl_seconds: int = 180
+ captcha_secret: str = "change_me_captcha"
+ reset_password_ttl_seconds: int = 3600
+
+ # Phone normalization
+ # Used as default region when parsing phone numbers without a country code
+ default_phone_region: str = "IR"
+
+ # CORS
+ cors_allowed_origins: list[str] = ["*"]
+
+ @property
+ def mysql_dsn(self) -> str:
+ return (
+ f"mysql+pymysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
+ )
+
+
+@lru_cache(maxsize=1)
+def get_settings() -> Settings:
+ return Settings()
diff --git a/hesabixAPI/build/lib/app/core/smart_normalizer.py b/hesabixAPI/build/lib/app/core/smart_normalizer.py
new file mode 100644
index 0000000..8113263
--- /dev/null
+++ b/hesabixAPI/build/lib/app/core/smart_normalizer.py
@@ -0,0 +1,200 @@
+"""
+Smart Number Normalizer
+تبدیل هوشمند اعداد فارسی/عربی/هندی به انگلیسی
+"""
+
+import json
+import re
+import logging
+from typing import Any, Dict, List, Union, Optional
+
+logger = logging.getLogger(__name__)
+
+
+class SmartNormalizerConfig:
+ """تنظیمات سیستم تبدیل هوشمند"""
+
+ # فیلدهایی که نباید تبدیل شوند
+ EXCLUDE_FIELDS = {'password', 'token', 'hash', 'secret', 'key'}
+
+ # الگوهای خاص برای شناسایی انواع مختلف
+ SPECIAL_PATTERNS = {
+ 'mobile': r'۰۹۱[۰-۹]+',
+ 'email': r'[۰-۹]+@',
+ 'code': r'[A-Za-z]+[۰-۹]+',
+ 'phone': r'[۰-۹]+-[۰-۹]+',
+ }
+
+ # فعال/غیرفعال کردن
+ ENABLED = True
+ LOG_CHANGES = True
+
+
+def smart_normalize_numbers(text: str) -> str:
+ """
+ تبدیل هوشمند اعداد فارسی/عربی/هندی به انگلیسی
+ فقط اعداد را تبدیل میکند، متن باقی میماند
+ """
+ if not text or not isinstance(text, str):
+ return text
+
+ # جدول تبدیل اعداد
+ number_mapping = {
+ # فارسی
+ '۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
+ '۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9',
+ # عربی
+ '٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
+ '٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9',
+ # هندی/بنگالی
+ '০': '0', '১': '1', '২': '2', '৩': '3', '৪': '4',
+ '৫': '5', '৬': '6', '৭': '7', '৮': '8', '৯': '9',
+ # هندی (دیگر)
+ '०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
+ '५': '5', '६': '6', '७': '7', '८': '8', '९': '9'
+ }
+
+ result = ""
+ for char in text:
+ result += number_mapping.get(char, char)
+
+ return result
+
+
+def smart_normalize_text(text: str) -> str:
+ """
+ تبدیل هوشمند برای متنهای پیچیده
+ """
+ if not text or not isinstance(text, str):
+ return text
+
+ # شناسایی الگوهای مختلف
+ patterns = [
+ # شماره موبایل: ۰۹۱۲۳۴۵۶۷۸۹
+ (r'۰۹۱[۰-۹]+', lambda m: smart_normalize_numbers(m.group())),
+ # کدهای ترکیبی: ABC-۱۲۳۴
+ (r'[A-Za-z]+[۰-۹]+', lambda m: smart_normalize_numbers(m.group())),
+ # اعداد خالص
+ (r'[۰-۹]+', lambda m: smart_normalize_numbers(m.group())),
+ ]
+
+ result = text
+ for pattern, replacement in patterns:
+ result = re.sub(pattern, replacement, result)
+
+ return result
+
+
+def smart_normalize_recursive(obj: Any, exclude_fields: Optional[set] = None) -> Any:
+ """
+ تبدیل recursive در ساختارهای پیچیده
+ """
+ if exclude_fields is None:
+ exclude_fields = SmartNormalizerConfig.EXCLUDE_FIELDS
+
+ if isinstance(obj, str):
+ return smart_normalize_text(obj)
+
+ elif isinstance(obj, dict):
+ result = {}
+ for key, value in obj.items():
+ # اگر فیلد در لیست مستثنیات است، تبدیل نکن
+ if key.lower() in exclude_fields:
+ result[key] = value
+ else:
+ result[key] = smart_normalize_recursive(value, exclude_fields)
+ return result
+
+ elif isinstance(obj, list):
+ return [smart_normalize_recursive(item, exclude_fields) for item in obj]
+
+ else:
+ return obj
+
+
+def smart_normalize_json(data: bytes) -> bytes:
+ """
+ تبدیل هوشمند اعداد در JSON
+ """
+ if not data:
+ return data
+
+ try:
+ # تبدیل bytes به dict
+ json_data = json.loads(data.decode('utf-8'))
+
+ # تبدیل recursive
+ normalized_data = smart_normalize_recursive(json_data)
+
+ # تبدیل به bytes
+ normalized_bytes = json.dumps(normalized_data, ensure_ascii=False).encode('utf-8')
+
+ # لاگ تغییرات
+ if SmartNormalizerConfig.LOG_CHANGES and normalized_bytes != data:
+ logger.info("Numbers normalized in JSON request")
+
+ return normalized_bytes
+
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
+ # اگر JSON نیست، به صورت متن تبدیل کن
+ try:
+ text = data.decode('utf-8', errors='ignore')
+ normalized_text = smart_normalize_text(text)
+ normalized_bytes = normalized_text.encode('utf-8')
+
+ if SmartNormalizerConfig.LOG_CHANGES and normalized_bytes != data:
+ logger.info("Numbers normalized in text request")
+
+ return normalized_bytes
+ except Exception:
+ logger.warning(f"Failed to normalize request data: {e}")
+ return data
+
+
+def smart_normalize_query_params(params: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ تبدیل هوشمند اعداد در query parameters
+ """
+ if not params:
+ return params
+
+ normalized_params = {}
+ for key, value in params.items():
+ if isinstance(value, str):
+ normalized_params[key] = smart_normalize_text(value)
+ else:
+ normalized_params[key] = smart_normalize_recursive(value)
+
+ return normalized_params
+
+
+def is_number_normalization_needed(text: str) -> bool:
+ """
+ بررسی اینکه آیا متن نیاز به تبدیل اعداد دارد یا نه
+ """
+ if not text or not isinstance(text, str):
+ return False
+
+ # بررسی وجود اعداد فارسی/عربی/هندی
+ persian_arabic_numbers = '۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩০১২৩৪৫৬৭৮৯०१२३४५६७८९'
+ return any(char in persian_arabic_numbers for char in text)
+
+
+def get_normalization_stats(data: bytes) -> Dict[str, int]:
+ """
+ آمار تبدیل اعداد
+ """
+ try:
+ text = data.decode('utf-8', errors='ignore')
+ persian_arabic_numbers = '۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩০১২৩৪৫৬৭৮৯०१२३४५६७८९'
+
+ total_chars = len(text)
+ persian_numbers = sum(1 for char in text if char in persian_arabic_numbers)
+
+ return {
+ 'total_chars': total_chars,
+ 'persian_numbers': persian_numbers,
+ 'normalization_ratio': persian_numbers / total_chars if total_chars > 0 else 0
+ }
+ except Exception:
+ return {'total_chars': 0, 'persian_numbers': 0, 'normalization_ratio': 0}
diff --git a/hesabixAPI/build/lib/app/main.py b/hesabixAPI/build/lib/app/main.py
new file mode 100644
index 0000000..b478cab
--- /dev/null
+++ b/hesabixAPI/build/lib/app/main.py
@@ -0,0 +1,361 @@
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.core.settings import get_settings
+from app.core.logging import configure_logging
+from adapters.api.v1.health import router as health_router
+from adapters.api.v1.auth import router as auth_router
+from adapters.api.v1.users import router as users_router
+from adapters.api.v1.businesses import router as businesses_router
+from adapters.api.v1.currencies import router as currencies_router
+from adapters.api.v1.business_dashboard import router as business_dashboard_router
+from adapters.api.v1.business_users import router as business_users_router
+from adapters.api.v1.accounts import router as accounts_router
+from adapters.api.v1.categories import router as categories_router
+from adapters.api.v1.product_attributes import router as product_attributes_router
+from adapters.api.v1.products import router as products_router
+from adapters.api.v1.price_lists import router as price_lists_router
+from adapters.api.v1.persons import router as persons_router
+from adapters.api.v1.tax_units import router as tax_units_router
+from adapters.api.v1.tax_units import alias_router as units_alias_router
+from adapters.api.v1.tax_types import router as tax_types_router
+from adapters.api.v1.support.tickets import router as support_tickets_router
+from adapters.api.v1.support.operator import router as support_operator_router
+from adapters.api.v1.support.categories import router as support_categories_router
+from adapters.api.v1.support.priorities import router as support_priorities_router
+from adapters.api.v1.support.statuses import router as support_statuses_router
+from adapters.api.v1.admin.file_storage import router as admin_file_storage_router
+from adapters.api.v1.admin.email_config import router as admin_email_config_router
+from app.core.i18n import negotiate_locale, Translator
+from app.core.error_handlers import register_error_handlers
+from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
+from app.core.calendar_middleware import add_calendar_type
+
+
+def create_app() -> FastAPI:
+ settings = get_settings()
+ configure_logging(settings)
+
+ application = FastAPI(
+ title=settings.app_name,
+ version=settings.app_version,
+ debug=settings.debug,
+ description="""
+ # Hesabix API
+
+ API جامع برای مدیریت کاربران، احراز هویت و سیستم معرفی
+
+ ## ویژگیهای اصلی:
+ - **احراز هویت**: ثبتنام، ورود، فراموشی رمز عبور
+ - **مدیریت کاربران**: لیست، جستجو، فیلتر و آمار کاربران
+ - **سیستم معرفی**: آمار و مدیریت معرفیها
+ - **خروجی**: PDF و Excel برای گزارشها
+ - **امنیت**: کپچا، کلیدهای API، رمزگذاری
+
+ ## 🔐 احراز هویت (Authentication)
+
+ ### کلیدهای API
+ تمام endpoint های محافظت شده نیاز به کلید API دارند که در header `Authorization` ارسال میشود:
+
+ ```
+ Authorization: Bearer sk_your_api_key_here
+ ```
+
+ ### نحوه دریافت کلید API:
+ 1. **ثبتنام**: با ثبتنام، یک کلید session دریافت میکنید
+ 2. **ورود**: با ورود موفق، کلید session دریافت میکنید
+ 3. **کلیدهای شخصی**: از endpoint `/api/v1/auth/api-keys` میتوانید کلیدهای شخصی ایجاد کنید
+
+ ### انواع کلیدهای API:
+ - **Session Keys**: کلیدهای موقت که با ورود ایجاد میشوند
+ - **Personal Keys**: کلیدهای دائمی که خودتان ایجاد میکنید
+
+ ### مثال درخواست با احراز هویت:
+ ```bash
+ curl -X GET "http://localhost:8000/api/v1/auth/me" \\
+ -H "Authorization: Bearer sk_1234567890abcdef" \\
+ -H "Accept: application/json"
+ ```
+
+ ## 🛡️ مجوزهای دسترسی (Permissions)
+
+ برخی endpoint ها نیاز به مجوزهای خاص دارند:
+
+ ### مجوزهای اپلیکیشن (App-Level Permissions):
+ - `user_management`: دسترسی به مدیریت کاربران
+ - `superadmin`: دسترسی کامل به سیستم
+ - `business_management`: مدیریت کسب و کارها
+ - `system_settings`: دسترسی به تنظیمات سیستم
+
+ ### مثال مجوزها در JSON:
+ ```json
+ {
+ "user_management": true,
+ "superadmin": false,
+ "business_management": true,
+ "system_settings": false
+ }
+ ```
+
+ ### endpoint های محافظت شده:
+ - تمام endpoint های `/api/v1/users/*` نیاز به مجوز `user_management` دارند
+ - endpoint های `/api/v1/auth/me` و `/api/v1/auth/api-keys/*` نیاز به احراز هویت دارند
+
+ ## 🌍 چندزبانه (Internationalization)
+
+ API از چندزبانه پشتیبانی میکند:
+
+ ### هدر زبان:
+ ```
+ Accept-Language: fa
+ Accept-Language: en
+ Accept-Language: fa-IR
+ Accept-Language: en-US
+ ```
+
+ ### زبانهای پشتیبانی شده:
+ - **فارسی (fa)**: پیشفرض
+ - **انگلیسی (en)**
+
+ ### مثال درخواست با زبان فارسی:
+ ```bash
+ curl -X GET "http://localhost:8000/api/v1/auth/me" \\
+ -H "Authorization: Bearer sk_1234567890abcdef" \\
+ -H "Accept-Language: fa" \\
+ -H "Accept: application/json"
+ ```
+
+ ## 📅 تقویم (Calendar)
+
+ API از تقویم شمسی (جلالی) پشتیبانی میکند:
+
+ ### هدر تقویم:
+ ```
+ X-Calendar-Type: jalali
+ X-Calendar-Type: gregorian
+ ```
+
+ ### انواع تقویم:
+ - **جلالی (jalali)**: تقویم شمسی - پیشفرض
+ - **میلادی (gregorian)**: تقویم میلادی
+
+ ### مثال درخواست با تقویم شمسی:
+ ```bash
+ curl -X GET "http://localhost:8000/api/v1/users" \\
+ -H "Authorization: Bearer sk_1234567890abcdef" \\
+ -H "X-Calendar-Type: jalali" \\
+ -H "Accept: application/json"
+ ```
+
+ ## 📊 فرمت پاسخها (Response Format)
+
+ تمام پاسخها در فرمت زیر هستند:
+
+ ```json
+ {
+ "success": true,
+ "message": "پیام توضیحی",
+ "data": {
+ // دادههای اصلی
+ }
+ }
+ ```
+
+ ### کدهای خطا:
+ - **200**: موفقیت
+ - **400**: خطا در اعتبارسنجی دادهها
+ - **401**: احراز هویت نشده
+ - **403**: دسترسی غیرمجاز
+ - **404**: منبع یافت نشد
+ - **422**: خطا در اعتبارسنجی
+ - **500**: خطای سرور
+
+ ## 🔒 امنیت (Security)
+
+ ### کپچا:
+ برای عملیات حساس از کپچا استفاده میشود:
+ - دریافت کپچا: `POST /api/v1/auth/captcha`
+ - استفاده در ثبتنام، ورود، فراموشی رمز عبور
+
+ ### رمزگذاری:
+ - رمزهای عبور با bcrypt رمزگذاری میشوند
+ - کلیدهای API با SHA-256 هش میشوند
+
+ ## 📝 مثال کامل درخواست:
+
+ ```bash
+ # 1. دریافت کپچا
+ curl -X POST "http://localhost:8000/api/v1/auth/captcha"
+
+ # 2. ورود
+ curl -X POST "http://localhost:8000/api/v1/auth/login" \\
+ -H "Content-Type: application/json" \\
+ -H "Accept-Language: fa" \\
+ -H "X-Calendar-Type: jalali" \\
+ -d '{
+ "identifier": "user@example.com",
+ "password": "password123",
+ "captcha_id": "captcha_id_from_step_1",
+ "captcha_code": "12345"
+ }'
+
+ # 3. استفاده از API با کلید دریافتی
+ curl -X GET "http://localhost:8000/api/v1/users" \\
+ -H "Authorization: Bearer sk_1234567890abcdef" \\
+ -H "Accept-Language: fa" \\
+ -H "X-Calendar-Type: jalali" \\
+ -H "Accept: application/json"
+ ```
+
+ ## 🚀 شروع سریع:
+
+ 1. **ثبتنام**: `POST /api/v1/auth/register`
+ 2. **ورود**: `POST /api/v1/auth/login`
+ 3. **دریافت اطلاعات کاربر**: `GET /api/v1/auth/me`
+ 4. **مدیریت کاربران**: `GET /api/v1/users` (نیاز به مجوز usermanager)
+
+ ## 📞 پشتیبانی:
+ - **ایمیل**: support@hesabix.ir
+ - **مستندات**: `/docs` (Swagger UI)
+ - **ReDoc**: `/redoc`
+ """,
+ contact={
+ "name": "Hesabix Team",
+ "email": "support@hesabix.ir",
+ "url": "https://hesabix.ir",
+ },
+ license_info={
+ "name": "GNU GPLv3 License",
+ "url": "https://opensource.org/licenses/GPL-3.0",
+ },
+ servers=[
+ {
+ "url": "http://localhost:8000",
+ "description": "Development server"
+ },
+ {
+ "url": "https://agent.hesabix.ir",
+ "description": "Production server"
+ }
+ ],
+ )
+
+ application.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_allowed_origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ @application.middleware("http")
+ async def smart_number_normalizer(request: Request, call_next):
+ """Middleware هوشمند برای تبدیل اعداد فارسی/عربی به انگلیسی"""
+ if SmartNormalizerConfig.ENABLED and request.method in ["POST", "PUT", "PATCH"]:
+ # فقط برای درخواستهای JSON اعمال شود تا فایلهای باینری/چندبخشی خراب نشوند
+ content_type = request.headers.get("Content-Type", "").lower()
+ if content_type.startswith("application/json"):
+ # خواندن body درخواست
+ body = await request.body()
+ if body:
+ # تبدیل اعداد در JSON
+ normalized_body = smart_normalize_json(body)
+ if normalized_body != body:
+ # ایجاد request جدید با body تبدیل شده
+ request._body = normalized_body
+
+ response = await call_next(request)
+ return response
+
+ @application.middleware("http")
+ async def add_locale(request: Request, call_next):
+ lang = negotiate_locale(request.headers.get("Accept-Language"))
+ request.state.locale = lang
+ request.state.translator = Translator(lang)
+ response = await call_next(request)
+ return response
+
+ @application.middleware("http")
+ async def add_calendar_middleware(request: Request, call_next):
+ return await add_calendar_type(request, call_next)
+
+ application.include_router(health_router, prefix=settings.api_v1_prefix)
+ application.include_router(auth_router, prefix=settings.api_v1_prefix)
+ application.include_router(users_router, prefix=settings.api_v1_prefix)
+ application.include_router(businesses_router, prefix=settings.api_v1_prefix)
+ application.include_router(currencies_router, prefix=settings.api_v1_prefix)
+ application.include_router(business_dashboard_router, prefix=settings.api_v1_prefix)
+ application.include_router(business_users_router, prefix=settings.api_v1_prefix)
+ application.include_router(accounts_router, prefix=settings.api_v1_prefix)
+ application.include_router(categories_router, prefix=settings.api_v1_prefix)
+ application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
+ application.include_router(products_router, prefix=settings.api_v1_prefix)
+ application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
+ application.include_router(persons_router, prefix=settings.api_v1_prefix)
+ application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
+ application.include_router(units_alias_router, prefix=settings.api_v1_prefix)
+ application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
+
+ # Support endpoints
+ application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
+ application.include_router(support_operator_router, prefix=f"{settings.api_v1_prefix}/support/operator")
+ application.include_router(support_categories_router, prefix=f"{settings.api_v1_prefix}/metadata/categories")
+ application.include_router(support_priorities_router, prefix=f"{settings.api_v1_prefix}/metadata/priorities")
+ application.include_router(support_statuses_router, prefix=f"{settings.api_v1_prefix}/metadata/statuses")
+
+ # Admin endpoints
+ application.include_router(admin_file_storage_router, prefix=settings.api_v1_prefix)
+ application.include_router(admin_email_config_router, prefix=settings.api_v1_prefix)
+
+ register_error_handlers(application)
+
+ @application.get("/",
+ summary="اطلاعات سرویس",
+ description="دریافت اطلاعات کلی سرویس و نسخه",
+ tags=["general"]
+ )
+ def read_root() -> dict[str, str]:
+ return {"service": settings.app_name, "version": settings.app_version}
+
+ # اضافه کردن security schemes
+ from fastapi.openapi.utils import get_openapi
+
+ def custom_openapi():
+ if application.openapi_schema:
+ return application.openapi_schema
+
+ openapi_schema = get_openapi(
+ title=application.title,
+ version=application.version,
+ description=application.description,
+ routes=application.routes,
+ )
+
+ # اضافه کردن security schemes
+ openapi_schema["components"]["securitySchemes"] = {
+ "ApiKeyAuth": {
+ "type": "http",
+ "scheme": "ApiKey",
+ "description": "کلید API برای احراز هویت. فرمت: ApiKey sk_your_api_key_here"
+ }
+ }
+
+ # اضافه کردن security به endpoint های محافظت شده
+ for path, methods in openapi_schema["paths"].items():
+ for method, details in methods.items():
+ if method in ["get", "post", "put", "delete", "patch"]:
+ # تمام endpoint های auth، users و support نیاز به احراز هویت دارند
+ if "/auth/" in path or "/users" in path or "/support" in path:
+ details["security"] = [{"ApiKeyAuth": []}]
+
+ application.openapi_schema = openapi_schema
+ return application.openapi_schema
+
+ application.openapi = custom_openapi
+
+ return application
+
+
+app = create_app()
+
+
diff --git a/hesabixAPI/build/lib/app/services/api_key_service.py b/hesabixAPI/build/lib/app/services/api_key_service.py
new file mode 100644
index 0000000..c8e332e
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/api_key_service.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy.orm import Session
+
+from adapters.db.repositories.api_key_repo import ApiKeyRepository
+from app.core.security import generate_api_key
+
+
+def list_personal_keys(db: Session, user_id: int) -> list[dict]:
+ repo = ApiKeyRepository(db)
+ from adapters.db.models.api_key import ApiKey
+ stmt = db.query(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.key_type == "personal")
+ items: list[dict] = []
+ for row in stmt.all():
+ items.append({
+ "id": row.id,
+ "name": row.name,
+ "scopes": row.scopes,
+ "created_at": row.created_at,
+ "expires_at": row.expires_at,
+ "revoked_at": row.revoked_at,
+ })
+ return items
+
+
+def create_personal_key(db: Session, user_id: int, name: str | None, scopes: str | None, expires_at: Optional[datetime]) -> tuple[int, str]:
+ api_key, key_hash = generate_api_key(prefix="ak_personal_")
+ repo = ApiKeyRepository(db)
+ obj = repo.create_session_key(user_id=user_id, key_hash=key_hash, device_id=None, user_agent=None, ip=None, expires_at=expires_at)
+ obj.key_type = "personal"
+ obj.name = name
+ obj.scopes = scopes
+ db.add(obj)
+ db.commit()
+ return obj.id, api_key
+
+
+def revoke_key(db: Session, user_id: int, key_id: int) -> None:
+ from adapters.db.models.api_key import ApiKey
+ obj = db.get(ApiKey, key_id)
+ if not obj or obj.user_id != user_id:
+ from app.core.responses import ApiError
+ raise ApiError("NOT_FOUND", "Key not found", http_status=404)
+ obj.revoked_at = datetime.utcnow()
+ db.add(obj)
+ db.commit()
+
+
diff --git a/hesabixAPI/build/lib/app/services/auth_service.py b/hesabixAPI/build/lib/app/services/auth_service.py
new file mode 100644
index 0000000..ab6a928
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/auth_service.py
@@ -0,0 +1,289 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from typing import Optional
+
+import phonenumbers
+from sqlalchemy.orm import Session
+
+from adapters.db.repositories.user_repo import UserRepository
+from adapters.db.repositories.api_key_repo import ApiKeyRepository
+from app.core.security import hash_password, verify_password, generate_api_key, consteq
+from app.core.settings import get_settings
+from app.services.captcha_service import validate_captcha
+from adapters.db.repositories.password_reset_repo import PasswordResetRepository
+import hashlib
+
+
+def _normalize_email(email: str | None) -> str | None:
+ return email.lower().strip() if email else None
+
+
+def _normalize_mobile(mobile: str | None) -> str | None:
+ if not mobile:
+ return None
+ # Clean input: keep digits and leading plus
+ raw = mobile.strip()
+ raw = ''.join(ch for ch in raw if ch.isdigit() or ch == '+')
+ try:
+ from app.core.settings import get_settings
+ settings = get_settings()
+ region = None if raw.startswith('+') else settings.default_phone_region
+ num = phonenumbers.parse(raw, region)
+ if not phonenumbers.is_valid_number(num):
+ return None
+ return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164)
+ except Exception:
+ return None
+
+
+def _detect_identifier(identifier: str) -> tuple[str, str | None, str | None]:
+ identifier = identifier.strip()
+ if "@" in identifier:
+ return "email", _normalize_email(identifier), None
+ mobile = _normalize_mobile(identifier)
+ return ("mobile", None, mobile) if mobile else ("invalid", None, None)
+
+
+def _generate_referral_code(db: Session) -> str:
+ from secrets import token_urlsafe
+ repo = UserRepository(db)
+ # try a few times to ensure uniqueness
+ for _ in range(10):
+ code = token_urlsafe(8).replace('-', '').replace('_', '')[:10]
+ if not repo.get_by_referral_code(code):
+ return code
+ # fallback longer code
+ return token_urlsafe(12).replace('-', '').replace('_', '')[:12]
+
+
+def register_user(*, db: Session, first_name: str | None, last_name: str | None, email: str | None, mobile: str | None, password: str, captcha_id: str, captcha_code: str, referrer_code: str | None = None) -> int:
+ if not validate_captcha(db, captcha_id, captcha_code):
+ from app.core.responses import ApiError
+ raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
+
+ email_n = _normalize_email(email)
+ mobile_n = _normalize_mobile(mobile)
+ if not email_n and not mobile_n:
+ from app.core.responses import ApiError
+ # اگر کاربر موبایل وارد کرده اما نامعتبر بوده، پیام دقیقتر بدهیم
+ if mobile and mobile.strip():
+ raise ApiError("INVALID_MOBILE", "Invalid mobile number")
+ # در غیر این صورت، هیچ شناسهٔ معتبری ارائه نشده است
+ raise ApiError("IDENTIFIER_REQUIRED", "Email or mobile is required")
+
+ repo = UserRepository(db)
+ if email_n and repo.get_by_email(email_n):
+ from app.core.responses import ApiError
+ raise ApiError("EMAIL_IN_USE", "Email is already in use")
+ if mobile_n and repo.get_by_mobile(mobile_n):
+ from app.core.responses import ApiError
+ raise ApiError("MOBILE_IN_USE", "Mobile is already in use")
+
+ pwd_hash = hash_password(password)
+ referred_by_user_id = None
+ if referrer_code:
+ ref_user = repo.get_by_referral_code(referrer_code)
+ if ref_user:
+ # prevent self-referral at signup theoretically not applicable; rule kept for safety
+ referred_by_user_id = ref_user.id
+ referral_code = _generate_referral_code(db)
+ user = repo.create(
+ email=email_n,
+ mobile=mobile_n,
+ password_hash=pwd_hash,
+ first_name=first_name,
+ last_name=last_name,
+ referral_code=referral_code,
+ referred_by_user_id=referred_by_user_id,
+ )
+ return user.id
+
+
+def login_user(*, db: Session, identifier: str, password: str, captcha_id: str, captcha_code: str, device_id: str | None, user_agent: str | None, ip: str | None) -> tuple[str, datetime | None, dict]:
+ if not validate_captcha(db, captcha_id, captcha_code):
+ from app.core.responses import ApiError
+ raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
+
+ kind, email, mobile = _detect_identifier(identifier)
+ if kind == "invalid":
+ from app.core.responses import ApiError
+ raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number")
+
+ repo = UserRepository(db)
+ user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type]
+ if not user or not verify_password(password, user.password_hash):
+ from app.core.responses import ApiError
+ raise ApiError("INVALID_CREDENTIALS", "Invalid credentials")
+ if not user.is_active:
+ from app.core.responses import ApiError
+ raise ApiError("ACCOUNT_DISABLED", "Your account is disabled")
+
+ settings = get_settings()
+ api_key, key_hash = generate_api_key()
+ expires_at = None # could be set from settings later
+ api_repo = ApiKeyRepository(db)
+ api_repo.create_session_key(user_id=user.id, key_hash=key_hash, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at)
+
+ user_data = {
+ "id": user.id,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ "email": user.email,
+ "mobile": user.mobile,
+ "referral_code": getattr(user, "referral_code", None),
+ }
+ return api_key, expires_at, user_data
+
+
+def _hash_reset_token(token: str) -> str:
+ settings = get_settings()
+ return hashlib.sha256(f"{settings.captcha_secret}:{token}".encode("utf-8")).hexdigest()
+
+
+def create_password_reset(*, db: Session, identifier: str, captcha_id: str, captcha_code: str) -> str:
+ if not validate_captcha(db, captcha_id, captcha_code):
+ from app.core.responses import ApiError
+ raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
+
+ kind, email, mobile = _detect_identifier(identifier)
+ if kind == "invalid":
+ from app.core.responses import ApiError
+ raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number")
+
+ repo = UserRepository(db)
+ user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type]
+ # Always respond OK to avoid user enumeration; but skip creation if user not found
+ if not user:
+ return ""
+
+ settings = get_settings()
+ from secrets import token_urlsafe
+ token = token_urlsafe(32)
+ token_hash = _hash_reset_token(token)
+ expires_at = datetime.utcnow() + timedelta(seconds=settings.reset_password_ttl_seconds)
+ pr_repo = PasswordResetRepository(db)
+ pr_repo.create(user_id=user.id, token_hash=token_hash, expires_at=expires_at)
+ return token
+
+
+def reset_password(*, db: Session, token: str, new_password: str, captcha_id: str, captcha_code: str) -> None:
+ if not validate_captcha(db, captcha_id, captcha_code):
+ from app.core.responses import ApiError
+ raise ApiError("INVALID_CAPTCHA", "Invalid captcha code")
+
+ pr_repo = PasswordResetRepository(db)
+ token_hash = _hash_reset_token(token)
+ pr = pr_repo.get_by_hash(token_hash)
+ if not pr or pr.expires_at < datetime.utcnow() or pr.used_at is not None:
+ from app.core.responses import ApiError
+ raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired")
+
+ # Update user password
+ from adapters.db.models.user import User
+ user = db.get(User, pr.user_id)
+ if not user:
+ from app.core.responses import ApiError
+ raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired")
+ user.password_hash = hash_password(new_password)
+ db.add(user)
+ db.commit()
+
+ pr_repo.mark_used(pr)
+
+
+
+
+def change_password(*, db: Session, user_id: int, current_password: str, new_password: str, confirm_password: str, translator=None) -> None:
+ """
+ تغییر کلمه عبور کاربر
+ """
+ # بررسی تطبیق کلمه عبور جدید و تکرار آن
+ if new_password != confirm_password:
+ from app.core.responses import ApiError
+ raise ApiError("PASSWORDS_DO_NOT_MATCH", "New password and confirm password do not match", translator=translator)
+
+ # بررسی اینکه کلمه عبور جدید با کلمه عبور فعلی متفاوت باشد
+ if current_password == new_password:
+ from app.core.responses import ApiError
+ raise ApiError("SAME_PASSWORD", "New password must be different from current password", translator=translator)
+
+ # دریافت کاربر
+ from adapters.db.models.user import User
+ user = db.get(User, user_id)
+ if not user:
+ from app.core.responses import ApiError
+ raise ApiError("USER_NOT_FOUND", "User not found", translator=translator)
+
+ # بررسی کلمه عبور فعلی
+ if not verify_password(current_password, user.password_hash):
+ from app.core.responses import ApiError
+ raise ApiError("INVALID_CURRENT_PASSWORD", "Current password is incorrect", translator=translator)
+
+ # بررسی اینکه کاربر فعال باشد
+ if not user.is_active:
+ from app.core.responses import ApiError
+ raise ApiError("ACCOUNT_DISABLED", "Your account is disabled", translator=translator)
+
+ # تغییر کلمه عبور
+ user.password_hash = hash_password(new_password)
+ db.add(user)
+ db.commit()
+
+
+def referral_stats(*, db: Session, user_id: int, start: datetime | None = None, end: datetime | None = None) -> dict:
+ from adapters.db.repositories.user_repo import UserRepository
+ repo = UserRepository(db)
+ # totals
+ total = repo.count_referred(user_id)
+ # month
+ now = datetime.utcnow()
+ month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
+ month_count = repo.count_referred_between(user_id, month_start, next_month)
+ # today
+ today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ tomorrow = today_start + timedelta(days=1)
+ today_count = repo.count_referred_between(user_id, today_start, tomorrow)
+ # custom range
+ custom = None
+ if start and end:
+ custom = repo.count_referred_between(user_id, start, end)
+ return {
+ "total": total,
+ "this_month": month_count,
+ "today": today_count,
+ "range": custom,
+ }
+
+
+def referral_list(*, db: Session, user_id: int, start: datetime | None = None, end: datetime | None = None, search: str | None = None, page: int = 1, limit: int = 20) -> dict:
+ from adapters.db.repositories.user_repo import UserRepository
+ repo = UserRepository(db)
+ page = max(1, page)
+ limit = max(1, min(100, limit))
+ offset = (page - 1) * limit
+ items = repo.list_referred(user_id, start_dt=start, end_dt=end, search=search, offset=offset, limit=limit)
+ total = repo.count_referred_filtered(user_id, start_dt=start, end_dt=end, search=search)
+ def mask_email(email: str | None) -> str | None:
+ if not email:
+ return None
+ try:
+ local, _, domain = email.partition('@')
+ if len(local) <= 2:
+ masked_local = local[0] + "*"
+ else:
+ masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
+ return masked_local + "@" + domain
+ except Exception:
+ return email
+ result = []
+ for u in items:
+ result.append({
+ "id": u.id,
+ "first_name": u.first_name,
+ "last_name": u.last_name,
+ "email": mask_email(u.email),
+ "created_at": u.created_at.isoformat(),
+ })
+ return {"items": result, "total": total, "page": page, "limit": limit}
\ No newline at end of file
diff --git a/hesabixAPI/build/lib/app/services/business_dashboard_service.py b/hesabixAPI/build/lib/app/services/business_dashboard_service.py
new file mode 100644
index 0000000..dd720a6
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/business_dashboard_service.py
@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+from typing import List, Optional, Dict, Any
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, func
+from datetime import datetime, timedelta
+
+from adapters.db.repositories.business_repo import BusinessRepository
+from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
+from adapters.db.repositories.user_repo import UserRepository
+from adapters.db.models.business import Business
+from adapters.db.models.business_permission import BusinessPermission
+from adapters.db.models.user import User
+from app.core.auth_dependency import AuthContext
+
+
+def get_business_dashboard_data(db: Session, business_id: int, ctx: AuthContext) -> Dict[str, Any]:
+ """دریافت دادههای داشبورد کسب و کار"""
+ business_repo = BusinessRepository(db)
+ business = business_repo.get_by_id(business_id)
+
+ if not business:
+ raise ValueError("کسب و کار یافت نشد")
+
+ # بررسی دسترسی کاربر
+ if not ctx.can_access_business(business_id):
+ raise ValueError("دسترسی غیرمجاز")
+
+ # دریافت اطلاعات کسب و کار
+ business_info = _get_business_info(business, db)
+
+ # دریافت آمار
+ statistics = _get_business_statistics(business_id, db)
+
+ # دریافت فعالیتهای اخیر
+ recent_activities = _get_recent_activities(business_id, db)
+
+ return {
+ "business_info": business_info,
+ "statistics": statistics,
+ "recent_activities": recent_activities
+ }
+
+
+def get_business_members(db: Session, business_id: int, ctx: AuthContext) -> Dict[str, Any]:
+ """دریافت لیست اعضای کسب و کار"""
+ if not ctx.can_access_business(business_id):
+ raise ValueError("دسترسی غیرمجاز")
+
+ permission_repo = BusinessPermissionRepository(db)
+ user_repo = UserRepository(db)
+
+ # دریافت دسترسیهای کسب و کار
+ permissions = permission_repo.get_business_users(business_id)
+
+ members = []
+ for permission in permissions:
+ user = user_repo.get_by_id(permission.user_id)
+ if user:
+ members.append({
+ "id": permission.id,
+ "user_id": user.id,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ "email": user.email,
+ "mobile": user.mobile,
+ "role": _get_user_role(permission.business_permissions),
+ "permissions": permission.business_permissions or {},
+ "joined_at": permission.created_at.isoformat()
+ })
+
+ return {
+ "items": members,
+ "pagination": {
+ "total": len(members),
+ "page": 1,
+ "per_page": len(members),
+ "total_pages": 1,
+ "has_next": False,
+ "has_prev": False
+ }
+ }
+
+
+def get_business_statistics(db: Session, business_id: int, ctx: AuthContext) -> Dict[str, Any]:
+ """دریافت آمار تفصیلی کسب و کار"""
+ if not ctx.can_access_business(business_id):
+ raise ValueError("دسترسی غیرمجاز")
+
+ # آمار فروش ماهانه (نمونه)
+ sales_by_month = [
+ {"month": "2024-01", "amount": 500000},
+ {"month": "2024-02", "amount": 750000},
+ {"month": "2024-03", "amount": 600000}
+ ]
+
+ # پرفروشترین محصولات (نمونه)
+ top_products = [
+ {"name": "محصول A", "sales_count": 100, "revenue": 500000},
+ {"name": "محصول B", "sales_count": 80, "revenue": 400000},
+ {"name": "محصول C", "sales_count": 60, "revenue": 300000}
+ ]
+
+ # آمار فعالیت اعضا
+ permission_repo = BusinessPermissionRepository(db)
+ members = permission_repo.get_business_users(business_id)
+
+ member_activity = {
+ "active_today": len([m for m in members if m.created_at.date() == datetime.now().date()]),
+ "active_this_week": len([m for m in members if m.created_at >= datetime.now() - timedelta(days=7)]),
+ "total_members": len(members)
+ }
+
+ return {
+ "sales_by_month": sales_by_month,
+ "top_products": top_products,
+ "member_activity": member_activity
+ }
+
+
+def _get_business_info(business: Business, db: Session) -> Dict[str, Any]:
+ """دریافت اطلاعات کسب و کار"""
+ permission_repo = BusinessPermissionRepository(db)
+ member_count = len(permission_repo.get_business_users(business.id))
+
+ return {
+ "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,
+ "created_at": business.created_at.isoformat(),
+ "member_count": member_count
+ }
+
+
+def _get_business_statistics(business_id: int, db: Session) -> Dict[str, Any]:
+ """دریافت آمار کلی کسب و کار"""
+ # در اینجا میتوانید آمار واقعی را از جداول مربوطه دریافت کنید
+ # فعلاً دادههای نمونه برمیگردانیم
+ return {
+ "total_sales": 1000000.0,
+ "total_purchases": 500000.0,
+ "active_members": 5,
+ "recent_transactions": 25
+ }
+
+
+def _get_recent_activities(business_id: int, db: Session) -> List[Dict[str, Any]]:
+ """دریافت فعالیتهای اخیر"""
+ # در اینجا میتوانید فعالیتهای واقعی را از جداول مربوطه دریافت کنید
+ # فعلاً دادههای نمونه برمیگردانیم
+ return [
+ {
+ "id": 1,
+ "title": "فروش جدید",
+ "description": "فروش محصول A به مبلغ 100,000 تومان",
+ "icon": "sell",
+ "time_ago": "2 ساعت پیش"
+ },
+ {
+ "id": 2,
+ "title": "عضو جدید",
+ "description": "احمد احمدی به تیم اضافه شد",
+ "icon": "person_add",
+ "time_ago": "5 ساعت پیش"
+ },
+ {
+ "id": 3,
+ "title": "گزارش ماهانه",
+ "description": "گزارش فروش ماه ژانویه تولید شد",
+ "icon": "assessment",
+ "time_ago": "1 روز پیش"
+ }
+ ]
+
+
+def _get_user_role(permissions: Optional[Dict[str, Any]]) -> str:
+ """تعیین نقش کاربر بر اساس دسترسیها"""
+ if not permissions:
+ return "عضو"
+
+ # بررسی دسترسیهای مختلف برای تعیین نقش
+ if permissions.get("settings", {}).get("manage_users"):
+ return "مدیر"
+ elif permissions.get("sales", {}).get("write"):
+ return "مدیر فروش"
+ elif permissions.get("accounting", {}).get("write"):
+ return "حسابدار"
+ else:
+ return "عضو"
diff --git a/hesabixAPI/build/lib/app/services/business_service.py b/hesabixAPI/build/lib/app/services/business_service.py
new file mode 100644
index 0000000..2ff1927
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/business_service.py
@@ -0,0 +1,360 @@
+from __future__ import annotations
+
+from typing import List, Optional, Dict, Any
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, func
+
+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.api.v1.schemas import (
+ BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse,
+ BusinessListResponse, BusinessSummaryResponse, PaginationInfo
+)
+from app.core.responses import format_datetime_fields
+
+
+def create_business(db: Session, business_data: BusinessCreateRequest, owner_id: int) -> Dict[str, Any]:
+ """ایجاد کسب و کار جدید"""
+ 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
+ )
+
+ # ایجاد سالهای مالی اولیه (در صورت ارسال)
+ 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)
+ )
+
+ # مدیریت ارزها
+ 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)
+ db.commit()
+
+ db.refresh(created_business)
+
+ # تبدیل به response format
+ return _business_to_dict(created_business)
+
+
+def get_business_by_id(db: Session, business_id: int, owner_id: int) -> Optional[Dict[str, Any]]:
+ """دریافت کسب و کار بر اساس شناسه"""
+ business_repo = BusinessRepository(db)
+ business = business_repo.get_by_id(business_id)
+
+ if not business or business.owner_id != owner_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
+
+ 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]) -> Dict[str, Any]:
+ """دریافت لیست کسب و کارهای کاربر (مالک + عضو)"""
+ business_repo = BusinessRepository(db)
+ permission_repo = BusinessPermissionRepository(db)
+
+ # دریافت کسب و کارهای مالک
+ owned_businesses = business_repo.get_by_owner_id(user_id)
+
+ # دریافت کسب و کارهای عضو
+ 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:
+ member_businesses.append(business)
+
+ # ترکیب لیستها
+ 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
+
+ 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: BusinessUpdateRequest, owner_id: int) -> Optional[Dict[str, Any]]:
+ """ویرایش کسب و کار"""
+ 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)
+ for field, value in update_data.items():
+ setattr(business, field, value)
+
+ # ذخیره تغییرات
+ updated_business = business_repo.update(business)
+
+ return _business_to_dict(updated_business)
+
+
+def delete_business(db: Session, business_id: int, owner_id: int) -> bool:
+ """حذف کسب و کار"""
+ business_repo = BusinessRepository(db)
+ business = business_repo.get_by_id(business_id)
+
+ if not business or business.owner_id != owner_id:
+ return False
+
+ business_repo.delete(business_id)
+ return True
+
+
+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 _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,
+ "created_at": business.created_at, # datetime object بماند
+ "updated_at": business.updated_at # datetime object بماند
+ }
+
+ # ارز پیشفرض
+ 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,
+ }
+ else:
+ data["default_currency"] = None
+
+ # ارزهای فعال کسبوکار
+ if getattr(business, "currencies", None):
+ data["currencies"] = [
+ {"id": c.id, "code": c.code, "title": c.title, "symbol": c.symbol}
+ for c in business.currencies
+ ]
+ else:
+ data["currencies"] = []
+
+ return data
diff --git a/hesabixAPI/build/lib/app/services/captcha_service.py b/hesabixAPI/build/lib/app/services/captcha_service.py
new file mode 100644
index 0000000..699af72
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/captcha_service.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import base64
+import io
+import os
+import secrets
+from datetime import datetime, timedelta
+from typing import Tuple
+
+from PIL import Image, ImageDraw, ImageFont, ImageFilter
+from sqlalchemy.orm import Session
+
+from adapters.db.models.captcha import Captcha
+from app.core.settings import get_settings
+import hashlib
+
+
+def _generate_numeric_code(length: int) -> str:
+ return "".join(str(secrets.randbelow(10)) for _ in range(length))
+
+
+def _hash_code(code: str, secret: str) -> str:
+ return hashlib.sha256(f"{secret}:{code}".encode("utf-8")).hexdigest()
+
+
+def _render_image(code: str, width: int = 140, height: int = 48) -> Image.Image:
+ bg_color = (245, 246, 248)
+ img = Image.new("RGB", (width, height), bg_color)
+ draw = ImageDraw.Draw(img)
+
+ try:
+ font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
+ font = ImageFont.truetype(font_path, 28)
+ except Exception:
+ font = ImageFont.load_default()
+
+ # Noise lines
+ for _ in range(3):
+ xy = [(secrets.randbelow(width), secrets.randbelow(height)) for _ in range(2)]
+ draw.line(xy, fill=(200, 205, 210), width=1)
+
+ # measure text
+ try:
+ bbox = draw.textbbox((0, 0), code, font=font)
+ text_w = bbox[2] - bbox[0]
+ text_h = bbox[3] - bbox[1]
+ except Exception:
+ # fallback approximate
+ text_w, text_h = (len(code) * 16, 24)
+
+ x = (width - text_w) // 2
+ y = (height - text_h) // 2
+ # Slight jitter per character
+ avg_char_w = max(1, text_w // max(1, len(code)))
+ for idx, ch in enumerate(code):
+ cx = x + idx * avg_char_w + secrets.randbelow(3)
+ cy = y + secrets.randbelow(3)
+ draw.text((cx, cy), ch, font=font, fill=(60, 70, 80))
+
+ img = img.filter(ImageFilter.SMOOTH)
+ return img
+
+
+def create_captcha(db: Session) -> tuple[str, str, int]:
+ settings = get_settings()
+ code = _generate_numeric_code(settings.captcha_length)
+ code_hash = _hash_code(code, settings.captcha_secret)
+ captcha_id = f"cpt_{secrets.token_hex(8)}"
+ expires_at = datetime.utcnow() + timedelta(seconds=settings.captcha_ttl_seconds)
+
+ obj = Captcha(id=captcha_id, code_hash=code_hash, expires_at=expires_at, attempts=0)
+ db.add(obj)
+ db.commit()
+
+ image = _render_image(code)
+ buf = io.BytesIO()
+ image.save(buf, format="PNG")
+ image_base64 = base64.b64encode(buf.getvalue()).decode("ascii")
+ return captcha_id, image_base64, settings.captcha_ttl_seconds
+
+
+def validate_captcha(db: Session, captcha_id: str, code: str) -> bool:
+ settings = get_settings()
+ obj = db.get(Captcha, captcha_id)
+ if obj is None:
+ return False
+ if obj.expires_at < datetime.utcnow():
+ return False
+ provided_hash = _hash_code(code.strip(), settings.captcha_secret)
+ if secrets.compare_digest(provided_hash, obj.code_hash):
+ return True
+ obj.attempts += 1
+ db.add(obj)
+ db.commit()
+ return False
+
+
diff --git a/hesabixAPI/build/lib/app/services/email_service.py b/hesabixAPI/build/lib/app/services/email_service.py
new file mode 100644
index 0000000..f1a2399
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/email_service.py
@@ -0,0 +1,143 @@
+import smtplib
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from typing import Optional, List
+from sqlalchemy.orm import Session
+
+from adapters.db.models.email_config import EmailConfig
+from adapters.db.repositories.email_config_repository import EmailConfigRepository
+
+
+class EmailService:
+ def __init__(self, db: Session):
+ self.db = db
+ self.email_repo = EmailConfigRepository(db)
+
+ def send_email(
+ self,
+ to: str,
+ subject: str,
+ body: str,
+ html_body: Optional[str] = None,
+ config_id: Optional[int] = None
+ ) -> bool:
+ """
+ Send email using SMTP configuration
+
+ Args:
+ to: Recipient email address
+ subject: Email subject
+ body: Plain text body
+ html_body: HTML body (optional)
+ config_id: Specific config ID to use (optional)
+
+ Returns:
+ bool: True if email sent successfully, False otherwise
+ """
+ try:
+ # Get email configuration - prioritize default config
+ if config_id:
+ config = self.email_repo.get_by_id(config_id)
+ else:
+ # First try to get default config
+ config = self.email_repo.get_default_config()
+ if not config:
+ # Fallback to active config
+ config = self.email_repo.get_active_config()
+
+ if not config:
+ return False
+
+ # Create message
+ msg = MIMEMultipart('alternative')
+ msg['From'] = f"{config.from_name} <{config.from_email}>"
+ msg['To'] = to
+ msg['Subject'] = subject
+
+ # Add plain text part
+ text_part = MIMEText(body, 'plain', 'utf-8')
+ msg.attach(text_part)
+
+ # Add HTML part if provided
+ if html_body:
+ html_part = MIMEText(html_body, 'html', 'utf-8')
+ msg.attach(html_part)
+
+ # Send email
+ return self._send_smtp_email(config, msg)
+
+ except Exception as e:
+ print(f"Error sending email: {e}")
+ return False
+
+ def send_template_email(
+ self,
+ template_name: str,
+ to: str,
+ context: dict,
+ config_id: Optional[int] = None
+ ) -> bool:
+ """
+ Send email using a template (placeholder for future template system)
+
+ Args:
+ template_name: Name of the template
+ to: Recipient email address
+ context: Template context variables
+ config_id: Specific config ID to use (optional)
+
+ Returns:
+ bool: True if email sent successfully, False otherwise
+ """
+ # For now, just use basic template substitution
+ # This can be extended with a proper template engine later
+ subject = context.get('subject', 'Email from Hesabix')
+ body = context.get('body', '')
+ html_body = context.get('html_body')
+
+ return self.send_email(to, subject, body, html_body, config_id)
+
+ def test_connection(self, config_id: int) -> bool:
+ """
+ Test SMTP connection for a specific configuration
+
+ Args:
+ config_id: Configuration ID to test
+
+ Returns:
+ bool: True if connection successful, False otherwise
+ """
+ config = self.email_repo.get_by_id(config_id)
+ if not config:
+ return False
+
+ return self.email_repo.test_connection(config)
+
+ def get_active_config(self) -> Optional[EmailConfig]:
+ """Get the currently active email configuration"""
+ return self.email_repo.get_active_config()
+
+ def get_all_configs(self) -> List[EmailConfig]:
+ """Get all email configurations"""
+ return self.email_repo.get_all_configs()
+
+ def _send_smtp_email(self, config: EmailConfig, msg: MIMEMultipart) -> bool:
+ """Internal method to send email via SMTP"""
+ try:
+ # Create SMTP connection
+ if config.use_ssl:
+ server = smtplib.SMTP_SSL(config.smtp_host, config.smtp_port)
+ else:
+ server = smtplib.SMTP(config.smtp_host, config.smtp_port)
+ if config.use_tls:
+ server.starttls()
+
+ # Login and send
+ server.login(config.smtp_username, config.smtp_password)
+ server.send_message(msg)
+ server.quit()
+
+ return True
+ except Exception as e:
+ print(f"SMTP error: {e}")
+ return False
diff --git a/hesabixAPI/build/lib/app/services/file_storage_service.py b/hesabixAPI/build/lib/app/services/file_storage_service.py
new file mode 100644
index 0000000..c7d4911
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/file_storage_service.py
@@ -0,0 +1,227 @@
+import os
+import hashlib
+import uuid
+from typing import Optional, Dict, Any, List
+from uuid import UUID
+from fastapi import UploadFile, HTTPException
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+
+from adapters.db.repositories.file_storage_repository import (
+ FileStorageRepository,
+ StorageConfigRepository,
+ FileVerificationRepository
+)
+from adapters.db.models.file_storage import FileStorage, StorageConfig
+
+
+class FileStorageService:
+ def __init__(self, db: Session):
+ self.db = db
+ self.file_repo = FileStorageRepository(db)
+ self.config_repo = StorageConfigRepository(db)
+ self.verification_repo = FileVerificationRepository(db)
+
+ async def upload_file(
+ self,
+ file: UploadFile,
+ user_id: UUID,
+ module_context: str,
+ context_id: Optional[UUID] = None,
+ developer_data: Optional[Dict] = None,
+ is_temporary: bool = False,
+ expires_in_days: int = 30,
+ storage_config_id: Optional[UUID] = None
+ ) -> Dict[str, Any]:
+ try:
+ # دریافت تنظیمات ذخیرهسازی
+ if storage_config_id:
+ storage_config = self.db.query(StorageConfig).filter(
+ StorageConfig.id == storage_config_id
+ ).first()
+ else:
+ storage_config = await self.config_repo.get_default_config()
+
+ if not storage_config:
+ raise HTTPException(status_code=400, detail="No storage configuration found")
+
+ # تولید نام فایل و مسیر
+ file_extension = os.path.splitext(file.filename)[1] if file.filename else ""
+ stored_name = f"{uuid.uuid4()}{file_extension}"
+
+ # تعیین مسیر ذخیرهسازی
+ if storage_config.storage_type == "local":
+ file_path = await self._get_local_file_path(stored_name, storage_config.config_data)
+ elif storage_config.storage_type == "ftp":
+ file_path = await self._get_ftp_file_path(stored_name, storage_config.config_data)
+ else:
+ raise HTTPException(status_code=400, detail="Unsupported storage type")
+
+ # خواندن محتوای فایل
+ file_content = await file.read()
+ file_size = len(file_content)
+
+ # محاسبه checksum
+ checksum = hashlib.sha256(file_content).hexdigest()
+
+ # ذخیره فایل
+ await self._save_file_to_storage(file_content, file_path, storage_config)
+
+ # ذخیره اطلاعات در دیتابیس
+ file_storage = await self.file_repo.create_file(
+ original_name=file.filename or "unknown",
+ stored_name=stored_name,
+ file_path=file_path,
+ file_size=file_size,
+ mime_type=file.content_type or "application/octet-stream",
+ storage_type=storage_config.storage_type,
+ uploaded_by=user_id,
+ module_context=module_context,
+ context_id=context_id,
+ developer_data=developer_data,
+ checksum=checksum,
+ is_temporary=is_temporary,
+ expires_in_days=expires_in_days,
+ storage_config_id=storage_config.id
+ )
+
+ # تولید توکن تایید برای فایلهای موقت
+ verification_token = None
+ if is_temporary:
+ verification_token = str(uuid.uuid4())
+ await self.verification_repo.create_verification(
+ file_id=file_storage.id,
+ module_name=module_context,
+ verification_token=verification_token,
+ verification_data=developer_data
+ )
+
+ return {
+ "file_id": str(file_storage.id),
+ "original_name": file_storage.original_name,
+ "file_size": file_storage.file_size,
+ "mime_type": file_storage.mime_type,
+ "is_temporary": file_storage.is_temporary,
+ "verification_token": verification_token,
+ "expires_at": file_storage.expires_at.isoformat() if file_storage.expires_at else None
+ }
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"File upload failed: {str(e)}")
+
+ async def get_file(self, file_id: UUID) -> Dict[str, Any]:
+ file_storage = await self.file_repo.get_file_by_id(file_id)
+ if not file_storage:
+ raise HTTPException(status_code=404, detail="File not found")
+
+ return {
+ "file_id": str(file_storage.id),
+ "original_name": file_storage.original_name,
+ "file_size": file_storage.file_size,
+ "mime_type": file_storage.mime_type,
+ "is_temporary": file_storage.is_temporary,
+ "is_verified": file_storage.is_verified,
+ "created_at": file_storage.created_at.isoformat(),
+ "expires_at": file_storage.expires_at.isoformat() if file_storage.expires_at else None
+ }
+
+ async def download_file(self, file_id: UUID) -> Dict[str, Any]:
+ file_storage = await self.file_repo.get_file_by_id(file_id)
+ if not file_storage:
+ raise HTTPException(status_code=404, detail="File not found")
+
+ # خواندن فایل از storage
+ file_content = await self._read_file_from_storage(file_storage.file_path, file_storage.storage_type)
+
+ return {
+ "content": file_content,
+ "filename": file_storage.original_name,
+ "mime_type": file_storage.mime_type
+ }
+
+ async def delete_file(self, file_id: UUID) -> bool:
+ file_storage = await self.file_repo.get_file_by_id(file_id)
+ if not file_storage:
+ return False
+
+ # حذف فایل از storage
+ await self._delete_file_from_storage(file_storage.file_path, file_storage.storage_type)
+
+ # حذف نرم از دیتابیس
+ return await self.file_repo.soft_delete_file(file_id)
+
+ async def verify_file_usage(self, file_id: UUID, verification_data: Dict) -> bool:
+ return await self.file_repo.verify_file(file_id, verification_data)
+
+ async def list_files_by_context(
+ self,
+ module_context: str,
+ context_id: UUID
+ ) -> List[Dict[str, Any]]:
+ files = await self.file_repo.get_files_by_context(module_context, context_id)
+ return [
+ {
+ "file_id": str(file.id),
+ "original_name": file.original_name,
+ "file_size": file.file_size,
+ "mime_type": file.mime_type,
+ "is_temporary": file.is_temporary,
+ "is_verified": file.is_verified,
+ "created_at": file.created_at.isoformat()
+ }
+ for file in files
+ ]
+
+ async def cleanup_unverified_files(self) -> Dict[str, Any]:
+ unverified_files = await self.file_repo.get_unverified_temporary_files()
+ cleaned_count = 0
+
+ for file_storage in unverified_files:
+ if file_storage.expires_at and file_storage.expires_at < datetime.utcnow():
+ await self._delete_file_from_storage(file_storage.file_path, file_storage.storage_type)
+ await self.file_repo.soft_delete_file(file_storage.id)
+ cleaned_count += 1
+
+ return {
+ "cleaned_files": cleaned_count,
+ "total_unverified": len(unverified_files)
+ }
+
+ async def get_storage_statistics(self) -> Dict[str, Any]:
+ return await self.file_repo.get_storage_statistics()
+
+ # Helper methods
+ async def _get_local_file_path(self, stored_name: str, config_data: Dict) -> str:
+ base_path = config_data.get("base_path", "/tmp/hesabix_files")
+ os.makedirs(base_path, exist_ok=True)
+ return os.path.join(base_path, stored_name)
+
+ async def _get_ftp_file_path(self, stored_name: str, config_data: Dict) -> str:
+ # برای FTP، مسیر نسبی را برمیگردانیم
+ base_path = config_data.get("base_path", "/hesabix_files")
+ return f"{base_path}/{stored_name}"
+
+ async def _save_file_to_storage(self, content: bytes, file_path: str, storage_config: StorageConfig):
+ if storage_config.storage_type == "local":
+ with open(file_path, "wb") as f:
+ f.write(content)
+ elif storage_config.storage_type == "ftp":
+ # TODO: پیادهسازی FTP upload
+ pass
+
+ async def _read_file_from_storage(self, file_path: str, storage_type: str) -> bytes:
+ if storage_type == "local":
+ with open(file_path, "rb") as f:
+ return f.read()
+ elif storage_type == "ftp":
+ # TODO: پیادهسازی FTP download
+ pass
+ return b""
+
+ async def _delete_file_from_storage(self, file_path: str, storage_type: str):
+ if storage_type == "local":
+ if os.path.exists(file_path):
+ os.remove(file_path)
+ elif storage_type == "ftp":
+ # TODO: پیادهسازی FTP delete
+ pass
diff --git a/hesabixAPI/build/lib/app/services/pdf/__init__.py b/hesabixAPI/build/lib/app/services/pdf/__init__.py
new file mode 100644
index 0000000..23f103e
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/pdf/__init__.py
@@ -0,0 +1,6 @@
+"""
+PDF Service Package
+"""
+from .base_pdf_service import PDFService
+
+__all__ = ['PDFService']
diff --git a/hesabixAPI/build/lib/app/services/pdf/base_pdf_service.py b/hesabixAPI/build/lib/app/services/pdf/base_pdf_service.py
new file mode 100644
index 0000000..d732522
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/pdf/base_pdf_service.py
@@ -0,0 +1,135 @@
+"""
+Base PDF Service for modular PDF generation
+"""
+import os
+from abc import ABC, abstractmethod
+from typing import Dict, Any, Optional, List
+from datetime import datetime
+from pathlib import Path
+
+from weasyprint import HTML, CSS
+from weasyprint.text.fonts import FontConfiguration
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+
+from app.core.calendar import CalendarConverter, CalendarType
+from app.core.i18n import get_translator
+from adapters.api.v1.schemas import QueryInfo
+
+
+class BasePDFModule(ABC):
+ """Base class for PDF modules"""
+
+ def __init__(self, module_name: str):
+ self.module_name = module_name
+ self.template_dir = Path(__file__).parent / "modules" / module_name / "templates"
+ self.jinja_env = Environment(
+ loader=FileSystemLoader(str(self.template_dir)),
+ autoescape=select_autoescape(['html', 'xml'])
+ )
+ self.font_config = FontConfiguration()
+
+ @abstractmethod
+ def generate_pdf(
+ self,
+ data: Dict[str, Any],
+ calendar_type: CalendarType = "jalali",
+ locale: str = "fa"
+ ) -> bytes:
+ """Generate PDF for this module"""
+ pass
+
+ @abstractmethod
+ def generate_excel_data(
+ self,
+ data: Dict[str, Any],
+ calendar_type: CalendarType = "jalali",
+ locale: str = "fa"
+ ) -> list:
+ """Generate Excel data for this module"""
+ pass
+
+ def format_datetime(self, dt: datetime, calendar_type: CalendarType) -> str:
+ """Format datetime based on calendar type"""
+ if dt is None:
+ return ""
+
+ formatted_date = CalendarConverter.format_datetime(dt, calendar_type)
+ return formatted_date['formatted']
+
+ def get_translator(self, locale: str = "fa"):
+ """Get translator for the given locale"""
+ return get_translator(locale)
+
+ def render_template(self, template_name: str, context: Dict[str, Any]) -> str:
+ """Render template with context"""
+ template = self.jinja_env.get_template(template_name)
+ return template.render(**context)
+
+
+class PDFService:
+ """Main PDF Service that manages modules"""
+
+ def __init__(self):
+ self.modules: Dict[str, BasePDFModule] = {}
+ self._register_modules()
+
+ def _register_modules(self):
+ """Register all available modules"""
+ from .modules.marketing.marketing_module import MarketingPDFModule
+ self.modules['marketing'] = MarketingPDFModule()
+
+ def generate_pdf(
+ self,
+ module_name: str,
+ data: Dict[str, Any],
+ calendar_type: CalendarType = "jalali",
+ locale: str = "fa",
+ db=None,
+ user_id: Optional[int] = None,
+ query_info: Optional[QueryInfo] = None,
+ selected_indices: Optional[List[int]] = None,
+ stats: Optional[Dict[str, Any]] = None
+ ) -> bytes:
+ """Generate PDF using specified module"""
+ if module_name not in self.modules:
+ raise ValueError(f"Module '{module_name}' not found")
+
+ return self.modules[module_name].generate_pdf_content(
+ db=db,
+ user_id=user_id,
+ query_info=query_info,
+ selected_indices=selected_indices,
+ stats=stats,
+ calendar_type=calendar_type,
+ locale=locale,
+ common_data=data
+ )
+
+ def generate_excel_data(
+ self,
+ module_name: str,
+ data: Dict[str, Any],
+ calendar_type: CalendarType = "jalali",
+ locale: str = "fa",
+ db=None,
+ user_id: Optional[int] = None,
+ query_info: Optional[QueryInfo] = None,
+ selected_indices: Optional[List[int]] = None
+ ) -> list:
+ """Generate Excel data using specified module"""
+ if module_name not in self.modules:
+ raise ValueError(f"Module '{module_name}' not found")
+
+ return self.modules[module_name].generate_excel_content(
+ db=db,
+ user_id=user_id,
+ query_info=query_info,
+ selected_indices=selected_indices,
+ calendar_type=calendar_type,
+ locale=locale,
+ common_data=data
+ )
+
+ def list_modules(self) -> list:
+ """List all available modules"""
+ return list(self.modules.keys())
diff --git a/hesabixAPI/build/lib/app/services/pdf/modules/__init__.py b/hesabixAPI/build/lib/app/services/pdf/modules/__init__.py
new file mode 100644
index 0000000..7d39827
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/pdf/modules/__init__.py
@@ -0,0 +1,3 @@
+"""
+PDF Modules Package
+"""
diff --git a/hesabixAPI/build/lib/app/services/pdf/modules/marketing/__init__.py b/hesabixAPI/build/lib/app/services/pdf/modules/marketing/__init__.py
new file mode 100644
index 0000000..9088041
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/pdf/modules/marketing/__init__.py
@@ -0,0 +1,6 @@
+"""
+Marketing PDF Module
+"""
+from .marketing_module import MarketingPDFModule
+
+__all__ = ['MarketingPDFModule']
diff --git a/hesabixAPI/build/lib/app/services/pdf/modules/marketing/marketing_module.py b/hesabixAPI/build/lib/app/services/pdf/modules/marketing/marketing_module.py
new file mode 100644
index 0000000..e55a5b4
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/pdf/modules/marketing/marketing_module.py
@@ -0,0 +1,441 @@
+"""
+Marketing PDF Module for referrals and marketing reports
+"""
+from typing import Dict, Any, List
+from datetime import datetime
+
+from ...base_pdf_service import BasePDFModule
+from app.core.calendar import CalendarType
+
+
+class MarketingPDFModule(BasePDFModule):
+ """PDF Module for marketing and referrals"""
+
+ def __init__(self):
+ super().__init__("marketing")
+ self.template_name = 'marketing_referrals.html'
+
+ def generate_pdf(
+ self,
+ data: Dict[str, Any],
+ calendar_type: CalendarType = "jalali",
+ locale: str = "fa"
+ ) -> bytes:
+ """Generate marketing referrals PDF"""
+ # Get translator
+ t = self.get_translator(locale)
+
+ # Format data with translations and calendar
+ formatted_data = self._format_data_for_template(data, calendar_type, t)
+
+ # Render template
+ html_content = self.render_template('marketing_referrals.html', formatted_data)
+
+ # Generate PDF
+ html_doc = HTML(string=html_content)
+ pdf_bytes = html_doc.write_pdf(font_config=self.font_config)
+
+ return pdf_bytes
+
+ def generate_excel_data(
+ self,
+ data: Dict[str, Any],
+ calendar_type: CalendarType = "jalali",
+ locale: str = "fa"
+ ) -> list:
+ """Generate marketing referrals Excel data"""
+ # Get translator
+ t = self.get_translator(locale)
+
+ # Format data
+ items = data.get('items', [])
+ excel_data = []
+
+ for i, item in enumerate(items, 1):
+ # Format created_at based on calendar type
+ created_at = item.get('created_at', '')
+ if created_at and isinstance(created_at, datetime):
+ created_at = self.format_datetime(created_at, calendar_type)
+ elif created_at and isinstance(created_at, str):
+ try:
+ dt = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
+ created_at = self.format_datetime(dt, calendar_type)
+ except:
+ pass
+
+ excel_data.append({
+ t('row_number'): i,
+ t('first_name'): item.get('first_name', ''),
+ t('last_name'): item.get('last_name', ''),
+ t('email'): item.get('email', ''),
+ t('registration_date'): created_at,
+ t('referral_code'): item.get('referral_code', ''),
+ t('status'): t('active') if item.get('is_active', False) else t('inactive')
+ })
+
+ return excel_data
+
+ def _format_data_for_template(
+ self,
+ data: Dict[str, Any],
+ calendar_type: CalendarType,
+ translator
+ ) -> Dict[str, Any]:
+ """Format data for template rendering"""
+ # Format items
+ items = data.get('items', [])
+ formatted_items = []
+
+ for item in items:
+ formatted_item = item.copy()
+ if item.get('created_at'):
+ if isinstance(item['created_at'], datetime):
+ formatted_item['created_at'] = self.format_datetime(item['created_at'], calendar_type)
+ elif isinstance(item['created_at'], str):
+ try:
+ dt = datetime.fromisoformat(item['created_at'].replace('Z', '+00:00'))
+ formatted_item['created_at'] = self.format_datetime(dt, calendar_type)
+ except:
+ pass
+ formatted_items.append(formatted_item)
+
+ # Format current date
+ now = datetime.now()
+ formatted_now = self.format_datetime(now, calendar_type)
+
+ # Prepare template data with translations
+ template_data = {
+ 'items': formatted_items,
+ 'total_count': data.get('total_count', 0),
+ 'report_date': formatted_now.split(' ')[0] if ' ' in formatted_now else formatted_now,
+ 'report_time': formatted_now.split(' ')[1] if ' ' in formatted_now else '',
+ 'selected_only': data.get('selected_only', False),
+ 'stats': data.get('stats', {}),
+ 'filters': self._format_filters(data.get('filters', []), translator),
+ 'calendar_type': calendar_type,
+ 'locale': translator.locale,
+ 't': translator, # Pass translator to template
+ }
+
+ return template_data
+
+ def _format_filters(self, query_info, locale: str, calendar_type: CalendarType = "jalali") -> List[str]:
+ """Format query filters for display in PDF"""
+ formatted_filters = []
+ translator = self.get_translator(locale)
+
+ # Add search filter
+ if query_info.search and query_info.search.strip():
+ search_fields = ', '.join(query_info.search_fields) if query_info.search_fields else translator.t('allFields')
+ formatted_filters.append(f"{translator.t('search')}: '{query_info.search}' {translator.t('in')} {search_fields}")
+
+ # Add column filters
+ if query_info.filters:
+ for filter_item in query_info.filters:
+ if filter_item.property == "referred_by_user_id":
+ continue # Skip internal filter
+
+ # Get translated column name
+ column_name = self._get_column_translation(filter_item.property, translator)
+ operator_text = self._get_operator_translation(filter_item.operator, translator)
+
+ # Format value based on column type and calendar
+ formatted_value = self._format_filter_value(filter_item.property, filter_item.value, calendar_type, translator)
+
+ formatted_filters.append(f"{column_name} {operator_text} '{formatted_value}'")
+
+ return formatted_filters
+
+ def _get_operator_translation(self, op: str, translator) -> str:
+ """Convert operator to translated text"""
+ operator_map = {
+ '=': translator.t('equals'),
+ '>': translator.t('greater_than'),
+ '>=': translator.t('greater_equal'),
+ '<': translator.t('less_than'),
+ '<=': translator.t('less_equal'),
+ '!=': translator.t('not_equals'),
+ '*': translator.t('contains'),
+ '*?': translator.t('starts_with'),
+ '?*': translator.t('ends_with'),
+ 'in': translator.t('in_list')
+ }
+
+ operator_text = operator_map.get(op, op)
+ return operator_text
+
+ def _get_column_translation(self, property_name: str, translator) -> str:
+ """Get translated column name"""
+ column_map = {
+ 'first_name': translator.t('firstName'),
+ 'last_name': translator.t('lastName'),
+ 'email': translator.t('email'),
+ 'created_at': translator.t('registrationDate'),
+ 'referral_code': translator.t('referralCode'),
+ 'is_active': translator.t('status'),
+ }
+ return column_map.get(property_name, property_name)
+
+ def _format_filter_value(self, property_name: str, value: Any, calendar_type: CalendarType, translator) -> str:
+ """Format filter value based on column type and calendar"""
+ # Handle date fields
+ if property_name == 'created_at':
+ try:
+ if isinstance(value, str):
+ # Try to parse ISO format
+ dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
+ elif isinstance(value, datetime):
+ dt = value
+ else:
+ return str(value)
+
+ # Format based on calendar type - only date, no time
+ from app.core.calendar import CalendarConverter
+ formatted_date = CalendarConverter.format_datetime(dt, calendar_type)
+ return formatted_date['date_only'] # Only show date, not time
+ except:
+ return str(value)
+
+ # Handle boolean fields
+ elif property_name == 'is_active':
+ if isinstance(value, bool):
+ return translator.t('active') if value else translator.t('inactive')
+ elif str(value).lower() in ['true', '1', 'yes']:
+ return translator.t('active')
+ elif str(value).lower() in ['false', '0', 'no']:
+ return translator.t('inactive')
+ else:
+ return str(value)
+
+ # Default: return as string
+ return str(value)
+
+ def _get_referral_data(self, db, user_id: int, query_info, selected_indices: List[int] | None = None) -> tuple[List[Dict[str, Any]], int]:
+ """Get referral data from database"""
+ from adapters.db.repositories.user_repo import UserRepository
+ from adapters.api.v1.schemas import FilterItem
+ from sqlalchemy.orm import Session
+ from adapters.db.models.user import User
+
+ repo = UserRepository(db)
+
+ # Add filter for referrals only (users with referred_by_user_id = current user)
+ referral_filter = FilterItem(
+ property="referred_by_user_id",
+ operator="=",
+ value=user_id
+ )
+
+ # Create a mutable copy of query_info.filters
+ current_filters = list(query_info.filters) if query_info.filters else []
+ current_filters.append(referral_filter)
+
+ # For export, we need to get all data without take limit
+ # Use the repository's direct query method
+ try:
+ # Get all referrals for the user without pagination
+ query = db.query(User).filter(User.referred_by_user_id == user_id)
+
+ # Apply search if provided
+ if query_info.search and query_info.search.strip():
+ search_term = f"%{query_info.search}%"
+ if query_info.search_fields:
+ search_conditions = []
+ for field in query_info.search_fields:
+ if hasattr(User, field):
+ search_conditions.append(getattr(User, field).ilike(search_term))
+ if search_conditions:
+ from sqlalchemy import or_
+ query = query.filter(or_(*search_conditions))
+ else:
+ # Search in common fields
+ query = query.filter(
+ (User.first_name.ilike(search_term)) |
+ (User.last_name.ilike(search_term)) |
+ (User.email.ilike(search_term))
+ )
+
+ # Apply additional filters
+ for filter_item in current_filters:
+ if filter_item.property == "referred_by_user_id":
+ continue # Already applied
+
+ if hasattr(User, filter_item.property):
+ field = getattr(User, filter_item.property)
+ if filter_item.operator == "=":
+ query = query.filter(field == filter_item.value)
+ elif filter_item.operator == "!=":
+ query = query.filter(field != filter_item.value)
+ elif filter_item.operator == ">":
+ query = query.filter(field > filter_item.value)
+ elif filter_item.operator == ">=":
+ query = query.filter(field >= filter_item.value)
+ elif filter_item.operator == "<":
+ query = query.filter(field < filter_item.value)
+ elif filter_item.operator == "<=":
+ query = query.filter(field <= filter_item.value)
+ elif filter_item.operator == "*": # contains
+ query = query.filter(field.ilike(f"%{filter_item.value}%"))
+ elif filter_item.operator == "*?": # starts with
+ query = query.filter(field.ilike(f"{filter_item.value}%"))
+ elif filter_item.operator == "?*": # ends with
+ query = query.filter(field.ilike(f"%{filter_item.value}"))
+ elif filter_item.operator == "in":
+ query = query.filter(field.in_(filter_item.value))
+
+ # Apply sorting
+ if query_info.sort_by and hasattr(User, query_info.sort_by):
+ sort_field = getattr(User, query_info.sort_by)
+ if query_info.sort_desc:
+ query = query.order_by(sort_field.desc())
+ else:
+ query = query.order_by(sort_field.asc())
+ else:
+ # Default sort by created_at desc
+ query = query.order_by(User.created_at.desc())
+
+ # Execute query
+ referrals = query.all()
+ total = len(referrals)
+ referral_dicts = [repo.to_dict(referral) for referral in referrals]
+
+ # Apply selected indices filter if provided
+ if selected_indices is not None:
+ filtered_referrals = [referral_dicts[i] for i in selected_indices if i < len(referral_dicts)]
+ return filtered_referrals, len(filtered_referrals)
+
+ return referral_dicts, total
+
+ except Exception as e:
+ print(f"Error in _get_referral_data: {e}")
+ # Fallback to repository method with max take
+ data_query_info = query_info.__class__(
+ sort_by=query_info.sort_by,
+ sort_desc=query_info.sort_desc,
+ search=query_info.search,
+ search_fields=query_info.search_fields,
+ filters=current_filters,
+ take=1000,
+ skip=0,
+ )
+
+ referrals, total = repo.query_with_filters(data_query_info)
+ referral_dicts = [repo.to_dict(referral) for referral in referrals]
+
+ if selected_indices is not None:
+ filtered_referrals = [referral_dicts[i] for i in selected_indices if i < len(referral_dicts)]
+ return filtered_referrals, len(filtered_referrals)
+
+ return referral_dicts, total
+
+ def generate_pdf_content(
+ self,
+ db,
+ user_id: int,
+ query_info,
+ selected_indices: List[int] | None = None,
+ stats: Dict[str, Any] | None = None,
+ calendar_type: CalendarType = "jalali",
+ locale: str = "fa",
+ common_data: Dict[str, Any] = None
+ ) -> bytes:
+ """Generate PDF content using the new signature"""
+ # Get referral data
+ referrals_data, total_count = self._get_referral_data(db, user_id, query_info, selected_indices)
+
+ # Format datetime fields for display in PDF
+ for item in referrals_data:
+ if 'created_at' in item and item['created_at']:
+ if isinstance(item['created_at'], datetime):
+ from app.core.calendar import CalendarConverter
+ formatted_date = CalendarConverter.format_datetime(item['created_at'], calendar_type)
+ item['formatted_created_at'] = formatted_date['formatted']
+ else:
+ try:
+ dt = datetime.fromisoformat(item['created_at'].replace('Z', '+00:00'))
+ from app.core.calendar import CalendarConverter
+ formatted_date = CalendarConverter.format_datetime(dt, calendar_type)
+ item['formatted_created_at'] = formatted_date['formatted']
+ except:
+ item['formatted_created_at'] = str(item['created_at'])
+ else:
+ item['formatted_created_at'] = '-'
+
+ # Prepare context for template
+ from app.core.calendar import CalendarConverter
+ current_time = datetime.now()
+ formatted_current_time = CalendarConverter.format_datetime(current_time, calendar_type)
+
+ context = {
+ 'items': referrals_data,
+ 'total_count': total_count,
+ 'stats': stats,
+ 'filters': self._format_filters(query_info, locale, calendar_type),
+ 'report_date': formatted_current_time['date_only'],
+ 'report_time': formatted_current_time['time_only'],
+ 'locale': locale,
+ 'selected_only': selected_indices is not None and len(selected_indices) > 0,
+ }
+
+ # Include common data if provided
+ if common_data:
+ context.update(common_data)
+
+ # Get translator
+ t = self.get_translator(locale)
+ context['t'] = t.t # Pass the t method instead of the object
+
+ # Render template
+ html_content = self.render_template(self.template_name, context)
+
+ # Generate PDF from HTML
+ from weasyprint import HTML
+ from pathlib import Path
+ pdf_file = HTML(string=html_content, base_url=str(Path(__file__).parent / "templates")).write_pdf(font_config=self.font_config)
+ return pdf_file
+
+ def generate_excel_content(
+ self,
+ db,
+ user_id: int,
+ query_info,
+ selected_indices: List[int] | None = None,
+ calendar_type: CalendarType = "jalali",
+ locale: str = "fa",
+ common_data: Dict[str, Any] = None
+ ) -> List[Dict[str, Any]]:
+ """Generate Excel content using the new signature"""
+ # Get referral data
+ referrals_data, total_count = self._get_referral_data(db, user_id, query_info, selected_indices)
+
+ # Format data for Excel with calendar support
+ excel_data = []
+ t = self.get_translator(locale)
+
+ for i, item in enumerate(referrals_data, 1):
+ # Format created_at based on calendar type
+ created_at = item.get('created_at', '')
+ if created_at and isinstance(created_at, datetime):
+ from app.core.calendar import CalendarConverter
+ formatted_date = CalendarConverter.format_datetime(created_at, calendar_type)
+ created_at = formatted_date['formatted']
+ elif created_at and isinstance(created_at, str):
+ try:
+ dt = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
+ from app.core.calendar import CalendarConverter
+ formatted_date = CalendarConverter.format_datetime(dt, calendar_type)
+ created_at = formatted_date['formatted']
+ except:
+ pass
+
+ excel_data.append({
+ t.t('rowNumber'): i,
+ t.t('firstName'): item.get('first_name', ''),
+ t.t('lastName'): item.get('last_name', ''),
+ t.t('email'): item.get('email', ''),
+ t.t('registrationDate'): created_at,
+ t.t('referralCode'): item.get('referral_code', ''),
+ t.t('status'): t.t('active') if item.get('is_active', False) else t.t('inactive')
+ })
+
+ return excel_data
diff --git a/hesabixAPI/build/lib/app/services/person_service.py b/hesabixAPI/build/lib/app/services/person_service.py
new file mode 100644
index 0000000..2c8615a
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/person_service.py
@@ -0,0 +1,516 @@
+from typing import List, Optional, Dict, Any
+import json
+from sqlalchemy.exc import IntegrityError
+from app.core.responses import ApiError
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, or_, func
+from adapters.db.models.person import Person, PersonBankAccount, PersonType
+from adapters.api.v1.schema_models.person import (
+ PersonCreateRequest, PersonUpdateRequest, PersonBankAccountCreateRequest
+)
+from app.core.responses import success_response
+
+
+def create_person(db: Session, business_id: int, person_data: PersonCreateRequest) -> Dict[str, Any]:
+ """ایجاد شخص جدید"""
+ # محاسبه/اعتبارسنجی کد یکتا
+ code: Optional[int] = getattr(person_data, 'code', None)
+ if code is not None:
+ exists = db.query(Person).filter(
+ and_(Person.business_id == business_id, Person.code == code)
+ ).first()
+ if exists:
+ raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400)
+ else:
+ # تولید خودکار کد: بیشینه فعلی + 1 (نسبت به همان کسب و کار)
+ max_code = db.query(func.max(Person.code)).filter(Person.business_id == business_id).scalar()
+ code = (max_code or 0) + 1
+
+ # آمادهسازی person_types (چندانتخابی) و سازگاری person_type تکی
+ types_list: List[str] = []
+ if getattr(person_data, 'person_types', None):
+ types_list = [t.value if hasattr(t, 'value') else str(t) for t in person_data.person_types] # type: ignore[attr-defined]
+ elif getattr(person_data, 'person_type', None):
+ t = person_data.person_type
+ types_list = [t.value if hasattr(t, 'value') else str(t)]
+
+ # نوع تکی برای استفادههای بعدی (قبل از هر استفاده تعریف شود)
+ incoming_single_type = getattr(person_data, 'person_type', None)
+
+ # اعتبارسنجی سهام برای سهامدار
+ is_shareholder = False
+ if types_list:
+ is_shareholder = 'سهامدار' in types_list
+ if not is_shareholder and incoming_single_type is not None:
+ try:
+ is_shareholder = (getattr(incoming_single_type, 'value', str(incoming_single_type)) == 'سهامدار')
+ except Exception:
+ is_shareholder = False
+ if is_shareholder:
+ sc_val = getattr(person_data, 'share_count', None)
+ if sc_val is None or not isinstance(sc_val, int) or sc_val <= 0:
+ raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
+
+ # ایجاد شخص
+ # نگاشت person_type دریافتی از اسکیما به Enum مدل
+ mapped_single_type = None
+ if incoming_single_type is not None:
+ try:
+ # incoming_single_type.value مقدار فارسی مانند "سهامدار"
+ mapped_single_type = PersonType(getattr(incoming_single_type, 'value', str(incoming_single_type)))
+ except Exception:
+ mapped_single_type = None
+
+ person = Person(
+ business_id=business_id,
+ code=code,
+ alias_name=person_data.alias_name,
+ first_name=person_data.first_name,
+ last_name=person_data.last_name,
+ # ذخیره مقدار Enum با مقدار فارسی (values_callable در مدل مقادیر فارسی را مینویسد)
+ person_type=(mapped_single_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER)),
+ person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None,
+ company_name=person_data.company_name,
+ payment_id=person_data.payment_id,
+ national_id=person_data.national_id,
+ registration_number=person_data.registration_number,
+ economic_id=person_data.economic_id,
+ country=person_data.country,
+ province=person_data.province,
+ city=person_data.city,
+ address=person_data.address,
+ postal_code=person_data.postal_code,
+ phone=person_data.phone,
+ mobile=person_data.mobile,
+ fax=person_data.fax,
+ email=person_data.email,
+ website=person_data.website,
+ share_count=getattr(person_data, 'share_count', None),
+ commission_sale_percent=getattr(person_data, 'commission_sale_percent', None),
+ commission_sales_return_percent=getattr(person_data, 'commission_sales_return_percent', None),
+ commission_sales_amount=getattr(person_data, 'commission_sales_amount', None),
+ commission_sales_return_amount=getattr(person_data, 'commission_sales_return_amount', None),
+ commission_exclude_discounts=bool(getattr(person_data, 'commission_exclude_discounts', False)),
+ commission_exclude_additions_deductions=bool(getattr(person_data, 'commission_exclude_additions_deductions', False)),
+ commission_post_in_invoice_document=bool(getattr(person_data, 'commission_post_in_invoice_document', False)),
+ )
+
+ db.add(person)
+ db.flush() # برای دریافت ID
+
+ # ایجاد حسابهای بانکی
+ if person_data.bank_accounts:
+ for bank_account_data in person_data.bank_accounts:
+ bank_account = PersonBankAccount(
+ person_id=person.id,
+ bank_name=bank_account_data.bank_name,
+ account_number=bank_account_data.account_number,
+ card_number=bank_account_data.card_number,
+ sheba_number=bank_account_data.sheba_number,
+ )
+ db.add(bank_account)
+
+ try:
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400)
+ db.refresh(person)
+
+ return success_response(
+ message="شخص با موفقیت ایجاد شد",
+ data=_person_to_dict(person)
+ )
+
+
+def get_person_by_id(db: Session, person_id: int, business_id: int) -> Optional[Dict[str, Any]]:
+ """دریافت شخص بر اساس شناسه"""
+ person = db.query(Person).filter(
+ and_(Person.id == person_id, Person.business_id == business_id)
+ ).first()
+
+ if not person:
+ return None
+
+ return _person_to_dict(person)
+
+
+def get_persons_by_business(
+ db: Session,
+ business_id: int,
+ query_info: Dict[str, Any]
+) -> Dict[str, Any]:
+ """دریافت لیست اشخاص با جستجو و فیلتر"""
+ query = db.query(Person).filter(Person.business_id == business_id)
+
+ # اعمال جستجو
+ if query_info.get('search') and query_info.get('search_fields'):
+ search_term = f"%{query_info['search']}%"
+ search_conditions = []
+
+ for field in query_info['search_fields']:
+ if field == 'code':
+ # تبدیل به رشته برای جستجو مانند LIKE
+ try:
+ code_int = int(query_info['search']) # type: ignore[arg-type]
+ search_conditions.append(Person.code == code_int)
+ except Exception:
+ pass
+ if field == 'alias_name':
+ search_conditions.append(Person.alias_name.ilike(search_term))
+ elif field == 'first_name':
+ search_conditions.append(Person.first_name.ilike(search_term))
+ elif field == 'last_name':
+ search_conditions.append(Person.last_name.ilike(search_term))
+ elif field == 'company_name':
+ search_conditions.append(Person.company_name.ilike(search_term))
+ elif field == 'mobile':
+ search_conditions.append(Person.mobile.ilike(search_term))
+ elif field == 'email':
+ search_conditions.append(Person.email.ilike(search_term))
+ elif field == 'national_id':
+ search_conditions.append(Person.national_id.ilike(search_term))
+
+ if search_conditions:
+ query = query.filter(or_(*search_conditions))
+
+ # اعمال فیلترها
+ if query_info.get('filters'):
+ for filter_item in query_info['filters']:
+ # پشتیبانی از هر دو حالت: دیکشنری یا شیء Pydantic
+ if isinstance(filter_item, dict):
+ field = filter_item.get('property')
+ operator = filter_item.get('operator')
+ value = filter_item.get('value')
+ else:
+ field = getattr(filter_item, 'property', None)
+ operator = getattr(filter_item, 'operator', None)
+ value = getattr(filter_item, 'value', None)
+
+ if not field or not operator:
+ continue
+
+ # کد
+ if field == 'code':
+ if operator == '=':
+ query = query.filter(Person.code == value)
+ elif operator == 'in' and isinstance(value, list):
+ query = query.filter(Person.code.in_(value))
+ continue
+
+ # نوع شخص تکانتخابی
+ if field == 'person_type':
+ if operator == '=':
+ query = query.filter(Person.person_type == value)
+ elif operator == 'in' and isinstance(value, list):
+ query = query.filter(Person.person_type.in_(value))
+ continue
+
+ # انواع شخص چندانتخابی (رشته JSON)
+ if field == 'person_types':
+ if operator == '=' and isinstance(value, str):
+ query = query.filter(Person.person_types.ilike(f'%"{value}"%'))
+ elif operator == 'in' and isinstance(value, list):
+ sub_filters = [Person.person_types.ilike(f'%"{v}"%') for v in value]
+ if sub_filters:
+ query = query.filter(or_(*sub_filters))
+ continue
+
+ # فیلترهای متنی عمومی (حمایت از عملگرهای contains/startsWith/endsWith)
+ def apply_text_filter(column):
+ nonlocal query
+ if operator == '=':
+ query = query.filter(column == value)
+ elif operator == 'like' or operator == '*':
+ query = query.filter(column.ilike(f"%{value}%"))
+ elif operator == '*?': # starts with
+ query = query.filter(column.ilike(f"{value}%"))
+ elif operator == '?*': # ends with
+ query = query.filter(column.ilike(f"%{value}"))
+
+ if field == 'country':
+ apply_text_filter(Person.country)
+ continue
+
+ if field == 'province':
+ apply_text_filter(Person.province)
+ continue
+
+ if field == 'alias_name':
+ apply_text_filter(Person.alias_name)
+ continue
+
+ if field == 'first_name':
+ apply_text_filter(Person.first_name)
+ continue
+
+ if field == 'last_name':
+ apply_text_filter(Person.last_name)
+ continue
+
+ if field == 'company_name':
+ apply_text_filter(Person.company_name)
+ continue
+
+ if field == 'mobile':
+ apply_text_filter(Person.mobile)
+ continue
+
+ if field == 'email':
+ apply_text_filter(Person.email)
+ continue
+
+ if field == 'national_id':
+ apply_text_filter(Person.national_id)
+ continue
+
+ if field == 'registration_number':
+ apply_text_filter(Person.registration_number)
+ continue
+
+ if field == 'economic_id':
+ apply_text_filter(Person.economic_id)
+ continue
+
+ if field == 'city':
+ apply_text_filter(Person.city)
+ continue
+
+ if field == 'address':
+ apply_text_filter(Person.address)
+ continue
+
+ # شمارش کل رکوردها
+ total = query.count()
+
+ # اعمال مرتبسازی
+ sort_by = query_info.get('sort_by', 'created_at')
+ sort_desc = query_info.get('sort_desc', True)
+
+ if sort_by == 'code':
+ query = query.order_by(Person.code.desc() if sort_desc else Person.code.asc())
+ elif sort_by == 'alias_name':
+ query = query.order_by(Person.alias_name.desc() if sort_desc else Person.alias_name.asc())
+ elif sort_by == 'first_name':
+ query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc())
+ elif sort_by == 'last_name':
+ query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc())
+ elif sort_by == 'person_type':
+ query = query.order_by(Person.person_type.desc() if sort_desc else Person.person_type.asc())
+ elif sort_by == 'created_at':
+ query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
+ elif sort_by == 'updated_at':
+ query = query.order_by(Person.updated_at.desc() if sort_desc else Person.updated_at.asc())
+ else:
+ query = query.order_by(Person.created_at.desc())
+
+ # اعمال صفحهبندی
+ skip = query_info.get('skip', 0)
+ take = query_info.get('take', 20)
+
+ persons = query.offset(skip).limit(take).all()
+
+ # تبدیل به دیکشنری
+ items = [_person_to_dict(person) for person in persons]
+
+ # محاسبه اطلاعات صفحهبندی
+ total_pages = (total + take - 1) // take
+ current_page = (skip // take) + 1
+
+ pagination = {
+ 'total': total,
+ 'page': current_page,
+ 'per_page': take,
+ 'total_pages': total_pages,
+ 'has_next': current_page < total_pages,
+ 'has_prev': current_page > 1
+ }
+
+ return {
+ 'items': items,
+ 'pagination': pagination,
+ 'query_info': query_info
+ }
+
+
+def update_person(
+ db: Session,
+ person_id: int,
+ business_id: int,
+ person_data: PersonUpdateRequest
+) -> Optional[Dict[str, Any]]:
+ """ویرایش شخص"""
+ person = db.query(Person).filter(
+ and_(Person.id == person_id, Person.business_id == business_id)
+ ).first()
+
+ if not person:
+ return None
+
+ # بهروزرسانی فیلدها
+ update_data = person_data.dict(exclude_unset=True)
+
+ # مدیریت کد یکتا
+ if 'code' in update_data and update_data['code'] is not None:
+ desired_code = update_data['code']
+ exists = db.query(Person).filter(
+ and_(Person.business_id == business_id, Person.code == desired_code, Person.id != person_id)
+ ).first()
+ if exists:
+ raise ValueError("کد شخص تکراری است")
+ person.code = desired_code
+
+ # مدیریت انواع شخص چندگانه
+ types_list: Optional[List[str]] = None
+ if 'person_types' in update_data and update_data['person_types'] is not None:
+ incoming = update_data['person_types'] or []
+ types_list = [t.value if hasattr(t, 'value') else str(t) for t in incoming]
+ person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
+ # همگام کردن person_type تکی برای سازگاری
+ if types_list:
+ # مقدار Enum را با مقدار فارسی ست میکنیم
+ try:
+ person.person_type = PersonType(types_list[0])
+ except Exception:
+ pass
+
+ # مدیریت person_type تکی از اسکیما
+ if 'person_type' in update_data and update_data['person_type'] is not None:
+ single_type = update_data['person_type']
+ # نگاشت به Enum (مقدار فارسی)
+ try:
+ person.person_type = PersonType(getattr(single_type, 'value', str(single_type)))
+ except Exception:
+ pass
+ # پس از ست کردن مستقیم، از دیکشنری حذف شود تا در حلقه عمومی دوباره اعمال نشود
+ update_data.pop('person_type', None)
+
+ # اگر شخص سهامدار شد، share_count معتبر باشد
+ resulting_types: List[str] = []
+ if person.person_types:
+ try:
+ tmp = json.loads(person.person_types)
+ if isinstance(tmp, list):
+ resulting_types = [str(x) for x in tmp]
+ except Exception:
+ resulting_types = []
+ if (person.person_type == 'سهامدار') or ('سهامدار' in resulting_types):
+ sc_val2 = update_data.get('share_count', person.share_count)
+ if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
+ raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
+
+ # سایر فیلدها
+ for field in list(update_data.keys()):
+ if field in {'code', 'person_types'}:
+ continue
+ setattr(person, field, update_data[field])
+
+ db.commit()
+ db.refresh(person)
+
+ return success_response(
+ message="شخص با موفقیت ویرایش شد",
+ data=_person_to_dict(person)
+ )
+
+
+def delete_person(db: Session, person_id: int, business_id: int) -> bool:
+ """حذف شخص"""
+ person = db.query(Person).filter(
+ and_(Person.id == person_id, Person.business_id == business_id)
+ ).first()
+
+ if not person:
+ return False
+
+ db.delete(person)
+ db.commit()
+
+ return True
+
+
+def get_person_summary(db: Session, business_id: int) -> Dict[str, Any]:
+ """دریافت خلاصه اشخاص"""
+ # تعداد کل اشخاص
+ total_persons = db.query(Person).filter(Person.business_id == business_id).count()
+
+ # حذف مفهوم فعال/غیرفعال
+ active_persons = 0
+ inactive_persons = total_persons
+
+ # تعداد بر اساس نوع
+ by_type = {}
+ for person_type in PersonType:
+ count = db.query(Person).filter(
+ and_(Person.business_id == business_id, Person.person_type == person_type)
+ ).count()
+ by_type[person_type.value] = count
+
+ return {
+ 'total_persons': total_persons,
+ 'by_type': by_type,
+ 'active_persons': active_persons,
+ 'inactive_persons': inactive_persons
+ }
+
+
+def _person_to_dict(person: Person) -> Dict[str, Any]:
+ """تبدیل مدل Person به دیکشنری"""
+ # Parse person_types JSON to list
+ types_list: List[str] = []
+ if person.person_types:
+ try:
+ types = json.loads(person.person_types)
+ if isinstance(types, list):
+ types_list = [str(x) for x in types]
+ except Exception:
+ types_list = []
+
+ return {
+ 'id': person.id,
+ 'business_id': person.business_id,
+ 'code': person.code,
+ 'alias_name': person.alias_name,
+ 'first_name': person.first_name,
+ 'last_name': person.last_name,
+ 'person_type': person.person_type.value,
+ 'person_types': types_list,
+ 'company_name': person.company_name,
+ 'payment_id': person.payment_id,
+ 'share_count': person.share_count,
+ 'commission_sale_percent': float(person.commission_sale_percent) if getattr(person, 'commission_sale_percent', None) is not None else None,
+ 'commission_sales_return_percent': float(person.commission_sales_return_percent) if getattr(person, 'commission_sales_return_percent', None) is not None else None,
+ 'commission_sales_amount': float(person.commission_sales_amount) if getattr(person, 'commission_sales_amount', None) is not None else None,
+ 'commission_sales_return_amount': float(person.commission_sales_return_amount) if getattr(person, 'commission_sales_return_amount', None) is not None else None,
+ 'commission_exclude_discounts': bool(person.commission_exclude_discounts),
+ 'commission_exclude_additions_deductions': bool(person.commission_exclude_additions_deductions),
+ 'commission_post_in_invoice_document': bool(person.commission_post_in_invoice_document),
+ 'national_id': person.national_id,
+ 'registration_number': person.registration_number,
+ 'economic_id': person.economic_id,
+ 'country': person.country,
+ 'province': person.province,
+ 'city': person.city,
+ 'address': person.address,
+ 'postal_code': person.postal_code,
+ 'phone': person.phone,
+ 'mobile': person.mobile,
+ 'fax': person.fax,
+ 'email': person.email,
+ 'website': person.website,
+ 'created_at': person.created_at.isoformat(),
+ 'updated_at': person.updated_at.isoformat(),
+ 'bank_accounts': [
+ {
+ 'id': ba.id,
+ 'person_id': ba.person_id,
+ 'bank_name': ba.bank_name,
+ 'account_number': ba.account_number,
+ 'card_number': ba.card_number,
+ 'sheba_number': ba.sheba_number,
+ 'created_at': ba.created_at.isoformat(),
+ 'updated_at': ba.updated_at.isoformat(),
+ }
+ for ba in person.bank_accounts
+ ]
+ }
diff --git a/hesabixAPI/build/lib/app/services/price_list_service.py b/hesabixAPI/build/lib/app/services/price_list_service.py
new file mode 100644
index 0000000..4f19668
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/price_list_service.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+from typing import Dict, Any, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_
+
+from app.core.responses import ApiError
+from adapters.db.repositories.price_list_repository import PriceListRepository, PriceItemRepository
+from adapters.db.models.price_list import PriceList, PriceItem
+from adapters.api.v1.schema_models.price_list import PriceListCreateRequest, PriceListUpdateRequest, PriceItemUpsertRequest
+from adapters.db.models.product import Product
+
+
+def create_price_list(db: Session, business_id: int, payload: PriceListCreateRequest) -> Dict[str, Any]:
+ repo = PriceListRepository(db)
+ # یکتایی نام در هر کسبوکار
+ dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip())).first()
+ if dup:
+ raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400)
+ obj = repo.create(
+ business_id=business_id,
+ name=payload.name.strip(),
+ is_active=payload.is_active,
+ )
+ return {"message": "لیست قیمت ایجاد شد", "data": _pl_to_dict(obj)}
+
+
+def list_price_lists(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ repo = PriceListRepository(db)
+ take = int(query.get("take", 20) or 20)
+ skip = int(query.get("skip", 0) or 0)
+ sort_by = query.get("sort_by")
+ sort_desc = bool(query.get("sort_desc", True))
+ search = query.get("search")
+ return repo.search(business_id=business_id, take=take, skip=skip, sort_by=sort_by, sort_desc=sort_desc, search=search)
+
+
+def get_price_list(db: Session, business_id: int, id: int) -> Optional[Dict[str, Any]]:
+ obj = db.get(PriceList, id)
+ if not obj or obj.business_id != business_id:
+ return None
+ return _pl_to_dict(obj)
+
+
+def update_price_list(db: Session, business_id: int, id: int, payload: PriceListUpdateRequest) -> Optional[Dict[str, Any]]:
+ repo = PriceListRepository(db)
+ obj = db.get(PriceList, id)
+ if not obj or obj.business_id != business_id:
+ return None
+ if payload.name is not None and payload.name.strip() and payload.name.strip() != obj.name:
+ dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip(), PriceList.id != id)).first()
+ if dup:
+ raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400)
+ updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, is_active=payload.is_active)
+ if not updated:
+ return None
+ return {"message": "لیست قیمت بروزرسانی شد", "data": _pl_to_dict(updated)}
+
+
+def delete_price_list(db: Session, business_id: int, id: int) -> bool:
+ repo = PriceListRepository(db)
+ obj = db.get(PriceList, id)
+ if not obj or obj.business_id != business_id:
+ return False
+ return repo.delete(id)
+
+
+def list_price_items(db: Session, business_id: int, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> Dict[str, Any]:
+ # مالکیت را از روی price_list بررسی میکنیم
+ pl = db.get(PriceList, price_list_id)
+ if not pl or pl.business_id != business_id:
+ raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404)
+ repo = PriceItemRepository(db)
+ return repo.list_for_price_list(price_list_id=price_list_id, take=take, skip=skip, product_id=product_id, currency_id=currency_id)
+
+
+def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload: PriceItemUpsertRequest) -> Dict[str, Any]:
+ pl = db.get(PriceList, price_list_id)
+ if not pl or pl.business_id != business_id:
+ raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404)
+ # صحت وجود محصول
+ pr = db.get(Product, payload.product_id)
+ if not pr or pr.business_id != business_id:
+ raise ApiError("NOT_FOUND", "کالا/خدمت یافت نشد", http_status=404)
+ # اگر unit_id داده شده و با واحدهای محصول سازگار نباشد، خطا بده
+ if payload.unit_id is not None and payload.unit_id not in [pr.main_unit_id, pr.secondary_unit_id]:
+ raise ApiError("INVALID_UNIT", "واحد انتخابی با واحدهای محصول همخوانی ندارد", http_status=400)
+
+ repo = PriceItemRepository(db)
+ obj = repo.upsert(
+ price_list_id=price_list_id,
+ product_id=payload.product_id,
+ unit_id=payload.unit_id,
+ currency_id=payload.currency_id,
+ tier_name=(payload.tier_name.strip() if isinstance(payload.tier_name, str) and payload.tier_name.strip() else 'پیشفرض'),
+ min_qty=payload.min_qty,
+ price=payload.price,
+ )
+ return {"message": "قیمت ثبت شد", "data": _pi_to_dict(obj)}
+
+
+def delete_price_item(db: Session, business_id: int, id: int) -> bool:
+ repo = PriceItemRepository(db)
+ pi = db.get(PriceItem, id)
+ if not pi:
+ return False
+ # بررسی مالکیت از طریق price_list
+ pl = db.get(PriceList, pi.price_list_id)
+ if not pl or pl.business_id != business_id:
+ return False
+ return repo.delete(id)
+
+
+def _pl_to_dict(obj: PriceList) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "name": obj.name,
+ "is_active": obj.is_active,
+ "created_at": obj.created_at,
+ "updated_at": obj.updated_at,
+ }
+
+
+def _pi_to_dict(obj: PriceItem) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "price_list_id": obj.price_list_id,
+ "product_id": obj.product_id,
+ "unit_id": obj.unit_id,
+ "currency_id": obj.currency_id,
+ "tier_name": obj.tier_name,
+ "min_qty": obj.min_qty,
+ "price": obj.price,
+ "created_at": obj.created_at,
+ "updated_at": obj.updated_at,
+ }
+
+
diff --git a/hesabixAPI/build/lib/app/services/product_attribute_service.py b/hesabixAPI/build/lib/app/services/product_attribute_service.py
new file mode 100644
index 0000000..06104fd
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/product_attribute_service.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from typing import Dict, Any, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, func
+
+from adapters.db.repositories.product_attribute_repository import ProductAttributeRepository
+from adapters.db.models.product_attribute import ProductAttribute
+from adapters.api.v1.schema_models.product_attribute import (
+ ProductAttributeCreateRequest,
+ ProductAttributeUpdateRequest,
+)
+from app.core.responses import ApiError
+
+
+def create_attribute(db: Session, business_id: int, payload: ProductAttributeCreateRequest) -> Dict[str, Any]:
+ repo = ProductAttributeRepository(db)
+ # جلوگیری از عنوان تکراری در هر کسبوکار
+ dup = db.query(ProductAttribute).filter(
+ and_(ProductAttribute.business_id == business_id, func.lower(ProductAttribute.title) == func.lower(payload.title.strip()))
+ ).first()
+ if dup:
+ raise ApiError("DUPLICATE_ATTRIBUTE_TITLE", "عنوان ویژگی تکراری است", http_status=400)
+
+ obj = repo.create(business_id=business_id, title=payload.title.strip(), description=payload.description)
+ return {
+ "message": "ویژگی با موفقیت ایجاد شد",
+ "data": _to_dict(obj),
+ }
+
+
+def list_attributes(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ repo = ProductAttributeRepository(db)
+ take = int(query.get("take", 20) or 20)
+ skip = int(query.get("skip", 0) or 0)
+ sort_by = query.get("sort_by")
+ sort_desc = bool(query.get("sort_desc", True))
+ search = query.get("search")
+ filters = query.get("filters")
+ result = repo.search(
+ business_id=business_id,
+ take=take,
+ skip=skip,
+ sort_by=sort_by,
+ sort_desc=sort_desc,
+ search=search,
+ filters=filters,
+ )
+ return result
+
+
+def get_attribute(db: Session, attribute_id: int, business_id: int) -> Optional[Dict[str, Any]]:
+ obj = db.get(ProductAttribute, attribute_id)
+ if not obj or obj.business_id != business_id:
+ return None
+ return _to_dict(obj)
+
+
+def update_attribute(db: Session, attribute_id: int, business_id: int, payload: ProductAttributeUpdateRequest) -> Optional[Dict[str, Any]]:
+ repo = ProductAttributeRepository(db)
+ # کنترل مالکیت
+ obj = db.get(ProductAttribute, attribute_id)
+ if not obj or obj.business_id != business_id:
+ return None
+ # بررسی تکراری نبودن عنوان
+ if payload.title is not None:
+ title_norm = payload.title.strip()
+ dup = db.query(ProductAttribute).filter(
+ and_(
+ ProductAttribute.business_id == business_id,
+ func.lower(ProductAttribute.title) == func.lower(title_norm),
+ ProductAttribute.id != attribute_id,
+ )
+ ).first()
+ if dup:
+ raise ApiError("DUPLICATE_ATTRIBUTE_TITLE", "عنوان ویژگی تکراری است", http_status=400)
+ updated = repo.update(
+ attribute_id=attribute_id,
+ title=payload.title.strip() if isinstance(payload.title, str) else None,
+ description=payload.description,
+ )
+ if not updated:
+ return None
+ return {
+ "message": "ویژگی با موفقیت ویرایش شد",
+ "data": _to_dict(updated),
+ }
+
+
+def delete_attribute(db: Session, attribute_id: int, business_id: int) -> bool:
+ repo = ProductAttributeRepository(db)
+ obj = db.get(ProductAttribute, attribute_id)
+ if not obj or obj.business_id != business_id:
+ return False
+ return repo.delete(attribute_id=attribute_id)
+
+
+def _to_dict(obj: ProductAttribute) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "title": obj.title,
+ "description": obj.description,
+ "is_active": obj.is_active,
+ "created_at": obj.created_at,
+ "updated_at": obj.updated_at,
+ }
+
+
diff --git a/hesabixAPI/build/lib/app/services/product_service.py b/hesabixAPI/build/lib/app/services/product_service.py
new file mode 100644
index 0000000..71f7666
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/product_service.py
@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+from typing import Dict, Any, Optional, List
+from sqlalchemy.orm import Session
+from sqlalchemy import select, and_, func
+from decimal import Decimal
+
+from app.core.responses import ApiError
+from adapters.db.models.product import Product, ProductItemType
+from adapters.db.models.product_attribute import ProductAttribute
+from adapters.db.models.product_attribute_link import ProductAttributeLink
+from adapters.db.repositories.product_repository import ProductRepository
+from adapters.api.v1.schema_models.product import ProductCreateRequest, ProductUpdateRequest
+
+
+def _generate_auto_code(db: Session, business_id: int) -> str:
+ codes = [
+ r[0] for r in db.execute(
+ select(Product.code).where(Product.business_id == business_id)
+ ).all()
+ ]
+ max_num = 0
+ for c in codes:
+ if c and c.isdigit():
+ try:
+ max_num = max(max_num, int(c))
+ except ValueError:
+ continue
+ if max_num > 0:
+ return str(max_num + 1)
+ max_id = db.execute(select(func.max(Product.id))).scalar() or 0
+ return f"P{max_id + 1:06d}"
+
+
+def _validate_tax(payload: ProductCreateRequest | ProductUpdateRequest) -> None:
+ if getattr(payload, 'is_sales_taxable', False) and getattr(payload, 'sales_tax_rate', None) is None:
+ pass
+ if getattr(payload, 'is_purchase_taxable', False) and getattr(payload, 'purchase_tax_rate', None) is None:
+ pass
+
+
+def _validate_units(main_unit_id: Optional[int], secondary_unit_id: Optional[int], factor: Optional[Decimal]) -> None:
+ if secondary_unit_id and not factor:
+ raise ApiError("INVALID_UNIT_FACTOR", "برای واحد فرعی تعیین ضریب تبدیل الزامی است", http_status=400)
+
+
+def _upsert_attributes(db: Session, product_id: int, business_id: int, attribute_ids: Optional[List[int]]) -> None:
+ if attribute_ids is None:
+ return
+ db.query(ProductAttributeLink).filter(ProductAttributeLink.product_id == product_id).delete()
+ if not attribute_ids:
+ db.commit()
+ return
+ valid_ids = [
+ a.id for a in db.query(ProductAttribute.id, ProductAttribute.business_id)
+ .filter(ProductAttribute.id.in_(attribute_ids), ProductAttribute.business_id == business_id)
+ .all()
+ ]
+ for aid in valid_ids:
+ db.add(ProductAttributeLink(product_id=product_id, attribute_id=aid))
+ db.commit()
+
+
+def create_product(db: Session, business_id: int, payload: ProductCreateRequest) -> Dict[str, Any]:
+ repo = ProductRepository(db)
+ _validate_tax(payload)
+ _validate_units(payload.main_unit_id, payload.secondary_unit_id, payload.unit_conversion_factor)
+
+ code = payload.code.strip() if isinstance(payload.code, str) and payload.code.strip() else None
+ if code:
+ dup = db.query(Product).filter(and_(Product.business_id == business_id, Product.code == code)).first()
+ if dup:
+ raise ApiError("DUPLICATE_PRODUCT_CODE", "کد کالا/خدمت تکراری است", http_status=400)
+ else:
+ code = _generate_auto_code(db, business_id)
+
+ obj = repo.create(
+ business_id=business_id,
+ item_type=payload.item_type,
+ code=code,
+ name=payload.name.strip(),
+ description=payload.description,
+ category_id=payload.category_id,
+ main_unit_id=payload.main_unit_id,
+ secondary_unit_id=payload.secondary_unit_id,
+ unit_conversion_factor=payload.unit_conversion_factor,
+ base_sales_price=payload.base_sales_price,
+ base_sales_note=payload.base_sales_note,
+ base_purchase_price=payload.base_purchase_price,
+ base_purchase_note=payload.base_purchase_note,
+ track_inventory=payload.track_inventory,
+ reorder_point=payload.reorder_point,
+ min_order_qty=payload.min_order_qty,
+ lead_time_days=payload.lead_time_days,
+ is_sales_taxable=payload.is_sales_taxable,
+ is_purchase_taxable=payload.is_purchase_taxable,
+ sales_tax_rate=payload.sales_tax_rate,
+ purchase_tax_rate=payload.purchase_tax_rate,
+ tax_type_id=payload.tax_type_id,
+ tax_code=payload.tax_code,
+ tax_unit_id=payload.tax_unit_id,
+ )
+
+ _upsert_attributes(db, obj.id, business_id, payload.attribute_ids)
+
+ return {"message": "آیتم با موفقیت ایجاد شد", "data": _to_dict(obj)}
+
+
+def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ repo = ProductRepository(db)
+ take = int(query.get("take", 20) or 20)
+ skip = int(query.get("skip", 0) or 0)
+ sort_by = query.get("sort_by")
+ sort_desc = bool(query.get("sort_desc", True))
+ search = query.get("search")
+ filters = query.get("filters")
+ return repo.search(
+ business_id=business_id,
+ take=take,
+ skip=skip,
+ sort_by=sort_by,
+ sort_desc=sort_desc,
+ search=search,
+ filters=filters,
+ )
+
+
+def get_product(db: Session, product_id: int, business_id: int) -> Optional[Dict[str, Any]]:
+ obj = db.get(Product, product_id)
+ if not obj or obj.business_id != business_id:
+ return None
+ return _to_dict(obj)
+
+
+def update_product(db: Session, product_id: int, business_id: int, payload: ProductUpdateRequest) -> Optional[Dict[str, Any]]:
+ repo = ProductRepository(db)
+ obj = db.get(Product, product_id)
+ if not obj or obj.business_id != business_id:
+ return None
+
+ if payload.code is not None and payload.code.strip() and payload.code.strip() != obj.code:
+ dup = db.query(Product).filter(and_(Product.business_id == business_id, Product.code == payload.code.strip(), Product.id != product_id)).first()
+ if dup:
+ raise ApiError("DUPLICATE_PRODUCT_CODE", "کد کالا/خدمت تکراری است", http_status=400)
+
+ _validate_tax(payload)
+ _validate_units(payload.main_unit_id if payload.main_unit_id is not None else obj.main_unit_id,
+ payload.secondary_unit_id if payload.secondary_unit_id is not None else obj.secondary_unit_id,
+ payload.unit_conversion_factor if payload.unit_conversion_factor is not None else obj.unit_conversion_factor)
+
+ updated = repo.update(
+ product_id,
+ item_type=payload.item_type,
+ code=payload.code.strip() if isinstance(payload.code, str) else None,
+ name=payload.name.strip() if isinstance(payload.name, str) else None,
+ description=payload.description,
+ category_id=payload.category_id,
+ main_unit_id=payload.main_unit_id,
+ secondary_unit_id=payload.secondary_unit_id,
+ unit_conversion_factor=payload.unit_conversion_factor,
+ base_sales_price=payload.base_sales_price,
+ base_sales_note=payload.base_sales_note,
+ base_purchase_price=payload.base_purchase_price,
+ base_purchase_note=payload.base_purchase_note,
+ track_inventory=payload.track_inventory if payload.track_inventory is not None else None,
+ reorder_point=payload.reorder_point,
+ min_order_qty=payload.min_order_qty,
+ lead_time_days=payload.lead_time_days,
+ is_sales_taxable=payload.is_sales_taxable,
+ is_purchase_taxable=payload.is_purchase_taxable,
+ sales_tax_rate=payload.sales_tax_rate,
+ purchase_tax_rate=payload.purchase_tax_rate,
+ tax_type_id=payload.tax_type_id,
+ tax_code=payload.tax_code,
+ tax_unit_id=payload.tax_unit_id,
+ )
+ if not updated:
+ return None
+
+ _upsert_attributes(db, product_id, business_id, payload.attribute_ids)
+ return {"message": "آیتم با موفقیت ویرایش شد", "data": _to_dict(updated)}
+
+
+def delete_product(db: Session, product_id: int, business_id: int) -> bool:
+ repo = ProductRepository(db)
+ obj = db.get(Product, product_id)
+ if not obj or obj.business_id != business_id:
+ return False
+ return repo.delete(product_id)
+
+
+def _to_dict(obj: Product) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "item_type": obj.item_type.value if hasattr(obj.item_type, 'value') else str(obj.item_type),
+ "code": obj.code,
+ "name": obj.name,
+ "description": obj.description,
+ "category_id": obj.category_id,
+ "main_unit_id": obj.main_unit_id,
+ "secondary_unit_id": obj.secondary_unit_id,
+ "unit_conversion_factor": obj.unit_conversion_factor,
+ "base_sales_price": obj.base_sales_price,
+ "base_sales_note": obj.base_sales_note,
+ "base_purchase_price": obj.base_purchase_price,
+ "base_purchase_note": obj.base_purchase_note,
+ "track_inventory": obj.track_inventory,
+ "reorder_point": obj.reorder_point,
+ "min_order_qty": obj.min_order_qty,
+ "lead_time_days": obj.lead_time_days,
+ "is_sales_taxable": obj.is_sales_taxable,
+ "is_purchase_taxable": obj.is_purchase_taxable,
+ "sales_tax_rate": obj.sales_tax_rate,
+ "purchase_tax_rate": obj.purchase_tax_rate,
+ "tax_type_id": obj.tax_type_id,
+ "tax_code": obj.tax_code,
+ "tax_unit_id": obj.tax_unit_id,
+ "created_at": obj.created_at,
+ "updated_at": obj.updated_at,
+ }
+
+
diff --git a/hesabixAPI/build/lib/app/services/query_service.py b/hesabixAPI/build/lib/app/services/query_service.py
new file mode 100644
index 0000000..59c4bb2
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/query_service.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+from typing import Any, Type, TypeVar
+from sqlalchemy import select, func, or_, and_
+from sqlalchemy.orm import Session
+from sqlalchemy.sql import Select
+
+from adapters.api.v1.schemas import QueryInfo, FilterItem
+
+T = TypeVar('T')
+
+
+class QueryBuilder:
+ """سرویس برای ساخت کوئریهای دینامیک بر اساس QueryInfo"""
+
+ def __init__(self, model_class: Type[T], db_session: Session) -> None:
+ self.model_class = model_class
+ self.db = db_session
+ self.stmt: Select = select(model_class)
+
+ def apply_filters(self, filters: list[FilterItem] | None) -> 'QueryBuilder':
+ """اعمال فیلترها بر روی کوئری"""
+ if not filters:
+ return self
+
+ conditions = []
+ for filter_item in filters:
+ try:
+ column = getattr(self.model_class, filter_item.property)
+ condition = self._build_condition(column, filter_item.operator, filter_item.value)
+ conditions.append(condition)
+ except AttributeError:
+ # اگر فیلد وجود نداشته باشد، آن را نادیده بگیر
+ continue
+
+ if conditions:
+ self.stmt = self.stmt.where(and_(*conditions))
+
+ return self
+
+ def apply_search(self, search: str | None, search_fields: list[str] | None) -> 'QueryBuilder':
+ """اعمال جستجو بر روی فیلدهای مشخص شده"""
+ if not search or not search_fields:
+ return self
+
+ conditions = []
+ for field in search_fields:
+ try:
+ column = getattr(self.model_class, field)
+ conditions.append(column.ilike(f"%{search}%"))
+ except AttributeError:
+ # اگر فیلد وجود نداشته باشد، آن را نادیده بگیر
+ continue
+
+ if conditions:
+ self.stmt = self.stmt.where(or_(*conditions))
+
+ return self
+
+ def apply_sorting(self, sort_by: str | None, sort_desc: bool) -> 'QueryBuilder':
+ """اعمال مرتبسازی بر روی کوئری"""
+ if not sort_by:
+ return self
+
+ try:
+ column = getattr(self.model_class, sort_by)
+ if sort_desc:
+ self.stmt = self.stmt.order_by(column.desc())
+ else:
+ self.stmt = self.stmt.order_by(column.asc())
+ except AttributeError:
+ # اگر فیلد وجود نداشته باشد، مرتبسازی را نادیده بگیر
+ pass
+
+ return self
+
+ def apply_pagination(self, skip: int, take: int) -> 'QueryBuilder':
+ """اعمال صفحهبندی بر روی کوئری"""
+ self.stmt = self.stmt.offset(skip).limit(take)
+ return self
+
+ def apply_query_info(self, query_info: QueryInfo) -> 'QueryBuilder':
+ """اعمال تمام تنظیمات QueryInfo بر روی کوئری"""
+ return (self
+ .apply_filters(query_info.filters)
+ .apply_search(query_info.search, query_info.search_fields)
+ .apply_sorting(query_info.sort_by, query_info.sort_desc)
+ .apply_pagination(query_info.skip, query_info.take))
+
+ def _build_condition(self, column, operator: str, value: Any):
+ """ساخت شرط بر اساس عملگر و مقدار"""
+ if operator == "=":
+ return column == value
+ elif operator == ">":
+ return column > value
+ elif operator == ">=":
+ return column >= value
+ elif operator == "<":
+ return column < value
+ elif operator == "<=":
+ return column <= value
+ elif operator == "!=":
+ return column != value
+ elif operator == "*": # contains
+ return column.ilike(f"%{value}%")
+ elif operator == "?*": # ends with
+ return column.ilike(f"%{value}")
+ elif operator == "*?": # starts with
+ return column.ilike(f"{value}%")
+ elif operator == "in":
+ if not isinstance(value, list):
+ raise ValueError("برای عملگر 'in' مقدار باید آرایه باشد")
+ return column.in_(value)
+ else:
+ raise ValueError(f"عملگر پشتیبانی نشده: {operator}")
+
+ def get_count_query(self) -> Select:
+ """دریافت کوئری شمارش (بدون pagination)"""
+ return select(func.count()).select_from(self.stmt.subquery())
+
+ def execute(self) -> list[T]:
+ """اجرای کوئری و بازگرداندن نتایج"""
+ return list(self.db.execute(self.stmt).scalars().all())
+
+ def execute_count(self) -> int:
+ """اجرای کوئری شمارش"""
+ count_stmt = self.get_count_query()
+ return int(self.db.execute(count_stmt).scalar() or 0)
+
+
+class QueryService:
+ """سرویس اصلی برای مدیریت کوئریهای فیلتر شده"""
+
+ @staticmethod
+ def query_with_filters(
+ model_class: Type[T],
+ db: Session,
+ query_info: QueryInfo
+ ) -> tuple[list[T], int]:
+ """
+ اجرای کوئری با فیلتر و بازگرداندن نتایج و تعداد کل
+
+ Args:
+ model_class: کلاس مدل SQLAlchemy
+ db: جلسه پایگاه داده
+ query_info: اطلاعات کوئری شامل فیلترها، مرتبسازی و صفحهبندی
+
+ Returns:
+ tuple: (لیست نتایج, تعداد کل رکوردها)
+ """
+ # کوئری شمارش (بدون pagination)
+ count_builder = QueryBuilder(model_class, db)
+ count_builder.apply_filters(query_info.filters)
+ count_builder.apply_search(query_info.search, query_info.search_fields)
+ total_count = count_builder.execute_count()
+
+ # کوئری دادهها (با pagination)
+ data_builder = QueryBuilder(model_class, db)
+ data_builder.apply_query_info(query_info)
+ results = data_builder.execute()
+
+ return results, total_count
diff --git a/hesabixAPI/build/lib/migrations/env.py b/hesabixAPI/build/lib/migrations/env.py
new file mode 100644
index 0000000..ff4638e
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/env.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config, pool
+from alembic import context
+
+from adapters.db.session import Base
+from app.core.settings import get_settings
+import adapters.db.models # noqa: F401 # Import models to register metadata
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+
+settings = get_settings()
+config.set_main_option("sqlalchemy.url", settings.mysql_dsn)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ compare_type=True,
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ # Ensure alembic_version.version_num can hold long revision strings
+ try:
+ res = connection.exec_driver_sql(
+ "SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.columns "
+ "WHERE table_name='alembic_version' AND column_name='version_num';"
+ )
+ row = res.fetchone()
+ if row is not None:
+ length = row[0] or 0
+ if length < 255:
+ connection.exec_driver_sql(
+ "ALTER TABLE alembic_version MODIFY COLUMN version_num VARCHAR(255) NOT NULL;"
+ )
+ except Exception:
+ # Best-effort; ignore if table doesn't exist yet
+ pass
+ context.configure(
+ connection=connection,
+ target_metadata=target_metadata,
+ compare_type=True,
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000003_add_business_table.py b/hesabixAPI/build/lib/migrations/versions/20250117_000003_add_business_table.py
new file mode 100644
index 0000000..739c141
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250117_000003_add_business_table.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = "20250117_000003"
+down_revision = "20250916_000002"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+
+ # Create businesses table if not exists
+ if 'businesses' not in inspector.get_table_names():
+ op.create_table(
+ 'businesses',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('business_type', mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', name='businesstype'), nullable=False),
+ sa.Column('business_field', mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', name='businessfield'), nullable=False),
+ sa.Column('owner_id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Create indexes if not exists
+ existing_indexes = {idx['name'] for idx in inspector.get_indexes('businesses')} if 'businesses' in inspector.get_table_names() else set()
+ if 'ix_businesses_name' not in existing_indexes:
+ op.create_index('ix_businesses_name', 'businesses', ['name'])
+ if 'ix_businesses_owner_id' not in existing_indexes:
+ op.create_index('ix_businesses_owner_id', 'businesses', ['owner_id'])
+
+
+def downgrade() -> None:
+ # Drop indexes
+ op.drop_index('ix_businesses_owner_id', table_name='businesses')
+ op.drop_index('ix_businesses_name', table_name='businesses')
+
+ # Drop table
+ op.drop_table('businesses')
diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000004_add_business_contact_fields.py b/hesabixAPI/build/lib/migrations/versions/20250117_000004_add_business_contact_fields.py
new file mode 100644
index 0000000..89862be
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250117_000004_add_business_contact_fields.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = "20250117_000004"
+down_revision = "20250117_000003"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Add new contact and identification fields to businesses table
+ op.add_column('businesses', sa.Column('address', sa.Text(), nullable=True))
+ op.add_column('businesses', sa.Column('phone', sa.String(length=20), nullable=True))
+ op.add_column('businesses', sa.Column('mobile', sa.String(length=20), nullable=True))
+ op.add_column('businesses', sa.Column('national_id', sa.String(length=20), nullable=True))
+ op.add_column('businesses', sa.Column('registration_number', sa.String(length=50), nullable=True))
+ op.add_column('businesses', sa.Column('economic_id', sa.String(length=50), nullable=True))
+
+ # Create indexes for the new fields
+ op.create_index('ix_businesses_national_id', 'businesses', ['national_id'])
+ op.create_index('ix_businesses_registration_number', 'businesses', ['registration_number'])
+ op.create_index('ix_businesses_economic_id', 'businesses', ['economic_id'])
+
+
+def downgrade() -> None:
+ # Drop indexes
+ op.drop_index('ix_businesses_economic_id', table_name='businesses')
+ op.drop_index('ix_businesses_registration_number', table_name='businesses')
+ op.drop_index('ix_businesses_national_id', table_name='businesses')
+
+ # Drop columns
+ op.drop_column('businesses', 'economic_id')
+ op.drop_column('businesses', 'registration_number')
+ op.drop_column('businesses', 'national_id')
+ op.drop_column('businesses', 'mobile')
+ op.drop_column('businesses', 'phone')
+ op.drop_column('businesses', 'address')
diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000005_add_business_geographic_fields.py b/hesabixAPI/build/lib/migrations/versions/20250117_000005_add_business_geographic_fields.py
new file mode 100644
index 0000000..d9bdafc
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250117_000005_add_business_geographic_fields.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = "20250117_000005"
+down_revision = "20250117_000004"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Add geographic fields to businesses table
+ op.add_column('businesses', sa.Column('country', sa.String(length=100), nullable=True))
+ op.add_column('businesses', sa.Column('province', sa.String(length=100), nullable=True))
+ op.add_column('businesses', sa.Column('city', sa.String(length=100), nullable=True))
+ op.add_column('businesses', sa.Column('postal_code', sa.String(length=20), nullable=True))
+
+
+def downgrade() -> None:
+ # Drop geographic columns
+ op.drop_column('businesses', 'postal_code')
+ op.drop_column('businesses', 'city')
+ op.drop_column('businesses', 'province')
+ op.drop_column('businesses', 'country')
diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000006_add_app_permissions_to_users.py b/hesabixAPI/build/lib/migrations/versions/20250117_000006_add_app_permissions_to_users.py
new file mode 100644
index 0000000..8ea4e9d
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250117_000006_add_app_permissions_to_users.py
@@ -0,0 +1,28 @@
+"""add app permissions to users
+
+Revision ID: 20250117_000006
+Revises: 20250117_000005
+Create Date: 2025-01-17 00:00:06.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '20250117_000006'
+down_revision = '20250117_000005'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('users', sa.Column('app_permissions', sa.JSON(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('users', 'app_permissions')
+ # ### end Alembic commands ###
diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000007_create_business_permissions_table.py b/hesabixAPI/build/lib/migrations/versions/20250117_000007_create_business_permissions_table.py
new file mode 100644
index 0000000..00a06d9
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250117_000007_create_business_permissions_table.py
@@ -0,0 +1,42 @@
+"""create business permissions table
+
+Revision ID: 20250117_000007
+Revises: 20250117_000006
+Create Date: 2025-01-17 00:00:07.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '20250117_000007'
+down_revision = '20250117_000006'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('business_permissions',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('business_permissions', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_business_permissions_business_id'), 'business_permissions', ['business_id'], unique=False)
+ op.create_index(op.f('ix_business_permissions_user_id'), 'business_permissions', ['user_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_business_permissions_user_id'), table_name='business_permissions')
+ op.drop_index(op.f('ix_business_permissions_business_id'), table_name='business_permissions')
+ op.drop_table('business_permissions')
+ # ### end Alembic commands ###
diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000008_add_email_config_table.py b/hesabixAPI/build/lib/migrations/versions/20250117_000008_add_email_config_table.py
new file mode 100644
index 0000000..e04e8a8
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250117_000008_add_email_config_table.py
@@ -0,0 +1,45 @@
+"""add_email_config_table
+
+Revision ID: 20250117_000008
+Revises: 5553f8745c6e
+Create Date: 2025-01-17 12:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250117_000008'
+down_revision = '5553f8745c6e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('email_configs',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('smtp_host', sa.String(length=255), nullable=False),
+ sa.Column('smtp_port', sa.Integer(), nullable=False),
+ sa.Column('smtp_username', sa.String(length=255), nullable=False),
+ sa.Column('smtp_password', sa.String(length=255), nullable=False),
+ sa.Column('use_tls', sa.Boolean(), nullable=False),
+ sa.Column('use_ssl', sa.Boolean(), nullable=False),
+ sa.Column('from_email', sa.String(length=255), nullable=False),
+ sa.Column('from_name', sa.String(length=100), nullable=False),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_email_configs_name'), 'email_configs', ['name'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_email_configs_name'), table_name='email_configs')
+ op.drop_table('email_configs')
+ # ### end Alembic commands ###
diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000009_add_is_default_to_email_config.py b/hesabixAPI/build/lib/migrations/versions/20250117_000009_add_is_default_to_email_config.py
new file mode 100644
index 0000000..c604f48
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250117_000009_add_is_default_to_email_config.py
@@ -0,0 +1,26 @@
+"""add is_default to email_config
+
+Revision ID: 20250117_000009
+Revises: 20250117_000008
+Create Date: 2025-01-17 12:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250117_000009'
+down_revision = '20250117_000008'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Add is_default column to email_configs table
+ op.add_column('email_configs', sa.Column('is_default', sa.Boolean(), nullable=False, server_default='0'))
+
+
+def downgrade():
+ # Remove is_default column from email_configs table
+ op.drop_column('email_configs', 'is_default')
diff --git a/hesabixAPI/build/lib/migrations/versions/20250120_000001_add_persons_tables.py b/hesabixAPI/build/lib/migrations/versions/20250120_000001_add_persons_tables.py
new file mode 100644
index 0000000..9b0e1e8
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250120_000001_add_persons_tables.py
@@ -0,0 +1,82 @@
+"""add_persons_tables
+
+Revision ID: 20250120_000001
+Revises: 5553f8745c6e
+Create Date: 2025-01-20 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = '20250120_000001'
+down_revision = '5553f8745c6e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+
+ # Create persons table
+ op.create_table('persons',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب و کار'),
+ sa.Column('alias_name', sa.String(length=255), nullable=False, comment='نام مستعار (الزامی)'),
+ sa.Column('first_name', sa.String(length=100), nullable=True, comment='نام'),
+ sa.Column('last_name', sa.String(length=100), nullable=True, comment='نام خانوادگی'),
+ sa.Column('person_type', sa.Enum('CUSTOMER', 'MARKETER', 'EMPLOYEE', 'SUPPLIER', 'PARTNER', 'SELLER', name='persontype'), nullable=False, comment='نوع شخص'),
+ sa.Column('company_name', sa.String(length=255), nullable=True, comment='نام شرکت'),
+ sa.Column('payment_id', sa.String(length=100), nullable=True, comment='شناسه پرداخت'),
+ sa.Column('national_id', sa.String(length=20), nullable=True, comment='شناسه ملی'),
+ sa.Column('registration_number', sa.String(length=50), nullable=True, comment='شماره ثبت'),
+ sa.Column('economic_id', sa.String(length=50), nullable=True, comment='شناسه اقتصادی'),
+ sa.Column('country', sa.String(length=100), nullable=True, comment='کشور'),
+ sa.Column('province', sa.String(length=100), nullable=True, comment='استان'),
+ sa.Column('city', sa.String(length=100), nullable=True, comment='شهرستان'),
+ sa.Column('address', sa.Text(), nullable=True, comment='آدرس'),
+ sa.Column('postal_code', sa.String(length=20), nullable=True, comment='کد پستی'),
+ sa.Column('phone', sa.String(length=20), nullable=True, comment='تلفن'),
+ sa.Column('mobile', sa.String(length=20), nullable=True, comment='موبایل'),
+ sa.Column('fax', sa.String(length=20), nullable=True, comment='فکس'),
+ sa.Column('email', sa.String(length=255), nullable=True, comment='پست الکترونیکی'),
+ sa.Column('website', sa.String(length=255), nullable=True, comment='وبسایت'),
+ sa.Column('is_active', sa.Boolean(), nullable=False, comment='وضعیت فعال بودن'),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_persons_business_id'), 'persons', ['business_id'], unique=False)
+ op.create_index(op.f('ix_persons_alias_name'), 'persons', ['alias_name'], unique=False)
+ op.create_index(op.f('ix_persons_national_id'), 'persons', ['national_id'], unique=False)
+
+ # Create person_bank_accounts table
+ op.create_table('person_bank_accounts',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('person_id', sa.Integer(), nullable=False, comment='شناسه شخص'),
+ sa.Column('bank_name', sa.String(length=255), nullable=False, comment='نام بانک'),
+ sa.Column('account_number', sa.String(length=50), nullable=True, comment='شماره حساب'),
+ sa.Column('card_number', sa.String(length=20), nullable=True, comment='شماره کارت'),
+ sa.Column('sheba_number', sa.String(length=30), nullable=True, comment='شماره شبا'),
+ sa.Column('is_active', sa.Boolean(), nullable=False, comment='وضعیت فعال بودن'),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['person_id'], ['persons.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_person_bank_accounts_person_id'), 'person_bank_accounts', ['person_id'], unique=False)
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_person_bank_accounts_person_id'), table_name='person_bank_accounts')
+ op.drop_table('person_bank_accounts')
+ op.drop_index(op.f('ix_persons_national_id'), table_name='persons')
+ op.drop_index(op.f('ix_persons_alias_name'), table_name='persons')
+ op.drop_index(op.f('ix_persons_business_id'), table_name='persons')
+ op.drop_table('persons')
+ # ### end Alembic commands ###
diff --git a/hesabixAPI/build/lib/migrations/versions/20250120_000002_add_join_permission.py b/hesabixAPI/build/lib/migrations/versions/20250120_000002_add_join_permission.py
new file mode 100644
index 0000000..9d75a0e
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250120_000002_add_join_permission.py
@@ -0,0 +1,30 @@
+"""add join permission
+
+Revision ID: 20250120_000002
+Revises: 20250120_000001
+Create Date: 2025-01-20 00:00:02.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250120_000002'
+down_revision = '20250120_000001'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ """Add join permission support"""
+ # این migration فقط برای مستندسازی است
+ # جدول business_permissions قبلاً وجود دارد و JSON field است
+ # بنابراین نیازی به تغییر schema نیست
+ pass
+
+
+def downgrade():
+ """Remove join permission support"""
+ # این migration فقط برای مستندسازی است
+ pass
diff --git a/hesabixAPI/build/lib/migrations/versions/20250915_000001_init_auth_tables.py b/hesabixAPI/build/lib/migrations/versions/20250915_000001_init_auth_tables.py
new file mode 100644
index 0000000..63f13dc
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250915_000001_init_auth_tables.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+from datetime import datetime
+
+# revision identifiers, used by Alembic.
+revision = "20250915_000001"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "users",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("email", sa.String(length=255), nullable=True),
+ sa.Column("mobile", sa.String(length=32), nullable=True),
+ sa.Column("first_name", sa.String(length=100), nullable=True),
+ sa.Column("last_name", sa.String(length=100), nullable=True),
+ sa.Column("password_hash", sa.String(length=255), nullable=False),
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
+ sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")),
+ )
+ op.create_index("ix_users_email", "users", ["email"], unique=True)
+ op.create_index("ix_users_mobile", "users", ["mobile"], unique=True)
+
+ op.create_table(
+ "api_keys",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("key_hash", sa.String(length=128), nullable=False),
+ sa.Column("key_type", sa.String(length=16), nullable=False),
+ sa.Column("name", sa.String(length=100), nullable=True),
+ sa.Column("scopes", sa.String(length=500), nullable=True),
+ sa.Column("device_id", sa.String(length=100), nullable=True),
+ sa.Column("user_agent", sa.String(length=255), nullable=True),
+ sa.Column("ip", sa.String(length=64), nullable=True),
+ sa.Column("expires_at", sa.DateTime(), nullable=True),
+ sa.Column("last_used_at", sa.DateTime(), nullable=True),
+ sa.Column("revoked_at", sa.DateTime(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ )
+ op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
+ op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"], unique=False)
+
+ op.create_table(
+ "captchas",
+ sa.Column("id", sa.String(length=40), primary_key=True),
+ sa.Column("code_hash", sa.String(length=128), nullable=False),
+ sa.Column("expires_at", sa.DateTime(), nullable=False),
+ sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ )
+
+ op.create_table(
+ "password_resets",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("token_hash", sa.String(length=128), nullable=False),
+ sa.Column("expires_at", sa.DateTime(), nullable=False),
+ sa.Column("used_at", sa.DateTime(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ )
+ op.create_index("ix_password_resets_token_hash", "password_resets", ["token_hash"], unique=True)
+ op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_password_resets_user_id", table_name="password_resets")
+ op.drop_index("ix_password_resets_token_hash", table_name="password_resets")
+ op.drop_table("password_resets")
+
+ op.drop_table("captchas")
+
+ op.drop_index("ix_api_keys_user_id", table_name="api_keys")
+ op.drop_index("ix_api_keys_key_hash", table_name="api_keys")
+ op.drop_table("api_keys")
+
+ op.drop_index("ix_users_mobile", table_name="users")
+ op.drop_index("ix_users_email", table_name="users")
+ op.drop_table("users")
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250916_000002_add_referral_fields.py b/hesabixAPI/build/lib/migrations/versions/20250916_000002_add_referral_fields.py
new file mode 100644
index 0000000..51f5b5b
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250916_000002_add_referral_fields.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.sql import table, column
+from sqlalchemy import String, Integer
+
+# revision identifiers, used by Alembic.
+revision = "20250916_000002"
+down_revision = "20250915_000001"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Add columns (referral_code nullable for backfill, then set NOT NULL)
+ op.add_column("users", sa.Column("referral_code", sa.String(length=32), nullable=True))
+ op.add_column("users", sa.Column("referred_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True))
+
+ # Backfill referral_code for existing users with unique random strings
+ bind = op.get_bind()
+ users_tbl = sa.table("users", sa.column("id", sa.Integer), sa.column("referral_code", sa.String))
+
+ # Fetch all user ids
+ res = bind.execute(sa.text("SELECT id FROM users"))
+ user_ids = [row[0] for row in res] if res else []
+
+ # Helper to generate unique codes
+ import secrets
+ def gen_code(length: int = 10) -> str:
+ return secrets.token_urlsafe(8).replace('-', '').replace('_', '')[:length]
+
+ # Ensure uniqueness at DB level by checking existing set
+ codes = set()
+ for uid in user_ids:
+ code = gen_code()
+ # try to avoid duplicates within the batch
+ while code in codes:
+ code = gen_code()
+ codes.add(code)
+ bind.execute(sa.text("UPDATE users SET referral_code = :code WHERE id = :id"), {"code": code, "id": uid})
+
+ # Now make referral_code NOT NULL and unique indexed
+ op.alter_column("users", "referral_code", existing_type=sa.String(length=32), nullable=False)
+ op.create_index("ix_users_referral_code", "users", ["referral_code"], unique=True)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_users_referral_code", table_name="users")
+ op.drop_column("users", "referred_by_user_id")
+ op.drop_column("users", "referral_code")
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250926_000010_add_person_code_and_types.py b/hesabixAPI/build/lib/migrations/versions/20250926_000010_add_person_code_and_types.py
new file mode 100644
index 0000000..9420d8d
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250926_000010_add_person_code_and_types.py
@@ -0,0 +1,47 @@
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = '20250926_000010_add_person_code_and_types'
+down_revision = '20250916_000002'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ # اگر جدول persons وجود ندارد، این مایگریشن را نادیده بگیر
+ if 'persons' not in inspector.get_table_names():
+ return
+ cols = {c['name'] for c in inspector.get_columns('persons')}
+ with op.batch_alter_table('persons') as batch_op:
+ if 'code' not in cols:
+ batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True))
+ if 'person_types' not in cols:
+ batch_op.add_column(sa.Column('person_types', sa.Text(), nullable=True))
+ # unique constraint if not exists
+ existing_uniques = {uc['name'] for uc in inspector.get_unique_constraints('persons')}
+ if 'uq_persons_business_code' not in existing_uniques:
+ batch_op.create_unique_constraint('uq_persons_business_code', ['business_id', 'code'])
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ if 'persons' not in inspector.get_table_names():
+ return
+ with op.batch_alter_table('persons') as batch_op:
+ try:
+ batch_op.drop_constraint('uq_persons_business_code', type_='unique')
+ except Exception:
+ pass
+ try:
+ batch_op.drop_column('person_types')
+ except Exception:
+ pass
+ try:
+ batch_op.drop_column('code')
+ except Exception:
+ pass
diff --git a/hesabixAPI/build/lib/migrations/versions/20250926_000011_drop_person_is_active.py b/hesabixAPI/build/lib/migrations/versions/20250926_000011_drop_person_is_active.py
new file mode 100644
index 0000000..1d8c3e1
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250926_000011_drop_person_is_active.py
@@ -0,0 +1,39 @@
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = '20250926_000011_drop_person_is_active'
+down_revision = '20250926_000010_add_person_code_and_types'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ tables = set(inspector.get_table_names())
+ if 'persons' in tables:
+ with op.batch_alter_table('persons') as batch_op:
+ try:
+ batch_op.drop_column('is_active')
+ except Exception:
+ pass
+ if 'person_bank_accounts' in tables:
+ with op.batch_alter_table('person_bank_accounts') as batch_op:
+ try:
+ batch_op.drop_column('is_active')
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ tables = set(inspector.get_table_names())
+ if 'persons' in tables:
+ with op.batch_alter_table('persons') as batch_op:
+ batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))
+ if 'person_bank_accounts' in tables:
+ with op.batch_alter_table('person_bank_accounts') as batch_op:
+ batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000012_add_fiscal_years_table.py b/hesabixAPI/build/lib/migrations/versions/20250927_000012_add_fiscal_years_table.py
new file mode 100644
index 0000000..baa60a2
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000012_add_fiscal_years_table.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000012_add_fiscal_years_table'
+down_revision = '20250926_000011_drop_person_is_active'
+branch_labels = None
+depends_on = ('20250117_000003',)
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+
+ # Create fiscal_years table if not exists
+ if 'fiscal_years' not in inspector.get_table_names():
+ op.create_table(
+ 'fiscal_years',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('title', sa.String(length=255), nullable=False),
+ sa.Column('start_date', sa.Date(), nullable=False),
+ sa.Column('end_date', sa.Date(), nullable=False),
+ sa.Column('is_last', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Indexes if not exists
+ existing_indexes = {idx['name'] for idx in inspector.get_indexes('fiscal_years')} if 'fiscal_years' in inspector.get_table_names() else set()
+ if 'ix_fiscal_years_business_id' not in existing_indexes:
+ op.create_index('ix_fiscal_years_business_id', 'fiscal_years', ['business_id'])
+ if 'ix_fiscal_years_title' not in existing_indexes:
+ op.create_index('ix_fiscal_years_title', 'fiscal_years', ['title'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_fiscal_years_title', table_name='fiscal_years')
+ op.drop_index('ix_fiscal_years_business_id', table_name='fiscal_years')
+ op.drop_table('fiscal_years')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py b/hesabixAPI/build/lib/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py
new file mode 100644
index 0000000..934e96d
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000013_add_currencies_and_business_currencies'
+down_revision = '20250927_000012_add_fiscal_years_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create currencies table
+ op.create_table(
+ 'currencies',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('title', sa.String(length=100), nullable=False),
+ sa.Column('symbol', sa.String(length=16), nullable=False),
+ sa.Column('code', sa.String(length=16), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+ # Unique constraints and indexes
+ op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
+ op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
+ op.create_index('ix_currencies_name', 'currencies', ['name'])
+
+ # Create business_currencies association table
+ op.create_table(
+ 'business_currencies',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('currency_id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ # Add default_currency_id to businesses if not exists
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ if 'businesses' in inspector.get_table_names():
+ cols = {c['name'] for c in inspector.get_columns('businesses')}
+ if 'default_currency_id' not in cols:
+ with op.batch_alter_table('businesses') as batch_op:
+ batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
+ batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
+ batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
+ # Unique and indexes for association
+ op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
+ op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
+ op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
+
+
+def downgrade() -> None:
+ # Drop index/foreign key/column default_currency_id if exists
+ with op.batch_alter_table('businesses') as batch_op:
+ try:
+ batch_op.drop_index('ix_businesses_default_currency_id')
+ except Exception:
+ pass
+ try:
+ batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
+ except Exception:
+ pass
+ try:
+ batch_op.drop_column('default_currency_id')
+ except Exception:
+ pass
+ op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
+ op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
+ op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
+ op.drop_table('business_currencies')
+
+ op.drop_index('ix_currencies_name', table_name='currencies')
+ op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
+ op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
+ op.drop_table('currencies')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000014_add_documents_table.py b/hesabixAPI/build/lib/migrations/versions/20250927_000014_add_documents_table.py
new file mode 100644
index 0000000..8f94d86
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000014_add_documents_table.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000014_add_documents_table'
+down_revision = '20250927_000013_add_currencies_and_business_currencies'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create documents table
+ op.create_table(
+ 'documents',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('code', sa.String(length=50), nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('currency_id', sa.Integer(), nullable=False),
+ sa.Column('created_by_user_id', sa.Integer(), nullable=False),
+ sa.Column('registered_at', sa.DateTime(), nullable=False),
+ sa.Column('document_date', sa.Date(), nullable=False),
+ sa.Column('document_type', sa.String(length=50), nullable=False),
+ sa.Column('is_proforma', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('extra_info', sa.JSON(), nullable=True),
+ sa.Column('developer_settings', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ # Unique per business code
+ op.create_unique_constraint('uq_documents_business_code', 'documents', ['business_id', 'code'])
+
+ # Indexes
+ op.create_index('ix_documents_code', 'documents', ['code'])
+ op.create_index('ix_documents_business_id', 'documents', ['business_id'])
+ op.create_index('ix_documents_currency_id', 'documents', ['currency_id'])
+ op.create_index('ix_documents_created_by_user_id', 'documents', ['created_by_user_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_documents_created_by_user_id', table_name='documents')
+ op.drop_index('ix_documents_currency_id', table_name='documents')
+ op.drop_index('ix_documents_business_id', table_name='documents')
+ op.drop_index('ix_documents_code', table_name='documents')
+ op.drop_constraint('uq_documents_business_code', 'documents', type_='unique')
+ op.drop_table('documents')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000015_add_document_lines_table.py b/hesabixAPI/build/lib/migrations/versions/20250927_000015_add_document_lines_table.py
new file mode 100644
index 0000000..be3bbc8
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000015_add_document_lines_table.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000015_add_document_lines_table'
+down_revision = '20250927_000014_add_documents_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'document_lines',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('document_id', sa.Integer(), nullable=False),
+ sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
+ sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('extra_info', sa.JSON(), nullable=True),
+ sa.Column('developer_data', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_document_lines_document_id', table_name='document_lines')
+ op.drop_table('document_lines')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000016_add_accounts_table.py b/hesabixAPI/build/lib/migrations/versions/20250927_000016_add_accounts_table.py
new file mode 100644
index 0000000..236159f
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000016_add_accounts_table.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000016_add_accounts_table'
+down_revision = '20250927_000015_add_document_lines_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'accounts',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=True),
+ sa.Column('account_type', sa.String(length=50), nullable=False),
+ sa.Column('code', sa.String(length=50), nullable=False),
+ sa.Column('parent_id', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['parent_id'], ['accounts.id'], ondelete='SET NULL'),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ op.create_unique_constraint('uq_accounts_business_code', 'accounts', ['business_id', 'code'])
+ op.create_index('ix_accounts_name', 'accounts', ['name'])
+ op.create_index('ix_accounts_business_id', 'accounts', ['business_id'])
+ op.create_index('ix_accounts_parent_id', 'accounts', ['parent_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_accounts_parent_id', table_name='accounts')
+ op.drop_index('ix_accounts_business_id', table_name='accounts')
+ op.drop_index('ix_accounts_name', table_name='accounts')
+ op.drop_constraint('uq_accounts_business_code', 'accounts', type_='unique')
+ op.drop_table('accounts')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000017_add_account_id_to_document_lines.py b/hesabixAPI/build/lib/migrations/versions/20250927_000017_add_account_id_to_document_lines.py
new file mode 100644
index 0000000..687e483
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000017_add_account_id_to_document_lines.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000017_add_account_id_to_document_lines'
+down_revision = '20250927_000016_add_accounts_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table('document_lines') as batch_op:
+ batch_op.add_column(sa.Column('account_id', sa.Integer(), nullable=True))
+ batch_op.create_foreign_key('fk_document_lines_account_id_accounts', 'accounts', ['account_id'], ['id'], ondelete='RESTRICT')
+ batch_op.create_index('ix_document_lines_account_id', ['account_id'])
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('document_lines') as batch_op:
+ batch_op.drop_index('ix_document_lines_account_id')
+ batch_op.drop_constraint('fk_document_lines_account_id_accounts', type_='foreignkey')
+ batch_op.drop_column('account_id')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000018_seed_currencies.py b/hesabixAPI/build/lib/migrations/versions/20250927_000018_seed_currencies.py
new file mode 100644
index 0000000..24ee953
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000018_seed_currencies.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000018_seed_currencies'
+down_revision = 'f876bfa36805'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ insert_sql = sa.text(
+ """
+ INSERT INTO currencies (name, title, symbol, code, created_at, updated_at)
+ VALUES (:name, :title, :symbol, :code, NOW(), NOW())
+ ON DUPLICATE KEY UPDATE
+ title = VALUES(title),
+ symbol = VALUES(symbol),
+ updated_at = VALUES(updated_at)
+ """
+ )
+
+ currencies = [
+ {"name": "Iranian Rial", "title": "ریال ایران", "symbol": "﷼", "code": "IRR"},
+ {"name": "United States Dollar", "title": "US Dollar", "symbol": "$", "code": "USD"},
+ {"name": "Euro", "title": "Euro", "symbol": "€", "code": "EUR"},
+ {"name": "British Pound", "title": "Pound Sterling", "symbol": "£", "code": "GBP"},
+ {"name": "Japanese Yen", "title": "Yen", "symbol": "¥", "code": "JPY"},
+ {"name": "Chinese Yuan", "title": "Yuan", "symbol": "¥", "code": "CNY"},
+ {"name": "Swiss Franc", "title": "Swiss Franc", "symbol": "CHF", "code": "CHF"},
+ {"name": "Canadian Dollar", "title": "Canadian Dollar", "symbol": "$", "code": "CAD"},
+ {"name": "Australian Dollar", "title": "Australian Dollar", "symbol": "$", "code": "AUD"},
+ {"name": "New Zealand Dollar", "title": "New Zealand Dollar", "symbol": "$", "code": "NZD"},
+ {"name": "Russian Ruble", "title": "Ruble", "symbol": "₽", "code": "RUB"},
+ {"name": "Turkish Lira", "title": "Lira", "symbol": "₺", "code": "TRY"},
+ {"name": "UAE Dirham", "title": "Dirham", "symbol": "د.إ", "code": "AED"},
+ {"name": "Saudi Riyal", "title": "Riyal", "symbol": "﷼", "code": "SAR"},
+ {"name": "Qatari Riyal", "title": "Qatari Riyal", "symbol": "﷼", "code": "QAR"},
+ {"name": "Kuwaiti Dinar", "title": "Kuwaiti Dinar", "symbol": "د.ك", "code": "KWD"},
+ {"name": "Omani Rial", "title": "Omani Rial", "symbol": "﷼", "code": "OMR"},
+ {"name": "Bahraini Dinar", "title": "Bahraini Dinar", "symbol": ".د.ب", "code": "BHD"},
+ {"name": "Iraqi Dinar", "title": "Iraqi Dinar", "symbol": "ع.د", "code": "IQD"},
+ {"name": "Afghan Afghani", "title": "Afghani", "symbol": "؋", "code": "AFN"},
+ {"name": "Pakistani Rupee", "title": "Rupee", "symbol": "₨", "code": "PKR"},
+ {"name": "Indian Rupee", "title": "Rupee", "symbol": "₹", "code": "INR"},
+ {"name": "Armenian Dram", "title": "Dram", "symbol": "֏", "code": "AMD"},
+ {"name": "Azerbaijani Manat", "title": "Manat", "symbol": "₼", "code": "AZN"},
+ {"name": "Georgian Lari", "title": "Lari", "symbol": "₾", "code": "GEL"},
+ {"name": "Kazakhstani Tenge", "title": "Tenge", "symbol": "₸", "code": "KZT"},
+ {"name": "Uzbekistani Som", "title": "Som", "symbol": "so'm", "code": "UZS"},
+ {"name": "Tajikistani Somoni", "title": "Somoni", "symbol": "ЅМ", "code": "TJS"},
+ {"name": "Turkmenistani Manat", "title": "Manat", "symbol": "m", "code": "TMT"},
+ {"name": "Afgani Lek", "title": "Lek", "symbol": "L", "code": "ALL"},
+ {"name": "Bulgarian Lev", "title": "Lev", "symbol": "лв", "code": "BGN"},
+ {"name": "Romanian Leu", "title": "Leu", "symbol": "lei", "code": "RON"},
+ {"name": "Polish Złoty", "title": "Zloty", "symbol": "zł", "code": "PLN"},
+ {"name": "Czech Koruna", "title": "Koruna", "symbol": "Kč", "code": "CZK"},
+ {"name": "Hungarian Forint", "title": "Forint", "symbol": "Ft", "code": "HUF"},
+ {"name": "Danish Krone", "title": "Krone", "symbol": "kr", "code": "DKK"},
+ {"name": "Norwegian Krone", "title": "Krone", "symbol": "kr", "code": "NOK"},
+ {"name": "Swedish Krona", "title": "Krona", "symbol": "kr", "code": "SEK"},
+ {"name": "Icelandic Króna", "title": "Krona", "symbol": "kr", "code": "ISK"},
+ {"name": "Croatian Kuna", "title": "Kuna", "symbol": "kn", "code": "HRK"},
+ {"name": "Serbian Dinar", "title": "Dinar", "symbol": "дин.", "code": "RSD"},
+ {"name": "Bosnia and Herzegovina Mark", "title": "Mark", "symbol": "KM", "code": "BAM"},
+ {"name": "Ukrainian Hryvnia", "title": "Hryvnia", "symbol": "₴", "code": "UAH"},
+ {"name": "Belarusian Ruble", "title": "Ruble", "symbol": "Br", "code": "BYN"},
+ {"name": "Egyptian Pound", "title": "Pound", "symbol": "£", "code": "EGP"},
+ {"name": "South African Rand", "title": "Rand", "symbol": "R", "code": "ZAR"},
+ {"name": "Nigerian Naira", "title": "Naira", "symbol": "₦", "code": "NGN"},
+ {"name": "Kenyan Shilling", "title": "Shilling", "symbol": "Sh", "code": "KES"},
+ {"name": "Ethiopian Birr", "title": "Birr", "symbol": "Br", "code": "ETB"},
+ {"name": "Moroccan Dirham", "title": "Dirham", "symbol": "د.م.", "code": "MAD"},
+ {"name": "Tunisian Dinar", "title": "Dinar", "symbol": "د.ت", "code": "TND"},
+ {"name": "Algerian Dinar", "title": "Dinar", "symbol": "د.ج", "code": "DZD"},
+ {"name": "Israeli New Shekel", "title": "Shekel", "symbol": "₪", "code": "ILS"},
+ {"name": "Jordanian Dinar", "title": "Dinar", "symbol": "د.ا", "code": "JOD"},
+ {"name": "Lebanese Pound", "title": "Pound", "symbol": "ل.ل", "code": "LBP"},
+ {"name": "Syrian Pound", "title": "Pound", "symbol": "£", "code": "SYP"},
+ {"name": "Azerbaijani Manat", "title": "Manat", "symbol": "₼", "code": "AZN"},
+ {"name": "Singapore Dollar", "title": "Singapore Dollar", "symbol": "$", "code": "SGD"},
+ {"name": "Hong Kong Dollar", "title": "Hong Kong Dollar", "symbol": "$", "code": "HKD"},
+ {"name": "Thai Baht", "title": "Baht", "symbol": "฿", "code": "THB"},
+ {"name": "Malaysian Ringgit", "title": "Ringgit", "symbol": "RM", "code": "MYR"},
+ {"name": "Indonesian Rupiah", "title": "Rupiah", "symbol": "Rp", "code": "IDR"},
+ {"name": "Philippine Peso", "title": "Peso", "symbol": "₱", "code": "PHP"},
+ {"name": "Vietnamese Dong", "title": "Dong", "symbol": "₫", "code": "VND"},
+ {"name": "South Korean Won", "title": "Won", "symbol": "₩", "code": "KRW"},
+ {"name": "Taiwan New Dollar", "title": "New Dollar", "symbol": "$", "code": "TWD"},
+ {"name": "Mexican Peso", "title": "Peso", "symbol": "$", "code": "MXN"},
+ {"name": "Brazilian Real", "title": "Real", "symbol": "R$", "code": "BRL"},
+ {"name": "Argentine Peso", "title": "Peso", "symbol": "$", "code": "ARS"},
+ {"name": "Chilean Peso", "title": "Peso", "symbol": "$", "code": "CLP"},
+ {"name": "Colombian Peso", "title": "Peso", "symbol": "$", "code": "COP"},
+ {"name": "Peruvian Sol", "title": "Sol", "symbol": "S/.", "code": "PEN"},
+ {"name": "Uruguayan Peso", "title": "Peso", "symbol": "$U", "code": "UYU"},
+ {"name": "Paraguayan Guarani", "title": "Guarani", "symbol": "₲", "code": "PYG"},
+ {"name": "Bolivian Boliviano", "title": "Boliviano", "symbol": "Bs.", "code": "BOB"},
+ {"name": "Dominican Peso", "title": "Peso", "symbol": "RD$", "code": "DOP"},
+ {"name": "Cuban Peso", "title": "Peso", "symbol": "$", "code": "CUP"},
+ {"name": "Costa Rican Colon", "title": "Colon", "symbol": "₡", "code": "CRC"},
+ {"name": "Guatemalan Quetzal", "title": "Quetzal", "symbol": "Q", "code": "GTQ"},
+ {"name": "Honduran Lempira", "title": "Lempira", "symbol": "L", "code": "HNL"},
+ {"name": "Nicaraguan Córdoba", "title": "Cordoba", "symbol": "C$", "code": "NIO"},
+ {"name": "Panamanian Balboa", "title": "Balboa", "symbol": "B/.", "code": "PAB"},
+ {"name": "Venezuelan Bolívar", "title": "Bolivar", "symbol": "Bs.", "code": "VES"},
+ ]
+
+ for row in currencies:
+ conn.execute(insert_sql, row)
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ codes = [
+ 'IRR','USD','EUR','GBP','JPY','CNY','CHF','CAD','AUD','NZD','RUB','TRY','AED','SAR','QAR','KWD','OMR','BHD','IQD','AFN','PKR','INR','AMD','AZN','GEL','KZT','UZS','TJS','TMT','ALL','BGN','RON','PLN','CZK','HUF','DKK','NOK','SEK','ISK','HRK','RSD','BAM','UAH','BYN','EGP','ZAR','NGN','KES','ETB','MAD','TND','DZD','ILS','JOD','LBP','SYP','SGD','HKD','THB','MYR','IDR','PHP','VND','KRW','TWD','MXN','BRL','ARS','CLP','COP','PEN','UYU','PYG','BOB','DOP','CUP','CRC','GTQ','HNL','NIO','PAB','VES'
+ ]
+ delete_sql = sa.text("DELETE FROM currencies WHERE code IN :codes")
+ conn.execute(delete_sql, {"codes": tuple(codes)})
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000019_seed_accounts_chart.py b/hesabixAPI/build/lib/migrations/versions/20250927_000019_seed_accounts_chart.py
new file mode 100644
index 0000000..333a1d7
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000019_seed_accounts_chart.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000019_seed_accounts_chart'
+down_revision = '20250927_000018_seed_currencies'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ # دادهها (خلاصهشده برای خوانایی؛ از JSON کاربر)
+ accounts = [
+ {"id":2452,"level":1,"code":"1","name":"دارایی ها","parentId":0,"accountType":0},
+ {"id":2453,"level":2,"code":"101","name":"دارایی های جاری","parentId":2452,"accountType":0},
+ {"id":2454,"level":3,"code":"102","name":"موجودی نقد و بانک","parentId":2453,"accountType":0},
+ {"id":2455,"level":4,"code":"10201","name":"تنخواه گردان","parentId":2454,"accountType":2},
+ {"id":2456,"level":4,"code":"10202","name":"صندوق","parentId":2454,"accountType":1},
+ {"id":2457,"level":4,"code":"10203","name":"بانک","parentId":2454,"accountType":3},
+ {"id":2458,"level":4,"code":"10204","name":"وجوه در راه","parentId":2454,"accountType":0},
+ {"id":2459,"level":3,"code":"103","name":"سپرده های کوتاه مدت","parentId":2453,"accountType":0},
+ {"id":2460,"level":4,"code":"10301","name":"سپرده شرکت در مناقصه و مزایده","parentId":2459,"accountType":0},
+ {"id":2461,"level":4,"code":"10302","name":"ضمانت نامه بانکی","parentId":2459,"accountType":0},
+ {"id":2462,"level":4,"code":"10303","name":"سایر سپرده ها","parentId":2459,"accountType":0},
+ {"id":2463,"level":3,"code":"104","name":"حساب های دریافتنی","parentId":2453,"accountType":0},
+ {"id":2464,"level":4,"code":"10401","name":"حساب های دریافتنی","parentId":2463,"accountType":4},
+ {"id":2465,"level":4,"code":"10402","name":"ذخیره مطالبات مشکوک الوصول","parentId":2463,"accountType":0},
+ {"id":2466,"level":4,"code":"10403","name":"اسناد دریافتنی","parentId":2463,"accountType":5},
+ {"id":2467,"level":4,"code":"10404","name":"اسناد در جریان وصول","parentId":2463,"accountType":6},
+ {"id":2468,"level":3,"code":"105","name":"سایر حساب های دریافتنی","parentId":2453,"accountType":0},
+ {"id":2469,"level":4,"code":"10501","name":"وام کارکنان","parentId":2468,"accountType":0},
+ {"id":2470,"level":4,"code":"10502","name":"سایر حساب های دریافتنی","parentId":2468,"accountType":0},
+ {"id":2471,"level":3,"code":"10101","name":"پیش پرداخت ها","parentId":2453,"accountType":0},
+ {"id":2472,"level":3,"code":"10102","name":"موجودی کالا","parentId":2453,"accountType":7},
+ {"id":2473,"level":3,"code":"10103","name":"ملزومات","parentId":2453,"accountType":0},
+ {"id":2474,"level":3,"code":"10104","name":"مالیات بر ارزش افزوده خرید","parentId":2453,"accountType":8},
+ {"id":2475,"level":2,"code":"106","name":"دارایی های غیر جاری","parentId":2452,"accountType":0},
+ {"id":2476,"level":3,"code":"107","name":"دارایی های ثابت","parentId":2475,"accountType":0},
+ {"id":2477,"level":4,"code":"10701","name":"زمین","parentId":2476,"accountType":0},
+ {"id":2478,"level":4,"code":"10702","name":"ساختمان","parentId":2476,"accountType":0},
+ {"id":2479,"level":4,"code":"10703","name":"وسائط نقلیه","parentId":2476,"accountType":0},
+ {"id":2480,"level":4,"code":"10704","name":"اثاثیه اداری","parentId":2476,"accountType":0},
+ {"id":2481,"level":3,"code":"108","name":"استهلاک انباشته","parentId":2475,"accountType":0},
+ {"id":2482,"level":4,"code":"10801","name":"استهلاک انباشته ساختمان","parentId":2481,"accountType":0},
+ {"id":2483,"level":4,"code":"10802","name":"استهلاک انباشته وسائط نقلیه","parentId":2481,"accountType":0},
+ {"id":2484,"level":4,"code":"10803","name":"استهلاک انباشته اثاثیه اداری","parentId":2481,"accountType":0},
+ {"id":2485,"level":3,"code":"109","name":"سپرده های بلندمدت","parentId":2475,"accountType":0},
+ {"id":2486,"level":3,"code":"110","name":"سایر دارائی ها","parentId":2475,"accountType":0},
+ {"id":2487,"level":4,"code":"11001","name":"حق الامتیازها","parentId":2486,"accountType":0},
+ {"id":2488,"level":4,"code":"11002","name":"نرم افزارها","parentId":2486,"accountType":0},
+ {"id":2489,"level":4,"code":"11003","name":"سایر دارایی های نامشهود","parentId":2486,"accountType":0},
+ {"id":2490,"level":1,"code":"2","name":"بدهی ها","parentId":0,"accountType":0},
+ {"id":2491,"level":2,"code":"201","name":"بدهیهای جاری","parentId":2490,"accountType":0},
+ {"id":2492,"level":3,"code":"202","name":"حساب ها و اسناد پرداختنی","parentId":2491,"accountType":0},
+ {"id":2493,"level":4,"code":"20201","name":"حساب های پرداختنی","parentId":2492,"accountType":9},
+ {"id":2494,"level":4,"code":"20202","name":"اسناد پرداختنی","parentId":2492,"accountType":10},
+ {"id":2495,"level":3,"code":"203","name":"سایر حساب های پرداختنی","parentId":2491,"accountType":0},
+ {"id":2496,"level":4,"code":"20301","name":"ذخیره مالیات بر درآمد پرداختنی","parentId":2495,"accountType":40},
+ {"id":2497,"level":4,"code":"20302","name":"مالیات بر درآمد پرداختنی","parentId":2495,"accountType":12},
+ {"id":2498,"level":4,"code":"20303","name":"مالیات حقوق و دستمزد پرداختنی","parentId":2495,"accountType":0},
+ {"id":2499,"level":4,"code":"20304","name":"حق بیمه پرداختنی","parentId":2495,"accountType":0},
+ {"id":2500,"level":4,"code":"20305","name":"حقوق و دستمزد پرداختنی","parentId":2495,"accountType":42},
+ {"id":2501,"level":4,"code":"20306","name":"عیدی و پاداش پرداختنی","parentId":2495,"accountType":0},
+ {"id":2502,"level":4,"code":"20307","name":"سایر هزینه های پرداختنی","parentId":2495,"accountType":0},
+ {"id":2503,"level":3,"code":"204","name":"پیش دریافت ها","parentId":2491,"accountType":0},
+ {"id":2504,"level":4,"code":"20401","name":"پیش دریافت فروش","parentId":2503,"accountType":0},
+ {"id":2505,"level":4,"code":"20402","name":"سایر پیش دریافت ها","parentId":2503,"accountType":0},
+ {"id":2506,"level":3,"code":"20101","name":"مالیات بر ارزش افزوده فروش","parentId":2491,"accountType":11},
+ {"id":2507,"level":2,"code":"205","name":"بدهیهای غیر جاری","parentId":2490,"accountType":0},
+ {"id":2508,"level":3,"code":"206","name":"حساب ها و اسناد پرداختنی بلندمدت","parentId":2507,"accountType":0},
+ {"id":2509,"level":4,"code":"20601","name":"حساب های پرداختنی بلندمدت","parentId":2508,"accountType":0},
+ {"id":2510,"level":4,"code":"20602","name":"اسناد پرداختنی بلندمدت","parentId":2508,"accountType":0},
+ {"id":2511,"level":3,"code":"20501","name":"وام پرداختنی","parentId":2507,"accountType":0},
+ {"id":2512,"level":3,"code":"20502","name":"ذخیره مزایای پایان خدمت کارکنان","parentId":2507,"accountType":0},
+ {"id":2513,"level":1,"code":"3","name":"حقوق صاحبان سهام","parentId":0,"accountType":0},
+ {"id":2514,"level":2,"code":"301","name":"سرمایه","parentId":2513,"accountType":0},
+ {"id":2515,"level":3,"code":"30101","name":"سرمایه اولیه","parentId":2514,"accountType":13},
+ {"id":2516,"level":3,"code":"30102","name":"افزایش یا کاهش سرمایه","parentId":2514,"accountType":14},
+ {"id":2517,"level":3,"code":"30103","name":"اندوخته قانونی","parentId":2514,"accountType":15},
+ {"id":2518,"level":3,"code":"30104","name":"برداشت ها","parentId":2514,"accountType":16},
+ {"id":2519,"level":3,"code":"30105","name":"سهم سود و زیان","parentId":2514,"accountType":17},
+ {"id":2520,"level":3,"code":"30106","name":"سود یا زیان انباشته (سنواتی)","parentId":2514,"accountType":18},
+ {"id":2521,"level":1,"code":"4","name":"بهای تمام شده کالای فروخته شده","parentId":0,"accountType":0},
+ {"id":2522,"level":2,"code":"40001","name":"بهای تمام شده کالای فروخته شده","parentId":2521,"accountType":19},
+ {"id":2523,"level":2,"code":"40002","name":"برگشت از خرید","parentId":2521,"accountType":20},
+ {"id":2524,"level":2,"code":"40003","name":"تخفیفات نقدی خرید","parentId":2521,"accountType":21},
+ {"id":2525,"level":1,"code":"5","name":"فروش","parentId":0,"accountType":0},
+ {"id":2526,"level":2,"code":"50001","name":"فروش کالا","parentId":2525,"accountType":22},
+ {"id":2527,"level":2,"code":"50002","name":"برگشت از فروش","parentId":2525,"accountType":23},
+ {"id":2528,"level":2,"code":"50003","name":"تخفیفات نقدی فروش","parentId":2525,"accountType":24},
+ {"id":2529,"level":1,"code":"6","name":"درآمد","parentId":0,"accountType":0},
+ {"id":2530,"level":2,"code":"601","name":"درآمد های عملیاتی","parentId":2529,"accountType":0},
+ {"id":2531,"level":3,"code":"60101","name":"درآمد حاصل از فروش خدمات","parentId":2530,"accountType":25},
+ {"id":2532,"level":3,"code":"60102","name":"برگشت از خرید خدمات","parentId":2530,"accountType":26},
+ {"id":2533,"level":3,"code":"60103","name":"درآمد اضافه کالا","parentId":2530,"accountType":27},
+ {"id":2534,"level":3,"code":"60104","name":"درآمد حمل کالا","parentId":2530,"accountType":28},
+ {"id":2535,"level":2,"code":"602","name":"درآمد های غیر عملیاتی","parentId":2529,"accountType":0},
+ {"id":2536,"level":3,"code":"60201","name":"درآمد حاصل از سرمایه گذاری","parentId":2535,"accountType":0},
+ {"id":2537,"level":3,"code":"60202","name":"درآمد سود سپرده ها","parentId":2535,"accountType":0},
+ {"id":2538,"level":3,"code":"60203","name":"سایر درآمد ها","parentId":2535,"accountType":0},
+ {"id":2539,"level":3,"code":"60204","name":"درآمد تسعیر ارز","parentId":2535,"accountType":36},
+ {"id":2540,"level":1,"code":"7","name":"هزینه ها","parentId":0,"accountType":0},
+ {"id":2541,"level":2,"code":"701","name":"هزینه های پرسنلی","parentId":2540,"accountType":0},
+ {"id":2542,"level":3,"code":"702","name":"هزینه حقوق و دستمزد","parentId":2541,"accountType":0},
+ {"id":2543,"level":4,"code":"70201","name":"حقوق پایه","parentId":2542,"accountType":0},
+ {"id":2544,"level":4,"code":"70202","name":"اضافه کار","parentId":2542,"accountType":0},
+ {"id":2545,"level":4,"code":"70203","name":"حق شیفت و شب کاری","parentId":2542,"accountType":0},
+ {"id":2546,"level":4,"code":"70204","name":"حق نوبت کاری","parentId":2542,"accountType":0},
+ {"id":2547,"level":4,"code":"70205","name":"حق ماموریت","parentId":2542,"accountType":0},
+ {"id":2548,"level":4,"code":"70206","name":"فوق العاده مسکن و خاروبار","parentId":2542,"accountType":0},
+ {"id":2549,"level":4,"code":"70207","name":"حق اولاد","parentId":2542,"accountType":0},
+ {"id":2550,"level":4,"code":"70208","name":"عیدی و پاداش","parentId":2542,"accountType":0},
+ {"id":2551,"level":4,"code":"70209","name":"بازخرید سنوات خدمت کارکنان","parentId":2542,"accountType":0},
+ {"id":2552,"level":4,"code":"70210","name":"بازخرید مرخصی","parentId":2542,"accountType":0},
+ {"id":2553,"level":4,"code":"70211","name":"بیمه سهم کارفرما","parentId":2542,"accountType":0},
+ {"id":2554,"level":4,"code":"70212","name":"بیمه بیکاری","parentId":2542,"accountType":0},
+ {"id":2555,"level":4,"code":"70213","name":"حقوق مزایای متفرقه","parentId":2542,"accountType":0},
+ {"id":2556,"level":3,"code":"703","name":"سایر هزینه های کارکنان","parentId":2541,"accountType":0},
+ {"id":2557,"level":4,"code":"70301","name":"سفر و ماموریت","parentId":2556,"accountType":0},
+ {"id":2558,"level":4,"code":"70302","name":"ایاب و ذهاب","parentId":2556,"accountType":0},
+ {"id":2559,"level":4,"code":"70303","name":"سایر هزینه های کارکنان","parentId":2556,"accountType":0},
+ {"id":2560,"level":2,"code":"704","name":"هزینه های عملیاتی","parentId":2540,"accountType":0},
+ {"id":2561,"level":3,"code":"70401","name":"خرید خدمات","parentId":2560,"accountType":30},
+ {"id":2562,"level":3,"code":"70402","name":"برگشت از فروش خدمات","parentId":2560,"accountType":29},
+ {"id":2563,"level":3,"code":"70403","name":"هزینه حمل کالا","parentId":2560,"accountType":31},
+ {"id":2564,"level":3,"code":"70404","name":"تعمیر و نگهداری اموال و اثاثیه","parentId":2560,"accountType":0},
+ {"id":2565,"level":3,"code":"70405","name":"هزینه اجاره محل","parentId":2560,"accountType":0},
+ {"id":2566,"level":2,"code":"705","name":"هزینه های عمومی","parentId":2540,"accountType":0},
+ {"id":2567,"level":4,"code":"70501","name":"هزینه آب و برق و گاز و تلفن","parentId":2566,"accountType":0},
+ {"id":2568,"level":4,"code":"70502","name":"هزینه پذیرایی و آبدارخانه","parentId":2566,"accountType":0},
+ {"id":2569,"level":3,"code":"70406","name":"هزینه ملزومات مصرفی","parentId":2560,"accountType":0},
+ {"id":2570,"level":3,"code":"70407","name":"هزینه کسری و ضایعات کالا","parentId":2560,"accountType":32},
+ {"id":2571,"level":3,"code":"70408","name":"بیمه دارایی های ثابت","parentId":2560,"accountType":0},
+ {"id":2572,"level":2,"code":"706","name":"هزینه های استهلاک","parentId":2540,"accountType":0},
+ {"id":2573,"level":3,"code":"70601","name":"هزینه استهلاک ساختمان","parentId":2572,"accountType":0},
+ {"id":2574,"level":3,"code":"70602","name":"هزینه استهلاک وسائط نقلیه","parentId":2572,"accountType":0},
+ {"id":2575,"level":3,"code":"70603","name":"هزینه استهلاک اثاثیه","parentId":2572,"accountType":0},
+ {"id":2576,"level":2,"code":"707","name":"هزینه های بازاریابی و توزیع و فروش","parentId":2540,"accountType":0},
+ {"id":2577,"level":3,"code":"70701","name":"هزینه آگهی و تبلیغات","parentId":2576,"accountType":0},
+ {"id":2578,"level":3,"code":"70702","name":"هزینه بازاریابی و پورسانت","parentId":2576,"accountType":0},
+ {"id":2579,"level":3,"code":"70703","name":"سایر هزینه های توزیع و فروش","parentId":2576,"accountType":0},
+ {"id":2580,"level":2,"code":"708","name":"هزینه های غیرعملیاتی","parentId":2540,"accountType":0},
+ {"id":2581,"level":3,"code":"709","name":"هزینه های بانکی","parentId":2580,"accountType":0},
+ {"id":2582,"level":4,"code":"70901","name":"سود و کارمزد وامها","parentId":2581,"accountType":0},
+ {"id":2583,"level":4,"code":"70902","name":"کارمزد خدمات بانکی","parentId":2581,"accountType":33},
+ {"id":2584,"level":4,"code":"70903","name":"جرائم دیرکرد بانکی","parentId":2581,"accountType":0},
+ {"id":2585,"level":3,"code":"70801","name":"هزینه تسعیر ارز","parentId":2580,"accountType":37},
+ {"id":2586,"level":3,"code":"70802","name":"هزینه مطالبات سوخت شده","parentId":2580,"accountType":0},
+ {"id":2587,"level":1,"code":"8","name":"سایر حساب ها","parentId":0,"accountType":0},
+ {"id":2588,"level":2,"code":"801","name":"حساب های انتظامی","parentId":2587,"accountType":0},
+ {"id":2589,"level":3,"code":"80101","name":"حساب های انتظامی","parentId":2588,"accountType":0},
+ {"id":2590,"level":3,"code":"80102","name":"طرف حساب های انتظامی","parentId":2588,"accountType":0},
+ {"id":2591,"level":2,"code":"802","name":"حساب های کنترلی","parentId":2587,"accountType":0},
+ {"id":2592,"level":3,"code":"80201","name":"کنترل کسری و اضافه کالا","parentId":2591,"accountType":34},
+ {"id":2593,"level":2,"code":"803","name":"حساب خلاصه سود و زیان","parentId":2587,"accountType":0},
+ {"id":2594,"level":3,"code":"80301","name":"خلاصه سود و زیان","parentId":2593,"accountType":35},
+ {"id":2595,"level":5,"code":"70503","name":"هزینه آب","parentId":2567,"accountType":0},
+ {"id":2596,"level":5,"code":"70504","name":"هزینه برق","parentId":2567,"accountType":0},
+ {"id":2597,"level":5,"code":"70505","name":"هزینه گاز","parentId":2567,"accountType":0},
+ {"id":2598,"level":5,"code":"70506","name":"هزینه تلفن","parentId":2567,"accountType":0},
+ {"id":2600,"level":4,"code":"20503","name":"وام از بانک ملت","parentId":2511,"accountType":0},
+ {"id":2601,"level":4,"code":"10405","name":"سود تحقق نیافته فروش اقساطی","parentId":2463,"accountType":39},
+ {"id":2602,"level":3,"code":"60205","name":"سود فروش اقساطی","parentId":2535,"accountType":38},
+ {"id":2603,"level":4,"code":"70214","name":"حق تاهل","parentId":2542,"accountType":0},
+ {"id":2604,"level":4,"code":"20504","name":"وام از بانک پارسیان","parentId":2511,"accountType":0},
+ {"id":2605,"level":3,"code":"10105","name":"مساعده","parentId":2453,"accountType":0},
+ {"id":2606,"level":3,"code":"60105","name":"تعمیرات لوازم آشپزخانه","parentId":2530,"accountType":0},
+ {"id":2607,"level":4,"code":"10705","name":"کامپیوتر","parentId":2476,"accountType":0},
+ {"id":2608,"level":3,"code":"60206","name":"درامد حاصل از فروش ضایعات","parentId":2535,"accountType":0},
+ {"id":2609,"level":3,"code":"60207","name":"سود فروش دارایی","parentId":2535,"accountType":0},
+ {"id":2610,"level":3,"code":"70803","name":"زیان فروش دارایی","parentId":2580,"accountType":0},
+ {"id":2611,"level":3,"code":"10106","name":"موجودی کالای در جریان ساخت","parentId":2453,"accountType":41},
+ {"id":2612,"level":3,"code":"20102","name":"سربار تولید پرداختنی","parentId":2491,"accountType":43},
+ ]
+
+ # نقشه id خارجی به id داخلی
+ ext_to_internal: dict[int, int] = {}
+
+ # کوئریها
+ select_existing = sa.text("SELECT id FROM accounts WHERE business_id IS NULL AND code = :code LIMIT 1")
+ insert_q = sa.text(
+ """
+ INSERT INTO accounts (name, business_id, account_type, code, parent_id, created_at, updated_at)
+ VALUES (:name, NULL, :account_type, :code, :parent_id, NOW(), NOW())
+ """
+ )
+ update_q = sa.text(
+ """
+ UPDATE accounts
+ SET name = :name, account_type = :account_type, parent_id = :parent_id, updated_at = NOW()
+ WHERE id = :id
+ """
+ )
+
+ for item in accounts:
+ parent_internal = None
+ if item.get("parentId") and item["parentId"] in ext_to_internal:
+ parent_internal = ext_to_internal[item["parentId"]]
+
+ # وجودی؟
+ res = conn.execute(select_existing, {"code": item["code"]})
+ row = res.fetchone()
+ if row is None:
+ result = conn.execute(
+ insert_q,
+ {
+ "name": item["name"],
+ "account_type": str(item.get("accountType", 0)),
+ "code": item["code"],
+ "parent_id": parent_internal,
+ },
+ )
+ new_id = result.lastrowid if hasattr(result, "lastrowid") else None
+ if new_id is None:
+ # fallback: انتخاب بر اساس code
+ res2 = conn.execute(select_existing, {"code": item["code"]})
+ row2 = res2.fetchone()
+ if row2:
+ new_id = row2[0]
+ else:
+ pass
+ if new_id is not None:
+ ext_to_internal[item["id"]] = int(new_id)
+ else:
+ acc_id = int(row[0])
+ conn.execute(
+ update_q,
+ {
+ "id": acc_id,
+ "name": item["name"],
+ "account_type": str(item.get("accountType", 0)),
+ "parent_id": parent_internal,
+ },
+ )
+ ext_to_internal[item["id"]] = acc_id
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ # حذف بر اساس کدها (فقط حسابهای عمومی یعنی business_id IS NULL)
+ codes = [
+ "1","101","102","10201","10202","10203","10204","103","10301","10302","10303","104","10401","10402","10403","10404","105","10501","10502","10101","10102","10103","10104","106","107","10701","10702","10703","10704","108","10801","10802","10803","109","110","11001","11002","11003","2","201","202","20201","20202","203","20301","20302","20303","20304","20305","20306","20307","204","20401","20402","20101","205","206","20601","20602","20501","20502","3","301","30101","30102","30103","30104","30105","30106","4","40001","40002","40003","5","50001","50002","50003","6","601","60101","60102","60103","60104","602","60201","60202","60203","60204","7","701","702","70201","70202","70203","70204","70205","70206","70207","70208","70209","70210","70211","70212","70213","703","70301","70302","70303","704","70401","70402","70403","70404","70405","705","70501","70502","70406","70407","70408","706","70601","70602","70603","707","70701","70702","70703","708","709","70901","70902","70903","70801","70802","8","801","80101","80102","802","80201","803","80301","70503","70504","70505","70506","20503","10405","60205","70214","20504","10105","60105","10705","60206","60207","70803","10106","20102"
+ ]
+ delete_q = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code")
+ for code in codes:
+ conn.execute(delete_q, {"code": code})
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py b/hesabixAPI/build/lib/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py
new file mode 100644
index 0000000..23f423b
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py
@@ -0,0 +1,45 @@
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000020_add_share_count_and_shareholder_type'
+down_revision = '20250927_000019_seed_accounts_chart'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ b = op.get_bind()
+ inspector = inspect(b)
+ cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set()
+ with op.batch_alter_table('persons') as batch_op:
+ if 'share_count' not in cols:
+ batch_op.add_column(sa.Column('share_count', sa.Integer(), nullable=True))
+
+ # افزودن مقدار جدید به ENUM ستون person_type (برای MySQL)
+ # مقادیر فارسی مطابق Enum مدل: 'مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده'
+ # مقدار جدید: 'سهامدار'
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده','سهامدار') NOT NULL
+ """
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('persons') as batch_op:
+ batch_op.drop_column('share_count')
+
+ # بازگردانی ENUM بدون مقدار سهامدار
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده') NOT NULL
+ """
+ )
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py b/hesabixAPI/build/lib/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py
new file mode 100644
index 0000000..a2c1be3
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py
@@ -0,0 +1,59 @@
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000021_update_person_type_enum_to_persian'
+down_revision = 'd3e84892c1c2'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # 1) Allow both English and Persian, plus new 'سهامدار'
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('CUSTOMER','MARKETER','EMPLOYEE','SUPPLIER','PARTNER','SELLER',
+ 'مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده','سهامدار') NOT NULL
+ """
+ )
+
+ # 2) Migrate existing data from English to Persian
+ op.execute("UPDATE persons SET person_type = 'مشتری' WHERE person_type = 'CUSTOMER'")
+ op.execute("UPDATE persons SET person_type = 'بازاریاب' WHERE person_type = 'MARKETER'")
+ op.execute("UPDATE persons SET person_type = 'کارمند' WHERE person_type = 'EMPLOYEE'")
+ op.execute("UPDATE persons SET person_type = 'تامینکننده' WHERE person_type = 'SUPPLIER'")
+ op.execute("UPDATE persons SET person_type = 'همکار' WHERE person_type = 'PARTNER'")
+ op.execute("UPDATE persons SET person_type = 'فروشنده' WHERE person_type = 'SELLER'")
+
+ # 3) Restrict enum to Persian only (including 'سهامدار')
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده','سهامدار') NOT NULL
+ """
+ )
+
+
+def downgrade() -> None:
+ # Revert to English-only (without shareholder)
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('CUSTOMER','MARKETER','EMPLOYEE','SUPPLIER','PARTNER','SELLER') NOT NULL
+ """
+ )
+
+ # Convert data back from Persian to English
+ reverse_mapping = {
+ 'مشتری': 'CUSTOMER',
+ 'بازاریاب': 'MARKETER',
+ 'کارمند': 'EMPLOYEE',
+ 'تامینکننده': 'SUPPLIER',
+ 'همکار': 'PARTNER',
+ 'فروشنده': 'SELLER',
+ }
+ for fa, en in reverse_mapping.items():
+ op.execute(text("UPDATE persons SET person_type = :en WHERE person_type = :fa"), {"fa": fa, "en": en})
diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000022_add_person_commission_fields.py b/hesabixAPI/build/lib/migrations/versions/20250927_000022_add_person_commission_fields.py
new file mode 100644
index 0000000..da6e07e
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250927_000022_add_person_commission_fields.py
@@ -0,0 +1,43 @@
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000022_add_person_commission_fields'
+down_revision = '20250927_000021_update_person_type_enum_to_persian'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set()
+ with op.batch_alter_table('persons') as batch_op:
+ if 'commission_sale_percent' not in cols:
+ batch_op.add_column(sa.Column('commission_sale_percent', sa.Numeric(5, 2), nullable=True))
+ if 'commission_sales_return_percent' not in cols:
+ batch_op.add_column(sa.Column('commission_sales_return_percent', sa.Numeric(5, 2), nullable=True))
+ if 'commission_sales_amount' not in cols:
+ batch_op.add_column(sa.Column('commission_sales_amount', sa.Numeric(12, 2), nullable=True))
+ if 'commission_sales_return_amount' not in cols:
+ batch_op.add_column(sa.Column('commission_sales_return_amount', sa.Numeric(12, 2), nullable=True))
+ if 'commission_exclude_discounts' not in cols:
+ batch_op.add_column(sa.Column('commission_exclude_discounts', sa.Boolean(), server_default=sa.text('0'), nullable=False))
+ if 'commission_exclude_additions_deductions' not in cols:
+ batch_op.add_column(sa.Column('commission_exclude_additions_deductions', sa.Boolean(), server_default=sa.text('0'), nullable=False))
+ if 'commission_post_in_invoice_document' not in cols:
+ batch_op.add_column(sa.Column('commission_post_in_invoice_document', sa.Boolean(), server_default=sa.text('0'), nullable=False))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('persons') as batch_op:
+ batch_op.drop_column('commission_post_in_invoice_document')
+ batch_op.drop_column('commission_exclude_additions_deductions')
+ batch_op.drop_column('commission_exclude_discounts')
+ batch_op.drop_column('commission_sales_return_amount')
+ batch_op.drop_column('commission_sales_amount')
+ batch_op.drop_column('commission_sales_return_percent')
+ batch_op.drop_column('commission_sale_percent')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250928_000023_remove_person_is_active_force.py b/hesabixAPI/build/lib/migrations/versions/20250928_000023_remove_person_is_active_force.py
new file mode 100644
index 0000000..8b45be2
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250928_000023_remove_person_is_active_force.py
@@ -0,0 +1,56 @@
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = '20250928_000023_remove_person_is_active_force'
+down_revision = '4b2ea782bcb3'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ tables = set(inspector.get_table_names())
+
+ # Drop is_active from persons if exists
+ if 'persons' in tables:
+ columns = {col['name'] for col in inspector.get_columns('persons')}
+ if 'is_active' in columns:
+ with op.batch_alter_table('persons') as batch_op:
+ try:
+ batch_op.drop_column('is_active')
+ except Exception:
+ pass
+
+ # Drop is_active from person_bank_accounts if exists
+ if 'person_bank_accounts' in tables:
+ columns = {col['name'] for col in inspector.get_columns('person_bank_accounts')}
+ if 'is_active' in columns:
+ with op.batch_alter_table('person_bank_accounts') as batch_op:
+ try:
+ batch_op.drop_column('is_active')
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ # Recreate columns with safe defaults if needed
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ tables = set(inspector.get_table_names())
+
+ if 'persons' in tables:
+ columns = {col['name'] for col in inspector.get_columns('persons')}
+ if 'is_active' not in columns:
+ with op.batch_alter_table('persons') as batch_op:
+ batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))
+
+ if 'person_bank_accounts' in tables:
+ columns = {col['name'] for col in inspector.get_columns('person_bank_accounts')}
+ if 'is_active' not in columns:
+ with op.batch_alter_table('person_bank_accounts') as batch_op:
+ batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000101_add_categories_table.py b/hesabixAPI/build/lib/migrations/versions/20250929_000101_add_categories_table.py
new file mode 100644
index 0000000..c033524
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250929_000101_add_categories_table.py
@@ -0,0 +1,36 @@
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000101_add_categories_table'
+down_revision = '20250928_000023_remove_person_is_active_force'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if 'categories' in inspector.get_table_names():
+ return
+
+ op.create_table(
+ 'categories',
+ sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False, index=True),
+ sa.Column('parent_id', sa.Integer(), sa.ForeignKey('categories.id', ondelete='SET NULL'), nullable=True, index=True),
+ sa.Column('type', sa.String(length=16), nullable=False, index=True),
+ sa.Column('title_translations', sa.JSON(), nullable=False),
+ sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
+ )
+ # Indexes are created automatically if defined at ORM/model level or can be added in a later migration if needed
+
+
+def downgrade() -> None:
+ op.drop_table('categories')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000201_drop_type_from_categories.py b/hesabixAPI/build/lib/migrations/versions/20250929_000201_drop_type_from_categories.py
new file mode 100644
index 0000000..17593c5
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250929_000201_drop_type_from_categories.py
@@ -0,0 +1,41 @@
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000201_drop_type_from_categories'
+down_revision = '20250929_000101_add_categories_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # حذف ایندکس مرتبط با ستون type اگر وجود دارد
+ try:
+ op.drop_index('ix_categories_type', table_name='categories')
+ except Exception:
+ pass
+ # حذف ستون type
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ cols = [c['name'] for c in inspector.get_columns('categories')]
+ if 'type' in cols:
+ with op.batch_alter_table('categories') as batch_op:
+ try:
+ batch_op.drop_column('type')
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ # بازگردانی ستون type (اختیاری)
+ with op.batch_alter_table('categories') as batch_op:
+ try:
+ batch_op.add_column(sa.Column('type', sa.String(length=16), nullable=False, server_default='global'))
+ except Exception:
+ pass
+ try:
+ op.create_index('ix_categories_type', 'categories', ['type'])
+ except Exception:
+ pass
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000301_add_product_attributes_table.py b/hesabixAPI/build/lib/migrations/versions/20250929_000301_add_product_attributes_table.py
new file mode 100644
index 0000000..5e514ed
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250929_000301_add_product_attributes_table.py
@@ -0,0 +1,34 @@
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000301_add_product_attributes_table'
+down_revision = '20250929_000201_drop_type_from_categories'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ if 'product_attributes' in inspector.get_table_names():
+ return
+
+ op.create_table(
+ 'product_attributes',
+ sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False, index=True),
+ sa.Column('title', sa.String(length=255), nullable=False, index=True),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
+ sa.UniqueConstraint('business_id', 'title', name='uq_product_attributes_business_title'),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table('product_attributes')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py b/hesabixAPI/build/lib/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py
new file mode 100644
index 0000000..1ed252c
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py
@@ -0,0 +1,31 @@
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000401_drop_is_active_from_product_attributes'
+down_revision = '20250929_000301_add_product_attributes_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ cols = [c['name'] for c in inspector.get_columns('product_attributes')]
+ if 'is_active' in cols:
+ with op.batch_alter_table('product_attributes') as batch_op:
+ try:
+ batch_op.drop_column('is_active')
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('product_attributes') as batch_op:
+ try:
+ batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))
+ except Exception:
+ pass
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000501_add_products_and_pricing.py b/hesabixAPI/build/lib/migrations/versions/20250929_000501_add_products_and_pricing.py
new file mode 100644
index 0000000..7295181
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250929_000501_add_products_and_pricing.py
@@ -0,0 +1,244 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250929_000501_add_products_and_pricing'
+down_revision = '20250929_000401_drop_is_active_from_product_attributes'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create products table (with existence check)
+ connection = op.get_bind()
+
+ # Check if products table exists
+ result = connection.execute(sa.text("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'products'
+ """)).fetchone()
+
+ if result[0] == 0:
+ op.create_table(
+ 'products',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('item_type', sa.Enum('کالا', 'خدمت', name='product_item_type_enum'), nullable=False),
+ sa.Column('code', sa.String(length=64), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('category_id', sa.Integer(), nullable=True),
+ sa.Column('main_unit_id', sa.Integer(), nullable=True),
+ sa.Column('secondary_unit_id', sa.Integer(), nullable=True),
+ sa.Column('unit_conversion_factor', sa.Numeric(18, 6), nullable=True),
+ sa.Column('base_sales_price', sa.Numeric(18, 2), nullable=True),
+ sa.Column('base_sales_note', sa.Text(), nullable=True),
+ sa.Column('base_purchase_price', sa.Numeric(18, 2), nullable=True),
+ sa.Column('base_purchase_note', sa.Text(), nullable=True),
+ sa.Column('track_inventory', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('reorder_point', sa.Integer(), nullable=True),
+ sa.Column('min_order_qty', sa.Integer(), nullable=True),
+ sa.Column('lead_time_days', sa.Integer(), nullable=True),
+ sa.Column('is_sales_taxable', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('is_purchase_taxable', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('sales_tax_rate', sa.Numeric(5, 2), nullable=True),
+ sa.Column('purchase_tax_rate', sa.Numeric(5, 2), nullable=True),
+ sa.Column('tax_type_id', sa.Integer(), nullable=True),
+ sa.Column('tax_code', sa.String(length=100), nullable=True),
+ sa.Column('tax_unit_id', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ # Create constraints and indexes (with existence checks)
+ try:
+ op.create_unique_constraint('uq_products_business_code', 'products', ['business_id', 'code'])
+ except Exception:
+ pass # Constraint already exists
+
+ try:
+ op.create_index('ix_products_business_id', 'products', ['business_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_index('ix_products_name', 'products', ['name'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_foreign_key(None, 'products', 'businesses', ['business_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'products', 'categories', ['category_id'], ['id'], ondelete='SET NULL')
+ except Exception:
+ pass # Foreign key already exists
+
+ # Create price_lists table (with existence check)
+ result = connection.execute(sa.text("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'price_lists'
+ """)).fetchone()
+
+ if result[0] == 0:
+ op.create_table(
+ 'price_lists',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('currency_id', sa.Integer(), nullable=True),
+ sa.Column('default_unit_id', sa.Integer(), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ try:
+ op.create_unique_constraint('uq_price_lists_business_name', 'price_lists', ['business_id', 'name'])
+ except Exception:
+ pass # Constraint already exists
+
+ try:
+ op.create_index('ix_price_lists_business_id', 'price_lists', ['business_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_foreign_key(None, 'price_lists', 'businesses', ['business_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'price_lists', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT')
+ except Exception:
+ pass # Foreign key already exists
+
+ # Create price_items table (with existence check)
+ result = connection.execute(sa.text("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'price_items'
+ """)).fetchone()
+
+ if result[0] == 0:
+ op.create_table(
+ 'price_items',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('price_list_id', sa.Integer(), nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=False),
+ sa.Column('unit_id', sa.Integer(), nullable=True),
+ sa.Column('currency_id', sa.Integer(), nullable=True),
+ sa.Column('tier_name', sa.String(length=64), nullable=False),
+ sa.Column('min_qty', sa.Numeric(18, 3), nullable=False, server_default=sa.text('0')),
+ sa.Column('price', sa.Numeric(18, 2), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ try:
+ op.create_unique_constraint('uq_price_items_unique_tier', 'price_items', ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty'])
+ except Exception:
+ pass # Constraint already exists
+
+ try:
+ op.create_index('ix_price_items_price_list_id', 'price_items', ['price_list_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_index('ix_price_items_product_id', 'price_items', ['product_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_foreign_key(None, 'price_items', 'price_lists', ['price_list_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'price_items', 'products', ['product_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'price_items', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT')
+ except Exception:
+ pass # Foreign key already exists
+
+ # Create product_attribute_links table (with existence check)
+ result = connection.execute(sa.text("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'product_attribute_links'
+ """)).fetchone()
+
+ if result[0] == 0:
+ op.create_table(
+ 'product_attribute_links',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('product_id', sa.Integer(), nullable=False),
+ sa.Column('attribute_id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ try:
+ op.create_unique_constraint('uq_product_attribute_links_unique', 'product_attribute_links', ['product_id', 'attribute_id'])
+ except Exception:
+ pass # Constraint already exists
+
+ try:
+ op.create_index('ix_product_attribute_links_product_id', 'product_attribute_links', ['product_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_index('ix_product_attribute_links_attribute_id', 'product_attribute_links', ['attribute_id'])
+ except Exception:
+ pass # Index already exists
+
+ try:
+ op.create_foreign_key(None, 'product_attribute_links', 'products', ['product_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+ try:
+ op.create_foreign_key(None, 'product_attribute_links', 'product_attributes', ['attribute_id'], ['id'], ondelete='CASCADE')
+ except Exception:
+ pass # Foreign key already exists
+
+
+def downgrade() -> None:
+ # Drop links and pricing first due to FKs
+ op.drop_constraint('uq_product_attribute_links_unique', 'product_attribute_links', type_='unique')
+ op.drop_table('product_attribute_links')
+
+ op.drop_constraint('uq_price_items_unique_tier', 'price_items', type_='unique')
+ op.drop_table('price_items')
+
+ op.drop_constraint('uq_price_lists_business_name', 'price_lists', type_='unique')
+ op.drop_table('price_lists')
+
+ op.drop_constraint('uq_products_business_code', 'products', type_='unique')
+ op.drop_table('products')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py b/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
new file mode 100644
index 0000000..46e0e1b
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20251001_000601_update_price_items_currency_unique_not_null'
+down_revision = '20250929_000501_add_products_and_pricing'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # 1) Backfill price_items.currency_id from price_lists.currency_id where NULL
+ op.execute(
+ sa.text(
+ """
+ UPDATE price_items pi
+ JOIN price_lists pl ON pl.id = pi.price_list_id
+ SET pi.currency_id = pl.currency_id
+ WHERE pi.currency_id IS NULL
+ """
+ )
+ )
+
+ # 2) Drop old unique constraint if exists
+ conn = op.get_bind()
+ dialect_name = conn.dialect.name
+
+ # MySQL: information_schema to check constraints
+ if dialect_name == 'mysql':
+ op.execute(
+ sa.text(
+ """
+ SET @exists := (
+ SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
+ WHERE CONSTRAINT_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'price_items'
+ AND CONSTRAINT_NAME = 'uq_price_items_unique_tier'
+ );
+ """
+ )
+ )
+ op.execute(sa.text("""SET @q := IF(@exists > 0, 'ALTER TABLE price_items DROP INDEX uq_price_items_unique_tier', 'SELECT 1'); PREPARE stmt FROM @q; EXECUTE stmt; DEALLOCATE PREPARE stmt;"""))
+ else:
+ # Generic drop constraint best-effort
+ try:
+ op.drop_constraint('uq_price_items_unique_tier', 'price_items', type_='unique')
+ except Exception:
+ pass
+
+ # 3) Make currency_id NOT NULL
+ op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=False, existing_nullable=True)
+
+ # 4) Create new unique constraint including currency_id
+ # For MySQL, unique constraints are created as indexes as well
+ op.create_unique_constraint(
+ 'uq_price_items_unique_tier_currency',
+ 'price_items',
+ ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id']
+ )
+
+
+def downgrade() -> None:
+ # Drop new unique constraint
+ try:
+ op.drop_constraint('uq_price_items_unique_tier_currency', 'price_items', type_='unique')
+ except Exception:
+ pass
+
+ # Make currency_id nullable again
+ op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=True, existing_nullable=False)
+
+ # Recreate old unique constraint
+ op.create_unique_constraint(
+ 'uq_price_items_unique_tier',
+ 'price_items',
+ ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty']
+ )
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py b/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
new file mode 100644
index 0000000..6807842
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20251001_001101_drop_price_list_currency_default_unit'
+down_revision = '20251001_000601_update_price_items_currency_unique_not_null'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ dialect = conn.dialect.name
+
+ # Try to drop FK on price_lists.currency_id if exists
+ if dialect == 'mysql':
+ # Find foreign key constraint name dynamically and drop it
+ op.execute(sa.text(
+ """
+ SET @fk_name := (
+ SELECT CONSTRAINT_NAME
+ FROM information_schema.KEY_COLUMN_USAGE
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'price_lists'
+ AND COLUMN_NAME = 'currency_id'
+ AND REFERENCED_TABLE_NAME IS NOT NULL
+ LIMIT 1
+ );
+ """
+ ))
+ op.execute(sa.text(
+ """
+ SET @q := IF(@fk_name IS NOT NULL, CONCAT('ALTER TABLE price_lists DROP FOREIGN KEY ', @fk_name), 'SELECT 1');
+ PREPARE stmt FROM @q; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+ """
+ ))
+ # Drop indexes on columns if any
+ for col in ('currency_id', 'default_unit_id'):
+ op.execute(sa.text(
+ f"""
+ SET @idx := (
+ SELECT INDEX_NAME FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'price_lists' AND COLUMN_NAME = '{col}' LIMIT 1
+ );
+ """
+ ))
+ op.execute(sa.text(
+ """
+ SET @qi := IF(@idx IS NOT NULL, CONCAT('ALTER TABLE price_lists DROP INDEX ', @idx), 'SELECT 1');
+ PREPARE s FROM @qi; EXECUTE s; DEALLOCATE PREPARE s;
+ """
+ ))
+
+ # Finally drop columns if they exist
+ op.execute(sa.text("ALTER TABLE price_lists DROP COLUMN IF EXISTS currency_id"))
+ op.execute(sa.text("ALTER TABLE price_lists DROP COLUMN IF EXISTS default_unit_id"))
+ else:
+ # Best-effort: drop constraint by common names, then drop columns
+ for name in ('price_lists_currency_id_fkey', 'fk_price_lists_currency_id', 'price_lists_currency_id_fk'):
+ try:
+ op.drop_constraint(name, 'price_lists', type_='foreignkey')
+ break
+ except Exception:
+ pass
+ try:
+ op.drop_column('price_lists', 'currency_id')
+ except Exception:
+ pass
+ try:
+ op.drop_column('price_lists', 'default_unit_id')
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ dialect = conn.dialect.name
+
+ # Recreate columns (nullable) and FK back to currencies
+ with op.batch_alter_table('price_lists') as batch_op:
+ try:
+ batch_op.add_column(sa.Column('currency_id', sa.Integer(), nullable=True))
+ except Exception:
+ pass
+ try:
+ batch_op.add_column(sa.Column('default_unit_id', sa.Integer(), nullable=True))
+ except Exception:
+ pass
+
+ # Add FK for currency_id where supported
+ try:
+ op.create_foreign_key(
+ 'fk_price_lists_currency_id',
+ 'price_lists', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT'
+ )
+ except Exception:
+ pass
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/4b2ea782bcb3_merge_heads.py b/hesabixAPI/build/lib/migrations/versions/4b2ea782bcb3_merge_heads.py
new file mode 100644
index 0000000..a049ce7
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/4b2ea782bcb3_merge_heads.py
@@ -0,0 +1,29 @@
+"""merge_heads
+
+Revision ID: 4b2ea782bcb3
+Revises: 20250120_000003, 20250927_000022_add_person_commission_fields
+Create Date: 2025-09-28 20:59:14.557570
+
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+
+# revision identifiers, used by Alembic.
+revision = '4b2ea782bcb3'
+down_revision = ('20250120_000002', '20250927_000022_add_person_commission_fields')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # این migration صرفاً برای ادغام شاخهها است و تغییری در اسکیما ایجاد نمیکند
+ pass
+
+
+def downgrade() -> None:
+ # بدون تغییر
+ pass
diff --git a/hesabixAPI/build/lib/migrations/versions/5553f8745c6e_add_support_tables.py b/hesabixAPI/build/lib/migrations/versions/5553f8745c6e_add_support_tables.py
new file mode 100644
index 0000000..c47f75f
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/5553f8745c6e_add_support_tables.py
@@ -0,0 +1,132 @@
+"""add_support_tables
+
+Revision ID: 5553f8745c6e
+Revises: 20250117_000007
+Create Date: 2025-09-20 14:02:19.543853
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = '5553f8745c6e'
+down_revision = '20250117_000007'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('support_categories',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
+ op.create_table('support_priorities',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=50), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('color', sa.String(length=7), nullable=True),
+ sa.Column('order', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
+ op.create_table('support_statuses',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=50), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('color', sa.String(length=7), nullable=True),
+ sa.Column('is_final', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
+ op.create_table('support_tickets',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('title', sa.String(length=255), nullable=False),
+ sa.Column('description', sa.Text(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('category_id', sa.Integer(), nullable=False),
+ sa.Column('priority_id', sa.Integer(), nullable=False),
+ sa.Column('status_id', sa.Integer(), nullable=False),
+ sa.Column('assigned_operator_id', sa.Integer(), nullable=True),
+ sa.Column('is_internal', sa.Boolean(), nullable=False),
+ sa.Column('closed_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'),
+ sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False)
+ op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False)
+ op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False)
+ op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
+ op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False)
+ op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False)
+ op.create_table('support_messages',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('ticket_id', sa.Integer(), nullable=False),
+ sa.Column('sender_id', sa.Integer(), nullable=False),
+ sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False),
+ sa.Column('content', sa.Text(), nullable=False),
+ sa.Column('is_internal', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False)
+ op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False)
+ op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False)
+ op.alter_column('businesses', 'business_type',
+ existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'),
+ type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'),
+ existing_nullable=False)
+ op.alter_column('businesses', 'business_field',
+ existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'),
+ type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'),
+ existing_nullable=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('businesses', 'business_field',
+ existing_type=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'),
+ type_=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'),
+ existing_nullable=False)
+ op.alter_column('businesses', 'business_type',
+ existing_type=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'),
+ type_=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'),
+ existing_nullable=False)
+ op.drop_index(op.f('ix_support_messages_ticket_id'), table_name='support_messages')
+ op.drop_index(op.f('ix_support_messages_sender_type'), table_name='support_messages')
+ op.drop_index(op.f('ix_support_messages_sender_id'), table_name='support_messages')
+ op.drop_table('support_messages')
+ op.drop_index(op.f('ix_support_tickets_user_id'), table_name='support_tickets')
+ op.drop_index(op.f('ix_support_tickets_title'), table_name='support_tickets')
+ op.drop_index(op.f('ix_support_tickets_status_id'), table_name='support_tickets')
+ op.drop_index(op.f('ix_support_tickets_priority_id'), table_name='support_tickets')
+ op.drop_index(op.f('ix_support_tickets_category_id'), table_name='support_tickets')
+ op.drop_index(op.f('ix_support_tickets_assigned_operator_id'), table_name='support_tickets')
+ op.drop_table('support_tickets')
+ op.drop_index(op.f('ix_support_statuses_name'), table_name='support_statuses')
+ op.drop_table('support_statuses')
+ op.drop_index(op.f('ix_support_priorities_name'), table_name='support_priorities')
+ op.drop_table('support_priorities')
+ op.drop_index(op.f('ix_support_categories_name'), table_name='support_categories')
+ op.drop_table('support_categories')
+ # ### end Alembic commands ###
diff --git a/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py b/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py
new file mode 100644
index 0000000..2079041
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py
@@ -0,0 +1,50 @@
+"""create_tax_units_table
+
+Revision ID: 9f9786ae7191
+Revises: caf3f4ef4b76
+Create Date: 2025-09-30 14:47:28.281817
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9f9786ae7191'
+down_revision = 'caf3f4ef4b76'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create tax_units table
+ op.create_table('tax_units',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'),
+ sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'),
+ sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'),
+ sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'),
+ sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ # Create indexes
+ op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False)
+
+ # Add foreign key constraint to products table
+ op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL')
+
+
+def downgrade() -> None:
+ # Drop foreign key constraint from products table
+ op.drop_constraint(None, 'products', type_='foreignkey')
+
+ # Drop indexes
+ op.drop_index(op.f('ix_tax_units_business_id'), table_name='tax_units')
+
+ # Drop tax_units table
+ op.drop_table('tax_units')
diff --git a/hesabixAPI/build/lib/migrations/versions/caf3f4ef4b76_add_tax_units_table.py b/hesabixAPI/build/lib/migrations/versions/caf3f4ef4b76_add_tax_units_table.py
new file mode 100644
index 0000000..e27ba39
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/caf3f4ef4b76_add_tax_units_table.py
@@ -0,0 +1,169 @@
+"""add_tax_units_table
+
+Revision ID: caf3f4ef4b76
+Revises: 20250929_000501_add_products_and_pricing
+Create Date: 2025-09-30 14:46:58.614162
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = 'caf3f4ef4b76'
+down_revision = '20250929_000501_add_products_and_pricing'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('persons', 'code',
+ existing_type=mysql.INTEGER(),
+ comment='کد یکتا در هر کسب و کار',
+ existing_nullable=True)
+ op.alter_column('persons', 'person_type',
+ existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
+ comment='نوع شخص',
+ existing_nullable=False)
+ op.alter_column('persons', 'person_types',
+ existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
+ comment='لیست انواع شخص به صورت JSON',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sale_percent',
+ existing_type=mysql.DECIMAL(precision=5, scale=2),
+ comment='درصد پورسانت از فروش',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_return_percent',
+ existing_type=mysql.DECIMAL(precision=5, scale=2),
+ comment='درصد پورسانت از برگشت از فروش',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_amount',
+ existing_type=mysql.DECIMAL(precision=12, scale=2),
+ comment='مبلغ فروش مبنا برای پورسانت',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_return_amount',
+ existing_type=mysql.DECIMAL(precision=12, scale=2),
+ comment='مبلغ برگشت از فروش مبنا برای پورسانت',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_exclude_discounts',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment='عدم محاسبه تخفیف در پورسانت',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_exclude_additions_deductions',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_post_in_invoice_document',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment='ثبت پورسانت در سند حسابداری فاکتور',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('price_items', 'tier_name',
+ existing_type=mysql.VARCHAR(length=64),
+ comment='نام پله قیمت (تکی/عمده/همکار/...)',
+ existing_nullable=False)
+ op.create_index(op.f('ix_price_items_currency_id'), 'price_items', ['currency_id'], unique=False)
+ op.create_index(op.f('ix_price_items_unit_id'), 'price_items', ['unit_id'], unique=False)
+ op.create_index(op.f('ix_price_lists_currency_id'), 'price_lists', ['currency_id'], unique=False)
+ op.create_index(op.f('ix_price_lists_default_unit_id'), 'price_lists', ['default_unit_id'], unique=False)
+ op.create_index(op.f('ix_price_lists_name'), 'price_lists', ['name'], unique=False)
+ op.alter_column('products', 'item_type',
+ existing_type=mysql.ENUM('کالا', 'خدمت'),
+ comment='نوع آیتم (کالا/خدمت)',
+ existing_nullable=False)
+ op.alter_column('products', 'code',
+ existing_type=mysql.VARCHAR(length=64),
+ comment='کد یکتا در هر کسب\u200cوکار',
+ existing_nullable=False)
+ op.create_index(op.f('ix_products_category_id'), 'products', ['category_id'], unique=False)
+ op.create_index(op.f('ix_products_main_unit_id'), 'products', ['main_unit_id'], unique=False)
+ op.create_index(op.f('ix_products_secondary_unit_id'), 'products', ['secondary_unit_id'], unique=False)
+ op.create_index(op.f('ix_products_tax_type_id'), 'products', ['tax_type_id'], unique=False)
+ op.create_index(op.f('ix_products_tax_unit_id'), 'products', ['tax_unit_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_products_tax_unit_id'), table_name='products')
+ op.drop_index(op.f('ix_products_tax_type_id'), table_name='products')
+ op.drop_index(op.f('ix_products_secondary_unit_id'), table_name='products')
+ op.drop_index(op.f('ix_products_main_unit_id'), table_name='products')
+ op.drop_index(op.f('ix_products_category_id'), table_name='products')
+ op.alter_column('products', 'code',
+ existing_type=mysql.VARCHAR(length=64),
+ comment=None,
+ existing_comment='کد یکتا در هر کسب\u200cوکار',
+ existing_nullable=False)
+ op.alter_column('products', 'item_type',
+ existing_type=mysql.ENUM('کالا', 'خدمت'),
+ comment=None,
+ existing_comment='نوع آیتم (کالا/خدمت)',
+ existing_nullable=False)
+ op.drop_index(op.f('ix_price_lists_name'), table_name='price_lists')
+ op.drop_index(op.f('ix_price_lists_default_unit_id'), table_name='price_lists')
+ op.drop_index(op.f('ix_price_lists_currency_id'), table_name='price_lists')
+ op.drop_index(op.f('ix_price_items_unit_id'), table_name='price_items')
+ op.drop_index(op.f('ix_price_items_currency_id'), table_name='price_items')
+ op.alter_column('price_items', 'tier_name',
+ existing_type=mysql.VARCHAR(length=64),
+ comment=None,
+ existing_comment='نام پله قیمت (تکی/عمده/همکار/...)',
+ existing_nullable=False)
+ op.alter_column('persons', 'commission_post_in_invoice_document',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment=None,
+ existing_comment='ثبت پورسانت در سند حسابداری فاکتور',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_exclude_additions_deductions',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment=None,
+ existing_comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_exclude_discounts',
+ existing_type=mysql.TINYINT(display_width=1),
+ comment=None,
+ existing_comment='عدم محاسبه تخفیف در پورسانت',
+ existing_nullable=False,
+ existing_server_default=sa.text("'0'"))
+ op.alter_column('persons', 'commission_sales_return_amount',
+ existing_type=mysql.DECIMAL(precision=12, scale=2),
+ comment=None,
+ existing_comment='مبلغ برگشت از فروش مبنا برای پورسانت',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_amount',
+ existing_type=mysql.DECIMAL(precision=12, scale=2),
+ comment=None,
+ existing_comment='مبلغ فروش مبنا برای پورسانت',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sales_return_percent',
+ existing_type=mysql.DECIMAL(precision=5, scale=2),
+ comment=None,
+ existing_comment='درصد پورسانت از برگشت از فروش',
+ existing_nullable=True)
+ op.alter_column('persons', 'commission_sale_percent',
+ existing_type=mysql.DECIMAL(precision=5, scale=2),
+ comment=None,
+ existing_comment='درصد پورسانت از فروش',
+ existing_nullable=True)
+ op.alter_column('persons', 'person_types',
+ existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
+ comment=None,
+ existing_comment='لیست انواع شخص به صورت JSON',
+ existing_nullable=True)
+ op.alter_column('persons', 'person_type',
+ existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
+ comment=None,
+ existing_comment='نوع شخص',
+ existing_nullable=False)
+ op.alter_column('persons', 'code',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='کد یکتا در هر کسب و کار',
+ existing_nullable=True)
+ # ### end Alembic commands ###
diff --git a/hesabixAPI/build/lib/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py b/hesabixAPI/build/lib/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
new file mode 100644
index 0000000..bc3ad76
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
@@ -0,0 +1,178 @@
+"""sync person_type enum values_callable to persian
+
+Revision ID: d3e84892c1c2
+Revises: 20250927_000020_add_share_count_and_shareholder_type
+Create Date: 2025-09-27 19:18:06.253391
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+from sqlalchemy import text
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = 'd3e84892c1c2'
+down_revision = '20250927_000020_add_share_count_and_shareholder_type'
+branch_labels = None
+depends_on = None
+
+
+def _table_exists(conn, name: str) -> bool:
+ res = conn.execute(text(
+ "SELECT COUNT(*) FROM information_schema.tables WHERE table_name=:t"
+ ), {"t": name})
+ return (res.scalar() or 0) > 0
+
+
+def _column_exists(conn, table: str, col: str) -> bool:
+ res = conn.execute(text(
+ "SELECT COUNT(*) FROM information_schema.columns WHERE table_name=:t AND column_name=:c"
+ ), {"t": table, "c": col})
+ return (res.scalar() or 0) > 0
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - guarded for idempotency ###
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ existing_tables = set(inspector.get_table_names())
+
+ if 'storage_configs' not in existing_tables:
+ op.create_table('storage_configs',
+ sa.Column('id', sa.String(length=36), nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('storage_type', sa.String(length=20), nullable=False),
+ sa.Column('is_default', sa.Boolean(), nullable=False),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('config_data', sa.JSON(), nullable=False),
+ sa.Column('created_by', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ if 'file_storage' not in existing_tables:
+ op.create_table('file_storage',
+ sa.Column('id', sa.String(length=36), nullable=False),
+ sa.Column('original_name', sa.String(length=255), nullable=False),
+ sa.Column('stored_name', sa.String(length=255), nullable=False),
+ sa.Column('file_path', sa.String(length=500), nullable=False),
+ sa.Column('file_size', sa.Integer(), nullable=False),
+ sa.Column('mime_type', sa.String(length=100), nullable=False),
+ sa.Column('storage_type', sa.String(length=20), nullable=False),
+ sa.Column('storage_config_id', sa.String(length=36), nullable=True),
+ sa.Column('uploaded_by', sa.Integer(), nullable=False),
+ sa.Column('module_context', sa.String(length=50), nullable=False),
+ sa.Column('context_id', sa.String(length=36), nullable=True),
+ sa.Column('developer_data', sa.JSON(), nullable=True),
+ sa.Column('checksum', sa.String(length=64), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('is_temporary', sa.Boolean(), nullable=False),
+ sa.Column('is_verified', sa.Boolean(), nullable=False),
+ sa.Column('verification_token', sa.String(length=100), nullable=True),
+ sa.Column('last_verified_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(['storage_config_id'], ['storage_configs.id'], ),
+ sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ if 'file_verifications' not in existing_tables:
+ op.create_table('file_verifications',
+ sa.Column('id', sa.String(length=36), nullable=False),
+ sa.Column('file_id', sa.String(length=36), nullable=False),
+ sa.Column('module_name', sa.String(length=50), nullable=False),
+ sa.Column('verification_token', sa.String(length=100), nullable=False),
+ sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('verified_by', sa.Integer(), nullable=True),
+ sa.Column('verification_data', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['file_id'], ['file_storage.id'], ),
+ sa.ForeignKeyConstraint(['verified_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # Drop index if exists
+ try:
+ bind = op.get_bind()
+ insp = inspect(bind)
+ if 'fiscal_years' in insp.get_table_names():
+ existing_indexes = {idx['name'] for idx in insp.get_indexes('fiscal_years')}
+ if 'ix_fiscal_years_title' in existing_indexes:
+ op.drop_index(op.f('ix_fiscal_years_title'), table_name='fiscal_years')
+ except Exception:
+ pass
+
+ conn = op.get_bind()
+ if _table_exists(conn, 'person_bank_accounts') and _column_exists(conn, 'person_bank_accounts', 'person_id'):
+ op.alter_column('person_bank_accounts', 'person_id',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='شناسه شخص',
+ existing_nullable=False)
+
+ if _table_exists(conn, 'persons'):
+ if _column_exists(conn, 'persons', 'business_id'):
+ op.alter_column('persons', 'business_id',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='شناسه کسب و کار',
+ existing_nullable=False)
+ if _column_exists(conn, 'persons', 'code'):
+ op.alter_column('persons', 'code',
+ existing_type=mysql.INTEGER(),
+ comment='کد یکتا در هر کسب و کار',
+ existing_nullable=True)
+ if _column_exists(conn, 'persons', 'person_types'):
+ op.alter_column('persons', 'person_types',
+ existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
+ comment='لیست انواع شخص به صورت JSON',
+ existing_nullable=True)
+ if _column_exists(conn, 'persons', 'share_count'):
+ op.alter_column('persons', 'share_count',
+ existing_type=mysql.INTEGER(),
+ comment='تعداد سهام (فقط برای سهامدار)',
+ existing_nullable=True)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ conn = op.get_bind()
+ if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'share_count'):
+ op.alter_column('persons', 'share_count',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='تعداد سهام (فقط برای سهامدار)',
+ existing_nullable=True)
+ if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'person_types'):
+ op.alter_column('persons', 'person_types',
+ existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
+ comment=None,
+ existing_comment='لیست انواع شخص به صورت JSON',
+ existing_nullable=True)
+ if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'code'):
+ op.alter_column('persons', 'code',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='کد یکتا در هر کسب و کار',
+ existing_nullable=True)
+ if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'business_id'):
+ op.alter_column('persons', 'business_id',
+ existing_type=mysql.INTEGER(),
+ comment='شناسه کسب و کار',
+ existing_nullable=False)
+ if _table_exists(conn, 'person_bank_accounts') and _column_exists(conn, 'person_bank_accounts', 'person_id'):
+ op.alter_column('person_bank_accounts', 'person_id',
+ existing_type=mysql.INTEGER(),
+ comment='شناسه شخص',
+ existing_nullable=False)
+ op.create_index(op.f('ix_fiscal_years_title'), 'fiscal_years', ['title'], unique=False)
+ op.drop_table('file_verifications')
+ op.drop_table('file_storage')
+ op.drop_table('storage_configs')
+ # ### end Alembic commands ###
diff --git a/hesabixAPI/build/lib/migrations/versions/f876bfa36805_merge_multiple_heads.py b/hesabixAPI/build/lib/migrations/versions/f876bfa36805_merge_multiple_heads.py
new file mode 100644
index 0000000..d318963
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/f876bfa36805_merge_multiple_heads.py
@@ -0,0 +1,24 @@
+"""merge multiple heads
+
+Revision ID: f876bfa36805
+Revises: 20250117_000009, 20250120_000002, 20250927_000017_add_account_id_to_document_lines
+Create Date: 2025-09-27 12:29:57.080003
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'f876bfa36805'
+down_revision = ('20250117_000009', '20250120_000002', '20250927_000017_add_account_id_to_document_lines')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/hesabixAPI/build/lib/tests/__init__.py b/hesabixAPI/build/lib/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/hesabixAPI/build/lib/tests/test_health.py b/hesabixAPI/build/lib/tests/test_health.py
new file mode 100644
index 0000000..21a8759
--- /dev/null
+++ b/hesabixAPI/build/lib/tests/test_health.py
@@ -0,0 +1,14 @@
+import pytest
+from httpx import AsyncClient, ASGITransport
+from fastapi import status
+
+from app.main import app
+
+
+@pytest.mark.asyncio
+async def test_health_returns_ok() -> None:
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ response = await ac.get("/api/v1/health")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"status": "ok"}
diff --git a/hesabixAPI/build/lib/tests/test_permissions.py b/hesabixAPI/build/lib/tests/test_permissions.py
new file mode 100644
index 0000000..3b8d700
--- /dev/null
+++ b/hesabixAPI/build/lib/tests/test_permissions.py
@@ -0,0 +1,226 @@
+"""
+تستهای سیستم دسترسی دو سطحی
+"""
+
+import pytest
+from unittest.mock import Mock
+from app.core.auth_dependency import AuthContext
+from adapters.db.models.user import User
+
+
+class TestAuthContextPermissions:
+ """تست کلاس AuthContext برای بررسی دسترسیها"""
+
+ def test_app_permissions(self):
+ """تست دسترسیهای اپلیکیشن"""
+ # ایجاد کاربر با دسترسی superadmin
+ user = Mock(spec=User)
+ user.app_permissions = {"superadmin": True}
+
+ ctx = AuthContext(user=user, api_key_id=1)
+
+ # تست دسترسیهای اپلیکیشن - SuperAdmin باید تمام دسترسیها را داشته باشد
+ assert ctx.has_app_permission("superadmin") == True
+ assert ctx.has_app_permission("user_management") == True # خودکار
+ assert ctx.has_app_permission("business_management") == True # خودکار
+ assert ctx.has_app_permission("system_settings") == True # خودکار
+ assert ctx.is_superadmin() == True
+ assert ctx.can_manage_users() == True
+ assert ctx.can_manage_businesses() == True
+
+ def test_app_permissions_normal_user(self):
+ """تست دسترسیهای اپلیکیشن برای کاربر عادی"""
+ user = Mock(spec=User)
+ user.app_permissions = {"user_management": True}
+
+ ctx = AuthContext(user=user, api_key_id=1)
+
+ # تست دسترسیهای اپلیکیشن
+ assert ctx.has_app_permission("superadmin") == False
+ assert ctx.has_app_permission("user_management") == True
+ assert ctx.has_app_permission("business_management") == False
+ assert ctx.is_superadmin() == False
+ assert ctx.can_manage_users() == True
+ assert ctx.can_manage_businesses() == False
+
+ def test_business_permissions(self):
+ """تست دسترسیهای کسب و کار"""
+ user = Mock(spec=User)
+ user.app_permissions = {}
+
+ # Mock دیتابیس
+ db = Mock()
+ business_permission_repo = Mock()
+ business_permission_repo.get_by_user_and_business.return_value = Mock(
+ business_permissions={
+ "sales": {"write": True, "delete": True},
+ "accounting": {"write": True}
+ }
+ )
+
+ ctx = AuthContext(
+ user=user,
+ api_key_id=1,
+ business_id=1,
+ db=db
+ )
+
+ # Mock کردن repository
+ with pytest.MonkeyPatch().context() as m:
+ m.setattr("app.core.auth_dependency.BusinessPermissionRepository",
+ lambda db: business_permission_repo)
+
+ # تست دسترسیهای کسب و کار
+ assert ctx.has_business_permission("sales", "write") == True
+ assert ctx.has_business_permission("sales", "delete") == True
+ assert ctx.has_business_permission("sales", "approve") == False
+ assert ctx.has_business_permission("accounting", "write") == True
+ assert ctx.has_business_permission("purchases", "read") == False
+ assert ctx.can_read_section("sales") == True
+ assert ctx.can_write_section("sales") == True
+ assert ctx.can_delete_section("sales") == True
+ assert ctx.can_approve_section("sales") == False
+
+ def test_empty_business_permissions(self):
+ """تست دسترسیهای خالی کسب و کار"""
+ user = Mock(spec=User)
+ user.app_permissions = {}
+
+ ctx = AuthContext(user=user, api_key_id=1, business_id=1)
+ ctx.business_permissions = {}
+
+ # اگر دسترسیها خالی باشد، فقط خواندن مجاز است
+ assert ctx.has_business_permission("sales", "read") == False
+ assert ctx.has_business_permission("sales", "write") == False
+ assert ctx.can_read_section("sales") == False
+
+ def test_section_with_empty_permissions(self):
+ """تست بخش با دسترسیهای خالی"""
+ user = Mock(spec=User)
+ user.app_permissions = {}
+
+ ctx = AuthContext(user=user, api_key_id=1, business_id=1)
+ ctx.business_permissions = {
+ "sales": {}, # بخش خالی
+ "accounting": {"write": True}
+ }
+
+ # بخش خالی فقط خواندن مجاز است
+ assert ctx.has_business_permission("sales", "read") == True
+ assert ctx.has_business_permission("sales", "write") == False
+ assert ctx.has_business_permission("accounting", "write") == True
+
+ def test_superadmin_override(self):
+ """تست override کردن دسترسیها توسط superadmin"""
+ user = Mock(spec=User)
+ user.app_permissions = {"superadmin": True}
+
+ ctx = AuthContext(user=user, api_key_id=1, business_id=1)
+ ctx.business_permissions = {} # بدون دسترسی کسب و کار
+
+ # SuperAdmin باید دسترسی کامل داشته باشد
+ assert ctx.has_any_permission("sales", "write") == True
+ assert ctx.has_any_permission("accounting", "delete") == True
+ assert ctx.can_access_business(999) == True # هر کسب و کاری
+
+ def test_business_access_control(self):
+ """تست کنترل دسترسی به کسب و کار"""
+ user = Mock(spec=User)
+ user.app_permissions = {}
+
+ ctx = AuthContext(user=user, api_key_id=1, business_id=1)
+
+ # فقط به کسب و کار خود دسترسی دارد
+ assert ctx.can_access_business(1) == True
+ assert ctx.can_access_business(2) == False
+
+ # SuperAdmin به همه دسترسی دارد
+ user.app_permissions = {"superadmin": True}
+ ctx = AuthContext(user=user, api_key_id=1, business_id=1)
+ assert ctx.can_access_business(999) == True
+
+ def test_business_owner_permissions(self):
+ """تست دسترسیهای مالک کسب و کار"""
+ user = Mock(spec=User)
+ user.app_permissions = {}
+ user.id = 1
+
+ # Mock دیتابیس و کسب و کار
+ db = Mock()
+ business = Mock()
+ business.owner_id = 1 # کاربر مالک است
+
+ ctx = AuthContext(user=user, api_key_id=1, business_id=1, db=db)
+
+ # Mock کردن Business model
+ with pytest.MonkeyPatch().context() as m:
+ m.setattr("app.core.auth_dependency.Business", Mock)
+ db.get.return_value = business
+
+ # مالک کسب و کار باید تمام دسترسیها را داشته باشد
+ assert ctx.is_business_owner() == True
+ assert ctx.has_business_permission("sales", "write") == True
+ assert ctx.has_business_permission("sales", "delete") == True
+ assert ctx.has_business_permission("accounting", "write") == True
+ assert ctx.has_business_permission("reports", "export") == True
+ assert ctx.can_read_section("sales") == True
+ assert ctx.can_write_section("sales") == True
+ assert ctx.can_delete_section("sales") == True
+ assert ctx.can_approve_section("sales") == True
+
+ def test_business_owner_override(self):
+ """تست override کردن دسترسیها توسط مالک کسب و کار"""
+ user = Mock(spec=User)
+ user.app_permissions = {}
+ user.id = 1
+
+ # Mock دیتابیس و کسب و کار
+ db = Mock()
+ business = Mock()
+ business.owner_id = 1
+
+ ctx = AuthContext(user=user, api_key_id=1, business_id=1, db=db)
+ ctx.business_permissions = {} # بدون دسترسی کسب و کار
+
+ # Mock کردن Business model
+ with pytest.MonkeyPatch().context() as m:
+ m.setattr("app.core.auth_dependency.Business", Mock)
+ db.get.return_value = business
+
+ # مالک کسب و کار باید دسترسی کامل داشته باشد حتی بدون business_permissions
+ assert ctx.is_business_owner() == True
+ assert ctx.has_business_permission("sales", "write") == True
+ assert ctx.has_business_permission("accounting", "delete") == True
+ assert ctx.can_read_section("purchases") == True
+ assert ctx.can_write_section("inventory") == True
+
+
+class TestPermissionDecorators:
+ """تست decorator های دسترسی"""
+
+ def test_require_app_permission(self):
+ """تست decorator دسترسی اپلیکیشن"""
+ from app.core.permissions import require_app_permission
+
+ @require_app_permission("user_management")
+ def test_function():
+ return "success"
+
+ # این تست نیاز به mock کردن get_current_user دارد
+ # که در محیط تست پیچیدهتر است
+ pass
+
+ def test_require_business_permission(self):
+ """تست decorator دسترسی کسب و کار"""
+ from app.core.permissions import require_business_permission
+
+ @require_business_permission("sales", "write")
+ def test_function():
+ return "success"
+
+ # این تست نیاز به mock کردن get_current_user دارد
+ pass
+
+
+if __name__ == "__main__":
+ pytest.main([__file__])
diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
index d53fd3d..430e268 100644
--- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
+++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
@@ -152,6 +152,9 @@ migrations/versions/20250929_000201_drop_type_from_categories.py
migrations/versions/20250929_000301_add_product_attributes_table.py
migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py
migrations/versions/20250929_000501_add_products_and_pricing.py
+migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
+migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
+migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py
migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/9f9786ae7191_create_tax_units_table.py
diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo
index be05735..7ac2fb0 100644
Binary files a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo and b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo differ
diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo
index 6e1b411..7d4df5e 100644
Binary files a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo and b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo differ
diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
index ec270ea..f5417ee 100644
--- a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
+++ b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
@@ -1,3 +1,24 @@
+# Inventory / Products & Price Lists
+msgid "PRODUCT_CREATED"
+msgstr "آیتم با موفقیت ایجاد شد"
+
+msgid "PRODUCT_UPDATED"
+msgstr "آیتم با موفقیت ویرایش شد"
+
+msgid "PRICE_LIST_CREATED"
+msgstr "لیست قیمت ایجاد شد"
+
+msgid "PRICE_LIST_UPDATED"
+msgstr "لیست قیمت بروزرسانی شد"
+
+msgid "PRICE_ITEM_UPSERTED"
+msgstr "قیمت ثبت شد"
+
+msgid "PRODUCTS_IMPORT_RESULT"
+msgstr "نتیجه ایمپورت محصولات"
+
+msgid "EMPTY_FILE"
+msgstr "فایل خالی است"
msgid ""
msgstr ""
"Project-Id-Version: hesabix-api\n"
diff --git a/hesabixAPI/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py b/hesabixAPI/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
new file mode 100644
index 0000000..e3c830b
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20251001_000601_update_price_items_currency_unique_not_null'
+down_revision = '20250929_000501_add_products_and_pricing'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # 1) Backfill price_items.currency_id from price_lists.currency_id where NULL
+ op.execute(
+ sa.text(
+ """
+ UPDATE price_items pi
+ JOIN price_lists pl ON pl.id = pi.price_list_id
+ SET pi.currency_id = pl.currency_id
+ WHERE pi.currency_id IS NULL
+ """
+ )
+ )
+
+ # 2) Drop old unique constraint if exists
+ conn = op.get_bind()
+ dialect_name = conn.dialect.name
+
+ if dialect_name == 'mysql':
+ # Check via information_schema and drop index if present
+ exists = conn.execute(sa.text(
+ """
+ SELECT COUNT(*) as cnt
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'price_items'
+ AND INDEX_NAME = 'uq_price_items_unique_tier'
+ """
+ )).scalar() or 0
+ if int(exists) > 0:
+ conn.execute(sa.text("ALTER TABLE price_items DROP INDEX uq_price_items_unique_tier"))
+ else:
+ # Generic drop constraint best-effort
+ try:
+ op.drop_constraint('uq_price_items_unique_tier', 'price_items', type_='unique')
+ except Exception:
+ pass
+
+ # 3) Make currency_id NOT NULL
+ op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=False, existing_nullable=True)
+
+ # 4) Create new unique constraint including currency_id (idempotent)
+ if dialect_name == 'mysql':
+ exists_uc = conn.execute(sa.text(
+ """
+ SELECT COUNT(*) as cnt
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'price_items'
+ AND INDEX_NAME = 'uq_price_items_unique_tier_currency'
+ """
+ )).scalar() or 0
+ if int(exists_uc) == 0:
+ op.create_unique_constraint(
+ 'uq_price_items_unique_tier_currency',
+ 'price_items',
+ ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id']
+ )
+ else:
+ try:
+ op.create_unique_constraint(
+ 'uq_price_items_unique_tier_currency',
+ 'price_items',
+ ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id']
+ )
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ # Drop new unique constraint
+ try:
+ op.drop_constraint('uq_price_items_unique_tier_currency', 'price_items', type_='unique')
+ except Exception:
+ pass
+
+ # Make currency_id nullable again
+ op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=True, existing_nullable=False)
+
+ # Recreate old unique constraint
+ op.create_unique_constraint(
+ 'uq_price_items_unique_tier',
+ 'price_items',
+ ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty']
+ )
+
+
diff --git a/hesabixAPI/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py b/hesabixAPI/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
new file mode 100644
index 0000000..1af902e
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20251001_001101_drop_price_list_currency_default_unit'
+down_revision = '20251001_000601_update_price_items_currency_unique_not_null'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ dialect = conn.dialect.name
+
+ # Try to drop FK on price_lists.currency_id if exists
+ if dialect == 'mysql':
+ # Find foreign key constraint name dynamically and drop it
+ fk_rows = conn.execute(sa.text(
+ """
+ SELECT CONSTRAINT_NAME
+ FROM information_schema.KEY_COLUMN_USAGE
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'price_lists'
+ AND COLUMN_NAME = 'currency_id'
+ AND REFERENCED_TABLE_NAME IS NOT NULL
+ GROUP BY CONSTRAINT_NAME
+ """
+ )).fetchall()
+ for (fk_name,) in fk_rows:
+ conn.execute(sa.text(f"ALTER TABLE price_lists DROP FOREIGN KEY {fk_name}"))
+
+ # Finally drop columns if they exist (manual check)
+ for col in ('currency_id', 'default_unit_id'):
+ exists = conn.execute(sa.text(
+ """
+ SELECT COUNT(*) as cnt FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'price_lists' AND COLUMN_NAME = :col
+ """
+ ), {"col": col}).scalar() or 0
+ if int(exists) > 0:
+ conn.execute(sa.text(f"ALTER TABLE price_lists DROP COLUMN {col}"))
+ else:
+ # Best-effort: drop constraint by common names, then drop columns
+ for name in ('price_lists_currency_id_fkey', 'fk_price_lists_currency_id', 'price_lists_currency_id_fk'):
+ try:
+ op.drop_constraint(name, 'price_lists', type_='foreignkey')
+ break
+ except Exception:
+ pass
+ try:
+ op.drop_column('price_lists', 'currency_id')
+ except Exception:
+ pass
+ try:
+ op.drop_column('price_lists', 'default_unit_id')
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ dialect = conn.dialect.name
+
+ # Recreate columns (nullable) and FK back to currencies
+ with op.batch_alter_table('price_lists') as batch_op:
+ try:
+ batch_op.add_column(sa.Column('currency_id', sa.Integer(), nullable=True))
+ except Exception:
+ pass
+ try:
+ batch_op.add_column(sa.Column('default_unit_id', sa.Integer(), nullable=True))
+ except Exception:
+ pass
+
+ # Add FK for currency_id where supported
+ try:
+ op.create_foreign_key(
+ 'fk_price_lists_currency_id',
+ 'price_lists', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT'
+ )
+ except Exception:
+ pass
+
+
diff --git a/hesabixAPI/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py b/hesabixAPI/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py
new file mode 100644
index 0000000..16b64c1
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from alembic import op # noqa: F401
+import sqlalchemy as sa # noqa: F401
+
+
+# revision identifiers, used by Alembic.
+revision = '20251001_001201_merge_heads_drop_currency_tax_units'
+down_revision = (
+ '20251001_001101_drop_price_list_currency_default_unit',
+ '9f9786ae7191',
+)
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Merge only; no operations.
+ pass
+
+
+def downgrade() -> None:
+ # Merge only; no operations.
+ pass
+
+
diff --git a/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py b/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py
index 2079041..713bcd1 100644
--- a/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py
+++ b/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py
@@ -7,6 +7,7 @@ Create Date: 2025-09-30 14:47:28.281817
"""
from alembic import op
import sqlalchemy as sa
+from sqlalchemy import inspect
# revision identifiers, used by Alembic.
@@ -17,26 +18,33 @@ depends_on = None
def upgrade() -> None:
- # Create tax_units table
- op.create_table('tax_units',
- sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
- sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'),
- sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'),
- sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'),
- sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'),
- sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'),
- sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'),
- sa.Column('created_at', sa.DateTime(), nullable=False),
- sa.Column('updated_at', sa.DateTime(), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- mysql_charset='utf8mb4'
- )
-
- # Create indexes
- op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False)
-
- # Add foreign key constraint to products table
- op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL')
+ bind = op.get_bind()
+ inspector = inspect(bind)
+
+ created_tax_units = False
+ if not inspector.has_table('tax_units'):
+ op.create_table(
+ 'tax_units',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'),
+ sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'),
+ sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'),
+ sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'),
+ sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+ created_tax_units = True
+
+ if created_tax_units:
+ # Create indexes
+ op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False)
+
+ # Add foreign key constraint to products table
+ op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL')
def downgrade() -> None:
diff --git a/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart
index 263e9f9..a96dbd1 100644
--- a/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart
+++ b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart
@@ -5,6 +5,8 @@ import '../services/category_service.dart';
import '../services/product_attribute_service.dart';
import '../services/unit_service.dart';
import '../services/tax_service.dart';
+import '../services/price_list_service.dart';
+import '../services/currency_service.dart';
import '../core/api_client.dart';
class ProductFormController extends ChangeNotifier {
@@ -16,6 +18,8 @@ class ProductFormController extends ChangeNotifier {
late final ProductAttributeService _attributeService;
late final UnitService _unitService;
late final TaxService _taxService;
+ late final PriceListService _priceListService;
+ late final CurrencyService _currencyService;
ProductFormData _formData = ProductFormData();
bool _isLoading = false;
@@ -28,6 +32,11 @@ class ProductFormController extends ChangeNotifier {
List