diff --git a/hesabixAPI/adapters/api/v1/checks.py b/hesabixAPI/adapters/api/v1/checks.py
index c223c7a..0c158e2 100644
--- a/hesabixAPI/adapters/api/v1/checks.py
+++ b/hesabixAPI/adapters/api/v1/checks.py
@@ -10,6 +10,12 @@ from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.schema_models.check import (
CheckCreateRequest,
CheckUpdateRequest,
+ CheckEndorseRequest,
+ CheckClearRequest,
+ CheckReturnRequest,
+ CheckBounceRequest,
+ CheckPayRequest,
+ CheckDepositRequest,
)
from app.services.check_service import (
create_check,
@@ -17,6 +23,12 @@ from app.services.check_service import (
delete_check,
get_check_by_id,
list_checks,
+ endorse_check,
+ clear_check,
+ return_check,
+ bounce_check,
+ pay_check,
+ deposit_check,
)
@@ -82,8 +94,183 @@ async def create_check_endpoint(
_: None = Depends(require_business_management_dep),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
- created = create_check(db, business_id, payload)
+ # اگر کاربر درخواست ثبت سند همزمان داد، باید دسترسی نوشتن حسابداری داشته باشد
+ try:
+ if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
+ raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
+ except Exception:
+ # در صورت هرگونه خطای غیرمنتظره در بررسی، اجازه ادامه نمیدهیم
+ raise
+ created = create_check(db, business_id, ctx.get_user_id(), payload)
return success_response(data=format_datetime_fields(created, request), request=request, message="CHECK_CREATED")
+@router.post(
+ "/checks/{check_id}/actions/endorse",
+ summary="واگذاری چک دریافتی به شخص",
+ description="واگذاری چک دریافتی به شخص دیگر",
+)
+async def endorse_check_endpoint(
+ request: Request,
+ check_id: int,
+ body: CheckEndorseRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ # access check
+ before = get_check_by_id(db, check_id)
+ if not before:
+ raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
+ try:
+ biz_id = int(before.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
+ raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
+ result = endorse_check(db, check_id, ctx.get_user_id(), payload)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_ENDORSED")
+
+
+@router.post(
+ "/checks/{check_id}/actions/clear",
+ summary="وصول/پاس چک",
+ description="انتقال حساب چک به بانک در زمان پاس/وصول",
+)
+async def clear_check_endpoint(
+ request: Request,
+ check_id: int,
+ body: CheckClearRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ before = get_check_by_id(db, check_id)
+ if not before:
+ raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
+ try:
+ biz_id = int(before.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
+ raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
+ result = clear_check(db, check_id, ctx.get_user_id(), payload)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_CLEARED")
+
+
+@router.post(
+ "/checks/{check_id}/actions/return",
+ summary="عودت چک",
+ description="عودت چک به طرف مقابل",
+)
+async def return_check_endpoint(
+ request: Request,
+ check_id: int,
+ body: CheckReturnRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ before = get_check_by_id(db, check_id)
+ if not before:
+ raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
+ try:
+ biz_id = int(before.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
+ raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
+ result = return_check(db, check_id, ctx.get_user_id(), payload)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_RETURNED")
+
+
+@router.post(
+ "/checks/{check_id}/actions/bounce",
+ summary="برگشت چک",
+ description="برگشت چک و ثبت هزینه احتمالی",
+)
+async def bounce_check_endpoint(
+ request: Request,
+ check_id: int,
+ body: CheckBounceRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ before = get_check_by_id(db, check_id)
+ if not before:
+ raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
+ try:
+ biz_id = int(before.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
+ raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
+ result = bounce_check(db, check_id, ctx.get_user_id(), payload)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_BOUNCED")
+
+
+@router.post(
+ "/checks/{check_id}/actions/pay",
+ summary="پرداخت چک پرداختنی",
+ description="پاس چک پرداختنی از بانک",
+)
+async def pay_check_endpoint(
+ request: Request,
+ check_id: int,
+ body: CheckPayRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ before = get_check_by_id(db, check_id)
+ if not before:
+ raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
+ try:
+ biz_id = int(before.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
+ raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
+ result = pay_check(db, check_id, ctx.get_user_id(), payload)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_PAID")
+
+
+@router.post(
+ "/checks/{check_id}/actions/deposit",
+ summary="سپرده چک به بانک (اختیاری)",
+ description="انتقال به اسناد در جریان وصول",
+)
+async def deposit_check_endpoint(
+ request: Request,
+ check_id: int,
+ body: CheckDepositRequest = Body(...),
+ db: Session = Depends(get_db),
+ ctx: AuthContext = Depends(get_current_user),
+):
+ payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
+ before = get_check_by_id(db, check_id)
+ if not before:
+ raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
+ try:
+ biz_id = int(before.get("business_id"))
+ except Exception:
+ biz_id = None
+ if biz_id is not None and not ctx.can_access_business(biz_id):
+ raise ApiError("FORBIDDEN", "Access denied", http_status=403)
+ if bool(payload.get("auto_post")) and not ctx.has_any_permission("accounting", "write"):
+ raise ApiError("FORBIDDEN", "Missing permission: accounting.write for auto_post", http_status=403)
+ result = deposit_check(db, check_id, ctx.get_user_id(), payload)
+ return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_DEPOSITED")
+
@router.get(
diff --git a/hesabixAPI/adapters/api/v1/kardex.py b/hesabixAPI/adapters/api/v1/kardex.py
new file mode 100644
index 0000000..dffdbdd
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/kardex.py
@@ -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"
"
+ f"| {cell(it.get('document_date'))} | "
+ f"{cell(it.get('document_code'))} | "
+ f"{cell(it.get('document_type'))} | "
+ f"{cell(it.get('description'))} | "
+ f"{cell(it.get('debit'))} | "
+ f"{cell(it.get('credit'))} | "
+ f"{cell(it.get('quantity'))} | "
+ f"{cell(it.get('running_amount'))} | "
+ f"{cell(it.get('running_quantity'))} | "
+ f"
"
+ for it in items
+ ])
+
+ html = f"""
+
+
+
+
+
+
+ گزارش کاردکس
+
+
+
+ | تاریخ سند |
+ کد سند |
+ نوع سند |
+ شرح |
+ بدهکار |
+ بستانکار |
+ تعداد |
+ مانده مبلغ |
+ مانده تعداد |
+
+
+
+ {rows_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",
+ },
+ )
+
+
diff --git a/hesabixAPI/adapters/api/v1/schema_models/check.py b/hesabixAPI/adapters/api/v1/schema_models/check.py
index dbcd69d..66e36bb 100644
--- a/hesabixAPI/adapters/api/v1/schema_models/check.py
+++ b/hesabixAPI/adapters/api/v1/schema_models/check.py
@@ -15,6 +15,10 @@ class CheckCreateRequest(BaseModel):
branch_name: Optional[str] = Field(default=None, max_length=255)
amount: float = Field(..., gt=0)
currency_id: int = Field(..., ge=1)
+ # گزینههای حسابداری
+ auto_post: Optional[bool] = Field(default=False)
+ document_date: Optional[str] = None
+ document_description: Optional[str] = Field(default=None, max_length=500)
@field_validator('sayad_code')
@classmethod
@@ -70,3 +74,51 @@ class CheckResponse(BaseModel):
from_attributes = True
+
+# =====================
+# Action Schemas
+# =====================
+
+class CheckEndorseRequest(BaseModel):
+ target_person_id: int = Field(..., ge=1)
+ document_date: Optional[str] = None
+ description: Optional[str] = Field(default=None, max_length=500)
+ auto_post: bool = Field(default=True)
+
+
+class CheckClearRequest(BaseModel):
+ bank_account_id: int = Field(..., ge=1)
+ document_date: Optional[str] = None
+ description: Optional[str] = Field(default=None, max_length=500)
+ auto_post: bool = Field(default=True)
+
+
+class CheckReturnRequest(BaseModel):
+ target_person_id: Optional[int] = Field(default=None, ge=1)
+ document_date: Optional[str] = None
+ description: Optional[str] = Field(default=None, max_length=500)
+ auto_post: bool = Field(default=True)
+
+
+class CheckBounceRequest(BaseModel):
+ bank_account_id: Optional[int] = Field(default=None, ge=1)
+ expense_account_id: Optional[int] = Field(default=None, ge=1)
+ expense_amount: Optional[float] = Field(default=None, gt=0)
+ document_date: Optional[str] = None
+ description: Optional[str] = Field(default=None, max_length=500)
+ auto_post: bool = Field(default=True)
+
+
+class CheckPayRequest(BaseModel):
+ bank_account_id: int = Field(..., ge=1)
+ document_date: Optional[str] = None
+ description: Optional[str] = Field(default=None, max_length=500)
+ auto_post: bool = Field(default=True)
+
+
+class CheckDepositRequest(BaseModel):
+ bank_account_id: int = Field(..., ge=1)
+ document_date: Optional[str] = None
+ description: Optional[str] = Field(default=None, max_length=500)
+ auto_post: bool = Field(default=True)
+
diff --git a/hesabixAPI/adapters/db/models/check.py b/hesabixAPI/adapters/db/models/check.py
index e27a165..a6e5edf 100644
--- a/hesabixAPI/adapters/db/models/check.py
+++ b/hesabixAPI/adapters/db/models/check.py
@@ -12,6 +12,7 @@ from sqlalchemy import (
Numeric,
Enum as SQLEnum,
Index,
+ JSON,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -23,6 +24,21 @@ class CheckType(str, Enum):
TRANSFERRED = "TRANSFERRED"
+class CheckStatus(str, Enum):
+ RECEIVED_ON_HAND = "RECEIVED_ON_HAND" # چک دریافتی در دست
+ TRANSFERRED_ISSUED = "TRANSFERRED_ISSUED" # چک پرداختنی صادر و تحویل شده
+ DEPOSITED = "DEPOSITED" # سپرده به بانک (در جریان وصول)
+ CLEARED = "CLEARED" # پاس/وصول شده
+ ENDORSED = "ENDORSED" # واگذار شده به شخص ثالث
+ RETURNED = "RETURNED" # عودت شده
+ BOUNCED = "BOUNCED" # برگشت خورده
+ CANCELLED = "CANCELLED" # ابطال شده
+
+class HolderType(str, Enum):
+ BUSINESS = "BUSINESS"
+ BANK = "BANK"
+ PERSON = "PERSON"
+
class Check(Base):
__tablename__ = "checks"
__table_args__ = (
@@ -54,6 +70,14 @@ class Check(Base):
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
+ # وضعیت و نگهدارنده
+ status: Mapped[CheckStatus | None] = mapped_column(SQLEnum(CheckStatus, name="check_status"), nullable=True, index=True)
+ status_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ current_holder_type: Mapped[HolderType | None] = mapped_column(SQLEnum(HolderType, name="check_holder_type"), nullable=True, index=True)
+ current_holder_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
+ last_action_document_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("documents.id", ondelete="SET NULL"), nullable=True, index=True)
+ developer_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -61,5 +85,6 @@ class Check(Base):
business = relationship("Business", backref="checks")
person = relationship("Person", lazy="joined")
currency = relationship("Currency")
+ last_action_document = relationship("Document")
diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py
index b51d966..e029d3d 100644
--- a/hesabixAPI/app/main.py
+++ b/hesabixAPI/app/main.py
@@ -35,6 +35,7 @@ from adapters.api.v1.transfers import router as transfers_router
from adapters.api.v1.fiscal_years import router as fiscal_years_router
from adapters.api.v1.expense_income import router as expense_income_router
from adapters.api.v1.documents import router as documents_router
+from adapters.api.v1.kardex import router as kardex_router
from app.core.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
@@ -319,6 +320,7 @@ def create_app() -> FastAPI:
application.include_router(expense_income_router, prefix=settings.api_v1_prefix)
application.include_router(documents_router, prefix=settings.api_v1_prefix)
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
+ application.include_router(kardex_router, prefix=settings.api_v1_prefix)
# Support endpoints
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
diff --git a/hesabixAPI/app/services/check_service.py b/hesabixAPI/app/services/check_service.py
index 4baa948..8b79753 100644
--- a/hesabixAPI/app/services/check_service.py
+++ b/hesabixAPI/app/services/check_service.py
@@ -1,14 +1,19 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
-from datetime import datetime
+from datetime import datetime, date
+from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
-from adapters.db.models.check import Check, CheckType
-from adapters.db.models.person import Person
+from adapters.db.models.check import Check, CheckType, CheckStatus, HolderType
+from adapters.db.models.document import Document
+from adapters.db.models.document_line import DocumentLine
+from adapters.db.models.account import Account
+from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.currency import Currency
+from adapters.db.models.person import Person
from app.core.responses import ApiError
@@ -19,7 +24,37 @@ def _parse_iso(dt: str) -> datetime:
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
-def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+def _parse_iso_date_only(dt: str | datetime | date) -> date:
+ if isinstance(dt, date) and not isinstance(dt, datetime):
+ return dt
+ if isinstance(dt, datetime):
+ return dt.date()
+ try:
+ return datetime.fromisoformat(str(dt)).date()
+ except Exception:
+ return datetime.utcnow().date()
+
+
+def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
+ account = db.query(Account).filter(Account.code == str(account_code)).first()
+ if not account:
+ from app.core.responses import ApiError
+ raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=404)
+ return account
+
+
+def _get_business_fiscal_year(db: Session, business_id: int) -> FiscalYear:
+ from sqlalchemy import and_ # local import to avoid unused import if not used elsewhere
+ fy = db.query(FiscalYear).filter(
+ and_(FiscalYear.business_id == business_id, FiscalYear.is_closed == False) # noqa: E712
+ ).order_by(FiscalYear.start_date.desc()).first()
+ if not fy:
+ from app.core.responses import ApiError
+ raise ApiError("FISCAL_YEAR_NOT_FOUND", "Active fiscal year not found", http_status=404)
+ return fy
+
+
+def create_check(db: Session, business_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(data.get('type', '')).lower()
if ctype not in ("received", "transferred"):
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
@@ -75,10 +110,109 @@ def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[st
currency_id=int(data.get('currency_id')),
)
+ # تعیین وضعیت اولیه
+ if ctype == "received":
+ obj.status = CheckStatus.RECEIVED_ON_HAND
+ obj.current_holder_type = HolderType.BUSINESS
+ obj.current_holder_id = None
+ else:
+ obj.status = CheckStatus.TRANSFERRED_ISSUED
+ obj.current_holder_type = HolderType.PERSON if person_id else HolderType.BUSINESS
+ obj.current_holder_id = int(person_id) if person_id else None
+
db.add(obj)
db.commit()
db.refresh(obj)
- return check_to_dict(db, obj)
+
+ # ایجاد سند حسابداری خودکار در صورت درخواست
+ created_document_id: Optional[int] = None
+ try:
+ if bool(data.get("auto_post")):
+ # آمادهسازی دادههای سند
+ document_date: date = _parse_iso_date_only(data.get("document_date") or issue_date)
+ fiscal_year = _get_business_fiscal_year(db, business_id)
+
+ # تعیین حسابها و سطرها
+ amount_dec = Decimal(str(amount_val))
+ lines: List[Dict[str, Any]] = []
+ description = (str(data.get("document_description")).strip() or None) if data.get("document_description") is not None else None
+
+ if ctype == "received":
+ # بدهکار: اسناد دریافتنی 10403
+ acc_notes_recv = _get_fixed_account_by_code(db, "10403")
+ lines.append({
+ "account_id": acc_notes_recv.id,
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "ثبت چک دریافتی",
+ "check_id": obj.id,
+ })
+ # بستانکار: حساب دریافتنی شخص 10401
+ acc_ar = _get_fixed_account_by_code(db, "10401")
+ lines.append({
+ "account_id": acc_ar.id,
+ "person_id": int(person_id) if person_id else None,
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "ثبت چک دریافتی",
+ "check_id": obj.id,
+ })
+ else: # transferred
+ # بدهکار: حساب پرداختنی شخص 20201 (در صورت وجود شخص)
+ acc_ap = _get_fixed_account_by_code(db, "20201")
+ lines.append({
+ "account_id": acc_ap.id,
+ "person_id": int(person_id) if person_id else None,
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "ثبت چک واگذار شده",
+ "check_id": obj.id,
+ })
+ # بستانکار: اسناد پرداختنی 20202
+ acc_notes_pay = _get_fixed_account_by_code(db, "20202")
+ lines.append({
+ "account_id": acc_notes_pay.id,
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "ثبت چک واگذار شده",
+ "check_id": obj.id,
+ })
+
+ # ایجاد سند
+ document = Document(
+ code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
+ business_id=business_id,
+ fiscal_year_id=fiscal_year.id,
+ currency_id=int(data.get("currency_id")),
+ created_by_user_id=int(user_id),
+ document_date=document_date,
+ document_type="check",
+ is_proforma=False,
+ description=description,
+ extra_info={
+ "source": "check_create",
+ "check_id": obj.id,
+ "check_type": ctype,
+ },
+ )
+ db.add(document)
+ db.flush()
+
+ for line in lines:
+ db.add(DocumentLine(document_id=document.id, **line))
+
+ db.commit()
+ db.refresh(document)
+ created_document_id = document.id
+ except Exception:
+ # در صورت شکست ایجاد سند، تغییری در ایجاد چک نمیدهیم و خطا نمیریزیم
+ # (میتوان رفتار را سختگیرانه کرد و رولبک نمود؛ فعلاً نرم)
+ db.rollback()
+
+ result = check_to_dict(db, obj)
+ if created_document_id:
+ result["document_id"] = created_document_id
+ return result
def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
@@ -86,6 +220,401 @@ def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
return check_to_dict(db, obj) if obj else None
+# =====================
+# Action helpers
+# =====================
+
+def _create_document_for_check_action(
+ db: Session,
+ *,
+ business_id: int,
+ user_id: int,
+ currency_id: int,
+ document_date: date,
+ description: Optional[str],
+ lines: List[Dict[str, Any]],
+ extra_info: Dict[str, Any],
+) -> int:
+ document = Document(
+ code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
+ business_id=business_id,
+ fiscal_year_id=_get_business_fiscal_year(db, business_id).id,
+ currency_id=int(currency_id),
+ created_by_user_id=int(user_id),
+ document_date=document_date,
+ document_type="check",
+ is_proforma=False,
+ description=description,
+ extra_info=extra_info,
+ )
+ db.add(document)
+ db.flush()
+ for line in lines:
+ db.add(DocumentLine(document_id=document.id, **line))
+ db.commit()
+ db.refresh(document)
+ return document.id
+
+
+def _ensure_account(db: Session, code: str) -> int:
+ return _get_fixed_account_by_code(db, code).id
+
+
+def _parse_optional_date(d: Any, fallback: date) -> date:
+ return _parse_iso_date_only(d) if d else fallback
+
+
+def _load_check_or_404(db: Session, check_id: int) -> Check:
+ obj = db.query(Check).filter(Check.id == check_id).first()
+ if not obj:
+ raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
+ return obj
+
+
+def endorse_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+ obj = _load_check_or_404(db, check_id)
+ if obj.type != CheckType.RECEIVED:
+ raise ApiError("INVALID_ACTION", "Only received checks can be endorsed", http_status=400)
+ if obj.status not in (CheckStatus.RECEIVED_ON_HAND, CheckStatus.RETURNED, CheckStatus.BOUNCED):
+ raise ApiError("INVALID_STATE", f"Cannot endorse from status {obj.status}", http_status=400)
+
+ target_person_id = int(data.get("target_person_id"))
+ document_date = _parse_optional_date(data.get("document_date"), obj.issue_date.date())
+ description = (data.get("description") or None)
+
+ lines: List[Dict[str, Any]] = []
+ amount_dec = Decimal(str(obj.amount))
+ # Dr 20201 (target person AP), Cr 10403
+ lines.append({
+ "account_id": _ensure_account(db, "20201"),
+ "person_id": target_person_id,
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "واگذاری چک",
+ "check_id": obj.id,
+ })
+ lines.append({
+ "account_id": _ensure_account(db, "10403"),
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "واگذاری چک",
+ "check_id": obj.id,
+ })
+
+ document_id = None
+ if bool(data.get("auto_post", True)):
+ document_id = _create_document_for_check_action(
+ db,
+ business_id=obj.business_id,
+ user_id=user_id,
+ currency_id=obj.currency_id,
+ document_date=document_date,
+ description=description,
+ lines=lines,
+ extra_info={"source": "check_action", "action": "endorse", "check_id": obj.id},
+ )
+
+ # Update state
+ obj.status = CheckStatus.ENDORSED
+ obj.status_at = datetime.utcnow()
+ obj.current_holder_type = HolderType.PERSON
+ obj.current_holder_id = target_person_id
+ obj.last_action_document_id = document_id
+ db.commit(); db.refresh(obj)
+ res = check_to_dict(db, obj)
+ if document_id:
+ res["document_id"] = document_id
+ return res
+
+
+def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+ obj = _load_check_or_404(db, check_id)
+ document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
+ description = (data.get("description") or None)
+ amount_dec = Decimal(str(obj.amount))
+ lines: List[Dict[str, Any]] = []
+
+ if obj.type == CheckType.RECEIVED:
+ # Dr 10203 (bank), Cr 10403
+ lines.append({
+ "account_id": _ensure_account(db, "10203"),
+ "bank_account_id": int(data.get("bank_account_id")),
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "وصول چک",
+ "check_id": obj.id,
+ })
+ lines.append({
+ "account_id": _ensure_account(db, "10403"),
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "وصول چک",
+ "check_id": obj.id,
+ })
+ else:
+ # transferred/pay: Dr 20202, Cr 10203
+ lines.append({
+ "account_id": _ensure_account(db, "20202"),
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "پرداخت چک",
+ "check_id": obj.id,
+ })
+ lines.append({
+ "account_id": _ensure_account(db, "10203"),
+ "bank_account_id": int(data.get("bank_account_id")),
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "پرداخت چک",
+ "check_id": obj.id,
+ })
+
+ document_id = None
+ if bool(data.get("auto_post", True)):
+ document_id = _create_document_for_check_action(
+ db,
+ business_id=obj.business_id,
+ user_id=user_id,
+ currency_id=obj.currency_id,
+ document_date=document_date,
+ description=description,
+ lines=lines,
+ extra_info={"source": "check_action", "action": "clear", "check_id": obj.id},
+ )
+
+ obj.status = CheckStatus.CLEARED
+ obj.status_at = datetime.utcnow()
+ obj.current_holder_type = HolderType.BANK
+ obj.current_holder_id = int(data.get("bank_account_id"))
+ obj.last_action_document_id = document_id
+ db.commit(); db.refresh(obj)
+ res = check_to_dict(db, obj)
+ if document_id:
+ res["document_id"] = document_id
+ return res
+
+
+def pay_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+ # alias to clear_check for transferred
+ obj = _load_check_or_404(db, check_id)
+ if obj.type != CheckType.TRANSFERRED:
+ raise ApiError("INVALID_ACTION", "Only transferred checks can be paid", http_status=400)
+ return clear_check(db, check_id, user_id, data)
+
+
+def return_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+ obj = _load_check_or_404(db, check_id)
+ document_date = _parse_optional_date(data.get("document_date"), obj.issue_date.date())
+ description = (data.get("description") or None)
+ amount_dec = Decimal(str(obj.amount))
+ lines: List[Dict[str, Any]] = []
+
+ if obj.type == CheckType.RECEIVED:
+ if not obj.person_id:
+ raise ApiError("PERSON_REQUIRED", "person_id is required on received check to return", http_status=400)
+ # Dr 10401(person), Cr 10403
+ lines.append({
+ "account_id": _ensure_account(db, "10401"),
+ "person_id": int(obj.person_id),
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "عودت چک",
+ "check_id": obj.id,
+ })
+ lines.append({
+ "account_id": _ensure_account(db, "10403"),
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "عودت چک",
+ "check_id": obj.id,
+ })
+ obj.current_holder_type = HolderType.PERSON
+ obj.current_holder_id = int(obj.person_id)
+ else:
+ # transferred: Dr 20202, Cr 20201(person)
+ if not obj.person_id:
+ raise ApiError("PERSON_REQUIRED", "person_id is required on transferred check to return", http_status=400)
+ lines.append({
+ "account_id": _ensure_account(db, "20202"),
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "عودت چک",
+ "check_id": obj.id,
+ })
+ lines.append({
+ "account_id": _ensure_account(db, "20201"),
+ "person_id": int(obj.person_id),
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "عودت چک",
+ "check_id": obj.id,
+ })
+ obj.current_holder_type = HolderType.BUSINESS
+ obj.current_holder_id = None
+
+ document_id = None
+ if bool(data.get("auto_post", True)):
+ document_id = _create_document_for_check_action(
+ db,
+ business_id=obj.business_id,
+ user_id=user_id,
+ currency_id=obj.currency_id,
+ document_date=document_date,
+ description=description,
+ lines=lines,
+ extra_info={"source": "check_action", "action": "return", "check_id": obj.id},
+ )
+
+ obj.status = CheckStatus.RETURNED
+ obj.status_at = datetime.utcnow()
+ obj.last_action_document_id = document_id
+ db.commit(); db.refresh(obj)
+ res = check_to_dict(db, obj)
+ if document_id:
+ res["document_id"] = document_id
+ return res
+
+
+def bounce_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+ obj = _load_check_or_404(db, check_id)
+ document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
+ description = (data.get("description") or None)
+ amount_dec = Decimal(str(obj.amount))
+ lines: List[Dict[str, Any]] = []
+
+ if obj.type == CheckType.RECEIVED:
+ # Reverse cash if previously cleared; simplified: Dr 10403, Cr 10203
+ bank_account_id = data.get("bank_account_id")
+ lines.append({
+ "account_id": _ensure_account(db, "10403"),
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "برگشت چک",
+ "check_id": obj.id,
+ })
+ lines.append({
+ "account_id": _ensure_account(db, "10203"),
+ **({"bank_account_id": int(bank_account_id)} if bank_account_id else {}),
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "برگشت چک",
+ "check_id": obj.id,
+ })
+ else:
+ # transferred: Dr 20202, Cr 20201(person) (increase AP again)
+ if not obj.person_id:
+ raise ApiError("PERSON_REQUIRED", "person_id is required on transferred check to bounce", http_status=400)
+ lines.append({
+ "account_id": _ensure_account(db, "20202"),
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "برگشت چک",
+ "check_id": obj.id,
+ })
+ lines.append({
+ "account_id": _ensure_account(db, "20201"),
+ "person_id": int(obj.person_id),
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "برگشت چک",
+ "check_id": obj.id,
+ })
+
+ # Optional expense fee
+ expense_amount = data.get("expense_amount")
+ expense_account_id = data.get("expense_account_id")
+ bank_account_id = data.get("bank_account_id")
+ if expense_amount and expense_account_id and float(expense_amount) > 0:
+ lines.append({
+ "account_id": int(expense_account_id),
+ "debit": Decimal(str(expense_amount)),
+ "credit": Decimal(0),
+ "description": description or "هزینه برگشت چک",
+ "check_id": obj.id,
+ })
+ lines.append({
+ "account_id": _ensure_account(db, "10203"),
+ **({"bank_account_id": int(bank_account_id)} if bank_account_id else {}),
+ "debit": Decimal(0),
+ "credit": Decimal(str(expense_amount)),
+ "description": description or "هزینه برگشت چک",
+ "check_id": obj.id,
+ })
+
+ document_id = None
+ if bool(data.get("auto_post", True)):
+ document_id = _create_document_for_check_action(
+ db,
+ business_id=obj.business_id,
+ user_id=user_id,
+ currency_id=obj.currency_id,
+ document_date=document_date,
+ description=description,
+ lines=lines,
+ extra_info={"source": "check_action", "action": "bounce", "check_id": obj.id},
+ )
+
+ obj.status = CheckStatus.BOUNCED
+ obj.status_at = datetime.utcnow()
+ obj.current_holder_type = HolderType.BUSINESS
+ obj.current_holder_id = None
+ obj.last_action_document_id = document_id
+ db.commit(); db.refresh(obj)
+ res = check_to_dict(db, obj)
+ if document_id:
+ res["document_id"] = document_id
+ return res
+
+
+def deposit_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
+ obj = _load_check_or_404(db, check_id)
+ if obj.type != CheckType.RECEIVED:
+ raise ApiError("INVALID_ACTION", "Only received checks can be deposited", http_status=400)
+ document_date = _parse_optional_date(data.get("document_date"), obj.due_date.date())
+ description = (data.get("description") or None)
+ amount_dec = Decimal(str(obj.amount))
+ # Requires account 10404 to exist
+ in_collection = _get_fixed_account_by_code(db, "10404") # may raise 404
+ lines: List[Dict[str, Any]] = [
+ {
+ "account_id": in_collection.id,
+ "debit": amount_dec,
+ "credit": Decimal(0),
+ "description": description or "سپرده چک به بانک",
+ "check_id": obj.id,
+ },
+ {
+ "account_id": _ensure_account(db, "10403"),
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "سپرده چک به بانک",
+ "check_id": obj.id,
+ },
+ ]
+ document_id = None
+ if bool(data.get("auto_post", True)):
+ document_id = _create_document_for_check_action(
+ db,
+ business_id=obj.business_id,
+ user_id=user_id,
+ currency_id=obj.currency_id,
+ document_date=document_date,
+ description=description,
+ lines=lines,
+ extra_info={"source": "check_action", "action": "deposit", "check_id": obj.id},
+ )
+
+ obj.status = CheckStatus.DEPOSITED
+ obj.status_at = datetime.utcnow()
+ obj.current_holder_type = HolderType.BANK
+ obj.last_action_document_id = document_id
+ db.commit(); db.refresh(obj)
+ res = check_to_dict(db, obj)
+ if document_id:
+ res["document_id"] = document_id
+ return res
+
+
def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
obj = db.query(Check).filter(Check.id == check_id).first()
if obj is None:
@@ -197,6 +726,22 @@ def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[st
q = q.filter(Check.type == enum_val)
except Exception:
pass
+ elif prop == 'status':
+ try:
+ if op == '=' and isinstance(val, str) and val:
+ enum_val = CheckStatus[val]
+ q = q.filter(Check.status == enum_val)
+ elif op == 'in' and isinstance(val, list) and val:
+ enum_vals = []
+ for v in val:
+ try:
+ enum_vals.append(CheckStatus[str(v)])
+ except Exception:
+ pass
+ if enum_vals:
+ q = q.filter(Check.status.in_(enum_vals))
+ except Exception:
+ pass
elif prop == 'currency' and op == '=':
try:
q = q.filter(Check.currency_id == int(val))
@@ -283,6 +828,11 @@ def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]
"amount": float(obj.amount),
"currency_id": obj.currency_id,
"currency": currency_title,
+ "status": (obj.status.name if obj.status else None),
+ "status_at": (obj.status_at.isoformat() if obj.status_at else None),
+ "current_holder_type": (obj.current_holder_type.name if obj.current_holder_type else None),
+ "current_holder_id": obj.current_holder_id,
+ "last_action_document_id": obj.last_action_document_id,
"created_at": obj.created_at.isoformat(),
"updated_at": obj.updated_at.isoformat(),
}
diff --git a/hesabixAPI/app/services/kardex_service.py b/hesabixAPI/app/services/kardex_service.py
new file mode 100644
index 0000000..2b67bce
--- /dev/null
+++ b/hesabixAPI/app/services/kardex_service.py
@@ -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,
+ }
+
+
diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
index e940fb4..9273166 100644
--- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
+++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
@@ -20,6 +20,7 @@ adapters/api/v1/expense_income.py
adapters/api/v1/fiscal_years.py
adapters/api/v1/health.py
adapters/api/v1/invoices.py
+adapters/api/v1/kardex.py
adapters/api/v1/persons.py
adapters/api/v1/petty_cash.py
adapters/api/v1/price_lists.py
@@ -146,6 +147,7 @@ app/services/email_service.py
app/services/expense_income_service.py
app/services/file_storage_service.py
app/services/invoice_service.py
+app/services/kardex_service.py
app/services/person_service.py
app/services/petty_cash_service.py
app/services/price_list_service.py
@@ -217,6 +219,7 @@ migrations/versions/20251014_000301_add_product_id_to_document_lines.py
migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
migrations/versions/20251014_000501_add_quantity_to_document_lines.py
migrations/versions/20251021_000601_add_bom_and_warehouses.py
+migrations/versions/20251102_120001_add_check_status_fields.py
migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
diff --git a/hesabixAPI/migrations/versions/20251102_120001_add_check_status_fields.py b/hesabixAPI/migrations/versions/20251102_120001_add_check_status_fields.py
new file mode 100644
index 0000000..95720bb
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20251102_120001_add_check_status_fields.py
@@ -0,0 +1,82 @@
+from typing import Sequence, Union
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = '20251102_120001_add_check_status_fields'
+down_revision: Union[str, None] = '20251011_000901_add_checks_table'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+
+ # افزودن ستونها اگر وجود ندارند (سازگار با MySQL و PostgreSQL)
+ columns = {c['name'] for c in inspector.get_columns('checks')}
+
+ if 'status' not in columns:
+ op.add_column('checks', sa.Column('status', sa.Enum(
+ 'RECEIVED_ON_HAND', 'TRANSFERRED_ISSUED', 'DEPOSITED', 'CLEARED', 'ENDORSED', 'RETURNED', 'BOUNCED', 'CANCELLED', name='check_status'
+ ), nullable=True))
+ try:
+ op.create_index('ix_checks_business_status', 'checks', ['business_id', 'status'])
+ except Exception:
+ pass
+
+ if 'status_at' not in columns:
+ op.add_column('checks', sa.Column('status_at', sa.DateTime(), nullable=True))
+
+ if 'current_holder_type' not in columns:
+ op.add_column('checks', sa.Column('current_holder_type', sa.Enum('BUSINESS', 'BANK', 'PERSON', name='check_holder_type'), nullable=True))
+ try:
+ op.create_index('ix_checks_business_holder_type', 'checks', ['business_id', 'current_holder_type'])
+ except Exception:
+ pass
+
+ if 'current_holder_id' not in columns:
+ op.add_column('checks', sa.Column('current_holder_id', sa.Integer(), nullable=True))
+ try:
+ op.create_index('ix_checks_business_holder_id', 'checks', ['business_id', 'current_holder_id'])
+ except Exception:
+ pass
+
+ if 'last_action_document_id' not in columns:
+ op.add_column('checks', sa.Column('last_action_document_id', sa.Integer(), sa.ForeignKey('documents.id', ondelete='SET NULL'), nullable=True))
+
+ if 'developer_data' not in columns:
+ # MySQL و PostgreSQL هر دو از JSON پشتیبانی میکنند
+ op.add_column('checks', sa.Column('developer_data', sa.JSON(), nullable=True))
+
+
+def downgrade() -> None:
+ # حذف ایندکسها و ستونها
+ try:
+ op.drop_index('ix_checks_business_status', table_name='checks')
+ except Exception:
+ pass
+ try:
+ op.drop_index('ix_checks_business_holder_type', table_name='checks')
+ except Exception:
+ pass
+ try:
+ op.drop_index('ix_checks_business_holder_id', table_name='checks')
+ except Exception:
+ pass
+
+ for col in ['developer_data', 'last_action_document_id', 'current_holder_id', 'current_holder_type', 'status_at', 'status']:
+ try:
+ op.drop_column('checks', col)
+ except Exception:
+ pass
+
+ # حذف انواع Enum فقط در پایگاههایی که لازم است (PostgreSQL)
+ # در MySQL نیازی به حذف نوع جداگانه نیست
+ try:
+ op.execute("DROP TYPE check_holder_type")
+ op.execute("DROP TYPE check_status")
+ except Exception:
+ pass
+
+
diff --git a/hesabixAPI/scripts/add_check_status_columns.py b/hesabixAPI/scripts/add_check_status_columns.py
new file mode 100644
index 0000000..2e01a89
--- /dev/null
+++ b/hesabixAPI/scripts/add_check_status_columns.py
@@ -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()
diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb
index 3f70a4f..5da4f42 100644
--- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb
+++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb
@@ -1046,7 +1046,7 @@
"currency": "واحد پول",
"isDefault": "پیشفرض",
"description": "توضیحات",
- "actions": "اقدامات",
+ "actions": "عملیات",
"yes": "بله",
"no": "خیر",
"pettyCash": "تنخواه گردان",
diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart
index 75ec874..4de620f 100644
--- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart
+++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart
@@ -1223,7 +1223,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get edit => 'ویرایش';
@override
- String get actions => 'اقدامات';
+ String get actions => 'عملیات';
@override
String get search => 'جستجو';
diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart
index f1e2dd5..3e3b2d8 100644
--- a/hesabixUI/hesabix_ui/lib/main.dart
+++ b/hesabixUI/hesabix_ui/lib/main.dart
@@ -30,6 +30,7 @@ import 'pages/business/new_invoice_page.dart';
import 'pages/business/settings_page.dart';
import 'pages/business/business_info_settings_page.dart';
import 'pages/business/reports_page.dart';
+import 'pages/business/kardex_page.dart';
import 'pages/business/persons_page.dart';
import 'pages/business/product_attributes_page.dart';
import 'pages/business/products_page.dart';
@@ -633,6 +634,19 @@ class _MyAppState extends State {
);
},
),
+ GoRoute(
+ path: '/business/:business_id/reports/kardex',
+ name: 'business_reports_kardex',
+ pageBuilder: (context, state) {
+ final businessId = int.parse(state.pathParameters['business_id']!);
+ return NoTransitionPage(
+ child: KardexPage(
+ businessId: businessId,
+ calendarController: _calendarController!,
+ ),
+ );
+ },
+ ),
GoRoute(
path: '/business/:business_id/settings',
name: 'business_settings',
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart
index 06f5453..74027dd 100644
--- a/hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart
@@ -34,12 +34,15 @@ class _CheckFormPageState extends State {
DateTime? _dueDate;
int? _currencyId;
dynamic _selectedPerson; // using Person type would be ideal; keep dynamic to avoid imports complexity
+ bool _autoPost = false;
+ DateTime? _documentDate;
final _checkNumberCtrl = TextEditingController();
final _sayadCtrl = TextEditingController();
final _bankCtrl = TextEditingController();
final _branchCtrl = TextEditingController();
final _amountCtrl = TextEditingController();
+ final _docDescCtrl = TextEditingController();
bool _loading = false;
@@ -50,6 +53,7 @@ class _CheckFormPageState extends State {
_currencyId = widget.authStore.selectedCurrencyId;
_issueDate = DateTime.now();
_dueDate = DateTime.now();
+ _documentDate = _issueDate;
if (widget.checkId != null) {
_loadData();
}
@@ -114,6 +118,9 @@ class _CheckFormPageState extends State {
if (_branchCtrl.text.trim().isNotEmpty) 'branch_name': _branchCtrl.text.trim(),
'amount': num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()),
'currency_id': _currencyId,
+ 'auto_post': _autoPost,
+ if (_autoPost && _documentDate != null) 'document_date': _documentDate!.toIso8601String(),
+ if (_autoPost && _docDescCtrl.text.trim().isNotEmpty) 'document_description': _docDescCtrl.text.trim(),
};
if (widget.checkId == null) {
@@ -152,6 +159,7 @@ class _CheckFormPageState extends State {
_bankCtrl.dispose();
_branchCtrl.dispose();
_amountCtrl.dispose();
+ _docDescCtrl.dispose();
super.dispose();
}
@@ -159,6 +167,7 @@ class _CheckFormPageState extends State {
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final isEdit = widget.checkId != null;
+ final canAccountingWrite = widget.authStore.canWriteSection('accounting');
if (!widget.authStore.canWriteSection('checks')) {
return AccessDeniedPage(message: t.accessDenied);
@@ -321,6 +330,43 @@ class _CheckFormPageState extends State {
],
),
+ const SizedBox(height: 16),
+ if (canAccountingWrite) ...[
+ SwitchListTile(
+ value: _autoPost,
+ onChanged: (v) => setState(() {
+ _autoPost = v;
+ _documentDate ??= _issueDate;
+ }),
+ title: const Text('ثبت سند حسابداری همزمان'),
+ ),
+ if (_autoPost) ...[
+ Row(
+ children: [
+ Expanded(
+ child: DateInputField(
+ value: _documentDate,
+ labelText: 'تاریخ سند',
+ hintText: 'انتخاب تاریخ سند',
+ calendarController: widget.calendarController!,
+ onChanged: (d) => setState(() => _documentDate = d),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: TextFormField(
+ controller: _docDescCtrl,
+ decoration: const InputDecoration(
+ labelText: 'شرح سند',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ],
+
const SizedBox(height: 16),
Row(
children: [
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart
index 03574ea..3e9a011 100644
--- a/hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart
@@ -7,6 +7,8 @@ import '../../widgets/data_table/data_table_config.dart';
import '../../widgets/permission/permission_widgets.dart';
import '../../widgets/invoice/person_combobox_widget.dart';
import '../../models/person_model.dart';
+import '../../services/check_service.dart';
+import '../../widgets/invoice/bank_account_combobox_widget.dart';
class ChecksPage extends StatefulWidget {
final int businessId;
@@ -25,6 +27,7 @@ class ChecksPage extends StatefulWidget {
class _ChecksPageState extends State {
final GlobalKey _tableKey = GlobalKey();
Person? _selectedPerson;
+ final _checkService = CheckService();
void _refresh() {
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
@@ -111,6 +114,33 @@ class _ChecksPageState extends State {
TextColumn('currency', 'ارز', width: ColumnWidth.small,
formatter: (row) => (row['currency'] ?? '-'),
),
+ TextColumn('status', 'وضعیت', width: ColumnWidth.medium,
+ filterType: ColumnFilterType.multiSelect,
+ filterOptions: const [
+ FilterOption(value: 'RECEIVED_ON_HAND', label: 'در دست (دریافتی)'),
+ FilterOption(value: 'TRANSFERRED_ISSUED', label: 'صادر شده (پرداختنی)'),
+ FilterOption(value: 'DEPOSITED', label: 'سپرده به بانک'),
+ FilterOption(value: 'CLEARED', label: 'پاس/وصول شده'),
+ FilterOption(value: 'ENDORSED', label: 'واگذار شده'),
+ FilterOption(value: 'RETURNED', label: 'عودت شده'),
+ FilterOption(value: 'BOUNCED', label: 'برگشت خورده'),
+ FilterOption(value: 'CANCELLED', label: 'ابطال'),
+ ],
+ formatter: (row) {
+ final s = (row['status'] ?? '').toString();
+ switch (s) {
+ case 'RECEIVED_ON_HAND': return 'در دست (دریافتی)';
+ case 'TRANSFERRED_ISSUED': return 'صادر شده (پرداختنی)';
+ case 'DEPOSITED': return 'سپرده به بانک';
+ case 'CLEARED': return 'پاس/وصول شده';
+ case 'ENDORSED': return 'واگذار شده';
+ case 'RETURNED': return 'عودت شده';
+ case 'BOUNCED': return 'برگشت خورده';
+ case 'CANCELLED': return 'ابطال';
+ }
+ return '-';
+ },
+ ),
ActionColumn('actions', t.actions, actions: [
DataTableAction(
icon: Icons.edit,
@@ -122,10 +152,87 @@ class _ChecksPageState extends State {
}
},
),
+ 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);
+ } 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);
+ } 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);
+ } 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);
+ } 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);
+ } 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);
+ } else {
+ ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('این عملیات برای وضعیت فعلی مجاز نیست')));
+ }
+ },
+ ),
]),
],
searchFields: ['check_number','sayad_code','bank_name','branch_name','person_name'],
- filterFields: ['type','currency','issue_date','due_date'],
+ filterFields: ['type','currency','issue_date','due_date','status'],
defaultPageSize: 20,
customHeaderActions: [
// فیلتر شخص
@@ -165,6 +272,158 @@ class _ChecksPageState extends State {
},
);
}
+
+ Future _openEndorseDialog(BuildContext context, Map 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 _openClearDialog(BuildContext context, Map 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 _openPayDialog(BuildContext context, Map row) async {
+ // پرداخت چک پرداختنی (pay)
+ await _openClearDialog(context, row);
+ }
+
+ Future _confirmReturn(BuildContext context, Map row) async {
+ final ok = await showDialog(
+ 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 _confirmBounce(BuildContext context, Map row) async {
+ final ok = await showDialog(
+ 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 _confirmDeposit(BuildContext context, Map row) async {
+ final ok = await showDialog(
+ 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')));
+ }
+ }
}
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart
new file mode 100644
index 0000000..31938fe
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart
@@ -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 createState() => _KardexPageState();
+}
+
+class _KardexPageState extends State {
+ 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