Files
ERP/docs/inventory-ledger-gaps.md
T
whqreza 3931baadd5 feat(inventory): list-based ChangeInventoryPage + mobile-first UX + return ledger controls
- All ChangeInventoryPage sections (withdrawal/damage/waste/loss/transfer/
  stock-count/project-transfer) become list-based like the withdrawal
  section: pick a warehouse, see its stock rows, material name is only an
  optional filter; manual material/brand/unit entry removed everywhere
  except PURCHASE.
- RETURN redesigned: day-range presets (today/yesterday/7d/30d + Jalali
  custom range) with server-side filters, immutable-ledger returnable
  remainder per withdrawal (REF:[tx-id] note tag + FIFO attribution for
  legacy returns, round4 float-safe), destination warehouse selector.
- PROJECT_TRANSFER: row-based list with one destination project per batch;
  sequential submits keep failed rows inline with errors and drop
  succeeded rows; history dashboard untouched.
- PURCHASE: unit locked to catalog (search-only Combobox), wizard-style
  '+ register new material' option with yellow warning, optional Key-Value
  specs editor (replaces mandatory text chips), continuous-entry UX
  (warehouse/date kept, auto-focus, session log, duplicate warning).
- Mobile-first: responsive table/cards core (StockTable), numeric keyboards
  (inputMode+pattern), sticky bottom submit bar with dynamic counter above
  MobileTabBar incl. safe-area inset, horizontally scrollable touch chips.
- Backend fix (prereq of RETURN): GET /inventory/transactions date filters
  crashed with 500 under asyncpg (string vs timestamptz); parse to aware
  UTC datetimes (DB runs Etc/UTC).
- docs/inventory-ledger-gaps.md: ledger gap report (server-side return cap,
  standalone TRANSFER_IN, transfer row fallback, audit coverage, legacy
  REF backfill, UTC day semantics, pagination gaps).
2026-10-03 21:56:46 +03:30

9.0 KiB
Raw Blame History

گزارش گپ‌های دفتر موجودی (Inventory Ledger Gaps)

بررسی کامل منطق تراکنش‌های انبار (بک‌اند + فرانت‌اند) در جریان بازطراحی صفحه «تغییر موجودی». این گزارش فقط فهرست گپ‌هاست برای تصمیم‌گیری معماری — به‌جز مورد ۱، هیچ‌کدام در این نسخه اصلاح نشده‌اند.

وضعیت‌ها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود


۱. ✅ اصلاح‌شده در همین نسخه (پیش‌نیاز فیچر برگشت)

GET /inventory/transactions?date_from=&date_to= → HTTP 500 (backend/app/routers/inventory.py — list_transactions)

ستون transaction_date (timestamptz) با رشته‌ی خام مقایسه می‌شد. درایور asyncpg تایپ سخت‌گیرانه دارد و این مقایسه با خطای operator does not exist: timestamp with time zone >= character varying کرش می‌کرد. هیچ‌جای UI قدیمی این فیلترها را صدا نمی‌زد، برای همین مخفی مانده بود.

اصلاح: پارامترهای YYYY-MM-DD به datetime آگاه از UTC تبدیل می‌شوند (YYYY-MM-DD 00:00 UTC تا +1 روز) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با timezone = Etc/UTC. ⚠️ نکته‌ی مرتبط: مورد ۱۲.


۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است

بک‌اند RETURN را دقیقاً مثل PURCHASE (افزایش ساده) می‌پذیرد (inventory_service.py:268-299) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API می‌توان موجودی یک انبار را بی‌نهایت تورم داد.

UI جدید این را کنترل می‌کند (تگ [REF:<tx-id>] در note + محاسبه‌ی مانده + سقف ورودی)، اما خود دفتر از سمت سرور محافظت نمی‌شود.

پیشنهاد برای تصمیم‌گیری: اعتبارسنجی سمت سرور — برگشت حتماً ref_transaction_id بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.

۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته می‌شود

POST /inventory/transactions با نوع TRANSFER_IN بدون هیچ TRANSFER_OUT متناظری پذیرفته می‌شود (سرویس آن را افزایش عمومی حساب می‌کند). یعنی از مسیر API می‌توان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساخته‌شده توسط خود سرویس transfer_pair_id دارد.

