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""" + + + + + + +
+
+
{title_text}
+
{label_biz}: {escape_val(business_name)}
+
+
{label_date}: {escape_val(now_str)}
+
+
+ + + {headers_html} + + + {''.join(rows_html)} + +
+
+
{footer_text}
+ + + """ + + 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>( + '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/bulk-delete', + data: { 'ids': ids }, + ); + 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'))); + } + } + } +} \ No newline at end of file diff --git a/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart new file mode 100644 index 0000000..49c6374 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/wallet_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 WalletPage extends StatefulWidget { + final int businessId; + final AuthStore authStore; + + const WalletPage({ + super.key, + required this.businessId, + required this.authStore, + }); + + @override + State createState() => _WalletPageState(); +} + +class _WalletPageState extends State { + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + if (!widget.authStore.canReadSection('wallet')) { + return AccessDeniedPage(message: t.accessDenied); + } + + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.wallet, + size: 80, + color: Theme.of(context).colorScheme.primary.withOpacity(0.6), + ), + const SizedBox(height: 24), + Text( + t.wallet, + 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/services/petty_cash_service.dart b/hesabixUI/hesabix_ui/lib/services/petty_cash_service.dart new file mode 100644 index 0000000..1b4a862 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/petty_cash_service.dart @@ -0,0 +1,95 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; +import '../models/petty_cash.dart'; + +class PettyCashService { + final ApiClient _client; + PettyCashService({ApiClient? client}) : _client = client ?? ApiClient(); + + Future> list({required int businessId, required Map queryInfo}) async { + try { + final res = await _client.post>( + '/api/v1/petty-cash/businesses/$businessId/petty-cash', + data: queryInfo, + ); + + // Null safety checks + final data = res.data ?? {}; + if (data['items'] == null) { + data['items'] = []; + } + + return data; + } catch (e) { + // Return safe fallback data structure + return { + 'items': [], + 'pagination': { + 'total': 0, + 'page': 1, + 'per_page': queryInfo['take'] ?? 10, + 'total_pages': 0, + 'has_next': false, + 'has_prev': false, + }, + 'query_info': queryInfo, + }; + } + } + + Future create({required int businessId, required Map payload}) async { + final res = await _client.post>( + '/api/v1/petty-cash/businesses/$businessId/petty-cash/create', + data: payload, + ); + final data = (res.data?['data'] as Map? ?? {}); + if (data.isEmpty) { + throw Exception('No data received from server'); + } + return PettyCash.fromJson(data); + } + + Future getById(int id) async { + final res = await _client.get>('/api/v1/petty-cash/petty-cash/$id'); + final data = (res.data?['data'] as Map? ?? {}); + return PettyCash.fromJson(data); + } + + Future update({required int id, required Map payload}) async { + final res = await _client.put>('/api/v1/petty-cash/petty-cash/$id', data: payload); + final data = (res.data?['data'] as Map? ?? {}); + return PettyCash.fromJson(data); + } + + Future delete(int id) async { + await _client.delete>('/api/v1/petty-cash/petty-cash/$id'); + } + + Future>> exportExcel({required int businessId, required Map body}) async { + return await _client.post>( + '/api/v1/petty-cash/businesses/$businessId/petty-cash/export/excel', + data: body, + options: Options( + responseType: ResponseType.bytes, + ), + ); + } + + Future>> exportPdf({required int businessId, required Map body}) async { + return await _client.post>( + '/api/v1/petty-cash/businesses/$businessId/petty-cash/export/pdf', + data: body, + options: Options( + responseType: ResponseType.bytes, + ), + ); + } + + Future> bulkDelete({required int businessId, required List ids}) async { + final res = await _client.post>( + '/api/v1/petty-cash/businesses/$businessId/petty-cash/bulk-delete', + data: {'ids': ids}, + ); + return (res.data ?? {}); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/banking/bank_account_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/banking/bank_account_form_dialog.dart index 5c0c03e..87d00b6 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/banking/bank_account_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/banking/bank_account_form_dialog.dart @@ -138,7 +138,7 @@ class _BankAccountFormDialogState extends State { } if (mounted) { - Navigator.of(context).pop(); + Navigator.of(context).pop(true); // Return true to indicate success widget.onSuccess?.call(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/hesabixUI/hesabix_ui/lib/widgets/banking/cash_register_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/banking/cash_register_form_dialog.dart index 0a5cd40..76408bc 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/banking/cash_register_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/banking/cash_register_form_dialog.dart @@ -106,7 +106,7 @@ class _CashRegisterFormDialogState extends State { } if (mounted) { - Navigator.of(context).pop(); + Navigator.of(context).pop(true); // Return true to indicate success widget.onSuccess?.call(); final t = AppLocalizations.of(context); ScaffoldMessenger.of(context).showSnackBar( diff --git a/hesabixUI/hesabix_ui/lib/widgets/banking/petty_cash_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/banking/petty_cash_form_dialog.dart new file mode 100644 index 0000000..479d913 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/banking/petty_cash_form_dialog.dart @@ -0,0 +1,320 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../models/petty_cash.dart'; +import '../../services/petty_cash_service.dart'; +import 'currency_picker_widget.dart'; + +class PettyCashFormDialog extends StatefulWidget { + final int businessId; + final PettyCash? pettyCash; // null برای افزودن، مقدار برای ویرایش + final VoidCallback? onSuccess; + + const PettyCashFormDialog({ + super.key, + required this.businessId, + this.pettyCash, + this.onSuccess, + }); + + @override + State createState() => _PettyCashFormDialogState(); +} + +class _PettyCashFormDialogState extends State { + final _formKey = GlobalKey(); + final _service = PettyCashService(); + bool _isLoading = false; + + final _codeController = TextEditingController(); + bool _autoGenerateCode = true; + + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + + bool _isActive = true; + bool _isDefault = false; + int? _currencyId; + + @override + void initState() { + super.initState(); + _initializeForm(); + } + + void _initializeForm() { + if (widget.pettyCash != null) { + final p = widget.pettyCash!; + if (p.code != null) { + _codeController.text = p.code!; + _autoGenerateCode = false; + } + _nameController.text = p.name; + _descriptionController.text = p.description ?? ''; + _isActive = p.isActive; + _isDefault = p.isDefault; + _currencyId = p.currencyId; + } + } + + @override + void dispose() { + _codeController.dispose(); + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + if (_currencyId == null) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.currency), backgroundColor: Colors.red), + ); + return; + } + + setState(() { _isLoading = true; }); + try { + final payload = { + 'name': _nameController.text.trim(), + 'code': _autoGenerateCode ? null : (_codeController.text.trim().isEmpty ? null : _codeController.text.trim()), + 'description': _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), + 'is_active': _isActive, + 'is_default': _isDefault, + 'currency_id': _currencyId, + }; + + if (widget.pettyCash == null) { + await _service.create(businessId: widget.businessId, payload: payload); + } else { + await _service.update(id: widget.pettyCash!.id!, payload: payload); + } + + if (mounted) { + Navigator.of(context).pop(true); // Return true to indicate success + widget.onSuccess?.call(); + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.pettyCash == null + ? (t.localeName == 'fa' ? 'تنخواه گردان با موفقیت ایجاد شد' : 'Petty cash created successfully') + : (t.localeName == 'fa' ? 'تنخواه گردان با موفقیت به‌روزرسانی شد' : 'Petty cash updated successfully') + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.error}: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) { + setState(() { _isLoading = false; }); + } + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final isEditing = widget.pettyCash != null; + + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + height: MediaQuery.of(context).size.height * 0.9, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Row( + children: [ + Icon(isEditing ? Icons.edit : Icons.add, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + isEditing ? (t.localeName == 'fa' ? 'ویرایش تنخواه گردان' : 'Edit Petty Cash') : (t.localeName == 'fa' ? 'افزودن تنخواه گردان' : 'Add Petty Cash'), + style: Theme.of(context).textTheme.headlineSmall, + ), + const Spacer(), + IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close)), + ], + ), + const Divider(), + const SizedBox(height: 16), + Expanded( + child: DefaultTabController( + length: 2, + child: Form( + key: _formKey, + child: Column( + children: [ + TabBar(isScrollable: true, tabs: [ + Tab(text: t.title), + Tab(text: t.settings), + ]), + const SizedBox(height: 12), + Expanded( + child: TabBarView( + children: [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildBasicInfo(t), + ), + ), + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildSettings(t), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + const Divider(), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: _isLoading ? null : () => Navigator.of(context).pop(), child: Text(t.cancel)), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _isLoading ? null : _save, + child: _isLoading + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : Text(isEditing ? t.update : t.add), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ); + } + + Widget _buildBasicInfo(AppLocalizations t) { + return Column( + children: [ + _buildSectionHeader(t.title), + const SizedBox(height: 16), + TextFormField( + controller: _nameController, + decoration: InputDecoration(labelText: t.title, hintText: t.title), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return t.title; + } + return null; + }, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _codeController, + readOnly: _autoGenerateCode, + decoration: InputDecoration( + labelText: t.code, + hintText: t.uniqueCodeNumeric, + suffixIcon: Container( + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: ToggleButtons( + isSelected: [_autoGenerateCode, !_autoGenerateCode], + borderRadius: BorderRadius.circular(6), + constraints: const BoxConstraints(minHeight: 32, minWidth: 64), + onPressed: (index) { + setState(() { _autoGenerateCode = (index == 0); }); + }, + children: [ + Padding(padding: const EdgeInsets.symmetric(horizontal: 6), child: Text(t.automatic)), + Padding(padding: const EdgeInsets.symmetric(horizontal: 6), child: Text(t.manual)), + ], + ), + ), + ), + keyboardType: TextInputType.text, + validator: (value) { + if (!_autoGenerateCode) { + if (value == null || value.trim().isEmpty) { + return t.personCodeRequired; + } + if (value.trim().length < 3) { + return t.passwordMinLength; // fallback + } + if (!RegExp(r'^\d+$').hasMatch(value.trim())) { + return t.codeMustBeNumeric; + } + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + CurrencyPickerWidget( + businessId: widget.businessId, + selectedCurrencyId: _currencyId, + onChanged: (value) { setState(() { _currencyId = value; }); }, + label: t.currency, + hintText: t.currency, + ), + const SizedBox(height: 16), + TextFormField( + controller: _descriptionController, + decoration: InputDecoration(labelText: t.description, hintText: t.description), + maxLines: 3, + ), + ], + ); + } + + Widget _buildSettings(AppLocalizations t) { + return Column( + children: [ + _buildSectionHeader(t.settings), + const SizedBox(height: 16), + SwitchListTile( + title: Text(t.active), + subtitle: Text(t.active), + value: _isActive, + onChanged: (value) { setState(() { _isActive = value; }); }, + ), + const SizedBox(height: 8), + SwitchListTile( + title: Text(t.isDefault), + subtitle: Text(t.defaultConfiguration), + value: _isDefault, + onChanged: (value) { setState(() { _isDefault = value; }); }, + ), + ], + ); + } +} + \ No newline at end of file diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup deleted file mode 100644 index 9763e93..0000000 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup +++ /dev/null @@ -1,1641 +0,0 @@ -import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'helpers/file_saver.dart'; -// import 'dart:html' as html; // Not available on Linux -import 'package:flutter/material.dart'; -import 'package:data_table_2/data_table_2.dart'; -import 'package:dio/dio.dart'; -import 'package:hesabix_ui/l10n/app_localizations.dart'; -import 'package:hesabix_ui/core/api_client.dart'; -import 'package:hesabix_ui/core/calendar_controller.dart'; -import 'data_table_config.dart'; -import 'data_table_search_dialog.dart'; -import 'column_settings_dialog.dart'; -import 'helpers/data_table_utils.dart'; -import 'helpers/column_settings_service.dart'; - -/// Main reusable data table widget -class DataTableWidget extends StatefulWidget { - final DataTableConfig config; - final T Function(Map) fromJson; - final CalendarController? calendarController; - final VoidCallback? onRefresh; - - const DataTableWidget({ - super.key, - required this.config, - required this.fromJson, - this.calendarController, - this.onRefresh, - }); - - @override - State> createState() => _DataTableWidgetState(); -} - -class _DataTableWidgetState extends State> { - // Data state - List _items = []; - bool _loadingList = false; - String? _error; - - // Pagination state - int _page = 1; - int _limit = 20; - int _total = 0; - int _totalPages = 0; - - // Search state - final TextEditingController _searchCtrl = TextEditingController(); - Timer? _searchDebounce; - - // Column search state - final Map _columnSearchValues = {}; - final Map _columnSearchTypes = {}; - final Map _columnSearchControllers = {}; - - // Enhanced filter state - final Map> _columnMultiSelectValues = {}; - final Map _columnDateFromValues = {}; - final Map _columnDateToValues = {}; - - // Sorting state - String? _sortBy; - bool _sortDesc = false; - - // Row selection state - final Set _selectedRows = {}; - bool _isExporting = false; - - // Column settings state - ColumnSettings? _columnSettings; - List _visibleColumns = []; - bool _isLoadingColumnSettings = false; - - // Scroll controller for horizontal scrolling - late ScrollController _horizontalScrollController; - - @override - void initState() { - super.initState(); - _horizontalScrollController = ScrollController(); - _limit = widget.config.defaultPageSize; - _setupSearchListener(); - _loadColumnSettings(); - _fetchData(); - } - - /// Public method to refresh the data table - void refresh() { - _fetchData(); - } - - @override - void dispose() { - _searchCtrl.dispose(); - _searchDebounce?.cancel(); - _horizontalScrollController.dispose(); - for (var controller in _columnSearchControllers.values) { - controller.dispose(); - } - super.dispose(); - } - - void _setupSearchListener() { - _searchCtrl.addListener(() { - _searchDebounce?.cancel(); - _searchDebounce = Timer(widget.config.searchDebounce ?? const Duration(milliseconds: 500), () { - _page = 1; - _fetchData(); - }); - }); - } - - Future _loadColumnSettings() async { - if (!widget.config.enableColumnSettings) { - _visibleColumns = List.from(widget.config.columns); - return; - } - - setState(() { - _isLoadingColumnSettings = true; - }); - - try { - final tableId = widget.config.effectiveTableId; - final savedSettings = await ColumnSettingsService.getColumnSettings(tableId); - - ColumnSettings effectiveSettings; - if (savedSettings != null) { - effectiveSettings = ColumnSettingsService.mergeWithDefaults( - savedSettings, - widget.config.columnKeys, - ); - } else if (widget.config.initialColumnSettings != null) { - effectiveSettings = ColumnSettingsService.mergeWithDefaults( - widget.config.initialColumnSettings, - widget.config.columnKeys, - ); - } else { - effectiveSettings = ColumnSettingsService.getDefaultSettings(widget.config.columnKeys); - } - - setState(() { - _columnSettings = effectiveSettings; - _visibleColumns = _getVisibleColumnsFromSettings(effectiveSettings); - }); - } catch (e) { - debugPrint('Error loading column settings: $e'); - setState(() { - _visibleColumns = List.from(widget.config.columns); - }); - } finally { - setState(() { - _isLoadingColumnSettings = false; - }); - } - } - - List _getVisibleColumnsFromSettings(ColumnSettings settings) { - final visibleColumns = []; - - // Add columns in the order specified by settings - for (final key in settings.columnOrder) { - final column = widget.config.getColumnByKey(key); - if (column != null && settings.visibleColumns.contains(key)) { - visibleColumns.add(column); - } - } - - return visibleColumns; - } - - Future _fetchData() async { - setState(() => _loadingList = true); - _error = null; - - try { - final api = ApiClient(); - - // Build QueryInfo payload - final queryInfo = QueryInfo( - take: _limit, - skip: (_page - 1) * _limit, - sortDesc: _sortDesc, - sortBy: _sortBy, - search: _searchCtrl.text.trim().isNotEmpty ? _searchCtrl.text.trim() : null, - searchFields: widget.config.searchFields.isNotEmpty ? widget.config.searchFields : null, - filters: _buildFilters(), - ); - - // Add additional parameters - final requestData = queryInfo.toJson(); - if (widget.config.additionalParams != null) { - requestData.addAll(widget.config.additionalParams!); - } - - final res = await api.post>(widget.config.endpoint, data: requestData); - final body = res.data; - - if (body is Map) { - final response = DataTableResponse.fromJson(body, widget.fromJson); - - setState(() { - _items = response.items; - _page = response.page; - _limit = response.limit; - _total = response.total; - _totalPages = response.totalPages; - _selectedRows.clear(); // Clear selection when data changes - }); - - // Call the refresh callback if provided - if (widget.onRefresh != null) { - widget.onRefresh!(); - } else if (widget.config.onRefresh != null) { - widget.config.onRefresh!(); - } - } - } catch (e) { - setState(() { - _error = e.toString(); - }); - } finally { - setState(() => _loadingList = false); - } - } - - List _buildFilters() { - final filters = []; - - // Text search filters - for (var entry in _columnSearchValues.entries) { - final columnName = entry.key; - final searchValue = entry.value.trim(); - final searchType = _columnSearchTypes[columnName] ?? '*'; - - if (searchValue.isNotEmpty) { - filters.add(DataTableUtils.createColumnFilter( - columnName, - searchValue, - searchType, - )); - } - } - - // Multi-select filters - for (var entry in _columnMultiSelectValues.entries) { - final columnName = entry.key; - final selectedValues = entry.value; - - if (selectedValues.isNotEmpty) { - filters.add(DataTableUtils.createMultiSelectFilter( - columnName, - selectedValues, - )); - } - } - - // Date range filters - for (var entry in _columnDateFromValues.entries) { - final columnName = entry.key; - final fromDate = entry.value; - final toDate = _columnDateToValues[columnName]; - - if (fromDate != null && toDate != null) { - filters.addAll(DataTableUtils.createDateRangeFilter( - columnName, - fromDate, - toDate, - )); - } - } - - return filters; - } - - void _openColumnSearchDialog(String columnName, String columnLabel) { - // Get column configuration - final column = widget.config.getColumnByKey(columnName); - final filterType = column?.filterType; - final filterOptions = column?.filterOptions; - - // Initialize controller if not exists - if (!_columnSearchControllers.containsKey(columnName)) { - _columnSearchControllers[columnName] = TextEditingController( - text: _columnSearchValues[columnName] ?? '', - ); - } - - // Initialize search type if not exists - _columnSearchTypes[columnName] ??= '*'; - - showDialog( - context: context, - builder: (context) => DataTableSearchDialog( - columnName: columnName, - columnLabel: columnLabel, - searchValue: _columnSearchValues[columnName] ?? '', - searchType: _columnSearchTypes[columnName] ?? '*', - filterType: filterType, - filterOptions: filterOptions, - calendarController: widget.calendarController, - onApply: (value, type) { - setState(() { - _columnSearchValues[columnName] = value; - _columnSearchTypes[columnName] = type; - }); - _page = 1; - _fetchData(); - }, - onApplyMultiSelect: (values) { - setState(() { - _columnMultiSelectValues[columnName] = values; - }); - _page = 1; - _fetchData(); - }, - onApplyDateRange: (fromDate, toDate) { - setState(() { - _columnDateFromValues[columnName] = fromDate; - _columnDateToValues[columnName] = toDate; - }); - _page = 1; - _fetchData(); - }, - onClear: () { - setState(() { - _columnSearchValues.remove(columnName); - _columnSearchTypes.remove(columnName); - _columnMultiSelectValues.remove(columnName); - _columnDateFromValues.remove(columnName); - _columnDateToValues.remove(columnName); - _columnSearchControllers[columnName]?.clear(); - }); - _page = 1; - _fetchData(); - }, - ), - ); - } - - - bool _hasActiveFilters() { - return _searchCtrl.text.isNotEmpty || - _columnSearchValues.isNotEmpty || - _columnMultiSelectValues.isNotEmpty || - _columnDateFromValues.isNotEmpty; - } - - void _clearAllFilters() { - setState(() { - _searchCtrl.clear(); - _sortBy = null; - _sortDesc = false; - _columnSearchValues.clear(); - _columnSearchTypes.clear(); - _columnMultiSelectValues.clear(); - _columnDateFromValues.clear(); - _columnDateToValues.clear(); - _selectedRows.clear(); - for (var controller in _columnSearchControllers.values) { - controller.clear(); - } - }); - _page = 1; - _fetchData(); - // Call the callback if provided - if (widget.config.onRowSelectionChanged != null) { - widget.config.onRowSelectionChanged!(_selectedRows); - } - } - - void _sortByColumn(String column) { - setState(() { - if (_sortBy == column) { - _sortDesc = !_sortDesc; - } else { - _sortBy = column; - _sortDesc = false; - } - }); - _fetchData(); - } - - void _toggleRowSelection(int rowIndex) { - if (!widget.config.enableRowSelection) return; - - setState(() { - if (widget.config.enableMultiRowSelection) { - if (_selectedRows.contains(rowIndex)) { - _selectedRows.remove(rowIndex); - } else { - _selectedRows.add(rowIndex); - } - } else { - _selectedRows.clear(); - _selectedRows.add(rowIndex); - } - }); - - if (widget.config.onRowSelectionChanged != null) { - widget.config.onRowSelectionChanged!(_selectedRows); - } - } - - void _selectAllRows() { - if (!widget.config.enableRowSelection || !widget.config.enableMultiRowSelection) return; - - setState(() { - _selectedRows.clear(); - for (int i = 0; i < _items.length; i++) { - _selectedRows.add(i); - } - }); - - if (widget.config.onRowSelectionChanged != null) { - widget.config.onRowSelectionChanged!(_selectedRows); - } - } - - void _clearRowSelection() { - if (!widget.config.enableRowSelection) return; - - setState(() { - _selectedRows.clear(); - }); - - if (widget.config.onRowSelectionChanged != null) { - widget.config.onRowSelectionChanged!(_selectedRows); - } - } - - Future _openColumnSettingsDialog() async { - if (!widget.config.enableColumnSettings || _columnSettings == null) return; - - final result = await showDialog( - context: context, - builder: (context) => ColumnSettingsDialog( - columns: widget.config.columns, - currentSettings: _columnSettings!, - tableTitle: widget.config.title ?? 'Table', - ), - ); - - if (result != null) { - await _saveColumnSettings(result); - } - } - - Future _saveColumnSettings(ColumnSettings settings) async { - if (!widget.config.enableColumnSettings) return; - - try { - // Ensure at least one column is visible - final validatedSettings = _validateColumnSettings(settings); - - final tableId = widget.config.effectiveTableId; - await ColumnSettingsService.saveColumnSettings(tableId, validatedSettings); - - setState(() { - _columnSettings = validatedSettings; - _visibleColumns = _getVisibleColumnsFromSettings(validatedSettings); - }); - - // Call the callback if provided - if (widget.config.onColumnSettingsChanged != null) { - widget.config.onColumnSettingsChanged!(validatedSettings); - } - } catch (e) { - debugPrint('Error saving column settings: $e'); - if (mounted) { - final t = Localizations.of(context, AppLocalizations)!; - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar( - content: Text('${t.error}: $e'), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - } - } - } - - ColumnSettings _validateColumnSettings(ColumnSettings settings) { - // Ensure at least one column is visible - if (settings.visibleColumns.isEmpty && widget.config.columns.isNotEmpty) { - return settings.copyWith( - visibleColumns: [widget.config.columns.first.key], - columnOrder: [widget.config.columns.first.key], - ); - } - return settings; - } - - Future _exportData(String format, bool selectedOnly) async { - if (widget.config.excelEndpoint == null && widget.config.pdfEndpoint == null) { - return; - } - - final t = Localizations.of(context, AppLocalizations)!; - - setState(() { - _isExporting = true; - }); - - try { - final api = ApiClient(); - final endpoint = format == 'excel' - ? widget.config.excelEndpoint! - : widget.config.pdfEndpoint!; - - // Build QueryInfo object - final filters = >[]; - - // Add column filters - _columnSearchValues.forEach((column, value) { - if (value.isNotEmpty) { - final searchType = _columnSearchTypes[column] ?? 'contains'; - String operator; - switch (searchType) { - case 'contains': - operator = '*'; - break; - case 'startsWith': - operator = '*?'; - break; - case 'endsWith': - operator = '?*'; - break; - case 'exactMatch': - operator = '='; - break; - default: - operator = '*'; - } - filters.add({ - 'property': column, - 'operator': operator, - 'value': value, - }); - } - }); - - - final queryInfo = { - 'sort_by': _sortBy, - 'sort_desc': _sortDesc, - 'take': _limit, - 'skip': (_page - 1) * _limit, - 'search': _searchCtrl.text.isNotEmpty ? _searchCtrl.text : null, - 'search_fields': _searchCtrl.text.isNotEmpty && widget.config.searchFields.isNotEmpty - ? widget.config.searchFields - : null, - 'filters': filters.isNotEmpty ? filters : null, - }; - - final params = { - 'selected_only': selectedOnly, - }; - - // Add selected row indices if exporting selected only - if (selectedOnly && _selectedRows.isNotEmpty) { - params['selected_indices'] = _selectedRows.toList(); - } - - // Add export columns in current visible order (excluding ActionColumn) - final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty - ? _visibleColumns - : widget.config.columns; - final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); - params['export_columns'] = dataColumnsToShow.map((c) => { - 'key': c.key, - 'label': c.label, - }).toList(); - - // Add custom export parameters if provided - if (widget.config.getExportParams != null) { - final customParams = widget.config.getExportParams!(); - params.addAll(customParams); - } - - final response = await api.post( - endpoint, - data: { - ...queryInfo, - ...params, - }, - options: Options( - headers: { - 'X-Calendar-Type': 'jalali', // Send Jalali calendar type - 'Accept-Language': Localizations.localeOf(context).languageCode, // Send locale - }, - ), - responseType: ResponseType.bytes, // Both PDF and Excel now return binary data - ); - - if (response.data != null) { - // Determine filename from Content-Disposition header if present - String? contentDisposition = response.headers.value('content-disposition'); - String filename = 'export_${DateTime.now().millisecondsSinceEpoch}.${format == 'pdf' ? 'pdf' : 'xlsx'}'; - if (contentDisposition != null) { - try { - final parts = contentDisposition.split(';').map((s) => s.trim()); - for (final p in parts) { - if (p.toLowerCase().startsWith('filename=')) { - var name = p.substring('filename='.length).trim(); - if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) { - name = name.substring(1, name.length - 1); - } - if (name.isNotEmpty) { - filename = name; - } - break; - } - } - } catch (_) { - // Fallback to default filename - } - } - final expectedExt = format == 'pdf' ? '.pdf' : '.xlsx'; - if (!filename.toLowerCase().endsWith(expectedExt)) { - filename = '$filename$expectedExt'; - } - - if (format == 'pdf') { - await _downloadPdf(response.data, filename); - } else if (format == 'excel') { - await _downloadExcel(response.data, filename); - } - - if (mounted) { - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar( - content: Text(t.exportSuccess), - backgroundColor: Theme.of(context).colorScheme.primary, - ), - ); - } - } - } catch (e) { - if (mounted) { - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar( - content: Text('${t.exportError}: $e'), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - } - } finally { - if (mounted) { - setState(() { - _isExporting = false; - }); - } - } - } - - // Cross-platform save using conditional FileSaver - Future _saveBytesToDownloads(dynamic data, String filename) async { - List bytes; - if (data is List) { - bytes = data; - } else if (data is Uint8List) { - bytes = data.toList(); - } else { - throw Exception('Unsupported binary data type: ${data.runtimeType}'); - } - await FileSaver.saveBytes(bytes, filename); - } - - Future _downloadPdf(dynamic data, String filename) async { - await _saveBytesToDownloads(data, filename); - } - - Future _downloadExcel(dynamic data, String filename) async { - await _saveBytesToDownloads(data, filename); - } - - - @override - Widget build(BuildContext context) { - final t = Localizations.of(context, AppLocalizations)!; - final theme = Theme.of(context); - - return Card( - elevation: widget.config.boxShadow != null ? 2 : 0, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( - borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), - ), - child: Container( - padding: widget.config.padding ?? const EdgeInsets.all(16), - margin: widget.config.margin, - decoration: BoxDecoration( - color: widget.config.backgroundColor, - borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), - border: widget.config.showBorder - ? Border.all( - color: widget.config.borderColor ?? theme.dividerColor, - width: widget.config.borderWidth ?? 1.0, - ) - : null, - ), - child: Column( - children: [ - // Header - if (widget.config.title != null) ...[ - _buildHeader(t, theme), - const SizedBox(height: 16), - ], - - // Search - if (widget.config.showSearch) ...[ - _buildSearch(t, theme), - const SizedBox(height: 12), - ], - - // Active Filters - if (widget.config.showActiveFilters) ...[ - ActiveFiltersWidget( - columnSearchValues: _columnSearchValues, - columnSearchTypes: _columnSearchTypes, - columnMultiSelectValues: _columnMultiSelectValues, - columnDateFromValues: _columnDateFromValues, - columnDateToValues: _columnDateToValues, - fromDate: null, - toDate: null, - columns: widget.config.columns, - calendarController: widget.calendarController, - onRemoveColumnFilter: (columnName) { - setState(() { - _columnSearchValues.remove(columnName); - _columnSearchTypes.remove(columnName); - _columnMultiSelectValues.remove(columnName); - _columnDateFromValues.remove(columnName); - _columnDateToValues.remove(columnName); - _columnSearchControllers[columnName]?.clear(); - }); - _page = 1; - _fetchData(); - }, - onClearAll: _clearAllFilters, - ), - const SizedBox(height: 10), - ], - - // Data Table - Expanded( - child: _buildDataTable(t, theme), - ), - - // Footer with Pagination - if (widget.config.showPagination) ...[ - const SizedBox(height: 12), - _buildFooter(t, theme), - ], - ], - ), - ), - ); - } - - Widget _buildHeader(AppLocalizations t, ThemeData theme) { - return Row( - children: [ - if (widget.config.showBackButton) ...[ - Tooltip( - message: MaterialLocalizations.of(context).backButtonTooltip, - child: IconButton( - onPressed: widget.config.onBack ?? () { - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - }, - icon: const Icon(Icons.arrow_back), - ), - ), - const SizedBox(width: 8), - ], - if (widget.config.showTableIcon) ...[ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.table_chart, - color: theme.colorScheme.onPrimaryContainer, - size: 18, - ), - ), - const SizedBox(width: 12), - ], - Text( - widget.config.title!, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurface, - ), - ), - if (widget.config.subtitle != null) ...[ - const SizedBox(width: 8), - Text( - widget.config.subtitle!, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - const Spacer(), - - - // Clear filters button (only show when filters are applied) - if (widget.config.showClearFiltersButton && _hasActiveFilters()) ...[ - Tooltip( - message: t.clear, - child: IconButton( - onPressed: _clearAllFilters, - icon: const Icon(Icons.clear_all), - tooltip: t.clear, - ), - ), - const SizedBox(width: 4), - ], - - // Export buttons - if (widget.config.excelEndpoint != null || widget.config.pdfEndpoint != null) ...[ - _buildExportButtons(t, theme), - const SizedBox(width: 8), - ], - - // Custom header actions - if (widget.config.customHeaderActions != null) ...[ - const SizedBox(width: 8), - ...widget.config.customHeaderActions!, - ], - - // Actions menu - PopupMenuButton( - icon: const Icon(Icons.more_vert), - tooltip: 'عملیات', - onSelected: (value) { - switch (value) { - case 'refresh': - _fetchData(); - break; - case 'columnSettings': - _openColumnSettingsDialog(); - break; - } - }, - itemBuilder: (context) => [ - if (widget.config.showRefreshButton) - PopupMenuItem( - value: 'refresh', - child: Row( - children: [ - const Icon(Icons.refresh, size: 20), - const SizedBox(width: 8), - Text(t.refresh), - ], - ), - ), - if (widget.config.showColumnSettingsButton && widget.config.enableColumnSettings) - PopupMenuItem( - value: 'columnSettings', - enabled: !_isLoadingColumnSettings, - child: Row( - children: [ - _isLoadingColumnSettings - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.view_column, size: 20), - const SizedBox(width: 8), - Text(t.columnSettings), - ], - ), - ), - ], - ), - ], - ); - } - - Widget _buildExportButtons(AppLocalizations t, ThemeData theme) { - return _buildExportButton(t, theme); - } - - Widget _buildExportButton( - AppLocalizations t, - ThemeData theme, - ) { - return Tooltip( - message: t.export, - child: GestureDetector( - onTap: () => _showExportOptions(t, theme), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), - ), - child: _isExporting - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - theme.colorScheme.primary, - ), - ), - ) - : Icon( - Icons.download, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ); - } - - void _showExportOptions( - AppLocalizations t, - ThemeData theme, - ) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (context) => Container( - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Handle bar - Container( - width: 40, - height: 4, - margin: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - - // Title - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Icon(Icons.download, color: theme.colorScheme.primary, size: 20), - const SizedBox(width: 8), - Text( - t.export, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - - const Divider(height: 1), - - // Excel options - if (widget.config.excelEndpoint != null) ...[ - ListTile( - leading: Icon(Icons.table_chart, color: Colors.green[600]), - title: Text(t.exportToExcel), - subtitle: Text(t.exportAll), - onTap: () { - Navigator.pop(context); - _exportData('excel', false); - }, - ), - - if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) - ListTile( - leading: Icon(Icons.table_chart, color: theme.colorScheme.primary), - title: Text(t.exportToExcel), - subtitle: Text(t.exportSelected), - onTap: () { - Navigator.pop(context); - _exportData('excel', true); - }, - ), - ], - - // PDF options - if (widget.config.pdfEndpoint != null) ...[ - if (widget.config.excelEndpoint != null) const Divider(height: 1), - - ListTile( - leading: Icon(Icons.picture_as_pdf, color: Colors.red[600]), - title: Text(t.exportToPdf), - subtitle: Text(t.exportAll), - onTap: () { - Navigator.pop(context); - _exportData('pdf', false); - }, - ), - - if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) - ListTile( - leading: Icon(Icons.picture_as_pdf, color: theme.colorScheme.primary), - title: Text(t.exportToPdf), - subtitle: Text(t.exportSelected), - onTap: () { - Navigator.pop(context); - _exportData('pdf', true); - }, - ), - ], - - const SizedBox(height: 16), - ], - ), - ), - ); - } - - Widget _buildFooter(AppLocalizations t, ThemeData theme) { - // Always show footer if pagination is enabled - - return Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: theme.dividerColor.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - // Results info - Text( - '${t.showing} ${((_page - 1) * _limit) + 1} ${t.to} ${(_page * _limit).clamp(0, _total)} ${t.ofText} $_total ${t.results}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const Spacer(), - - // Page size selector - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - t.recordsPerPage, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - DropdownButton( - value: _limit, - items: widget.config.pageSizeOptions.map((size) { - return DropdownMenuItem( - value: size, - child: Text(size.toString()), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _limit = value; - _page = 1; - }); - _fetchData(); - } - }, - style: theme.textTheme.bodySmall, - underline: const SizedBox.shrink(), - isDense: true, - ), - ], - ), - const SizedBox(width: 16), - - // Pagination controls (only show if more than 1 page) - if (_totalPages > 1) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - // First page - IconButton( - onPressed: _page > 1 ? () { - setState(() => _page = 1); - _fetchData(); - } : null, - icon: const Icon(Icons.first_page), - iconSize: 20, - tooltip: t.firstPage, - ), - - // Previous page - IconButton( - onPressed: _page > 1 ? () { - setState(() => _page--); - _fetchData(); - } : null, - icon: const Icon(Icons.chevron_left), - iconSize: 20, - tooltip: t.previousPage, - ), - - // Page numbers - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '$_page / $_totalPages', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ), - - // Next page - IconButton( - onPressed: _page < _totalPages ? () { - setState(() => _page++); - _fetchData(); - } : null, - icon: const Icon(Icons.chevron_right), - iconSize: 20, - tooltip: t.nextPage, - ), - - // Last page - IconButton( - onPressed: _page < _totalPages ? () { - setState(() => _page = _totalPages); - _fetchData(); - } : null, - icon: const Icon(Icons.last_page), - iconSize: 20, - tooltip: t.lastPage, - ), - ], - ), - ], - ), - ); - } - - Widget _buildSearch(AppLocalizations t, ThemeData theme) { - return Row( - children: [ - Expanded( - child: TextField( - controller: _searchCtrl, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.search, size: 18), - hintText: t.searchInNameEmail, - border: const OutlineInputBorder(), - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - ), - ), - ), - ], - ); - } - - Widget _buildDataTable(AppLocalizations t, ThemeData theme) { - if (_loadingList) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (widget.config.loadingWidget != null) - widget.config.loadingWidget! - else - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - widget.config.loadingMessage ?? t.loading, - style: theme.textTheme.bodyMedium, - ), - ], - ), - ); - } - - if (_error != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (widget.config.errorWidget != null) - widget.config.errorWidget! - else - Icon( - Icons.error_outline, - size: 64, - color: theme.colorScheme.error, - ), - const SizedBox(height: 16), - Text( - widget.config.errorMessage ?? t.dataLoadingError, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.error, - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _fetchData, - icon: const Icon(Icons.refresh), - label: Text(t.refresh), - ), - ], - ), - ); - } - - if (_items.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (widget.config.emptyStateWidget != null) - widget.config.emptyStateWidget! - else - Icon( - Icons.inbox_outlined, - size: 64, - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), - ), - const SizedBox(height: 16), - Text( - widget.config.emptyStateMessage ?? t.noDataFound, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), - ), - ), - ], - ), - ); - } - - // Build columns list - final List columns = []; - - // Add selection column if enabled (first) - if (widget.config.enableRowSelection) { - columns.add(DataColumn2( - label: widget.config.enableMultiRowSelection - ? Checkbox( - value: _selectedRows.length == _items.length && _items.isNotEmpty, - tristate: true, - onChanged: (value) { - if (value == true) { - _selectAllRows(); - } else { - _clearRowSelection(); - } - }, - ) - : const SizedBox.shrink(), - size: ColumnSize.S, - fixedWidth: 50.0, - )); - } - - // Add row number column if enabled (second) - if (widget.config.showRowNumbers) { - columns.add(DataColumn2( - label: Text( - '#', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurface, - ), - ), - size: ColumnSize.S, - fixedWidth: 60.0, - )); - } - - // Resolve action column (if defined in config) - ActionColumn? actionColumn; - for (final c in widget.config.columns) { - if (c is ActionColumn) { - actionColumn = c; - break; - } - } - - // Fixed action column immediately after selection and row number columns - if (actionColumn != null) { - columns.add(DataColumn2( - label: Text( - actionColumn.label, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurface, - ), - overflow: TextOverflow.ellipsis, - ), - size: ColumnSize.S, - fixedWidth: 80.0, - )); - } - - // Add data columns (use visible columns if column settings are enabled), excluding ActionColumn - final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty - ? _visibleColumns - : widget.config.columns; - final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); - - columns.addAll(dataColumnsToShow.map((column) { - return DataColumn2( - label: _ColumnHeaderWithSearch( - text: column.label, - sortBy: column.key, - currentSort: _sortBy, - sortDesc: _sortDesc, - onSort: widget.config.enableSorting ? _sortByColumn : (_) { }, - onSearch: widget.config.showColumnSearch && column.searchable - ? () => _openColumnSearchDialog(column.key, column.label) - : () { }, - hasActiveFilter: _columnSearchValues.containsKey(column.key), - enabled: widget.config.enableSorting && column.sortable, - ), - size: DataTableUtils.getColumnSize(column.width), - fixedWidth: DataTableUtils.getColumnWidth(column.width), - ); - })); - - return Scrollbar( - controller: _horizontalScrollController, - thumbVisibility: true, - child: DataTable2( - columnSpacing: 8, - horizontalMargin: 8, - minWidth: widget.config.minTableWidth ?? 600, - horizontalScrollController: _horizontalScrollController, - columns: columns, - rows: _items.asMap().entries.map((entry) { - final index = entry.key; - final item = entry.value; - final isSelected = _selectedRows.contains(index); - - // Build cells list - final List cells = []; - - // Add selection cell if enabled (first) - if (widget.config.enableRowSelection) { - cells.add(DataCell( - Checkbox( - value: isSelected, - onChanged: (value) => _toggleRowSelection(index), - ), - )); - } - - // Add row number cell if enabled (second) - if (widget.config.showRowNumbers) { - cells.add(DataCell( - Text( - '${((_page - 1) * _limit) + index + 1}', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - )); - } - - // 3) Fixed action cell (immediately after selection and row number) - // Resolve action column once (same logic as header) - ActionColumn? actionColumn; - for (final c in widget.config.columns) { - if (c is ActionColumn) { - actionColumn = c; - break; - } - } - if (actionColumn != null) { - cells.add(DataCell( - _buildActionButtons(item, actionColumn), - )); - } - - // 4) Add data cells - if (widget.config.customRowBuilder != null) { - cells.add(DataCell( - widget.config.customRowBuilder!(item) ?? const SizedBox.shrink(), - )); - } else { - final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty - ? _visibleColumns - : widget.config.columns; - final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); - - cells.addAll(dataColumnsToShow.map((column) { - return DataCell( - _buildCellContent(item, column, index), - ); - })); - } - - return DataRow2( - selected: isSelected, - onTap: widget.config.onRowTap != null - ? () => widget.config.onRowTap!(item) - : null, - onDoubleTap: widget.config.onRowDoubleTap != null - ? () => widget.config.onRowDoubleTap!(item) - : null, - cells: cells, - ); - }).toList(), - ), - ); - } - - Widget _buildCellContent(dynamic item, DataTableColumn column, int index) { - // 1) Custom widget builder takes precedence - if (column is CustomColumn && column.builder != null) { - return column.builder!(item, index); - } - - // 2) Action column - if (column is ActionColumn) { - return _buildActionButtons(item, column); - } - - // 3) If a formatter is provided on the column, call it with the full item - // This allows working with strongly-typed objects (not just Map) - if (column is TextColumn && column.formatter != null) { - final text = column.formatter!(item) ?? ''; - return Text( - text, - textAlign: _getTextAlign(column), - maxLines: _getMaxLines(column), - overflow: _getOverflow(column), - ); - } - if (column is NumberColumn && column.formatter != null) { - final text = column.formatter!(item) ?? ''; - return Text( - text, - textAlign: _getTextAlign(column), - maxLines: _getMaxLines(column), - overflow: _getOverflow(column), - ); - } - if (column is DateColumn && column.formatter != null) { - final text = column.formatter!(item) ?? ''; - return Text( - text, - textAlign: _getTextAlign(column), - maxLines: _getMaxLines(column), - overflow: _getOverflow(column), - ); - } - - // 4) Fallback: get property value from Map items by key - final value = DataTableUtils.getCellValue(item, column.key); - final formattedValue = DataTableUtils.formatCellValue(value, column); - return Text( - formattedValue, - textAlign: _getTextAlign(column), - maxLines: _getMaxLines(column), - overflow: _getOverflow(column), - ); - } - - Widget _buildActionButtons(dynamic item, ActionColumn column) { - if (column.actions.isEmpty) return const SizedBox.shrink(); - - return PopupMenuButton( - tooltip: column.label, - icon: const Icon(Icons.more_vert, size: 20), - onSelected: (index) { - final action = column.actions[index]; - if (action.enabled) action.onTap(item); - }, - itemBuilder: (context) { - return List.generate(column.actions.length, (index) { - final action = column.actions[index]; - return PopupMenuItem( - value: index, - enabled: action.enabled, - child: Row( - children: [ - Icon( - action.icon, - color: action.isDestructive - ? Theme.of(context).colorScheme.error - : (action.color ?? Theme.of(context).iconTheme.color), - size: 18, - ), - const SizedBox(width: 8), - Text(action.label), - ], - ), - ); - }); - }, - ); - } - - TextAlign _getTextAlign(DataTableColumn column) { - if (column is NumberColumn) return column.textAlign; - if (column is DateColumn) return column.textAlign; - if (column is TextColumn && column.textAlign != null) return column.textAlign!; - return TextAlign.start; - } - - int? _getMaxLines(DataTableColumn column) { - if (column is TextColumn) return column.maxLines; - return null; - } - - TextOverflow? _getOverflow(DataTableColumn column) { - if (column is TextColumn && column.overflow != null) { - return column.overflow! ? TextOverflow.ellipsis : null; - } - return null; - } -} - -/// Column header with search functionality -class _ColumnHeaderWithSearch extends StatelessWidget { - final String text; - final String sortBy; - final String? currentSort; - final bool sortDesc; - final Function(String) onSort; - final VoidCallback onSearch; - final bool hasActiveFilter; - final bool enabled; - - const _ColumnHeaderWithSearch({ - required this.text, - required this.sortBy, - required this.currentSort, - required this.sortDesc, - required this.onSort, - required this.onSearch, - required this.hasActiveFilter, - this.enabled = true, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isActive = currentSort == sortBy; - - return InkWell( - onTap: enabled ? () => onSort(sortBy) : null, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: SizedBox( - width: double.infinity, - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - text, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: isActive ? theme.colorScheme.primary : theme.colorScheme.onSurface, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (enabled) ...[ - const SizedBox(width: 4), - if (isActive) - Icon( - sortDesc ? Icons.arrow_downward : Icons.arrow_upward, - size: 16, - color: theme.colorScheme.primary, - ) - else - Icon( - Icons.unfold_more, - size: 16, - color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - // Search button - InkWell( - onTap: onSearch, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: hasActiveFilter - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: hasActiveFilter - ? Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.3)) - : null, - ), - child: Icon( - Icons.search, - size: 14, - color: hasActiveFilter - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ), - ], - ), - ), - ), - ); - } -}