progress in bank accounts
This commit is contained in:
parent
c1f3b8287c
commit
dd3a17fbd8
514
hesabixAPI/adapters/api/v1/bank_accounts.py
Normal file
514
hesabixAPI/adapters/api/v1/bank_accounts.py
Normal file
|
|
@ -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"<td>{escape_val(value)}</td>")
|
||||||
|
rows_html.append(f"<tr>{''.join(tds)}</tr>")
|
||||||
|
|
||||||
|
headers_html = ''.join(f"<th>{escape_val(h)}</th>" for h in headers)
|
||||||
|
|
||||||
|
table_html = f"""
|
||||||
|
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8'>
|
||||||
|
<style>
|
||||||
|
@page {{
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 12mm;
|
||||||
|
@bottom-{ 'left' if is_fa else 'right' } {{
|
||||||
|
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #222;
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #444;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.title {{
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.meta {{
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
}}
|
||||||
|
.table-wrapper {{
|
||||||
|
width: 100%;
|
||||||
|
}}
|
||||||
|
table.report-table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}}
|
||||||
|
thead th {{
|
||||||
|
background: #f0f3f7;
|
||||||
|
border: 1px solid #c7cdd6;
|
||||||
|
padding: 6px 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
tbody td {{
|
||||||
|
border: 1px solid #d7dde6;
|
||||||
|
padding: 5px 4px;
|
||||||
|
vertical-align: top;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
position: running(footer);
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: {'left' if is_fa else 'right'};
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class=\"header\">
|
||||||
|
<div>
|
||||||
|
<div class=\"title\">{title_text}</div>
|
||||||
|
<div class=\"meta\">{label_biz}: {escape_val(business_name)}</div>
|
||||||
|
</div>
|
||||||
|
<div class=\"meta\">{label_date}: {escape_val(now_str)}</div>
|
||||||
|
</div>
|
||||||
|
<div class=\"table-wrapper\">
|
||||||
|
<table class=\"report-table\">
|
||||||
|
<thead>
|
||||||
|
<tr>{headers_html}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{''.join(rows_html)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class=\"footer\">{footer_text}</div>
|
||||||
|
</body>
|
||||||
|
</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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,6 +23,84 @@ from adapters.db.models.business import Business
|
||||||
router = APIRouter(prefix="/persons", tags=["persons"])
|
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",
|
@router.post("/businesses/{business_id}/persons/create",
|
||||||
summary="ایجاد شخص جدید",
|
summary="ایجاد شخص جدید",
|
||||||
description="ایجاد شخص جدید برای کسب و کار مشخص",
|
description="ایجاد شخص جدید برای کسب و کار مشخص",
|
||||||
|
|
|
||||||
93
hesabixAPI/adapters/api/v1/schema_models/bank_account.py
Normal file
93
hesabixAPI/adapters/api/v1/schema_models/bank_account.py
Normal file
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -36,3 +36,4 @@ from .product import Product # noqa: F401
|
||||||
from .price_list import PriceList, PriceItem # noqa: F401
|
from .price_list import PriceList, PriceItem # noqa: F401
|
||||||
from .product_attribute_link import ProductAttributeLink # noqa: F401
|
from .product_attribute_link import ProductAttributeLink # noqa: F401
|
||||||
from .tax_unit import TaxUnit # noqa: F401
|
from .tax_unit import TaxUnit # noqa: F401
|
||||||
|
from .bank_account import BankAccount # noqa: F401
|
||||||
|
|
|
||||||
47
hesabixAPI/adapters/db/models/bank_account.py
Normal file
47
hesabixAPI/adapters/db/models/bank_account.py
Normal file
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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.products import router as products_router
|
||||||
from adapters.api.v1.price_lists import router as price_lists_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.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 router as tax_units_router
|
||||||
from adapters.api.v1.tax_units import alias_router as units_alias_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
|
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(products_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(price_lists_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(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(tax_units_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(units_alias_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)
|
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
@ -334,8 +336,9 @@ def create_app() -> FastAPI:
|
||||||
# اضافه کردن security schemes
|
# اضافه کردن security schemes
|
||||||
openapi_schema["components"]["securitySchemes"] = {
|
openapi_schema["components"]["securitySchemes"] = {
|
||||||
"ApiKeyAuth": {
|
"ApiKeyAuth": {
|
||||||
"type": "http",
|
"type": "apiKey",
|
||||||
"scheme": "ApiKey",
|
"in": "header",
|
||||||
|
"name": "Authorization",
|
||||||
"description": "کلید API برای احراز هویت. فرمت: ApiKey sk_your_api_key_here"
|
"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 path, methods in openapi_schema["paths"].items():
|
||||||
for method, details in methods.items():
|
for method, details in methods.items():
|
||||||
if method in ["get", "post", "put", "delete", "patch"]:
|
if method in ["get", "post", "put", "delete", "patch"]:
|
||||||
# تمام endpoint های auth، users و support نیاز به احراز هویت دارند
|
# تمام endpoint های auth، users، support و bank-accounts نیاز به احراز هویت دارند
|
||||||
if "/auth/" in path or "/users" in path or "/support" in path:
|
if "/auth/" in path or "/users" in path or "/support" in path or "/bank-accounts" in path:
|
||||||
details["security"] = [{"ApiKeyAuth": []}]
|
details["security"] = [{"ApiKeyAuth": []}]
|
||||||
|
|
||||||
application.openapi_schema = openapi_schema
|
application.openapi_schema = openapi_schema
|
||||||
|
|
|
||||||
255
hesabixAPI/app/services/bank_account_service.py
Normal file
255
hesabixAPI/app/services/bank_account_service.py
Normal file
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -5,6 +5,7 @@ adapters/api/__init__.py
|
||||||
adapters/api/v1/__init__.py
|
adapters/api/v1/__init__.py
|
||||||
adapters/api/v1/accounts.py
|
adapters/api/v1/accounts.py
|
||||||
adapters/api/v1/auth.py
|
adapters/api/v1/auth.py
|
||||||
|
adapters/api/v1/bank_accounts.py
|
||||||
adapters/api/v1/business_dashboard.py
|
adapters/api/v1/business_dashboard.py
|
||||||
adapters/api/v1/business_users.py
|
adapters/api/v1/business_users.py
|
||||||
adapters/api/v1/businesses.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/admin/file_storage.py
|
||||||
adapters/api/v1/schema_models/__init__.py
|
adapters/api/v1/schema_models/__init__.py
|
||||||
adapters/api/v1/schema_models/account.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/email.py
|
||||||
adapters/api/v1/schema_models/file_storage.py
|
adapters/api/v1/schema_models/file_storage.py
|
||||||
adapters/api/v1/schema_models/person.py
|
adapters/api/v1/schema_models/person.py
|
||||||
|
|
@ -41,6 +43,7 @@ adapters/db/session.py
|
||||||
adapters/db/models/__init__.py
|
adapters/db/models/__init__.py
|
||||||
adapters/db/models/account.py
|
adapters/db/models/account.py
|
||||||
adapters/db/models/api_key.py
|
adapters/db/models/api_key.py
|
||||||
|
adapters/db/models/bank_account.py
|
||||||
adapters/db/models/business.py
|
adapters/db/models/business.py
|
||||||
adapters/db/models/business_permission.py
|
adapters/db/models/business_permission.py
|
||||||
adapters/db/models/captcha.py
|
adapters/db/models/captcha.py
|
||||||
|
|
@ -101,6 +104,8 @@ app/core/settings.py
|
||||||
app/core/smart_normalizer.py
|
app/core/smart_normalizer.py
|
||||||
app/services/api_key_service.py
|
app/services/api_key_service.py
|
||||||
app/services/auth_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_dashboard_service.py
|
||||||
app/services/business_service.py
|
app/services/business_service.py
|
||||||
app/services/captcha_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/requires.txt
|
||||||
hesabix_api.egg-info/top_level.txt
|
hesabix_api.egg-info/top_level.txt
|
||||||
migrations/env.py
|
migrations/env.py
|
||||||
|
migrations/versions/20250102_000001_seed_support_data.py
|
||||||
migrations/versions/20250117_000003_add_business_table.py
|
migrations/versions/20250117_000003_add_business_table.py
|
||||||
migrations/versions/20250117_000004_add_business_contact_fields.py
|
migrations/versions/20250117_000004_add_business_contact_fields.py
|
||||||
migrations/versions/20250117_000005_add_business_geographic_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_000601_update_price_items_currency_unique_not_null.py
|
||||||
migrations/versions/20251001_001101_drop_price_list_currency_default_unit.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/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/4b2ea782bcb3_merge_heads.py
|
||||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||||
migrations/versions/9f9786ae7191_create_tax_units_table.py
|
migrations/versions/9f9786ae7191_create_tax_units_table.py
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,39 @@ msgstr "Request failed"
|
||||||
|
|
||||||
msgid "VALIDATION_ERROR"
|
msgid "VALIDATION_ERROR"
|
||||||
msgstr "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"
|
msgid "STRING_TOO_SHORT"
|
||||||
msgstr "String is too short"
|
msgstr "String is too short"
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,39 @@ msgstr "درخواست ناموفق بود"
|
||||||
|
|
||||||
msgid "VALIDATION_ERROR"
|
msgid "VALIDATION_ERROR"
|
||||||
msgstr "خطای اعتبارسنجی"
|
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"
|
msgid "STRING_TOO_SHORT"
|
||||||
msgstr "رشته خیلی کوتاه است"
|
msgstr "رشته خیلی کوتاه است"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@ import 'pages/business/business_shell.dart';
|
||||||
import 'pages/business/dashboard/business_dashboard_page.dart';
|
import 'pages/business/dashboard/business_dashboard_page.dart';
|
||||||
import 'pages/business/users_permissions_page.dart';
|
import 'pages/business/users_permissions_page.dart';
|
||||||
import 'pages/business/accounts_page.dart';
|
import 'pages/business/accounts_page.dart';
|
||||||
|
import 'pages/business/bank_accounts_page.dart';
|
||||||
import 'pages/business/settings_page.dart';
|
import 'pages/business/settings_page.dart';
|
||||||
import 'pages/business/persons_page.dart';
|
import 'pages/business/persons_page.dart';
|
||||||
import 'pages/business/product_attributes_page.dart';
|
import 'pages/business/product_attributes_page.dart';
|
||||||
|
|
@ -553,7 +554,10 @@ class _MyAppState extends State<MyApp> {
|
||||||
localeController: controller,
|
localeController: controller,
|
||||||
calendarController: _calendarController!,
|
calendarController: _calendarController!,
|
||||||
themeController: themeController,
|
themeController: themeController,
|
||||||
child: AccountsPage(businessId: businessId),
|
child: BankAccountsPage(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
85
hesabixUI/hesabix_ui/lib/models/bank_account_model.dart
Normal file
85
hesabixUI/hesabix_ui/lib/models/bank_account_model.dart
Normal file
|
|
@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
337
hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart
Normal file
337
hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart
Normal file
|
|
@ -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<BankAccountsPage> createState() => _BankAccountsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankAccountsPageState extends State<BankAccountsPage> {
|
||||||
|
final _bankAccountService = BankAccountService();
|
||||||
|
final _currencyService = CurrencyService(ApiClient());
|
||||||
|
final GlobalKey _bankAccountsTableKey = GlobalKey();
|
||||||
|
Map<int, String> _currencyNames = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCurrencies();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCurrencies() async {
|
||||||
|
try {
|
||||||
|
final currencies = await _currencyService.listBusinessCurrencies(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
);
|
||||||
|
final currencyMap = <int, String>{};
|
||||||
|
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<BankAccount>(
|
||||||
|
key: _bankAccountsTableKey,
|
||||||
|
config: _buildDataTableConfig(t),
|
||||||
|
fromJson: BankAccount.fromJson,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTableConfig<BankAccount> _buildDataTableConfig(AppLocalizations t) {
|
||||||
|
return DataTableConfig<BankAccount>(
|
||||||
|
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<int>?) ?? const <int>[];
|
||||||
|
final items = (state?.getSelectedItems() as List<dynamic>?) ?? const <dynamic>[];
|
||||||
|
if (selectedIndices.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.noRowsSelectedError)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ids = <int>[];
|
||||||
|
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<String, dynamic>) {
|
||||||
|
final id = row['id'];
|
||||||
|
if (id is int) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
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<Map<String, dynamic>>(
|
||||||
|
'/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<void> _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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -6,6 +6,7 @@ import '../../core/calendar_controller.dart';
|
||||||
import '../../theme/theme_controller.dart';
|
import '../../theme/theme_controller.dart';
|
||||||
import '../../widgets/combined_user_menu_button.dart';
|
import '../../widgets/combined_user_menu_button.dart';
|
||||||
import '../../widgets/person/person_form_dialog.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/product/product_form_dialog.dart';
|
||||||
import '../../widgets/category/category_tree_dialog.dart';
|
import '../../widgets/category/category_tree_dialog.dart';
|
||||||
import '../../services/business_dashboard_service.dart';
|
import '../../services/business_dashboard_service.dart';
|
||||||
|
|
@ -705,7 +706,13 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
} else if (child.label == t.productAttributes) {
|
} else if (child.label == t.productAttributes) {
|
||||||
// Navigate to add product attribute
|
// Navigate to add product attribute
|
||||||
} else if (child.label == t.accounts) {
|
} 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) {
|
} else if (child.label == t.pettyCash) {
|
||||||
// Navigate to add petty cash
|
// Navigate to add petty cash
|
||||||
} else if (child.label == t.cashBox) {
|
} else if (child.label == t.cashBox) {
|
||||||
|
|
@ -851,6 +858,13 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (item.label == t.people) {
|
if (item.label == t.people) {
|
||||||
showAddPersonDialog();
|
showAddPersonDialog();
|
||||||
|
} else if (item.label == t.accounts) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => BankAccountFormDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// سایر مسیرهای افزودن در آینده متصل میشوند
|
// سایر مسیرهای افزودن در آینده متصل میشوند
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.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_widget.dart';
|
||||||
import '../../widgets/data_table/data_table_config.dart';
|
import '../../widgets/data_table/data_table_config.dart';
|
||||||
import '../../widgets/person/person_form_dialog.dart';
|
import '../../widgets/person/person_form_dialog.dart';
|
||||||
|
|
@ -278,6 +279,67 @@ class _PersonsPageState extends State<PersonsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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<int>?) ?? const <int>[];
|
||||||
|
final items = (state?.getSelectedItems() as List<dynamic>?) ?? const <dynamic>[];
|
||||||
|
if (selectedIndices.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.noRowsSelectedError)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ids = <int>[];
|
||||||
|
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<String, dynamic>) {
|
||||||
|
final id = row['id'];
|
||||||
|
if (id is int) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
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<Map<String, dynamic>>(
|
||||||
|
'/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) {
|
Builder(builder: (context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
|
|
|
||||||
59
hesabixUI/hesabix_ui/lib/services/bank_account_service.dart
Normal file
59
hesabixUI/hesabix_ui/lib/services/bank_account_service.dart
Normal file
|
|
@ -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<Map<String, dynamic>> list({required int businessId, required Map<String, dynamic> queryInfo}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/bank-accounts/businesses/$businessId/bank-accounts',
|
||||||
|
data: queryInfo,
|
||||||
|
);
|
||||||
|
return (res.data ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BankAccount> create({required int businessId, required Map<String, dynamic> payload}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/bank-accounts/businesses/$businessId/bank-accounts/create',
|
||||||
|
data: payload,
|
||||||
|
);
|
||||||
|
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
return BankAccount.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BankAccount> getById(int id) async {
|
||||||
|
final res = await _client.get<Map<String, dynamic>>('/api/v1/bank-accounts/bank-accounts/$id');
|
||||||
|
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
return BankAccount.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BankAccount> update({required int id, required Map<String, dynamic> payload}) async {
|
||||||
|
final res = await _client.put<Map<String, dynamic>>('/api/v1/bank-accounts/bank-accounts/$id', data: payload);
|
||||||
|
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
return BankAccount.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
await _client.delete<Map<String, dynamic>>('/api/v1/bank-accounts/bank-accounts/$id');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<List<int>>> exportExcel({required int businessId, required Map<String, dynamic> body}) async {
|
||||||
|
return _client.post<List<int>>(
|
||||||
|
'/api/v1/bank-accounts/businesses/$businessId/bank-accounts/export/excel',
|
||||||
|
data: body,
|
||||||
|
responseType: ResponseType.bytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<List<int>>> exportPdf({required int businessId, required Map<String, dynamic> body}) async {
|
||||||
|
return _client.post<List<int>>(
|
||||||
|
'/api/v1/bank-accounts/businesses/$businessId/bank-accounts/export/pdf',
|
||||||
|
data: body,
|
||||||
|
responseType: ResponseType.bytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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<BankAccountFormDialog> createState() => _BankAccountFormDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankAccountFormDialogState extends State<BankAccountFormDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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<int?> 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<CurrencyPickerWidget> createState() => _CurrencyPickerWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CurrencyPickerWidgetState extends State<CurrencyPickerWidget> {
|
||||||
|
final CurrencyService _currencyService = CurrencyService(ApiClient());
|
||||||
|
List<Map<String, dynamic>> _currencies = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCurrencies();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<int>(
|
||||||
|
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<int>(
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue