progress in products
This commit is contained in:
parent
6b908eea5d
commit
1363270445
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from adapters.api.v1.schemas import QueryInfo, SuccessResponse
|
|||
from app.core.responses import success_response, format_datetime_fields
|
||||
from app.core.auth_dependency import get_current_user, AuthContext
|
||||
from app.core.permissions import require_business_management_dep
|
||||
from app.core.i18n import negotiate_locale
|
||||
from app.services.person_service import (
|
||||
create_person, get_person_by_id, get_persons_by_business,
|
||||
update_person, delete_person, get_person_summary
|
||||
|
|
@ -207,6 +208,14 @@ async def export_persons_excel(
|
|||
ws = wb.active
|
||||
ws.title = "Persons"
|
||||
|
||||
# Locale and RTL/LTR handling
|
||||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||
if locale == 'fa':
|
||||
try:
|
||||
ws.sheet_view.rightToLeft = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
|
@ -226,7 +235,10 @@ async def export_persons_excel(
|
|||
value = item.get(key, "")
|
||||
if isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
ws.cell(row=row_idx, column=col_idx, value=value).border = border
|
||||
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
cell.border = border
|
||||
if locale == 'fa':
|
||||
cell.alignment = Alignment(horizontal="right")
|
||||
|
||||
# Auto-width columns
|
||||
for column in ws.columns:
|
||||
|
|
@ -344,7 +356,12 @@ async def export_persons_pdf(
|
|||
except Exception:
|
||||
business_name = ""
|
||||
|
||||
# Styled HTML (A4 landscape, RTL)
|
||||
# Styled HTML with dynamic direction/locale
|
||||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||
is_fa = (locale == 'fa')
|
||||
html_lang = 'fa' if is_fa else 'en'
|
||||
html_dir = 'rtl' if is_fa else 'ltr'
|
||||
|
||||
def escape(s: Any) -> str:
|
||||
try:
|
||||
return str(s).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
|
@ -373,16 +390,24 @@ async def export_persons_pdf(
|
|||
now = formatted_now.get('formatted', formatted_now.get('date_time', ''))
|
||||
except Exception:
|
||||
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||
|
||||
title_text = "گزارش لیست اشخاص" if is_fa else "Persons List Report"
|
||||
label_biz = "نام کسبوکار" if is_fa else "Business Name"
|
||||
label_date = "تاریخ گزارش" if is_fa else "Report Date"
|
||||
footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
|
||||
page_label_left = "صفحه " if is_fa else "Page "
|
||||
page_label_of = " از " if is_fa else " of "
|
||||
|
||||
table_html = f"""
|
||||
<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>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = """
|
||||
<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; }
|
||||
</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>
|
||||
</table>
|
||||
</body></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('&', '&').replace('<', '<').replace('>', '>')
|
||||
except Exception:
|
||||
return str(s)
|
||||
|
||||
# Build rows
|
||||
rows_html = []
|
||||
for item in items:
|
||||
tds = []
|
||||
for key in keys:
|
||||
value = item.get(key)
|
||||
if value is None:
|
||||
value = ""
|
||||
elif isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
tds.append(f"<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=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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="خلاصه تغییرات")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -69,8 +69,6 @@ class PriceListRepository(BaseRepository[PriceList]):
|
|||
"id": pl.id,
|
||||
"business_id": pl.business_id,
|
||||
"name": pl.name,
|
||||
"currency_id": pl.currency_id,
|
||||
"default_unit_id": pl.default_unit_id,
|
||||
"is_active": pl.is_active,
|
||||
"created_at": pl.created_at,
|
||||
"updated_at": pl.updated_at,
|
||||
|
|
@ -81,8 +79,12 @@ class PriceItemRepository(BaseRepository[PriceItem]):
|
|||
def __init__(self, db: Session) -> None:
|
||||
super().__init__(db, PriceItem)
|
||||
|
||||
def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0) -> dict[str, Any]:
|
||||
def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> dict[str, Any]:
|
||||
stmt = select(PriceItem).where(PriceItem.price_list_id == price_list_id)
|
||||
if product_id is not None:
|
||||
stmt = stmt.where(PriceItem.product_id == product_id)
|
||||
if currency_id is not None:
|
||||
stmt = stmt.where(PriceItem.currency_id == currency_id)
|
||||
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
|
||||
rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all())
|
||||
items = [self._to_dict(pi) for pi in rows]
|
||||
|
|
@ -98,22 +100,22 @@ class PriceItemRepository(BaseRepository[PriceItem]):
|
|||
},
|
||||
}
|
||||
|
||||
def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int | None, tier_name: str, min_qty, price) -> PriceItem:
|
||||
def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int, tier_name: str | None, min_qty, price) -> PriceItem:
|
||||
# Try find existing unique combination
|
||||
stmt = select(PriceItem).where(
|
||||
and_(
|
||||
PriceItem.price_list_id == price_list_id,
|
||||
PriceItem.product_id == product_id,
|
||||
PriceItem.unit_id.is_(unit_id) if unit_id is None else PriceItem.unit_id == unit_id,
|
||||
PriceItem.tier_name == tier_name,
|
||||
PriceItem.tier_name == (tier_name or 'پیشفرض'),
|
||||
PriceItem.min_qty == min_qty,
|
||||
PriceItem.currency_id == currency_id,
|
||||
)
|
||||
)
|
||||
existing = self.db.execute(stmt).scalars().first()
|
||||
if existing:
|
||||
existing.price = price
|
||||
if currency_id is not None:
|
||||
existing.currency_id = currency_id
|
||||
existing.currency_id = currency_id
|
||||
self.db.commit()
|
||||
self.db.refresh(existing)
|
||||
return existing
|
||||
|
|
@ -122,7 +124,7 @@ class PriceItemRepository(BaseRepository[PriceItem]):
|
|||
product_id=product_id,
|
||||
unit_id=unit_id,
|
||||
currency_id=currency_id,
|
||||
tier_name=tier_name,
|
||||
tier_name=(tier_name or 'پیشفرض'),
|
||||
min_qty=min_qty,
|
||||
price=price,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
233
hesabixAPI/app/services/bulk_price_update_service.py
Normal file
233
hesabixAPI/app/services/bulk_price_update_service.py
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
# اعمال فیلترها
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
0
hesabixAPI/build/lib/adapters/__init__.py
Normal file
0
hesabixAPI/build/lib/adapters/__init__.py
Normal file
0
hesabixAPI/build/lib/adapters/api/__init__.py
Normal file
0
hesabixAPI/build/lib/adapters/api/__init__.py
Normal file
5
hesabixAPI/build/lib/adapters/api/v1/__init__.py
Normal file
5
hesabixAPI/build/lib/adapters/api/v1/__init__.py
Normal 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
|
||||
|
||||
57
hesabixAPI/build/lib/adapters/api/v1/accounts.py
Normal file
57
hesabixAPI/build/lib/adapters/api/v1/accounts.py
Normal 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)
|
||||
|
||||
|
||||
349
hesabixAPI/build/lib/adapters/api/v1/admin/email_config.py
Normal file
349
hesabixAPI/build/lib/adapters/api/v1/admin/email_config.py
Normal 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))
|
||||
725
hesabixAPI/build/lib/adapters/api/v1/admin/file_storage.py
Normal file
725
hesabixAPI/build/lib/adapters/api/v1/admin/file_storage.py
Normal 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()
|
||||
}
|
||||
936
hesabixAPI/build/lib/adapters/api/v1/auth.py
Normal file
936
hesabixAPI/build/lib/adapters/api/v1/auth.py
Normal 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"
|
||||
}
|
||||
)
|
||||
|
||||
293
hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py
Normal file
293
hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py
Normal 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)
|
||||
564
hesabixAPI/build/lib/adapters/api/v1/business_users.py
Normal file
564
hesabixAPI/build/lib/adapters/api/v1/business_users.py
Normal 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="کاربر با موفقیت حذف شد"
|
||||
)
|
||||
320
hesabixAPI/build/lib/adapters/api/v1/businesses.py
Normal file
320
hesabixAPI/build/lib/adapters/api/v1/businesses.py
Normal 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)
|
||||
148
hesabixAPI/build/lib/adapters/api/v1/categories.py
Normal file
148
hesabixAPI/build/lib/adapters/api/v1/categories.py
Normal 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)
|
||||
|
||||
|
||||
30
hesabixAPI/build/lib/adapters/api/v1/currencies.py
Normal file
30
hesabixAPI/build/lib/adapters/api/v1/currencies.py
Normal 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)
|
||||
|
||||
|
||||
30
hesabixAPI/build/lib/adapters/api/v1/health.py
Normal file
30
hesabixAPI/build/lib/adapters/api/v1/health.py
Normal 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"}
|
||||
945
hesabixAPI/build/lib/adapters/api/v1/persons.py
Normal file
945
hesabixAPI/build/lib/adapters/api/v1/persons.py
Normal 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('&', '&').replace('<', '<').replace('>', '>')
|
||||
except Exception:
|
||||
return str(s)
|
||||
|
||||
rows_html = []
|
||||
for item in items:
|
||||
tds = []
|
||||
for key in keys:
|
||||
value = item.get(key)
|
||||
if value is None:
|
||||
value = ""
|
||||
elif isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
tds.append(f"<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)}")
|
||||
165
hesabixAPI/build/lib/adapters/api/v1/price_lists.py
Normal file
165
hesabixAPI/build/lib/adapters/api/v1/price_lists.py
Normal 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)
|
||||
|
||||
|
||||
124
hesabixAPI/build/lib/adapters/api/v1/product_attributes.py
Normal file
124
hesabixAPI/build/lib/adapters/api/v1/product_attributes.py
Normal 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)
|
||||
|
||||
|
||||
509
hesabixAPI/build/lib/adapters/api/v1/products.py
Normal file
509
hesabixAPI/build/lib/adapters/api/v1/products.py
Normal 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('&', '&').replace('<', '<').replace('>', '>')
|
||||
except Exception:
|
||||
return str(s)
|
||||
|
||||
# Build rows
|
||||
rows_html = []
|
||||
for item in items:
|
||||
tds = []
|
||||
for key in keys:
|
||||
value = item.get(key)
|
||||
if value is None:
|
||||
value = ""
|
||||
elif isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
tds.append(f"<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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -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 *
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
59
hesabixAPI/build/lib/adapters/api/v1/schema_models/email.py
Normal file
59
hesabixAPI/build/lib/adapters/api/v1/schema_models/email.py
Normal 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
|
||||
|
|
@ -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="کل فایلهای تایید نشده")
|
||||
242
hesabixAPI/build/lib/adapters/api/v1/schema_models/person.py
Normal file
242
hesabixAPI/build/lib/adapters/api/v1/schema_models/person.py
Normal 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="تعداد اشخاص غیرفعال")
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
110
hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py
Normal file
110
hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py
Normal 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
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
339
hesabixAPI/build/lib/adapters/api/v1/schemas.py
Normal file
339
hesabixAPI/build/lib/adapters/api/v1/schemas.py
Normal 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
|
||||
|
||||
|
||||
1
hesabixAPI/build/lib/adapters/api/v1/support/__init__.py
Normal file
1
hesabixAPI/build/lib/adapters/api/v1/support/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Support API endpoints
|
||||
29
hesabixAPI/build/lib/adapters/api/v1/support/categories.py
Normal file
29
hesabixAPI/build/lib/adapters/api/v1/support/categories.py
Normal 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)
|
||||
296
hesabixAPI/build/lib/adapters/api/v1/support/operator.py
Normal file
296
hesabixAPI/build/lib/adapters/api/v1/support/operator.py
Normal 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)
|
||||
29
hesabixAPI/build/lib/adapters/api/v1/support/priorities.py
Normal file
29
hesabixAPI/build/lib/adapters/api/v1/support/priorities.py
Normal 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)
|
||||
134
hesabixAPI/build/lib/adapters/api/v1/support/schemas.py
Normal file
134
hesabixAPI/build/lib/adapters/api/v1/support/schemas.py
Normal 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
|
||||
29
hesabixAPI/build/lib/adapters/api/v1/support/statuses.py
Normal file
29
hesabixAPI/build/lib/adapters/api/v1/support/statuses.py
Normal 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)
|
||||
256
hesabixAPI/build/lib/adapters/api/v1/support/tickets.py
Normal file
256
hesabixAPI/build/lib/adapters/api/v1/support/tickets.py
Normal 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)
|
||||
49
hesabixAPI/build/lib/adapters/api/v1/tax_types.py
Normal file
49
hesabixAPI/build/lib/adapters/api/v1/tax_types.py
Normal 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)
|
||||
|
||||
|
||||
387
hesabixAPI/build/lib/adapters/api/v1/tax_units.py
Normal file
387
hesabixAPI/build/lib/adapters/api/v1/tax_units.py
Normal 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)
|
||||
362
hesabixAPI/build/lib/adapters/api/v1/users.py
Normal file
362
hesabixAPI/build/lib/adapters/api/v1/users.py
Normal 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)
|
||||
|
||||
|
||||
0
hesabixAPI/build/lib/adapters/db/__init__.py
Normal file
0
hesabixAPI/build/lib/adapters/db/__init__.py
Normal file
38
hesabixAPI/build/lib/adapters/db/models/__init__.py
Normal file
38
hesabixAPI/build/lib/adapters/db/models/__init__.py
Normal 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
|
||||
32
hesabixAPI/build/lib/adapters/db/models/account.py
Normal file
32
hesabixAPI/build/lib/adapters/db/models/account.py
Normal 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")
|
||||
|
||||
|
||||
28
hesabixAPI/build/lib/adapters/db/models/api_key.py
Normal file
28
hesabixAPI/build/lib/adapters/db/models/api_key.py
Normal 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)
|
||||
|
||||
|
||||
64
hesabixAPI/build/lib/adapters/db/models/business.py
Normal file
64
hesabixAPI/build/lib/adapters/db/models/business.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
20
hesabixAPI/build/lib/adapters/db/models/captcha.py
Normal file
20
hesabixAPI/build/lib/adapters/db/models/captcha.py
Normal 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)
|
||||
|
||||
|
||||
32
hesabixAPI/build/lib/adapters/db/models/category.py
Normal file
32
hesabixAPI/build/lib/adapters/db/models/category.py
Normal 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")
|
||||
|
||||
|
||||
43
hesabixAPI/build/lib/adapters/db/models/currency.py
Normal file
43
hesabixAPI/build/lib/adapters/db/models/currency.py
Normal 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)
|
||||
|
||||
|
||||
37
hesabixAPI/build/lib/adapters/db/models/document.py
Normal file
37
hesabixAPI/build/lib/adapters/db/models/document.py
Normal 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")
|
||||
|
||||
|
||||
30
hesabixAPI/build/lib/adapters/db/models/document_line.py
Normal file
30
hesabixAPI/build/lib/adapters/db/models/document_line.py
Normal 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")
|
||||
|
||||
|
||||
27
hesabixAPI/build/lib/adapters/db/models/email_config.py
Normal file
27
hesabixAPI/build/lib/adapters/db/models/email_config.py
Normal 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)
|
||||
71
hesabixAPI/build/lib/adapters/db/models/file_storage.py
Normal file
71
hesabixAPI/build/lib/adapters/db/models/file_storage.py
Normal 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])
|
||||
26
hesabixAPI/build/lib/adapters/db/models/fiscal_year.py
Normal file
26
hesabixAPI/build/lib/adapters/db/models/fiscal_year.py
Normal 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")
|
||||
|
||||
|
||||
21
hesabixAPI/build/lib/adapters/db/models/password_reset.py
Normal file
21
hesabixAPI/build/lib/adapters/db/models/password_reset.py
Normal 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)
|
||||
|
||||
|
||||
100
hesabixAPI/build/lib/adapters/db/models/person.py
Normal file
100
hesabixAPI/build/lib/adapters/db/models/person.py
Normal 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")
|
||||
51
hesabixAPI/build/lib/adapters/db/models/price_list.py
Normal file
51
hesabixAPI/build/lib/adapters/db/models/price_list.py
Normal 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)
|
||||
|
||||
|
||||
87
hesabixAPI/build/lib/adapters/db/models/product.py
Normal file
87
hesabixAPI/build/lib/adapters/db/models/product.py
Normal 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)
|
||||
|
||||
|
||||
30
hesabixAPI/build/lib/adapters/db/models/product_attribute.py
Normal file
30
hesabixAPI/build/lib/adapters/db/models/product_attribute.py
Normal 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)
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
23
hesabixAPI/build/lib/adapters/db/models/support/category.py
Normal file
23
hesabixAPI/build/lib/adapters/db/models/support/category.py
Normal 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")
|
||||
35
hesabixAPI/build/lib/adapters/db/models/support/message.py
Normal file
35
hesabixAPI/build/lib/adapters/db/models/support/message.py
Normal 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")
|
||||
24
hesabixAPI/build/lib/adapters/db/models/support/priority.py
Normal file
24
hesabixAPI/build/lib/adapters/db/models/support/priority.py
Normal 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")
|
||||
24
hesabixAPI/build/lib/adapters/db/models/support/status.py
Normal file
24
hesabixAPI/build/lib/adapters/db/models/support/status.py
Normal 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")
|
||||
40
hesabixAPI/build/lib/adapters/db/models/support/ticket.py
Normal file
40
hesabixAPI/build/lib/adapters/db/models/support/ticket.py
Normal 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")
|
||||
24
hesabixAPI/build/lib/adapters/db/models/tax_unit.py
Normal file
24
hesabixAPI/build/lib/adapters/db/models/tax_unit.py
Normal 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)
|
||||
35
hesabixAPI/build/lib/adapters/db/models/user.py
Normal file
35
hesabixAPI/build/lib/adapters/db/models/user.py
Normal 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")
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
64
hesabixAPI/build/lib/adapters/db/repositories/base_repo.py
Normal file
64
hesabixAPI/build/lib/adapters/db/repositories/base_repo.py
Normal 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()
|
||||
|
|
@ -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
|
||||
145
hesabixAPI/build/lib/adapters/db/repositories/business_repo.py
Normal file
145
hesabixAPI/build/lib/adapters/db/repositories/business_repo.py
Normal 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())
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Support repositories
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
Loading…
Reference in a new issue