progress in c/i

This commit is contained in:
Hesabix 2025-10-21 11:30:01 +03:30
parent 1b6e2eb71c
commit c88b1ccdd0
22 changed files with 3973 additions and 753 deletions

View file

@ -0,0 +1,88 @@
"""
API endpoints برای هزینه و درآمد (Expense & Income)
"""
from typing import Any, Dict
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.permissions import require_business_management_dep, require_business_access
from app.core.responses import success_response, format_datetime_fields
from adapters.api.v1.schemas import QueryInfo
from app.services.expense_income_service import (
create_expense_income,
list_expense_income,
)
router = APIRouter(tags=["expense-income"])
@router.post(
"/businesses/{business_id}/expense-income/create",
summary="ایجاد سند هزینه یا درآمد",
description="ایجاد سند هزینه/درآمد با چند سطر حساب و چند طرف‌حساب",
)
@require_business_access("business_id")
async def create_expense_income_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),
):
created = create_expense_income(db, business_id, ctx.get_user_id(), body)
return success_response(
data=format_datetime_fields(created, request),
request=request,
message="EXPENSE_INCOME_CREATED",
)
@router.post(
"/businesses/{business_id}/expense-income",
summary="لیست اسناد هزینه/درآمد",
description="دریافت لیست اسناد هزینه/درآمد با جستجو و صفحه‌بندی",
)
@require_business_access("business_id")
async def list_expense_income_endpoint(
request: Request,
business_id: int,
query_info: QueryInfo = Body(...),
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,
}
# Read extra body filters
try:
body_json = await request.json()
if isinstance(body_json, dict):
for key in ["document_type", "from_date", "to_date"]:
if key in body_json:
query_dict[key] = body_json[key]
except Exception:
pass
# Fiscal year from header
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
except Exception:
pass
result = list_expense_income(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="EXPENSE_INCOME_LIST_FETCHED")

View file

@ -0,0 +1,341 @@
"""
API endpoints برای انتقال وجه (Transfers)
"""
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request, Body
from sqlalchemy.orm import Session
from fastapi.responses import Response
import io, datetime, re
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.transfer_service import (
create_transfer,
get_transfer,
list_transfers,
delete_transfer,
update_transfer,
)
from adapters.db.models.business import Business
router = APIRouter(tags=["transfers"])
@router.post(
"/businesses/{business_id}/transfers",
summary="لیست اسناد انتقال",
description="دریافت لیست اسناد انتقال با فیلتر و جستجو",
)
@require_business_access("business_id")
async def list_transfers_endpoint(
request: Request,
business_id: int,
query_info: QueryInfo = Body(...),
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,
}
try:
body_json = await request.json()
if isinstance(body_json, dict):
for key in ["from_date", "to_date"]:
if key in body_json:
query_dict[key] = body_json[key]
except Exception:
pass
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
except Exception:
pass
result = list_transfers(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="TRANSFERS_LIST_FETCHED")
@router.post(
"/businesses/{business_id}/transfers/create",
summary="ایجاد سند انتقال",
description="ایجاد سند انتقال جدید",
)
@require_business_access("business_id")
async def create_transfer_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),
):
created = create_transfer(db, business_id, ctx.get_user_id(), body)
return success_response(data=format_datetime_fields(created, request), request=request, message="TRANSFER_CREATED")
@router.get(
"/transfers/{document_id}",
summary="جزئیات سند انتقال",
description="دریافت جزئیات یک سند انتقال",
)
async def get_transfer_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
result = get_transfer(db, document_id)
if not result:
raise ApiError("DOCUMENT_NOT_FOUND", "Transfer document not found", http_status=404)
business_id = result.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(data=format_datetime_fields(result, request), request=request, message="TRANSFER_DETAILS")
@router.delete(
"/transfers/{document_id}",
summary="حذف سند انتقال",
description="حذف یک سند انتقال",
)
async def delete_transfer_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
result = get_transfer(db, document_id)
if result:
business_id = result.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
ok = delete_transfer(db, document_id)
if not ok:
raise ApiError("DOCUMENT_NOT_FOUND", "Transfer document not found", http_status=404)
return success_response(data=None, request=request, message="TRANSFER_DELETED")
@router.put(
"/transfers/{document_id}",
summary="ویرایش سند انتقال",
description="به‌روزرسانی یک سند انتقال",
)
async def update_transfer_endpoint(
request: Request,
document_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
result = get_transfer(db, document_id)
if not result:
raise ApiError("DOCUMENT_NOT_FOUND", "Transfer document not found", http_status=404)
business_id = result.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
updated = update_transfer(db, document_id, ctx.get_user_id(), body)
return success_response(data=format_datetime_fields(updated, request), request=request, message="TRANSFER_UPDATED")
@router.post(
"/businesses/{business_id}/transfers/export/excel",
summary="خروجی Excel لیست اسناد انتقال",
description="خروجی Excel لیست اسناد انتقال با فیلتر و جستجو",
)
@require_business_access("business_id")
async def export_transfers_excel(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
max_export_records = 10000
take_value = min(int(body.get("take", 1000)), max_export_records)
query_dict = {
"take": take_value,
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"from_date": body.get("from_date"),
"to_date": body.get("to_date"),
}
result = list_transfers(db, business_id, query_dict)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
wb = Workbook()
ws = wb.active
ws.title = "Transfers"
headers = ["کد", "تاریخ", "مبلغ کل", "ایجادکننده"]
keys = ["code", "document_date", "total_amount", "created_by_name"]
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(keys, 1):
val = item.get(key, "")
ws.cell(row=row_idx, column=col_idx, value=val).border = border
# 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)
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "transfers"
if biz_name:
base += f"_{slugify(biz_name)}"
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/transfers/export/pdf",
summary="خروجی PDF لیست اسناد انتقال",
description="خروجی PDF لیست اسناد انتقال با فیلتر و جستجو",
)
@require_business_access("business_id")
async def export_transfers_pdf(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from html import escape
max_export_records = 10000
take_value = min(int(body.get("take", 1000)), max_export_records)
query_dict = {
"take": take_value,
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"from_date": body.get("from_date"),
"to_date": body.get("to_date"),
}
result = list_transfers(db, business_id, query_dict)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
headers = ["کد", "تاریخ", "مبلغ کل", "ایجادکننده"]
keys = ["code", "document_date", "total_amount", "created_by_name"]
header_html = ''.join(f'<th>{escape(h)}</th>' for h in headers)
rows_html = []
for it in items:
row_cells = []
for k in keys:
v = it.get(k, "")
row_cells.append(f'<td>{escape(str(v))}</td>')
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
html = f"""
<!DOCTYPE html>
<html dir='rtl'>
<head>
<meta charset='utf-8'>
<title>لیست انتقالها</title>
<style>
@page {{ margin: 1cm; size: A4; }}
body {{ font-family: Tahoma, Arial; font-size: 12px; color: #333; }}
.header {{ display: flex; justify-content: space-between; margin-bottom: 16px; border-bottom: 2px solid #366092; padding-bottom: 8px; }}
.title {{ font-weight: bold; color: #366092; font-size: 18px; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ border: 1px solid #ddd; padding: 6px; text-align: right; }}
thead th {{ background-color: #f0f0f0; }}
</style>
</head>
<body>
<div class='header'>
<div class='title'>لیست انتقالها</div>
<div>تاریخ تولید: {escape(now)}</div>
</div>
<table>
<thead><tr>{header_html}</tr></thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</body>
</html>
"""
font_config = FontConfiguration()
pdf_bytes = HTML(string=html).write_pdf(font_config=font_config)
filename = f"transfers_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)

View file

@ -31,6 +31,7 @@ from adapters.api.v1.support.statuses import router as support_statuses_router
from adapters.api.v1.admin.file_storage import router as admin_file_storage_router from adapters.api.v1.admin.file_storage import router as admin_file_storage_router
from adapters.api.v1.admin.email_config import router as admin_email_config_router from adapters.api.v1.admin.email_config import router as admin_email_config_router
from adapters.api.v1.receipts_payments import router as receipts_payments_router from adapters.api.v1.receipts_payments import router as receipts_payments_router
from adapters.api.v1.transfers import router as transfers_router
from adapters.api.v1.fiscal_years import router as fiscal_years_router from adapters.api.v1.fiscal_years import router as fiscal_years_router
from app.core.i18n import negotiate_locale, Translator from app.core.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers from app.core.error_handlers import register_error_handlers
@ -308,6 +309,9 @@ def create_app() -> FastAPI:
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(tax_types_router, prefix=settings.api_v1_prefix) application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix) application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix)
application.include_router(transfers_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.expense_income import router as expense_income_router
application.include_router(expense_income_router, prefix=settings.api_v1_prefix)
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix) application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
# Support endpoints # Support endpoints

View file

