diff --git a/hesabixAPI/adapters/api/v1/bank_accounts.py b/hesabixAPI/adapters/api/v1/bank_accounts.py
index 21e4523..fae0e41 100644
--- a/hesabixAPI/adapters/api/v1/bank_accounts.py
+++ b/hesabixAPI/adapters/api/v1/bank_accounts.py
@@ -211,9 +211,32 @@ async def export_bank_accounts_excel(
result = list_bank_accounts(db, business_id, query_dict)
items: List[Dict[str, Any]] = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
+
+ # Map currency_id -> currency title for display
+ try:
+ from adapters.db.models.currency import Currency
+ currency_ids = set()
+ for it in items:
+ cid = it.get("currency_id")
+ try:
+ if cid is not None:
+ currency_ids.add(int(cid))
+ except Exception:
+ pass
+ currency_map: Dict[int, str] = {}
+ if currency_ids:
+ rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
+ currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
+ for it in items:
+ cid = it.get("currency_id")
+ it["currency"] = currency_map.get(cid, cid)
+ except Exception:
+ for it in items:
+ if "currency" not in it and "currency_id" in it:
+ it["currency"] = it.get("currency_id")
headers: List[str] = [
- "code", "name", "branch", "account_number", "sheba_number", "card_number", "owner_name", "pos_number", "is_active", "is_default"
+ "code", "name", "branch", "account_number", "sheba_number", "card_number", "owner_name", "pos_number", "currency", "is_active", "is_default"
]
wb = Workbook()
@@ -245,6 +268,12 @@ async def export_bank_accounts_excel(
for row_idx, item in enumerate(items, 2):
for col_idx, key in enumerate(headers, 1):
value = item.get(key, "")
+ if key in ("is_active", "is_default"):
+ try:
+ truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
+ except Exception:
+ truthy = bool(value)
+ value = "✓" if truthy else "✗"
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
cell = ws.cell(row=row_idx, column=col_idx, value=value)
@@ -314,6 +343,29 @@ async def export_bank_accounts_pdf(
items: List[Dict[str, Any]] = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
+ # Map currency_id -> currency title for display
+ try:
+ from adapters.db.models.currency import Currency
+ currency_ids = set()
+ for it in items:
+ cid = it.get("currency_id")
+ try:
+ if cid is not None:
+ currency_ids.add(int(cid))
+ except Exception:
+ pass
+ currency_map: Dict[int, str] = {}
+ if currency_ids:
+ rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
+ currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
+ for it in items:
+ cid = it.get("currency_id")
+ it["currency"] = currency_map.get(cid, cid)
+ except Exception:
+ for it in items:
+ if "currency" not in it and "currency_id" in it:
+ it["currency"] = it.get("currency_id")
+
# Selection handling
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
@@ -339,7 +391,10 @@ async def export_bank_accounts_pdf(
key = col.get('key')
label = col.get('label', key)
if key:
- keys.append(str(key))
+ if str(key) == 'currency_id':
+ keys.append('currency')
+ else:
+ keys.append(str(key))
headers.append(str(label))
else:
if items:
@@ -348,7 +403,7 @@ async def export_bank_accounts_pdf(
else:
keys = [
"code", "name", "branch", "account_number", "sheba_number",
- "card_number", "owner_name", "pos_number", "is_active", "is_default",
+ "card_number", "owner_name", "pos_number", "currency", "is_active", "is_default",
]
headers = keys
@@ -403,6 +458,12 @@ async def export_bank_accounts_pdf(
value = ""
elif isinstance(value, list):
value = ", ".join(str(v) for v in value)
+ elif key in ("is_active", "is_default"):
+ try:
+ truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
+ except Exception:
+ truthy = bool(value)
+ value = "✓" if truthy else "✗"
tds.append(f"
{escape_val(value)} | ")
rows_html.append(f"{''.join(tds)}
")
diff --git a/hesabixAPI/adapters/api/v1/cash_registers.py b/hesabixAPI/adapters/api/v1/cash_registers.py
index ad610a8..879e8b3 100644
--- a/hesabixAPI/adapters/api/v1/cash_registers.py
+++ b/hesabixAPI/adapters/api/v1/cash_registers.py
@@ -201,6 +201,30 @@ async def export_cash_registers_excel(
items: List[Dict[str, Any]] = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
+ # Map currency_id -> currency title for display
+ try:
+ from adapters.db.models.currency import Currency
+ currency_ids = set()
+ for it in items:
+ cid = it.get("currency_id")
+ try:
+ if cid is not None:
+ currency_ids.add(int(cid))
+ except Exception:
+ pass
+ currency_map: Dict[int, str] = {}
+ if currency_ids:
+ rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
+ currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
+ for it in items:
+ cid = it.get("currency_id")
+ it["currency"] = currency_map.get(cid, cid)
+ except Exception:
+ # In case of any issue, fallback without blocking export
+ for it in items:
+ if "currency" not in it and "currency_id" in it:
+ it["currency"] = it.get("currency_id")
+
headers: List[str] = [
"code", "name", "currency", "is_active", "is_default",
"payment_switch_number", "payment_terminal_number", "merchant_id",
@@ -233,6 +257,12 @@ async def export_cash_registers_excel(
for row_idx, item in enumerate(items, 2):
for col_idx, key in enumerate(headers, 1):
value = item.get(key, "")
+ if key in ("is_active", "is_default"):
+ try:
+ truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
+ except Exception:
+ truthy = bool(value)
+ value = "✓" if truthy else "✗"
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
cell = ws.cell(row=row_idx, column=col_idx, value=value)
@@ -298,6 +328,29 @@ async def export_cash_registers_pdf(
items: List[Dict[str, Any]] = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
+ # Map currency_id -> currency title for display
+ try:
+ from adapters.db.models.currency import Currency
+ currency_ids = set()
+ for it in items:
+ cid = it.get("currency_id")
+ try:
+ if cid is not None:
+ currency_ids.add(int(cid))
+ except Exception:
+ pass
+ currency_map: Dict[int, str] = {}
+ if currency_ids:
+ rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
+ currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
+ for it in items:
+ cid = it.get("currency_id")
+ it["currency"] = currency_map.get(cid, cid)
+ except Exception:
+ for it in items:
+ if "currency" not in it and "currency_id" in it:
+ it["currency"] = it.get("currency_id")
+
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
@@ -321,11 +374,15 @@ async def export_cash_registers_pdf(
key = col.get('key')
label = col.get('label', key)
if key:
- keys.append(str(key))
+ # Replace currency_id key with currency to show human-readable value
+ if str(key) == 'currency_id':
+ keys.append('currency')
+ else:
+ keys.append(str(key))
headers.append(str(label))
else:
keys = [
- "code", "name", "currency_id", "is_active", "is_default",
+ "code", "name", "currency", "is_active", "is_default",
"payment_switch_number", "payment_terminal_number", "merchant_id",
"description",
]
@@ -372,6 +429,12 @@ async def export_cash_registers_pdf(
value = ""
elif isinstance(value, list):
value = ", ".join(str(v) for v in value)
+ elif key in ("is_active", "is_default"):
+ try:
+ truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
+ except Exception:
+ truthy = bool(value)
+ value = "✓" if truthy else "✗"
tds.append(f"{esc(value)} | ")
rows_html.append(f"{''.join(tds)}
")
diff --git a/hesabixAPI/adapters/api/v1/petty_cash.py b/hesabixAPI/adapters/api/v1/petty_cash.py
new file mode 100644
index 0000000..3cfa823
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/petty_cash.py
@@ -0,0 +1,473 @@
+from typing import Any, Dict, List
+from fastapi import APIRouter, Depends, Request, Body
+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.responses import success_response, format_datetime_fields, ApiError
+from app.core.permissions import require_business_management_dep, require_business_access
+from adapters.api.v1.schemas import QueryInfo
+from app.services.petty_cash_service import (
+ create_petty_cash,
+ update_petty_cash,
+ delete_petty_cash,
+ get_petty_cash_by_id,
+ list_petty_cash,
+ bulk_delete_petty_cash,
+)
+
+
+router = APIRouter(prefix="/petty-cash", tags=["petty-cash"])
+
+
+@router.post(
+ "/businesses/{business_id}/petty-cash",
+ summary="لیست تنخواه گردانها",
+ description="دریافت لیست تنخواه گردانهای یک کسب و کار با امکان جستجو و فیلتر",
+)
+@require_business_access("business_id")
+async def list_petty_cash_endpoint(
+ request: Request,
+ business_id: int,
+ query_info: QueryInfo,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ query_dict: Dict[str, Any] = {
+ "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 = list_petty_cash(db, business_id, query_dict)
+ result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
+ return success_response(data=result, request=request, message="PETTY_CASH_LIST_FETCHED")
+
+
+@router.post(
+ "/businesses/{business_id}/petty-cash/create",
+ summary="ایجاد تنخواه گردان جدید",
+ description="ایجاد تنخوان گردان برای یک کسبوکار مشخص",
+)
+@require_business_access("business_id")
+async def create_petty_cash_endpoint(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ payload: Dict[str, Any] = dict(body or {})
+ created = create_petty_cash(db, business_id, payload)
+ return success_response(data=format_datetime_fields(created, request), request=request, message="PETTY_CASH_CREATED")
+
+
+@router.get(
+ "/petty-cash/{petty_cash_id}",
+ summary="جزئیات تنخواه گردان",
+ description="دریافت جزئیات تنخواه گردان بر اساس شناسه",
+)
+async def get_petty_cash_endpoint(
+ request: Request,
+ petty_cash_id: int,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ result = get_petty_cash_by_id(db, petty_cash_id)
+ if not result:
+ raise ApiError("PETTY_CASH_NOT_FOUND", "Petty cash not found", http_status=404)
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="PETTY_CASH_DETAILS")
+
+
+@router.put(
+ "/petty-cash/{petty_cash_id}",
+ summary="ویرایش تنخواه گردان",
+ description="ویرایش اطلاعات تنخواه گردان",
+)
+async def update_petty_cash_endpoint(
+ request: Request,
+ petty_cash_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ payload: Dict[str, Any] = dict(body or {})
+ result = update_petty_cash(db, petty_cash_id, payload)
+ if result is None:
+ raise ApiError("PETTY_CASH_NOT_FOUND", "Petty cash not found", http_status=404)
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="PETTY_CASH_UPDATED")
+
+
+@router.delete(
+ "/petty-cash/{petty_cash_id}",
+ summary="حذف تنخواه گردان",
+ description="حذف یک تنخواه گردان",
+)
+async def delete_petty_cash_endpoint(
+ request: Request,
+ petty_cash_id: int,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ result = get_petty_cash_by_id(db, petty_cash_id)
+ if result:
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ ok = delete_petty_cash(db, petty_cash_id)
+ if not ok:
+ raise ApiError("PETTY_CASH_NOT_FOUND", "Petty cash not found", http_status=404)
+ return success_response(data=None, request=request, message="PETTY_CASH_DELETED")
+
+
+@router.post(
+ "/businesses/{business_id}/petty-cash/bulk-delete",
+ summary="حذف گروهی تنخواه گردانها",
+ description="حذف چندین تنخواه گردان بر اساس شناسهها",
+)
+@require_business_access("business_id")
+async def bulk_delete_petty_cash_endpoint(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ ids = body.get("ids")
+ if not isinstance(ids, list):
+ ids = []
+ try:
+ ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
+ except Exception:
+ ids = []
+ if not ids:
+ return success_response({"deleted": 0, "skipped": 0, "total_requested": 0}, request, message="NO_VALID_IDS_FOR_DELETE")
+ result = bulk_delete_petty_cash(db, business_id, ids)
+ return success_response(result, request, message="PETTY_CASH_BULK_DELETE_DONE")
+
+
+@router.post(
+ "/businesses/{business_id}/petty-cash/export/excel",
+ summary="خروجی Excel لیست تنخواه گردانها",
+ description="خروجی Excel لیست تنخواه گردانها با قابلیت فیلتر و مرتبسازی",
+)
+@require_business_access("business_id")
+async def export_petty_cash_excel(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ from fastapi.responses import Response
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+ from app.core.i18n import negotiate_locale
+
+ 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_petty_cash(db, business_id, query_dict)
+ items: List[Dict[str, Any]] = result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ # Map currency_id -> currency title for display
+ try:
+ from adapters.db.models.currency import Currency
+ currency_ids = set()
+ for it in items:
+ cid = it.get("currency_id")
+ try:
+ if cid is not None:
+ currency_ids.add(int(cid))
+ except Exception:
+ pass
+ currency_map: Dict[int, str] = {}
+ if currency_ids:
+ rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
+ currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
+ for it in items:
+ cid = it.get("currency_id")
+ it["currency"] = currency_map.get(cid, cid)
+ except Exception:
+ # In case of any issue, fallback without blocking export
+ for it in items:
+ if "currency" not in it and "currency_id" in it:
+ it["currency"] = it.get("currency_id")
+
+ headers: List[str] = [
+ "code", "name", "currency", "is_active", "is_default", "description",
+ ]
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "PettyCash"
+
+ 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'))
+
+ 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
+
+ for row_idx, item in enumerate(items, 2):
+ for col_idx, key in enumerate(headers, 1):
+ value = item.get(key, "")
+ if key in ("is_active", "is_default"):
+ try:
+ truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
+ except Exception:
+ truthy = bool(value)
+ value = "✓" if truthy else "✗"
+ 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")
+
+ 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)
+
+ buffer = io.BytesIO()
+ wb.save(buffer)
+ buffer.seek(0)
+ content = buffer.getvalue()
+ return Response(
+ content=content,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": "attachment; filename=petty_cash.xlsx",
+ "Content-Length": str(len(content)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
+@router.post(
+ "/businesses/{business_id}/petty-cash/export/pdf",
+ summary="خروجی PDF لیست تنخواه گردانها",
+ description="خروجی PDF لیست تنخواه گردانها با قابلیت فیلتر و مرتبسازی",
+)
+@require_business_access("business_id")
+async def export_petty_cash_pdf(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ from fastapi.responses import Response
+ from weasyprint import HTML
+ from weasyprint.text.fonts import FontConfiguration
+ from app.core.i18n import negotiate_locale
+
+ 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_petty_cash(db, business_id, query_dict)
+ items: List[Dict[str, Any]] = result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ # Map currency_id -> currency title for display
+ try:
+ from adapters.db.models.currency import Currency
+ currency_ids = set()
+ for it in items:
+ cid = it.get("currency_id")
+ try:
+ if cid is not None:
+ currency_ids.add(int(cid))
+ except Exception:
+ pass
+ currency_map: Dict[int, str] = {}
+ if currency_ids:
+ rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
+ currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
+ for it in items:
+ cid = it.get("currency_id")
+ it["currency"] = currency_map.get(cid, cid)
+ except Exception:
+ for it in items:
+ if "currency" not in it and "currency_id" in it:
+ it["currency"] = it.get("currency_id")
+
+ 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):
+ import json
+ 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:
+ # Replace currency_id key with currency to show human-readable value
+ if str(key) == 'currency_id':
+ keys.append('currency')
+ else:
+ keys.append(str(key))
+ headers.append(str(label))
+ else:
+ keys = [
+ "code", "name", "currency", "is_active", "is_default", "description",
+ ]
+ headers = keys
+
+ business_name = ""
+ try:
+ from adapters.db.models.business import Business
+ biz = db.query(Business).filter(Business.id == business_id).first()
+ if biz is not None:
+ business_name = biz.name or ""
+ except Exception:
+ business_name = ""
+
+ 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'
+
+ try:
+ from app.core.calendar import CalendarConverter
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
+ formatted_now = CalendarConverter.format_datetime(
+ __import__("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:
+ from datetime import datetime
+ now_str = datetime.now().strftime('%Y/%m/%d %H:%M')
+
+ def esc(v: Any) -> str:
+ try:
+ return str(v).replace('&', '&').replace('<', '<').replace('>', '>')
+ except Exception:
+ return str(v)
+
+ rows_html: List[str] = []
+ 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)
+ elif key in ("is_active", "is_default"):
+ try:
+ truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
+ except Exception:
+ truthy = bool(value)
+ value = "✓" if truthy else "✗"
+ tds.append(f"{esc(value)} | ")
+ rows_html.append(f"{''.join(tds)}
")
+
+ headers_html = ''.join(f"{esc(h)} | " for h in headers)
+
+ table_html = f"""
+
+
+
+
+
+
+ {esc('گزارش تنخواه گردانها' if is_fa else 'Petty Cash Report')}
+ {esc('نام کسبوکار' if is_fa else 'Business Name')}: {esc(business_name)} | {esc('تاریخ گزارش' if is_fa else 'Report Date')}: {esc(now_str)}
+
+ {headers_html}
+ {''.join(rows_html)}
+
+
+
+ """
+
+ font_config = FontConfiguration()
+ pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": "attachment; filename=petty_cash.pdf",
+ "Content-Length": str(len(pdf_bytes)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py
index 56c289a..e861578 100644
--- a/hesabixAPI/adapters/db/models/__init__.py
+++ b/hesabixAPI/adapters/db/models/__init__.py
@@ -37,3 +37,4 @@ from .price_list import PriceList, PriceItem # noqa: F401
from .product_attribute_link import ProductAttributeLink # noqa: F401
from .tax_unit import TaxUnit # noqa: F401
from .bank_account import BankAccount # noqa: F401
+from .petty_cash import PettyCash # noqa: F401
diff --git a/hesabixAPI/adapters/db/models/petty_cash.py b/hesabixAPI/adapters/db/models/petty_cash.py
new file mode 100644
index 0000000..21099de
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/petty_cash.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint, Boolean
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class PettyCash(Base):
+ __tablename__ = "petty_cash"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'code', name='uq_petty_cash_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)
+
+ # مشخصات
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام تنخواه گردان")
+ code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True, comment="کد یکتا در هر کسبوکار (اختیاری)")
+ description: Mapped[str | None] = mapped_column(String(500), nullable=True)
+
+ # تنظیمات
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
+ is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
+
+ # زمان بندی
+ 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)
+
+ # روابط
+ business = relationship("Business", backref="petty_cash")
+ currency = relationship("Currency", backref="petty_cash")
diff --git a/hesabixAPI/adapters/db/repositories/petty_cash_repository.py b/hesabixAPI/adapters/db/repositories/petty_cash_repository.py
new file mode 100644
index 0000000..f40bc13
--- /dev/null
+++ b/hesabixAPI/adapters/db/repositories/petty_cash_repository.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, or_, func
+
+from adapters.db.models.petty_cash import PettyCash
+
+
+class PettyCashRepository:
+ def __init__(self, db: Session) -> None:
+ self.db = db
+
+ def create(self, business_id: int, data: Dict[str, Any]) -> PettyCash:
+ obj = PettyCash(
+ business_id=business_id,
+ name=data.get("name"),
+ code=data.get("code"),
+ description=data.get("description"),
+ currency_id=int(data["currency_id"]),
+ is_active=bool(data.get("is_active", True)),
+ is_default=bool(data.get("is_default", False)),
+ )
+ self.db.add(obj)
+ self.db.flush()
+ return obj
+
+ def get_by_id(self, id_: int) -> Optional[PettyCash]:
+ return self.db.query(PettyCash).filter(PettyCash.id == id_).first()
+
+ def update(self, obj: PettyCash, data: Dict[str, Any]) -> PettyCash:
+ for key in [
+ "name","code","description","currency_id","is_active","is_default",
+ ]:
+ if key in data and data[key] is not None:
+ setattr(obj, key, data[key] if key != "currency_id" else int(data[key]))
+ return obj
+
+ def delete(self, obj: PettyCash) -> None:
+ self.db.delete(obj)
+
+ def bulk_delete(self, business_id: int, ids: List[int]) -> Dict[str, int]:
+ items = self.db.query(PettyCash).filter(
+ PettyCash.business_id == business_id,
+ PettyCash.id.in_(ids)
+ ).all()
+ deleted = 0
+ skipped = 0
+ for it in items:
+ try:
+ self.db.delete(it)
+ deleted += 1
+ except Exception:
+ skipped += 1
+ return {"deleted": deleted, "skipped": skipped, "total_requested": len(ids)}
+
+ def clear_default(self, business_id: int, except_id: Optional[int] = None) -> None:
+ q = self.db.query(PettyCash).filter(PettyCash.business_id == business_id)
+ if except_id is not None:
+ q = q.filter(PettyCash.id != except_id)
+ q.update({PettyCash.is_default: False})
+
+ def list(self, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ q = self.db.query(PettyCash).filter(PettyCash.business_id == business_id)
+
+ # search
+ search = query.get("search")
+ search_fields = query.get("search_fields") or []
+ if search and search_fields:
+ term = f"%{search}%"
+ conditions = []
+ for f in search_fields:
+ if f == "name":
+ conditions.append(PettyCash.name.ilike(term))
+ elif f == "code":
+ conditions.append(PettyCash.code.ilike(term))
+ elif f == "description":
+ conditions.append(PettyCash.description.ilike(term))
+ if conditions:
+ q = q.filter(or_(*conditions))
+
+ # filters
+ for flt in (query.get("filters") or []):
+ prop = flt.get("property")
+ op = flt.get("operator")
+ val = flt.get("value")
+ if not prop or not op:
+ continue
+ if prop in {"is_active","is_default"} and op == "=":
+ q = q.filter(getattr(PettyCash, prop) == val)
+ elif prop == "currency_id" and op == "=":
+ q = q.filter(PettyCash.currency_id == val)
+
+ # sort
+ sort_by = query.get("sort_by") or "created_at"
+ sort_desc = bool(query.get("sort_desc", True))
+ col = getattr(PettyCash, sort_by, PettyCash.created_at)
+ q = q.order_by(col.desc() if sort_desc else col.asc())
+
+ # pagination
+ skip = int(query.get("skip", 0))
+ take = int(query.get("take", 20))
+ total = q.count()
+ items = q.offset(skip).limit(take).all()
+
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ "query_info": query,
+ }
diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py
index 05c918b..28bd0c5 100644
--- a/hesabixAPI/app/main.py
+++ b/hesabixAPI/app/main.py
@@ -18,6 +18,7 @@ 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.bank_accounts import router as bank_accounts_router
from adapters.api.v1.cash_registers import router as cash_registers_router
+from adapters.api.v1.petty_cash import router as petty_cash_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
@@ -296,6 +297,7 @@ def create_app() -> FastAPI:
application.include_router(persons_router, prefix=settings.api_v1_prefix)
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
+ application.include_router(petty_cash_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)
diff --git a/hesabixAPI/app/services/cash_register_service.py b/hesabixAPI/app/services/cash_register_service.py
index ac7453c..46bf1ca 100644
--- a/hesabixAPI/app/services/cash_register_service.py
+++ b/hesabixAPI/app/services/cash_register_service.py
@@ -10,6 +10,11 @@ from app.core.responses import ApiError
def create_cash_register(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+ # validate required fields
+ name = (data.get("name") or "").strip()
+ if name == "":
+ raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
+
# code uniqueness in business if provided; else auto-generate numeric min 3 digits
code = data.get("code")
if code is not None and str(code).strip() != "":
@@ -35,7 +40,7 @@ def create_cash_register(db: Session, business_id: int, data: Dict[str, Any]) ->
repo = CashRegisterRepository(db)
obj = repo.create(business_id, {
- "name": data.get("name"),
+ "name": name,
"code": code,
"description": data.get("description"),
"currency_id": int(data["currency_id"]),
@@ -66,6 +71,12 @@ def update_cash_register(db: Session, id_: int, data: Dict[str, Any]) -> Optiona
if obj is None:
return None
+ # validate name if provided
+ if "name" in data:
+ name_val = (data.get("name") or "").strip()
+ if name_val == "":
+ raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
+
if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
if not str(data["code"]).isdigit():
raise ApiError("INVALID_CASH_CODE", "Cash register code must be numeric", http_status=400)
diff --git a/hesabixAPI/app/services/petty_cash_service.py b/hesabixAPI/app/services/petty_cash_service.py
new file mode 100644
index 0000000..aad9589
--- /dev/null
+++ b/hesabixAPI/app/services/petty_cash_service.py
@@ -0,0 +1,137 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, func
+
+from adapters.db.models.petty_cash import PettyCash
+from adapters.db.repositories.petty_cash_repository import PettyCashRepository
+from app.core.responses import ApiError
+
+
+def create_petty_cash(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+ # validate required fields
+ name = (data.get("name") or "").strip()
+ if name == "":
+ raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
+
+ # code uniqueness in business if provided; else auto-generate numeric min 3 digits
+ code = data.get("code")
+ if code is not None and str(code).strip() != "":
+ if not str(code).isdigit():
+ raise ApiError("INVALID_PETTY_CASH_CODE", "Petty cash code must be numeric", http_status=400)
+ if len(str(code)) < 3:
+ raise ApiError("INVALID_PETTY_CASH_CODE", "Petty cash code must be at least 3 digits", http_status=400)
+ exists = db.query(PettyCash).filter(and_(PettyCash.business_id == business_id, PettyCash.code == str(code))).first()
+ if exists:
+ raise ApiError("DUPLICATE_PETTY_CASH_CODE", "Duplicate petty cash code", http_status=400)
+ else:
+ max_code = db.query(func.max(PettyCash.code)).filter(PettyCash.business_id == business_id).scalar()
+ try:
+ if max_code is not None and str(max_code).isdigit():
+ next_code_int = int(max_code) + 1
+ else:
+ next_code_int = 100
+ if next_code_int < 100:
+ next_code_int = 100
+ code = str(next_code_int)
+ except Exception:
+ code = "100"
+
+ repo = PettyCashRepository(db)
+ obj = repo.create(business_id, {
+ "name": name,
+ "code": code,
+ "description": data.get("description"),
+ "currency_id": int(data["currency_id"]),
+ "is_active": bool(data.get("is_active", True)),
+ "is_default": bool(data.get("is_default", False)),
+ })
+
+ # ensure single default
+ if obj.is_default:
+ repo.clear_default(business_id, except_id=obj.id)
+
+ db.commit()
+ db.refresh(obj)
+ return petty_cash_to_dict(obj)
+
+
+def get_petty_cash_by_id(db: Session, id_: int) -> Optional[Dict[str, Any]]:
+ obj = db.query(PettyCash).filter(PettyCash.id == id_).first()
+ return petty_cash_to_dict(obj) if obj else None
+
+
+def update_petty_cash(db: Session, id_: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ repo = PettyCashRepository(db)
+ obj = repo.get_by_id(id_)
+ if obj is None:
+ return None
+
+ # validate name if provided
+ if "name" in data:
+ name_val = (data.get("name") or "").strip()
+ if name_val == "":
+ raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
+
+ if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
+ if not str(data["code"]).isdigit():
+ raise ApiError("INVALID_PETTY_CASH_CODE", "Petty cash code must be numeric", http_status=400)
+ if len(str(data["code"])) < 3:
+ raise ApiError("INVALID_PETTY_CASH_CODE", "Petty cash code must be at least 3 digits", http_status=400)
+ exists = db.query(PettyCash).filter(and_(PettyCash.business_id == obj.business_id, PettyCash.code == str(data["code"]), PettyCash.id != obj.id)).first()
+ if exists:
+ raise ApiError("DUPLICATE_PETTY_CASH_CODE", "Duplicate petty cash code", http_status=400)
+
+ repo.update(obj, data)
+ if obj.is_default:
+ repo.clear_default(obj.business_id, except_id=obj.id)
+
+ db.commit()
+ db.refresh(obj)
+ return petty_cash_to_dict(obj)
+
+
+def delete_petty_cash(db: Session, id_: int) -> bool:
+ obj = db.query(PettyCash).filter(PettyCash.id == id_).first()
+ if obj is None:
+ return False
+ db.delete(obj)
+ db.commit()
+ return True
+
+
+def bulk_delete_petty_cash(db: Session, business_id: int, ids: List[int]) -> Dict[str, Any]:
+ repo = PettyCashRepository(db)
+ result = repo.bulk_delete(business_id, ids)
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise ApiError("BULK_DELETE_FAILED", "Bulk delete failed for petty cash", http_status=500)
+ return result
+
+
+def list_petty_cash(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ repo = PettyCashRepository(db)
+ res = repo.list(business_id, query)
+ return {
+ "items": [petty_cash_to_dict(i) for i in res["items"]],
+ "pagination": res["pagination"],
+ "query_info": res["query_info"],
+ }
+
+
+def petty_cash_to_dict(obj: PettyCash) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "name": obj.name,
+ "code": obj.code,
+ "description": obj.description,
+ "currency_id": obj.currency_id,
+ "is_active": bool(obj.is_active),
+ "is_default": bool(obj.is_default),
+ "created_at": obj.created_at.isoformat(),
+ "updated_at": obj.updated_at.isoformat(),
+ }
diff --git a/hesabixAPI/build/lib/adapters/api/v1/bank_accounts.py b/hesabixAPI/build/lib/adapters/api/v1/bank_accounts.py
new file mode 100644
index 0000000..21e4523
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/bank_accounts.py
@@ -0,0 +1,514 @@
+from typing import Any, Dict, List, Optional
+from fastapi import APIRouter, Depends, Request, Body, HTTPException
+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.responses import success_response, format_datetime_fields, ApiError
+from app.core.permissions import require_business_management_dep, require_business_access
+from adapters.api.v1.schemas import QueryInfo
+from adapters.api.v1.schema_models.bank_account import (
+ BankAccountCreateRequest,
+ BankAccountUpdateRequest,
+)
+from app.services.bank_account_service import (
+ create_bank_account,
+ update_bank_account,
+ delete_bank_account,
+ get_bank_account_by_id,
+ list_bank_accounts,
+ bulk_delete_bank_accounts,
+)
+
+router = APIRouter(prefix="/bank-accounts", tags=["bank-accounts"])
+
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts",
+ summary="لیست حسابهای بانکی کسبوکار",
+ description="دریافت لیست حسابهای بانکی یک کسب و کار با امکان جستجو و فیلتر",
+)
+@require_business_access("business_id")
+async def list_bank_accounts_endpoint(
+ request: Request,
+ business_id: int,
+ query_info: QueryInfo,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ query_dict: Dict[str, Any] = {
+ "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 = list_bank_accounts(db, business_id, query_dict)
+ result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
+ return success_response(data=result, request=request, message="BANK_ACCOUNTS_LIST_FETCHED")
+
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts/create",
+ summary="ایجاد حساب بانکی جدید",
+ description="ایجاد حساب بانکی برای یک کسبوکار مشخص",
+)
+@require_business_access("business_id")
+async def create_bank_account_endpoint(
+ request: Request,
+ business_id: int,
+ body: BankAccountCreateRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ created = create_bank_account(db, business_id, payload)
+ return success_response(data=format_datetime_fields(created, request), request=request, message="BANK_ACCOUNT_CREATED")
+
+
+@router.get(
+ "/bank-accounts/{account_id}",
+ summary="جزئیات حساب بانکی",
+ description="دریافت جزئیات حساب بانکی بر اساس شناسه",
+)
+async def get_bank_account_endpoint(
+ request: Request,
+ account_id: int,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ result = get_bank_account_by_id(db, account_id)
+ if not result:
+ raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
+ # بررسی دسترسی به کسبوکار مرتبط
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="BANK_ACCOUNT_DETAILS")
+
+
+@router.put(
+ "/bank-accounts/{account_id}",
+ summary="ویرایش حساب بانکی",
+ description="ویرایش اطلاعات حساب بانکی",
+)
+async def update_bank_account_endpoint(
+ request: Request,
+ account_id: int,
+ body: BankAccountUpdateRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ result = update_bank_account(db, account_id, payload)
+ if result is None:
+ raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
+ # بررسی دسترسی به کسبوکار مرتبط
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="BANK_ACCOUNT_UPDATED")
+
+
+@router.delete(
+ "/bank-accounts/{account_id}",
+ summary="حذف حساب بانکی",
+ description="حذف یک حساب بانکی",
+)
+async def delete_bank_account_endpoint(
+ request: Request,
+ account_id: int,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ # ابتدا بررسی دسترسی بر اساس business مربوط به حساب
+ result = get_bank_account_by_id(db, account_id)
+ if result:
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ ok = delete_bank_account(db, account_id)
+ if not ok:
+ raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
+ return success_response(data=None, request=request, message="BANK_ACCOUNT_DELETED")
+
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts/bulk-delete",
+ summary="حذف گروهی حسابهای بانکی",
+ description="حذف چندین حساب بانکی بر اساس شناسهها",
+)
+@require_business_access("business_id")
+async def bulk_delete_bank_accounts_endpoint(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ ids = body.get("ids")
+ if not isinstance(ids, list):
+ ids = []
+ try:
+ ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
+ except Exception:
+ ids = []
+
+ if not ids:
+ return success_response({"deleted": 0, "skipped": 0, "total_requested": 0}, request, message="NO_VALID_IDS_FOR_DELETE")
+
+ # فراخوانی تابع حذف گروهی از سرویس
+ result = bulk_delete_bank_accounts(db, business_id, ids)
+
+ return success_response(result, request, message="BANK_ACCOUNTS_BULK_DELETE_DONE")
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts/export/excel",
+ summary="خروجی Excel لیست حسابهای بانکی",
+ description="خروجی Excel لیست حسابهای بانکی با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
+)
+@require_business_access("business_id")
+async def export_bank_accounts_excel(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ from fastapi.responses import Response
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+ from app.core.i18n import negotiate_locale
+
+ # دریافت دادهها از سرویس
+ query_dict = {
+ "take": int(body.get("take", 1000)), # برای export همه دادهها
+ "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_bank_accounts(db, business_id, query_dict)
+ items: List[Dict[str, Any]] = result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ headers: List[str] = [
+ "code", "name", "branch", "account_number", "sheba_number", "card_number", "owner_name", "pos_number", "is_active", "is_default"
+ ]
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "BankAccounts"
+
+ # RTL/LTR
+ 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'))
+
+ # Header
+ 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
+
+ # Rows
+ for row_idx, item in enumerate(items, 2):
+ for col_idx, key in enumerate(headers, 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
+ 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)
+
+ buffer = io.BytesIO()
+ wb.save(buffer)
+ buffer.seek(0)
+
+ content = buffer.getvalue()
+ return Response(
+ content=content,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": "attachment; filename=bank_accounts.xlsx",
+ "Content-Length": str(len(content)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts/export/pdf",
+ summary="خروجی PDF لیست حسابهای بانکی",
+ description="خروجی PDF لیست حسابهای بانکی با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
+)
+@require_business_access("business_id")
+async def export_bank_accounts_pdf(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ from fastapi.responses import Response
+ from weasyprint import HTML, CSS
+ from weasyprint.text.fonts import FontConfiguration
+ from app.core.i18n import negotiate_locale
+
+ # Build query dict similar to persons export
+ query_dict = {
+ "take": int(body.get("take", 1000)), # برای export همه دادهها
+ "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_bank_accounts(db, business_id, query_dict)
+ items: List[Dict[str, Any]] = result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ # Selection handling
+ 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):
+ import json
+ 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/keys from 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:
+ if items:
+ keys = list(items[0].keys())
+ headers = keys
+ else:
+ keys = [
+ "code", "name", "branch", "account_number", "sheba_number",
+ "card_number", "owner_name", "pos_number", "is_active", "is_default",
+ ]
+ headers = keys
+
+ # Load business info
+ business_name = ""
+ try:
+ from adapters.db.models.business import Business
+ biz = db.query(Business).filter(Business.id == business_id).first()
+ if biz is not None:
+ business_name = biz.name or ""
+ except Exception:
+ business_name = ""
+
+ # Locale and calendar-aware date
+ 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'
+
+ try:
+ from app.core.calendar import CalendarConverter
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
+ formatted_now = CalendarConverter.format_datetime(
+ __import__("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:
+ from datetime import datetime
+ now_str = datetime.now().strftime('%Y/%m/%d %H:%M')
+
+ # Labels
+ title_text = "گزارش لیست حسابهای بانکی" if is_fa else "Bank Accounts 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 "
+
+ def escape_val(v: Any) -> str:
+ try:
+ return str(v).replace('&', '&').replace('<', '<').replace('>', '>')
+ except Exception:
+ return str(v)
+
+ rows_html: List[str] = []
+ for item in items:
+ tds = []
+ for key in keys:
+ value = item.get(key)
+ if value is None:
+ value = ""
+ elif isinstance(value, list):
+ value = ", ".join(str(v) for v in value)
+ tds.append(f"{escape_val(value)} | ")
+ rows_html.append(f"{''.join(tds)}
")
+
+ headers_html = ''.join(f"{escape_val(h)} | " for h in headers)
+
+ table_html = f"""
+
+
+
+
+
+
+
+
+
+
+ {headers_html}
+
+
+ {''.join(rows_html)}
+
+
+
+
+
+
+ """
+
+ font_config = FontConfiguration()
+ pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": "attachment; filename=bank_accounts.pdf",
+ "Content-Length": str(len(pdf_bytes)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py b/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py
index da722d2..35b7c68 100644
--- a/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py
+++ b/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py
@@ -250,24 +250,93 @@ def get_business_info_with_permissions(
db: Session = Depends(get_db)
) -> dict:
"""دریافت اطلاعات کسب و کار همراه با دسترسیهای کاربر"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ logger.info(f"=== get_business_info_with_permissions START ===")
+ logger.info(f"Business ID: {business_id}")
+ logger.info(f"User ID: {ctx.get_user_id()}")
+ logger.info(f"User context business_id: {ctx.business_id}")
+ logger.info(f"Is superadmin: {ctx.is_superadmin()}")
+ logger.info(f"Is business owner: {ctx.is_business_owner(business_id)}")
+
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:
+ logger.error(f"Business {business_id} not found")
from app.core.responses import ApiError
raise ApiError("NOT_FOUND", "Business not found", http_status=404)
+ logger.info(f"Business found: {business.name} (Owner ID: {business.owner_id})")
+
# دریافت دسترسیهای کاربر
permissions = {}
- if not ctx.is_superadmin() and not ctx.is_business_owner(business_id):
+
+ # Debug logging
+ logger.info(f"Checking permissions for user {ctx.get_user_id()}")
+ logger.info(f"Is superadmin: {ctx.is_superadmin()}")
+ logger.info(f"Is business owner of {business_id}: {ctx.is_business_owner(business_id)}")
+ logger.info(f"Context business_id: {ctx.business_id}")
+
+ if ctx.is_superadmin():
+ logger.info("User is superadmin, but superadmin permissions don't apply to business operations")
+ # SuperAdmin فقط برای مدیریت سیستم است، نه برای کسب و کارهای خاص
+ # باید دسترسیهای کسب و کار را از جدول business_permissions دریافت کند
+ permission_repo = BusinessPermissionRepository(db)
+ business_permission = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id)
+ logger.info(f"Business permission object for superadmin: {business_permission}")
+
+ if business_permission:
+ permissions = business_permission.business_permissions or {}
+ logger.info(f"Superadmin business permissions: {permissions}")
+ else:
+ logger.info("No business permission found for superadmin user")
+ permissions = {}
+ elif ctx.is_business_owner(business_id):
+ logger.info("User is business owner, granting full permissions")
+ # مالک کسب و کار تمام دسترسیها را دارد
+ permissions = {
+ "people": {"add": True, "edit": True, "view": True, "delete": True},
+ "products": {"add": True, "edit": True, "view": True, "delete": True},
+ "bank_accounts": {"add": True, "edit": True, "view": True, "delete": True},
+ "invoices": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
+ "people_transactions": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
+ "expenses_income": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
+ "transfers": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
+ "checks": {"add": True, "edit": True, "view": True, "delete": True, "return": True, "collect": True, "transfer": True},
+ "accounting_documents": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
+ "chart_of_accounts": {"add": True, "edit": True, "view": True, "delete": True},
+ "opening_balance": {"edit": True, "view": True},
+ "settings": {"print": True, "users": True, "history": True, "business": True},
+ "categories": {"add": True, "edit": True, "view": True, "delete": True},
+ "product_attributes": {"add": True, "edit": True, "view": True, "delete": True},
+ "warehouses": {"add": True, "edit": True, "view": True, "delete": True},
+ "warehouse_transfers": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
+ "cash": {"add": True, "edit": True, "view": True, "delete": True},
+ "petty_cash": {"add": True, "edit": True, "view": True, "delete": True},
+ "wallet": {"view": True, "charge": True},
+ "storage": {"view": True, "delete": True},
+ "marketplace": {"buy": True, "view": True, "invoices": True},
+ "price_lists": {"add": True, "edit": True, "view": True, "delete": True},
+ "sms": {"history": True, "templates": True},
+ "join": True
+ }
+ else:
+ logger.info("User is not superadmin and not business owner, checking permissions")
# دریافت دسترسیهای کسب و کار از 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)
+ logger.info(f"Business permission object: {business_permission}")
+
if business_permission:
permissions = business_permission.business_permissions or {}
+ logger.info(f"User permissions: {permissions}")
+ else:
+ logger.info("No business permission found for user")
business_info = {
"id": business.id,
@@ -281,13 +350,19 @@ def get_business_info_with_permissions(
"created_at": business.created_at.isoformat(),
}
+ is_owner = ctx.is_business_owner(business_id)
+ has_access = ctx.can_access_business(business_id)
+
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)
+ "is_owner": is_owner,
+ "role": "مالک" if is_owner else "عضو",
+ "has_access": has_access
}
+ logger.info(f"Response data: {response_data}")
+ logger.info(f"=== get_business_info_with_permissions END ===")
+
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request)
diff --git a/hesabixAPI/build/lib/adapters/api/v1/cash_registers.py b/hesabixAPI/build/lib/adapters/api/v1/cash_registers.py
new file mode 100644
index 0000000..ad610a8
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/cash_registers.py
@@ -0,0 +1,414 @@
+from typing import Any, Dict, List
+from fastapi import APIRouter, Depends, Request, Body
+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.responses import success_response, format_datetime_fields, ApiError
+from app.core.permissions import require_business_management_dep, require_business_access
+from adapters.api.v1.schemas import QueryInfo
+from app.services.cash_register_service import (
+ create_cash_register,
+ update_cash_register,
+ delete_cash_register,
+ get_cash_register_by_id,
+ list_cash_registers,
+ bulk_delete_cash_registers,
+)
+
+
+router = APIRouter(prefix="/cash-registers", tags=["cash-registers"])
+
+
+@router.post(
+ "/businesses/{business_id}/cash-registers",
+ summary="لیست صندوقها",
+ description="دریافت لیست صندوقهای یک کسب و کار با امکان جستجو و فیلتر",
+)
+@require_business_access("business_id")
+async def list_cash_registers_endpoint(
+ request: Request,
+ business_id: int,
+ query_info: QueryInfo,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ query_dict: Dict[str, Any] = {
+ "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 = list_cash_registers(db, business_id, query_dict)
+ result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
+ return success_response(data=result, request=request, message="CASH_REGISTERS_LIST_FETCHED")
+
+
+@router.post(
+ "/businesses/{business_id}/cash-registers/create",
+ summary="ایجاد صندوق جدید",
+ description="ایجاد صندوق برای یک کسبوکار مشخص",
+)
+@require_business_access("business_id")
+async def create_cash_register_endpoint(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ payload: Dict[str, Any] = dict(body or {})
+ created = create_cash_register(db, business_id, payload)
+ return success_response(data=format_datetime_fields(created, request), request=request, message="CASH_REGISTER_CREATED")
+
+
+@router.get(
+ "/cash-registers/{cash_id}",
+ summary="جزئیات صندوق",
+ description="دریافت جزئیات صندوق بر اساس شناسه",
+)
+async def get_cash_register_endpoint(
+ request: Request,
+ cash_id: int,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ result = get_cash_register_by_id(db, cash_id)
+ if not result:
+ raise ApiError("CASH_REGISTER_NOT_FOUND", "Cash register not found", http_status=404)
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="CASH_REGISTER_DETAILS")
+
+
+@router.put(
+ "/cash-registers/{cash_id}",
+ summary="ویرایش صندوق",
+ description="ویرایش اطلاعات صندوق",
+)
+async def update_cash_register_endpoint(
+ request: Request,
+ cash_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ payload: Dict[str, Any] = dict(body or {})
+ result = update_cash_register(db, cash_id, payload)
+ if result is None:
+ raise ApiError("CASH_REGISTER_NOT_FOUND", "Cash register not found", http_status=404)
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="CASH_REGISTER_UPDATED")
+
+
+@router.delete(
+ "/cash-registers/{cash_id}",
+ summary="حذف صندوق",
+ description="حذف یک صندوق",
+)
+async def delete_cash_register_endpoint(
+ request: Request,
+ cash_id: int,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ result = get_cash_register_by_id(db, cash_id)
+ if result:
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ ok = delete_cash_register(db, cash_id)
+ if not ok:
+ raise ApiError("CASH_REGISTER_NOT_FOUND", "Cash register not found", http_status=404)
+ return success_response(data=None, request=request, message="CASH_REGISTER_DELETED")
+
+
+@router.post(
+ "/businesses/{business_id}/cash-registers/bulk-delete",
+ summary="حذف گروهی صندوقها",
+ description="حذف چندین صندوق بر اساس شناسهها",
+)
+@require_business_access("business_id")
+async def bulk_delete_cash_registers_endpoint(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ ids = body.get("ids")
+ if not isinstance(ids, list):
+ ids = []
+ try:
+ ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
+ except Exception:
+ ids = []
+ if not ids:
+ return success_response({"deleted": 0, "skipped": 0, "total_requested": 0}, request, message="NO_VALID_IDS_FOR_DELETE")
+ result = bulk_delete_cash_registers(db, business_id, ids)
+ return success_response(result, request, message="CASH_REGISTERS_BULK_DELETE_DONE")
+
+
+@router.post(
+ "/businesses/{business_id}/cash-registers/export/excel",
+ summary="خروجی Excel لیست صندوقها",
+ description="خروجی Excel لیست صندوقها با قابلیت فیلتر و مرتبسازی",
+)
+@require_business_access("business_id")
+async def export_cash_registers_excel(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ from fastapi.responses import Response
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+ from app.core.i18n import negotiate_locale
+
+ 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_cash_registers(db, business_id, query_dict)
+ items: List[Dict[str, Any]] = result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ headers: List[str] = [
+ "code", "name", "currency", "is_active", "is_default",
+ "payment_switch_number", "payment_terminal_number", "merchant_id",
+ "description",
+ ]
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "CashRegisters"
+
+ 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'))
+
+ 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
+
+ for row_idx, item in enumerate(items, 2):
+ for col_idx, key in enumerate(headers, 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")
+
+ 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)
+
+ buffer = io.BytesIO()
+ wb.save(buffer)
+ buffer.seek(0)
+ content = buffer.getvalue()
+ return Response(
+ content=content,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": "attachment; filename=cash_registers.xlsx",
+ "Content-Length": str(len(content)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
+@router.post(
+ "/businesses/{business_id}/cash-registers/export/pdf",
+ summary="خروجی PDF لیست صندوقها",
+ description="خروجی PDF لیست صندوقها با قابلیت فیلتر و مرتبسازی",
+)
+@require_business_access("business_id")
+async def export_cash_registers_pdf(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ from fastapi.responses import Response
+ from weasyprint import HTML
+ from weasyprint.text.fonts import FontConfiguration
+ from app.core.i18n import negotiate_locale
+
+ 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_cash_registers(db, business_id, query_dict)
+ items: List[Dict[str, Any]] = 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):
+ import json
+ 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:
+ keys = [
+ "code", "name", "currency_id", "is_active", "is_default",
+ "payment_switch_number", "payment_terminal_number", "merchant_id",
+ "description",
+ ]
+ headers = keys
+
+ business_name = ""
+ try:
+ from adapters.db.models.business import Business
+ biz = db.query(Business).filter(Business.id == business_id).first()
+ if biz is not None:
+ business_name = biz.name or ""
+ except Exception:
+ business_name = ""
+
+ 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'
+
+ try:
+ from app.core.calendar import CalendarConverter
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
+ formatted_now = CalendarConverter.format_datetime(
+ __import__("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:
+ from datetime import datetime
+ now_str = datetime.now().strftime('%Y/%m/%d %H:%M')
+
+ def esc(v: Any) -> str:
+ try:
+ return str(v).replace('&', '&').replace('<', '<').replace('>', '>')
+ except Exception:
+ return str(v)
+
+ rows_html: List[str] = []
+ 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"{esc(value)} | ")
+ rows_html.append(f"{''.join(tds)}
")
+
+ headers_html = ''.join(f"{esc(h)} | " for h in headers)
+
+ table_html = f"""
+
+
+
+
+
+
+ {esc('گزارش صندوقها' if is_fa else 'Cash Registers Report')}
+ {esc('نام کسبوکار' if is_fa else 'Business Name')}: {esc(business_name)} | {esc('تاریخ گزارش' if is_fa else 'Report Date')}: {esc(now_str)}
+
+ {headers_html}
+ {''.join(rows_html)}
+
+
+
+ """
+
+ font_config = FontConfiguration()
+ pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": "attachment; filename=cash_registers.pdf",
+ "Content-Length": str(len(pdf_bytes)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
diff --git a/hesabixAPI/build/lib/adapters/api/v1/categories.py b/hesabixAPI/build/lib/adapters/api/v1/categories.py
index 6290dbc..ddfec4b 100644
--- a/hesabixAPI/build/lib/adapters/api/v1/categories.py
+++ b/hesabixAPI/build/lib/adapters/api/v1/categories.py
@@ -146,3 +146,39 @@ def delete_category(
return success_response({"deleted": ok}, request)
+# Server-side search categories with breadcrumb path
+@router.post("/business/{business_id}/search")
+@require_business_access("business_id")
+def search_categories(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] | None = None,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.can_read_section("categories"):
+ raise ApiError("FORBIDDEN", "Missing business permission: categories.view", http_status=403)
+ q = (body or {}).get("query") if isinstance(body, dict) else None
+ limit = (body or {}).get("limit") if isinstance(body, dict) else None
+ if not isinstance(q, str) or not q.strip():
+ return success_response({"items": []}, request)
+ try:
+ limit_int = int(limit) if isinstance(limit, int) or (isinstance(limit, str) and str(limit).isdigit()) else 50
+ limit_int = max(1, min(limit_int, 200))
+ except Exception:
+ limit_int = 50
+ repo = CategoryRepository(db)
+ items = repo.search_with_paths(business_id=business_id, query=q.strip(), limit=limit_int)
+ # map label consistently
+ mapped = [
+ {
+ "id": it.get("id"),
+ "parent_id": it.get("parent_id"),
+ "label": it.get("title") or "",
+ "translations": it.get("translations") or {},
+ "path": it.get("path") or [],
+ }
+ for it in items
+ ]
+ return success_response({"items": mapped}, request)
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/currencies.py b/hesabixAPI/build/lib/adapters/api/v1/currencies.py
index 244babd..0429b23 100644
--- a/hesabixAPI/build/lib/adapters/api/v1/currencies.py
+++ b/hesabixAPI/build/lib/adapters/api/v1/currencies.py
@@ -1,9 +1,13 @@
from fastapi import APIRouter, Depends, Request
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, joinedload
from adapters.db.session import get_db
from adapters.db.models.currency import Currency
from app.core.responses import success_response
+from app.core.responses import ApiError
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from adapters.db.models.business import Business
router = APIRouter(prefix="/currencies", tags=["currencies"])
@@ -28,3 +32,60 @@ def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict:
return success_response(items, request)
+@router.get(
+ "/business/{business_id}",
+ summary="فهرست ارزهای کسبوکار",
+ description="دریافت ارز پیشفرض کسبوکار بهعلاوه ارزهای فعال آن کسبوکار (بدون تکرار)",
+)
+@require_business_access()
+def list_business_currencies(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ business = (
+ db.query(Business)
+ .options(
+ joinedload(Business.default_currency),
+ joinedload(Business.currencies),
+ )
+ .filter(Business.id == business_id)
+ .first()
+ )
+ if not business:
+ raise ApiError("NOT_FOUND", "کسبوکار یافت نشد", http_status=404)
+
+ seen_ids = set()
+ result = []
+
+ # Add default currency first if exists
+ if business.default_currency:
+ c = business.default_currency
+ result.append({
+ "id": c.id,
+ "name": c.name,
+ "title": c.title,
+ "symbol": c.symbol,
+ "code": c.code,
+ "is_default": True,
+ })
+ seen_ids.add(c.id)
+
+ # Add active business currencies (excluding duplicates)
+ for c in business.currencies or []:
+ if c.id in seen_ids:
+ continue
+ result.append({
+ "id": c.id,
+ "name": c.name,
+ "title": c.title,
+ "symbol": c.symbol,
+ "code": c.code,
+ "is_default": False,
+ })
+ seen_ids.add(c.id)
+
+ # If nothing found, return empty list
+ return success_response(result, request)
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/persons.py b/hesabixAPI/build/lib/adapters/api/v1/persons.py
index 2185f0c..39ad0a7 100644
--- a/hesabixAPI/build/lib/adapters/api/v1/persons.py
+++ b/hesabixAPI/build/lib/adapters/api/v1/persons.py
@@ -23,6 +23,84 @@ from adapters.db.models.business import Business
router = APIRouter(prefix="/persons", tags=["persons"])
+@router.post("/businesses/{business_id}/persons/bulk-delete",
+ summary="حذف گروهی اشخاص",
+ description="حذف چندین شخص بر اساس شناسهها یا کدها",
+)
+async def bulk_delete_persons_endpoint(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ auth_context: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ """حذف گروهی اشخاص برای یک کسبوکار مشخص
+
+ ورودی:
+ - ids: لیست شناسههای اشخاص
+ - codes: لیست کدهای اشخاص در همان کسبوکار
+ """
+ from sqlalchemy import and_ as _and
+ from adapters.db.models.person import Person
+
+ ids = body.get("ids")
+ codes = body.get("codes")
+ deleted = 0
+ skipped = 0
+
+ if not ids and not codes:
+ return success_response({"deleted": 0, "skipped": 0}, request)
+
+ # Normalize inputs
+ if isinstance(ids, list):
+ try:
+ ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
+ except Exception:
+ ids = []
+ else:
+ ids = []
+
+ if isinstance(codes, list):
+ try:
+ codes = [int(str(x).strip()) for x in codes if str(x).strip().isdigit()]
+ except Exception:
+ codes = []
+ else:
+ codes = []
+
+ # Delete by IDs first
+ if ids:
+ for pid in ids:
+ try:
+ person = db.query(Person).filter(_and(Person.id == pid, Person.business_id == business_id)).first()
+ if person is None:
+ skipped += 1
+ continue
+ db.delete(person)
+ deleted += 1
+ except Exception:
+ skipped += 1
+ db.commit()
+
+ # Delete by codes
+ if codes:
+ try:
+ items = db.query(Person).filter(_and(Person.business_id == business_id, Person.code.in_(codes))).all()
+ for obj in items:
+ try:
+ db.delete(obj)
+ deleted += 1
+ except Exception:
+ skipped += 1
+ db.commit()
+ except Exception:
+ # In case of query issues, treat all as skipped
+ skipped += len(codes)
+
+ return success_response({"deleted": deleted, "skipped": skipped}, request)
+
+
@router.post("/businesses/{business_id}/persons/create",
summary="ایجاد شخص جدید",
description="ایجاد شخص جدید برای کسب و کار مشخص",
diff --git a/hesabixAPI/build/lib/adapters/api/v1/products.py b/hesabixAPI/build/lib/adapters/api/v1/products.py
index a76bfdb..d8b734c 100644
--- a/hesabixAPI/build/lib/adapters/api/v1/products.py
+++ b/hesabixAPI/build/lib/adapters/api/v1/products.py
@@ -12,6 +12,8 @@ from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.schema_models.product import (
ProductCreateRequest,
ProductUpdateRequest,
+ BulkPriceUpdateRequest,
+ BulkPriceUpdatePreviewResponse,
)
from app.services.product_service import (
create_product,
@@ -20,8 +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"])
@@ -114,6 +121,64 @@ def delete_product_endpoint(
return success_response({"deleted": ok}, request)
+@router.post("/business/{business_id}/bulk-delete",
+ summary="حذف گروهی محصولات",
+ description="حذف چندین آیتم بر اساس شناسهها یا کدها",
+)
+@require_business_access("business_id")
+def bulk_delete_products_endpoint(
+ 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("inventory", "delete"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
+
+ from sqlalchemy import and_ as _and
+ from adapters.db.models.product import Product
+
+ ids = body.get("ids")
+ codes = body.get("codes")
+ deleted = 0
+ skipped = 0
+
+ if not ids and not codes:
+ return success_response({"deleted": 0, "skipped": 0}, request)
+
+ # Normalize inputs
+ if isinstance(ids, list):
+ ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
+ else:
+ ids = []
+ if isinstance(codes, list):
+ codes = [str(x).strip() for x in codes if str(x).strip()]
+ else:
+ codes = []
+
+ # Delete by IDs first
+ if ids:
+ for pid in ids:
+ ok = delete_product(db, pid, business_id)
+ if ok:
+ deleted += 1
+ else:
+ skipped += 1
+
+ # Delete by codes
+ if codes:
+ items = db.query(Product).filter(_and(Product.business_id == business_id, Product.code.in_(codes))).all()
+ for obj in items:
+ try:
+ db.delete(obj)
+ deleted += 1
+ except Exception:
+ skipped += 1
+ db.commit()
+
+ return success_response({"deleted": deleted, "skipped": skipped}, request)
+
@router.post("/business/{business_id}/export/excel",
summary="خروجی Excel لیست محصولات",
description="خروجی Excel لیست محصولات با قابلیت فیلتر، انتخاب ستونها و ترتیب آنها",
@@ -267,6 +332,285 @@ async def export_products_excel(
)
+@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 لیست محصولات با قابلیت فیلتر و انتخاب ستونها",
@@ -507,3 +851,41 @@ async def export_products_pdf(
)
+@router.post("/business/{business_id}/bulk-price-update/preview",
+ summary="پیشنمایش تغییر قیمتهای گروهی",
+ description="پیشنمایش تغییرات قیمت قبل از اعمال",
+)
+@require_business_access("business_id")
+def preview_bulk_price_update_endpoint(
+ request: Request,
+ business_id: int,
+ payload: BulkPriceUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+
+ result = preview_bulk_price_update(db, business_id, payload)
+ return success_response(data=result.dict(), request=request)
+
+
+@router.post("/business/{business_id}/bulk-price-update/apply",
+ summary="اعمال تغییر قیمتهای گروهی",
+ description="اعمال تغییرات قیمت بر روی کالاهای انتخاب شده",
+)
+@require_business_access("business_id")
+def apply_bulk_price_update_endpoint(
+ request: Request,
+ business_id: int,
+ payload: BulkPriceUpdateRequest,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> Dict[str, Any]:
+ if not ctx.has_business_permission("inventory", "write"):
+ raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
+
+ result = apply_bulk_price_update(db, business_id, payload)
+ return success_response(data=result, request=request)
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/bank_account.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/bank_account.py
new file mode 100644
index 0000000..2b173d8
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/bank_account.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from typing import Optional
+from pydantic import BaseModel, Field
+
+
+class BankAccountCreateRequest(BaseModel):
+ code: Optional[str] = Field(default=None, max_length=50)
+ name: str = Field(..., min_length=1, max_length=255)
+ branch: Optional[str] = Field(default=None, max_length=255)
+ account_number: Optional[str] = Field(default=None, max_length=50)
+ sheba_number: Optional[str] = Field(default=None, max_length=30)
+ card_number: Optional[str] = Field(default=None, max_length=20)
+ owner_name: Optional[str] = Field(default=None, max_length=255)
+ pos_number: Optional[str] = Field(default=None, max_length=50)
+ payment_id: Optional[str] = Field(default=None, max_length=100)
+ description: Optional[str] = Field(default=None, max_length=500)
+ currency_id: int = Field(..., ge=1)
+ is_active: bool = Field(default=True)
+ is_default: bool = Field(default=False)
+
+ @classmethod
+ def __get_validators__(cls):
+ yield from super().__get_validators__()
+
+ @classmethod
+ def validate(cls, value): # type: ignore[override]
+ obj = super().validate(value)
+ if getattr(obj, 'code', None) is not None:
+ code_val = str(getattr(obj, 'code'))
+ if code_val.strip() != '':
+ if not code_val.isdigit():
+ raise ValueError("کد حساب باید فقط عددی باشد")
+ if len(code_val) < 3:
+ raise ValueError("کد حساب باید حداقل ۳ رقم باشد")
+ return obj
+
+
+class BankAccountUpdateRequest(BaseModel):
+ code: Optional[str] = Field(default=None, max_length=50)
+ name: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ branch: Optional[str] = Field(default=None, max_length=255)
+ account_number: Optional[str] = Field(default=None, max_length=50)
+ sheba_number: Optional[str] = Field(default=None, max_length=30)
+ card_number: Optional[str] = Field(default=None, max_length=20)
+ owner_name: Optional[str] = Field(default=None, max_length=255)
+ pos_number: Optional[str] = Field(default=None, max_length=50)
+ payment_id: Optional[str] = Field(default=None, max_length=100)
+ description: Optional[str] = Field(default=None, max_length=500)
+ currency_id: Optional[int] = Field(default=None, ge=1)
+ is_active: Optional[bool] = Field(default=None)
+ is_default: Optional[bool] = Field(default=None)
+
+ @classmethod
+ def __get_validators__(cls):
+ yield from super().__get_validators__()
+
+ @classmethod
+ def validate(cls, value): # type: ignore[override]
+ obj = super().validate(value)
+ if getattr(obj, 'code', None) is not None:
+ code_val = str(getattr(obj, 'code'))
+ if code_val.strip() != '':
+ if not code_val.isdigit():
+ raise ValueError("کد حساب باید فقط عددی باشد")
+ if len(code_val) < 3:
+ raise ValueError("کد حساب باید حداقل ۳ رقم باشد")
+ return obj
+
+
+class BankAccountResponse(BaseModel):
+ id: int
+ business_id: int
+ code: Optional[str]
+ name: str
+ branch: Optional[str]
+ account_number: Optional[str]
+ sheba_number: Optional[str]
+ card_number: Optional[str]
+ owner_name: Optional[str]
+ pos_number: Optional[str]
+ payment_id: Optional[str]
+ description: Optional[str]
+ currency_id: int
+ is_active: bool
+ is_default: bool
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py
index 129bf46..80535a7 100644
--- a/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py
+++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py
@@ -108,3 +108,59 @@ class ProductResponse(BaseModel):
from_attributes = True
+class BulkPriceUpdateType(str, Enum):
+ PERCENTAGE = "percentage"
+ AMOUNT = "amount"
+
+
+class BulkPriceUpdateDirection(str, Enum):
+ INCREASE = "increase"
+ DECREASE = "decrease"
+
+
+class BulkPriceUpdateTarget(str, Enum):
+ SALES_PRICE = "sales_price"
+ PURCHASE_PRICE = "purchase_price"
+ BOTH = "both"
+
+
+class BulkPriceUpdateRequest(BaseModel):
+ """درخواست تغییر قیمتهای گروهی"""
+ update_type: BulkPriceUpdateType = Field(..., description="نوع تغییر: درصدی یا مقداری")
+ direction: BulkPriceUpdateDirection = Field(default=BulkPriceUpdateDirection.INCREASE, description="جهت تغییر: افزایش یا کاهش")
+ target: BulkPriceUpdateTarget = Field(..., description="هدف تغییر: قیمت فروش، خرید یا هر دو")
+ value: Decimal = Field(..., description="مقدار تغییر (درصد یا مبلغ)")
+
+ # فیلترهای انتخاب کالاها
+ category_ids: Optional[List[int]] = Field(default=None, description="شناسههای دستهبندی")
+ currency_ids: Optional[List[int]] = Field(default=None, description="شناسههای ارز")
+ price_list_ids: Optional[List[int]] = Field(default=None, description="شناسههای لیست قیمت")
+ item_types: Optional[List[ProductItemType]] = Field(default=None, description="نوع آیتمها")
+ product_ids: Optional[List[int]] = Field(default=None, description="شناسههای کالاهای خاص")
+
+ # گزینههای اضافی
+ only_products_with_inventory: Optional[bool] = Field(default=None, description="فقط کالاهای با موجودی")
+ only_products_with_base_price: Optional[bool] = Field(default=True, description="فقط کالاهای با قیمت پایه")
+
+
+class BulkPriceUpdatePreview(BaseModel):
+ """پیشنمایش تغییرات قیمت"""
+ product_id: int
+ product_name: str
+ product_code: str
+ category_name: Optional[str] = None
+ current_sales_price: Optional[Decimal] = None
+ current_purchase_price: Optional[Decimal] = None
+ new_sales_price: Optional[Decimal] = None
+ new_purchase_price: Optional[Decimal] = None
+ sales_price_change: Optional[Decimal] = None
+ purchase_price_change: Optional[Decimal] = None
+
+
+class BulkPriceUpdatePreviewResponse(BaseModel):
+ """پاسخ پیشنمایش تغییرات قیمت"""
+ total_products: int
+ affected_products: List[BulkPriceUpdatePreview]
+ summary: dict = Field(..., description="خلاصه تغییرات")
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/__init__.py b/hesabixAPI/build/lib/adapters/db/models/__init__.py
index a8d4d3c..56c289a 100644
--- a/hesabixAPI/build/lib/adapters/db/models/__init__.py
+++ b/hesabixAPI/build/lib/adapters/db/models/__init__.py
@@ -36,3 +36,4 @@ 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
+from .bank_account import BankAccount # noqa: F401
diff --git a/hesabixAPI/build/lib/adapters/db/models/bank_account.py b/hesabixAPI/build/lib/adapters/db/models/bank_account.py
new file mode 100644
index 0000000..5df4665
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/bank_account.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint, Boolean
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class BankAccount(Base):
+ __tablename__ = "bank_accounts"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'code', name='uq_bank_accounts_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[str | None] = mapped_column(String(50), nullable=True, index=True, comment="کد یکتا در هر کسبوکار (اختیاری)")
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام حساب")
+ description: Mapped[str | None] = mapped_column(String(500), nullable=True)
+
+ # اطلاعات بانکی
+ branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ account_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ sheba_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
+ card_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ owner_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ pos_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+
+ # تنظیمات
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
+ is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
+
+ # زمانبندی
+ 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)
+
+ # روابط
+ business = relationship("Business", backref="bank_accounts")
+ currency = relationship("Currency", backref="bank_accounts")
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/models/cash_register.py b/hesabixAPI/build/lib/adapters/db/models/cash_register.py
new file mode 100644
index 0000000..93672e4
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/models/cash_register.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint, Boolean
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class CashRegister(Base):
+ __tablename__ = "cash_registers"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'code', name='uq_cash_registers_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)
+
+ # مشخصات
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام صندوق")
+ code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True, comment="کد یکتا در هر کسبوکار (اختیاری)")
+ description: Mapped[str | None] = mapped_column(String(500), nullable=True)
+
+ # تنظیمات
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
+ is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
+
+ # پرداخت
+ payment_switch_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ payment_terminal_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ merchant_id: Mapped[str | None] = mapped_column(String(100), 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)
+
+ # روابط
+ business = relationship("Business", backref="cash_registers")
+ currency = relationship("Currency", backref="cash_registers")
+
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py
index b364fa2..fbb502b 100644
--- a/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py
+++ b/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py
@@ -15,13 +15,31 @@ class BusinessPermissionRepository(BaseRepository[BusinessPermission]):
def get_by_user_and_business(self, user_id: int, business_id: int) -> Optional[BusinessPermission]:
"""دریافت دسترسیهای کاربر برای کسب و کار خاص"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ logger.info(f"=== get_by_user_and_business START ===")
+ logger.info(f"User ID: {user_id}")
+ logger.info(f"Business ID: {business_id}")
+
stmt = select(BusinessPermission).where(
and_(
BusinessPermission.user_id == user_id,
BusinessPermission.business_id == business_id
)
)
- return self.db.execute(stmt).scalars().first()
+
+ logger.info(f"SQL Query: {stmt}")
+
+ result = self.db.execute(stmt).scalars().first()
+
+ logger.info(f"Query result: {result}")
+ if result:
+ logger.info(f"Business permissions: {result.business_permissions}")
+ logger.info(f"Type: {type(result.business_permissions)}")
+
+ logger.info(f"=== get_by_user_and_business END ===")
+ return result
def create_or_update(self, user_id: int, business_id: int, permissions: dict) -> BusinessPermission:
"""ایجاد یا بهروزرسانی دسترسیهای کاربر برای کسب و کار"""
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/cash_register_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/cash_register_repository.py
new file mode 100644
index 0000000..28916e1
--- /dev/null
+++ b/hesabixAPI/build/lib/adapters/db/repositories/cash_register_repository.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, or_, func
+
+from adapters.db.models.cash_register import CashRegister
+
+
+class CashRegisterRepository:
+ def __init__(self, db: Session) -> None:
+ self.db = db
+
+ def create(self, business_id: int, data: Dict[str, Any]) -> CashRegister:
+ obj = CashRegister(
+ business_id=business_id,
+ name=data.get("name"),
+ code=data.get("code"),
+ description=data.get("description"),
+ currency_id=int(data["currency_id"]),
+ is_active=bool(data.get("is_active", True)),
+ is_default=bool(data.get("is_default", False)),
+ payment_switch_number=data.get("payment_switch_number"),
+ payment_terminal_number=data.get("payment_terminal_number"),
+ merchant_id=data.get("merchant_id"),
+ )
+ self.db.add(obj)
+ self.db.flush()
+ return obj
+
+ def get_by_id(self, id_: int) -> Optional[CashRegister]:
+ return self.db.query(CashRegister).filter(CashRegister.id == id_).first()
+
+ def update(self, obj: CashRegister, data: Dict[str, Any]) -> CashRegister:
+ for key in [
+ "name","code","description","currency_id","is_active","is_default",
+ "payment_switch_number","payment_terminal_number","merchant_id",
+ ]:
+ if key in data and data[key] is not None:
+ setattr(obj, key, data[key] if key != "currency_id" else int(data[key]))
+ return obj
+
+ def delete(self, obj: CashRegister) -> None:
+ self.db.delete(obj)
+
+ def bulk_delete(self, business_id: int, ids: List[int]) -> Dict[str, int]:
+ items = self.db.query(CashRegister).filter(
+ CashRegister.business_id == business_id,
+ CashRegister.id.in_(ids)
+ ).all()
+ deleted = 0
+ skipped = 0
+ for it in items:
+ try:
+ self.db.delete(it)
+ deleted += 1
+ except Exception:
+ skipped += 1
+ return {"deleted": deleted, "skipped": skipped, "total_requested": len(ids)}
+
+ def clear_default(self, business_id: int, except_id: Optional[int] = None) -> None:
+ q = self.db.query(CashRegister).filter(CashRegister.business_id == business_id)
+ if except_id is not None:
+ q = q.filter(CashRegister.id != except_id)
+ q.update({CashRegister.is_default: False})
+
+ def list(self, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ q = self.db.query(CashRegister).filter(CashRegister.business_id == business_id)
+
+ # search
+ search = query.get("search")
+ search_fields = query.get("search_fields") or []
+ if search and search_fields:
+ term = f"%{search}%"
+ conditions = []
+ for f in search_fields:
+ if f == "name":
+ conditions.append(CashRegister.name.ilike(term))
+ if f == "code":
+ conditions.append(CashRegister.code.ilike(term))
+ elif f == "description":
+ conditions.append(CashRegister.description.ilike(term))
+ elif f in {"payment_switch_number","payment_terminal_number","merchant_id"}:
+ conditions.append(getattr(CashRegister, f).ilike(term))
+ if conditions:
+ q = q.filter(or_(*conditions))
+
+ # filters
+ for flt in (query.get("filters") or []):
+ prop = flt.get("property")
+ op = flt.get("operator")
+ val = flt.get("value")
+ if not prop or not op:
+ continue
+ if prop in {"is_active","is_default"} and op == "=":
+ q = q.filter(getattr(CashRegister, prop) == val)
+ elif prop == "currency_id" and op == "=":
+ q = q.filter(CashRegister.currency_id == val)
+
+ # sort
+ sort_by = query.get("sort_by") or "created_at"
+ sort_desc = bool(query.get("sort_desc", True))
+ col = getattr(CashRegister, sort_by, CashRegister.created_at)
+ q = q.order_by(col.desc() if sort_desc else col.asc())
+
+ # pagination
+ skip = int(query.get("skip", 0))
+ take = int(query.get("take", 20))
+ total = q.count()
+ items = q.offset(skip).limit(take).all()
+
+ return {
+ "items": items,
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ "query_info": query,
+ }
+
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py
index 43ad61e..a84d944 100644
--- a/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py
+++ b/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import List, Dict, Any
from sqlalchemy.orm import Session
-from sqlalchemy import select, and_, or_
+from sqlalchemy import select, and_, or_, func
from .base_repo import BaseRepository
from ..models.category import BusinessCategory
@@ -90,3 +90,55 @@ class CategoryRepository(BaseRepository[BusinessCategory]):
return True
+ def search_with_paths(self, *, business_id: int, query: str, limit: int = 50) -> list[Dict[str, Any]]:
+ q = (query or "").strip()
+ if not q:
+ return []
+ # Basic ILIKE search over fa/en translations by JSON string casting
+ # Note: For performance, consider a materialized path or FTS in future
+ stmt = (
+ select(BusinessCategory)
+ .where(BusinessCategory.business_id == business_id)
+ )
+ rows = list(self.db.execute(stmt).scalars().all())
+ # Build in-memory tree index
+ by_id: dict[int, BusinessCategory] = {r.id: r for r in rows}
+ def get_title(r: BusinessCategory) -> str:
+ trans = r.title_translations or {}
+ return (trans.get("fa") or trans.get("en") or "").strip()
+ # Filter by query
+ q_lower = q.lower()
+ matched: list[BusinessCategory] = []
+ for r in rows:
+ if q_lower in get_title(r).lower():
+ matched.append(r)
+ matched = matched[: max(1, min(limit, 200))]
+ # Build path for each match
+ def build_path(r: BusinessCategory) -> list[Dict[str, Any]]:
+ path: list[Dict[str, Any]] = []
+ current = r
+ seen: set[int] = set()
+ while current is not None and current.id not in seen:
+ seen.add(current.id)
+ title = get_title(current)
+ path.append({
+ "id": current.id,
+ "parent_id": current.parent_id,
+ "title": title,
+ "translations": current.title_translations or {},
+ })
+ pid = current.parent_id
+ current = by_id.get(pid) if pid else None
+ path.reverse()
+ return path
+ result: list[Dict[str, Any]] = []
+ for r in matched:
+ result.append({
+ "id": r.id,
+ "parent_id": r.parent_id,
+ "title": get_title(r),
+ "translations": r.title_translations or {},
+ "path": build_path(r),
+ })
+ return result
+
diff --git a/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py
index db63dc2..e28deba 100644
--- a/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py
+++ b/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
-from sqlalchemy import select, and_, func
+from sqlalchemy import select, and_, or_, func
from .base_repo import BaseRepository
from ..models.product import Product
@@ -25,6 +25,38 @@ class ProductRepository(BaseRepository[Product]):
)
)
+ # Apply filters (supports minimal set used by clients)
+ if filters:
+ for f in filters:
+ # Support both dict and pydantic-like objects
+ if isinstance(f, dict):
+ field = f.get("property")
+ operator = f.get("operator")
+ value = f.get("value")
+ else:
+ field = getattr(f, "property", None)
+ operator = getattr(f, "operator", None)
+ value = getattr(f, "value", None)
+
+ if not field or not operator:
+ continue
+
+ # Code filters
+ if field == "code":
+ if operator == "=":
+ stmt = stmt.where(Product.code == value)
+ elif operator == "in" and isinstance(value, (list, tuple)):
+ stmt = stmt.where(Product.code.in_(list(value)))
+ continue
+
+ # Name contains
+ if field == "name":
+ if operator in {"contains", "ilike"} and isinstance(value, str):
+ stmt = stmt.where(Product.name.ilike(f"%{value}%"))
+ elif operator == "=":
+ stmt = stmt.where(Product.name == value)
+ continue
+
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
# Sorting
diff --git a/hesabixAPI/build/lib/app/core/auth_dependency.py b/hesabixAPI/build/lib/app/core/auth_dependency.py
index c38b5ac..0711e2d 100644
--- a/hesabixAPI/build/lib/app/core/auth_dependency.py
+++ b/hesabixAPI/build/lib/app/core/auth_dependency.py
@@ -48,21 +48,43 @@ class AuthContext:
@staticmethod
def _normalize_permissions_value(value) -> dict:
"""نرمالسازی مقدار JSON دسترسیها به dict برای سازگاری با دادههای legacy"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ logger.info(f"=== _normalize_permissions_value START ===")
+ logger.info(f"Input value type: {type(value)}")
+ logger.info(f"Input value: {value}")
+
if isinstance(value, dict):
+ logger.info("Value is already a dict, returning as is")
+ logger.info(f"=== _normalize_permissions_value END ===")
return value
if isinstance(value, list):
+ logger.info("Value is a list, processing...")
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)}
+ logger.info("Detected list of key-value pairs")
+ result = {k: v for k, v in value if isinstance(k, str)}
+ logger.info(f"Converted to dict: {result}")
+ logger.info(f"=== _normalize_permissions_value END ===")
+ return result
# لیست دیکشنریها مانند [{"join": true}, {"sales": {...}}]
if all(isinstance(item, dict) for item in value):
+ logger.info("Detected list of dictionaries")
merged = {}
for item in value:
merged.update({k: v for k, v in item.items()})
+ logger.info(f"Merged to dict: {merged}")
+ logger.info(f"=== _normalize_permissions_value END ===")
return merged
- except Exception:
+ except Exception as e:
+ logger.error(f"Error processing list: {e}")
+ logger.info(f"=== _normalize_permissions_value END ===")
return {}
+
+ logger.info(f"Unsupported value type {type(value)}, returning empty dict")
+ logger.info(f"=== _normalize_permissions_value END ===")
return {}
def get_translator(self) -> Translator:
@@ -101,15 +123,34 @@ class AuthContext:
def _get_business_permissions(self) -> dict:
"""دریافت دسترسیهای کسب و کار از دیتابیس"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ logger.info(f"=== _get_business_permissions START ===")
+ logger.info(f"User ID: {self.user.id}")
+ logger.info(f"Business ID: {self.business_id}")
+ logger.info(f"DB available: {self.db is not None}")
+
if not self.business_id or not self.db:
+ logger.info("No business_id or db, returning empty permissions")
return {}
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
repo = BusinessPermissionRepository(self.db)
permission_obj = repo.get_by_user_and_business(self.user.id, self.business_id)
+ logger.info(f"Permission object found: {permission_obj}")
+
if permission_obj and permission_obj.business_permissions:
- return AuthContext._normalize_permissions_value(permission_obj.business_permissions)
+ raw_permissions = permission_obj.business_permissions
+ logger.info(f"Raw permissions: {raw_permissions}")
+ normalized_permissions = AuthContext._normalize_permissions_value(raw_permissions)
+ logger.info(f"Normalized permissions: {normalized_permissions}")
+ logger.info(f"=== _get_business_permissions END ===")
+ return normalized_permissions
+
+ logger.info("No permissions found, returning empty dict")
+ logger.info(f"=== _get_business_permissions END ===")
return {}
# بررسی دسترسیهای اپلیکیشن
@@ -146,15 +187,33 @@ class AuthContext:
import logging
logger = logging.getLogger(__name__)
+ logger.info(f"=== is_business_owner START ===")
+ logger.info(f"Requested business_id: {business_id}")
+ logger.info(f"Context business_id: {self.business_id}")
+ logger.info(f"User ID: {self.user.id}")
+ logger.info(f"DB available: {self.db is not None}")
+
target_business_id = business_id or self.business_id
+ logger.info(f"Target business_id: {target_business_id}")
+
if not target_business_id or not self.db:
logger.info(f"is_business_owner: no business_id ({target_business_id}) or db ({self.db is not None})")
+ logger.info(f"=== is_business_owner END (no business_id or db) ===")
return False
from adapters.db.models.business import Business
business = self.db.get(Business, target_business_id)
- is_owner = business and business.owner_id == self.user.id
- logger.info(f"is_business_owner: business_id={target_business_id}, business={business}, owner_id={business.owner_id if business else None}, user_id={self.user.id}, is_owner={is_owner}")
+ logger.info(f"Business lookup result: {business}")
+
+ if business:
+ logger.info(f"Business owner_id: {business.owner_id}")
+ is_owner = business.owner_id == self.user.id
+ logger.info(f"is_owner: {is_owner}")
+ else:
+ logger.info("Business not found")
+ is_owner = False
+
+ logger.info(f"=== is_business_owner END (result: {is_owner}) ===")
return is_owner
# بررسی دسترسیهای کسب و کار
@@ -250,22 +309,66 @@ class AuthContext:
import logging
logger = logging.getLogger(__name__)
- logger.info(f"Checking business access: user {self.user.id}, business {business_id}, context business_id {self.business_id}")
+ logger.info(f"=== can_access_business START ===")
+ logger.info(f"User ID: {self.user.id}")
+ logger.info(f"Requested business ID: {business_id}")
+ logger.info(f"User context business_id: {self.business_id}")
+ logger.info(f"User app permissions: {self.app_permissions}")
# SuperAdmin دسترسی به همه کسب و کارها دارد
if self.is_superadmin():
logger.info(f"User {self.user.id} is superadmin, granting access to business {business_id}")
+ logger.info(f"=== can_access_business END (superadmin) ===")
return True
- # اگر مالک کسب و کار است، دسترسی دارد
- if self.is_business_owner() and business_id == self.business_id:
- logger.info(f"User {self.user.id} is business owner of {business_id}, granting access")
+ # بررسی مالکیت کسب و کار
+ if self.db:
+ from adapters.db.models.business import Business
+ business = self.db.get(Business, business_id)
+ logger.info(f"Business lookup result: {business}")
+ if business:
+ logger.info(f"Business owner ID: {business.owner_id}")
+ if business.owner_id == self.user.id:
+ logger.info(f"User {self.user.id} is business owner of {business_id}, granting access")
+ logger.info(f"=== can_access_business END (owner) ===")
+ return True
+ else:
+ logger.info("No database connection available for business lookup")
+
+ # بررسی عضویت در کسب و کار
+ if self.db:
+ from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
+ permission_repo = BusinessPermissionRepository(self.db)
+ business_permission = permission_repo.get_by_user_and_business(self.user.id, business_id)
+ logger.info(f"Business permission lookup result: {business_permission}")
+
+ if business_permission:
+ # بررسی دسترسی join
+ permissions = business_permission.business_permissions or {}
+ logger.info(f"User permissions for business {business_id}: {permissions}")
+ join_permission = permissions.get('join')
+ logger.info(f"Join permission: {join_permission}")
+
+ if join_permission == True:
+ logger.info(f"User {self.user.id} is member of business {business_id}, granting access")
+ logger.info(f"=== can_access_business END (member) ===")
+ return True
+ else:
+ logger.info(f"User {self.user.id} does not have join permission for business {business_id}")
+ else:
+ logger.info(f"No business permission found for user {self.user.id} and business {business_id}")
+ else:
+ logger.info("No database connection available for permission lookup")
+
+ # اگر کسب و کار در context کاربر است، دسترسی دارد
+ if business_id == self.business_id:
+ logger.info(f"User {self.user.id} has context access to business {business_id}")
+ logger.info(f"=== can_access_business END (context) ===")
return True
- # بررسی دسترسیهای کسب و کار
- has_access = business_id == self.business_id
- logger.info(f"Business access check: {business_id} == {self.business_id} = {has_access}")
- return has_access
+ logger.info(f"User {self.user.id} does not have access to business {business_id}")
+ logger.info(f"=== can_access_business END (denied) ===")
+ return False
def is_business_member(self, business_id: int) -> bool:
"""بررسی اینکه آیا کاربر عضو کسب و کار است یا نه (دسترسی join)"""
@@ -378,7 +481,15 @@ def get_current_user(
# تشخیص سال مالی از هدر X-Fiscal-Year-ID (آینده)
fiscal_year_id = _detect_fiscal_year_id(request)
- return AuthContext(
+ logger.info(f"Creating AuthContext for user {user.id}:")
+ logger.info(f" - Language: {language}")
+ logger.info(f" - Calendar type: {calendar_type}")
+ logger.info(f" - Timezone: {timezone}")
+ logger.info(f" - Business ID: {business_id}")
+ logger.info(f" - Fiscal year ID: {fiscal_year_id}")
+ logger.info(f" - App permissions: {user.app_permissions}")
+
+ auth_context = AuthContext(
user=user,
api_key_id=obj.id,
language=language,
@@ -388,6 +499,9 @@ def get_current_user(
fiscal_year_id=fiscal_year_id,
db=db
)
+
+ logger.info(f"AuthContext created successfully")
+ return auth_context
def _detect_language(request: Request) -> str:
@@ -409,12 +523,22 @@ def _detect_timezone(request: Request) -> Optional[str]:
def _detect_business_id(request: Request) -> Optional[int]:
"""تشخیص ID کسب و کار از هدر X-Business-ID (آینده)"""
+ import logging
+ logger = logging.getLogger(__name__)
+
business_id_str = request.headers.get("X-Business-ID")
+ logger.info(f"X-Business-ID header: {business_id_str}")
+
if business_id_str:
try:
- return int(business_id_str)
+ business_id = int(business_id_str)
+ logger.info(f"Detected business ID: {business_id}")
+ return business_id
except ValueError:
+ logger.warning(f"Invalid business ID format: {business_id_str}")
pass
+
+ logger.info("No business ID detected from headers")
return None
diff --git a/hesabixAPI/build/lib/app/core/permissions.py b/hesabixAPI/build/lib/app/core/permissions.py
index ce5d65f..352aaa9 100644
--- a/hesabixAPI/build/lib/app/core/permissions.py
+++ b/hesabixAPI/build/lib/app/core/permissions.py
@@ -104,9 +104,22 @@ def require_business_access(business_id_param: str = "business_id"):
except Exception:
business_id = None
- if business_id and not ctx.can_access_business(int(business_id)):
- logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}")
- raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
+ if business_id:
+ logger.info(f"=== require_business_access decorator ===")
+ logger.info(f"Checking access for user {ctx.get_user_id()} to business {business_id}")
+ logger.info(f"User context business_id: {ctx.business_id}")
+ logger.info(f"Is superadmin: {ctx.is_superadmin()}")
+
+ has_access = ctx.can_access_business(int(business_id))
+ logger.info(f"Access check result: {has_access}")
+
+ if not has_access:
+ 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)
+ else:
+ logger.info(f"User {ctx.get_user_id()} has access to business {business_id}")
+ else:
+ logger.info("No business_id provided, skipping access check")
# فراخوانی تابع اصلی و await در صورت نیاز
result = func(*args, **kwargs)
diff --git a/hesabixAPI/build/lib/app/core/responses.py b/hesabixAPI/build/lib/app/core/responses.py
index c44d1f1..2c2730d 100644
--- a/hesabixAPI/build/lib/app/core/responses.py
+++ b/hesabixAPI/build/lib/app/core/responses.py
@@ -14,9 +14,15 @@ def success_response(data: Any, request: Request = None, message: str = None) ->
if data is not None:
response["data"] = data
- # Add message if provided
+ # Add message if provided (translate if translator exists)
if message is not None:
- response["message"] = message
+ translated = message
+ try:
+ if request is not None and hasattr(request.state, 'translator') and request.state.translator is not None:
+ translated = request.state.translator.t(message, default=message)
+ except Exception:
+ translated = message
+ response["message"] = translated
# Add calendar type information if request is available
if request and hasattr(request.state, 'calendar_type'):
diff --git a/hesabixAPI/build/lib/app/main.py b/hesabixAPI/build/lib/app/main.py
index b478cab..05c918b 100644
--- a/hesabixAPI/build/lib/app/main.py
+++ b/hesabixAPI/build/lib/app/main.py
@@ -16,6 +16,8 @@ from adapters.api.v1.product_attributes import router as product_attributes_rout
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.bank_accounts import router as bank_accounts_router
+from adapters.api.v1.cash_registers import router as cash_registers_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
@@ -292,6 +294,8 @@ def create_app() -> FastAPI:
application.include_router(products_router, prefix=settings.api_v1_prefix)
application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
application.include_router(persons_router, prefix=settings.api_v1_prefix)
+ application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
+ application.include_router(cash_registers_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)
@@ -334,8 +338,9 @@ def create_app() -> FastAPI:
# اضافه کردن security schemes
openapi_schema["components"]["securitySchemes"] = {
"ApiKeyAuth": {
- "type": "http",
- "scheme": "ApiKey",
+ "type": "apiKey",
+ "in": "header",
+ "name": "Authorization",
"description": "کلید API برای احراز هویت. فرمت: ApiKey sk_your_api_key_here"
}
}
@@ -344,8 +349,8 @@ def create_app() -> FastAPI:
for path, methods in openapi_schema["paths"].items():
for method, details in methods.items():
if method in ["get", "post", "put", "delete", "patch"]:
- # تمام endpoint های auth، users و support نیاز به احراز هویت دارند
- if "/auth/" in path or "/users" in path or "/support" in path:
+ # تمام endpoint های auth، users، support و bank-accounts نیاز به احراز هویت دارند
+ if "/auth/" in path or "/users" in path or "/support" in path or "/bank-accounts" in path:
details["security"] = [{"ApiKeyAuth": []}]
application.openapi_schema = openapi_schema
diff --git a/hesabixAPI/build/lib/app/services/bank_account_service.py b/hesabixAPI/build/lib/app/services/bank_account_service.py
new file mode 100644
index 0000000..4e47559
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/bank_account_service.py
@@ -0,0 +1,255 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, func
+
+from adapters.db.models.bank_account import BankAccount
+from app.core.responses import ApiError
+
+
+def create_bank_account(
+ db: Session,
+ business_id: int,
+ data: Dict[str, Any],
+) -> Dict[str, Any]:
+ # مدیریت کد یکتا در هر کسبوکار (در صورت ارسال)
+ code = data.get("code")
+ if code is not None and str(code).strip() != "":
+ # اعتبارسنجی عددی بودن کد
+ if not str(code).isdigit():
+ raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be numeric", http_status=400)
+ # اعتبارسنجی حداقل طول کد
+ if len(str(code)) < 3:
+ raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be at least 3 digits", http_status=400)
+ exists = db.query(BankAccount).filter(and_(BankAccount.business_id == business_id, BankAccount.code == str(code))).first()
+ if exists:
+ raise ApiError("DUPLICATE_BANK_ACCOUNT_CODE", "Duplicate bank account code", http_status=400)
+ else:
+ # تولید خودکار کد: max + 1 به صورت رشته (حداقل ۳ رقم)
+ max_code = db.query(func.max(BankAccount.code)).filter(BankAccount.business_id == business_id).scalar()
+ try:
+ if max_code is not None and str(max_code).isdigit():
+ next_code_int = int(max_code) + 1
+ else:
+ next_code_int = 100 # شروع از ۱۰۰ برای حداقل ۳ رقم
+
+ # اگر کد کمتر از ۳ رقم است، آن را به ۳ رقم تبدیل کن
+ if next_code_int < 100:
+ next_code_int = 100
+
+ code = str(next_code_int)
+ except Exception:
+ code = "100" # در صورت خطا، حداقل کد ۳ رقمی
+
+ obj = BankAccount(
+ business_id=business_id,
+ code=code,
+ name=data.get("name"),
+ branch=data.get("branch"),
+ account_number=data.get("account_number"),
+ sheba_number=data.get("sheba_number"),
+ card_number=data.get("card_number"),
+ owner_name=data.get("owner_name"),
+ pos_number=data.get("pos_number"),
+ payment_id=data.get("payment_id"),
+ description=data.get("description"),
+ currency_id=int(data.get("currency_id")),
+ is_active=bool(data.get("is_active", True)),
+ is_default=bool(data.get("is_default", False)),
+ )
+
+ # اگر پیش فرض شد، بقیه را غیر پیش فرض کن
+ if obj.is_default:
+ db.query(BankAccount).filter(BankAccount.business_id == business_id, BankAccount.id != obj.id).update({BankAccount.is_default: False})
+
+ db.add(obj)
+ db.commit()
+ db.refresh(obj)
+ return bank_account_to_dict(obj)
+
+
+def get_bank_account_by_id(db: Session, account_id: int) -> Optional[Dict[str, Any]]:
+ obj = db.query(BankAccount).filter(BankAccount.id == account_id).first()
+ return bank_account_to_dict(obj) if obj else None
+
+
+def update_bank_account(db: Session, account_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ obj = db.query(BankAccount).filter(BankAccount.id == account_id).first()
+ if obj is None:
+ return None
+
+ if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
+ if not str(data["code"]).isdigit():
+ raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be numeric", http_status=400)
+ if len(str(data["code"])) < 3:
+ raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be at least 3 digits", http_status=400)
+ exists = db.query(BankAccount).filter(and_(BankAccount.business_id == obj.business_id, BankAccount.code == str(data["code"]), BankAccount.id != obj.id)).first()
+ if exists:
+ raise ApiError("DUPLICATE_BANK_ACCOUNT_CODE", "Duplicate bank account code", http_status=400)
+ obj.code = str(data["code"])
+
+ for field in [
+ "name","branch","account_number","sheba_number","card_number",
+ "owner_name","pos_number","payment_id","description",
+ ]:
+ if field in data:
+ setattr(obj, field, data.get(field))
+
+ if "currency_id" in data and data["currency_id"] is not None:
+ obj.currency_id = int(data["currency_id"]) # TODO: اعتبارسنجی وجود ارز
+
+ if "is_active" in data and data["is_active"] is not None:
+ obj.is_active = bool(data["is_active"])
+ if "is_default" in data and data["is_default"] is not None:
+ obj.is_default = bool(data["is_default"])
+ if obj.is_default:
+ # تنها یک حساب پیشفرض در هر بیزنس
+ db.query(BankAccount).filter(BankAccount.business_id == obj.business_id, BankAccount.id != obj.id).update({BankAccount.is_default: False})
+
+ db.commit()
+ db.refresh(obj)
+ return bank_account_to_dict(obj)
+
+
+def delete_bank_account(db: Session, account_id: int) -> bool:
+ obj = db.query(BankAccount).filter(BankAccount.id == account_id).first()
+ if obj is None:
+ return False
+ db.delete(obj)
+ db.commit()
+ return True
+
+
+def list_bank_accounts(
+ db: Session,
+ business_id: int,
+ query: Dict[str, Any],
+) -> Dict[str, Any]:
+ q = db.query(BankAccount).filter(BankAccount.business_id == business_id)
+
+ # جستجو
+ if query.get("search") and query.get("search_fields"):
+ term = f"%{query['search']}%"
+ from sqlalchemy import or_
+ conditions = []
+ for f in query["search_fields"]:
+ if f == "code":
+ conditions.append(BankAccount.code.ilike(term))
+ elif f == "name":
+ conditions.append(BankAccount.name.ilike(term))
+ elif f == "branch":
+ conditions.append(BankAccount.branch.ilike(term))
+ elif f == "account_number":
+ conditions.append(BankAccount.account_number.ilike(term))
+ elif f == "sheba_number":
+ conditions.append(BankAccount.sheba_number.ilike(term))
+ elif f == "card_number":
+ conditions.append(BankAccount.card_number.ilike(term))
+ elif f == "owner_name":
+ conditions.append(BankAccount.owner_name.ilike(term))
+ elif f == "pos_number":
+ conditions.append(BankAccount.pos_number.ilike(term))
+ elif f == "payment_id":
+ conditions.append(BankAccount.payment_id.ilike(term))
+ if conditions:
+ q = q.filter(or_(*conditions))
+
+ # فیلترها
+ if query.get("filters"):
+ for flt in query["filters"]:
+ prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property')
+ op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator')
+ val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value')
+ if not prop or not op:
+ continue
+ if prop in {"is_active", "is_default"} and op == "=":
+ q = q.filter(getattr(BankAccount, prop) == val)
+ elif prop == "currency_id" and op == "=":
+ q = q.filter(BankAccount.currency_id == val)
+
+ # مرتب سازی
+ sort_by = query.get("sort_by") or "created_at"
+ sort_desc = bool(query.get("sort_desc", True))
+ col = getattr(BankAccount, sort_by, BankAccount.created_at)
+ q = q.order_by(col.desc() if sort_desc else col.asc())
+
+ # صفحهبندی
+ skip = int(query.get("skip", 0))
+ take = int(query.get("take", 20))
+ total = q.count()
+ items = q.offset(skip).limit(take).all()
+
+ return {
+ "items": [bank_account_to_dict(i) for i in items],
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ "query_info": query,
+ }
+
+
+def bulk_delete_bank_accounts(db: Session, business_id: int, account_ids: List[int]) -> Dict[str, Any]:
+ """
+ حذف گروهی حسابهای بانکی
+ """
+ if not account_ids:
+ return {"deleted": 0, "skipped": 0}
+
+ # بررسی وجود حسابها و دسترسی به کسبوکار
+ accounts = db.query(BankAccount).filter(
+ BankAccount.id.in_(account_ids),
+ BankAccount.business_id == business_id
+ ).all()
+
+ deleted_count = 0
+ skipped_count = 0
+
+ for account in accounts:
+ try:
+ db.delete(account)
+ deleted_count += 1
+ except Exception:
+ skipped_count += 1
+
+ # commit تغییرات
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise ApiError("BULK_DELETE_FAILED", "Bulk delete failed for bank accounts", http_status=500)
+
+ return {
+ "deleted": deleted_count,
+ "skipped": skipped_count,
+ "total_requested": len(account_ids)
+ }
+
+
+def bank_account_to_dict(obj: BankAccount) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "code": obj.code,
+ "name": obj.name,
+ "branch": obj.branch,
+ "account_number": obj.account_number,
+ "sheba_number": obj.sheba_number,
+ "card_number": obj.card_number,
+ "owner_name": obj.owner_name,
+ "pos_number": obj.pos_number,
+ "payment_id": obj.payment_id,
+ "description": obj.description,
+ "currency_id": obj.currency_id,
+ "is_active": bool(obj.is_active),
+ "is_default": bool(obj.is_default),
+ "created_at": obj.created_at.isoformat(),
+ "updated_at": obj.updated_at.isoformat(),
+ }
+
+
diff --git a/hesabixAPI/build/lib/app/services/bulk_price_update_service.py b/hesabixAPI/build/lib/app/services/bulk_price_update_service.py
new file mode 100644
index 0000000..056a8dd
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/bulk_price_update_service.py
@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+from typing import Dict, Any, List, Optional
+from decimal import Decimal
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, or_
+
+from adapters.db.models.product import Product
+from adapters.db.models.price_list import PriceItem
+from adapters.db.models.category import BusinessCategory
+from adapters.db.models.currency import Currency
+from adapters.api.v1.schema_models.product import (
+ BulkPriceUpdateRequest,
+ BulkPriceUpdatePreview,
+ BulkPriceUpdatePreviewResponse,
+ BulkPriceUpdateType,
+ BulkPriceUpdateTarget,
+ BulkPriceUpdateDirection,
+ ProductItemType
+)
+
+
+def _quantize_non_negative_integer(value: Decimal) -> Decimal:
+ """رُند کردن به عدد صحیح غیرمنفی (بدون اعشار)."""
+ # حذف اعشار: round-half-up به نزدیکترین عدد صحیح
+ quantized = value.quantize(Decimal('1'))
+ if quantized < 0:
+ return Decimal('0')
+ return quantized
+
+def _quantize_integer_keep_sign(value: Decimal) -> Decimal:
+ """رُند کردن به عدد صحیح با حفظ علامت (بدون اعشار)."""
+ return value.quantize(Decimal('1'))
+
+
+def calculate_new_price(current_price: Optional[Decimal], update_type: BulkPriceUpdateType, direction: BulkPriceUpdateDirection, value: Decimal) -> Optional[Decimal]:
+ """محاسبه قیمت جدید بر اساس نوع تغییر با جهت، سپس رُند و کلَمپ به صفر"""
+ if current_price is None:
+ return None
+
+ delta = Decimal('0')
+ if update_type == BulkPriceUpdateType.PERCENTAGE:
+ sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1')
+ multiplier = Decimal('1') + (sign * (value / Decimal('100')))
+ new_value = current_price * multiplier
+ else:
+ sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1')
+ delta = sign * value
+ new_value = current_price + delta
+
+ # رُند به عدد صحیح و کلَمپ به صفر
+ return _quantize_non_negative_integer(new_value)
+
+
+def get_filtered_products(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> List[Product]:
+ """دریافت کالاهای فیلتر شده بر اساس معیارهای درخواست"""
+ query = db.query(Product).filter(Product.business_id == business_id)
+
+ # فیلتر بر اساس دستهبندی
+ if request.category_ids:
+ query = query.filter(Product.category_id.in_(request.category_ids))
+
+ # فیلتر بر اساس نوع آیتم
+ if request.item_types:
+ query = query.filter(Product.item_type.in_([t.value for t in request.item_types]))
+
+ # فیلتر بر اساس ارز: محصولی که قیمتهای لیست مرتبط با ارزهای انتخابی دارد
+ if request.currency_ids:
+ query = query.filter(
+ db.query(PriceItem.id)
+ .filter(
+ PriceItem.product_id == Product.id,
+ PriceItem.currency_id.in_(request.currency_ids)
+ ).exists()
+ )
+
+ # فیلتر بر اساس لیست قیمت: محصولی که در هر یک از لیستهای انتخابی آیتم قیمت دارد
+ if request.price_list_ids:
+ query = query.filter(
+ db.query(PriceItem.id)
+ .filter(
+ PriceItem.product_id == Product.id,
+ PriceItem.price_list_id.in_(request.price_list_ids)
+ ).exists()
+ )
+
+ # فیلتر بر اساس شناسههای کالاهای خاص
+ if request.product_ids:
+ query = query.filter(Product.id.in_(request.product_ids))
+
+ # فیلتر بر اساس موجودی
+ if request.only_products_with_inventory is not None:
+ if request.only_products_with_inventory:
+ query = query.filter(Product.track_inventory == True)
+ else:
+ query = query.filter(Product.track_inventory == False)
+
+ # فیلتر بر اساس وجود قیمت پایه
+ if request.only_products_with_base_price:
+ if request.target == BulkPriceUpdateTarget.SALES_PRICE:
+ query = query.filter(Product.base_sales_price.isnot(None))
+ elif request.target == BulkPriceUpdateTarget.PURCHASE_PRICE:
+ query = query.filter(Product.base_purchase_price.isnot(None))
+ else:
+ # در حالت هر دو، حداقل یکی موجود باشد
+ query = query.filter(or_(Product.base_sales_price.isnot(None), Product.base_purchase_price.isnot(None)))
+
+ return query.all()
+
+
+def preview_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> BulkPriceUpdatePreviewResponse:
+ """پیشنمایش تغییرات قیمت گروهی"""
+ products = get_filtered_products(db, business_id, request)
+
+ # کش نام دستهها برای کاهش کوئری
+ category_titles: Dict[int, str] = {}
+ def _resolve_category_name(cid: Optional[int]) -> Optional[str]:
+ if cid is None:
+ return None
+ if cid in category_titles:
+ return category_titles[cid]
+ try:
+ cat = db.query(BusinessCategory).filter(BusinessCategory.id == cid, BusinessCategory.business_id == business_id).first()
+ if cat and isinstance(cat.title_translations, dict):
+ title = cat.title_translations.get('fa') or cat.title_translations.get('default') or ''
+ category_titles[cid] = title
+ return title
+ except Exception:
+ return None
+ return None
+
+ affected_products = []
+ total_sales_change = Decimal('0')
+ total_purchase_change = Decimal('0')
+ products_with_sales_change = 0
+ products_with_purchase_change = 0
+
+ for product in products:
+ preview = BulkPriceUpdatePreview(
+ product_id=product.id,
+ product_name=product.name or "بدون نام",
+ product_code=product.code or "بدون کد",
+ category_name=_resolve_category_name(product.category_id),
+ current_sales_price=product.base_sales_price,
+ current_purchase_price=product.base_purchase_price,
+ new_sales_price=None,
+ new_purchase_price=None,
+ sales_price_change=None,
+ purchase_price_change=None
+ )
+
+ # محاسبه تغییرات قیمت فروش
+ if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None:
+ new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value)
+ preview.new_sales_price = new_sales_price
+ preview.sales_price_change = (new_sales_price - product.base_sales_price) if new_sales_price is not None else None
+ total_sales_change += (preview.sales_price_change or Decimal('0'))
+ products_with_sales_change += 1
+
+ # محاسبه تغییرات قیمت خرید
+ if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None:
+ new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value)
+ preview.new_purchase_price = new_purchase_price
+ preview.purchase_price_change = (new_purchase_price - product.base_purchase_price) if new_purchase_price is not None else None
+ total_purchase_change += (preview.purchase_price_change or Decimal('0'))
+ products_with_purchase_change += 1
+
+ affected_products.append(preview)
+
+ summary = {
+ "total_products": len(products),
+ "affected_products": len(affected_products),
+ "products_with_sales_change": products_with_sales_change,
+ "products_with_purchase_change": products_with_purchase_change,
+ "total_sales_change": float(_quantize_integer_keep_sign(total_sales_change)),
+ "total_purchase_change": float(_quantize_integer_keep_sign(total_purchase_change)),
+ "update_type": request.update_type.value,
+ "direction": request.direction.value,
+ "target": request.target.value,
+ "value": float(_quantize_non_negative_integer(request.value)) if request.update_type == BulkPriceUpdateType.AMOUNT else float(request.value)
+ }
+
+ return BulkPriceUpdatePreviewResponse(
+ total_products=len(products),
+ affected_products=affected_products,
+ summary=summary
+ )
+
+
+def apply_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> Dict[str, Any]:
+ """اعمال تغییرات قیمت گروهی"""
+ products = get_filtered_products(db, business_id, request)
+
+ updated_count = 0
+ errors = []
+
+ # اگر price_list_ids مشخص شده باشد، هم قیمت پایه و هم PriceItemها باید بهروزرسانی شوند
+ for product in products:
+ try:
+ # بروزرسانی قیمت فروش
+ if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None:
+ new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value)
+ product.base_sales_price = new_sales_price
+
+ # بروزرسانی قیمت خرید
+ if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None:
+ new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value)
+ product.base_purchase_price = new_purchase_price
+
+ # بروزرسانی آیتمهای لیست قیمت مرتبط (در صورت مشخص بودن فیلترها)
+ q = db.query(PriceItem).filter(PriceItem.product_id == product.id)
+ if request.currency_ids:
+ q = q.filter(PriceItem.currency_id.in_(request.currency_ids))
+ if request.price_list_ids:
+ q = q.filter(PriceItem.price_list_id.in_(request.price_list_ids))
+ # اگر هدف فقط فروش/خرید نیست چون PriceItem فقط یک فیلد price دارد، همان price را تغییر میدهیم
+ for pi in q.all():
+ new_pi_price = calculate_new_price(Decimal(pi.price), request.update_type, request.direction, request.value)
+ pi.price = new_pi_price
+
+ updated_count += 1
+
+ except Exception as e:
+ errors.append(f"خطا در بروزرسانی کالای {product.name}: {str(e)}")
+
+ db.commit()
+
+ return {
+ "message": f"تغییرات قیمت برای {updated_count} کالا اعمال شد",
+ "updated_count": updated_count,
+ "total_products": len(products),
+ "errors": errors
+ }
diff --git a/hesabixAPI/build/lib/app/services/cash_register_service.py b/hesabixAPI/build/lib/app/services/cash_register_service.py
new file mode 100644
index 0000000..46bf1ca
--- /dev/null
+++ b/hesabixAPI/build/lib/app/services/cash_register_service.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, func
+
+from adapters.db.models.cash_register import CashRegister
+from adapters.db.repositories.cash_register_repository import CashRegisterRepository
+from app.core.responses import ApiError
+
+
+def create_cash_register(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+ # validate required fields
+ name = (data.get("name") or "").strip()
+ if name == "":
+ raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
+
+ # code uniqueness in business if provided; else auto-generate numeric min 3 digits
+ code = data.get("code")
+ if code is not None and str(code).strip() != "":
+ if not str(code).isdigit():
+ raise ApiError("INVALID_CASH_CODE", "Cash register code must be numeric", http_status=400)
+ if len(str(code)) < 3:
+ raise ApiError("INVALID_CASH_CODE", "Cash register code must be at least 3 digits", http_status=400)
+ exists = db.query(CashRegister).filter(and_(CashRegister.business_id == business_id, CashRegister.code == str(code))).first()
+ if exists:
+ raise ApiError("DUPLICATE_CASH_CODE", "Duplicate cash register code", http_status=400)
+ else:
+ max_code = db.query(func.max(CashRegister.code)).filter(CashRegister.business_id == business_id).scalar()
+ try:
+ if max_code is not None and str(max_code).isdigit():
+ next_code_int = int(max_code) + 1
+ else:
+ next_code_int = 100
+ if next_code_int < 100:
+ next_code_int = 100
+ code = str(next_code_int)
+ except Exception:
+ code = "100"
+
+ repo = CashRegisterRepository(db)
+ obj = repo.create(business_id, {
+ "name": name,
+ "code": code,
+ "description": data.get("description"),
+ "currency_id": int(data["currency_id"]),
+ "is_active": bool(data.get("is_active", True)),
+ "is_default": bool(data.get("is_default", False)),
+ "payment_switch_number": data.get("payment_switch_number"),
+ "payment_terminal_number": data.get("payment_terminal_number"),
+ "merchant_id": data.get("merchant_id"),
+ })
+
+ # ensure single default
+ if obj.is_default:
+ repo.clear_default(business_id, except_id=obj.id)
+
+ db.commit()
+ db.refresh(obj)
+ return cash_register_to_dict(obj)
+
+
+def get_cash_register_by_id(db: Session, id_: int) -> Optional[Dict[str, Any]]:
+ obj = db.query(CashRegister).filter(CashRegister.id == id_).first()
+ return cash_register_to_dict(obj) if obj else None
+
+
+def update_cash_register(db: Session, id_: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ repo = CashRegisterRepository(db)
+ obj = repo.get_by_id(id_)
+ if obj is None:
+ return None
+
+ # validate name if provided
+ if "name" in data:
+ name_val = (data.get("name") or "").strip()
+ if name_val == "":
+ raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
+
+ if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
+ if not str(data["code"]).isdigit():
+ raise ApiError("INVALID_CASH_CODE", "Cash register code must be numeric", http_status=400)
+ if len(str(data["code"])) < 3:
+ raise ApiError("INVALID_CASH_CODE", "Cash register code must be at least 3 digits", http_status=400)
+ exists = db.query(CashRegister).filter(and_(CashRegister.business_id == obj.business_id, CashRegister.code == str(data["code"]), CashRegister.id != obj.id)).first()
+ if exists:
+ raise ApiError("DUPLICATE_CASH_CODE", "Duplicate cash register code", http_status=400)
+
+ repo.update(obj, data)
+ if obj.is_default:
+ repo.clear_default(obj.business_id, except_id=obj.id)
+
+ db.commit()
+ db.refresh(obj)
+ return cash_register_to_dict(obj)
+
+
+def delete_cash_register(db: Session, id_: int) -> bool:
+ obj = db.query(CashRegister).filter(CashRegister.id == id_).first()
+ if obj is None:
+ return False
+ db.delete(obj)
+ db.commit()
+ return True
+
+
+def bulk_delete_cash_registers(db: Session, business_id: int, ids: List[int]) -> Dict[str, Any]:
+ repo = CashRegisterRepository(db)
+ result = repo.bulk_delete(business_id, ids)
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise ApiError("BULK_DELETE_FAILED", "Bulk delete failed for cash registers", http_status=500)
+ return result
+
+
+def list_cash_registers(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
+ repo = CashRegisterRepository(db)
+ res = repo.list(business_id, query)
+ return {
+ "items": [cash_register_to_dict(i) for i in res["items"]],
+ "pagination": res["pagination"],
+ "query_info": res["query_info"],
+ }
+
+
+def cash_register_to_dict(obj: CashRegister) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "name": obj.name,
+ "code": obj.code,
+ "description": obj.description,
+ "currency_id": obj.currency_id,
+ "is_active": bool(obj.is_active),
+ "is_default": bool(obj.is_default),
+ "payment_switch_number": obj.payment_switch_number,
+ "payment_terminal_number": obj.payment_terminal_number,
+ "merchant_id": obj.merchant_id,
+ "created_at": obj.created_at.isoformat(),
+ "updated_at": obj.updated_at.isoformat(),
+ }
+
+
diff --git a/hesabixAPI/build/lib/app/services/price_list_service.py b/hesabixAPI/build/lib/app/services/price_list_service.py
index 4f19668..0a40588 100644
--- a/hesabixAPI/build/lib/app/services/price_list_service.py
+++ b/hesabixAPI/build/lib/app/services/price_list_service.py
@@ -22,7 +22,7 @@ def create_price_list(db: Session, business_id: int, payload: PriceListCreateReq
name=payload.name.strip(),
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]:
@@ -54,7 +54,7 @@ def update_price_list(db: Session, business_id: int, id: int, payload: PriceList
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:
@@ -96,7 +96,7 @@ def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload
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:
diff --git a/hesabixAPI/build/lib/app/services/product_service.py b/hesabixAPI/build/lib/app/services/product_service.py
index 71f7666..6d09190 100644
--- a/hesabixAPI/build/lib/app/services/product_service.py
+++ b/hesabixAPI/build/lib/app/services/product_service.py
@@ -103,7 +103,7 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest)
_upsert_attributes(db, obj.id, business_id, payload.attribute_ids)
- return {"message": "آیتم با موفقیت ایجاد شد", "data": _to_dict(obj)}
+ return {"message": "PRODUCT_CREATED", "data": _to_dict(obj)}
def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
@@ -178,7 +178,7 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod
return None
_upsert_attributes(db, product_id, business_id, payload.attribute_ids)
- return {"message": "آیتم با موفقیت ویرایش شد", "data": _to_dict(updated)}
+ return {"message": "PRODUCT_UPDATED", "data": _to_dict(updated)}
def delete_product(db: Session, product_id: int, business_id: int) -> bool:
diff --git a/hesabixAPI/build/lib/migrations/versions/20250102_000001_seed_support_data.py b/hesabixAPI/build/lib/migrations/versions/20250102_000001_seed_support_data.py
new file mode 100644
index 0000000..36f6934
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20250102_000001_seed_support_data.py
@@ -0,0 +1,179 @@
+"""seed_support_data
+
+Revision ID: 20250102_000001
+Revises: 5553f8745c6e
+Create Date: 2025-01-02 00:00:01.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from datetime import datetime
+
+# revision identifiers, used by Alembic.
+revision = '20250102_000001'
+down_revision = '5553f8745c6e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # اضافه کردن دستهبندیهای اولیه
+ categories_table = sa.table('support_categories',
+ sa.column('id', sa.Integer),
+ sa.column('name', sa.String),
+ sa.column('description', sa.Text),
+ sa.column('is_active', sa.Boolean),
+ sa.column('created_at', sa.DateTime),
+ sa.column('updated_at', sa.DateTime)
+ )
+
+ categories_data = [
+ {
+ 'name': 'مشکل فنی',
+ 'description': 'مشکلات فنی و باگها',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'درخواست ویژگی',
+ 'description': 'درخواست ویژگیهای جدید',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'سوال',
+ 'description': 'سوالات عمومی',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'شکایت',
+ 'description': 'شکایات و انتقادات',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'سایر',
+ 'description': 'سایر موارد',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ }
+ ]
+
+ op.bulk_insert(categories_table, categories_data)
+
+ # اضافه کردن اولویتهای اولیه
+ priorities_table = sa.table('support_priorities',
+ sa.column('id', sa.Integer),
+ sa.column('name', sa.String),
+ sa.column('description', sa.Text),
+ sa.column('color', sa.String),
+ sa.column('order', sa.Integer),
+ sa.column('created_at', sa.DateTime),
+ sa.column('updated_at', sa.DateTime)
+ )
+
+ priorities_data = [
+ {
+ 'name': 'کم',
+ 'description': 'اولویت کم',
+ 'color': '#28a745',
+ 'order': 1,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'متوسط',
+ 'description': 'اولویت متوسط',
+ 'color': '#ffc107',
+ 'order': 2,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'بالا',
+ 'description': 'اولویت بالا',
+ 'color': '#fd7e14',
+ 'order': 3,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'فوری',
+ 'description': 'اولویت فوری',
+ 'color': '#dc3545',
+ 'order': 4,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ }
+ ]
+
+ op.bulk_insert(priorities_table, priorities_data)
+
+ # اضافه کردن وضعیتهای اولیه
+ statuses_table = sa.table('support_statuses',
+ sa.column('id', sa.Integer),
+ sa.column('name', sa.String),
+ sa.column('description', sa.Text),
+ sa.column('color', sa.String),
+ sa.column('is_final', sa.Boolean),
+ sa.column('created_at', sa.DateTime),
+ sa.column('updated_at', sa.DateTime)
+ )
+
+ statuses_data = [
+ {
+ 'name': 'باز',
+ 'description': 'تیکت باز و در انتظار پاسخ',
+ 'color': '#007bff',
+ 'is_final': False,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'در حال پیگیری',
+ 'description': 'تیکت در حال بررسی',
+ 'color': '#6f42c1',
+ 'is_final': False,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'در انتظار کاربر',
+ 'description': 'در انتظار پاسخ کاربر',
+ 'color': '#17a2b8',
+ 'is_final': False,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'بسته',
+ 'description': 'تیکت بسته شده',
+ 'color': '#6c757d',
+ 'is_final': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'حل شده',
+ 'description': 'مشکل حل شده',
+ 'color': '#28a745',
+ 'is_final': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ }
+ ]
+
+ op.bulk_insert(statuses_table, statuses_data)
+
+
+def downgrade() -> None:
+ # حذف دادههای اضافه شده
+ op.execute("DELETE FROM support_statuses")
+ op.execute("DELETE FROM support_priorities")
+ op.execute("DELETE FROM support_categories")
diff --git a/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py b/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
index 46e0e1b..e3c830b 100644
--- a/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
+++ b/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
@@ -28,21 +28,19 @@ def upgrade() -> None:
conn = op.get_bind()
dialect_name = conn.dialect.name
- # MySQL: information_schema to check constraints
if dialect_name == 'mysql':
- op.execute(
- sa.text(
- """
- SET @exists := (
- SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
- WHERE CONSTRAINT_SCHEMA = DATABASE()
- AND TABLE_NAME = 'price_items'
- AND CONSTRAINT_NAME = 'uq_price_items_unique_tier'
- );
- """
- )
- )
- op.execute(sa.text("""SET @q := IF(@exists > 0, 'ALTER TABLE price_items DROP INDEX uq_price_items_unique_tier', 'SELECT 1'); PREPARE stmt FROM @q; EXECUTE stmt; DEALLOCATE PREPARE stmt;"""))
+ # Check via information_schema and drop index if present
+ exists = conn.execute(sa.text(
+ """
+ SELECT COUNT(*) as cnt
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'price_items'
+ AND INDEX_NAME = 'uq_price_items_unique_tier'
+ """
+ )).scalar() or 0
+ if int(exists) > 0:
+ conn.execute(sa.text("ALTER TABLE price_items DROP INDEX uq_price_items_unique_tier"))
else:
# Generic drop constraint best-effort
try:
@@ -53,13 +51,32 @@ def upgrade() -> None:
# 3) Make currency_id NOT NULL
op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=False, existing_nullable=True)
- # 4) Create new unique constraint including currency_id
- # For MySQL, unique constraints are created as indexes as well
- op.create_unique_constraint(
- 'uq_price_items_unique_tier_currency',
- 'price_items',
- ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id']
- )
+ # 4) Create new unique constraint including currency_id (idempotent)
+ if dialect_name == 'mysql':
+ exists_uc = conn.execute(sa.text(
+ """
+ SELECT COUNT(*) as cnt
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'price_items'
+ AND INDEX_NAME = 'uq_price_items_unique_tier_currency'
+ """
+ )).scalar() or 0
+ if int(exists_uc) == 0:
+ op.create_unique_constraint(
+ 'uq_price_items_unique_tier_currency',
+ 'price_items',
+ ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id']
+ )
+ else:
+ try:
+ op.create_unique_constraint(
+ 'uq_price_items_unique_tier_currency',
+ 'price_items',
+ ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id']
+ )
+ except Exception:
+ pass
def downgrade() -> None:
diff --git a/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py b/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
index 6807842..1af902e 100644
--- a/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
+++ b/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
@@ -18,45 +18,30 @@ def upgrade() -> None:
# Try to drop FK on price_lists.currency_id if exists
if dialect == 'mysql':
# Find foreign key constraint name dynamically and drop it
- op.execute(sa.text(
+ fk_rows = conn.execute(sa.text(
"""
- SET @fk_name := (
- SELECT CONSTRAINT_NAME
- FROM information_schema.KEY_COLUMN_USAGE
- WHERE TABLE_SCHEMA = DATABASE()
- AND TABLE_NAME = 'price_lists'
- AND COLUMN_NAME = 'currency_id'
- AND REFERENCED_TABLE_NAME IS NOT NULL
- LIMIT 1
- );
+ SELECT CONSTRAINT_NAME
+ FROM information_schema.KEY_COLUMN_USAGE
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'price_lists'
+ AND COLUMN_NAME = 'currency_id'
+ AND REFERENCED_TABLE_NAME IS NOT NULL
+ GROUP BY CONSTRAINT_NAME
"""
- ))
- op.execute(sa.text(
- """
- SET @q := IF(@fk_name IS NOT NULL, CONCAT('ALTER TABLE price_lists DROP FOREIGN KEY ', @fk_name), 'SELECT 1');
- PREPARE stmt FROM @q; EXECUTE stmt; DEALLOCATE PREPARE stmt;
- """
- ))
- # Drop indexes on columns if any
- for col in ('currency_id', 'default_unit_id'):
- op.execute(sa.text(
- f"""
- SET @idx := (
- SELECT INDEX_NAME FROM information_schema.STATISTICS
- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'price_lists' AND COLUMN_NAME = '{col}' LIMIT 1
- );
- """
- ))
- op.execute(sa.text(
- """
- SET @qi := IF(@idx IS NOT NULL, CONCAT('ALTER TABLE price_lists DROP INDEX ', @idx), 'SELECT 1');
- PREPARE s FROM @qi; EXECUTE s; DEALLOCATE PREPARE s;
- """
- ))
+ )).fetchall()
+ for (fk_name,) in fk_rows:
+ conn.execute(sa.text(f"ALTER TABLE price_lists DROP FOREIGN KEY {fk_name}"))
- # Finally drop columns if they exist
- op.execute(sa.text("ALTER TABLE price_lists DROP COLUMN IF EXISTS currency_id"))
- op.execute(sa.text("ALTER TABLE price_lists DROP COLUMN IF EXISTS default_unit_id"))
+ # Finally drop columns if they exist (manual check)
+ for col in ('currency_id', 'default_unit_id'):
+ exists = conn.execute(sa.text(
+ """
+ SELECT COUNT(*) as cnt FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'price_lists' AND COLUMN_NAME = :col
+ """
+ ), {"col": col}).scalar() or 0
+ if int(exists) > 0:
+ conn.execute(sa.text(f"ALTER TABLE price_lists DROP COLUMN {col}"))
else:
# Best-effort: drop constraint by common names, then drop columns
for name in ('price_lists_currency_id_fkey', 'fk_price_lists_currency_id', 'price_lists_currency_id_fk'):
diff --git a/hesabixAPI/build/lib/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py b/hesabixAPI/build/lib/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py
new file mode 100644
index 0000000..16b64c1
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from alembic import op # noqa: F401
+import sqlalchemy as sa # noqa: F401
+
+
+# revision identifiers, used by Alembic.
+revision = '20251001_001201_merge_heads_drop_currency_tax_units'
+down_revision = (
+ '20251001_001101_drop_price_list_currency_default_unit',
+ '9f9786ae7191',
+)
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Merge only; no operations.
+ pass
+
+
+def downgrade() -> None:
+ # Merge only; no operations.
+ pass
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20251002_000101_add_bank_accounts_table.py b/hesabixAPI/build/lib/migrations/versions/20251002_000101_add_bank_accounts_table.py
new file mode 100644
index 0000000..99e0060
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20251002_000101_add_bank_accounts_table.py
@@ -0,0 +1,70 @@
+"""add bank_accounts table
+
+Revision ID: 20251002_000101_add_bank_accounts_table
+Revises: 20251001_001201_merge_heads_drop_currency_tax_units
+Create Date: 2025-10-02 00:01:01.000001
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20251002_000101_add_bank_accounts_table'
+down_revision = '20251001_001201_merge_heads_drop_currency_tax_units'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ if 'bank_accounts' not in inspector.get_table_names():
+ op.create_table(
+ 'bank_accounts',
+ sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
+ sa.Column('code', sa.String(length=50), nullable=True),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('description', sa.String(length=500), nullable=True),
+ sa.Column('branch', sa.String(length=255), nullable=True),
+ sa.Column('account_number', sa.String(length=50), nullable=True),
+ sa.Column('sheba_number', sa.String(length=30), nullable=True),
+ sa.Column('card_number', sa.String(length=20), nullable=True),
+ sa.Column('owner_name', sa.String(length=255), nullable=True),
+ sa.Column('pos_number', sa.String(length=50), nullable=True),
+ sa.Column('payment_id', sa.String(length=100), nullable=True),
+ sa.Column('currency_id', sa.Integer(), sa.ForeignKey('currencies.id', ondelete='RESTRICT'), nullable=False),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
+ sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.UniqueConstraint('business_id', 'code', name='uq_bank_accounts_business_code'),
+ )
+ try:
+ op.create_index('ix_bank_accounts_business_id', 'bank_accounts', ['business_id'])
+ op.create_index('ix_bank_accounts_currency_id', 'bank_accounts', ['currency_id'])
+ except Exception:
+ pass
+ else:
+ # تلاش برای ایجاد ایندکسها اگر وجود ندارند
+ existing_indexes = {idx['name'] for idx in inspector.get_indexes('bank_accounts')}
+ if 'ix_bank_accounts_business_id' not in existing_indexes:
+ try:
+ op.create_index('ix_bank_accounts_business_id', 'bank_accounts', ['business_id'])
+ except Exception:
+ pass
+ if 'ix_bank_accounts_currency_id' not in existing_indexes:
+ try:
+ op.create_index('ix_bank_accounts_currency_id', 'bank_accounts', ['currency_id'])
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ op.drop_index('ix_bank_accounts_currency_id', table_name='bank_accounts')
+ op.drop_index('ix_bank_accounts_business_id', table_name='bank_accounts')
+ op.drop_table('bank_accounts')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20251003_000201_add_cash_registers_table.py b/hesabixAPI/build/lib/migrations/versions/20251003_000201_add_cash_registers_table.py
new file mode 100644
index 0000000..3552d43
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20251003_000201_add_cash_registers_table.py
@@ -0,0 +1,54 @@
+"""add cash_registers table
+
+Revision ID: 20251003_000201_add_cash_registers_table
+Revises:
+Create Date: 2025-10-03 00:02:01.000001
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20251003_000201_add_cash_registers_table'
+down_revision = 'a1443c153b47'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ if 'cash_registers' not in inspector.get_table_names():
+ op.create_table(
+ 'cash_registers',
+ sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
+ sa.Column('code', sa.String(length=50), nullable=True),
+ sa.Column('description', sa.String(length=500), nullable=True),
+ sa.Column('currency_id', sa.Integer(), sa.ForeignKey('currencies.id', ondelete='RESTRICT'), nullable=False),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
+ sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('payment_switch_number', sa.String(length=100), nullable=True),
+ sa.Column('payment_terminal_number', sa.String(length=100), nullable=True),
+ sa.Column('merchant_id', sa.String(length=100), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.UniqueConstraint('business_id', 'code', name='uq_cash_registers_business_code'),
+ )
+ try:
+ op.create_index('ix_cash_registers_business_id', 'cash_registers', ['business_id'])
+ op.create_index('ix_cash_registers_currency_id', 'cash_registers', ['currency_id'])
+ op.create_index('ix_cash_registers_is_active', 'cash_registers', ['is_active'])
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ op.drop_index('ix_cash_registers_is_active', table_name='cash_registers')
+ op.drop_index('ix_cash_registers_currency_id', table_name='cash_registers')
+ op.drop_index('ix_cash_registers_business_id', table_name='cash_registers')
+ op.drop_table('cash_registers')
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/20251003_010501_add_name_to_cash_registers.py b/hesabixAPI/build/lib/migrations/versions/20251003_010501_add_name_to_cash_registers.py
new file mode 100644
index 0000000..6d9ee61
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/20251003_010501_add_name_to_cash_registers.py
@@ -0,0 +1,52 @@
+"""add name to cash_registers
+
+Revision ID: 20251003_010501_add_name_to_cash_registers
+Revises: 20251003_000201_add_cash_registers_table
+Create Date: 2025-10-03 01:05:01.000001
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = '20251003_010501_add_name_to_cash_registers'
+down_revision = '20251003_000201_add_cash_registers_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Add column if not exists (MySQL safe): try/except
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+ cols = [c['name'] for c in inspector.get_columns('cash_registers')]
+ if 'name' not in cols:
+ op.add_column('cash_registers', sa.Column('name', sa.String(length=255), nullable=True))
+ # Fill default empty name from code or merchant_id to avoid nulls
+ try:
+ conn.execute(sa.text("UPDATE cash_registers SET name = COALESCE(name, code)"))
+ except Exception:
+ pass
+ # Alter to not null
+ with op.batch_alter_table('cash_registers') as batch_op:
+ batch_op.alter_column('name', existing_type=sa.String(length=255), nullable=False)
+ # Create index
+ try:
+ op.create_index('ix_cash_registers_name', 'cash_registers', ['name'])
+ except Exception:
+ pass
+
+
+def downgrade() -> None:
+ try:
+ op.drop_index('ix_cash_registers_name', table_name='cash_registers')
+ except Exception:
+ pass
+ with op.batch_alter_table('cash_registers') as batch_op:
+ try:
+ batch_op.drop_column('name')
+ except Exception:
+ pass
+
+
diff --git a/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py b/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py
index 2079041..713bcd1 100644
--- a/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py
+++ b/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py
@@ -7,6 +7,7 @@ Create Date: 2025-09-30 14:47:28.281817
"""
from alembic import op
import sqlalchemy as sa
+from sqlalchemy import inspect
# revision identifiers, used by Alembic.
@@ -17,26 +18,33 @@ depends_on = None
def upgrade() -> None:
- # Create tax_units table
- op.create_table('tax_units',
- sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
- sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'),
- sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'),
- sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'),
- sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'),
- sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'),
- sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'),
- sa.Column('created_at', sa.DateTime(), nullable=False),
- sa.Column('updated_at', sa.DateTime(), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- mysql_charset='utf8mb4'
- )
-
- # Create indexes
- op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False)
-
- # Add foreign key constraint to products table
- op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL')
+ bind = op.get_bind()
+ inspector = inspect(bind)
+
+ created_tax_units = False
+ if not inspector.has_table('tax_units'):
+ op.create_table(
+ 'tax_units',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'),
+ sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'),
+ sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'),
+ sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'),
+ sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+ created_tax_units = True
+
+ if created_tax_units:
+ # Create indexes
+ op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False)
+
+ # Add foreign key constraint to products table
+ op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL')
def downgrade() -> None:
diff --git a/hesabixAPI/build/lib/migrations/versions/a1443c153b47_merge_heads.py b/hesabixAPI/build/lib/migrations/versions/a1443c153b47_merge_heads.py
new file mode 100644
index 0000000..229a94a
--- /dev/null
+++ b/hesabixAPI/build/lib/migrations/versions/a1443c153b47_merge_heads.py
@@ -0,0 +1,24 @@
+"""merge heads
+
+Revision ID: a1443c153b47
+Revises: 20250102_000001, 20251002_000101_add_bank_accounts_table
+Create Date: 2025-10-03 14:25:49.978103
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'a1443c153b47'
+down_revision = ('20250102_000001', '20251002_000101_add_bank_accounts_table')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
index e1082d3..8594726 100644
--- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
+++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
@@ -14,6 +14,7 @@ adapters/api/v1/categories.py
adapters/api/v1/currencies.py
adapters/api/v1/health.py
adapters/api/v1/persons.py
+adapters/api/v1/petty_cash.py
adapters/api/v1/price_lists.py
adapters/api/v1/product_attributes.py
adapters/api/v1/products.py
@@ -58,6 +59,7 @@ adapters/db/models/file_storage.py
adapters/db/models/fiscal_year.py
adapters/db/models/password_reset.py
adapters/db/models/person.py
+adapters/db/models/petty_cash.py
adapters/db/models/price_list.py
adapters/db/models/product.py
adapters/db/models/product_attribute.py
@@ -80,6 +82,7 @@ adapters/db/repositories/email_config_repository.py
adapters/db/repositories/file_storage_repository.py
adapters/db/repositories/fiscal_year_repo.py
adapters/db/repositories/password_reset_repo.py
+adapters/db/repositories/petty_cash_repository.py
adapters/db/repositories/price_list_repository.py
adapters/db/repositories/product_attribute_repository.py
adapters/db/repositories/product_repository.py
@@ -116,6 +119,7 @@ app/services/cash_register_service.py
app/services/email_service.py
app/services/file_storage_service.py
app/services/person_service.py
+app/services/petty_cash_service.py
app/services/price_list_service.py
app/services/product_attribute_service.py
app/services/product_service.py
@@ -131,6 +135,7 @@ hesabix_api.egg-info/dependency_links.txt
hesabix_api.egg-info/requires.txt
hesabix_api.egg-info/top_level.txt
migrations/env.py
+migrations/versions/1f0abcdd7300_add_petty_cash_table.py
migrations/versions/20250102_000001_seed_support_data.py
migrations/versions/20250117_000003_add_business_table.py
migrations/versions/20250117_000004_add_business_contact_fields.py
@@ -167,6 +172,7 @@ migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py
migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py
migrations/versions/20251002_000101_add_bank_accounts_table.py
migrations/versions/20251003_000201_add_cash_registers_table.py
+migrations/versions/20251003_010501_add_name_to_cash_registers.py
migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/9f9786ae7191_create_tax_units_table.py
diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo
index 6a74195..330c26b 100644
Binary files a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo and b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo differ
diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.po b/hesabixAPI/locales/en/LC_MESSAGES/messages.po
index e3897c1..b1ea560 100644
--- a/hesabixAPI/locales/en/LC_MESSAGES/messages.po
+++ b/hesabixAPI/locales/en/LC_MESSAGES/messages.po
@@ -293,6 +293,34 @@ msgstr "of"
msgid "noDataFound"
msgstr "No data found"
+# Banking / Petty Cash
+msgid "PETTY_CASH_LIST_FETCHED"
+msgstr "Petty cash list retrieved successfully"
+
+msgid "PETTY_CASH_CREATED"
+msgstr "Petty cash created successfully"
+
+msgid "PETTY_CASH_DETAILS"
+msgstr "Petty cash details"
+
+msgid "PETTY_CASH_UPDATED"
+msgstr "Petty cash updated successfully"
+
+msgid "PETTY_CASH_DELETED"
+msgstr "Petty cash deleted successfully"
+
+msgid "PETTY_CASH_NOT_FOUND"
+msgstr "Petty cash not found"
+
+msgid "PETTY_CASH_BULK_DELETE_DONE"
+msgstr "Bulk delete completed for petty cash"
+
+msgid "INVALID_PETTY_CASH_CODE"
+msgstr "Invalid petty cash code"
+
+msgid "DUPLICATE_PETTY_CASH_CODE"
+msgstr "Duplicate petty cash code"
+
msgid "activeFilters"
msgstr "Active Filters"
diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo
index 8b5dbf8..bb7b738 100644
Binary files a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo and b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo differ
diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
index e573cc0..179ad4d 100644
--- a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
+++ b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
@@ -137,6 +137,34 @@ msgstr "پیکربندی ایمیل یافت نشد"
msgid "Configuration name already exists"
msgstr "نام پیکربندی قبلاً استفاده شده است"
+# Banking / Petty Cash
+msgid "PETTY_CASH_LIST_FETCHED"
+msgstr "لیست تنخواه گردانها با موفقیت دریافت شد"
+
+msgid "PETTY_CASH_CREATED"
+msgstr "تنخواه گردان با موفقیت ایجاد شد"
+
+msgid "PETTY_CASH_DETAILS"
+msgstr "جزئیات تنخواه گردان"
+
+msgid "PETTY_CASH_UPDATED"
+msgstr "تنخواه گردان با موفقیت بهروزرسانی شد"
+
+msgid "PETTY_CASH_DELETED"
+msgstr "تنخواه گردان با موفقیت حذف شد"
+
+msgid "PETTY_CASH_NOT_FOUND"
+msgstr "تنخواه گردان یافت نشد"
+
+msgid "PETTY_CASH_BULK_DELETE_DONE"
+msgstr "حذف گروهی تنخواه گردانها انجام شد"
+
+msgid "INVALID_PETTY_CASH_CODE"
+msgstr "کد تنخواه گردان نامعتبر است"
+
+msgid "DUPLICATE_PETTY_CASH_CODE"
+msgstr "کد تنخواه گردان تکراری است"
+
msgid "Connection test completed"
msgstr "تست اتصال تکمیل شد"
diff --git a/hesabixAPI/migrations/versions/1f0abcdd7300_add_petty_cash_table.py b/hesabixAPI/migrations/versions/1f0abcdd7300_add_petty_cash_table.py
new file mode 100644
index 0000000..ba1c5d7
--- /dev/null
+++ b/hesabixAPI/migrations/versions/1f0abcdd7300_add_petty_cash_table.py
@@ -0,0 +1,51 @@
+"""add_petty_cash_table
+
+Revision ID: 1f0abcdd7300
+Revises: 20251003_010501_add_name_to_cash_registers
+Create Date: 2025-10-04 01:16:30.244567
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '1f0abcdd7300'
+down_revision = '20251003_010501_add_name_to_cash_registers'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('petty_cash',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False, comment='نام تنخواه گردان'),
+ sa.Column('code', sa.String(length=50), nullable=True, comment='کد یکتا در هر کسب\u200cوکار (اختیاری)'),
+ sa.Column('description', sa.String(length=500), nullable=True),
+ sa.Column('currency_id', sa.Integer(), nullable=False),
+ sa.Column('is_active', sa.Boolean(), server_default=sa.text('1'), nullable=False),
+ sa.Column('is_default', sa.Boolean(), server_default=sa.text('0'), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('business_id', 'code', name='uq_petty_cash_business_code')
+ )
+ op.create_index(op.f('ix_petty_cash_business_id'), 'petty_cash', ['business_id'], unique=False)
+ op.create_index(op.f('ix_petty_cash_code'), 'petty_cash', ['code'], unique=False)
+ op.create_index(op.f('ix_petty_cash_currency_id'), 'petty_cash', ['currency_id'], unique=False)
+ op.create_index(op.f('ix_petty_cash_name'), 'petty_cash', ['name'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_petty_cash_name'), table_name='petty_cash')
+ op.drop_index(op.f('ix_petty_cash_currency_id'), table_name='petty_cash')
+ op.drop_index(op.f('ix_petty_cash_code'), table_name='petty_cash')
+ op.drop_index(op.f('ix_petty_cash_business_id'), table_name='petty_cash')
+ op.drop_table('petty_cash')
+ # ### end Alembic commands ###
diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb
index 2bd011a..bb96960 100644
--- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb
+++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb
@@ -1065,6 +1065,31 @@
"description": "Description",
"actions": "Actions",
"yes": "Yes",
- "no": "No"
+ "no": "No",
+ "pettyCash": "Petty Cash",
+ "pettyCashManagement": "Petty Cash Management",
+ "addPettyCash": "Add Petty Cash",
+ "editPettyCash": "Edit Petty Cash",
+ "deletePettyCash": "Delete Petty Cash",
+ "viewPettyCash": "View Petty Cash",
+ "pettyCashName": "Petty Cash Name",
+ "pettyCashCode": "Petty Cash Code",
+ "pettyCashDescription": "Petty Cash Description",
+ "pettyCashCurrency": "Currency",
+ "pettyCashIsActive": "Active",
+ "pettyCashIsDefault": "Default",
+ "pettyCashCreatedSuccessfully": "Petty cash created successfully",
+ "pettyCashUpdatedSuccessfully": "Petty cash updated successfully",
+ "pettyCashDeletedSuccessfully": "Petty cash deleted successfully",
+ "pettyCashNotFound": "Petty cash not found",
+ "pettyCashNameRequired": "Petty cash name is required",
+ "duplicatePettyCashCode": "Duplicate petty cash code",
+ "invalidPettyCashCode": "Invalid petty cash code",
+ "pettyCashBulkDeleted": "Petty cash items deleted successfully",
+ "pettyCashListFetched": "Petty cash list fetched",
+ "pettyCashDetails": "Petty cash details",
+ "pettyCashExportExcel": "Export petty cash to Excel",
+ "pettyCashExportPdf": "Export petty cash to PDF",
+ "pettyCashReport": "Petty Cash Report"
}
diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb
index d526001..fb787d7 100644
--- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb
+++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb
@@ -1048,6 +1048,31 @@
"description": "توضیحات",
"actions": "اقدامات",
"yes": "بله",
- "no": "خیر"
+ "no": "خیر",
+ "pettyCash": "تنخواه گردان",
+ "pettyCashManagement": "مدیریت تنخواه گردان",
+ "addPettyCash": "افزودن تنخواه گردان",
+ "editPettyCash": "ویرایش تنخواه گردان",
+ "deletePettyCash": "حذف تنخواه گردان",
+ "viewPettyCash": "مشاهده تنخواه گردان",
+ "pettyCashName": "نام تنخواه گردان",
+ "pettyCashCode": "کد تنخواه گردان",
+ "pettyCashDescription": "توضیحات تنخواه گردان",
+ "pettyCashCurrency": "واحد پولی",
+ "pettyCashIsActive": "فعال",
+ "pettyCashIsDefault": "پیشفرض",
+ "pettyCashCreatedSuccessfully": "تنخواه گردان با موفقیت ایجاد شد",
+ "pettyCashUpdatedSuccessfully": "تنخواه گردان با موفقیت ویرایش شد",
+ "pettyCashDeletedSuccessfully": "تنخواه گردان با موفقیت حذف شد",
+ "pettyCashNotFound": "تنخواه گردان یافت نشد",
+ "pettyCashNameRequired": "نام تنخواه گردان الزامی است",
+ "duplicatePettyCashCode": "کد تنخواه گردان تکراری است",
+ "invalidPettyCashCode": "کد تنخواه گردان نامعتبر است",
+ "pettyCashBulkDeleted": "تنخواه گردانها با موفقیت حذف شدند",
+ "pettyCashListFetched": "لیست تنخواه گردانها دریافت شد",
+ "pettyCashDetails": "جزئیات تنخواه گردان",
+ "pettyCashExportExcel": "خروجی Excel تنخواه گردانها",
+ "pettyCashExportPdf": "خروجی PDF تنخواه گردانها",
+ "pettyCashReport": "گزارش تنخواه گردانها"
}
diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart
index e9ee45e..b65d678 100644
--- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart
+++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart
@@ -5599,6 +5599,126 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Coming soon'**
String get comingSoon;
+
+ /// No description provided for @pettyCashManagement.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty Cash Management'**
+ String get pettyCashManagement;
+
+ /// No description provided for @pettyCashName.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty Cash Name'**
+ String get pettyCashName;
+
+ /// No description provided for @pettyCashCode.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty Cash Code'**
+ String get pettyCashCode;
+
+ /// No description provided for @pettyCashDescription.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty Cash Description'**
+ String get pettyCashDescription;
+
+ /// No description provided for @pettyCashCurrency.
+ ///
+ /// In en, this message translates to:
+ /// **'Currency'**
+ String get pettyCashCurrency;
+
+ /// No description provided for @pettyCashIsActive.
+ ///
+ /// In en, this message translates to:
+ /// **'Active'**
+ String get pettyCashIsActive;
+
+ /// No description provided for @pettyCashIsDefault.
+ ///
+ /// In en, this message translates to:
+ /// **'Default'**
+ String get pettyCashIsDefault;
+
+ /// No description provided for @pettyCashCreatedSuccessfully.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty cash created successfully'**
+ String get pettyCashCreatedSuccessfully;
+
+ /// No description provided for @pettyCashUpdatedSuccessfully.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty cash updated successfully'**
+ String get pettyCashUpdatedSuccessfully;
+
+ /// No description provided for @pettyCashDeletedSuccessfully.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty cash deleted successfully'**
+ String get pettyCashDeletedSuccessfully;
+
+ /// No description provided for @pettyCashNotFound.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty cash not found'**
+ String get pettyCashNotFound;
+
+ /// No description provided for @pettyCashNameRequired.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty cash name is required'**
+ String get pettyCashNameRequired;
+
+ /// No description provided for @duplicatePettyCashCode.
+ ///
+ /// In en, this message translates to:
+ /// **'Duplicate petty cash code'**
+ String get duplicatePettyCashCode;
+
+ /// No description provided for @invalidPettyCashCode.
+ ///
+ /// In en, this message translates to:
+ /// **'Invalid petty cash code'**
+ String get invalidPettyCashCode;
+
+ /// No description provided for @pettyCashBulkDeleted.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty cash items deleted successfully'**
+ String get pettyCashBulkDeleted;
+
+ /// No description provided for @pettyCashListFetched.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty cash list fetched'**
+ String get pettyCashListFetched;
+
+ /// No description provided for @pettyCashDetails.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty cash details'**
+ String get pettyCashDetails;
+
+ /// No description provided for @pettyCashExportExcel.
+ ///
+ /// In en, this message translates to:
+ /// **'Export petty cash to Excel'**
+ String get pettyCashExportExcel;
+
+ /// No description provided for @pettyCashExportPdf.
+ ///
+ /// In en, this message translates to:
+ /// **'Export petty cash to PDF'**
+ String get pettyCashExportPdf;
+
+ /// No description provided for @pettyCashReport.
+ ///
+ /// In en, this message translates to:
+ /// **'Petty Cash Report'**
+ String get pettyCashReport;
}
class _AppLocalizationsDelegate
diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart
index f9febd2..b2fbb91 100644
--- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart
+++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart
@@ -2837,4 +2837,64 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get comingSoon => 'Coming soon';
+
+ @override
+ String get pettyCashManagement => 'Petty Cash Management';
+
+ @override
+ String get pettyCashName => 'Petty Cash Name';
+
+ @override
+ String get pettyCashCode => 'Petty Cash Code';
+
+ @override
+ String get pettyCashDescription => 'Petty Cash Description';
+
+ @override
+ String get pettyCashCurrency => 'Currency';
+
+ @override
+ String get pettyCashIsActive => 'Active';
+
+ @override
+ String get pettyCashIsDefault => 'Default';
+
+ @override
+ String get pettyCashCreatedSuccessfully => 'Petty cash created successfully';
+
+ @override
+ String get pettyCashUpdatedSuccessfully => 'Petty cash updated successfully';
+
+ @override
+ String get pettyCashDeletedSuccessfully => 'Petty cash deleted successfully';
+
+ @override
+ String get pettyCashNotFound => 'Petty cash not found';
+
+ @override
+ String get pettyCashNameRequired => 'Petty cash name is required';
+
+ @override
+ String get duplicatePettyCashCode => 'Duplicate petty cash code';
+
+ @override
+ String get invalidPettyCashCode => 'Invalid petty cash code';
+
+ @override
+ String get pettyCashBulkDeleted => 'Petty cash items deleted successfully';
+
+ @override
+ String get pettyCashListFetched => 'Petty cash list fetched';
+
+ @override
+ String get pettyCashDetails => 'Petty cash details';
+
+ @override
+ String get pettyCashExportExcel => 'Export petty cash to Excel';
+
+ @override
+ String get pettyCashExportPdf => 'Export petty cash to PDF';
+
+ @override
+ String get pettyCashReport => 'Petty Cash Report';
}
diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart
index dd3a3f7..4b4b3eb 100644
--- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart
+++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart
@@ -1874,16 +1874,16 @@ class AppLocalizationsFa extends AppLocalizations {
String get deleteCash => 'حذف صندوقها';
@override
- String get addPettyCash => 'افزودن تنخواه';
+ String get addPettyCash => 'افزودن تنخواه گردان';
@override
- String get viewPettyCash => 'مشاهده تنخواهها';
+ String get viewPettyCash => 'مشاهده تنخواه گردان';
@override
- String get editPettyCash => 'ویرایش تنخواهها';
+ String get editPettyCash => 'ویرایش تنخواه گردان';
@override
- String get deletePettyCash => 'حذف تنخواهها';
+ String get deletePettyCash => 'حذف تنخواه گردان';
@override
String get addCheck => 'افزودن چک';
@@ -2816,4 +2816,64 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get comingSoon => 'بهزودی';
+
+ @override
+ String get pettyCashManagement => 'مدیریت تنخواه گردان';
+
+ @override
+ String get pettyCashName => 'نام تنخواه گردان';
+
+ @override
+ String get pettyCashCode => 'کد تنخواه گردان';
+
+ @override
+ String get pettyCashDescription => 'توضیحات تنخواه گردان';
+
+ @override
+ String get pettyCashCurrency => 'واحد پولی';
+
+ @override
+ String get pettyCashIsActive => 'فعال';
+
+ @override
+ String get pettyCashIsDefault => 'پیشفرض';
+
+ @override
+ String get pettyCashCreatedSuccessfully => 'تنخواه گردان با موفقیت ایجاد شد';
+
+ @override
+ String get pettyCashUpdatedSuccessfully => 'تنخواه گردان با موفقیت ویرایش شد';
+
+ @override
+ String get pettyCashDeletedSuccessfully => 'تنخواه گردان با موفقیت حذف شد';
+
+ @override
+ String get pettyCashNotFound => 'تنخواه گردان یافت نشد';
+
+ @override
+ String get pettyCashNameRequired => 'نام تنخواه گردان الزامی است';
+
+ @override
+ String get duplicatePettyCashCode => 'کد تنخواه گردان تکراری است';
+
+ @override
+ String get invalidPettyCashCode => 'کد تنخواه گردان نامعتبر است';
+
+ @override
+ String get pettyCashBulkDeleted => 'تنخواه گردانها با موفقیت حذف شدند';
+
+ @override
+ String get pettyCashListFetched => 'لیست تنخواه گردانها دریافت شد';
+
+ @override
+ String get pettyCashDetails => 'جزئیات تنخواه گردان';
+
+ @override
+ String get pettyCashExportExcel => 'خروجی Excel تنخواه گردانها';
+
+ @override
+ String get pettyCashExportPdf => 'خروجی PDF تنخواه گردانها';
+
+ @override
+ String get pettyCashReport => 'گزارش تنخواه گردانها';
}
diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart
index ffd3270..a35b0d9 100644
--- a/hesabixUI/hesabix_ui/lib/main.dart
+++ b/hesabixUI/hesabix_ui/lib/main.dart
@@ -24,6 +24,9 @@ import 'pages/business/dashboard/business_dashboard_page.dart';
import 'pages/business/users_permissions_page.dart';
import 'pages/business/accounts_page.dart';
import 'pages/business/bank_accounts_page.dart';
+import 'pages/business/wallet_page.dart';
+import 'pages/business/invoice_page.dart';
+import 'pages/business/new_invoice_page.dart';
import 'pages/business/settings_page.dart';
import 'pages/business/persons_page.dart';
import 'pages/business/product_attributes_page.dart';
@@ -31,6 +34,7 @@ import 'pages/business/products_page.dart';
import 'pages/business/price_lists_page.dart';
import 'pages/business/price_list_items_page.dart';
import 'pages/business/cash_registers_page.dart';
+import 'pages/business/petty_cash_page.dart';
import 'pages/error_404_page.dart';
import 'core/locale_controller.dart';
import 'core/calendar_controller.dart';
@@ -562,6 +566,24 @@ class _MyAppState extends State {
);
},
),
+ GoRoute(
+ path: 'petty-cash',
+ name: 'business_petty_cash',
+ builder: (context, state) {
+ final businessId = int.parse(state.pathParameters['business_id']!);
+ return BusinessShell(
+ businessId: businessId,
+ authStore: _authStore!,
+ localeController: controller,
+ calendarController: _calendarController!,
+ themeController: themeController,
+ child: PettyCashPage(
+ businessId: businessId,
+ authStore: _authStore!,
+ ),
+ );
+ },
+ ),
GoRoute(
path: 'cash-box',
name: 'business_cash_box',
@@ -580,6 +602,60 @@ class _MyAppState extends State {
);
},
),
+ GoRoute(
+ path: 'wallet',
+ name: 'business_wallet',
+ builder: (context, state) {
+ final businessId = int.parse(state.pathParameters['business_id']!);
+ return BusinessShell(
+ businessId: businessId,
+ authStore: _authStore!,
+ localeController: controller,
+ calendarController: _calendarController!,
+ themeController: themeController,
+ child: WalletPage(
+ businessId: businessId,
+ authStore: _authStore!,
+ ),
+ );
+ },
+ ),
+ GoRoute(
+ path: 'invoice',
+ name: 'business_invoice',
+ builder: (context, state) {
+ final businessId = int.parse(state.pathParameters['business_id']!);
+ return BusinessShell(
+ businessId: businessId,
+ authStore: _authStore!,
+ localeController: controller,
+ calendarController: _calendarController!,
+ themeController: themeController,
+ child: InvoicePage(
+ businessId: businessId,
+ authStore: _authStore!,
+ ),
+ );
+ },
+ ),
+ GoRoute(
+ path: 'invoice/new',
+ name: 'business_new_invoice',
+ builder: (context, state) {
+ final businessId = int.parse(state.pathParameters['business_id']!);
+ return BusinessShell(
+ businessId: businessId,
+ authStore: _authStore!,
+ localeController: controller,
+ calendarController: _calendarController!,
+ themeController: themeController,
+ child: NewInvoicePage(
+ businessId: businessId,
+ authStore: _authStore!,
+ ),
+ );
+ },
+ ),
GoRoute(
path: 'settings',
name: 'business_settings',
diff --git a/hesabixUI/hesabix_ui/lib/models/petty_cash.dart b/hesabixUI/hesabix_ui/lib/models/petty_cash.dart
new file mode 100644
index 0000000..1949a86
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/models/petty_cash.dart
@@ -0,0 +1,110 @@
+class PettyCash {
+ final int? id;
+ final int businessId;
+ final String name;
+ final String? code;
+ final int currencyId;
+ final bool isActive;
+ final bool isDefault;
+ final String? description;
+ final DateTime? createdAt;
+ final DateTime? updatedAt;
+
+ const PettyCash({
+ this.id,
+ required this.businessId,
+ required this.name,
+ this.code,
+ required this.currencyId,
+ this.isActive = true,
+ this.isDefault = false,
+ this.description,
+ this.createdAt,
+ this.updatedAt,
+ });
+
+ factory PettyCash.fromJson(Map json) {
+ return PettyCash(
+ id: json['id'] as int?,
+ businessId: (json['business_id'] ?? json['businessId']) as int,
+ name: (json['name'] ?? '') as String,
+ code: json['code'] as String?,
+ currencyId: (json['currency_id'] ?? json['currencyId']) as int,
+ isActive: (json['is_active'] ?? true) as bool,
+ isDefault: (json['is_default'] ?? false) as bool,
+ description: json['description'] as String?,
+ createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null,
+ updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'name': name,
+ 'code': code,
+ 'currency_id': currencyId,
+ 'is_active': isActive,
+ 'is_default': isDefault,
+ 'description': description,
+ };
+ }
+
+ PettyCash copyWith({
+ int? id,
+ int? businessId,
+ String? name,
+ String? code,
+ int? currencyId,
+ bool? isActive,
+ bool? isDefault,
+ String? description,
+ DateTime? createdAt,
+ DateTime? updatedAt,
+ }) {
+ return PettyCash(
+ id: id ?? this.id,
+ businessId: businessId ?? this.businessId,
+ name: name ?? this.name,
+ code: code ?? this.code,
+ currencyId: currencyId ?? this.currencyId,
+ isActive: isActive ?? this.isActive,
+ isDefault: isDefault ?? this.isDefault,
+ description: description ?? this.description,
+ createdAt: createdAt ?? this.createdAt,
+ updatedAt: updatedAt ?? this.updatedAt,
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ return other is PettyCash &&
+ other.id == id &&
+ other.businessId == businessId &&
+ other.name == name &&
+ other.code == code &&
+ other.currencyId == currencyId &&
+ other.isActive == isActive &&
+ other.isDefault == isDefault &&
+ other.description == description;
+ }
+
+ @override
+ int get hashCode {
+ return Object.hash(
+ id,
+ businessId,
+ name,
+ code,
+ currencyId,
+ isActive,
+ isDefault,
+ description,
+ );
+ }
+
+ @override
+ String toString() {
+ return 'PettyCash(id: $id, businessId: $businessId, name: $name, code: $code, currencyId: $currencyId, isActive: $isActive, isDefault: $isDefault, description: $description)';
+ }
+}
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart
index 8fbb169..71e7f81 100644
--- a/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart
@@ -49,6 +49,23 @@ class _BankAccountsPageState extends State {
}
}
+ /// Public method to refresh the data table
+ void refresh() {
+ try {
+ (_bankAccountsTableKey.currentState as dynamic)?.refresh();
+ } catch (_) {}
+ }
+
+ @override
+ void didUpdateWidget(BankAccountsPage oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ // This will be called when the widget is updated
+ // Refresh the data table to show any new data
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ refresh();
+ });
+ }
+
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart
index 2a757b2..4231338 100644
--- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart
@@ -7,6 +7,8 @@ import '../../theme/theme_controller.dart';
import '../../widgets/combined_user_menu_button.dart';
import '../../widgets/person/person_form_dialog.dart';
import '../../widgets/banking/bank_account_form_dialog.dart';
+import '../../widgets/banking/cash_register_form_dialog.dart';
+import '../../widgets/banking/petty_cash_form_dialog.dart';
import '../../widgets/product/product_form_dialog.dart';
import '../../widgets/category/category_tree_dialog.dart';
import '../../services/business_dashboard_service.dart';
@@ -61,6 +63,19 @@ class _BusinessShellState extends State {
_loadBusinessInfo();
}
+ @override
+ void dispose() {
+ super.dispose();
+ }
+
+ void _refreshCurrentPage() {
+ // Force a rebuild of the current page
+ setState(() {
+ // This will cause the current page to rebuild
+ // and if it's PettyCashPage, it will refresh its data
+ });
+ }
+
Future _loadBusinessInfo() async {
print('=== _loadBusinessInfo START ===');
print('Current business ID: ${widget.businessId}');
@@ -544,6 +559,58 @@ class _BusinessShellState extends State {
}
}
+ Future showAddCashBoxDialog() async {
+ final result = await showDialog(
+ context: context,
+ builder: (context) => CashRegisterFormDialog(
+ businessId: widget.businessId,
+ onSuccess: () {
+ // Refresh the cash registers page if it's currently open
+ _refreshCurrentPage();
+ },
+ ),
+ );
+ if (result == true) {
+ // Cash register was successfully added, refresh the current page
+ _refreshCurrentPage();
+ }
+ }
+
+ Future showAddPettyCashDialog() async {
+ final result = await showDialog(
+ context: context,
+ builder: (context) => PettyCashFormDialog(
+ businessId: widget.businessId,
+ onSuccess: () {
+ // Refresh the petty cash page if it's currently open
+ _refreshCurrentPage();
+ },
+ ),
+ );
+ if (result == true) {
+ // Petty cash was successfully added, refresh the current page
+ _refreshCurrentPage();
+ }
+ }
+
+ Future showAddBankAccountDialog() async {
+ final result = await showDialog(
+ context: context,
+ builder: (context) => BankAccountFormDialog(
+ businessId: widget.businessId,
+ onSuccess: () {
+ // Refresh the bank accounts page if it's currently open
+ _refreshCurrentPage();
+ },
+ ),
+ );
+ if (result == true) {
+ // Bank account was successfully added, refresh the current page
+ _refreshCurrentPage();
+ }
+ }
+
+
bool isExpanded(_MenuItem item) {
if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded;
if (item.label == t.banking) return _isBankingExpanded;
@@ -728,23 +795,20 @@ class _BusinessShellState extends State {
// Navigate to add product attribute
} else if (child.label == t.accounts) {
// Open add bank account dialog
- showDialog(
- context: context,
- builder: (ctx) => BankAccountFormDialog(
- businessId: widget.businessId,
- ),
- );
+ showAddBankAccountDialog();
} else if (child.label == t.pettyCash) {
- // Navigate to add petty cash
+ // Open add petty cash dialog
+ showAddPettyCashDialog();
} else if (child.label == t.cashBox) {
- // For cash box, navigate to the page and use its add
- context.go('/business/${widget.businessId}/cash-box');
+ // Open add cash register dialog
+ showAddCashBoxDialog();
} else if (child.label == t.wallet) {
// Navigate to add wallet
} else if (child.label == t.checks) {
// Navigate to add check
} else if (child.label == t.invoice) {
// Navigate to add invoice
+ context.go('/business/${widget.businessId}/invoice/new');
} else if (child.label == t.expenseAndIncome) {
// Navigate to add expense/income
} else if (child.label == t.warehouses) {
@@ -881,14 +945,9 @@ class _BusinessShellState extends State {
if (item.label == t.people) {
showAddPersonDialog();
} else if (item.label == t.accounts) {
- showDialog(
- context: context,
- builder: (ctx) => BankAccountFormDialog(
- businessId: widget.businessId,
- ),
- );
+ showAddBankAccountDialog();
} else if (item.label == t.cashBox) {
- context.go('/business/${widget.businessId}/cash-box');
+ showAddCashBoxDialog();
}
// سایر مسیرهای افزودن در آینده متصل میشوند
},
@@ -1036,17 +1095,21 @@ class _BusinessShellState extends State {
} else if (child.label == t.productAttributes) {
// Navigate to add product attribute
} else if (child.label == t.accounts) {
- // Navigate to add account
+ // Open add bank account dialog
+ showAddBankAccountDialog();
} else if (child.label == t.pettyCash) {
- // Navigate to add petty cash
+ // Open add petty cash dialog
+ showAddPettyCashDialog();
} else if (child.label == t.cashBox) {
- // Navigate to add cash box
+ // Open add cash register dialog
+ showAddCashBoxDialog();
} else if (child.label == t.wallet) {
// Navigate to add wallet
} else if (child.label == t.checks) {
// Navigate to add check
} else if (child.label == t.invoice) {
// Navigate to add invoice
+ context.go('/business/${widget.businessId}/invoice/new');
} else if (child.label == t.expenseAndIncome) {
// Navigate to add expense/income
} else if (child.label == t.warehouses) {
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart
index c37e9b1..89e0cb1 100644
--- a/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart
@@ -47,6 +47,23 @@ class _CashRegistersPageState extends State {
} catch (_) {}
}
+ /// Public method to refresh the data table
+ void refresh() {
+ try {
+ (_tableKey.currentState as dynamic)?.refresh();
+ } catch (_) {}
+ }
+
+ @override
+ void didUpdateWidget(CashRegistersPage oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ // This will be called when the widget is updated
+ // Refresh the data table to show any new data
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ refresh();
+ });
+ }
+
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
@@ -151,7 +168,7 @@ class _CashRegistersPageState extends State {
],
),
],
- searchFields: ['code','description','payment_switch_number','payment_terminal_number','merchant_id'],
+ searchFields: ['name','code','description','payment_switch_number','payment_terminal_number','merchant_id'],
filterFields: ['is_active','is_default','currency_id'],
defaultPageSize: 20,
customHeaderActions: [
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/invoice_page.dart
new file mode 100644
index 0000000..ab5cac2
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/pages/business/invoice_page.dart
@@ -0,0 +1,58 @@
+import 'package:flutter/material.dart';
+import 'package:hesabix_ui/l10n/app_localizations.dart';
+import '../../core/auth_store.dart';
+import '../../widgets/permission/access_denied_page.dart';
+
+class InvoicePage extends StatefulWidget {
+ final int businessId;
+ final AuthStore authStore;
+
+ const InvoicePage({
+ super.key,
+ required this.businessId,
+ required this.authStore,
+ });
+
+ @override
+ State createState() => _InvoicePageState();
+}
+
+class _InvoicePageState extends State {
+ @override
+ Widget build(BuildContext context) {
+ final t = AppLocalizations.of(context);
+
+ if (!widget.authStore.canReadSection('invoices')) {
+ return AccessDeniedPage(message: t.accessDenied);
+ }
+
+ return Scaffold(
+ body: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.receipt,
+ size: 80,
+ color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
+ ),
+ const SizedBox(height: 24),
+ Text(
+ t.invoice,
+ style: Theme.of(context).textTheme.headlineMedium?.copyWith(
+ color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'صفحه فاکتور در حال توسعه است',
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart
new file mode 100644
index 0000000..1443230
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart
@@ -0,0 +1,81 @@
+import 'package:flutter/material.dart';
+import 'package:hesabix_ui/l10n/app_localizations.dart';
+import '../../core/auth_store.dart';
+import '../../widgets/permission/access_denied_page.dart';
+
+class NewInvoicePage extends StatefulWidget {
+ final int businessId;
+ final AuthStore authStore;
+
+ const NewInvoicePage({
+ super.key,
+ required this.businessId,
+ required this.authStore,
+ });
+
+ @override
+ State createState() => _NewInvoicePageState();
+}
+
+class _NewInvoicePageState extends State {
+ @override
+ Widget build(BuildContext context) {
+ final t = AppLocalizations.of(context);
+
+ if (!widget.authStore.canWriteSection('invoices')) {
+ return AccessDeniedPage(message: t.accessDenied);
+ }
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(t.addInvoice),
+ backgroundColor: Theme.of(context).colorScheme.surface,
+ foregroundColor: Theme.of(context).colorScheme.onSurface,
+ elevation: 0,
+ ),
+ body: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.add_circle_outline,
+ size: 80,
+ color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
+ ),
+ const SizedBox(height: 24),
+ Text(
+ t.addInvoice,
+ style: Theme.of(context).textTheme.headlineMedium?.copyWith(
+ color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
+ ),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'فرم ایجاد فاکتور جدید در حال توسعه است',
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
+ ),
+ ),
+ const SizedBox(height: 32),
+ ElevatedButton.icon(
+ onPressed: () {
+ // TODO: پیادهسازی منطق ایجاد فاکتور
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('فرم ایجاد فاکتور به زودی اضافه خواهد شد'),
+ backgroundColor: Theme.of(context).colorScheme.primary,
+ ),
+ );
+ },
+ icon: const Icon(Icons.add),
+ label: Text(t.addInvoice),
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/petty_cash_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/petty_cash_page.dart
new file mode 100644
index 0000000..0e7c3f3
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/pages/business/petty_cash_page.dart
@@ -0,0 +1,283 @@
+import 'package:flutter/material.dart';
+import 'package:hesabix_ui/l10n/app_localizations.dart';
+import 'package:hesabix_ui/core/api_client.dart';
+import '../../widgets/data_table/data_table_widget.dart';
+import '../../widgets/data_table/data_table_config.dart';
+import '../../widgets/permission/permission_widgets.dart';
+import '../../core/auth_store.dart';
+import '../../models/petty_cash.dart';
+import '../../services/petty_cash_service.dart';
+import '../../services/currency_service.dart';
+import '../../widgets/banking/petty_cash_form_dialog.dart';
+
+class PettyCashPage extends StatefulWidget {
+ final int businessId;
+ final AuthStore authStore;
+
+ const PettyCashPage({super.key, required this.businessId, required this.authStore});
+
+ @override
+ State createState() => _PettyCashPageState();
+}
+
+class _PettyCashPageState extends State {
+ final _service = PettyCashService();
+ final _currencyService = CurrencyService(ApiClient());
+ final GlobalKey _tableKey = GlobalKey();
+ Map _currencyNames = {};
+
+ @override
+ void initState() {
+ super.initState();
+ _loadCurrencies();
+ }
+
+ @override
+ void didUpdateWidget(PettyCashPage oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ // This will be called when the widget is updated
+ // Refresh the data table to show any new data
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ refresh();
+ });
+ }
+
+ Future _loadCurrencies() async {
+ try {
+ final currencies = await _currencyService.listBusinessCurrencies(
+ businessId: widget.businessId,
+ );
+ final currencyMap = {};
+ for (final currency in currencies) {
+ currencyMap[currency['id'] as int] = '${currency['title']} (${currency['code']})';
+ }
+ setState(() {
+ _currencyNames = currencyMap;
+ });
+ } catch (_) {}
+ }
+
+ /// Public method to refresh the data table
+ void refresh() {
+ try {
+ (_tableKey.currentState as dynamic)?.refresh();
+ } catch (_) {}
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final t = AppLocalizations.of(context);
+
+ if (!widget.authStore.canReadSection('petty_cash')) {
+ return AccessDeniedPage(message: t.accessDenied);
+ }
+
+ return Scaffold(
+ body: DataTableWidget(
+ key: _tableKey,
+ config: _buildConfig(t),
+ fromJson: PettyCash.fromJson,
+ ),
+ );
+ }
+
+ DataTableConfig _buildConfig(AppLocalizations t) {
+ return DataTableConfig(
+ endpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash',
+ title: (t.localeName == 'fa') ? 'تنخواه گردان' : 'Petty Cash',
+ excelEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/excel',
+ pdfEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/pdf',
+ getExportParams: () => {'business_id': widget.businessId},
+ showBackButton: true,
+ onBack: () => Navigator.of(context).maybePop(),
+ showTableIcon: false,
+ showRowNumbers: true,
+ enableRowSelection: true,
+ enableMultiRowSelection: true,
+ columns: [
+ TextColumn(
+ 'code',
+ t.code,
+ width: ColumnWidth.small,
+ formatter: (row) => (row.code?.toString() ?? '-'),
+ textAlign: TextAlign.center,
+ ),
+ TextColumn(
+ 'name',
+ t.title,
+ width: ColumnWidth.large,
+ formatter: (row) => row.name,
+ ),
+ TextColumn(
+ 'currency_id',
+ t.currency,
+ width: ColumnWidth.medium,
+ formatter: (row) => _currencyNames[row.currencyId] ?? (t.localeName == 'fa' ? 'نامشخص' : 'Unknown'),
+ ),
+ TextColumn(
+ 'is_active',
+ t.active,
+ width: ColumnWidth.small,
+ formatter: (row) => row.isActive ? t.active : t.inactive,
+ ),
+ TextColumn(
+ 'is_default',
+ t.isDefault,
+ width: ColumnWidth.small,
+ formatter: (row) => row.isDefault ? t.yes : t.no,
+ ),
+ TextColumn(
+ 'description',
+ t.description,
+ width: ColumnWidth.large,
+ formatter: (row) => row.description ?? '-',
+ ),
+ ActionColumn(
+ 'actions',
+ t.actions,
+ actions: [
+ DataTableAction(
+ icon: Icons.edit,
+ label: t.edit,
+ onTap: (row) => _edit(row),
+ ),
+ DataTableAction(
+ icon: Icons.delete,
+ label: t.delete,
+ color: Colors.red,
+ onTap: (row) => _delete(row),
+ ),
+ ],
+ ),
+ ],
+ searchFields: ['name','code','description'],
+ filterFields: ['is_active','is_default','currency_id'],
+ defaultPageSize: 20,
+ customHeaderActions: [
+ PermissionButton(
+ section: 'petty_cash',
+ action: 'add',
+ authStore: widget.authStore,
+ child: Tooltip(
+ message: t.add,
+ child: IconButton(
+ onPressed: _add,
+ icon: const Icon(Icons.add),
+ ),
+ ),
+ ),
+ if (widget.authStore.canDeleteSection('petty_cash'))
+ Tooltip(
+ message: t.deleteSelected,
+ child: IconButton(
+ onPressed: _bulkDelete,
+ icon: const Icon(Icons.delete_sweep_outlined),
+ ),
+ ),
+ ],
+ );
+ }
+
+ void _add() async {
+ await showDialog(
+ context: context,
+ builder: (ctx) => PettyCashFormDialog(
+ businessId: widget.businessId,
+ onSuccess: () {
+ try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
+ },
+ ),
+ );
+ }
+
+ void _edit(PettyCash row) async {
+ await showDialog(
+ context: context,
+ builder: (ctx) => PettyCashFormDialog(
+ businessId: widget.businessId,
+ pettyCash: row,
+ onSuccess: () {
+ try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
+ },
+ ),
+ );
+ }
+
+ Future _delete(PettyCash row) async {
+ final t = AppLocalizations.of(context);
+ final confirm = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Text(t.delete),
+ content: Text(t.deleteConfirm(row.code ?? '')),
+ actions: [
+ TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
+ FilledButton.tonal(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)),
+ ],
+ ),
+ );
+ if (confirm != true) return;
+ try {
+ await _service.delete(row.id!);
+ try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.deletedSuccessfully)));
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e')));
+ }
+ }
+ }
+
+ Future _bulkDelete() async {
+ final t = AppLocalizations.of(context);
+ try {
+ final state = _tableKey.currentState as dynamic;
+ final selectedIndices = (state?.getSelectedRowIndices() as List?) ?? const [];
+ final items = (state?.getSelectedItems() as List?) ?? const [];
+ if (selectedIndices.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.noRowsSelectedError)));
+ return;
+ }
+ final ids = [];
+ for (final i in selectedIndices) {
+ if (i >= 0 && i < items.length) {
+ final row = items[i];
+ if (row is PettyCash && row.id != null) {
+ ids.add(row.id!);
+ } else if (row is Map) {
+ final id = row['id'];
+ if (id is int) ids.add(id);
+ }
+ }
+ }
+ if (ids.isEmpty) return;
+ final confirm = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Text(t.deleteSelected),
+ content: Text(t.deleteSelected),
+ actions: [
+ TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
+ FilledButton.tonal(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)),
+ ],
+ ),
+ );
+ if (confirm != true) return;
+ final client = ApiClient();
+ await client.post