diff --git a/hesabixAPI/adapters/api/v1/expense_income.py b/hesabixAPI/adapters/api/v1/expense_income.py
new file mode 100644
index 0000000..b9244d3
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/expense_income.py
@@ -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")
+
+
diff --git a/hesabixAPI/adapters/api/v1/transfers.py b/hesabixAPI/adapters/api/v1/transfers.py
new file mode 100644
index 0000000..3909fd4
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/transfers.py
@@ -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'
{escape(h)} | ' for h in headers)
+ rows_html = []
+ for it in items:
+ row_cells = []
+ for k in keys:
+ v = it.get(k, "")
+ row_cells.append(f'{escape(str(v))} | ')
+ rows_html.append(f'{"".join(row_cells)}
')
+
+ now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
+ html = f"""
+
+
+
+
+ لیست انتقالها
+
+
+
+
+
+ {header_html}
+
+ {''.join(rows_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",
+ },
+ )
+
+
diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py
index 8080bca..b70cb33 100644
--- a/hesabixAPI/app/main.py
+++ b/hesabixAPI/app/main.py
@@ -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
diff --git a/hesabixAPI/app/services/expense_income_service.py b/hesabixAPI/app/services/expense_income_service.py
new file mode 100644
index 0000000..f61f2e3
--- /dev/null
+++ b/hesabixAPI/app/services/expense_income_service.py
@@ -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-
+ 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,
+ }
+
+
diff --git a/hesabixAPI/app/services/transfer_service.py b/hesabixAPI/app/services/transfer_service.py
new file mode 100644
index 0000000..27b1314
--- /dev/null
+++ b/hesabixAPI/app/services/transfer_service.py
@@ -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(),
+ }
+
+
diff --git a/hesabixUI/hesabix_ui/android/settings.gradle.kts b/hesabixUI/hesabix_ui/android/settings.gradle.kts
index fb605bc..173d3ef 100644
--- a/hesabixUI/hesabix_ui/android/settings.gradle.kts
+++ b/hesabixUI/hesabix_ui/android/settings.gradle.kts
@@ -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
}
diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart
index c09279e..eea3d9e 100644
--- a/hesabixUI/hesabix_ui/lib/main.dart
+++ b/hesabixUI/hesabix_ui/lib/main.dart
@@ -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 {
),
],
),
- 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 {
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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 {
),
// 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 {
},
),
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 {
},
),
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 {
},
),
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 {
},
),
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!,
diff --git a/hesabixUI/hesabix_ui/lib/models/transfer_document.dart b/hesabixUI/hesabix_ui/lib/models/transfer_document.dart
new file mode 100644
index 0000000..166e909
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/models/transfer_document.dart
@@ -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 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 => 'انتقال';
+}
+
+
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/expense_income_dialog.dart b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_dialog.dart
new file mode 100644
index 0000000..20b378f
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_dialog.dart
@@ -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? 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 createState() => _ExpenseIncomeDialogState();
+}
+
+class _ExpenseIncomeDialogState extends State {
+ final _formKey = GlobalKey();
+ String _docType = 'expense';
+ DateTime _docDate = DateTime.now();
+ int? _currencyId;
+ final _descCtrl = TextEditingController();
+
+ final List<_ItemLine> _items = <_ItemLine>[];
+ final List _transactions = [];
+
+ @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(0, (p, e) => p + e.amount);
+ final sumTx = _transactions.fold(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(
+ 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(0, (p, e) => p + e.amount);
+ final sumTx = _transactions.fold(0, (p, e) => p + (e.amount.toDouble()));
+ return sumItems == sumTx;
+ }
+
+ Future _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> 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,
+ );
+}
+
+
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart
new file mode 100644
index 0000000..e53a19d
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart
@@ -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 createState() => _ExpenseIncomeListPageState();
+}
+
+class _ExpenseIncomeListPageState extends State {
+ int _tabIndex = 0; // 0 expense, 1 income
+ final List