progress in c/i
This commit is contained in:
parent
1b6e2eb71c
commit
c88b1ccdd0
88
hesabixAPI/adapters/api/v1/expense_income.py
Normal file
88
hesabixAPI/adapters/api/v1/expense_income.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
341
hesabixAPI/adapters/api/v1/transfers.py
Normal file
341
hesabixAPI/adapters/api/v1/transfers.py
Normal 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
398
hesabixAPI/app/services/expense_income_service.py
Normal file
398
hesabixAPI/app/services/expense_income_service.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
680
hesabixAPI/app/services/transfer_service.py
Normal file
680
hesabixAPI/app/services/transfer_service.py
Normal 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(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!,
|
||||||
|
|
|
||||||
48
hesabixUI/hesabix_ui/lib/models/transfer_document.dart
Normal file
48
hesabixUI/hesabix_ui/lib/models/transfer_document.dart
Normal 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 => 'انتقال';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
431
hesabixUI/hesabix_ui/lib/pages/business/expense_income_page.dart
Normal file
431
hesabixUI/hesabix_ui/lib/pages/business/expense_income_page.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
145
hesabixUI/hesabix_ui/lib/services/transfer_service.dart
Normal file
145
hesabixUI/hesabix_ui/lib/services/transfer_service.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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('بستن'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in a new issue