From 5cc575e3d9ba8b8a3103d5d00bff6833a80debe8 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Fri, 3 Oct 2025 17:02:07 +0330 Subject: [PATCH] progress in bank and cashdesk --- .../adapters/api/v1/business_dashboard.py | 83 +++- hesabixAPI/adapters/api/v1/cash_registers.py | 414 ++++++++++++++++++ .../adapters/db/models/cash_register.py | 44 ++ .../repositories/business_permission_repo.py | 20 +- .../repositories/cash_register_repository.py | 125 ++++++ hesabixAPI/app/core/auth_dependency.py | 154 ++++++- hesabixAPI/app/core/permissions.py | 19 +- hesabixAPI/app/main.py | 2 + .../app/services/cash_register_service.py | 134 ++++++ hesabixAPI/hesabix.db | 0 hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 6 + hesabixAPI/locales/en/LC_MESSAGES/messages.mo | Bin 6527 -> 7529 bytes hesabixAPI/locales/en/LC_MESSAGES/messages.po | 28 ++ hesabixAPI/locales/fa/LC_MESSAGES/messages.mo | Bin 8667 -> 9947 bytes hesabixAPI/locales/fa/LC_MESSAGES/messages.po | 28 ++ ...20251002_000101_add_bank_accounts_table.py | 65 ++- ...0251003_000201_add_cash_registers_table.py | 54 +++ ...51003_010501_add_name_to_cash_registers.py | 52 +++ .../versions/a1443c153b47_merge_heads.py | 24 + hesabixUI/hesabix_ui/lib/core/auth_store.dart | 45 +- hesabixUI/hesabix_ui/lib/l10n/app_en.arb | 11 + hesabixUI/hesabix_ui/lib/l10n/app_fa.arb | 12 +- .../lib/l10n/app_localizations.dart | 20 +- .../lib/l10n/app_localizations_en.dart | 11 +- .../lib/l10n/app_localizations_fa.dart | 13 +- hesabixUI/hesabix_ui/lib/main.dart | 19 + .../hesabix_ui/lib/models/cash_register.dart | 67 +++ .../lib/pages/business/business_shell.dart | 106 ++++- .../pages/business/cash_registers_page.dart | 286 ++++++++++++ .../services/business_dashboard_service.dart | 87 +++- .../lib/services/cash_register_service.dart | 57 +++ .../banking/cash_register_form_dialog.dart | 370 ++++++++++++++++ 32 files changed, 2275 insertions(+), 81 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/cash_registers.py create mode 100644 hesabixAPI/adapters/db/models/cash_register.py create mode 100644 hesabixAPI/adapters/db/repositories/cash_register_repository.py create mode 100644 hesabixAPI/app/services/cash_register_service.py create mode 100644 hesabixAPI/hesabix.db create mode 100644 hesabixAPI/migrations/versions/20251003_000201_add_cash_registers_table.py create mode 100644 hesabixAPI/migrations/versions/20251003_010501_add_name_to_cash_registers.py create mode 100644 hesabixAPI/migrations/versions/a1443c153b47_merge_heads.py create mode 100644 hesabixUI/hesabix_ui/lib/models/cash_register.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/cash_register_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/banking/cash_register_form_dialog.dart diff --git a/hesabixAPI/adapters/api/v1/business_dashboard.py b/hesabixAPI/adapters/api/v1/business_dashboard.py index da722d2..35b7c68 100644 --- a/hesabixAPI/adapters/api/v1/business_dashboard.py +++ b/hesabixAPI/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/adapters/api/v1/cash_registers.py b/hesabixAPI/adapters/api/v1/cash_registers.py new file mode 100644 index 0000000..ad610a8 --- /dev/null +++ b/hesabixAPI/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/adapters/db/models/cash_register.py b/hesabixAPI/adapters/db/models/cash_register.py new file mode 100644 index 0000000..93672e4 --- /dev/null +++ b/hesabixAPI/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/adapters/db/repositories/business_permission_repo.py b/hesabixAPI/adapters/db/repositories/business_permission_repo.py index b364fa2..fbb502b 100644 --- a/hesabixAPI/adapters/db/repositories/business_permission_repo.py +++ b/hesabixAPI/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/adapters/db/repositories/cash_register_repository.py b/hesabixAPI/adapters/db/repositories/cash_register_repository.py new file mode 100644 index 0000000..28916e1 --- /dev/null +++ b/hesabixAPI/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/app/core/auth_dependency.py b/hesabixAPI/app/core/auth_dependency.py index c38b5ac..0711e2d 100644 --- a/hesabixAPI/app/core/auth_dependency.py +++ b/hesabixAPI/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/app/core/permissions.py b/hesabixAPI/app/core/permissions.py index ce5d65f..352aaa9 100644 --- a/hesabixAPI/app/core/permissions.py +++ b/hesabixAPI/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/app/main.py b/hesabixAPI/app/main.py index 93c5d78..05c918b 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -17,6 +17,7 @@ from adapters.api.v1.products import router as products_router from adapters.api.v1.price_lists import router as price_lists_router from adapters.api.v1.persons import router as persons_router from adapters.api.v1.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 @@ -294,6 +295,7 @@ def create_app() -> FastAPI: application.include_router(price_lists_router, prefix=settings.api_v1_prefix) application.include_router(persons_router, prefix=settings.api_v1_prefix) application.include_router(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) diff --git a/hesabixAPI/app/services/cash_register_service.py b/hesabixAPI/app/services/cash_register_service.py new file mode 100644 index 0000000..ac7453c --- /dev/null +++ b/hesabixAPI/app/services/cash_register_service.py @@ -0,0 +1,134 @@ +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]: + # 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": data.get("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 + + 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/hesabix.db b/hesabixAPI/hesabix.db new file mode 100644 index 0000000..e69de29 diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index bea8e82..e1082d3 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -9,6 +9,7 @@ adapters/api/v1/bank_accounts.py adapters/api/v1/business_dashboard.py adapters/api/v1/business_users.py adapters/api/v1/businesses.py +adapters/api/v1/cash_registers.py adapters/api/v1/categories.py adapters/api/v1/currencies.py adapters/api/v1/health.py @@ -47,6 +48,7 @@ adapters/db/models/bank_account.py adapters/db/models/business.py adapters/db/models/business_permission.py adapters/db/models/captcha.py +adapters/db/models/cash_register.py adapters/db/models/category.py adapters/db/models/currency.py adapters/db/models/document.py @@ -72,6 +74,7 @@ adapters/db/repositories/api_key_repo.py adapters/db/repositories/base_repo.py adapters/db/repositories/business_permission_repo.py adapters/db/repositories/business_repo.py +adapters/db/repositories/cash_register_repository.py adapters/db/repositories/category_repository.py adapters/db/repositories/email_config_repository.py adapters/db/repositories/file_storage_repository.py @@ -109,6 +112,7 @@ app/services/bulk_price_update_service.py app/services/business_dashboard_service.py app/services/business_service.py app/services/captcha_service.py +app/services/cash_register_service.py app/services/email_service.py app/services/file_storage_service.py app/services/person_service.py @@ -162,9 +166,11 @@ migrations/versions/20251001_000601_update_price_items_currency_unique_not_null. 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/4b2ea782bcb3_merge_heads.py migrations/versions/5553f8745c6e_add_support_tables.py migrations/versions/9f9786ae7191_create_tax_units_table.py +migrations/versions/a1443c153b47_merge_heads.py migrations/versions/caf3f4ef4b76_add_tax_units_table.py migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py migrations/versions/f876bfa36805_merge_multiple_heads.py diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo index 7ac2fb0503a3c31f3dc8a8408835c96d535c09b0..6a74195810643498f250d3b5f137fd063842e141 100644 GIT binary patch literal 7529 zcmeI0TZ|-C8OM*ZsEokEE-He`S&-#skFyguEVHg|Pj~ffvwgF=W(6^6Yr3mv%B`+y zs;YNpNK7Ck;*~_Rz7W<26QV&SNP-W_1ByY3!D}KK@cy7+FhoKK4{QAYPo3_rY6`MG z_~vA$e|>J>`M&Rb=km=TcJ26zz|R)`-i5zs&SGi$bMKplxB$gd;CsLaz)Qd~x7=xFBC&9OY4{AQH`3(3D)c;GD=gtx0LX;&) zIg2329|o@gKMw8zZwKEAehs9)kANKaG|2wXYo0SF#BP+Y1kVL;0Ivlr;ML%*n)iV3 zM)@)DTJX=h{@k|;aUIJ0LF#FP=Y!iI?fwk76Fdb{pKpV-^T(hBe-E_mOf2j2+p z1-V|=fSm6zNWWPiVGT9 z@ppi<>&qa=e+%ULJf_?K2vYACK!_ACgY?hY5JJ6of(Q+<3#7bD!Lz~lf$X;ka{LE% zxdc*99fT{yEg=1{0aD)WAVOH&2~zIoHBW+Ek5eG!egnj>cnBX@I`Ko0?LPxK-|uw! zPv8!e{{eEo7c@_U)OQXa9Cs(xgOus^}ht)i}H&g^|%;8!u_!q zycTSNmxH%yo&p&ckAn2?Z*=`X!FiM~#3Y>eFi5#;AmjfoUH?sx`aTIRf`0=sR9pg~ z>^~1OPTC;Ps{mxYeF3CC_kq;!7n-kt+`pH@D7GI4*?t_n4m=4`&qqP7!*4;({~XA3 zZU-7U{xXo|L!bn20->7N2C4tuAlKs|5TPm_0r4w-iVw>BD+pD@X^?VuLurnm2PwY< zvfT!0_a+Dx#Qh-S;Rm1ue*$v8XFZ0KWs$Pk#Xs0^)oO=6dV_CAc5tdL9G0K3gC{PMiX%_X8mP z@h~_C{s^SrKL@$beg)F+PlJr(=Rx}MMUdl9gB-U5i%q#ZLB_WPVS*@tl(zzM|K12v zt_f1kEg<98(d7uFoKNZcyFtpo7o?o8gWPBLgFC?Qf*k*S%_l(W`xMB2&w}*FOW;}H zIao~UeICeh7lE|@a*+M^>Gne)=PiS@yQ#Sfa@;MTD3(gi)rQq6SKGzqno$F74w_k&pW z9M6dzpl^@7Sat)y@2-zRJ9YzKl+q>X+XF}1Ug+4pZRu>eQ5>;{?{wK&#!kfXgCPa> zMA@hmS8LYn^p)-q{B=My&cl+mc$b)u+0^1SWbu3Z>>MjaDZo`FRAy&sJHUhaIM6Nj#I;!IQ< z4SQ##S|ndrXyo{D)(@4cQ7dB?d}Ot1BC6CX>A>x@S4$S~|R{ zWhhgHVdm7XYU5b3RxRfQ)wrcpY~hG1ifI|Ag9@=p4Q!(1A`YTS-KuG#uG1>E+aGP3 zkfl72}E8IBL}iST92D zNp+`=t$;&r#X2Ie4u;jSnn#TW1SbL^%ebkfmRc`UC$%x##kw&yg`;0%Zzplkwu%-G z&uXcyJ)hmt`L&b7GTT|3SQazY(}QuQAvs=@3(^so>eVnxR<+saScu1*erC0?0`D|C zwPs@l(@x6mBTds1YE(+v^gLZ{95YPDS6TUPY^GKycZhO6oCR$X*Qry<5eX`;7dv4j zY|pE>j@QGtSm1n)ZP$-P&y9wjz3uc&ryGQ5OtuF$O4QkO;|<~5I?Qpp5%eu*D;B{h9*$ybd+3Ovz3zz6={sR)d!?W^ zEz~%D=&S?DwFHT5rTo;_6~}kb?(~|z2!qYWXt3slB694oy8(E(rPzi;eq1nZA9F^r z9md*c_#TfU5pTFr9gf}*anQ53@fq7*@;UZ)D-1pfiG^yfa14ut`)^4~?Z$#VbmtQL z3-xH-?G=_s>ruf9mgHQkX%$Lb2#hFD`z5)!u(-dla6@6=ez~x)w6Jj90)Ei3U^*vU zN`BpbH7rNL3T;2~>^KN9MGa<+;LRoJ_>)4xa_oVsX?eKay+S1j2ho!BhpHGIT0AI| zuZOPo<(@-w-!%v4>eae2HE-Yi!W^#q7?(wXOSXg?e=B}JqR{mZ%I=08MoxTa6!!}U zrv0cG*0f;w-JplCSds_U+<0!XScT>7CAn;`Ii6gc-*-Kx5H|;-5LbcWd&=C4KGP%jhM(zy?TKxdshr;T znd;ozJ8fe>xd2~nV9EBd(d|&Gt&<*-bxYTdHefop4o{L&rue#!$#l*4Otwp|Yi7G7 zzRA{3os+H4^^WBXuw$98$r1ZajD(hEF?(ERIm-EO|mMN#xGI~SXGO5dzCd#9s=XN15vu9v1PTXl* zHytTSWAci|ejH~2t}jQCGoSm0{r~eil}_Y5uX&&IYzuK2yCJeAT=q=DRO*x$!}|R` zUTd{?lR@fuEASb;=17;>*8nHV(+U|K#^-tKI+slc@aZA@8#<*Qn6i)l+MCfDy?B!&5{>+QgC=5}M zLpzE#@lfLIE-oX~cH_zH>hUD)UJw3?(z*^tFaeIWWuj)%5r@hkSmToU>X|WBYo_`} z+kTqqCM-Ij?rSVQuLaY=#f<qj^vo&ONVvnoiCJCl-3}&?% zPtkMxxbNxlei#g7cgFo%5+>v$@ip?d@oXv&W;edeaEH0wXrf7~E$7INL_6;!&%em> zyXJ1j{I1h0EWbTD!DZeiO%2HWA0@wUNM|5?;vkS-;IG5VbXBy$CwJmK{x>FZ-NZe5 zf>*n6D;;#4$aET$9GTT|8pC&UB9+O@ zRHP~?dG&EB0}WL!gG~y1ZT+tsF1pf!*^FqaOeT4cwV2A7I+c+smzk7QGGnHa8ObC7 zy*WeX`}#Pak=XlYuqoO}D$~}fj8wVIq?Ar(692W6WX3`=Ba>{#QrV29vKjR`@%?`Q DpK>ek delta 2356 zcmYk;e@vBC9LMn^7c&r$Ujk~%gO^`Iic$y?sn>fERJtJCTcH?S;0|1bKaf9K*sZme zZ2lNMtLAjYAFbAEGr6wSA2n=kuGMO_)*5T|Pi{!@M}O6ZjNYHiVe5=9uXCQ~Ip;ag z_nd>?t6!>){g$2lhN1Km4-pL>W5%)T0e(?ldeE3md>Pa6bC7xl?pDriRF0PI)|HS=cF#*--Wv0g_Za!=HpGwz^^eE zf5CM)kD9rl__ zMfGpC?N-z{oyZ)`0BVWOp~kz2Yw!wc+_y5wzbcblQ0hNG4SWmrC0`)fHQ(9$GpHBd zwe7z!nf5=Z7v8s~&{-4CLycR8+I-ch@dK!Fg6qh?I)=HRnfIbzc-lTNhU)MN>dP-# zT~xoTIEU|{Qa!<2HO_~)13yEZhFL7eRA!-5QiZzS5u>7xqgahsP@CjSREM8YYrTLK zn9GjSDcENnM4gfe)cqUw`V3am_Ar>1pb8nnG@;(ti5oFCLPY~ywiiA^hxQ%RF_}fp zcoCI}A}*3;Q;8b59<@o^ksmY2FO7HJUcZLbv~Qv|;UB2fFQB9IpG0qM4lgQYrC5q# z+=^q!v*sOq6mOv>bPu(g(-~E}e;Xr!DQ5OLI{&p)94;I| zt;rB7^_P)lGjY_6ze0WaH<*eusEOZ2ZQ@x}ivLFK6%XsCjHRIZ=b-v+M2%a5X*&PA zsc237r~yx)j!6${;6cR5rwKppD&CVRgF_2OfwFOOQEMfE#_s}K@PdJs#A2Cct} z)@2WInCK_k2`xc{PzF{?fXeoj2KErjKqs-EI7T!QD@FTBnd`6@_F)sDov*!=c)wDm za)8)HuzZR1gXtwgM2Q-(FQKBp2P)eA^~4iu{I49bb!AQ`LK*Yw`yVD$ z^p8Rf(M%}KD@98ZCY~a+bIS?sd=5ZjhaW2N~1$ diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.po b/hesabixAPI/locales/en/LC_MESSAGES/messages.po index fbcd8a5..e3897c1 100644 --- a/hesabixAPI/locales/en/LC_MESSAGES/messages.po +++ b/hesabixAPI/locales/en/LC_MESSAGES/messages.po @@ -54,6 +54,34 @@ msgstr "Duplicate bank account code" msgid "BULK_DELETE_FAILED" msgstr "Bulk delete failed for bank accounts" +# Banking / Cash Registers +msgid "CASH_REGISTERS_LIST_FETCHED" +msgstr "Cash registers list retrieved successfully" + +msgid "CASH_REGISTER_CREATED" +msgstr "Cash register created successfully" + +msgid "CASH_REGISTER_DETAILS" +msgstr "Cash register details" + +msgid "CASH_REGISTER_UPDATED" +msgstr "Cash register updated successfully" + +msgid "CASH_REGISTER_DELETED" +msgstr "Cash register deleted successfully" + +msgid "CASH_REGISTER_NOT_FOUND" +msgstr "Cash register not found" + +msgid "CASH_REGISTERS_BULK_DELETE_DONE" +msgstr "Bulk delete completed for cash registers" + +msgid "INVALID_CASH_CODE" +msgstr "Invalid cash register code" + +msgid "DUPLICATE_CASH_CODE" +msgstr "Duplicate cash register code" + msgid "STRING_TOO_SHORT" msgstr "String is too short" diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo index 7d4df5eeeae316afbdf9d6d473db91a6cf7650cd..8b5dbf89a3fb651a1c838ceb4d337843e2f2565b 100644 GIT binary patch delta 3512 zcma*n3v5(X9>?)hTcxe#(UwOAgewKCRuER}qtN2KTE-4-?Fyc$@mwXkAKHGnl>lTm>EBoJIYuwtO!ZQhpc}=ub!ha~MnU7+!?m z;{{lhpI-1~sBv|u{u`|wSgdV7#!CSW`;kBM3Xe@JZwk)%zZpkTJauU&cVH?vwId5@D1xns6gLXPoetd z5mgIutjkb$^oBM${= zMP=dv)VL>5XWM7n|BTw{anu67L1pv@)J9Ju_s{%)D*0EyNj%QQ5>&@|r~qZQd^sx6 zwMf=YEh=*fRKU$R3GYS)zQ?*9b%c9RaUVtIHcunh!yHQ44zHsoeAkwbp}xewq9#0U z{SNsvr+6svBrbsNKoJj3un-kzDQa8=YT-fDgpIcSCRG2_?|9M9TdiAB13NIDWlR@d zL3xk^t;Q)_j3}nC4)Zzi3S5UPum?5K`?w0vXRv+;>hNZ4Mcs{Kcs2e5HJ`&pE7kYE zh8L~y52(v`kcR@li&dD#P16EaVL5i7CVCDd_${u+a#pLr?WkYUy|@_PL}ln(T#2(- z%~DL@HOy}wnTewY-fznX+~h)Xqidk^@&?rBv&vCe%V&QE_(JcGs3uPuq%vs0F-=9J@J+CJ+iW ztgTNZ!_jyk7>k5WFi>BYw6*bMaBZwE8IHsviAXZsP#+oB9E-*i$(l$aw0hjgWT+_; zNDNn|M&j8>V<(|95s1d(V?rnE8wkwG#`Rs>7*2;BwXPaQGgBxKi`0h$P07TD#t5bO z>SR--HX2VQB28!6$Vi9b_Axsd)0WxF*xGbbH0_y*h?CjNsM-ZokL+et1NC-0?y9_# zmxtFj#-bs1mmGZr$xuT$Vn*7A$E$9jEPus0g%i5Ezb%-Zt0k$AduPGcne`3Hb%9tk zoQ#I!q*QCti+0xS1 z+}^TjNwA~R2{zx_vf25~(&bk#UFN<}c-1EFMene`!x`-J`u*L0_fU^R&4AbI?fZIr zkKgU}s%D?p>vs+A*DEjHL7uxD?Tz$L>7)*NN4)2W+&e1rs6J*wFqWq7{tgDPLM`oVeY6wD-^EUM_?--3-TVA*cB^D%*dFD$;tE@PU~57;yzP6)2%DIWMFR5zp@$!_j`SrQyz5| zY@DWkj;eq7e&{xsbmviXOzbm|JH05UY~uOr__$u)Yu*7)tDi(EqFY>Y>7wyb&)Rcf vWyz+jplW7bhr@cLX_rNn3tjzrx+HQBS delta 2491 zcmY+^Z%oxy9LMnk7v+y$kqZ$8$_2td5G7ZU|9`Ic@=p;6FC>Bj0Yac{EP0grd*Ir7 z&{pft4P`5OlClbCcg~fp2eg)LZdR+6sZF1_iLKUhHS(qJ((cDRJdQaygHPcC zZp2%t=N_Z(cPAOM))>cZrNYnb;eJ_4~JEe6xmL)Iqk@hq|#IHE^%>2x=*hqwb$ZerAq?7w1u1 zbsP1jzoGhxV^%T~HE|#2;~sQ2Q<}gYA0YErmkoGHDCrOWIpmUJ`TDufXdod)Bydcj>b^; zPoO4#5;fqP_WBg+{`XLCK5M;zdhQY~;(|kEJC%bM%~@dP&E7x*l$ zB@y)hP>!3h3ybjtR^bJF4u8cm%w+$zVhDBpBIemM|9^uphNG=WquuVh?8XucfVg4fWhtsDAGwKjY?Gu3>yr zNF{({s0(wbH@t?tg!uv4PV+k|$z0jd>&d9^I0KceHK^=ww(V|IvJN40GUKQmdjmD` zv*_qExJyM@d=L4=%|m2YjLRE+FcbA)8ES&Nu?9y`1D`|n^9ky{%cvLl8rAQ2$l{ti zsOyiBv@-4-)?XL$SRXx5gX*vmb-n{Nk)x=NUbol7w*9Vce}tOAWn_`eJjNiH7YOyG z{#CTnEyQ->koKQ1&6G~1cSQDW# z9_7E|UR%pkcIZ!HWpUByTX904&TgV$`J6uxXzOeZ^#z;v`FFL{2Tj1=Qr{Z%cl3q2 z+v~$`B`!v0lPY541OE0 None: - 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'), - ) - op.create_index('ix_bank_accounts_business_id', 'bank_accounts', ['business_id']) - op.create_index('ix_bank_accounts_currency_id', 'bank_accounts', ['currency_id']) + 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: diff --git a/hesabixAPI/migrations/versions/20251003_000201_add_cash_registers_table.py b/hesabixAPI/migrations/versions/20251003_000201_add_cash_registers_table.py new file mode 100644 index 0000000..3552d43 --- /dev/null +++ b/hesabixAPI/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/migrations/versions/20251003_010501_add_name_to_cash_registers.py b/hesabixAPI/migrations/versions/20251003_010501_add_name_to_cash_registers.py new file mode 100644 index 0000000..6d9ee61 --- /dev/null +++ b/hesabixAPI/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/migrations/versions/a1443c153b47_merge_heads.py b/hesabixAPI/migrations/versions/a1443c153b47_merge_heads.py new file mode 100644 index 0000000..229a94a --- /dev/null +++ b/hesabixAPI/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/hesabixUI/hesabix_ui/lib/core/auth_store.dart b/hesabixUI/hesabix_ui/lib/core/auth_store.dart index f6e436b..0923d5c 100644 --- a/hesabixUI/hesabix_ui/lib/core/auth_store.dart +++ b/hesabixUI/hesabix_ui/lib/core/auth_store.dart @@ -251,8 +251,19 @@ class AuthStore with ChangeNotifier { // مدیریت کسب و کار فعلی Future setCurrentBusiness(BusinessWithPermission business) async { + print('=== setCurrentBusiness START ==='); + print('Setting business: ${business.name} (ID: ${business.id})'); + print('Is owner: ${business.isOwner}'); + print('Role: ${business.role}'); + print('Permissions: ${business.permissions}'); + _currentBusiness = business; _businessPermissions = business.permissions; + + print('AuthStore updated:'); + print(' - Current business: ${_currentBusiness?.name}'); + print(' - Business permissions: $_businessPermissions'); + notifyListeners(); // ذخیره در حافظه محلی @@ -260,6 +271,8 @@ class AuthStore with ChangeNotifier { // اگر ارز انتخاب نشده یا ارز انتخابی با کسب‌وکار ناسازگار است، ارز پیشفرض کسب‌وکار را ست کن await _ensureCurrencyForBusiness(); + + print('=== setCurrentBusiness END ==='); } Future clearCurrentBusiness() async { @@ -340,14 +353,36 @@ class AuthStore with ChangeNotifier { // بررسی دسترسی‌های کسب و کار bool hasBusinessPermission(String section, String action) { - if (_currentBusiness?.isOwner == true) return true; - if (_businessPermissions == null) return false; + print('=== hasBusinessPermission ==='); + print('Section: $section, Action: $action'); + print('Current business: ${_currentBusiness?.name} (ID: ${_currentBusiness?.id})'); + print('Is owner: ${_currentBusiness?.isOwner}'); + print('Business permissions: $_businessPermissions'); + + if (_currentBusiness?.isOwner == true) { + print('User is owner - GRANTED'); + return true; + } + + if (_businessPermissions == null) { + print('No business permissions - DENIED'); + return false; + } final sectionPerms = _businessPermissions![section] as Map?; - // اگر سکشن در دسترسی‌ها موجود نیست، هیچ دسترسی‌ای وجود ندارد - if (sectionPerms == null) return false; + print('Section permissions for "$section": $sectionPerms'); - return sectionPerms[action] == true; + // اگر سکشن در دسترسی‌ها موجود نیست، هیچ دسترسی‌ای وجود ندارد + if (sectionPerms == null) { + print('Section not found in permissions - DENIED'); + return false; + } + + final hasPermission = sectionPerms[action] == true; + print('Permission "$action" for section "$section": $hasPermission'); + print('=== hasBusinessPermission END ==='); + + return hasPermission; } // دسترسی‌های کلی diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index d0725d2..2bd011a 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -1055,5 +1055,16 @@ "productDeletedSuccessfully": "Product or service deleted successfully", "productsDeletedSuccessfully": "Selected items deleted successfully", "noRowsSelectedError": "No rows selected" + , + "deleteSelected": "Delete Selected", + "deletedSuccessfully": "Deleted successfully", + "comingSoon": "Coming soon", + "code": "Code", + "currency": "Currency", + "isDefault": "Default", + "description": "Description", + "actions": "Actions", + "yes": "Yes", + "no": "No" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index c24d07c..d526001 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -1038,6 +1038,16 @@ "editPriceTitle": "ویرایش قیمت", "productDeletedSuccessfully": "کالا/خدمت با موفقیت حذف شد", "productsDeletedSuccessfully": "آیتم‌های انتخاب‌شده با موفقیت حذف شدند", - "noRowsSelectedError": "هیچ سطری انتخاب نشده است" + "noRowsSelectedError": "هیچ سطری انتخاب نشده است", + "deleteSelected": "حذف انتخاب‌شده‌ها", + "deletedSuccessfully": "با موفقیت حذف شد", + "comingSoon": "به‌زودی", + "code": "کد", + "currency": "واحد پول", + "isDefault": "پیش‌فرض", + "description": "توضیحات", + "actions": "اقدامات", + "yes": "بله", + "no": "خیر" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index fc36f77..e9ee45e 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -4715,7 +4715,7 @@ abstract class AppLocalizations { /// No description provided for @code. /// /// In en, this message translates to: - /// **'code'** + /// **'Code'** String get code; /// No description provided for @conflictPolicy. @@ -5581,6 +5581,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'No rows selected'** String get noRowsSelectedError; + + /// No description provided for @deleteSelected. + /// + /// In en, this message translates to: + /// **'Delete Selected'** + String get deleteSelected; + + /// No description provided for @deletedSuccessfully. + /// + /// In en, this message translates to: + /// **'Deleted successfully'** + String get deletedSuccessfully; + + /// No description provided for @comingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get comingSoon; } 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 bb216e8..f9febd2 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -2380,7 +2380,7 @@ class AppLocalizationsEn extends AppLocalizations { String get matchBy => 'Match by'; @override - String get code => 'code'; + String get code => 'Code'; @override String get conflictPolicy => 'Conflict policy'; @@ -2828,4 +2828,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noRowsSelectedError => 'No rows selected'; + + @override + String get deleteSelected => 'Delete Selected'; + + @override + String get deletedSuccessfully => 'Deleted successfully'; + + @override + String get comingSoon => 'Coming soon'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 397b3ef..dd3a3f7 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -1223,7 +1223,7 @@ class AppLocalizationsFa extends AppLocalizations { String get edit => 'ویرایش'; @override - String get actions => 'عملیات'; + String get actions => 'اقدامات'; @override String get search => 'جستجو'; @@ -2594,7 +2594,7 @@ class AppLocalizationsFa extends AppLocalizations { String get price => 'قیمت'; @override - String get currency => 'ارز'; + String get currency => 'واحد پول'; @override String get noPriceListsTitle => 'لیست قیمت موجود نیست'; @@ -2807,4 +2807,13 @@ class AppLocalizationsFa extends AppLocalizations { @override String get noRowsSelectedError => 'هیچ سطری انتخاب نشده است'; + + @override + String get deleteSelected => 'حذف انتخاب‌شده‌ها'; + + @override + String get deletedSuccessfully => 'با موفقیت حذف شد'; + + @override + String get comingSoon => 'به‌زودی'; } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index c0bdd9e..ffd3270 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -30,6 +30,7 @@ import 'pages/business/product_attributes_page.dart'; 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/error_404_page.dart'; import 'core/locale_controller.dart'; import 'core/calendar_controller.dart'; @@ -561,6 +562,24 @@ class _MyAppState extends State { ); }, ), + GoRoute( + path: 'cash-box', + name: 'business_cash_box', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + localeController: controller, + calendarController: _calendarController!, + themeController: themeController, + child: CashRegistersPage( + businessId: businessId, + authStore: _authStore!, + ), + ); + }, + ), GoRoute( path: 'settings', name: 'business_settings', diff --git a/hesabixUI/hesabix_ui/lib/models/cash_register.dart b/hesabixUI/hesabix_ui/lib/models/cash_register.dart new file mode 100644 index 0000000..8a92359 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/cash_register.dart @@ -0,0 +1,67 @@ +class CashRegister { + 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 String? paymentSwitchNumber; + final String? paymentTerminalNumber; + final String? merchantId; + final DateTime? createdAt; + final DateTime? updatedAt; + + const CashRegister({ + this.id, + required this.businessId, + required this.name, + this.code, + required this.currencyId, + this.isActive = true, + this.isDefault = false, + this.description, + this.paymentSwitchNumber, + this.paymentTerminalNumber, + this.merchantId, + this.createdAt, + this.updatedAt, + }); + + factory CashRegister.fromJson(Map json) { + return CashRegister( + 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?, + paymentSwitchNumber: json['payment_switch_number'] as String?, + paymentTerminalNumber: json['payment_terminal_number'] as String?, + merchantId: json['merchant_id'] 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 { + 'id': id, + 'business_id': businessId, + 'name': name, + 'code': code, + 'currency_id': currencyId, + 'is_active': isActive, + 'is_default': isDefault, + 'description': description, + 'payment_switch_number': paymentSwitchNumber, + 'payment_terminal_number': paymentTerminalNumber, + 'merchant_id': merchantId, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index d1f9193..2a757b2 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -46,6 +46,10 @@ class _BusinessShellState extends State { @override void initState() { super.initState(); + // اطمینان از bind بودن AuthStore برای ApiClient (جهت هدرها و تنظیمات) + try { + ApiClient.bindAuthStore(widget.authStore); + } catch (_) {} // اضافه کردن listener برای AuthStore widget.authStore.addListener(() { if (mounted) { @@ -58,14 +62,30 @@ class _BusinessShellState extends State { } Future _loadBusinessInfo() async { + print('=== _loadBusinessInfo START ==='); + print('Current business ID: ${widget.businessId}'); + print('AuthStore current business ID: ${widget.authStore.currentBusiness?.id}'); + if (widget.authStore.currentBusiness?.id == widget.businessId) { + print('Business info already loaded, skipping...'); return; // اطلاعات قبلاً بارگذاری شده } try { + print('Loading business info for business ID: ${widget.businessId}'); final businessData = await _businessService.getBusinessWithPermissions(widget.businessId); + print('Business data loaded successfully:'); + print(' - Name: ${businessData.name}'); + print(' - ID: ${businessData.id}'); + print(' - Is Owner: ${businessData.isOwner}'); + print(' - Role: ${businessData.role}'); + print(' - Permissions: ${businessData.permissions}'); + await widget.authStore.setCurrentBusiness(businessData); + print('Business info set in authStore'); + print('AuthStore business permissions: ${widget.authStore.businessPermissions}'); } catch (e) { + print('Error loading business info: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -75,6 +95,7 @@ class _BusinessShellState extends State { ); } } + print('=== _loadBusinessInfo END ==='); } @override @@ -716,7 +737,8 @@ class _BusinessShellState extends State { } else if (child.label == t.pettyCash) { // Navigate to add petty cash } else if (child.label == t.cashBox) { - // Navigate to add cash box + // For cash box, navigate to the page and use its add + context.go('/business/${widget.businessId}/cash-box'); } else if (child.label == t.wallet) { // Navigate to add wallet } else if (child.label == t.checks) { @@ -865,6 +887,8 @@ class _BusinessShellState extends State { businessId: widget.businessId, ), ); + } else if (item.label == t.cashBox) { + context.go('/business/${widget.businessId}/cash-box'); } // سایر مسیرهای افزودن در آینده متصل می‌شوند }, @@ -1073,36 +1097,96 @@ class _BusinessShellState extends State { // فیلتر کردن منو بر اساس دسترسی‌ها List<_MenuItem> _getFilteredMenuItems(List<_MenuItem> allItems) { - return allItems.where((item) { - if (item.type == _MenuItemType.separator) return true; + print('=== _getFilteredMenuItems START ==='); + print('Total menu items: ${allItems.length}'); + print('Current business: ${widget.authStore.currentBusiness?.name} (ID: ${widget.authStore.currentBusiness?.id})'); + print('Is owner: ${widget.authStore.currentBusiness?.isOwner}'); + print('Business permissions: ${widget.authStore.businessPermissions}'); + + final filteredItems = allItems.where((item) { + if (item.type == _MenuItemType.separator) { + print('Separator item: ${item.label} - KEEPING'); + return true; + } if (item.type == _MenuItemType.simple) { - return _hasAccessToMenuItem(item); + final hasAccess = _hasAccessToMenuItem(item); + print('Simple item: ${item.label} - ${hasAccess ? 'KEEPING' : 'REMOVING'}'); + return hasAccess; } if (item.type == _MenuItemType.expandable) { - return _hasAccessToExpandableMenuItem(item); + final hasAccess = _hasAccessToExpandableMenuItem(item); + print('Expandable item: ${item.label} - ${hasAccess ? 'KEEPING' : 'REMOVING'}'); + return hasAccess; } + print('Unknown item type: ${item.label} - REMOVING'); return false; }).toList(); + + print('Filtered menu items: ${filteredItems.length}'); + for (final item in filteredItems) { + print(' - ${item.label} (${item.type})'); + } + print('=== _getFilteredMenuItems END ==='); + + return filteredItems; } bool _hasAccessToMenuItem(_MenuItem item) { final section = _sectionForLabel(item.label, AppLocalizations.of(context)); + print(' Checking access for: ${item.label} -> section: $section'); + // داشبورد همیشه قابل مشاهده است - if (item.path != null && item.path!.endsWith('/dashboard')) return true; + if (item.path != null && item.path!.endsWith('/dashboard')) { + print(' Dashboard item - ALWAYS ACCESSIBLE'); + return true; + } + // اگر سکشن تعریف نشده، نمایش داده نشود - if (section == null) return false; - // فقط وقتی اجازه خواندن دارد نمایش بده - return widget.authStore.canReadSection(section); + if (section == null) { + print(' No section mapping found - DENIED'); + return false; + } + + // بررسی دسترسی‌های مختلف برای نمایش منو + // اگر کاربر مالک است، همه منوها قابل مشاهده هستند + if (widget.authStore.currentBusiness?.isOwner == true) { + print(' User is owner - GRANTED'); + return true; + } + + // برای کاربران عضو، بررسی دسترسی view + final hasAccess = widget.authStore.canReadSection(section); + print(' Checking view permission for section "$section": $hasAccess'); + + // Debug: بررسی دقیق‌تر دسترسی‌ها + if (widget.authStore.businessPermissions != null) { + final sectionPerms = widget.authStore.businessPermissions![section]; + print(' Section permissions for "$section": $sectionPerms'); + if (sectionPerms != null) { + final viewPerm = sectionPerms['view']; + print(' View permission: $viewPerm'); + } + } + + return hasAccess; } bool _hasAccessToExpandableMenuItem(_MenuItem item) { - if (item.children == null) return false; + if (item.children == null) { + print(' Expandable item "${item.label}" has no children - DENIED'); + return false; + } + + print(' Checking expandable item: ${item.label} with ${item.children!.length} children'); // اگر حداقل یکی از زیرآیتم‌ها قابل دسترسی باشد، منو نمایش داده شود - return item.children!.any((child) => _hasAccessToMenuItem(child)); + final hasAccess = item.children!.any((child) => _hasAccessToMenuItem(child)); + print(' Expandable item "${item.label}" access: $hasAccess'); + + return hasAccess; } // تبدیل برچسب محلی‌شده منو به کلید سکشن دسترسی diff --git a/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart new file mode 100644 index 0000000..c37e9b1 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart @@ -0,0 +1,286 @@ +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/cash_register.dart'; +import '../../services/cash_register_service.dart'; +import '../../services/currency_service.dart'; +import '../../widgets/banking/cash_register_form_dialog.dart'; + +class CashRegistersPage extends StatefulWidget { + final int businessId; + final AuthStore authStore; + + const CashRegistersPage({super.key, required this.businessId, required this.authStore}); + + @override + State createState() => _CashRegistersPageState(); +} + +class _CashRegistersPageState extends State { + final _service = CashRegisterService(); + final _currencyService = CurrencyService(ApiClient()); + final GlobalKey _tableKey = GlobalKey(); + Map _currencyNames = {}; + + @override + void initState() { + super.initState(); + _loadCurrencies(); + } + + 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 (_) {} + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + if (!widget.authStore.canReadSection('cash')) { + return AccessDeniedPage(message: t.accessDenied); + } + + return Scaffold( + body: DataTableWidget( + key: _tableKey, + config: _buildConfig(t), + fromJson: CashRegister.fromJson, + ), + ); + } + + DataTableConfig _buildConfig(AppLocalizations t) { + return DataTableConfig( + endpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers', + title: t.cashBox, + excelEndpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers/export/excel', + pdfEndpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers/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( + 'payment_switch_number', + (t.localeName == 'fa') ? 'شماره سویچ پرداخت' : 'Payment Switch No.', + width: ColumnWidth.large, + formatter: (row) => row.paymentSwitchNumber ?? '-', + ), + TextColumn( + 'payment_terminal_number', + (t.localeName == 'fa') ? 'شماره ترمینال' : 'Terminal No.', + width: ColumnWidth.large, + formatter: (row) => row.paymentTerminalNumber ?? '-', + ), + TextColumn( + 'merchant_id', + (t.localeName == 'fa') ? 'پذیرنده' : 'Merchant ID', + width: ColumnWidth.large, + formatter: (row) => row.merchantId ?? '-', + ), + 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: ['code','description','payment_switch_number','payment_terminal_number','merchant_id'], + filterFields: ['is_active','is_default','currency_id'], + defaultPageSize: 20, + customHeaderActions: [ + PermissionButton( + section: 'cash', + action: 'add', + authStore: widget.authStore, + child: Tooltip( + message: t.add, + child: IconButton( + onPressed: _add, + icon: const Icon(Icons.add), + ), + ), + ), + if (widget.authStore.canDeleteSection('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) => CashRegisterFormDialog( + businessId: widget.businessId, + onSuccess: () { + try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {} + }, + ), + ); + } + + void _edit(CashRegister row) async { + await showDialog( + context: context, + builder: (ctx) => CashRegisterFormDialog( + businessId: widget.businessId, + register: row, + onSuccess: () { + try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {} + }, + ), + ); + } + + Future _delete(CashRegister 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 CashRegister && 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/cash-registers/businesses/${widget.businessId}/cash-registers/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'))); + } + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart index 5e36d38..266a5f3 100644 --- a/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart @@ -120,43 +120,101 @@ class BusinessDashboardService { /// دریافت اطلاعات کسب و کار همراه با دسترسی‌های کاربر Future getBusinessWithPermissions(int businessId) async { + print('=== getBusinessWithPermissions START ==='); + print('Business ID: $businessId'); + try { + print('Calling API: /api/v1/business/$businessId/info-with-permissions'); final response = await _apiClient.post>( '/api/v1/business/$businessId/info-with-permissions', ); + print('API Response received:'); + print(' - Success: ${response.data?['success']}'); + print(' - Message: ${response.data?['message']}'); + print(' - Data: ${response.data?['data']}'); + if (response.data?['success'] == true) { final data = response.data!['data'] as Map; // تبدیل اطلاعات کسب و کار final businessInfo = data['business_info'] as Map; - final userPermissions = data['user_permissions'] as Map? ?? {}; + // نرمال‌سازی دسترسی‌ها: هم Map پشتیبانی می‌شود و هم List مثل 'inventory.read' + final dynamic userPermissionsRaw = data['user_permissions']; + final Map userPermissions = {}; + if (userPermissionsRaw is Map) { + userPermissions.addAll(userPermissionsRaw); + } else if (userPermissionsRaw is List) { + for (final item in userPermissionsRaw) { + if (item is String) { + final parts = item.split('.'); + if (parts.length >= 2) { + final String section = parts[0]; + final String action = parts[1]; + final Map sectionPerms = + (userPermissions[section] as Map?) ?? {}; + sectionPerms[action] = true; + userPermissions[section] = sectionPerms; + } + } + } + } final isOwner = data['is_owner'] as bool? ?? false; final role = data['role'] as String? ?? 'عضو'; final hasAccess = data['has_access'] as bool? ?? false; + print('Parsed data:'); + print(' - Business Info: $businessInfo'); + print(' - User Permissions: $userPermissions'); + print(' - Is Owner: $isOwner'); + print(' - Role: $role'); + print(' - Has Access: $hasAccess'); + if (!hasAccess) { + print('Access denied by API'); throw Exception('دسترسی غیرمجاز به این کسب و کار'); } - return BusinessWithPermission( - id: businessInfo['id'] as int, - name: businessInfo['name'] as String, - businessType: businessInfo['business_type'] as String, - businessField: businessInfo['business_field'] as String, - ownerId: businessInfo['owner_id'] as int, - address: businessInfo['address'] as String?, - phone: businessInfo['phone'] as String?, - mobile: businessInfo['mobile'] as String?, - createdAt: businessInfo['created_at'] as String, - isOwner: isOwner, - role: role, - permissions: userPermissions, + // ساخت یک Map ترکیبی از اطلاعات کسب و کار + متادیتاهای دسترسی کاربر + final Map combined = { + ...businessInfo, + 'is_owner': isOwner, + 'role': role, + 'permissions': userPermissions, + }; + + // اگر سرور ارز پیش‌فرض یا لیست ارزها را نیز ارسال کرد، اضافه کنیم + if (data.containsKey('default_currency')) { + combined['default_currency'] = data['default_currency']; + } + if (data.containsKey('currencies')) { + combined['currencies'] = data['currencies']; + } + + // استفاده از fromJson برای مدیریت امن انواع (مثلاً created_at می‌تواند String یا Map باشد) + final businessWithPermission = BusinessWithPermission.fromJson( + Map.from(combined), ); + + print('BusinessWithPermission created:'); + print(' - Name: ${businessWithPermission.name}'); + print(' - ID: ${businessWithPermission.id}'); + print(' - Is Owner: ${businessWithPermission.isOwner}'); + print(' - Role: ${businessWithPermission.role}'); + print(' - Permissions: ${businessWithPermission.permissions}'); + + print('=== getBusinessWithPermissions END ==='); + return businessWithPermission; } else { + print('API returned error: ${response.data?['message']}'); throw Exception('Failed to load business info: ${response.data?['message']}'); } } on DioException catch (e) { + print('DioException occurred:'); + print(' - Status Code: ${e.response?.statusCode}'); + print(' - Response Data: ${e.response?.data}'); + print(' - Message: ${e.message}'); + if (e.response?.statusCode == 403) { throw Exception('دسترسی غیرمجاز به این کسب و کار'); } else if (e.response?.statusCode == 404) { @@ -165,6 +223,7 @@ class BusinessDashboardService { throw Exception('خطا در بارگذاری اطلاعات کسب و کار: ${e.message}'); } } catch (e) { + print('General Exception: $e'); throw Exception('خطا در بارگذاری اطلاعات کسب و کار: $e'); } } diff --git a/hesabixUI/hesabix_ui/lib/services/cash_register_service.dart b/hesabixUI/hesabix_ui/lib/services/cash_register_service.dart new file mode 100644 index 0000000..3366a4e --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/cash_register_service.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; +import '../models/cash_register.dart'; + +class CashRegisterService { + final ApiClient _client; + CashRegisterService({ApiClient? client}) : _client = client ?? ApiClient(); + + Future> list({required int businessId, required Map queryInfo}) async { + final res = await _client.post>( + '/api/v1/cash-registers/businesses/$businessId/cash-registers', + data: queryInfo, + ); + return (res.data ?? {}); + } + + Future create({required int businessId, required Map payload}) async { + final res = await _client.post>( + '/api/v1/cash-registers/businesses/$businessId/cash-registers/create', + data: payload, + ); + final data = (res.data?['data'] as Map? ?? {}); + return CashRegister.fromJson(data); + } + + Future getById(int id) async { + final res = await _client.get>('/api/v1/cash-registers/cash-registers/$id'); + final data = (res.data?['data'] as Map? ?? {}); + return CashRegister.fromJson(data); + } + + Future update({required int id, required Map payload}) async { + final res = await _client.put>('/api/v1/cash-registers/cash-registers/$id', data: payload); + final data = (res.data?['data'] as Map? ?? {}); + return CashRegister.fromJson(data); + } + + Future delete(int id) async { + await _client.delete>('/api/v1/cash-registers/cash-registers/$id'); + } + + Future>> exportExcel({required int businessId, required Map body}) async { + return _client.post>( + '/api/v1/cash-registers/businesses/$businessId/cash-registers/export/excel', + data: body, + responseType: ResponseType.bytes, + ); + } + + Future>> exportPdf({required int businessId, required Map body}) async { + return _client.post>( + '/api/v1/cash-registers/businesses/$businessId/cash-registers/export/pdf', + data: body, + responseType: ResponseType.bytes, + ); + } +} 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 new file mode 100644 index 0000000..0a5cd40 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/banking/cash_register_form_dialog.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../models/cash_register.dart'; +import '../../services/cash_register_service.dart'; +import 'currency_picker_widget.dart'; + +class CashRegisterFormDialog extends StatefulWidget { + final int businessId; + final CashRegister? register; // null برای افزودن، مقدار برای ویرایش + final VoidCallback? onSuccess; + + const CashRegisterFormDialog({ + super.key, + required this.businessId, + this.register, + this.onSuccess, + }); + + @override + State createState() => _CashRegisterFormDialogState(); +} + +class _CashRegisterFormDialogState extends State { + final _formKey = GlobalKey(); + final _service = CashRegisterService(); + bool _isLoading = false; + + final _codeController = TextEditingController(); + bool _autoGenerateCode = true; + + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _paymentSwitchController = TextEditingController(); + final _paymentTerminalController = TextEditingController(); + final _merchantIdController = TextEditingController(); + + bool _isActive = true; + bool _isDefault = false; + int? _currencyId; + + @override + void initState() { + super.initState(); + _initializeForm(); + } + + void _initializeForm() { + if (widget.register != null) { + final r = widget.register!; + if (r.code != null) { + _codeController.text = r.code!; + _autoGenerateCode = false; + } + _nameController.text = r.name; + _descriptionController.text = r.description ?? ''; + _paymentSwitchController.text = r.paymentSwitchNumber ?? ''; + _paymentTerminalController.text = r.paymentTerminalNumber ?? ''; + _merchantIdController.text = r.merchantId ?? ''; + _isActive = r.isActive; + _isDefault = r.isDefault; + _currencyId = r.currencyId; + } + } + + @override + void dispose() { + _codeController.dispose(); + _nameController.dispose(); + _descriptionController.dispose(); + _paymentSwitchController.dispose(); + _paymentTerminalController.dispose(); + _merchantIdController.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(), + 'payment_switch_number': _paymentSwitchController.text.trim().isEmpty ? null : _paymentSwitchController.text.trim(), + 'payment_terminal_number': _paymentTerminalController.text.trim().isEmpty ? null : _paymentTerminalController.text.trim(), + 'merchant_id': _merchantIdController.text.trim().isEmpty ? null : _merchantIdController.text.trim(), + 'is_active': _isActive, + 'is_default': _isDefault, + 'currency_id': _currencyId, + }; + + if (widget.register == null) { + await _service.create(businessId: widget.businessId, payload: payload); + } else { + await _service.update(id: widget.register!.id!, payload: payload); + } + + if (mounted) { + Navigator.of(context).pop(); + widget.onSuccess?.call(); + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.register == null + ? (t.localeName == 'fa' ? 'صندوق با موفقیت ایجاد شد' : 'Cash register created successfully') + : (t.localeName == 'fa' ? 'صندوق با موفقیت به‌روزرسانی شد' : 'Cash register 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.register != 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 Cash Register') : (t.localeName == 'fa' ? 'افزودن صندوق' : 'Add Cash Register'), + 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, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _paymentSwitchController, + decoration: InputDecoration( + labelText: (t.localeName == 'fa') ? 'شماره سویچ پرداخت' : 'Payment Switch Number', + hintText: (t.localeName == 'fa') ? 'شماره سویچ پرداخت' : 'Payment Switch Number', + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _paymentTerminalController, + decoration: InputDecoration( + labelText: (t.localeName == 'fa') ? 'شماره ترمینال' : 'Terminal Number', + hintText: (t.localeName == 'fa') ? 'شماره ترمینال' : 'Terminal Number', + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _merchantIdController, + decoration: InputDecoration( + labelText: (t.localeName == 'fa') ? 'شماره پذیرنده' : 'Merchant ID', + hintText: (t.localeName == 'fa') ? 'شماره پذیرنده' : 'Merchant ID', + ), + ), + ], + ); + } + + 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; }); }, + ), + ], + ); + } +} + +