diff --git a/docs/QUICK_SALES_STOCK_SHORTAGE_WORKFLOW.md b/docs/QUICK_SALES_STOCK_SHORTAGE_WORKFLOW.md new file mode 100644 index 00000000..4658f441 --- /dev/null +++ b/docs/QUICK_SALES_STOCK_SHORTAGE_WORKFLOW.md @@ -0,0 +1,124 @@ +# گردش‌کار کسری موجودی در فروش سریع + +## هدف + +این قابلیت برای حالتی است که کالا به‌صورت فیزیکی در انبار وجود دارد اما موجودی سیستمی به علت ثبت‌نشدن یا اشتباه یک رویداد قبلی کافی نیست. رفتار پیش‌فرض سامانه همچنان جلوگیری از ثبت فاکتور دارای کسری است. امکان عبور فقط برای همان فاکتور فروش سریع، با تنظیم فعال، مجوز مستقل و ثبت علت فراهم می‌شود. + +این قابلیت سیاست عمومی موجودی منفی کسب‌وکار را تغییر نمی‌دهد و روی انتقال بین انبارها اثر ندارد. کالای یونیک/سریالی نیز از مجوز یک‌باره مستثناست. + +## رفتار کاربر در فروش سریع + +API همهٔ اقلام دارای کسری را در یک پاسخ `409 / INSUFFICIENT_STOCK` برمی‌گرداند. دیالوگ فروش سریع نام کالا، انبار، موجودی قابل استفاده و مقدار موردنیاز را نمایش می‌دهد و این انتخاب‌ها را ارائه می‌کند: + +1. **انصراف**: فاکتور و موجودی تغییری نمی‌کند. +2. **پارک فروش**: فروش جاری در فهرست فروش‌های پارک‌شده نگهداری و یک فروش خالی باز می‌شود. +3. **اصلاح موجودی**: فقط برای موجودی فیزیکیِ مازاد با منشأ نامشخص است. کاربر باید این وضعیت را صریحاً تأیید کند. سامانه سند «کالای درآمدشده» را با تاریخ واقعی روز کشف، مقدار کسری و بهای خرید پایه کالا ثبت و قطعی می‌کند؛ سپس ثبت فاکتور را دوباره انجام می‌دهد. +4. **ثبت با موجودی منفی**: کاربر علت را انتخاب می‌کند و فاکتور فقط برای کالاهای فله ثبت می‌شود. یک پرونده مغایرت باز و به فاکتور و حواله انبار پیوند داده می‌شود. + +اگر خرید، انتقال یا موجودی اول دوره از قلم افتاده است، کاربر نباید گزینه «اصلاح موجودی» را انتخاب کند. در آن حالت سند اصلی خرید، انتقال یا افتتاحیه باید با تاریخ واقعی خودش ثبت شود. + +## تنظیم و مجوزها + +- کلید `quick_sales_settings.enable_stock_shortage_override` به‌صورت پیش‌فرض `false` است. +- فعال‌سازی از «تنظیمات فروش سریع ← تنظیمات موجودی ← گردش‌کار کسری موجودی» انجام می‌شود. +- ثبت یک‌باره موجودی منفی به مجوز `inventory.negative_stock_override` نیاز دارد. +- اصلاح فوری با کالای درآمدشده به مجوزهای `goods_expense_income.add` و `goods_expense_income.post` نیاز دارد. +- مشاهده پرونده‌های مغایرت به `inventory.view` نیاز دارد. +- مالک کسب‌وکار و مدیر کل طبق منطق عمومی مجوزها دسترسی دارند. + +## قرارداد API + +پاسخ کسری نمونه: + +```json +{ + "success": false, + "error": { + "code": "INSUFFICIENT_STOCK", + "message": "موجودی کافی برای یک یا چند کالای فاکتور وجود ندارد", + "details": { + "shortages": [ + { + "product_id": 10, + "product_name": "کالای نمونه", + "product_code": "A-10", + "warehouse_id": 3, + "warehouse_name": "انبار اصلی", + "inventory_mode": "bulk", + "available_quantity": 0, + "requested_quantity": 1, + "shortage_quantity": 1 + } + ] + } + } +} +``` + +در تلاش دوم، کلاینت `stock_override` را در ریشه درخواست فاکتور می‌فرستد: + +```json +{ + "stock_override": { + "reason_code": "physical_stock_exists", + "reason_note": "یک عدد در قفسه شمارش شد", + "request_token": "qs-4952-unique-token" + } +} +``` + +سرور هر مقدار `_stock_override` ارسالی در `extra_info` را حذف می‌کند. علامت داخلی مجازبودن فقط پس از کنترل موارد زیر ساخته می‌شود: + +- درخواست واقعاً فروش سریع باشد (`extra_info.quick_sale == true`) +- قابلیت در تنظیمات فروش سریع فعال باشد +- کاربر مجوز `inventory.negative_stock_override` داشته باشد +- علت معتبر و شناسه درخواست موجود باشد +- هیچ‌کدام از کسری‌ها کالای یونیک نباشد + +APIهای رسیدگی: + +- `GET /api/v1/businesses/{business_id}/quick-sales/stock-discrepancies?status=open` +- `POST /api/v1/businesses/{business_id}/quick-sales/stock-corrections` +- `POST /api/v1/businesses/{business_id}/quick-sales/stock-discrepancies/{case_id}/resolve` + +## رسیدگی پس از ثبت موجودی منفی + +پرونده‌های ایجادشده از مسیر «انبار ← مغایرت‌های موجودی فروش سریع» قابل مشاهده‌اند. فیلتر صفحه پرونده‌های باز، رفع‌شده یا همه موارد را نشان می‌دهد و اطلاعات فاکتور، علت، کالا، انبار و مقدار کسری را نمایش می‌دهد. + +برای یک پرونده باز، گزینه «رفع مغایرت» را انتخاب کنید، تاریخ واقعی کشف اصلاح را وارد کنید و موجودی فیزیکی با منشأ نامشخص را تأیید کنید. بهای واحد به‌صورت پیش‌فرض از قیمت خرید پایه کالا گرفته می‌شود و در صورت فعال‌بودن تغییر دستی بها و داشتن مجوز مربوط، قابل جایگزینی است. ثبت نهایی سند قطعی «کالای درآمدشده» را ایجاد می‌کند، پرونده را می‌بندد و بهای تمام‌شده و سود ناخالص ردیف فروش را بازشناسی می‌کند. + +اگر منشأ کسری یک خرید، انتقال یا موجودی اول دوره ثبت‌نشده است، از این عملیات استفاده نکنید و سند اصلی را با تاریخ واقعی همان رویداد ثبت کنید. + +## ردپای حسابرسی + +جدول `inventory_discrepancy_cases` اطلاعات فاکتور، حواله، کاربر ثبت‌کننده، علت، وضعیت و سند رفع مغایرت را نگه می‌دارد. جدول `inventory_discrepancy_lines` مقدار موجود، مقدار درخواست‌شده، کسری و مقدار رفع‌شده را برای هر کالا و انبار ثبت می‌کند. + +`request_token` در محدوده کسب‌وکار یکتا است. شناسه پرونده داخل `Document.extra_info._stock_override.discrepancy_case_id` ذخیره می‌شود. رفع مغایرت نیز روی ردیف فاکتور در `stock_shortage_resolutions` ثبت می‌شود. + +## تاریخ و بهای تمام‌شده + +تاریخ اصلاح، تاریخ واقعی کشف و ثبت اصلاح است و به روز قبل از فروش جعل نمی‌شود. حوالهٔ ورودی کالای درآمدشده با `unit_cost` وارد جریان تحلیلی FIFO/LIFO می‌شود. در رفع پرونده پس از فروش، مقدار و بهای اصلاح به ردیف فروش تخصیص داده و دفتر بهای تمام‌شده و سود ناخالص همان ردیف بازشناسی می‌شود. + +این طراحی زنجیره زمانی انبار را حفظ می‌کند: رویداد گذشته فقط وقتی با تاریخ گذشته ثبت می‌شود که واقعاً سند اصلیِ گذشته، مانند خرید یا انتقال، بازیابی و ثبت شده باشد. + +## مهاجرت و راه‌اندازی + +مهاجرت زیر باید پیش از فعال‌کردن گزینه اجرا شود: + +```bash +cd hesabixAPI +.venv/bin/alembic upgrade head +``` + +مهاجرت `20261012_000001_quick_sales_stock_shortage` ستون تنظیمات و دو جدول پرونده مغایرت را ایجاد می‌کند. پس از مهاجرت، API باید reload شود. فرانت در حالت توسعه با hot restart و در نسخه وب release با build/release معمول پروژه به‌روز می‌شود. + +## سناریوهای پذیرش + +1. با دو کالای فله دارای کسری، پاسخ یک‌باره شامل هر دو کالا باشد. +2. بدون فعال‌بودن تنظیم یا بدون مجوز، درخواست override با `403` رد شود. +3. مقدار `_stock_override` جعلی در `extra_info` هیچ اثری نداشته باشد. +4. کالای یونیک حتی با مجوز override ثبت نشود. +5. ثبت منفی، فاکتور و حواله قطعی و یک پرونده باز بسازد. +6. اصلاح موجودی، سند کالای درآمدشده را با تاریخ روز ایجاد کند و ثبت فاکتور در تلاش بعدی موفق شود. +7. رفع پرونده، سند اصلاح را پیوند دهد، پرونده را `resolved` کند و بهای تمام‌شده ردیف فروش را بازشناسی کند. +8. انتقال انبار همچنان با کنترل مثبت موجودی اجرا شود. diff --git a/hesabixAPI/adapters/api/v1/invoices.py b/hesabixAPI/adapters/api/v1/invoices.py index 626a87aa..469efa83 100755 --- a/hesabixAPI/adapters/api/v1/invoices.py +++ b/hesabixAPI/adapters/api/v1/invoices.py @@ -25,6 +25,7 @@ from app.core.permissions import ( has_invoice_type_permission_for_business, allowed_invoice_types_for_business, user_can_change_invoice_unit_price, + has_business_permission_for_business, ) from app.core.responses import success_response, format_datetime_fields from app.core.cache import get_cache @@ -217,6 +218,79 @@ def create_invoice_endpoint( _: None = Depends(require_business_permission_dep("invoices", "add")), __: None = Depends(require_invoice_type_permission_from_payload_dep("add")), ) -> Dict[str, Any]: + # کلاینت اجازه ساختن علامت داخلی عبور از کنترل موجودی را ندارد. این مقدار فقط + # پس از کنترل ویژگی فروش سریع، تنظیمات کسب‌وکار و مجوز کاربر در سرور ساخته می‌شود. + extra = dict(payload.get("extra_info") or {}) + extra.pop("_stock_override", None) + requested_override = payload.pop("stock_override", None) + if requested_override is not None: + from adapters.db.models.quick_sales_settings import QuickSalesSetting + + if extra.get("quick_sale") is not True: + raise ApiError( + "STOCK_OVERRIDE_QUICK_SALE_ONLY", + "مجوز یک‌باره موجودی منفی فقط در فروش سریع قابل استفاده است", + http_status=400, + ) + settings_row = ( + db.query(QuickSalesSetting) + .filter(QuickSalesSetting.business_id == int(business_id)) + .first() + ) + enabled = bool( + settings_row + and getattr(settings_row, "enable_stock_shortage_override", False) + ) + if not enabled: + raise ApiError( + "STOCK_OVERRIDE_DISABLED", + "ثبت فروش با موجودی منفی در تنظیمات فروش سریع فعال نیست", + http_status=403, + ) + if not has_business_permission_for_business( + ctx, db, business_id, "inventory", "negative_stock_override" + ): + raise ApiError( + "STOCK_OVERRIDE_FORBIDDEN", + "مجوز ثبت یک‌باره فروش با موجودی منفی را ندارید", + http_status=403, + ) + override = requested_override if isinstance(requested_override, dict) else {} + reason_code = str(override.get("reason_code") or "").strip() + reason_note = str(override.get("reason_note") or "").strip() + request_token = str(override.get("request_token") or "").strip() + if reason_code not in {"physical_stock_exists", "urgent_sale", "other"}: + raise ApiError("STOCK_OVERRIDE_REASON_REQUIRED", "علت ثبت موجودی منفی را انتخاب کنید", http_status=400) + if reason_code == "other" and not reason_note: + raise ApiError("STOCK_OVERRIDE_NOTE_REQUIRED", "برای علت «سایر» توضیح وارد کنید", http_status=400) + if not request_token or len(request_token) > 80: + raise ApiError("STOCK_OVERRIDE_TOKEN_REQUIRED", "شناسه درخواست نامعتبر است", http_status=400) + from adapters.db.models.inventory_discrepancy import InventoryDiscrepancyCase + + existing_case = db.query(InventoryDiscrepancyCase).filter( + InventoryDiscrepancyCase.business_id == int(business_id), + InventoryDiscrepancyCase.request_token == request_token, + ).first() + if existing_case: + existing_invoice = db.query(Document).filter( + Document.id == int(existing_case.invoice_document_id), + Document.business_id == int(business_id), + ).first() + if existing_invoice: + return success_response( + data=invoice_document_to_dict(db, existing_invoice), + request=request, + message="INVOICE_ALREADY_CREATED", + ) + extra["_stock_override"] = { + "authorized": True, + "authorized_by_user_id": ctx.get_user_id(), + "reason_code": reason_code, + "reason_note": reason_note or None, + "request_token": request_token, + "authorized_at": datetime.datetime.utcnow().isoformat(), + } + payload["extra_info"] = extra can_pick = _user_can_select_fx_rate_for_business(db, ctx, business_id) can_change_unit = user_can_change_invoice_unit_price(ctx, db, business_id) result = create_invoice( @@ -5681,4 +5755,3 @@ async def calculate_invoices_remaining_endpoint( request=request, message="INVOICE_REMAINING_CALCULATED", ) - diff --git a/hesabixAPI/adapters/api/v1/quick_sales.py b/hesabixAPI/adapters/api/v1/quick_sales.py index 433df947..482abe03 100755 --- a/hesabixAPI/adapters/api/v1/quick_sales.py +++ b/hesabixAPI/adapters/api/v1/quick_sales.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from datetime import date, datetime import logging from typing import Dict, Any @@ -10,8 +11,12 @@ from sqlalchemy.orm import Session from adapters.db.session import get_db from adapters.db.models.person import PersonType from app.core.auth_dependency import get_current_user, AuthContext -from app.core.permissions import require_business_access_dep, require_business_permission_dep -from app.core.responses import success_response +from app.core.permissions import ( + has_business_permission_for_business, + require_business_access_dep, + require_business_permission_dep, +) +from app.core.responses import ApiError, success_response from app.services.quick_sales_service import ( get_quick_sales_settings, update_quick_sales_settings, @@ -24,6 +29,17 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/businesses/{business_id}/quick-sales", tags=["quick-sales"]) +def _can(ctx: AuthContext, db: Session, business_id: int, section: str, action: str) -> bool: + return has_business_permission_for_business(ctx, db, business_id, section, action) + + +def _body_date(value: Any) -> date: + try: + return datetime.fromisoformat(str(value)).date() + except Exception as exc: + raise ApiError("INVALID_DATE", "تاریخ اصلاح موجودی نامعتبر است", http_status=400) from exc + + @router.get( "/settings", summary="دریافت تنظیمات فروش سریع", @@ -93,3 +109,80 @@ def get_anonymous_customer_endpoint( logger.error(f"Error in get_anonymous_customer_endpoint for business {business_id}: {type(e).__name__}: {str(e)}", exc_info=True) raise + +@router.get("/stock-discrepancies", summary="فهرست مغایرت‌های موجودی فروش سریع") +def list_stock_discrepancies_endpoint( + request: Request, + business_id: int, + status: str | None = None, + _: None = Depends(require_business_access_dep), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +) -> dict: + if not _can(ctx, db, business_id, "inventory", "view"): + raise ApiError("FORBIDDEN", "دسترسی مشاهده موجودی را ندارید", http_status=403) + from app.services.inventory_discrepancy_service import list_cases + + return success_response(list_cases(db, business_id, status), request) + + +@router.post("/stock-corrections", summary="اصلاح کسری پیش از ثبت فروش سریع") +def create_stock_correction_endpoint( + request: Request, + business_id: int, + payload: Dict[str, Any] = Body(...), + _: None = Depends(require_business_access_dep), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +) -> dict: + if payload.get("confirm_unexplained_physical_surplus") is not True: + raise ApiError( + "PHYSICAL_SURPLUS_CONFIRMATION_REQUIRED", + "باید تأیید کنید که کالا واقعاً موجود است و منشأ ثبت‌نشده دیگری ندارد", + http_status=400, + ) + if not _can(ctx, db, business_id, "goods_expense_income", "add"): + raise ApiError("FORBIDDEN", "مجوز ایجاد کالای درآمدشده را ندارید", http_status=403) + from app.services.inventory_discrepancy_service import create_goods_income_correction + + result = create_goods_income_correction( + db, + business_id=business_id, + user_id=ctx.get_user_id(), + document_date=_body_date(payload.get("document_date")), + lines=list(payload.get("lines") or []), + description=str(payload.get("description") or "اصلاح کسری کشف‌شده در فروش سریع"), + user_can_allocate=_can(ctx, db, business_id, "goods_expense_income", "allocate"), + user_can_post=_can(ctx, db, business_id, "goods_expense_income", "post"), + user_can_change_unit_cost=_can(ctx, db, business_id, "goods_expense_income", "change_unit_cost"), + ) + db.commit() + return success_response(result, request, message="QUICK_SALES_STOCK_CORRECTED") + + +@router.post("/stock-discrepancies/{case_id}/resolve", summary="رفع مغایرت با کالای درآمدشده") +def resolve_stock_discrepancy_endpoint( + request: Request, + business_id: int, + case_id: int, + payload: Dict[str, Any] = Body(...), + _: None = Depends(require_business_access_dep), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +) -> dict: + if not _can(ctx, db, business_id, "goods_expense_income", "add"): + raise ApiError("FORBIDDEN", "مجوز ایجاد کالای درآمدشده را ندارید", http_status=403) + from app.services.inventory_discrepancy_service import resolve_case_with_goods_income + + result = resolve_case_with_goods_income( + db, + business_id=business_id, + case_id=case_id, + user_id=ctx.get_user_id(), + lines=list(payload.get("lines") or []), + document_date=_body_date(payload.get("document_date")), + user_can_allocate=_can(ctx, db, business_id, "goods_expense_income", "allocate"), + user_can_post=_can(ctx, db, business_id, "goods_expense_income", "post"), + user_can_change_unit_cost=_can(ctx, db, business_id, "goods_expense_income", "change_unit_cost"), + ) + return success_response(result, request, message="INVENTORY_DISCREPANCY_RESOLVED") diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index fed7aa59..40643503 100755 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -88,6 +88,7 @@ from .warehouse_product_placement import WarehouseProductPlacement # noqa: F401 from .warehouse_document import WarehouseDocument # noqa: F401 from .warehouse_document_line import WarehouseDocumentLine # noqa: F401 from .goods_expense_income import GoodsExpenseIncomeDocument, GoodsExpenseIncomeLine # noqa: F401 +from .inventory_discrepancy import InventoryDiscrepancyCase, InventoryDiscrepancyLine # noqa: F401 from .hscript_report import HScriptReport, HScriptReportVersion, HScriptReportRun, HScriptReportSchedule # noqa: F401 from .product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation # noqa: F401 from .ping_pong_score import PingPongScore # noqa: F401 diff --git a/hesabixAPI/adapters/db/models/inventory_discrepancy.py b/hesabixAPI/adapters/db/models/inventory_discrepancy.py new file mode 100644 index 00000000..73df659c --- /dev/null +++ b/hesabixAPI/adapters/db/models/inventory_discrepancy.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class InventoryDiscrepancyCase(Base): + """ردپای قابل رسیدگی برای فروش‌هایی که با کسری سیستمی ثبت شده‌اند.""" + + __tablename__ = "inventory_discrepancy_cases" + __table_args__ = ( + UniqueConstraint("business_id", "request_token", name="uq_inventory_discrepancy_request"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + business_id: Mapped[int] = mapped_column( + Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True + ) + invoice_document_id: Mapped[int] = mapped_column( + Integer, ForeignKey("documents.id", ondelete="RESTRICT"), nullable=False, index=True + ) + warehouse_document_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("warehouse_documents.id", ondelete="SET NULL"), nullable=True, index=True + ) + resolution_document_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("goods_expense_income_documents.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + status: Mapped[str] = mapped_column(String(24), nullable=False, default="open", index=True) + source_type: Mapped[str] = mapped_column(String(32), nullable=False, default="quick_sale") + request_token: Mapped[str] = mapped_column(String(80), nullable=False) + reason_code: Mapped[str] = mapped_column(String(48), nullable=False) + reason_note: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by_user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + resolved_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + resolution_type: Mapped[str | None] = mapped_column(String(48), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + lines = relationship( + "InventoryDiscrepancyLine", + back_populates="case", + cascade="all, delete-orphan", + order_by="InventoryDiscrepancyLine.id", + ) + invoice_document = relationship("Document", foreign_keys=[invoice_document_id]) + + +class InventoryDiscrepancyLine(Base): + __tablename__ = "inventory_discrepancy_lines" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + case_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("inventory_discrepancy_cases.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + product_id: Mapped[int] = mapped_column( + Integer, ForeignKey("products.id", ondelete="RESTRICT"), nullable=False, index=True + ) + warehouse_id: Mapped[int] = mapped_column( + Integer, ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False, index=True + ) + invoice_item_line_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("invoice_item_lines.id", ondelete="SET NULL"), nullable=True + ) + available_quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) + requested_quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) + shortage_quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) + resolved_quantity: Mapped[Decimal] = mapped_column( + Numeric(18, 6), nullable=False, default=Decimal("0") + ) + resolution_unit_cost: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + case = relationship("InventoryDiscrepancyCase", back_populates="lines") + product = relationship("Product", foreign_keys=[product_id]) + warehouse = relationship("Warehouse", foreign_keys=[warehouse_id]) diff --git a/hesabixAPI/adapters/db/models/quick_sales_settings.py b/hesabixAPI/adapters/db/models/quick_sales_settings.py index ede7e6f2..d8cbe372 100755 --- a/hesabixAPI/adapters/db/models/quick_sales_settings.py +++ b/hesabixAPI/adapters/db/models/quick_sales_settings.py @@ -118,6 +118,13 @@ class QuickSalesSetting(Base): server_default="1", comment="نمایش موجودی در صفحه فروش سریع" ) + enable_stock_shortage_override: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="0", + comment="اجازه نمایش گردش‌کار ثبت یک‌باره فروش سریع با موجودی منفی", + ) # تنظیمات حسابداری auto_create_payment_document: Mapped[bool] = mapped_column( @@ -183,4 +190,3 @@ class QuickSalesSetting(Base): default_cash_register = relationship("CashRegister", foreign_keys=[default_cash_register_id]) default_currency = relationship("Currency", foreign_keys=[default_currency_id]) default_price_list = relationship("PriceList", foreign_keys=[default_price_list_id]) - diff --git a/hesabixAPI/app/services/inventory_discrepancy_service.py b/hesabixAPI/app/services/inventory_discrepancy_service.py new file mode 100644 index 00000000..84b09dda --- /dev/null +++ b/hesabixAPI/app/services/inventory_discrepancy_service.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy.orm import Session, selectinload +from sqlalchemy.orm.attributes import flag_modified + +from adapters.db.models.document import Document +from adapters.db.models.inventory_discrepancy import ( + InventoryDiscrepancyCase, + InventoryDiscrepancyLine, +) +from adapters.db.models.invoice_item_line import InvoiceItemLine +from app.core.responses import ApiError + + +def _decimal(value: Any) -> Decimal: + return Decimal(str(value or 0)) + + +def _correction_lines_by_case_line(lines: list[dict[str, Any]]) -> dict[int, dict[str, Any]]: + indexed: dict[int, dict[str, Any]] = {} + for line in lines: + extra = line.get("extra_info") + if not isinstance(extra, dict) or extra.get("inventory_discrepancy_line_id") is None: + continue + indexed[int(extra["inventory_discrepancy_line_id"])] = line + return indexed + + +def _serialize(case: InventoryDiscrepancyCase) -> dict[str, Any]: + return { + "id": case.id, + "business_id": case.business_id, + "invoice_document_id": case.invoice_document_id, + "invoice_code": case.invoice_document.code if case.invoice_document else None, + "invoice_date": ( + case.invoice_document.document_date.isoformat() + if case.invoice_document and case.invoice_document.document_date + else None + ), + "warehouse_document_id": case.warehouse_document_id, + "resolution_document_id": case.resolution_document_id, + "status": case.status, + "source_type": case.source_type, + "reason_code": case.reason_code, + "reason_note": case.reason_note, + "created_by_user_id": case.created_by_user_id, + "resolved_by_user_id": case.resolved_by_user_id, + "resolution_type": case.resolution_type, + "created_at": case.created_at.isoformat() if case.created_at else None, + "resolved_at": case.resolved_at.isoformat() if case.resolved_at else None, + "lines": [ + { + "id": line.id, + "product_id": line.product_id, + "product_name": line.product.name if line.product else None, + "product_code": line.product.code if line.product else None, + "warehouse_id": line.warehouse_id, + "warehouse_name": line.warehouse.name if line.warehouse else None, + "invoice_item_line_id": line.invoice_item_line_id, + "available_quantity": float(line.available_quantity or 0), + "requested_quantity": float(line.requested_quantity or 0), + "shortage_quantity": float(line.shortage_quantity or 0), + "resolved_quantity": float(line.resolved_quantity or 0), + "resolution_unit_cost": ( + float(line.resolution_unit_cost) if line.resolution_unit_cost is not None else None + ), + "suggested_unit_cost": ( + float(line.product.base_purchase_price or 0) if line.product else 0 + ), + } + for line in case.lines + ], + } + + +def create_case_for_invoice( + db: Session, + *, + business_id: int, + document: Document, + user_id: int, + warehouse_document_ids: list[int], +) -> InventoryDiscrepancyCase | None: + override = (document.extra_info or {}).get("_stock_override") + if not isinstance(override, dict) or override.get("authorized") is not True: + return None + shortages = override.get("shortages") + if not isinstance(shortages, list) or not shortages: + return None + token = str(override.get("request_token") or "").strip() + existing = db.query(InventoryDiscrepancyCase).filter( + InventoryDiscrepancyCase.business_id == int(business_id), + InventoryDiscrepancyCase.request_token == token, + ).first() + if existing: + return existing + + wh_id = int(warehouse_document_ids[0]) if warehouse_document_ids else None + case = InventoryDiscrepancyCase( + business_id=int(business_id), + invoice_document_id=int(document.id), + warehouse_document_id=wh_id, + status="open", + source_type="quick_sale", + request_token=token, + reason_code=str(override.get("reason_code") or "other"), + reason_note=override.get("reason_note"), + created_by_user_id=int(user_id), + ) + db.add(case) + db.flush() + + invoice_lines = db.query(InvoiceItemLine).filter( + InvoiceItemLine.document_id == int(document.id) + ).all() + for shortage in shortages: + pid = int(shortage["product_id"]) + warehouse_id = shortage.get("warehouse_id") + if warehouse_id is None: + continue + invoice_line = next((x for x in invoice_lines if int(x.product_id) == pid), None) + db.add( + InventoryDiscrepancyLine( + case_id=case.id, + product_id=pid, + warehouse_id=int(warehouse_id), + invoice_item_line_id=int(invoice_line.id) if invoice_line else None, + available_quantity=_decimal(shortage.get("available_quantity")), + requested_quantity=_decimal(shortage.get("requested_quantity")), + shortage_quantity=_decimal(shortage.get("shortage_quantity")), + resolved_quantity=Decimal("0"), + ) + ) + db.flush() + extra = dict(document.extra_info or {}) + override = dict(override) + override["discrepancy_case_id"] = case.id + extra["_stock_override"] = override + document.extra_info = extra + flag_modified(document, "extra_info") + db.flush() + return case + + +def list_cases(db: Session, business_id: int, status: str | None = None) -> dict[str, Any]: + q = db.query(InventoryDiscrepancyCase).options( + selectinload(InventoryDiscrepancyCase.invoice_document), + selectinload(InventoryDiscrepancyCase.lines).selectinload(InventoryDiscrepancyLine.product), + selectinload(InventoryDiscrepancyCase.lines).selectinload(InventoryDiscrepancyLine.warehouse), + ).filter(InventoryDiscrepancyCase.business_id == int(business_id)) + if status: + q = q.filter(InventoryDiscrepancyCase.status == str(status)) + rows = q.order_by(InventoryDiscrepancyCase.created_at.desc()).limit(200).all() + return {"items": [_serialize(row) for row in rows], "total": len(rows)} + + +def get_case(db: Session, business_id: int, case_id: int) -> InventoryDiscrepancyCase: + row = db.query(InventoryDiscrepancyCase).options( + selectinload(InventoryDiscrepancyCase.invoice_document), + selectinload(InventoryDiscrepancyCase.lines).selectinload(InventoryDiscrepancyLine.product), + selectinload(InventoryDiscrepancyCase.lines).selectinload(InventoryDiscrepancyLine.warehouse), + ).filter( + InventoryDiscrepancyCase.id == int(case_id), + InventoryDiscrepancyCase.business_id == int(business_id), + ).first() + if not row: + raise ApiError("INVENTORY_DISCREPANCY_NOT_FOUND", "پرونده مغایرت یافت نشد", http_status=404) + return row + + +def create_goods_income_correction( + db: Session, + *, + business_id: int, + user_id: int, + document_date: date, + lines: list[dict[str, Any]], + description: str, + user_can_allocate: bool, + user_can_post: bool, + user_can_change_unit_cost: bool, + case_id: int | None = None, +) -> dict[str, Any]: + """کسری ناشی از موجودی فیزیکی ثبت‌نشده را با تاریخ واقعی کشف اصلاح می‌کند.""" + if not user_can_post: + raise ApiError( + "GOODS_INCOME_POST_PERMISSION_REQUIRED", + "برای اصلاح فوری موجودی، مجوز قطعی‌کردن کالای درآمدشده لازم است", + http_status=403, + ) + from app.services.goods_expense_income_service import create_document, post_document + + payload = { + "doc_kind": "goods_income", + "document_date": document_date.isoformat(), + "description": description, + "auto_post": True, + "extra_info": { + "source": "quick_sales_stock_correction", + "inventory_discrepancy_case_id": case_id, + }, + "lines": lines, + } + result = create_document( + db, + business_id, + user_id, + payload, + user_can_allocate=user_can_allocate, + user_can_post=user_can_post, + user_can_change_unit_cost=user_can_change_unit_cost, + commit=False, + ) + if result.get("status") != "posted": + result = post_document(db, business_id, user_id, int(result["id"]), commit=False) + return result + + +def resolve_case_with_goods_income( + db: Session, + *, + business_id: int, + case_id: int, + user_id: int, + lines: list[dict[str, Any]], + document_date: date, + user_can_allocate: bool, + user_can_post: bool, + user_can_change_unit_cost: bool, +) -> dict[str, Any]: + case = get_case(db, business_id, case_id) + if case.status != "open": + raise ApiError("INVENTORY_DISCREPANCY_ALREADY_RESOLVED", "این پرونده قبلاً رسیدگی شده است", http_status=409) + by_line_id = {int(x.get("case_line_id")): x for x in lines if x.get("case_line_id")} + correction_lines: list[dict[str, Any]] = [] + for case_line in case.lines: + requested = by_line_id.get(int(case_line.id), {}) + remaining = _decimal(case_line.shortage_quantity) - _decimal(case_line.resolved_quantity) + qty = _decimal(requested.get("quantity", remaining)) + if qty <= 0 or qty != remaining: + raise ApiError( + "INVALID_RESOLUTION_QUANTITY", + "برای بستن پرونده باید تمام مقدار باقیمانده اصلاح شود", + http_status=400, + ) + correction_lines.append( + { + "product_id": case_line.product_id, + "warehouse_id": case_line.warehouse_id, + "quantity": float(qty), + **({"unit_cost": requested["unit_cost"]} if requested.get("unit_cost") is not None else {}), + "description": f"رفع مغایرت فروش سریع، پرونده {case.id}", + "extra_info": {"inventory_discrepancy_line_id": case_line.id}, + } + ) + correction = create_goods_income_correction( + db, + business_id=business_id, + user_id=user_id, + document_date=document_date, + lines=correction_lines, + description=f"اصلاح موجودی واقعی بابت پرونده مغایرت فروش سریع {case.id}", + user_can_allocate=user_can_allocate, + user_can_post=user_can_post, + user_can_change_unit_cost=user_can_change_unit_cost, + case_id=case.id, + ) + actual_lines = correction.get("lines") or [] + actual_by_case_line = _correction_lines_by_case_line(actual_lines) + for case_line in case.lines: + actual = actual_by_case_line.get(int(case_line.id)) + if not actual: + raise ApiError( + "INVENTORY_RESOLUTION_LINE_MISMATCH", + "ردیف سند اصلاح با پرونده مغایرت تطبیق ندارد", + http_status=500, + ) + qty = _decimal(actual.get("quantity")) + unit_cost = _decimal(actual.get("unit_cost")) + case_line.resolved_quantity = _decimal(case_line.resolved_quantity) + qty + case_line.resolution_unit_cost = unit_cost + if case_line.invoice_item_line_id: + sale_line = db.query(InvoiceItemLine).filter( + InvoiceItemLine.id == int(case_line.invoice_item_line_id) + ).first() + if sale_line: + extra = dict(sale_line.extra_info or {}) + allocations = list(extra.get("stock_shortage_resolutions") or []) + allocations.append( + { + "case_line_id": case_line.id, + "quantity": float(qty), + "unit_cost": float(unit_cost), + "resolution_document_id": correction["id"], + "resolved_at": datetime.utcnow().isoformat(), + } + ) + extra["stock_shortage_resolutions"] = allocations + sale_line.extra_info = extra + flag_modified(sale_line, "extra_info") + sale_qty = _decimal(sale_line.quantity) + if sale_qty > 0: + previous_unit = _decimal(sale_line.ledger_unit_cogs) + # اگر موجودی پیش از این فروش منفی بوده، مقدار اصلاح می‌تواند از + # تعداد همین فاکتور بیشتر باشد؛ فقط سهم همین ردیف به COGS آن تخصیص می‌یابد. + allocated_qty = min(_decimal(case_line.shortage_quantity), sale_qty) + normal_qty = sale_qty - allocated_qty + new_total = normal_qty * previous_unit + allocated_qty * unit_cost + sale_line.ledger_unit_cogs = new_total / sale_qty + sale_line.ledger_line_cogs = new_total + sale_amount = _decimal(extra.get("line_total")) + sale_line.ledger_line_gross_profit = sale_amount - new_total + sale_line.ledger_recognized_at = datetime.utcnow() + sale_line.ledger_recognition_event = "inventory_discrepancy_resolution" + case.status = "resolved" + case.resolution_document_id = int(correction["id"]) + case.resolution_type = "goods_income_unexplained_surplus" + case.resolved_by_user_id = int(user_id) + case.resolved_at = datetime.utcnow() + db.flush() + db.commit() + return {"case": _serialize(get_case(db, business_id, case.id)), "correction_document": correction} diff --git a/hesabixAPI/app/services/invoice_service.py b/hesabixAPI/app/services/invoice_service.py index c3b93bed..6c1a54e2 100755 --- a/hesabixAPI/app/services/invoice_service.py +++ b/hesabixAPI/app/services/invoice_service.py @@ -1350,7 +1350,7 @@ def get_financial_stock_bulk( return stock_dict -def _ensure_stock_sufficient( +def collect_stock_shortages( db: Session, business_id: int, document_date: date, @@ -1359,7 +1359,7 @@ def _ensure_stock_sufficient( *, exclude_invoice_source_document_id: Optional[int] = None, exclude_warehouse_document_ids: Optional[List[int]] = None, -) -> None: +) -> List[Dict[str, Any]]: # تجمیع نیاز خروجی به تفکیک کالا/انبار required: Dict[Tuple[int, Optional[int]], Decimal] = {} for ln in outgoing_lines: @@ -1391,7 +1391,17 @@ def _ensure_stock_sufficient( ): warehouse_labels[int(row.id)] = str(row.name or "") - # بررسی موجودی + shortages: List[Dict[str, Any]] = [] + mode_map: Dict[int, str] = {} + if pids: + mode_map = { + int(row.id): str(row.inventory_mode or "bulk") + for row in db.query(Product.id, Product.inventory_mode) + .filter(Product.business_id == business_id, Product.id.in_(pids)) + .all() + } + + # همهٔ کسری‌ها یک‌جا برگردانده می‌شوند تا کاربر مجبور به چند بار ذخیره نباشد. for (pid, wh_id), req in required.items(): avail = _compute_available_stock( db, @@ -1405,22 +1415,57 @@ def _ensure_stock_sufficient( ) if avail < req: pname, pcode = product_labels.get(pid, ("", "")) - if not pname: - pname = "کالا" - p_disp = f"«{pname}»" + (f" (کد {pcode})" if pcode else f" (شناسه {pid})") - if wh_id is not None: - wn = warehouse_labels.get(int(wh_id), "") - wh_disp = f"«{wn}»" if wn else f"انبار (شناسه {wh_id})" - else: - wh_disp = "—" - raise ApiError( - "INSUFFICIENT_STOCK", - f"موجودی کافی برای کالا {p_disp} در انبار {wh_disp} موجود نیست. " - f"موجودی قابل استفاده: {_format_quantity_for_user_message(avail)}، " - f"موردنیاز: {_format_quantity_for_user_message(req)}", - http_status=409, - details={"product_id": pid, "warehouse_id": wh_id}, + shortages.append( + { + "product_id": pid, + "product_name": pname or "کالا", + "product_code": pcode or None, + "warehouse_id": wh_id, + "warehouse_name": warehouse_labels.get(int(wh_id), "") if wh_id else None, + "inventory_mode": mode_map.get(pid, "bulk"), + "available_quantity": float(avail), + "requested_quantity": float(req), + "shortage_quantity": float(req - avail), + } ) + return shortages + + +def _ensure_stock_sufficient( + db: Session, + business_id: int, + document_date: date, + outgoing_lines: List[Dict[str, Any]], + exclude_document_id: Optional[int] = None, + *, + exclude_invoice_source_document_id: Optional[int] = None, + exclude_warehouse_document_ids: Optional[List[int]] = None, +) -> None: + shortages = collect_stock_shortages( + db, + business_id, + document_date, + outgoing_lines, + exclude_document_id, + exclude_invoice_source_document_id=exclude_invoice_source_document_id, + exclude_warehouse_document_ids=exclude_warehouse_document_ids, + ) + if not shortages: + return + first = shortages[0] + pname = first.get("product_name") or "کالا" + pcode = first.get("product_code") + p_disp = f"«{pname}»" + (f" (کد {pcode})" if pcode else f" (شناسه {first['product_id']})") + wh_name = first.get("warehouse_name") + wh_id = first.get("warehouse_id") + wh_disp = f"«{wh_name}»" if wh_name else (f"انبار (شناسه {wh_id})" if wh_id else "—") + suffix = f" و {len(shortages) - 1} کالای دیگر" if len(shortages) > 1 else "" + raise ApiError( + "INSUFFICIENT_STOCK", + f"موجودی کافی برای کالا {p_disp}{suffix} در انبار {wh_disp} موجود نیست.", + http_status=409, + details={"shortages": shortages}, + ) def _validate_outgoing_stock_before_invoice_commit( @@ -1525,11 +1570,8 @@ def _validate_outgoing_stock_before_invoice_commit( ) if not lines_to_check: return - _ensure_stock_sufficient( - db, - business_id, - document.document_date, - lines_to_check, + shortages = collect_stock_shortages( + db, business_id, document.document_date, lines_to_check, # InvoiceItemLineهای سند جاری پیش از این کنترل flush شده‌اند. اگر خود سند # حذف نشود، مقدار فروش یک‌بار در موجودی قابل‌استفاده کسر و بار دوم به‌عنوان # مقدار موردنیاز مقایسه می‌شود (مثلاً موجودی ۱ برای فروش ۱ رد می‌شود). @@ -1537,6 +1579,34 @@ def _validate_outgoing_stock_before_invoice_commit( exclude_invoice_source_document_id=None, exclude_warehouse_document_ids=None, ) + if not shortages: + return + + override = (document.extra_info or {}).get("_stock_override") + if isinstance(override, dict) and override.get("authorized") is True: + unique = [s for s in shortages if _product_inventory_mode_is_unique(s.get("inventory_mode"))] + if unique: + raise ApiError( + "UNIQUE_STOCK_OVERRIDE_FORBIDDEN", + "ثبت موجودی منفی برای کالای یونیک/سریالی مجاز نیست", + http_status=409, + details={"shortages": unique}, + ) + extra = dict(document.extra_info or {}) + override = dict(override) + override["shortages"] = shortages + extra["_stock_override"] = override + document.extra_info = _normalize_document_extra_info_for_storage(extra) + flag_modified(document, "extra_info") + db.flush() + return + + raise ApiError( + "INSUFFICIENT_STOCK", + "موجودی کافی برای یک یا چند کالای فاکتور وجود ندارد", + http_status=409, + details={"shortages": shortages}, + ) def _product_inventory_mode_is_unique(inventory_mode: Optional[str]) -> bool: @@ -3272,6 +3342,54 @@ def _iter_profit_movements_from_invoice_lines( "invoice_item_line_id": int(line.id), } ) + # کالای درآمدشده یک ورود واقعی و قیمت‌دار به انبار است و باید در لایه‌های + # FIFO/LIFO دیده شود. حواله‌های فاکتور و افتتاحیه عمداً اینجا اضافه نمی‌شوند + # چون حرکت متناظرشان از invoice_item_lines محاسبه شده است. + from adapters.db.models.warehouse_document import WarehouseDocument + from adapters.db.models.warehouse_document_line import WarehouseDocumentLine + + warehouse_rows = ( + db.query(WarehouseDocumentLine, WarehouseDocument) + .join( + WarehouseDocument, + WarehouseDocument.id == WarehouseDocumentLine.warehouse_document_id, + ) + .filter( + WarehouseDocument.business_id == int(business_id), + WarehouseDocument.status == "posted", + WarehouseDocument.source_type == "goods_expense_income", + WarehouseDocument.document_date <= as_of_date, + WarehouseDocumentLine.product_id == int(product_id), + WarehouseDocumentLine.movement == "in", + ) + .all() + ) + for line, doc in warehouse_rows: + if warehouse_id is not None and int(line.warehouse_id or 0) != int(warehouse_id): + continue + info = line.extra_info or {} + movements.append( + { + "document_id": -int(doc.id), + "document_date": doc.document_date, + "registered_at": doc.created_at, + "document_type": "goods_income", + "product_id": int(product_id), + "warehouse_id": int(line.warehouse_id) if line.warehouse_id else None, + "movement": "in", + "quantity": Decimal(str(line.quantity or 0)), + "cost_price": Decimal(str(info.get("unit_cost") or 0)), + "invoice_item_line_id": -int(line.id), + } + ) + movements.sort( + key=lambda mv: ( + mv.get("document_date"), + str(mv.get("registered_at") or ""), + int(mv.get("document_id") or 0), + int(mv.get("invoice_item_line_id") or 0), + ) + ) return movements @@ -5510,6 +5628,16 @@ def create_invoice( ) if created_wh_ids: db.flush() + if invoice_type == INVOICE_SALES: + from app.services.inventory_discrepancy_service import create_case_for_invoice + + create_case_for_invoice( + db, + business_id=business_id, + document=document, + user_id=user_id, + warehouse_document_ids=created_wh_ids, + ) except ApiError: raise except Exception as ex: diff --git a/hesabixAPI/app/services/quick_sales_service.py b/hesabixAPI/app/services/quick_sales_service.py index 16d8b7c6..0f722fa0 100755 --- a/hesabixAPI/app/services/quick_sales_service.py +++ b/hesabixAPI/app/services/quick_sales_service.py @@ -125,6 +125,7 @@ def get_quick_sales_settings(db: Session, business_id: int) -> Dict[str, Any]: "warehouse_document_type": "posted", "auto_post_warehouse": True, "show_inventory": True, + "enable_stock_shortage_override": False, "auto_create_payment_document": True, "show_purchase_price": False, **_share_settings_from_obj(None), @@ -149,6 +150,9 @@ def get_quick_sales_settings(db: Session, business_id: int) -> Dict[str, Any]: "warehouse_document_type": str(getattr(obj, 'warehouse_document_type', 'posted')), "auto_post_warehouse": bool(obj.auto_post_warehouse), "show_inventory": bool(obj.show_inventory), + "enable_stock_shortage_override": bool( + getattr(obj, "enable_stock_shortage_override", False) + ), "auto_create_payment_document": bool(obj.auto_create_payment_document), "show_purchase_price": bool(obj.show_purchase_price), **_share_settings_from_obj(obj), @@ -274,6 +278,11 @@ def update_quick_sales_settings(db: Session, business_id: int, payload: Dict[str if "show_inventory" in payload: obj.show_inventory = bool(payload.get("show_inventory")) + + if "enable_stock_shortage_override" in payload: + obj.enable_stock_shortage_override = bool( + payload.get("enable_stock_shortage_override") + ) if "auto_create_payment_document" in payload: obj.auto_create_payment_document = bool(payload.get("auto_create_payment_document")) @@ -418,4 +427,3 @@ def get_or_create_anonymous_customer( "مشتری ناشناس تعریف نشده است. لطفاً در تنظیمات فروش سریع یک مشتری پیش‌فرض انتخاب کنید.", http_status=400 ) - diff --git a/hesabixAPI/app/services/warehouse_service.py b/hesabixAPI/app/services/warehouse_service.py index eb6ffbc1..17ed5514 100755 --- a/hesabixAPI/app/services/warehouse_service.py +++ b/hesabixAPI/app/services/warehouse_service.py @@ -2863,6 +2863,17 @@ def post_warehouse_document( biz = db.query(Business).filter(Business.id == int(wh.business_id)).first() allow_bulk = bool(getattr(biz, "allow_negative_inventory_for_bulk", False)) if biz else False allow_unique = bool(getattr(biz, "allow_negative_inventory_for_unique", False)) if biz else False + # مجوز یک‌باره فقط از فاکتور مبدأِ تأییدشده توسط endpoint خوانده می‌شود. + # کالاهای یونیک همچنان سخت‌گیرانه کنترل می‌شوند. + if str(wh.source_type or "").strip().lower() == "invoice" and wh.source_document_id: + source_invoice = db.query(Document).filter( + Document.id == int(wh.source_document_id), + Document.business_id == int(wh.business_id), + ).first() + override = (source_invoice.extra_info or {}).get("_stock_override") if source_invoice else None + if isinstance(override, dict) and override.get("authorized") is True: + allow_bulk = True + allow_unique = False transfer_strict = bool(getattr(biz, "warehouse_transfer_require_positive_stock", True)) if biz else True lines_to_check = filter_outgoing_lines_for_stock_enforcement( db, diff --git a/hesabixAPI/migrations/versions/20261012_000001_quick_sales_stock_shortage.py b/hesabixAPI/migrations/versions/20261012_000001_quick_sales_stock_shortage.py new file mode 100644 index 00000000..5e565e63 --- /dev/null +++ b/hesabixAPI/migrations/versions/20261012_000001_quick_sales_stock_shortage.py @@ -0,0 +1,85 @@ +"""Quick-sales stock shortage override and discrepancy cases. + +Revision ID: 20261012_000001_quick_sales_stock_shortage +Revises: 20261003_000001_product_bundles +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "20261012_000001_quick_sales_stock_shortage" +down_revision = "20261003_000001_product_bundles" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "quick_sales_settings", + sa.Column( + "enable_stock_shortage_override", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.create_table( + "inventory_discrepancy_cases", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("business_id", sa.Integer(), nullable=False), + sa.Column("invoice_document_id", sa.Integer(), nullable=False), + sa.Column("warehouse_document_id", sa.Integer(), nullable=True), + sa.Column("resolution_document_id", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("source_type", sa.String(length=32), nullable=False), + sa.Column("request_token", sa.String(length=80), nullable=False), + sa.Column("reason_code", sa.String(length=48), nullable=False), + sa.Column("reason_note", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=False), + sa.Column("resolved_by_user_id", sa.Integer(), nullable=True), + sa.Column("resolution_type", sa.String(length=48), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("resolved_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["business_id"], ["businesses.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["invoice_document_id"], ["documents.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["warehouse_document_id"], ["warehouse_documents.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["resolution_document_id"], ["goods_expense_income_documents.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("business_id", "request_token", name="uq_inventory_discrepancy_request"), + ) + for column in ("business_id", "invoice_document_id", "warehouse_document_id", "resolution_document_id", "status"): + op.create_index(f"ix_inventory_discrepancy_cases_{column}", "inventory_discrepancy_cases", [column]) + op.create_table( + "inventory_discrepancy_lines", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("case_id", sa.Integer(), nullable=False), + sa.Column("product_id", sa.Integer(), nullable=False), + sa.Column("warehouse_id", sa.Integer(), nullable=False), + sa.Column("invoice_item_line_id", sa.Integer(), nullable=True), + sa.Column("available_quantity", sa.Numeric(18, 6), nullable=False), + sa.Column("requested_quantity", sa.Numeric(18, 6), nullable=False), + sa.Column("shortage_quantity", sa.Numeric(18, 6), nullable=False), + sa.Column("resolved_quantity", sa.Numeric(18, 6), nullable=False), + sa.Column("resolution_unit_cost", sa.Numeric(18, 6), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["case_id"], ["inventory_discrepancy_cases.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["warehouse_id"], ["warehouses.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["invoice_item_line_id"], ["invoice_item_lines.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + for column in ("case_id", "product_id", "warehouse_id"): + op.create_index(f"ix_inventory_discrepancy_lines_{column}", "inventory_discrepancy_lines", [column]) + + +def downgrade() -> None: + for column in ("warehouse_id", "product_id", "case_id"): + op.drop_index(f"ix_inventory_discrepancy_lines_{column}", table_name="inventory_discrepancy_lines") + op.drop_table("inventory_discrepancy_lines") + for column in ("status", "resolution_document_id", "warehouse_document_id", "invoice_document_id", "business_id"): + op.drop_index(f"ix_inventory_discrepancy_cases_{column}", table_name="inventory_discrepancy_cases") + op.drop_table("inventory_discrepancy_cases") + op.drop_column("quick_sales_settings", "enable_stock_shortage_override") diff --git a/hesabixAPI/tests/test_inventory_discrepancy_service.py b/hesabixAPI/tests/test_inventory_discrepancy_service.py new file mode 100644 index 00000000..8191b236 --- /dev/null +++ b/hesabixAPI/tests/test_inventory_discrepancy_service.py @@ -0,0 +1,74 @@ +from types import SimpleNamespace + +from app.services.inventory_discrepancy_service import ( + _correction_lines_by_case_line, + _serialize, +) + + +def test_serialize_includes_labels_needed_by_discrepancy_ui(): + invoice = SimpleNamespace(code="QS-42", document_date=SimpleNamespace(isoformat=lambda: "2026-10-12")) + product = SimpleNamespace(name="کالای نمونه", code="P-1", base_purchase_price=125_000) + warehouse = SimpleNamespace(name="انبار اصلی") + line = SimpleNamespace( + id=7, + product_id=11, + product=product, + warehouse_id=3, + warehouse=warehouse, + invoice_item_line_id=13, + available_quantity=0, + requested_quantity=2, + shortage_quantity=2, + resolved_quantity=0, + resolution_unit_cost=None, + ) + case = SimpleNamespace( + id=5, + business_id=1, + invoice_document_id=42, + invoice_document=invoice, + warehouse_document_id=9, + resolution_document_id=None, + status="open", + source_type="quick_sale", + reason_code="urgent_sale", + reason_note=None, + created_by_user_id=2, + resolved_by_user_id=None, + resolution_type=None, + created_at=None, + resolved_at=None, + lines=[line], + ) + + result = _serialize(case) + + assert result["invoice_code"] == "QS-42" + assert result["invoice_date"] == "2026-10-12" + assert result["lines"][0]["product_name"] == "کالای نمونه" + assert result["lines"][0]["product_code"] == "P-1" + assert result["lines"][0]["warehouse_name"] == "انبار اصلی" + assert result["lines"][0]["suggested_unit_cost"] == 125_000 + + +def test_correction_lines_are_matched_by_case_line_not_only_product(): + lines = [ + { + "product_id": 11, + "warehouse_id": 1, + "unit_cost": 100, + "extra_info": {"inventory_discrepancy_line_id": 7}, + }, + { + "product_id": 11, + "warehouse_id": 2, + "unit_cost": 120, + "extra_info": {"inventory_discrepancy_line_id": 8}, + }, + ] + + indexed = _correction_lines_by_case_line(lines) + + assert indexed[7]["warehouse_id"] == 1 + assert indexed[8]["warehouse_id"] == 2 diff --git a/hesabixAPI/tests/test_invoice_stock_prevalidation.py b/hesabixAPI/tests/test_invoice_stock_prevalidation.py index 0b06e00d..c18907f5 100644 --- a/hesabixAPI/tests/test_invoice_stock_prevalidation.py +++ b/hesabixAPI/tests/test_invoice_stock_prevalidation.py @@ -12,6 +12,7 @@ from app.services.invoice_service import ( INVOICE_SALES, _ensure_stock_sufficient, _validate_outgoing_stock_before_invoice_commit, + collect_stock_shortages, ) @@ -59,7 +60,7 @@ def test_precommit_validation_excludes_current_invoice_from_available_stock() -> "filter_outgoing_lines_for_stock_enforcement", side_effect=lambda _db, _business_id, outgoing_lines, **_kwargs: outgoing_lines, ), - patch.object(invoice_service, "_ensure_stock_sufficient") as ensure_stock, + patch.object(invoice_service, "collect_stock_shortages", return_value=[]) as collect, ): _validate_outgoing_stock_before_invoice_commit( db, @@ -70,8 +71,8 @@ def test_precommit_validation_excludes_current_invoice_from_available_stock() -> data=data, ) - ensure_stock.assert_called_once() - assert ensure_stock.call_args.kwargs["exclude_document_id"] == document.id + collect.assert_called_once() + assert collect.call_args.kwargs["exclude_document_id"] == document.id def test_stock_validation_accepts_quantity_equal_to_available_stock() -> None: @@ -81,6 +82,7 @@ def test_stock_validation_accepts_quantity_equal_to_available_stock() -> None: query.filter.return_value.all.side_effect = [ [SimpleNamespace(id=520098, name="کالای نمونه", code="520098")], [SimpleNamespace(id=9, name="انبار اصلی")], + [SimpleNamespace(id=520098, inventory_mode="bulk")], ] outgoing_lines = [ { @@ -102,3 +104,33 @@ def test_stock_validation_accepts_quantity_equal_to_available_stock() -> None: outgoing_lines=outgoing_lines, exclude_document_id=731, ) + + +def test_collect_stock_shortages_returns_all_short_products() -> None: + db = MagicMock() + query = db.query.return_value + query.filter.return_value.all.side_effect = [ + [ + SimpleNamespace(id=1, name="کالای اول", code="A"), + SimpleNamespace(id=2, name="کالای دوم", code="B"), + ], + [SimpleNamespace(id=9, name="انبار اصلی")], + [ + SimpleNamespace(id=1, inventory_mode="bulk"), + SimpleNamespace(id=2, inventory_mode="unique"), + ], + ] + lines = [ + {"product_id": 1, "quantity": 3, "extra_info": {"warehouse_id": 9}}, + {"product_id": 2, "quantity": 2, "extra_info": {"warehouse_id": 9}}, + ] + with patch.object( + invoice_service, + "_compute_available_stock", + side_effect=[Decimal("1"), Decimal("0")], + ): + shortages = collect_stock_shortages(db, 17, date(2026, 10, 3), lines) + + assert [row["product_id"] for row in shortages] == [1, 2] + assert [row["shortage_quantity"] for row in shortages] == [2.0, 2.0] + assert shortages[1]["inventory_mode"] == "unique" diff --git a/hesabixAPI/tests/test_quick_sales_service.py b/hesabixAPI/tests/test_quick_sales_service.py index 2e6bd87c..b33cca1e 100644 --- a/hesabixAPI/tests/test_quick_sales_service.py +++ b/hesabixAPI/tests/test_quick_sales_service.py @@ -79,6 +79,7 @@ def test_get_quick_sales_settings_includes_share_defaults_when_missing_row() -> assert result["default_share_channels"] == DEFAULT_SHARE_CHANNELS assert result["default_share_expiry_hours"] == 168 assert result["default_currency_id"] == 42 + assert result["enable_stock_shortage_override"] is False def test_invoice_share_link_event_type_in_seed_catalog() -> None: diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 29bd0dcd..f6bc76cd 100755 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -196,6 +196,7 @@ import 'pages/business/warehouse_locations_page.dart'; import 'pages/warehouse/warehouse_docs_page.dart'; import 'pages/warehouse/warehouse_document_details_page.dart'; import 'pages/warehouse/stock_count_page.dart'; +import 'pages/warehouse/quick_sales_stock_discrepancies_page.dart'; import 'pages/warehouse/goods_expense_income_list_page.dart'; import 'pages/business/installments_report_page.dart'; import 'pages/business/credit_settings_page.dart'; @@ -5448,6 +5449,21 @@ class _MyAppState extends State { ); }, ), + GoRoute( + path: 'quick-sales-stock-discrepancies', + pageBuilder: (context, state) { + final businessId = int.parse( + state.pathParameters['business_id']!, + ); + return hesabixNoTransitionPage( + state, + QuickSalesStockDiscrepanciesPage( + businessId: businessId, + calendarController: _calendarController!, + ), + ); + }, + ), GoRoute( path: 'goods-expense-income', pageBuilder: (context, state) { diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index ebb6360f..c291251f 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -1803,6 +1803,15 @@ class _BusinessShellState extends State { type: _MenuItemType.simple, hasAddButton: false, ), + _MenuItem( + key: 'quick-sales-stock-discrepancies', + label: 'مغایرت‌های موجودی فروش سریع', + icon: Icons.rule_folder_outlined, + selectedIcon: Icons.rule_folder, + path: _bu('quick-sales-stock-discrepancies'), + type: _MenuItemType.simple, + hasAddButton: false, + ), _MenuItem( key: 'goods-expense-income', label: 'کالای هزینه/درآمد شده', @@ -3917,6 +3926,7 @@ class _BusinessShellState extends State { // انبارگردانی (Stock Count) در نهایت به ایجاد/مدیریت حواله‌های تعدیل منجر می‌شود؛ // بنابراین در مدل دسترسی فعلی زیر مجموعه‌ی warehouse_transfers در نظر گرفته می‌شود. if (label == 'انبار گردانی' || label == 'انبارگردانی' || label == 'Stock Count') return 'warehouse_transfers'; + if (label == 'مغایرت‌های موجودی فروش سریع') return 'inventory'; if (label == 'کالای هزینه/درآمد شده' || label == 'کالای هزینه‌شده / کالای درآمدشده' || label == 'Goods Expense/Income') { diff --git a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart index bed9db80..096b99c2 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:dio/dio.dart' as dio; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; @@ -83,6 +84,8 @@ String? _quickSalesDisplayProductBusinessCode(Map p) { /// نوع عملیات ثبت در حال اجرا (برای لودینگ دقیق روی دکمه‌ها). enum _QuickSalesSaveAction { none, save, saveAndPrint } +enum _StockShortageAction { cancel, park, correct, override } + class QuickSalesPage extends StatefulWidget { final int businessId; final AuthStore authStore; @@ -179,6 +182,7 @@ class _QuickSalesPageState extends State bool _enableWarehouseDocument = true; String _warehouseDocumentType = 'posted'; // 'draft' or 'posted' bool _showInventory = true; + bool _enableStockShortageOverride = false; bool _showPurchasePrice = false; int? _printTemplateId; String? _printPaperSize = '80mm'; @@ -492,6 +496,8 @@ class _QuickSalesPageState extends State _warehouseDocumentType = settings['warehouse_document_type'] ?? 'posted'; _showInventory = settings['show_inventory'] ?? true; + _enableStockShortageOverride = + settings['enable_stock_shortage_override'] ?? false; _showPurchasePrice = (settings['show_purchase_price'] ?? false) && widget.authStore.canViewPurchasePrice(); @@ -2254,7 +2260,217 @@ class _QuickSalesPageState extends State _cartItems.fold(0, (sum, item) => sum + item.total); } - Future _saveInvoice({bool print = false}) async { + List> _stockShortagesFromError(Object error) { + if (error is! dio.DioException) return const []; + final raw = error.response?.data; + if (raw is! Map) return const []; + final data = Map.from(raw); + final errorBody = data['error']; + if (errorBody is! Map || errorBody['code'] != 'INSUFFICIENT_STOCK') { + return const []; + } + final details = errorBody['details']; + if (details is! Map || details['shortages'] is! List) return const []; + return (details['shortages'] as List) + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + + Future<_StockShortageAction?> _showStockShortageDialog( + List> shortages, + ) { + final canOverride = + _enableStockShortageOverride && + widget.authStore.hasBusinessPermission( + 'inventory', + 'negative_stock_override', + ); + final canCorrect = + _enableStockShortageOverride && + widget.authStore.hasBusinessPermission('goods_expense_income', 'add') && + widget.authStore.hasBusinessPermission('goods_expense_income', 'post'); + return showDialog<_StockShortageAction>( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: const Text('کسری موجودی'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'موجودی سیستمی کالاهای زیر برای ثبت فاکتور کافی نیست:', + ), + const SizedBox(height: 12), + ...shortages.map((item) { + final name = item['product_name'] ?? 'کالا'; + final warehouse = item['warehouse_name']; + final available = item['available_quantity'] ?? 0; + final requested = item['requested_quantity'] ?? 0; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(name.toString()), + subtitle: Text( + '${warehouse == null ? '' : '$warehouse — '}موجودی: $available، موردنیاز: $requested', + ), + ); + }), + if (!_enableStockShortageOverride) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'گردش‌کار کسری موجودی در تنظیمات فروش سریع فعال نیست.', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(dialogContext, _StockShortageAction.cancel), + child: const Text('انصراف'), + ), + TextButton( + onPressed: () => + Navigator.pop(dialogContext, _StockShortageAction.park), + child: const Text('پارک فروش'), + ), + if (canCorrect) + OutlinedButton( + onPressed: () => + Navigator.pop(dialogContext, _StockShortageAction.correct), + child: const Text('اصلاح موجودی'), + ), + if (canOverride) + FilledButton( + onPressed: () => + Navigator.pop(dialogContext, _StockShortageAction.override), + child: const Text('ثبت با موجودی منفی'), + ), + ], + ), + ); + } + + Future?> _askStockOverrideReason() async { + var reason = 'physical_stock_exists'; + final note = TextEditingController(); + final result = await showDialog>( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('ثبت یک‌باره با موجودی منفی'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + value: reason, + decoration: const InputDecoration(labelText: 'علت'), + items: const [ + DropdownMenuItem( + value: 'physical_stock_exists', + child: Text('کالا به‌صورت فیزیکی موجود است'), + ), + DropdownMenuItem( + value: 'urgent_sale', + child: Text('فروش فوری؛ اصلاح بعداً انجام می‌شود'), + ), + DropdownMenuItem(value: 'other', child: Text('سایر')), + ], + onChanged: (value) => setDialogState( + () => reason = value ?? 'physical_stock_exists', + ), + ), + const SizedBox(height: 12), + TextField( + controller: note, + decoration: InputDecoration( + labelText: reason == 'other' + ? 'توضیح (الزامی)' + : 'توضیح (اختیاری)', + ), + maxLines: 2, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: () { + if (reason == 'other' && note.text.trim().isEmpty) return; + Navigator.pop(dialogContext, { + 'reason_code': reason, + 'reason_note': note.text.trim(), + 'request_token': + 'qs-${widget.businessId}-${DateTime.now().microsecondsSinceEpoch}', + }); + }, + child: const Text('تأیید و ثبت'), + ), + ], + ), + ), + ); + note.dispose(); + return result; + } + + Future _correctShortagesBeforeSale( + List> shortages, + ) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('اصلاح موجودی'), + content: const Text( + 'این گزینه «کالای درآمدشده» را با تاریخ امروز ثبت می‌کند. فقط وقتی ادامه دهید که کالا واقعاً در انبار موجود است و خرید، انتقال یا موجودی اول دوره‌ای از قلم نیفتاده باشد.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('تأیید موجودی فیزیکی'), + ), + ], + ), + ); + if (confirmed != true) return false; + final today = DateTime.now().toIso8601String().split('T').first; + await _quickSalesService.createStockCorrection( + businessId: widget.businessId, + payload: { + 'document_date': today, + 'confirm_unexplained_physical_surplus': true, + 'description': 'اصلاح کسری کشف‌شده هنگام فروش سریع', + 'lines': shortages + .map( + (s) => { + 'product_id': s['product_id'], + 'warehouse_id': s['warehouse_id'], + 'quantity': s['shortage_quantity'], + }, + ) + .toList(), + }, + ); + return true; + } + + Future _saveInvoice({ + bool print = false, + Map? stockOverride, + }) async { // اعتبارسنجی سبد خرید if (_cartItems.isEmpty) { SnackBarHelper.show(context, message: 'سبد خرید خالی است', isError: true); @@ -2343,6 +2559,9 @@ class _QuickSalesPageState extends State final savedShareAmount = _remainingAmount > 0 ? _remainingAmount : _totalAmount; + Map? retryOverride; + var retryAfterCorrection = false; + var parkAfterFailure = false; try { // ساخت payload @@ -2448,6 +2667,7 @@ class _QuickSalesPageState extends State 'lines': lines, if (payments.isNotEmpty) 'payments': payments, if (desc.isNotEmpty) 'description': desc, + if (stockOverride != null) 'stock_override': stockOverride, }; final result = await _invoiceService.createInvoice( @@ -2500,16 +2720,47 @@ class _QuickSalesPageState extends State } } catch (e) { if (mounted) { - String errorMessage = 'خطا در ثبت فاکتور'; - if (e.toString().contains('INSUFFICIENT_STOCK')) { - errorMessage = 'موجودی کافی برای برخی محصولات وجود ندارد'; - } else if (e.toString().contains('CURRENCY')) { - errorMessage = 'خطا در تنظیمات ارز'; + final shortages = _stockShortagesFromError(e); + if (shortages.isNotEmpty && stockOverride == null) { + final action = await _showStockShortageDialog(shortages); + if (!mounted) return; + if (action == _StockShortageAction.override) { + retryOverride = await _askStockOverrideReason(); + if (!mounted) return; + } else if (action == _StockShortageAction.correct) { + try { + retryAfterCorrection = await _correctShortagesBeforeSale( + shortages, + ); + } catch (correctionError) { + if (!mounted) return; + SnackBarHelper.show( + context, + message: + 'اصلاح موجودی انجام نشد: ${ErrorExtractor.forContext(correctionError, context)}', + isError: true, + ); + } + } else if (action == _StockShortageAction.park) { + parkAfterFailure = true; + } + if (action != null && action != _StockShortageAction.cancel) { + // عملیات انتخاب‌شده پس از آزاد شدن وضعیت ذخیره اجرا می‌شود. + } else { + return; + } } else { - errorMessage = - 'خطا در ثبت فاکتور: ${ErrorExtractor.forContext(e, context)}'; + String errorMessage = 'خطا در ثبت فاکتور'; + if (e.toString().contains('INSUFFICIENT_STOCK')) { + errorMessage = 'موجودی کافی برای برخی محصولات وجود ندارد'; + } else if (e.toString().contains('CURRENCY')) { + errorMessage = 'خطا در تنظیمات ارز'; + } else { + errorMessage = + 'خطا در ثبت فاکتور: ${ErrorExtractor.forContext(e, context)}'; + } + SnackBarHelper.show(context, message: errorMessage, isError: true); } - SnackBarHelper.show(context, message: errorMessage, isError: true); } } finally { if (mounted && _isSaving) { @@ -2518,6 +2769,12 @@ class _QuickSalesPageState extends State }); } } + if (!mounted) return; + if (parkAfterFailure) { + _startNewSale(); + } else if (retryAfterCorrection || retryOverride != null) { + await _saveInvoice(print: print, stockOverride: retryOverride); + } } Future _runPostSaveTasks({ @@ -2635,10 +2892,12 @@ class _QuickSalesPageState extends State if (stock != null && stock <= item.quantity) { SnackBarHelper.show( context, - message: 'موجودی کافی نیست. موجودی: ${_formatNumber(stock)}', + message: _enableStockShortageOverride + ? 'هشدار کسری موجودی؛ هنگام ثبت درباره نحوه ادامه تصمیم می‌گیرید. موجودی: ${_formatNumber(stock)}' + : 'موجودی کافی نیست. موجودی: ${_formatNumber(stock)}', isError: true, ); - return; + if (!_enableStockShortageOverride) return; } } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_settings_page.dart index fccb0f1e..f61eef8c 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_settings_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_settings_page.dart @@ -57,6 +57,7 @@ class _QuickSalesSettingsPageState extends State { String _warehouseDocumentType = 'posted'; // 'draft' or 'posted' bool _autoPostWarehouse = true; // برای سازگاری با گذشته bool _showInventory = true; + bool _enableStockShortageOverride = false; bool _autoCreatePaymentDocument = true; bool _showPurchasePrice = false; @@ -106,6 +107,7 @@ class _QuickSalesSettingsPageState extends State { _warehouseDocumentType = settings['warehouse_document_type'] ?? 'posted'; _autoPostWarehouse = settings['auto_post_warehouse'] ?? true; _showInventory = settings['show_inventory'] ?? true; + _enableStockShortageOverride = settings['enable_stock_shortage_override'] ?? false; _autoCreatePaymentDocument = settings['auto_create_payment_document'] ?? true; _showPurchasePrice = settings['show_purchase_price'] ?? false; _defaultShareOnlinePayment = settings['default_share_online_payment'] ?? true; @@ -249,6 +251,7 @@ class _QuickSalesSettingsPageState extends State { 'warehouse_document_type': _warehouseDocumentType, 'auto_post_warehouse': _autoPostWarehouse, 'show_inventory': _showInventory, + 'enable_stock_shortage_override': _enableStockShortageOverride, 'auto_create_payment_document': _autoCreatePaymentDocument, 'show_purchase_price': _showPurchasePrice, 'default_share_online_payment': _defaultShareOnlinePayment, @@ -551,6 +554,15 @@ class _QuickSalesSettingsPageState extends State { }); }, ), + const SizedBox(height: 8), + SwitchListTile( + title: const Text('گردش‌کار کسری موجودی در فروش سریع'), + subtitle: const Text('هنگام کسری، امکان اصلاح موجودی یا ثبت یک‌باره فروش با موجودی منفی را به کاربران مجاز نشان می‌دهد'), + value: _enableStockShortageOverride, + onChanged: _enableWarehouseDocument && _warehouseDocumentType == 'posted' + ? (v) => setState(() => _enableStockShortageOverride = v) + : null, + ), ], ), const SizedBox(height: 24), @@ -791,4 +803,3 @@ class _QuickSalesSettingsPageState extends State { ); } } - diff --git a/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart index 84a02f53..5143a656 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart @@ -1209,6 +1209,10 @@ class _PermissionsDialogState extends State<_PermissionsDialog> { 'edit': '${t.edit} ${t.warehouses}', 'delete': '${t.delete} ${t.warehouses}', }, + 'inventory': { + 'view': 'مشاهده موجودی و مغایرت‌ها', + 'negative_stock_override': 'ثبت یک‌باره فروش سریع با موجودی منفی', + }, 'warehouse_transfers': { 'add': '${t.add} ${t.warehouseTransfers}', 'view': '${t.view} ${t.warehouseTransfers}', @@ -1846,7 +1850,7 @@ class _PermissionsDialogState extends State<_PermissionsDialog> { { 'title': t.warehouseManagement, 'icon': Icons.warehouse, - 'sections': ['warehouses', 'warehouse_transfers', 'goods_expense_income'], + 'sections': ['warehouses', 'inventory', 'warehouse_transfers', 'goods_expense_income'], 'permissionHintKey': 'warehouse_inventory_bridge', }, { @@ -2028,6 +2032,8 @@ class _PermissionsDialogState extends State<_PermissionsDialog> { return t.openingBalance; case 'warehouses': return t.warehouses; + case 'inventory': + return 'موجودی و مغایرت‌ها'; case 'warehouse_transfers': return t.warehouseTransfers; case 'goods_expense_income': diff --git a/hesabixUI/hesabix_ui/lib/pages/warehouse/quick_sales_stock_discrepancies_page.dart b/hesabixUI/hesabix_ui/lib/pages/warehouse/quick_sales_stock_discrepancies_page.dart new file mode 100644 index 00000000..22ebdda9 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/warehouse/quick_sales_stock_discrepancies_page.dart @@ -0,0 +1,635 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/calendar_controller.dart'; +import '../../core/date_utils.dart'; +import '../../core/api_client.dart'; +import '../../services/goods_expense_income_service.dart'; +import '../../services/quick_sales_service.dart'; +import '../../utils/error_extractor.dart'; +import '../../utils/number_formatters.dart' show formatWithThousands; +import '../../utils/snackbar_helper.dart'; +import '../../widgets/date_input_field.dart'; + +class QuickSalesStockDiscrepanciesPage extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + + const QuickSalesStockDiscrepanciesPage({ + super.key, + required this.businessId, + required this.calendarController, + }); + + @override + State createState() => + _QuickSalesStockDiscrepanciesPageState(); +} + +class _QuickSalesStockDiscrepanciesPageState + extends State { + final QuickSalesService _service = QuickSalesService(); + final GoodsExpenseIncomeService _goodsService = GoodsExpenseIncomeService(); + List> _cases = const []; + String _status = 'open'; + bool _loading = false; + bool _allowManualUnitCost = false; + + bool get _canResolve { + final auth = ApiClient.getAuthStore(); + return auth?.hasBusinessPermission('goods_expense_income', 'add') == true && + auth?.hasBusinessPermission('goods_expense_income', 'post') == true; + } + + @override + void initState() { + super.initState(); + _load(); + _loadCorrectionSettings(); + } + + Future _loadCorrectionSettings() async { + if (!_canResolve) return; + try { + final settings = await _goodsService.getSettings( + businessId: widget.businessId, + ); + if (!mounted) return; + setState(() { + _allowManualUnitCost = + settings['allow_manual_unit_cost'] == true && + (ApiClient.getAuthStore()?.hasBusinessPermission( + 'goods_expense_income', + 'change_unit_cost', + ) == + true); + }); + } catch (_) { + // بهای پایه کالا همچنان قابل استفاده است؛ فقط ویرایش دستی غیرفعال می‌ماند. + } + } + + Future _load() async { + setState(() => _loading = true); + try { + final result = await _service.listStockDiscrepancies( + businessId: widget.businessId, + status: _status == 'all' ? null : _status, + ); + final items = (result['items'] as List? ?? const []) + .whereType() + .map((item) => Map.from(item)) + .toList(); + if (!mounted) return; + setState(() => _cases = items); + } catch (error) { + if (!mounted) return; + SnackBarHelper.showError( + context, + message: + 'خطا در دریافت مغایرت‌ها: ${ErrorExtractor.forContext(error, context)}', + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _resolve(Map discrepancy) async { + final payload = await showDialog>( + context: context, + barrierDismissible: false, + builder: (context) => _ResolveDiscrepancyDialog( + discrepancy: discrepancy, + calendarController: widget.calendarController, + allowManualUnitCost: _allowManualUnitCost, + ), + ); + if (payload == null || !mounted) return; + + final caseId = (discrepancy['id'] as num?)?.toInt(); + if (caseId == null) return; + setState(() => _loading = true); + try { + await _service.resolveStockDiscrepancy( + businessId: widget.businessId, + caseId: caseId, + payload: payload, + ); + if (!mounted) return; + SnackBarHelper.showSuccess( + context, + message: 'مغایرت رفع و سند کالای درآمدشده قطعی شد', + ); + await _load(); + } catch (error) { + if (!mounted) return; + SnackBarHelper.showError( + context, + message: + 'اصلاح موجودی انجام نشد: ${ErrorExtractor.forContext(error, context)}', + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + String _reasonLabel(String? code) { + switch (code) { + case 'physical_stock_exists': + return 'کالا به‌صورت فیزیکی موجود بوده است'; + case 'urgent_sale': + return 'فروش فوری؛ اصلاح بعدی'; + case 'other': + return 'سایر'; + default: + return code ?? '-'; + } + } + + String _dateTime(dynamic value) { + final parsed = value == null ? null : DateTime.tryParse(value.toString()); + return HesabixDateUtils.formatDateTime( + parsed, + widget.calendarController.isJalali, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('مغایرت‌های موجودی فروش سریع'), + actions: [ + IconButton( + tooltip: 'بازخوانی', + onPressed: _loading ? null : _load, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + SizedBox( + width: 210, + child: DropdownButtonFormField( + initialValue: _status, + decoration: const InputDecoration( + labelText: 'وضعیت پرونده', + isDense: true, + ), + items: const [ + DropdownMenuItem(value: 'open', child: Text('باز')), + DropdownMenuItem( + value: 'resolved', + child: Text('رفع‌شده'), + ), + DropdownMenuItem(value: 'all', child: Text('همه')), + ], + onChanged: _loading + ? null + : (value) { + if (value == null || value == _status) return; + setState(() => _status = value); + _load(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'فروش‌هایی که با مجوز یک‌باره موجودی منفی ثبت شده‌اند در این بخش رسیدگی می‌شوند.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + if (_loading) const LinearProgressIndicator(minHeight: 2), + Expanded( + child: _cases.isEmpty && !_loading + ? _EmptyState(status: _status) + : RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: _cases.length, + separatorBuilder: (_, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) => + _buildCaseCard(_cases[index]), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCaseCard(Map item) { + final isOpen = item['status'] == 'open'; + final lines = (item['lines'] as List? ?? const []) + .whereType() + .map((line) => Map.from(line)) + .toList(); + final invoiceCode = item['invoice_code']?.toString(); + final invoiceId = item['invoice_document_id']; + final note = item['reason_note']?.toString().trim() ?? ''; + + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + 'پرونده #${item['id']}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Chip( + avatar: Icon( + isOpen + ? Icons.warning_amber_rounded + : Icons.check_circle_outline, + size: 18, + ), + label: Text(isOpen ? 'باز' : 'رفع‌شده'), + side: BorderSide.none, + ), + Text('فاکتور: ${invoiceCode ?? '#$invoiceId'}'), + Text('ثبت: ${_dateTime(item['created_at'])}'), + if (!isOpen && item['resolved_at'] != null) + Text('رفع: ${_dateTime(item['resolved_at'])}'), + ], + ), + const SizedBox(height: 6), + Text('علت: ${_reasonLabel(item['reason_code']?.toString())}'), + if (note.isNotEmpty) ...[ + const SizedBox(height: 4), + Text('توضیح: $note'), + ], + const SizedBox(height: 12), + _DiscrepancyLinesTable(lines: lines), + if (!isOpen && item['resolution_document_id'] != null) ...[ + const SizedBox(height: 8), + Text( + 'سند اصلاح: #${item['resolution_document_id']}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (isOpen && _canResolve) ...[ + const SizedBox(height: 12), + Align( + alignment: AlignmentDirectional.centerEnd, + child: FilledButton.icon( + onPressed: _loading ? null : () => _resolve(item), + icon: const Icon(Icons.inventory_2_outlined), + label: const Text('رفع مغایرت'), + ), + ), + ] else if (isOpen) ...[ + const SizedBox(height: 12), + Text( + 'برای رفع مغایرت، مجوزهای ایجاد و قطعی‌کردن کالای درآمدشده لازم است.', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ), + ), + ); + } +} + +class _DiscrepancyLinesTable extends StatelessWidget { + final List> lines; + + const _DiscrepancyLinesTable({required this.lines}); + + num _number(Map line, String key) => + line[key] is num ? line[key] as num : num.tryParse('${line[key]}') ?? 0; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columnSpacing: 24, + headingRowHeight: 40, + dataRowMinHeight: 42, + dataRowMaxHeight: 56, + columns: const [ + DataColumn(label: Text('کالا')), + DataColumn(label: Text('انبار')), + DataColumn(label: Text('موجودی زمان فروش'), numeric: true), + DataColumn(label: Text('مقدار فروش'), numeric: true), + DataColumn(label: Text('کسری'), numeric: true), + DataColumn(label: Text('اصلاح‌شده'), numeric: true), + ], + rows: lines.map((line) { + final productName = + line['product_name'] ?? 'کالای #${line['product_id']}'; + final productCode = line['product_code']?.toString(); + return DataRow( + cells: [ + DataCell( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(productName.toString()), + if (productCode != null && productCode.isNotEmpty) + Text( + productCode, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + DataCell( + Text('${line['warehouse_name'] ?? '#${line['warehouse_id']}'}'), + ), + DataCell( + Text(formatWithThousands(_number(line, 'available_quantity'))), + ), + DataCell( + Text(formatWithThousands(_number(line, 'requested_quantity'))), + ), + DataCell( + Text(formatWithThousands(_number(line, 'shortage_quantity'))), + ), + DataCell( + Text(formatWithThousands(_number(line, 'resolved_quantity'))), + ), + ], + ); + }).toList(), + ), + ); + } +} + +class _ResolveDiscrepancyDialog extends StatefulWidget { + final Map discrepancy; + final CalendarController calendarController; + final bool allowManualUnitCost; + + const _ResolveDiscrepancyDialog({ + required this.discrepancy, + required this.calendarController, + required this.allowManualUnitCost, + }); + + @override + State<_ResolveDiscrepancyDialog> createState() => + _ResolveDiscrepancyDialogState(); +} + +class _ResolveDiscrepancyDialogState extends State<_ResolveDiscrepancyDialog> { + late final List> _lines; + late final List _costControllers; + DateTime? _documentDate = DateTime.now(); + bool _confirmed = false; + + @override + void initState() { + super.initState(); + _lines = (widget.discrepancy['lines'] as List? ?? const []) + .whereType() + .map((line) => Map.from(line)) + .toList(); + _costControllers = List.generate( + _lines.length, + (_) => TextEditingController(), + ); + } + + @override + void dispose() { + for (final controller in _costControllers) { + controller.dispose(); + } + super.dispose(); + } + + double _number(dynamic value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString() ?? '') ?? 0; + } + + double? _parseCost(String value) { + var normalized = value + .trim() + .replaceAll(',', '') + .replaceAll('٬', '') + .replaceAll(' ', ''); + const persianDigits = '۰۱۲۳۴۵۶۷۸۹'; + const arabicDigits = '٠١٢٣٤٥٦٧٨٩'; + for (var i = 0; i < 10; i++) { + normalized = normalized + .replaceAll(persianDigits[i], '$i') + .replaceAll(arabicDigits[i], '$i'); + } + if (normalized.isEmpty) return null; + return double.tryParse(normalized); + } + + void _submit() { + if (_documentDate == null || !_confirmed) return; + final requestLines = >[]; + for (var index = 0; index < _lines.length; index++) { + final line = _lines[index]; + final rawCost = _costControllers[index].text.trim(); + final unitCost = _parseCost(rawCost); + if (rawCost.isNotEmpty && (unitCost == null || unitCost < 0)) { + SnackBarHelper.showError( + context, + message: 'بهای واحد سطر ${index + 1} معتبر نیست', + ); + return; + } + final remaining = + _number(line['shortage_quantity']) - + _number(line['resolved_quantity']); + final requestLine = { + 'case_line_id': line['id'], + 'quantity': remaining, + }; + if (unitCost != null) requestLine['unit_cost'] = unitCost; + requestLines.add(requestLine); + } + + Navigator.of(context).pop({ + 'document_date': HesabixDateUtils.formatForApiDate(_documentDate!), + 'lines': requestLines, + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('رفع مغایرت پرونده #${widget.discrepancy['id']}'), + content: SizedBox( + width: 700, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'این عملیات به‌اندازه تمام کسری، سند قطعی «کالای درآمدشده» می‌سازد، موجودی را اصلاح می‌کند و بهای تمام‌شده فاکتور را بازشناسی می‌کند.', + ), + const SizedBox(height: 8), + Text( + 'تاریخ اصلاح باید روز واقعی کشف و ثبت اصلاح باشد. اگر خرید، انتقال یا موجودی ابتدای دوره ثبت نشده، ابتدا همان سند اصلی را ثبت کنید.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + SizedBox( + width: 260, + child: DateInputField( + value: _documentDate, + onChanged: (value) => setState(() => _documentDate = value), + labelText: 'تاریخ اصلاح', + calendarController: widget.calendarController, + isDense: true, + ), + ), + const SizedBox(height: 16), + ...List.generate(_lines.length, (index) { + final line = _lines[index]; + final remaining = + _number(line['shortage_quantity']) - + _number(line['resolved_quantity']); + final suggested = _number(line['suggested_unit_cost']); + return Card.outlined( + margin: const EdgeInsets.only(bottom: 10), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${line['product_name'] ?? 'کالای #${line['product_id']}'}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'انبار: ${line['warehouse_name'] ?? '#${line['warehouse_id']}'} — مقدار اصلاح: ${formatWithThousands(remaining)}', + ), + ], + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 220, + child: TextField( + controller: _costControllers[index], + enabled: widget.allowManualUnitCost, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'[0-9۰-۹٠-٩.,٬]'), + ), + ], + decoration: InputDecoration( + labelText: widget.allowManualUnitCost + ? 'بهای واحد (اختیاری)' + : 'بهای واحد پایه', + hintText: suggested > 0 + ? formatWithThousands(suggested) + : 'قیمت خرید پایه', + helperText: widget.allowManualUnitCost + ? 'خالی = بهای پایه کالا' + : 'از قیمت خرید پایه کالا خوانده می‌شود', + isDense: true, + ), + ), + ), + ], + ), + ), + ); + }), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: _confirmed, + onChanged: (value) => + setState(() => _confirmed = value == true), + title: const Text( + 'تأیید می‌کنم این موجودی فیزیکی، مازادِ ثبت‌نشده با منشأ نامشخص است.', + ), + subtitle: const Text( + 'این گزینه جایگزین ثبت سند خرید، انتقال یا موجودی اول دوره نیست.', + ), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: _confirmed && _documentDate != null ? _submit : null, + child: const Text('ثبت اصلاح و بستن پرونده'), + ), + ], + ); + } +} + +class _EmptyState extends StatelessWidget { + final String status; + + const _EmptyState({required this.status}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + status == 'open' ? Icons.inventory_2_outlined : Icons.search_off, + size: 56, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(height: 12), + Text( + status == 'open' + ? 'پرونده مغایرت بازی وجود ندارد' + : 'موردی برای نمایش پیدا نشد', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/services/quick_sales_service.dart b/hesabixUI/hesabix_ui/lib/services/quick_sales_service.dart index 74705ce4..ed5f2011 100755 --- a/hesabixUI/hesabix_ui/lib/services/quick_sales_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/quick_sales_service.dart @@ -6,9 +6,7 @@ class QuickSalesService { QuickSalesService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient(); /// دریافت تنظیمات فروش سریع - Future> getSettings({ - required int businessId, - }) async { + Future> getSettings({required int businessId}) async { final res = await _api.get>( '/api/v1/businesses/$businessId/quick-sales/settings', ); @@ -36,5 +34,38 @@ class QuickSalesService { ); return Map.from(res.data?['data'] ?? const {}); } -} + Future> createStockCorrection({ + required int businessId, + required Map payload, + }) async { + final res = await _api.post>( + '/api/v1/businesses/$businessId/quick-sales/stock-corrections', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> listStockDiscrepancies({ + required int businessId, + String? status, + }) async { + final res = await _api.get>( + '/api/v1/businesses/$businessId/quick-sales/stock-discrepancies', + query: status == null ? null : {'status': status}, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> resolveStockDiscrepancy({ + required int businessId, + required int caseId, + required Map payload, + }) async { + final res = await _api.post>( + '/api/v1/businesses/$businessId/quick-sales/stock-discrepancies/$caseId/resolve', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } +}