progress in cardex

This commit is contained in:
Hesabix 2025-11-03 12:24:44 +00:00
parent b7a860e3e5
commit 8f4248e83f
21 changed files with 2795 additions and 11 deletions

View file

@ -10,6 +10,12 @@ from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.schema_models.check import ( from adapters.api.v1.schema_models.check import (
CheckCreateRequest, CheckCreateRequest,
CheckUpdateRequest, CheckUpdateRequest,
CheckEndorseRequest,
CheckClearRequest,
CheckReturnRequest,
CheckBounceRequest,
CheckPayRequest,
CheckDepositRequest,
) )
from app.services.check_service import ( from app.services.check_service import (
create_check, create_check,
@ -17,6 +23,12 @@ from app.services.check_service import (
delete_check, delete_check,
get_check_by_id, get_check_by_id,
list_checks, list_checks,
endorse_check,
clear_check,
return_check,
bounce_check,
pay_check,
deposit_check,
) )
@ -82,8 +94,183 @@ async def create_check_endpoint(
_: None = Depends(require_business_management_dep), _: None = Depends(require_business_management_dep),
): ):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True) payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
created = create_check(db, business_id, payload) # اگر کاربر درخواست ثبت سند همزمان داد، باید دسترسی نوشتن حسابداری داشته باشد
try:
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
except Exception:
# در صورت هرگونه خطای غیرمنتظره در بررسی، اجازه ادامه نمی‌دهیم
raise
created = create_check(db, business_id, ctx.get_user_id(), payload)
return success_response(data=format_datetime_fields(created, request), request=request, message="CHECK_CREATED") return success_response(data=format_datetime_fields(created, request), request=request, message="CHECK_CREATED")
@router.post(
"/checks/{check_id}/actions/endorse",
summary="واگذاری چک دریافتی به شخص",
description="واگذاری چک دریافتی به شخص دیگر",
)
async def endorse_check_endpoint(
request: Request,
check_id: int,
body: CheckEndorseRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
# access check
before = get_check_by_id(db, check_id)
if not before:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
try:
biz_id = int(before.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
result = endorse_check(db, check_id, ctx.get_user_id(), payload)
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_ENDORSED")
@router.post(
"/checks/{check_id}/actions/clear",
summary="وصول/پاس چک",
description="انتقال حساب چک به بانک در زمان پاس/وصول",
)
async def clear_check_endpoint(
request: Request,
check_id: int,
body: CheckClearRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
before = get_check_by_id(db, check_id)
if not before:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
try:
biz_id = int(before.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
result = clear_check(db, check_id, ctx.get_user_id(), payload)
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_CLEARED")
@router.post(
"/checks/{check_id}/actions/return",
summary="عودت چک",
description="عودت چک به طرف مقابل",
)
async def return_check_endpoint(
request: Request,
check_id: int,
body: CheckReturnRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
before = get_check_by_id(db, check_id)
if not before:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
try:
biz_id = int(before.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
result = return_check(db, check_id, ctx.get_user_id(), payload)
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_RETURNED")
@router.post(
"/checks/{check_id}/actions/bounce",
summary="برگشت چک",
description="برگشت چک و ثبت هزینه احتمالی",
)
async def bounce_check_endpoint(
request: Request,
check_id: int,
body: CheckBounceRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
before = get_check_by_id(db, check_id)
if not before:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
try:
biz_id = int(before.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
result = bounce_check(db, check_id, ctx.get_user_id(), payload)
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_BOUNCED")
@router.post(
"/checks/{check_id}/actions/pay",
summary="پرداخت چک پرداختنی",
description="پاس چک پرداختنی از بانک",
)
async def pay_check_endpoint(
request: Request,
check_id: int,
body: CheckPayRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
before = get_check_by_id(db, check_id)
if not before:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
try:
biz_id = int(before.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
result = pay_check(db, check_id, ctx.get_user_id(), payload)
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_PAID")
@router.post(
"/checks/{check_id}/actions/deposit",
summary="سپرده چک به بانک (اختیاری)",
description="انتقال به اسناد در جریان وصول",
)
async def deposit_check_endpoint(
request: Request,
check_id: int,
body: CheckDepositRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
before = get_check_by_id(db, check_id)
if not before:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
try:
biz_id = int(before.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
result = deposit_check(db, check_id, ctx.get_user_id(), payload)
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_DEPOSITED")
@router.get( @router.get(

View 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",
},
)

View file

@ -15,6 +15,10 @@ class CheckCreateRequest(BaseModel):
branch_name: Optional[str] = Field(default=None, max_length=255) branch_name: Optional[str] = Field(default=None, max_length=255)
amount: float = Field(..., gt=0) amount: float = Field(..., gt=0)
currency_id: int = Field(..., ge=1) currency_id: int = Field(..., ge=1)
# گزینه‌های حسابداری
auto_post: Optional[bool] = Field(default=False)
document_date: Optional[str] = None
document_description: Optional[str] = Field(default=None, max_length=500)
@field_validator('sayad_code') @field_validator('sayad_code')
@classmethod @classmethod
@ -70,3 +74,51 @@ class CheckResponse(BaseModel):
from_attributes = True from_attributes = True
# =====================
# Action Schemas
# =====================
class CheckEndorseRequest(BaseModel):
target_person_id: int = Field(..., ge=1)
document_date: Optional[str] = None
description: Optional[str] = Field(default=None, max_length=500)
auto_post: bool = Field(default=True)
class CheckClearRequest(BaseModel):
bank_account_id: int = Field(..., ge=1)
document_date: Optional[str] = None
description: Optional[str] = Field(default=None, max_length=500)
auto_post: bool = Field(default=True)
class CheckReturnRequest(BaseModel):
target_person_id: Optional[int] = Field(default=None, ge=1)
document_date: Optional[str] = None
description: Optional[str] = Field(default=None, max_length=500)
auto_post: bool = Field(default=True)
class CheckBounceRequest(BaseModel):
bank_account_id: Optional[int] = Field(default=None, ge=1)
expense_account_id: Optional[int] = Field(default=None, ge=1)
expense_amount: Optional[float] = Field(default=None, gt=0)
document_date: Optional[str] = None
description: Optional[str] = Field(default=None, max_length=500)
auto_post: bool = Field(default=True)
class CheckPayRequest(BaseModel):
bank_account_id: int = Field(..., ge=1)
document_date: Optional[str] = None
description: Optional[str] = Field(default=None, max_length=500)
auto_post: bool = Field(default=True)
class CheckDepositRequest(BaseModel):
bank_account_id: int = Field(..., ge=1)
document_date: Optional[str] = None
description: Optional[str] = Field(default=None, max_length=500)
auto_post: bool = Field(default=True)

View file

@ -12,6 +12,7 @@ from sqlalchemy import (
Numeric, Numeric,
Enum as SQLEnum, Enum as SQLEnum,
Index, Index,
JSON,
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -23,6 +24,21 @@ class CheckType(str, Enum):
TRANSFERRED = "TRANSFERRED" TRANSFERRED = "TRANSFERRED"
class CheckStatus(str, Enum):
RECEIVED_ON_HAND = "RECEIVED_ON_HAND" # چک دریافتی در دست
TRANSFERRED_ISSUED = "TRANSFERRED_ISSUED" # چک پرداختنی صادر و تحویل شده
DEPOSITED = "DEPOSITED" # سپرده به بانک (در جریان وصول)
CLEARED = "CLEARED" # پاس/وصول شده
ENDORSED = "ENDORSED" # واگذار شده به شخص ثالث
RETURNED = "RETURNED" # عودت شده
BOUNCED = "BOUNCED" # برگشت خورده
CANCELLED = "CANCELLED" # ابطال شده
class HolderType(str, Enum):
BUSINESS = "BUSINESS"
BANK = "BANK"
PERSON = "PERSON"
class Check(Base): class Check(Base):
__tablename__ = "checks" __tablename__ = "checks"
__table_args__ = ( __table_args__ = (
@ -54,6 +70,14 @@ class Check(Base):
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False) amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True) currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
# وضعیت و نگهدارنده
status: Mapped[CheckStatus | None] = mapped_column(SQLEnum(CheckStatus, name="check_status"), nullable=True, index=True)
status_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
current_holder_type: Mapped[HolderType | None] = mapped_column(SQLEnum(HolderType, name="check_holder_type"), nullable=True, index=True)
current_holder_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
last_action_document_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("documents.id", ondelete="SET NULL"), nullable=True, index=True)
developer_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@ -61,5 +85,6 @@ class Check(Base):
business = relationship("Business", backref="checks") business = relationship("Business", backref="checks")
person = relationship("Person", lazy="joined") person = relationship("Person", lazy="joined")
currency = relationship("Currency") currency = relationship("Currency")
last_action_document = relationship("Document")

View file

@ -35,6 +35,7 @@ from adapters.api.v1.transfers import router as transfers_router
from adapters.api.v1.fiscal_years import router as fiscal_years_router from adapters.api.v1.fiscal_years import router as fiscal_years_router
from adapters.api.v1.expense_income import router as expense_income_router from adapters.api.v1.expense_income import router as expense_income_router
from adapters.api.v1.documents import router as documents_router from adapters.api.v1.documents import router as documents_router
from adapters.api.v1.kardex import router as kardex_router
from app.core.i18n import negotiate_locale, Translator from app.core.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers from app.core.error_handlers import register_error_handlers
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
@ -319,6 +320,7 @@ def create_app() -> FastAPI:
application.include_router(expense_income_router, prefix=settings.api_v1_prefix) application.include_router(expense_income_router, prefix=settings.api_v1_prefix)
application.include_router(documents_router, prefix=settings.api_v1_prefix) application.include_router(documents_router, prefix=settings.api_v1_prefix)
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix) application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
application.include_router(kardex_router, prefix=settings.api_v1_prefix)
# Support endpoints # Support endpoints
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")

View file

@ -1,14 +1,19 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from datetime import datetime from datetime import datetime, date
from decimal import Decimal
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func from sqlalchemy import and_, or_, func
from adapters.db.models.check import Check, CheckType from adapters.db.models.check import Check, CheckType, CheckStatus, HolderType
from adapters.db.models.person import Person from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.account import Account
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.currency import Currency from adapters.db.models.currency import Currency
from adapters.db.models.person import Person
from app.core.responses import ApiError from app.core.responses import ApiError
@ -19,7 +24,37 @@ def _parse_iso(dt: str) -> datetime:
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400) raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]: def _parse_iso_date_only(dt: str | datetime | date) -> date:
if isinstance(dt, date) and not isinstance(dt, datetime):
return dt
if isinstance(dt, datetime):
return dt.date()
try:
return datetime.fromisoformat(str(dt)).date()
except Exception:
return datetime.utcnow().date()
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
account = db.query(Account).filter(Account.code == str(account_code)).first()
if not account:
from app.core.responses import ApiError
raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=404)
return account
def _get_business_fiscal_year(db: Session, business_id: int) -> FiscalYear:
from sqlalchemy import and_ # local import to avoid unused import if not used elsewhere
fy = db.query(FiscalYear).filter(
and_(FiscalYear.business_id == business_id, FiscalYear.is_closed == False) # noqa: E712
).order_by(FiscalYear.start_date.desc()).first()
if not fy:
from app.core.responses import ApiError
raise ApiError("FISCAL_YEAR_NOT_FOUND", "Active fiscal year not found", http_status=404)
return fy
def create_check(db: Session, business_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(data.get('type', '')).lower() ctype = str(data.get('type', '')).lower()
if ctype not in ("received", "transferred"): if ctype not in ("received", "transferred"):
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400) raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
@ -75,10 +110,109 @@ def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[st
currency_id=int(data.get('currency_id')), currency_id=int(data.get('currency_id')),
) )
# تعیین وضعیت اولیه
if ctype == "received":
obj.status = CheckStatus.RECEIVED_ON_HAND
obj.current_holder_type = HolderType.BUSINESS
obj.current_holder_id = None
else:
obj.status = CheckStatus.TRANSFERRED_ISSUED
obj.current_holder_type = HolderType.PERSON if person_id else HolderType.BUSINESS
obj.current_holder_id = int(person_id) if person_id else None
db.add(obj) db.add(obj)
db.commit() db.commit()
db.refresh(obj) db.refresh(obj)
return check_to_dict(db, obj)
# ایجاد سند حسابداری خودکار در صورت درخواست
created_document_id: Optional[int] = None
try:
if bool(data.get("auto_post")):
# آماده‌سازی داده‌های سند
document_date: date = _parse_iso_date_only(data.get("document_date") or issue_date)
fiscal_year = _get_business_fiscal_year(db, business_id)
# تعیین حساب‌ها و سطرها
amount_dec = Decimal(str(amount_val))
lines: List[Dict[str, Any]] = []
description = (str(data.get("document_description")).strip() or None) if data.get("document_description") is not None else None
if ctype == "received":
# بدهکار: اسناد دریافتنی 10403
acc_notes_recv = _get_fixed_account_by_code(db, "10403")
lines.append({
"account_id": acc_notes_recv.id,
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "ثبت چک دریافتی",
"check_id": obj.id,
})
# بستانکار: حساب دریافتنی شخص 10401
acc_ar = _get_fixed_account_by_code(db, "10401")
lines.append({
"account_id": acc_ar.id,
"person_id": int(person_id) if person_id else None,
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "ثبت چک دریافتی",
"check_id": obj.id,
})
else: # transferred
# بدهکار: حساب پرداختنی شخص 20201 (در صورت وجود شخص)
acc_ap = _get_fixed_account_by_code(db, "20201")
lines.append({
"account_id": acc_ap.id,
"person_id": int(person_id) if person_id else None,
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "ثبت چک واگذار شده",
"check_id": obj.id,
})
# بستانکار: اسناد پرداختنی 20202
acc_notes_pay = _get_fixed_account_by_code(db, "20202")
lines.append({
"account_id": acc_notes_pay.id,
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "ثبت چک واگذار شده",
"check_id": obj.id,
})
# ایجاد سند
document = Document(
code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
business_id=business_id,
fiscal_year_id=fiscal_year.id,
currency_id=int(data.get("currency_id")),
created_by_user_id=int(user_id),
document_date=document_date,
document_type="check",
is_proforma=False,
description=description,
extra_info={
"source": "check_create",
"check_id": obj.id,
"check_type": ctype,
},
)
db.add(document)
db.flush()
for line in lines:
db.add(DocumentLine(document_id=document.id, **line))
db.commit()
db.refresh(document)
created_document_id = document.id
except Exception:
# در صورت شکست ایجاد سند، تغییری در ایجاد چک نمی‌دهیم و خطا نمی‌ریزیم
# (می‌توان رفتار را سخت‌گیرانه کرد و رول‌بک نمود؛ فعلاً نرم)
db.rollback()
result = check_to_dict(db, obj)
if created_document_id:
result["document_id"] = created_document_id
return result
def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]: def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
@ -86,6 +220,401 @@ def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
return check_to_dict(db, obj) if obj else None return check_to_dict(db, obj) if obj else None
# =====================
# Action helpers
# =====================
def _create_document_for_check_action(
db: Session,
*,
business_id: int,
user_id: int,
currency_id: int,
document_date: date,
description: Optional[str],
lines: List[Dict[str, Any]],
extra_info: Dict[str, Any],
) -> int:
document = Document(
code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
business_id=business_id,
fiscal_year_id=_get_business_fiscal_year(db, business_id).id,
currency_id=int(currency_id),
created_by_user_id=int(user_id),
document_date=document_date,
document_type="check",
is_proforma=False,
description=description,
extra_info=extra_info,
)
db.add(document)
db.flush()
for line in lines:
db.add(DocumentLine(document_id=document.id, **line))
db.commit()
db.refresh(document)
return document.id
def _ensure_account(db: Session, code: str) -> int:
return _get_fixed_account_by_code(db, code).id
def _parse_optional_date(d: Any, fallback: date) -> date:
return _parse_iso_date_only(d) if d else fallback
def _load_check_or_404(db: Session, check_id: int) -> Check:
obj = db.query(Check).filter(Check.id == check_id).first()
if not obj:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
return obj
def endorse_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
if obj.type != CheckType.RECEIVED:
raise ApiError("INVALID_ACTION", "Only received checks can be endorsed", http_status=400)
if obj.status not in (CheckStatus.RECEIVED_ON_HAND, CheckStatus.RETURNED, CheckStatus.BOUNCED):
raise ApiError("INVALID_STATE", f"Cannot endorse from status {obj.status}", http_status=400)
target_person_id = int(data.get("target_person_id"))
document_date = _parse_optional_date(data.get("document_date"), obj.issue_date.date())
description = (data.get("description") or None)
lines: List[Dict[str, Any]] = []
amount_dec = Decimal(str(obj.amount))
# Dr 20201 (target person AP), Cr 10403
lines.append({
"account_id": _ensure_account(db, "20201"),
"person_id": target_person_id,
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "واگذاری چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10403"),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "واگذاری چک",
"check_id": obj.id,
})
document_id = None
if bool(data.get("auto_post", True)):
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info={"source": "check_action", "action": "endorse", "check_id": obj.id},
)
# Update state
obj.status = CheckStatus.ENDORSED
obj.status_at = datetime.utcnow()
obj.current_holder_type = HolderType.PERSON
obj.current_holder_id = target_person_id
obj.last_action_document_id = document_id
db.commit(); db.refresh(obj)
res = check_to_dict(db, obj)
if document_id:
res["document_id"] = document_id
return res
def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
description = (data.get("description") or None)
amount_dec = Decimal(str(obj.amount))
lines: List[Dict[str, Any]] = []
if obj.type == CheckType.RECEIVED:
# Dr 10203 (bank), Cr 10403
lines.append({
"account_id": _ensure_account(db, "10203"),
"bank_account_id": int(data.get("bank_account_id")),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "وصول چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10403"),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "وصول چک",
"check_id": obj.id,
})
else:
# transferred/pay: Dr 20202, Cr 10203
lines.append({
"account_id": _ensure_account(db, "20202"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "پرداخت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10203"),
"bank_account_id": int(data.get("bank_account_id")),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "پرداخت چک",
"check_id": obj.id,
})
document_id = None
if bool(data.get("auto_post", True)):
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info={"source": "check_action", "action": "clear", "check_id": obj.id},
)
obj.status = CheckStatus.CLEARED
obj.status_at = datetime.utcnow()
obj.current_holder_type = HolderType.BANK
obj.current_holder_id = int(data.get("bank_account_id"))
obj.last_action_document_id = document_id
db.commit(); db.refresh(obj)
res = check_to_dict(db, obj)
if document_id:
res["document_id"] = document_id
return res
def pay_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
# alias to clear_check for transferred
obj = _load_check_or_404(db, check_id)
if obj.type != CheckType.TRANSFERRED:
raise ApiError("INVALID_ACTION", "Only transferred checks can be paid", http_status=400)
return clear_check(db, check_id, user_id, data)
def return_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
document_date = _parse_optional_date(data.get("document_date"), obj.issue_date.date())
description = (data.get("description") or None)
amount_dec = Decimal(str(obj.amount))
lines: List[Dict[str, Any]] = []
if obj.type == CheckType.RECEIVED:
if not obj.person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required on received check to return", http_status=400)
# Dr 10401(person), Cr 10403
lines.append({
"account_id": _ensure_account(db, "10401"),
"person_id": int(obj.person_id),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "عودت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10403"),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "عودت چک",
"check_id": obj.id,
})
obj.current_holder_type = HolderType.PERSON
obj.current_holder_id = int(obj.person_id)
else:
# transferred: Dr 20202, Cr 20201(person)
if not obj.person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required on transferred check to return", http_status=400)
lines.append({
"account_id": _ensure_account(db, "20202"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "عودت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "20201"),
"person_id": int(obj.person_id),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "عودت چک",
"check_id": obj.id,
})
obj.current_holder_type = HolderType.BUSINESS
obj.current_holder_id = None
document_id = None
if bool(data.get("auto_post", True)):
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info={"source": "check_action", "action": "return", "check_id": obj.id},
)
obj.status = CheckStatus.RETURNED
obj.status_at = datetime.utcnow()
obj.last_action_document_id = document_id
db.commit(); db.refresh(obj)
res = check_to_dict(db, obj)
if document_id:
res["document_id"] = document_id
return res
def bounce_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
description = (data.get("description") or None)
amount_dec = Decimal(str(obj.amount))
lines: List[Dict[str, Any]] = []
if obj.type == CheckType.RECEIVED:
# Reverse cash if previously cleared; simplified: Dr 10403, Cr 10203
bank_account_id = data.get("bank_account_id")
lines.append({
"account_id": _ensure_account(db, "10403"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "برگشت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10203"),
**({"bank_account_id": int(bank_account_id)} if bank_account_id else {}),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "برگشت چک",
"check_id": obj.id,
})
else:
# transferred: Dr 20202, Cr 20201(person) (increase AP again)
if not obj.person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required on transferred check to bounce", http_status=400)
lines.append({
"account_id": _ensure_account(db, "20202"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "برگشت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "20201"),
"person_id": int(obj.person_id),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "برگشت چک",
"check_id": obj.id,
})
# Optional expense fee
expense_amount = data.get("expense_amount")
expense_account_id = data.get("expense_account_id")
bank_account_id = data.get("bank_account_id")
if expense_amount and expense_account_id and float(expense_amount) > 0:
lines.append({
"account_id": int(expense_account_id),
"debit": Decimal(str(expense_amount)),
"credit": Decimal(0),
"description": description or "هزینه برگشت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10203"),
**({"bank_account_id": int(bank_account_id)} if bank_account_id else {}),
"debit": Decimal(0),
"credit": Decimal(str(expense_amount)),
"description": description or "هزینه برگشت چک",
"check_id": obj.id,
})
document_id = None
if bool(data.get("auto_post", True)):
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info={"source": "check_action", "action": "bounce", "check_id": obj.id},
)
obj.status = CheckStatus.BOUNCED
obj.status_at = datetime.utcnow()
obj.current_holder_type = HolderType.BUSINESS
obj.current_holder_id = None
obj.last_action_document_id = document_id
db.commit(); db.refresh(obj)
res = check_to_dict(db, obj)
if document_id:
res["document_id"] = document_id
return res
def deposit_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
obj = _load_check_or_404(db, check_id)
if obj.type != CheckType.RECEIVED:
raise ApiError("INVALID_ACTION", "Only received checks can be deposited", http_status=400)
document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
description = (data.get("description") or None)
amount_dec = Decimal(str(obj.amount))
# Requires account 10404 to exist
in_collection = _get_fixed_account_by_code(db, "10404") # may raise 404
lines: List[Dict[str, Any]] = [
{
"account_id": in_collection.id,
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "سپرده چک به بانک",
"check_id": obj.id,
},
{
"account_id": _ensure_account(db, "10403"),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "سپرده چک به بانک",
"check_id": obj.id,
},
]
document_id = None
if bool(data.get("auto_post", True)):
document_id = _create_document_for_check_action(
db,
business_id=obj.business_id,
user_id=user_id,
currency_id=obj.currency_id,
document_date=document_date,
description=description,
lines=lines,
extra_info={"source": "check_action", "action": "deposit", "check_id": obj.id},
)
obj.status = CheckStatus.DEPOSITED
obj.status_at = datetime.utcnow()
obj.current_holder_type = HolderType.BANK
obj.last_action_document_id = document_id
db.commit(); db.refresh(obj)
res = check_to_dict(db, obj)
if document_id:
res["document_id"] = document_id
return res
def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
obj = db.query(Check).filter(Check.id == check_id).first() obj = db.query(Check).filter(Check.id == check_id).first()
if obj is None: if obj is None:
@ -197,6 +726,22 @@ def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[st
q = q.filter(Check.type == enum_val) q = q.filter(Check.type == enum_val)
except Exception: except Exception:
pass pass
elif prop == 'status':
try:
if op == '=' and isinstance(val, str) and val:
enum_val = CheckStatus[val]
q = q.filter(Check.status == enum_val)
elif op == 'in' and isinstance(val, list) and val:
enum_vals = []
for v in val:
try:
enum_vals.append(CheckStatus[str(v)])
except Exception:
pass
if enum_vals:
q = q.filter(Check.status.in_(enum_vals))
except Exception:
pass
elif prop == 'currency' and op == '=': elif prop == 'currency' and op == '=':
try: try:
q = q.filter(Check.currency_id == int(val)) q = q.filter(Check.currency_id == int(val))
@ -283,6 +828,11 @@ def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]
"amount": float(obj.amount), "amount": float(obj.amount),
"currency_id": obj.currency_id, "currency_id": obj.currency_id,
"currency": currency_title, "currency": currency_title,
"status": (obj.status.name if obj.status else None),
"status_at": (obj.status_at.isoformat() if obj.status_at else None),
"current_holder_type": (obj.current_holder_type.name if obj.current_holder_type else None),
"current_holder_id": obj.current_holder_id,
"last_action_document_id": obj.last_action_document_id,
"created_at": obj.created_at.isoformat(), "created_at": obj.created_at.isoformat(),
"updated_at": obj.updated_at.isoformat(), "updated_at": obj.updated_at.isoformat(),
} }

