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.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.transfers import router as transfers_router
|
||||
from adapters.api.v1.fiscal_years import router as fiscal_years_router
|
||||
from app.core.i18n import negotiate_locale, Translator
|
||||
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_types_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)
|
||||
|
||||
# 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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import 'pages/business/petty_cash_page.dart';
|
|||
import 'pages/business/checks_page.dart';
|
||||
import 'pages/business/check_form_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/error_404_page.dart';
|
||||
import 'core/locale_controller.dart';
|
||||
|
|
@ -489,10 +490,8 @@ class _MyAppState extends State<MyApp> {
|
|||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/business/:business_id',
|
||||
name: 'business_shell',
|
||||
builder: (context, state) {
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
|
|
@ -500,36 +499,25 @@ class _MyAppState extends State<MyApp> {
|
|||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: const SizedBox.shrink(), // Will be replaced by child routes
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'dashboard',
|
||||
path: '/business/:business_id/dashboard',
|
||||
name: 'business_dashboard',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: BusinessDashboardPage(businessId: businessId),
|
||||
);
|
||||
},
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: BusinessDashboardPage(
|
||||
businessId: int.parse(state.pathParameters['business_id']!),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'users-permissions',
|
||||
path: '/business/:business_id/users-permissions',
|
||||
name: 'business_users_permissions',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: UsersPermissionsPage(
|
||||
businessId: businessId.toString(),
|
||||
authStore: _authStore!,
|
||||
|
|
@ -539,31 +527,20 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'chart-of-accounts',
|
||||
path: '/business/:business_id/chart-of-accounts',
|
||||
name: 'business_chart_of_accounts',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: AccountsPage(businessId: businessId),
|
||||
);
|
||||
},
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: AccountsPage(
|
||||
businessId: int.parse(state.pathParameters['business_id']!),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'accounts',
|
||||
path: '/business/:business_id/accounts',
|
||||
name: 'business_accounts',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: BankAccountsPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -572,16 +549,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'petty-cash',
|
||||
path: '/business/:business_id/petty-cash',
|
||||
name: 'business_petty_cash',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: PettyCashPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -590,16 +562,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'cash-box',
|
||||
path: '/business/:business_id/cash-box',
|
||||
name: 'business_cash_box',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: CashRegistersPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -608,16 +575,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'wallet',
|
||||
path: '/business/:business_id/wallet',
|
||||
name: 'business_wallet',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: WalletPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -626,16 +588,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'invoice',
|
||||
path: '/business/:business_id/invoice',
|
||||
name: 'business_invoice',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: InvoicePage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -644,16 +601,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'invoice/new',
|
||||
path: '/business/:business_id/invoice/new',
|
||||
name: 'business_new_invoice',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: NewInvoicePage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -663,16 +615,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'reports',
|
||||
path: '/business/:business_id/reports',
|
||||
name: 'business_reports',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: ReportsPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -681,20 +628,17 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'settings',
|
||||
path: '/business/:business_id/settings',
|
||||
name: 'business_settings',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
// گارد دسترسی: فقط کاربرانی که دسترسی join دارند
|
||||
if (!_authStore!.hasBusinessPermission('settings', 'join')) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
return NoTransitionPage(
|
||||
child: PermissionGuard.buildAccessDeniedPage(),
|
||||
);
|
||||
}
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: SettingsPage(
|
||||
businessId: businessId,
|
||||
localeController: controller,
|
||||
|
|
@ -705,16 +649,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'product-attributes',
|
||||
path: '/business/:business_id/product-attributes',
|
||||
name: 'business_product_attributes',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: ProductAttributesPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -723,16 +662,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'products',
|
||||
path: '/business/:business_id/products',
|
||||
name: 'business_products',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: ProductsPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -741,16 +675,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'price-lists',
|
||||
path: '/business/:business_id/price-lists',
|
||||
name: 'business_price_lists',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: PriceListsPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -759,17 +688,12 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'price-lists/:price_list_id/items',
|
||||
path: '/business/:business_id/price-lists/:price_list_id/items',
|
||||
name: 'business_price_list_items',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
final priceListId = int.parse(state.pathParameters['price_list_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: PriceListItemsPage(
|
||||
businessId: businessId,
|
||||
priceListId: priceListId,
|
||||
|
|
@ -779,16 +703,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'persons',
|
||||
path: '/business/:business_id/persons',
|
||||
name: 'business_persons',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: PersonsPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -798,16 +717,11 @@ class _MyAppState extends State<MyApp> {
|
|||
),
|
||||
// Receipts & Payments: list with data table
|
||||
GoRoute(
|
||||
path: 'receipts-payments',
|
||||
path: '/business/:business_id/receipts-payments',
|
||||
name: 'business_receipts_payments',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: ReceiptsPaymentsListPage(
|
||||
businessId: businessId,
|
||||
calendarController: _calendarController!,
|
||||
|
|
@ -818,16 +732,26 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'transfers',
|
||||
name: 'business_transfers',
|
||||
builder: (context, state) {
|
||||
path: '/business/:business_id/expense-income',
|
||||
name: 'business_expense_income',
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: ExpenseIncomeListPage(
|
||||
businessId: businessId,
|
||||
calendarController: _calendarController!,
|
||||
authStore: _authStore!,
|
||||
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(
|
||||
businessId: businessId,
|
||||
calendarController: _calendarController!,
|
||||
|
|
@ -838,16 +762,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'checks',
|
||||
path: '/business/:business_id/checks',
|
||||
name: 'business_checks',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: ChecksPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -856,16 +775,11 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'checks/new',
|
||||
path: '/business/:business_id/checks/new',
|
||||
name: 'business_new_check',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: CheckFormPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -875,17 +789,12 @@ class _MyAppState extends State<MyApp> {
|
|||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'checks/:check_id/edit',
|
||||
path: '/business/:business_id/checks/:check_id/edit',
|
||||
name: 'business_edit_check',
|
||||
builder: (context, state) {
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
final checkId = int.tryParse(state.pathParameters['check_id'] ?? '0');
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
return NoTransitionPage(
|
||||
child: CheckFormPage(
|
||||
businessId: businessId,
|
||||
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:hesabix_ui/l10n/app_localizations.dart';
|
||||
import '../../core/auth_store.dart';
|
||||
import '../../core/calendar_controller.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 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import '../../widgets/transfer/transfer_details_dialog.dart';
|
||||
|
||||
class TransfersPage extends StatefulWidget {
|
||||
final int businessId;
|
||||
|
|
@ -24,79 +32,44 @@ class TransfersPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _TransfersPageState extends State<TransfersPage> {
|
||||
Future<void> _showAddTransferDialog() async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => TransferFormDialog(
|
||||
businessId: widget.businessId,
|
||||
calendarController: widget.calendarController,
|
||||
onSuccess: () {
|
||||
// TODO: بروزرسانی لیست انتقالات
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('انتقال با موفقیت ثبت شد'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
// بروزرسانی صفحه در صورت نیاز
|
||||
setState(() {});
|
||||
// کنترل جدول برای دسترسی به refresh
|
||||
final GlobalKey _tableKey = GlobalKey();
|
||||
DateTime? _fromDate;
|
||||
DateTime? _toDate;
|
||||
|
||||
void _refreshData() {
|
||||
final state = _tableKey.currentState;
|
||||
if (state != null) {
|
||||
try {
|
||||
// ignore: avoid_dynamic_calls
|
||||
(state as dynamic).refresh();
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(t.transfers),
|
||||
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(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.swap_horiz,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'صفحه لیست انتقال',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
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),
|
||||
_buildHeader(t),
|
||||
_buildFilters(t),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: DataTableWidget<TransferDocument>(
|
||||
key: _tableKey,
|
||||
config: _buildTableConfig(t),
|
||||
fromJson: (json) => TransferDocument.fromJson(json),
|
||||
calendarController: widget.calendarController,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -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 '../core/fiscal_year_controller.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
|
||||
class FiscalYearSwitcher extends StatelessWidget {
|
||||
final FiscalYearController controller;
|
||||
|
|
@ -11,7 +10,6 @@ class FiscalYearSwitcher extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final int? selectedId = controller.fiscalYearId ?? _currentDefaultId();
|
||||
|
||||
return DropdownButtonHideUnderline(
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ import 'dart:async';
|
|||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/bank_account_service.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import '../../services/currency_service.dart';
|
||||
|
||||
class BankAccountOption {
|
||||
final String id;
|
||||
final String name;
|
||||
const BankAccountOption(this.id, this.name);
|
||||
final int? currencyId;
|
||||
const BankAccountOption(this.id, this.name, {this.currencyId});
|
||||
}
|
||||
|
||||
class BankAccountComboboxWidget extends StatefulWidget {
|
||||
|
|
@ -16,6 +19,7 @@ class BankAccountComboboxWidget extends StatefulWidget {
|
|||
final String label;
|
||||
final String hintText;
|
||||
final bool isRequired;
|
||||
final int? filterCurrencyId;
|
||||
|
||||
const BankAccountComboboxWidget({
|
||||
super.key,
|
||||
|
|
@ -25,6 +29,7 @@ class BankAccountComboboxWidget extends StatefulWidget {
|
|||
this.label = 'بانک',
|
||||
this.hintText = 'جستوجو و انتخاب بانک',
|
||||
this.isRequired = false,
|
||||
this.filterCurrencyId,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -38,6 +43,8 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
|
|||
int _seq = 0;
|
||||
String _latestQuery = '';
|
||||
void Function(void Function())? _setModalState;
|
||||
final CurrencyService _currencyService = CurrencyService(ApiClient());
|
||||
Map<int, Map<String, dynamic>> _currencyById = <int, Map<String, dynamic>>{};
|
||||
|
||||
List<BankAccountOption> _items = <BankAccountOption>[];
|
||||
bool _isLoading = false;
|
||||
|
|
@ -47,9 +54,19 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrencies();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BankAccountComboboxWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filterCurrencyId != widget.filterCurrencyId) {
|
||||
// بازخوانی با فیلتر جدید ارز
|
||||
_performSearch(_latestQuery);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
|
|
@ -61,6 +78,35 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
|
|||
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) {
|
||||
_debounceTimer?.cancel();
|
||||
_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)
|
||||
? (res['data'] as Map)['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 id = m['id']?.toString();
|
||||
final name = m['name']?.toString() ?? 'نامشخص';
|
||||
log('Bank account item: id=$id, name=$name');
|
||||
return BankAccountOption(id ?? '', name);
|
||||
final currencyId = (m['currency_id'] ?? m['currencyId']);
|
||||
log('Bank account item: id=$id, name=$name, currencyId=$currencyId');
|
||||
return BankAccountOption(id ?? '', name, currencyId: currencyId is int ? currencyId : int.tryParse('${currencyId ?? ''}'));
|
||||
}).toList();
|
||||
// Filter by currency if requested
|
||||
if (widget.filterCurrencyId != null) {
|
||||
items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList();
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
|
|
@ -149,6 +200,7 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
|
|||
isSearching: _isSearching,
|
||||
hasSearched: _hasSearched,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
currencyLabelBuilder: _formatCurrencyLabel,
|
||||
onSelected: (opt) {
|
||||
widget.onChanged(opt);
|
||||
Navigator.pop(context);
|
||||
|
|
@ -166,8 +218,11 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
|
|||
(e) => e.id == widget.selectedAccountId,
|
||||
orElse: () => const BankAccountOption('', ''),
|
||||
);
|
||||
final currencyText = _formatCurrencyLabel(selected.currencyId);
|
||||
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;
|
||||
|
||||
return InkWell(
|
||||
|
|
@ -209,6 +264,7 @@ class _BankPickerBottomSheet extends StatelessWidget {
|
|||
final bool hasSearched;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<BankAccountOption?> onSelected;
|
||||
final String Function(int?)? currencyLabelBuilder;
|
||||
|
||||
const _BankPickerBottomSheet({
|
||||
required this.label,
|
||||
|
|
@ -219,6 +275,7 @@ class _BankPickerBottomSheet extends StatelessWidget {
|
|||
required this.isSearching,
|
||||
required this.hasSearched,
|
||||
required this.onSearchChanged,
|
||||
required this.currencyLabelBuilder,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
|
|
@ -272,16 +329,34 @@ class _BankPickerBottomSheet extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final it = items[index];
|
||||
final currencyText = (it.currencyId != null) ? (currencyLabelBuilder?.call(it.currencyId) ?? '') : '';
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(Icons.account_balance, color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
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),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/cash_register_service.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import '../../services/currency_service.dart';
|
||||
|
||||
class CashRegisterOption {
|
||||
final String id;
|
||||
final String name;
|
||||
const CashRegisterOption(this.id, this.name);
|
||||
final int? currencyId;
|
||||
const CashRegisterOption(this.id, this.name, {this.currencyId});
|
||||
}
|
||||
|
||||
class CashRegisterComboboxWidget extends StatefulWidget {
|
||||
|
|
@ -15,6 +18,7 @@ class CashRegisterComboboxWidget extends StatefulWidget {
|
|||
final String label;
|
||||
final String hintText;
|
||||
final bool isRequired;
|
||||
final int? filterCurrencyId;
|
||||
|
||||
const CashRegisterComboboxWidget({
|
||||
super.key,
|
||||
|
|
@ -24,6 +28,7 @@ class CashRegisterComboboxWidget extends StatefulWidget {
|
|||
this.label = 'صندوق',
|
||||
this.hintText = 'جستوجو و انتخاب صندوق',
|
||||
this.isRequired = false,
|
||||
this.filterCurrencyId,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -42,13 +47,24 @@ class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget>
|
|||
bool _isLoading = false;
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
final CurrencyService _currencyService = CurrencyService(ApiClient());
|
||||
Map<int, Map<String, dynamic>> _currencyById = <int, Map<String, dynamic>>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrencies();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant CashRegisterComboboxWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filterCurrencyId != widget.filterCurrencyId) {
|
||||
_performSearch(_latestQuery);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
|
|
@ -60,6 +76,35 @@ class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget>
|
|||
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) {
|
||||
_debounceTimer?.cancel();
|
||||
_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)
|
||||
? (res['data'] as Map)['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);
|
||||
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();
|
||||
if (widget.filterCurrencyId != null) {
|
||||
items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList();
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
|
|
@ -143,6 +196,7 @@ class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget>
|
|||
isSearching: _isSearching,
|
||||
hasSearched: _hasSearched,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
currencyLabelBuilder: _formatCurrencyLabel,
|
||||
onSelected: (opt) {
|
||||
widget.onChanged(opt);
|
||||
Navigator.pop(context);
|
||||
|
|
@ -160,8 +214,11 @@ class _CashRegisterComboboxWidgetState extends State<CashRegisterComboboxWidget>
|
|||
(e) => e.id == widget.selectedRegisterId,
|
||||
orElse: () => const CashRegisterOption('', ''),
|
||||
);
|
||||
final currencyText = _formatCurrencyLabel(selected.currencyId);
|
||||
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;
|
||||
|
||||
return InkWell(
|
||||
|
|
@ -203,6 +260,7 @@ class _CashRegisterPickerBottomSheet extends StatelessWidget {
|
|||
final bool hasSearched;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<CashRegisterOption?> onSelected;
|
||||
final String Function(int?)? currencyLabelBuilder;
|
||||
|
||||
const _CashRegisterPickerBottomSheet({
|
||||
required this.label,
|
||||
|
|
@ -213,6 +271,7 @@ class _CashRegisterPickerBottomSheet extends StatelessWidget {
|
|||
required this.isSearching,
|
||||
required this.hasSearched,
|
||||
required this.onSearchChanged,
|
||||
required this.currencyLabelBuilder,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
|
|
@ -270,12 +329,30 @@ class _CashRegisterPickerBottomSheet extends StatelessWidget {
|
|||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final it = items[index];
|
||||
final currencyText = (it.currencyId != null) ? (currencyLabelBuilder?.call(it.currencyId) ?? '') : '';
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(Icons.point_of_sale, color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
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),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/petty_cash_service.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import '../../services/currency_service.dart';
|
||||
|
||||
class PettyCashOption {
|
||||
final String id;
|
||||
final String name;
|
||||
const PettyCashOption(this.id, this.name);
|
||||
final int? currencyId;
|
||||
const PettyCashOption(this.id, this.name, {this.currencyId});
|
||||
}
|
||||
|
||||
class PettyCashComboboxWidget extends StatefulWidget {
|
||||
|
|
@ -15,6 +18,7 @@ class PettyCashComboboxWidget extends StatefulWidget {
|
|||
final String label;
|
||||
final String hintText;
|
||||
final bool isRequired;
|
||||
final int? filterCurrencyId;
|
||||
|
||||
const PettyCashComboboxWidget({
|
||||
super.key,
|
||||
|
|
@ -24,6 +28,7 @@ class PettyCashComboboxWidget extends StatefulWidget {
|
|||
this.label = 'تنخواهگردان',
|
||||
this.hintText = 'جستوجو و انتخاب تنخواهگردان',
|
||||
this.isRequired = false,
|
||||
this.filterCurrencyId,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -42,13 +47,24 @@ class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
|
|||
bool _isLoading = false;
|
||||
bool _isSearching = false;
|
||||
bool _hasSearched = false;
|
||||
final CurrencyService _currencyService = CurrencyService(ApiClient());
|
||||
Map<int, Map<String, dynamic>> _currencyById = <int, Map<String, dynamic>>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCurrencies();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PettyCashComboboxWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filterCurrencyId != widget.filterCurrencyId) {
|
||||
_performSearch(_latestQuery);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
|
|
@ -60,6 +76,35 @@ class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
|
|||
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) {
|
||||
_debounceTimer?.cancel();
|
||||
_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)
|
||||
? (res['data'] as Map)['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);
|
||||
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();
|
||||
if (widget.filterCurrencyId != null) {
|
||||
items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList();
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
|
|
@ -143,6 +196,7 @@ class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
|
|||
isSearching: _isSearching,
|
||||
hasSearched: _hasSearched,
|
||||
onSearchChanged: _onSearchChanged,
|
||||
currencyLabelBuilder: _formatCurrencyLabel,
|
||||
onSelected: (opt) {
|
||||
widget.onChanged(opt);
|
||||
Navigator.pop(context);
|
||||
|
|
@ -160,8 +214,11 @@ class _PettyCashComboboxWidgetState extends State<PettyCashComboboxWidget> {
|
|||
(e) => e.id == widget.selectedPettyCashId,
|
||||
orElse: () => const PettyCashOption('', ''),
|
||||
);
|
||||
final currencyText = _formatCurrencyLabel(selected.currencyId);
|
||||
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;
|
||||
|
||||
return InkWell(
|
||||
|
|
@ -203,6 +260,7 @@ class _PettyCashPickerBottomSheet extends StatelessWidget {
|
|||
final bool hasSearched;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<PettyCashOption?> onSelected;
|
||||
final String Function(int?)? currencyLabelBuilder;
|
||||
|
||||
const _PettyCashPickerBottomSheet({
|
||||
required this.label,
|
||||
|
|
@ -213,6 +271,7 @@ class _PettyCashPickerBottomSheet extends StatelessWidget {
|
|||
required this.isSearching,
|
||||
required this.hasSearched,
|
||||
required this.onSearchChanged,
|
||||
required this.currencyLabelBuilder,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
|
|
@ -270,12 +329,30 @@ class _PettyCashPickerBottomSheet extends StatelessWidget {
|
|||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final it = items[index];
|
||||
final currencyText = (it.currencyId != null) ? (currencyLabelBuilder?.call(it.currencyId) ?? '') : '';
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(Icons.wallet, color: colorScheme.onPrimaryContainer),
|
||||
),
|
||||
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),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
|
|||
bool _commissionExcludeAdditionsDeductions = false;
|
||||
bool _commissionPostInInvoiceDocument = false;
|
||||
|
||||
// ignore: unused_field
|
||||
PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility)
|
||||
final Set<PersonType> _selectedPersonTypes = <PersonType>{};
|
||||
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) {
|
||||
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