fix(invoices): allow sales at exact stock #10
4 changed files with 186 additions and 1 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
# همگامسازی موجودی افتتاحیه با اسناد انبار در ایمپورت کالا
|
||||
|
||||
> برای نحوهٔ مصرف موجودی افتتاحیه در فروش سریع و کنترل حالت موجودی دقیقاً برابر
|
||||
> با مقدار فروش، سند
|
||||
> [کنترل موجودی دقیق در فروش سریع](QUICK_SALES_EXACT_STOCK_VALIDATION.md) را ببینید.
|
||||
|
||||
## هدف
|
||||
|
||||
ردیف دارای «تعداد اولیه» در ایمپورت Excel دو اثر هماهنگ دارد:
|
||||
|
|
|
|||
74
hesabixAPI/docs/QUICK_SALES_EXACT_STOCK_VALIDATION.md
Normal file
74
hesabixAPI/docs/QUICK_SALES_EXACT_STOCK_VALIDATION.md
Normal file
|
|
@ -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،
|
||||
ثبت فاکتورهای جدید از رفتار اصلاحشده استفاده میکند.
|
||||
- فاکتور ناموفق قبلی در تراکنش ثبت نشده است و باید پس از استقرار دوباره ثبت شود.
|
||||
104
hesabixAPI/tests/test_invoice_stock_prevalidation.py
Normal file
104
hesabixAPI/tests/test_invoice_stock_prevalidation.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
Loading…
Reference in a new issue