From dd3a17fbd83cd7624c29d0c36c93f34689cc84cc Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Fri, 3 Oct 2025 02:25:35 +0330 Subject: [PATCH] progress in bank accounts --- hesabixAPI/adapters/api/v1/bank_accounts.py | 514 +++++++++++++++++ hesabixAPI/adapters/api/v1/persons.py | 78 +++ .../api/v1/schema_models/bank_account.py | 93 ++++ hesabixAPI/adapters/db/models/__init__.py | 1 + hesabixAPI/adapters/db/models/bank_account.py | 47 ++ hesabixAPI/app/main.py | 11 +- .../app/services/bank_account_service.py | 255 +++++++++ hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 7 + hesabixAPI/locales/en/LC_MESSAGES/messages.po | 33 ++ hesabixAPI/locales/fa/LC_MESSAGES/messages.po | 33 ++ .../20250102_000001_seed_support_data.py | 179 ++++++ ...20251002_000101_add_bank_accounts_table.py | 51 ++ hesabixUI/hesabix_ui/lib/main.dart | 6 +- .../lib/models/bank_account_model.dart | 85 +++ .../pages/business/bank_accounts_page.dart | 337 +++++++++++ .../lib/pages/business/business_shell.dart | 16 +- .../lib/pages/business/persons_page.dart | 62 +++ .../lib/services/bank_account_service.dart | 59 ++ .../banking/bank_account_form_dialog.dart | 524 ++++++++++++++++++ .../banking/currency_picker_widget.dart | 164 ++++++ 20 files changed, 2549 insertions(+), 6 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/bank_accounts.py create mode 100644 hesabixAPI/adapters/api/v1/schema_models/bank_account.py create mode 100644 hesabixAPI/adapters/db/models/bank_account.py create mode 100644 hesabixAPI/app/services/bank_account_service.py create mode 100644 hesabixAPI/migrations/versions/20250102_000001_seed_support_data.py create mode 100644 hesabixAPI/migrations/versions/20251002_000101_add_bank_accounts_table.py create mode 100644 hesabixUI/hesabix_ui/lib/models/bank_account_model.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/bank_account_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/banking/bank_account_form_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/banking/currency_picker_widget.dart 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""" + + + + + + +
+
+
{title_text}
+
{label_biz}: {escape_val(business_name)}
+
+
{label_date}: {escape_val(now_str)}
+
+
+ + + {headers_html} + + + {''.join(rows_html)} + +
+
+
{footer_text}
+ + + """ + + font_config = FontConfiguration() + pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": "attachment; filename=bank_accounts.pdf", + "Content-Length": str(len(pdf_bytes)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + diff --git a/hesabixAPI/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>( + '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/bulk-delete', + data: { 'ids': ids }, + ); + try { ( _bankAccountsTableKey.currentState as dynamic)?.refresh(); } catch (_) {} + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.bankAccountDeletedSuccessfully))); + } + } catch (e) { + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e'))); + } + } + }, + icon: const Icon(Icons.delete_sweep_outlined), + ), + ), + ], + ); + } + + void _addBankAccount() { + showDialog( + context: context, + builder: (context) => BankAccountFormDialog( + businessId: widget.businessId, + onSuccess: () { + final state = _bankAccountsTableKey.currentState; + try { + // Call public refresh() via dynamic to avoid private state typing + // ignore: avoid_dynamic_calls + (state as dynamic)?.refresh(); + } catch (_) {} + }, + ), + ); + } + + void _editBankAccount(BankAccount account) { + showDialog( + context: context, + builder: (context) => BankAccountFormDialog( + businessId: widget.businessId, + account: account, + onSuccess: () { + final state = _bankAccountsTableKey.currentState; + try { + // ignore: avoid_dynamic_calls + (state as dynamic)?.refresh(); + } catch (_) {} + }, + ), + ); + } + + void _deleteBankAccount(BankAccount account) { + final t = AppLocalizations.of(context); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.deleteBankAccount), + content: Text(t.deleteConfirm(account.name)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(t.cancel), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + await _performDelete(account); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(t.delete), + ), + ], + ), + ); + } + + Future _performDelete(BankAccount account) async { + final t = AppLocalizations.of(context); + try { + await _bankAccountService.delete(account.id!); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.bankAccountDeletedSuccessfully), + backgroundColor: Colors.green, + ), + ); + // Refresh the table after successful deletion + try { + (_bankAccountsTableKey.currentState as dynamic)?.refresh(); + } catch (_) {} + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${t.error}: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index 26d5b50..d1f9193 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -6,6 +6,7 @@ import '../../core/calendar_controller.dart'; import '../../theme/theme_controller.dart'; import '../../widgets/combined_user_menu_button.dart'; import '../../widgets/person/person_form_dialog.dart'; +import '../../widgets/banking/bank_account_form_dialog.dart'; import '../../widgets/product/product_form_dialog.dart'; import '../../widgets/category/category_tree_dialog.dart'; import '../../services/business_dashboard_service.dart'; @@ -705,7 +706,13 @@ class _BusinessShellState extends State { } else if (child.label == t.productAttributes) { // Navigate to add product attribute } else if (child.label == t.accounts) { - // Navigate to add account + // Open add bank account dialog + showDialog( + context: context, + builder: (ctx) => BankAccountFormDialog( + businessId: widget.businessId, + ), + ); } else if (child.label == t.pettyCash) { // Navigate to add petty cash } else if (child.label == t.cashBox) { @@ -851,6 +858,13 @@ class _BusinessShellState extends State { onTap: () { if (item.label == t.people) { showAddPersonDialog(); + } else if (item.label == t.accounts) { + showDialog( + context: context, + builder: (ctx) => BankAccountFormDialog( + businessId: widget.businessId, + ), + ); } // سایر مسیرهای افزودن در آینده متصل می‌شوند }, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index 6bbccda..9a8d671 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -1,5 +1,6 @@ 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/person/person_form_dialog.dart'; @@ -278,6 +279,67 @@ class _PersonsPageState extends State { ), ), ), + if (widget.authStore.canDeleteSection('people')) + Tooltip( + message: AppLocalizations.of(context).deletePerson, + child: IconButton( + onPressed: () async { + final t = AppLocalizations.of(context); + try { + final state = _personsTableKey.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 Person && 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.deletePerson), + content: Text(t.deleteConfirm('${ids.length}')), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)), + FilledButton.tonal(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)), + ], + ), + ); + if (confirm != true) return; + + final client = ApiClient(); + await client.post>( + '/api/v1/persons/businesses/${widget.businessId}/persons/bulk-delete', + data: { 'ids': ids }, + ); + try { ( _personsTableKey.currentState as dynamic)?.refresh(); } catch (_) {} + if (mounted) { + // Reuse generic success text available in l10n + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.productsDeletedSuccessfully))); + } + } catch (e) { + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e'))); + } + } + }, + icon: const Icon(Icons.delete_sweep_outlined), + ), + ), Builder(builder: (context) { final theme = Theme.of(context); return Tooltip( diff --git a/hesabixUI/hesabix_ui/lib/services/bank_account_service.dart b/hesabixUI/hesabix_ui/lib/services/bank_account_service.dart new file mode 100644 index 0000000..70f8839 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/bank_account_service.dart @@ -0,0 +1,59 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; +import '../models/bank_account_model.dart'; + +class BankAccountService { + final ApiClient _client; + BankAccountService({ApiClient? client}) : _client = client ?? ApiClient(); + + Future> list({required int businessId, required Map queryInfo}) async { + final res = await _client.post>( + '/api/v1/bank-accounts/businesses/$businessId/bank-accounts', + data: queryInfo, + ); + return (res.data ?? {}); + } + + Future create({required int businessId, required Map payload}) async { + final res = await _client.post>( + '/api/v1/bank-accounts/businesses/$businessId/bank-accounts/create', + data: payload, + ); + final data = (res.data?['data'] as Map? ?? {}); + return BankAccount.fromJson(data); + } + + Future getById(int id) async { + final res = await _client.get>('/api/v1/bank-accounts/bank-accounts/$id'); + final data = (res.data?['data'] as Map? ?? {}); + return BankAccount.fromJson(data); + } + + Future update({required int id, required Map payload}) async { + final res = await _client.put>('/api/v1/bank-accounts/bank-accounts/$id', data: payload); + final data = (res.data?['data'] as Map? ?? {}); + return BankAccount.fromJson(data); + } + + Future delete(int id) async { + await _client.delete>('/api/v1/bank-accounts/bank-accounts/$id'); + } + + Future>> exportExcel({required int businessId, required Map body}) async { + return _client.post>( + '/api/v1/bank-accounts/businesses/$businessId/bank-accounts/export/excel', + data: body, + responseType: ResponseType.bytes, + ); + } + + Future>> exportPdf({required int businessId, required Map body}) async { + return _client.post>( + '/api/v1/bank-accounts/businesses/$businessId/bank-accounts/export/pdf', + data: body, + responseType: ResponseType.bytes, + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/banking/bank_account_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/banking/bank_account_form_dialog.dart new file mode 100644 index 0000000..5c0c03e --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/banking/bank_account_form_dialog.dart @@ -0,0 +1,524 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../models/bank_account_model.dart'; +import '../../services/bank_account_service.dart'; +import 'currency_picker_widget.dart'; + +class BankAccountFormDialog extends StatefulWidget { + final int businessId; + final BankAccount? account; // null برای افزودن، مقدار برای ویرایش + final VoidCallback? onSuccess; + + const BankAccountFormDialog({ + super.key, + required this.businessId, + this.account, + this.onSuccess, + }); + + @override + State createState() => _BankAccountFormDialogState(); +} + +class _BankAccountFormDialogState extends State { + final _formKey = GlobalKey(); + final _bankAccountService = BankAccountService(); + bool _isLoading = false; + + // Code (unique) controls + final _codeController = TextEditingController(); + bool _autoGenerateCode = true; + + // Controllers for basic info + final _nameController = TextEditingController(); + final _branchController = TextEditingController(); + final _accountNumberController = TextEditingController(); + final _shebaNumberController = TextEditingController(); + final _cardNumberController = TextEditingController(); + final _ownerNameController = TextEditingController(); + final _posNumberController = TextEditingController(); + final _paymentIdController = TextEditingController(); + final _descriptionController = TextEditingController(); + + bool _isActive = true; + bool _isDefault = false; + int? _currencyId; // TODO: wired later to currency picker + + @override + void initState() { + super.initState(); + _initializeForm(); + } + + void _initializeForm() { + if (widget.account != null) { + final account = widget.account!; + if (account.code != null) { + _codeController.text = account.code!; + _autoGenerateCode = false; + } + _nameController.text = account.name; + _branchController.text = account.branch ?? ''; + _accountNumberController.text = account.accountNumber ?? ''; + _shebaNumberController.text = account.shebaNumber ?? ''; + _cardNumberController.text = account.cardNumber ?? ''; + _ownerNameController.text = account.ownerName ?? ''; + _posNumberController.text = account.posNumber ?? ''; + _paymentIdController.text = account.paymentId ?? ''; + _descriptionController.text = account.description ?? ''; + _isActive = account.isActive; + _isDefault = account.isDefault; + _currencyId = account.currencyId; + } + } + + @override + void dispose() { + _codeController.dispose(); + _nameController.dispose(); + _branchController.dispose(); + _accountNumberController.dispose(); + _shebaNumberController.dispose(); + _cardNumberController.dispose(); + _ownerNameController.dispose(); + _posNumberController.dispose(); + _paymentIdController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _saveBankAccount() async { + if (!_formKey.currentState!.validate()) return; + + if (_currencyId == null) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.currency), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final accountData = { + 'code': _autoGenerateCode ? null : _codeController.text.trim().isEmpty ? null : _codeController.text.trim(), + 'name': _nameController.text.trim(), + 'branch': _branchController.text.trim().isEmpty ? null : _branchController.text.trim(), + 'account_number': _accountNumberController.text.trim().isEmpty ? null : _accountNumberController.text.trim(), + 'sheba_number': _shebaNumberController.text.trim().isEmpty ? null : _shebaNumberController.text.trim(), + 'card_number': _cardNumberController.text.trim().isEmpty ? null : _cardNumberController.text.trim(), + 'owner_name': _ownerNameController.text.trim().isEmpty ? null : _ownerNameController.text.trim(), + 'pos_number': _posNumberController.text.trim().isEmpty ? null : _posNumberController.text.trim(), + 'payment_id': _paymentIdController.text.trim().isEmpty ? null : _paymentIdController.text.trim(), + 'description': _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), + 'is_active': _isActive, + 'is_default': _isDefault, + 'currency_id': _currencyId, + }; + + if (widget.account == null) { + // Create new bank account + await _bankAccountService.create( + businessId: widget.businessId, + payload: accountData, + ); + } else { + // Update existing bank account + await _bankAccountService.update( + id: widget.account!.id!, + payload: accountData, + ); + } + + if (mounted) { + Navigator.of(context).pop(); + widget.onSuccess?.call(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.account == null + ? 'حساب بانکی با موفقیت ایجاد شد' + : 'حساب بانکی با موفقیت به‌روزرسانی شد'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final isEditing = widget.account != null; + + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + height: MediaQuery.of(context).size.height * 0.9, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // Header + Row( + children: [ + Icon( + isEditing ? Icons.edit : Icons.add, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 8), + Text( + isEditing ? t.editBankAccount : t.addBankAccount, + style: Theme.of(context).textTheme.headlineSmall, + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const Divider(), + const SizedBox(height: 16), + + // Form with tabs + Expanded( + child: Form( + key: _formKey, + child: DefaultTabController( + length: 3, + child: Column( + children: [ + TabBar( + isScrollable: true, + tabs: [ + Tab(text: t.title), + Tab(text: t.personBankInfo), + Tab(text: t.settings), + ], + ), + const SizedBox(height: 12), + Expanded( + child: TabBarView( + children: [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildBasicInfoFields(t), + ), + ), + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildBankingInfoFields(t), + ), + ), + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildSettingsFields(t), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + + // Actions + const Divider(), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + child: Text(t.cancel), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _isLoading ? null : _saveBankAccount, + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isEditing ? t.update : t.add), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ); + } + + Widget _buildBasicInfoFields(AppLocalizations t) { + return Column( + children: [ + _buildSectionHeader(t.title), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _codeController, + readOnly: _autoGenerateCode, + decoration: InputDecoration( + labelText: t.code, + hintText: t.uniqueCodeNumeric, + suffixIcon: Container( + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 6), + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: ToggleButtons( + isSelected: [_autoGenerateCode, !_autoGenerateCode], + borderRadius: BorderRadius.circular(6), + constraints: const BoxConstraints(minHeight: 32, minWidth: 64), + onPressed: (index) { + setState(() { + _autoGenerateCode = (index == 0); + }); + }, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text(t.automatic), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text(t.manual), + ), + ], + ), + ), + ), + keyboardType: TextInputType.text, + validator: (value) { + if (!_autoGenerateCode) { + if (value == null || value.trim().isEmpty) { + return t.personCodeRequired; + } + if (value.trim().length < 3) { + return t.passwordMinLength; // fallback generic + } + if (!RegExp(r'^\d+$').hasMatch(value.trim())) { + return t.codeMustBeNumeric; + } + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Currency picker + CurrencyPickerWidget( + businessId: widget.businessId, + selectedCurrencyId: _currencyId, + onChanged: (value) { + setState(() { + _currencyId = value; + }); + }, + label: t.currency, + hintText: t.currency, + ), + const SizedBox(height: 16), + + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: t.title, + hintText: t.title, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'نام حساب الزامی است'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: t.description, + hintText: t.description, + ), + maxLines: 3, + ), + ], + ); + } + + Widget _buildBankingInfoFields(AppLocalizations t) { + return Column( + children: [ + _buildSectionHeader(t.personBankInfo), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _branchController, + decoration: InputDecoration( + labelText: (t.localeName == 'fa') ? 'شعبه' : 'Branch', + hintText: (t.localeName == 'fa') ? 'شعبه' : 'Branch', + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _ownerNameController, + decoration: InputDecoration( + labelText: t.owner, + hintText: t.owner, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _accountNumberController, + decoration: InputDecoration( + labelText: t.accountNumber, + hintText: t.accountNumber, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _cardNumberController, + decoration: InputDecoration( + labelText: t.cardNumber, + hintText: t.cardNumber, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _shebaNumberController, + decoration: InputDecoration( + labelText: t.shebaNumber, + hintText: t.shebaNumber, + ), + keyboardType: TextInputType.text, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Za-z]')), + LengthLimitingTextInputFormatter(24), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _posNumberController, + decoration: InputDecoration( + labelText: (t.localeName == 'fa') ? 'شماره پوز' : 'POS Number', + hintText: (t.localeName == 'fa') ? 'شماره پوز' : 'POS Number', + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _paymentIdController, + decoration: InputDecoration( + labelText: t.personPaymentId, + hintText: t.personPaymentId, + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildSettingsFields(AppLocalizations t) { + return Column( + children: [ + _buildSectionHeader(t.settings), + const SizedBox(height: 16), + SwitchListTile( + title: Text(t.active), + subtitle: Text(t.active), + value: _isActive, + onChanged: (value) { + setState(() { + _isActive = value; + }); + }, + ), + const SizedBox(height: 8), + SwitchListTile( + title: Text(t.isDefault), + subtitle: Text(t.defaultConfiguration), + value: _isDefault, + onChanged: (value) { + setState(() { + _isDefault = value; + }); + }, + ), + ], + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/banking/currency_picker_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/banking/currency_picker_widget.dart new file mode 100644 index 0000000..2929e2a --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/banking/currency_picker_widget.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import '../../core/api_client.dart'; +import '../../services/currency_service.dart'; + +class CurrencyPickerWidget extends StatefulWidget { + final int? selectedCurrencyId; + final int businessId; + final ValueChanged onChanged; + final String? label; + final String? hintText; + final bool enabled; + + const CurrencyPickerWidget({ + super.key, + this.selectedCurrencyId, + required this.businessId, + required this.onChanged, + this.label, + this.hintText, + this.enabled = true, + }); + + @override + State createState() => _CurrencyPickerWidgetState(); +} + +class _CurrencyPickerWidgetState extends State { + final CurrencyService _currencyService = CurrencyService(ApiClient()); + List> _currencies = []; + bool _isLoading = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadCurrencies(); + } + + Future _loadCurrencies() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final currencies = await _currencyService.listBusinessCurrencies( + businessId: widget.businessId, + ); + setState(() { + _currencies = currencies; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const SizedBox( + height: 56, + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (_error != null) { + return Container( + height: 56, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.red), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.red), + const SizedBox(width: 8), + Expanded( + child: Text( + 'خطا در بارگذاری ارزها: $_error', + style: const TextStyle(color: Colors.red), + ), + ), + TextButton( + onPressed: _loadCurrencies, + child: const Text('تلاش مجدد'), + ), + ], + ), + ); + } + + if (_currencies.isEmpty) { + return Container( + height: 56, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Text('هیچ ارزی یافت نشد'), + ), + ); + } + + return DropdownButtonFormField( + value: widget.selectedCurrencyId, + onChanged: widget.enabled ? widget.onChanged : null, + decoration: InputDecoration( + labelText: widget.label ?? 'ارز', + hintText: widget.hintText ?? 'انتخاب ارز', + border: const OutlineInputBorder(), + enabled: widget.enabled, + ), + items: _currencies.map((currency) { + final isDefault = currency['is_default'] == true; + return DropdownMenuItem( + value: currency['id'] as int, + child: Row( + children: [ + Expanded( + child: Text( + '${currency['title']} (${currency['code']})', + style: TextStyle( + fontWeight: isDefault ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + if (isDefault) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'پیش‌فرض', + style: TextStyle( + fontSize: 10, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + }).toList(), + validator: (value) { + if (value == null) { + return 'انتخاب ارز الزامی است'; + } + return null; + }, + ); + } +}