@ -0,0 +1,398 @@
"""
سرویس هزینه و درآمد (Expense & Income)
این سرویس ثبت اسناد «هزینه/درآمد» را با چند سطر حساب و چند سطر طرفحساب پشتیبانی میکند.
الگوی پیادهسازی بر اساس سرویس دریافت/پرداخت است.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from datetime import datetime, date
from decimal import Decimal
import logging
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.account import Account
from adapters.db.models.currency import Currency
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.user import User
from app.core.responses import ApiError
logger = logging.getLogger(__name__)
# نوع‌های سند
DOCUMENT_TYPE_EXPENSE = "expense"
DOCUMENT_TYPE_INCOME = "income"
def _parse_iso_date(dt: str | datetime | date) -> date:
if isinstance(dt, date) and not isinstance(dt, datetime):
return dt
if isinstance(dt, datetime):
return dt.date()
try:
return datetime.fromisoformat(str(dt)).date()
except Exception:
return datetime.utcnow().date()
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
account = db.query(Account).filter(Account.code == str(account_code)).first()
if not account:
raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=404)
return account
def _get_business_fiscal_year(db: Session, business_id: int) -> FiscalYear:
fy = db.query(FiscalYear).filter(
and_(FiscalYear.business_id == business_id, FiscalYear.is_closed == False) # noqa: E712
).order_by(FiscalYear.start_date.desc()).first()
if not fy:
raise ApiError("FISCAL_YEAR_NOT_FOUND", "Active fiscal year not found", http_status=404)
return fy
def create_expense_income(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
"""
ایجاد سند هزینه/درآمد با چند سطر حساب و چند سطر طرفحساب
data = {
"document_type": "expense" | "income",
"document_date": "2025-10-20",
"currency_id": 1,
"description": str?,
"item_lines": [ # سطرهای حساب‌های هزینه/درآمد
{"account_id": 123, "amount": 100000, "description": str?},
],
"counterparty_lines": [ # سطرهای طرف‌حساب (بانک/صندوق/شخص/چک ...)
{
"transaction_type": "bank" | "cash_register" | "petty_cash" | "check" | "person",
"amount": 100000,
"transaction_date": "2025-10-20T10:00:00",
"description": str?,
"commission": float?, # اختیاری
# فیلدهای اختیاری متناسب با نوع
"bank_id": int?, "bank_name": str?,
"cash_register_id": int?, "cash_register_name": str?,
"petty_cash_id": int?, "petty_cash_name": str?,
"check_id": int?, "check_number": str?,
"person_id": int?, "person_name": str?,
}
]
}
"""
document_type = str(data.get("document_type", "")).lower()
if document_type not in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME):
raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'expense' or 'income'", http_status=400)
is_income = document_type == DOCUMENT_TYPE_INCOME
# تاریخ
document_date = _parse_iso_date(data.get("document_date", datetime.utcnow()))
# ارز
currency_id = data.get("currency_id")
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
# سال مالی فعال
fiscal_year = _get_business_fiscal_year(db, business_id)
# اعتبارسنجی خطوط
item_lines: List[Dict[str, Any]] = list(data.get("item_lines") or [])
counterparty_lines: List[Dict[str, Any]] = list(data.get("counterparty_lines") or [])
if not item_lines:
raise ApiError("LINES_REQUIRED", "item_lines is required", http_status=400)
if not counterparty_lines:
raise ApiError("LINES_REQUIRED", "counterparty_lines is required", http_status=400)
sum_items = Decimal(0)
for idx, line in enumerate(item_lines):
if not line.get("account_id"):
raise ApiError("ACCOUNT_REQUIRED", f"item_lines[{idx}].account_id is required", http_status=400)
amount = Decimal(str(line.get("amount", 0)))
if amount <= 0:
raise ApiError("AMOUNT_INVALID", f"item_lines[{idx}].amount must be > 0", http_status=400)
sum_items += amount
sum_counterparties = Decimal(0)
for idx, line in enumerate(counterparty_lines):
amount = Decimal(str(line.get("amount", 0)))
if amount <= 0:
raise ApiError("AMOUNT_INVALID", f"counterparty_lines[{idx}].amount must be > 0", http_status=400)
sum_counterparties += amount
if sum_items != sum_counterparties:
raise ApiError("LINES_NOT_BALANCED", "Sum of items and counterparties must be equal", http_status=400)
# ایجاد سند
user = db.query(User).filter(User.id == int(user_id)).first()
if not user:
raise ApiError("USER_NOT_FOUND", "User not found", http_status=404)
# کد سند ساده: EI-YYYYMMDD-<rand>
code = f"EI-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}"
document = Document(
code=code,
business_id=business_id,
fiscal_year_id=fiscal_year.id,
currency_id=int(currency_id),
created_by_user_id=int(user_id),
document_date=document_date,
document_type=document_type,
is_proforma=False,
description=(data.get("description") or None),
extra_info=(data.get("extra_info") if isinstance(data.get("extra_info"), dict) else None),
)
db.add(document)
db.flush()
# سطرهای حساب‌های هزینه/درآمد
for line in item_lines:
account = db.query(Account).filter(
and_(
Account.id == int(line.get("account_id")),
or_(Account.business_id == business_id, Account.business_id == None), # noqa: E711
)
).first()
if not account:
raise ApiError("ACCOUNT_NOT_FOUND", "Item account not found", http_status=404)
amount = Decimal(str(line.get("amount", 0)))
description = (line.get("description") or "").strip() or None
debit_amount = amount if not is_income else Decimal(0)
credit_amount = amount if is_income else Decimal(0)
db.add(DocumentLine(
document_id=document.id,
account_id=account.id,
debit=debit_amount,
credit=credit_amount,
description=description,
))
# سطرهای طرف‌حساب (بانک/صندوق/شخص/چک/تنخواه)
for line in counterparty_lines:
amount = Decimal(str(line.get("amount", 0)))
description = (line.get("description") or "").strip() or None
transaction_type: Optional[str] = line.get("transaction_type")
# انتخاب حساب طرف‌حساب
account: Optional[Account] = None
if transaction_type == "bank":
account = _get_fixed_account_by_code(db, "10203")
elif transaction_type == "cash_register":
account = _get_fixed_account_by_code(db, "10202")
elif transaction_type == "petty_cash":
account = _get_fixed_account_by_code(db, "10201")
elif transaction_type == "check":
# برای چک‌ها از کدهای اسناد دریافتنی/پرداختنی استفاده شود
account = _get_fixed_account_by_code(db, "10403" if is_income else "20202")
elif transaction_type == "person":
# پرداخت/دریافت با شخص عمومی پرداختنی
account = _get_fixed_account_by_code(db, "20201")
elif line.get("account_id"):
account = db.query(Account).filter(
and_(
Account.id == int(line.get("account_id")),
or_(Account.business_id == business_id, Account.business_id == None), # noqa: E711
)
).first()
if not account:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found for counterparty line", http_status=404)
extra_info: Dict[str, Any] = {}
if transaction_type:
extra_info["transaction_type"] = transaction_type
if line.get("transaction_date"):
extra_info["transaction_date"] = line.get("transaction_date")
if line.get("commission"):
extra_info["commission"] = float(line.get("commission"))
if transaction_type == "bank":
if line.get("bank_id"):
extra_info["bank_id"] = line.get("bank_id")
if line.get("bank_name"):
extra_info["bank_name"] = line.get("bank_name")
elif transaction_type == "cash_register":
if line.get("cash_register_id"):
extra_info["cash_register_id"] = line.get("cash_register_id")
if line.get("cash_register_name"):
extra_info["cash_register_name"] = line.get("cash_register_name")
elif transaction_type == "petty_cash":
if line.get("petty_cash_id"):
extra_info["petty_cash_id"] = line.get("petty_cash_id")
if line.get("petty_cash_name"):
extra_info["petty_cash_name"] = line.get("petty_cash_name")
elif transaction_type == "check":
if line.get("check_id"):
extra_info["check_id"] = line.get("check_id")
if line.get("check_number"):
extra_info["check_number"] = line.get("check_number")
elif transaction_type == "person":
if line.get("person_id"):
extra_info["person_id"] = line.get("person_id")
if line.get("person_name"):
extra_info["person_name"] = line.get("person_name")
debit_amount = amount if is_income else Decimal(0)
credit_amount = amount if not is_income else Decimal(0)
db.add(DocumentLine(
document_id=document.id,
account_id=account.id,
person_id=(int(line["person_id"]) if transaction_type == "person" and line.get("person_id") else None),
bank_account_id=(int(line["bank_id"]) if transaction_type == "bank" and line.get("bank_id") else None),
cash_register_id=line.get("cash_register_id"),
petty_cash_id=line.get("petty_cash_id"),
check_id=line.get("check_id"),
debit=debit_amount,
credit=credit_amount,
description=description,
extra_info=extra_info or None,
))
# توجه: خطوط کارمزد در این نسخه پیاده‌سازی نمی‌شود (می‌توان مشابه سرویس دریافت/پرداخت اضافه کرد)
db.commit()
db.refresh(document)
return document_to_dict(db, document)
def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all()
items: List[Dict[str, Any]] = []
counterparties: List[Dict[str, Any]] = []
for ln in lines:
account = db.query(Account).filter(Account.id == ln.account_id).first()
row = {
"id": ln.id,
"account_id": ln.account_id,
"account_name": account.name if account else None,
"debit": float(ln.debit or 0),
"credit": float(ln.credit or 0),
"description": ln.description,
"extra_info": ln.extra_info,
"person_id": ln.person_id,
"bank_account_id": ln.bank_account_id,
"cash_register_id": ln.cash_register_id,
"petty_cash_id": ln.petty_cash_id,
"check_id": ln.check_id,
}
# ساده: بر اساس وجود transaction_type در extra_info، به عنوان طرف‌حساب تلقی می‌شود
if ln.extra_info and ln.extra_info.get("transaction_type"):
counterparties.append(row)
else:
items.append(row)
return {
"id": document.id,
"code": document.code,
"business_id": document.business_id,
"fiscal_year_id": document.fiscal_year_id,
"currency_id": document.currency_id,
"document_type": document.document_type,
"document_date": document.document_date.isoformat(),
"description": document.description,
"items": items,
"counterparties": counterparties,
}
def list_expense_income(
db: Session,
business_id: int,
query: Dict[str, Any],
) -> Dict[str, Any]:
"""لیست اسناد هزینه و درآمد با فیلتر، جست‌وجو و صفحه‌بندی"""
q = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type.in_([DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME]),
)
)
# سال مالی
fiscal_year_id = query.get("fiscal_year_id")
try:
fiscal_year_id_int = int(fiscal_year_id) if fiscal_year_id is not None else None
except Exception:
fiscal_year_id_int = None
if fiscal_year_id_int is None:
try:
fy = _get_business_fiscal_year(db, business_id)
fiscal_year_id_int = fy.id
except Exception:
fiscal_year_id_int = None
if fiscal_year_id_int is not None:
q = q.filter(Document.fiscal_year_id == fiscal_year_id_int)
# نوع سند
doc_type = query.get("document_type")
if doc_type in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME):
q = q.filter(Document.document_type == doc_type)
# فیلتر تاریخ
from_date = query.get("from_date")
to_date = query.get("to_date")
if from_date:
try:
q = q.filter(Document.document_date >= _parse_iso_date(from_date))
except Exception:
pass
if to_date:
try:
q = q.filter(Document.document_date <= _parse_iso_date(to_date))
except Exception:
pass
# جست‌وجو در کد سند
search = query.get("search")
if search:
q = q.filter(Document.code.ilike(f"%{search}%"))
# مرتب‌سازی
sort_by = query.get("sort_by", "document_date")
sort_desc = bool(query.get("sort_desc", True))
if isinstance(sort_by, str) and hasattr(Document, sort_by):
col = getattr(Document, sort_by)
q = q.order_by(col.desc() if sort_desc else col.asc())
else:
q = q.order_by(Document.document_date.desc())
# صفحه‌بندی
skip = int(query.get("skip", 0))
take = int(query.get("take", 20))
total = q.count()
docs = q.offset(skip).limit(take).all()
return {
"items": [document_to_dict(db, d) for d in docs],
"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,
}

View file

@ -0,0 +1,680 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from datetime import datetime, date
from decimal import Decimal
import logging
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.account import Account
from adapters.db.models.currency import Currency
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.user import User
from adapters.db.models.bank_account import BankAccount
from adapters.db.models.cash_register import CashRegister
from adapters.db.models.petty_cash import PettyCash
from app.core.responses import ApiError
import jdatetime
logger = logging.getLogger(__name__)
DOCUMENT_TYPE_TRANSFER = "transfer"
def _parse_iso_date(dt: str | datetime | date) -> date:
if isinstance(dt, date):
return dt
if isinstance(dt, datetime):
return dt.date()
dt_str = str(dt).strip()
try:
dt_str_clean = dt_str.replace('Z', '+00:00')
parsed = datetime.fromisoformat(dt_str_clean)
return parsed.date()
except Exception:
pass
try:
if len(dt_str) == 10 and dt_str.count('-') == 2:
return datetime.strptime(dt_str, '%Y-%m-%d').date()
except Exception:
pass
try:
if len(dt_str) == 10 and dt_str.count('/') == 2:
parts = dt_str.split('/')
if len(parts) == 3:
year, month, day = parts
try:
year_int = int(year)
month_int = int(month)
day_int = int(day)
if year_int > 1500:
jalali_date = jdatetime.date(year_int, month_int, day_int)
gregorian_date = jalali_date.togregorian()
return gregorian_date
else:
return datetime.strptime(dt_str, '%Y/%m/%d').date()
except (ValueError, jdatetime.JalaliDateError):
return datetime.strptime(dt_str, '%Y/%m/%d').date()
except Exception:
pass
raise ApiError("INVALID_DATE", f"Invalid date format: {dt}", http_status=400)
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True,
)
).first()
if not fiscal_year:
raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400)
return fiscal_year
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
account = db.query(Account).filter(
and_(
Account.business_id == None,
Account.code == account_code,
)
).first()
if not account:
raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=500)
return account
def _account_code_for_type(account_type: str) -> str:
if account_type == "bank":
return "10203"
if account_type == "cash_register":
return "10202"
if account_type == "petty_cash":
return "10201"
raise ApiError("INVALID_ACCOUNT_TYPE", f"Invalid account type: {account_type}", http_status=400)
def _build_doc_code(prefix_base: str) -> str:
today = datetime.now().date()
prefix = f"{prefix_base}-{today.strftime('%Y%m%d')}"
return prefix
def create_transfer(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
logger.info("=== شروع ایجاد سند انتقال ===")
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
currency_id = data.get("currency_id")
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
fiscal_year = _get_current_fiscal_year(db, business_id)
source = data.get("source") or {}
destination = data.get("destination") or {}
amount = Decimal(str(data.get("amount", 0)))
commission = Decimal(str(data.get("commission", 0))) if data.get("commission") is not None else Decimal(0)
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "amount must be greater than 0", http_status=400)
if commission < 0:
raise ApiError("INVALID_COMMISSION", "commission must be >= 0", http_status=400)
src_type = str(source.get("type") or "").strip()
dst_type = str(destination.get("type") or "").strip()
src_id = source.get("id")
dst_id = destination.get("id")
if src_type not in ("bank", "cash_register", "petty_cash"):
raise ApiError("INVALID_SOURCE", "source.type must be bank|cash_register|petty_cash", http_status=400)
if dst_type not in ("bank", "cash_register", "petty_cash"):
raise ApiError("INVALID_DESTINATION", "destination.type must be bank|cash_register|petty_cash", http_status=400)
if src_type == dst_type and src_id and dst_id and str(src_id) == str(dst_id):
raise ApiError("SAME_SOURCE_DESTINATION", "source and destination cannot be the same", http_status=400)
# Resolve accounts by fixed codes
src_account = _get_fixed_account_by_code(db, _account_code_for_type(src_type))
dst_account = _get_fixed_account_by_code(db, _account_code_for_type(dst_type))
# Generate document code TR-YYYYMMDD-NNNN
prefix = _build_doc_code("TR")
last_doc = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.code.like(f"{prefix}-%"),
)
).order_by(Document.code.desc()).first()
if last_doc:
try:
last_num = int(last_doc.code.split("-")[-1])
next_num = last_num + 1
except Exception:
next_num = 1
else:
next_num = 1
doc_code = f"{prefix}-{next_num:04d}"
# Resolve names for auto description if needed
def _resolve_name(tp: str, _id: Any) -> str | None:
try:
if tp == "bank" and _id is not None:
ba = db.query(BankAccount).filter(BankAccount.id == int(_id)).first()
return ba.name if ba else None
if tp == "cash_register" and _id is not None:
cr = db.query(CashRegister).filter(CashRegister.id == int(_id)).first()
return cr.name if cr else None
if tp == "petty_cash" and _id is not None:
pc = db.query(PettyCash).filter(PettyCash.id == int(_id)).first()
return pc.name if pc else None
except Exception:
return None
return None
auto_description = None
if not data.get("description"):
src_name = _resolve_name(src_type, src_id) or "مبدأ"
dst_name = _resolve_name(dst_type, dst_id) or "مقصد"
# human readable types
def _type_name(tp: str) -> str:
return "حساب بانکی" if tp == "bank" else ("صندوق" if tp == "cash_register" else "تنخواه")
auto_description = f"انتقال از {_type_name(src_type)} {src_name} به {_type_name(dst_type)} {dst_name}"
document = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code,
document_type=DOCUMENT_TYPE_TRANSFER,
document_date=document_date,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=datetime.utcnow(),
is_proforma=False,
description=data.get("description") or auto_description,
extra_info=data.get("extra_info"),
)
db.add(document)
db.flush()
# Destination line (Debit)
dest_kwargs: Dict[str, Any] = {}
if dst_type == "bank" and dst_id is not None:
try:
dest_kwargs["bank_account_id"] = int(dst_id)
except Exception:
pass
elif dst_type == "cash_register" and dst_id is not None:
dest_kwargs["cash_register_id"] = dst_id
elif dst_type == "petty_cash" and dst_id is not None:
dest_kwargs["petty_cash_id"] = dst_id
dest_line = DocumentLine(
document_id=document.id,
account_id=dst_account.id,
debit=amount,
credit=Decimal(0),
description=data.get("destination_description") or data.get("description"),
extra_info={
"side": "destination",
"destination_type": dst_type,
"destination_id": dst_id,
},
**dest_kwargs,
)
db.add(dest_line)
# Source line (Credit)
src_kwargs: Dict[str, Any] = {}
if src_type == "bank" and src_id is not None:
try:
src_kwargs["bank_account_id"] = int(src_id)
except Exception:
pass
elif src_type == "cash_register" and src_id is not None:
src_kwargs["cash_register_id"] = src_id
elif src_type == "petty_cash" and src_id is not None:
src_kwargs["petty_cash_id"] = src_id
src_line = DocumentLine(
document_id=document.id,
account_id=src_account.id,
debit=Decimal(0),
credit=amount,
description=data.get("source_description") or data.get("description"),
extra_info={
"side": "source",
"source_type": src_type,
"source_id": src_id,
},
**src_kwargs,
)
db.add(src_line)
if commission > 0:
# Debit commission expense 70902
commission_service_account = _get_fixed_account_by_code(db, "70902")
commission_expense_line = DocumentLine(
document_id=document.id,
account_id=commission_service_account.id,
debit=commission,
credit=Decimal(0),
description="کارمزد خدمات بانکی",
extra_info={
"side": "commission",
"is_commission_line": True,
},
**src_kwargs,
)
db.add(commission_expense_line)
# Credit commission to source account (increase credit of source)
commission_credit_line = DocumentLine(
document_id=document.id,
account_id=src_account.id,
debit=Decimal(0),
credit=commission,
description="کارمزد انتقال (ثبت در مبدأ)",
extra_info={
"side": "commission",
"is_commission_line": True,
"source_type": src_type,
"source_id": src_id,
},
**src_kwargs,
)
db.add(commission_credit_line)
db.commit()
db.refresh(document)
return transfer_document_to_dict(db, document)
def get_transfer(db: Session, document_id: int) -> Optional[Dict[str, Any]]:
document = db.query(Document).filter(Document.id == document_id).first()
if not document or document.document_type != DOCUMENT_TYPE_TRANSFER:
return None
return transfer_document_to_dict(db, document)
def list_transfers(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
q = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == DOCUMENT_TYPE_TRANSFER,
)
)
fiscal_year_id = query.get("fiscal_year_id")
if fiscal_year_id is not None:
try:
fiscal_year_id = int(fiscal_year_id)
except (TypeError, ValueError):
fiscal_year_id = None
if fiscal_year_id is None:
try:
fiscal_year = _get_current_fiscal_year(db, business_id)
fiscal_year_id = fiscal_year.id
except Exception:
fiscal_year_id = None
if fiscal_year_id is not None:
q = q.filter(Document.fiscal_year_id == fiscal_year_id)
from_date = query.get("from_date")
to_date = query.get("to_date")
if from_date:
try:
from_dt = _parse_iso_date(from_date)
q = q.filter(Document.document_date >= from_dt)
except Exception:
pass
if to_date:
try:
to_dt = _parse_iso_date(to_date)
q = q.filter(Document.document_date <= to_dt)
except Exception:
pass
search = query.get("search")
if search:
q = q.filter(Document.code.ilike(f"%{search}%"))
sort_by = query.get("sort_by", "document_date")
sort_desc = bool(query.get("sort_desc", True))
if sort_by and isinstance(sort_by, str) and hasattr(Document, sort_by):
col = getattr(Document, sort_by)
q = q.order_by(col.desc() if sort_desc else col.asc())
else:
q = q.order_by(Document.document_date.desc())
skip = int(query.get("skip", 0))
take = int(query.get("take", 20))
total = q.count()
items = q.offset(skip).limit(take).all()
return {
"items": [transfer_document_to_dict(db, doc) for doc 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 delete_transfer(db: Session, document_id: int) -> bool:
document = db.query(Document).filter(Document.id == document_id).first()
if not document or document.document_type != DOCUMENT_TYPE_TRANSFER:
return False
try:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first()
if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True:
raise ApiError("FISCAL_YEAR_LOCKED", "سند متعلق به سال مالی جاری نیست و قابل حذف نمی‌باشد", http_status=409)
except ApiError:
raise
except Exception:
pass
db.delete(document)
db.commit()
return True
def update_transfer(
db: Session,
document_id: int,
user_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
document = db.query(Document).filter(Document.id == document_id).first()
if document is None or document.document_type != DOCUMENT_TYPE_TRANSFER:
raise ApiError("DOCUMENT_NOT_FOUND", "Transfer document not found", http_status=404)
try:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first()
if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True:
raise ApiError("FISCAL_YEAR_LOCKED", "سند متعلق به سال مالی جاری نیست و قابل ویرایش نمی‌باشد", http_status=409)
except ApiError:
raise
except Exception:
pass
document_date = _parse_iso_date(data.get("document_date", document.document_date))
currency_id = data.get("currency_id", document.currency_id)
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
source = data.get("source") or {}
destination = data.get("destination") or {}
amount = Decimal(str(data.get("amount", 0)))
commission = Decimal(str(data.get("commission", 0))) if data.get("commission") is not None else Decimal(0)
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "amount must be greater than 0", http_status=400)
if commission < 0:
raise ApiError("INVALID_COMMISSION", "commission must be >= 0", http_status=400)
src_type = str(source.get("type") or "").strip()
dst_type = str(destination.get("type") or "").strip()
src_id = source.get("id")
dst_id = destination.get("id")
if src_type not in ("bank", "cash_register", "petty_cash"):
raise ApiError("INVALID_SOURCE", "source.type must be bank|cash_register|petty_cash", http_status=400)
if dst_type not in ("bank", "cash_register", "petty_cash"):
raise ApiError("INVALID_DESTINATION", "destination.type must be bank|cash_register|petty_cash", http_status=400)
if src_type == dst_type and src_id and dst_id and str(src_id) == str(dst_id):
raise ApiError("SAME_SOURCE_DESTINATION", "source and destination cannot be the same", http_status=400)
# Update document fields
document.document_date = document_date
document.currency_id = int(currency_id)
if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None:
document.extra_info = data.get("extra_info")
if isinstance(data.get("description"), str) or data.get("description") is None:
if data.get("description"):
document.description = data.get("description")
else:
# regenerate auto description
def _resolve_name(tp: str, _id: Any) -> str | None:
try:
if tp == "bank" and _id is not None:
ba = db.query(BankAccount).filter(BankAccount.id == int(_id)).first()
return ba.name if ba else None
if tp == "cash_register" and _id is not None:
cr = db.query(CashRegister).filter(CashRegister.id == int(_id)).first()
return cr.name if cr else None
if tp == "petty_cash" and _id is not None:
pc = db.query(PettyCash).filter(PettyCash.id == int(_id)).first()
return pc.name if pc else None
except Exception:
return None
return None
def _type_name(tp: str) -> str:
return "حساب بانکی" if tp == "bank" else ("صندوق" if tp == "cash_register" else "تنخواه")
src_name = _resolve_name(src_type, src_id) or "مبدأ"
dst_name = _resolve_name(dst_type, dst_id) or "مقصد"
document.description = f"انتقال از {_type_name(src_type)} {src_name} به {_type_name(dst_type)} {dst_name}"
# Remove old lines and recreate
db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False)
src_account = _get_fixed_account_by_code(db, _account_code_for_type(src_type))
dst_account = _get_fixed_account_by_code(db, _account_code_for_type(dst_type))
dest_kwargs: Dict[str, Any] = {}
if dst_type == "bank" and dst_id is not None:
try:
dest_kwargs["bank_account_id"] = int(dst_id)
except Exception:
pass
elif dst_type == "cash_register" and dst_id is not None:
dest_kwargs["cash_register_id"] = dst_id
elif dst_type == "petty_cash" and dst_id is not None:
dest_kwargs["petty_cash_id"] = dst_id
db.add(DocumentLine(
document_id=document.id,
account_id=dst_account.id,
debit=amount,
credit=Decimal(0),
description=data.get("destination_description") or data.get("description"),
extra_info={
"side": "destination",
"destination_type": dst_type,
"destination_id": dst_id,
},
**dest_kwargs,
))
src_kwargs: Dict[str, Any] = {}
if src_type == "bank" and src_id is not None:
try:
src_kwargs["bank_account_id"] = int(src_id)
except Exception:
pass
elif src_type == "cash_register" and src_id is not None:
src_kwargs["cash_register_id"] = src_id
elif src_type == "petty_cash" and src_id is not None:
src_kwargs["petty_cash_id"] = src_id
db.add(DocumentLine(
document_id=document.id,
account_id=src_account.id,
debit=Decimal(0),
credit=amount,
description=data.get("source_description") or data.get("description"),
extra_info={
"side": "source",
"source_type": src_type,
"source_id": src_id,
},
**src_kwargs,
))
if commission > 0:
commission_service_account = _get_fixed_account_by_code(db, "70902")
db.add(DocumentLine(
document_id=document.id,
account_id=commission_service_account.id,
debit=commission,
credit=Decimal(0),
description="کارمزد خدمات بانکی",
extra_info={
"side": "commission",
"is_commission_line": True,
},
**src_kwargs,
))
db.add(DocumentLine(
document_id=document.id,
account_id=src_account.id,
debit=Decimal(0),
credit=commission,
description="کارمزد انتقال (ثبت در مبدأ)",
extra_info={
"side": "commission",
"is_commission_line": True,
"source_type": src_type,
"source_id": src_id,
},
**src_kwargs,
))
db.commit()
db.refresh(document)
return transfer_document_to_dict(db, document)
def transfer_document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all()
account_lines = []
source_name = None
destination_name = None
source_type = None
destination_type = None
for line in lines:
account = db.query(Account).filter(Account.id == line.account_id).first()
if not account:
continue
line_dict: Dict[str, Any] = {
"id": line.id,
"account_id": line.account_id,
"bank_account_id": line.bank_account_id,
"cash_register_id": line.cash_register_id,
"petty_cash_id": line.petty_cash_id,
"quantity": float(line.quantity) if line.quantity else None,
"account_name": account.name,
"account_code": account.code,
"account_type": account.account_type,
"debit": float(line.debit),
"credit": float(line.credit),
"amount": float(line.debit if line.debit > 0 else line.credit),
"description": line.description,
"extra_info": line.extra_info,
}
if line.extra_info:
if "side" in line.extra_info:
line_dict["side"] = line.extra_info["side"]
if "source_type" in line.extra_info:
line_dict["source_type"] = line.extra_info["source_type"]
source_type = source_type or line.extra_info["source_type"]
if "destination_type" in line.extra_info:
line_dict["destination_type"] = line.extra_info["destination_type"]
destination_type = destination_type or line.extra_info["destination_type"]
if "is_commission_line" in line.extra_info:
line_dict["is_commission_line"] = line.extra_info["is_commission_line"]
# capture source/destination names from linked entities
try:
if line_dict.get("side") == "source":
if line_dict.get("bank_account_id"):
ba = db.query(BankAccount).filter(BankAccount.id == int(line_dict["bank_account_id"])) .first()
source_name = ba.name if ba else source_name
elif line_dict.get("cash_register_id"):
cr = db.query(CashRegister).filter(CashRegister.id == int(line_dict["cash_register_id"])) .first()
source_name = cr.name if cr else source_name
elif line_dict.get("petty_cash_id"):
pc = db.query(PettyCash).filter(PettyCash.id == int(line_dict["petty_cash_id"])) .first()
source_name = pc.name if pc else source_name
elif line_dict.get("side") == "destination":
if line_dict.get("bank_account_id"):
ba = db.query(BankAccount).filter(BankAccount.id == int(line_dict["bank_account_id"])) .first()
destination_name = ba.name if ba else destination_name
elif line_dict.get("cash_register_id"):
cr = db.query(CashRegister).filter(CashRegister.id == int(line_dict["cash_register_id"])) .first()
destination_name = cr.name if cr else destination_name
elif line_dict.get("petty_cash_id"):
pc = db.query(PettyCash).filter(PettyCash.id == int(line_dict["petty_cash_id"])) .first()
destination_name = pc.name if pc else destination_name
except Exception:
pass
account_lines.append(line_dict)
# Compute total as sum of debits of non-commission lines (destination line amount)
total_amount = sum(l.get("debit", 0) for l in account_lines if not l.get("is_commission_line"))
created_by = db.query(User).filter(User.id == document.created_by_user_id).first()
created_by_name = f"{created_by.first_name} {created_by.last_name}".strip() if created_by else None
currency = db.query(Currency).filter(Currency.id == document.currency_id).first()
currency_code = currency.code if currency else None
return {
"id": document.id,
"code": document.code,
"business_id": document.business_id,
"document_type": document.document_type,
"document_type_name": "انتقال",
"document_date": document.document_date.isoformat(),
"registered_at": document.registered_at.isoformat(),
"currency_id": document.currency_id,
"currency_code": currency_code,
"created_by_user_id": document.created_by_user_id,
"created_by_name": created_by_name,
"is_proforma": document.is_proforma,
"description": document.description,
"source_type": source_type,
"source_name": source_name,
"destination_type": destination_type,
"destination_name": destination_name,
"extra_info": document.extra_info,
"person_lines": [],
"account_lines": account_lines,
"total_amount": float(total_amount),
"person_lines_count": 0,
"account_lines_count": len(account_lines),
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
}

View file

@ -19,7 +19,7 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false id("com.android.application") version "7.3.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false
} }

View file

@ -39,6 +39,7 @@ import 'pages/business/petty_cash_page.dart';
import 'pages/business/checks_page.dart'; import 'pages/business/checks_page.dart';
import 'pages/business/check_form_page.dart'; import 'pages/business/check_form_page.dart';
import 'pages/business/receipts_payments_list_page.dart'; import 'pages/business/receipts_payments_list_page.dart';
import 'pages/business/expense_income_list_page.dart';
import 'pages/business/transfers_page.dart'; import 'pages/business/transfers_page.dart';
import 'pages/error_404_page.dart'; import 'pages/error_404_page.dart';
import 'core/locale_controller.dart'; import 'core/locale_controller.dart';
@ -489,10 +490,8 @@ class _MyAppState extends State<MyApp> {
), ),
], ],
), ),
GoRoute( ShellRoute(
path: '/business/:business_id', builder: (context, state, child) {
name: 'business_shell',
builder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return BusinessShell(
businessId: businessId, businessId: businessId,
@ -500,36 +499,25 @@ class _MyAppState extends State<MyApp> {
localeController: controller, localeController: controller,
calendarController: _calendarController!, calendarController: _calendarController!,
themeController: themeController, themeController: themeController,
child: const SizedBox.shrink(), // Will be replaced by child routes child: child,
); );
}, },
routes: [ routes: [
GoRoute( GoRoute(
path: 'dashboard', path: '/business/:business_id/dashboard',
name: 'business_dashboard', name: 'business_dashboard',
builder: (context, state) { pageBuilder: (context, state) => NoTransitionPage(
final businessId = int.parse(state.pathParameters['business_id']!); child: BusinessDashboardPage(
return BusinessShell( businessId: int.parse(state.pathParameters['business_id']!),
businessId: businessId, ),
authStore: _authStore!, ),
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: BusinessDashboardPage(businessId: businessId),
);
},
), ),
GoRoute( GoRoute(
path: 'users-permissions', path: '/business/:business_id/users-permissions',
name: 'business_users_permissions', name: 'business_users_permissions',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: UsersPermissionsPage( child: UsersPermissionsPage(
businessId: businessId.toString(), businessId: businessId.toString(),
authStore: _authStore!, authStore: _authStore!,
@ -539,31 +527,20 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'chart-of-accounts', path: '/business/:business_id/chart-of-accounts',
name: 'business_chart_of_accounts', name: 'business_chart_of_accounts',
builder: (context, state) { pageBuilder: (context, state) => NoTransitionPage(
final businessId = int.parse(state.pathParameters['business_id']!); child: AccountsPage(
return BusinessShell( businessId: int.parse(state.pathParameters['business_id']!),
businessId: businessId, ),
authStore: _authStore!, ),
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: AccountsPage(businessId: businessId),
);
},
), ),
GoRoute( GoRoute(
path: 'accounts', path: '/business/:business_id/accounts',
name: 'business_accounts', name: 'business_accounts',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: BankAccountsPage( child: BankAccountsPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -572,16 +549,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'petty-cash', path: '/business/:business_id/petty-cash',
name: 'business_petty_cash', name: 'business_petty_cash',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: PettyCashPage( child: PettyCashPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -590,16 +562,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'cash-box', path: '/business/:business_id/cash-box',
name: 'business_cash_box', name: 'business_cash_box',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: CashRegistersPage( child: CashRegistersPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -608,16 +575,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'wallet', path: '/business/:business_id/wallet',
name: 'business_wallet', name: 'business_wallet',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: WalletPage( child: WalletPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -626,16 +588,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'invoice', path: '/business/:business_id/invoice',
name: 'business_invoice', name: 'business_invoice',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: InvoicePage( child: InvoicePage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -644,16 +601,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'invoice/new', path: '/business/:business_id/invoice/new',
name: 'business_new_invoice', name: 'business_new_invoice',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: NewInvoicePage( child: NewInvoicePage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -663,16 +615,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'reports', path: '/business/:business_id/reports',
name: 'business_reports', name: 'business_reports',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: ReportsPage( child: ReportsPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -681,20 +628,17 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'settings', path: '/business/:business_id/settings',
name: 'business_settings', name: 'business_settings',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
// گارد دسترسی: فقط کاربرانی که دسترسی join دارند // گارد دسترسی: فقط کاربرانی که دسترسی join دارند
if (!_authStore!.hasBusinessPermission('settings', 'join')) { if (!_authStore!.hasBusinessPermission('settings', 'join')) {
return PermissionGuard.buildAccessDeniedPage(); return NoTransitionPage(
child: PermissionGuard.buildAccessDeniedPage(),
);
} }
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: SettingsPage( child: SettingsPage(
businessId: businessId, businessId: businessId,
localeController: controller, localeController: controller,
@ -705,16 +649,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'product-attributes', path: '/business/:business_id/product-attributes',
name: 'business_product_attributes', name: 'business_product_attributes',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: ProductAttributesPage( child: ProductAttributesPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -723,16 +662,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'products', path: '/business/:business_id/products',
name: 'business_products', name: 'business_products',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: ProductsPage( child: ProductsPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -741,16 +675,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'price-lists', path: '/business/:business_id/price-lists',
name: 'business_price_lists', name: 'business_price_lists',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: PriceListsPage( child: PriceListsPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -759,17 +688,12 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'price-lists/:price_list_id/items', path: '/business/:business_id/price-lists/:price_list_id/items',
name: 'business_price_list_items', name: 'business_price_list_items',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
final priceListId = int.parse(state.pathParameters['price_list_id']!); final priceListId = int.parse(state.pathParameters['price_list_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: PriceListItemsPage( child: PriceListItemsPage(
businessId: businessId, businessId: businessId,
priceListId: priceListId, priceListId: priceListId,
@ -779,16 +703,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'persons', path: '/business/:business_id/persons',
name: 'business_persons', name: 'business_persons',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: PersonsPage( child: PersonsPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -798,16 +717,11 @@ class _MyAppState extends State<MyApp> {
), ),
// Receipts & Payments: list with data table // Receipts & Payments: list with data table
GoRoute( GoRoute(
path: 'receipts-payments', path: '/business/:business_id/receipts-payments',
name: 'business_receipts_payments', name: 'business_receipts_payments',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: ReceiptsPaymentsListPage( child: ReceiptsPaymentsListPage(
businessId: businessId, businessId: businessId,
calendarController: _calendarController!, calendarController: _calendarController!,
@ -818,16 +732,26 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'transfers', path: '/business/:business_id/expense-income',
name: 'business_transfers', name: 'business_expense_income',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId, child: ExpenseIncomeListPage(
authStore: _authStore!, businessId: businessId,
localeController: controller, calendarController: _calendarController!,
calendarController: _calendarController!, authStore: _authStore!,
themeController: themeController, apiClient: ApiClient(),
),
);
},
),
GoRoute(
path: '/business/:business_id/transfers',
name: 'business_transfers',
pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return NoTransitionPage(
child: TransfersPage( child: TransfersPage(
businessId: businessId, businessId: businessId,
calendarController: _calendarController!, calendarController: _calendarController!,
@ -838,16 +762,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'checks', path: '/business/:business_id/checks',
name: 'business_checks', name: 'business_checks',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: ChecksPage( child: ChecksPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -856,16 +775,11 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'checks/new', path: '/business/:business_id/checks/new',
name: 'business_new_check', name: 'business_new_check',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: CheckFormPage( child: CheckFormPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,
@ -875,17 +789,12 @@ class _MyAppState extends State<MyApp> {
}, },
), ),
GoRoute( GoRoute(
path: 'checks/:check_id/edit', path: '/business/:business_id/checks/:check_id/edit',
name: 'business_edit_check', name: 'business_edit_check',
builder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
final checkId = int.tryParse(state.pathParameters['check_id'] ?? '0'); final checkId = int.tryParse(state.pathParameters['check_id'] ?? '0');
return BusinessShell( return NoTransitionPage(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: CheckFormPage( child: CheckFormPage(
businessId: businessId, businessId: businessId,
authStore: _authStore!, authStore: _authStore!,

View file

@ -0,0 +1,48 @@
/// مدل سند انتقال
class TransferDocument {
final int id;
final String code;
final DateTime documentDate;
final DateTime registeredAt;
final double totalAmount;
final String? createdByName;
final String? description;
final String? sourceType;
final String? sourceName;
final String? destinationType;
final String? destinationName;
TransferDocument({
required this.id,
required this.code,
required this.documentDate,
required this.registeredAt,
required this.totalAmount,
this.createdByName,
this.description,
this.sourceType,
this.sourceName,
this.destinationType,
this.destinationName,
});
factory TransferDocument.fromJson(Map<String, dynamic> json) {
return TransferDocument(
id: json['id'] as int,
code: (json['code'] ?? '').toString(),
documentDate: DateTime.tryParse((json['document_date'] ?? '').toString()) ?? DateTime.now(),
registeredAt: DateTime.tryParse((json['registered_at'] ?? '').toString()) ?? DateTime.now(),
totalAmount: (json['total_amount'] as num?)?.toDouble() ?? 0,
createdByName: (json['created_by_name'] ?? '') as String?,
description: (json['description'] ?? '') as String?,
sourceType: (json['source_type'] ?? '') as String?,
sourceName: (json['source_name'] ?? '') as String?,
destinationType: (json['destination_type'] ?? '') as String?,
destinationName: (json['destination_name'] ?? '') as String?,
);
}
String get documentTypeName => 'انتقال';
}

View file

@ -0,0 +1,338 @@
import 'package:flutter/material.dart';
import '../../core/calendar_controller.dart';
import '../../core/api_client.dart';
import '../../core/auth_store.dart';
import '../../widgets/date_input_field.dart';
import '../../widgets/invoice/invoice_transactions_widget.dart';
import '../../widgets/invoice/account_tree_combobox_widget.dart';
import '../../models/invoice_type_model.dart';
import '../../models/invoice_transaction.dart';
import '../../models/account_tree_node.dart';
import '../../services/expense_income_service.dart';
class ExpenseIncomeDialog extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final AuthStore authStore;
final ApiClient apiClient;
final Map<String, dynamic>? initial; // optional document for edit
const ExpenseIncomeDialog({super.key, required this.businessId, required this.calendarController, required this.authStore, required this.apiClient, this.initial});
@override
State<ExpenseIncomeDialog> createState() => _ExpenseIncomeDialogState();
}
class _ExpenseIncomeDialogState extends State<ExpenseIncomeDialog> {
final _formKey = GlobalKey<FormState>();
String _docType = 'expense';
DateTime _docDate = DateTime.now();
int? _currencyId;
final _descCtrl = TextEditingController();
final List<_ItemLine> _items = <_ItemLine>[];
final List<InvoiceTransaction> _transactions = <InvoiceTransaction>[];
@override
void initState() {
super.initState();
_currencyId = widget.authStore.currentBusiness?.defaultCurrency?.id;
if (widget.initial != null) {
_docType = (widget.initial!['document_type'] as String?) ?? 'expense';
final dd = widget.initial!['document_date'] as String?;
if (dd != null) _docDate = DateTime.tryParse(dd) ?? _docDate;
_descCtrl.text = (widget.initial!['description'] as String?) ?? '';
// items and counterparties could be mapped if provided
}
}
@override
void dispose() {
_descCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final sumItems = _items.fold<double>(0, (p, e) => p + e.amount);
final sumTx = _transactions.fold<double>(0, (p, e) => p + (e.amount.toDouble()));
final diff = (_docType == 'income') ? (sumTx - sumItems) : (sumItems - sumTx);
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100, maxHeight: 720),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
const Expanded(child: Text('هزینه و درآمد', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600))),
SegmentedButton<String>(
segments: const [ButtonSegment(value: 'expense', label: Text('هزینه')), ButtonSegment(value: 'income', label: Text('درآمد'))],
selected: {_docType},
onSelectionChanged: (s) => setState(() => _docType = s.first),
),
const SizedBox(width: 12),
SizedBox(
width: 200,
child: DateInputField(
value: _docDate,
calendarController: widget.calendarController,
onChanged: (d) => setState(() => _docDate = d ?? _docDate),
labelText: 'تاریخ سند',
hintText: 'انتخاب تاریخ',
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField(
controller: _descCtrl,
decoration: const InputDecoration(labelText: 'توضیحات کلی سند', border: OutlineInputBorder()),
maxLines: 2,
),
),
const Divider(height: 1),
Expanded(
child: Row(
children: [
Expanded(child: _ItemsPanel(businessId: widget.businessId, lines: _items, onChanged: (ls) => setState(() { _items..clear()..addAll(ls);}))),
const VerticalDivider(width: 1),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: InvoiceTransactionsWidget(
transactions: _transactions,
onChanged: (txs) => setState(() { _transactions..clear()..addAll(txs); }),
businessId: widget.businessId,
calendarController: widget.calendarController,
invoiceType: _docType == 'income' ? InvoiceType.sales : InvoiceType.purchase,
),
),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: Wrap(spacing: 16, runSpacing: 8, children: [
_chip('جمع اقلام', sumItems),
_chip('جمع طرف‌حساب', sumTx),
_chip('اختلاف', diff, isError: diff != 0),
]),
),
TextButton(onPressed: () => Navigator.pop(context), child: const Text('انصراف')),
const SizedBox(width: 8),
FilledButton.icon(onPressed: _canSave ? _save : null, icon: const Icon(Icons.save), label: const Text('ثبت')),
],
),
),
],
),
),
),
);
}
bool get _canSave {
if (_currencyId == null) return false;
if (_items.isEmpty || _transactions.isEmpty) return false;
final sumItems = _items.fold<double>(0, (p, e) => p + e.amount);
final sumTx = _transactions.fold<double>(0, (p, e) => p + (e.amount.toDouble()));
return sumItems == sumTx;
}
Future<void> _save() async {
showDialog(context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator()));
try {
final service = ExpenseIncomeService(widget.apiClient);
final itemsData = _items.map((e) => {'account_id': e.account?.id, 'amount': e.amount, if (e.description?.isNotEmpty == true) 'description': e.description}).toList();
final txData = _transactions.map((tx) => {
'transaction_type': tx.type.value,
'transaction_date': tx.transactionDate.toIso8601String(),
'amount': tx.amount.toDouble(),
if (tx.commission != null) 'commission': tx.commission?.toDouble(),
if (tx.description != null && tx.description!.isNotEmpty) 'description': tx.description,
'bank_id': tx.bankId,
'bank_name': tx.bankName,
'cash_register_id': tx.cashRegisterId,
'cash_register_name': tx.cashRegisterName,
'petty_cash_id': tx.pettyCashId,
'petty_cash_name': tx.pettyCashName,
'check_id': tx.checkId,
'check_number': tx.checkNumber,
'person_id': tx.personId,
'person_name': tx.personName,
'account_id': tx.accountId,
'account_name': tx.accountName,
}..removeWhere((k, v) => v == null)).toList();
await service.create(
businessId: widget.businessId,
documentType: _docType,
documentDate: _docDate,
currencyId: _currencyId!,
description: _descCtrl.text.trim(),
itemLines: itemsData,
counterpartyLines: txData,
);
if (!mounted) return;
Navigator.pop(context); // loading
Navigator.pop(context, true); // dialog success
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('سند با موفقیت ثبت شد'), backgroundColor: Colors.green));
} catch (e) {
if (!mounted) return;
Navigator.pop(context); // loading
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red));
}
}
}
class _ItemsPanel extends StatelessWidget {
final int businessId;
final List<_ItemLine> lines;
final ValueChanged<List<_ItemLine>> onChanged;
const _ItemsPanel({required this.businessId, required this.lines, required this.onChanged});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Expanded(child: Text('اقلام')),
IconButton(onPressed: () { final nl = List<_ItemLine>.from(lines); nl.add(_ItemLine.empty()); onChanged(nl); }, icon: const Icon(Icons.add)),
],
),
const SizedBox(height: 8),
Expanded(
child: lines.isEmpty
? const Center(child: Text('موردی ثبت نشده'))
: ListView.separated(
itemCount: lines.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (ctx, i) => _ItemTile(
businessId: businessId,
line: lines[i],
onChanged: (l) { final nl = List<_ItemLine>.from(lines); nl[i] = l; onChanged(nl); },
onDelete: () { final nl = List<_ItemLine>.from(lines); nl.removeAt(i); onChanged(nl); },
),
),
),
],
),
);
}
}
class _ItemTile extends StatefulWidget {
final int businessId;
final _ItemLine line;
final ValueChanged<_ItemLine> onChanged;
final VoidCallback onDelete;
const _ItemTile({required this.businessId, required this.line, required this.onChanged, required this.onDelete});
@override
State<_ItemTile> createState() => _ItemTileState();
}
class _ItemTileState extends State<_ItemTile> {
final _amountCtrl = TextEditingController();
final _descCtrl = TextEditingController();
@override
void initState() {
super.initState();
_amountCtrl.text = widget.line.amount == 0 ? '' : widget.line.amount.toStringAsFixed(0);
_descCtrl.text = widget.line.description ?? '';
}
@override
void dispose() {
_amountCtrl.dispose();
_descCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Row(
children: [
Expanded(
child: AccountTreeComboboxWidget(
businessId: widget.businessId,
selectedAccount: widget.line.account,
onChanged: (acc) => widget.onChanged(widget.line.copyWith(account: acc)),
label: 'حساب *',
hintText: 'انتخاب حساب',
isRequired: true,
),
),
const SizedBox(width: 8),
SizedBox(
width: 180,
child: TextFormField(
controller: _amountCtrl,
decoration: const InputDecoration(labelText: 'مبلغ', hintText: '1,000,000'),
keyboardType: TextInputType.number,
onChanged: (v) {
final val = double.tryParse(v.replaceAll(',', '')) ?? 0;
widget.onChanged(widget.line.copyWith(amount: val));
},
),
),
const SizedBox(width: 8),
IconButton(onPressed: widget.onDelete, icon: const Icon(Icons.delete_outline)),
],
),
const SizedBox(height: 8),
TextFormField(
controller: _descCtrl,
decoration: const InputDecoration(labelText: 'توضیحات'),
onChanged: (v) => widget.onChanged(widget.line.copyWith(description: v.trim().isEmpty ? null : v.trim())),
),
],
),
),
);
}
}
class _ItemLine {
final AccountTreeNode? account;
final double amount;
final String? description;
const _ItemLine({this.account, required this.amount, this.description});
factory _ItemLine.empty() => const _ItemLine(amount: 0);
_ItemLine copyWith({AccountTreeNode? account, double? amount, String? description}) => _ItemLine(
account: account ?? this.account,
amount: amount ?? this.amount,
description: description ?? this.description,
);
}
Widget _chip(String label, double value, {bool isError = false}) {
return Chip(
label: Text('$label: ${value.toStringAsFixed(0)}'),
backgroundColor: isError ? Colors.red.shade100 : Colors.grey.shade200,
);
}

View file

@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import '../../core/api_client.dart';
import '../../core/auth_store.dart';
import '../../core/calendar_controller.dart';
import '../../core/date_utils.dart' show HesabixDateUtils;
import '../../utils/number_formatters.dart' show formatWithThousands;
import '../../services/expense_income_service.dart';
import 'expense_income_dialog.dart';
class ExpenseIncomeListPage extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final AuthStore authStore;
final ApiClient apiClient;
const ExpenseIncomeListPage({super.key, required this.businessId, required this.calendarController, required this.authStore, required this.apiClient});
@override
State<ExpenseIncomeListPage> createState() => _ExpenseIncomeListPageState();
}
class _ExpenseIncomeListPageState extends State<ExpenseIncomeListPage> {
int _tabIndex = 0; // 0 expense, 1 income
final List<Map<String, dynamic>> _items = <Map<String, dynamic>>[];
int _skip = 0;
int _take = 20;
int _total = 0;
bool _loading = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final svc = ExpenseIncomeService(widget.apiClient);
final res = await svc.list(
businessId: widget.businessId,
documentType: _tabIndex == 0 ? 'expense' : 'income',
skip: _skip,
take: _take,
);
final data = (res['items'] as List<dynamic>? ?? const <dynamic>[]).cast<Map<String, dynamic>>();
setState(() {
_items
..clear()
..addAll(data);
_total = (res['pagination']?['total'] as int?) ?? data.length;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red));
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
const Expanded(child: Text('هزینه و درآمد', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600))),
SegmentedButton<int>(
segments: const [ButtonSegment(value: 0, label: Text('هزینه')), ButtonSegment(value: 1, label: Text('درآمد'))],
selected: {_tabIndex},
onSelectionChanged: (s) async {
setState(() { _tabIndex = s.first; _skip = 0; });
await _load();
},
),
const SizedBox(width: 12),
FilledButton.icon(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => ExpenseIncomeDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
authStore: widget.authStore,
apiClient: widget.apiClient,
),
);
if (ok == true) _load();
},
icon: const Icon(Icons.add),
label: const Text('افزودن'),
),
],
),
),
Expanded(
child: Card(
margin: const EdgeInsets.all(8),
child: _loading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty
? const Center(child: Text('داده‌ای یافت نشد'))
: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final it = _items[i];
final code = (it['code'] ?? '').toString();
final type = (it['document_type'] ?? '').toString();
final dateStr = (it['document_date'] ?? '').toString();
final date = dateStr.isNotEmpty ? DateTime.tryParse(dateStr) : null;
final sumItems = _sum(it['items'] as List<dynamic>?);
final sumCps = _sum(it['counterparties'] as List<dynamic>?);
return ListTile(
title: Text(code),
subtitle: Text('${type == 'income' ? 'درآمد' : 'هزینه'}${date != null ? HesabixDateUtils.formatForDisplay(date, true) : '-'}'),
trailing: Text('${formatWithThousands(sumItems)} | ${formatWithThousands(sumCps)}'),
onTap: () async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => ExpenseIncomeDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
authStore: widget.authStore,
apiClient: widget.apiClient,
initial: it,
),
);
if (ok == true) _load();
},
);
},
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
Text('$_skip - ${_skip + _items.length} از $_total'),
const Spacer(),
IconButton(onPressed: _skip <= 0 ? null : () { setState(() { _skip = (_skip - _take).clamp(0, _total); }); _load(); }, icon: const Icon(Icons.chevron_right)),
IconButton(onPressed: (_skip + _take) >= _total ? null : () { setState(() { _skip = _skip + _take; }); _load(); }, icon: const Icon(Icons.chevron_left)),
],
),
)
],
),
),
);
}
double _sum(List<dynamic>? lines) {
if (lines == null) return 0;
double s = 0;
for (final l in lines) {
final m = (l as Map<String, dynamic>);
s += ((m['debit'] ?? 0) as num).toDouble();
s += ((m['credit'] ?? 0) as num).toDouble();
}
return s;
}
}

View file

@ -0,0 +1,431 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../../core/auth_store.dart';
import '../../core/calendar_controller.dart';
import '../../widgets/date_input_field.dart';
import '../../widgets/invoice/invoice_transactions_widget.dart';
import '../../widgets/invoice/account_tree_combobox_widget.dart';
import '../../models/invoice_type_model.dart';
import '../../models/invoice_transaction.dart';
import '../../models/account_tree_node.dart';
import '../../utils/number_formatters.dart';
import '../../services/expense_income_service.dart';
import '../../core/api_client.dart';
class ExpenseIncomePage extends StatefulWidget {
final int businessId;
final AuthStore authStore;
final CalendarController calendarController;
final ApiClient apiClient;
const ExpenseIncomePage({super.key, required this.businessId, required this.authStore, required this.calendarController, required this.apiClient});
@override
State<ExpenseIncomePage> createState() => _ExpenseIncomePageState();
}
class _ExpenseIncomePageState extends State<ExpenseIncomePage> {
// uuid reserved for future draft IDs if needed
DateTime _docDate = DateTime.now();
String _docType = 'expense';
int? _currencyId;
final _descriptionController = TextEditingController();
final List<_ItemLine> _itemLines = <_ItemLine>[];
final List<_TxLine> _txLines = <_TxLine>[];
@override
void initState() {
super.initState();
_currencyId = widget.authStore.currentBusiness?.defaultCurrency?.id;
}
@override
void dispose() {
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
const Expanded(
child: Text('هزینه و درآمد', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
),
SegmentedButton<String>(
segments: const [
ButtonSegment<String>(value: 'expense', label: Text('هزینه')),
ButtonSegment<String>(value: 'income', label: Text('درآمد')),
],
selected: {_docType},
onSelectionChanged: (s) => setState(() => _docType = s.first),
),
const SizedBox(width: 12),
SizedBox(
width: 220,
child: DateInputField(
value: _docDate,
calendarController: widget.calendarController,
onChanged: (d) => setState(() => _docDate = d ?? DateTime.now()),
labelText: 'تاریخ سند',
hintText: 'انتخاب تاریخ',
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'توضیحات کلی سند',
hintText: 'توضیحات اختیاری...',
border: OutlineInputBorder(),
),
maxLines: 2,
),
),
const Divider(height: 1),
Expanded(
child: Row(
children: [
// پنل سطرهای حساب (هزینه/درآمد)
Expanded(
child: _ItemsPanel(
businessId: widget.businessId,
lines: _itemLines,
onChanged: (ls) => setState(() {
_itemLines
..clear()
..addAll(ls);
}),
),
),
const VerticalDivider(width: 1),
// پنل سطرهای طرفحساب (بازاستفاده از ویجت تراکنشها)
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: InvoiceTransactionsWidget(
transactions: const <InvoiceTransaction>[],
onChanged: (txs) => setState(() {
_txLines
..clear()
..addAll(txs.map(_TxLine.fromInvoiceTransaction));
}),
businessId: widget.businessId,
calendarController: widget.calendarController,
invoiceType: _docType == 'income' ? InvoiceType.sales : InvoiceType.purchase,
),
),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: Wrap(
spacing: 16,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_chip('جمع اقلام', _sumItems()),
_chip('جمع طرف‌حساب', _sumTxs()),
_chip('اختلاف', (_docType == 'income' ? _sumTxs() - _sumItems() : _sumItems() - _sumTxs()), isError: _sumItems() != _sumTxs()),
],
),
),
TextButton(onPressed: () => Navigator.pop(context), child: const Text('انصراف')),
const SizedBox(width: 8),
FilledButton.icon(onPressed: _canSave ? _save : null, icon: const Icon(Icons.save), label: const Text('ثبت')),
],
),
),
],
),
),
);
}
double _sumItems() => _itemLines.fold<double>(0, (p, e) => p + e.amount);
double _sumTxs() => _txLines.fold<double>(0, (p, e) => p + e.amount);
bool get _canSave => _currencyId != null && _itemLines.isNotEmpty && _txLines.isNotEmpty && _sumItems() == _sumTxs();
Future<void> _save() async {
showDialog(context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator()));
try {
final service = ExpenseIncomeService(widget.apiClient);
final itemLinesData = _itemLines.map((e) => {
'account_id': e.account?.id,
'amount': e.amount,
if (e.description?.isNotEmpty == true) 'description': e.description,
}).toList();
final counterpartyLinesData = _txLines.map((e) => e.toApiMap()).toList();
await service.create(
businessId: widget.businessId,
documentType: _docType,
documentDate: _docDate,
currencyId: _currencyId!,
description: _descriptionController.text.trim(),
itemLines: itemLinesData,
counterpartyLines: counterpartyLinesData,
);
if (!mounted) return;
Navigator.pop(context); // loading
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('سند با موفقیت ثبت شد'), backgroundColor: Colors.green));
} catch (e) {
if (!mounted) return;
Navigator.pop(context); // loading
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red));
}
}
}
class _ItemsPanel extends StatelessWidget {
final int businessId;
final List<_ItemLine> lines;
final ValueChanged<List<_ItemLine>> onChanged;
const _ItemsPanel({required this.businessId, required this.lines, required this.onChanged});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Expanded(child: Text('اقلام هزینه/درآمد')),
IconButton(
onPressed: () {
final newLines = List<_ItemLine>.from(lines);
newLines.add(_ItemLine.empty());
onChanged(newLines);
},
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 8),
Expanded(
child: lines.isEmpty
? const Center(child: Text('موردی ثبت نشده'))
: ListView.separated(
itemCount: lines.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (ctx, i) => _ItemTile(
businessId: businessId,
line: lines[i],
onChanged: (l) {
final newLines = List<_ItemLine>.from(lines);
newLines[i] = l;
onChanged(newLines);
},
onDelete: () {
final newLines = List<_ItemLine>.from(lines);
newLines.removeAt(i);
onChanged(newLines);
},
),
),
),
],
),
);
}
}
class _ItemTile extends StatefulWidget {
final int businessId;
final _ItemLine line;
final ValueChanged<_ItemLine> onChanged;
final VoidCallback onDelete;
const _ItemTile({required this.businessId, required this.line, required this.onChanged, required this.onDelete});
@override
State<_ItemTile> createState() => _ItemTileState();
}
class _ItemTileState extends State<_ItemTile> {
final _amountController = TextEditingController();
final _descController = TextEditingController();
@override
void initState() {
super.initState();
_amountController.text = widget.line.amount == 0 ? '' : widget.line.amount.toStringAsFixed(0);
_descController.text = widget.line.description ?? '';
}
@override
void dispose() {
_amountController.dispose();
_descController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Row(
children: [
Expanded(
child: AccountTreeComboboxWidget(
businessId: widget.businessId,
selectedAccount: widget.line.account,
onChanged: (acc) => widget.onChanged(widget.line.copyWith(account: acc)),
label: 'حساب *',
hintText: 'انتخاب حساب هزینه/درآمد',
isRequired: true,
),
),
const SizedBox(width: 8),
SizedBox(
width: 180,
child: TextFormField(
controller: _amountController,
decoration: const InputDecoration(labelText: 'مبلغ', hintText: '1,000,000'),
keyboardType: TextInputType.number,
onChanged: (v) {
final val = double.tryParse(v.replaceAll(',', '')) ?? 0;
widget.onChanged(widget.line.copyWith(amount: val));
},
),
),
const SizedBox(width: 8),
IconButton(onPressed: widget.onDelete, icon: const Icon(Icons.delete_outline)),
],
),
const SizedBox(height: 8),
TextFormField(
controller: _descController,
decoration: const InputDecoration(labelText: 'توضیحات'),
onChanged: (v) => widget.onChanged(widget.line.copyWith(description: v.trim().isEmpty ? null : v.trim())),
),
],
),
),
);
}
}
class _ItemLine {
final AccountTreeNode? account;
final double amount;
final String? description;
const _ItemLine({this.account, required this.amount, this.description});
factory _ItemLine.empty() => const _ItemLine(amount: 0);
_ItemLine copyWith({AccountTreeNode? account, double? amount, String? description}) => _ItemLine(
account: account ?? this.account,
amount: amount ?? this.amount,
description: description ?? this.description,
);
}
class _TxLine {
final String id;
final DateTime date;
final String type; // bank|cash_register|petty_cash|check|person|account
final double amount;
final double? commission;
final String? description;
final String? bankId;
final String? bankName;
final String? cashRegisterId;
final String? cashRegisterName;
final String? pettyCashId;
final String? pettyCashName;
final String? checkId;
final String? checkNumber;
final String? personId;
final String? personName;
final AccountTreeNode? account;
_TxLine({
required this.id,
required this.date,
required this.type,
required this.amount,
this.commission,
this.description,
this.bankId,
this.bankName,
this.cashRegisterId,
this.cashRegisterName,
this.pettyCashId,
this.pettyCashName,
this.checkId,
this.checkNumber,
this.personId,
this.personName,
this.account,
});
Map<String, dynamic> toApiMap() => {
'transaction_type': type,
'transaction_date': date.toIso8601String(),
'amount': amount,
if (commission != null) 'commission': commission,
if (description?.isNotEmpty == true) 'description': description,
'bank_id': bankId,
'bank_name': bankName,
'cash_register_id': cashRegisterId,
'cash_register_name': cashRegisterName,
'petty_cash_id': pettyCashId,
'petty_cash_name': pettyCashName,
'check_id': checkId,
'check_number': checkNumber,
'person_id': personId,
'person_name': personName,
if (account != null) 'account_id': account!.id,
}..removeWhere((k, v) => v == null);
// اتصال به ویجت InvoiceTransactionsWidget
factory _TxLine.fromInvoiceTransaction(dynamic tx) => _TxLine(
id: tx.id ?? const Uuid().v4(),
date: tx.transactionDate,
type: tx.type.value,
amount: tx.amount.toDouble(),
commission: tx.commission?.toDouble(),
description: tx.description,
bankId: tx.bankId,
bankName: tx.bankName,
cashRegisterId: tx.cashRegisterId,
cashRegisterName: tx.cashRegisterName,
pettyCashId: tx.pettyCashId,
pettyCashName: tx.pettyCashName,
checkId: tx.checkId,
checkNumber: tx.checkNumber,
personId: tx.personId,
personName: tx.personName,
account: null,
);
// Not needed: we only consume transactions from widget
}
Widget _chip(String label, double value, {bool isError = false}) {
return Chip(
label: Text('$label: ${formatWithThousands(value)}'),
backgroundColor: isError ? Colors.red.shade100 : Colors.grey.shade200,
);
}

View file

@ -1,9 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/auth_store.dart'; import '../../core/auth_store.dart';
import '../../core/calendar_controller.dart'; import '../../core/calendar_controller.dart';
import '../../core/api_client.dart'; import '../../core/api_client.dart';
import '../../models/transfer_document.dart';
import '../../services/transfer_service.dart';
import '../../widgets/data_table/data_table_widget.dart';
import '../../widgets/data_table/data_table_config.dart';
import '../../core/date_utils.dart' show HesabixDateUtils;
import '../../utils/number_formatters.dart' show formatWithThousands;
import '../../widgets/date_input_field.dart';
import '../../widgets/transfer/transfer_form_dialog.dart'; import '../../widgets/transfer/transfer_form_dialog.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../widgets/transfer/transfer_details_dialog.dart';
class TransfersPage extends StatefulWidget { class TransfersPage extends StatefulWidget {
final int businessId; final int businessId;
@ -24,79 +32,44 @@ class TransfersPage extends StatefulWidget {
} }
class _TransfersPageState extends State<TransfersPage> { class _TransfersPageState extends State<TransfersPage> {
Future<void> _showAddTransferDialog() async { // کنترل جدول برای دسترسی به refresh
final result = await showDialog<bool>( final GlobalKey _tableKey = GlobalKey();
context: context, DateTime? _fromDate;
builder: (context) => TransferFormDialog( DateTime? _toDate;
businessId: widget.businessId,
calendarController: widget.calendarController, void _refreshData() {
onSuccess: () { final state = _tableKey.currentState;
// TODO: بروزرسانی لیست انتقالات if (state != null) {
ScaffoldMessenger.of(context).showSnackBar( try {
const SnackBar( // ignore: avoid_dynamic_calls
content: Text('انتقال با موفقیت ثبت شد'), (state as dynamic).refresh();
backgroundColor: Colors.green, return;
), } catch (_) {}
);
},
),
);
if (result == true) {
// بروزرسانی صفحه در صورت نیاز
setState(() {});
} }
if (mounted) setState(() {});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.surface,
title: Text(t.transfers), body: SafeArea(
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showAddTransferDialog(),
tooltip: 'اضافه کردن انتقال جدید',
),
],
),
body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Icon( _buildHeader(t),
Icons.swap_horiz, _buildFilters(t),
size: 80, Expanded(
color: Theme.of(context).colorScheme.primary.withOpacity(0.5), child: Padding(
), padding: const EdgeInsets.all(8.0),
const SizedBox(height: 24), child: DataTableWidget<TransferDocument>(
Text( key: _tableKey,
'صفحه لیست انتقال', config: _buildTableConfig(t),
style: Theme.of(context).textTheme.headlineSmall?.copyWith( fromJson: (json) => TransferDocument.fromJson(json),
color: Theme.of(context).colorScheme.onSurface, calendarController: widget.calendarController,
fontWeight: FontWeight.bold, ),
),
),
const SizedBox(height: 16),
Text(
'این صفحه به زودی آماده خواهد شد',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () => _showAddTransferDialog(),
icon: const Icon(Icons.add),
label: const Text('اضافه کردن انتقال جدید'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
), ),
), ),
], ],
@ -104,4 +77,281 @@ class _TransfersPageState extends State<TransfersPage> {
), ),
); );
} }
Widget _buildHeader(AppLocalizations t) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.transfers,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'مدیریت اسناد انتقال وجه',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
Tooltip(
message: 'افزودن انتقال جدید',
child: FilledButton.icon(
onPressed: _onAddNew,
icon: const Icon(Icons.add),
label: Text(t.add),
),
),
],
),
);
}
Widget _buildFilters(AppLocalizations t) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: Row(
children: [
Expanded(
child: DateInputField(
value: _fromDate,
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _fromDate = date);
_refreshData();
},
labelText: 'از تاریخ',
hintText: 'انتخاب تاریخ شروع',
),
),
const SizedBox(width: 8),
Expanded(
child: DateInputField(
value: _toDate,
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _toDate = date);
_refreshData();
},
labelText: 'تا تاریخ',
hintText: 'انتخاب تاریخ پایان',
),
),
IconButton(
onPressed: () {
setState(() {
_fromDate = null;
_toDate = null;
});
_refreshData();
},
icon: const Icon(Icons.clear),
tooltip: 'پاک کردن فیلتر تاریخ',
),
],
),
),
],
),
);
}
DataTableConfig<TransferDocument> _buildTableConfig(AppLocalizations t) {
return DataTableConfig<TransferDocument>(
endpoint: '/businesses/${widget.businessId}/transfers',
title: t.transfers,
excelEndpoint: '/businesses/${widget.businessId}/transfers/export/excel',
pdfEndpoint: '/businesses/${widget.businessId}/transfers/export/pdf',
getExportParams: () => {
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
},
columns: [
TextColumn(
'code',
'کد سند',
width: ColumnWidth.medium,
formatter: (it) => it.code,
),
TextColumn(
'description',
'توضیحات',
width: ColumnWidth.large,
formatter: (it) => (it.description ?? '').isNotEmpty ? it.description! : _composeDesc(it),
),
TextColumn(
'route',
'مبدا → مقصد',
width: ColumnWidth.large,
formatter: (it) => _composeRoute(it),
),
DateColumn(
'document_date',
'تاریخ سند',
width: ColumnWidth.medium,
formatter: (it) => HesabixDateUtils.formatForDisplay(it.documentDate, widget.calendarController.isJalali),
),
NumberColumn(
'total_amount',
'مبلغ کل',
width: ColumnWidth.large,
formatter: (it) => formatWithThousands(it.totalAmount),
suffix: ' ریال',
),
TextColumn(
'created_by_name',
'ایجادکننده',
width: ColumnWidth.medium,
formatter: (it) => it.createdByName ?? 'نامشخص',
),
DateColumn(
'registered_at',
'تاریخ ثبت',
width: ColumnWidth.medium,
formatter: (it) => HesabixDateUtils.formatForDisplay(it.registeredAt, widget.calendarController.isJalali),
),
ActionColumn(
'actions',
'عملیات',
width: ColumnWidth.medium,
actions: [
DataTableAction(icon: Icons.visibility, label: 'مشاهده', onTap: (it) => _onView(it as TransferDocument)),
DataTableAction(icon: Icons.edit, label: 'ویرایش', onTap: (it) => _onEdit(it as TransferDocument)),
DataTableAction(icon: Icons.delete, label: 'حذف', onTap: (it) => _onDelete(it as TransferDocument), isDestructive: true),
],
),
],
searchFields: ['code', 'created_by_name'],
dateRangeField: 'document_date',
showSearch: true,
showFilters: true,
showPagination: true,
showColumnSearch: true,
showRefreshButton: true,
showClearFiltersButton: true,
enableRowSelection: true,
enableMultiRowSelection: true,
showExportButtons: true,
showExcelExport: true,
showPdfExport: true,
defaultPageSize: 20,
pageSizeOptions: [10, 20, 50, 100],
// انتخاب سطرها در این صفحه استفاده خاصی ندارد
additionalParams: {
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
},
onRowTap: (item) => _onView(item as TransferDocument),
onRowDoubleTap: (item) => _onEdit(item as TransferDocument),
emptyStateMessage: 'هیچ سند انتقالی یافت نشد',
loadingMessage: 'در حال بارگذاری اسناد انتقال...',
errorMessage: 'خطا در بارگذاری اسناد انتقال',
);
}
String _typeFa(String? t) {
switch (t) {
case 'bank':
return 'بانک';
case 'cash_register':
return 'صندوق';
case 'petty_cash':
return 'تنخواه';
default:
return t ?? '';
}
}
String _composeRoute(TransferDocument it) {
final src = '${_typeFa(it.sourceType)} ${it.sourceName ?? ''}'.trim();
final dst = '${_typeFa(it.destinationType)} ${it.destinationName ?? ''}'.trim();
if (src.isEmpty && dst.isEmpty) return '';
return '$src$dst';
}
String _composeDesc(TransferDocument it) {
final r = _composeRoute(it);
if (r.isEmpty) return '';
return 'انتقال $r';
}
void _onAddNew() async {
final result = await showDialog<bool>(
context: context,
builder: (context) => TransferFormDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
authStore: widget.authStore,
apiClient: widget.apiClient,
onSuccess: () {},
),
);
if (result == true) _refreshData();
}
void _onView(TransferDocument item) async {
final svc = TransferService(widget.apiClient);
final full = await svc.getById(item.id);
if (!mounted) return;
showDialog(
context: context,
builder: (_) => TransferDetailsDialog(document: full),
);
}
void _onEdit(TransferDocument item) async {
final svc = TransferService(widget.apiClient);
final full = await svc.getById(item.id);
if (!mounted) return;
final result = await showDialog<bool>(
context: context,
builder: (_) => TransferFormDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
authStore: widget.authStore,
apiClient: widget.apiClient,
initial: full,
onSuccess: () {},
),
);
if (result == true) _refreshData();
}
void _onDelete(TransferDocument item) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('حذف انتقال'),
content: Text('آیا از حذف سند ${item.code} مطمئن هستید؟'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('حذف')),
],
),
);
if (confirm == true) {
try {
final svc = TransferService(widget.apiClient);
await svc.deleteById(item.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('حذف شد'), backgroundColor: Colors.green));
}
_refreshData();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red));
}
}
}
}
} }

View file

@ -0,0 +1,60 @@
import 'package:hesabix_ui/core/api_client.dart';
class ExpenseIncomeService {
final ApiClient api;
ExpenseIncomeService(this.api);
Future<Map<String, dynamic>> create({
required int businessId,
required String documentType, // 'expense' | 'income'
required DateTime documentDate,
required int currencyId,
String? description,
List<Map<String, dynamic>> itemLines = const [],
List<Map<String, dynamic>> counterpartyLines = const [],
}) async {
final body = <String, dynamic>{
'document_type': documentType,
'document_date': documentDate.toIso8601String(),
'currency_id': currencyId,
if (description != null && description.isNotEmpty) 'description': description,
'item_lines': itemLines,
'counterparty_lines': counterpartyLines,
};
final res = await api.post<Map<String, dynamic>>(
'/api/v1/businesses/$businessId/expense-income/create',
data: body,
);
return res.data ?? <String, dynamic>{};
}
Future<Map<String, dynamic>> list({
required int businessId,
String? documentType, // 'expense' | 'income'
DateTime? fromDate,
DateTime? toDate,
int skip = 0,
int take = 20,
String? search,
String? sortBy,
bool sortDesc = true,
}) async {
final body = <String, dynamic>{
'skip': skip,
'take': take,
'sort_desc': sortDesc,
if (sortBy != null) 'sort_by': sortBy,
if (search != null && search.isNotEmpty) 'search': search,
if (documentType != null) 'document_type': documentType,
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(),
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
};
final res = await api.post<Map<String, dynamic>>(
'/api/v1/businesses/$businessId/expense-income',
data: body,
);
return res.data ?? <String, dynamic>{};
}
}

View file

@ -0,0 +1,145 @@
import '../core/api_client.dart';
import 'package:dio/dio.dart';
class TransferService {
final ApiClient _apiClient;
TransferService(this._apiClient);
Future<Map<String, dynamic>> create({
required int businessId,
required DateTime documentDate,
required int currencyId,
required Map<String, dynamic> source,
required Map<String, dynamic> destination,
required double amount,
double? commission,
String? description,
Map<String, dynamic>? extraInfo,
}) async {
final body = <String, dynamic>{
'document_date': documentDate.toIso8601String(),
'currency_id': currencyId,
'source': source,
'destination': destination,
'amount': amount,
if (commission != null) 'commission': commission,
if (description != null && description.isNotEmpty) 'description': description,
if (extraInfo != null) 'extra_info': extraInfo,
};
final res = await _apiClient.post('/businesses/$businessId/transfers/create', data: body);
return (res.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
}
Future<Map<String, dynamic>> list({
required int businessId,
int skip = 0,
int take = 20,
String? search,
DateTime? fromDate,
DateTime? toDate,
String? sortBy,
bool sortDesc = true,
}) async {
final body = <String, dynamic>{
'skip': skip,
'take': take,
'sort_desc': sortDesc,
if (sortBy != null) 'sort_by': sortBy,
if (search != null && search.isNotEmpty) 'search': search,
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(),
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
};
final res = await _apiClient.post('/businesses/$businessId/transfers', data: body);
return (res.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
}
Future<List<int>> exportExcel({
required int businessId,
int skip = 0,
int take = 1000,
String? search,
DateTime? fromDate,
DateTime? toDate,
String? sortBy,
bool sortDesc = true,
}) async {
final body = <String, dynamic>{
'skip': skip,
'take': take,
'sort_desc': sortDesc,
if (sortBy != null) 'sort_by': sortBy,
if (search != null && search.isNotEmpty) 'search': search,
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(),
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
};
final res = await _apiClient.post<List<int>>(
'/businesses/$businessId/transfers/export/excel',
data: body,
responseType: ResponseType.bytes,
);
return res.data ?? <int>[];
}
Future<List<int>> exportPdf({
required int businessId,
int skip = 0,
int take = 1000,
String? search,
DateTime? fromDate,
DateTime? toDate,
String? sortBy,
bool sortDesc = true,
}) async {
final body = <String, dynamic>{
'skip': skip,
'take': take,
'sort_desc': sortDesc,
if (sortBy != null) 'sort_by': sortBy,
if (search != null && search.isNotEmpty) 'search': search,
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(),
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
};
final res = await _apiClient.post<List<int>>(
'/businesses/$businessId/transfers/export/pdf',
data: body,
responseType: ResponseType.bytes,
);
return res.data ?? <int>[];
}
Future<Map<String, dynamic>> getById(int documentId) async {
final res = await _apiClient.get('/transfers/$documentId');
return (res.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
}
Future<Map<String, dynamic>> update({
required int documentId,
required DateTime documentDate,
required int currencyId,
required Map<String, dynamic> source,
required Map<String, dynamic> destination,
required double amount,
double? commission,
String? description,
Map<String, dynamic>? extraInfo,
}) async {
final body = <String, dynamic>{
'document_date': documentDate.toIso8601String(),
'currency_id': currencyId,
'source': source,
'destination': destination,
'amount': amount,
if (commission != null) 'commission': commission,
if (description != null && description.isNotEmpty) 'description': description,
if (extraInfo != null) 'extra_info': extraInfo,
};
final res = await _apiClient.put('/transfers/$documentId', data: body);
return (res.data as Map<String, dynamic>)['data'] as Map<String, dynamic>;
}
Future<void> deleteById(int documentId) async {
await _apiClient.delete('/transfers/$documentId');
}
}

View file

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../core/fiscal_year_controller.dart'; import '../core/fiscal_year_controller.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
class FiscalYearSwitcher extends StatelessWidget { class FiscalYearSwitcher extends StatelessWidget {
final FiscalYearController controller; final FiscalYearController controller;
@ -11,7 +10,6 @@ class FiscalYearSwitcher extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final int? selectedId = controller.fiscalYearId ?? _currentDefaultId(); final int? selectedId = controller.fiscalYearId ?? _currentDefaultId();
return DropdownButtonHideUnderline( return DropdownButtonHideUnderline(

View file

@ -2,11 +2,14 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../services/bank_account_service.dart'; import '../../services/bank_account_service.dart';
import '../../core/api_client.dart';
import '../../services/currency_service.dart';
class BankAccountOption { class BankAccountOption {
final String id; final String id;
final String name; final String name;
const BankAccountOption(this.id, this.name); final int? currencyId;
const BankAccountOption(this.id, this.name, {this.currencyId});
} }
class BankAccountComboboxWidget extends StatefulWidget { class BankAccountComboboxWidget extends StatefulWidget {
@ -16,6 +19,7 @@ class BankAccountComboboxWidget extends StatefulWidget {
final String label; final String label;
final String hintText; final String hintText;
final bool isRequired; final bool isRequired;
final int? filterCurrencyId;
const BankAccountComboboxWidget({ const BankAccountComboboxWidget({
super.key, super.key,
@ -25,6 +29,7 @@ class BankAccountComboboxWidget extends StatefulWidget {
this.label = 'بانک', this.label = 'بانک',
this.hintText = 'جست‌وجو و انتخاب بانک', this.hintText = 'جست‌وجو و انتخاب بانک',
this.isRequired = false, this.isRequired = false,
this.filterCurrencyId,
}); });
@override @override
@ -38,6 +43,8 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
int _seq = 0; int _seq = 0;
String _latestQuery = ''; String _latestQuery = '';
void Function(void Function())? _setModalState; void Function(void Function())? _setModalState;
final CurrencyService _currencyService = CurrencyService(ApiClient());
Map<int, Map<String, dynamic>> _currencyById = <int, Map<String, dynamic>>{};
List<BankAccountOption> _items = <BankAccountOption>[]; List<BankAccountOption> _items = <BankAccountOption>[];
bool _isLoading = false; bool _isLoading = false;
@ -47,9 +54,19 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadCurrencies();
_load(); _load();
} }
@override
void didUpdateWidget(covariant BankAccountComboboxWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filterCurrencyId != widget.filterCurrencyId) {
// بازخوانی با فیلتر جدید ارز
_performSearch(_latestQuery);
}
}
@override @override
void dispose() { void dispose() {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
@ -61,6 +78,35 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
await _performSearch(''); await _performSearch('');
} }
Future<void> _loadCurrencies() async {
try {
final list = await _currencyService.listBusinessCurrencies(businessId: widget.businessId);
final map = <int, Map<String, dynamic>>{};
for (final m in list) {
final id = m['id'];
if (id is int) {
map[id] = m;
}
}
if (!mounted) return;
setState(() {
_currencyById = map;
});
} catch (_) {
// ignore errors, currency labels will be omitted
}
}
String _formatCurrencyLabel(int? currencyId) {
if (currencyId == null) return '';
final m = _currencyById[currencyId];
if (m == null) return '';
final code = (m['code'] ?? '').toString();
final title = (m['title'] ?? '').toString();
if (code.isNotEmpty && title.isNotEmpty) return '$code';
return code.isNotEmpty ? code : title;
}
void _onSearchChanged(String q) { void _onSearchChanged(String q) {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
@ -98,13 +144,18 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null) final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
? (res['data'] as Map)['items'] ? (res['data'] as Map)['items']
: res['items']; : res['items'];
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) { var items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
final m = Map<String, dynamic>.from(e as Map); final m = Map<String, dynamic>.from(e as Map);
final id = m['id']?.toString(); final id = m['id']?.toString();
final name = m['name']?.toString() ?? 'نامشخص'; final name = m['name']?.toString() ?? 'نامشخص';
log('Bank account item: id=$id, name=$name'); final currencyId = (m['currency_id'] ?? m['currencyId']);
return BankAccountOption(id ?? '', name); log('Bank account item: id=$id, name=$name, currencyId=$currencyId');
return BankAccountOption(id ?? '', name, currencyId: currencyId is int ? currencyId : int.tryParse('${currencyId ?? ''}'));
}).toList(); }).toList();
// Filter by currency if requested
if (widget.filterCurrencyId != null) {
items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList();
}
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_items = items; _items = items;
@ -149,6 +200,7 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
isSearching: _isSearching, isSearching: _isSearching,
hasSearched: _hasSearched, hasSearched: _hasSearched,
onSearchChanged: _onSearchChanged, onSearchChanged: _onSearchChanged,
currencyLabelBuilder: _formatCurrencyLabel,
onSelected: (opt) { onSelected: (opt) {
widget.onChanged(opt); widget.onChanged(opt);
Navigator.pop(context); Navigator.pop(context);
@ -166,8 +218,11 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
(e) => e.id == widget.selectedAccountId, (e) => e.id == widget.selectedAccountId,
orElse: () => const BankAccountOption('', ''), orElse: () => const BankAccountOption('', ''),
); );
final currencyText = _formatCurrencyLabel(selected.currencyId);
final text = (widget.selectedAccountId != null && widget.selectedAccountId!.isNotEmpty) final text = (widget.selectedAccountId != null && widget.selectedAccountId!.isNotEmpty)
? (selected.name.isNotEmpty ? selected.name : widget.hintText) ? (selected.name.isNotEmpty
? (currencyText.isNotEmpty ? '${selected.name} - $currencyText' : selected.name)
: widget.hintText)
: widget.hintText; : widget.hintText;
return InkWell( return InkWell(
@ -209,6 +264,7 @@ class _BankPickerBottomSheet extends StatelessWidget {
final bool hasSearched; final bool hasSearched;
final ValueChanged<String> onSearchChanged; final ValueChanged<String> onSearchChanged;
final ValueChanged<BankAccountOption?> onSelected; final ValueChanged<BankAccountOption?> onSelected;
final String Function(int?)? currencyLabelBuilder;
const _BankPickerBottomSheet({ const _BankPickerBottomSheet({
required this.label, required this.label,
@ -219,6 +275,7 @@ class _BankPickerBottomSheet extends StatelessWidget {
required this.isSearching, required this.isSearching,
required this.hasSearched, required this.hasSearched,
required this.onSearchChanged, required this.onSearchChanged,
required this.currencyLabelBuilder,
required this.onSelected, required this.onSelected,
}); });
@ -272,16 +329,34 @@ class _BankPickerBottomSheet extends StatelessWidget {
], ],
), ),
) )
: ListView.builder( : ListView.builder(
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final it = items[index]; final it = items[index];
final currencyText = (it.currencyId != null) ? (currencyLabelBuilder?.call(it.currencyId) ?? '') : '';
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: colorScheme.primaryContainer, backgroundColor: colorScheme.primaryContainer,
child: Icon(Icons.account_balance, color: colorScheme.onPrimaryContainer), child: Icon(Icons.account_balance, color: colorScheme.onPrimaryContainer),
), ),
title: Text(it.name), title: Text(it.name),
trailing: currencyText.isNotEmpty
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
currencyText,
style: TextStyle(
color: colorScheme.primary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
)
: null,
onTap: () => onSelected(it), onTap: () => onSelected(it),
); );
}, },

View file

@ -1,11 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../services/cash_register_service.dart'; import '../../services/cash_register_service.dart';
import '../../core/api_client.dart';
import '../../services/currency_service.dart';
class CashRegisterOption { class CashRegisterOption {
final String id; final String id;
final String name; final String name;
const CashRegisterOption(this.id, this.name); final int? currencyId;
const CashRegisterOption(this.id, this.name, {this.currencyId});
} }
class CashRegisterComboboxWidget extends StatefulWidget { class CashRegisterComboboxWidget extends StatefulWidget {
@ -15,6 +18,7 @@ class CashRegisterComboboxWidget extends StatefulWidget {
final String label; final String label;
final String hintText; final String hintText;
final bool isRequired; final bool isRequired;
final int? filterCurrencyId;
const CashRegisterComboboxWidget({ const CashRegisterComboboxWidget({
super.key, super.key,
@ -24,6 +28,7 @@ class CashRegisterComboboxWidget extends StatefulWidget {
this.label = 'صندوق', this.label = 'صندوق',
this.hintText = 'جست‌وجو و انتخاب صندوق', this.hintText = 'جست‌وجو و انتخاب صندوق',
this.isRequired = false, this.isRequired = false,
this.filterCurrencyId,
}); });
@override @override
@ -42,13 +47,24 @@ class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget>
bool _isLoading = false; bool _isLoading = false;
bool _isSearching = false; bool _isSearching = false;
bool _hasSearched = false; bool _hasSearched = false;
final CurrencyService _currencyService = CurrencyService(ApiClient());
Map<int, Map<String, dynamic>> _currencyById = <int, Map<String, dynamic>>{};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadCurrencies();
_load(); _load();
} }
@override
void didUpdateWidget(covariant CashRegisterComboboxWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filterCurrencyId != widget.filterCurrencyId) {
_performSearch(_latestQuery);
}
}
@override @override
void dispose() { void dispose() {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
@ -60,6 +76,35 @@ class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget>
await _performSearch(''); await _performSearch('');
} }
Future<void> _loadCurrencies() async {
try {
final list = await _currencyService.listBusinessCurrencies(businessId: widget.businessId);
final map = <int, Map<String, dynamic>>{};
for (final m in list) {
final id = m['id'];
if (id is int) {
map[id] = m;
}
}
if (!mounted) return;
setState(() {
_currencyById = map;
});
} catch (_) {
// ignore errors
}
}
String _formatCurrencyLabel(int? currencyId) {
if (currencyId == null) return '';
final m = _currencyById[currencyId];
if (m == null) return '';
final code = (m['code'] ?? '').toString();
final title = (m['title'] ?? '').toString();
if (code.isNotEmpty && title.isNotEmpty) return '$code';
return code.isNotEmpty ? code : title;
}
void _onSearchChanged(String q) { void _onSearchChanged(String q) {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
@ -95,10 +140,18 @@ class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget>
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null) final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
? (res['data'] as Map)['items'] ? (res['data'] as Map)['items']
: res['items']; : res['items'];
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) { var items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
final m = Map<String, dynamic>.from(e as Map); final m = Map<String, dynamic>.from(e as Map);
return CashRegisterOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص')); final currencyId = (m['currency_id'] ?? m['currencyId']);
return CashRegisterOption(
'${m['id']}',
(m['name']?.toString() ?? 'نامشخص'),
currencyId: currencyId is int ? currencyId : int.tryParse('${currencyId ?? ''}'),
);
}).toList(); }).toList();
if (widget.filterCurrencyId != null) {
items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList();
}
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_items = items; _items = items;
@ -143,6 +196,7 @@ class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget>
isSearching: _isSearching, isSearching: _isSearching,
hasSearched: _hasSearched, hasSearched: _hasSearched,
onSearchChanged: _onSearchChanged, onSearchChanged: _onSearchChanged,
currencyLabelBuilder: _formatCurrencyLabel,
onSelected: (opt) { onSelected: (opt) {
widget.onChanged(opt); widget.onChanged(opt);
Navigator.pop(context); Navigator.pop(context);
@ -160,8 +214,11 @@ class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget>
(e) => e.id == widget.selectedRegisterId, (e) => e.id == widget.selectedRegisterId,
orElse: () => const CashRegisterOption('', ''), orElse: () => const CashRegisterOption('', ''),
); );
final currencyText = _formatCurrencyLabel(selected.currencyId);
final text = (widget.selectedRegisterId != null && widget.selectedRegisterId!.isNotEmpty) final text = (widget.selectedRegisterId != null && widget.selectedRegisterId!.isNotEmpty)
? (selected.name.isNotEmpty ? selected.name : widget.hintText) ? (selected.name.isNotEmpty
? (currencyText.isNotEmpty ? '${selected.name} - $currencyText' : selected.name)
: widget.hintText)
: widget.hintText; : widget.hintText;
return InkWell( return InkWell(
@ -203,6 +260,7 @@ class _CashRegisterPickerBottomSheet extends StatelessWidget {
final bool hasSearched; final bool hasSearched;
final ValueChanged<String> onSearchChanged; final ValueChanged<String> onSearchChanged;
final ValueChanged<CashRegisterOption?> onSelected; final ValueChanged<CashRegisterOption?> onSelected;
final String Function(int?)? currencyLabelBuilder;
const _CashRegisterPickerBottomSheet({ const _CashRegisterPickerBottomSheet({
required this.label, required this.label,
@ -213,6 +271,7 @@ class _CashRegisterPickerBottomSheet extends StatelessWidget {
required this.isSearching, required this.isSearching,
required this.hasSearched, required this.hasSearched,
required this.onSearchChanged, required this.onSearchChanged,
required this.currencyLabelBuilder,
required this.onSelected, required this.onSelected,
}); });
@ -270,12 +329,30 @@ class _CashRegisterPickerBottomSheet extends StatelessWidget {
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final it = items[index]; final it = items[index];
final currencyText = (it.currencyId != null) ? (currencyLabelBuilder?.call(it.currencyId) ?? '') : '';
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: colorScheme.primaryContainer, backgroundColor: colorScheme.primaryContainer,
child: Icon(Icons.point_of_sale, color: colorScheme.onPrimaryContainer), child: Icon(Icons.point_of_sale, color: colorScheme.onPrimaryContainer),
), ),
title: Text(it.name), title: Text(it.name),
trailing: currencyText.isNotEmpty
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
currencyText,
style: TextStyle(
color: colorScheme.primary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
)
: null,
onTap: () => onSelected(it), onTap: () => onSelected(it),
); );
}, },

View file

@ -1,11 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../services/petty_cash_service.dart'; import '../../services/petty_cash_service.dart';
import '../../core/api_client.dart';
import '../../services/currency_service.dart';
class PettyCashOption { class PettyCashOption {
final String id; final String id;
final String name; final String name;
const PettyCashOption(this.id, this.name); final int? currencyId;
const PettyCashOption(this.id, this.name, {this.currencyId});
} }
class PettyCashComboboxWidget extends StatefulWidget { class PettyCashComboboxWidget extends StatefulWidget {
@ -15,6 +18,7 @@ class PettyCashComboboxWidget extends StatefulWidget {
final String label; final String label;
final String hintText; final String hintText;
final bool isRequired; final bool isRequired;
final int? filterCurrencyId;
const PettyCashComboboxWidget({ const PettyCashComboboxWidget({
super.key, super.key,
@ -24,6 +28,7 @@ class PettyCashComboboxWidget extends StatefulWidget {
this.label = 'تنخواه‌گردان', this.label = 'تنخواه‌گردان',
this.hintText = 'جست‌وجو و انتخاب تنخواه‌گردان', this.hintText = 'جست‌وجو و انتخاب تنخواه‌گردان',
this.isRequired = false, this.isRequired = false,
this.filterCurrencyId,
}); });
@override @override
@ -42,13 +47,24 @@ class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
bool _isLoading = false; bool _isLoading = false;
bool _isSearching = false; bool _isSearching = false;
bool _hasSearched = false; bool _hasSearched = false;
final CurrencyService _currencyService = CurrencyService(ApiClient());
Map<int, Map<String, dynamic>> _currencyById = <int, Map<String, dynamic>>{};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadCurrencies();
_load(); _load();
} }
@override
void didUpdateWidget(covariant PettyCashComboboxWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filterCurrencyId != widget.filterCurrencyId) {
_performSearch(_latestQuery);
}
}
@override @override
void dispose() { void dispose() {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
@ -60,6 +76,35 @@ class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
await _performSearch(''); await _performSearch('');
} }
Future<void> _loadCurrencies() async {
try {
final list = await _currencyService.listBusinessCurrencies(businessId: widget.businessId);
final map = <int, Map<String, dynamic>>{};
for (final m in list) {
final id = m['id'];
if (id is int) {
map[id] = m;
}
}
if (!mounted) return;
setState(() {
_currencyById = map;
});
} catch (_) {
// ignore errors
}
}
String _formatCurrencyLabel(int? currencyId) {
if (currencyId == null) return '';
final m = _currencyById[currencyId];
if (m == null) return '';
final code = (m['code'] ?? '').toString();
final title = (m['title'] ?? '').toString();
if (code.isNotEmpty && title.isNotEmpty) return '$code';
return code.isNotEmpty ? code : title;
}
void _onSearchChanged(String q) { void _onSearchChanged(String q) {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
@ -95,10 +140,18 @@ class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null) final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
? (res['data'] as Map)['items'] ? (res['data'] as Map)['items']
: res['items']; : res['items'];
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) { var items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
final m = Map<String, dynamic>.from(e as Map); final m = Map<String, dynamic>.from(e as Map);
return PettyCashOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص')); final currencyId = (m['currency_id'] ?? m['currencyId']);
return PettyCashOption(
'${m['id']}',
(m['name']?.toString() ?? 'نامشخص'),
currencyId: currencyId is int ? currencyId : int.tryParse('${currencyId ?? ''}'),
);
}).toList(); }).toList();
if (widget.filterCurrencyId != null) {
items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList();
}
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_items = items; _items = items;
@ -143,6 +196,7 @@ class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
isSearching: _isSearching, isSearching: _isSearching,
hasSearched: _hasSearched, hasSearched: _hasSearched,
onSearchChanged: _onSearchChanged, onSearchChanged: _onSearchChanged,
currencyLabelBuilder: _formatCurrencyLabel,
onSelected: (opt) { onSelected: (opt) {
widget.onChanged(opt); widget.onChanged(opt);
Navigator.pop(context); Navigator.pop(context);
@ -160,8 +214,11 @@ class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
(e) => e.id == widget.selectedPettyCashId, (e) => e.id == widget.selectedPettyCashId,
orElse: () => const PettyCashOption('', ''), orElse: () => const PettyCashOption('', ''),
); );
final currencyText = _formatCurrencyLabel(selected.currencyId);
final text = (widget.selectedPettyCashId != null && widget.selectedPettyCashId!.isNotEmpty) final text = (widget.selectedPettyCashId != null && widget.selectedPettyCashId!.isNotEmpty)
? (selected.name.isNotEmpty ? selected.name : widget.hintText) ? (selected.name.isNotEmpty
? (currencyText.isNotEmpty ? '${selected.name} - $currencyText' : selected.name)
: widget.hintText)
: widget.hintText; : widget.hintText;
return InkWell( return InkWell(
@ -203,6 +260,7 @@ class _PettyCashPickerBottomSheet extends StatelessWidget {
final bool hasSearched; final bool hasSearched;
final ValueChanged<String> onSearchChanged; final ValueChanged<String> onSearchChanged;
final ValueChanged<PettyCashOption?> onSelected; final ValueChanged<PettyCashOption?> onSelected;
final String Function(int?)? currencyLabelBuilder;
const _PettyCashPickerBottomSheet({ const _PettyCashPickerBottomSheet({
required this.label, required this.label,
@ -213,6 +271,7 @@ class _PettyCashPickerBottomSheet extends StatelessWidget {
required this.isSearching, required this.isSearching,
required this.hasSearched, required this.hasSearched,
required this.onSearchChanged, required this.onSearchChanged,
required this.currencyLabelBuilder,
required this.onSelected, required this.onSelected,
}); });
@ -270,12 +329,30 @@ class _PettyCashPickerBottomSheet extends StatelessWidget {
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final it = items[index]; final it = items[index];
final currencyText = (it.currencyId != null) ? (currencyLabelBuilder?.call(it.currencyId) ?? '') : '';
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: colorScheme.primaryContainer, backgroundColor: colorScheme.primaryContainer,
child: Icon(Icons.wallet, color: colorScheme.onPrimaryContainer), child: Icon(Icons.wallet, color: colorScheme.onPrimaryContainer),
), ),
title: Text(it.name), title: Text(it.name),
trailing: currencyText.isNotEmpty
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
currencyText,
style: TextStyle(
color: colorScheme.primary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
)
: null,
onTap: () => onSelected(it), onTap: () => onSelected(it),
); );
}, },

View file

@ -62,6 +62,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
bool _commissionExcludeAdditionsDeductions = false; bool _commissionExcludeAdditionsDeductions = false;
bool _commissionPostInInvoiceDocument = false; bool _commissionPostInInvoiceDocument = false;
// ignore: unused_field
PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility) PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility)
final Set<PersonType> _selectedPersonTypes = <PersonType>{}; final Set<PersonType> _selectedPersonTypes = <PersonType>{};
bool _isActive = true; bool _isActive = true;
@ -588,15 +589,6 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
); );
} }
Widget _buildSectionHeader(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
);
}
Widget _buildBasicInfoFields(AppLocalizations t) { Widget _buildBasicInfoFields(AppLocalizations t) {
return Column( return Column(

View file

@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
class TransferDetailsDialog extends StatelessWidget {
final Map<String, dynamic> document;
const TransferDetailsDialog({super.key, required this.document});
@override
Widget build(BuildContext context) {
final lines = List<Map<String, dynamic>>.from(document['account_lines'] as List? ?? const []);
final code = document['code'] as String? ?? '';
final date = document['document_date'] as String? ?? '';
final total = (document['total_amount'] as num?)?.toStringAsFixed(0) ?? '0';
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800, maxHeight: 600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
const Icon(Icons.swap_horiz),
const SizedBox(width: 8),
Expanded(child: Text('سند انتقال $code')),
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Chip(label: Text('تاریخ: $date')),
const SizedBox(width: 8),
Chip(label: Text('مبلغ کل: $total')),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.separated(
itemCount: lines.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final l = lines[i];
final name = (l['account_name'] as String?) ?? '';
final code = (l['account_code'] as String?) ?? '';
final side = (l['side'] as String?) ?? '';
final isCommission = (l['is_commission_line'] as bool?) ?? false;
final amount = (l['amount'] as num?)?.toStringAsFixed(0) ?? '';
return ListTile(
leading: Icon(isCommission ? Icons.receipt_long : Icons.account_balance_wallet),
title: Text(name),
subtitle: Text('کد: $code • سمت: ${isCommission ? 'کارمزد' : side}'),
trailing: Text(amount),
);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(12),
child: Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('بستن'),
),
),
)
],
),
),
);
}
}

View file

@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import '../../core/auth_store.dart';
class CheckFormPage extends StatelessWidget {
final int businessId;
final AuthStore authStore;
final int? checkId; // null => new, not null => edit
const CheckFormPage({
super.key,
required this.businessId,
required this.authStore,
this.checkId,
});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: SizedBox.expand(),
);
}
}