diff --git a/hesabixAPI/adapters/api/v1/accounts.py b/hesabixAPI/adapters/api/v1/accounts.py index 6d64d71..641791d 100644 --- a/hesabixAPI/adapters/api/v1/accounts.py +++ b/hesabixAPI/adapters/api/v1/accounts.py @@ -31,7 +31,16 @@ def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]: roots: list[AccountTreeNode] = [] for n in nodes: node = AccountTreeNode( - id=n['id'], code=n['code'], name=n['name'], account_type=n.get('account_type'), parent_id=n.get('parent_id') + id=n['id'], + code=n['code'], + name=n['name'], + account_type=n.get('account_type'), + parent_id=n.get('parent_id'), + business_id=n.get('business_id'), + is_public=n.get('is_public'), + has_children=n.get('has_children'), + can_edit=n.get('can_edit'), + can_delete=n.get('can_delete'), ) by_id[node.id] = node for node in list(by_id.values()): @@ -58,10 +67,29 @@ def get_accounts_tree( rows = db.query(Account).filter( (Account.business_id == None) | (Account.business_id == business_id) # noqa: E711 ).order_by(Account.code.asc()).all() - flat = [ - {"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id} - for r in rows - ] + # محاسبه has_children با شمارش فرزندان در مجموعه + children_map: dict[int, int] = {} + for r in rows: + if r.parent_id: + children_map[r.parent_id] = children_map.get(r.parent_id, 0) + 1 + flat: list[Dict[str, Any]] = [] + for r in rows: + is_public = r.business_id is None + has_children = children_map.get(r.id, 0) > 0 + can_edit = (r.business_id == business_id) and True # شرط دسترسی نوشتن پایین‌تر بررسی می‌شود در UI/Endpoint + can_delete = can_edit and (not has_children) + flat.append({ + "id": r.id, + "code": r.code, + "name": r.name, + "account_type": r.account_type, + "parent_id": r.parent_id, + "business_id": r.business_id, + "is_public": is_public, + "has_children": has_children, + "can_edit": can_edit, + "can_delete": can_delete, + }) tree = _build_tree(flat) return success_response({"items": [n.model_dump() for n in tree]}, request) @@ -214,6 +242,17 @@ def create_business_account( # اجازه نوشتن در بخش حسابداری لازم است if not ctx.can_write_section("accounting"): raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403) + # والد اجباری است + if body.parent_id is None: + raise ApiError("PARENT_REQUIRED", "Parent account is required", http_status=400) + # اگر والد عمومی است باید قبلا دارای زیرمجموعه باشد (اجازه ایجاد زیر شاخه برای برگ عمومی را نمی‌دهیم) + parent = db.get(Account, int(body.parent_id)) if body.parent_id is not None else None + if parent is None: + raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400) + if parent.business_id is None: + # lazy-load children count + if not parent.children or len(parent.children) == 0: + raise ApiError("INVALID_PUBLIC_PARENT", "Cannot add child under a public leaf account", http_status=400) try: created = create_account( db, @@ -238,7 +277,7 @@ def create_business_account( @router.put( "/account/{account_id}", summary="ویرایش حساب", - description="ویرایش حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).", + description="ویرایش حساب اختصاصی بیزنس (دارای دسترسی write). حساب‌های عمومی غیرقابل‌ویرایش هستند.", ) def update_account_endpoint( request: Request, @@ -251,9 +290,9 @@ def update_account_endpoint( if not data: raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) acc_business_id = data.get("business_id") - # اگر عمومی است، فقط سوپرادمین - if acc_business_id is None and not ctx.is_superadmin(): - raise ApiError("FORBIDDEN", "Only superadmin can edit public accounts", http_status=403) + # حساب‌های عمومی غیرقابل‌ویرایش هستند + if acc_business_id is None: + raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403) # اگر متعلق به بیزنس است باید دسترسی داشته باشد و write accounting داشته باشد if acc_business_id is not None: if not ctx.can_access_business(int(acc_business_id)): @@ -280,13 +319,15 @@ def update_account_endpoint( raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400) if code == "INVALID_PARENT_BUSINESS": raise ApiError("INVALID_PARENT_BUSINESS", "Parent must be public or within the same business", http_status=400) + if code == "PUBLIC_IMMUTABLE": + raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403) raise @router.delete( "/account/{account_id}", summary="حذف حساب", - description="حذف حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).", + description="حذف حساب اختصاصی بیزنس (دارای دسترسی write). حساب‌های عمومی غیرقابل‌حذف هستند.", ) def delete_account_endpoint( request: Request, @@ -298,16 +339,25 @@ def delete_account_endpoint( if not data: raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) acc_business_id = data.get("business_id") - if acc_business_id is None and not ctx.is_superadmin(): - raise ApiError("FORBIDDEN", "Only superadmin can delete public accounts", http_status=403) + # حساب‌های عمومی غیرقابل‌حذف هستند + if acc_business_id is None: + raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403) if acc_business_id is not None: if not ctx.can_access_business(int(acc_business_id)): raise ApiError("FORBIDDEN", "No access to business", http_status=403) if not ctx.can_write_section("accounting"): raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403) - ok = delete_account(db, account_id) - if not ok: - raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) - return success_response(None, request, message="ACCOUNT_DELETED") + try: + ok = delete_account(db, account_id) + if not ok: + raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) + return success_response(None, request, message="ACCOUNT_DELETED") + except ValueError as e: + code = str(e) + if code == "ACCOUNT_HAS_CHILDREN": + raise ApiError("ACCOUNT_HAS_CHILDREN", "Cannot delete account with children", http_status=400) + if code == "ACCOUNT_IN_USE": + raise ApiError("ACCOUNT_IN_USE", "Cannot delete account that is referenced by documents", http_status=400) + raise diff --git a/hesabixAPI/adapters/api/v1/inventory_transfers.py b/hesabixAPI/adapters/api/v1/inventory_transfers.py new file mode 100644 index 0000000..a531e29 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/inventory_transfers.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +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.permissions import require_business_access +from app.core.responses import success_response, ApiError, format_datetime_fields +from adapters.api.v1.schemas import QueryInfo +from adapters.db.models.document import Document +from sqlalchemy import and_ + +from app.services.inventory_transfer_service import create_inventory_transfer, DOCUMENT_TYPE_INVENTORY_TRANSFER + + +router = APIRouter(prefix="/inventory-transfers", tags=["inventory_transfers"]) + + +@router.post("/business/{business_id}") +@require_business_access("business_id") +def create_inventory_transfer_endpoint( + request: Request, + business_id: int, + payload: Dict[str, Any] = Body(...), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = create_inventory_transfer(db, business_id, ctx.user_id, payload) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.post("/business/{business_id}/query") +@require_business_access("business_id") +def query_inventory_transfers_endpoint( + request: Request, + business_id: int, + payload: QueryInfo, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + take = max(1, payload.take) + skip = max(0, payload.skip) + q = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.document_type == DOCUMENT_TYPE_INVENTORY_TRANSFER, + ) + ) + total = q.count() + rows = q.order_by(Document.document_date.desc(), Document.id.desc()).offset(skip).limit(take).all() + items = [{ + "id": d.id, + "code": d.code, + "document_date": d.document_date, + "currency_id": d.currency_id, + "description": d.description, + } for d in rows] + return success_response(data={ + "items": format_datetime_fields(items, request), + "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": payload.model_dump(), + }, request=request) + + +@router.post("/business/{business_id}/export/excel", + summary="خروجی Excel لیست انتقال موجودی", + description="خروجی اکسل از لیست اسناد انتقال موجودی بین انبارها", +) +@require_business_access("business_id") +def export_inventory_transfers_excel( + request: Request, + business_id: int, + body: Dict[str, Any] = Body(default={}), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + + from fastapi.responses import Response + from openpyxl import Workbook + from io import BytesIO + import datetime + + take = min(int(body.get("take", 1000)), 10000) + skip = int(body.get("skip", 0)) + q = db.query(Document).filter(and_(Document.business_id == business_id, Document.document_type == DOCUMENT_TYPE_INVENTORY_TRANSFER)) + rows = q.order_by(Document.document_date.desc(), Document.id.desc()).offset(skip).limit(take).all() + + wb = Workbook() + ws = wb.active + ws.title = "InventoryTransfers" + ws.append(["code", "document_date", "description"]) + for d in rows: + ws.append([d.code, d.document_date.isoformat(), d.description]) + + buf = BytesIO() + wb.save(buf) + content = buf.getvalue() + filename = f"inventory_transfers_{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("/business/{business_id}/export/pdf", + summary="خروجی PDF لیست انتقال موجودی", + description="خروجی PDF از لیست اسناد انتقال موجودی بین انبارها", +) +@require_business_access("business_id") +def export_inventory_transfers_pdf( + request: Request, + business_id: int, + body: Dict[str, Any] = Body(default={}), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + + from fastapi.responses import Response + from weasyprint import HTML, CSS + from weasyprint.text.fonts import FontConfiguration + from html import escape + import datetime + + take = min(int(body.get("take", 1000)), 10000) + skip = int(body.get("skip", 0)) + q = db.query(Document).filter(and_(Document.business_id == business_id, Document.document_type == DOCUMENT_TYPE_INVENTORY_TRANSFER)) + rows = q.order_by(Document.document_date.desc(), Document.id.desc()).offset(skip).limit(take).all() + + def cell(v: Any) -> str: + return escape(v.isoformat()) if hasattr(v, 'isoformat') else escape(str(v) if v is not None else "") + + rows_html = "".join([ + f"" + f"{cell(d.code)}" + f"{cell(d.document_date)}" + f"{cell(d.description)}" + f"" for d in rows + ]) + + html = f""" + + + + + + +

لیست انتقال موجودی بین انبارها

+ + + + + + + + + + {rows_html} + +
کد سندتاریخ سندشرح
+ + + """ + + font_config = FontConfiguration() + pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 portrait; margin: 12mm; }")], font_config=font_config) + filename = f"inventory_transfers_{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/kardex.py b/hesabixAPI/adapters/api/v1/kardex.py index dffdbdd..66573f5 100644 --- a/hesabixAPI/adapters/api/v1/kardex.py +++ b/hesabixAPI/adapters/api/v1/kardex.py @@ -53,6 +53,7 @@ async def list_kardex_lines_endpoint( "petty_cash_ids", "account_ids", "check_ids", + "warehouse_ids", "match_mode", "result_scope", ): @@ -113,6 +114,7 @@ async def export_kardex_excel_endpoint( "petty_cash_ids": body.get("petty_cash_ids"), "account_ids": body.get("account_ids"), "check_ids": body.get("check_ids"), + "warehouse_ids": body.get("warehouse_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)), @@ -130,7 +132,7 @@ async def export_kardex_excel_endpoint( ws = wb.active ws.title = "Kardex" headers = [ - "document_date", "document_code", "document_type", "description", + "document_date", "document_code", "document_type", "warehouse", "movement", "description", "debit", "credit", "quantity", "running_amount", "running_quantity", ] ws.append(headers) @@ -139,6 +141,8 @@ async def export_kardex_excel_endpoint( it.get("document_date"), it.get("document_code"), it.get("document_type"), + it.get("warehouse_name") or it.get("warehouse_id"), + it.get("movement"), it.get("description"), it.get("debit"), it.get("credit"), @@ -205,6 +209,7 @@ async def export_kardex_pdf_endpoint( "petty_cash_ids": body.get("petty_cash_ids"), "account_ids": body.get("account_ids"), "check_ids": body.get("check_ids"), + "warehouse_ids": body.get("warehouse_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)), @@ -223,6 +228,8 @@ async def export_kardex_pdf_endpoint( f"{cell(it.get('document_date'))}" f"{cell(it.get('document_code'))}" f"{cell(it.get('document_type'))}" + f"{cell(it.get('warehouse_name') or it.get('warehouse_id'))}" + f"{cell(it.get('movement'))}" f"{cell(it.get('description'))}" f"{cell(it.get('debit'))}" f"{cell(it.get('credit'))}" @@ -252,6 +259,8 @@ async def export_kardex_pdf_endpoint( تاریخ سند کد سند نوع سند + انبار + جهت حرکت شرح بدهکار بستانکار diff --git a/hesabixAPI/adapters/api/v1/schema_models/account.py b/hesabixAPI/adapters/api/v1/schema_models/account.py index 2405f44..ff44a59 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/account.py +++ b/hesabixAPI/adapters/api/v1/schema_models/account.py @@ -10,6 +10,11 @@ class AccountTreeNode(BaseModel): name: str = Field(..., description="نام حساب") account_type: Optional[str] = Field(default=None, description="نوع حساب") parent_id: Optional[int] = Field(default=None, description="شناسه والد") + business_id: Optional[int] = Field(default=None, description="شناسه کسب‌وکار؛ اگر تهی باشد حساب عمومی است") + is_public: Optional[bool] = Field(default=None, description="True اگر حساب عمومی باشد") + has_children: Optional[bool] = Field(default=None, description="دارای فرزند") + can_edit: Optional[bool] = Field(default=None, description="آیا کاربر فعلی می‌تواند ویرایش کند") + can_delete: Optional[bool] = Field(default=None, description="آیا کاربر فعلی می‌تواند حذف کند") level: Optional[int] = Field(default=None, description="سطح حساب در درخت") children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان") diff --git a/hesabixAPI/adapters/db/session.py b/hesabixAPI/adapters/db/session.py index 066d8d3..7ca9cb9 100644 --- a/hesabixAPI/adapters/db/session.py +++ b/hesabixAPI/adapters/db/session.py @@ -11,7 +11,15 @@ class Base(DeclarativeBase): settings = get_settings() -engine = create_engine(settings.mysql_dsn, echo=settings.sqlalchemy_echo, pool_pre_ping=True, pool_recycle=3600) +engine = create_engine( + settings.mysql_dsn, + echo=settings.sqlalchemy_echo, + pool_pre_ping=True, + pool_recycle=3600, + pool_size=settings.db_pool_size, + max_overflow=settings.db_max_overflow, + pool_timeout=settings.db_pool_timeout, +) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) diff --git a/hesabixAPI/app/core/settings.py b/hesabixAPI/app/core/settings.py index 54215a2..2784a3f 100644 --- a/hesabixAPI/app/core/settings.py +++ b/hesabixAPI/app/core/settings.py @@ -19,6 +19,10 @@ class Settings(BaseSettings): db_port: int = 3306 db_name: str = "hesabix" sqlalchemy_echo: bool = False + # DB Pooling + db_pool_size: int = 10 + db_max_overflow: int = 20 + db_pool_timeout: int = 10 # Logging log_level: str = "INFO" diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index e029d3d..1d64b53 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -36,6 +36,7 @@ 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 adapters.api.v1.inventory_transfers import router as inventory_transfers_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 @@ -321,6 +322,7 @@ def create_app() -> FastAPI: 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) + application.include_router(inventory_transfers_router, prefix=settings.api_v1_prefix) # Support endpoints application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") @@ -335,6 +337,25 @@ def create_app() -> FastAPI: register_error_handlers(application) + @application.middleware("http") + async def log_slow_requests(request: Request, call_next): + import time + import structlog + start = time.perf_counter() + try: + response = await call_next(request) + return response + finally: + duration_ms = int((time.perf_counter() - start) * 1000) + if duration_ms > 2000: + logger = structlog.get_logger() + logger.warning( + "slow_request", + path=str(request.url.path), + method=request.method, + duration_ms=duration_ms, + ) + @application.get("/", summary="اطلاعات سرویس", description="دریافت اطلاعات کلی سرویس و نسخه", diff --git a/hesabixAPI/app/services/account_service.py b/hesabixAPI/app/services/account_service.py index fb4e142..761011b 100644 --- a/hesabixAPI/app/services/account_service.py +++ b/hesabixAPI/app/services/account_service.py @@ -72,6 +72,9 @@ def update_account( obj = db.get(Account, account_id) if not obj: return None + # جلوگیری از تغییر حساب‌های عمومی در لایه سرویس + if obj.business_id is None: + raise ValueError("PUBLIC_IMMUTABLE") if parent_id is not None: parent_id = _validate_parent(db, parent_id, obj.business_id) if name is not None: @@ -94,6 +97,12 @@ def delete_account(db: Session, account_id: int) -> bool: obj = db.get(Account, account_id) if not obj: return False + # جلوگیری از حذف اگر فرزند دارد + if obj.children and len(obj.children) > 0: + raise ValueError("ACCOUNT_HAS_CHILDREN") + # جلوگیری از حذف اگر در اسناد استفاده شده است + if obj.document_lines and len(obj.document_lines) > 0: + raise ValueError("ACCOUNT_IN_USE") db.delete(obj) db.commit() return True diff --git a/hesabixAPI/app/services/check_service.py b/hesabixAPI/app/services/check_service.py index 8b79753..65e592e 100644 --- a/hesabixAPI/app/services/check_service.py +++ b/hesabixAPI/app/services/check_service.py @@ -178,32 +178,34 @@ def create_check(db: Session, business_id: int, user_id: int, data: Dict[str, An "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() + # ایجاد سند (اگر چک واگذار شخص ندارد، از ثبت سند صرف‌نظر می شود) + skip_autopost = (ctype == "transferred" and not person_id) + if not skip_autopost: + 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)) + for line in lines: + db.add(DocumentLine(document_id=document.id, **line)) - db.commit() - db.refresh(document) - created_document_id = document.id + db.commit() + db.refresh(document) + created_document_id = document.id except Exception: # در صورت شکست ایجاد سند، تغییری در ایجاد چک نمی‌دهیم و خطا نمی‌ریزیم # (می‌توان رفتار را سخت‌گیرانه کرد و رول‌بک نمود؛ فعلاً نرم) @@ -335,7 +337,8 @@ def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) lines: List[Dict[str, Any]] = [] if obj.type == CheckType.RECEIVED: - # Dr 10203 (bank), Cr 10403 + # Dr 10203 (bank), Cr 10403 یا 10404 بسته به وضعیت + credit_code = "10404" if obj.status == CheckStatus.DEPOSITED else "10403" lines.append({ "account_id": _ensure_account(db, "10203"), "bank_account_id": int(data.get("bank_account_id")), @@ -345,7 +348,7 @@ def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) "check_id": obj.id, }) lines.append({ - "account_id": _ensure_account(db, "10403"), + "account_id": _ensure_account(db, credit_code), "debit": Decimal(0), "credit": amount_dec, "description": description or "وصول چک", @@ -483,23 +486,45 @@ def bounce_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any]) lines: List[Dict[str, Any]] = [] if obj.type == CheckType.RECEIVED: - # Reverse cash if previously cleared; simplified: Dr 10403, Cr 10203 + # فقط از وضعیت های DEPOSITED یا CLEARED اجازه برگشت + if obj.status not in (CheckStatus.DEPOSITED, CheckStatus.CLEARED): + raise ApiError("INVALID_STATE", f"Cannot bounce from status {obj.status}", http_status=400) 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, - }) + if obj.status == CheckStatus.DEPOSITED: + # Dr 10403, Cr 10404 + 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, "10404"), + "debit": Decimal(0), + "credit": amount_dec, + "description": description or "برگشت چک", + "check_id": obj.id, + }) + else: + # CLEARED: Dr 10403, Cr 10203 (نیازمند bank_account_id) + if not bank_account_id: + raise ApiError("BANK_ACCOUNT_REQUIRED", "bank_account_id is required to bounce a cleared check", http_status=400) + 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), + "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: diff --git a/hesabixAPI/app/services/inventory_transfer_service.py b/hesabixAPI/app/services/inventory_transfer_service.py new file mode 100644 index 0000000..5710563 --- /dev/null +++ b/hesabixAPI/app/services/inventory_transfer_service.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime, date +from decimal import Decimal + +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from adapters.db.models.document import Document +from adapters.db.models.document_line import DocumentLine +from adapters.db.models.currency import Currency +from adapters.db.models.fiscal_year import FiscalYear +from adapters.db.models.product import Product +from app.core.responses import ApiError + +# از توابع موجود برای تاریخ و کنترل موجودی استفاده می‌کنیم +from app.services.invoice_service import _parse_iso_date, _get_current_fiscal_year, _ensure_stock_sufficient + + +DOCUMENT_TYPE_INVENTORY_TRANSFER = "inventory_transfer" + + +def _build_doc_code(prefix_base: str) -> str: + today = datetime.now().date() + prefix = f"{prefix_base}-{today.strftime('%Y%m%d')}" + return prefix + + +def _build_transfer_code(db: Session, business_id: int) -> str: + prefix = _build_doc_code("ITR") + last_doc = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.code.like(f"{prefix}-%"), + ) + ).order_by(Document.code.desc()).first() + if last_doc: + try: + last_num = int(last_doc.code.split("-")[-1]) + next_num = last_num + 1 + except Exception: + next_num = 1 + else: + next_num = 1 + return f"{prefix}-{next_num:04d}" + + +def create_inventory_transfer( + db: Session, + business_id: int, + user_id: int, + data: Dict[str, Any], +) -> Dict[str, Any]: + """ایجاد سند انتقال موجودی بین انبارها (بدون ثبت حسابداری).""" + + document_date = _parse_iso_date(data.get("document_date", datetime.now())) + currency_id = data.get("currency_id") + if not currency_id: + raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400) + currency = db.query(Currency).filter(Currency.id == int(currency_id)).first() + if not currency: + raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404) + + fiscal_year = _get_current_fiscal_year(db, business_id) + + raw_lines: List[Dict[str, Any]] = list(data.get("lines") or []) + if not raw_lines: + raise ApiError("LINES_REQUIRED", "At least one transfer line is required", http_status=400) + + # اعتبارسنجی خطوط و آماده‌سازی برای کنترل کسری + outgoing_lines: List[Dict[str, Any]] = [] + for i, ln in enumerate(raw_lines, start=1): + pid = ln.get("product_id") + qty = Decimal(str(ln.get("quantity", 0) or 0)) + src_wh = ln.get("source_warehouse_id") + dst_wh = ln.get("destination_warehouse_id") + if not pid or qty <= 0: + raise ApiError("INVALID_LINE", f"line {i}: product_id and positive quantity are required", http_status=400) + if src_wh is None or dst_wh is None: + raise ApiError("WAREHOUSE_REQUIRED", f"line {i}: source_warehouse_id and destination_warehouse_id are required", http_status=400) + if int(src_wh) == int(dst_wh): + raise ApiError("INVALID_WAREHOUSES", f"line {i}: source and destination warehouse cannot be the same", http_status=400) + + # فقط برای محصولات کنترل موجودی، کنترل کسری لازم است + tracked = db.query(Product.track_inventory).filter( + and_(Product.business_id == business_id, Product.id == int(pid)) + ).scalar() + if bool(tracked): + outgoing_lines.append({ + "product_id": int(pid), + "quantity": float(qty), + "extra_info": { + "warehouse_id": int(src_wh), + "movement": "out", + "inventory_tracked": True, + }, + }) + + # کنترل کسری موجودی بر مبنای انبار مبدا + if outgoing_lines: + _ensure_stock_sufficient(db, business_id, document_date, outgoing_lines) + + # ایجاد سند بدون ثبت حسابداری + doc_code = _build_transfer_code(db, business_id) + document = Document( + business_id=business_id, + fiscal_year_id=fiscal_year.id, + code=doc_code, + document_type=DOCUMENT_TYPE_INVENTORY_TRANSFER, + document_date=document_date, + currency_id=int(currency_id), + created_by_user_id=user_id, + registered_at=datetime.utcnow(), + is_proforma=False, + description=data.get("description"), + extra_info={"source": "inventory_transfer"}, + ) + db.add(document) + db.flush() + + # ایجاد خطوط کالایی: یک خروج از انبار مبدا و یک ورود به انبار مقصد + for ln in raw_lines: + pid = int(ln.get("product_id")) + qty = Decimal(str(ln.get("quantity", 0) or 0)) + src_wh = int(ln.get("source_warehouse_id")) + dst_wh = int(ln.get("destination_warehouse_id")) + desc = ln.get("description") + + db.add(DocumentLine( + document_id=document.id, + product_id=pid, + quantity=qty, + debit=Decimal(0), + credit=Decimal(0), + description=desc, + extra_info={ + "movement": "out", + "warehouse_id": src_wh, + "inventory_tracked": True, + }, + )) + db.add(DocumentLine( + document_id=document.id, + product_id=pid, + quantity=qty, + debit=Decimal(0), + credit=Decimal(0), + description=desc, + extra_info={ + "movement": "in", + "warehouse_id": dst_wh, + "inventory_tracked": True, + }, + )) + + db.commit() + db.refresh(document) + + return { + "message": "INVENTORY_TRANSFER_CREATED", + "data": { + "id": document.id, + "code": document.code, + "document_date": document.document_date.isoformat(), + }, + } + + diff --git a/hesabixAPI/app/services/invoice_service.py b/hesabixAPI/app/services/invoice_service.py index bcd51af..efa2886 100644 --- a/hesabixAPI/app/services/invoice_service.py +++ b/hesabixAPI/app/services/invoice_service.py @@ -54,6 +54,23 @@ def _get_costing_method(data: Dict[str, Any]) -> str: return "average" +def _is_inventory_posting_enabled(data: Dict[str, Any]) -> bool: + """خواندن فلگ ثبت اسناد انبار از extra_info. پیش‌فرض: فعال (True).""" + try: + extra = data.get("extra_info") or {} + val = extra.get("post_inventory") + if val is None: + return True + if isinstance(val, bool): + return val + if isinstance(val, (int, float)): + return bool(val) + s = str(val).strip().lower() + return s not in ("false", "0", "no", "off") + except Exception: + return True + + def _iter_product_movements( db: Session, business_id: int, @@ -94,6 +111,13 @@ def _iter_product_movements( movements = [] for line, doc in rows: info = line.extra_info or {} + # اگر خط صراحتاً به عنوان عدم ثبت انبار علامت‌گذاری شده، از حرکت صرف‌نظر کن + try: + posted = info.get("inventory_posted") + if posted is False: + continue + except Exception: + pass movement = (info.get("movement") or None) wh_id = info.get("warehouse_id") if movement is None: @@ -319,9 +343,19 @@ def _get_fixed_account_by_code(db: Session, account_code: str) -> Account: return account -def _get_person_control_account(db: Session) -> Account: - # عمومی اشخاص (پرداختنی/دریافتنی) پیش‌فرض: 20201 - return _get_fixed_account_by_code(db, "20201") +def _get_person_control_account(db: Session, invoice_type: str | None = None) -> Account: + # انتخاب حساب طرف‌شخص بر اساس نوع فاکتور + # فروش/برگشت از فروش → دریافتنی ها 10401 + # خرید/برگشت از خرید → پرداختنی ها 20201 (پیش‌فرض) + try: + inv_type = (invoice_type or "").strip() + if inv_type in {INVOICE_SALES, INVOICE_SALES_RETURN}: + return _get_fixed_account_by_code(db, "10401") + # سایر موارد (شامل خرید/برگشت از خرید) + return _get_fixed_account_by_code(db, "20201") + except Exception: + # fallback امن + return _get_fixed_account_by_code(db, "20201") def _build_doc_code(prefix_base: str) -> str: @@ -368,6 +402,9 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal: # فقط برای کالاهای دارای کنترل موجودی if not bool(info.get("inventory_tracked")): continue + # اگر خط برای انبار پست نشده، در COGS لحاظ نشود + if info.get("inventory_posted") is False: + continue qty = Decimal(str(line.get("quantity", 0) or 0)) if info.get("cogs_amount") is not None: total += Decimal(str(info.get("cogs_amount"))) @@ -386,25 +423,113 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal: def _resolve_accounts_for_invoice(db: Session, data: Dict[str, Any]) -> Dict[str, Account]: # امکان override از extra_info.account_codes overrides = ((data.get("extra_info") or {}).get("account_codes") or {}) + invoice_type = str(data.get("invoice_type", "")).strip() def code(name: str, default_code: str) -> str: return str(overrides.get(name) or default_code) return { - "revenue": _get_fixed_account_by_code(db, code("revenue", "70101")), - "sales_return": _get_fixed_account_by_code(db, code("sales_return", "70102")), - "inventory": _get_fixed_account_by_code(db, code("inventory", "10301")), - "inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10302")), - "cogs": _get_fixed_account_by_code(db, code("cogs", "60101")), - "vat_out": _get_fixed_account_by_code(db, code("vat_out", "20801")), - "vat_in": _get_fixed_account_by_code(db, code("vat_in", "10801")), - "direct_consumption": _get_fixed_account_by_code(db, code("direct_consumption", "60201")), - "wip": _get_fixed_account_by_code(db, code("wip", "60301")), - "waste_expense": _get_fixed_account_by_code(db, code("waste_expense", "60401")), - "person": _get_person_control_account(db), + # درآمد و برگشت فروش مطابق چارت سید: + "revenue": _get_fixed_account_by_code(db, code("revenue", "50001")), + "sales_return": _get_fixed_account_by_code(db, code("sales_return", "50002")), + # موجودی و ساخته‌شده (در نبود حساب مجزا) هر دو 10102 + "inventory": _get_fixed_account_by_code(db, code("inventory", "10102")), + "inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10102")), + # بهای تمام شده و VAT ها مطابق سید + "cogs": _get_fixed_account_by_code(db, code("cogs", "40001")), + "vat_out": _get_fixed_account_by_code(db, code("vat_out", "20101")), + "vat_in": _get_fixed_account_by_code(db, code("vat_in", "10104")), + # مصرف مستقیم و ضایعات + "direct_consumption": _get_fixed_account_by_code(db, code("direct_consumption", "70406")), + "wip": _get_fixed_account_by_code(db, code("wip", "10106")), + "waste_expense": _get_fixed_account_by_code(db, code("waste_expense", "70407")), + # طرف‌شخص بر اساس نوع فاکتور + "person": _get_person_control_account(db, invoice_type), } +def _calculate_seller_commission( + db: Session, + invoice_type: str, + header_extra: Dict[str, Any], + totals: Dict[str, Any], +) -> Tuple[int | None, Decimal]: + """محاسبه پورسانت فروشنده/بازاریاب بر اساس تنظیمات شخص یا override در فاکتور. + + Returns: (seller_id, commission_amount) + """ + try: + ei = header_extra or {} + seller_id_raw = ei.get("seller_id") + seller_id: int | None = int(seller_id_raw) if seller_id_raw is not None else None + except Exception: + seller_id = None + if not seller_id: + return (None, Decimal(0)) + + # مبنای محاسبه + gross = Decimal(str((totals or {}).get("gross", 0))) + discount = Decimal(str((totals or {}).get("discount", 0))) + net = gross - discount + + # اگر در فاکتور override شده باشد، همان اعمال شود + commission_cfg = ei.get("commission") if isinstance(ei.get("commission"), dict) else None + if commission_cfg: + value = Decimal(str(commission_cfg.get("value", 0))) if commission_cfg.get("value") is not None else Decimal(0) + ctype = (commission_cfg.get("type") or "").strip().lower() + if value <= 0: + return (seller_id, Decimal(0)) + if ctype == "percentage": + amount = (net * value) / Decimal(100) + return (seller_id, amount) + if ctype == "amount": + return (seller_id, value) + return (seller_id, Decimal(0)) + + # در غیر اینصورت، از تنظیمات شخص استفاده می‌کنیم + person = db.query(Person).filter(Person.id == seller_id).first() + if not person: + return (seller_id, Decimal(0)) + + # اگر شخص اجازه‌ی ثبت پورسانت در سند فاکتور را نداده است، صفر برگردان + try: + if not bool(getattr(person, "commission_post_in_invoice_document", False)): + return (seller_id, Decimal(0)) + except Exception: + pass + + exclude_discounts = bool(getattr(person, "commission_exclude_discounts", False)) + base_amount = gross if exclude_discounts else net + + amount = Decimal(0) + if invoice_type == INVOICE_SALES: + percent = getattr(person, "commission_sale_percent", None) + fixed = getattr(person, "commission_sales_amount", None) + elif invoice_type == INVOICE_SALES_RETURN: + percent = getattr(person, "commission_sales_return_percent", None) + fixed = getattr(person, "commission_sales_return_amount", None) + else: + percent = None + fixed = None + + if percent is not None: + try: + p = Decimal(str(percent)) + if p > 0: + amount = (base_amount * p) / Decimal(100) + except Exception: + pass + elif fixed is not None: + try: + f = Decimal(str(fixed)) + if f > 0: + amount = f + except Exception: + pass + + return (seller_id, amount) + + def _person_id_from_header(data: Dict[str, Any]) -> Optional[int]: try: ei = data.get("extra_info") or {} @@ -492,6 +617,7 @@ def create_invoice( totals = _extract_totals_from_lines(lines_input) # Inventory validation and costing pre-calculation + post_inventory: bool = _is_inventory_posting_enabled(data) # Determine outgoing lines for stock checks movement_hint, _ = _movement_from_type(invoice_type) outgoing_lines: List[Dict[str, Any]] = [] @@ -518,6 +644,17 @@ def create_invoice( info = dict(ln.get("extra_info") or {}) info["inventory_tracked"] = bool(track_map.get(int(pid), False)) ln["extra_info"] = info + # اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت + if post_inventory: + for ln in lines_input: + info = ln.get("extra_info") or {} + inv_tracked = bool(info.get("inventory_tracked")) + mv = info.get("movement") or movement_hint + if inv_tracked and mv in ("in", "out"): + wh = info.get("warehouse_id") + if wh is None: + raise ApiError("WAREHOUSE_REQUIRED", "برای ردیف‌های دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400) + # Filter outgoing lines to only inventory-tracked products for stock checks tracked_outgoing_lines: List[Dict[str, Any]] = [] @@ -527,12 +664,12 @@ def create_invoice( tracked_outgoing_lines.append(ln) # Ensure stock sufficiency for outgoing (only for tracked products) - if tracked_outgoing_lines: + if post_inventory and tracked_outgoing_lines: _ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines) # Costing method (only for tracked products) costing_method = _get_costing_method(data) - if costing_method == "fifo" and tracked_outgoing_lines: + if post_inventory and costing_method == "fifo" and tracked_outgoing_lines: fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines) # annotate lines with cogs_amount in the same order as tracked_outgoing_lines i = 0 @@ -580,7 +717,9 @@ def create_invoice( qty = Decimal(str(line.get("quantity", 0) or 0)) if not product_id or qty <= 0: raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400) - extra_info = line.get("extra_info") or {} + extra_info = dict(line.get("extra_info") or {}) + # علامت‌گذاری اینکه این خط در انبار پست شده/نشده است + extra_info["inventory_posted"] = bool(post_inventory) db.add(DocumentLine( document_id=document.id, product_id=int(product_id), @@ -599,7 +738,7 @@ def create_invoice( tax = Decimal(str(totals["tax"])) total_with_tax = net + tax - # COGS when applicable + # COGS when applicable (خطوط غیرپست انبار، در COGS لحاظ نمی‌شوند) cogs_total = _extract_cogs_total(lines_input) # Sales @@ -646,6 +785,51 @@ def create_invoice( description="خروج از موجودی بابت فروش", )) + # --- پورسانت فروشنده/بازاریاب (در صورت وجود) --- + # محاسبه و ثبت پورسانت برای فروش و برگشت از فروش + if invoice_type in (INVOICE_SALES, INVOICE_SALES_RETURN): + seller_id, commission_amount = _calculate_seller_commission(db, invoice_type, header_extra, totals) + if seller_id and commission_amount > 0: + # هزینه پورسانت: 70702، بستانکار: پرداختنی به فروشنده 20201 + commission_expense = _get_fixed_account_by_code(db, "70702") + seller_payable = _get_fixed_account_by_code(db, "20201") + if invoice_type == INVOICE_SALES: + # بدهکار هزینه، بستانکار فروشنده + db.add(DocumentLine( + document_id=document.id, + account_id=commission_expense.id, + debit=commission_amount, + credit=Decimal(0), + description="هزینه پورسانت فروش", + )) + db.add(DocumentLine( + document_id=document.id, + account_id=seller_payable.id, + person_id=int(seller_id), + debit=Decimal(0), + credit=commission_amount, + description="بابت پورسانت فروشنده/بازاریاب", + extra_info={"seller_id": int(seller_id)}, + )) + else: + # برگشت از فروش: معکوس + db.add(DocumentLine( + document_id=document.id, + account_id=seller_payable.id, + person_id=int(seller_id), + debit=commission_amount, + credit=Decimal(0), + description="تعدیل پورسانت فروشنده بابت برگشت از فروش", + extra_info={"seller_id": int(seller_id)}, + )) + db.add(DocumentLine( + document_id=document.id, + account_id=commission_expense.id, + debit=Decimal(0), + credit=commission_amount, + description="تعدیل هزینه پورسانت", + )) + # Sales Return elif invoice_type == INVOICE_SALES_RETURN: if person_id: @@ -957,6 +1141,16 @@ def update_invoice( info = dict(ln.get("extra_info") or {}) info["inventory_tracked"] = bool(track_map.get(int(pid), False)) ln["extra_info"] = info + # اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت + if post_inventory_update: + for ln in lines_input: + info = ln.get("extra_info") or {} + inv_tracked = bool(info.get("inventory_tracked")) + mv = info.get("movement") or movement_hint + if inv_tracked and mv in ("in", "out"): + wh = info.get("warehouse_id") + if wh is None: + raise ApiError("WAREHOUSE_REQUIRED", "برای ردیف‌های دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400) tracked_outgoing_lines: List[Dict[str, Any]] = [] for ln in outgoing_lines: @@ -964,12 +1158,13 @@ def update_invoice( if pid and track_map.get(int(pid)): tracked_outgoing_lines.append(ln) - if tracked_outgoing_lines: + header_for_costing = data if data else {"extra_info": document.extra_info} + post_inventory_update: bool = _is_inventory_posting_enabled(header_for_costing) + if post_inventory_update and tracked_outgoing_lines: _ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id) - header_for_costing = data if data else {"extra_info": document.extra_info} costing_method = _get_costing_method(header_for_costing) - if costing_method == "fifo" and tracked_outgoing_lines: + if post_inventory_update and costing_method == "fifo" and tracked_outgoing_lines: fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id) i = 0 for ln in lines_input: @@ -987,7 +1182,8 @@ def update_invoice( qty = Decimal(str(line.get("quantity", 0) or 0)) if not product_id or qty <= 0: raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400) - extra_info = line.get("extra_info") or {} + extra_info = dict(line.get("extra_info") or {}) + extra_info["inventory_posted"] = bool(post_inventory_update) db.add(DocumentLine( document_id=document.id, product_id=int(product_id), @@ -1000,7 +1196,8 @@ def update_invoice( # Accounting lines if finalized if not document.is_proforma: - accounts = _resolve_accounts_for_invoice(db, data if data else {"extra_info": document.extra_info}) + header_for_accounts: Dict[str, Any] = {"invoice_type": inv_type, **(data or {"extra_info": document.extra_info})} + accounts = _resolve_accounts_for_invoice(db, header_for_accounts) header_extra = data.get("extra_info") or document.extra_info or {} totals = (header_extra.get("totals") or {}) if not totals: @@ -1059,6 +1256,47 @@ def update_invoice( db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory_finished"].id, debit=finished_cost, credit=Decimal(0), description="ورود ساخته‌شده")) db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=Decimal(0), credit=finished_cost, description="انتقال از کاردرجریان")) + # --- پورسانت فروشنده/بازاریاب (به‌صورت تکمیلی) --- + if inv_type in (INVOICE_SALES, INVOICE_SALES_RETURN): + seller_id, commission_amount = _calculate_seller_commission(db, inv_type, header_extra, totals) + if seller_id and commission_amount > 0: + commission_expense = _get_fixed_account_by_code(db, "70702") + seller_payable = _get_fixed_account_by_code(db, "20201") + if inv_type == INVOICE_SALES: + db.add(DocumentLine( + document_id=document.id, + account_id=commission_expense.id, + debit=commission_amount, + credit=Decimal(0), + description="هزینه پورسانت فروش", + )) + db.add(DocumentLine( + document_id=document.id, + account_id=seller_payable.id, + person_id=int(seller_id), + debit=Decimal(0), + credit=commission_amount, + description="بابت پورسانت فروشنده/بازاریاب", + extra_info={"seller_id": int(seller_id)}, + )) + else: + db.add(DocumentLine( + document_id=document.id, + account_id=seller_payable.id, + person_id=int(seller_id), + debit=commission_amount, + credit=Decimal(0), + description="تعدیل پورسانت فروشنده بابت برگشت از فروش", + extra_info={"seller_id": int(seller_id)}, + )) + db.add(DocumentLine( + document_id=document.id, + account_id=commission_expense.id, + debit=Decimal(0), + credit=commission_amount, + description="تعدیل هزینه پورسانت", + )) + db.commit() db.refresh(document) return invoice_document_to_dict(db, document) diff --git a/hesabixAPI/app/services/kardex_service.py b/hesabixAPI/app/services/kardex_service.py index 2b67bce..a80de76 100644 --- a/hesabixAPI/app/services/kardex_service.py +++ b/hesabixAPI/app/services/kardex_service.py @@ -4,11 +4,13 @@ from typing import Any, Dict, List, Optional, Tuple from datetime import date from sqlalchemy.orm import Session -from sqlalchemy import and_, or_, exists, select +import logging +from sqlalchemy import and_, or_, exists, select, Integer, cast from adapters.db.models.document import Document from adapters.db.models.document_line import DocumentLine from adapters.db.models.fiscal_year import FiscalYear +from adapters.db.models.warehouse import Warehouse # Helpers (reuse existing helpers from other services when possible) @@ -42,6 +44,14 @@ def _collect_ids(query: Dict[str, Any], key: str) -> List[int]: def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: + logger = logging.getLogger(__name__) + try: + logger.debug("KARDEX list_kardex_lines called | business_id=%s | keys=%s", business_id, list(query.keys())) + logger.debug("KARDEX filters | person_ids=%s product_ids=%s account_ids=%s match_mode=%s result_scope=%s from=%s to=%s fy=%s", + query.get('person_ids'), query.get('product_ids'), query.get('account_ids'), + query.get('match_mode'), query.get('result_scope'), query.get('from_date'), query.get('to_date'), query.get('fiscal_year_id')) + except Exception: + pass """لیست خطوط اسناد (کاردکس) با پشتیبانی از انتخاب چندگانه و حالت‌های تطابق. پارامترهای ورودی مورد انتظار در query: @@ -97,6 +107,7 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D petty_cash_ids = _collect_ids(query, "petty_cash_ids") account_ids = _collect_ids(query, "account_ids") check_ids = _collect_ids(query, "check_ids") + warehouse_ids = _collect_ids(query, "warehouse_ids") # Match mode match_mode = str(query.get("match_mode") or "any").lower() @@ -176,6 +187,17 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D # any: OR across groups on the same line q = q.filter(or_(*group_filters)) + # Warehouse filter (JSON attribute inside extra_info) + if warehouse_ids: + try: + q = q.filter(cast(DocumentLine.extra_info["warehouse_id"].as_string(), Integer).in_(warehouse_ids)) + except Exception: + try: + q = q.filter(cast(DocumentLine.extra_info["warehouse_id"].astext, Integer).in_(warehouse_ids)) + except Exception: + # در صورت عدم پشتیبانی از عملگر JSON، از فیلتر نرم‌افزاری بعد از واکشی استفاده خواهد شد + pass + # Sorting sort_by = (query.get("sort_by") or "document_date") sort_desc = bool(query.get("sort_desc", True)) @@ -206,6 +228,10 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D take = 20 total = q.count() + try: + logger.debug("KARDEX query total=%s (after filters)", total) + except Exception: + pass rows: List[Tuple[DocumentLine, Document]] = q.offset(skip).limit(take).all() # Running balance (optional) @@ -213,6 +239,38 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D running_amount: float = 0.0 running_quantity: float = 0.0 + # گردآوری شناسه‌های انبار جهت نام‌گذاری + wh_ids_in_page: set[int] = set() + for line, _ in rows: + try: + info = line.extra_info or {} + wid = info.get("warehouse_id") + if wid is not None: + wh_ids_in_page.add(int(wid)) + except Exception: + pass + + wh_map: Dict[int, str] = {} + if wh_ids_in_page: + for w in db.query(Warehouse).filter(Warehouse.business_id == business_id, Warehouse.id.in_(list(wh_ids_in_page))).all(): + try: + name = (w.name or "").strip() + code = (w.code or "").strip() + wh_map[int(w.id)] = f"{code} - {name}" if code else name + except Exception: + continue + + def _movement_from_type(inv_type: str | None) -> str | None: + t = (inv_type or "").strip() + if t in ("invoice_sales",): + return "out" + if t in ("invoice_sales_return", "invoice_purchase"): + return "in" + if t in ("invoice_purchase_return", "invoice_direct_consumption", "invoice_waste"): + return "out" + # production: both in/out ممکن است + return None + items: List[Dict[str, Any]] = [] for line, doc in rows: item: Dict[str, Any] = { @@ -234,6 +292,20 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D "check_id": line.check_id, } + # movement & warehouse + try: + info = line.extra_info or {} + mv = info.get("movement") + if mv is None: + mv = _movement_from_type(getattr(doc, "document_type", None)) + wid = info.get("warehouse_id") + item["movement"] = mv + item["warehouse_id"] = int(wid) if wid is not None else None + if wid is not None: + item["warehouse_name"] = wh_map.get(int(wid)) + except Exception: + pass + if include_running: try: running_amount += float(line.debit or 0) - float(line.credit or 0) diff --git a/hesabixAPI/env.example b/hesabixAPI/env.example index df7db48..6f569f8 100644 --- a/hesabixAPI/env.example +++ b/hesabixAPI/env.example @@ -11,6 +11,9 @@ DB_HOST=localhost DB_PORT=3306 DB_NAME=hesabixpy SQLALCHEMY_ECHO=false +DB_POOL_SIZE=10 +DB_MAX_OVERFLOW=20 +DB_POOL_TIMEOUT=10 # Logging LOG_LEVEL=INFO diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 9273166..9977015 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -19,6 +19,7 @@ adapters/api/v1/documents.py adapters/api/v1/expense_income.py adapters/api/v1/fiscal_years.py adapters/api/v1/health.py +adapters/api/v1/inventory_transfers.py adapters/api/v1/invoices.py adapters/api/v1/kardex.py adapters/api/v1/persons.py @@ -146,6 +147,7 @@ app/services/document_service.py app/services/email_service.py app/services/expense_income_service.py app/services/file_storage_service.py +app/services/inventory_transfer_service.py app/services/invoice_service.py app/services/kardex_service.py app/services/person_service.py diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 3e3b2d8..580b9b2 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -45,6 +45,7 @@ import 'pages/business/expense_income_list_page.dart'; import 'pages/business/transfers_page.dart'; import 'pages/business/documents_page.dart'; import 'pages/business/warehouses_page.dart'; +import 'pages/business/inventory_transfers_page.dart'; import 'pages/error_404_page.dart'; import 'core/locale_controller.dart'; import 'core/calendar_controller.dart'; @@ -639,10 +640,37 @@ class _MyAppState extends State { name: 'business_reports_kardex', pageBuilder: (context, state) { final businessId = int.parse(state.pathParameters['business_id']!); + // Parse person_id(s) from query + final qp = state.uri.queryParameters; + final qpAll = state.uri.queryParametersAll; + final Set initialPersonIds = {}; + final single = int.tryParse(qp['person_id'] ?? ''); + if (single != null) initialPersonIds.add(single); + final multi = (qpAll['person_id'] ?? const []) + .map((e) => int.tryParse(e)) + .whereType(); + initialPersonIds.addAll(multi); + // Also parse from extra + try { + if (state.extra is Map) { + final extra = state.extra as Map; + final list = extra['person_ids']; + if (list is List) { + for (final v in list) { + if (v is int) initialPersonIds.add(v); + else { + final p = int.tryParse('$v'); + if (p != null) initialPersonIds.add(p); + } + } + } + } + } catch (_) {} return NoTransitionPage( child: KardexPage( businessId: businessId, calendarController: _calendarController!, + initialPersonIds: initialPersonIds.toList(), ), ); }, @@ -808,6 +836,19 @@ class _MyAppState extends State { ); }, ), + GoRoute( + path: '/business/:business_id/inventory-transfers', + name: 'business_inventory_transfers', + pageBuilder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return NoTransitionPage( + child: InventoryTransfersPage( + businessId: businessId, + calendarController: _calendarController!, + ), + ); + }, + ), GoRoute( path: '/business/:business_id/documents', name: 'business_documents', diff --git a/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart b/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart index fd4c49d..5c8d852 100644 --- a/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart +++ b/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart @@ -29,6 +29,7 @@ class InvoiceLineItem { // inventory/constraints final int? minOrderQty; final bool trackInventory; + final int? warehouseId; // انبار انتخابی برای ردیف // presentation String? description; @@ -52,6 +53,7 @@ class InvoiceLineItem { this.basePurchasePriceMainUnit, this.minOrderQty, this.trackInventory = false, + this.warehouseId, }); InvoiceLineItem copyWith({ @@ -73,6 +75,7 @@ class InvoiceLineItem { num? basePurchasePriceMainUnit, int? minOrderQty, bool? trackInventory, + int? warehouseId, }) { return InvoiceLineItem( productId: productId ?? this.productId, @@ -93,6 +96,7 @@ class InvoiceLineItem { basePurchasePriceMainUnit: basePurchasePriceMainUnit ?? this.basePurchasePriceMainUnit, minOrderQty: minOrderQty ?? this.minOrderQty, trackInventory: trackInventory ?? this.trackInventory, + warehouseId: warehouseId ?? this.warehouseId, ); } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart index 291dfdb..cc04668 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart @@ -8,6 +8,7 @@ class AccountNode { final String code; final String name; final String? accountType; + final int? businessId; final List children; final bool hasChildren; @@ -16,6 +17,7 @@ class AccountNode { required this.code, required this.name, this.accountType, + this.businessId, this.children = const [], this.hasChildren = false, }); @@ -30,6 +32,9 @@ class AccountNode { code: json['code']?.toString() ?? '', name: json['name']?.toString() ?? '', accountType: json['account_type']?.toString(), + businessId: json['business_id'] is int + ? (json['business_id'] as int) + : (json['business_id'] != null ? int.tryParse(json['business_id'].toString()) : null), children: parsedChildren, hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty, ); @@ -86,6 +91,8 @@ class _AccountsPageState extends State { items.add({ "id": n.id, "title": ("\u200f" * level) + n.code + " - " + n.name, + "business_id": n.businessId?.toString() ?? "", + "has_children": n.hasChildren ? "1" : "0", }); for (final c in n.children) { dfs(c, level + 1); @@ -97,46 +104,115 @@ class _AccountsPageState extends State { return items; } - Future _openCreateDialog() async { + String? _suggestNextCode({String? parentId}) { + List codes = []; + if (parentId == null || parentId.isEmpty) { + codes = _roots.map((e) => e.code).toList(); + } else { + AccountNode? find(AccountNode n) { + if (n.id == parentId) return n; + for (final c in n.children) { + final x = find(c); + if (x != null) return x; + } + return null; + } + AccountNode? parent; + for (final r in _roots) { + parent = find(r); + if (parent != null) break; + } + if (parent != null) codes = parent.children.map((e) => e.code).toList(); + } + final numeric = codes.map((c) => int.tryParse(c)).whereType().toList(); + if (numeric.isEmpty) return null; + final next = (numeric..sort()).last + 1; + return next.toString(); + } + + Future _openCreateDialog({AccountNode? parent}) async { final t = AppLocalizations.of(context); final codeCtrl = TextEditingController(); final nameCtrl = TextEditingController(); - final typeCtrl = TextEditingController(); - String? selectedParentId; + String? selectedType; + String? selectedParentId = parent?.id; final parents = _flattenNodes(); final result = await showDialog( context: context, builder: (ctx) { return AlertDialog( title: Text(t.addAccount), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: codeCtrl, - decoration: InputDecoration(labelText: t.code), - ), - TextField( - controller: nameCtrl, - decoration: InputDecoration(labelText: t.title), - ), - TextField( - controller: typeCtrl, - decoration: InputDecoration(labelText: t.type), - ), - DropdownButtonFormField( - value: selectedParentId, - items: [ - DropdownMenuItem(value: null, child: Text('بدون والد')), - ...parents.map((p) => DropdownMenuItem(value: p["id"], child: Text(p["title"]!))).toList(), - ], - onChanged: (v) { - selectedParentId = v; - }, - decoration: const InputDecoration(labelText: 'حساب والد'), - ), - ], + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row(children: [ + Expanded(child: TextField( + controller: codeCtrl, + decoration: InputDecoration(labelText: t.code, prefixIcon: const Icon(Icons.numbers)), + )), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () { + final s = _suggestNextCode(parentId: selectedParentId); + if (s != null) codeCtrl.text = s; + }, + icon: const Icon(Icons.auto_fix_high, size: 18), + label: const Text('پیشنهاد کد'), + ), + ]), + const SizedBox(height: 10), + TextField( + controller: nameCtrl, + decoration: InputDecoration(labelText: t.title, prefixIcon: const Icon(Icons.title)), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + value: selectedType, + items: const [ + DropdownMenuItem(value: 'bank', child: Text('بانک')), + DropdownMenuItem(value: 'cash_register', child: Text('صندوق')), + DropdownMenuItem(value: 'petty_cash', child: Text('تنخواه')), + DropdownMenuItem(value: 'check', child: Text('چک')), + DropdownMenuItem(value: 'person', child: Text('شخص')), + DropdownMenuItem(value: 'product', child: Text('کالا')), + DropdownMenuItem(value: 'service', child: Text('خدمت')), + DropdownMenuItem(value: 'accounting_document', child: Text('سند حسابداری')), + ], + onChanged: (v) { selectedType = v; }, + decoration: InputDecoration(labelText: t.type, prefixIcon: const Icon(Icons.category)), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + value: selectedParentId, + items: [ + ...(() { + List> src = parents; + if (parent != null) { + return src.where((p) => p['id'] == parent.id).map((p) => DropdownMenuItem(value: p["id"], child: Text(p["title"]!))).toList(); + } + return src.where((p) { + final bid = p['business_id']; + final hc = p['has_children']; + final isPublic = (bid == null || bid.isEmpty); + final isSameBusiness = bid == widget.businessId.toString(); + return (isPublic && hc == '1') || isSameBusiness; + }).map((p) => DropdownMenuItem(value: p["id"], child: Text(p["title"]!))).toList(); + })(), + ], + onChanged: parent != null ? null : (v) { + selectedParentId = v; + if ((codeCtrl.text).trim().isEmpty) { + final s = _suggestNextCode(parentId: selectedParentId); + if (s != null) codeCtrl.text = s; + } + }, + decoration: const InputDecoration(labelText: 'حساب والد', prefixIcon: Icon(Icons.account_tree)), + ), + ], + ), ), ), actions: [ @@ -145,8 +221,8 @@ class _AccountsPageState extends State { onPressed: () async { final name = nameCtrl.text.trim(); final code = codeCtrl.text.trim(); - final atype = typeCtrl.text.trim(); - if (name.isEmpty || code.isEmpty || atype.isEmpty) { + final atype = (selectedType ?? '').trim(); + if (name.isEmpty || code.isEmpty || atype.isEmpty || selectedParentId == null || selectedParentId!.isEmpty) { return; } final Map payload = { @@ -158,24 +234,24 @@ class _AccountsPageState extends State { final pid = int.tryParse(selectedParentId!); if (pid != null) payload["parent_id"] = pid; } - try { - final api = ApiClient(); - await api.post( - '/api/v1/accounts/business/${widget.businessId}/create', - data: payload, + try { + final api = ApiClient(); + await api.post( + '/api/v1/accounts/business/${widget.businessId}/create', + data: payload, + ); + if (context.mounted) Navigator.of(ctx).pop(true); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در ایجاد حساب: $e')), ); - if (context.mounted) Navigator.of(ctx).pop(true); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('خطا در ایجاد حساب: $e')), - ); } - } - }, + } + }, child: Text(t.add), ), - ], + ], ); }, ); @@ -216,7 +292,7 @@ class _AccountsPageState extends State { final t = AppLocalizations.of(context); final codeCtrl = TextEditingController(text: node.code); final nameCtrl = TextEditingController(text: node.name); - final typeCtrl = TextEditingController(text: node.accountType ?? ''); + String? selectedType = node.accountType; final parents = _flattenNodes(); String? selectedParentId; final result = await showDialog( @@ -230,12 +306,29 @@ class _AccountsPageState extends State { children: [ TextField(controller: codeCtrl, decoration: InputDecoration(labelText: t.code)), TextField(controller: nameCtrl, decoration: InputDecoration(labelText: t.title)), - TextField(controller: typeCtrl, decoration: InputDecoration(labelText: t.type)), + DropdownButtonFormField( + value: selectedType, + items: const [ + DropdownMenuItem(value: 'bank', child: Text('بانک')), + DropdownMenuItem(value: 'cash_register', child: Text('صندوق')), + DropdownMenuItem(value: 'petty_cash', child: Text('تنخواه')), + DropdownMenuItem(value: 'check', child: Text('چک')), + DropdownMenuItem(value: 'person', child: Text('شخص')), + DropdownMenuItem(value: 'product', child: Text('کالا')), + DropdownMenuItem(value: 'service', child: Text('خدمت')), + DropdownMenuItem(value: 'accounting_document', child: Text('سند حسابداری')), + ], + onChanged: (v) { selectedType = v; }, + decoration: InputDecoration(labelText: t.type), + ), DropdownButtonFormField( value: selectedParentId, items: [ DropdownMenuItem(value: null, child: Text('بدون والد')), - ...parents.map((p) => DropdownMenuItem(value: p["id"], child: Text(p["title"]!))).toList(), + ...parents.where((p) { + final bid = p['business_id']; + return (bid == null || bid.isEmpty) || bid == widget.businessId.toString(); + }).map((p) => DropdownMenuItem(value: p["id"], child: Text(p["title"]!))).toList(), ], onChanged: (v) { selectedParentId = v; }, decoration: const InputDecoration(labelText: 'حساب والد'), @@ -249,7 +342,7 @@ class _AccountsPageState extends State { onPressed: () async { final name = nameCtrl.text.trim(); final code = codeCtrl.text.trim(); - final atype = typeCtrl.text.trim(); + final atype = (selectedType ?? '').trim(); if (name.isEmpty || code.isEmpty || atype.isEmpty) return; final Map payload = {"name": name, "code": code, "account_type": atype}; if (selectedParentId != null && selectedParentId!.isNotEmpty) { @@ -412,26 +505,44 @@ class _AccountsPageState extends State { padding: EdgeInsets.zero, iconSize: 20, visualDensity: VisualDensity.compact, - icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right), + icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right), onPressed: () => _toggleExpand(node), ) : const SizedBox.shrink(), ), - Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))), + if (node.businessId == null) const SizedBox(width: 20, child: Icon(Icons.lock_outline, size: 16)), + Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))), Expanded(flex: 5, child: Text(node.name)), Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))), SizedBox( width: 40, child: PopupMenuButton( padding: EdgeInsets.zero, - onSelected: (v) { - if (v == 'edit') _openEditDialog(node); - if (v == 'delete') _confirmDelete(node); - }, - itemBuilder: (context) => [ - const PopupMenuItem(value: 'edit', child: Text('ویرایش')), - const PopupMenuItem(value: 'delete', child: Text('حذف')), - ], + onSelected: (v) { + if (v == 'add_child') _openCreateDialog(parent: node); + if (v == 'edit') _openEditDialog(node); + if (v == 'delete') _confirmDelete(node); + }, + itemBuilder: (context) { + final bool isOwned = node.businessId != null && node.businessId == widget.businessId; + final bool canEdit = isOwned; + final bool canDelete = isOwned && !node.hasChildren; + final bool canAddChild = widget.authStore.canWriteSection('accounting') && ((node.businessId == null && node.hasChildren) || isOwned); + final List> items = >[]; + if (canAddChild) { + items.add(const PopupMenuItem(value: 'add_child', child: Text('افزودن ریز حساب'))); + } + if (canEdit) { + items.add(const PopupMenuItem(value: 'edit', child: Text('ویرایش'))); + } + if (canDelete) { + items.add(const PopupMenuItem(value: 'delete', child: Text('حذف'))); + } + if (items.isEmpty) { + return [const PopupMenuItem(value: 'noop', enabled: false, child: Text('غیرقابل ویرایش'))]; + } + return items; + }, ), ), ], diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index 4c76df8..c2138a7 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -380,9 +380,9 @@ class _BusinessShellState extends State { label: t.shipments, icon: Icons.local_shipping, selectedIcon: Icons.local_shipping, - path: '/business/${widget.businessId}/shipments', + path: '/business/${widget.businessId}/inventory-transfers', type: _MenuItemType.simple, - hasAddButton: true, + hasAddButton: false, ), ], ), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/inventory_transfers_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/inventory_transfers_page.dart new file mode 100644 index 0000000..795f2d0 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/inventory_transfers_page.dart @@ -0,0 +1,90 @@ +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/core/calendar_controller.dart'; +import 'package:hesabix_ui/widgets/transfer/inventory_transfer_form_dialog.dart'; + +class InventoryTransfersPage extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + const InventoryTransfersPage({super.key, required this.businessId, required this.calendarController}); + + @override + State createState() => _InventoryTransfersPageState(); +} + +class _InventoryTransfersPageState extends State { + final GlobalKey _tableKey = GlobalKey(); + + void _refreshTable() { + final state = _tableKey.currentState; + if (state != null) { + try { + (state as dynamic).refresh(); + return; + } catch (_) {} + } + if (mounted) setState(() {}); + } + + Future _onAddNew() async { + final res = await showDialog( + context: context, + builder: (_) => InventoryTransferFormDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + ), + ); + if (res == true) _refreshTable(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text('انتقال موجودی بین انبارها', style: Theme.of(context).textTheme.titleLarge), + const Spacer(), + FilledButton.icon(onPressed: _onAddNew, icon: const Icon(Icons.add), label: const Text('افزودن انتقال')), + ], + ), + const SizedBox(height: 8), + Expanded( + child: DataTableWidget>( + key: _tableKey, + config: DataTableConfig>( + endpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/query', + excelEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/excel', + pdfEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/pdf', + title: 'انتقال موجودی بین انبارها', + showBackButton: true, + showSearch: false, + showPagination: true, + showRowNumbers: true, + enableSorting: true, + showExportButtons: true, + columns: [ + TextColumn('code', 'کد سند', formatter: (it) => (it as Map)['code']?.toString()), + DateColumn('document_date', 'تاریخ سند', formatter: (it) => (it as Map)['document_date']?.toString()), + TextColumn('description', 'شرح', formatter: (it) => (it as Map)['description']?.toString()), + ], + searchFields: const [], + defaultPageSize: 20, + ), + fromJson: (json) => Map.from(json as Map), + calendarController: widget.calendarController, + ), + ), + ], + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart index 31938fe..362f34f 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart @@ -15,18 +15,46 @@ 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'; +import 'package:hesabix_ui/services/person_service.dart'; +import 'package:hesabix_ui/services/product_service.dart'; +import 'package:hesabix_ui/services/bank_account_service.dart'; +import 'package:hesabix_ui/services/cash_register_service.dart'; +import 'package:hesabix_ui/services/petty_cash_service.dart'; +import 'package:hesabix_ui/services/account_service.dart'; +import 'package:hesabix_ui/services/check_service.dart'; +import 'package:hesabix_ui/services/warehouse_service.dart'; +import 'package:go_router/go_router.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; class KardexPage extends StatefulWidget { final int businessId; final CalendarController calendarController; - const KardexPage({super.key, required this.businessId, required this.calendarController}); + final List? initialPersonIds; + const KardexPage({super.key, required this.businessId, required this.calendarController, this.initialPersonIds}); @override State createState() => _KardexPageState(); } +enum FilterType { person, product, bank, cash, petty, account, check } + class _KardexPageState extends State { final GlobalKey _tableKey = GlobalKey(); + final GlobalKey _addFilterBtnKey = GlobalKey(); + void _log(String msg) { + // ignore: avoid_print + print('[KardexPage] ' + msg); + } + + // Unified filter picker control + FilterType? _activePicker; + bool _manualApply = false; + Timer? _applyDebounce; + // Presets + Map> _presets = >{}; + String? _selectedPresetName; // Simple filter inputs (initial version) DateTime? _fromDate; @@ -45,8 +73,25 @@ class _KardexPageState extends State { final List _selectedPettyCash = []; final List _selectedAccounts = []; final List _selectedChecks = []; + final List> _selectedWarehouses = []; // Initial filters from URL List _initialPersonIds = const []; + final PersonService _personService = PersonService(); + List _initialProductIds = const []; + List _initialBankAccountIds = const []; + List _initialCashRegisterIds = const []; + List _initialPettyCashIds = const []; + List _initialAccountIds = const []; + List _initialCheckIds = const []; + List _initialWarehouseIds = const []; + + final ProductService _productService = ProductService(); + final BankAccountService _bankAccountService = BankAccountService(); + final CashRegisterService _cashRegisterService = CashRegisterService(); + final PettyCashService _pettyCashService = PettyCashService(); + final AccountService _accountService = AccountService(); + final CheckService _checkService = CheckService(); + final WarehouseService _warehouseService = WarehouseService(); // Temp selections for pickers (to clear after add) Person? _personToAdd; @@ -63,6 +108,7 @@ class _KardexPageState extends State { } void _refreshData() { + _log('Manual refresh triggered. additionalParams=' + _additionalParams().toString()); final state = _tableKey.currentState; if (state != null) { try { @@ -73,20 +119,229 @@ class _KardexPageState extends State { if (mounted) setState(() {}); } + void _scheduleApply() { + if (_manualApply) return; + _applyDebounce?.cancel(); + _applyDebounce = Timer(const Duration(milliseconds: 500), () { + if (!mounted) return; + _refreshData(); + _updateRouteQuery(); + }); + } + + void _updateRouteQuery() { + try { + final qp = {}; + Map params = _additionalParams(); + List idsOf(String key) => (params[key] as List? ?? const []) + .map((e) => int.tryParse('$e')) + .whereType() + .toList(); + + void addCsv(String key, List ids) { + if (ids.isEmpty) return; + qp[key] = ids.join(','); + } + + addCsv('person_ids', idsOf('person_ids')); + addCsv('product_ids', idsOf('product_ids')); + addCsv('bank_account_ids', idsOf('bank_account_ids')); + addCsv('cash_register_ids', idsOf('cash_register_ids')); + addCsv('petty_cash_ids', idsOf('petty_cash_ids')); + addCsv('account_ids', idsOf('account_ids')); + addCsv('check_ids', idsOf('check_ids')); + addCsv('warehouse_ids', idsOf('warehouse_ids')); + if (params['from_date'] != null) qp['dateFrom'] = '${params['from_date']}'; + if (params['to_date'] != null) qp['dateTo'] = '${params['to_date']}'; + if (params['fiscal_year_id'] != null) qp['fiscal_year_id'] = '${params['fiscal_year_id']}'; + if ((params['match_mode'] ?? '').toString().isNotEmpty) qp['match_mode'] = '${params['match_mode']}'; + if ((params['result_scope'] ?? '').toString().isNotEmpty) qp['result_scope'] = '${params['result_scope']}'; + + final path = '/business/${widget.businessId}/reports/kardex'; + final uri = Uri(path: path, queryParameters: qp.isEmpty ? null : qp); + if (!mounted) return; + context.go(uri.toString()); + } catch (_) {} + } + + void _clearAllFilters() { + setState(() { + _fromDate = null; + _toDate = null; + _selectedFiscalYearId = _selectedFiscalYearId; // نگه‌داشتن سال مالی انتخاب‌شده + _matchMode = 'any'; + _resultScope = 'lines_matching'; + _includeRunningBalance = false; + + _selectedPersons.clear(); + _selectedProducts.clear(); + _selectedBankAccounts.clear(); + _selectedCashRegisters.clear(); + _selectedPettyCash.clear(); + _selectedAccounts.clear(); + _selectedChecks.clear(); + + // همچنین fallback اولیه را خنثی می‌کنیم تا بعد از ریست از URL خوانده نشود + _initialPersonIds = const []; + _initialProductIds = const []; + _initialBankAccountIds = const []; + _initialCashRegisterIds = const []; + _initialPettyCashIds = const []; + _initialAccountIds = const []; + _initialCheckIds = const []; + _initialWarehouseIds = const []; + _activePicker = null; + }); + // اعمال فوری + _refreshData(); + _updateRouteQuery(); + } + + String _presetsKey() => 'kardex_presets_${widget.businessId}'; + + Future _loadPresets() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_presetsKey()); + if (raw != null && raw.isNotEmpty) { + final map = Map.from(jsonDecode(raw) as Map); + final converted = >{}; + for (final entry in map.entries) { + converted[entry.key] = Map.from(entry.value as Map); + } + if (!mounted) return; + setState(() { + _presets = converted; + if (_presets.isNotEmpty && _selectedPresetName == null) { + _selectedPresetName = _presets.keys.first; + } + }); + } + } catch (_) {} + } + + Future _savePreset(String name) async { + try { + final prefs = await SharedPreferences.getInstance(); + final params = _additionalParams(); + final updated = Map>.from(_presets); + updated[name] = params; + await prefs.setString(_presetsKey(), jsonEncode(updated)); + if (!mounted) return; + setState(() { + _presets = updated; + _selectedPresetName = name; + }); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('پریست ذخیره شد'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در ذخیره پریست: $e'))); + } + } + + Future _deletePreset(String name) async { + try { + final prefs = await SharedPreferences.getInstance(); + final updated = Map>.from(_presets); + updated.remove(name); + await prefs.setString(_presetsKey(), jsonEncode(updated)); + if (!mounted) return; + setState(() { + _presets = updated; + if (_selectedPresetName == name) { + _selectedPresetName = _presets.isNotEmpty ? _presets.keys.first : null; + } + }); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در حذف پریست: $e'))); + } + } + + Future _applyPreset(Map p) async { + try { + DateTime? parseDate(String? s) => (s == null || s.isEmpty) ? null : DateTime.tryParse(s); + final from = parseDate(p['from_date']?.toString()); + final to = parseDate(p['to_date']?.toString()); + List ids(String key) { + final raw = (p[key] as List? ?? const []); + return raw.map((e) => int.tryParse('$e')).whereType().toList(); + } + setState(() { + _fromDate = from; + _toDate = to; + _selectedFiscalYearId = (p['fiscal_year_id'] is int) ? p['fiscal_year_id'] as int : int.tryParse('${p['fiscal_year_id'] ?? ''}'); + _matchMode = (p['match_mode'] ?? 'any').toString(); + _resultScope = (p['result_scope'] ?? 'lines_matching').toString(); + _includeRunningBalance = (p['include_running_balance'] == true); + + _selectedPersons.clear(); + _selectedProducts.clear(); + _selectedBankAccounts.clear(); + _selectedCashRegisters.clear(); + _selectedPettyCash.clear(); + _selectedAccounts.clear(); + _selectedChecks.clear(); + + _initialPersonIds = ids('person_ids'); + _initialProductIds = ids('product_ids'); + _initialBankAccountIds = ids('bank_account_ids'); + _initialCashRegisterIds = ids('cash_register_ids'); + _initialPettyCashIds = ids('petty_cash_ids'); + _initialAccountIds = ids('account_ids'); + _initialCheckIds = ids('check_ids'); + _initialWarehouseIds = ids('warehouse_ids'); + }); + + if (_initialPersonIds.isNotEmpty) await _hydrateInitialPersons(_initialPersonIds); + if (_initialProductIds.isNotEmpty) await _hydrateInitialProducts(_initialProductIds); + if (_initialBankAccountIds.isNotEmpty) await _hydrateInitialBankAccounts(_initialBankAccountIds); + if (_initialCashRegisterIds.isNotEmpty) await _hydrateInitialCashRegisters(_initialCashRegisterIds); + if (_initialPettyCashIds.isNotEmpty) await _hydrateInitialPettyCash(_initialPettyCashIds); + if (_initialAccountIds.isNotEmpty) await _hydrateInitialAccounts(_initialAccountIds); + if (_initialCheckIds.isNotEmpty) await _hydrateInitialChecks(_initialCheckIds); + if (_initialWarehouseIds.isNotEmpty) await _hydrateInitialWarehouses(_initialWarehouseIds); + + _updateRouteQuery(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در اعمال پریست: $e'))); + } + } + 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(); + var productIds = _selectedProducts.map((m) => m['id']).map((e) => int.tryParse('$e')).whereType().toList(); + if (productIds.isEmpty && _initialProductIds.isNotEmpty) { + productIds = List.from(_initialProductIds); + } + var bankIds = _selectedBankAccounts.map((b) => int.tryParse(b.id)).whereType().toList(); + if (bankIds.isEmpty && _initialBankAccountIds.isNotEmpty) { + bankIds = List.from(_initialBankAccountIds); + } + var cashIds = _selectedCashRegisters.map((c) => int.tryParse(c.id)).whereType().toList(); + if (cashIds.isEmpty && _initialCashRegisterIds.isNotEmpty) { + cashIds = List.from(_initialCashRegisterIds); + } + var pettyIds = _selectedPettyCash.map((p) => int.tryParse(p.id)).whereType().toList(); + if (pettyIds.isEmpty && _initialPettyCashIds.isNotEmpty) { + pettyIds = List.from(_initialPettyCashIds); + } + var accountIds = _selectedAccounts.map((a) => a.id).whereType().toList(); + if (accountIds.isEmpty && _initialAccountIds.isNotEmpty) { + accountIds = List.from(_initialAccountIds); + } + var checkIds = _selectedChecks.map((c) => int.tryParse(c.id)).whereType().toList(); + if (checkIds.isEmpty && _initialCheckIds.isNotEmpty) { + checkIds = List.from(_initialCheckIds); + } + var warehouseIds = _selectedWarehouses.map((w) => int.tryParse('${w['id']}')).whereType().toList(); + if (warehouseIds.isEmpty && _initialWarehouseIds.isNotEmpty) { + warehouseIds = List.from(_initialWarehouseIds); + } - return { + final params = { if (_fromDate != null) 'from_date': fmt(_fromDate), if (_toDate != null) 'to_date': fmt(_toDate), 'person_ids': personIds, @@ -96,11 +351,14 @@ class _KardexPageState extends State { 'petty_cash_ids': pettyIds, 'account_ids': accountIds, 'check_ids': checkIds, + 'warehouse_ids': warehouseIds, 'match_mode': _matchMode, 'result_scope': _resultScope, 'include_running_balance': _includeRunningBalance, if (_selectedFiscalYearId != null) 'fiscal_year_id': _selectedFiscalYearId, }; + _log('Built additionalParams=' + params.toString()); + return params; } DataTableConfig> _buildTableConfig(AppLocalizations t) { @@ -115,6 +373,13 @@ class _KardexPageState extends State { formatter: (item) => (item as Map)['document_code']?.toString()), TextColumn('document_type', 'نوع سند', formatter: (item) => (item as Map)['document_type']?.toString()), + TextColumn('warehouse_name', 'انبار', + formatter: (item) { + final m = (item as Map); + return (m['warehouse_name'] ?? m['warehouse_id'])?.toString(); + }), + TextColumn('movement', 'جهت حرکت', + formatter: (item) => (item as Map)['movement']?.toString()), TextColumn('description', 'شرح', formatter: (item) => (item as Map)['description']?.toString()), NumberColumn('debit', 'بدهکار', @@ -141,18 +406,220 @@ class _KardexPageState extends State { super.initState(); _loadFiscalYears(); _parseInitialQueryParams(); + _loadPresets(); + if (widget.initialPersonIds != null && widget.initialPersonIds!.isNotEmpty) { + _initialPersonIds = List.from(widget.initialPersonIds!); + } + _log('initState: initialPersonIds=' + _initialPersonIds.toString()); + if (_initialPersonIds.isNotEmpty) { + _hydrateInitialPersons(_initialPersonIds); + } + if (_initialProductIds.isNotEmpty) { + _hydrateInitialProducts(_initialProductIds); + } + if (_initialBankAccountIds.isNotEmpty) { + _hydrateInitialBankAccounts(_initialBankAccountIds); + } + if (_initialCashRegisterIds.isNotEmpty) { + _hydrateInitialCashRegisters(_initialCashRegisterIds); + } + if (_initialPettyCashIds.isNotEmpty) { + _hydrateInitialPettyCash(_initialPettyCashIds); + } + if (_initialAccountIds.isNotEmpty) { + _hydrateInitialAccounts(_initialAccountIds); + } + if (_initialCheckIds.isNotEmpty) { + _hydrateInitialChecks(_initialCheckIds); + } } + Future _hydrateInitialPersons(List ids) async { + try { + final added = { for (final p in _selectedPersons) if (p.id != null) p.id! }; + for (final id in ids) { + if (added.contains(id)) continue; + final person = await _personService.getPerson(id); + if (!mounted) return; + setState(() { + _selectedPersons.add(person); + }); + } + _log('Hydrated selected persons from ids=' + ids.toString()); + // بعد از نمایش چیپ‌ها، رفرش کن تا پارامترهای انتخابی همواره ارسال شوند + _refreshData(); + } catch (e) { + _log('Failed to hydrate persons: ' + e.toString()); + } + } + + Future _hydrateInitialProducts(List ids) async { + try { + final added = { for (final m in _selectedProducts) int.tryParse('${m['id']}') ?? -1 }; + for (final id in ids) { + if (added.contains(id)) continue; + try { + final m = await _productService.getProduct(businessId: widget.businessId, productId: id); + if (!mounted) return; + setState(() { + _selectedProducts.add({ + 'id': m['id'], + 'code': m['code'], + 'name': m['name'], + }); + }); + } catch (_) {} + } + _refreshData(); + } catch (_) {} + } + + Future _hydrateInitialBankAccounts(List ids) async { + try { + final added = { for (final it in _selectedBankAccounts) int.tryParse(it.id) ?? -1 }; + for (final id in ids) { + if (added.contains(id)) continue; + try { + final acc = await _bankAccountService.getById(id); + if (!mounted) return; + setState(() { + _selectedBankAccounts.add(BankAccountOption('${acc.id}', acc.name, currencyId: acc.currencyId)); + }); + } catch (_) {} + } + _refreshData(); + } catch (_) {} + } + + Future _hydrateInitialCashRegisters(List ids) async { + try { + final added = { for (final it in _selectedCashRegisters) int.tryParse(it.id) ?? -1 }; + for (final id in ids) { + if (added.contains(id)) continue; + try { + final cr = await _cashRegisterService.getById(id); + if (!mounted) return; + setState(() { + _selectedCashRegisters.add(CashRegisterOption('${cr.id}', cr.name, currencyId: cr.currencyId)); + }); + } catch (_) {} + } + _refreshData(); + } catch (_) {} + } + + Future _hydrateInitialPettyCash(List ids) async { + try { + final added = { for (final it in _selectedPettyCash) int.tryParse(it.id) ?? -1 }; + for (final id in ids) { + if (added.contains(id)) continue; + try { + final pc = await _pettyCashService.getById(id); + if (!mounted) return; + setState(() { + _selectedPettyCash.add(PettyCashOption('${pc.id}', pc.name, currencyId: pc.currencyId)); + }); + } catch (_) {} + } + _refreshData(); + } catch (_) {} + } + + Future _hydrateInitialAccounts(List ids) async { + try { + final added = { for (final it in _selectedAccounts) it.id ?? -1 }; + for (final id in ids) { + if (added.contains(id)) continue; + try { + final m = await _accountService.getAccount(businessId: widget.businessId, accountId: id); + if (!mounted) return; + setState(() { + _selectedAccounts.add(Account.fromJson(m)); + }); + } catch (_) {} + } + _refreshData(); + } catch (_) {} + } + + Future _hydrateInitialChecks(List ids) async { + try { + final added = { for (final it in _selectedChecks) int.tryParse(it.id) ?? -1 }; + for (final id in ids) { + if (added.contains(id)) continue; + try { + final m = await _checkService.getById(id); + if (!mounted) return; + final checkNumber = (m['check_number'] ?? '').toString(); + final personName = (m['person_name'] ?? m['holder_name'])?.toString(); + final bankName = (m['bank_name'] ?? '').toString(); + final sayad = (m['sayad_code'] ?? '').toString(); + setState(() { + _selectedChecks.add(CheckOption( + id: '$id', + number: checkNumber, + personName: personName, + bankName: bankName, + sayadCode: sayad, + )); + }); + } catch (_) {} + } + _refreshData(); + } catch (_) {} + } + + Future _hydrateInitialWarehouses(List ids) async { + try { + final added = { for (final it in _selectedWarehouses) int.tryParse('${it['id']}') ?? -1 }; + for (final id in ids) { + if (added.contains(id)) continue; + try { + final w = await _warehouseService.getWarehouse(businessId: widget.businessId, warehouseId: id); + if (!mounted) return; + setState(() { + _selectedWarehouses.add({ + 'id': w.id, + 'name': w.name, + 'code': w.code, + }); + }); + } catch (_) {} + } + _refreshData(); + } catch (_) {} + } 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(); + _log('Parsing query params: ' + uri.toString()); + List _parseIds(String singularKey, String pluralKey) { + final out = {}; + final repeated = uri.queryParametersAll[singularKey] ?? const []; + for (final v in repeated) { + final p = int.tryParse(v); + if (p != null) out.add(p); + } + final csv = uri.queryParameters[pluralKey]; + if (csv != null && csv.trim().isNotEmpty) { + for (final part in csv.split(',')) { + final p = int.tryParse(part.trim()); + if (p != null) out.add(p); + } + } + return out.toList(); + } + + _initialPersonIds = _parseIds('person_id', 'person_ids'); + _initialProductIds = _parseIds('product_id', 'product_ids'); + _initialBankAccountIds = _parseIds('bank_account_id', 'bank_account_ids'); + _initialCashRegisterIds = _parseIds('cash_register_id', 'cash_register_ids'); + _initialPettyCashIds = _parseIds('petty_cash_id', 'petty_cash_ids'); + _initialAccountIds = _parseIds('account_id', 'account_ids'); + _initialCheckIds = _parseIds('check_id', 'check_ids'); + _initialWarehouseIds = _parseIds('warehouse_id', 'warehouse_ids'); + + _log('Parsed initial ids | person=' + _initialPersonIds.toString() + ' product=' + _initialProductIds.toString() + ' bank=' + _initialBankAccountIds.toString() + ' cash=' + _initialCashRegisterIds.toString() + ' petty=' + _initialPettyCashIds.toString() + ' account=' + _initialAccountIds.toString() + ' check=' + _initialCheckIds.toString() + ' warehouse=' + _initialWarehouseIds.toString()); } catch (_) {} } @@ -209,12 +676,113 @@ class _KardexPageState extends State { runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + // Add filter button + ElevatedButton.icon( + key: _addFilterBtnKey, + onPressed: () async { + RelativeRect position = const RelativeRect.fromLTRB(100, 100, 0, 0); + try { + final RenderBox button = _addFilterBtnKey.currentContext!.findRenderObject() as RenderBox; + final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + position = RelativeRect.fromRect( + Rect.fromPoints( + button.localToGlobal(Offset.zero, ancestor: overlay), + button.localToGlobal(button.size.bottomRight(Offset.zero), ancestor: overlay), + ), + Offset.zero & overlay.size, + ); + } catch (_) {} + final picked = await showMenu( + context: context, + position: position, + items: [ + PopupMenuItem(value: FilterType.person, child: Text('افزودن فیلتر: اشخاص')), + PopupMenuItem(value: FilterType.product, child: Text('افزودن فیلتر: کالا/خدمت')), + PopupMenuItem(value: FilterType.bank, child: Text('افزودن فیلتر: بانک')), + PopupMenuItem(value: FilterType.cash, child: Text('افزودن فیلتر: صندوق')), + PopupMenuItem(value: FilterType.petty, child: Text('افزودن فیلتر: تنخواه')), + PopupMenuItem(value: FilterType.account, child: Text('افزودن فیلتر: حساب دفتری')), + PopupMenuItem(value: FilterType.check, child: Text('افزودن فیلتر: چک')), + ], + ); + if (picked != null && mounted) setState(() => _activePicker = picked); + }, + icon: const Icon(Icons.add), + label: const Text('افزودن فیلتر'), + ), + TextButton.icon( + onPressed: _clearAllFilters, + icon: const Icon(Icons.refresh), + label: const Text('بازنشانی'), + ), + + // Presets controls + if (_presets.isNotEmpty) + SizedBox( + width: 220, + child: DropdownButtonFormField( + value: _selectedPresetName, + items: _presets.keys + .map((name) => DropdownMenuItem(value: name, child: Text(name))) + .toList(), + onChanged: (v) => setState(() => _selectedPresetName = v), + decoration: const InputDecoration( + labelText: 'پریست‌ها', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + if (_presets.isNotEmpty) + ElevatedButton.icon( + onPressed: (_selectedPresetName != null) + ? () => _applyPreset(_presets[_selectedPresetName] ?? const {}) + : null, + icon: const Icon(Icons.playlist_add_check), + label: const Text('اعمال پریست'), + ), + if (_presets.isNotEmpty) + IconButton( + onPressed: (_selectedPresetName != null) + ? () => _deletePreset(_selectedPresetName!) + : null, + tooltip: 'حذف پریست انتخاب‌شده', + icon: const Icon(Icons.delete_outline), + ), + TextButton.icon( + onPressed: () async { + final controller = TextEditingController(); + final name = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('ذخیره پریست'), + content: TextField( + controller: controller, + decoration: const InputDecoration(hintText: 'نام پریست را وارد کنید'), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('انصراف')), + ElevatedButton(onPressed: () => Navigator.pop(ctx, controller.text.trim()), child: const Text('ذخیره')), + ], + ), + ); + if (name != null && name.isNotEmpty) { + await _savePreset(name); + } + }, + icon: const Icon(Icons.save_alt), + label: const Text('ذخیره پریست'), + ), + SizedBox( width: 200, child: DateInputField( labelText: 'از تاریخ', value: _fromDate, - onChanged: (d) => setState(() => _fromDate = d), + onChanged: (d) { + setState(() => _fromDate = d); + _scheduleApply(); + }, calendarController: widget.calendarController, ), ), @@ -223,7 +791,10 @@ class _KardexPageState extends State { child: DateInputField( labelText: 'تا تاریخ', value: _toDate, - onChanged: (d) => setState(() => _toDate = d), + onChanged: (d) { + setState(() => _toDate = d); + _scheduleApply(); + }, calendarController: widget.calendarController, ), ), @@ -246,7 +817,7 @@ class _KardexPageState extends State { }).toList(), onChanged: (val) { setState(() => _selectedFiscalYearId = val); - _refreshData(); + _scheduleApply(); }, ), ), @@ -255,8 +826,9 @@ class _KardexPageState extends State { chips: _selectedPersons.map((p) => _ChipData(id: p.id!, label: p.displayName)).toList(), onRemove: (id) { setState(() => _selectedPersons.removeWhere((p) => p.id == id)); + _scheduleApply(); }, - picker: SizedBox( + picker: _activePicker == FilterType.person ? SizedBox( width: 260, child: PersonComboboxWidget( businessId: widget.businessId, @@ -267,12 +839,14 @@ class _KardexPageState extends State { setState(() { if (!exists) _selectedPersons.add(person); _personToAdd = null; + _activePicker = null; }); - _refreshData(); + _scheduleApply(); }, hintText: 'افزودن شخص', ), - ), + ) : const SizedBox.shrink(), + type: FilterType.person, ), _chipsSection( label: 'کالا/خدمت', @@ -282,8 +856,11 @@ class _KardexPageState extends State { 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( + onRemove: (id) { + setState(() => _selectedProducts.removeWhere((m) => int.tryParse('${m['id']}') == id)); + _scheduleApply(); + }, + picker: _activePicker == FilterType.product ? SizedBox( width: 260, child: ProductComboboxWidget( businessId: widget.businessId, @@ -295,17 +872,22 @@ class _KardexPageState extends State { setState(() { if (!exists) _selectedProducts.add(prod); _productToAdd = null; + _activePicker = null; }); - _refreshData(); + _scheduleApply(); }, ), - ), + ) : const SizedBox.shrink(), + type: FilterType.product, ), _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( + onRemove: (id) { + setState(() => _selectedBankAccounts.removeWhere((b) => int.tryParse(b.id) == id)); + _scheduleApply(); + }, + picker: _activePicker == FilterType.bank ? SizedBox( width: 260, child: BankAccountComboboxWidget( businessId: widget.businessId, @@ -316,18 +898,23 @@ class _KardexPageState extends State { setState(() { if (!exists) _selectedBankAccounts.add(opt); _bankToAdd = null; + _activePicker = null; }); - _refreshData(); + _scheduleApply(); }, hintText: 'افزودن حساب بانکی', ), - ), + ) : const SizedBox.shrink(), + type: FilterType.bank, ), _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( + onRemove: (id) { + setState(() => _selectedCashRegisters.removeWhere((c) => int.tryParse(c.id) == id)); + _scheduleApply(); + }, + picker: _activePicker == FilterType.cash ? SizedBox( width: 260, child: CashRegisterComboboxWidget( businessId: widget.businessId, @@ -338,18 +925,23 @@ class _KardexPageState extends State { setState(() { if (!exists) _selectedCashRegisters.add(opt); _cashToAdd = null; + _activePicker = null; }); - _refreshData(); + _scheduleApply(); }, hintText: 'افزودن صندوق', ), - ), + ) : const SizedBox.shrink(), + type: FilterType.cash, ), _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( + onRemove: (id) { + setState(() => _selectedPettyCash.removeWhere((p) => int.tryParse(p.id) == id)); + _scheduleApply(); + }, + picker: _activePicker == FilterType.petty ? SizedBox( width: 260, child: PettyCashComboboxWidget( businessId: widget.businessId, @@ -360,18 +952,23 @@ class _KardexPageState extends State { setState(() { if (!exists) _selectedPettyCash.add(opt); _pettyToAdd = null; + _activePicker = null; }); - _refreshData(); + _scheduleApply(); }, hintText: 'افزودن تنخواه', ), - ), + ) : const SizedBox.shrink(), + type: FilterType.petty, ), _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( + onRemove: (id) { + setState(() => _selectedAccounts.removeWhere((a) => a.id == id)); + _scheduleApply(); + }, + picker: _activePicker == FilterType.account ? SizedBox( width: 260, child: AccountTreeComboboxWidget( businessId: widget.businessId, @@ -382,18 +979,23 @@ class _KardexPageState extends State { setState(() { if (!exists) _selectedAccounts.add(acc); _accountToAdd = null; + _activePicker = null; }); - _refreshData(); + _scheduleApply(); }, hintText: 'افزودن حساب', ), - ), + ) : const SizedBox.shrink(), + type: FilterType.account, ), _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( + onRemove: (id) { + setState(() => _selectedChecks.removeWhere((c) => int.tryParse(c.id) == id)); + _scheduleApply(); + }, + picker: _activePicker == FilterType.check ? SizedBox( width: 260, child: CheckComboboxWidget( businessId: widget.businessId, @@ -404,15 +1006,34 @@ class _KardexPageState extends State { setState(() { if (!exists) _selectedChecks.add(opt); _checkToAdd = null; + _activePicker = null; }); - _refreshData(); + _scheduleApply(); }, ), - ), + ) : const SizedBox.shrink(), + type: FilterType.check, + ), + _chipsSection( + label: 'انبار', + chips: _selectedWarehouses.map((w) { + final id = int.tryParse('${w['id']}') ?? 0; + final code = (w['code'] ?? '').toString(); + final name = (w['name'] ?? '').toString(); + return _ChipData(id: id, label: code.isNotEmpty ? '$code - $name' : name); + }).toList(), + onRemove: (id) { + setState(() => _selectedWarehouses.removeWhere((w) => int.tryParse('${w['id']}') == id)); + _scheduleApply(); + }, + picker: _activePicker == null ? const SizedBox.shrink() : const SizedBox.shrink(), ), DropdownButton( value: _matchMode, - onChanged: (v) => setState(() => _matchMode = v ?? 'any'), + onChanged: (v) { + setState(() => _matchMode = v ?? 'any'); + _scheduleApply(); + }, items: const [ DropdownMenuItem(value: 'any', child: Text('هرکدام')), DropdownMenuItem(value: 'same_line', child: Text('هم‌زمان در یک خط')), @@ -421,7 +1042,10 @@ class _KardexPageState extends State { ), DropdownButton( value: _resultScope, - onChanged: (v) => setState(() => _resultScope = v ?? 'lines_matching'), + onChanged: (v) { + setState(() => _resultScope = v ?? 'lines_matching'); + _scheduleApply(); + }, items: const [ DropdownMenuItem(value: 'lines_matching', child: Text('فقط خطوط منطبق')), DropdownMenuItem(value: 'lines_of_document', child: Text('کل خطوط سند')), @@ -430,13 +1054,27 @@ class _KardexPageState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ - Switch(value: _includeRunningBalance, onChanged: (v) => setState(() => _includeRunningBalance = v)), + Switch(value: _includeRunningBalance, onChanged: (v) { + setState(() => _includeRunningBalance = v); + _scheduleApply(); + }), const SizedBox(width: 6), const Text('مانده تجمعی'), ], ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch(value: _manualApply, onChanged: (v) => setState(() => _manualApply = v)), + const SizedBox(width: 6), + const Text('اعمال دستی'), + ], + ), ElevatedButton.icon( - onPressed: _refreshData, + onPressed: () { + _refreshData(); + _updateRouteQuery(); + }, icon: const Icon(Icons.search), label: const Text('اعمال فیلتر'), ), @@ -451,6 +1089,7 @@ class _KardexPageState extends State { // حداقل ارتفاع مناسب برای جدول؛ اگر فضا کمتر بود، صفحه اسکرول می‌خورد final tableHeight = screenH - 280.0; // تقریبی با احتساب فیلترها و پدینگ final effectiveHeight = tableHeight < 420 ? 420.0 : tableHeight; + _log('Building table area with height=' + effectiveHeight.toString()); return SizedBox( height: effectiveHeight, child: Padding( @@ -471,6 +1110,7 @@ class _KardexPageState extends State { required List<_ChipData> chips, required void Function(int id) onRemove, required Widget picker, + FilterType? type, }) { return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 900), @@ -489,7 +1129,7 @@ class _KardexPageState extends State { spacing: 6, runSpacing: 6, children: [ - _chips(items: chips, onRemove: onRemove), + _chips(items: chips, onRemove: onRemove, type: type), picker, ], ), @@ -502,17 +1142,33 @@ class _KardexPageState extends State { Widget _chips({ required List<_ChipData> items, required void Function(int id) onRemove, + FilterType? type, }) { if (items.isEmpty) return const SizedBox.shrink(); + const int maxToShow = 5; + final List<_ChipData> visible = (items.length > maxToShow) + ? items.sublist(0, maxToShow - 1) + : items; + final int remaining = items.length - visible.length; return Wrap( spacing: 6, runSpacing: 6, - children: items - .map((it) => Chip( - label: Text(it.label), - onDeleted: () => onRemove(it.id), - )) - .toList(), + children: [ + ...visible.map((it) => InputChip( + label: Text(it.label), + onDeleted: () => onRemove(it.id), + onPressed: () { + if (type != null) setState(() => _activePicker = type); + }, + )), + if (remaining > 0) + InputChip( + label: Text('+$remaining مورد دیگر'), + onPressed: () { + if (type != null) setState(() => _activePicker = type); + }, + ), + ], ); } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart index 190f1a7..e2e28ad 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart @@ -42,6 +42,8 @@ class NewInvoicePage extends StatefulWidget { } class _NewInvoicePageState extends State with SingleTickerProviderStateMixin { + // تنظیمات انبار + bool _postInventory = true; // ثبت اسناد انبار late TabController _tabController; InvoiceType? _selectedInvoiceType; @@ -360,13 +362,17 @@ class _NewInvoicePageState extends State with SingleTickerProvid _selectedSeller = seller; // تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده if (seller != null) { - if (seller.commissionSalePercent != null) { + final isSales = _selectedInvoiceType == InvoiceType.sales; + final isSalesReturn = _selectedInvoiceType == InvoiceType.salesReturn; + final percent = isSales ? seller.commissionSalePercent : (isSalesReturn ? seller.commissionSalesReturnPercent : null); + final amount = isSales ? seller.commissionSalesAmount : (isSalesReturn ? seller.commissionSalesReturnAmount : null); + if (percent != null) { _commissionType = CommissionType.percentage; - _commissionPercentage = seller.commissionSalePercent; + _commissionPercentage = percent; _commissionAmount = null; - } else if (seller.commissionSalesAmount != null) { + } else if (amount != null) { _commissionType = CommissionType.amount; - _commissionAmount = seller.commissionSalesAmount; + _commissionAmount = amount; _commissionPercentage = null; } } else { @@ -678,13 +684,17 @@ class _NewInvoicePageState extends State with SingleTickerProvid _selectedSeller = seller; // تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده if (seller != null) { - if (seller.commissionSalePercent != null) { + final isSales = _selectedInvoiceType == InvoiceType.sales; + final isSalesReturn = _selectedInvoiceType == InvoiceType.salesReturn; + final percent = isSales ? seller.commissionSalePercent : (isSalesReturn ? seller.commissionSalesReturnPercent : null); + final amount = isSales ? seller.commissionSalesAmount : (isSalesReturn ? seller.commissionSalesReturnAmount : null); + if (percent != null) { _commissionType = CommissionType.percentage; - _commissionPercentage = seller.commissionSalePercent; + _commissionPercentage = percent; _commissionAmount = null; - } else if (seller.commissionSalesAmount != null) { + } else if (amount != null) { _commissionType = CommissionType.amount; - _commissionAmount = seller.commissionSalesAmount; + _commissionAmount = amount; _commissionPercentage = null; } } else { @@ -837,6 +847,18 @@ class _NewInvoicePageState extends State with SingleTickerProvid if (r.taxRate < 0 || r.taxRate > 100) { return 'درصد مالیات ردیف ${i + 1} باید بین 0 تا 100 باشد'; } + // الزام انبار در حالت ثبت اسناد انبار و کالاهای تحت کنترل موجودی + if (_postInventory && r.trackInventory) { + final isOut = _selectedInvoiceType == InvoiceType.sales || + _selectedInvoiceType == InvoiceType.purchaseReturn || + _selectedInvoiceType == InvoiceType.directConsumption || + _selectedInvoiceType == InvoiceType.waste; + final isIn = _selectedInvoiceType == InvoiceType.purchase || + _selectedInvoiceType == InvoiceType.salesReturn; + if ((isOut || isIn) && r.warehouseId == null) { + return 'انبار ردیف ${i + 1} الزامی است'; + } + } } final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn; @@ -877,6 +899,8 @@ class _NewInvoicePageState extends State with SingleTickerProvid 'net': _sumTotal, }, }; + // سوییچ ثبت اسناد انبار + extraInfo['post_inventory'] = _postInventory; // افزودن person_id بر اساس نوع فاکتور if (isSalesOrReturn && _selectedCustomer != null) { @@ -947,6 +971,7 @@ class _NewInvoicePageState extends State with SingleTickerProvid 'tax_amount': taxAmount, 'line_total': lineTotal, if (movement != null) 'movement': movement, + if (_postInventory && e.warehouseId != null) 'warehouse_id': e.warehouseId, // اطلاعات اضافی برای ردیابی 'unit': e.selectedUnit ?? e.mainUnit, 'unit_price_source': e.unitPriceSource, @@ -979,6 +1004,7 @@ class _NewInvoicePageState extends State with SingleTickerProvid businessId: widget.businessId, selectedCurrencyId: _selectedCurrencyId, invoiceType: (_selectedInvoiceType?.value ?? 'sales'), + postInventory: _postInventory, onChanged: (rows) { setState(() { _lineItems = rows; @@ -1057,6 +1083,30 @@ class _NewInvoicePageState extends State with SingleTickerProvid ), ), const SizedBox(height: 24), + + // تنظیمات انبار + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: const Text('ثبت اسناد انبار'), + subtitle: const Text('در صورت غیرفعال‌سازی، حرکات موجودی ثبت نمی‌شوند و کنترل کسری انجام نمی‌گردد'), + value: _postInventory, + onChanged: (value) { + setState(() { + _postInventory = value; + }); + }, + ), + ], + ), + ), + ), + + const SizedBox(height: 24), // چاپ فاکتور بعد از صدور Card( diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index a90ec94..978ccef 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -77,7 +77,12 @@ class _PersonsPageState extends State { return InkWell( onTap: () { if (person.id != null) { - context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}'); + context.go( + '/business/${widget.businessId}/reports/kardex', + extra: { + 'person_ids': [person.id] + }, + ); } }, child: Text( @@ -337,11 +342,21 @@ class _PersonsPageState extends State { label: 'کاردکس', onTap: (person) { if (person is Person && person.id != null) { - context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}'); + context.go( + '/business/${widget.businessId}/reports/kardex', + extra: { + 'person_ids': [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'); + context.go( + '/business/${widget.businessId}/reports/kardex', + extra: { + 'person_ids': [id] + }, + ); } } }, diff --git a/hesabixUI/hesabix_ui/lib/services/inventory_transfer_service.dart b/hesabixUI/hesabix_ui/lib/services/inventory_transfer_service.dart new file mode 100644 index 0000000..1ad4392 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/inventory_transfer_service.dart @@ -0,0 +1,13 @@ +import '../core/api_client.dart'; + +class InventoryTransferService { + final ApiClient _api; + InventoryTransferService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient(); + + Future> create({required int businessId, required Map payload}) async { + final res = await _api.post>('/api/v1/inventory-transfers/business/$businessId', data: payload); + return Map.from(res.data?['data'] as Map? ?? {}); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/column_settings_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/column_settings_dialog.dart index ea8d612..f13d8d6 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/column_settings_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/column_settings_dialog.dart @@ -26,6 +26,8 @@ class _ColumnSettingsDialogState extends State { late List _columnOrder; late Map _columnWidths; late List _columns; // Local copy of columns + late Set _pinnedLeft; + late Set _pinnedRight; @override void initState() { @@ -34,6 +36,8 @@ class _ColumnSettingsDialogState extends State { _columnOrder = List.from(widget.currentSettings.columnOrder); _columnWidths = Map.from(widget.currentSettings.columnWidths); _columns = List.from(widget.columns); // Create local copy + _pinnedLeft = Set.from(widget.currentSettings.pinnedLeft); + _pinnedRight = Set.from(widget.currentSettings.pinnedRight); } @override @@ -172,6 +176,13 @@ class _ColumnSettingsDialogState extends State { ), ), const SizedBox(width: 8), + Text( + 'پین', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), Text( t.order, style: theme.textTheme.titleSmall?.copyWith( @@ -269,6 +280,52 @@ class _ColumnSettingsDialogState extends State { ), ), const SizedBox(width: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + message: 'پین چپ', + child: IconButton( + icon: Icon(Icons.push_pin, + size: 16, + color: _pinnedLeft.contains(column.key) + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant), + onPressed: () { + setState(() { + _pinnedRight.remove(column.key); + if (_pinnedLeft.contains(column.key)) { + _pinnedLeft.remove(column.key); + } else { + _pinnedLeft.add(column.key); + } + }); + }, + ), + ), + Tooltip( + message: 'پین راست', + child: IconButton( + icon: Icon(Icons.push_pin_outlined, + size: 16, + color: _pinnedRight.contains(column.key) + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant), + onPressed: () { + setState(() { + _pinnedLeft.remove(column.key); + if (_pinnedRight.contains(column.key)) { + _pinnedRight.remove(column.key); + } else { + _pinnedRight.add(column.key); + } + }); + }, + ), + ), + ], + ), + const SizedBox(width: 8), Icon( Icons.drag_handle, size: 16, @@ -312,6 +369,8 @@ class _ColumnSettingsDialogState extends State { visibleColumns: _visibleColumns, columnOrder: _columnOrder, columnWidths: _columnWidths, + pinnedLeft: _pinnedLeft.toList(), + pinnedRight: _pinnedRight.toList(), ); Navigator.of(context).pop(newSettings); diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index 9984c1b..bd3dd85 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -1,9 +1,12 @@ import 'dart:async'; import 'dart:math' as math; import 'dart:typed_data'; +import 'dart:ui' show FontFeature; import 'package:flutter/foundation.dart'; import 'package:file_saver/file_saver.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:data_table_2/data_table_2.dart'; import 'package:dio/dio.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; @@ -76,6 +79,14 @@ class _DataTableWidgetState extends State> { // Scroll controller for horizontal scrolling late ScrollController _horizontalScrollController; + // Density (row height) + bool _dense = false; + + // Keyboard focus and navigation + final FocusNode _tableFocusNode = FocusNode(debugLabel: 'DataTableFocus'); + int _activeRowIndex = -1; + int? _lastSelectedRowIndex; + @override void initState() { super.initState(); @@ -83,6 +94,7 @@ class _DataTableWidgetState extends State> { _limit = widget.config.defaultPageSize; _setupSearchListener(); _loadColumnSettings(); + _loadDensityPreference(); _fetchData(); } @@ -112,6 +124,7 @@ class _DataTableWidgetState extends State> { _searchCtrl.dispose(); _searchDebounce?.cancel(); _horizontalScrollController.dispose(); + _tableFocusNode.dispose(); for (var controller in _columnSearchControllers.values) { controller.dispose(); } @@ -128,6 +141,23 @@ class _DataTableWidgetState extends State> { }); } + Future _loadDensityPreference() async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = 'data_table_density_${widget.config.effectiveTableId}'; + final dense = prefs.getBool(key) ?? false; + if (mounted) setState(() => _dense = dense); + } catch (_) {} + } + + Future _saveDensityPreference() async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = 'data_table_density_${widget.config.effectiveTableId}'; + await prefs.setBool(key, _dense); + } catch (_) {} + } + Future _loadColumnSettings() async { if (!widget.config.enableColumnSettings) { _visibleColumns = List.from(widget.config.columns); @@ -227,6 +257,8 @@ class _DataTableWidgetState extends State> { _total = response.total; _totalPages = response.totalPages; _selectedRows.clear(); // Clear selection when data changes + _activeRowIndex = _items.isNotEmpty ? 0 : -1; + _lastSelectedRowIndex = null; }); } @@ -411,15 +443,27 @@ class _DataTableWidgetState extends State> { if (!widget.config.enableRowSelection) return; setState(() { - if (widget.config.enableMultiRowSelection) { - if (_selectedRows.contains(rowIndex)) { - _selectedRows.remove(rowIndex); - } else { - _selectedRows.add(rowIndex); + final bool isShift = HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight); + + if (widget.config.enableMultiRowSelection && isShift && _lastSelectedRowIndex != null) { + final int start = math.min(_lastSelectedRowIndex!, rowIndex); + final int end = math.max(_lastSelectedRowIndex!, rowIndex); + for (int i = start; i <= end; i++) { + _selectedRows.add(i); } } else { - _selectedRows.clear(); - _selectedRows.add(rowIndex); + if (widget.config.enableMultiRowSelection) { + if (_selectedRows.contains(rowIndex)) { + _selectedRows.remove(rowIndex); + } else { + _selectedRows.add(rowIndex); + } + } else { + _selectedRows.clear(); + _selectedRows.add(rowIndex); + } + _lastSelectedRowIndex = rowIndex; } }); @@ -762,7 +806,51 @@ class _DataTableWidgetState extends State> { shape: RoundedRectangleBorder( borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), ), - child: Container( + child: Shortcuts( + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.keyJ): const MoveRowIntent(1), + LogicalKeySet(LogicalKeyboardKey.keyK): const MoveRowIntent(-1), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const MoveRowIntent(1), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const MoveRowIntent(-1), + LogicalKeySet(LogicalKeyboardKey.enter): const ActivateRowIntent(), + LogicalKeySet(LogicalKeyboardKey.space): const ToggleSelectionIntent(), + LogicalKeySet(LogicalKeyboardKey.escape): const ClearSelectionIntent(), + LogicalKeySet(LogicalKeyboardKey.keyA, LogicalKeyboardKey.control): const SelectAllIntent(), + }, + child: Actions( + actions: >{ + MoveRowIntent: CallbackAction(onInvoke: (intent) { + if (_items.isEmpty) return null; + setState(() { + final next = (_activeRowIndex == -1 ? 0 : _activeRowIndex) + intent.delta; + _activeRowIndex = next.clamp(0, _items.length - 1); + }); + return null; + }), + ActivateRowIntent: CallbackAction(onInvoke: (intent) { + if (_activeRowIndex >= 0 && _activeRowIndex < _items.length && widget.config.onRowTap != null) { + widget.config.onRowTap!(_items[_activeRowIndex]); + } + return null; + }), + ToggleSelectionIntent: CallbackAction(onInvoke: (intent) { + if (widget.config.enableRowSelection && _activeRowIndex >= 0 && _activeRowIndex < _items.length) { + _toggleRowSelection(_activeRowIndex); + } + return null; + }), + ClearSelectionIntent: CallbackAction(onInvoke: (intent) { + _clearRowSelection(); + return null; + }), + SelectAllIntent: CallbackAction(onInvoke: (intent) { + _selectAllRows(); + return null; + }), + }, + child: Focus( + focusNode: _tableFocusNode, + child: Container( padding: widget.config.padding ?? const EdgeInsets.all(16), margin: widget.config.margin, decoration: BoxDecoration( @@ -818,6 +906,53 @@ class _DataTableWidgetState extends State> { const SizedBox(height: 10), ], + // Selection toolbar + if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Icon(Icons.check_box, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text('${_selectedRows.length} مورد انتخاب شده', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: _clearRowSelection, + icon: const Icon(Icons.clear), + label: const Text('لغو انتخاب'), + ), + if (widget.config.excelEndpoint != null) ...[ + const SizedBox(width: 8), + FilledButton.icon( + onPressed: () => _exportData('excel', true), + icon: const Icon(Icons.table_chart), + label: const Text('خروجی اکسل انتخاب‌ها'), + ), + ], + if (widget.config.pdfEndpoint != null) ...[ + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => _exportData('pdf', true), + icon: const Icon(Icons.picture_as_pdf), + label: const Text('PDF انتخاب‌ها'), + ), + ], + ], + ), + ), + const SizedBox(height: 10), + ], + // Data Table Expanded( child: _buildDataTable(t, theme), @@ -829,6 +964,9 @@ class _DataTableWidgetState extends State> { _buildFooter(t, theme), ], ], + ), + ), + ), ), ), ); @@ -922,6 +1060,12 @@ class _DataTableWidgetState extends State> { case 'columnSettings': _openColumnSettingsDialog(); break; + case 'toggleDensity': + setState(() { + _dense = !_dense; + }); + _saveDensityPreference(); + break; } }, itemBuilder: (context) => [ @@ -954,6 +1098,17 @@ class _DataTableWidgetState extends State> { ], ), ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'toggleDensity', + child: Row( + children: [ + Icon(_dense ? Icons.check_box : Icons.check_box_outline_blank, size: 20), + const SizedBox(width: 8), + const Text('حالت فشرده'), + ], + ), + ), ], ), ], @@ -1253,21 +1408,36 @@ class _DataTableWidgetState extends State> { Widget _buildDataTable(AppLocalizations t, ThemeData theme) { if (_loadingList) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (widget.config.loadingWidget != null) - widget.config.loadingWidget! - else - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - widget.config.loadingMessage ?? t.loading, - style: theme.textTheme.bodyMedium, + return Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: 8, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + children: List.generate(5, (i) { + return Expanded( + child: Container( + height: _dense ? 28 : 36, + margin: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(6), + ), + ), + ); + }), + ), + ); + }, ), - ], - ), + ), + const SizedBox(height: 12), + Text(widget.config.loadingMessage ?? t.loading, style: theme.textTheme.bodyMedium), + const SizedBox(height: 8), + ], ); } @@ -1322,6 +1492,23 @@ class _DataTableWidgetState extends State> { color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), ), ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + FilledButton.icon( + onPressed: _fetchData, + icon: const Icon(Icons.refresh), + label: Text(t.refresh), + ), + if (_hasActiveFilters()) + OutlinedButton.icon( + onPressed: _clearAllFilters, + icon: const Icon(Icons.filter_alt_off), + label: Text(t.clear), + ), + ], + ), ], ), ); @@ -1395,7 +1582,19 @@ class _DataTableWidgetState extends State> { final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty ? _visibleColumns : widget.config.columns; - final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); + List dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); + // Reorder by pinning if settings available + if (widget.config.enableColumnSettings && _columnSettings != null) { + final visibleKeys = _columnSettings!.visibleColumns.toSet(); + final order = _columnSettings!.columnOrder; + List middleKeys = order.where((k) => visibleKeys.contains(k)).toList(); + final leftKeys = _columnSettings!.pinnedLeft.where((k) => middleKeys.contains(k)).toList(); + final rightKeys = _columnSettings!.pinnedRight.where((k) => middleKeys.contains(k)).toList(); + middleKeys.removeWhere((k) => leftKeys.contains(k) || rightKeys.contains(k)); + List finalOrder = [...leftKeys, ...middleKeys, ...rightKeys]; + final mapByKey = {for (final c in dataColumnsToShow) c.key: c}; + dataColumnsToShow = finalOrder.map((k) => mapByKey[k]).whereType().toList(); + } columns.addAll(dataColumnsToShow.map((column) { final headerTextStyle = theme.textTheme.titleSmall?.copyWith( @@ -1403,9 +1602,12 @@ class _DataTableWidgetState extends State> { color: theme.colorScheme.onSurface, ) ?? const TextStyle(fontSize: 14, fontWeight: FontWeight.w600); final double baseWidth = DataTableUtils.getColumnWidth(column.width); - final double affordancePadding = 48.0; + final double affordancePadding = 64.0; // space for icons + resize handle final double headerTextWidth = _measureTextWidth(column.label, headerTextStyle) + affordancePadding; - final double computedWidth = math.max(baseWidth, headerTextWidth); + final double minWidth = 96.0; + final double defaultWidth = math.max(baseWidth, headerTextWidth); + final double savedWidth = _columnSettings?.columnWidths[column.key] ?? defaultWidth; + final double computedWidth = math.max(savedWidth, minWidth); return DataColumn2( label: _ColumnHeaderWithSearch( @@ -1419,6 +1621,74 @@ class _DataTableWidgetState extends State> { : () { }, hasActiveFilter: _columnSearchValues.containsKey(column.key), enabled: widget.config.enableSorting && column.sortable, + onResizeDrag: widget.config.enableColumnSettings ? (dx) { + if (_columnSettings == null) return; + final current = _columnSettings!.columnWidths[column.key] ?? savedWidth; + final next = math.max(minWidth, current + dx); + final updated = _columnSettings!.copyWith( + columnWidths: { + ..._columnSettings!.columnWidths, + column.key: next, + }, + ); + setState(() { + _columnSettings = updated; + }); + ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated); + } : null, + onPinLeft: widget.config.enableColumnSettings ? () { + if (_columnSettings == null) return; + final updated = _columnSettings!.copyWith( + pinnedLeft: { + ..._columnSettings!.pinnedLeft, + column.key, + }.toList(), + pinnedRight: _columnSettings!.pinnedRight.where((k) => k != column.key).toList(), + ); + setState(() { + _columnSettings = updated; + _visibleColumns = _getVisibleColumnsFromSettings(updated); + }); + ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated); + } : null, + onPinRight: widget.config.enableColumnSettings ? () { + if (_columnSettings == null) return; + final updated = _columnSettings!.copyWith( + pinnedRight: { + ..._columnSettings!.pinnedRight, + column.key, + }.toList(), + pinnedLeft: _columnSettings!.pinnedLeft.where((k) => k != column.key).toList(), + ); + setState(() { + _columnSettings = updated; + _visibleColumns = _getVisibleColumnsFromSettings(updated); + }); + ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated); + } : null, + onUnpin: widget.config.enableColumnSettings ? () { + if (_columnSettings == null) return; + final updated = _columnSettings!.copyWith( + pinnedLeft: _columnSettings!.pinnedLeft.where((k) => k != column.key).toList(), + pinnedRight: _columnSettings!.pinnedRight.where((k) => k != column.key).toList(), + ); + setState(() { + _columnSettings = updated; + _visibleColumns = _getVisibleColumnsFromSettings(updated); + }); + ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated); + } : null, + onHide: widget.config.enableColumnSettings ? () { + if (_columnSettings == null) return; + final updated = _columnSettings!.copyWith( + visibleColumns: _columnSettings!.visibleColumns.where((k) => k != column.key).toList(), + ); + setState(() { + _columnSettings = updated; + _visibleColumns = _getVisibleColumnsFromSettings(updated); + }); + ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated); + } : null, ), size: DataTableUtils.getColumnSize(column.width), fixedWidth: computedWidth, @@ -1444,7 +1714,8 @@ class _DataTableWidgetState extends State> { horizontalMargin: 8, minWidth: widget.config.minTableWidth ?? 600, horizontalScrollController: _horizontalScrollController, - headingRowHeight: 44, + headingRowHeight: _dense ? 40 : 44, + dataRowHeight: _dense ? 38 : 48, columns: columns, rows: _items.asMap().entries.map((entry) { final index = entry.key; @@ -1510,6 +1781,21 @@ class _DataTableWidgetState extends State> { } return DataRow2( + color: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return theme.colorScheme.primary.withValues(alpha: 0.08); + } + if (states.contains(WidgetState.hovered)) { + return theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3); + } + if (index == _activeRowIndex && _tableFocusNode.hasFocus) { + return theme.colorScheme.primary.withValues(alpha: 0.06); + } + final Color? base = widget.config.rowBackgroundColor; + final Color? alt = widget.config.alternateRowBackgroundColor ?? + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.15); + return (index % 2 == 1) ? alt : base; + }), selected: isSelected, onTap: widget.config.onRowTap != null ? () => widget.config.onRowTap!(item) @@ -1540,41 +1826,101 @@ class _DataTableWidgetState extends State> { // This allows working with strongly-typed objects (not just Map) if (column is TextColumn && column.formatter != null) { final text = column.formatter!(item) ?? ''; - return Text( + final overflow = _getOverflow(column); + final textWidget = Text( text, textAlign: _getTextAlign(column), maxLines: _getMaxLines(column), - overflow: _getOverflow(column), + overflow: overflow, ); + final wrapped = GestureDetector( + onLongPress: () { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('متن کپی شد')), + ); + }, + child: textWidget, + ); + return (overflow == TextOverflow.ellipsis && text.isNotEmpty) + ? Tooltip(message: text, child: wrapped) + : wrapped; } if (column is NumberColumn && column.formatter != null) { final text = column.formatter!(item) ?? ''; - return Text( + final overflow = _getOverflow(column); + final textWidget = Text( text, textAlign: _getTextAlign(column), maxLines: _getMaxLines(column), - overflow: _getOverflow(column), + overflow: overflow, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), ); + final wrapped = GestureDetector( + onLongPress: () { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('عدد کپی شد')), + ); + }, + child: textWidget, + ); + return (overflow == TextOverflow.ellipsis && text.isNotEmpty) + ? Tooltip(message: text, child: wrapped) + : wrapped; } if (column is DateColumn && column.formatter != null) { final text = column.formatter!(item) ?? ''; - return Text( + final overflow = _getOverflow(column); + final textWidget = Text( text, textAlign: _getTextAlign(column), maxLines: _getMaxLines(column), - overflow: _getOverflow(column), + overflow: overflow, ); + final wrapped = GestureDetector( + onLongPress: () { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('تاریخ کپی شد')), + ); + }, + child: textWidget, + ); + return (overflow == TextOverflow.ellipsis && text.isNotEmpty) + ? Tooltip(message: text, child: wrapped) + : wrapped; } // 4) Fallback: get property value from Map items by key final value = DataTableUtils.getCellValue(item, column.key); final formattedValue = DataTableUtils.formatCellValue(value, column); - return Text( + final overflow = _getOverflow(column); + final textWidget = Text( formattedValue, textAlign: _getTextAlign(column), maxLines: _getMaxLines(column), - overflow: _getOverflow(column), + overflow: overflow, + style: column is NumberColumn + ? Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ) + : null, ); + final wrapped = GestureDetector( + onLongPress: () { + Clipboard.setData(ClipboardData(text: formattedValue)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('مقدار کپی شد')), + ); + }, + child: textWidget, + ); + return (overflow == TextOverflow.ellipsis && formattedValue.isNotEmpty) + ? Tooltip(message: formattedValue, child: wrapped) + : wrapped; } Widget _buildActionButtons(dynamic item, ActionColumn column) { @@ -1642,6 +1988,11 @@ class _ColumnHeaderWithSearch extends StatelessWidget { final VoidCallback onSearch; final bool hasActiveFilter; final bool enabled; + final void Function(double dx)? onResizeDrag; + final VoidCallback? onPinLeft; + final VoidCallback? onPinRight; + final VoidCallback? onUnpin; + final VoidCallback? onHide; const _ColumnHeaderWithSearch({ required this.text, @@ -1652,6 +2003,11 @@ class _ColumnHeaderWithSearch extends StatelessWidget { required this.onSearch, required this.hasActiveFilter, this.enabled = true, + this.onResizeDrag, + this.onPinLeft, + this.onPinRight, + this.onUnpin, + this.onHide, }); @override @@ -1726,6 +2082,55 @@ class _ColumnHeaderWithSearch extends StatelessWidget { ), ), ), + if (onResizeDrag != null) ...[ + const SizedBox(width: 6), + MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (details) => onResizeDrag!(details.delta.dx), + child: Container( + width: 8, + height: 28, + ), + ), + ), + ], + if (onPinLeft != null || onPinRight != null || onUnpin != null || onHide != null) ...[ + const SizedBox(width: 4), + PopupMenuButton( + padding: EdgeInsets.zero, + tooltip: 'تنظیمات ستون', + icon: Icon(Icons.more_vert, size: 16, color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7)), + onSelected: (value) { + switch (value) { + case 'pinLeft': + onPinLeft?.call(); + break; + case 'pinRight': + onPinRight?.call(); + break; + case 'unpin': + onUnpin?.call(); + break; + case 'hide': + onHide?.call(); + break; + } + }, + itemBuilder: (context) => [ + if (onPinLeft != null) + const PopupMenuItem(value: 'pinLeft', child: Text('پین چپ')), + if (onPinRight != null) + const PopupMenuItem(value: 'pinRight', child: Text('پین راست')), + if (onUnpin != null) + const PopupMenuItem(value: 'unpin', child: Text('برداشتن پین')), + const PopupMenuDivider(), + if (onHide != null) + const PopupMenuItem(value: 'hide', child: Text('مخفی کردن ستون')), + ], + ), + ], ], ), ), @@ -1733,3 +2138,25 @@ class _ColumnHeaderWithSearch extends StatelessWidget { ); } } + +// Keyboard intents +class MoveRowIntent extends Intent { + final int delta; + const MoveRowIntent(this.delta); +} + +class ActivateRowIntent extends Intent { + const ActivateRowIntent(); +} + +class ToggleSelectionIntent extends Intent { + const ToggleSelectionIntent(); +} + +class ClearSelectionIntent extends Intent { + const ClearSelectionIntent(); +} + +class SelectAllIntent extends Intent { + const SelectAllIntent(); +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart index 2da036d..9a09222 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart @@ -7,11 +7,15 @@ class ColumnSettings { final List visibleColumns; final List columnOrder; final Map columnWidths; + final List pinnedLeft; + final List pinnedRight; const ColumnSettings({ required this.visibleColumns, required this.columnOrder, this.columnWidths = const {}, + this.pinnedLeft = const [], + this.pinnedRight = const [], }); Map toJson() { @@ -19,6 +23,8 @@ class ColumnSettings { 'visibleColumns': visibleColumns, 'columnOrder': columnOrder, 'columnWidths': columnWidths, + 'pinnedLeft': pinnedLeft, + 'pinnedRight': pinnedRight, }; } @@ -27,6 +33,8 @@ class ColumnSettings { visibleColumns: List.from(json['visibleColumns'] ?? []), columnOrder: List.from(json['columnOrder'] ?? []), columnWidths: Map.from(json['columnWidths'] ?? {}), + pinnedLeft: List.from(json['pinnedLeft'] ?? []), + pinnedRight: List.from(json['pinnedRight'] ?? []), ); } @@ -34,11 +42,15 @@ class ColumnSettings { List? visibleColumns, List? columnOrder, Map? columnWidths, + List? pinnedLeft, + List? pinnedRight, }) { return ColumnSettings( visibleColumns: visibleColumns ?? this.visibleColumns, columnOrder: columnOrder ?? this.columnOrder, columnWidths: columnWidths ?? this.columnWidths, + pinnedLeft: pinnedLeft ?? this.pinnedLeft, + pinnedRight: pinnedRight ?? this.pinnedRight, ); } } @@ -92,6 +104,8 @@ class ColumnSettingsService { return ColumnSettings( visibleColumns: List.from(columnKeys), columnOrder: List.from(columnKeys), + pinnedLeft: const [], + pinnedRight: const [], ); } @@ -142,11 +156,22 @@ class ColumnSettingsService { validColumnWidths[entry.key] = entry.value; } } + // Sanitize pins to only include visible columns + final leftPins = []; + for (final key in userSettings.pinnedLeft) { + if (visibleColumns.contains(key)) leftPins.add(key); + } + final rightPins = []; + for (final key in userSettings.pinnedRight) { + if (visibleColumns.contains(key)) rightPins.add(key); + } return userSettings.copyWith( visibleColumns: visibleColumns, columnOrder: columnOrder, columnWidths: validColumnWidths, + pinnedLeft: leftPins, + pinnedRight: rightPins, ); } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart index 05b78e6..04b32b0 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart @@ -5,12 +5,14 @@ import './product_combobox_widget.dart'; // import './price_list_combobox_widget.dart'; import '../../services/price_list_service.dart'; import '../../core/api_client.dart'; +import './warehouse_combobox_widget.dart'; class InvoiceLineItemsTable extends StatefulWidget { final int businessId; final int? selectedCurrencyId; // از تب ارز فاکتور final ValueChanged>? onChanged; final String invoiceType; // sales | purchase | sales_return | purchase_return | ... + final bool postInventory; const InvoiceLineItemsTable({ super.key, @@ -18,6 +20,7 @@ class InvoiceLineItemsTable extends StatefulWidget { this.selectedCurrencyId, this.onChanged, this.invoiceType = 'sales', + this.postInventory = true, }); @override @@ -272,6 +275,15 @@ class _InvoiceLineItemsTableState extends State { ), ), const SizedBox(width: 8), + if (widget.postInventory) + Expanded( + flex: 2, + child: Tooltip( + message: 'انبار', + child: Text('انبار', style: style), + ), + ), + const SizedBox(width: 8), Expanded( flex: 3, child: Tooltip( @@ -392,6 +404,24 @@ class _InvoiceLineItemsTableState extends State { }), ), const SizedBox(width: 8), + if (widget.postInventory) + Flexible( + flex: 2, + child: SizedBox( + height: 36, + child: WarehouseComboboxWidget( + businessId: widget.businessId, + selectedWarehouseId: item.warehouseId, + onChanged: (wid) { + _updateRow(index, item.copyWith(warehouseId: wid)); + }, + label: 'انبار', + hintText: 'انتخاب انبار', + isRequired: item.trackInventory, + ), + ), + ), + if (widget.postInventory) const SizedBox(width: 8), Flexible( flex: 3, child: SizedBox( diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/warehouse_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/warehouse_combobox_widget.dart new file mode 100644 index 0000000..2ee84d4 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/warehouse_combobox_widget.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../services/warehouse_service.dart'; +import '../../models/warehouse_model.dart'; + +class WarehouseComboboxWidget extends StatefulWidget { + final int businessId; + final int? selectedWarehouseId; + final ValueChanged onChanged; + final String label; + final String hintText; + final bool isRequired; + + const WarehouseComboboxWidget({ + super.key, + required this.businessId, + required this.onChanged, + this.selectedWarehouseId, + this.label = 'انبار', + this.hintText = 'انتخاب انبار', + this.isRequired = false, + }); + + @override + State createState() => _WarehouseComboboxWidgetState(); +} + +class _WarehouseComboboxWidgetState extends State { + final WarehouseService _service = WarehouseService(); + List _items = const []; + bool _loading = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final items = await _service.listWarehouses(businessId: widget.businessId); + if (!mounted) return; + setState(() => _items = items); + } catch (_) { + if (!mounted) return; + setState(() => _items = const []); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (_loading) { + return SizedBox( + height: 36, + child: Center( + child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary)), + ), + ); + } + return DropdownButtonFormField( + value: widget.selectedWarehouseId, + isDense: true, + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + ), + items: _items.map((w) { + final title = (w.code.isNotEmpty) ? '${w.code} - ${w.name}' : w.name; + return DropdownMenuItem(value: w.id!, child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis)); + }).toList(), + onChanged: widget.onChanged, + hint: Text(widget.hintText), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/transfer/inventory_transfer_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/transfer/inventory_transfer_form_dialog.dart new file mode 100644 index 0000000..a2f3a64 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/transfer/inventory_transfer_form_dialog.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/widgets/date_input_field.dart'; +import 'package:hesabix_ui/widgets/invoice/product_combobox_widget.dart'; +import 'package:hesabix_ui/widgets/invoice/warehouse_combobox_widget.dart'; +import 'package:hesabix_ui/widgets/banking/currency_picker_widget.dart'; +import 'package:hesabix_ui/services/inventory_transfer_service.dart'; + +class InventoryTransferFormDialog extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + + const InventoryTransferFormDialog({ + super.key, + required this.businessId, + required this.calendarController, + }); + + @override + State createState() => _InventoryTransferFormDialogState(); +} + +class _InventoryTransferFormDialogState extends State { + final _formKey = GlobalKey(); + final InventoryTransferService _service = InventoryTransferService(); + + DateTime _documentDate = DateTime.now(); + int? _currencyId; + String? _description; + + final List<_TransferRow> _rows = <_TransferRow>[]; + + void _addRow() { + setState(() => _rows.add(_TransferRow())); + } + + void _removeRow(int index) { + setState(() => _rows.removeAt(index)); + } + + Future _submit() async { + if (_currencyId == null) { + _showError('انتخاب ارز الزامی است'); + return; + } + if (_rows.isEmpty) { + _showError('حداقل یک ردیف انتقال اضافه کنید'); + return; + } + for (int i = 0; i < _rows.length; i++) { + final r = _rows[i]; + if (r.productId == null) { _showError('محصول ردیف ${i + 1} انتخاب نشده است'); return; } + if ((r.quantity ?? 0) <= 0) { _showError('تعداد ردیف ${i + 1} باید > 0 باشد'); return; } + if (r.sourceWarehouseId == null || r.destinationWarehouseId == null) { _showError('انبار مبدا و مقصد در ردیف ${i + 1} الزامی است'); return; } + if (r.sourceWarehouseId == r.destinationWarehouseId) { _showError('انبار مبدا و مقصد در ردیف ${i + 1} نمی‌تواند یکسان باشد'); return; } + } + + final payload = { + 'document_date': _documentDate.toIso8601String().substring(0, 10), + 'currency_id': _currencyId, + if ((_description ?? '').isNotEmpty) 'description': _description, + 'lines': _rows.map((r) => { + 'product_id': r.productId, + 'quantity': r.quantity, + 'source_warehouse_id': r.sourceWarehouseId, + 'destination_warehouse_id': r.destinationWarehouseId, + if ((r.description ?? '').isNotEmpty) 'description': r.description, + }).toList(), + }; + + try { + await _service.create(businessId: widget.businessId, payload: payload); + if (!mounted) return; + Navigator.of(context).pop(true); + } catch (e) { + _showError('خطا در ثبت انتقال: $e'); + } + } + + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message), backgroundColor: Colors.red)); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('انتقال موجودی بین انبارها'), + content: SizedBox( + width: 900, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: DateInputField( + value: _documentDate, + labelText: 'تاریخ سند *', + calendarController: widget.calendarController, + onChanged: (d) => setState(() => _documentDate = d ?? DateTime.now()), + ), + ), + const SizedBox(width: 12), + Expanded( + child: CurrencyPickerWidget( + businessId: widget.businessId, + selectedCurrencyId: _currencyId, + onChanged: (cid) => setState(() => _currencyId = cid), + label: 'ارز *', + hintText: 'انتخاب ارز', + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + decoration: const InputDecoration( + labelText: 'شرح', + border: OutlineInputBorder(), + ), + onChanged: (v) => _description = v.trim(), + ), + const SizedBox(height: 12), + Row( + children: [ + ElevatedButton.icon(onPressed: _addRow, icon: const Icon(Icons.add), label: const Text('افزودن ردیف')), + ], + ), + const SizedBox(height: 8), + _rows.isEmpty + ? const Padding( + padding: EdgeInsets.all(8.0), + child: Text('ردیفی افزوده نشده است'), + ) + : Column( + children: _rows.asMap().entries.map((e) => _buildRow(context, e.key, e.value)).toList(), + ), + ], + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')), + FilledButton.icon(onPressed: _submit, icon: const Icon(Icons.save), label: const Text('ثبت انتقال')), + ], + ); + } + + Widget _buildRow(BuildContext context, int index, _TransferRow row) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 260, + height: 36, + child: ProductComboboxWidget( + businessId: widget.businessId, + selectedProduct: row.productId != null ? {'id': row.productId, 'code': row.productCode, 'name': row.productName} : null, + onChanged: (p) => setState(() { + if (p == null) { + row.productId = null; + row.productCode = null; + row.productName = null; + } else { + row.productId = int.tryParse('${p['id']}'); + row.productCode = p['code']?.toString(); + row.productName = p['name']?.toString(); + } + }), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 120, + height: 36, + child: TextFormField( + initialValue: (row.quantity ?? 0).toString(), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), labelText: 'تعداد'), + onChanged: (v) => row.quantity = num.tryParse(v.replaceAll(',', '')) ?? 0, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 220, + height: 36, + child: WarehouseComboboxWidget( + businessId: widget.businessId, + selectedWarehouseId: row.sourceWarehouseId, + onChanged: (wid) => setState(() => row.sourceWarehouseId = wid), + label: 'انبار مبدا', + hintText: 'انتخاب انبار مبدا', + isRequired: true, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 220, + height: 36, + child: WarehouseComboboxWidget( + businessId: widget.businessId, + selectedWarehouseId: row.destinationWarehouseId, + onChanged: (wid) => setState(() => row.destinationWarehouseId = wid), + label: 'انبار مقصد', + hintText: 'انتخاب انبار مقصد', + isRequired: true, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 220, + height: 36, + child: TextFormField( + initialValue: row.description ?? '', + onChanged: (v) => row.description = v.trim(), + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + hintText: 'شرح ردیف', + ), + ), + ), + const SizedBox(width: 8), + IconButton(onPressed: () => _removeRow(index), icon: const Icon(Icons.delete, color: Colors.red)), + ], + ), + ); + } +} + +class _TransferRow { + int? productId; + String? productCode; + String? productName; + num? quantity = 1; + int? sourceWarehouseId; + int? destinationWarehouseId; + String? description; +} + + diff --git a/run_local.sh b/run_local.sh index fcfdb0b..9e48e3f 100755 --- a/run_local.sh +++ b/run_local.sh @@ -33,8 +33,13 @@ case "$CMD" in serve) uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ;; + serve-workers) + # اجرای uvicorn با چند worker (بدون reload) + WORKERS=${WORKERS:-4} + uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers "$WORKERS" + ;; *) - echo "Usage: $0 [serve|migrate|test]" + echo "Usage: $0 [serve|serve-workers|migrate|test]" exit 1 ;; esac