View 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,
}

View file

@ -20,6 +20,7 @@ adapters/api/v1/expense_income.py
adapters/api/v1/fiscal_years.py adapters/api/v1/fiscal_years.py
adapters/api/v1/health.py adapters/api/v1/health.py
adapters/api/v1/invoices.py adapters/api/v1/invoices.py
adapters/api/v1/kardex.py
adapters/api/v1/persons.py adapters/api/v1/persons.py
adapters/api/v1/petty_cash.py adapters/api/v1/petty_cash.py
adapters/api/v1/price_lists.py adapters/api/v1/price_lists.py
@ -146,6 +147,7 @@ app/services/email_service.py
app/services/expense_income_service.py app/services/expense_income_service.py
app/services/file_storage_service.py app/services/file_storage_service.py
app/services/invoice_service.py app/services/invoice_service.py
app/services/kardex_service.py
app/services/person_service.py app/services/person_service.py
app/services/petty_cash_service.py app/services/petty_cash_service.py
app/services/price_list_service.py app/services/price_list_service.py
@ -217,6 +219,7 @@ migrations/versions/20251014_000301_add_product_id_to_document_lines.py
migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
migrations/versions/20251014_000501_add_quantity_to_document_lines.py migrations/versions/20251014_000501_add_quantity_to_document_lines.py
migrations/versions/20251021_000601_add_bom_and_warehouses.py migrations/versions/20251021_000601_add_bom_and_warehouses.py
migrations/versions/20251102_120001_add_check_status_fields.py
migrations/versions/4b2ea782bcb3_merge_heads.py migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py