پیشنهاد: TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفت‌سازی داخلی سرویس ثبت شود).

۴. 🟡 create انتقال بین‌کارگاهی «ردیف انبار» نمی‌گیرد

POST /inventory/project-transfers به‌جای inventory_row_id فقط material/brand/unit/specs می‌گیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر می‌کند (project_transfer_service.py:105-151). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.

پیشنهاد: پذیرش inventory_row_id اختیاری در payload و کسر دقیق از همان ردیف.

۵. 🟡 Audit Trail ناقص است

فقط POST /inventory/transactions رکورد audit می‌نویسد (inventory.py:491-500). این‌ها audit ندارند:

  • ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بین‌کارگاهی
  • تأیید تحویل PM (/pm/receipts و دریافت انتقال)
  • CRUD انبارها و تغییرات کاربران

پیشنهاد: گسترش audit_record به این مسیرها (helper موجود آماده است و هرگز raise نمی‌کند).

۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال می‌شوند

  • delivered_to (تحویل‌گیرنده برداشت) در UI اجباری است؛ API بدون آن 201 می‌دهد.
  • ظرفیت‌سنجی مشابه برای «توضیح» (note) شمارش موجودی و… .

۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ

در بخش‌های لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیف‌های ۱..k-1 موفق ثبت‌شده در جدول باقی می‌مانند → retry کاربر = ثبت تکراری. (در انتقال بین‌کارگاهی و برگشتِ این نسخه، ردیف‌های موفق از لیست حذف می‌شوند — الگوی درست.)

پیشنهاد: انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.

۸. 🟡 برگشت‌های قدیمی بدون REF قابل انتساب دقیق نیستند

برگشت‌های ثبت‌شده قبل از این نسخه هیچ اشاره‌ای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشت‌های هم‌ردیف (متریال+برند+مشخصات+واحد) جبران می‌کند، ولی این حدس است و در دو حالت خطا دارد:

  • برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمی‌شود.
  • تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحه‌های بعد را جابه‌جا کند.

پیشنهاد (داده): اسکریپت یک‌باره‌ی backfill — تطبیق برگشت‌های قدیمی با برداشت‌های همان ردیف (قدیمی‌ترین برداشتِ باقیمانده) و درج REF در note.

۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)

فیلتر date_from/date_to (بعد از اصلاح مورد ۱) مرز روز را در UTC می‌برد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC می‌افتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر tz از کلاینت) اضافه شود.

۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص

پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich می‌کند). UI فعلی از آن استفاده نمی‌کند ولی برای دیباگ/گزارش‌ها گمراه‌کننده است.

۱۱. 🔵 سقف ۱۰۰ رکوردی واکشی‌های لیست

GET /inventory و lookup ها در useInventoryData با page_size=100 واکشی می‌شوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمی‌شود (محدودیت قدیمی، در لیست‌های جدید این صفحه هم از همان دیتا می‌خواند). لیست برگشت و مفقودی‌ها صفحه‌بندی/واکشی کامل دارند.

۱۲. 🔵 لیست انتقال‌های بین‌کارگاهی بدون صفحه‌بندی

GET /inventory/project-transfers و /pm/transfers/on-the-way همه‌ی ردیف‌ها را بدون pagination برمی‌گردانند؛ با رشد داده بار پاسخ زیاد می‌شود.


چیزهایی که بررسی شد و درست است ✅

  • Immutable Ledger در سطح API: برای تراکنش‌ها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بین‌کارگاهی است که تراکنش خروجی خودش را بازمی‌گرداند و موجودی را ترمیم می‌کند.
  • سقف FOUND در بک‌اند اعمال می‌شود: بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
  • STOCK_COUNT فقط روی رکورد موجود و با ثبت اختلاف علامت‌دار صحیح.
  • TRANSFER_OUT جفتی: کسر مبدأ و ایجاد TRANSFER_IN مقصد با transfer_pair_id مشترک و قفل ردیف؛ موجودی منفی نمی‌شود.
  • دریافت انتقال بین‌کارگاهی: مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
  • هویت ردیف انبار با specs_hash (SHA256 مرتب‌شده‌ی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.