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> _fiscalYears = const []; + + // Multi-select state + final List _selectedPersons = []; + final List> _selectedProducts = []; + final List _selectedBankAccounts = []; + final List _selectedCashRegisters = []; + final List _selectedPettyCash = []; + final List _selectedAccounts = []; + final List _selectedChecks = []; + // Initial filters from URL + List _initialPersonIds = const []; + + // Temp selections for pickers (to clear after add) + Person? _personToAdd; + Map? _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 _additionalParams() { + String? fmt(DateTime? d) => d == null ? null : d.toIso8601String().substring(0, 10); + var personIds = _selectedPersons.map((p) => p.id).whereType().toList(); + if (personIds.isEmpty && _initialPersonIds.isNotEmpty) { + personIds = List.from(_initialPersonIds); + } + final productIds = _selectedProducts.map((m) => m['id']).map((e) => int.tryParse('$e')).whereType().toList(); + final bankIds = _selectedBankAccounts.map((b) => int.tryParse(b.id)).whereType().toList(); + final cashIds = _selectedCashRegisters.map((c) => int.tryParse(c.id)).whereType().toList(); + final pettyIds = _selectedPettyCash.map((p) => int.tryParse(p.id)).whereType().toList(); + final accountIds = _selectedAccounts.map((a) => a.id).whereType().toList(); + final checkIds = _selectedChecks.map((c) => int.tryParse(c.id)).whereType().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> _buildTableConfig(AppLocalizations t) { + return DataTableConfig>( + 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)['document_date']?.toString()), + TextColumn('document_code', 'کد سند', + formatter: (item) => (item as Map)['document_code']?.toString()), + TextColumn('document_type', 'نوع سند', + formatter: (item) => (item as Map)['document_type']?.toString()), + TextColumn('description', 'شرح', + formatter: (item) => (item as Map)['description']?.toString()), + NumberColumn('debit', 'بدهکار', + formatter: (item) => ((item as Map)['debit'])?.toString()), + NumberColumn('credit', 'بستانکار', + formatter: (item) => ((item as Map)['credit'])?.toString()), + NumberColumn('quantity', 'تعداد', + formatter: (item) => ((item as Map)['quantity'])?.toString()), + NumberColumn('running_amount', 'مانده مبلغ', + formatter: (item) => ((item as Map)['running_amount'])?.toString()), + NumberColumn('running_quantity', 'مانده تعداد', + formatter: (item) => ((item as Map)['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().toList() ?? const []; + final s = {}; + if (single != null) s.add(single); + s.addAll(multi); + // در initState مقدار را مستقیم ست می‌کنیم تا اولین build همان فیلتر را ارسال کند + _initialPersonIds = s.toList(); + } catch (_) {} + } + + Future _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 {}, + ); + 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( + value: _selectedFiscalYearId, + decoration: const InputDecoration( + labelText: 'سال مالی', + border: OutlineInputBorder(), + isDense: true, + ), + items: _fiscalYears.map>((fy) { + final id = fy['id'] as int?; + final title = (fy['title'] ?? '').toString(); + return DropdownMenuItem( + 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( + 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( + 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>( + key: _tableKey, + config: _buildTableConfig(t), + fromJson: (json) => Map.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 جایگزین شد + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index a9f4bd5..a90ec94 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/core/api_client.dart'; @@ -65,12 +66,27 @@ class _PersonsPageState extends State { enableRowSelection: true, enableMultiRowSelection: true, columns: [ - NumberColumn( + CustomColumn( 'code', t.personCode, width: ColumnWidth.small, + sortable: true, 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( 'alias_name', @@ -316,6 +332,20 @@ class _PersonsPageState extends State { 'actions', t.actions, actions: [ + DataTableAction( + icon: Icons.view_kanban, + label: 'کاردکس', + onTap: (person) { + if (person is Person && person.id != null) { + context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}'); + } else if (person is Map) { + final id = person['id']; + if (id is int) { + context.go('/business/${widget.businessId}/reports/kardex?person_id=$id'); + } + } + }, + ), DataTableAction( icon: Icons.edit, label: t.edit, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart index 1521994..f291ee5 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/reports_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; import '../../core/auth_store.dart'; import '../../widgets/permission/access_denied_page.dart'; @@ -27,6 +28,22 @@ class ReportsPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildSection( + context, + title: 'گزارشات عمومی', + icon: Icons.assessment, + children: [ + _buildReportItem( + context, + title: 'کاردکس اسناد', + subtitle: 'نمایش ریز تراکنش‌ها بر اساس شخص/کالا/بانک/حساب/چک با فیلتر تاریخ', + icon: Icons.view_kanban, + onTap: () => context.go('/business/$businessId/reports/kardex'), + ), + ], + ), + + const SizedBox(height: 24), _buildSection( context, title: 'گزارشات اشخاص', diff --git a/hesabixUI/hesabix_ui/lib/services/check_service.dart b/hesabixUI/hesabix_ui/lib/services/check_service.dart index 4d56a20..dd060cb 100644 --- a/hesabixUI/hesabix_ui/lib/services/check_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/check_service.dart @@ -67,6 +67,37 @@ class CheckService { options: Options(responseType: ResponseType.bytes), ); } + + // ===== Actions ===== + Future> endorse({required int checkId, required Map body}) async { + final res = await _client.post>('/api/v1/checks/checks/$checkId/actions/endorse', data: body); + return (res.data?['data'] as Map? ?? {}); + } + + Future> clear({required int checkId, required Map body}) async { + final res = await _client.post>('/api/v1/checks/checks/$checkId/actions/clear', data: body); + return (res.data?['data'] as Map? ?? {}); + } + + Future> pay({required int checkId, required Map body}) async { + final res = await _client.post>('/api/v1/checks/checks/$checkId/actions/pay', data: body); + return (res.data?['data'] as Map? ?? {}); + } + + Future> returnCheck({required int checkId, required Map body}) async { + final res = await _client.post>('/api/v1/checks/checks/$checkId/actions/return', data: body); + return (res.data?['data'] as Map? ?? {}); + } + + Future> bounce({required int checkId, required Map body}) async { + final res = await _client.post>('/api/v1/checks/checks/$checkId/actions/bounce', data: body); + return (res.data?['data'] as Map? ?? {}); + } + + Future> deposit({required int checkId, required Map body}) async { + final res = await _client.post>('/api/v1/checks/checks/$checkId/actions/deposit', data: body); + return (res.data?['data'] as Map? ?? {}); + } } diff --git a/hesabixUI/hesabix_ui/lib/services/kardex_service.dart b/hesabixUI/hesabix_ui/lib/services/kardex_service.dart new file mode 100644 index 0000000..8f52e2c --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/kardex_service.dart @@ -0,0 +1,35 @@ +import '../core/api_client.dart'; + +class KardexService { + final ApiClient _client; + KardexService({ApiClient? client}) : _client = client ?? ApiClient(); + + Future> listLines({ + required int businessId, + required Map queryInfo, + }) async { + try { + final res = await _client.post>( + '/api/v1/kardex/businesses/$businessId/lines', + data: queryInfo, + ); + return res.data ?? {}; + } catch (e) { + return { + 'items': [], + '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(), + }; + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/check_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/check_combobox_widget.dart new file mode 100644 index 0000000..31cfe1d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/check_combobox_widget.dart @@ -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 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 createState() => _CheckComboboxWidgetState(); +} + +class _CheckComboboxWidgetState extends State { + final CheckService _service = CheckService(); + final TextEditingController _searchController = TextEditingController(); + Timer? _debounceTimer; + void Function(void Function())? _setModalState; + + List _items = []; + 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 _load() async { + await _performSearch(''); + } + + void _onSearchChanged(String q) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 400), () => _performSearch(q.trim())); + } + + Future _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? ?? const [])).map((e) { + final m = Map.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 = []; + 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 items; + final TextEditingController searchController; + final bool isLoading; + final bool isSearching; + final bool hasSearched; + final ValueChanged onSearchChanged; + final ValueChanged 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().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), + ); + }, + ), + ), + ], + ), + ); + } +}