View file

@ -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

View 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()

View file

@ -1046,7 +1046,7 @@
"currency": "واحد پول", "currency": "واحد پول",
"isDefault": "پیش‌فرض", "isDefault": "پیش‌فرض",
"description": "توضیحات", "description": "توضیحات",
"actions": "اقدامات", "actions": "عملیات",
"yes": "بله", "yes": "بله",
"no": "خیر", "no": "خیر",
"pettyCash": "تنخواه گردان", "pettyCash": "تنخواه گردان",

View file

@ -1223,7 +1223,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get edit => 'ویرایش'; String get edit => 'ویرایش';
@override @override
String get actions => 'اقدامات'; String get actions => 'عملیات';
@override @override
String get search => 'جستجو'; String get search => 'جستجو';

View file

@ -30,6 +30,7 @@ import 'pages/business/new_invoice_page.dart';
import 'pages/business/settings_page.dart'; import 'pages/business/settings_page.dart';
import 'pages/business/business_info_settings_page.dart'; import 'pages/business/business_info_settings_page.dart';
import 'pages/business/reports_page.dart'; import 'pages/business/reports_page.dart';
import 'pages/business/kardex_page.dart';
import 'pages/business/persons_page.dart'; import 'pages/business/persons_page.dart';
import 'pages/business/product_attributes_page.dart'; import 'pages/business/product_attributes_page.dart';
import 'pages/business/products_page.dart'; import 'pages/business/products_page.dart';
@ -633,6 +634,19 @@ class _MyAppState extends State<MyApp> {
); );
}, },
), ),
GoRoute(
path: '/business/:business_id/reports/kardex',
name: 'business_reports_kardex',
pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return NoTransitionPage(
child: KardexPage(
businessId: businessId,
calendarController: _calendarController!,
),
);
},
),
GoRoute( GoRoute(
path: '/business/:business_id/settings', path: '/business/:business_id/settings',
name: 'business_settings', name: 'business_settings',

View file

@ -34,12 +34,15 @@ class _CheckFormPageState extends State<CheckFormPage> {
DateTime? _dueDate; DateTime? _dueDate;
int? _currencyId; int? _currencyId;
dynamic _selectedPerson; // using Person type would be ideal; keep dynamic to avoid imports complexity dynamic _selectedPerson; // using Person type would be ideal; keep dynamic to avoid imports complexity
bool _autoPost = false;
DateTime? _documentDate;
final _checkNumberCtrl = TextEditingController(); final _checkNumberCtrl = TextEditingController();
final _sayadCtrl = TextEditingController(); final _sayadCtrl = TextEditingController();
final _bankCtrl = TextEditingController(); final _bankCtrl = TextEditingController();
final _branchCtrl = TextEditingController(); final _branchCtrl = TextEditingController();
final _amountCtrl = TextEditingController(); final _amountCtrl = TextEditingController();
final _docDescCtrl = TextEditingController();
bool _loading = false; bool _loading = false;
@ -50,6 +53,7 @@ class _CheckFormPageState extends State<CheckFormPage> {
_currencyId = widget.authStore.selectedCurrencyId; _currencyId = widget.authStore.selectedCurrencyId;
_issueDate = DateTime.now(); _issueDate = DateTime.now();
_dueDate = DateTime.now(); _dueDate = DateTime.now();
_documentDate = _issueDate;
if (widget.checkId != null) { if (widget.checkId != null) {
_loadData(); _loadData();
} }
@ -114,6 +118,9 @@ class _CheckFormPageState extends State<CheckFormPage> {
if (_branchCtrl.text.trim().isNotEmpty) 'branch_name': _branchCtrl.text.trim(), if (_branchCtrl.text.trim().isNotEmpty) 'branch_name': _branchCtrl.text.trim(),
'amount': num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()), 'amount': num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()),
'currency_id': _currencyId, 'currency_id': _currencyId,
'auto_post': _autoPost,
if (_autoPost && _documentDate != null) 'document_date': _documentDate!.toIso8601String(),
if (_autoPost && _docDescCtrl.text.trim().isNotEmpty) 'document_description': _docDescCtrl.text.trim(),
}; };
if (widget.checkId == null) { if (widget.checkId == null) {
@ -152,6 +159,7 @@ class _CheckFormPageState extends State<CheckFormPage> {
_bankCtrl.dispose(); _bankCtrl.dispose();
_branchCtrl.dispose(); _branchCtrl.dispose();
_amountCtrl.dispose(); _amountCtrl.dispose();
_docDescCtrl.dispose();
super.dispose(); super.dispose();
} }
@ -159,6 +167,7 @@ class _CheckFormPageState extends State<CheckFormPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
final isEdit = widget.checkId != null; final isEdit = widget.checkId != null;
final canAccountingWrite = widget.authStore.canWriteSection('accounting');
if (!widget.authStore.canWriteSection('checks')) { if (!widget.authStore.canWriteSection('checks')) {
return AccessDeniedPage(message: t.accessDenied); return AccessDeniedPage(message: t.accessDenied);
@ -321,6 +330,43 @@ class _CheckFormPageState extends State<CheckFormPage> {
], ],
), ),
const SizedBox(height: 16),
if (canAccountingWrite) ...[
SwitchListTile(
value: _autoPost,
onChanged: (v) => setState(() {
_autoPost = v;
_documentDate ??= _issueDate;
}),
title: const Text('ثبت سند حسابداری همزمان'),
),
if (_autoPost) ...[
Row(
children: [
Expanded(
child: DateInputField(
value: _documentDate,
labelText: 'تاریخ سند',
hintText: 'انتخاب تاریخ سند',
calendarController: widget.calendarController!,
onChanged: (d) => setState(() => _documentDate = d),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _docDescCtrl,
decoration: const InputDecoration(
labelText: 'شرح سند',
border: OutlineInputBorder(),
),
),
),
],
),
],
],
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
children: [ children: [

View file

@ -7,6 +7,8 @@ import '../../widgets/data_table/data_table_config.dart';
import '../../widgets/permission/permission_widgets.dart'; import '../../widgets/permission/permission_widgets.dart';
import '../../widgets/invoice/person_combobox_widget.dart'; import '../../widgets/invoice/person_combobox_widget.dart';
import '../../models/person_model.dart'; import '../../models/person_model.dart';
import '../../services/check_service.dart';
import '../../widgets/invoice/bank_account_combobox_widget.dart';
class ChecksPage extends StatefulWidget { class ChecksPage extends StatefulWidget {
final int businessId; final int businessId;
@ -25,6 +27,7 @@ class ChecksPage extends StatefulWidget {
class _ChecksPageState extends State<ChecksPage> { class _ChecksPageState extends State<ChecksPage> {
final GlobalKey _tableKey = GlobalKey(); final GlobalKey _tableKey = GlobalKey();
Person? _selectedPerson; Person? _selectedPerson;
final _checkService = CheckService();
void _refresh() { void _refresh() {
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {} try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
@ -111,6 +114,33 @@ class _ChecksPageState extends State<ChecksPage> {
TextColumn('currency', 'ارز', width: ColumnWidth.small, TextColumn('currency', 'ارز', width: ColumnWidth.small,
formatter: (row) => (row['currency'] ?? '-'), formatter: (row) => (row['currency'] ?? '-'),
), ),
TextColumn('status', 'وضعیت', width: ColumnWidth.medium,
filterType: ColumnFilterType.multiSelect,
filterOptions: const [
FilterOption(value: 'RECEIVED_ON_HAND', label: 'در دست (دریافتی)'),
FilterOption(value: 'TRANSFERRED_ISSUED', label: 'صادر شده (پرداختنی)'),
FilterOption(value: 'DEPOSITED', label: 'سپرده به بانک'),
FilterOption(value: 'CLEARED', label: 'پاس/وصول شده'),
FilterOption(value: 'ENDORSED', label: 'واگذار شده'),
FilterOption(value: 'RETURNED', label: 'عودت شده'),
FilterOption(value: 'BOUNCED', label: 'برگشت خورده'),
FilterOption(value: 'CANCELLED', label: 'ابطال'),
],
formatter: (row) {
final s = (row['status'] ?? '').toString();
switch (s) {
case 'RECEIVED_ON_HAND': return 'در دست (دریافتی)';
case 'TRANSFERRED_ISSUED': return 'صادر شده (پرداختنی)';
case 'DEPOSITED': return 'سپرده به بانک';
case 'CLEARED': return 'پاس/وصول شده';
case 'ENDORSED': return 'واگذار شده';
case 'RETURNED': return 'عودت شده';
case 'BOUNCED': return 'برگشت خورده';
case 'CANCELLED': return 'ابطال';
}
return '-';
},
),
ActionColumn('actions', t.actions, actions: [ ActionColumn('actions', t.actions, actions: [
DataTableAction( DataTableAction(
icon: Icons.edit, icon: Icons.edit,
@ -122,10 +152,87 @@ class _ChecksPageState extends State<ChecksPage> {
} }
}, },
), ),
DataTableAction(
icon: Icons.arrow_forward,
label: 'واگذاری',
onTap: (row) {
final type = (row['type'] ?? '').toString();
final status = (row['status'] ?? '').toString();
final can = type == 'received' && (status.isEmpty || ['RECEIVED_ON_HAND','RETURNED','BOUNCED'].contains(status));
if (can) {
_openEndorseDialog(context, row as Map<String, dynamic>);
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این عملیات برای وضعیت فعلی مجاز نیست')));
}
},
),
DataTableAction(
icon: Icons.check_circle,
label: 'وصول',
onTap: (row) {
final type = (row['type'] ?? '').toString();
final status = (row['status'] ?? '').toString();
if (type == 'received' && status != 'CLEARED') {
_openClearDialog(context, row as Map<String, dynamic>);
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این عملیات برای این چک قابل انجام نیست')));
}
},
),
DataTableAction(
icon: Icons.payment,
label: 'پرداخت',
onTap: (row) {
final type = (row['type'] ?? '').toString();
final status = (row['status'] ?? '').toString();
if (type == 'transferred' && status != 'CLEARED') {
_openPayDialog(context, row as Map<String, dynamic>);
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این عملیات برای این چک قابل انجام نیست')));
}
},
),
DataTableAction(
icon: Icons.reply,
label: 'عودت',
onTap: (row) {
final status = (row['status'] ?? '').toString();
if (status != 'CLEARED') {
_confirmReturn(context, row as Map<String, dynamic>);
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این چک قبلاً پاس شده است')));
}
},
),
DataTableAction(
icon: Icons.block,
label: 'برگشت',
onTap: (row) {
final status = (row['status'] ?? '').toString();
if (status != 'CLEARED') {
_confirmBounce(context, row as Map<String, dynamic>);
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این چک قبلاً پاس شده است')));
}
},
),
DataTableAction(
icon: Icons.account_balance,
label: 'سپرده',
onTap: (row) {
final type = (row['type'] ?? '').toString();
final status = (row['status'] ?? '').toString();
if (type == 'received' && (status.isEmpty || status == 'RECEIVED_ON_HAND')) {
_confirmDeposit(context, row as Map<String, dynamic>);
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این عملیات برای وضعیت فعلی مجاز نیست')));
}
},
),
]), ]),
], ],
searchFields: ['check_number','sayad_code','bank_name','branch_name','person_name'], searchFields: ['check_number','sayad_code','bank_name','branch_name','person_name'],
filterFields: ['type','currency','issue_date','due_date'], filterFields: ['type','currency','issue_date','due_date','status'],
defaultPageSize: 20, defaultPageSize: 20,
customHeaderActions: [ customHeaderActions: [
// فیلتر شخص // فیلتر شخص
@ -165,6 +272,158 @@ class _ChecksPageState extends State<ChecksPage> {
}, },
); );
} }
Future<void> _openEndorseDialog(BuildContext context, Map<String, dynamic> row) async {
Person? selected;
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('واگذاری چک به شخص'),
content: SizedBox(
width: 360,
child: PersonComboboxWidget(
businessId: widget.businessId,
selectedPerson: selected,
onChanged: (p) => selected = p,
isRequired: true,
label: 'شخص مقصد',
hintText: 'انتخاب شخص',
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('انصراف')),
FilledButton(
onPressed: () async {
if (selected == null) return;
try {
await _checkService.endorse(checkId: row['id'] as int, body: {
'target_person_id': (selected as dynamic).id,
'auto_post': true,
});
if (mounted) Navigator.pop(ctx);
_refresh();
} catch (e) {
if (mounted) Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
},
child: const Text('ثبت'),
),
],
),
);
}
Future<void> _openClearDialog(BuildContext context, Map<String, dynamic> row) async {
BankAccountOption? selected;
final currencyId = row['currency_id'] as int?;
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('وصول چک به بانک'),
content: SizedBox(
width: 420,
child: BankAccountComboboxWidget(
businessId: widget.businessId,
selectedAccountId: null,
filterCurrencyId: currencyId,
onChanged: (opt) => selected = opt,
label: 'حساب بانکی',
hintText: 'انتخاب حساب بانکی',
isRequired: true,
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('انصراف')),
FilledButton(
onPressed: () async {
if (selected == null || (selected!.id).isEmpty) return;
try {
await _checkService.clear(checkId: row['id'] as int, body: {
'bank_account_id': int.tryParse(selected!.id) ?? 0,
'auto_post': true,
});
if (mounted) Navigator.pop(ctx);
_refresh();
} catch (e) {
if (mounted) Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
},
child: const Text('ثبت'),
),
],
),
);
}
Future<void> _openPayDialog(BuildContext context, Map<String, dynamic> row) async {
// پرداخت چک پرداختنی (pay)
await _openClearDialog(context, row);
}
Future<void> _confirmReturn(BuildContext context, Map<String, dynamic> row) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('عودت چک'),
content: const Text('آیا از عودت این چک مطمئن هستید؟'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('خیر')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('بله')),
],
),
);
if (ok != true) return;
try {
await _checkService.returnCheck(checkId: row['id'] as int, body: {'auto_post': true});
_refresh();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
Future<void> _confirmBounce(BuildContext context, Map<String, dynamic> row) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('برگشت چک'),
content: const Text('آیا از برگشت این چک مطمئن هستید؟'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('خیر')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('بله')),
],
),
);
if (ok != true) return;
try {
await _checkService.bounce(checkId: row['id'] as int, body: {'auto_post': true});
_refresh();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
Future<void> _confirmDeposit(BuildContext context, Map<String, dynamic> row) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('سپرده چک به بانک'),
content: const Text('چک به اسناد در جریان وصول منتقل می‌شود.'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('تایید')),
],
),
);
if (ok != true) return;
try {
await _checkService.deposit(checkId: row['id'] as int, body: {'auto_post': true});
_refresh();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
} }

