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 (
|
||||
CheckCreateRequest,
|
||||
CheckUpdateRequest,
|
||||
CheckEndorseRequest,
|
||||
CheckClearRequest,
|
||||
CheckReturnRequest,
|
||||
CheckBounceRequest,
|
||||
CheckPayRequest,
|
||||
CheckDepositRequest,
|
||||
)
|
||||
from app.services.check_service import (
|
||||
create_check,
|
||||
|
|
@ -17,6 +23,12 @@ from app.services.check_service import (
|
|||
delete_check,
|
||||
get_check_by_id,
|
||||
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),
|
||||
):
|
||||
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")
|
||||
@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(
|
||||
|
|
|
|||
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)
|
||||
amount: float = Field(..., gt=0)
|
||||
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')
|
||||
@classmethod
|
||||
|
|
@ -70,3 +74,51 @@ class CheckResponse(BaseModel):
|
|||
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,
|
||||
Enum as SQLEnum,
|
||||
Index,
|
||||
JSON,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -23,6 +24,21 @@ class CheckType(str, Enum):
|
|||
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):
|
||||
__tablename__ = "checks"
|
||||
__table_args__ = (
|
||||
|
|
@ -54,6 +70,14 @@ class Check(Base):
|
|||
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)
|
||||
|
||||
# وضعیت و نگهدارنده
|
||||
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)
|
||||
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")
|
||||
person = relationship("Person", lazy="joined")
|
||||
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.expense_income import router as expense_income_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.error_handlers import register_error_handlers
|
||||
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(documents_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
|
||||
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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 import and_, or_, func
|
||||
|
||||
from adapters.db.models.check import Check, CheckType
|
||||
from adapters.db.models.person import Person
|
||||
from adapters.db.models.check import Check, CheckType, CheckStatus, HolderType
|
||||
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.person import Person
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
if ctype not in ("received", "transferred"):
|
||||
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')),
|
||||
)
|
||||
|
||||
# تعیین وضعیت اولیه
|
||||
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.commit()
|
||||
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]]:
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
# =====================
|
||||
# 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]]:
|
||||
obj = db.query(Check).filter(Check.id == check_id).first()
|
||||
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)
|
||||
except Exception:
|
||||
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 == '=':
|
||||
try:
|
||||
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),
|
||||
"currency_id": obj.currency_id,
|
||||
"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(),
|
||||
"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/health.py
|
||||
adapters/api/v1/invoices.py
|
||||
adapters/api/v1/kardex.py
|
||||
adapters/api/v1/persons.py
|
||||
adapters/api/v1/petty_cash.py
|
||||
adapters/api/v1/price_lists.py
|
||||
|
|
@ -146,6 +147,7 @@ app/services/email_service.py
|
|||
app/services/expense_income_service.py
|
||||
app/services/file_storage_service.py
|
||||
app/services/invoice_service.py
|
||||
app/services/kardex_service.py
|
||||
app/services/person_service.py
|
||||
app/services/petty_cash_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_000501_add_quantity_to_document_lines.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/5553f8745c6e_add_support_tables.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": "واحد پول",
|
||||
"isDefault": "پیشفرض",
|
||||
"description": "توضیحات",
|
||||
"actions": "اقدامات",
|
||||
"actions": "عملیات",
|
||||
"yes": "بله",
|
||||
"no": "خیر",
|
||||
"pettyCash": "تنخواه گردان",
|
||||
|
|
|
|||
|
|
@ -1223,7 +1223,7 @@ class AppLocalizationsFa extends AppLocalizations {
|
|||
String get edit => 'ویرایش';
|
||||
|
||||
@override
|
||||
String get actions => 'اقدامات';
|
||||
String get actions => 'عملیات';
|
||||
|
||||
@override
|
||||
String get search => 'جستجو';
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import 'pages/business/new_invoice_page.dart';
|
|||
import 'pages/business/settings_page.dart';
|
||||
import 'pages/business/business_info_settings_page.dart';
|
||||
import 'pages/business/reports_page.dart';
|
||||
import 'pages/business/kardex_page.dart';
|
||||
import 'pages/business/persons_page.dart';
|
||||
import 'pages/business/product_attributes_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(
|
||||
path: '/business/:business_id/settings',
|
||||
name: 'business_settings',
|
||||
|
|
|
|||
|
|
@ -34,12 +34,15 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
|||
DateTime? _dueDate;
|
||||
int? _currencyId;
|
||||
dynamic _selectedPerson; // using Person type would be ideal; keep dynamic to avoid imports complexity
|
||||
bool _autoPost = false;
|
||||
DateTime? _documentDate;
|
||||
|
||||
final _checkNumberCtrl = TextEditingController();
|
||||
final _sayadCtrl = TextEditingController();
|
||||
final _bankCtrl = TextEditingController();
|
||||
final _branchCtrl = TextEditingController();
|
||||
final _amountCtrl = TextEditingController();
|
||||
final _docDescCtrl = TextEditingController();
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
|
|
@ -50,6 +53,7 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
|||
_currencyId = widget.authStore.selectedCurrencyId;
|
||||
_issueDate = DateTime.now();
|
||||
_dueDate = DateTime.now();
|
||||
_documentDate = _issueDate;
|
||||
if (widget.checkId != null) {
|
||||
_loadData();
|
||||
}
|
||||
|
|
@ -114,6 +118,9 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
|||
if (_branchCtrl.text.trim().isNotEmpty) 'branch_name': _branchCtrl.text.trim(),
|
||||
'amount': num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()),
|
||||
'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) {
|
||||
|
|
@ -152,6 +159,7 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
|||
_bankCtrl.dispose();
|
||||
_branchCtrl.dispose();
|
||||
_amountCtrl.dispose();
|
||||
_docDescCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +167,7 @@ class _CheckFormPageState extends State<CheckFormPage> {
|
|||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final isEdit = widget.checkId != null;
|
||||
final canAccountingWrite = widget.authStore.canWriteSection('accounting');
|
||||
|
||||
if (!widget.authStore.canWriteSection('checks')) {
|
||||
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),
|
||||
Row(
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import '../../widgets/data_table/data_table_config.dart';
|
|||
import '../../widgets/permission/permission_widgets.dart';
|
||||
import '../../widgets/invoice/person_combobox_widget.dart';
|
||||
import '../../models/person_model.dart';
|
||||
import '../../services/check_service.dart';
|
||||
import '../../widgets/invoice/bank_account_combobox_widget.dart';
|
||||
|
||||
class ChecksPage extends StatefulWidget {
|
||||
final int businessId;
|
||||
|
|
@ -25,6 +27,7 @@ class ChecksPage extends StatefulWidget {
|
|||
class _ChecksPageState extends State<ChecksPage> {
|
||||
final GlobalKey _tableKey = GlobalKey();
|
||||
Person? _selectedPerson;
|
||||
final _checkService = CheckService();
|
||||
|
||||
void _refresh() {
|
||||
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
|
||||
|
|
@ -111,6 +114,33 @@ class _ChecksPageState extends State<ChecksPage> {
|
|||
TextColumn('currency', 'ارز', width: ColumnWidth.small,
|
||||
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: [
|
||||
DataTableAction(
|
||||
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'],
|
||||
filterFields: ['type','currency','issue_date','due_date'],
|
||||
filterFields: ['type','currency','issue_date','due_date','status'],
|
||||
defaultPageSize: 20,
|
||||
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:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'package:hesabix_ui/core/api_client.dart';
|
||||
|
|
@ -65,12 +66,27 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
columns: [
|
||||
NumberColumn(
|
||||
CustomColumn(
|
||||
'code',
|
||||
t.personCode,
|
||||
width: ColumnWidth.small,
|
||||
sortable: true,
|
||||
formatter: (person) => (person.code?.toString() ?? '-'),
|
||||
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(
|
||||
'alias_name',
|
||||
|
|
@ -316,6 +332,20 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
'actions',
|
||||
t.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(
|
||||
icon: Icons.edit,
|
||||
label: t.edit,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/auth_store.dart';
|
||||
import '../../widgets/permission/access_denied_page.dart';
|
||||
|
||||
|
|
@ -27,6 +28,22 @@ class ReportsPage extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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(
|
||||
context,
|
||||
title: 'گزارشات اشخاص',
|
||||
|
|
|
|||
|
|
@ -67,6 +67,37 @@ class CheckService {
|
|||
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