diff --git a/hesabixAPI/adapters/api/v1/bank_accounts.py b/hesabixAPI/adapters/api/v1/bank_accounts.py
new file mode 100644
index 0000000..21e4523
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/bank_accounts.py
@@ -0,0 +1,514 @@
+from typing import Any, Dict, List, Optional
+from fastapi import APIRouter, Depends, Request, Body, HTTPException
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.responses import success_response, format_datetime_fields, ApiError
+from app.core.permissions import require_business_management_dep, require_business_access
+from adapters.api.v1.schemas import QueryInfo
+from adapters.api.v1.schema_models.bank_account import (
+ BankAccountCreateRequest,
+ BankAccountUpdateRequest,
+)
+from app.services.bank_account_service import (
+ create_bank_account,
+ update_bank_account,
+ delete_bank_account,
+ get_bank_account_by_id,
+ list_bank_accounts,
+ bulk_delete_bank_accounts,
+)
+
+router = APIRouter(prefix="/bank-accounts", tags=["bank-accounts"])
+
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts",
+ summary="لیست حسابهای بانکی کسبوکار",
+ description="دریافت لیست حسابهای بانکی یک کسب و کار با امکان جستجو و فیلتر",
+)
+@require_business_access("business_id")
+async def list_bank_accounts_endpoint(
+ request: Request,
+ business_id: int,
+ query_info: QueryInfo,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ query_dict: Dict[str, Any] = {
+ "take": query_info.take,
+ "skip": query_info.skip,
+ "sort_by": query_info.sort_by,
+ "sort_desc": query_info.sort_desc,
+ "search": query_info.search,
+ "search_fields": query_info.search_fields,
+ "filters": query_info.filters,
+ }
+ result = list_bank_accounts(db, business_id, query_dict)
+ result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
+ return success_response(data=result, request=request, message="BANK_ACCOUNTS_LIST_FETCHED")
+
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts/create",
+ summary="ایجاد حساب بانکی جدید",
+ description="ایجاد حساب بانکی برای یک کسبوکار مشخص",
+)
+@require_business_access("business_id")
+async def create_bank_account_endpoint(
+ request: Request,
+ business_id: int,
+ body: BankAccountCreateRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ created = create_bank_account(db, business_id, payload)
+ return success_response(data=format_datetime_fields(created, request), request=request, message="BANK_ACCOUNT_CREATED")
+
+
+@router.get(
+ "/bank-accounts/{account_id}",
+ summary="جزئیات حساب بانکی",
+ description="دریافت جزئیات حساب بانکی بر اساس شناسه",
+)
+async def get_bank_account_endpoint(
+ request: Request,
+ account_id: int,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ result = get_bank_account_by_id(db, account_id)
+ if not result:
+ raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
+ # بررسی دسترسی به کسبوکار مرتبط
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="BANK_ACCOUNT_DETAILS")
+
+
+@router.put(
+ "/bank-accounts/{account_id}",
+ summary="ویرایش حساب بانکی",
+ description="ویرایش اطلاعات حساب بانکی",
+)
+async def update_bank_account_endpoint(
+ request: Request,
+ account_id: int,
+ body: BankAccountUpdateRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ result = update_bank_account(db, account_id, payload)
+ if result is None:
+ raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
+ # بررسی دسترسی به کسبوکار مرتبط
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="BANK_ACCOUNT_UPDATED")
+
+
+@router.delete(
+ "/bank-accounts/{account_id}",
+ summary="حذف حساب بانکی",
+ description="حذف یک حساب بانکی",
+)
+async def delete_bank_account_endpoint(
+ request: Request,
+ account_id: int,
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ # ابتدا بررسی دسترسی بر اساس business مربوط به حساب
+ result = get_bank_account_by_id(db, account_id)
+ if result:
+ try:
+ biz_id = int(result.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ ok = delete_bank_account(db, account_id)
+ if not ok:
+ raise ApiError("BANK_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
+ return success_response(data=None, request=request, message="BANK_ACCOUNT_DELETED")
+
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts/bulk-delete",
+ summary="حذف گروهی حسابهای بانکی",
+ description="حذف چندین حساب بانکی بر اساس شناسهها",
+)
+@require_business_access("business_id")
+async def bulk_delete_bank_accounts_endpoint(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ ids = body.get("ids")
+ if not isinstance(ids, list):
+ ids = []
+ try:
+ ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
+ except Exception:
+ ids = []
+
+ if not ids:
+ return success_response({"deleted": 0, "skipped": 0, "total_requested": 0}, request, message="NO_VALID_IDS_FOR_DELETE")
+
+ # فراخوانی تابع حذف گروهی از سرویس
+ result = bulk_delete_bank_accounts(db, business_id, ids)
+
+ return success_response(result, request, message="BANK_ACCOUNTS_BULK_DELETE_DONE")
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts/export/excel",
+ summary="خروجی Excel لیست حسابهای بانکی",
+ description="خروجی Excel لیست حسابهای بانکی با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
+)
+@require_business_access("business_id")
+async def export_bank_accounts_excel(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ from fastapi.responses import Response
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+ from app.core.i18n import negotiate_locale
+
+ # دریافت دادهها از سرویس
+ query_dict = {
+ "take": int(body.get("take", 1000)), # برای export همه دادهها
+ "skip": int(body.get("skip", 0)),
+ "sort_by": body.get("sort_by"),
+ "sort_desc": bool(body.get("sort_desc", False)),
+ "search": body.get("search"),
+ "search_fields": body.get("search_fields"),
+ "filters": body.get("filters"),
+ }
+
+ result = list_bank_accounts(db, business_id, query_dict)
+ items: List[Dict[str, Any]] = result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ headers: List[str] = [
+ "code", "name", "branch", "account_number", "sheba_number", "card_number", "owner_name", "pos_number", "is_active", "is_default"
+ ]
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "BankAccounts"
+
+ # RTL/LTR
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ if locale == 'fa':
+ try:
+ ws.sheet_view.rightToLeft = True
+ except Exception:
+ pass
+
+ header_font = Font(bold=True, color="FFFFFF")
+ header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
+ header_alignment = Alignment(horizontal="center", vertical="center")
+ border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
+
+ # Header
+ for col_idx, header in enumerate(headers, 1):
+ cell = ws.cell(row=1, column=col_idx, value=header)
+ cell.font = header_font
+ cell.fill = header_fill
+ cell.alignment = header_alignment
+ cell.border = border
+
+ # Rows
+ for row_idx, item in enumerate(items, 2):
+ for col_idx, key in enumerate(headers, 1):
+ value = item.get(key, "")
+ if isinstance(value, list):
+ value = ", ".join(str(v) for v in value)
+ cell = ws.cell(row=row_idx, column=col_idx, value=value)
+ cell.border = border
+ if locale == 'fa':
+ cell.alignment = Alignment(horizontal="right")
+
+ # Auto-width
+ for column in ws.columns:
+ max_length = 0
+ column_letter = column[0].column_letter
+ for cell in column:
+ try:
+ if len(str(cell.value)) > max_length:
+ max_length = len(str(cell.value))
+ except Exception:
+ pass
+ ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
+
+ buffer = io.BytesIO()
+ wb.save(buffer)
+ buffer.seek(0)
+
+ content = buffer.getvalue()
+ return Response(
+ content=content,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": "attachment; filename=bank_accounts.xlsx",
+ "Content-Length": str(len(content)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
+@router.post(
+ "/businesses/{business_id}/bank-accounts/export/pdf",
+ summary="خروجی PDF لیست حسابهای بانکی",
+ description="خروجی PDF لیست حسابهای بانکی با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
+)
+@require_business_access("business_id")
+async def export_bank_accounts_pdf(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ from fastapi.responses import Response
+ from weasyprint import HTML, CSS
+ from weasyprint.text.fonts import FontConfiguration
+ from app.core.i18n import negotiate_locale
+
+ # Build query dict similar to persons export
+ query_dict = {
+ "take": int(body.get("take", 1000)), # برای export همه دادهها
+ "skip": int(body.get("skip", 0)),
+ "sort_by": body.get("sort_by"),
+ "sort_desc": bool(body.get("sort_desc", False)),
+ "search": body.get("search"),
+ "search_fields": body.get("search_fields"),
+ "filters": body.get("filters"),
+ }
+
+ # دریافت دادهها از سرویس
+ result = list_bank_accounts(db, business_id, query_dict)
+ items: List[Dict[str, Any]] = result.get("items", [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ # Selection handling
+ selected_only = bool(body.get('selected_only', False))
+ selected_indices = body.get('selected_indices')
+ if selected_only and selected_indices is not None:
+ indices = None
+ if isinstance(selected_indices, str):
+ import json
+ try:
+ indices = json.loads(selected_indices)
+ except (json.JSONDecodeError, TypeError):
+ indices = None
+ elif isinstance(selected_indices, list):
+ indices = selected_indices
+ if isinstance(indices, list):
+ items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
+
+ # Prepare headers/keys from export_columns (order + visibility)
+ headers: List[str] = []
+ keys: List[str] = []
+ export_columns = body.get('export_columns')
+ if export_columns:
+ for col in export_columns:
+ key = col.get('key')
+ label = col.get('label', key)
+ if key:
+ keys.append(str(key))
+ headers.append(str(label))
+ else:
+ if items:
+ keys = list(items[0].keys())
+ headers = keys
+ else:
+ keys = [
+ "code", "name", "branch", "account_number", "sheba_number",
+ "card_number", "owner_name", "pos_number", "is_active", "is_default",
+ ]
+ headers = keys
+
+ # Load business info
+ business_name = ""
+ try:
+ from adapters.db.models.business import Business
+ biz = db.query(Business).filter(Business.id == business_id).first()
+ if biz is not None:
+ business_name = biz.name or ""
+ except Exception:
+ business_name = ""
+
+ # Locale and calendar-aware date
+ locale = negotiate_locale(request.headers.get("Accept-Language"))
+ is_fa = (locale == 'fa')
+ html_lang = 'fa' if is_fa else 'en'
+ html_dir = 'rtl' if is_fa else 'ltr'
+
+ try:
+ from app.core.calendar import CalendarConverter
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
+ formatted_now = CalendarConverter.format_datetime(
+ __import__("datetime").datetime.now(),
+ "jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian",
+ )
+ now_str = formatted_now.get('formatted', formatted_now.get('date_time', ''))
+ except Exception:
+ from datetime import datetime
+ now_str = datetime.now().strftime('%Y/%m/%d %H:%M')
+
+ # Labels
+ title_text = "گزارش لیست حسابهای بانکی" if is_fa else "Bank Accounts List Report"
+ label_biz = "نام کسبوکار" if is_fa else "Business Name"
+ label_date = "تاریخ گزارش" if is_fa else "Report Date"
+ footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
+ page_label_left = "صفحه " if is_fa else "Page "
+ page_label_of = " از " if is_fa else " of "
+
+ def escape_val(v: Any) -> str:
+ try:
+ return str(v).replace('&', '&').replace('<', '<').replace('>', '>')
+ except Exception:
+ return str(v)
+
+ rows_html: List[str] = []
+ for item in items:
+ tds = []
+ for key in keys:
+ value = item.get(key)
+ if value is None:
+ value = ""
+ elif isinstance(value, list):
+ value = ", ".join(str(v) for v in value)
+ tds.append(f"
{escape_val(value)} | ")
+ rows_html.append(f"{''.join(tds)}
")
+
+ headers_html = ''.join(f"{escape_val(h)} | " for h in headers)
+
+ table_html = f"""
+
+
+
+
+
+
+
+
+
+
+ {headers_html}
+
+
+ {''.join(rows_html)}
+
+
+
+
+
+
+ """
+
+ font_config = FontConfiguration()
+ pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": "attachment; filename=bank_accounts.pdf",
+ "Content-Length": str(len(pdf_bytes)),
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ },
+ )
+
+
diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py
index 2185f0c..39ad0a7 100644
--- a/hesabixAPI/adapters/api/v1/persons.py
+++ b/hesabixAPI/adapters/api/v1/persons.py
@@ -23,6 +23,84 @@ from adapters.db.models.business import Business
router = APIRouter(prefix="/persons", tags=["persons"])
+@router.post("/businesses/{business_id}/persons/bulk-delete",
+ summary="حذف گروهی اشخاص",
+ description="حذف چندین شخص بر اساس شناسهها یا کدها",
+)
+async def bulk_delete_persons_endpoint(
+ request: Request,
+ business_id: int,
+ body: Dict[str, Any] = Body(...),
+ db: Session = Depends(get_db),
+ auth_context: AuthContext = Depends(get_current_user),
+ _: None = Depends(require_business_management_dep),
+):
+ """حذف گروهی اشخاص برای یک کسبوکار مشخص
+
+ ورودی:
+ - ids: لیست شناسههای اشخاص
+ - codes: لیست کدهای اشخاص در همان کسبوکار
+ """
+ from sqlalchemy import and_ as _and
+ from adapters.db.models.person import Person
+
+ ids = body.get("ids")
+ codes = body.get("codes")
+ deleted = 0
+ skipped = 0
+
+ if not ids and not codes:
+ return success_response({"deleted": 0, "skipped": 0}, request)
+
+ # Normalize inputs
+ if isinstance(ids, list):
+ try:
+ ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
+ except Exception:
+ ids = []
+ else:
+ ids = []
+
+ if isinstance(codes, list):
+ try:
+ codes = [int(str(x).strip()) for x in codes if str(x).strip().isdigit()]
+ except Exception:
+ codes = []
+ else:
+ codes = []
+
+ # Delete by IDs first
+ if ids:
+ for pid in ids:
+ try:
+ person = db.query(Person).filter(_and(Person.id == pid, Person.business_id == business_id)).first()
+ if person is None:
+ skipped += 1
+ continue
+ db.delete(person)
+ deleted += 1
+ except Exception:
+ skipped += 1
+ db.commit()
+
+ # Delete by codes
+ if codes:
+ try:
+ items = db.query(Person).filter(_and(Person.business_id == business_id, Person.code.in_(codes))).all()
+ for obj in items:
+ try:
+ db.delete(obj)
+ deleted += 1
+ except Exception:
+ skipped += 1
+ db.commit()
+ except Exception:
+ # In case of query issues, treat all as skipped
+ skipped += len(codes)
+
+ return success_response({"deleted": deleted, "skipped": skipped}, request)
+
+
@router.post("/businesses/{business_id}/persons/create",
summary="ایجاد شخص جدید",
description="ایجاد شخص جدید برای کسب و کار مشخص",
diff --git a/hesabixAPI/adapters/api/v1/schema_models/bank_account.py b/hesabixAPI/adapters/api/v1/schema_models/bank_account.py
new file mode 100644
index 0000000..2b173d8
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/schema_models/bank_account.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from typing import Optional
+from pydantic import BaseModel, Field
+
+
+class BankAccountCreateRequest(BaseModel):
+ code: Optional[str] = Field(default=None, max_length=50)
+ name: str = Field(..., min_length=1, max_length=255)
+ branch: Optional[str] = Field(default=None, max_length=255)
+ account_number: Optional[str] = Field(default=None, max_length=50)
+ sheba_number: Optional[str] = Field(default=None, max_length=30)
+ card_number: Optional[str] = Field(default=None, max_length=20)
+ owner_name: Optional[str] = Field(default=None, max_length=255)
+ pos_number: Optional[str] = Field(default=None, max_length=50)
+ payment_id: Optional[str] = Field(default=None, max_length=100)
+ description: Optional[str] = Field(default=None, max_length=500)
+ currency_id: int = Field(..., ge=1)
+ is_active: bool = Field(default=True)
+ is_default: bool = Field(default=False)
+
+ @classmethod
+ def __get_validators__(cls):
+ yield from super().__get_validators__()
+
+ @classmethod
+ def validate(cls, value): # type: ignore[override]
+ obj = super().validate(value)
+ if getattr(obj, 'code', None) is not None:
+ code_val = str(getattr(obj, 'code'))
+ if code_val.strip() != '':
+ if not code_val.isdigit():
+ raise ValueError("کد حساب باید فقط عددی باشد")
+ if len(code_val) < 3:
+ raise ValueError("کد حساب باید حداقل ۳ رقم باشد")
+ return obj
+
+
+class BankAccountUpdateRequest(BaseModel):
+ code: Optional[str] = Field(default=None, max_length=50)
+ name: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ branch: Optional[str] = Field(default=None, max_length=255)
+ account_number: Optional[str] = Field(default=None, max_length=50)
+ sheba_number: Optional[str] = Field(default=None, max_length=30)
+ card_number: Optional[str] = Field(default=None, max_length=20)
+ owner_name: Optional[str] = Field(default=None, max_length=255)
+ pos_number: Optional[str] = Field(default=None, max_length=50)
+ payment_id: Optional[str] = Field(default=None, max_length=100)
+ description: Optional[str] = Field(default=None, max_length=500)
+ currency_id: Optional[int] = Field(default=None, ge=1)
+ is_active: Optional[bool] = Field(default=None)
+ is_default: Optional[bool] = Field(default=None)
+
+ @classmethod
+ def __get_validators__(cls):
+ yield from super().__get_validators__()
+
+ @classmethod
+ def validate(cls, value): # type: ignore[override]
+ obj = super().validate(value)
+ if getattr(obj, 'code', None) is not None:
+ code_val = str(getattr(obj, 'code'))
+ if code_val.strip() != '':
+ if not code_val.isdigit():
+ raise ValueError("کد حساب باید فقط عددی باشد")
+ if len(code_val) < 3:
+ raise ValueError("کد حساب باید حداقل ۳ رقم باشد")
+ return obj
+
+
+class BankAccountResponse(BaseModel):
+ id: int
+ business_id: int
+ code: Optional[str]
+ name: str
+ branch: Optional[str]
+ account_number: Optional[str]
+ sheba_number: Optional[str]
+ card_number: Optional[str]
+ owner_name: Optional[str]
+ pos_number: Optional[str]
+ payment_id: Optional[str]
+ description: Optional[str]
+ currency_id: int
+ is_active: bool
+ is_default: bool
+ created_at: str
+ updated_at: str
+
+ class Config:
+ from_attributes = True
+
+
diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py
index a8d4d3c..56c289a 100644
--- a/hesabixAPI/adapters/db/models/__init__.py
+++ b/hesabixAPI/adapters/db/models/__init__.py
@@ -36,3 +36,4 @@ from .product import Product # noqa: F401
from .price_list import PriceList, PriceItem # noqa: F401
from .product_attribute_link import ProductAttributeLink # noqa: F401
from .tax_unit import TaxUnit # noqa: F401
+from .bank_account import BankAccount # noqa: F401
diff --git a/hesabixAPI/adapters/db/models/bank_account.py b/hesabixAPI/adapters/db/models/bank_account.py
new file mode 100644
index 0000000..5df4665
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/bank_account.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint, Boolean
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class BankAccount(Base):
+ __tablename__ = "bank_accounts"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'code', name='uq_bank_accounts_business_code'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+
+ # اطلاعات اصلی/نمایشی
+ code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True, comment="کد یکتا در هر کسبوکار (اختیاری)")
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام حساب")
+ description: Mapped[str | None] = mapped_column(String(500), nullable=True)
+
+ # اطلاعات بانکی
+ branch: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ account_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ sheba_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
+ card_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ owner_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ pos_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+
+ # تنظیمات
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
+ is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
+
+ # زمانبندی
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # روابط
+ business = relationship("Business", backref="bank_accounts")
+ currency = relationship("Currency", backref="bank_accounts")
+
+
diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py
index b478cab..93c5d78 100644
--- a/hesabixAPI/app/main.py
+++ b/hesabixAPI/app/main.py
@@ -16,6 +16,7 @@ from adapters.api.v1.product_attributes import router as product_attributes_rout
from adapters.api.v1.products import router as products_router
from adapters.api.v1.price_lists import router as price_lists_router
from adapters.api.v1.persons import router as persons_router
+from adapters.api.v1.bank_accounts import router as bank_accounts_router
from adapters.api.v1.tax_units import router as tax_units_router
from adapters.api.v1.tax_units import alias_router as units_alias_router
from adapters.api.v1.tax_types import router as tax_types_router
@@ -292,6 +293,7 @@ def create_app() -> FastAPI:
application.include_router(products_router, prefix=settings.api_v1_prefix)
application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
application.include_router(persons_router, prefix=settings.api_v1_prefix)
+ application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
application.include_router(units_alias_router, prefix=settings.api_v1_prefix)
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
@@ -334,8 +336,9 @@ def create_app() -> FastAPI:
# اضافه کردن security schemes
openapi_schema["components"]["securitySchemes"] = {
"ApiKeyAuth": {
- "type": "http",
- "scheme": "ApiKey",
+ "type": "apiKey",
+ "in": "header",
+ "name": "Authorization",
"description": "کلید API برای احراز هویت. فرمت: ApiKey sk_your_api_key_here"
}
}
@@ -344,8 +347,8 @@ def create_app() -> FastAPI:
for path, methods in openapi_schema["paths"].items():
for method, details in methods.items():
if method in ["get", "post", "put", "delete", "patch"]:
- # تمام endpoint های auth، users و support نیاز به احراز هویت دارند
- if "/auth/" in path or "/users" in path or "/support" in path:
+ # تمام endpoint های auth، users، support و bank-accounts نیاز به احراز هویت دارند
+ if "/auth/" in path or "/users" in path or "/support" in path or "/bank-accounts" in path:
details["security"] = [{"ApiKeyAuth": []}]
application.openapi_schema = openapi_schema
diff --git a/hesabixAPI/app/services/bank_account_service.py b/hesabixAPI/app/services/bank_account_service.py
new file mode 100644
index 0000000..4e47559
--- /dev/null
+++ b/hesabixAPI/app/services/bank_account_service.py
@@ -0,0 +1,255 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, func
+
+from adapters.db.models.bank_account import BankAccount
+from app.core.responses import ApiError
+
+
+def create_bank_account(
+ db: Session,
+ business_id: int,
+ data: Dict[str, Any],
+) -> Dict[str, Any]:
+ # مدیریت کد یکتا در هر کسبوکار (در صورت ارسال)
+ code = data.get("code")
+ if code is not None and str(code).strip() != "":
+ # اعتبارسنجی عددی بودن کد
+ if not str(code).isdigit():
+ raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be numeric", http_status=400)
+ # اعتبارسنجی حداقل طول کد
+ if len(str(code)) < 3:
+ raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be at least 3 digits", http_status=400)
+ exists = db.query(BankAccount).filter(and_(BankAccount.business_id == business_id, BankAccount.code == str(code))).first()
+ if exists:
+ raise ApiError("DUPLICATE_BANK_ACCOUNT_CODE", "Duplicate bank account code", http_status=400)
+ else:
+ # تولید خودکار کد: max + 1 به صورت رشته (حداقل ۳ رقم)
+ max_code = db.query(func.max(BankAccount.code)).filter(BankAccount.business_id == business_id).scalar()
+ try:
+ if max_code is not None and str(max_code).isdigit():
+ next_code_int = int(max_code) + 1
+ else:
+ next_code_int = 100 # شروع از ۱۰۰ برای حداقل ۳ رقم
+
+ # اگر کد کمتر از ۳ رقم است، آن را به ۳ رقم تبدیل کن
+ if next_code_int < 100:
+ next_code_int = 100
+
+ code = str(next_code_int)
+ except Exception:
+ code = "100" # در صورت خطا، حداقل کد ۳ رقمی
+
+ obj = BankAccount(
+ business_id=business_id,
+ code=code,
+ name=data.get("name"),
+ branch=data.get("branch"),
+ account_number=data.get("account_number"),
+ sheba_number=data.get("sheba_number"),
+ card_number=data.get("card_number"),
+ owner_name=data.get("owner_name"),
+ pos_number=data.get("pos_number"),
+ payment_id=data.get("payment_id"),
+ description=data.get("description"),
+ currency_id=int(data.get("currency_id")),
+ is_active=bool(data.get("is_active", True)),
+ is_default=bool(data.get("is_default", False)),
+ )
+
+ # اگر پیش فرض شد، بقیه را غیر پیش فرض کن
+ if obj.is_default:
+ db.query(BankAccount).filter(BankAccount.business_id == business_id, BankAccount.id != obj.id).update({BankAccount.is_default: False})
+
+ db.add(obj)
+ db.commit()
+ db.refresh(obj)
+ return bank_account_to_dict(obj)
+
+
+def get_bank_account_by_id(db: Session, account_id: int) -> Optional[Dict[str, Any]]:
+ obj = db.query(BankAccount).filter(BankAccount.id == account_id).first()
+ return bank_account_to_dict(obj) if obj else None
+
+
+def update_bank_account(db: Session, account_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ obj = db.query(BankAccount).filter(BankAccount.id == account_id).first()
+ if obj is None:
+ return None
+
+ if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
+ if not str(data["code"]).isdigit():
+ raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be numeric", http_status=400)
+ if len(str(data["code"])) < 3:
+ raise ApiError("INVALID_BANK_ACCOUNT_CODE", "Bank account code must be at least 3 digits", http_status=400)
+ exists = db.query(BankAccount).filter(and_(BankAccount.business_id == obj.business_id, BankAccount.code == str(data["code"]), BankAccount.id != obj.id)).first()
+ if exists:
+ raise ApiError("DUPLICATE_BANK_ACCOUNT_CODE", "Duplicate bank account code", http_status=400)
+ obj.code = str(data["code"])
+
+ for field in [
+ "name","branch","account_number","sheba_number","card_number",
+ "owner_name","pos_number","payment_id","description",
+ ]:
+ if field in data:
+ setattr(obj, field, data.get(field))
+
+ if "currency_id" in data and data["currency_id"] is not None:
+ obj.currency_id = int(data["currency_id"]) # TODO: اعتبارسنجی وجود ارز
+
+ if "is_active" in data and data["is_active"] is not None:
+ obj.is_active = bool(data["is_active"])
+ if "is_default" in data and data["is_default"] is not None:
+ obj.is_default = bool(data["is_default"])
+ if obj.is_default:
+ # تنها یک حساب پیشفرض در هر بیزنس
+ db.query(BankAccount).filter(BankAccount.business_id == obj.business_id, BankAccount.id != obj.id).update({BankAccount.is_default: False})
+
+ db.commit()
+ db.refresh(obj)
+ return bank_account_to_dict(obj)
+
+
+def delete_bank_account(db: Session, account_id: int) -> bool:
+ obj = db.query(BankAccount).filter(BankAccount.id == account_id).first()
+ if obj is None:
+ return False
+ db.delete(obj)
+ db.commit()
+ return True
+
+
+def list_bank_accounts(
+ db: Session,
+ business_id: int,
+ query: Dict[str, Any],
+) -> Dict[str, Any]:
+ q = db.query(BankAccount).filter(BankAccount.business_id == business_id)
+
+ # جستجو
+ if query.get("search") and query.get("search_fields"):
+ term = f"%{query['search']}%"
+ from sqlalchemy import or_
+ conditions = []
+ for f in query["search_fields"]:
+ if f == "code":
+ conditions.append(BankAccount.code.ilike(term))
+ elif f == "name":
+ conditions.append(BankAccount.name.ilike(term))
+ elif f == "branch":
+ conditions.append(BankAccount.branch.ilike(term))
+ elif f == "account_number":
+ conditions.append(BankAccount.account_number.ilike(term))
+ elif f == "sheba_number":
+ conditions.append(BankAccount.sheba_number.ilike(term))
+ elif f == "card_number":
+ conditions.append(BankAccount.card_number.ilike(term))
+ elif f == "owner_name":
+ conditions.append(BankAccount.owner_name.ilike(term))
+ elif f == "pos_number":
+ conditions.append(BankAccount.pos_number.ilike(term))
+ elif f == "payment_id":
+ conditions.append(BankAccount.payment_id.ilike(term))
+ if conditions:
+ q = q.filter(or_(*conditions))
+
+ # فیلترها
+ if query.get("filters"):
+ for flt in query["filters"]:
+ prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property')
+ op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator')
+ val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value')
+ if not prop or not op:
+ continue
+ if prop in {"is_active", "is_default"} and op == "=":
+ q = q.filter(getattr(BankAccount, prop) == val)
+ elif prop == "currency_id" and op == "=":
+ q = q.filter(BankAccount.currency_id == val)
+
+ # مرتب سازی
+ sort_by = query.get("sort_by") or "created_at"
+ sort_desc = bool(query.get("sort_desc", True))
+ col = getattr(BankAccount, sort_by, BankAccount.created_at)
+ q = q.order_by(col.desc() if sort_desc else col.asc())
+
+ # صفحهبندی
+ skip = int(query.get("skip", 0))
+ take = int(query.get("take", 20))
+ total = q.count()
+ items = q.offset(skip).limit(take).all()
+
+ return {
+ "items": [bank_account_to_dict(i) for i in items],
+ "pagination": {
+ "total": total,
+ "page": (skip // take) + 1,
+ "per_page": take,
+ "total_pages": (total + take - 1) // take,
+ "has_next": skip + take < total,
+ "has_prev": skip > 0,
+ },
+ "query_info": query,
+ }
+
+
+def bulk_delete_bank_accounts(db: Session, business_id: int, account_ids: List[int]) -> Dict[str, Any]:
+ """
+ حذف گروهی حسابهای بانکی
+ """
+ if not account_ids:
+ return {"deleted": 0, "skipped": 0}
+
+ # بررسی وجود حسابها و دسترسی به کسبوکار
+ accounts = db.query(BankAccount).filter(
+ BankAccount.id.in_(account_ids),
+ BankAccount.business_id == business_id
+ ).all()
+
+ deleted_count = 0
+ skipped_count = 0
+
+ for account in accounts:
+ try:
+ db.delete(account)
+ deleted_count += 1
+ except Exception:
+ skipped_count += 1
+
+ # commit تغییرات
+ try:
+ db.commit()
+ except Exception:
+ db.rollback()
+ raise ApiError("BULK_DELETE_FAILED", "Bulk delete failed for bank accounts", http_status=500)
+
+ return {
+ "deleted": deleted_count,
+ "skipped": skipped_count,
+ "total_requested": len(account_ids)
+ }
+
+
+def bank_account_to_dict(obj: BankAccount) -> Dict[str, Any]:
+ return {
+ "id": obj.id,
+ "business_id": obj.business_id,
+ "code": obj.code,
+ "name": obj.name,
+ "branch": obj.branch,
+ "account_number": obj.account_number,
+ "sheba_number": obj.sheba_number,
+ "card_number": obj.card_number,
+ "owner_name": obj.owner_name,
+ "pos_number": obj.pos_number,
+ "payment_id": obj.payment_id,
+ "description": obj.description,
+ "currency_id": obj.currency_id,
+ "is_active": bool(obj.is_active),
+ "is_default": bool(obj.is_default),
+ "created_at": obj.created_at.isoformat(),
+ "updated_at": obj.updated_at.isoformat(),
+ }
+
+
diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
index 430e268..bea8e82 100644
--- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
+++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
@@ -5,6 +5,7 @@ adapters/api/__init__.py
adapters/api/v1/__init__.py
adapters/api/v1/accounts.py
adapters/api/v1/auth.py
+adapters/api/v1/bank_accounts.py
adapters/api/v1/business_dashboard.py
adapters/api/v1/business_users.py
adapters/api/v1/businesses.py
@@ -23,6 +24,7 @@ adapters/api/v1/admin/email_config.py
adapters/api/v1/admin/file_storage.py
adapters/api/v1/schema_models/__init__.py
adapters/api/v1/schema_models/account.py
+adapters/api/v1/schema_models/bank_account.py
adapters/api/v1/schema_models/email.py
adapters/api/v1/schema_models/file_storage.py
adapters/api/v1/schema_models/person.py
@@ -41,6 +43,7 @@ adapters/db/session.py
adapters/db/models/__init__.py
adapters/db/models/account.py
adapters/db/models/api_key.py
+adapters/db/models/bank_account.py
adapters/db/models/business.py
adapters/db/models/business_permission.py
adapters/db/models/captcha.py
@@ -101,6 +104,8 @@ app/core/settings.py
app/core/smart_normalizer.py
app/services/api_key_service.py
app/services/auth_service.py
+app/services/bank_account_service.py
+app/services/bulk_price_update_service.py
app/services/business_dashboard_service.py
app/services/business_service.py
app/services/captcha_service.py
@@ -122,6 +127,7 @@ hesabix_api.egg-info/dependency_links.txt
hesabix_api.egg-info/requires.txt
hesabix_api.egg-info/top_level.txt
migrations/env.py
+migrations/versions/20250102_000001_seed_support_data.py
migrations/versions/20250117_000003_add_business_table.py
migrations/versions/20250117_000004_add_business_contact_fields.py
migrations/versions/20250117_000005_add_business_geographic_fields.py
@@ -155,6 +161,7 @@ migrations/versions/20250929_000501_add_products_and_pricing.py
migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py
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/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/9f9786ae7191_create_tax_units_table.py
diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.po b/hesabixAPI/locales/en/LC_MESSAGES/messages.po
index db10484..fbcd8a5 100644
--- a/hesabixAPI/locales/en/LC_MESSAGES/messages.po
+++ b/hesabixAPI/locales/en/LC_MESSAGES/messages.po
@@ -20,6 +20,39 @@ msgstr "Request failed"
msgid "VALIDATION_ERROR"
msgstr "Validation error"
+# Banking / Bank Accounts
+msgid "BANK_ACCOUNTS_LIST_FETCHED"
+msgstr "Bank accounts list retrieved successfully"
+
+msgid "BANK_ACCOUNT_CREATED"
+msgstr "Bank account created successfully"
+
+msgid "BANK_ACCOUNT_DETAILS"
+msgstr "Bank account details"
+
+msgid "BANK_ACCOUNT_UPDATED"
+msgstr "Bank account updated successfully"
+
+msgid "BANK_ACCOUNT_DELETED"
+msgstr "Bank account deleted successfully"
+
+msgid "BANK_ACCOUNT_NOT_FOUND"
+msgstr "Bank account not found"
+
+msgid "NO_VALID_IDS_FOR_DELETE"
+msgstr "No valid IDs submitted for deletion"
+
+msgid "BANK_ACCOUNTS_BULK_DELETE_DONE"
+msgstr "Bulk delete completed for bank accounts"
+
+msgid "INVALID_BANK_ACCOUNT_CODE"
+msgstr "Invalid bank account code"
+
+msgid "DUPLICATE_BANK_ACCOUNT_CODE"
+msgstr "Duplicate bank account code"
+
+msgid "BULK_DELETE_FAILED"
+msgstr "Bulk delete failed for bank accounts"
msgid "STRING_TOO_SHORT"
msgstr "String is too short"
diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
index f5417ee..f94faf0 100644
--- a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
+++ b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po
@@ -41,6 +41,39 @@ msgstr "درخواست ناموفق بود"
msgid "VALIDATION_ERROR"
msgstr "خطای اعتبارسنجی"
+# Banking / Bank Accounts
+msgid "BANK_ACCOUNTS_LIST_FETCHED"
+msgstr "لیست حسابهای بانکی با موفقیت دریافت شد"
+
+msgid "BANK_ACCOUNT_CREATED"
+msgstr "حساب بانکی با موفقیت ایجاد شد"
+
+msgid "BANK_ACCOUNT_DETAILS"
+msgstr "جزئیات حساب بانکی"
+
+msgid "BANK_ACCOUNT_UPDATED"
+msgstr "حساب بانکی با موفقیت بهروزرسانی شد"
+
+msgid "BANK_ACCOUNT_DELETED"
+msgstr "حساب بانکی با موفقیت حذف شد"
+
+msgid "BANK_ACCOUNT_NOT_FOUND"
+msgstr "حساب بانکی یافت نشد"
+
+msgid "NO_VALID_IDS_FOR_DELETE"
+msgstr "هیچ شناسه معتبری برای حذف ارسال نشده"
+
+msgid "BANK_ACCOUNTS_BULK_DELETE_DONE"
+msgstr "حذف گروهی حسابهای بانکی انجام شد"
+
+msgid "INVALID_BANK_ACCOUNT_CODE"
+msgstr "کد حساب بانکی نامعتبر است"
+
+msgid "DUPLICATE_BANK_ACCOUNT_CODE"
+msgstr "کد حساب بانکی تکراری است"
+
+msgid "BULK_DELETE_FAILED"
+msgstr "خطا در حذف گروهی حسابهای بانکی"
msgid "STRING_TOO_SHORT"
msgstr "رشته خیلی کوتاه است"
diff --git a/hesabixAPI/migrations/versions/20250102_000001_seed_support_data.py b/hesabixAPI/migrations/versions/20250102_000001_seed_support_data.py
new file mode 100644
index 0000000..36f6934
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250102_000001_seed_support_data.py
@@ -0,0 +1,179 @@
+"""seed_support_data
+
+Revision ID: 20250102_000001
+Revises: 5553f8745c6e
+Create Date: 2025-01-02 00:00:01.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from datetime import datetime
+
+# revision identifiers, used by Alembic.
+revision = '20250102_000001'
+down_revision = '5553f8745c6e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # اضافه کردن دستهبندیهای اولیه
+ categories_table = sa.table('support_categories',
+ sa.column('id', sa.Integer),
+ sa.column('name', sa.String),
+ sa.column('description', sa.Text),
+ sa.column('is_active', sa.Boolean),
+ sa.column('created_at', sa.DateTime),
+ sa.column('updated_at', sa.DateTime)
+ )
+
+ categories_data = [
+ {
+ 'name': 'مشکل فنی',
+ 'description': 'مشکلات فنی و باگها',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'درخواست ویژگی',
+ 'description': 'درخواست ویژگیهای جدید',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'سوال',
+ 'description': 'سوالات عمومی',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'شکایت',
+ 'description': 'شکایات و انتقادات',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'سایر',
+ 'description': 'سایر موارد',
+ 'is_active': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ }
+ ]
+
+ op.bulk_insert(categories_table, categories_data)
+
+ # اضافه کردن اولویتهای اولیه
+ priorities_table = sa.table('support_priorities',
+ sa.column('id', sa.Integer),
+ sa.column('name', sa.String),
+ sa.column('description', sa.Text),
+ sa.column('color', sa.String),
+ sa.column('order', sa.Integer),
+ sa.column('created_at', sa.DateTime),
+ sa.column('updated_at', sa.DateTime)
+ )
+
+ priorities_data = [
+ {
+ 'name': 'کم',
+ 'description': 'اولویت کم',
+ 'color': '#28a745',
+ 'order': 1,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'متوسط',
+ 'description': 'اولویت متوسط',
+ 'color': '#ffc107',
+ 'order': 2,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'بالا',
+ 'description': 'اولویت بالا',
+ 'color': '#fd7e14',
+ 'order': 3,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'فوری',
+ 'description': 'اولویت فوری',
+ 'color': '#dc3545',
+ 'order': 4,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ }
+ ]
+
+ op.bulk_insert(priorities_table, priorities_data)
+
+ # اضافه کردن وضعیتهای اولیه
+ statuses_table = sa.table('support_statuses',
+ sa.column('id', sa.Integer),
+ sa.column('name', sa.String),
+ sa.column('description', sa.Text),
+ sa.column('color', sa.String),
+ sa.column('is_final', sa.Boolean),
+ sa.column('created_at', sa.DateTime),
+ sa.column('updated_at', sa.DateTime)
+ )
+
+ statuses_data = [
+ {
+ 'name': 'باز',
+ 'description': 'تیکت باز و در انتظار پاسخ',
+ 'color': '#007bff',
+ 'is_final': False,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'در حال پیگیری',
+ 'description': 'تیکت در حال بررسی',
+ 'color': '#6f42c1',
+ 'is_final': False,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'در انتظار کاربر',
+ 'description': 'در انتظار پاسخ کاربر',
+ 'color': '#17a2b8',
+ 'is_final': False,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'بسته',
+ 'description': 'تیکت بسته شده',
+ 'color': '#6c757d',
+ 'is_final': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ },
+ {
+ 'name': 'حل شده',
+ 'description': 'مشکل حل شده',
+ 'color': '#28a745',
+ 'is_final': True,
+ 'created_at': datetime.utcnow(),
+ 'updated_at': datetime.utcnow()
+ }
+ ]
+
+ op.bulk_insert(statuses_table, statuses_data)
+
+
+def downgrade() -> None:
+ # حذف دادههای اضافه شده
+ op.execute("DELETE FROM support_statuses")
+ op.execute("DELETE FROM support_priorities")
+ op.execute("DELETE FROM support_categories")
diff --git a/hesabixAPI/migrations/versions/20251002_000101_add_bank_accounts_table.py b/hesabixAPI/migrations/versions/20251002_000101_add_bank_accounts_table.py
new file mode 100644
index 0000000..0621814
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20251002_000101_add_bank_accounts_table.py
@@ -0,0 +1,51 @@
+"""add bank_accounts table
+
+Revision ID: 20251002_000101_add_bank_accounts_table
+Revises: 20251001_001201_merge_heads_drop_currency_tax_units
+Create Date: 2025-10-02 00:01:01.000001
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20251002_000101_add_bank_accounts_table'
+down_revision = '20251001_001201_merge_heads_drop_currency_tax_units'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ 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'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_bank_accounts_currency_id', table_name='bank_accounts')
+ op.drop_index('ix_bank_accounts_business_id', table_name='bank_accounts')
+ op.drop_table('bank_accounts')
+
+
diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart
index 310062b..c0bdd9e 100644
--- a/hesabixUI/hesabix_ui/lib/main.dart
+++ b/hesabixUI/hesabix_ui/lib/main.dart
@@ -23,6 +23,7 @@ import 'pages/business/business_shell.dart';
import 'pages/business/dashboard/business_dashboard_page.dart';
import 'pages/business/users_permissions_page.dart';
import 'pages/business/accounts_page.dart';
+import 'pages/business/bank_accounts_page.dart';
import 'pages/business/settings_page.dart';
import 'pages/business/persons_page.dart';
import 'pages/business/product_attributes_page.dart';
@@ -553,7 +554,10 @@ class _MyAppState extends State {
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
- child: AccountsPage(businessId: businessId),
+ child: BankAccountsPage(
+ businessId: businessId,
+ authStore: _authStore!,
+ ),
);
},
),
diff --git a/hesabixUI/hesabix_ui/lib/models/bank_account_model.dart b/hesabixUI/hesabix_ui/lib/models/bank_account_model.dart
new file mode 100644
index 0000000..af1f4b1
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/models/bank_account_model.dart
@@ -0,0 +1,85 @@
+class BankAccount {
+ final int? id;
+ final int businessId;
+ final String? code;
+ final String name;
+ final String? branch;
+ final String? accountNumber;
+ final String? shebaNumber;
+ final String? cardNumber;
+ final String? ownerName;
+ final String? posNumber;
+ final int currencyId;
+ final String? paymentId;
+ final String? description;
+ final bool isActive;
+ final bool isDefault;
+ final DateTime? createdAt;
+ final DateTime? updatedAt;
+
+ const BankAccount({
+ this.id,
+ required this.businessId,
+ this.code,
+ required this.name,
+ this.branch,
+ this.accountNumber,
+ this.shebaNumber,
+ this.cardNumber,
+ this.ownerName,
+ this.posNumber,
+ required this.currencyId,
+ this.paymentId,
+ this.description,
+ this.isActive = true,
+ this.isDefault = false,
+ this.createdAt,
+ this.updatedAt,
+ });
+
+ factory BankAccount.fromJson(Map json) {
+ return BankAccount(
+ id: json['id'] as int?,
+ businessId: (json['business_id'] ?? json['businessId']) as int,
+ code: json['code'] as String?,
+ name: (json['name'] ?? '') as String,
+ branch: json['branch'] as String?,
+ accountNumber: json['account_number'] as String?,
+ shebaNumber: json['sheba_number'] as String?,
+ cardNumber: json['card_number'] as String?,
+ ownerName: json['owner_name'] as String?,
+ posNumber: json['pos_number'] as String?,
+ currencyId: (json['currency_id'] ?? json['currencyId']) as int,
+ paymentId: json['payment_id'] as String?,
+ description: json['description'] as String?,
+ isActive: (json['is_active'] ?? true) as bool,
+ isDefault: (json['is_default'] ?? false) as bool,
+ 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,
+ 'code': code,
+ 'name': name,
+ 'branch': branch,
+ 'account_number': accountNumber,
+ 'sheba_number': shebaNumber,
+ 'card_number': cardNumber,
+ 'owner_name': ownerName,
+ 'pos_number': posNumber,
+ 'currency_id': currencyId,
+ 'payment_id': paymentId,
+ 'description': description,
+ 'is_active': isActive,
+ 'is_default': isDefault,
+ 'created_at': createdAt?.toIso8601String(),
+ 'updated_at': updatedAt?.toIso8601String(),
+ };
+ }
+}
+
+
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart
new file mode 100644
index 0000000..8fbb169
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart
@@ -0,0 +1,337 @@
+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/bank_account_model.dart';
+import '../../widgets/banking/bank_account_form_dialog.dart';
+import '../../services/bank_account_service.dart';
+import '../../services/currency_service.dart';
+
+class BankAccountsPage extends StatefulWidget {
+ final int businessId;
+ final AuthStore authStore;
+
+ const BankAccountsPage({super.key, required this.businessId, required this.authStore});
+
+ @override
+ State createState() => _BankAccountsPageState();
+}
+
+class _BankAccountsPageState extends State {
+ final _bankAccountService = BankAccountService();
+ final _currencyService = CurrencyService(ApiClient());
+ final GlobalKey _bankAccountsTableKey = 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 (e) {
+ // Handle error silently for now
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final t = AppLocalizations.of(context);
+
+ if (!widget.authStore.canReadSection('bank_accounts')) {
+ return AccessDeniedPage(message: t.accessDenied);
+ }
+
+ return Scaffold(
+ body: DataTableWidget(
+ key: _bankAccountsTableKey,
+ config: _buildDataTableConfig(t),
+ fromJson: BankAccount.fromJson,
+ ),
+ );
+ }
+
+ DataTableConfig _buildDataTableConfig(AppLocalizations t) {
+ return DataTableConfig(
+ endpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts',
+ title: t.accounts,
+ excelEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/excel',
+ pdfEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/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: (account) => (account.code?.toString() ?? '-'),
+ textAlign: TextAlign.center,
+ ),
+ TextColumn(
+ 'name',
+ t.title,
+ width: ColumnWidth.large,
+ formatter: (account) => account.name,
+ ),
+ TextColumn(
+ 'branch',
+ (t.localeName == 'fa') ? 'شعبه' : 'Branch',
+ width: ColumnWidth.medium,
+ formatter: (account) => account.branch ?? '-',
+ ),
+ TextColumn(
+ 'account_number',
+ t.accountNumber,
+ width: ColumnWidth.large,
+ formatter: (account) => account.accountNumber ?? '-',
+ ),
+ TextColumn(
+ 'sheba_number',
+ t.shebaNumber,
+ width: ColumnWidth.large,
+ formatter: (account) => account.shebaNumber ?? '-',
+ ),
+ TextColumn(
+ 'card_number',
+ t.cardNumber,
+ width: ColumnWidth.medium,
+ formatter: (account) => account.cardNumber ?? '-',
+ ),
+ TextColumn(
+ 'owner_name',
+ t.owner,
+ width: ColumnWidth.medium,
+ formatter: (account) => account.ownerName ?? '-',
+ ),
+ TextColumn(
+ 'pos_number',
+ (t.localeName == 'fa') ? 'شماره پوز' : 'POS Number',
+ width: ColumnWidth.medium,
+ formatter: (account) => account.posNumber ?? '-',
+ ),
+ TextColumn(
+ 'currency_id',
+ t.currency,
+ width: ColumnWidth.medium,
+ formatter: (account) => _currencyNames[account.currencyId] ?? ((t.localeName == 'fa') ? 'نامشخص' : 'Unknown'),
+ ),
+ TextColumn(
+ 'is_active',
+ t.active,
+ width: ColumnWidth.small,
+ formatter: (account) => account.isActive ? t.active : t.inactive,
+ ),
+ TextColumn(
+ 'is_default',
+ t.isDefault,
+ width: ColumnWidth.small,
+ formatter: (account) => account.isDefault ? t.yes : t.no,
+ ),
+ ActionColumn(
+ 'actions',
+ t.actions,
+ actions: [
+ DataTableAction(
+ icon: Icons.edit,
+ label: t.edit,
+ onTap: (account) => _editBankAccount(account),
+ ),
+ DataTableAction(
+ icon: Icons.delete,
+ label: t.delete,
+ color: Colors.red,
+ onTap: (account) => _deleteBankAccount(account),
+ ),
+ ],
+ ),
+ ],
+ searchFields: ['code', 'name', 'branch', 'account_number', 'sheba_number', 'card_number', 'owner_name', 'pos_number', 'payment_id'],
+ filterFields: ['is_active', 'is_default', 'currency_id'],
+ defaultPageSize: 20,
+ customHeaderActions: [
+ PermissionButton(
+ section: 'bank_accounts',
+ action: 'add',
+ authStore: widget.authStore,
+ child: Tooltip(
+ message: t.addBankAccount,
+ child: IconButton(
+ onPressed: _addBankAccount,
+ icon: const Icon(Icons.add),
+ ),
+ ),
+ ),
+ if (widget.authStore.canDeleteSection('bank_accounts'))
+ Tooltip(
+ message: t.deleteBankAccounts,
+ child: IconButton(
+ onPressed: () async {
+ final t = AppLocalizations.of(context);
+ try {
+ final state = _bankAccountsTableKey.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 BankAccount && 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.deleteBankAccounts),
+ content: Text(t.deleteBankAccounts),
+ 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