View 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 جایگزین شد

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart'; import 'package:hesabix_ui/core/api_client.dart';
@ -65,12 +66,27 @@ class _PersonsPageState extends State<PersonsPage> {
enableRowSelection: true, enableRowSelection: true,
enableMultiRowSelection: true, enableMultiRowSelection: true,
columns: [ columns: [
NumberColumn( CustomColumn(
'code', 'code',
t.personCode, t.personCode,
width: ColumnWidth.small, width: ColumnWidth.small,
sortable: true,
formatter: (person) => (person.code?.toString() ?? '-'), formatter: (person) => (person.code?.toString() ?? '-'),
textAlign: TextAlign.center, builder: (person, index) {
final codeText = person.code?.toString() ?? '-';
return InkWell(
onTap: () {
if (person.id != null) {
context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}');
}
},
child: Text(
codeText,
textAlign: TextAlign.center,
style: const TextStyle(decoration: TextDecoration.underline),
),
);
},
), ),
TextColumn( TextColumn(
'alias_name', 'alias_name',
@ -316,6 +332,20 @@ class _PersonsPageState extends State<PersonsPage> {
'actions', 'actions',
t.actions, t.actions,
actions: [ actions: [
DataTableAction(
icon: Icons.view_kanban,
label: 'کاردکس',
onTap: (person) {
if (person is Person && person.id != null) {
context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}');
} else if (person is Map<String, dynamic>) {
final id = person['id'];
if (id is int) {
context.go('/business/${widget.businessId}/reports/kardex?person_id=$id');
}
}
},
),
DataTableAction( DataTableAction(
icon: Icons.edit, icon: Icons.edit,
label: t.edit, label: t.edit,

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth_store.dart'; import '../../core/auth_store.dart';
import '../../widgets/permission/access_denied_page.dart'; import '../../widgets/permission/access_denied_page.dart';
@ -27,6 +28,22 @@ class ReportsPage extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSection(
context,
title: 'گزارشات عمومی',
icon: Icons.assessment,
children: [
_buildReportItem(
context,
title: 'کاردکس اسناد',
subtitle: 'نمایش ریز تراکنش‌ها بر اساس شخص/کالا/بانک/حساب/چک با فیلتر تاریخ',
icon: Icons.view_kanban,
onTap: () => context.go('/business/$businessId/reports/kardex'),
),
],
),
const SizedBox(height: 24),
_buildSection( _buildSection(
context, context,
title: 'گزارشات اشخاص', title: 'گزارشات اشخاص',

View file

@ -67,6 +67,37 @@ class CheckService {
options: Options(responseType: ResponseType.bytes), options: Options(responseType: ResponseType.bytes),
); );
} }
// ===== Actions =====
Future<Map<String, dynamic>> endorse({required int checkId, required Map<String, dynamic> body}) async {
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/endorse', data: body);
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
Future<Map<String, dynamic>> clear({required int checkId, required Map<String, dynamic> body}) async {
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/clear', data: body);
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
Future<Map<String, dynamic>> pay({required int checkId, required Map<String, dynamic> body}) async {
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/pay', data: body);
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
Future<Map<String, dynamic>> returnCheck({required int checkId, required Map<String, dynamic> body}) async {
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/return', data: body);
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
Future<Map<String, dynamic>> bounce({required int checkId, required Map<String, dynamic> body}) async {
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/bounce', data: body);
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
Future<Map<String, dynamic>> deposit({required int checkId, required Map<String, dynamic> body}) async {
final res = await _client.post<Map<String, dynamic>>('/api/v1/checks/checks/$checkId/actions/deposit', data: body);
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
} }

View 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(),
};
}
}
}

View file

@ -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),
);
},
),
),
],
),
);
}
}