fix(invoices): allow sales at exact stock #10

Merged
morrning merged 1 commit from Seyyed/Seyyed_arc:fix/product-excel-import-warehouse-receipts into master 2026-10-03 18:05:46 +03:30
4 changed files with 186 additions and 1 deletions

View file

@ -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,
)

View file

@ -1,5 +1,9 @@
# همگام‌سازی موجودی افتتاحیه با اسناد انبار در ایمپورت کالا
> برای نحوهٔ مصرف موجودی افتتاحیه در فروش سریع و کنترل حالت موجودی دقیقاً برابر
> با مقدار فروش، سند
> [کنترل موجودی دقیق در فروش سریع](QUICK_SALES_EXACT_STOCK_VALIDATION.md) را ببینید.
## هدف
ردیف دارای «تعداد اولیه» در ایمپورت Excel دو اثر هماهنگ دارد:

View 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،
ثبت فاکتورهای جدید از رفتار اصلاح‌شده استفاده می‌کند.
- فاکتور ناموفق قبلی در تراکنش ثبت نشده است و باید پس از استقرار دوباره ثبت شود.

View 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,
)