progress in cardex
This commit is contained in:
parent
b7a860e3e5
commit
8f4248e83f
|
|
@ -10,6 +10,12 @@ from adapters.api.v1.schemas import QueryInfo
|
||||||
from adapters.api.v1.schema_models.check import (
|
from adapters.api.v1.schema_models.check import (
|
||||||
CheckCreateRequest,
|
CheckCreateRequest,
|
||||||
CheckUpdateRequest,
|
CheckUpdateRequest,
|
||||||
|
CheckEndorseRequest,
|
||||||
|
CheckClearRequest,
|
||||||
|
CheckReturnRequest,
|
||||||
|
CheckBounceRequest,
|
||||||
|
CheckPayRequest,
|
||||||
|
CheckDepositRequest,
|
||||||
)
|
)
|
||||||
from app.services.check_service import (
|
from app.services.check_service import (
|
||||||
create_check,
|
create_check,
|
||||||
|
|
@ -17,6 +23,12 @@ from app.services.check_service import (
|
||||||
delete_check,
|
delete_check,
|
||||||
get_check_by_id,
|
get_check_by_id,
|
||||||
list_checks,
|
list_checks,
|
||||||
|
endorse_check,
|
||||||
|
clear_check,
|
||||||
|
return_check,
|
||||||
|
bounce_check,
|
||||||
|
pay_check,
|
||||||
|
deposit_check,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -82,8 +94,183 @@ async def create_check_endpoint(
|
||||||
_: None = Depends(require_business_management_dep),
|
_: None = Depends(require_business_management_dep),
|
||||||
):
|
):
|
||||||
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
||||||
created = create_check(db, business_id, payload)
|
# اگر کاربر درخواست ثبت سند همزمان داد، باید دسترسی نوشتن حسابداری داشته باشد
|
||||||
|
try:
|
||||||
|
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
|
||||||
|
except Exception:
|
||||||
|
# در صورت هرگونه خطای غیرمنتظره در بررسی، اجازه ادامه نمیدهیم
|
||||||
|
raise
|
||||||
|
created = create_check(db, business_id, ctx.get_user_id(), payload)
|
||||||
return success_response(data=format_datetime_fields(created, request), request=request, message="CHECK_CREATED")
|
return success_response(data=format_datetime_fields(created, request), request=request, message="CHECK_CREATED")
|
||||||
|
@router.post(
|
||||||
|
"/checks/{check_id}/actions/endorse",
|
||||||
|
summary="واگذاری چک دریافتی به شخص",
|
||||||
|
description="واگذاری چک دریافتی به شخص دیگر",
|
||||||
|
)
|
||||||
|
async def endorse_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
check_id: int,
|
||||||
|
body: CheckEndorseRequest = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
||||||
|
# access check
|
||||||
|
before = get_check_by_id(db, check_id)
|
||||||
|
if not before:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(before.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
|
||||||
|
result = endorse_check(db, check_id, ctx.get_user_id(), payload)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_ENDORSED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/checks/{check_id}/actions/clear",
|
||||||
|
summary="وصول/پاس چک",
|
||||||
|
description="انتقال حساب چک به بانک در زمان پاس/وصول",
|
||||||
|
)
|
||||||
|
async def clear_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
check_id: int,
|
||||||
|
body: CheckClearRequest = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
||||||
|
before = get_check_by_id(db, check_id)
|
||||||
|
if not before:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(before.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
|
||||||
|
result = clear_check(db, check_id, ctx.get_user_id(), payload)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_CLEARED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/checks/{check_id}/actions/return",
|
||||||
|
summary="عودت چک",
|
||||||
|
description="عودت چک به طرف مقابل",
|
||||||
|
)
|
||||||
|
async def return_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
check_id: int,
|
||||||
|
body: CheckReturnRequest = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
||||||
|
before = get_check_by_id(db, check_id)
|
||||||
|
if not before:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(before.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
|
||||||
|
result = return_check(db, check_id, ctx.get_user_id(), payload)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_RETURNED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/checks/{check_id}/actions/bounce",
|
||||||
|
summary="برگشت چک",
|
||||||
|
description="برگشت چک و ثبت هزینه احتمالی",
|
||||||
|
)
|
||||||
|
async def bounce_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
check_id: int,
|
||||||
|
body: CheckBounceRequest = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
||||||
|
before = get_check_by_id(db, check_id)
|
||||||
|
if not before:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(before.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
|
||||||
|
result = bounce_check(db, check_id, ctx.get_user_id(), payload)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_BOUNCED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/checks/{check_id}/actions/pay",
|
||||||
|
summary="پرداخت چک پرداختنی",
|
||||||
|
description="پاس چک پرداختنی از بانک",
|
||||||
|
)
|
||||||
|
async def pay_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
check_id: int,
|
||||||
|
body: CheckPayRequest = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
||||||
|
before = get_check_by_id(db, check_id)
|
||||||
|
if not before:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(before.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
|
||||||
|
result = pay_check(db, check_id, ctx.get_user_id(), payload)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_PAID")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/checks/{check_id}/actions/deposit",
|
||||||
|
summary="سپرده چک به بانک (اختیاری)",
|
||||||
|
description="انتقال به اسناد در جریان وصول",
|
||||||
|
)
|
||||||
|
async def deposit_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
check_id: int,
|
||||||
|
body: CheckDepositRequest = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
||||||
|
before = get_check_by_id(db, check_id)
|
||||||
|
if not before:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(before.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
|
||||||
|
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
|
||||||
|
result = deposit_check(db, check_id, ctx.get_user_id(), payload)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_DEPOSITED")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
|
||||||
285
hesabixAPI/adapters/api/v1/kardex.py
Normal file
285
hesabixAPI/adapters/api/v1/kardex.py
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
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.responses import success_response, format_datetime_fields
|
||||||
|
from app.core.permissions import require_business_access
|
||||||
|
from adapters.api.v1.schemas import QueryInfo
|
||||||
|
from app.services.kardex_service import list_kardex_lines
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/kardex", tags=["kardex"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/lines",
|
||||||
|
summary="لیست کاردکس (خطوط اسناد)",
|
||||||
|
description="دریافت خطوط اسناد مرتبط با انتخابهای چندگانه موجودیتها با فیلتر تاریخ",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def list_kardex_lines_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
query_info: QueryInfo,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Compose query dict from QueryInfo and additional parameters from body
|
||||||
|
query_dict: Dict[str, Any] = {
|
||||||
|
"take": query_info.take,
|
||||||
|
"skip": query_info.skip,
|
||||||
|
"sort_by": query_info.sort_by or "document_date",
|
||||||
|
"sort_desc": query_info.sort_desc,
|
||||||
|
"search": query_info.search,
|
||||||
|
"search_fields": query_info.search_fields,
|
||||||
|
"filters": query_info.filters,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Additional params from body (DataTable additionalParams)
|
||||||
|
try:
|
||||||
|
body_json = await request.json()
|
||||||
|
if isinstance(body_json, dict):
|
||||||
|
for key in (
|
||||||
|
"from_date",
|
||||||
|
"to_date",
|
||||||
|
"fiscal_year_id",
|
||||||
|
"person_ids",
|
||||||
|
"product_ids",
|
||||||
|
"bank_account_ids",
|
||||||
|
"cash_register_ids",
|
||||||
|
"petty_cash_ids",
|
||||||
|
"account_ids",
|
||||||
|
"check_ids",
|
||||||
|
"match_mode",
|
||||||
|
"result_scope",
|
||||||
|
):
|
||||||
|
if key in body_json and body_json.get(key) is not None:
|
||||||
|
query_dict[key] = body_json.get(key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = list_kardex_lines(db, business_id, query_dict)
|
||||||
|
|
||||||
|
# Format date fields in response items (document_date)
|
||||||
|
try:
|
||||||
|
items = result.get("items", [])
|
||||||
|
for item in items:
|
||||||
|
# Use format_datetime_fields for consistency
|
||||||
|
item.update(format_datetime_fields({"document_date": item.get("document_date")}, request))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return success_response(data=result, request=request, message="KARDEX_LINES")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/lines/export/excel",
|
||||||
|
summary="خروجی Excel کاردکس",
|
||||||
|
description="خروجی اکسل از لیست خطوط کاردکس با فیلترهای اعمالشده",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def export_kardex_excel_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from fastapi.responses import Response
|
||||||
|
import datetime
|
||||||
|
try:
|
||||||
|
max_export_records = 10000
|
||||||
|
take_value = min(int(body.get("take", 1000)), max_export_records)
|
||||||
|
except Exception:
|
||||||
|
take_value = 1000
|
||||||
|
|
||||||
|
query_dict: Dict[str, Any] = {
|
||||||
|
"take": take_value,
|
||||||
|
"skip": int(body.get("skip", 0)),
|
||||||
|
"sort_by": body.get("sort_by") or "document_date",
|
||||||
|
"sort_desc": bool(body.get("sort_desc", True)),
|
||||||
|
"search": body.get("search"),
|
||||||
|
"search_fields": body.get("search_fields"),
|
||||||
|
"filters": body.get("filters"),
|
||||||
|
"from_date": body.get("from_date"),
|
||||||
|
"to_date": body.get("to_date"),
|
||||||
|
"person_ids": body.get("person_ids"),
|
||||||
|
"product_ids": body.get("product_ids"),
|
||||||
|
"bank_account_ids": body.get("bank_account_ids"),
|
||||||
|
"cash_register_ids": body.get("cash_register_ids"),
|
||||||
|
"petty_cash_ids": body.get("petty_cash_ids"),
|
||||||
|
"account_ids": body.get("account_ids"),
|
||||||
|
"check_ids": body.get("check_ids"),
|
||||||
|
"match_mode": body.get("match_mode") or "any",
|
||||||
|
"result_scope": body.get("result_scope") or "lines_matching",
|
||||||
|
"include_running_balance": bool(body.get("include_running_balance", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = list_kardex_lines(db, business_id, query_dict)
|
||||||
|
items = result.get("items", [])
|
||||||
|
items = [format_datetime_fields(it, request) for it in items]
|
||||||
|
|
||||||
|
# Build simple Excel using openpyxl
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Kardex"
|
||||||
|
headers = [
|
||||||
|
"document_date", "document_code", "document_type", "description",
|
||||||
|
"debit", "credit", "quantity", "running_amount", "running_quantity",
|
||||||
|
]
|
||||||
|
ws.append(headers)
|
||||||
|
for it in items:
|
||||||
|
ws.append([
|
||||||
|
it.get("document_date"),
|
||||||
|
it.get("document_code"),
|
||||||
|
it.get("document_type"),
|
||||||
|
it.get("description"),
|
||||||
|
it.get("debit"),
|
||||||
|
it.get("credit"),
|
||||||
|
it.get("quantity"),
|
||||||
|
it.get("running_amount"),
|
||||||
|
it.get("running_quantity"),
|
||||||
|
])
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
content = buf.getvalue()
|
||||||
|
filename = f"kardex_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||||
|
|
||||||
|
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}/lines/export/pdf",
|
||||||
|
summary="خروجی PDF کاردکس",
|
||||||
|
description="خروجی PDF از لیست خطوط کاردکس با فیلترهای اعمالشده",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def export_kardex_pdf_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from fastapi.responses import Response
|
||||||
|
import datetime
|
||||||
|
from weasyprint import HTML, CSS
|
||||||
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
try:
|
||||||
|
max_export_records = 10000
|
||||||
|
take_value = min(int(body.get("take", 1000)), max_export_records)
|
||||||
|
except Exception:
|
||||||
|
take_value = 1000
|
||||||
|
|
||||||
|
query_dict: Dict[str, Any] = {
|
||||||
|
"take": take_value,
|
||||||
|
"skip": int(body.get("skip", 0)),
|
||||||
|
"sort_by": body.get("sort_by") or "document_date",
|
||||||
|
"sort_desc": bool(body.get("sort_desc", True)),
|
||||||
|
"search": body.get("search"),
|
||||||
|
"search_fields": body.get("search_fields"),
|
||||||
|
"filters": body.get("filters"),
|
||||||
|
"from_date": body.get("from_date"),
|
||||||
|
"to_date": body.get("to_date"),
|
||||||
|
"person_ids": body.get("person_ids"),
|
||||||
|
"product_ids": body.get("product_ids"),
|
||||||
|
"bank_account_ids": body.get("bank_account_ids"),
|
||||||
|
"cash_register_ids": body.get("cash_register_ids"),
|
||||||
|
"petty_cash_ids": body.get("petty_cash_ids"),
|
||||||
|
"account_ids": body.get("account_ids"),
|
||||||
|
"check_ids": body.get("check_ids"),
|
||||||
|
"match_mode": body.get("match_mode") or "any",
|
||||||
|
"result_scope": body.get("result_scope") or "lines_matching",
|
||||||
|
"include_running_balance": bool(body.get("include_running_balance", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = list_kardex_lines(db, business_id, query_dict)
|
||||||
|
items = result.get("items", [])
|
||||||
|
items = [format_datetime_fields(it, request) for it in items]
|
||||||
|
|
||||||
|
# Build simple HTML table
|
||||||
|
def cell(val: Any) -> str:
|
||||||
|
return escape(str(val)) if val is not None else ""
|
||||||
|
|
||||||
|
rows_html = "".join([
|
||||||
|
f"<tr>"
|
||||||
|
f"<td>{cell(it.get('document_date'))}</td>"
|
||||||
|
f"<td>{cell(it.get('document_code'))}</td>"
|
||||||
|
f"<td>{cell(it.get('document_type'))}</td>"
|
||||||
|
f"<td>{cell(it.get('description'))}</td>"
|
||||||
|
f"<td style='text-align:right'>{cell(it.get('debit'))}</td>"
|
||||||
|
f"<td style='text-align:right'>{cell(it.get('credit'))}</td>"
|
||||||
|
f"<td style='text-align:right'>{cell(it.get('quantity'))}</td>"
|
||||||
|
f"<td style='text-align:right'>{cell(it.get('running_amount'))}</td>"
|
||||||
|
f"<td style='text-align:right'>{cell(it.get('running_quantity'))}</td>"
|
||||||
|
f"</tr>"
|
||||||
|
for it in items
|
||||||
|
])
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8'/>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: sans-serif; }}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; }}
|
||||||
|
th, td {{ border: 1px solid #ddd; padding: 6px; font-size: 12px; }}
|
||||||
|
th {{ background: #f5f5f5; text-align: right; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h3>گزارش کاردکس</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>تاریخ سند</th>
|
||||||
|
<th>کد سند</th>
|
||||||
|
<th>نوع سند</th>
|
||||||
|
<th>شرح</th>
|
||||||
|
<th>بدهکار</th>
|
||||||
|
<th>بستانکار</th>
|
||||||
|
<th>تعداد</th>
|
||||||
|
<th>مانده مبلغ</th>
|
||||||
|
<th>مانده تعداد</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows_html}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
font_config = FontConfiguration()
|
||||||
|
pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 landscape; margin: 12mm; }")], font_config=font_config)
|
||||||
|
|
||||||
|
filename = f"kardex_{business_id}_{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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,6 +15,10 @@ class CheckCreateRequest(BaseModel):
|
||||||
branch_name: Optional[str] = Field(default=None, max_length=255)
|
branch_name: Optional[str] = Field(default=None, max_length=255)
|
||||||
amount: float = Field(..., gt=0)
|
amount: float = Field(..., gt=0)
|
||||||
currency_id: int = Field(..., ge=1)
|
currency_id: int = Field(..., ge=1)
|
||||||
|
# گزینههای حسابداری
|
||||||
|
auto_post: Optional[bool] = Field(default=False)
|
||||||
|
document_date: Optional[str] = None
|
||||||
|
document_description: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
@field_validator('sayad_code')
|
@field_validator('sayad_code')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -70,3 +74,51 @@ class CheckResponse(BaseModel):
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Action Schemas
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
class CheckEndorseRequest(BaseModel):
|
||||||
|
target_person_id: int = Field(..., ge=1)
|
||||||
|
document_date: Optional[str] = None
|
||||||
|
description: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
auto_post: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckClearRequest(BaseModel):
|
||||||
|
bank_account_id: int = Field(..., ge=1)
|
||||||
|
document_date: Optional[str] = None
|
||||||
|
description: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
auto_post: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckReturnRequest(BaseModel):
|
||||||
|
target_person_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
document_date: Optional[str] = None
|
||||||
|
description: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
auto_post: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckBounceRequest(BaseModel):
|
||||||
|
bank_account_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
expense_account_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
expense_amount: Optional[float] = Field(default=None, gt=0)
|
||||||
|
document_date: Optional[str] = None
|
||||||
|
description: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
auto_post: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckPayRequest(BaseModel):
|
||||||
|
bank_account_id: int = Field(..., ge=1)
|
||||||
|
document_date: Optional[str] = None
|
||||||
|
description: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
auto_post: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckDepositRequest(BaseModel):
|
||||||
|
bank_account_id: int = Field(..., ge=1)
|
||||||
|
document_date: Optional[str] = None
|
||||||
|
description: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
auto_post: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from sqlalchemy import (
|
||||||
Numeric,
|
Numeric,
|
||||||
Enum as SQLEnum,
|
Enum as SQLEnum,
|
||||||
Index,
|
Index,
|
||||||
|
JSON,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
@ -23,6 +24,21 @@ class CheckType(str, Enum):
|
||||||
TRANSFERRED = "TRANSFERRED"
|
TRANSFERRED = "TRANSFERRED"
|
||||||
|
|
||||||
|
|
||||||
|
class CheckStatus(str, Enum):
|
||||||
|
RECEIVED_ON_HAND = "RECEIVED_ON_HAND" # چک دریافتی در دست
|
||||||
|
TRANSFERRED_ISSUED = "TRANSFERRED_ISSUED" # چک پرداختنی صادر و تحویل شده
|
||||||
|
DEPOSITED = "DEPOSITED" # سپرده به بانک (در جریان وصول)
|
||||||
|
CLEARED = "CLEARED" # پاس/وصول شده
|
||||||
|
ENDORSED = "ENDORSED" # واگذار شده به شخص ثالث
|
||||||
|
RETURNED = "RETURNED" # عودت شده
|
||||||
|
BOUNCED = "BOUNCED" # برگشت خورده
|
||||||
|
CANCELLED = "CANCELLED" # ابطال شده
|
||||||
|
|
||||||
|
class HolderType(str, Enum):
|
||||||
|
BUSINESS = "BUSINESS"
|
||||||
|
BANK = "BANK"
|
||||||
|
PERSON = "PERSON"
|
||||||
|
|
||||||
class Check(Base):
|
class Check(Base):
|
||||||
__tablename__ = "checks"
|
__tablename__ = "checks"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|
@ -54,6 +70,14 @@ class Check(Base):
|
||||||
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
|
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
|
||||||
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
|
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# وضعیت و نگهدارنده
|
||||||
|
status: Mapped[CheckStatus | None] = mapped_column(SQLEnum(CheckStatus, name="check_status"), nullable=True, index=True)
|
||||||
|
status_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
current_holder_type: Mapped[HolderType | None] = mapped_column(SQLEnum(HolderType, name="check_holder_type"), nullable=True, index=True)
|
||||||
|
current_holder_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
last_action_document_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("documents.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
developer_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
@ -61,5 +85,6 @@ class Check(Base):
|
||||||
business = relationship("Business", backref="checks")
|
business = relationship("Business", backref="checks")
|
||||||
person = relationship("Person", lazy="joined")
|
person = relationship("Person", lazy="joined")
|
||||||
currency = relationship("Currency")
|
currency = relationship("Currency")
|
||||||
|
last_action_document = relationship("Document")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from adapters.api.v1.transfers import router as transfers_router
|
||||||
from adapters.api.v1.fiscal_years import router as fiscal_years_router
|
from adapters.api.v1.fiscal_years import router as fiscal_years_router
|
||||||
from adapters.api.v1.expense_income import router as expense_income_router
|
from adapters.api.v1.expense_income import router as expense_income_router
|
||||||
from adapters.api.v1.documents import router as documents_router
|
from adapters.api.v1.documents import router as documents_router
|
||||||
|
from adapters.api.v1.kardex import router as kardex_router
|
||||||
from app.core.i18n import negotiate_locale, Translator
|
from app.core.i18n import negotiate_locale, Translator
|
||||||
from app.core.error_handlers import register_error_handlers
|
from app.core.error_handlers import register_error_handlers
|
||||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
||||||
|
|
@ -319,6 +320,7 @@ def create_app() -> FastAPI:
|
||||||
application.include_router(expense_income_router, prefix=settings.api_v1_prefix)
|
application.include_router(expense_income_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(documents_router, prefix=settings.api_v1_prefix)
|
application.include_router(documents_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
|
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
|
||||||
|
application.include_router(kardex_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
||||||
# Support endpoints
|
# Support endpoints
|
||||||
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_, func
|
from sqlalchemy import and_, or_, func
|
||||||
|
|
||||||
from adapters.db.models.check import Check, CheckType
|
from adapters.db.models.check import Check, CheckType, CheckStatus, HolderType
|
||||||
from adapters.db.models.person import Person
|
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.fiscal_year import FiscalYear
|
||||||
from adapters.db.models.currency import Currency
|
from adapters.db.models.currency import Currency
|
||||||
|
from adapters.db.models.person import Person
|
||||||
from app.core.responses import ApiError
|
from app.core.responses import ApiError
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,7 +24,37 @@ def _parse_iso(dt: str) -> datetime:
|
||||||
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
|
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
|
||||||
|
|
||||||
|
|
||||||
def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
def _parse_iso_date_only(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:
|
||||||
|
from app.core.responses import ApiError
|
||||||
|
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:
|
||||||
|
from sqlalchemy import and_ # local import to avoid unused import if not used elsewhere
|
||||||
|
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:
|
||||||
|
from app.core.responses import ApiError
|
||||||
|
raise ApiError("FISCAL_YEAR_NOT_FOUND", "Active fiscal year not found", http_status=404)
|
||||||
|
return fy
|
||||||
|
|
||||||
|
|
||||||
|
def create_check(db: Session, business_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
ctype = str(data.get('type', '')).lower()
|
ctype = str(data.get('type', '')).lower()
|
||||||
if ctype not in ("received", "transferred"):
|
if ctype not in ("received", "transferred"):
|
||||||
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
|
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
|
||||||
|
|
@ -75,10 +110,109 @@ def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[st
|
||||||
currency_id=int(data.get('currency_id')),
|
currency_id=int(data.get('currency_id')),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# تعیین وضعیت اولیه
|
||||||
|
if ctype == "received":
|
||||||
|
obj.status = CheckStatus.RECEIVED_ON_HAND
|
||||||
|
obj.current_holder_type = HolderType.BUSINESS
|
||||||
|
obj.current_holder_id = None
|
||||||
|
else:
|
||||||
|
obj.status = CheckStatus.TRANSFERRED_ISSUED
|
||||||
|
obj.current_holder_type = HolderType.PERSON if person_id else HolderType.BUSINESS
|
||||||
|
obj.current_holder_id = int(person_id) if person_id else None
|
||||||
|
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(obj)
|
db.refresh(obj)
|
||||||
return check_to_dict(db, obj)
|
|
||||||
|
# ایجاد سند حسابداری خودکار در صورت درخواست
|
||||||
|
created_document_id: Optional[int] = None
|
||||||
|
try:
|
||||||
|
if bool(data.get("auto_post")):
|
||||||
|
# آمادهسازی دادههای سند
|
||||||
|
document_date: date = _parse_iso_date_only(data.get("document_date") or issue_date)
|
||||||
|
fiscal_year = _get_business_fiscal_year(db, business_id)
|
||||||
|
|
||||||
|
# تعیین حسابها و سطرها
|
||||||
|
amount_dec = Decimal(str(amount_val))
|
||||||
|
lines: List[Dict[str, Any]] = []
|
||||||
|
description = (str(data.get("document_description")).strip() or None) if data.get("document_description") is not None else None
|
||||||
|
|
||||||
|
if ctype == "received":
|
||||||
|
# بدهکار: اسناد دریافتنی 10403
|
||||||
|
acc_notes_recv = _get_fixed_account_by_code(db, "10403")
|
||||||
|
lines.append({
|
||||||
|
"account_id": acc_notes_recv.id,
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "ثبت چک دریافتی",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
# بستانکار: حساب دریافتنی شخص 10401
|
||||||
|
acc_ar = _get_fixed_account_by_code(db, "10401")
|
||||||
|
lines.append({
|
||||||
|
"account_id": acc_ar.id,
|
||||||
|
"person_id": int(person_id) if person_id else None,
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "ثبت چک دریافتی",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
else: # transferred
|
||||||
|
# بدهکار: حساب پرداختنی شخص 20201 (در صورت وجود شخص)
|
||||||
|
acc_ap = _get_fixed_account_by_code(db, "20201")
|
||||||
|
lines.append({
|
||||||
|
"account_id": acc_ap.id,
|
||||||
|
"person_id": int(person_id) if person_id else None,
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "ثبت چک واگذار شده",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
# بستانکار: اسناد پرداختنی 20202
|
||||||
|
acc_notes_pay = _get_fixed_account_by_code(db, "20202")
|
||||||
|
lines.append({
|
||||||
|
"account_id": acc_notes_pay.id,
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "ثبت چک واگذار شده",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ایجاد سند
|
||||||
|
document = Document(
|
||||||
|
code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
|
||||||
|
business_id=business_id,
|
||||||
|
fiscal_year_id=fiscal_year.id,
|
||||||
|
currency_id=int(data.get("currency_id")),
|
||||||
|
created_by_user_id=int(user_id),
|
||||||
|
document_date=document_date,
|
||||||
|
document_type="check",
|
||||||
|
is_proforma=False,
|
||||||
|
description=description,
|
||||||
|
extra_info={
|
||||||
|
"source": "check_create",
|
||||||
|
"check_id": obj.id,
|
||||||
|
"check_type": ctype,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(document)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
db.add(DocumentLine(document_id=document.id, **line))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(document)
|
||||||
|
created_document_id = document.id
|
||||||
|
except Exception:
|
||||||
|
# در صورت شکست ایجاد سند، تغییری در ایجاد چک نمیدهیم و خطا نمیریزیم
|
||||||
|
# (میتوان رفتار را سختگیرانه کرد و رولبک نمود؛ فعلاً نرم)
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
result = check_to_dict(db, obj)
|
||||||
|
if created_document_id:
|
||||||
|
result["document_id"] = created_document_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
|
def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
|
@ -86,6 +220,401 @@ def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return check_to_dict(db, obj) if obj else None
|
return check_to_dict(db, obj) if obj else None
|
||||||
|
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Action helpers
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
def _create_document_for_check_action(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
business_id: int,
|
||||||
|
user_id: int,
|
||||||
|
currency_id: int,
|
||||||
|
document_date: date,
|
||||||
|
description: Optional[str],
|
||||||
|
lines: List[Dict[str, Any]],
|
||||||
|
extra_info: Dict[str, Any],
|
||||||
|
) -> int:
|
||||||
|
document = Document(
|
||||||
|
code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
|
||||||
|
business_id=business_id,
|
||||||
|
fiscal_year_id=_get_business_fiscal_year(db, business_id).id,
|
||||||
|
currency_id=int(currency_id),
|
||||||
|
created_by_user_id=int(user_id),
|
||||||
|
document_date=document_date,
|
||||||
|
document_type="check",
|
||||||
|
is_proforma=False,
|
||||||
|
description=description,
|
||||||
|
extra_info=extra_info,
|
||||||
|
)
|
||||||
|
db.add(document)
|
||||||
|
db.flush()
|
||||||
|
for line in lines:
|
||||||
|
db.add(DocumentLine(document_id=document.id, **line))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(document)
|
||||||
|
return document.id
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_account(db: Session, code: str) -> int:
|
||||||
|
return _get_fixed_account_by_code(db, code).id
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_date(d: Any, fallback: date) -> date:
|
||||||
|
return _parse_iso_date_only(d) if d else fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _load_check_or_404(db: Session, check_id: int) -> Check:
|
||||||
|
obj = db.query(Check).filter(Check.id == check_id).first()
|
||||||
|
if not obj:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def endorse_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
obj = _load_check_or_404(db, check_id)
|
||||||
|
if obj.type != CheckType.RECEIVED:
|
||||||
|
raise ApiError("INVALID_ACTION", "Only received checks can be endorsed", http_status=400)
|
||||||
|
if obj.status not in (CheckStatus.RECEIVED_ON_HAND, CheckStatus.RETURNED, CheckStatus.BOUNCED):
|
||||||
|
raise ApiError("INVALID_STATE", f"Cannot endorse from status {obj.status}", http_status=400)
|
||||||
|
|
||||||
|
target_person_id = int(data.get("target_person_id"))
|
||||||
|
document_date = _parse_optional_date(data.get("document_date"), obj.issue_date.date())
|
||||||
|
description = (data.get("description") or None)
|
||||||
|
|
||||||
|
lines: List[Dict[str, Any]] = []
|
||||||
|
amount_dec = Decimal(str(obj.amount))
|
||||||
|
# Dr 20201 (target person AP), Cr 10403
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "20201"),
|
||||||
|
"person_id": target_person_id,
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "واگذاری چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "10403"),
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "واگذاری چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
document_id = None
|
||||||
|
if bool(data.get("auto_post", True)):
|
||||||
|
document_id = _create_document_for_check_action(
|
||||||
|
db,
|
||||||
|
business_id=obj.business_id,
|
||||||
|
user_id=user_id,
|
||||||
|
currency_id=obj.currency_id,
|
||||||
|
document_date=document_date,
|
||||||
|
description=description,
|
||||||
|
lines=lines,
|
||||||
|
extra_info={"source": "check_action", "action": "endorse", "check_id": obj.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
obj.status = CheckStatus.ENDORSED
|
||||||
|
obj.status_at = datetime.utcnow()
|
||||||
|
obj.current_holder_type = HolderType.PERSON
|
||||||
|
obj.current_holder_id = target_person_id
|
||||||
|
obj.last_action_document_id = document_id
|
||||||
|
db.commit(); db.refresh(obj)
|
||||||
|
res = check_to_dict(db, obj)
|
||||||
|
if document_id:
|
||||||
|
res["document_id"] = document_id
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
obj = _load_check_or_404(db, check_id)
|
||||||
|
document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
|
||||||
|
description = (data.get("description") or None)
|
||||||
|
amount_dec = Decimal(str(obj.amount))
|
||||||
|
lines: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if obj.type == CheckType.RECEIVED:
|
||||||
|
# Dr 10203 (bank), Cr 10403
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "10203"),
|
||||||
|
"bank_account_id": int(data.get("bank_account_id")),
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "وصول چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "10403"),
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "وصول چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# transferred/pay: Dr 20202, Cr 10203
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "20202"),
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "پرداخت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "10203"),
|
||||||
|
"bank_account_id": int(data.get("bank_account_id")),
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "پرداخت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
document_id = None
|
||||||
|
if bool(data.get("auto_post", True)):
|
||||||
|
document_id = _create_document_for_check_action(
|
||||||
|
db,
|
||||||
|
business_id=obj.business_id,
|
||||||
|
user_id=user_id,
|
||||||
|
currency_id=obj.currency_id,
|
||||||
|
document_date=document_date,
|
||||||
|
description=description,
|
||||||
|
lines=lines,
|
||||||
|
extra_info={"source": "check_action", "action": "clear", "check_id": obj.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
obj.status = CheckStatus.CLEARED
|
||||||
|
obj.status_at = datetime.utcnow()
|
||||||
|
obj.current_holder_type = HolderType.BANK
|
||||||
|
obj.current_holder_id = int(data.get("bank_account_id"))
|
||||||
|
obj.last_action_document_id = document_id
|
||||||
|
db.commit(); db.refresh(obj)
|
||||||
|
res = check_to_dict(db, obj)
|
||||||
|
if document_id:
|
||||||
|
res["document_id"] = document_id
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def pay_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# alias to clear_check for transferred
|
||||||
|
obj = _load_check_or_404(db, check_id)
|
||||||
|
if obj.type != CheckType.TRANSFERRED:
|
||||||
|
raise ApiError("INVALID_ACTION", "Only transferred checks can be paid", http_status=400)
|
||||||
|
return clear_check(db, check_id, user_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
def return_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
obj = _load_check_or_404(db, check_id)
|
||||||
|
document_date = _parse_optional_date(data.get("document_date"), obj.issue_date.date())
|
||||||
|
description = (data.get("description") or None)
|
||||||
|
amount_dec = Decimal(str(obj.amount))
|
||||||
|
lines: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if obj.type == CheckType.RECEIVED:
|
||||||
|
if not obj.person_id:
|
||||||
|
raise ApiError("PERSON_REQUIRED", "person_id is required on received check to return", http_status=400)
|
||||||
|
# Dr 10401(person), Cr 10403
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "10401"),
|
||||||
|
"person_id": int(obj.person_id),
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "عودت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "10403"),
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "عودت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
obj.current_holder_type = HolderType.PERSON
|
||||||
|
obj.current_holder_id = int(obj.person_id)
|
||||||
|
else:
|
||||||
|
# transferred: Dr 20202, Cr 20201(person)
|
||||||
|
if not obj.person_id:
|
||||||
|
raise ApiError("PERSON_REQUIRED", "person_id is required on transferred check to return", http_status=400)
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "20202"),
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "عودت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "20201"),
|
||||||
|
"person_id": int(obj.person_id),
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "عودت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
obj.current_holder_type = HolderType.BUSINESS
|
||||||
|
obj.current_holder_id = None
|
||||||
|
|
||||||
|
document_id = None
|
||||||
|
if bool(data.get("auto_post", True)):
|
||||||
|
document_id = _create_document_for_check_action(
|
||||||
|
db,
|
||||||
|
business_id=obj.business_id,
|
||||||
|
user_id=user_id,
|
||||||
|
currency_id=obj.currency_id,
|
||||||
|
document_date=document_date,
|
||||||
|
description=description,
|
||||||
|
lines=lines,
|
||||||
|
extra_info={"source": "check_action", "action": "return", "check_id": obj.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
obj.status = CheckStatus.RETURNED
|
||||||
|
obj.status_at = datetime.utcnow()
|
||||||
|
obj.last_action_document_id = document_id
|
||||||
|
db.commit(); db.refresh(obj)
|
||||||
|
res = check_to_dict(db, obj)
|
||||||
|
if document_id:
|
||||||
|
res["document_id"] = document_id
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def bounce_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
obj = _load_check_or_404(db, check_id)
|
||||||
|
document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
|
||||||
|
description = (data.get("description") or None)
|
||||||
|
amount_dec = Decimal(str(obj.amount))
|
||||||
|
lines: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
if obj.type == CheckType.RECEIVED:
|
||||||
|
# Reverse cash if previously cleared; simplified: Dr 10403, Cr 10203
|
||||||
|
bank_account_id = data.get("bank_account_id")
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "10403"),
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "برگشت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "10203"),
|
||||||
|
**({"bank_account_id": int(bank_account_id)} if bank_account_id else {}),
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "برگشت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# transferred: Dr 20202, Cr 20201(person) (increase AP again)
|
||||||
|
if not obj.person_id:
|
||||||
|
raise ApiError("PERSON_REQUIRED", "person_id is required on transferred check to bounce", http_status=400)
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "20202"),
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "برگشت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "20201"),
|
||||||
|
"person_id": int(obj.person_id),
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "برگشت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Optional expense fee
|
||||||
|
expense_amount = data.get("expense_amount")
|
||||||
|
expense_account_id = data.get("expense_account_id")
|
||||||
|
bank_account_id = data.get("bank_account_id")
|
||||||
|
if expense_amount and expense_account_id and float(expense_amount) > 0:
|
||||||
|
lines.append({
|
||||||
|
"account_id": int(expense_account_id),
|
||||||
|
"debit": Decimal(str(expense_amount)),
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "هزینه برگشت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
lines.append({
|
||||||
|
"account_id": _ensure_account(db, "10203"),
|
||||||
|
**({"bank_account_id": int(bank_account_id)} if bank_account_id else {}),
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": Decimal(str(expense_amount)),
|
||||||
|
"description": description or "هزینه برگشت چک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
document_id = None
|
||||||
|
if bool(data.get("auto_post", True)):
|
||||||
|
document_id = _create_document_for_check_action(
|
||||||
|
db,
|
||||||
|
business_id=obj.business_id,
|
||||||
|
user_id=user_id,
|
||||||
|
currency_id=obj.currency_id,
|
||||||
|
document_date=document_date,
|
||||||
|
description=description,
|
||||||
|
lines=lines,
|
||||||
|
extra_info={"source": "check_action", "action": "bounce", "check_id": obj.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
obj.status = CheckStatus.BOUNCED
|
||||||
|
obj.status_at = datetime.utcnow()
|
||||||
|
obj.current_holder_type = HolderType.BUSINESS
|
||||||
|
obj.current_holder_id = None
|
||||||
|
obj.last_action_document_id = document_id
|
||||||
|
db.commit(); db.refresh(obj)
|
||||||
|
res = check_to_dict(db, obj)
|
||||||
|
if document_id:
|
||||||
|
res["document_id"] = document_id
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def deposit_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
obj = _load_check_or_404(db, check_id)
|
||||||
|
if obj.type != CheckType.RECEIVED:
|
||||||
|
raise ApiError("INVALID_ACTION", "Only received checks can be deposited", http_status=400)
|
||||||
|
document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
|
||||||
|
description = (data.get("description") or None)
|
||||||
|
amount_dec = Decimal(str(obj.amount))
|
||||||
|
# Requires account 10404 to exist
|
||||||
|
in_collection = _get_fixed_account_by_code(db, "10404") # may raise 404
|
||||||
|
lines: List[Dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"account_id": in_collection.id,
|
||||||
|
"debit": amount_dec,
|
||||||
|
"credit": Decimal(0),
|
||||||
|
"description": description or "سپرده چک به بانک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"account_id": _ensure_account(db, "10403"),
|
||||||
|
"debit": Decimal(0),
|
||||||
|
"credit": amount_dec,
|
||||||
|
"description": description or "سپرده چک به بانک",
|
||||||
|
"check_id": obj.id,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
document_id = None
|
||||||
|
if bool(data.get("auto_post", True)):
|
||||||
|
document_id = _create_document_for_check_action(
|
||||||
|
db,
|
||||||
|
business_id=obj.business_id,
|
||||||
|
user_id=user_id,
|
||||||
|
currency_id=obj.currency_id,
|
||||||
|
document_date=document_date,
|
||||||
|
description=description,
|
||||||
|
lines=lines,
|
||||||
|
extra_info={"source": "check_action", "action": "deposit", "check_id": obj.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
obj.status = CheckStatus.DEPOSITED
|
||||||
|
obj.status_at = datetime.utcnow()
|
||||||
|
obj.current_holder_type = HolderType.BANK
|
||||||
|
obj.last_action_document_id = document_id
|
||||||
|
db.commit(); db.refresh(obj)
|
||||||
|
res = check_to_dict(db, obj)
|
||||||
|
if document_id:
|
||||||
|
res["document_id"] = document_id
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
obj = db.query(Check).filter(Check.id == check_id).first()
|
obj = db.query(Check).filter(Check.id == check_id).first()
|
||||||
if obj is None:
|
if obj is None:
|
||||||
|
|
@ -197,6 +726,22 @@ def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[st
|
||||||
q = q.filter(Check.type == enum_val)
|
q = q.filter(Check.type == enum_val)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
elif prop == 'status':
|
||||||
|
try:
|
||||||
|
if op == '=' and isinstance(val, str) and val:
|
||||||
|
enum_val = CheckStatus[val]
|
||||||
|
q = q.filter(Check.status == enum_val)
|
||||||
|
elif op == 'in' and isinstance(val, list) and val:
|
||||||
|
enum_vals = []
|
||||||
|
for v in val:
|
||||||
|
try:
|
||||||
|
enum_vals.append(CheckStatus[str(v)])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if enum_vals:
|
||||||
|
q = q.filter(Check.status.in_(enum_vals))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
elif prop == 'currency' and op == '=':
|
elif prop == 'currency' and op == '=':
|
||||||
try:
|
try:
|
||||||
q = q.filter(Check.currency_id == int(val))
|
q = q.filter(Check.currency_id == int(val))
|
||||||
|
|
@ -283,6 +828,11 @@ def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]
|
||||||
"amount": float(obj.amount),
|
"amount": float(obj.amount),
|
||||||
"currency_id": obj.currency_id,
|
"currency_id": obj.currency_id,
|
||||||
"currency": currency_title,
|
"currency": currency_title,
|
||||||
|
"status": (obj.status.name if obj.status else None),
|
||||||
|
"status_at": (obj.status_at.isoformat() if obj.status_at else None),
|
||||||
|
"current_holder_type": (obj.current_holder_type.name if obj.current_holder_type else None),
|
||||||
|
"current_holder_id": obj.current_holder_id,
|
||||||
|
"last_action_document_id": obj.last_action_document_id,
|
||||||
"created_at": obj.created_at.isoformat(),
|
"created_at": obj.created_at.isoformat(),
|
||||||
"updated_at": obj.updated_at.isoformat(),
|
"updated_at": obj.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
267
hesabixAPI/app/services/kardex_service.py
Normal file
267
hesabixAPI/app/services/kardex_service.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, exists, select
|
||||||
|
|
||||||
|
from adapters.db.models.document import Document
|
||||||
|
from adapters.db.models.document_line import DocumentLine
|
||||||
|
from adapters.db.models.fiscal_year import FiscalYear
|
||||||
|
|
||||||
|
|
||||||
|
# Helpers (reuse existing helpers from other services when possible)
|
||||||
|
def _parse_iso_date(dt: str) -> date:
|
||||||
|
from app.services.transfer_service import _parse_iso_date as _p # type: ignore
|
||||||
|
return _p(dt)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
|
||||||
|
from app.services.transfer_service import _get_current_fiscal_year as _g # type: ignore
|
||||||
|
return _g(db, business_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_group_condition(column, ids: List[int]) -> Any:
|
||||||
|
if not ids:
|
||||||
|
return None
|
||||||
|
return column.in_(ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_ids(query: Dict[str, Any], key: str) -> List[int]:
|
||||||
|
vals = query.get(key)
|
||||||
|
if not isinstance(vals, (list, tuple)):
|
||||||
|
return []
|
||||||
|
out: List[int] = []
|
||||||
|
for v in vals:
|
||||||
|
try:
|
||||||
|
out.append(int(v))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""لیست خطوط اسناد (کاردکس) با پشتیبانی از انتخاب چندگانه و حالتهای تطابق.
|
||||||
|
|
||||||
|
پارامترهای ورودی مورد انتظار در query:
|
||||||
|
- from_date, to_date: str (ISO)
|
||||||
|
- fiscal_year_id: int (اختیاری؛ در غیر این صورت سال مالی جاری)
|
||||||
|
- person_ids, product_ids, bank_account_ids, cash_register_ids, petty_cash_ids, account_ids, check_ids: List[int]
|
||||||
|
- match_mode: "any" | "same_line" | "document_and" (پیشفرض: any)
|
||||||
|
- result_scope: "lines_matching" | "lines_of_document" (پیشفرض: lines_matching)
|
||||||
|
- sort_by: یکی از: document_date, document_code, debit, credit, quantity, created_at (پیشفرض: document_date)
|
||||||
|
- sort_desc: bool
|
||||||
|
- skip, take: pagination
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Base query: DocumentLine join Document
|
||||||
|
q = db.query(DocumentLine, Document).join(Document, Document.id == DocumentLine.document_id).filter(
|
||||||
|
Document.business_id == business_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fiscal year handling
|
||||||
|
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_current_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)
|
||||||
|
|
||||||
|
# Date range
|
||||||
|
from_date = query.get("from_date")
|
||||||
|
to_date = query.get("to_date")
|
||||||
|
if isinstance(from_date, str) and from_date:
|
||||||
|
try:
|
||||||
|
q = q.filter(Document.document_date >= _parse_iso_date(from_date))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if isinstance(to_date, str) and to_date:
|
||||||
|
try:
|
||||||
|
q = q.filter(Document.document_date <= _parse_iso_date(to_date))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Read selected IDs
|
||||||
|
person_ids = _collect_ids(query, "person_ids")
|
||||||
|
product_ids = _collect_ids(query, "product_ids")
|
||||||
|
bank_account_ids = _collect_ids(query, "bank_account_ids")
|
||||||
|
cash_register_ids = _collect_ids(query, "cash_register_ids")
|
||||||
|
petty_cash_ids = _collect_ids(query, "petty_cash_ids")
|
||||||
|
account_ids = _collect_ids(query, "account_ids")
|
||||||
|
check_ids = _collect_ids(query, "check_ids")
|
||||||
|
|
||||||
|
# Match mode
|
||||||
|
match_mode = str(query.get("match_mode") or "any").lower()
|
||||||
|
result_scope = str(query.get("result_scope") or "lines_matching").lower()
|
||||||
|
|
||||||
|
# Build conditions by group
|
||||||
|
group_filters = []
|
||||||
|
if person_ids:
|
||||||
|
group_filters.append(DocumentLine.person_id.in_(person_ids))
|
||||||
|
if product_ids:
|
||||||
|
group_filters.append(DocumentLine.product_id.in_(product_ids))
|
||||||
|
if bank_account_ids:
|
||||||
|
group_filters.append(DocumentLine.bank_account_id.in_(bank_account_ids))
|
||||||
|
if cash_register_ids:
|
||||||
|
group_filters.append(DocumentLine.cash_register_id.in_(cash_register_ids))
|
||||||
|
if petty_cash_ids:
|
||||||
|
group_filters.append(DocumentLine.petty_cash_id.in_(petty_cash_ids))
|
||||||
|
if account_ids:
|
||||||
|
group_filters.append(DocumentLine.account_id.in_(account_ids))
|
||||||
|
if check_ids:
|
||||||
|
group_filters.append(DocumentLine.check_id.in_(check_ids))
|
||||||
|
|
||||||
|
# Apply matching logic
|
||||||
|
if group_filters:
|
||||||
|
if match_mode == "same_line":
|
||||||
|
# AND across non-empty groups on the same line
|
||||||
|
q = q.filter(and_(*group_filters))
|
||||||
|
elif match_mode == "document_and":
|
||||||
|
# Require each non-empty group to exist in some line of the same document
|
||||||
|
doc_conditions = []
|
||||||
|
if person_ids:
|
||||||
|
sub = db.query(DocumentLine.id).filter(
|
||||||
|
and_(DocumentLine.document_id == Document.id, DocumentLine.person_id.in_(person_ids))
|
||||||
|
).exists()
|
||||||
|
doc_conditions.append(sub)
|
||||||
|
if product_ids:
|
||||||
|
sub = db.query(DocumentLine.id).filter(
|
||||||
|
and_(DocumentLine.document_id == Document.id, DocumentLine.product_id.in_(product_ids))
|
||||||
|
).exists()
|
||||||
|
doc_conditions.append(sub)
|
||||||
|
if bank_account_ids:
|
||||||
|
sub = db.query(DocumentLine.id).filter(
|
||||||
|
and_(DocumentLine.document_id == Document.id, DocumentLine.bank_account_id.in_(bank_account_ids))
|
||||||
|
).exists()
|
||||||
|
doc_conditions.append(sub)
|
||||||
|
if cash_register_ids:
|
||||||
|
sub = db.query(DocumentLine.id).filter(
|
||||||
|
and_(DocumentLine.document_id == Document.id, DocumentLine.cash_register_id.in_(cash_register_ids))
|
||||||
|
).exists()
|
||||||
|
doc_conditions.append(sub)
|
||||||
|
if petty_cash_ids:
|
||||||
|
sub = db.query(DocumentLine.id).filter(
|
||||||
|
and_(DocumentLine.document_id == Document.id, DocumentLine.petty_cash_id.in_(petty_cash_ids))
|
||||||
|
).exists()
|
||||||
|
doc_conditions.append(sub)
|
||||||
|
if account_ids:
|
||||||
|
sub = db.query(DocumentLine.id).filter(
|
||||||
|
and_(DocumentLine.document_id == Document.id, DocumentLine.account_id.in_(account_ids))
|
||||||
|
).exists()
|
||||||
|
doc_conditions.append(sub)
|
||||||
|
if check_ids:
|
||||||
|
sub = db.query(DocumentLine.id).filter(
|
||||||
|
and_(DocumentLine.document_id == Document.id, DocumentLine.check_id.in_(check_ids))
|
||||||
|
).exists()
|
||||||
|
doc_conditions.append(sub)
|
||||||
|
|
||||||
|
if doc_conditions:
|
||||||
|
q = q.filter(and_(*doc_conditions))
|
||||||
|
|
||||||
|
# For lines scope: either only matching lines or all lines of matching documents
|
||||||
|
if result_scope == "lines_matching":
|
||||||
|
q = q.filter(or_(*group_filters))
|
||||||
|
else:
|
||||||
|
# lines_of_document: no extra line filter
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# any: OR across groups on the same line
|
||||||
|
q = q.filter(or_(*group_filters))
|
||||||
|
|
||||||
|
# Sorting
|
||||||
|
sort_by = (query.get("sort_by") or "document_date")
|
||||||
|
sort_desc = bool(query.get("sort_desc", True))
|
||||||
|
if sort_by == "document_date":
|
||||||
|
order_col = Document.document_date
|
||||||
|
elif sort_by == "document_code":
|
||||||
|
order_col = Document.code
|
||||||
|
elif sort_by == "debit":
|
||||||
|
order_col = DocumentLine.debit
|
||||||
|
elif sort_by == "credit":
|
||||||
|
order_col = DocumentLine.credit
|
||||||
|
elif sort_by == "quantity":
|
||||||
|
order_col = DocumentLine.quantity
|
||||||
|
elif sort_by == "created_at":
|
||||||
|
order_col = DocumentLine.created_at
|
||||||
|
else:
|
||||||
|
order_col = Document.document_date
|
||||||
|
q = q.order_by(order_col.desc() if sort_desc else order_col.asc())
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
try:
|
||||||
|
skip = int(query.get("skip", 0))
|
||||||
|
except Exception:
|
||||||
|
skip = 0
|
||||||
|
try:
|
||||||
|
take = int(query.get("take", 20))
|
||||||
|
except Exception:
|
||||||
|
take = 20
|
||||||
|
|
||||||
|
total = q.count()
|
||||||
|
rows: List[Tuple[DocumentLine, Document]] = q.offset(skip).limit(take).all()
|
||||||
|
|
||||||
|
# Running balance (optional)
|
||||||
|
include_running = bool(query.get("include_running_balance", False))
|
||||||
|
running_amount: float = 0.0
|
||||||
|
running_quantity: float = 0.0
|
||||||
|
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
for line, doc in rows:
|
||||||
|
item: Dict[str, Any] = {
|
||||||
|
"line_id": line.id,
|
||||||
|
"document_id": doc.id,
|
||||||
|
"document_code": getattr(doc, "code", None),
|
||||||
|
"document_date": getattr(doc, "document_date", None),
|
||||||
|
"document_type": getattr(doc, "document_type", None),
|
||||||
|
"description": line.description,
|
||||||
|
"debit": float(line.debit or 0),
|
||||||
|
"credit": float(line.credit or 0),
|
||||||
|
"quantity": float(line.quantity or 0) if line.quantity is not None else None,
|
||||||
|
"account_id": line.account_id,
|
||||||
|
"person_id": line.person_id,
|
||||||
|
"product_id": line.product_id,
|
||||||
|
"bank_account_id": line.bank_account_id,
|
||||||
|
"cash_register_id": line.cash_register_id,
|
||||||
|
"petty_cash_id": line.petty_cash_id,
|
||||||
|
"check_id": line.check_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_running:
|
||||||
|
try:
|
||||||
|
running_amount += float(line.debit or 0) - float(line.credit or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if line.quantity is not None:
|
||||||
|
running_quantity += float(line.quantity or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
item["running_amount"] = running_amount
|
||||||
|
# فقط اگر ستون quantity وجود داشته باشد
|
||||||
|
if line.quantity is not None:
|
||||||
|
item["running_quantity"] = running_quantity
|
||||||
|
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"pagination": {
|
||||||
|
"total": total,
|
||||||
|
"page": (skip // take) + 1,
|
||||||
|
"per_page": take,
|
||||||
|
"total_pages": (total + take - 1) // take,
|
||||||
|
"has_next": skip + take < total,
|
||||||
|
"has_prev": skip > 0,
|
||||||
|
},
|
||||||
|
"query_info": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ adapters/api/v1/expense_income.py
|
||||||
adapters/api/v1/fiscal_years.py
|
adapters/api/v1/fiscal_years.py
|
||||||
adapters/api/v1/health.py
|
adapters/api/v1/health.py
|
||||||
adapters/api/v1/invoices.py
|
adapters/api/v1/invoices.py
|
||||||
|
adapters/api/v1/kardex.py
|
||||||
adapters/api/v1/persons.py
|
adapters/api/v1/persons.py
|
||||||
adapters/api/v1/petty_cash.py
|
adapters/api/v1/petty_cash.py
|
||||||
adapters/api/v1/price_lists.py
|
adapters/api/v1/price_lists.py
|
||||||
|
|
@ -146,6 +147,7 @@ app/services/email_service.py
|
||||||
app/services/expense_income_service.py
|
app/services/expense_income_service.py
|
||||||
app/services/file_storage_service.py
|
app/services/file_storage_service.py
|
||||||
app/services/invoice_service.py
|
app/services/invoice_service.py
|
||||||
|
app/services/kardex_service.py
|
||||||
app/services/person_service.py
|
app/services/person_service.py
|
||||||
app/services/petty_cash_service.py
|
app/services/petty_cash_service.py
|
||||||
app/services/price_list_service.py
|
app/services/price_list_service.py
|
||||||
|
|
@ -217,6 +219,7 @@ migrations/versions/20251014_000301_add_product_id_to_document_lines.py
|
||||||
migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
|
migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
|
||||||
migrations/versions/20251014_000501_add_quantity_to_document_lines.py
|
migrations/versions/20251014_000501_add_quantity_to_document_lines.py
|
||||||
migrations/versions/20251021_000601_add_bom_and_warehouses.py
|
migrations/versions/20251021_000601_add_bom_and_warehouses.py
|
||||||
|
migrations/versions/20251102_120001_add_check_status_fields.py
|
||||||
migrations/versions/4b2ea782bcb3_merge_heads.py
|
migrations/versions/4b2ea782bcb3_merge_heads.py
|
||||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||||
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '20251102_120001_add_check_status_fields'
|
||||||
|
down_revision: Union[str, None] = '20251011_000901_add_checks_table'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
# افزودن ستونها اگر وجود ندارند (سازگار با MySQL و PostgreSQL)
|
||||||
|
columns = {c['name'] for c in inspector.get_columns('checks')}
|
||||||
|
|
||||||
|
if 'status' not in columns:
|
||||||
|
op.add_column('checks', sa.Column('status', sa.Enum(
|
||||||
|
'RECEIVED_ON_HAND', 'TRANSFERRED_ISSUED', 'DEPOSITED', 'CLEARED', 'ENDORSED', 'RETURNED', 'BOUNCED', 'CANCELLED', name='check_status'
|
||||||
|
), nullable=True))
|
||||||
|
try:
|
||||||
|
op.create_index('ix_checks_business_status', 'checks', ['business_id', 'status'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 'status_at' not in columns:
|
||||||
|
op.add_column('checks', sa.Column('status_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
if 'current_holder_type' not in columns:
|
||||||
|
op.add_column('checks', sa.Column('current_holder_type', sa.Enum('BUSINESS', 'BANK', 'PERSON', name='check_holder_type'), nullable=True))
|
||||||
|
try:
|
||||||
|
op.create_index('ix_checks_business_holder_type', 'checks', ['business_id', 'current_holder_type'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 'current_holder_id' not in columns:
|
||||||
|
op.add_column('checks', sa.Column('current_holder_id', sa.Integer(), nullable=True))
|
||||||
|
try:
|
||||||
|
op.create_index('ix_checks_business_holder_id', 'checks', ['business_id', 'current_holder_id'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 'last_action_document_id' not in columns:
|
||||||
|
op.add_column('checks', sa.Column('last_action_document_id', sa.Integer(), sa.ForeignKey('documents.id', ondelete='SET NULL'), nullable=True))
|
||||||
|
|
||||||
|
if 'developer_data' not in columns:
|
||||||
|
# MySQL و PostgreSQL هر دو از JSON پشتیبانی میکنند
|
||||||
|
op.add_column('checks', sa.Column('developer_data', sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# حذف ایندکسها و ستونها
|
||||||
|
try:
|
||||||
|
op.drop_index('ix_checks_business_status', table_name='checks')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
op.drop_index('ix_checks_business_holder_type', table_name='checks')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
op.drop_index('ix_checks_business_holder_id', table_name='checks')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for col in ['developer_data', 'last_action_document_id', 'current_holder_id', 'current_holder_type', 'status_at', 'status']:
|
||||||
|
try:
|
||||||
|
op.drop_column('checks', col)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# حذف انواع Enum فقط در پایگاههایی که لازم است (PostgreSQL)
|
||||||
|
# در MySQL نیازی به حذف نوع جداگانه نیست
|
||||||
|
try:
|
||||||
|
op.execute("DROP TYPE check_holder_type")
|
||||||
|
op.execute("DROP TYPE check_status")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
64
hesabixAPI/scripts/add_check_status_columns.py
Normal file
64
hesabixAPI/scripts/add_check_status_columns.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from adapters.db.session import engine
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
insp = inspect(conn)
|
||||||
|
cols = {c['name'] for c in insp.get_columns('checks')}
|
||||||
|
|
||||||
|
# Add status columns if missing
|
||||||
|
ddl_statements: list[str] = []
|
||||||
|
|
||||||
|
if 'status' not in cols:
|
||||||
|
ddl_statements.append(
|
||||||
|
"ALTER TABLE `checks` ADD COLUMN `status` ENUM('RECEIVED_ON_HAND','TRANSFERRED_ISSUED','DEPOSITED','CLEARED','ENDORSED','RETURNED','BOUNCED','CANCELLED') NULL AFTER `currency_id`"
|
||||||
|
)
|
||||||
|
if 'status_at' not in cols:
|
||||||
|
ddl_statements.append(
|
||||||
|
"ALTER TABLE `checks` ADD COLUMN `status_at` DATETIME NULL AFTER `status`"
|
||||||
|
)
|
||||||
|
if 'current_holder_type' not in cols:
|
||||||
|
ddl_statements.append(
|
||||||
|
"ALTER TABLE `checks` ADD COLUMN `current_holder_type` ENUM('BUSINESS','BANK','PERSON') NULL AFTER `status_at`"
|
||||||
|
)
|
||||||
|
if 'current_holder_id' not in cols:
|
||||||
|
ddl_statements.append(
|
||||||
|
"ALTER TABLE `checks` ADD COLUMN `current_holder_id` INT NULL AFTER `current_holder_type`"
|
||||||
|
)
|
||||||
|
if 'last_action_document_id' not in cols:
|
||||||
|
ddl_statements.append(
|
||||||
|
"ALTER TABLE `checks` ADD COLUMN `last_action_document_id` INT NULL AFTER `current_holder_id`"
|
||||||
|
)
|
||||||
|
if 'developer_data' not in cols:
|
||||||
|
ddl_statements.append(
|
||||||
|
"ALTER TABLE `checks` ADD COLUMN `developer_data` JSON NULL AFTER `last_action_document_id`"
|
||||||
|
)
|
||||||
|
|
||||||
|
for stmt in ddl_statements:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
|
||||||
|
# Create indexes if missing
|
||||||
|
existing_indexes = {idx['name'] for idx in insp.get_indexes('checks')}
|
||||||
|
if 'ix_checks_business_status' not in existing_indexes and 'status' in {c['name'] for c in insp.get_columns('checks')}:
|
||||||
|
conn.execute(text("CREATE INDEX `ix_checks_business_status` ON `checks` (`business_id`, `status`)"))
|
||||||
|
if 'ix_checks_business_holder_type' not in existing_indexes and 'current_holder_type' in {c['name'] for c in insp.get_columns('checks')}:
|
||||||
|
conn.execute(text("CREATE INDEX `ix_checks_business_holder_type` ON `checks` (`business_id`, `current_holder_type`)"))
|
||||||
|
if 'ix_checks_business_holder_id' not in existing_indexes and 'current_holder_id' in {c['name'] for c in insp.get_columns('checks')}:
|
||||||
|
conn.execute(text("CREATE INDEX `ix_checks_business_holder_id` ON `checks` (`business_id`, `current_holder_id`)"))
|
||||||
|
|
||||||
|
# Add FK if missing
|
||||||
|
fks = insp.get_foreign_keys('checks')
|
||||||
|
fk_names = {fk.get('name') for fk in fks if fk.get('name')}
|
||||||
|
if 'fk_checks_last_action_document' not in fk_names and 'last_action_document_id' in {c['name'] for c in insp.get_columns('checks')}:
|
||||||
|
conn.execute(text(
|
||||||
|
"ALTER TABLE `checks` ADD CONSTRAINT `fk_checks_last_action_document` FOREIGN KEY (`last_action_document_id`) REFERENCES `documents`(`id`) ON DELETE SET NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
@ -1046,7 +1046,7 @@
|
||||||
"currency": "واحد پول",
|
"currency": "واحد پول",
|
||||||
"isDefault": "پیشفرض",
|
"isDefault": "پیشفرض",
|
||||||
"description": "توضیحات",
|
"description": "توضیحات",
|
||||||
"actions": "اقدامات",
|
"actions": "عملیات",
|
||||||
"yes": "بله",
|
"yes": "بله",
|
||||||
"no": "خیر",
|
"no": "خیر",
|
||||||
"pettyCash": "تنخواه گردان",
|
"pettyCash": "تنخواه گردان",
|
||||||
|
|
|
||||||
|
|
@ -1223,7 +1223,7 @@ class AppLocalizationsFa extends AppLocalizations {
|
||||||
String get edit => 'ویرایش';
|
String get edit => 'ویرایش';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actions => 'اقدامات';
|
String get actions => 'عملیات';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get search => 'جستجو';
|
String get search => 'جستجو';
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import 'pages/business/new_invoice_page.dart';
|
||||||
import 'pages/business/settings_page.dart';
|
import 'pages/business/settings_page.dart';
|
||||||
import 'pages/business/business_info_settings_page.dart';
|
import 'pages/business/business_info_settings_page.dart';
|
||||||
import 'pages/business/reports_page.dart';
|
import 'pages/business/reports_page.dart';
|
||||||
|
import 'pages/business/kardex_page.dart';
|
||||||
import 'pages/business/persons_page.dart';
|
import 'pages/business/persons_page.dart';
|
||||||
import 'pages/business/product_attributes_page.dart';
|
import 'pages/business/product_attributes_page.dart';
|
||||||
import 'pages/business/products_page.dart';
|
import 'pages/business/products_page.dart';
|
||||||
|
|
@ -633,6 +634,19 @@ class _MyAppState extends State<MyApp> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/business/:business_id/reports/kardex',
|
||||||
|
name: 'business_reports_kardex',
|
||||||
|
pageBuilder: (context, state) {
|
||||||
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
return NoTransitionPage(
|
||||||
|
child: KardexPage(
|
||||||
|
businessId: businessId,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/business/:business_id/settings',
|
path: '/business/:business_id/settings',
|
||||||
name: 'business_settings',
|
name: 'business_settings',
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,15 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
||||||
DateTime? _dueDate;
|
DateTime? _dueDate;
|
||||||
int? _currencyId;
|
int? _currencyId;
|
||||||
dynamic _selectedPerson; // using Person type would be ideal; keep dynamic to avoid imports complexity
|
dynamic _selectedPerson; // using Person type would be ideal; keep dynamic to avoid imports complexity
|
||||||
|
bool _autoPost = false;
|
||||||
|
DateTime? _documentDate;
|
||||||
|
|
||||||
final _checkNumberCtrl = TextEditingController();
|
final _checkNumberCtrl = TextEditingController();
|
||||||
final _sayadCtrl = TextEditingController();
|
final _sayadCtrl = TextEditingController();
|
||||||
final _bankCtrl = TextEditingController();
|
final _bankCtrl = TextEditingController();
|
||||||
final _branchCtrl = TextEditingController();
|
final _branchCtrl = TextEditingController();
|
||||||
final _amountCtrl = TextEditingController();
|
final _amountCtrl = TextEditingController();
|
||||||
|
final _docDescCtrl = TextEditingController();
|
||||||
|
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
|
||||||
|
|
@ -50,6 +53,7 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
||||||
_currencyId = widget.authStore.selectedCurrencyId;
|
_currencyId = widget.authStore.selectedCurrencyId;
|
||||||
_issueDate = DateTime.now();
|
_issueDate = DateTime.now();
|
||||||
_dueDate = DateTime.now();
|
_dueDate = DateTime.now();
|
||||||
|
_documentDate = _issueDate;
|
||||||
if (widget.checkId != null) {
|
if (widget.checkId != null) {
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +118,9 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
||||||
if (_branchCtrl.text.trim().isNotEmpty) 'branch_name': _branchCtrl.text.trim(),
|
if (_branchCtrl.text.trim().isNotEmpty) 'branch_name': _branchCtrl.text.trim(),
|
||||||
'amount': num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()),
|
'amount': num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()),
|
||||||
'currency_id': _currencyId,
|
'currency_id': _currencyId,
|
||||||
|
'auto_post': _autoPost,
|
||||||
|
if (_autoPost && _documentDate != null) 'document_date': _documentDate!.toIso8601String(),
|
||||||
|
if (_autoPost && _docDescCtrl.text.trim().isNotEmpty) 'document_description': _docDescCtrl.text.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (widget.checkId == null) {
|
if (widget.checkId == null) {
|
||||||
|
|
@ -152,6 +159,7 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
||||||
_bankCtrl.dispose();
|
_bankCtrl.dispose();
|
||||||
_branchCtrl.dispose();
|
_branchCtrl.dispose();
|
||||||
_amountCtrl.dispose();
|
_amountCtrl.dispose();
|
||||||
|
_docDescCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,6 +167,7 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
final isEdit = widget.checkId != null;
|
final isEdit = widget.checkId != null;
|
||||||
|
final canAccountingWrite = widget.authStore.canWriteSection('accounting');
|
||||||
|
|
||||||
if (!widget.authStore.canWriteSection('checks')) {
|
if (!widget.authStore.canWriteSection('checks')) {
|
||||||
return AccessDeniedPage(message: t.accessDenied);
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
|
|
@ -321,6 +330,43 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (canAccountingWrite) ...[
|
||||||
|
SwitchListTile(
|
||||||
|
value: _autoPost,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_autoPost = v;
|
||||||
|
_documentDate ??= _issueDate;
|
||||||
|
}),
|
||||||
|
title: const Text('ثبت سند حسابداری همزمان'),
|
||||||
|
),
|
||||||
|
if (_autoPost) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DateInputField(
|
||||||
|
value: _documentDate,
|
||||||
|
labelText: 'تاریخ سند',
|
||||||
|
hintText: 'انتخاب تاریخ سند',
|
||||||
|
calendarController: widget.calendarController!,
|
||||||
|
onChanged: (d) => setState(() => _documentDate = d),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _docDescCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'شرح سند',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import '../../widgets/data_table/data_table_config.dart';
|
||||||
import '../../widgets/permission/permission_widgets.dart';
|
import '../../widgets/permission/permission_widgets.dart';
|
||||||
import '../../widgets/invoice/person_combobox_widget.dart';
|
import '../../widgets/invoice/person_combobox_widget.dart';
|
||||||
import '../../models/person_model.dart';
|
import '../../models/person_model.dart';
|
||||||
|
import '../../services/check_service.dart';
|
||||||
|
import '../../widgets/invoice/bank_account_combobox_widget.dart';
|
||||||
|
|
||||||
class ChecksPage extends StatefulWidget {
|
class ChecksPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
|
|
@ -25,6 +27,7 @@ class ChecksPage extends StatefulWidget {
|
||||||
class _ChecksPageState extends State<ChecksPage> {
|
class _ChecksPageState extends State<ChecksPage> {
|
||||||
final GlobalKey _tableKey = GlobalKey();
|
final GlobalKey _tableKey = GlobalKey();
|
||||||
Person? _selectedPerson;
|
Person? _selectedPerson;
|
||||||
|
final _checkService = CheckService();
|
||||||
|
|
||||||
void _refresh() {
|
void _refresh() {
|
||||||
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
|
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
|
||||||
|
|
@ -111,6 +114,33 @@ class _ChecksPageState extends State<ChecksPage> {
|
||||||
TextColumn('currency', 'ارز', width: ColumnWidth.small,
|
TextColumn('currency', 'ارز', width: ColumnWidth.small,
|
||||||
formatter: (row) => (row['currency'] ?? '-'),
|
formatter: (row) => (row['currency'] ?? '-'),
|
||||||
),
|
),
|
||||||
|
TextColumn('status', 'وضعیت', width: ColumnWidth.medium,
|
||||||
|
filterType: ColumnFilterType.multiSelect,
|
||||||
|
filterOptions: const [
|
||||||
|
FilterOption(value: 'RECEIVED_ON_HAND', label: 'در دست (دریافتی)'),
|
||||||
|
FilterOption(value: 'TRANSFERRED_ISSUED', label: 'صادر شده (پرداختنی)'),
|
||||||
|
FilterOption(value: 'DEPOSITED', label: 'سپرده به بانک'),
|
||||||
|
FilterOption(value: 'CLEARED', label: 'پاس/وصول شده'),
|
||||||
|
FilterOption(value: 'ENDORSED', label: 'واگذار شده'),
|
||||||
|
FilterOption(value: 'RETURNED', label: 'عودت شده'),
|
||||||
|
FilterOption(value: 'BOUNCED', label: 'برگشت خورده'),
|
||||||
|
FilterOption(value: 'CANCELLED', label: 'ابطال'),
|
||||||
|
],
|
||||||
|
formatter: (row) {
|
||||||
|
final s = (row['status'] ?? '').toString();
|
||||||
|
switch (s) {
|
||||||
|
case 'RECEIVED_ON_HAND': return 'در دست (دریافتی)';
|
||||||
|
case 'TRANSFERRED_ISSUED': return 'صادر شده (پرداختنی)';
|
||||||
|
case 'DEPOSITED': return 'سپرده به بانک';
|
||||||
|
case 'CLEARED': return 'پاس/وصول شده';
|
||||||
|
case 'ENDORSED': return 'واگذار شده';
|
||||||
|
case 'RETURNED': return 'عودت شده';
|
||||||
|
case 'BOUNCED': return 'برگشت خورده';
|
||||||
|
case 'CANCELLED': return 'ابطال';
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
},
|
||||||
|
),
|
||||||
ActionColumn('actions', t.actions, actions: [
|
ActionColumn('actions', t.actions, actions: [
|
||||||
DataTableAction(
|
DataTableAction(
|
||||||
icon: Icons.edit,
|
icon: Icons.edit,
|
||||||
|
|
@ -122,10 +152,87 @@ class _ChecksPageState extends State<ChecksPage> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.arrow_forward,
|
||||||
|
label: 'واگذاری',
|
||||||
|
onTap: (row) {
|
||||||
|
final type = (row['type'] ?? '').toString();
|
||||||
|
final status = (row['status'] ?? '').toString();
|
||||||
|
final can = type == 'received' && (status.isEmpty || ['RECEIVED_ON_HAND','RETURNED','BOUNCED'].contains(status));
|
||||||
|
if (can) {
|
||||||
|
_openEndorseDialog(context, row as Map<String, dynamic>);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این عملیات برای وضعیت فعلی مجاز نیست')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.check_circle,
|
||||||
|
label: 'وصول',
|
||||||
|
onTap: (row) {
|
||||||
|
final type = (row['type'] ?? '').toString();
|
||||||
|
final status = (row['status'] ?? '').toString();
|
||||||
|
if (type == 'received' && status != 'CLEARED') {
|
||||||
|
_openClearDialog(context, row as Map<String, dynamic>);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این عملیات برای این چک قابل انجام نیست')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.payment,
|
||||||
|
label: 'پرداخت',
|
||||||
|
onTap: (row) {
|
||||||
|
final type = (row['type'] ?? '').toString();
|
||||||
|
final status = (row['status'] ?? '').toString();
|
||||||
|
if (type == 'transferred' && status != 'CLEARED') {
|
||||||
|
_openPayDialog(context, row as Map<String, dynamic>);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این عملیات برای این چک قابل انجام نیست')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.reply,
|
||||||
|
label: 'عودت',
|
||||||
|
onTap: (row) {
|
||||||
|
final status = (row['status'] ?? '').toString();
|
||||||
|
if (status != 'CLEARED') {
|
||||||
|
_confirmReturn(context, row as Map<String, dynamic>);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این چک قبلاً پاس شده است')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.block,
|
||||||
|
label: 'برگشت',
|
||||||
|
onTap: (row) {
|
||||||
|
final status = (row['status'] ?? '').toString();
|
||||||
|
if (status != 'CLEARED') {
|
||||||
|
_confirmBounce(context, row as Map<String, dynamic>);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این چک قبلاً پاس شده است')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.account_balance,
|
||||||
|
label: 'سپرده',
|
||||||
|
onTap: (row) {
|
||||||
|
final type = (row['type'] ?? '').toString();
|
||||||
|
final status = (row['status'] ?? '').toString();
|
||||||
|
if (type == 'received' && (status.isEmpty || status == 'RECEIVED_ON_HAND')) {
|
||||||
|
_confirmDeposit(context, row as Map<String, dynamic>);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این عملیات برای وضعیت فعلی مجاز نیست')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
searchFields: ['check_number','sayad_code','bank_name','branch_name','person_name'],
|
searchFields: ['check_number','sayad_code','bank_name','branch_name','person_name'],
|
||||||
filterFields: ['type','currency','issue_date','due_date'],
|
filterFields: ['type','currency','issue_date','due_date','status'],
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
customHeaderActions: [
|
customHeaderActions: [
|
||||||
// فیلتر شخص
|
// فیلتر شخص
|
||||||
|
|
@ -165,6 +272,158 @@ class _ChecksPageState extends State<ChecksPage> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openEndorseDialog(BuildContext context, Map<String, dynamic> row) async {
|
||||||
|
Person? selected;
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('واگذاری چک به شخص'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 360,
|
||||||
|
child: PersonComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedPerson: selected,
|
||||||
|
onChanged: (p) => selected = p,
|
||||||
|
isRequired: true,
|
||||||
|
label: 'شخص مقصد',
|
||||||
|
hintText: 'انتخاب شخص',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('انصراف')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (selected == null) return;
|
||||||
|
try {
|
||||||
|
await _checkService.endorse(checkId: row['id'] as int, body: {
|
||||||
|
'target_person_id': (selected as dynamic).id,
|
||||||
|
'auto_post': true,
|
||||||
|
});
|
||||||
|
if (mounted) Navigator.pop(ctx);
|
||||||
|
_refresh();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) Navigator.pop(ctx);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('ثبت'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openClearDialog(BuildContext context, Map<String, dynamic> row) async {
|
||||||
|
BankAccountOption? selected;
|
||||||
|
final currencyId = row['currency_id'] as int?;
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('وصول چک به بانک'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: BankAccountComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedAccountId: null,
|
||||||
|
filterCurrencyId: currencyId,
|
||||||
|
onChanged: (opt) => selected = opt,
|
||||||
|
label: 'حساب بانکی',
|
||||||
|
hintText: 'انتخاب حساب بانکی',
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('انصراف')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
if (selected == null || (selected!.id).isEmpty) return;
|
||||||
|
try {
|
||||||
|
await _checkService.clear(checkId: row['id'] as int, body: {
|
||||||
|
'bank_account_id': int.tryParse(selected!.id) ?? 0,
|
||||||
|
'auto_post': true,
|
||||||
|
});
|
||||||
|
if (mounted) Navigator.pop(ctx);
|
||||||
|
_refresh();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) Navigator.pop(ctx);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('ثبت'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openPayDialog(BuildContext context, Map<String, dynamic> row) async {
|
||||||
|
// پرداخت چک پرداختنی (pay)
|
||||||
|
await _openClearDialog(context, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmReturn(BuildContext context, Map<String, dynamic> row) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('عودت چک'),
|
||||||
|
content: const Text('آیا از عودت این چک مطمئن هستید؟'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('خیر')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('بله')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
try {
|
||||||
|
await _checkService.returnCheck(checkId: row['id'] as int, body: {'auto_post': true});
|
||||||
|
_refresh();
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmBounce(BuildContext context, Map<String, dynamic> row) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('برگشت چک'),
|
||||||
|
content: const Text('آیا از برگشت این چک مطمئن هستید؟'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('خیر')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('بله')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
try {
|
||||||
|
await _checkService.bounce(checkId: row['id'] as int, body: {'auto_post': true});
|
||||||
|
_refresh();
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeposit(BuildContext context, Map<String, dynamic> row) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('سپرده چک به بانک'),
|
||||||
|
content: const Text('چک به اسناد در جریان وصول منتقل میشود.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('انصراف')),
|
||||||
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('تایید')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
try {
|
||||||
|
await _checkService.deposit(checkId: row['id'] as int, body: {'auto_post': true});
|
||||||
|
_refresh();
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
529
hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart
Normal file
529
hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart
Normal file
|
|
@ -0,0 +1,529 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/data_table/data_table_config.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import 'package:hesabix_ui/core/calendar_controller.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/date_input_field.dart';
|
||||||
|
import 'package:hesabix_ui/models/person_model.dart';
|
||||||
|
import 'package:hesabix_ui/models/account_model.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/invoice/person_combobox_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/invoice/product_combobox_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/invoice/bank_account_combobox_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/invoice/cash_register_combobox_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/invoice/petty_cash_combobox_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/invoice/account_tree_combobox_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/invoice/check_combobox_widget.dart';
|
||||||
|
import 'package:hesabix_ui/core/api_client.dart';
|
||||||
|
import 'package:hesabix_ui/services/business_dashboard_service.dart';
|
||||||
|
|
||||||
|
class KardexPage extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final CalendarController calendarController;
|
||||||
|
const KardexPage({super.key, required this.businessId, required this.calendarController});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<KardexPage> createState() => _KardexPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KardexPageState extends State<KardexPage> {
|
||||||
|
final GlobalKey _tableKey = GlobalKey();
|
||||||
|
|
||||||
|
// Simple filter inputs (initial version)
|
||||||
|
DateTime? _fromDate;
|
||||||
|
DateTime? _toDate;
|
||||||
|
String _matchMode = 'any';
|
||||||
|
String _resultScope = 'lines_matching';
|
||||||
|
bool _includeRunningBalance = false;
|
||||||
|
int? _selectedFiscalYearId;
|
||||||
|
List<Map<String, dynamic>> _fiscalYears = const [];
|
||||||
|
|
||||||
|
// Multi-select state
|
||||||
|
final List<Person> _selectedPersons = [];
|
||||||
|
final List<Map<String, dynamic>> _selectedProducts = [];
|
||||||
|
final List<BankAccountOption> _selectedBankAccounts = [];
|
||||||
|
final List<CashRegisterOption> _selectedCashRegisters = [];
|
||||||
|
final List<PettyCashOption> _selectedPettyCash = [];
|
||||||
|
final List<Account> _selectedAccounts = [];
|
||||||
|
final List<CheckOption> _selectedChecks = [];
|
||||||
|
// Initial filters from URL
|
||||||
|
List<int> _initialPersonIds = const [];
|
||||||
|
|
||||||
|
// Temp selections for pickers (to clear after add)
|
||||||
|
Person? _personToAdd;
|
||||||
|
Map<String, dynamic>? _productToAdd;
|
||||||
|
BankAccountOption? _bankToAdd;
|
||||||
|
CashRegisterOption? _cashToAdd;
|
||||||
|
PettyCashOption? _pettyToAdd;
|
||||||
|
Account? _accountToAdd;
|
||||||
|
CheckOption? _checkToAdd;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshData() {
|
||||||
|
final state = _tableKey.currentState;
|
||||||
|
if (state != null) {
|
||||||
|
try {
|
||||||
|
(state as dynamic).refresh();
|
||||||
|
return;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _additionalParams() {
|
||||||
|
String? fmt(DateTime? d) => d == null ? null : d.toIso8601String().substring(0, 10);
|
||||||
|
var personIds = _selectedPersons.map((p) => p.id).whereType<int>().toList();
|
||||||
|
if (personIds.isEmpty && _initialPersonIds.isNotEmpty) {
|
||||||
|
personIds = List<int>.from(_initialPersonIds);
|
||||||
|
}
|
||||||
|
final productIds = _selectedProducts.map((m) => m['id']).map((e) => int.tryParse('$e')).whereType<int>().toList();
|
||||||
|
final bankIds = _selectedBankAccounts.map((b) => int.tryParse(b.id)).whereType<int>().toList();
|
||||||
|
final cashIds = _selectedCashRegisters.map((c) => int.tryParse(c.id)).whereType<int>().toList();
|
||||||
|
final pettyIds = _selectedPettyCash.map((p) => int.tryParse(p.id)).whereType<int>().toList();
|
||||||
|
final accountIds = _selectedAccounts.map((a) => a.id).whereType<int>().toList();
|
||||||
|
final checkIds = _selectedChecks.map((c) => int.tryParse(c.id)).whereType<int>().toList();
|
||||||
|
|
||||||
|
return {
|
||||||
|
if (_fromDate != null) 'from_date': fmt(_fromDate),
|
||||||
|
if (_toDate != null) 'to_date': fmt(_toDate),
|
||||||
|
'person_ids': personIds,
|
||||||
|
'product_ids': productIds,
|
||||||
|
'bank_account_ids': bankIds,
|
||||||
|
'cash_register_ids': cashIds,
|
||||||
|
'petty_cash_ids': pettyIds,
|
||||||
|
'account_ids': accountIds,
|
||||||
|
'check_ids': checkIds,
|
||||||
|
'match_mode': _matchMode,
|
||||||
|
'result_scope': _resultScope,
|
||||||
|
'include_running_balance': _includeRunningBalance,
|
||||||
|
if (_selectedFiscalYearId != null) 'fiscal_year_id': _selectedFiscalYearId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTableConfig<Map<String, dynamic>> _buildTableConfig(AppLocalizations t) {
|
||||||
|
return DataTableConfig<Map<String, dynamic>>(
|
||||||
|
endpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines',
|
||||||
|
excelEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/excel',
|
||||||
|
pdfEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/pdf',
|
||||||
|
columns: [
|
||||||
|
DateColumn('document_date', 'تاریخ سند',
|
||||||
|
formatter: (item) => (item as Map<String, dynamic>)['document_date']?.toString()),
|
||||||
|
TextColumn('document_code', 'کد سند',
|
||||||
|
formatter: (item) => (item as Map<String, dynamic>)['document_code']?.toString()),
|
||||||
|
TextColumn('document_type', 'نوع سند',
|
||||||
|
formatter: (item) => (item as Map<String, dynamic>)['document_type']?.toString()),
|
||||||
|
TextColumn('description', 'شرح',
|
||||||
|
formatter: (item) => (item as Map<String, dynamic>)['description']?.toString()),
|
||||||
|
NumberColumn('debit', 'بدهکار',
|
||||||
|
formatter: (item) => ((item as Map<String, dynamic>)['debit'])?.toString()),
|
||||||
|
NumberColumn('credit', 'بستانکار',
|
||||||
|
formatter: (item) => ((item as Map<String, dynamic>)['credit'])?.toString()),
|
||||||
|
NumberColumn('quantity', 'تعداد',
|
||||||
|
formatter: (item) => ((item as Map<String, dynamic>)['quantity'])?.toString()),
|
||||||
|
NumberColumn('running_amount', 'مانده مبلغ',
|
||||||
|
formatter: (item) => ((item as Map<String, dynamic>)['running_amount'])?.toString()),
|
||||||
|
NumberColumn('running_quantity', 'مانده تعداد',
|
||||||
|
formatter: (item) => ((item as Map<String, dynamic>)['running_quantity'])?.toString()),
|
||||||
|
],
|
||||||
|
searchFields: const [],
|
||||||
|
defaultPageSize: 20,
|
||||||
|
additionalParams: _additionalParams(),
|
||||||
|
showExportButtons: true,
|
||||||
|
getExportParams: () => _additionalParams(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadFiscalYears();
|
||||||
|
_parseInitialQueryParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseInitialQueryParams() {
|
||||||
|
try {
|
||||||
|
final uri = Uri.base;
|
||||||
|
final single = int.tryParse(uri.queryParameters['person_id'] ?? '');
|
||||||
|
final multi = uri.queryParametersAll['person_id']?.map((e) => int.tryParse(e)).whereType<int>().toList() ?? const <int>[];
|
||||||
|
final s = <int>{};
|
||||||
|
if (single != null) s.add(single);
|
||||||
|
s.addAll(multi);
|
||||||
|
// در initState مقدار را مستقیم ست میکنیم تا اولین build همان فیلتر را ارسال کند
|
||||||
|
_initialPersonIds = s.toList();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFiscalYears() async {
|
||||||
|
try {
|
||||||
|
final svc = BusinessDashboardService(ApiClient());
|
||||||
|
final items = await svc.listFiscalYears(widget.businessId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_fiscalYears = items;
|
||||||
|
final current = items.firstWhere(
|
||||||
|
(e) => (e['is_current'] == true),
|
||||||
|
orElse: () => const <String, dynamic>{},
|
||||||
|
);
|
||||||
|
final id = current['id'];
|
||||||
|
if (id is int) {
|
||||||
|
_selectedFiscalYearId = id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// ignore errors; dropdown remains empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
body: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildFilters(t),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildTableArea(t),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilters(AppLocalizations t) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.all(0),
|
||||||
|
elevation: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: DateInputField(
|
||||||
|
labelText: 'از تاریخ',
|
||||||
|
value: _fromDate,
|
||||||
|
onChanged: (d) => setState(() => _fromDate = d),
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: DateInputField(
|
||||||
|
labelText: 'تا تاریخ',
|
||||||
|
value: _toDate,
|
||||||
|
onChanged: (d) => setState(() => _toDate = d),
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 220,
|
||||||
|
child: DropdownButtonFormField<int>(
|
||||||
|
value: _selectedFiscalYearId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'سال مالی',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
items: _fiscalYears.map<DropdownMenuItem<int>>((fy) {
|
||||||
|
final id = fy['id'] as int?;
|
||||||
|
final title = (fy['title'] ?? '').toString();
|
||||||
|
return DropdownMenuItem<int>(
|
||||||
|
value: id,
|
||||||
|
child: Text(title.isNotEmpty ? title : 'FY ${id ?? ''}'),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() => _selectedFiscalYearId = val);
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_chipsSection(
|
||||||
|
label: 'اشخاص',
|
||||||
|
chips: _selectedPersons.map((p) => _ChipData(id: p.id!, label: p.displayName)).toList(),
|
||||||
|
onRemove: (id) {
|
||||||
|
setState(() => _selectedPersons.removeWhere((p) => p.id == id));
|
||||||
|
},
|
||||||
|
picker: SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: PersonComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedPerson: _personToAdd,
|
||||||
|
onChanged: (person) {
|
||||||
|
if (person == null) return;
|
||||||
|
final exists = _selectedPersons.any((p) => p.id == person.id);
|
||||||
|
setState(() {
|
||||||
|
if (!exists) _selectedPersons.add(person);
|
||||||
|
_personToAdd = null;
|
||||||
|
});
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
hintText: 'افزودن شخص',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_chipsSection(
|
||||||
|
label: 'کالا/خدمت',
|
||||||
|
chips: _selectedProducts.map((m) {
|
||||||
|
final id = int.tryParse('${m['id']}') ?? 0;
|
||||||
|
final code = (m['code'] ?? '').toString();
|
||||||
|
final name = (m['name'] ?? '').toString();
|
||||||
|
return _ChipData(id: id, label: code.isNotEmpty ? '$code - $name' : name);
|
||||||
|
}).toList(),
|
||||||
|
onRemove: (id) => setState(() => _selectedProducts.removeWhere((m) => int.tryParse('${m['id']}') == id)),
|
||||||
|
picker: SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: ProductComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedProduct: _productToAdd,
|
||||||
|
onChanged: (prod) {
|
||||||
|
if (prod == null) return;
|
||||||
|
final pid = int.tryParse('${prod['id']}');
|
||||||
|
final exists = _selectedProducts.any((m) => int.tryParse('${m['id']}') == pid);
|
||||||
|
setState(() {
|
||||||
|
if (!exists) _selectedProducts.add(prod);
|
||||||
|
_productToAdd = null;
|
||||||
|
});
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_chipsSection(
|
||||||
|
label: 'بانک',
|
||||||
|
chips: _selectedBankAccounts.map((b) => _ChipData(id: int.tryParse(b.id) ?? 0, label: b.name)).toList(),
|
||||||
|
onRemove: (id) => setState(() => _selectedBankAccounts.removeWhere((b) => int.tryParse(b.id) == id)),
|
||||||
|
picker: SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: BankAccountComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedAccountId: _bankToAdd?.id,
|
||||||
|
onChanged: (opt) {
|
||||||
|
if (opt == null) return;
|
||||||
|
final exists = _selectedBankAccounts.any((b) => b.id == opt.id);
|
||||||
|
setState(() {
|
||||||
|
if (!exists) _selectedBankAccounts.add(opt);
|
||||||
|
_bankToAdd = null;
|
||||||
|
});
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
hintText: 'افزودن حساب بانکی',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_chipsSection(
|
||||||
|
label: 'صندوق',
|
||||||
|
chips: _selectedCashRegisters.map((c) => _ChipData(id: int.tryParse(c.id) ?? 0, label: c.name)).toList(),
|
||||||
|
onRemove: (id) => setState(() => _selectedCashRegisters.removeWhere((c) => int.tryParse(c.id) == id)),
|
||||||
|
picker: SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: CashRegisterComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedRegisterId: _cashToAdd?.id,
|
||||||
|
onChanged: (opt) {
|
||||||
|
if (opt == null) return;
|
||||||
|
final exists = _selectedCashRegisters.any((c) => c.id == opt.id);
|
||||||
|
setState(() {
|
||||||
|
if (!exists) _selectedCashRegisters.add(opt);
|
||||||
|
_cashToAdd = null;
|
||||||
|
});
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
hintText: 'افزودن صندوق',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_chipsSection(
|
||||||
|
label: 'تنخواه',
|
||||||
|
chips: _selectedPettyCash.map((p) => _ChipData(id: int.tryParse(p.id) ?? 0, label: p.name)).toList(),
|
||||||
|
onRemove: (id) => setState(() => _selectedPettyCash.removeWhere((p) => int.tryParse(p.id) == id)),
|
||||||
|
picker: SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: PettyCashComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedPettyCashId: _pettyToAdd?.id,
|
||||||
|
onChanged: (opt) {
|
||||||
|
if (opt == null) return;
|
||||||
|
final exists = _selectedPettyCash.any((p) => p.id == opt.id);
|
||||||
|
setState(() {
|
||||||
|
if (!exists) _selectedPettyCash.add(opt);
|
||||||
|
_pettyToAdd = null;
|
||||||
|
});
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
hintText: 'افزودن تنخواه',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_chipsSection(
|
||||||
|
label: 'حساب دفتری',
|
||||||
|
chips: _selectedAccounts.map((a) => _ChipData(id: a.id!, label: '${a.code} - ${a.name}')).toList(),
|
||||||
|
onRemove: (id) => setState(() => _selectedAccounts.removeWhere((a) => a.id == id)),
|
||||||
|
picker: SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: AccountTreeComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedAccount: _accountToAdd,
|
||||||
|
onChanged: (acc) {
|
||||||
|
if (acc == null) return;
|
||||||
|
final exists = _selectedAccounts.any((a) => a.id == acc.id);
|
||||||
|
setState(() {
|
||||||
|
if (!exists) _selectedAccounts.add(acc);
|
||||||
|
_accountToAdd = null;
|
||||||
|
});
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
hintText: 'افزودن حساب',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_chipsSection(
|
||||||
|
label: 'چک',
|
||||||
|
chips: _selectedChecks.map((c) => _ChipData(id: int.tryParse(c.id) ?? 0, label: c.number.isNotEmpty ? c.number : 'چک #${c.id}')).toList(),
|
||||||
|
onRemove: (id) => setState(() => _selectedChecks.removeWhere((c) => int.tryParse(c.id) == id)),
|
||||||
|
picker: SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: CheckComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCheckId: _checkToAdd?.id,
|
||||||
|
onChanged: (opt) {
|
||||||
|
if (opt == null) return;
|
||||||
|
final exists = _selectedChecks.any((c) => c.id == opt.id);
|
||||||
|
setState(() {
|
||||||
|
if (!exists) _selectedChecks.add(opt);
|
||||||
|
_checkToAdd = null;
|
||||||
|
});
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownButton<String>(
|
||||||
|
value: _matchMode,
|
||||||
|
onChanged: (v) => setState(() => _matchMode = v ?? 'any'),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'any', child: Text('هرکدام')),
|
||||||
|
DropdownMenuItem(value: 'same_line', child: Text('همزمان در یک خط')),
|
||||||
|
DropdownMenuItem(value: 'document_and', child: Text('همزمان در یک سند')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
DropdownButton<String>(
|
||||||
|
value: _resultScope,
|
||||||
|
onChanged: (v) => setState(() => _resultScope = v ?? 'lines_matching'),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'lines_matching', child: Text('فقط خطوط منطبق')),
|
||||||
|
DropdownMenuItem(value: 'lines_of_document', child: Text('کل خطوط سند')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Switch(value: _includeRunningBalance, onChanged: (v) => setState(() => _includeRunningBalance = v)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Text('مانده تجمعی'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _refreshData,
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
label: const Text('اعمال فیلتر'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTableArea(AppLocalizations t) {
|
||||||
|
final screenH = MediaQuery.of(context).size.height;
|
||||||
|
// حداقل ارتفاع مناسب برای جدول؛ اگر فضا کمتر بود، صفحه اسکرول میخورد
|
||||||
|
final tableHeight = screenH - 280.0; // تقریبی با احتساب فیلترها و پدینگ
|
||||||
|
final effectiveHeight = tableHeight < 420 ? 420.0 : tableHeight;
|
||||||
|
return SizedBox(
|
||||||
|
height: effectiveHeight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
|
||||||
|
child: DataTableWidget<Map<String, dynamic>>(
|
||||||
|
key: _tableKey,
|
||||||
|
config: _buildTableConfig(t),
|
||||||
|
fromJson: (json) => Map<String, dynamic>.from(json as Map),
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chips helpers
|
||||||
|
Widget _chipsSection({
|
||||||
|
required String label,
|
||||||
|
required List<_ChipData> chips,
|
||||||
|
required void Function(int id) onRemove,
|
||||||
|
required Widget picker,
|
||||||
|
}) {
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 900),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 90,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
child: Text(label, textAlign: TextAlign.right),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
_chips(items: chips, onRemove: onRemove),
|
||||||
|
picker,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _chips({
|
||||||
|
required List<_ChipData> items,
|
||||||
|
required void Function(int id) onRemove,
|
||||||
|
}) {
|
||||||
|
if (items.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: items
|
||||||
|
.map((it) => Chip(
|
||||||
|
label: Text(it.label),
|
||||||
|
onDeleted: () => onRemove(it.id),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChipData {
|
||||||
|
final int id;
|
||||||
|
final String label;
|
||||||
|
_ChipData({required this.id, required this.label});
|
||||||
|
}
|
||||||
|
|
||||||
|
// _DateBox حذف شد و با DateInputField جایگزین شد
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import 'package:hesabix_ui/core/api_client.dart';
|
import 'package:hesabix_ui/core/api_client.dart';
|
||||||
|
|
@ -65,12 +66,27 @@ class _PersonsPageState extends State<PersonsPage> {
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
enableMultiRowSelection: true,
|
enableMultiRowSelection: true,
|
||||||
columns: [
|
columns: [
|
||||||
NumberColumn(
|
CustomColumn(
|
||||||
'code',
|
'code',
|
||||||
t.personCode,
|
t.personCode,
|
||||||
width: ColumnWidth.small,
|
width: ColumnWidth.small,
|
||||||
|
sortable: true,
|
||||||
formatter: (person) => (person.code?.toString() ?? '-'),
|
formatter: (person) => (person.code?.toString() ?? '-'),
|
||||||
textAlign: TextAlign.center,
|
builder: (person, index) {
|
||||||
|
final codeText = person.code?.toString() ?? '-';
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
if (person.id != null) {
|
||||||
|
context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
codeText,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(decoration: TextDecoration.underline),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
TextColumn(
|
TextColumn(
|
||||||
'alias_name',
|
'alias_name',
|
||||||
|
|
@ -316,6 +332,20 @@ class _PersonsPageState extends State<PersonsPage> {
|
||||||
'actions',
|
'actions',
|
||||||
t.actions,
|
t.actions,
|
||||||
actions: [
|
actions: [
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.view_kanban,
|
||||||
|
label: 'کاردکس',
|
||||||
|
onTap: (person) {
|
||||||
|
if (person is Person && person.id != null) {
|
||||||
|
context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}');
|
||||||
|
} else if (person is Map<String, dynamic>) {
|
||||||
|
final id = person['id'];
|
||||||
|
if (id is int) {
|
||||||
|
context.go('/business/${widget.businessId}/reports/kardex?person_id=$id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
DataTableAction(
|
DataTableAction(
|
||||||
icon: Icons.edit,
|
icon: Icons.edit,
|
||||||
label: t.edit,
|
label: t.edit,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/auth_store.dart';
|
import '../../core/auth_store.dart';
|
||||||
import '../../widgets/permission/access_denied_page.dart';
|
import '../../widgets/permission/access_denied_page.dart';
|
||||||
|
|
||||||
|
|
@ -27,6 +28,22 @@ class ReportsPage extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
_buildSection(
|
||||||
|
context,
|
||||||
|
title: 'گزارشات عمومی',
|
||||||
|
icon: Icons.assessment,
|
||||||
|
children: [
|
||||||
|
_buildReportItem(
|
||||||
|
context,
|
||||||
|
title: 'کاردکس اسناد',
|
||||||
|
subtitle: 'نمایش ریز تراکنشها بر اساس شخص/کالا/بانک/حساب/چک با فیلتر تاریخ',
|
||||||
|
icon: Icons.view_kanban,
|
||||||
|
onTap: () => context.go('/business/$businessId/reports/kardex'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
_buildSection(
|
_buildSection(
|
||||||
context,
|
context,
|
||||||
title: 'گزارشات اشخاص',
|
title: 'گزارشات اشخاص',
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,37 @@ class CheckService {
|
||||||
options: Options(responseType: ResponseType.bytes),
|
options: Options(responseType: ResponseType.bytes),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Actions =====
|
||||||
|
Future<Map<String, dynamic>> endorse({required int checkId, required Map<String, dynamic> body}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/endorse', data: body);
|
||||||
|
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> clear({required int checkId, required Map<String, dynamic> body}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/clear', data: body);
|
||||||
|
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> pay({required int checkId, required Map<String, dynamic> body}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/pay', data: body);
|
||||||
|
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> returnCheck({required int checkId, required Map<String, dynamic> body}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/return', data: body);
|
||||||
|
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> bounce({required int checkId, required Map<String, dynamic> body}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/bounce', data: body);
|
||||||
|
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> deposit({required int checkId, required Map<String, dynamic> body}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/deposit', data: body);
|
||||||
|
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
35
hesabixUI/hesabix_ui/lib/services/kardex_service.dart
Normal file
35
hesabixUI/hesabix_ui/lib/services/kardex_service.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import '../core/api_client.dart';
|
||||||
|
|
||||||
|
class KardexService {
|
||||||
|
final ApiClient _client;
|
||||||
|
KardexService({ApiClient? client}) : _client = client ?? ApiClient();
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> listLines({
|
||||||
|
required int businessId,
|
||||||
|
required Map<String, dynamic> queryInfo,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/kardex/businesses/$businessId/lines',
|
||||||
|
data: queryInfo,
|
||||||
|
);
|
||||||
|
return res.data ?? <String, dynamic>{};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
'items': <dynamic>[],
|
||||||
|
'pagination': {
|
||||||
|
'total': 0,
|
||||||
|
'page': 1,
|
||||||
|
'per_page': queryInfo['take'] ?? 20,
|
||||||
|
'total_pages': 0,
|
||||||
|
'has_next': false,
|
||||||
|
'has_prev': false,
|
||||||
|
},
|
||||||
|
'query_info': queryInfo,
|
||||||
|
'error': e.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../services/check_service.dart';
|
||||||
|
|
||||||
|
class CheckOption {
|
||||||
|
final String id;
|
||||||
|
final String number;
|
||||||
|
final String? personName;
|
||||||
|
final String? bankName;
|
||||||
|
final String? sayadCode;
|
||||||
|
const CheckOption({
|
||||||
|
required this.id,
|
||||||
|
required this.number,
|
||||||
|
this.personName,
|
||||||
|
this.bankName,
|
||||||
|
this.sayadCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class CheckComboboxWidget extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final String? selectedCheckId;
|
||||||
|
final ValueChanged<CheckOption?> onChanged;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
|
||||||
|
const CheckComboboxWidget({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.onChanged,
|
||||||
|
this.selectedCheckId,
|
||||||
|
this.label = 'چک',
|
||||||
|
this.hintText = 'جستوجو و انتخاب چک',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CheckComboboxWidget> createState() => _CheckComboboxWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckComboboxWidgetState extends State<CheckComboboxWidget> {
|
||||||
|
final CheckService _service = CheckService();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
void Function(void Function())? _setModalState;
|
||||||
|
|
||||||
|
List<CheckOption> _items = <CheckOption>[];
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isSearching = false;
|
||||||
|
bool _hasSearched = false;
|
||||||
|
int _seq = 0;
|
||||||
|
String _latestQuery = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
await _performSearch('');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String q) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSearch(String query) async {
|
||||||
|
final int seq = ++_seq;
|
||||||
|
_latestQuery = query;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = true;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = true;
|
||||||
|
_hasSearched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await _service.list(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
queryInfo: {
|
||||||
|
'take': query.isEmpty ? 50 : 20,
|
||||||
|
'skip': 0,
|
||||||
|
if (query.isNotEmpty) 'search': query,
|
||||||
|
if (query.isNotEmpty) 'search_fields': ['check_number', 'sayad_code', 'person_name'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (seq != _seq || query != _latestQuery) return;
|
||||||
|
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) {
|
||||||
|
final m = Map<String, dynamic>.from(e as Map);
|
||||||
|
return CheckOption(
|
||||||
|
id: '${m['id']}',
|
||||||
|
number: (m['check_number'] ?? '').toString(),
|
||||||
|
personName: (m['person_name'] ?? m['holder_name'])?.toString(),
|
||||||
|
bankName: (m['bank_name'] ?? '').toString(),
|
||||||
|
sayadCode: (m['sayad_code'] ?? '').toString(),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = items;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
} catch (e) {
|
||||||
|
if (seq != _seq || query != _latestQuery) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = <CheckOption>[];
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasSearched = false;
|
||||||
|
} else {
|
||||||
|
_isSearching = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_setModalState?.call(() {});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دریافت لیست چکها: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
_setModalState = setModalState;
|
||||||
|
return _CheckPickerBottomSheet(
|
||||||
|
label: widget.label,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
items: _items,
|
||||||
|
searchController: _searchController,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
isSearching: _isSearching,
|
||||||
|
hasSearched: _hasSearched,
|
||||||
|
onSearchChanged: _onSearchChanged,
|
||||||
|
onSelected: (opt) {
|
||||||
|
widget.onChanged(opt);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final selected = _items.firstWhere(
|
||||||
|
(e) => e.id == widget.selectedCheckId,
|
||||||
|
orElse: () => const CheckOption(id: '', number: ''),
|
||||||
|
);
|
||||||
|
final text = (widget.selectedCheckId != null && widget.selectedCheckId!.isNotEmpty)
|
||||||
|
? (selected.number.isNotEmpty ? selected.number : widget.hintText)
|
||||||
|
: widget.hintText;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: _openPicker,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.receipt_long, color: theme.colorScheme.primary, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.arrow_drop_down, color: theme.colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckPickerBottomSheet extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
final List<CheckOption> items;
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final bool isLoading;
|
||||||
|
final bool isSearching;
|
||||||
|
final bool hasSearched;
|
||||||
|
final ValueChanged<String> onSearchChanged;
|
||||||
|
final ValueChanged<CheckOption?> onSelected;
|
||||||
|
|
||||||
|
const _CheckPickerBottomSheet({
|
||||||
|
required this.label,
|
||||||
|
required this.hintText,
|
||||||
|
required this.items,
|
||||||
|
required this.searchController,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.isSearching,
|
||||||
|
required this.hasSearched,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
return Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.7,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hintText,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
suffixIcon: isSearching
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: onSearchChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: items.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.receipt_long, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
hasSearched ? 'چکی با این مشخصات یافت نشد' : 'چکی ثبت نشده است',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final it = items[index];
|
||||||
|
final subtitle = [
|
||||||
|
if ((it.personName ?? '').isNotEmpty) it.personName,
|
||||||
|
if ((it.bankName ?? '').isNotEmpty) it.bankName,
|
||||||
|
if ((it.sayadCode ?? '').isNotEmpty) 'صیاد: ${it.sayadCode}',
|
||||||
|
].whereType<String>().join(' | ');
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: colorScheme.primaryContainer,
|
||||||
|
child: Icon(Icons.receipt_long, color: colorScheme.onPrimaryContainer),
|
||||||
|
),
|
||||||
|
title: Text(it.number.isNotEmpty ? it.number : 'چک #${it.id}'),
|
||||||
|
subtitle: subtitle.isNotEmpty ? Text(subtitle) : null,
|
||||||
|
onTap: () => onSelected(it),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue