From 05c625c6c374778c173941db9844943330e059c6 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sat, 3 Oct 2026 17:11:35 +0330 Subject: [PATCH] fix(invoices): allow sales at exact stock --- hesabixAPI/app/services/invoice_service.py | 5 +- .../PRODUCT_EXCEL_IMPORT_WAREHOUSE_SYNC.md | 4 + .../QUICK_SALES_EXACT_STOCK_VALIDATION.md | 74 +++++++++++++ .../tests/test_invoice_stock_prevalidation.py | 104 ++++++++++++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 hesabixAPI/docs/QUICK_SALES_EXACT_STOCK_VALIDATION.md create mode 100644 hesabixAPI/tests/test_invoice_stock_prevalidation.py diff --git a/hesabixAPI/app/services/invoice_service.py b/hesabixAPI/app/services/invoice_service.py index 69e41f4e..0ef5a511 100755 --- a/hesabixAPI/app/services/invoice_service.py +++ b/hesabixAPI/app/services/invoice_service.py @@ -1530,7 +1530,10 @@ def _validate_outgoing_stock_before_invoice_commit( business_id, document.document_date, lines_to_check, - exclude_document_id=None, + # InvoiceItemLineهای سند جاری پیش از این کنترل flush شده‌اند. اگر خود سند + # حذف نشود، مقدار فروش یک‌بار در موجودی قابل‌استفاده کسر و بار دوم به‌عنوان + # مقدار موردنیاز مقایسه می‌شود (مثلاً موجودی ۱ برای فروش ۱ رد می‌شود). + exclude_document_id=int(document.id), exclude_invoice_source_document_id=None, exclude_warehouse_document_ids=None, ) diff --git a/hesabixAPI/docs/PRODUCT_EXCEL_IMPORT_WAREHOUSE_SYNC.md b/hesabixAPI/docs/PRODUCT_EXCEL_IMPORT_WAREHOUSE_SYNC.md index aa1fd3a2..6f2dc202 100644 --- a/hesabixAPI/docs/PRODUCT_EXCEL_IMPORT_WAREHOUSE_SYNC.md +++ b/hesabixAPI/docs/PRODUCT_EXCEL_IMPORT_WAREHOUSE_SYNC.md @@ -1,5 +1,9 @@ # همگام‌سازی موجودی افتتاحیه با اسناد انبار در ایمپورت کالا +> برای نحوهٔ مصرف موجودی افتتاحیه در فروش سریع و کنترل حالت موجودی دقیقاً برابر +> با مقدار فروش، سند +> [کنترل موجودی دقیق در فروش سریع](QUICK_SALES_EXACT_STOCK_VALIDATION.md) را ببینید. + ## هدف ردیف دارای «تعداد اولیه» در ایمپورت Excel دو اثر هماهنگ دارد: diff --git a/hesabixAPI/docs/QUICK_SALES_EXACT_STOCK_VALIDATION.md b/hesabixAPI/docs/QUICK_SALES_EXACT_STOCK_VALIDATION.md new file mode 100644 index 00000000..64d3e019 --- /dev/null +++ b/hesabixAPI/docs/QUICK_SALES_EXACT_STOCK_VALIDATION.md @@ -0,0 +1,74 @@ +# کنترل موجودی دقیق در فروش سریع + +## مسئله + +در فروش سریع، رابط کاربری موجودی فیزیکی انبار را می‌خواند و فقط وقتی مقدار +درخواستی از موجودی بیشتر باشد خط را نامعتبر نمایش می‌دهد. بنابراین فروش یک عدد +از کالایی با موجودی یک عدد باید مجاز باشد. + +پیش از این، سمت API ردیف‌های فاکتور را در `invoice_item_lines` ذخیره و flush +می‌کرد و سپس کنترل اولیه موجودی را اجرا می‌کرد. محاسبه موجودی، ردیف خروجی همان +فاکتور را نیز می‌دید؛ در نتیجه مقدار جاری یک‌بار از موجودی کسر و دوباره به‌عنوان +نیاز فاکتور مقایسه می‌شد. + +اگر موجودی پیش از فروش `S` و مقدار فروش `Q` باشد، کنترل معیوب عملاً این شرط را +اعمال می‌کرد: + +```text +S - Q >= Q +``` + +یعنی به‌جای `S >= Q`، موجودی باید حداقل دو برابر فروش می‌بود. نمونهٔ آشکار آن +ردشدن فروش یک عدد از کالایی با موجودی دقیقاً یک عدد بود. + +## رفتار اصلاح‌شده + +کنترل اولیهٔ `_validate_outgoing_stock_before_invoice_commit` اکنون شناسه سند +جاری را به `_ensure_stock_sufficient` می‌دهد. `_iter_product_movements` با +`exclude_document_id` فقط `InvoiceItemLine` و `DocumentLine`های همان سند را از +موجودی تاریخی حذف می‌کند. سپس مقدار درخواستی فاکتور یک‌بار و فقط یک‌بار با +موجودی پیش از فاکتور مقایسه می‌شود. + +این حذف به معنی نادیده‌گرفتن سایر فاکتورها یا حواله‌ها نیست. تمام حرکات قطعی +دیگر تا تاریخ سند همچنان در موجودی قابل‌استفاده لحاظ می‌شوند. + +## ارتباط با حواله انبار + +فروش سریع با گزینه‌های `post_inventory=true` و `auto_post_warehouse=true` دو +لایه کنترل دارد: + +1. کنترل اولیه پیش از تکمیل تراکنش فاکتور؛ +2. کنترل هنگام قطعی‌سازی حواله خروج مرتبط با فاکتور. + +مسیر دوم از قبل سند مالی مبدأ را با `source_document_id` حذف می‌کرد. اصلاح حاضر +همان قاعده را در مسیر اول نیز برقرار می‌کند تا هر دو کنترل یک موجودی مبنا داشته +باشند. تراکنش همچنان اتمیک است و در صورت کسری واقعی، فاکتور و حواله با هم ثبت +نمی‌شوند. + +## حالت‌های مرزی + +- موجودی ۱ و فروش ۱: مجاز؛ موجودی نهایی صفر. +- موجودی صفر و فروش ۱: خطای `INSUFFICIENT_STOCK`. +- موجودی ۲ و فروش ۲: مجاز؛ موجودی نهایی صفر. +- چند خط از یک کالا و یک انبار: نیاز خطوط پیش از مقایسه تجمیع می‌شود. +- انبارهای متفاوت: موجودی و نیاز برای هر جفت کالا/انبار مستقل کنترل می‌شود. +- سیاست مجازبودن موجودی منفی برای کالای فله یا یونیک بدون تغییر باقی می‌ماند. +- ایجاد و ویرایش فاکتور هر دو از همین تابع کنترل اولیه استفاده می‌کنند. + +## تست رگرسیون + +فایل `tests/test_invoice_stock_prevalidation.py` دو قرارداد را تثبیت می‌کند: + +1. شناسه فاکتور جاری حتماً به‌عنوان `exclude_document_id` ارسال شود؛ +2. برابر بودن موجودی قابل‌استفاده و مقدار موردنیاز خطا محسوب نشود. + +برای اجرای امن تست‌ها باید از wrapper دیتابیس ایزولهٔ محیط توسعه استفاده شود؛ +اجرای مستقیم pytest روی دیتابیس توسعه مجاز نیست. + +## ملاحظات عملیاتی + +- تغییر schema یا migration لازم ندارد. +- قرارداد API و payload فروش سریع تغییر نکرده است. +- refresh یا hot reload برای اصلاح داده لازم نیست؛ پس از استقرار نسخه جدید API، + ثبت فاکتورهای جدید از رفتار اصلاح‌شده استفاده می‌کند. +- فاکتور ناموفق قبلی در تراکنش ثبت نشده است و باید پس از استقرار دوباره ثبت شود. diff --git a/hesabixAPI/tests/test_invoice_stock_prevalidation.py b/hesabixAPI/tests/test_invoice_stock_prevalidation.py new file mode 100644 index 00000000..0b06e00d --- /dev/null +++ b/hesabixAPI/tests/test_invoice_stock_prevalidation.py @@ -0,0 +1,104 @@ +"""Regression tests for invoice stock validation before warehouse posting.""" + +from datetime import date +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from adapters.db.models.business import Business +from adapters.db.models.product import Product +from app.services import invoice_service +from app.services.invoice_service import ( + INVOICE_SALES, + _ensure_stock_sufficient, + _validate_outgoing_stock_before_invoice_commit, +) + + +def _prevalidation_db() -> MagicMock: + db = MagicMock() + + def _query(model, *unused_models): + query = MagicMock() + if model is Product: + query.filter.return_value.first.return_value = SimpleNamespace( + id=520098, + track_inventory=True, + ) + elif model is Business: + query.filter.return_value.first.return_value = SimpleNamespace( + allow_negative_inventory_for_bulk=False, + allow_negative_inventory_for_unique=False, + warehouse_transfer_require_positive_stock=True, + ) + return query + + db.query.side_effect = _query + return db + + +def test_precommit_validation_excludes_current_invoice_from_available_stock() -> None: + """The flushed sales line must not reduce stock before its own validation.""" + db = _prevalidation_db() + document = SimpleNamespace( + id=731, + is_proforma=False, + document_date=date(2026, 10, 3), + extra_info={"auto_post_warehouse": True, "warehouse_id": 9}, + ) + lines = [{"product_id": 520098, "quantity": 1, "extra_info": {}}] + data = {"extra_info": {"post_inventory": True}} + + with ( + patch( + "app.services.warehouse_service.invoice_lines_have_trackable_inventory_products", + return_value=True, + ), + patch.object( + invoice_service, + "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, + ): + _validate_outgoing_stock_before_invoice_commit( + db, + business_id=17, + document=document, + invoice_type=INVOICE_SALES, + lines_input=lines, + data=data, + ) + + ensure_stock.assert_called_once() + assert ensure_stock.call_args.kwargs["exclude_document_id"] == document.id + + +def test_stock_validation_accepts_quantity_equal_to_available_stock() -> None: + """Equality is sufficient: one available item can satisfy a one-item sale.""" + db = MagicMock() + query = db.query.return_value + query.filter.return_value.all.side_effect = [ + [SimpleNamespace(id=520098, name="کالای نمونه", code="520098")], + [SimpleNamespace(id=9, name="انبار اصلی")], + ] + outgoing_lines = [ + { + "product_id": 520098, + "quantity": 1, + "extra_info": {"warehouse_id": 9, "movement": "out"}, + } + ] + + with patch.object( + invoice_service, + "_compute_available_stock", + return_value=Decimal("1"), + ): + _ensure_stock_sufficient( + db, + business_id=17, + document_date=date(2026, 10, 3), + outgoing_lines=outgoing_lines, + exclude_document_id=731, + ) -- 2.43.0