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 7ac2fb0..6a74195 100644
Binary files a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo and b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo differ
diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.po b/hesabixAPI/locales/en/LC_MESSAGES/messages.po
index 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 7d4df5e..8b5dbf8 100644
Binary files a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo and b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo differ
diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
index f94faf0..e573cc0 100644
--- a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
+++ b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
@@ -75,6 +75,34 @@ msgstr "کد حساب بانکی تکراری است"
msgid "BULK_DELETE_FAILED"
msgstr "خطا در حذف گروهی حسابهای بانکی"
+# Banking / Cash Registers
+msgid "CASH_REGISTERS_LIST_FETCHED"
+msgstr "لیست صندوقها با موفقیت دریافت شد"
+
+msgid "CASH_REGISTER_CREATED"
+msgstr "صندوق با موفقیت ایجاد شد"
+
+msgid "CASH_REGISTER_DETAILS"
+msgstr "جزئیات صندوق"
+
+msgid "CASH_REGISTER_UPDATED"
+msgstr "صندوق با موفقیت بهروزرسانی شد"
+
+msgid "CASH_REGISTER_DELETED"
+msgstr "صندوق با موفقیت حذف شد"
+
+msgid "CASH_REGISTER_NOT_FOUND"
+msgstr "صندوق یافت نشد"
+
+msgid "CASH_REGISTERS_BULK_DELETE_DONE"
+msgstr "حذف گروهی صندوقها انجام شد"
+
+msgid "INVALID_CASH_CODE"
+msgstr "کد صندوق نامعتبر است"
+
+msgid "DUPLICATE_CASH_CODE"
+msgstr "کد صندوق تکراری است"
+
msgid "STRING_TOO_SHORT"
msgstr "رشته خیلی کوتاه است"
diff --git a/hesabixAPI/migrations/versions/20251002_000101_add_bank_accounts_table.py b/hesabixAPI/migrations/versions/20251002_000101_add_bank_accounts_table.py
index 0621814..99e0060 100644
--- a/hesabixAPI/migrations/versions/20251002_000101_add_bank_accounts_table.py
+++ b/hesabixAPI/migrations/versions/20251002_000101_add_bank_accounts_table.py
@@ -18,29 +18,48 @@ depends_on = None
def upgrade() -> 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