some bug fixes
This commit is contained in:
parent
5cc575e3d9
commit
6c1606fe24
|
|
@ -211,9 +211,32 @@ async def export_bank_accounts_excel(
|
||||||
result = list_bank_accounts(db, business_id, query_dict)
|
result = list_bank_accounts(db, business_id, query_dict)
|
||||||
items: List[Dict[str, Any]] = result.get("items", [])
|
items: List[Dict[str, Any]] = result.get("items", [])
|
||||||
items = [format_datetime_fields(item, request) for item in items]
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
# Map currency_id -> currency title for display
|
||||||
|
try:
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
currency_ids = set()
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
try:
|
||||||
|
if cid is not None:
|
||||||
|
currency_ids.add(int(cid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
currency_map: Dict[int, str] = {}
|
||||||
|
if currency_ids:
|
||||||
|
rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
|
||||||
|
currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
it["currency"] = currency_map.get(cid, cid)
|
||||||
|
except Exception:
|
||||||
|
for it in items:
|
||||||
|
if "currency" not in it and "currency_id" in it:
|
||||||
|
it["currency"] = it.get("currency_id")
|
||||||
|
|
||||||
headers: List[str] = [
|
headers: List[str] = [
|
||||||
"code", "name", "branch", "account_number", "sheba_number", "card_number", "owner_name", "pos_number", "is_active", "is_default"
|
"code", "name", "branch", "account_number", "sheba_number", "card_number", "owner_name", "pos_number", "currency", "is_active", "is_default"
|
||||||
]
|
]
|
||||||
|
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
|
|
@ -245,6 +268,12 @@ async def export_bank_accounts_excel(
|
||||||
for row_idx, item in enumerate(items, 2):
|
for row_idx, item in enumerate(items, 2):
|
||||||
for col_idx, key in enumerate(headers, 1):
|
for col_idx, key in enumerate(headers, 1):
|
||||||
value = item.get(key, "")
|
value = item.get(key, "")
|
||||||
|
if key in ("is_active", "is_default"):
|
||||||
|
try:
|
||||||
|
truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
|
||||||
|
except Exception:
|
||||||
|
truthy = bool(value)
|
||||||
|
value = "✓" if truthy else "✗"
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
value = ", ".join(str(v) for v in value)
|
value = ", ".join(str(v) for v in value)
|
||||||
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
|
@ -314,6 +343,29 @@ async def export_bank_accounts_pdf(
|
||||||
items: List[Dict[str, Any]] = result.get("items", [])
|
items: List[Dict[str, Any]] = result.get("items", [])
|
||||||
items = [format_datetime_fields(item, request) for item in items]
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
# Map currency_id -> currency title for display
|
||||||
|
try:
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
currency_ids = set()
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
try:
|
||||||
|
if cid is not None:
|
||||||
|
currency_ids.add(int(cid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
currency_map: Dict[int, str] = {}
|
||||||
|
if currency_ids:
|
||||||
|
rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
|
||||||
|
currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
it["currency"] = currency_map.get(cid, cid)
|
||||||
|
except Exception:
|
||||||
|
for it in items:
|
||||||
|
if "currency" not in it and "currency_id" in it:
|
||||||
|
it["currency"] = it.get("currency_id")
|
||||||
|
|
||||||
# Selection handling
|
# Selection handling
|
||||||
selected_only = bool(body.get('selected_only', False))
|
selected_only = bool(body.get('selected_only', False))
|
||||||
selected_indices = body.get('selected_indices')
|
selected_indices = body.get('selected_indices')
|
||||||
|
|
@ -339,7 +391,10 @@ async def export_bank_accounts_pdf(
|
||||||
key = col.get('key')
|
key = col.get('key')
|
||||||
label = col.get('label', key)
|
label = col.get('label', key)
|
||||||
if key:
|
if key:
|
||||||
keys.append(str(key))
|
if str(key) == 'currency_id':
|
||||||
|
keys.append('currency')
|
||||||
|
else:
|
||||||
|
keys.append(str(key))
|
||||||
headers.append(str(label))
|
headers.append(str(label))
|
||||||
else:
|
else:
|
||||||
if items:
|
if items:
|
||||||
|
|
@ -348,7 +403,7 @@ async def export_bank_accounts_pdf(
|
||||||
else:
|
else:
|
||||||
keys = [
|
keys = [
|
||||||
"code", "name", "branch", "account_number", "sheba_number",
|
"code", "name", "branch", "account_number", "sheba_number",
|
||||||
"card_number", "owner_name", "pos_number", "is_active", "is_default",
|
"card_number", "owner_name", "pos_number", "currency", "is_active", "is_default",
|
||||||
]
|
]
|
||||||
headers = keys
|
headers = keys
|
||||||
|
|
||||||
|
|
@ -403,6 +458,12 @@ async def export_bank_accounts_pdf(
|
||||||
value = ""
|
value = ""
|
||||||
elif isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
value = ", ".join(str(v) for v in value)
|
value = ", ".join(str(v) for v in value)
|
||||||
|
elif key in ("is_active", "is_default"):
|
||||||
|
try:
|
||||||
|
truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
|
||||||
|
except Exception:
|
||||||
|
truthy = bool(value)
|
||||||
|
value = "✓" if truthy else "✗"
|
||||||
tds.append(f"<td>{escape_val(value)}</td>")
|
tds.append(f"<td>{escape_val(value)}</td>")
|
||||||
rows_html.append(f"<tr>{''.join(tds)}</tr>")
|
rows_html.append(f"<tr>{''.join(tds)}</tr>")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,30 @@ async def export_cash_registers_excel(
|
||||||
items: List[Dict[str, Any]] = result.get("items", [])
|
items: List[Dict[str, Any]] = result.get("items", [])
|
||||||
items = [format_datetime_fields(item, request) for item in items]
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
# Map currency_id -> currency title for display
|
||||||
|
try:
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
currency_ids = set()
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
try:
|
||||||
|
if cid is not None:
|
||||||
|
currency_ids.add(int(cid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
currency_map: Dict[int, str] = {}
|
||||||
|
if currency_ids:
|
||||||
|
rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
|
||||||
|
currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
it["currency"] = currency_map.get(cid, cid)
|
||||||
|
except Exception:
|
||||||
|
# In case of any issue, fallback without blocking export
|
||||||
|
for it in items:
|
||||||
|
if "currency" not in it and "currency_id" in it:
|
||||||
|
it["currency"] = it.get("currency_id")
|
||||||
|
|
||||||
headers: List[str] = [
|
headers: List[str] = [
|
||||||
"code", "name", "currency", "is_active", "is_default",
|
"code", "name", "currency", "is_active", "is_default",
|
||||||
"payment_switch_number", "payment_terminal_number", "merchant_id",
|
"payment_switch_number", "payment_terminal_number", "merchant_id",
|
||||||
|
|
@ -233,6 +257,12 @@ async def export_cash_registers_excel(
|
||||||
for row_idx, item in enumerate(items, 2):
|
for row_idx, item in enumerate(items, 2):
|
||||||
for col_idx, key in enumerate(headers, 1):
|
for col_idx, key in enumerate(headers, 1):
|
||||||
value = item.get(key, "")
|
value = item.get(key, "")
|
||||||
|
if key in ("is_active", "is_default"):
|
||||||
|
try:
|
||||||
|
truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
|
||||||
|
except Exception:
|
||||||
|
truthy = bool(value)
|
||||||
|
value = "✓" if truthy else "✗"
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
value = ", ".join(str(v) for v in value)
|
value = ", ".join(str(v) for v in value)
|
||||||
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
|
@ -298,6 +328,29 @@ async def export_cash_registers_pdf(
|
||||||
items: List[Dict[str, Any]] = result.get("items", [])
|
items: List[Dict[str, Any]] = result.get("items", [])
|
||||||
items = [format_datetime_fields(item, request) for item in items]
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
# Map currency_id -> currency title for display
|
||||||
|
try:
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
currency_ids = set()
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
try:
|
||||||
|
if cid is not None:
|
||||||
|
currency_ids.add(int(cid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
currency_map: Dict[int, str] = {}
|
||||||
|
if currency_ids:
|
||||||
|
rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
|
||||||
|
currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
it["currency"] = currency_map.get(cid, cid)
|
||||||
|
except Exception:
|
||||||
|
for it in items:
|
||||||
|
if "currency" not in it and "currency_id" in it:
|
||||||
|
it["currency"] = it.get("currency_id")
|
||||||
|
|
||||||
selected_only = bool(body.get('selected_only', False))
|
selected_only = bool(body.get('selected_only', False))
|
||||||
selected_indices = body.get('selected_indices')
|
selected_indices = body.get('selected_indices')
|
||||||
if selected_only and selected_indices is not None:
|
if selected_only and selected_indices is not None:
|
||||||
|
|
@ -321,11 +374,15 @@ async def export_cash_registers_pdf(
|
||||||
key = col.get('key')
|
key = col.get('key')
|
||||||
label = col.get('label', key)
|
label = col.get('label', key)
|
||||||
if key:
|
if key:
|
||||||
keys.append(str(key))
|
# Replace currency_id key with currency to show human-readable value
|
||||||
|
if str(key) == 'currency_id':
|
||||||
|
keys.append('currency')
|
||||||
|
else:
|
||||||
|
keys.append(str(key))
|
||||||
headers.append(str(label))
|
headers.append(str(label))
|
||||||
else:
|
else:
|
||||||
keys = [
|
keys = [
|
||||||
"code", "name", "currency_id", "is_active", "is_default",
|
"code", "name", "currency", "is_active", "is_default",
|
||||||
"payment_switch_number", "payment_terminal_number", "merchant_id",
|
"payment_switch_number", "payment_terminal_number", "merchant_id",
|
||||||
"description",
|
"description",
|
||||||
]
|
]
|
||||||
|
|
@ -372,6 +429,12 @@ async def export_cash_registers_pdf(
|
||||||
value = ""
|
value = ""
|
||||||
elif isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
value = ", ".join(str(v) for v in value)
|
value = ", ".join(str(v) for v in value)
|
||||||
|
elif key in ("is_active", "is_default"):
|
||||||
|
try:
|
||||||
|
truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
|
||||||
|
except Exception:
|
||||||
|
truthy = bool(value)
|
||||||
|
value = "✓" if truthy else "✗"
|
||||||
tds.append(f"<td>{esc(value)}</td>")
|
tds.append(f"<td>{esc(value)}</td>")
|
||||||
rows_html.append(f"<tr>{''.join(tds)}</tr>")
|
rows_html.append(f"<tr>{''.join(tds)}</tr>")
|
||||||
|
|
||||||
|
|
|
||||||
473
hesabixAPI/adapters/api/v1/petty_cash.py
Normal file
473
hesabixAPI/adapters/api/v1/petty_cash.py
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from fastapi import APIRouter, Depends, Request, Body
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from adapters.db.session import get_db
|
||||||
|
from app.core.auth_dependency import get_current_user, AuthContext
|
||||||
|
from app.core.responses import success_response, format_datetime_fields, ApiError
|
||||||
|
from app.core.permissions import require_business_management_dep, require_business_access
|
||||||
|
from adapters.api.v1.schemas import QueryInfo
|
||||||
|
from app.services.petty_cash_service import (
|
||||||
|
create_petty_cash,
|
||||||
|
update_petty_cash,
|
||||||
|
delete_petty_cash,
|
||||||
|
get_petty_cash_by_id,
|
||||||
|
list_petty_cash,
|
||||||
|
bulk_delete_petty_cash,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/petty-cash", tags=["petty-cash"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/petty-cash",
|
||||||
|
summary="لیست تنخواه گردانها",
|
||||||
|
description="دریافت لیست تنخواه گردانهای یک کسب و کار با امکان جستجو و فیلتر",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def list_petty_cash_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_petty_cash(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="PETTY_CASH_LIST_FETCHED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/petty-cash/create",
|
||||||
|
summary="ایجاد تنخواه گردان جدید",
|
||||||
|
description="ایجاد تنخوان گردان برای یک کسبوکار مشخص",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def create_petty_cash_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = dict(body or {})
|
||||||
|
created = create_petty_cash(db, business_id, payload)
|
||||||
|
return success_response(data=format_datetime_fields(created, request), request=request, message="PETTY_CASH_CREATED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/petty-cash/{petty_cash_id}",
|
||||||
|
summary="جزئیات تنخواه گردان",
|
||||||
|
description="دریافت جزئیات تنخواه گردان بر اساس شناسه",
|
||||||
|
)
|
||||||
|
async def get_petty_cash_endpoint(
|
||||||
|
request: Request,
|
||||||
|
petty_cash_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
result = get_petty_cash_by_id(db, petty_cash_id)
|
||||||
|
if not result:
|
||||||
|
raise ApiError("PETTY_CASH_NOT_FOUND", "Petty cash 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="PETTY_CASH_DETAILS")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/petty-cash/{petty_cash_id}",
|
||||||
|
summary="ویرایش تنخواه گردان",
|
||||||
|
description="ویرایش اطلاعات تنخواه گردان",
|
||||||
|
)
|
||||||
|
async def update_petty_cash_endpoint(
|
||||||
|
request: Request,
|
||||||
|
petty_cash_id: int,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = dict(body or {})
|
||||||
|
result = update_petty_cash(db, petty_cash_id, payload)
|
||||||
|
if result is None:
|
||||||
|
raise ApiError("PETTY_CASH_NOT_FOUND", "Petty cash 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="PETTY_CASH_UPDATED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/petty-cash/{petty_cash_id}",
|
||||||
|
summary="حذف تنخواه گردان",
|
||||||
|
description="حذف یک تنخواه گردان",
|
||||||
|
)
|
||||||
|
async def delete_petty_cash_endpoint(
|
||||||
|
request: Request,
|
||||||
|
petty_cash_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
result = get_petty_cash_by_id(db, petty_cash_id)
|
||||||
|
if result:
|
||||||
|
try:
|
||||||
|
biz_id = int(result.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
ok = delete_petty_cash(db, petty_cash_id)
|
||||||
|
if not ok:
|
||||||
|
raise ApiError("PETTY_CASH_NOT_FOUND", "Petty cash not found", http_status=404)
|
||||||
|
return success_response(data=None, request=request, message="PETTY_CASH_DELETED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/petty-cash/bulk-delete",
|
||||||
|
summary="حذف گروهی تنخواه گردانها",
|
||||||
|
description="حذف چندین تنخواه گردان بر اساس شناسهها",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def bulk_delete_petty_cash_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_petty_cash(db, business_id, ids)
|
||||||
|
return success_response(result, request, message="PETTY_CASH_BULK_DELETE_DONE")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/petty-cash/export/excel",
|
||||||
|
summary="خروجی Excel لیست تنخواه گردانها",
|
||||||
|
description="خروجی Excel لیست تنخواه گردانها با قابلیت فیلتر و مرتبسازی",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def export_petty_cash_excel(
|
||||||
|
business_id: int,
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
import io
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||||
|
from app.core.i18n import negotiate_locale
|
||||||
|
|
||||||
|
query_dict = {
|
||||||
|
"take": int(body.get("take", 1000)),
|
||||||
|
"skip": int(body.get("skip", 0)),
|
||||||
|
"sort_by": body.get("sort_by"),
|
||||||
|
"sort_desc": bool(body.get("sort_desc", False)),
|
||||||
|
"search": body.get("search"),
|
||||||
|
"search_fields": body.get("search_fields"),
|
||||||
|
"filters": body.get("filters"),
|
||||||
|
}
|
||||||
|
result = list_petty_cash(db, business_id, query_dict)
|
||||||
|
items: List[Dict[str, Any]] = result.get("items", [])
|
||||||
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
# Map currency_id -> currency title for display
|
||||||
|
try:
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
currency_ids = set()
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
try:
|
||||||
|
if cid is not None:
|
||||||
|
currency_ids.add(int(cid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
currency_map: Dict[int, str] = {}
|
||||||
|
if currency_ids:
|
||||||
|
rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
|
||||||
|
currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
it["currency"] = currency_map.get(cid, cid)
|
||||||
|
except Exception:
|
||||||
|
# In case of any issue, fallback without blocking export
|
||||||
|
for it in items:
|
||||||
|
if "currency" not in it and "currency_id" in it:
|
||||||
|
it["currency"] = it.get("currency_id")
|
||||||
|
|
||||||
|
headers: List[str] = [
|
||||||
|
"code", "name", "currency", "is_active", "is_default", "description",
|
||||||
|
]
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "PettyCash"
|
||||||
|
|
||||||
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||||
|
if locale == 'fa':
|
||||||
|
try:
|
||||||
|
ws.sheet_view.rightToLeft = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
header_font = Font(bold=True, color="FFFFFF")
|
||||||
|
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||||||
|
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
||||||
|
|
||||||
|
for col_idx, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=1, column=col_idx, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = header_alignment
|
||||||
|
cell.border = border
|
||||||
|
|
||||||
|
for row_idx, item in enumerate(items, 2):
|
||||||
|
for col_idx, key in enumerate(headers, 1):
|
||||||
|
value = item.get(key, "")
|
||||||
|
if key in ("is_active", "is_default"):
|
||||||
|
try:
|
||||||
|
truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
|
||||||
|
except Exception:
|
||||||
|
truthy = bool(value)
|
||||||
|
value = "✓" if truthy else "✗"
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = ", ".join(str(v) for v in value)
|
||||||
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
cell.border = border
|
||||||
|
if locale == 'fa':
|
||||||
|
cell.alignment = Alignment(horizontal="right")
|
||||||
|
|
||||||
|
for column in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
column_letter = column[0].column_letter
|
||||||
|
for cell in column:
|
||||||
|
try:
|
||||||
|
if len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
wb.save(buffer)
|
||||||
|
buffer.seek(0)
|
||||||
|
content = buffer.getvalue()
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": "attachment; filename=petty_cash.xlsx",
|
||||||
|
"Content-Length": str(len(content)),
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/petty-cash/export/pdf",
|
||||||
|
summary="خروجی PDF لیست تنخواه گردانها",
|
||||||
|
description="خروجی PDF لیست تنخواه گردانها با قابلیت فیلتر و مرتبسازی",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def export_petty_cash_pdf(
|
||||||
|
business_id: int,
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from weasyprint import HTML
|
||||||
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
|
from app.core.i18n import negotiate_locale
|
||||||
|
|
||||||
|
query_dict = {
|
||||||
|
"take": int(body.get("take", 1000)),
|
||||||
|
"skip": int(body.get("skip", 0)),
|
||||||
|
"sort_by": body.get("sort_by"),
|
||||||
|
"sort_desc": bool(body.get("sort_desc", False)),
|
||||||
|
"search": body.get("search"),
|
||||||
|
"search_fields": body.get("search_fields"),
|
||||||
|
"filters": body.get("filters"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = list_petty_cash(db, business_id, query_dict)
|
||||||
|
items: List[Dict[str, Any]] = result.get("items", [])
|
||||||
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
# Map currency_id -> currency title for display
|
||||||
|
try:
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
currency_ids = set()
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
try:
|
||||||
|
if cid is not None:
|
||||||
|
currency_ids.add(int(cid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
currency_map: Dict[int, str] = {}
|
||||||
|
if currency_ids:
|
||||||
|
rows = db.query(Currency).filter(Currency.id.in_(list(currency_ids))).all()
|
||||||
|
currency_map = {c.id: (c.title or c.code or str(c.id)) for c in rows}
|
||||||
|
for it in items:
|
||||||
|
cid = it.get("currency_id")
|
||||||
|
it["currency"] = currency_map.get(cid, cid)
|
||||||
|
except Exception:
|
||||||
|
for it in items:
|
||||||
|
if "currency" not in it and "currency_id" in it:
|
||||||
|
it["currency"] = it.get("currency_id")
|
||||||
|
|
||||||
|
selected_only = bool(body.get('selected_only', False))
|
||||||
|
selected_indices = body.get('selected_indices')
|
||||||
|
if selected_only and selected_indices is not None:
|
||||||
|
indices = None
|
||||||
|
if isinstance(selected_indices, str):
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
indices = json.loads(selected_indices)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
indices = None
|
||||||
|
elif isinstance(selected_indices, list):
|
||||||
|
indices = selected_indices
|
||||||
|
if isinstance(indices, list):
|
||||||
|
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
|
||||||
|
|
||||||
|
headers: List[str] = []
|
||||||
|
keys: List[str] = []
|
||||||
|
export_columns = body.get('export_columns')
|
||||||
|
if export_columns:
|
||||||
|
for col in export_columns:
|
||||||
|
key = col.get('key')
|
||||||
|
label = col.get('label', key)
|
||||||
|
if key:
|
||||||
|
# Replace currency_id key with currency to show human-readable value
|
||||||
|
if str(key) == 'currency_id':
|
||||||
|
keys.append('currency')
|
||||||
|
else:
|
||||||
|
keys.append(str(key))
|
||||||
|
headers.append(str(label))
|
||||||
|
else:
|
||||||
|
keys = [
|
||||||
|
"code", "name", "currency", "is_active", "is_default", "description",
|
||||||
|
]
|
||||||
|
headers = keys
|
||||||
|
|
||||||
|
business_name = ""
|
||||||
|
try:
|
||||||
|
from adapters.db.models.business import Business
|
||||||
|
biz = db.query(Business).filter(Business.id == business_id).first()
|
||||||
|
if biz is not None:
|
||||||
|
business_name = biz.name or ""
|
||||||
|
except Exception:
|
||||||
|
business_name = ""
|
||||||
|
|
||||||
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||||
|
is_fa = (locale == 'fa')
|
||||||
|
html_lang = 'fa' if is_fa else 'en'
|
||||||
|
html_dir = 'rtl' if is_fa else 'ltr'
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.core.calendar import CalendarConverter
|
||||||
|
calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
|
||||||
|
formatted_now = CalendarConverter.format_datetime(
|
||||||
|
__import__("datetime").datetime.now(),
|
||||||
|
"jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian",
|
||||||
|
)
|
||||||
|
now_str = formatted_now.get('formatted', formatted_now.get('date_time', ''))
|
||||||
|
except Exception:
|
||||||
|
from datetime import datetime
|
||||||
|
now_str = datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||||
|
|
||||||
|
def esc(v: Any) -> str:
|
||||||
|
try:
|
||||||
|
return str(v).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
except Exception:
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
rows_html: List[str] = []
|
||||||
|
for item in items:
|
||||||
|
tds = []
|
||||||
|
for key in keys:
|
||||||
|
value = item.get(key)
|
||||||
|
if value is None:
|
||||||
|
value = ""
|
||||||
|
elif isinstance(value, list):
|
||||||
|
value = ", ".join(str(v) for v in value)
|
||||||
|
elif key in ("is_active", "is_default"):
|
||||||
|
try:
|
||||||
|
truthy = bool(value) if isinstance(value, bool) else str(value).strip().lower() in ("true", "1", "yes", "y", "t")
|
||||||
|
except Exception:
|
||||||
|
truthy = bool(value)
|
||||||
|
value = "✓" if truthy else "✗"
|
||||||
|
tds.append(f"<td>{esc(value)}</td>")
|
||||||
|
rows_html.append(f"<tr>{''.join(tds)}</tr>")
|
||||||
|
|
||||||
|
headers_html = ''.join(f"<th>{esc(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; }}
|
||||||
|
body {{ font-family: sans-serif; font-size: 11px; color: #222; }}
|
||||||
|
.title {{ font-size: 16px; font-weight: 700; margin-bottom: 10px; }}
|
||||||
|
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; white-space: nowrap; }}
|
||||||
|
tbody td {{ border: 1px solid #d7dde6; padding: 5px 4px; vertical-align: top; overflow-wrap: anywhere; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="title">{esc('گزارش تنخواه گردانها' if is_fa else 'Petty Cash Report')}</div>
|
||||||
|
<div style="margin-bottom:6px;">{esc('نام کسبوکار' if is_fa else 'Business Name')}: {esc(business_name)} | {esc('تاریخ گزارش' if is_fa else 'Report Date')}: {esc(now_str)}</div>
|
||||||
|
<table class="report-table">
|
||||||
|
<thead><tr>{headers_html}</tr></thead>
|
||||||
|
<tbody>{''.join(rows_html)}</tbody>
|
||||||
|
</table>
|
||||||
|
</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=petty_cash.pdf",
|
||||||
|
"Content-Length": str(len(pdf_bytes)),
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -37,3 +37,4 @@ 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
|
from .bank_account import BankAccount # noqa: F401
|
||||||
|
from .petty_cash import PettyCash # noqa: F401
|
||||||
|
|
|
||||||
36
hesabixAPI/adapters/db/models/petty_cash.py
Normal file
36
hesabixAPI/adapters/db/models/petty_cash.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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 PettyCash(Base):
|
||||||
|
__tablename__ = "petty_cash"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('business_id', 'code', name='uq_petty_cash_business_code'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# مشخصات
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام تنخواه گردان")
|
||||||
|
code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True, comment="کد یکتا در هر کسبوکار (اختیاری)")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# تنظیمات
|
||||||
|
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
|
||||||
|
# زمان بندی
|
||||||
|
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="petty_cash")
|
||||||
|
currency = relationship("Currency", backref="petty_cash")
|
||||||
117
hesabixAPI/adapters/db/repositories/petty_cash_repository.py
Normal file
117
hesabixAPI/adapters/db/repositories/petty_cash_repository.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func
|
||||||
|
|
||||||
|
from adapters.db.models.petty_cash import PettyCash
|
||||||
|
|
||||||
|
|
||||||
|
class PettyCashRepository:
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create(self, business_id: int, data: Dict[str, Any]) -> PettyCash:
|
||||||
|
obj = PettyCash(
|
||||||
|
business_id=business_id,
|
||||||
|
name=data.get("name"),
|
||||||
|
code=data.get("code"),
|
||||||
|
description=data.get("description"),
|
||||||
|
currency_id=int(data["currency_id"]),
|
||||||
|
is_active=bool(data.get("is_active", True)),
|
||||||
|
is_default=bool(data.get("is_default", False)),
|
||||||
|
)
|
||||||
|
self.db.add(obj)
|
||||||
|
self.db.flush()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def get_by_id(self, id_: int) -> Optional[PettyCash]:
|
||||||
|
return self.db.query(PettyCash).filter(PettyCash.id == id_).first()
|
||||||
|
|
||||||
|
def update(self, obj: PettyCash, data: Dict[str, Any]) -> PettyCash:
|
||||||
|
for key in [
|
||||||
|
"name","code","description","currency_id","is_active","is_default",
|
||||||
|
]:
|
||||||
|
if key in data and data[key] is not None:
|
||||||
|
setattr(obj, key, data[key] if key != "currency_id" else int(data[key]))
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def delete(self, obj: PettyCash) -> None:
|
||||||
|
self.db.delete(obj)
|
||||||
|
|
||||||
|
def bulk_delete(self, business_id: int, ids: List[int]) -> Dict[str, int]:
|
||||||
|
items = self.db.query(PettyCash).filter(
|
||||||
|
PettyCash.business_id == business_id,
|
||||||
|
PettyCash.id.in_(ids)
|
||||||
|
).all()
|
||||||
|
deleted = 0
|
||||||
|
skipped = 0
|
||||||
|
for it in items:
|
||||||
|
try:
|
||||||
|
self.db.delete(it)
|
||||||
|
deleted += 1
|
||||||
|
except Exception:
|
||||||
|
skipped += 1
|
||||||
|
return {"deleted": deleted, "skipped": skipped, "total_requested": len(ids)}
|
||||||
|
|
||||||
|
def clear_default(self, business_id: int, except_id: Optional[int] = None) -> None:
|
||||||
|
q = self.db.query(PettyCash).filter(PettyCash.business_id == business_id)
|
||||||
|
if except_id is not None:
|
||||||
|
q = q.filter(PettyCash.id != except_id)
|
||||||
|
q.update({PettyCash.is_default: False})
|
||||||
|
|
||||||
|
def list(self, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
q = self.db.query(PettyCash).filter(PettyCash.business_id == business_id)
|
||||||
|
|
||||||
|
# search
|
||||||
|
search = query.get("search")
|
||||||
|
search_fields = query.get("search_fields") or []
|
||||||
|
if search and search_fields:
|
||||||
|
term = f"%{search}%"
|
||||||
|
conditions = []
|
||||||
|
for f in search_fields:
|
||||||
|
if f == "name":
|
||||||
|
conditions.append(PettyCash.name.ilike(term))
|
||||||
|
elif f == "code":
|
||||||
|
conditions.append(PettyCash.code.ilike(term))
|
||||||
|
elif f == "description":
|
||||||
|
conditions.append(PettyCash.description.ilike(term))
|
||||||
|
if conditions:
|
||||||
|
q = q.filter(or_(*conditions))
|
||||||
|
|
||||||
|
# filters
|
||||||
|
for flt in (query.get("filters") or []):
|
||||||
|
prop = flt.get("property")
|
||||||
|
op = flt.get("operator")
|
||||||
|
val = flt.get("value")
|
||||||
|
if not prop or not op:
|
||||||
|
continue
|
||||||
|
if prop in {"is_active","is_default"} and op == "=":
|
||||||
|
q = q.filter(getattr(PettyCash, prop) == val)
|
||||||
|
elif prop == "currency_id" and op == "=":
|
||||||
|
q = q.filter(PettyCash.currency_id == val)
|
||||||
|
|
||||||
|
# sort
|
||||||
|
sort_by = query.get("sort_by") or "created_at"
|
||||||
|
sort_desc = bool(query.get("sort_desc", True))
|
||||||
|
col = getattr(PettyCash, sort_by, PettyCash.created_at)
|
||||||
|
q = q.order_by(col.desc() if sort_desc else col.asc())
|
||||||
|
|
||||||
|
# pagination
|
||||||
|
skip = int(query.get("skip", 0))
|
||||||
|
take = int(query.get("take", 20))
|
||||||
|
total = q.count()
|
||||||
|
items = q.offset(skip).limit(take).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"pagination": {
|
||||||
|
"total": total,
|
||||||
|
"page": (skip // take) + 1,
|
||||||
|
"per_page": take,
|
||||||
|
"total_pages": (total + take - 1) // take,
|
||||||
|
"has_next": skip + take < total,
|
||||||
|
"has_prev": skip > 0,
|
||||||
|
},
|
||||||
|
"query_info": query,
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ 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.bank_accounts import router as bank_accounts_router
|
||||||
from adapters.api.v1.cash_registers import router as cash_registers_router
|
from adapters.api.v1.cash_registers import router as cash_registers_router
|
||||||
|
from adapters.api.v1.petty_cash import router as petty_cash_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
|
||||||
|
|
@ -296,6 +297,7 @@ def create_app() -> FastAPI:
|
||||||
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(bank_accounts_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
|
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
|
||||||
|
application.include_router(petty_cash_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)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ from app.core.responses import ApiError
|
||||||
|
|
||||||
|
|
||||||
def create_cash_register(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
def create_cash_register(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# validate required fields
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if name == "":
|
||||||
|
raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
|
||||||
|
|
||||||
# code uniqueness in business if provided; else auto-generate numeric min 3 digits
|
# code uniqueness in business if provided; else auto-generate numeric min 3 digits
|
||||||
code = data.get("code")
|
code = data.get("code")
|
||||||
if code is not None and str(code).strip() != "":
|
if code is not None and str(code).strip() != "":
|
||||||
|
|
@ -35,7 +40,7 @@ def create_cash_register(db: Session, business_id: int, data: Dict[str, Any]) ->
|
||||||
|
|
||||||
repo = CashRegisterRepository(db)
|
repo = CashRegisterRepository(db)
|
||||||
obj = repo.create(business_id, {
|
obj = repo.create(business_id, {
|
||||||
"name": data.get("name"),
|
"name": name,
|
||||||
"code": code,
|
"code": code,
|
||||||
"description": data.get("description"),
|
"description": data.get("description"),
|
||||||
"currency_id": int(data["currency_id"]),
|
"currency_id": int(data["currency_id"]),
|
||||||
|
|
@ -66,6 +71,12 @@ def update_cash_register(db: Session, id_: int, data: Dict[str, Any]) -> Optiona
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# validate name if provided
|
||||||
|
if "name" in data:
|
||||||
|
name_val = (data.get("name") or "").strip()
|
||||||
|
if name_val == "":
|
||||||
|
raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
|
||||||
|
|
||||||
if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
|
if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
|
||||||
if not str(data["code"]).isdigit():
|
if not str(data["code"]).isdigit():
|
||||||
raise ApiError("INVALID_CASH_CODE", "Cash register code must be numeric", http_status=400)
|
raise ApiError("INVALID_CASH_CODE", "Cash register code must be numeric", http_status=400)
|
||||||
|
|
|
||||||
137
hesabixAPI/app/services/petty_cash_service.py
Normal file
137
hesabixAPI/app/services/petty_cash_service.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
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.petty_cash import PettyCash
|
||||||
|
from adapters.db.repositories.petty_cash_repository import PettyCashRepository
|
||||||
|
from app.core.responses import ApiError
|
||||||
|
|
||||||
|
|
||||||
|
def create_petty_cash(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# validate required fields
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if name == "":
|
||||||
|
raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
|
||||||
|
|
||||||
|
# code uniqueness in business if provided; else auto-generate numeric min 3 digits
|
||||||
|
code = data.get("code")
|
||||||
|
if code is not None and str(code).strip() != "":
|
||||||
|
if not str(code).isdigit():
|
||||||
|
raise ApiError("INVALID_PETTY_CASH_CODE", "Petty cash code must be numeric", http_status=400)
|
||||||
|
if len(str(code)) < 3:
|
||||||
|
raise ApiError("INVALID_PETTY_CASH_CODE", "Petty cash code must be at least 3 digits", http_status=400)
|
||||||
|
exists = db.query(PettyCash).filter(and_(PettyCash.business_id == business_id, PettyCash.code == str(code))).first()
|
||||||
|
if exists:
|
||||||
|
raise ApiError("DUPLICATE_PETTY_CASH_CODE", "Duplicate petty cash code", http_status=400)
|
||||||
|
else:
|
||||||
|
max_code = db.query(func.max(PettyCash.code)).filter(PettyCash.business_id == business_id).scalar()
|
||||||
|
try:
|
||||||
|
if max_code is not None and str(max_code).isdigit():
|
||||||
|
next_code_int = int(max_code) + 1
|
||||||
|
else:
|
||||||
|
next_code_int = 100
|
||||||
|
if next_code_int < 100:
|
||||||
|
next_code_int = 100
|
||||||
|
code = str(next_code_int)
|
||||||
|
except Exception:
|
||||||
|
code = "100"
|
||||||
|
|
||||||
|
repo = PettyCashRepository(db)
|
||||||
|
obj = repo.create(business_id, {
|
||||||
|
"name": name,
|
||||||
|
"code": code,
|
||||||
|
"description": data.get("description"),
|
||||||
|
"currency_id": int(data["currency_id"]),
|
||||||
|
"is_active": bool(data.get("is_active", True)),
|
||||||
|
"is_default": bool(data.get("is_default", False)),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ensure single default
|
||||||
|
if obj.is_default:
|
||||||
|
repo.clear_default(business_id, except_id=obj.id)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return petty_cash_to_dict(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def get_petty_cash_by_id(db: Session, id_: int) -> Optional[Dict[str, Any]]:
|
||||||
|
obj = db.query(PettyCash).filter(PettyCash.id == id_).first()
|
||||||
|
return petty_cash_to_dict(obj) if obj else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_petty_cash(db: Session, id_: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
repo = PettyCashRepository(db)
|
||||||
|
obj = repo.get_by_id(id_)
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# validate name if provided
|
||||||
|
if "name" in data:
|
||||||
|
name_val = (data.get("name") or "").strip()
|
||||||
|
if name_val == "":
|
||||||
|
raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
|
||||||
|
|
||||||
|
if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
|
||||||
|
if not str(data["code"]).isdigit():
|
||||||
|
raise ApiError("INVALID_PETTY_CASH_CODE", "Petty cash code must be numeric", http_status=400)
|
||||||
|
if len(str(data["code"])) < 3:
|
||||||
|
raise ApiError("INVALID_PETTY_CASH_CODE", "Petty cash code must be at least 3 digits", http_status=400)
|
||||||
|
exists = db.query(PettyCash).filter(and_(PettyCash.business_id == obj.business_id, PettyCash.code == str(data["code"]), PettyCash.id != obj.id)).first()
|
||||||
|
if exists:
|
||||||
|
raise ApiError("DUPLICATE_PETTY_CASH_CODE", "Duplicate petty cash code", http_status=400)
|
||||||
|
|
||||||
|
repo.update(obj, data)
|
||||||
|
if obj.is_default:
|
||||||
|
repo.clear_default(obj.business_id, except_id=obj.id)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return petty_cash_to_dict(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_petty_cash(db: Session, id_: int) -> bool:
|
||||||
|
obj = db.query(PettyCash).filter(PettyCash.id == id_).first()
|
||||||
|
if obj is None:
|
||||||
|
return False
|
||||||
|
db.delete(obj)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_delete_petty_cash(db: Session, business_id: int, ids: List[int]) -> Dict[str, Any]:
|
||||||
|
repo = PettyCashRepository(db)
|
||||||
|
result = repo.bulk_delete(business_id, ids)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise ApiError("BULK_DELETE_FAILED", "Bulk delete failed for petty cash", http_status=500)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_petty_cash(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
repo = PettyCashRepository(db)
|
||||||
|
res = repo.list(business_id, query)
|
||||||
|
return {
|
||||||
|
"items": [petty_cash_to_dict(i) for i in res["items"]],
|
||||||
|
"pagination": res["pagination"],
|
||||||
|
"query_info": res["query_info"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def petty_cash_to_dict(obj: PettyCash) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": obj.id,
|
||||||
|
"business_id": obj.business_id,
|
||||||
|
"name": obj.name,
|
||||||
|
"code": obj.code,
|
||||||
|
"description": obj.description,
|
||||||
|
"currency_id": obj.currency_id,
|
||||||
|
"is_active": bool(obj.is_active),
|
||||||
|
"is_default": bool(obj.is_default),
|
||||||
|
"created_at": obj.created_at.isoformat(),
|
||||||
|
"updated_at": obj.updated_at.isoformat(),
|
||||||
|
}
|
||||||
514
hesabixAPI/build/lib/adapters/api/v1/bank_accounts.py
Normal file
514
hesabixAPI/build/lib/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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -250,24 +250,93 @@ def get_business_info_with_permissions(
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""دریافت اطلاعات کسب و کار همراه با دسترسیهای کاربر"""
|
"""دریافت اطلاعات کسب و کار همراه با دسترسیهای کاربر"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info(f"=== get_business_info_with_permissions START ===")
|
||||||
|
logger.info(f"Business ID: {business_id}")
|
||||||
|
logger.info(f"User ID: {ctx.get_user_id()}")
|
||||||
|
logger.info(f"User context business_id: {ctx.business_id}")
|
||||||
|
logger.info(f"Is superadmin: {ctx.is_superadmin()}")
|
||||||
|
logger.info(f"Is business owner: {ctx.is_business_owner(business_id)}")
|
||||||
|
|
||||||
from adapters.db.models.business import Business
|
from adapters.db.models.business import Business
|
||||||
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
|
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
|
||||||
|
|
||||||
# دریافت اطلاعات کسب و کار
|
# دریافت اطلاعات کسب و کار
|
||||||
business = db.get(Business, business_id)
|
business = db.get(Business, business_id)
|
||||||
if not business:
|
if not business:
|
||||||
|
logger.error(f"Business {business_id} not found")
|
||||||
from app.core.responses import ApiError
|
from app.core.responses import ApiError
|
||||||
raise ApiError("NOT_FOUND", "Business not found", http_status=404)
|
raise ApiError("NOT_FOUND", "Business not found", http_status=404)
|
||||||
|
|
||||||
|
logger.info(f"Business found: {business.name} (Owner ID: {business.owner_id})")
|
||||||
|
|
||||||
# دریافت دسترسیهای کاربر
|
# دریافت دسترسیهای کاربر
|
||||||
permissions = {}
|
permissions = {}
|
||||||
if not ctx.is_superadmin() and not ctx.is_business_owner(business_id):
|
|
||||||
|
# Debug logging
|
||||||
|
logger.info(f"Checking permissions for user {ctx.get_user_id()}")
|
||||||
|
logger.info(f"Is superadmin: {ctx.is_superadmin()}")
|
||||||
|
logger.info(f"Is business owner of {business_id}: {ctx.is_business_owner(business_id)}")
|
||||||
|
logger.info(f"Context business_id: {ctx.business_id}")
|
||||||
|
|
||||||
|
if ctx.is_superadmin():
|
||||||
|
logger.info("User is superadmin, but superadmin permissions don't apply to business operations")
|
||||||
|
# SuperAdmin فقط برای مدیریت سیستم است، نه برای کسب و کارهای خاص
|
||||||
|
# باید دسترسیهای کسب و کار را از جدول business_permissions دریافت کند
|
||||||
|
permission_repo = BusinessPermissionRepository(db)
|
||||||
|
business_permission = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id)
|
||||||
|
logger.info(f"Business permission object for superadmin: {business_permission}")
|
||||||
|
|
||||||
|
if business_permission:
|
||||||
|
permissions = business_permission.business_permissions or {}
|
||||||
|
logger.info(f"Superadmin business permissions: {permissions}")
|
||||||
|
else:
|
||||||
|
logger.info("No business permission found for superadmin user")
|
||||||
|
permissions = {}
|
||||||
|
elif ctx.is_business_owner(business_id):
|
||||||
|
logger.info("User is business owner, granting full permissions")
|
||||||
|
# مالک کسب و کار تمام دسترسیها را دارد
|
||||||
|
permissions = {
|
||||||
|
"people": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"products": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"bank_accounts": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"invoices": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
|
||||||
|
"people_transactions": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
|
||||||
|
"expenses_income": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
|
||||||
|
"transfers": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
|
||||||
|
"checks": {"add": True, "edit": True, "view": True, "delete": True, "return": True, "collect": True, "transfer": True},
|
||||||
|
"accounting_documents": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
|
||||||
|
"chart_of_accounts": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"opening_balance": {"edit": True, "view": True},
|
||||||
|
"settings": {"print": True, "users": True, "history": True, "business": True},
|
||||||
|
"categories": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"product_attributes": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"warehouses": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"warehouse_transfers": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
|
||||||
|
"cash": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"petty_cash": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"wallet": {"view": True, "charge": True},
|
||||||
|
"storage": {"view": True, "delete": True},
|
||||||
|
"marketplace": {"buy": True, "view": True, "invoices": True},
|
||||||
|
"price_lists": {"add": True, "edit": True, "view": True, "delete": True},
|
||||||
|
"sms": {"history": True, "templates": True},
|
||||||
|
"join": True
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.info("User is not superadmin and not business owner, checking permissions")
|
||||||
# دریافت دسترسیهای کسب و کار از business_permissions
|
# دریافت دسترسیهای کسب و کار از business_permissions
|
||||||
permission_repo = BusinessPermissionRepository(db)
|
permission_repo = BusinessPermissionRepository(db)
|
||||||
# ترتیب آرگومانها: (user_id, business_id)
|
# ترتیب آرگومانها: (user_id, business_id)
|
||||||
business_permission = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id)
|
business_permission = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id)
|
||||||
|
logger.info(f"Business permission object: {business_permission}")
|
||||||
|
|
||||||
if business_permission:
|
if business_permission:
|
||||||
permissions = business_permission.business_permissions or {}
|
permissions = business_permission.business_permissions or {}
|
||||||
|
logger.info(f"User permissions: {permissions}")
|
||||||
|
else:
|
||||||
|
logger.info("No business permission found for user")
|
||||||
|
|
||||||
business_info = {
|
business_info = {
|
||||||
"id": business.id,
|
"id": business.id,
|
||||||
|
|
@ -281,13 +350,19 @@ def get_business_info_with_permissions(
|
||||||
"created_at": business.created_at.isoformat(),
|
"created_at": business.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is_owner = ctx.is_business_owner(business_id)
|
||||||
|
has_access = ctx.can_access_business(business_id)
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"business_info": business_info,
|
"business_info": business_info,
|
||||||
"user_permissions": permissions,
|
"user_permissions": permissions,
|
||||||
"is_owner": ctx.is_business_owner(business_id),
|
"is_owner": is_owner,
|
||||||
"role": "مالک" if ctx.is_business_owner(business_id) else "عضو",
|
"role": "مالک" if is_owner else "عضو",
|
||||||
"has_access": ctx.can_access_business(business_id)
|
"has_access": has_access
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(f"Response data: {response_data}")
|
||||||
|
logger.info(f"=== get_business_info_with_permissions END ===")
|
||||||
|
|
||||||
formatted_data = format_datetime_fields(response_data, request)
|
formatted_data = format_datetime_fields(response_data, request)
|
||||||
return success_response(formatted_data, request)
|
return success_response(formatted_data, request)
|
||||||
|
|
|
||||||
414
hesabixAPI/build/lib/adapters/api/v1/cash_registers.py
Normal file
414
hesabixAPI/build/lib/adapters/api/v1/cash_registers.py
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from fastapi import APIRouter, Depends, Request, Body
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from adapters.db.session import get_db
|
||||||
|
from app.core.auth_dependency import get_current_user, AuthContext
|
||||||
|
from app.core.responses import success_response, format_datetime_fields, ApiError
|
||||||
|
from app.core.permissions import require_business_management_dep, require_business_access
|
||||||
|
from adapters.api.v1.schemas import QueryInfo
|
||||||
|
from app.services.cash_register_service import (
|
||||||
|
create_cash_register,
|
||||||
|
update_cash_register,
|
||||||
|
delete_cash_register,
|
||||||
|
get_cash_register_by_id,
|
||||||
|
list_cash_registers,
|
||||||
|
bulk_delete_cash_registers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cash-registers", tags=["cash-registers"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/cash-registers",
|
||||||
|
summary="لیست صندوقها",
|
||||||
|
description="دریافت لیست صندوقهای یک کسب و کار با امکان جستجو و فیلتر",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def list_cash_registers_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
query_info: QueryInfo,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
query_dict: Dict[str, Any] = {
|
||||||
|
"take": query_info.take,
|
||||||
|
"skip": query_info.skip,
|
||||||
|
"sort_by": query_info.sort_by,
|
||||||
|
"sort_desc": query_info.sort_desc,
|
||||||
|
"search": query_info.search,
|
||||||
|
"search_fields": query_info.search_fields,
|
||||||
|
"filters": query_info.filters,
|
||||||
|
}
|
||||||
|
result = list_cash_registers(db, business_id, query_dict)
|
||||||
|
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
|
||||||
|
return success_response(data=result, request=request, message="CASH_REGISTERS_LIST_FETCHED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/cash-registers/create",
|
||||||
|
summary="ایجاد صندوق جدید",
|
||||||
|
description="ایجاد صندوق برای یک کسبوکار مشخص",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def create_cash_register_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = dict(body or {})
|
||||||
|
created = create_cash_register(db, business_id, payload)
|
||||||
|
return success_response(data=format_datetime_fields(created, request), request=request, message="CASH_REGISTER_CREATED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/cash-registers/{cash_id}",
|
||||||
|
summary="جزئیات صندوق",
|
||||||
|
description="دریافت جزئیات صندوق بر اساس شناسه",
|
||||||
|
)
|
||||||
|
async def get_cash_register_endpoint(
|
||||||
|
request: Request,
|
||||||
|
cash_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
result = get_cash_register_by_id(db, cash_id)
|
||||||
|
if not result:
|
||||||
|
raise ApiError("CASH_REGISTER_NOT_FOUND", "Cash register not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(result.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CASH_REGISTER_DETAILS")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/cash-registers/{cash_id}",
|
||||||
|
summary="ویرایش صندوق",
|
||||||
|
description="ویرایش اطلاعات صندوق",
|
||||||
|
)
|
||||||
|
async def update_cash_register_endpoint(
|
||||||
|
request: Request,
|
||||||
|
cash_id: int,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = dict(body or {})
|
||||||
|
result = update_cash_register(db, cash_id, payload)
|
||||||
|
if result is None:
|
||||||
|
raise ApiError("CASH_REGISTER_NOT_FOUND", "Cash register not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(result.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CASH_REGISTER_UPDATED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/cash-registers/{cash_id}",
|
||||||
|
summary="حذف صندوق",
|
||||||
|
description="حذف یک صندوق",
|
||||||
|
)
|
||||||
|
async def delete_cash_register_endpoint(
|
||||||
|
request: Request,
|
||||||
|
cash_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
result = get_cash_register_by_id(db, cash_id)
|
||||||
|
if result:
|
||||||
|
try:
|
||||||
|
biz_id = int(result.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
ok = delete_cash_register(db, cash_id)
|
||||||
|
if not ok:
|
||||||
|
raise ApiError("CASH_REGISTER_NOT_FOUND", "Cash register not found", http_status=404)
|
||||||
|
return success_response(data=None, request=request, message="CASH_REGISTER_DELETED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/cash-registers/bulk-delete",
|
||||||
|
summary="حذف گروهی صندوقها",
|
||||||
|
description="حذف چندین صندوق بر اساس شناسهها",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def bulk_delete_cash_registers_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
ids = body.get("ids")
|
||||||
|
if not isinstance(ids, list):
|
||||||
|
ids = []
|
||||||
|
try:
|
||||||
|
ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
|
||||||
|
except Exception:
|
||||||
|
ids = []
|
||||||
|
if not ids:
|
||||||
|
return success_response({"deleted": 0, "skipped": 0, "total_requested": 0}, request, message="NO_VALID_IDS_FOR_DELETE")
|
||||||
|
result = bulk_delete_cash_registers(db, business_id, ids)
|
||||||
|
return success_response(result, request, message="CASH_REGISTERS_BULK_DELETE_DONE")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/cash-registers/export/excel",
|
||||||
|
summary="خروجی Excel لیست صندوقها",
|
||||||
|
description="خروجی Excel لیست صندوقها با قابلیت فیلتر و مرتبسازی",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def export_cash_registers_excel(
|
||||||
|
business_id: int,
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
import io
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||||
|
from app.core.i18n import negotiate_locale
|
||||||
|
|
||||||
|
query_dict = {
|
||||||
|
"take": int(body.get("take", 1000)),
|
||||||
|
"skip": int(body.get("skip", 0)),
|
||||||
|
"sort_by": body.get("sort_by"),
|
||||||
|
"sort_desc": bool(body.get("sort_desc", False)),
|
||||||
|
"search": body.get("search"),
|
||||||
|
"search_fields": body.get("search_fields"),
|
||||||
|
"filters": body.get("filters"),
|
||||||
|
}
|
||||||
|
result = list_cash_registers(db, business_id, query_dict)
|
||||||
|
items: List[Dict[str, Any]] = result.get("items", [])
|
||||||
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
headers: List[str] = [
|
||||||
|
"code", "name", "currency", "is_active", "is_default",
|
||||||
|
"payment_switch_number", "payment_terminal_number", "merchant_id",
|
||||||
|
"description",
|
||||||
|
]
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "CashRegisters"
|
||||||
|
|
||||||
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||||
|
if locale == 'fa':
|
||||||
|
try:
|
||||||
|
ws.sheet_view.rightToLeft = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
header_font = Font(bold=True, color="FFFFFF")
|
||||||
|
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||||||
|
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
||||||
|
|
||||||
|
for col_idx, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=1, column=col_idx, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = header_alignment
|
||||||
|
cell.border = border
|
||||||
|
|
||||||
|
for row_idx, item in enumerate(items, 2):
|
||||||
|
for col_idx, key in enumerate(headers, 1):
|
||||||
|
value = item.get(key, "")
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = ", ".join(str(v) for v in value)
|
||||||
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
cell.border = border
|
||||||
|
if locale == 'fa':
|
||||||
|
cell.alignment = Alignment(horizontal="right")
|
||||||
|
|
||||||
|
for column in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
column_letter = column[0].column_letter
|
||||||
|
for cell in column:
|
||||||
|
try:
|
||||||
|
if len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
wb.save(buffer)
|
||||||
|
buffer.seek(0)
|
||||||
|
content = buffer.getvalue()
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": "attachment; filename=cash_registers.xlsx",
|
||||||
|
"Content-Length": str(len(content)),
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/cash-registers/export/pdf",
|
||||||
|
summary="خروجی PDF لیست صندوقها",
|
||||||
|
description="خروجی PDF لیست صندوقها با قابلیت فیلتر و مرتبسازی",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def export_cash_registers_pdf(
|
||||||
|
business_id: int,
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from weasyprint import HTML
|
||||||
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
|
from app.core.i18n import negotiate_locale
|
||||||
|
|
||||||
|
query_dict = {
|
||||||
|
"take": int(body.get("take", 1000)),
|
||||||
|
"skip": int(body.get("skip", 0)),
|
||||||
|
"sort_by": body.get("sort_by"),
|
||||||
|
"sort_desc": bool(body.get("sort_desc", False)),
|
||||||
|
"search": body.get("search"),
|
||||||
|
"search_fields": body.get("search_fields"),
|
||||||
|
"filters": body.get("filters"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = list_cash_registers(db, business_id, query_dict)
|
||||||
|
items: List[Dict[str, Any]] = result.get("items", [])
|
||||||
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
selected_only = bool(body.get('selected_only', False))
|
||||||
|
selected_indices = body.get('selected_indices')
|
||||||
|
if selected_only and selected_indices is not None:
|
||||||
|
indices = None
|
||||||
|
if isinstance(selected_indices, str):
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
indices = json.loads(selected_indices)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
indices = None
|
||||||
|
elif isinstance(selected_indices, list):
|
||||||
|
indices = selected_indices
|
||||||
|
if isinstance(indices, list):
|
||||||
|
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
|
||||||
|
|
||||||
|
headers: List[str] = []
|
||||||
|
keys: List[str] = []
|
||||||
|
export_columns = body.get('export_columns')
|
||||||
|
if export_columns:
|
||||||
|
for col in export_columns:
|
||||||
|
key = col.get('key')
|
||||||
|
label = col.get('label', key)
|
||||||
|
if key:
|
||||||
|
keys.append(str(key))
|
||||||
|
headers.append(str(label))
|
||||||
|
else:
|
||||||
|
keys = [
|
||||||
|
"code", "name", "currency_id", "is_active", "is_default",
|
||||||
|
"payment_switch_number", "payment_terminal_number", "merchant_id",
|
||||||
|
"description",
|
||||||
|
]
|
||||||
|
headers = keys
|
||||||
|
|
||||||
|
business_name = ""
|
||||||
|
try:
|
||||||
|
from adapters.db.models.business import Business
|
||||||
|
biz = db.query(Business).filter(Business.id == business_id).first()
|
||||||
|
if biz is not None:
|
||||||
|
business_name = biz.name or ""
|
||||||
|
except Exception:
|
||||||
|
business_name = ""
|
||||||
|
|
||||||
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||||
|
is_fa = (locale == 'fa')
|
||||||
|
html_lang = 'fa' if is_fa else 'en'
|
||||||
|
html_dir = 'rtl' if is_fa else 'ltr'
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.core.calendar import CalendarConverter
|
||||||
|
calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
|
||||||
|
formatted_now = CalendarConverter.format_datetime(
|
||||||
|
__import__("datetime").datetime.now(),
|
||||||
|
"jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian",
|
||||||
|
)
|
||||||
|
now_str = formatted_now.get('formatted', formatted_now.get('date_time', ''))
|
||||||
|
except Exception:
|
||||||
|
from datetime import datetime
|
||||||
|
now_str = datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||||
|
|
||||||
|
def esc(v: Any) -> str:
|
||||||
|
try:
|
||||||
|
return str(v).replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
except Exception:
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
rows_html: List[str] = []
|
||||||
|
for item in items:
|
||||||
|
tds = []
|
||||||
|
for key in keys:
|
||||||
|
value = item.get(key)
|
||||||
|
if value is None:
|
||||||
|
value = ""
|
||||||
|
elif isinstance(value, list):
|
||||||
|
value = ", ".join(str(v) for v in value)
|
||||||
|
tds.append(f"<td>{esc(value)}</td>")
|
||||||
|
rows_html.append(f"<tr>{''.join(tds)}</tr>")
|
||||||
|
|
||||||
|
headers_html = ''.join(f"<th>{esc(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; }}
|
||||||
|
body {{ font-family: sans-serif; font-size: 11px; color: #222; }}
|
||||||
|
.title {{ font-size: 16px; font-weight: 700; margin-bottom: 10px; }}
|
||||||
|
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; white-space: nowrap; }}
|
||||||
|
tbody td {{ border: 1px solid #d7dde6; padding: 5px 4px; vertical-align: top; overflow-wrap: anywhere; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="title">{esc('گزارش صندوقها' if is_fa else 'Cash Registers Report')}</div>
|
||||||
|
<div style="margin-bottom:6px;">{esc('نام کسبوکار' if is_fa else 'Business Name')}: {esc(business_name)} | {esc('تاریخ گزارش' if is_fa else 'Report Date')}: {esc(now_str)}</div>
|
||||||
|
<table class="report-table">
|
||||||
|
<thead><tr>{headers_html}</tr></thead>
|
||||||
|
<tbody>{''.join(rows_html)}</tbody>
|
||||||
|
</table>
|
||||||
|
</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=cash_registers.pdf",
|
||||||
|
"Content-Length": str(len(pdf_bytes)),
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -146,3 +146,39 @@ def delete_category(
|
||||||
return success_response({"deleted": ok}, request)
|
return success_response({"deleted": ok}, request)
|
||||||
|
|
||||||
|
|
||||||
|
# Server-side search categories with breadcrumb path
|
||||||
|
@router.post("/business/{business_id}/search")
|
||||||
|
@require_business_access("business_id")
|
||||||
|
def search_categories(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
body: Dict[str, Any] | None = None,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if not ctx.can_read_section("categories"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing business permission: categories.view", http_status=403)
|
||||||
|
q = (body or {}).get("query") if isinstance(body, dict) else None
|
||||||
|
limit = (body or {}).get("limit") if isinstance(body, dict) else None
|
||||||
|
if not isinstance(q, str) or not q.strip():
|
||||||
|
return success_response({"items": []}, request)
|
||||||
|
try:
|
||||||
|
limit_int = int(limit) if isinstance(limit, int) or (isinstance(limit, str) and str(limit).isdigit()) else 50
|
||||||
|
limit_int = max(1, min(limit_int, 200))
|
||||||
|
except Exception:
|
||||||
|
limit_int = 50
|
||||||
|
repo = CategoryRepository(db)
|
||||||
|
items = repo.search_with_paths(business_id=business_id, query=q.strip(), limit=limit_int)
|
||||||
|
# map label consistently
|
||||||
|
mapped = [
|
||||||
|
{
|
||||||
|
"id": it.get("id"),
|
||||||
|
"parent_id": it.get("parent_id"),
|
||||||
|
"label": it.get("title") or "",
|
||||||
|
"translations": it.get("translations") or {},
|
||||||
|
"path": it.get("path") or [],
|
||||||
|
}
|
||||||
|
for it in items
|
||||||
|
]
|
||||||
|
return success_response({"items": mapped}, request)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from adapters.db.session import get_db
|
from adapters.db.session import get_db
|
||||||
from adapters.db.models.currency import Currency
|
from adapters.db.models.currency import Currency
|
||||||
from app.core.responses import success_response
|
from app.core.responses import success_response
|
||||||
|
from app.core.responses import ApiError
|
||||||
|
from app.core.auth_dependency import get_current_user, AuthContext
|
||||||
|
from app.core.permissions import require_business_access
|
||||||
|
from adapters.db.models.business import Business
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/currencies", tags=["currencies"])
|
router = APIRouter(prefix="/currencies", tags=["currencies"])
|
||||||
|
|
@ -28,3 +32,60 @@ def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict:
|
||||||
return success_response(items, request)
|
return success_response(items, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/business/{business_id}",
|
||||||
|
summary="فهرست ارزهای کسبوکار",
|
||||||
|
description="دریافت ارز پیشفرض کسبوکار بهعلاوه ارزهای فعال آن کسبوکار (بدون تکرار)",
|
||||||
|
)
|
||||||
|
@require_business_access()
|
||||||
|
def list_business_currencies(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
business = (
|
||||||
|
db.query(Business)
|
||||||
|
.options(
|
||||||
|
joinedload(Business.default_currency),
|
||||||
|
joinedload(Business.currencies),
|
||||||
|
)
|
||||||
|
.filter(Business.id == business_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not business:
|
||||||
|
raise ApiError("NOT_FOUND", "کسبوکار یافت نشد", http_status=404)
|
||||||
|
|
||||||
|
seen_ids = set()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
# Add default currency first if exists
|
||||||
|
if business.default_currency:
|
||||||
|
c = business.default_currency
|
||||||
|
result.append({
|
||||||
|
"id": c.id,
|
||||||
|
"name": c.name,
|
||||||
|
"title": c.title,
|
||||||
|
"symbol": c.symbol,
|
||||||
|
"code": c.code,
|
||||||
|
"is_default": True,
|
||||||
|
})
|
||||||
|
seen_ids.add(c.id)
|
||||||
|
|
||||||
|
# Add active business currencies (excluding duplicates)
|
||||||
|
for c in business.currencies or []:
|
||||||
|
if c.id in seen_ids:
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
"id": c.id,
|
||||||
|
"name": c.name,
|
||||||
|
"title": c.title,
|
||||||
|
"symbol": c.symbol,
|
||||||
|
"code": c.code,
|
||||||
|
"is_default": False,
|
||||||
|
})
|
||||||
|
seen_ids.add(c.id)
|
||||||
|
|
||||||
|
# If nothing found, return empty list
|
||||||
|
return success_response(result, request)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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="ایجاد شخص جدید برای کسب و کار مشخص",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ from adapters.api.v1.schemas import QueryInfo
|
||||||
from adapters.api.v1.schema_models.product import (
|
from adapters.api.v1.schema_models.product import (
|
||||||
ProductCreateRequest,
|
ProductCreateRequest,
|
||||||
ProductUpdateRequest,
|
ProductUpdateRequest,
|
||||||
|
BulkPriceUpdateRequest,
|
||||||
|
BulkPriceUpdatePreviewResponse,
|
||||||
)
|
)
|
||||||
from app.services.product_service import (
|
from app.services.product_service import (
|
||||||
create_product,
|
create_product,
|
||||||
|
|
@ -20,8 +22,13 @@ from app.services.product_service import (
|
||||||
update_product,
|
update_product,
|
||||||
delete_product,
|
delete_product,
|
||||||
)
|
)
|
||||||
|
from app.services.bulk_price_update_service import (
|
||||||
|
preview_bulk_price_update,
|
||||||
|
apply_bulk_price_update,
|
||||||
|
)
|
||||||
from adapters.db.models.business import Business
|
from adapters.db.models.business import Business
|
||||||
from app.core.i18n import negotiate_locale
|
from app.core.i18n import negotiate_locale
|
||||||
|
from fastapi import UploadFile, File, Form
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/products", tags=["products"])
|
router = APIRouter(prefix="/products", tags=["products"])
|
||||||
|
|
@ -114,6 +121,64 @@ def delete_product_endpoint(
|
||||||
return success_response({"deleted": ok}, request)
|
return success_response({"deleted": ok}, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/business/{business_id}/bulk-delete",
|
||||||
|
summary="حذف گروهی محصولات",
|
||||||
|
description="حذف چندین آیتم بر اساس شناسهها یا کدها",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
def bulk_delete_products_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
body: Dict[str, Any],
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if not ctx.has_business_permission("inventory", "delete"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
|
||||||
|
|
||||||
|
from sqlalchemy import and_ as _and
|
||||||
|
from adapters.db.models.product import Product
|
||||||
|
|
||||||
|
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):
|
||||||
|
ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
|
||||||
|
else:
|
||||||
|
ids = []
|
||||||
|
if isinstance(codes, list):
|
||||||
|
codes = [str(x).strip() for x in codes if str(x).strip()]
|
||||||
|
else:
|
||||||
|
codes = []
|
||||||
|
|
||||||
|
# Delete by IDs first
|
||||||
|
if ids:
|
||||||
|
for pid in ids:
|
||||||
|
ok = delete_product(db, pid, business_id)
|
||||||
|
if ok:
|
||||||
|
deleted += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
# Delete by codes
|
||||||
|
if codes:
|
||||||
|
items = db.query(Product).filter(_and(Product.business_id == business_id, Product.code.in_(codes))).all()
|
||||||
|
for obj in items:
|
||||||
|
try:
|
||||||
|
db.delete(obj)
|
||||||
|
deleted += 1
|
||||||
|
except Exception:
|
||||||
|
skipped += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return success_response({"deleted": deleted, "skipped": skipped}, request)
|
||||||
|
|
||||||
@router.post("/business/{business_id}/export/excel",
|
@router.post("/business/{business_id}/export/excel",
|
||||||
summary="خروجی Excel لیست محصولات",
|
summary="خروجی Excel لیست محصولات",
|
||||||
description="خروجی Excel لیست محصولات با قابلیت فیلتر، انتخاب ستونها و ترتیب آنها",
|
description="خروجی Excel لیست محصولات با قابلیت فیلتر، انتخاب ستونها و ترتیب آنها",
|
||||||
|
|
@ -267,6 +332,285 @@ async def export_products_excel(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/business/{business_id}/import/template",
|
||||||
|
summary="دانلود تمپلیت ایمپورت محصولات",
|
||||||
|
description="فایل Excel تمپلیت برای ایمپورت کالا/خدمت را برمیگرداند",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def download_products_import_template(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
import io
|
||||||
|
import datetime
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, Alignment
|
||||||
|
|
||||||
|
if not ctx.has_business_permission("inventory", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Template"
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
"code","name","item_type","description","category_id",
|
||||||
|
"main_unit_id","secondary_unit_id","unit_conversion_factor",
|
||||||
|
"base_sales_price","base_purchase_price","track_inventory",
|
||||||
|
"reorder_point","min_order_qty","lead_time_days",
|
||||||
|
"is_sales_taxable","is_purchase_taxable","sales_tax_rate","purchase_tax_rate",
|
||||||
|
"tax_type_id","tax_code","tax_unit_id",
|
||||||
|
# attribute_ids can be comma-separated ids
|
||||||
|
"attribute_ids",
|
||||||
|
]
|
||||||
|
for col, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=1, column=col, value=header)
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.alignment = Alignment(horizontal="center")
|
||||||
|
|
||||||
|
sample = [
|
||||||
|
"P1001","نمونه کالا","کالا","توضیح اختیاری", "",
|
||||||
|
"", "", "",
|
||||||
|
"150000", "120000", "TRUE",
|
||||||
|
"0", "0", "",
|
||||||
|
"FALSE", "FALSE", "", "",
|
||||||
|
"", "", "",
|
||||||
|
"1,2,3",
|
||||||
|
]
|
||||||
|
for col, val in enumerate(sample, 1):
|
||||||
|
ws.cell(row=2, column=col, value=val)
|
||||||
|
|
||||||
|
# Auto width
|
||||||
|
for column in ws.columns:
|
||||||
|
try:
|
||||||
|
letter = column[0].column_letter
|
||||||
|
max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column)
|
||||||
|
ws.column_dimensions[letter].width = min(max_len + 2, 50)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
filename = f"products_import_template_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={filename}",
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/business/{business_id}/import/excel",
|
||||||
|
summary="ایمپورت محصولات از فایل Excel",
|
||||||
|
description="فایل اکسل را دریافت میکند و بهصورت dry-run یا واقعی پردازش میکند",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def import_products_excel(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
dry_run: str = Form(default="true"),
|
||||||
|
match_by: str = Form(default="code"),
|
||||||
|
conflict_policy: str = Form(default="upsert"),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
if not ctx.has_business_permission("inventory", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _validate_excel_signature(content: bytes) -> bool:
|
||||||
|
try:
|
||||||
|
if not content.startswith(b'PK'):
|
||||||
|
return False
|
||||||
|
with zipfile.ZipFile(io.BytesIO(content), 'r') as zf:
|
||||||
|
return any(n.startswith('xl/') for n in zf.namelist())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_dry_run = str(dry_run).lower() in ("true","1","yes","on")
|
||||||
|
|
||||||
|
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
||||||
|
raise ApiError("INVALID_FILE", "فرمت فایل معتبر نیست. تنها xlsx پشتیبانی میشود", http_status=400)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) < 100 or not _validate_excel_signature(content):
|
||||||
|
raise ApiError("INVALID_FILE", "فایل Excel معتبر نیست یا خالی است", http_status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filename=io.BytesIO(content), data_only=True)
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
raise ApiError("INVALID_FILE", "فایل Excel خراب است یا فرمت آن معتبر نیست", http_status=400)
|
||||||
|
|
||||||
|
ws = wb.active
|
||||||
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
if not rows:
|
||||||
|
return success_response(data={"summary": {"total": 0}}, request=request, message="EMPTY_FILE")
|
||||||
|
|
||||||
|
headers = [str(h).strip() if h is not None else "" for h in rows[0]]
|
||||||
|
data_rows = rows[1:]
|
||||||
|
|
||||||
|
def _parse_bool(v: object) -> Optional[bool]:
|
||||||
|
if v is None: return None
|
||||||
|
s = str(v).strip().lower()
|
||||||
|
if s in ("true","1","yes","on","بله","هست"):
|
||||||
|
return True
|
||||||
|
if s in ("false","0","no","off","خیر","نیست"):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_decimal(v: object) -> Optional[Decimal]:
|
||||||
|
if v is None or str(v).strip() == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(str(v).replace(",",""))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_int(v: object) -> Optional[int]:
|
||||||
|
if v is None or str(v).strip() == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(str(v).split(".")[0])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _normalize_item_type(v: object) -> Optional[str]:
|
||||||
|
if v is None: return None
|
||||||
|
s = str(v).strip()
|
||||||
|
mapping = {"product": "کالا", "service": "خدمت"}
|
||||||
|
low = s.lower()
|
||||||
|
if low in mapping: return mapping[low]
|
||||||
|
if s in ("کالا","خدمت"): return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
errors: list[dict] = []
|
||||||
|
valid_items: list[dict] = []
|
||||||
|
|
||||||
|
for idx, row in enumerate(data_rows, start=2):
|
||||||
|
item: dict[str, Any] = {}
|
||||||
|
row_errors: list[str] = []
|
||||||
|
|
||||||
|
for ci, key in enumerate(headers):
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
val = row[ci] if ci < len(row) else None
|
||||||
|
if isinstance(val, str):
|
||||||
|
val = val.strip()
|
||||||
|
item[key] = val
|
||||||
|
|
||||||
|
# normalize & cast
|
||||||
|
if 'item_type' in item:
|
||||||
|
item['item_type'] = _normalize_item_type(item.get('item_type')) or 'کالا'
|
||||||
|
for k in ['base_sales_price','base_purchase_price','sales_tax_rate','purchase_tax_rate','unit_conversion_factor']:
|
||||||
|
if k in item:
|
||||||
|
item[k] = _parse_decimal(item.get(k))
|
||||||
|
for k in ['reorder_point','min_order_qty','lead_time_days','category_id','main_unit_id','secondary_unit_id','tax_type_id','tax_unit_id']:
|
||||||
|
if k in item:
|
||||||
|
item[k] = _parse_int(item.get(k))
|
||||||
|
for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']:
|
||||||
|
if k in item:
|
||||||
|
item[k] = _parse_bool(item.get(k)) if item.get(k) is not None else None
|
||||||
|
|
||||||
|
# attribute_ids: comma-separated
|
||||||
|
if 'attribute_ids' in item and item['attribute_ids']:
|
||||||
|
try:
|
||||||
|
parts = [p.strip() for p in str(item['attribute_ids']).split(',') if p and p.strip()]
|
||||||
|
item['attribute_ids'] = [int(p) for p in parts if p.isdigit()]
|
||||||
|
except Exception:
|
||||||
|
item['attribute_ids'] = []
|
||||||
|
|
||||||
|
# validations
|
||||||
|
name = item.get('name')
|
||||||
|
if not name or str(name).strip() == "":
|
||||||
|
row_errors.append('name الزامی است')
|
||||||
|
|
||||||
|
# if code is empty, it will be auto-generated in service
|
||||||
|
code = item.get('code')
|
||||||
|
if code is not None and str(code).strip() == "":
|
||||||
|
item['code'] = None
|
||||||
|
|
||||||
|
if row_errors:
|
||||||
|
errors.append({"row": idx, "errors": row_errors})
|
||||||
|
continue
|
||||||
|
|
||||||
|
valid_items.append(item)
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
if not is_dry_run and valid_items:
|
||||||
|
from sqlalchemy import and_ as _and
|
||||||
|
from adapters.db.models.product import Product
|
||||||
|
from adapters.api.v1.schema_models.product import ProductCreateRequest, ProductUpdateRequest
|
||||||
|
from app.services.product_service import create_product, update_product
|
||||||
|
|
||||||
|
def _find_existing(session: Session, data: dict) -> Optional[Product]:
|
||||||
|
if match_by == 'code' and data.get('code'):
|
||||||
|
return session.query(Product).filter(_and(Product.business_id == business_id, Product.code == str(data['code']).strip())).first()
|
||||||
|
if match_by == 'name' and data.get('name'):
|
||||||
|
return session.query(Product).filter(_and(Product.business_id == business_id, Product.name == str(data['name']).strip())).first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
for data in valid_items:
|
||||||
|
existing = _find_existing(db, data)
|
||||||
|
if existing is None:
|
||||||
|
try:
|
||||||
|
create_product(db, business_id, ProductCreateRequest(**data))
|
||||||
|
inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Create product failed: {e}")
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
if conflict_policy == 'insert':
|
||||||
|
skipped += 1
|
||||||
|
elif conflict_policy in ('update','upsert'):
|
||||||
|
try:
|
||||||
|
update_product(db, existing.id, business_id, ProductUpdateRequest(**data))
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Update product failed: {e}")
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"total": len(data_rows),
|
||||||
|
"valid": len(valid_items),
|
||||||
|
"invalid": len(errors),
|
||||||
|
"inserted": inserted,
|
||||||
|
"updated": updated,
|
||||||
|
"skipped": skipped,
|
||||||
|
"dry_run": is_dry_run,
|
||||||
|
}
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={"summary": summary, "errors": errors},
|
||||||
|
request=request,
|
||||||
|
message="PRODUCTS_IMPORT_RESULT",
|
||||||
|
)
|
||||||
|
except ApiError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Import error: {e}", exc_info=True)
|
||||||
|
raise ApiError("IMPORT_ERROR", f"خطا در پردازش فایل: {e}", http_status=500)
|
||||||
@router.post("/business/{business_id}/export/pdf",
|
@router.post("/business/{business_id}/export/pdf",
|
||||||
summary="خروجی PDF لیست محصولات",
|
summary="خروجی PDF لیست محصولات",
|
||||||
description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستونها",
|
description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستونها",
|
||||||
|
|
@ -507,3 +851,41 @@ async def export_products_pdf(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/business/{business_id}/bulk-price-update/preview",
|
||||||
|
summary="پیشنمایش تغییر قیمتهای گروهی",
|
||||||
|
description="پیشنمایش تغییرات قیمت قبل از اعمال",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
def preview_bulk_price_update_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
payload: BulkPriceUpdateRequest,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if not ctx.has_business_permission("inventory", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
||||||
|
|
||||||
|
result = preview_bulk_price_update(db, business_id, payload)
|
||||||
|
return success_response(data=result.dict(), request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/business/{business_id}/bulk-price-update/apply",
|
||||||
|
summary="اعمال تغییر قیمتهای گروهی",
|
||||||
|
description="اعمال تغییرات قیمت بر روی کالاهای انتخاب شده",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
def apply_bulk_price_update_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
payload: BulkPriceUpdateRequest,
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if not ctx.has_business_permission("inventory", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
|
||||||
|
|
||||||
|
result = apply_bulk_price_update(db, business_id, payload)
|
||||||
|
return success_response(data=result, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,3 +108,59 @@ class ProductResponse(BaseModel):
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BulkPriceUpdateType(str, Enum):
|
||||||
|
PERCENTAGE = "percentage"
|
||||||
|
AMOUNT = "amount"
|
||||||
|
|
||||||
|
|
||||||
|
class BulkPriceUpdateDirection(str, Enum):
|
||||||
|
INCREASE = "increase"
|
||||||
|
DECREASE = "decrease"
|
||||||
|
|
||||||
|
|
||||||
|
class BulkPriceUpdateTarget(str, Enum):
|
||||||
|
SALES_PRICE = "sales_price"
|
||||||
|
PURCHASE_PRICE = "purchase_price"
|
||||||
|
BOTH = "both"
|
||||||
|
|
||||||
|
|
||||||
|
class BulkPriceUpdateRequest(BaseModel):
|
||||||
|
"""درخواست تغییر قیمتهای گروهی"""
|
||||||
|
update_type: BulkPriceUpdateType = Field(..., description="نوع تغییر: درصدی یا مقداری")
|
||||||
|
direction: BulkPriceUpdateDirection = Field(default=BulkPriceUpdateDirection.INCREASE, description="جهت تغییر: افزایش یا کاهش")
|
||||||
|
target: BulkPriceUpdateTarget = Field(..., description="هدف تغییر: قیمت فروش، خرید یا هر دو")
|
||||||
|
value: Decimal = Field(..., description="مقدار تغییر (درصد یا مبلغ)")
|
||||||
|
|
||||||
|
# فیلترهای انتخاب کالاها
|
||||||
|
category_ids: Optional[List[int]] = Field(default=None, description="شناسههای دستهبندی")
|
||||||
|
currency_ids: Optional[List[int]] = Field(default=None, description="شناسههای ارز")
|
||||||
|
price_list_ids: Optional[List[int]] = Field(default=None, description="شناسههای لیست قیمت")
|
||||||
|
item_types: Optional[List[ProductItemType]] = Field(default=None, description="نوع آیتمها")
|
||||||
|
product_ids: Optional[List[int]] = Field(default=None, description="شناسههای کالاهای خاص")
|
||||||
|
|
||||||
|
# گزینههای اضافی
|
||||||
|
only_products_with_inventory: Optional[bool] = Field(default=None, description="فقط کالاهای با موجودی")
|
||||||
|
only_products_with_base_price: Optional[bool] = Field(default=True, description="فقط کالاهای با قیمت پایه")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkPriceUpdatePreview(BaseModel):
|
||||||
|
"""پیشنمایش تغییرات قیمت"""
|
||||||
|
product_id: int
|
||||||
|
product_name: str
|
||||||
|
product_code: str
|
||||||
|
category_name: Optional[str] = None
|
||||||
|
current_sales_price: Optional[Decimal] = None
|
||||||
|
current_purchase_price: Optional[Decimal] = None
|
||||||
|
new_sales_price: Optional[Decimal] = None
|
||||||
|
new_purchase_price: Optional[Decimal] = None
|
||||||
|
sales_price_change: Optional[Decimal] = None
|
||||||
|
purchase_price_change: Optional[Decimal] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BulkPriceUpdatePreviewResponse(BaseModel):
|
||||||
|
"""پاسخ پیشنمایش تغییرات قیمت"""
|
||||||
|
total_products: int
|
||||||
|
affected_products: List[BulkPriceUpdatePreview]
|
||||||
|
summary: dict = Field(..., description="خلاصه تغییرات")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/build/lib/adapters/db/models/bank_account.py
Normal file
47
hesabixAPI/build/lib/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")
|
||||||
|
|
||||||
|
|
||||||
44
hesabixAPI/build/lib/adapters/db/models/cash_register.py
Normal file
44
hesabixAPI/build/lib/adapters/db/models/cash_register.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from adapters.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CashRegister(Base):
|
||||||
|
__tablename__ = "cash_registers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('business_id', 'code', name='uq_cash_registers_business_code'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# مشخصات
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام صندوق")
|
||||||
|
code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True, comment="کد یکتا در هر کسبوکار (اختیاری)")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# تنظیمات
|
||||||
|
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
|
||||||
|
# پرداخت
|
||||||
|
payment_switch_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
payment_terminal_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
merchant_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# زمان بندی
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
# روابط
|
||||||
|
business = relationship("Business", backref="cash_registers")
|
||||||
|
currency = relationship("Currency", backref="cash_registers")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,13 +15,31 @@ class BusinessPermissionRepository(BaseRepository[BusinessPermission]):
|
||||||
|
|
||||||
def get_by_user_and_business(self, user_id: int, business_id: int) -> Optional[BusinessPermission]:
|
def get_by_user_and_business(self, user_id: int, business_id: int) -> Optional[BusinessPermission]:
|
||||||
"""دریافت دسترسیهای کاربر برای کسب و کار خاص"""
|
"""دریافت دسترسیهای کاربر برای کسب و کار خاص"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info(f"=== get_by_user_and_business START ===")
|
||||||
|
logger.info(f"User ID: {user_id}")
|
||||||
|
logger.info(f"Business ID: {business_id}")
|
||||||
|
|
||||||
stmt = select(BusinessPermission).where(
|
stmt = select(BusinessPermission).where(
|
||||||
and_(
|
and_(
|
||||||
BusinessPermission.user_id == user_id,
|
BusinessPermission.user_id == user_id,
|
||||||
BusinessPermission.business_id == business_id
|
BusinessPermission.business_id == business_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return self.db.execute(stmt).scalars().first()
|
|
||||||
|
logger.info(f"SQL Query: {stmt}")
|
||||||
|
|
||||||
|
result = self.db.execute(stmt).scalars().first()
|
||||||
|
|
||||||
|
logger.info(f"Query result: {result}")
|
||||||
|
if result:
|
||||||
|
logger.info(f"Business permissions: {result.business_permissions}")
|
||||||
|
logger.info(f"Type: {type(result.business_permissions)}")
|
||||||
|
|
||||||
|
logger.info(f"=== get_by_user_and_business END ===")
|
||||||
|
return result
|
||||||
|
|
||||||
def create_or_update(self, user_id: int, business_id: int, permissions: dict) -> BusinessPermission:
|
def create_or_update(self, user_id: int, business_id: int, permissions: dict) -> BusinessPermission:
|
||||||
"""ایجاد یا بهروزرسانی دسترسیهای کاربر برای کسب و کار"""
|
"""ایجاد یا بهروزرسانی دسترسیهای کاربر برای کسب و کار"""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func
|
||||||
|
|
||||||
|
from adapters.db.models.cash_register import CashRegister
|
||||||
|
|
||||||
|
|
||||||
|
class CashRegisterRepository:
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create(self, business_id: int, data: Dict[str, Any]) -> CashRegister:
|
||||||
|
obj = CashRegister(
|
||||||
|
business_id=business_id,
|
||||||
|
name=data.get("name"),
|
||||||
|
code=data.get("code"),
|
||||||
|
description=data.get("description"),
|
||||||
|
currency_id=int(data["currency_id"]),
|
||||||
|
is_active=bool(data.get("is_active", True)),
|
||||||
|
is_default=bool(data.get("is_default", False)),
|
||||||
|
payment_switch_number=data.get("payment_switch_number"),
|
||||||
|
payment_terminal_number=data.get("payment_terminal_number"),
|
||||||
|
merchant_id=data.get("merchant_id"),
|
||||||
|
)
|
||||||
|
self.db.add(obj)
|
||||||
|
self.db.flush()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def get_by_id(self, id_: int) -> Optional[CashRegister]:
|
||||||
|
return self.db.query(CashRegister).filter(CashRegister.id == id_).first()
|
||||||
|
|
||||||
|
def update(self, obj: CashRegister, data: Dict[str, Any]) -> CashRegister:
|
||||||
|
for key in [
|
||||||
|
"name","code","description","currency_id","is_active","is_default",
|
||||||
|
"payment_switch_number","payment_terminal_number","merchant_id",
|
||||||
|
]:
|
||||||
|
if key in data and data[key] is not None:
|
||||||
|
setattr(obj, key, data[key] if key != "currency_id" else int(data[key]))
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def delete(self, obj: CashRegister) -> None:
|
||||||
|
self.db.delete(obj)
|
||||||
|
|
||||||
|
def bulk_delete(self, business_id: int, ids: List[int]) -> Dict[str, int]:
|
||||||
|
items = self.db.query(CashRegister).filter(
|
||||||
|
CashRegister.business_id == business_id,
|
||||||
|
CashRegister.id.in_(ids)
|
||||||
|
).all()
|
||||||
|
deleted = 0
|
||||||
|
skipped = 0
|
||||||
|
for it in items:
|
||||||
|
try:
|
||||||
|
self.db.delete(it)
|
||||||
|
deleted += 1
|
||||||
|
except Exception:
|
||||||
|
skipped += 1
|
||||||
|
return {"deleted": deleted, "skipped": skipped, "total_requested": len(ids)}
|
||||||
|
|
||||||
|
def clear_default(self, business_id: int, except_id: Optional[int] = None) -> None:
|
||||||
|
q = self.db.query(CashRegister).filter(CashRegister.business_id == business_id)
|
||||||
|
if except_id is not None:
|
||||||
|
q = q.filter(CashRegister.id != except_id)
|
||||||
|
q.update({CashRegister.is_default: False})
|
||||||
|
|
||||||
|
def list(self, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
q = self.db.query(CashRegister).filter(CashRegister.business_id == business_id)
|
||||||
|
|
||||||
|
# search
|
||||||
|
search = query.get("search")
|
||||||
|
search_fields = query.get("search_fields") or []
|
||||||
|
if search and search_fields:
|
||||||
|
term = f"%{search}%"
|
||||||
|
conditions = []
|
||||||
|
for f in search_fields:
|
||||||
|
if f == "name":
|
||||||
|
conditions.append(CashRegister.name.ilike(term))
|
||||||
|
if f == "code":
|
||||||
|
conditions.append(CashRegister.code.ilike(term))
|
||||||
|
elif f == "description":
|
||||||
|
conditions.append(CashRegister.description.ilike(term))
|
||||||
|
elif f in {"payment_switch_number","payment_terminal_number","merchant_id"}:
|
||||||
|
conditions.append(getattr(CashRegister, f).ilike(term))
|
||||||
|
if conditions:
|
||||||
|
q = q.filter(or_(*conditions))
|
||||||
|
|
||||||
|
# filters
|
||||||
|
for flt in (query.get("filters") or []):
|
||||||
|
prop = flt.get("property")
|
||||||
|
op = flt.get("operator")
|
||||||
|
val = flt.get("value")
|
||||||
|
if not prop or not op:
|
||||||
|
continue
|
||||||
|
if prop in {"is_active","is_default"} and op == "=":
|
||||||
|
q = q.filter(getattr(CashRegister, prop) == val)
|
||||||
|
elif prop == "currency_id" and op == "=":
|
||||||
|
q = q.filter(CashRegister.currency_id == val)
|
||||||
|
|
||||||
|
# sort
|
||||||
|
sort_by = query.get("sort_by") or "created_at"
|
||||||
|
sort_desc = bool(query.get("sort_desc", True))
|
||||||
|
col = getattr(CashRegister, sort_by, CashRegister.created_at)
|
||||||
|
q = q.order_by(col.desc() if sort_desc else col.asc())
|
||||||
|
|
||||||
|
# pagination
|
||||||
|
skip = int(query.get("skip", 0))
|
||||||
|
take = int(query.get("take", 20))
|
||||||
|
total = q.count()
|
||||||
|
items = q.offset(skip).limit(take).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"pagination": {
|
||||||
|
"total": total,
|
||||||
|
"page": (skip // take) + 1,
|
||||||
|
"per_page": take,
|
||||||
|
"total_pages": (total + take - 1) // take,
|
||||||
|
"has_next": skip + take < total,
|
||||||
|
"has_prev": skip > 0,
|
||||||
|
},
|
||||||
|
"query_info": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import select, and_, or_
|
from sqlalchemy import select, and_, or_, func
|
||||||
|
|
||||||
from .base_repo import BaseRepository
|
from .base_repo import BaseRepository
|
||||||
from ..models.category import BusinessCategory
|
from ..models.category import BusinessCategory
|
||||||
|
|
@ -90,3 +90,55 @@ class CategoryRepository(BaseRepository[BusinessCategory]):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def search_with_paths(self, *, business_id: int, query: str, limit: int = 50) -> list[Dict[str, Any]]:
|
||||||
|
q = (query or "").strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
# Basic ILIKE search over fa/en translations by JSON string casting
|
||||||
|
# Note: For performance, consider a materialized path or FTS in future
|
||||||
|
stmt = (
|
||||||
|
select(BusinessCategory)
|
||||||
|
.where(BusinessCategory.business_id == business_id)
|
||||||
|
)
|
||||||
|
rows = list(self.db.execute(stmt).scalars().all())
|
||||||
|
# Build in-memory tree index
|
||||||
|
by_id: dict[int, BusinessCategory] = {r.id: r for r in rows}
|
||||||
|
def get_title(r: BusinessCategory) -> str:
|
||||||
|
trans = r.title_translations or {}
|
||||||
|
return (trans.get("fa") or trans.get("en") or "").strip()
|
||||||
|
# Filter by query
|
||||||
|
q_lower = q.lower()
|
||||||
|
matched: list[BusinessCategory] = []
|
||||||
|
for r in rows:
|
||||||
|
if q_lower in get_title(r).lower():
|
||||||
|
matched.append(r)
|
||||||
|
matched = matched[: max(1, min(limit, 200))]
|
||||||
|
# Build path for each match
|
||||||
|
def build_path(r: BusinessCategory) -> list[Dict[str, Any]]:
|
||||||
|
path: list[Dict[str, Any]] = []
|
||||||
|
current = r
|
||||||
|
seen: set[int] = set()
|
||||||
|
while current is not None and current.id not in seen:
|
||||||
|
seen.add(current.id)
|
||||||
|
title = get_title(current)
|
||||||
|
path.append({
|
||||||
|
"id": current.id,
|
||||||
|
"parent_id": current.parent_id,
|
||||||
|
"title": title,
|
||||||
|
"translations": current.title_translations or {},
|
||||||
|
})
|
||||||
|
pid = current.parent_id
|
||||||
|
current = by_id.get(pid) if pid else None
|
||||||
|
path.reverse()
|
||||||
|
return path
|
||||||
|
result: list[Dict[str, Any]] = []
|
||||||
|
for r in matched:
|
||||||
|
result.append({
|
||||||
|
"id": r.id,
|
||||||
|
"parent_id": r.parent_id,
|
||||||
|
"title": get_title(r),
|
||||||
|
"translations": r.title_translations or {},
|
||||||
|
"path": build_path(r),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import select, and_, func
|
from sqlalchemy import select, and_, or_, func
|
||||||
|
|
||||||
from .base_repo import BaseRepository
|
from .base_repo import BaseRepository
|
||||||
from ..models.product import Product
|
from ..models.product import Product
|
||||||
|
|
@ -25,6 +25,38 @@ class ProductRepository(BaseRepository[Product]):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Apply filters (supports minimal set used by clients)
|
||||||
|
if filters:
|
||||||
|
for f in filters:
|
||||||
|
# Support both dict and pydantic-like objects
|
||||||
|
if isinstance(f, dict):
|
||||||
|
field = f.get("property")
|
||||||
|
operator = f.get("operator")
|
||||||
|
value = f.get("value")
|
||||||
|
else:
|
||||||
|
field = getattr(f, "property", None)
|
||||||
|
operator = getattr(f, "operator", None)
|
||||||
|
value = getattr(f, "value", None)
|
||||||
|
|
||||||
|
if not field or not operator:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Code filters
|
||||||
|
if field == "code":
|
||||||
|
if operator == "=":
|
||||||
|
stmt = stmt.where(Product.code == value)
|
||||||
|
elif operator == "in" and isinstance(value, (list, tuple)):
|
||||||
|
stmt = stmt.where(Product.code.in_(list(value)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Name contains
|
||||||
|
if field == "name":
|
||||||
|
if operator in {"contains", "ilike"} and isinstance(value, str):
|
||||||
|
stmt = stmt.where(Product.name.ilike(f"%{value}%"))
|
||||||
|
elif operator == "=":
|
||||||
|
stmt = stmt.where(Product.name == value)
|
||||||
|
continue
|
||||||
|
|
||||||
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
|
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
|
||||||
|
|
||||||
# Sorting
|
# Sorting
|
||||||
|
|
|
||||||
|
|
@ -48,21 +48,43 @@ class AuthContext:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_permissions_value(value) -> dict:
|
def _normalize_permissions_value(value) -> dict:
|
||||||
"""نرمالسازی مقدار JSON دسترسیها به dict برای سازگاری با دادههای legacy"""
|
"""نرمالسازی مقدار JSON دسترسیها به dict برای سازگاری با دادههای legacy"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info(f"=== _normalize_permissions_value START ===")
|
||||||
|
logger.info(f"Input value type: {type(value)}")
|
||||||
|
logger.info(f"Input value: {value}")
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
|
logger.info("Value is already a dict, returning as is")
|
||||||
|
logger.info(f"=== _normalize_permissions_value END ===")
|
||||||
return value
|
return value
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
|
logger.info("Value is a list, processing...")
|
||||||
try:
|
try:
|
||||||
# لیست جفتها مانند [["join", true], ["sales", {..}]]
|
# لیست جفتها مانند [["join", true], ["sales", {..}]]
|
||||||
if all(isinstance(item, list) and len(item) == 2 for item in value):
|
if all(isinstance(item, list) and len(item) == 2 for item in value):
|
||||||
return {k: v for k, v in value if isinstance(k, str)}
|
logger.info("Detected list of key-value pairs")
|
||||||
|
result = {k: v for k, v in value if isinstance(k, str)}
|
||||||
|
logger.info(f"Converted to dict: {result}")
|
||||||
|
logger.info(f"=== _normalize_permissions_value END ===")
|
||||||
|
return result
|
||||||
# لیست دیکشنریها مانند [{"join": true}, {"sales": {...}}]
|
# لیست دیکشنریها مانند [{"join": true}, {"sales": {...}}]
|
||||||
if all(isinstance(item, dict) for item in value):
|
if all(isinstance(item, dict) for item in value):
|
||||||
|
logger.info("Detected list of dictionaries")
|
||||||
merged = {}
|
merged = {}
|
||||||
for item in value:
|
for item in value:
|
||||||
merged.update({k: v for k, v in item.items()})
|
merged.update({k: v for k, v in item.items()})
|
||||||
|
logger.info(f"Merged to dict: {merged}")
|
||||||
|
logger.info(f"=== _normalize_permissions_value END ===")
|
||||||
return merged
|
return merged
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing list: {e}")
|
||||||
|
logger.info(f"=== _normalize_permissions_value END ===")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
logger.info(f"Unsupported value type {type(value)}, returning empty dict")
|
||||||
|
logger.info(f"=== _normalize_permissions_value END ===")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def get_translator(self) -> Translator:
|
def get_translator(self) -> Translator:
|
||||||
|
|
@ -101,15 +123,34 @@ class AuthContext:
|
||||||
|
|
||||||
def _get_business_permissions(self) -> dict:
|
def _get_business_permissions(self) -> dict:
|
||||||
"""دریافت دسترسیهای کسب و کار از دیتابیس"""
|
"""دریافت دسترسیهای کسب و کار از دیتابیس"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info(f"=== _get_business_permissions START ===")
|
||||||
|
logger.info(f"User ID: {self.user.id}")
|
||||||
|
logger.info(f"Business ID: {self.business_id}")
|
||||||
|
logger.info(f"DB available: {self.db is not None}")
|
||||||
|
|
||||||
if not self.business_id or not self.db:
|
if not self.business_id or not self.db:
|
||||||
|
logger.info("No business_id or db, returning empty permissions")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
|
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
|
||||||
repo = BusinessPermissionRepository(self.db)
|
repo = BusinessPermissionRepository(self.db)
|
||||||
permission_obj = repo.get_by_user_and_business(self.user.id, self.business_id)
|
permission_obj = repo.get_by_user_and_business(self.user.id, self.business_id)
|
||||||
|
|
||||||
|
logger.info(f"Permission object found: {permission_obj}")
|
||||||
|
|
||||||
if permission_obj and permission_obj.business_permissions:
|
if permission_obj and permission_obj.business_permissions:
|
||||||
return AuthContext._normalize_permissions_value(permission_obj.business_permissions)
|
raw_permissions = permission_obj.business_permissions
|
||||||
|
logger.info(f"Raw permissions: {raw_permissions}")
|
||||||
|
normalized_permissions = AuthContext._normalize_permissions_value(raw_permissions)
|
||||||
|
logger.info(f"Normalized permissions: {normalized_permissions}")
|
||||||
|
logger.info(f"=== _get_business_permissions END ===")
|
||||||
|
return normalized_permissions
|
||||||
|
|
||||||
|
logger.info("No permissions found, returning empty dict")
|
||||||
|
logger.info(f"=== _get_business_permissions END ===")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# بررسی دسترسیهای اپلیکیشن
|
# بررسی دسترسیهای اپلیکیشن
|
||||||
|
|
@ -146,15 +187,33 @@ class AuthContext:
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info(f"=== is_business_owner START ===")
|
||||||
|
logger.info(f"Requested business_id: {business_id}")
|
||||||
|
logger.info(f"Context business_id: {self.business_id}")
|
||||||
|
logger.info(f"User ID: {self.user.id}")
|
||||||
|
logger.info(f"DB available: {self.db is not None}")
|
||||||
|
|
||||||
target_business_id = business_id or self.business_id
|
target_business_id = business_id or self.business_id
|
||||||
|
logger.info(f"Target business_id: {target_business_id}")
|
||||||
|
|
||||||
if not target_business_id or not self.db:
|
if not target_business_id or not self.db:
|
||||||
logger.info(f"is_business_owner: no business_id ({target_business_id}) or db ({self.db is not None})")
|
logger.info(f"is_business_owner: no business_id ({target_business_id}) or db ({self.db is not None})")
|
||||||
|
logger.info(f"=== is_business_owner END (no business_id or db) ===")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
from adapters.db.models.business import Business
|
from adapters.db.models.business import Business
|
||||||
business = self.db.get(Business, target_business_id)
|
business = self.db.get(Business, target_business_id)
|
||||||
is_owner = business and business.owner_id == self.user.id
|
logger.info(f"Business lookup result: {business}")
|
||||||
logger.info(f"is_business_owner: business_id={target_business_id}, business={business}, owner_id={business.owner_id if business else None}, user_id={self.user.id}, is_owner={is_owner}")
|
|
||||||
|
if business:
|
||||||
|
logger.info(f"Business owner_id: {business.owner_id}")
|
||||||
|
is_owner = business.owner_id == self.user.id
|
||||||
|
logger.info(f"is_owner: {is_owner}")
|
||||||
|
else:
|
||||||
|
logger.info("Business not found")
|
||||||
|
is_owner = False
|
||||||
|
|
||||||
|
logger.info(f"=== is_business_owner END (result: {is_owner}) ===")
|
||||||
return is_owner
|
return is_owner
|
||||||
|
|
||||||
# بررسی دسترسیهای کسب و کار
|
# بررسی دسترسیهای کسب و کار
|
||||||
|
|
@ -250,22 +309,66 @@ class AuthContext:
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
logger.info(f"Checking business access: user {self.user.id}, business {business_id}, context business_id {self.business_id}")
|
logger.info(f"=== can_access_business START ===")
|
||||||
|
logger.info(f"User ID: {self.user.id}")
|
||||||
|
logger.info(f"Requested business ID: {business_id}")
|
||||||
|
logger.info(f"User context business_id: {self.business_id}")
|
||||||
|
logger.info(f"User app permissions: {self.app_permissions}")
|
||||||
|
|
||||||
# SuperAdmin دسترسی به همه کسب و کارها دارد
|
# SuperAdmin دسترسی به همه کسب و کارها دارد
|
||||||
if self.is_superadmin():
|
if self.is_superadmin():
|
||||||
logger.info(f"User {self.user.id} is superadmin, granting access to business {business_id}")
|
logger.info(f"User {self.user.id} is superadmin, granting access to business {business_id}")
|
||||||
|
logger.info(f"=== can_access_business END (superadmin) ===")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# اگر مالک کسب و کار است، دسترسی دارد
|
# بررسی مالکیت کسب و کار
|
||||||
if self.is_business_owner() and business_id == self.business_id:
|
if self.db:
|
||||||
logger.info(f"User {self.user.id} is business owner of {business_id}, granting access")
|
from adapters.db.models.business import Business
|
||||||
|
business = self.db.get(Business, business_id)
|
||||||
|
logger.info(f"Business lookup result: {business}")
|
||||||
|
if business:
|
||||||
|
logger.info(f"Business owner ID: {business.owner_id}")
|
||||||
|
if business.owner_id == self.user.id:
|
||||||
|
logger.info(f"User {self.user.id} is business owner of {business_id}, granting access")
|
||||||
|
logger.info(f"=== can_access_business END (owner) ===")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.info("No database connection available for business lookup")
|
||||||
|
|
||||||
|
# بررسی عضویت در کسب و کار
|
||||||
|
if self.db:
|
||||||
|
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
|
||||||
|
permission_repo = BusinessPermissionRepository(self.db)
|
||||||
|
business_permission = permission_repo.get_by_user_and_business(self.user.id, business_id)
|
||||||
|
logger.info(f"Business permission lookup result: {business_permission}")
|
||||||
|
|
||||||
|
if business_permission:
|
||||||
|
# بررسی دسترسی join
|
||||||
|
permissions = business_permission.business_permissions or {}
|
||||||
|
logger.info(f"User permissions for business {business_id}: {permissions}")
|
||||||
|
join_permission = permissions.get('join')
|
||||||
|
logger.info(f"Join permission: {join_permission}")
|
||||||
|
|
||||||
|
if join_permission == True:
|
||||||
|
logger.info(f"User {self.user.id} is member of business {business_id}, granting access")
|
||||||
|
logger.info(f"=== can_access_business END (member) ===")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.info(f"User {self.user.id} does not have join permission for business {business_id}")
|
||||||
|
else:
|
||||||
|
logger.info(f"No business permission found for user {self.user.id} and business {business_id}")
|
||||||
|
else:
|
||||||
|
logger.info("No database connection available for permission lookup")
|
||||||
|
|
||||||
|
# اگر کسب و کار در context کاربر است، دسترسی دارد
|
||||||
|
if business_id == self.business_id:
|
||||||
|
logger.info(f"User {self.user.id} has context access to business {business_id}")
|
||||||
|
logger.info(f"=== can_access_business END (context) ===")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# بررسی دسترسیهای کسب و کار
|
logger.info(f"User {self.user.id} does not have access to business {business_id}")
|
||||||
has_access = business_id == self.business_id
|
logger.info(f"=== can_access_business END (denied) ===")
|
||||||
logger.info(f"Business access check: {business_id} == {self.business_id} = {has_access}")
|
return False
|
||||||
return has_access
|
|
||||||
|
|
||||||
def is_business_member(self, business_id: int) -> bool:
|
def is_business_member(self, business_id: int) -> bool:
|
||||||
"""بررسی اینکه آیا کاربر عضو کسب و کار است یا نه (دسترسی join)"""
|
"""بررسی اینکه آیا کاربر عضو کسب و کار است یا نه (دسترسی join)"""
|
||||||
|
|
@ -378,7 +481,15 @@ def get_current_user(
|
||||||
# تشخیص سال مالی از هدر X-Fiscal-Year-ID (آینده)
|
# تشخیص سال مالی از هدر X-Fiscal-Year-ID (آینده)
|
||||||
fiscal_year_id = _detect_fiscal_year_id(request)
|
fiscal_year_id = _detect_fiscal_year_id(request)
|
||||||
|
|
||||||
return AuthContext(
|
logger.info(f"Creating AuthContext for user {user.id}:")
|
||||||
|
logger.info(f" - Language: {language}")
|
||||||
|
logger.info(f" - Calendar type: {calendar_type}")
|
||||||
|
logger.info(f" - Timezone: {timezone}")
|
||||||
|
logger.info(f" - Business ID: {business_id}")
|
||||||
|
logger.info(f" - Fiscal year ID: {fiscal_year_id}")
|
||||||
|
logger.info(f" - App permissions: {user.app_permissions}")
|
||||||
|
|
||||||
|
auth_context = AuthContext(
|
||||||
user=user,
|
user=user,
|
||||||
api_key_id=obj.id,
|
api_key_id=obj.id,
|
||||||
language=language,
|
language=language,
|
||||||
|
|
@ -388,6 +499,9 @@ def get_current_user(
|
||||||
fiscal_year_id=fiscal_year_id,
|
fiscal_year_id=fiscal_year_id,
|
||||||
db=db
|
db=db
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"AuthContext created successfully")
|
||||||
|
return auth_context
|
||||||
|
|
||||||
|
|
||||||
def _detect_language(request: Request) -> str:
|
def _detect_language(request: Request) -> str:
|
||||||
|
|
@ -409,12 +523,22 @@ def _detect_timezone(request: Request) -> Optional[str]:
|
||||||
|
|
||||||
def _detect_business_id(request: Request) -> Optional[int]:
|
def _detect_business_id(request: Request) -> Optional[int]:
|
||||||
"""تشخیص ID کسب و کار از هدر X-Business-ID (آینده)"""
|
"""تشخیص ID کسب و کار از هدر X-Business-ID (آینده)"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
business_id_str = request.headers.get("X-Business-ID")
|
business_id_str = request.headers.get("X-Business-ID")
|
||||||
|
logger.info(f"X-Business-ID header: {business_id_str}")
|
||||||
|
|
||||||
if business_id_str:
|
if business_id_str:
|
||||||
try:
|
try:
|
||||||
return int(business_id_str)
|
business_id = int(business_id_str)
|
||||||
|
logger.info(f"Detected business ID: {business_id}")
|
||||||
|
return business_id
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
logger.warning(f"Invalid business ID format: {business_id_str}")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
logger.info("No business ID detected from headers")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,22 @@ def require_business_access(business_id_param: str = "business_id"):
|
||||||
except Exception:
|
except Exception:
|
||||||
business_id = None
|
business_id = None
|
||||||
|
|
||||||
if business_id and not ctx.can_access_business(int(business_id)):
|
if business_id:
|
||||||
logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}")
|
logger.info(f"=== require_business_access decorator ===")
|
||||||
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
|
logger.info(f"Checking access for user {ctx.get_user_id()} to business {business_id}")
|
||||||
|
logger.info(f"User context business_id: {ctx.business_id}")
|
||||||
|
logger.info(f"Is superadmin: {ctx.is_superadmin()}")
|
||||||
|
|
||||||
|
has_access = ctx.can_access_business(int(business_id))
|
||||||
|
logger.info(f"Access check result: {has_access}")
|
||||||
|
|
||||||
|
if not has_access:
|
||||||
|
logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}")
|
||||||
|
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
|
||||||
|
else:
|
||||||
|
logger.info(f"User {ctx.get_user_id()} has access to business {business_id}")
|
||||||
|
else:
|
||||||
|
logger.info("No business_id provided, skipping access check")
|
||||||
|
|
||||||
# فراخوانی تابع اصلی و await در صورت نیاز
|
# فراخوانی تابع اصلی و await در صورت نیاز
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,15 @@ def success_response(data: Any, request: Request = None, message: str = None) ->
|
||||||
if data is not None:
|
if data is not None:
|
||||||
response["data"] = data
|
response["data"] = data
|
||||||
|
|
||||||
# Add message if provided
|
# Add message if provided (translate if translator exists)
|
||||||
if message is not None:
|
if message is not None:
|
||||||
response["message"] = message
|
translated = message
|
||||||
|
try:
|
||||||
|
if request is not None and hasattr(request.state, 'translator') and request.state.translator is not None:
|
||||||
|
translated = request.state.translator.t(message, default=message)
|
||||||
|
except Exception:
|
||||||
|
translated = message
|
||||||
|
response["message"] = translated
|
||||||
|
|
||||||
# Add calendar type information if request is available
|
# Add calendar type information if request is available
|
||||||
if request and hasattr(request.state, 'calendar_type'):
|
if request and hasattr(request.state, 'calendar_type'):
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ 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.cash_registers import router as cash_registers_router
|
||||||
from adapters.api.v1.tax_units import router as tax_units_router
|
from adapters.api.v1.tax_units import 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 +294,8 @@ 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(cash_registers_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 +338,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 +349,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/build/lib/app/services/bank_account_service.py
Normal file
255
hesabixAPI/build/lib/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(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
233
hesabixAPI/build/lib/app/services/bulk_price_update_service.py
Normal file
233
hesabixAPI/build/lib/app/services/bulk_price_update_service.py
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
|
from adapters.db.models.product import Product
|
||||||
|
from adapters.db.models.price_list import PriceItem
|
||||||
|
from adapters.db.models.category import BusinessCategory
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
from adapters.api.v1.schema_models.product import (
|
||||||
|
BulkPriceUpdateRequest,
|
||||||
|
BulkPriceUpdatePreview,
|
||||||
|
BulkPriceUpdatePreviewResponse,
|
||||||
|
BulkPriceUpdateType,
|
||||||
|
BulkPriceUpdateTarget,
|
||||||
|
BulkPriceUpdateDirection,
|
||||||
|
ProductItemType
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _quantize_non_negative_integer(value: Decimal) -> Decimal:
|
||||||
|
"""رُند کردن به عدد صحیح غیرمنفی (بدون اعشار)."""
|
||||||
|
# حذف اعشار: round-half-up به نزدیکترین عدد صحیح
|
||||||
|
quantized = value.quantize(Decimal('1'))
|
||||||
|
if quantized < 0:
|
||||||
|
return Decimal('0')
|
||||||
|
return quantized
|
||||||
|
|
||||||
|
def _quantize_integer_keep_sign(value: Decimal) -> Decimal:
|
||||||
|
"""رُند کردن به عدد صحیح با حفظ علامت (بدون اعشار)."""
|
||||||
|
return value.quantize(Decimal('1'))
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_new_price(current_price: Optional[Decimal], update_type: BulkPriceUpdateType, direction: BulkPriceUpdateDirection, value: Decimal) -> Optional[Decimal]:
|
||||||
|
"""محاسبه قیمت جدید بر اساس نوع تغییر با جهت، سپس رُند و کلَمپ به صفر"""
|
||||||
|
if current_price is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
delta = Decimal('0')
|
||||||
|
if update_type == BulkPriceUpdateType.PERCENTAGE:
|
||||||
|
sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1')
|
||||||
|
multiplier = Decimal('1') + (sign * (value / Decimal('100')))
|
||||||
|
new_value = current_price * multiplier
|
||||||
|
else:
|
||||||
|
sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1')
|
||||||
|
delta = sign * value
|
||||||
|
new_value = current_price + delta
|
||||||
|
|
||||||
|
# رُند به عدد صحیح و کلَمپ به صفر
|
||||||
|
return _quantize_non_negative_integer(new_value)
|
||||||
|
|
||||||
|
|
||||||
|
def get_filtered_products(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> List[Product]:
|
||||||
|
"""دریافت کالاهای فیلتر شده بر اساس معیارهای درخواست"""
|
||||||
|
query = db.query(Product).filter(Product.business_id == business_id)
|
||||||
|
|
||||||
|
# فیلتر بر اساس دستهبندی
|
||||||
|
if request.category_ids:
|
||||||
|
query = query.filter(Product.category_id.in_(request.category_ids))
|
||||||
|
|
||||||
|
# فیلتر بر اساس نوع آیتم
|
||||||
|
if request.item_types:
|
||||||
|
query = query.filter(Product.item_type.in_([t.value for t in request.item_types]))
|
||||||
|
|
||||||
|
# فیلتر بر اساس ارز: محصولی که قیمتهای لیست مرتبط با ارزهای انتخابی دارد
|
||||||
|
if request.currency_ids:
|
||||||
|
query = query.filter(
|
||||||
|
db.query(PriceItem.id)
|
||||||
|
.filter(
|
||||||
|
PriceItem.product_id == Product.id,
|
||||||
|
PriceItem.currency_id.in_(request.currency_ids)
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# فیلتر بر اساس لیست قیمت: محصولی که در هر یک از لیستهای انتخابی آیتم قیمت دارد
|
||||||
|
if request.price_list_ids:
|
||||||
|
query = query.filter(
|
||||||
|
db.query(PriceItem.id)
|
||||||
|
.filter(
|
||||||
|
PriceItem.product_id == Product.id,
|
||||||
|
PriceItem.price_list_id.in_(request.price_list_ids)
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# فیلتر بر اساس شناسههای کالاهای خاص
|
||||||
|
if request.product_ids:
|
||||||
|
query = query.filter(Product.id.in_(request.product_ids))
|
||||||
|
|
||||||
|
# فیلتر بر اساس موجودی
|
||||||
|
if request.only_products_with_inventory is not None:
|
||||||
|
if request.only_products_with_inventory:
|
||||||
|
query = query.filter(Product.track_inventory == True)
|
||||||
|
else:
|
||||||
|
query = query.filter(Product.track_inventory == False)
|
||||||
|
|
||||||
|
# فیلتر بر اساس وجود قیمت پایه
|
||||||
|
if request.only_products_with_base_price:
|
||||||
|
if request.target == BulkPriceUpdateTarget.SALES_PRICE:
|
||||||
|
query = query.filter(Product.base_sales_price.isnot(None))
|
||||||
|
elif request.target == BulkPriceUpdateTarget.PURCHASE_PRICE:
|
||||||
|
query = query.filter(Product.base_purchase_price.isnot(None))
|
||||||
|
else:
|
||||||
|
# در حالت هر دو، حداقل یکی موجود باشد
|
||||||
|
query = query.filter(or_(Product.base_sales_price.isnot(None), Product.base_purchase_price.isnot(None)))
|
||||||
|
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
|
||||||
|
def preview_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> BulkPriceUpdatePreviewResponse:
|
||||||
|
"""پیشنمایش تغییرات قیمت گروهی"""
|
||||||
|
products = get_filtered_products(db, business_id, request)
|
||||||
|
|
||||||
|
# کش نام دستهها برای کاهش کوئری
|
||||||
|
category_titles: Dict[int, str] = {}
|
||||||
|
def _resolve_category_name(cid: Optional[int]) -> Optional[str]:
|
||||||
|
if cid is None:
|
||||||
|
return None
|
||||||
|
if cid in category_titles:
|
||||||
|
return category_titles[cid]
|
||||||
|
try:
|
||||||
|
cat = db.query(BusinessCategory).filter(BusinessCategory.id == cid, BusinessCategory.business_id == business_id).first()
|
||||||
|
if cat and isinstance(cat.title_translations, dict):
|
||||||
|
title = cat.title_translations.get('fa') or cat.title_translations.get('default') or ''
|
||||||
|
category_titles[cid] = title
|
||||||
|
return title
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
affected_products = []
|
||||||
|
total_sales_change = Decimal('0')
|
||||||
|
total_purchase_change = Decimal('0')
|
||||||
|
products_with_sales_change = 0
|
||||||
|
products_with_purchase_change = 0
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
preview = BulkPriceUpdatePreview(
|
||||||
|
product_id=product.id,
|
||||||
|
product_name=product.name or "بدون نام",
|
||||||
|
product_code=product.code or "بدون کد",
|
||||||
|
category_name=_resolve_category_name(product.category_id),
|
||||||
|
current_sales_price=product.base_sales_price,
|
||||||
|
current_purchase_price=product.base_purchase_price,
|
||||||
|
new_sales_price=None,
|
||||||
|
new_purchase_price=None,
|
||||||
|
sales_price_change=None,
|
||||||
|
purchase_price_change=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# محاسبه تغییرات قیمت فروش
|
||||||
|
if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None:
|
||||||
|
new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value)
|
||||||
|
preview.new_sales_price = new_sales_price
|
||||||
|
preview.sales_price_change = (new_sales_price - product.base_sales_price) if new_sales_price is not None else None
|
||||||
|
total_sales_change += (preview.sales_price_change or Decimal('0'))
|
||||||
|
products_with_sales_change += 1
|
||||||
|
|
||||||
|
# محاسبه تغییرات قیمت خرید
|
||||||
|
if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None:
|
||||||
|
new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value)
|
||||||
|
preview.new_purchase_price = new_purchase_price
|
||||||
|
preview.purchase_price_change = (new_purchase_price - product.base_purchase_price) if new_purchase_price is not None else None
|
||||||
|
total_purchase_change += (preview.purchase_price_change or Decimal('0'))
|
||||||
|
products_with_purchase_change += 1
|
||||||
|
|
||||||
|
affected_products.append(preview)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"total_products": len(products),
|
||||||
|
"affected_products": len(affected_products),
|
||||||
|
"products_with_sales_change": products_with_sales_change,
|
||||||
|
"products_with_purchase_change": products_with_purchase_change,
|
||||||
|
"total_sales_change": float(_quantize_integer_keep_sign(total_sales_change)),
|
||||||
|
"total_purchase_change": float(_quantize_integer_keep_sign(total_purchase_change)),
|
||||||
|
"update_type": request.update_type.value,
|
||||||
|
"direction": request.direction.value,
|
||||||
|
"target": request.target.value,
|
||||||
|
"value": float(_quantize_non_negative_integer(request.value)) if request.update_type == BulkPriceUpdateType.AMOUNT else float(request.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BulkPriceUpdatePreviewResponse(
|
||||||
|
total_products=len(products),
|
||||||
|
affected_products=affected_products,
|
||||||
|
summary=summary
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> Dict[str, Any]:
|
||||||
|
"""اعمال تغییرات قیمت گروهی"""
|
||||||
|
products = get_filtered_products(db, business_id, request)
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# اگر price_list_ids مشخص شده باشد، هم قیمت پایه و هم PriceItemها باید بهروزرسانی شوند
|
||||||
|
for product in products:
|
||||||
|
try:
|
||||||
|
# بروزرسانی قیمت فروش
|
||||||
|
if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None:
|
||||||
|
new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value)
|
||||||
|
product.base_sales_price = new_sales_price
|
||||||
|
|
||||||
|
# بروزرسانی قیمت خرید
|
||||||
|
if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None:
|
||||||
|
new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value)
|
||||||
|
product.base_purchase_price = new_purchase_price
|
||||||
|
|
||||||
|
# بروزرسانی آیتمهای لیست قیمت مرتبط (در صورت مشخص بودن فیلترها)
|
||||||
|
q = db.query(PriceItem).filter(PriceItem.product_id == product.id)
|
||||||
|
if request.currency_ids:
|
||||||
|
q = q.filter(PriceItem.currency_id.in_(request.currency_ids))
|
||||||
|
if request.price_list_ids:
|
||||||
|
q = q.filter(PriceItem.price_list_id.in_(request.price_list_ids))
|
||||||
|
# اگر هدف فقط فروش/خرید نیست چون PriceItem فقط یک فیلد price دارد، همان price را تغییر میدهیم
|
||||||
|
for pi in q.all():
|
||||||
|
new_pi_price = calculate_new_price(Decimal(pi.price), request.update_type, request.direction, request.value)
|
||||||
|
pi.price = new_pi_price
|
||||||
|
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"خطا در بروزرسانی کالای {product.name}: {str(e)}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"تغییرات قیمت برای {updated_count} کالا اعمال شد",
|
||||||
|
"updated_count": updated_count,
|
||||||
|
"total_products": len(products),
|
||||||
|
"errors": errors
|
||||||
|
}
|
||||||
145
hesabixAPI/build/lib/app/services/cash_register_service.py
Normal file
145
hesabixAPI/build/lib/app/services/cash_register_service.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, func
|
||||||
|
|
||||||
|
from adapters.db.models.cash_register import CashRegister
|
||||||
|
from adapters.db.repositories.cash_register_repository import CashRegisterRepository
|
||||||
|
from app.core.responses import ApiError
|
||||||
|
|
||||||
|
|
||||||
|
def create_cash_register(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# validate required fields
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if name == "":
|
||||||
|
raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
|
||||||
|
|
||||||
|
# code uniqueness in business if provided; else auto-generate numeric min 3 digits
|
||||||
|
code = data.get("code")
|
||||||
|
if code is not None and str(code).strip() != "":
|
||||||
|
if not str(code).isdigit():
|
||||||
|
raise ApiError("INVALID_CASH_CODE", "Cash register code must be numeric", http_status=400)
|
||||||
|
if len(str(code)) < 3:
|
||||||
|
raise ApiError("INVALID_CASH_CODE", "Cash register code must be at least 3 digits", http_status=400)
|
||||||
|
exists = db.query(CashRegister).filter(and_(CashRegister.business_id == business_id, CashRegister.code == str(code))).first()
|
||||||
|
if exists:
|
||||||
|
raise ApiError("DUPLICATE_CASH_CODE", "Duplicate cash register code", http_status=400)
|
||||||
|
else:
|
||||||
|
max_code = db.query(func.max(CashRegister.code)).filter(CashRegister.business_id == business_id).scalar()
|
||||||
|
try:
|
||||||
|
if max_code is not None and str(max_code).isdigit():
|
||||||
|
next_code_int = int(max_code) + 1
|
||||||
|
else:
|
||||||
|
next_code_int = 100
|
||||||
|
if next_code_int < 100:
|
||||||
|
next_code_int = 100
|
||||||
|
code = str(next_code_int)
|
||||||
|
except Exception:
|
||||||
|
code = "100"
|
||||||
|
|
||||||
|
repo = CashRegisterRepository(db)
|
||||||
|
obj = repo.create(business_id, {
|
||||||
|
"name": name,
|
||||||
|
"code": code,
|
||||||
|
"description": data.get("description"),
|
||||||
|
"currency_id": int(data["currency_id"]),
|
||||||
|
"is_active": bool(data.get("is_active", True)),
|
||||||
|
"is_default": bool(data.get("is_default", False)),
|
||||||
|
"payment_switch_number": data.get("payment_switch_number"),
|
||||||
|
"payment_terminal_number": data.get("payment_terminal_number"),
|
||||||
|
"merchant_id": data.get("merchant_id"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ensure single default
|
||||||
|
if obj.is_default:
|
||||||
|
repo.clear_default(business_id, except_id=obj.id)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return cash_register_to_dict(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cash_register_by_id(db: Session, id_: int) -> Optional[Dict[str, Any]]:
|
||||||
|
obj = db.query(CashRegister).filter(CashRegister.id == id_).first()
|
||||||
|
return cash_register_to_dict(obj) if obj else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_cash_register(db: Session, id_: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
repo = CashRegisterRepository(db)
|
||||||
|
obj = repo.get_by_id(id_)
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# validate name if provided
|
||||||
|
if "name" in data:
|
||||||
|
name_val = (data.get("name") or "").strip()
|
||||||
|
if name_val == "":
|
||||||
|
raise ApiError("STRING_TOO_SHORT", "Name is required", http_status=400)
|
||||||
|
|
||||||
|
if "code" in data and data["code"] is not None and str(data["code"]).strip() != "":
|
||||||
|
if not str(data["code"]).isdigit():
|
||||||
|
raise ApiError("INVALID_CASH_CODE", "Cash register code must be numeric", http_status=400)
|
||||||
|
if len(str(data["code"])) < 3:
|
||||||
|
raise ApiError("INVALID_CASH_CODE", "Cash register code must be at least 3 digits", http_status=400)
|
||||||
|
exists = db.query(CashRegister).filter(and_(CashRegister.business_id == obj.business_id, CashRegister.code == str(data["code"]), CashRegister.id != obj.id)).first()
|
||||||
|
if exists:
|
||||||
|
raise ApiError("DUPLICATE_CASH_CODE", "Duplicate cash register code", http_status=400)
|
||||||
|
|
||||||
|
repo.update(obj, data)
|
||||||
|
if obj.is_default:
|
||||||
|
repo.clear_default(obj.business_id, except_id=obj.id)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return cash_register_to_dict(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_cash_register(db: Session, id_: int) -> bool:
|
||||||
|
obj = db.query(CashRegister).filter(CashRegister.id == id_).first()
|
||||||
|
if obj is None:
|
||||||
|
return False
|
||||||
|
db.delete(obj)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_delete_cash_registers(db: Session, business_id: int, ids: List[int]) -> Dict[str, Any]:
|
||||||
|
repo = CashRegisterRepository(db)
|
||||||
|
result = repo.bulk_delete(business_id, ids)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise ApiError("BULK_DELETE_FAILED", "Bulk delete failed for cash registers", http_status=500)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_cash_registers(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
repo = CashRegisterRepository(db)
|
||||||
|
res = repo.list(business_id, query)
|
||||||
|
return {
|
||||||
|
"items": [cash_register_to_dict(i) for i in res["items"]],
|
||||||
|
"pagination": res["pagination"],
|
||||||
|
"query_info": res["query_info"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cash_register_to_dict(obj: CashRegister) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": obj.id,
|
||||||
|
"business_id": obj.business_id,
|
||||||
|
"name": obj.name,
|
||||||
|
"code": obj.code,
|
||||||
|
"description": obj.description,
|
||||||
|
"currency_id": obj.currency_id,
|
||||||
|
"is_active": bool(obj.is_active),
|
||||||
|
"is_default": bool(obj.is_default),
|
||||||
|
"payment_switch_number": obj.payment_switch_number,
|
||||||
|
"payment_terminal_number": obj.payment_terminal_number,
|
||||||
|
"merchant_id": obj.merchant_id,
|
||||||
|
"created_at": obj.created_at.isoformat(),
|
||||||
|
"updated_at": obj.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ def create_price_list(db: Session, business_id: int, payload: PriceListCreateReq
|
||||||
name=payload.name.strip(),
|
name=payload.name.strip(),
|
||||||
is_active=payload.is_active,
|
is_active=payload.is_active,
|
||||||
)
|
)
|
||||||
return {"message": "لیست قیمت ایجاد شد", "data": _pl_to_dict(obj)}
|
return {"message": "PRICE_LIST_CREATED", "data": _pl_to_dict(obj)}
|
||||||
|
|
||||||
|
|
||||||
def list_price_lists(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
def list_price_lists(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
|
@ -54,7 +54,7 @@ def update_price_list(db: Session, business_id: int, id: int, payload: PriceList
|
||||||
updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, is_active=payload.is_active)
|
updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, is_active=payload.is_active)
|
||||||
if not updated:
|
if not updated:
|
||||||
return None
|
return None
|
||||||
return {"message": "لیست قیمت بروزرسانی شد", "data": _pl_to_dict(updated)}
|
return {"message": "PRICE_LIST_UPDATED", "data": _pl_to_dict(updated)}
|
||||||
|
|
||||||
|
|
||||||
def delete_price_list(db: Session, business_id: int, id: int) -> bool:
|
def delete_price_list(db: Session, business_id: int, id: int) -> bool:
|
||||||
|
|
@ -96,7 +96,7 @@ def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload
|
||||||
min_qty=payload.min_qty,
|
min_qty=payload.min_qty,
|
||||||
price=payload.price,
|
price=payload.price,
|
||||||
)
|
)
|
||||||
return {"message": "قیمت ثبت شد", "data": _pi_to_dict(obj)}
|
return {"message": "PRICE_ITEM_UPSERTED", "data": _pi_to_dict(obj)}
|
||||||
|
|
||||||
|
|
||||||
def delete_price_item(db: Session, business_id: int, id: int) -> bool:
|
def delete_price_item(db: Session, business_id: int, id: int) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest)
|
||||||
|
|
||||||
_upsert_attributes(db, obj.id, business_id, payload.attribute_ids)
|
_upsert_attributes(db, obj.id, business_id, payload.attribute_ids)
|
||||||
|
|
||||||
return {"message": "آیتم با موفقیت ایجاد شد", "data": _to_dict(obj)}
|
return {"message": "PRODUCT_CREATED", "data": _to_dict(obj)}
|
||||||
|
|
||||||
|
|
||||||
def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
|
@ -178,7 +178,7 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_upsert_attributes(db, product_id, business_id, payload.attribute_ids)
|
_upsert_attributes(db, product_id, business_id, payload.attribute_ids)
|
||||||
return {"message": "آیتم با موفقیت ویرایش شد", "data": _to_dict(updated)}
|
return {"message": "PRODUCT_UPDATED", "data": _to_dict(updated)}
|
||||||
|
|
||||||
|
|
||||||
def delete_product(db: Session, product_id: int, business_id: int) -> bool:
|
def delete_product(db: Session, product_id: int, business_id: int) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -28,21 +28,19 @@ def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
conn = op.get_bind()
|
||||||
dialect_name = conn.dialect.name
|
dialect_name = conn.dialect.name
|
||||||
|
|
||||||
# MySQL: information_schema to check constraints
|
|
||||||
if dialect_name == 'mysql':
|
if dialect_name == 'mysql':
|
||||||
op.execute(
|
# Check via information_schema and drop index if present
|
||||||
sa.text(
|
exists = conn.execute(sa.text(
|
||||||
"""
|
"""
|
||||||
SET @exists := (
|
SELECT COUNT(*) as cnt
|
||||||
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
FROM information_schema.STATISTICS
|
||||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
AND TABLE_NAME = 'price_items'
|
AND TABLE_NAME = 'price_items'
|
||||||
AND CONSTRAINT_NAME = 'uq_price_items_unique_tier'
|
AND INDEX_NAME = 'uq_price_items_unique_tier'
|
||||||
);
|
"""
|
||||||
"""
|
)).scalar() or 0
|
||||||
)
|
if int(exists) > 0:
|
||||||
)
|
conn.execute(sa.text("ALTER TABLE price_items DROP INDEX uq_price_items_unique_tier"))
|
||||||
op.execute(sa.text("""SET @q := IF(@exists > 0, 'ALTER TABLE price_items DROP INDEX uq_price_items_unique_tier', 'SELECT 1'); PREPARE stmt FROM @q; EXECUTE stmt; DEALLOCATE PREPARE stmt;"""))
|
|
||||||
else:
|
else:
|
||||||
# Generic drop constraint best-effort
|
# Generic drop constraint best-effort
|
||||||
try:
|
try:
|
||||||
|
|
@ -53,13 +51,32 @@ def upgrade() -> None:
|
||||||
# 3) Make currency_id NOT NULL
|
# 3) Make currency_id NOT NULL
|
||||||
op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=False, existing_nullable=True)
|
op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=False, existing_nullable=True)
|
||||||
|
|
||||||
# 4) Create new unique constraint including currency_id
|
# 4) Create new unique constraint including currency_id (idempotent)
|
||||||
# For MySQL, unique constraints are created as indexes as well
|
if dialect_name == 'mysql':
|
||||||
op.create_unique_constraint(
|
exists_uc = conn.execute(sa.text(
|
||||||
'uq_price_items_unique_tier_currency',
|
"""
|
||||||
'price_items',
|
SELECT COUNT(*) as cnt
|
||||||
['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id']
|
FROM information_schema.STATISTICS
|
||||||
)
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'price_items'
|
||||||
|
AND INDEX_NAME = 'uq_price_items_unique_tier_currency'
|
||||||
|
"""
|
||||||
|
)).scalar() or 0
|
||||||
|
if int(exists_uc) == 0:
|
||||||
|
op.create_unique_constraint(
|
||||||
|
'uq_price_items_unique_tier_currency',
|
||||||
|
'price_items',
|
||||||
|
['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id']
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
op.create_unique_constraint(
|
||||||
|
'uq_price_items_unique_tier_currency',
|
||||||
|
'price_items',
|
||||||
|
['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id']
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -18,45 +18,30 @@ def upgrade() -> None:
|
||||||
# Try to drop FK on price_lists.currency_id if exists
|
# Try to drop FK on price_lists.currency_id if exists
|
||||||
if dialect == 'mysql':
|
if dialect == 'mysql':
|
||||||
# Find foreign key constraint name dynamically and drop it
|
# Find foreign key constraint name dynamically and drop it
|
||||||
op.execute(sa.text(
|
fk_rows = conn.execute(sa.text(
|
||||||
"""
|
"""
|
||||||
SET @fk_name := (
|
SELECT CONSTRAINT_NAME
|
||||||
SELECT CONSTRAINT_NAME
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
FROM information_schema.KEY_COLUMN_USAGE
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
AND TABLE_NAME = 'price_lists'
|
||||||
AND TABLE_NAME = 'price_lists'
|
AND COLUMN_NAME = 'currency_id'
|
||||||
AND COLUMN_NAME = 'currency_id'
|
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
GROUP BY CONSTRAINT_NAME
|
||||||
LIMIT 1
|
|
||||||
);
|
|
||||||
"""
|
"""
|
||||||
))
|
)).fetchall()
|
||||||
op.execute(sa.text(
|
for (fk_name,) in fk_rows:
|
||||||
"""
|
conn.execute(sa.text(f"ALTER TABLE price_lists DROP FOREIGN KEY {fk_name}"))
|
||||||
SET @q := IF(@fk_name IS NOT NULL, CONCAT('ALTER TABLE price_lists DROP FOREIGN KEY ', @fk_name), 'SELECT 1');
|
|
||||||
PREPARE stmt FROM @q; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
||||||
"""
|
|
||||||
))
|
|
||||||
# Drop indexes on columns if any
|
|
||||||
for col in ('currency_id', 'default_unit_id'):
|
|
||||||
op.execute(sa.text(
|
|
||||||
f"""
|
|
||||||
SET @idx := (
|
|
||||||
SELECT INDEX_NAME FROM information_schema.STATISTICS
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'price_lists' AND COLUMN_NAME = '{col}' LIMIT 1
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
))
|
|
||||||
op.execute(sa.text(
|
|
||||||
"""
|
|
||||||
SET @qi := IF(@idx IS NOT NULL, CONCAT('ALTER TABLE price_lists DROP INDEX ', @idx), 'SELECT 1');
|
|
||||||
PREPARE s FROM @qi; EXECUTE s; DEALLOCATE PREPARE s;
|
|
||||||
"""
|
|
||||||
))
|
|
||||||
|
|
||||||
# Finally drop columns if they exist
|
# Finally drop columns if they exist (manual check)
|
||||||
op.execute(sa.text("ALTER TABLE price_lists DROP COLUMN IF EXISTS currency_id"))
|
for col in ('currency_id', 'default_unit_id'):
|
||||||
op.execute(sa.text("ALTER TABLE price_lists DROP COLUMN IF EXISTS default_unit_id"))
|
exists = conn.execute(sa.text(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) as cnt FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'price_lists' AND COLUMN_NAME = :col
|
||||||
|
"""
|
||||||
|
), {"col": col}).scalar() or 0
|
||||||
|
if int(exists) > 0:
|
||||||
|
conn.execute(sa.text(f"ALTER TABLE price_lists DROP COLUMN {col}"))
|
||||||
else:
|
else:
|
||||||
# Best-effort: drop constraint by common names, then drop columns
|
# Best-effort: drop constraint by common names, then drop columns
|
||||||
for name in ('price_lists_currency_id_fkey', 'fk_price_lists_currency_id', 'price_lists_currency_id_fk'):
|
for name in ('price_lists_currency_id_fkey', 'fk_price_lists_currency_id', 'price_lists_currency_id_fk'):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op # noqa: F401
|
||||||
|
import sqlalchemy as sa # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20251001_001201_merge_heads_drop_currency_tax_units'
|
||||||
|
down_revision = (
|
||||||
|
'20251001_001101_drop_price_list_currency_default_unit',
|
||||||
|
'9f9786ae7191',
|
||||||
|
)
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Merge only; no operations.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Merge only; no operations.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""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:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if 'bank_accounts' not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
'bank_accounts',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('branch', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('account_number', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('sheba_number', sa.String(length=30), nullable=True),
|
||||||
|
sa.Column('card_number', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('owner_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('pos_number', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('payment_id', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('currency_id', sa.Integer(), sa.ForeignKey('currencies.id', ondelete='RESTRICT'), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
||||||
|
sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.UniqueConstraint('business_id', 'code', name='uq_bank_accounts_business_code'),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
op.create_index('ix_bank_accounts_business_id', 'bank_accounts', ['business_id'])
|
||||||
|
op.create_index('ix_bank_accounts_currency_id', 'bank_accounts', ['currency_id'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# تلاش برای ایجاد ایندکسها اگر وجود ندارند
|
||||||
|
existing_indexes = {idx['name'] for idx in inspector.get_indexes('bank_accounts')}
|
||||||
|
if 'ix_bank_accounts_business_id' not in existing_indexes:
|
||||||
|
try:
|
||||||
|
op.create_index('ix_bank_accounts_business_id', 'bank_accounts', ['business_id'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if 'ix_bank_accounts_currency_id' not in existing_indexes:
|
||||||
|
try:
|
||||||
|
op.create_index('ix_bank_accounts_currency_id', 'bank_accounts', ['currency_id'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
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')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""add cash_registers table
|
||||||
|
|
||||||
|
Revision ID: 20251003_000201_add_cash_registers_table
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-10-03 00:02:01.000001
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20251003_000201_add_cash_registers_table'
|
||||||
|
down_revision = 'a1443c153b47'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if 'cash_registers' not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
'cash_registers',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('description', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('currency_id', sa.Integer(), sa.ForeignKey('currencies.id', ondelete='RESTRICT'), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
||||||
|
sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text('0')),
|
||||||
|
sa.Column('payment_switch_number', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('payment_terminal_number', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('merchant_id', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.UniqueConstraint('business_id', 'code', name='uq_cash_registers_business_code'),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
op.create_index('ix_cash_registers_business_id', 'cash_registers', ['business_id'])
|
||||||
|
op.create_index('ix_cash_registers_currency_id', 'cash_registers', ['currency_id'])
|
||||||
|
op.create_index('ix_cash_registers_is_active', 'cash_registers', ['is_active'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_cash_registers_is_active', table_name='cash_registers')
|
||||||
|
op.drop_index('ix_cash_registers_currency_id', table_name='cash_registers')
|
||||||
|
op.drop_index('ix_cash_registers_business_id', table_name='cash_registers')
|
||||||
|
op.drop_table('cash_registers')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""add name to cash_registers
|
||||||
|
|
||||||
|
Revision ID: 20251003_010501_add_name_to_cash_registers
|
||||||
|
Revises: 20251003_000201_add_cash_registers_table
|
||||||
|
Create Date: 2025-10-03 01:05:01.000001
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '20251003_010501_add_name_to_cash_registers'
|
||||||
|
down_revision = '20251003_000201_add_cash_registers_table'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add column if not exists (MySQL safe): try/except
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
cols = [c['name'] for c in inspector.get_columns('cash_registers')]
|
||||||
|
if 'name' not in cols:
|
||||||
|
op.add_column('cash_registers', sa.Column('name', sa.String(length=255), nullable=True))
|
||||||
|
# Fill default empty name from code or merchant_id to avoid nulls
|
||||||
|
try:
|
||||||
|
conn.execute(sa.text("UPDATE cash_registers SET name = COALESCE(name, code)"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Alter to not null
|
||||||
|
with op.batch_alter_table('cash_registers') as batch_op:
|
||||||
|
batch_op.alter_column('name', existing_type=sa.String(length=255), nullable=False)
|
||||||
|
# Create index
|
||||||
|
try:
|
||||||
|
op.create_index('ix_cash_registers_name', 'cash_registers', ['name'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
try:
|
||||||
|
op.drop_index('ix_cash_registers_name', table_name='cash_registers')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
with op.batch_alter_table('cash_registers') as batch_op:
|
||||||
|
try:
|
||||||
|
batch_op.drop_column('name')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -7,6 +7,7 @@ Create Date: 2025-09-30 14:47:28.281817
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
|
@ -17,26 +18,33 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Create tax_units table
|
bind = op.get_bind()
|
||||||
op.create_table('tax_units',
|
inspector = inspect(bind)
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'),
|
created_tax_units = False
|
||||||
sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'),
|
if not inspector.has_table('tax_units'):
|
||||||
sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'),
|
op.create_table(
|
||||||
sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'),
|
'tax_units',
|
||||||
sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'),
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'),
|
sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'),
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'),
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'),
|
||||||
mysql_charset='utf8mb4'
|
sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'),
|
||||||
)
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
# Create indexes
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False)
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_charset='utf8mb4'
|
||||||
# Add foreign key constraint to products table
|
)
|
||||||
op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL')
|
created_tax_units = True
|
||||||
|
|
||||||
|
if created_tax_units:
|
||||||
|
# Create indexes
|
||||||
|
op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False)
|
||||||
|
|
||||||
|
# Add foreign key constraint to products table
|
||||||
|
op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""merge heads
|
||||||
|
|
||||||
|
Revision ID: a1443c153b47
|
||||||
|
Revises: 20250102_000001, 20251002_000101_add_bank_accounts_table
|
||||||
|
Create Date: 2025-10-03 14:25:49.978103
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a1443c153b47'
|
||||||
|
down_revision = ('20250102_000001', '20251002_000101_add_bank_accounts_table')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
@ -14,6 +14,7 @@ adapters/api/v1/categories.py
|
||||||
adapters/api/v1/currencies.py
|
adapters/api/v1/currencies.py
|
||||||
adapters/api/v1/health.py
|
adapters/api/v1/health.py
|
||||||
adapters/api/v1/persons.py
|
adapters/api/v1/persons.py
|
||||||
|
adapters/api/v1/petty_cash.py
|
||||||
adapters/api/v1/price_lists.py
|
adapters/api/v1/price_lists.py
|
||||||
adapters/api/v1/product_attributes.py
|
adapters/api/v1/product_attributes.py
|
||||||
adapters/api/v1/products.py
|
adapters/api/v1/products.py
|
||||||
|
|
@ -58,6 +59,7 @@ adapters/db/models/file_storage.py
|
||||||
adapters/db/models/fiscal_year.py
|
adapters/db/models/fiscal_year.py
|
||||||
adapters/db/models/password_reset.py
|
adapters/db/models/password_reset.py
|
||||||
adapters/db/models/person.py
|
adapters/db/models/person.py
|
||||||
|
adapters/db/models/petty_cash.py
|
||||||
adapters/db/models/price_list.py
|
adapters/db/models/price_list.py
|
||||||
adapters/db/models/product.py
|
adapters/db/models/product.py
|
||||||
adapters/db/models/product_attribute.py
|
adapters/db/models/product_attribute.py
|
||||||
|
|
@ -80,6 +82,7 @@ adapters/db/repositories/email_config_repository.py
|
||||||
adapters/db/repositories/file_storage_repository.py
|
adapters/db/repositories/file_storage_repository.py
|
||||||
adapters/db/repositories/fiscal_year_repo.py
|
adapters/db/repositories/fiscal_year_repo.py
|
||||||
adapters/db/repositories/password_reset_repo.py
|
adapters/db/repositories/password_reset_repo.py
|
||||||
|
adapters/db/repositories/petty_cash_repository.py
|
||||||
adapters/db/repositories/price_list_repository.py
|
adapters/db/repositories/price_list_repository.py
|
||||||
adapters/db/repositories/product_attribute_repository.py
|
adapters/db/repositories/product_attribute_repository.py
|
||||||
adapters/db/repositories/product_repository.py
|
adapters/db/repositories/product_repository.py
|
||||||
|
|
@ -116,6 +119,7 @@ app/services/cash_register_service.py
|
||||||
app/services/email_service.py
|
app/services/email_service.py
|
||||||
app/services/file_storage_service.py
|
app/services/file_storage_service.py
|
||||||
app/services/person_service.py
|
app/services/person_service.py
|
||||||
|
app/services/petty_cash_service.py
|
||||||
app/services/price_list_service.py
|
app/services/price_list_service.py
|
||||||
app/services/product_attribute_service.py
|
app/services/product_attribute_service.py
|
||||||
app/services/product_service.py
|
app/services/product_service.py
|
||||||
|
|
@ -131,6 +135,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/1f0abcdd7300_add_petty_cash_table.py
|
||||||
migrations/versions/20250102_000001_seed_support_data.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
|
||||||
|
|
@ -167,6 +172,7 @@ 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/20251002_000101_add_bank_accounts_table.py
|
||||||
migrations/versions/20251003_000201_add_cash_registers_table.py
|
migrations/versions/20251003_000201_add_cash_registers_table.py
|
||||||
|
migrations/versions/20251003_010501_add_name_to_cash_registers.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
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -293,6 +293,34 @@ msgstr "of"
|
||||||
msgid "noDataFound"
|
msgid "noDataFound"
|
||||||
msgstr "No data found"
|
msgstr "No data found"
|
||||||
|
|
||||||
|
# Banking / Petty Cash
|
||||||
|
msgid "PETTY_CASH_LIST_FETCHED"
|
||||||
|
msgstr "Petty cash list retrieved successfully"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_CREATED"
|
||||||
|
msgstr "Petty cash created successfully"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_DETAILS"
|
||||||
|
msgstr "Petty cash details"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_UPDATED"
|
||||||
|
msgstr "Petty cash updated successfully"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_DELETED"
|
||||||
|
msgstr "Petty cash deleted successfully"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_NOT_FOUND"
|
||||||
|
msgstr "Petty cash not found"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_BULK_DELETE_DONE"
|
||||||
|
msgstr "Bulk delete completed for petty cash"
|
||||||
|
|
||||||
|
msgid "INVALID_PETTY_CASH_CODE"
|
||||||
|
msgstr "Invalid petty cash code"
|
||||||
|
|
||||||
|
msgid "DUPLICATE_PETTY_CASH_CODE"
|
||||||
|
msgstr "Duplicate petty cash code"
|
||||||
|
|
||||||
msgid "activeFilters"
|
msgid "activeFilters"
|
||||||
msgstr "Active Filters"
|
msgstr "Active Filters"
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -137,6 +137,34 @@ msgstr "پیکربندی ایمیل یافت نشد"
|
||||||
msgid "Configuration name already exists"
|
msgid "Configuration name already exists"
|
||||||
msgstr "نام پیکربندی قبلاً استفاده شده است"
|
msgstr "نام پیکربندی قبلاً استفاده شده است"
|
||||||
|
|
||||||
|
# Banking / Petty Cash
|
||||||
|
msgid "PETTY_CASH_LIST_FETCHED"
|
||||||
|
msgstr "لیست تنخواه گردانها با موفقیت دریافت شد"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_CREATED"
|
||||||
|
msgstr "تنخواه گردان با موفقیت ایجاد شد"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_DETAILS"
|
||||||
|
msgstr "جزئیات تنخواه گردان"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_UPDATED"
|
||||||
|
msgstr "تنخواه گردان با موفقیت بهروزرسانی شد"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_DELETED"
|
||||||
|
msgstr "تنخواه گردان با موفقیت حذف شد"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_NOT_FOUND"
|
||||||
|
msgstr "تنخواه گردان یافت نشد"
|
||||||
|
|
||||||
|
msgid "PETTY_CASH_BULK_DELETE_DONE"
|
||||||
|
msgstr "حذف گروهی تنخواه گردانها انجام شد"
|
||||||
|
|
||||||
|
msgid "INVALID_PETTY_CASH_CODE"
|
||||||
|
msgstr "کد تنخواه گردان نامعتبر است"
|
||||||
|
|
||||||
|
msgid "DUPLICATE_PETTY_CASH_CODE"
|
||||||
|
msgstr "کد تنخواه گردان تکراری است"
|
||||||
|
|
||||||
msgid "Connection test completed"
|
msgid "Connection test completed"
|
||||||
msgstr "تست اتصال تکمیل شد"
|
msgstr "تست اتصال تکمیل شد"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""add_petty_cash_table
|
||||||
|
|
||||||
|
Revision ID: 1f0abcdd7300
|
||||||
|
Revises: 20251003_010501_add_name_to_cash_registers
|
||||||
|
Create Date: 2025-10-04 01:16:30.244567
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1f0abcdd7300'
|
||||||
|
down_revision = '20251003_010501_add_name_to_cash_registers'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('petty_cash',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False, comment='نام تنخواه گردان'),
|
||||||
|
sa.Column('code', sa.String(length=50), nullable=True, comment='کد یکتا در هر کسب\u200cوکار (اختیاری)'),
|
||||||
|
sa.Column('description', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('currency_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default=sa.text('1'), nullable=False),
|
||||||
|
sa.Column('is_default', sa.Boolean(), server_default=sa.text('0'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('business_id', 'code', name='uq_petty_cash_business_code')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_petty_cash_business_id'), 'petty_cash', ['business_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_petty_cash_code'), 'petty_cash', ['code'], unique=False)
|
||||||
|
op.create_index(op.f('ix_petty_cash_currency_id'), 'petty_cash', ['currency_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_petty_cash_name'), 'petty_cash', ['name'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_petty_cash_name'), table_name='petty_cash')
|
||||||
|
op.drop_index(op.f('ix_petty_cash_currency_id'), table_name='petty_cash')
|
||||||
|
op.drop_index(op.f('ix_petty_cash_code'), table_name='petty_cash')
|
||||||
|
op.drop_index(op.f('ix_petty_cash_business_id'), table_name='petty_cash')
|
||||||
|
op.drop_table('petty_cash')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -1065,6 +1065,31 @@
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No"
|
"no": "No",
|
||||||
|
"pettyCash": "Petty Cash",
|
||||||
|
"pettyCashManagement": "Petty Cash Management",
|
||||||
|
"addPettyCash": "Add Petty Cash",
|
||||||
|
"editPettyCash": "Edit Petty Cash",
|
||||||
|
"deletePettyCash": "Delete Petty Cash",
|
||||||
|
"viewPettyCash": "View Petty Cash",
|
||||||
|
"pettyCashName": "Petty Cash Name",
|
||||||
|
"pettyCashCode": "Petty Cash Code",
|
||||||
|
"pettyCashDescription": "Petty Cash Description",
|
||||||
|
"pettyCashCurrency": "Currency",
|
||||||
|
"pettyCashIsActive": "Active",
|
||||||
|
"pettyCashIsDefault": "Default",
|
||||||
|
"pettyCashCreatedSuccessfully": "Petty cash created successfully",
|
||||||
|
"pettyCashUpdatedSuccessfully": "Petty cash updated successfully",
|
||||||
|
"pettyCashDeletedSuccessfully": "Petty cash deleted successfully",
|
||||||
|
"pettyCashNotFound": "Petty cash not found",
|
||||||
|
"pettyCashNameRequired": "Petty cash name is required",
|
||||||
|
"duplicatePettyCashCode": "Duplicate petty cash code",
|
||||||
|
"invalidPettyCashCode": "Invalid petty cash code",
|
||||||
|
"pettyCashBulkDeleted": "Petty cash items deleted successfully",
|
||||||
|
"pettyCashListFetched": "Petty cash list fetched",
|
||||||
|
"pettyCashDetails": "Petty cash details",
|
||||||
|
"pettyCashExportExcel": "Export petty cash to Excel",
|
||||||
|
"pettyCashExportPdf": "Export petty cash to PDF",
|
||||||
|
"pettyCashReport": "Petty Cash Report"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1048,6 +1048,31 @@
|
||||||
"description": "توضیحات",
|
"description": "توضیحات",
|
||||||
"actions": "اقدامات",
|
"actions": "اقدامات",
|
||||||
"yes": "بله",
|
"yes": "بله",
|
||||||
"no": "خیر"
|
"no": "خیر",
|
||||||
|
"pettyCash": "تنخواه گردان",
|
||||||
|
"pettyCashManagement": "مدیریت تنخواه گردان",
|
||||||
|
"addPettyCash": "افزودن تنخواه گردان",
|
||||||
|
"editPettyCash": "ویرایش تنخواه گردان",
|
||||||
|
"deletePettyCash": "حذف تنخواه گردان",
|
||||||
|
"viewPettyCash": "مشاهده تنخواه گردان",
|
||||||
|
"pettyCashName": "نام تنخواه گردان",
|
||||||
|
"pettyCashCode": "کد تنخواه گردان",
|
||||||
|
"pettyCashDescription": "توضیحات تنخواه گردان",
|
||||||
|
"pettyCashCurrency": "واحد پولی",
|
||||||
|
"pettyCashIsActive": "فعال",
|
||||||
|
"pettyCashIsDefault": "پیشفرض",
|
||||||
|
"pettyCashCreatedSuccessfully": "تنخواه گردان با موفقیت ایجاد شد",
|
||||||
|
"pettyCashUpdatedSuccessfully": "تنخواه گردان با موفقیت ویرایش شد",
|
||||||
|
"pettyCashDeletedSuccessfully": "تنخواه گردان با موفقیت حذف شد",
|
||||||
|
"pettyCashNotFound": "تنخواه گردان یافت نشد",
|
||||||
|
"pettyCashNameRequired": "نام تنخواه گردان الزامی است",
|
||||||
|
"duplicatePettyCashCode": "کد تنخواه گردان تکراری است",
|
||||||
|
"invalidPettyCashCode": "کد تنخواه گردان نامعتبر است",
|
||||||
|
"pettyCashBulkDeleted": "تنخواه گردانها با موفقیت حذف شدند",
|
||||||
|
"pettyCashListFetched": "لیست تنخواه گردانها دریافت شد",
|
||||||
|
"pettyCashDetails": "جزئیات تنخواه گردان",
|
||||||
|
"pettyCashExportExcel": "خروجی Excel تنخواه گردانها",
|
||||||
|
"pettyCashExportPdf": "خروجی PDF تنخواه گردانها",
|
||||||
|
"pettyCashReport": "گزارش تنخواه گردانها"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5599,6 +5599,126 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Coming soon'**
|
/// **'Coming soon'**
|
||||||
String get comingSoon;
|
String get comingSoon;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashManagement.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty Cash Management'**
|
||||||
|
String get pettyCashManagement;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashName.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty Cash Name'**
|
||||||
|
String get pettyCashName;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashCode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty Cash Code'**
|
||||||
|
String get pettyCashCode;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashDescription.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty Cash Description'**
|
||||||
|
String get pettyCashDescription;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashCurrency.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Currency'**
|
||||||
|
String get pettyCashCurrency;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashIsActive.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Active'**
|
||||||
|
String get pettyCashIsActive;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashIsDefault.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Default'**
|
||||||
|
String get pettyCashIsDefault;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashCreatedSuccessfully.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty cash created successfully'**
|
||||||
|
String get pettyCashCreatedSuccessfully;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashUpdatedSuccessfully.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty cash updated successfully'**
|
||||||
|
String get pettyCashUpdatedSuccessfully;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashDeletedSuccessfully.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty cash deleted successfully'**
|
||||||
|
String get pettyCashDeletedSuccessfully;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashNotFound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty cash not found'**
|
||||||
|
String get pettyCashNotFound;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashNameRequired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty cash name is required'**
|
||||||
|
String get pettyCashNameRequired;
|
||||||
|
|
||||||
|
/// No description provided for @duplicatePettyCashCode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Duplicate petty cash code'**
|
||||||
|
String get duplicatePettyCashCode;
|
||||||
|
|
||||||
|
/// No description provided for @invalidPettyCashCode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Invalid petty cash code'**
|
||||||
|
String get invalidPettyCashCode;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashBulkDeleted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty cash items deleted successfully'**
|
||||||
|
String get pettyCashBulkDeleted;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashListFetched.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty cash list fetched'**
|
||||||
|
String get pettyCashListFetched;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashDetails.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty cash details'**
|
||||||
|
String get pettyCashDetails;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashExportExcel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Export petty cash to Excel'**
|
||||||
|
String get pettyCashExportExcel;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashExportPdf.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Export petty cash to PDF'**
|
||||||
|
String get pettyCashExportPdf;
|
||||||
|
|
||||||
|
/// No description provided for @pettyCashReport.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty Cash Report'**
|
||||||
|
String get pettyCashReport;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2837,4 +2837,64 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get comingSoon => 'Coming soon';
|
String get comingSoon => 'Coming soon';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashManagement => 'Petty Cash Management';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashName => 'Petty Cash Name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashCode => 'Petty Cash Code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashDescription => 'Petty Cash Description';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashCurrency => 'Currency';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashIsActive => 'Active';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashIsDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashCreatedSuccessfully => 'Petty cash created successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashUpdatedSuccessfully => 'Petty cash updated successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashDeletedSuccessfully => 'Petty cash deleted successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashNotFound => 'Petty cash not found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashNameRequired => 'Petty cash name is required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get duplicatePettyCashCode => 'Duplicate petty cash code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get invalidPettyCashCode => 'Invalid petty cash code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashBulkDeleted => 'Petty cash items deleted successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashListFetched => 'Petty cash list fetched';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashDetails => 'Petty cash details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashExportExcel => 'Export petty cash to Excel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashExportPdf => 'Export petty cash to PDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashReport => 'Petty Cash Report';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1874,16 +1874,16 @@ class AppLocalizationsFa extends AppLocalizations {
|
||||||
String get deleteCash => 'حذف صندوقها';
|
String get deleteCash => 'حذف صندوقها';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addPettyCash => 'افزودن تنخواه';
|
String get addPettyCash => 'افزودن تنخواه گردان';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get viewPettyCash => 'مشاهده تنخواهها';
|
String get viewPettyCash => 'مشاهده تنخواه گردان';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get editPettyCash => 'ویرایش تنخواهها';
|
String get editPettyCash => 'ویرایش تنخواه گردان';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get deletePettyCash => 'حذف تنخواهها';
|
String get deletePettyCash => 'حذف تنخواه گردان';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addCheck => 'افزودن چک';
|
String get addCheck => 'افزودن چک';
|
||||||
|
|
@ -2816,4 +2816,64 @@ class AppLocalizationsFa extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get comingSoon => 'بهزودی';
|
String get comingSoon => 'بهزودی';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashManagement => 'مدیریت تنخواه گردان';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashName => 'نام تنخواه گردان';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashCode => 'کد تنخواه گردان';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashDescription => 'توضیحات تنخواه گردان';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashCurrency => 'واحد پولی';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashIsActive => 'فعال';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashIsDefault => 'پیشفرض';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashCreatedSuccessfully => 'تنخواه گردان با موفقیت ایجاد شد';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashUpdatedSuccessfully => 'تنخواه گردان با موفقیت ویرایش شد';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashDeletedSuccessfully => 'تنخواه گردان با موفقیت حذف شد';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashNotFound => 'تنخواه گردان یافت نشد';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashNameRequired => 'نام تنخواه گردان الزامی است';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get duplicatePettyCashCode => 'کد تنخواه گردان تکراری است';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get invalidPettyCashCode => 'کد تنخواه گردان نامعتبر است';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashBulkDeleted => 'تنخواه گردانها با موفقیت حذف شدند';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashListFetched => 'لیست تنخواه گردانها دریافت شد';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashDetails => 'جزئیات تنخواه گردان';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashExportExcel => 'خروجی Excel تنخواه گردانها';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashExportPdf => 'خروجی PDF تنخواه گردانها';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pettyCashReport => 'گزارش تنخواه گردانها';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ 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/bank_accounts_page.dart';
|
||||||
|
import 'pages/business/wallet_page.dart';
|
||||||
|
import 'pages/business/invoice_page.dart';
|
||||||
|
import 'pages/business/new_invoice_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';
|
||||||
|
|
@ -31,6 +34,7 @@ import 'pages/business/products_page.dart';
|
||||||
import 'pages/business/price_lists_page.dart';
|
import 'pages/business/price_lists_page.dart';
|
||||||
import 'pages/business/price_list_items_page.dart';
|
import 'pages/business/price_list_items_page.dart';
|
||||||
import 'pages/business/cash_registers_page.dart';
|
import 'pages/business/cash_registers_page.dart';
|
||||||
|
import 'pages/business/petty_cash_page.dart';
|
||||||
import 'pages/error_404_page.dart';
|
import 'pages/error_404_page.dart';
|
||||||
import 'core/locale_controller.dart';
|
import 'core/locale_controller.dart';
|
||||||
import 'core/calendar_controller.dart';
|
import 'core/calendar_controller.dart';
|
||||||
|
|
@ -562,6 +566,24 @@ class _MyAppState extends State<MyApp> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'petty-cash',
|
||||||
|
name: 'business_petty_cash',
|
||||||
|
builder: (context, state) {
|
||||||
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
return BusinessShell(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
localeController: controller,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
themeController: themeController,
|
||||||
|
child: PettyCashPage(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'cash-box',
|
path: 'cash-box',
|
||||||
name: 'business_cash_box',
|
name: 'business_cash_box',
|
||||||
|
|
@ -580,6 +602,60 @@ class _MyAppState extends State<MyApp> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'wallet',
|
||||||
|
name: 'business_wallet',
|
||||||
|
builder: (context, state) {
|
||||||
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
return BusinessShell(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
localeController: controller,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
themeController: themeController,
|
||||||
|
child: WalletPage(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'invoice',
|
||||||
|
name: 'business_invoice',
|
||||||
|
builder: (context, state) {
|
||||||
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
return BusinessShell(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
localeController: controller,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
themeController: themeController,
|
||||||
|
child: InvoicePage(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'invoice/new',
|
||||||
|
name: 'business_new_invoice',
|
||||||
|
builder: (context, state) {
|
||||||
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
return BusinessShell(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
localeController: controller,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
themeController: themeController,
|
||||||
|
child: NewInvoicePage(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'business_settings',
|
name: 'business_settings',
|
||||||
|
|
|
||||||
110
hesabixUI/hesabix_ui/lib/models/petty_cash.dart
Normal file
110
hesabixUI/hesabix_ui/lib/models/petty_cash.dart
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
class PettyCash {
|
||||||
|
final int? id;
|
||||||
|
final int businessId;
|
||||||
|
final String name;
|
||||||
|
final String? code;
|
||||||
|
final int currencyId;
|
||||||
|
final bool isActive;
|
||||||
|
final bool isDefault;
|
||||||
|
final String? description;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
const PettyCash({
|
||||||
|
this.id,
|
||||||
|
required this.businessId,
|
||||||
|
required this.name,
|
||||||
|
this.code,
|
||||||
|
required this.currencyId,
|
||||||
|
this.isActive = true,
|
||||||
|
this.isDefault = false,
|
||||||
|
this.description,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PettyCash.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PettyCash(
|
||||||
|
id: json['id'] as int?,
|
||||||
|
businessId: (json['business_id'] ?? json['businessId']) as int,
|
||||||
|
name: (json['name'] ?? '') as String,
|
||||||
|
code: json['code'] as String?,
|
||||||
|
currencyId: (json['currency_id'] ?? json['currencyId']) as int,
|
||||||
|
isActive: (json['is_active'] ?? true) as bool,
|
||||||
|
isDefault: (json['is_default'] ?? false) as bool,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
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 {
|
||||||
|
'name': name,
|
||||||
|
'code': code,
|
||||||
|
'currency_id': currencyId,
|
||||||
|
'is_active': isActive,
|
||||||
|
'is_default': isDefault,
|
||||||
|
'description': description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PettyCash copyWith({
|
||||||
|
int? id,
|
||||||
|
int? businessId,
|
||||||
|
String? name,
|
||||||
|
String? code,
|
||||||
|
int? currencyId,
|
||||||
|
bool? isActive,
|
||||||
|
bool? isDefault,
|
||||||
|
String? description,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return PettyCash(
|
||||||
|
id: id ?? this.id,
|
||||||
|
businessId: businessId ?? this.businessId,
|
||||||
|
name: name ?? this.name,
|
||||||
|
code: code ?? this.code,
|
||||||
|
currencyId: currencyId ?? this.currencyId,
|
||||||
|
isActive: isActive ?? this.isActive,
|
||||||
|
isDefault: isDefault ?? this.isDefault,
|
||||||
|
description: description ?? this.description,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is PettyCash &&
|
||||||
|
other.id == id &&
|
||||||
|
other.businessId == businessId &&
|
||||||
|
other.name == name &&
|
||||||
|
other.code == code &&
|
||||||
|
other.currencyId == currencyId &&
|
||||||
|
other.isActive == isActive &&
|
||||||
|
other.isDefault == isDefault &&
|
||||||
|
other.description == description;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(
|
||||||
|
id,
|
||||||
|
businessId,
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
currencyId,
|
||||||
|
isActive,
|
||||||
|
isDefault,
|
||||||
|
description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'PettyCash(id: $id, businessId: $businessId, name: $name, code: $code, currencyId: $currencyId, isActive: $isActive, isDefault: $isDefault, description: $description)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,23 @@ class _BankAccountsPageState extends State<BankAccountsPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public method to refresh the data table
|
||||||
|
void refresh() {
|
||||||
|
try {
|
||||||
|
(_bankAccountsTableKey.currentState as dynamic)?.refresh();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(BankAccountsPage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// This will be called when the widget is updated
|
||||||
|
// Refresh the data table to show any new data
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ 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/banking/bank_account_form_dialog.dart';
|
||||||
|
import '../../widgets/banking/cash_register_form_dialog.dart';
|
||||||
|
import '../../widgets/banking/petty_cash_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';
|
||||||
|
|
@ -61,6 +63,19 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
_loadBusinessInfo();
|
_loadBusinessInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshCurrentPage() {
|
||||||
|
// Force a rebuild of the current page
|
||||||
|
setState(() {
|
||||||
|
// This will cause the current page to rebuild
|
||||||
|
// and if it's PettyCashPage, it will refresh its data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadBusinessInfo() async {
|
Future<void> _loadBusinessInfo() async {
|
||||||
print('=== _loadBusinessInfo START ===');
|
print('=== _loadBusinessInfo START ===');
|
||||||
print('Current business ID: ${widget.businessId}');
|
print('Current business ID: ${widget.businessId}');
|
||||||
|
|
@ -544,6 +559,58 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> showAddCashBoxDialog() async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => CashRegisterFormDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
onSuccess: () {
|
||||||
|
// Refresh the cash registers page if it's currently open
|
||||||
|
_refreshCurrentPage();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
// Cash register was successfully added, refresh the current page
|
||||||
|
_refreshCurrentPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showAddPettyCashDialog() async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => PettyCashFormDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
onSuccess: () {
|
||||||
|
// Refresh the petty cash page if it's currently open
|
||||||
|
_refreshCurrentPage();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
// Petty cash was successfully added, refresh the current page
|
||||||
|
_refreshCurrentPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showAddBankAccountDialog() async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => BankAccountFormDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
onSuccess: () {
|
||||||
|
// Refresh the bank accounts page if it's currently open
|
||||||
|
_refreshCurrentPage();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
// Bank account was successfully added, refresh the current page
|
||||||
|
_refreshCurrentPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
bool isExpanded(_MenuItem item) {
|
bool isExpanded(_MenuItem item) {
|
||||||
if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded;
|
if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded;
|
||||||
if (item.label == t.banking) return _isBankingExpanded;
|
if (item.label == t.banking) return _isBankingExpanded;
|
||||||
|
|
@ -728,23 +795,20 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
// Navigate to add product attribute
|
// Navigate to add product attribute
|
||||||
} else if (child.label == t.accounts) {
|
} else if (child.label == t.accounts) {
|
||||||
// Open add bank account dialog
|
// Open add bank account dialog
|
||||||
showDialog(
|
showAddBankAccountDialog();
|
||||||
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
|
// Open add petty cash dialog
|
||||||
|
showAddPettyCashDialog();
|
||||||
} else if (child.label == t.cashBox) {
|
} else if (child.label == t.cashBox) {
|
||||||
// For cash box, navigate to the page and use its add
|
// Open add cash register dialog
|
||||||
context.go('/business/${widget.businessId}/cash-box');
|
showAddCashBoxDialog();
|
||||||
} else if (child.label == t.wallet) {
|
} else if (child.label == t.wallet) {
|
||||||
// Navigate to add wallet
|
// Navigate to add wallet
|
||||||
} else if (child.label == t.checks) {
|
} else if (child.label == t.checks) {
|
||||||
// Navigate to add check
|
// Navigate to add check
|
||||||
} else if (child.label == t.invoice) {
|
} else if (child.label == t.invoice) {
|
||||||
// Navigate to add invoice
|
// Navigate to add invoice
|
||||||
|
context.go('/business/${widget.businessId}/invoice/new');
|
||||||
} else if (child.label == t.expenseAndIncome) {
|
} else if (child.label == t.expenseAndIncome) {
|
||||||
// Navigate to add expense/income
|
// Navigate to add expense/income
|
||||||
} else if (child.label == t.warehouses) {
|
} else if (child.label == t.warehouses) {
|
||||||
|
|
@ -881,14 +945,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
if (item.label == t.people) {
|
if (item.label == t.people) {
|
||||||
showAddPersonDialog();
|
showAddPersonDialog();
|
||||||
} else if (item.label == t.accounts) {
|
} else if (item.label == t.accounts) {
|
||||||
showDialog(
|
showAddBankAccountDialog();
|
||||||
context: context,
|
|
||||||
builder: (ctx) => BankAccountFormDialog(
|
|
||||||
businessId: widget.businessId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (item.label == t.cashBox) {
|
} else if (item.label == t.cashBox) {
|
||||||
context.go('/business/${widget.businessId}/cash-box');
|
showAddCashBoxDialog();
|
||||||
}
|
}
|
||||||
// سایر مسیرهای افزودن در آینده متصل میشوند
|
// سایر مسیرهای افزودن در آینده متصل میشوند
|
||||||
},
|
},
|
||||||
|
|
@ -1036,17 +1095,21 @@ 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
|
||||||
|
showAddBankAccountDialog();
|
||||||
} else if (child.label == t.pettyCash) {
|
} else if (child.label == t.pettyCash) {
|
||||||
// Navigate to add petty cash
|
// Open add petty cash dialog
|
||||||
|
showAddPettyCashDialog();
|
||||||
} else if (child.label == t.cashBox) {
|
} else if (child.label == t.cashBox) {
|
||||||
// Navigate to add cash box
|
// Open add cash register dialog
|
||||||
|
showAddCashBoxDialog();
|
||||||
} else if (child.label == t.wallet) {
|
} else if (child.label == t.wallet) {
|
||||||
// Navigate to add wallet
|
// Navigate to add wallet
|
||||||
} else if (child.label == t.checks) {
|
} else if (child.label == t.checks) {
|
||||||
// Navigate to add check
|
// Navigate to add check
|
||||||
} else if (child.label == t.invoice) {
|
} else if (child.label == t.invoice) {
|
||||||
// Navigate to add invoice
|
// Navigate to add invoice
|
||||||
|
context.go('/business/${widget.businessId}/invoice/new');
|
||||||
} else if (child.label == t.expenseAndIncome) {
|
} else if (child.label == t.expenseAndIncome) {
|
||||||
// Navigate to add expense/income
|
// Navigate to add expense/income
|
||||||
} else if (child.label == t.warehouses) {
|
} else if (child.label == t.warehouses) {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,23 @@ class _CashRegistersPageState extends State<CashRegistersPage> {
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public method to refresh the data table
|
||||||
|
void refresh() {
|
||||||
|
try {
|
||||||
|
(_tableKey.currentState as dynamic)?.refresh();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(CashRegistersPage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// This will be called when the widget is updated
|
||||||
|
// Refresh the data table to show any new data
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
|
|
@ -151,7 +168,7 @@ class _CashRegistersPageState extends State<CashRegistersPage> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
searchFields: ['code','description','payment_switch_number','payment_terminal_number','merchant_id'],
|
searchFields: ['name','code','description','payment_switch_number','payment_terminal_number','merchant_id'],
|
||||||
filterFields: ['is_active','is_default','currency_id'],
|
filterFields: ['is_active','is_default','currency_id'],
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
customHeaderActions: [
|
customHeaderActions: [
|
||||||
|
|
|
||||||
58
hesabixUI/hesabix_ui/lib/pages/business/invoice_page.dart
Normal file
58
hesabixUI/hesabix_ui/lib/pages/business/invoice_page.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../widgets/permission/access_denied_page.dart';
|
||||||
|
|
||||||
|
class InvoicePage extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final AuthStore authStore;
|
||||||
|
|
||||||
|
const InvoicePage({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.authStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InvoicePage> createState() => _InvoicePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoicePageState extends State<InvoicePage> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
if (!widget.authStore.canReadSection('invoices')) {
|
||||||
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.receipt,
|
||||||
|
size: 80,
|
||||||
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
t.invoice,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'صفحه فاکتور در حال توسعه است',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../widgets/permission/access_denied_page.dart';
|
||||||
|
|
||||||
|
class NewInvoicePage extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final AuthStore authStore;
|
||||||
|
|
||||||
|
const NewInvoicePage({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.authStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NewInvoicePage> createState() => _NewInvoicePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewInvoicePageState extends State<NewInvoicePage> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
if (!widget.authStore.canWriteSection('invoices')) {
|
||||||
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(t.addInvoice),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.add_circle_outline,
|
||||||
|
size: 80,
|
||||||
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
t.addInvoice,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'فرم ایجاد فاکتور جدید در حال توسعه است',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: پیادهسازی منطق ایجاد فاکتور
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('فرم ایجاد فاکتور به زودی اضافه خواهد شد'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(t.addInvoice),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
283
hesabixUI/hesabix_ui/lib/pages/business/petty_cash_page.dart
Normal file
283
hesabixUI/hesabix_ui/lib/pages/business/petty_cash_page.dart
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
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/petty_cash.dart';
|
||||||
|
import '../../services/petty_cash_service.dart';
|
||||||
|
import '../../services/currency_service.dart';
|
||||||
|
import '../../widgets/banking/petty_cash_form_dialog.dart';
|
||||||
|
|
||||||
|
class PettyCashPage extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final AuthStore authStore;
|
||||||
|
|
||||||
|
const PettyCashPage({super.key, required this.businessId, required this.authStore});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PettyCashPage> createState() => _PettyCashPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PettyCashPageState extends State<PettyCashPage> {
|
||||||
|
final _service = PettyCashService();
|
||||||
|
final _currencyService = CurrencyService(ApiClient());
|
||||||
|
final GlobalKey _tableKey = GlobalKey();
|
||||||
|
Map<int, String> _currencyNames = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCurrencies();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(PettyCashPage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// This will be called when the widget is updated
|
||||||
|
// Refresh the data table to show any new data
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public method to refresh the data table
|
||||||
|
void refresh() {
|
||||||
|
try {
|
||||||
|
(_tableKey.currentState as dynamic)?.refresh();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
if (!widget.authStore.canReadSection('petty_cash')) {
|
||||||
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: DataTableWidget<PettyCash>(
|
||||||
|
key: _tableKey,
|
||||||
|
config: _buildConfig(t),
|
||||||
|
fromJson: PettyCash.fromJson,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTableConfig<PettyCash> _buildConfig(AppLocalizations t) {
|
||||||
|
return DataTableConfig<PettyCash>(
|
||||||
|
endpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash',
|
||||||
|
title: (t.localeName == 'fa') ? 'تنخواه گردان' : 'Petty Cash',
|
||||||
|
excelEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/excel',
|
||||||
|
pdfEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/pdf',
|
||||||
|
getExportParams: () => {'business_id': widget.businessId},
|
||||||
|
showBackButton: true,
|
||||||
|
onBack: () => Navigator.of(context).maybePop(),
|
||||||
|
showTableIcon: false,
|
||||||
|
showRowNumbers: true,
|
||||||
|
enableRowSelection: true,
|
||||||
|
enableMultiRowSelection: true,
|
||||||
|
columns: [
|
||||||
|
TextColumn(
|
||||||
|
'code',
|
||||||
|
t.code,
|
||||||
|
width: ColumnWidth.small,
|
||||||
|
formatter: (row) => (row.code?.toString() ?? '-'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
TextColumn(
|
||||||
|
'name',
|
||||||
|
t.title,
|
||||||
|
width: ColumnWidth.large,
|
||||||
|
formatter: (row) => row.name,
|
||||||
|
),
|
||||||
|
TextColumn(
|
||||||
|
'currency_id',
|
||||||
|
t.currency,
|
||||||
|
width: ColumnWidth.medium,
|
||||||
|
formatter: (row) => _currencyNames[row.currencyId] ?? (t.localeName == 'fa' ? 'نامشخص' : 'Unknown'),
|
||||||
|
),
|
||||||
|
TextColumn(
|
||||||
|
'is_active',
|
||||||
|
t.active,
|
||||||
|
width: ColumnWidth.small,
|
||||||
|
formatter: (row) => row.isActive ? t.active : t.inactive,
|
||||||
|
),
|
||||||
|
TextColumn(
|
||||||
|
'is_default',
|
||||||
|
t.isDefault,
|
||||||
|
width: ColumnWidth.small,
|
||||||
|
formatter: (row) => row.isDefault ? t.yes : t.no,
|
||||||
|
),
|
||||||
|
TextColumn(
|
||||||
|
'description',
|
||||||
|
t.description,
|
||||||
|
width: ColumnWidth.large,
|
||||||
|
formatter: (row) => row.description ?? '-',
|
||||||
|
),
|
||||||
|
ActionColumn(
|
||||||
|
'actions',
|
||||||
|
t.actions,
|
||||||
|
actions: [
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.edit,
|
||||||
|
label: t.edit,
|
||||||
|
onTap: (row) => _edit(row),
|
||||||
|
),
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.delete,
|
||||||
|
label: t.delete,
|
||||||
|
color: Colors.red,
|
||||||
|
onTap: (row) => _delete(row),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
searchFields: ['name','code','description'],
|
||||||
|
filterFields: ['is_active','is_default','currency_id'],
|
||||||
|
defaultPageSize: 20,
|
||||||
|
customHeaderActions: [
|
||||||
|
PermissionButton(
|
||||||
|
section: 'petty_cash',
|
||||||
|
action: 'add',
|
||||||
|
authStore: widget.authStore,
|
||||||
|
child: Tooltip(
|
||||||
|
message: t.add,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: _add,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.authStore.canDeleteSection('petty_cash'))
|
||||||
|
Tooltip(
|
||||||
|
message: t.deleteSelected,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: _bulkDelete,
|
||||||
|
icon: const Icon(Icons.delete_sweep_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _add() async {
|
||||||
|
await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => PettyCashFormDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
onSuccess: () {
|
||||||
|
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _edit(PettyCash row) async {
|
||||||
|
await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => PettyCashFormDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
pettyCash: row,
|
||||||
|
onSuccess: () {
|
||||||
|
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(PettyCash row) async {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(t.delete),
|
||||||
|
content: Text(t.deleteConfirm(row.code ?? '')),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
|
||||||
|
FilledButton.tonal(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm != true) return;
|
||||||
|
try {
|
||||||
|
await _service.delete(row.id!);
|
||||||
|
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.deletedSuccessfully)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _bulkDelete() async {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
try {
|
||||||
|
final state = _tableKey.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 PettyCash && 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.deleteSelected),
|
||||||
|
content: Text(t.deleteSelected),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
|
||||||
|
FilledButton.tonal(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.delete)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm != true) return;
|
||||||
|
final client = ApiClient();
|
||||||
|
await client.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/bulk-delete',
|
||||||
|
data: { 'ids': ids },
|
||||||
|
);
|
||||||
|
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.deletedSuccessfully)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart
Normal file
58
hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../widgets/permission/access_denied_page.dart';
|
||||||
|
|
||||||
|
class WalletPage extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final AuthStore authStore;
|
||||||
|
|
||||||
|
const WalletPage({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.authStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WalletPage> createState() => _WalletPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WalletPageState extends State<WalletPage> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
if (!widget.authStore.canReadSection('wallet')) {
|
||||||
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.wallet,
|
||||||
|
size: 80,
|
||||||
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
t.wallet,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'صفحه کیف پول در حال توسعه است',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
hesabixUI/hesabix_ui/lib/services/petty_cash_service.dart
Normal file
95
hesabixUI/hesabix_ui/lib/services/petty_cash_service.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../core/api_client.dart';
|
||||||
|
import '../models/petty_cash.dart';
|
||||||
|
|
||||||
|
class PettyCashService {
|
||||||
|
final ApiClient _client;
|
||||||
|
PettyCashService({ApiClient? client}) : _client = client ?? ApiClient();
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> list({required int businessId, required Map<String, dynamic> queryInfo}) async {
|
||||||
|
try {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/petty-cash/businesses/$businessId/petty-cash',
|
||||||
|
data: queryInfo,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Null safety checks
|
||||||
|
final data = res.data ?? <String, dynamic>{};
|
||||||
|
if (data['items'] == null) {
|
||||||
|
data['items'] = <dynamic>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
// Return safe fallback data structure
|
||||||
|
return {
|
||||||
|
'items': <dynamic>[],
|
||||||
|
'pagination': {
|
||||||
|
'total': 0,
|
||||||
|
'page': 1,
|
||||||
|
'per_page': queryInfo['take'] ?? 10,
|
||||||
|
'total_pages': 0,
|
||||||
|
'has_next': false,
|
||||||
|
'has_prev': false,
|
||||||
|
},
|
||||||
|
'query_info': queryInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PettyCash> create({required int businessId, required Map<String, dynamic> payload}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/petty-cash/businesses/$businessId/petty-cash/create',
|
||||||
|
data: payload,
|
||||||
|
);
|
||||||
|
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
if (data.isEmpty) {
|
||||||
|
throw Exception('No data received from server');
|
||||||
|
}
|
||||||
|
return PettyCash.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PettyCash> getById(int id) async {
|
||||||
|
final res = await _client.get<Map<String, dynamic>>('/api/v1/petty-cash/petty-cash/$id');
|
||||||
|
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
return PettyCash.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PettyCash> update({required int id, required Map<String, dynamic> payload}) async {
|
||||||
|
final res = await _client.put<Map<String, dynamic>>('/api/v1/petty-cash/petty-cash/$id', data: payload);
|
||||||
|
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
return PettyCash.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
await _client.delete<Map<String, dynamic>>('/api/v1/petty-cash/petty-cash/$id');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<List<int>>> exportExcel({required int businessId, required Map<String, dynamic> body}) async {
|
||||||
|
return await _client.post<List<int>>(
|
||||||
|
'/api/v1/petty-cash/businesses/$businessId/petty-cash/export/excel',
|
||||||
|
data: body,
|
||||||
|
options: Options(
|
||||||
|
responseType: ResponseType.bytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<List<int>>> exportPdf({required int businessId, required Map<String, dynamic> body}) async {
|
||||||
|
return await _client.post<List<int>>(
|
||||||
|
'/api/v1/petty-cash/businesses/$businessId/petty-cash/export/pdf',
|
||||||
|
data: body,
|
||||||
|
options: Options(
|
||||||
|
responseType: ResponseType.bytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> bulkDelete({required int businessId, required List<int> ids}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/petty-cash/businesses/$businessId/petty-cash/bulk-delete',
|
||||||
|
data: {'ids': ids},
|
||||||
|
);
|
||||||
|
return (res.data ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -138,7 +138,7 @@ class _BankAccountFormDialogState extends State<BankAccountFormDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop(true); // Return true to indicate success
|
||||||
widget.onSuccess?.call();
|
widget.onSuccess?.call();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ class _CashRegisterFormDialogState extends State<CashRegisterFormDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop(true); // Return true to indicate success
|
||||||
widget.onSuccess?.call();
|
widget.onSuccess?.call();
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import '../../models/petty_cash.dart';
|
||||||
|
import '../../services/petty_cash_service.dart';
|
||||||
|
import 'currency_picker_widget.dart';
|
||||||
|
|
||||||
|
class PettyCashFormDialog extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final PettyCash? pettyCash; // null برای افزودن، مقدار برای ویرایش
|
||||||
|
final VoidCallback? onSuccess;
|
||||||
|
|
||||||
|
const PettyCashFormDialog({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
this.pettyCash,
|
||||||
|
this.onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PettyCashFormDialog> createState() => _PettyCashFormDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PettyCashFormDialogState extends State<PettyCashFormDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _service = PettyCashService();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
final _codeController = TextEditingController();
|
||||||
|
bool _autoGenerateCode = true;
|
||||||
|
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
|
||||||
|
bool _isActive = true;
|
||||||
|
bool _isDefault = false;
|
||||||
|
int? _currencyId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeForm() {
|
||||||
|
if (widget.pettyCash != null) {
|
||||||
|
final p = widget.pettyCash!;
|
||||||
|
if (p.code != null) {
|
||||||
|
_codeController.text = p.code!;
|
||||||
|
_autoGenerateCode = false;
|
||||||
|
}
|
||||||
|
_nameController.text = p.name;
|
||||||
|
_descriptionController.text = p.description ?? '';
|
||||||
|
_isActive = p.isActive;
|
||||||
|
_isDefault = p.isDefault;
|
||||||
|
_currencyId = p.currencyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_codeController.dispose();
|
||||||
|
_nameController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
if (_currencyId == null) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(t.currency), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() { _isLoading = true; });
|
||||||
|
try {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'name': _nameController.text.trim(),
|
||||||
|
'code': _autoGenerateCode ? null : (_codeController.text.trim().isEmpty ? null : _codeController.text.trim()),
|
||||||
|
'description': _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(),
|
||||||
|
'is_active': _isActive,
|
||||||
|
'is_default': _isDefault,
|
||||||
|
'currency_id': _currencyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (widget.pettyCash == null) {
|
||||||
|
await _service.create(businessId: widget.businessId, payload: payload);
|
||||||
|
} else {
|
||||||
|
await _service.update(id: widget.pettyCash!.id!, payload: payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop(true); // Return true to indicate success
|
||||||
|
widget.onSuccess?.call();
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(widget.pettyCash == null
|
||||||
|
? (t.localeName == 'fa' ? 'تنخواه گردان با موفقیت ایجاد شد' : 'Petty cash created successfully')
|
||||||
|
: (t.localeName == 'fa' ? 'تنخواه گردان با موفقیت بهروزرسانی شد' : 'Petty cash updated successfully')
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${t.error}: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() { _isLoading = false; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
final isEditing = widget.pettyCash != null;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
child: Container(
|
||||||
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
height: MediaQuery.of(context).size.height * 0.9,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(isEditing ? Icons.edit : Icons.add, color: Theme.of(context).primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
isEditing ? (t.localeName == 'fa' ? 'ویرایش تنخواه گردان' : 'Edit Petty Cash') : (t.localeName == 'fa' ? 'افزودن تنخواه گردان' : 'Add Petty Cash'),
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TabBar(isScrollable: true, tabs: [
|
||||||
|
Tab(text: t.title),
|
||||||
|
Tab(text: t.settings),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
child: _buildBasicInfo(t),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
child: _buildSettings(t),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(onPressed: _isLoading ? null : () => Navigator.of(context).pop(), child: Text(t.cancel)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _save,
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: Text(isEditing ? t.update : t.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionHeader(String title) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBasicInfo(AppLocalizations t) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildSectionHeader(t.title),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: InputDecoration(labelText: t.title, hintText: t.title),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return t.title;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _codeController,
|
||||||
|
readOnly: _autoGenerateCode,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t.code,
|
||||||
|
hintText: t.uniqueCodeNumeric,
|
||||||
|
suffixIcon: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
child: ToggleButtons(
|
||||||
|
isSelected: [_autoGenerateCode, !_autoGenerateCode],
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
constraints: const BoxConstraints(minHeight: 32, minWidth: 64),
|
||||||
|
onPressed: (index) {
|
||||||
|
setState(() { _autoGenerateCode = (index == 0); });
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
Padding(padding: const EdgeInsets.symmetric(horizontal: 6), child: Text(t.automatic)),
|
||||||
|
Padding(padding: const EdgeInsets.symmetric(horizontal: 6), child: Text(t.manual)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
validator: (value) {
|
||||||
|
if (!_autoGenerateCode) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return t.personCodeRequired;
|
||||||
|
}
|
||||||
|
if (value.trim().length < 3) {
|
||||||
|
return t.passwordMinLength; // fallback
|
||||||
|
}
|
||||||
|
if (!RegExp(r'^\d+$').hasMatch(value.trim())) {
|
||||||
|
return t.codeMustBeNumeric;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CurrencyPickerWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCurrencyId: _currencyId,
|
||||||
|
onChanged: (value) { setState(() { _currencyId = value; }); },
|
||||||
|
label: t.currency,
|
||||||
|
hintText: t.currency,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
decoration: InputDecoration(labelText: t.description, hintText: t.description),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSettings(AppLocalizations t) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildSectionHeader(t.settings),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(t.active),
|
||||||
|
subtitle: Text(t.active),
|
||||||
|
value: _isActive,
|
||||||
|
onChanged: (value) { setState(() { _isActive = value; }); },
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(t.isDefault),
|
||||||
|
subtitle: Text(t.defaultConfiguration),
|
||||||
|
value: _isDefault,
|
||||||
|
onChanged: (value) { setState(() { _isDefault = value; }); },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue