progress in products

This commit is contained in:
Hesabix 2025-10-02 03:21:43 +03:30
parent 6b908eea5d
commit 1363270445
218 changed files with 24064 additions and 361 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
@ -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"""
<html lang=\"fa\" dir=\"rtl\">
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
<meta charset='utf-8'>
<style>
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-right {{
content: "صفحه " counter(page) " از " counter(pages);
@bottom-{ 'left' if is_fa else 'right' } {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
}}
@ -437,17 +462,17 @@ async def export_persons_pdf(
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: left;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">گزارش لیست اشخاص</div>
<div class=\"meta\">نام کسب‌وکار: {escape(business_name)}</div>
<div class=\"title\">{title_text}</div>
<div class=\"meta\">{label_biz}: {escape(business_name)}</div>
</div>
<div class=\"meta\">تاریخ گزارش: {escape(now)}</div>
<div class=\"meta\">{label_date}: {escape(now)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
@ -459,7 +484,7 @@ async def export_persons_pdf(
</tbody>
</table>
</div>
<div class=\"footer\">تولید شده توسط Hesabix</div>
<div class=\"footer\">{footer_text}</div>
</body>
</html>
"""

View file

@ -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)

View file

@ -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 = """
# 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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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"<td>{escape(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{escape(h)}</th>" 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"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
<meta charset='utf-8'>
<style>
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #777; padding: 6px; font-size: 12px; }
th { background: #eee; }
h1 { font-size: 16px; }
.meta { font-size: 12px; color: #666; margin-bottom: 10px; }
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-{ 'left' if is_fa else 'right' } {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
}}
}}
body {{
font-family: sans-serif;
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
}}
.table-wrapper {{
width: 100%;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 6px 4px;
text-align: center;
font-weight: 700;
white-space: nowrap;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
"""
title = "گزارش فهرست محصولات"
now = datetime.datetime.utcnow().isoformat()
header_row = "".join([f"<th>{h}</th>" for h in headers])
body_rows = "".join([
"<tr>" + "".join([f"<td>{(it.get(k) if it.get(k) is not None else '')}</td>" for k in keys]) + "</tr>"
for it in items
])
html = f"""
<html><head>{head_html}</head><body>
<h1>{title}</h1>
<div class=meta>زمان تولید: {now}</div>
<table>
<thead><tr>{header_row}</tr></thead>
<tbody>{body_rows}</tbody>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">{title_text}</div>
<div class=\"meta\">{label_biz}: {escape(business_name)}</div>
</div>
<div class=\"meta\">{label_date}: {escape(now_str)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</body></html>
</div>
<div class=\"footer\">{footer_text}</div>
</body>
</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)

View file

@ -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

View file

@ -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="خلاصه تغییرات")

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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,21 +100,21 @@ 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
self.db.commit()
self.db.refresh(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,
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'):

View file

@ -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

View file

@ -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
}

View file

@ -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)
# اعمال فیلترها

View file

@ -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,

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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))

View file

@ -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()
}

View file

@ -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"
}
)

View file

@ -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)

View file

@ -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="کاربر با موفقیت حذف شد"
)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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"}

View file

@ -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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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"<td>{escape(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{escape(h)}</th>" 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"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
<meta charset='utf-8'>
<style>
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-{ 'left' if is_fa else 'right' } {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
}}
}}
body {{
font-family: sans-serif;
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
}}
.table-wrapper {{
width: 100%;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 6px 4px;
text-align: center;
font-weight: 700;
white-space: nowrap;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">{title_text}</div>
<div class=\"meta\">{label_biz}: {escape(business_name)}</div>
</div>
<div class=\"meta\">{label_date}: {escape(now)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
<div class=\"footer\">{footer_text}</div>
</body>
</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)}")

View file

@ -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)

View file

@ -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)

View file

@ -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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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"<td>{escape(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{escape(h)}</th>" 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"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
<meta charset='utf-8'>
<style>
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-{ 'left' if is_fa else 'right' } {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
}}
}}
body {{
font-family: sans-serif;
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
}}
.table-wrapper {{
width: 100%;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 6px 4px;
text-align: center;
font-weight: 700;
white-space: nowrap;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">{title_text}</div>
<div class=\"meta\">{label_biz}: {escape(business_name)}</div>
</div>
<div class=\"meta\">{label_date}: {escape(now_str)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
<div class=\"footer\">{footer_text}</div>
</body>
</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",
},
)

View file

@ -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 *

View file

@ -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

View file

@ -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

View file

@ -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="کل فایل‌های تایید نشده")

View file

@ -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="تعداد اشخاص غیرفعال")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
# Support API endpoints

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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")

View file

@ -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)

View file

@ -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])

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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())

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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,
}

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
# Support repositories

View file

@ -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()

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more