diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index c2bb04a..95cd4bf 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -12,6 +12,7 @@ import base64 import uuid from contextlib import nullcontext from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from decimal import Decimal from fastapi import APIRouter, Depends, Query, Request @@ -448,9 +449,18 @@ async def list_transactions( if transaction_type is not None: stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type) if date_from is not None: - stmt = stmt.where(InventoryTransaction.transaction_date >= date_from) + # asyncpg تایپ سخت‌گیرانه دارد: رشته باید به datetime آگاه تبدیل شود. + # دیتابیس با TZ=UTC اجرا می‌شود، پس «YYYY-MM-DD» یعنی نیمه‌شب UTC + # (همان معناشناسی کست قدیمیِ رشته در PostgreSQL). + stmt = stmt.where( + InventoryTransaction.transaction_date + >= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc) + ) if date_to is not None: - stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00") + stmt = stmt.where( + InventoryTransaction.transaction_date + < datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1) + ) stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date") items, meta = await paginate(stmt, page, page_size, db) diff --git a/docs/inventory-ledger-gaps.md b/docs/inventory-ledger-gaps.md new file mode 100644 index 0000000..26528f8 --- /dev/null +++ b/docs/inventory-ledger-gaps.md @@ -0,0 +1,94 @@ +# گزارش گپ‌های دفتر موجودی (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:]` در 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 یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد. diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx index 3b02ec8..52034fe 100644 --- a/frontend/src/components/Combobox.tsx +++ b/frontend/src/components/Combobox.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type RefObject } from "react"; import { normalize } from "../lib/normalize"; import { cn } from "../lib/utils"; import { useClickOutside } from "./useClickOutside"; @@ -17,6 +17,7 @@ export interface ComboboxProps { onChange?: (item: ComboboxItem) => void; items: ComboboxSource; // آرایه از string یا {id, name} allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید" + addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد addSuffix?: string; // متن بعد از + placeholder?: string; className?: string; @@ -25,6 +26,8 @@ export interface ComboboxProps { onAdd?: (val: string) => void; onInputChange?: (val: string) => void; onBlur?: () => void; + // برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته) + inputRef?: RefObject; // با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند — // فقط کلیک یا تایپِ صریح کاربر بازش می‌کند. openOnFocus?: boolean; @@ -44,6 +47,7 @@ export function Combobox({ onChange, items, allowAdd = true, + addPrefix, addSuffix = "به عنوان گزینه جدید", placeholder, className, @@ -52,6 +56,7 @@ export function Combobox({ onAdd, onInputChange, onBlur, + inputRef, openOnFocus = true, }: ComboboxProps) { const [text, setText] = useState(value ?? ""); @@ -125,6 +130,7 @@ export function Combobox({
setHighlight(filtered.length)} > + - - «{text.trim()}» {addSuffix} - + {addPrefix ? ( + + {addPrefix} «{text.trim()}» + + ) : ( + + «{text.trim()}» {addSuffix} + + )} )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 37178a7..bee88f8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1856,11 +1856,18 @@ export const apiV26 = { const data = await getData[0][]>(`/inventory?${qs}`); return data.map(rawInventory); }, - transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise => { + transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise => { const pid = params.project_id ?? activeProjectId() ?? undefined; const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString(); return getData(`/inventory/transactions?${qs}`); }, + // همان transactions با meta.total برای صفحه‌بندی «نمایش بیشتر» + transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => { + const pid = params.project_id ?? activeProjectId() ?? undefined; + const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString(); + const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`); + return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 }; + }, createTransaction: async (body: { transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null; material_id: string; brand_id?: string | null; quantity?: number | null; diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index 47f1bfb..547191d 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; import { Combobox } from "../../components/Combobox"; -import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { useAuthStore } from "../../store/auth"; import { inventorySubheader } from "./panelMenu"; import { api, apiV26 } from "../../lib/api"; @@ -15,38 +14,61 @@ import type { InventoryTransaction, TransactionType, Project, ProjectTransfer } import { faNum, fmtDate } from "../../lib/formatters"; import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali"; import { cn } from "../../lib/utils"; +import { StockTable, type RowDraft } from "./change/StockTable"; +import { ReturnFilterBar } from "./change/ReturnFilterBar"; +import { ReturnTable } from "./change/ReturnTable"; +import { FoundTable, type LossItem } from "./change/FoundTable"; +import { ProjectTransferList } from "./change/ProjectTransferList"; +import { PurchaseSessionLog, type PurchaseSessionEntry } from "./change/PurchaseSessionLog"; +import { SpecsKvEditor, specsKvToObject, type SpecKvRow } from "./change/SpecsKvEditor"; +import { + buildReturnNote, + computeLossRemainders, + computeRemainders, + fetchAllTypeInWarehouse, + fetchProjectReturns, + fetchWithdrawalsPage, + presetRange, + type DatePreset, +} from "./change/returnLogic"; type FormType = TransactionType | "TRANSFER" | "PROJECT_TRANSFER"; const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [ - { t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی الزامی است." }, - { t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." }, - { t: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انتقال متریال از انبار این پروژه به یک پروژه دیگر — پس از ثبت، موجودی در انبار مبدأ کسر شده و کالا به عنوان بار در راه برای پروژه مقصد ارسال می‌شود تا مدیر پروژه مقصد آن را تحویل بگیرد." }, - { t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." }, - { t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." }, - { t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." }, - { t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#e11d48", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." }, - { t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." }, - { t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودی‌های همان انبار و متریال (یا «همه مفقودی‌ها») نمایش داده می‌شود." }, - { t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#2563eb", desc: "شمارش فیزیکی — مقدار شمارش‌شده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد." }, + { t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. واحد از فهرست واحدهای استاندارد انتخاب می‌شود و مشخصات فنی اختیاری است." }, + { t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "با انتخاب انبار، همه‌ی اقلام موجود نمایش داده می‌شود؛ مقدار هر قلم را وارد کنید. نام تحویل‌گیرنده الزامی است. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." }, + { t: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انتقال متریال از انبار این پروژه به یک پروژه دیگر — با انتخاب انبار مبدأ، اقلام موجود لیست می‌شوند؛ پس از ثبت، موجودی در انبار مبدأ کسر شده و کالا به عنوان بار در راه برای پروژه مقصد ارسال می‌شود تا مدیر پروژه مقصد آن را تحویل بگیرد." }, + { t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برداشت‌های گذشته را بر اساس انبار و بازه‌ی روز ببینید و مقدار برگشت هر قلم را وارد کنید؛ کالا به انبار مقصد انتخابی اضافه می‌شود. مقدار برگشت از مانده‌ی همان برداشت بیشتر نمی‌شود." }, + { t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — با انتخاب انبار مبدأ و مقصد، همه‌ی اقلام موجود لیست می‌شوند و از هر مدل مقدار دلخواه را منتقل کنید." }, + { t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — با انتخاب انبار، لیست اقلام نمایش داده می‌شود و مقدار ضایعات هر مدل را وارد کنید." }, + { t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#e11d48", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — با انتخاب انبار، لیست اقلام نمایش داده می‌شود." }, + { t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت مفقودی — با انتخاب انبار، لیست اقلام نمایش داده می‌شود و مقدار مفقودی هر مدل را وارد کنید." }, + { t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت پیدا شدن کالاهای مفقود — با انتخاب انبار، همه‌ی مفقودی‌های باقی‌مانده نمایش داده می‌شود؛ بیش از مانده‌ی مفقودی نمی‌توان ثبت کرد." }, + { t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#2563eb", desc: "شمارش فیزیکی — با انتخاب انبار، همه‌ی رکوردها (حتی موجودی صفر) لیست می‌شود؛ مقدار شمارش‌شده هر ردیف را وارد کنید تا موجودی اصلاح شود." }, ]; -interface RowDraft { key: string; qty: string; note: string; } - -function todayKey(): string { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; -} - -function gregKey(d: Date): string { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; -} - function jalDateLabel(d: Date): string { const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`; } +function parseNum(s: string): number { + const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); + return isFinite(v) ? v : NaN; +} + +/** فیلتر اختیاری نام متریال/برند/مشخصه — الگوی مشترک همه‌ی لیست‌ها */ +function matchesFilter( + r: { material?: { name: string } | null; brand?: { name?: string } | null; specs?: Record }, + txtLower: string, +): boolean { + if (!txtLower) return true; + const mMatch = r.material?.name?.toLowerCase().includes(txtLower) ?? false; + const bMatch = r.brand?.name?.toLowerCase().includes(txtLower) ?? false; + const sMatch = Object.values(r.specs || {}).some((v) => String(v).toLowerCase().includes(txtLower)); + return mMatch || bMatch || sMatch; +} + export function ChangeInventoryPage() { const user = useAuthStore((s) => s.user); const data = useInventoryData(); @@ -54,10 +76,33 @@ export function ChangeInventoryPage() { const [type, setType] = useState("PURCHASE"); const [whId, setWhId] = useState(""); + const [fromId, setFromId] = useState(""); + const [toId, setToId] = useState(""); + const [matTxt, setMatTxt] = useState(""); + const [brandTxt, setBrandTxt] = useState(""); + const [qty, setQty] = useState(""); + const [dateInput, setDateInput] = useState(() => new Date().toISOString()); + const [note, setNote] = useState(""); + const [deliveredTo, setDeliveredTo] = useState(""); + const [rows, setRows] = useState([]); + const [busy, setBusy] = useState(false); + + // ---------- موجودی اولیه (PURCHASE) ---------- + const [unitId, setUnitId] = useState(null); + const [unitLabel, setUnitLabel] = useState(""); + const [specRows, setSpecRows] = useState([]); + const [sessionLog, setSessionLog] = useState([]); + const materialRef = useRef(null); + + // ---------- انتقال بین کارگاهی (PROJECT_TRANSFER) ---------- const [toProjectId, setToProjectId] = useState(""); const [allProjects, setAllProjects] = useState([]); const [transfers, setTransfers] = useState([]); const [loadingTransfers, setLoadingTransfers] = useState(false); + const [ptQty, setPtQty] = useState>({}); + const [ptErrors, setPtErrors] = useState>({}); + const [ptSucceeded, setPtSucceeded] = useState>(new Set()); + const [ptBusy, setPtBusy] = useState(false); // Edit modal state const [editingTransfer, setEditingTransfer] = useState(null); @@ -70,18 +115,37 @@ export function ChangeInventoryPage() { const [deleteConfirmTransfer, setDeleteConfirmTransfer] = useState(null); const [deleteBusy, setDeleteBusy] = useState(false); - const [fromId, setFromId] = useState(""); - const [toId, setToId] = useState(""); - const [matTxt, setMatTxt] = useState(""); - const [brandTxt, setBrandTxt] = useState(""); - const [unitTxt, setUnitTxt] = useState(""); - const [qty, setQty] = useState(""); - const [dateInput, setDateInput] = useState(() => new Date().toISOString()); - const [specsChips, setSpecsChips] = useState([]); - const [note, setNote] = useState(""); - const [deliveredTo, setDeliveredTo] = useState(""); - const [rows, setRows] = useState([]); - const [busy, setBusy] = useState(false); + // ---------- برگشت (RETURN) ---------- + const [retPreset, setRetPreset] = useState("today"); + const [retCustomFrom, setRetCustomFrom] = useState(null); + const [retCustomTo, setRetCustomTo] = useState(null); + const [withdrawals, setWithdrawals] = useState([]); + const [wdTotal, setWdTotal] = useState(0); + const [wdPage, setWdPage] = useState(1); + const [wdLoading, setWdLoading] = useState(false); + const [projectReturns, setProjectReturns] = useState([]); + const [retQty, setRetQty] = useState>({}); + const [retNote, setRetNote] = useState>({}); + const [retDone, setRetDone] = useState>(new Set()); + const [dstWhId, setDstWhId] = useState(""); + const [retTick, setRetTick] = useState(0); + + // ---------- پیدا شده (FOUND) ---------- + const [pendingLosses, setPendingLosses] = useState([]); + const [foundLoading, setFoundLoading] = useState(false); + const [foundTick, setFoundTick] = useState(0); + + // ---------- شمارش موجودی (STOCK_COUNT) ---------- + interface CountConfirmRow { + material: string; + unit: string; + warehouse: string; + current: number; + counted: number; + variance: number; + } + const [countConfirm, setCountConfirm] = useState(null); + const [countBusy, setCountBusy] = useState(false); useEffect(() => { const t = params.get("type"); @@ -104,30 +168,8 @@ export function ChangeInventoryPage() { [warehouses, isAdmin, user?.project_id], ); - const formRecords = useMemo(() => { - const txt = matTxt.trim().toLowerCase(); - const wid = type === "TRANSFER" ? fromId : whId; - return data.inventory.filter((r) => { - if (r.warehouse?.id !== wid) return false; - if (type === "WITHDRAWAL") { - if (r.quantity_number <= 0) return false; - if (txt) { - const mMatch = r.material.name.toLowerCase().includes(txt); - const bMatch = r.brand?.name?.toLowerCase().includes(txt) ?? false; - const sMatch = Object.values(r.specs || {}).some((v) => String(v).toLowerCase().includes(txt)); - if (!mMatch && !bMatch && !sMatch) return false; - } - return true; - } - if (txt && !r.material.name.includes(txt)) return false; - return true; - }); - }, [data.inventory, whId, fromId, type, matTxt]); - const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null; const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null; - const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null; - const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; }; const toServerIso = () => { try { @@ -137,93 +179,136 @@ export function ChangeInventoryPage() { return new Date().toISOString(); }; - const specsFromChips = () => { - const out: Record = {}; - specsChips.forEach((c) => { - const k = c.trim(); - if (k) out[k] = ""; + // ---------- فهرست اقلام انبار برای بخش‌های لیست‌محور ---------- + const formRecords = useMemo(() => { + const txt = matTxt.trim().toLowerCase(); + const wid = type === "TRANSFER" ? fromId : whId; + return data.inventory.filter((r) => { + if (r.warehouse?.id !== wid) return false; + if (type !== "STOCK_COUNT" && r.quantity_number <= 0) return false; + return matchesFilter(r, txt); }); - return out; - }; - const specsComplete = specsChips.some((c) => c.trim()); + }, [data.inventory, whId, fromId, type, matTxt]); - const todayWithdrawals = useMemo(() => { - const tk = todayKey(); - const txt = matTxt.trim(); - return data.transactions - .filter((t) => { - if (t.transaction_type !== "WITHDRAWAL" || t.warehouse_id !== whId) return false; - if (gregKey(new Date(t.transaction_date)) !== tk) return false; - if (txt && !(t.material?.name || "").includes(txt)) return false; - return true; - }) - .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); - }, [data.transactions, whId, matTxt]); - - const lossTxs = useMemo(() => { - const txt = matTxt.trim(); - return data.transactions - .filter((t) => { - if (t.transaction_type !== "LOSS" || t.warehouse_id !== whId) return false; - if (txt && !(t.material?.name || "").includes(txt)) return false; - return true; - }) - .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); - }, [data.transactions, whId, matTxt]); - - const foundRemaining = useMemo(() => { - const foundByKey = new Map(); - data.transactions - .filter((x) => x.transaction_type === "FOUND") - .forEach((x) => { - const k = lossKey(x); - foundByKey.set(k, (foundByKey.get(k) || 0) + Math.abs(x.quantity_number || 0)); - }); - return lossTxs.map((t) => { - const loss = Math.abs(t.quantity_number || 0); - const found = foundByKey.get(lossKey(t)) || 0; - return Math.max(0, Math.round((loss - found) * 10000) / 10000); + const ptRecords = useMemo(() => { + const txt = matTxt.trim().toLowerCase(); + return data.inventory.filter((r) => { + if (r.warehouse?.id !== whId) return false; + if (r.quantity_number <= 0) return false; + return matchesFilter(r, txt); }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lossTxs, data.transactions, whId, matTxt]); + }, [data.inventory, whId, matTxt]); - const lossKey = (t: InventoryTransaction) => - [t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|"); + const ptVisible = useMemo(() => ptRecords.filter((r) => !ptSucceeded.has(r.id)), [ptRecords, ptSucceeded]); - const pendingLosses = useMemo( - () => - lossTxs - .map((tx, i) => ({ tx, remaining: foundRemaining[i] ?? 0 })) - .filter((x) => x.remaining > 0), - [lossTxs, foundRemaining], + // ---------- برگشت: واکشی و محاسبه مانده ---------- + const visibleWithdrawals = useMemo(() => withdrawals.filter((w) => !retDone.has(w.id)), [withdrawals, retDone]); + + const filteredWithdrawals = useMemo(() => { + const txt = matTxt.trim().toLowerCase(); + return visibleWithdrawals.filter((w) => matchesFilter(w, txt)); + }, [visibleWithdrawals, matTxt]); + + const remainders = useMemo( + () => computeRemainders(withdrawals, projectReturns, whId), + [withdrawals, projectReturns, whId], ); - const postOne = async (op: Record) => { - await apiV26.inventory.createTransaction(op as never); - }; - - const postSeq = async (ops: Record[]) => { - setBusy(true); - try { - for (const op of ops) await postOne(op); - toast.success("ثبت شد ✓"); - notifyDataChanged("inventory"); - setRows([]); - setMatTxt(""); - setBrandTxt(""); - setUnitTxt(""); - setQty(""); - setSpecsChips([]); - setNote(""); - setDeliveredTo(""); - await data.refresh(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "خطا در ثبت"); - } finally { - setBusy(false); + useEffect(() => { + if (type !== "RETURN" || !whId) { + setWithdrawals([]); + setProjectReturns([]); + setWdTotal(0); + return; } - }; + const { date_from, date_to } = presetRange(retPreset, retCustomFrom ?? undefined, retCustomTo ?? undefined); + let cancelled = false; + setWdLoading(true); + (async () => { + try { + const { rows: wrows, total } = await fetchWithdrawalsPage(whId, date_from, date_to, wdPage); + if (cancelled) return; + setWithdrawals((prev) => (wdPage === 1 ? wrows : [...prev, ...wrows])); + setWdTotal(total); + try { + const rets = await fetchProjectReturns(date_from); + if (!cancelled) setProjectReturns(rets); + } catch { + if (!cancelled) setProjectReturns([]); + } + } catch (err) { + if (!cancelled) { + setWithdrawals((prev) => (wdPage === 1 ? [] : prev)); + setWdTotal(0); + toast.error(err instanceof Error ? err.message : "خطا در بارگذاری برداشت‌ها"); + } + } finally { + if (!cancelled) setWdLoading(false); + } + })(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, whId, retPreset, retCustomFrom, retCustomTo, wdPage, retTick]); + // ---------- پیدا شده: مانده مفقودی‌های انبار ---------- + useEffect(() => { + if (type !== "FOUND" || !whId) { + setPendingLosses([]); + return; + } + let cancelled = false; + setFoundLoading(true); + (async () => { + try { + const [losses, founds] = await Promise.all([ + fetchAllTypeInWarehouse(whId, "LOSS"), + fetchAllTypeInWarehouse(whId, "FOUND"), + ]); + if (!cancelled) setPendingLosses(computeLossRemainders(losses, founds)); + } catch (err) { + if (!cancelled) { + setPendingLosses([]); + toast.error(err instanceof Error ? err.message : "خطا در بارگذاری مفقودی‌ها"); + } + } finally { + if (!cancelled) setFoundLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [type, whId, foundTick]); + + // ---------- همگام‌سازی ردیف‌ها با لیست هر بخش ---------- + useEffect(() => { + if (type === "FOUND") { + setRows(pendingLosses.map((x) => ({ key: x.tx.inventory_id, qty: "", note: "" }))); + } else if (type === "PURCHASE" || type === "PROJECT_TRANSFER" || type === "RETURN") { + // این بخش‌ها state کلید-محور خودشان را دارند + } else { + setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" }))); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, whId, fromId, toId, matTxt, formRecords.length, pendingLosses]); + + // ریست state بخش‌ها هنگام تغییر زمینه + useEffect(() => { + setPtQty({}); + setPtErrors({}); + setPtSucceeded(new Set()); + }, [type, whId, matTxt]); + + useEffect(() => { + setRetDone(new Set()); + setRetQty({}); + setRetNote({}); + setWdPage(1); + }, [whId, retPreset, retCustomFrom, retCustomTo]); + + // انبار مقصد برگشت به‌صورت پیش‌فرض همان انبار مبدأ است + useEffect(() => { + if (type === "RETURN") setDstWhId(whId); + }, [type, whId]); + + // ---------- انتقال بین کارگاهی: سوابق ---------- const loadTransfers = useCallback(async () => { if (!user?.project_id) return; setLoadingTransfers(true); @@ -251,13 +336,6 @@ export function ChangeInventoryPage() { } }, [type, loadTransfers]); - const whStock = useMemo(() => { - if (!whId) return []; - return (data.inventory || []).filter((r) => r.warehouse?.id === whId && r.quantity_number > 0); - }, [data.inventory, whId]); - - - const handleSaveEdit = async () => { if (!editingTransfer) return; const n = parseNum(editQty); @@ -300,24 +378,48 @@ export function ChangeInventoryPage() { } }; + // ============================================================ + // ثبت‌ها + // ============================================================ + + const postOne = async (op: Record) => { + await apiV26.inventory.createTransaction(op as never); + }; + + const postSeq = async (ops: Record[]) => { + setBusy(true); + try { + for (const op of ops) await postOne(op); + toast.success("ثبت شد ✓"); + notifyDataChanged("inventory"); + setRows([]); + setMatTxt(""); + setBrandTxt(""); + setQty(""); + setNote(""); + setDeliveredTo(""); + await data.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در ثبت"); + } finally { + setBusy(false); + } + }; + + // ---------- موجودی اولیه: ثبت پیوسته ---------- const submitPurchase = async () => { const matName = matTxt.trim(); const brandName = brandTxt.trim(); - const unitName = unitTxt.trim(); - const n = parseNum(qty); if (!whId) return toast.error("انبار را انتخاب کنید"); if (!matName) return toast.error("نام متریال را وارد کنید"); if (!brandName) return toast.error("نام برند را وارد کنید"); - if (!unitName) return toast.error("نام واحد را وارد کنید"); + if (!unitId) return toast.error("واحد را از فهرست واحدها انتخاب کنید"); + const n = parseNum(qty); if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد"); - if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید"); + const specs = specsKvToObject(specRows); + setBusy(true); try { - let uid = unitIdByName(unitName); - if (!uid) { - const created = await apiV26.catalog.createUnit(unitName); - uid = created.id; - } let bid = brandIdByName(brandName); if (!bid) { const created = await apiV26.catalog.createBrand(brandName); @@ -325,97 +427,154 @@ export function ChangeInventoryPage() { } let m = matByName(matName); if (!m) { - const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] }); + const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: unitId, brand_ids: bid ? [bid] : [] }); m = { id: created.id, name: created.name, canonical_unit: null }; } const op: Record = { transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, - quantity: n, unit_id: uid, specs: specsFromChips(), note, + quantity: n, unit_id: unitId, specs, note, transaction_date: toServerIso(), }; - await postSeq([op]); + await postOne(op); + toast.success("ثبت شد ✓"); + notifyDataChanged("inventory"); + setSessionLog((prev) => [ + { material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: new Date().toTimeString().slice(0, 5), whName: whName(warehouses, whId) }, + ...prev, + ]); + // حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا + setMatTxt(""); + setBrandTxt(""); + setQty(""); + setSpecRows([]); + setNote(""); + await data.refresh(); } catch (err) { - setBusy(false); toast.error(err instanceof Error ? err.message : "خطا در ثبت موجودی اولیه"); + } finally { + setBusy(false); + // فوکوس خودکار برای ثبت قلم بعدی + materialRef.current?.focus(); } }; - const submitProjectTransfer = async () => { - const matName = matTxt.trim(); - const brandName = brandTxt.trim(); - const unitName = unitTxt.trim(); - const n = parseNum(qty); + // هشدار ثبت تکراری در نشست جاری (متریال+برند+مشخصات یکسان) + const sessionDuplicate = useMemo(() => { + const m = matTxt.trim(); + if (!m || !sessionLog.length) return false; + const sk = JSON.stringify(specsKvToObject(specRows)); + return sessionLog.some( + (e) => e.material === m && (e.brand || "") === brandTxt.trim() && JSON.stringify(e.specs) === sk, + ); + }, [sessionLog, matTxt, brandTxt, specRows]); + + // ---------- برگشت: ثبت با تگ REF ---------- + const submitReturns = async () => { if (!whId) return toast.error("انبار مبدأ را انتخاب کنید"); - if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید"); - if (!matName) return toast.error("نام متریال را وارد کنید"); - if (!unitName) return toast.error("نام واحد را وارد کنید"); - if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد"); + if (!dstWhId) return toast.error("انبار مقصد برگشت را انتخاب کنید"); + const chosen = visibleWithdrawals + .map((w) => ({ w, qty: parseNum(retQty[w.id] || "") })) + .filter((x) => x.qty > 0); + if (!chosen.length) return toast.error("هیچ ردیفی پر نشده است"); + for (const { w, qty } of chosen) { + const rem = remainders.get(w.id) ?? 0; + if (qty > rem + 1e-9) { + return toast.error(`مقدار برگشت «${w.material?.name || "—"}» بیشتر از مانده قابل برگشت (${faNum(rem)}) است`); + } + } setBusy(true); + let okCount = 0; + const failed: string[] = []; try { - // Find matching inventory in the selected source warehouse - const invInWh = (data.inventory || []).filter( - (r) => r.warehouse?.id === whId && r.material.name.trim() === matName - ); - const matchedInv = invInWh.find( - (r) => (brandName ? r.brand?.name === brandName : !r.brand) - ) || invInWh[0]; - - let uid = matchedInv?.unit?.id ?? unitIdByName(unitName); - if (!uid) { - const created = await apiV26.catalog.createUnit(unitName); - uid = created.id; - } - - let bid: string | null = matchedInv?.brand?.id ?? (brandName ? brandIdByName(brandName) : null); - if (brandName && !bid) { - const created = await apiV26.catalog.createBrand(brandName); - bid = created.id; - } - - let mid = matchedInv?.material.id; - if (!mid) { - let m = matByName(matName); - if (!m) { - const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] }); - mid = created.id; - } else { - mid = m.id; + for (const { w, qty } of chosen) { + try { + await postOne({ + transaction_type: "RETURN", + warehouse_id: dstWhId, + material_id: w.material?.id, + brand_id: w.brand?.id || null, + quantity: qty, + unit_id: w.unit?.id ?? null, + specs: w.specs || {}, + note: buildReturnNote(retNote[w.id], w.id), + transaction_date: new Date().toISOString(), + }); + okCount++; + setRetDone((prev) => new Set(prev).add(w.id)); + } catch (err) { + failed.push(`${w.material?.name || "—"}: ${err instanceof Error ? err.message : "خطا"}`); } } - - const finalSpecs = matchedInv ? (matchedInv.specs || {}) : specsFromChips(); - - await apiV26.projectTransfers.create({ - from_warehouse_id: whId, - to_project_id: toProjectId, - material_id: mid, - brand_id: bid || null, - unit_id: uid, - quantity: n, - specs: finalSpecs, - note, - transfer_date: toServerIso(), - }, user?.project_id); - - toast.success("انتقال بین کارگاهی ثبت شد و از انبار مبدأ کسر گردید ✓"); - notifyDataChanged("inventory"); - setQty(""); - setSpecsChips([]); - setNote(""); - setMatTxt(""); - setBrandTxt(""); - setUnitTxt(""); - setToProjectId(""); - await data.refresh(); - await loadTransfers(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "خطا در ثبت انتقال بین کارگاهی"); } finally { setBusy(false); } + if (okCount) { + toast.success(`${faNum(okCount)} قلم برگشت ثبت شد ✓`); + notifyDataChanged("inventory"); + await data.refresh(); + setRetTick((t) => t + 1); // بازخوانی برداشت‌ها/برگشت‌ها برای مانده‌های به‌روز + } + if (failed.length) { + toast.error(`ثبت ${faNum(failed.length)} قلم ناموفق بود: ${failed.join("، ")}`); + } }; + // ---------- انتقال بین کارگاهی: ثبت متوالی چند ردیف ---------- + const submitProjectTransferBatch = async () => { + if (!whId) return toast.error("انبار مبدأ را انتخاب کنید"); + if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید"); + const chosen = ptVisible.filter((r) => parseNum(ptQty[r.id] || "") > 0); + if (!chosen.length) return toast.error("هیچ ردیفی پر نشده است"); + for (const r of chosen) { + if (parseNum(ptQty[r.id]) > r.quantity_number) { + return toast.error(`مقدار «${r.material.name}» بیشتر از موجودی انبار است`); + } + if (!r.unit?.id) { + return toast.error(`واحد رکورد «${r.material.name}» نامعتبر است`); + } + } + + setPtBusy(true); + let ok = 0; + const errors: Record = {}; + try { + for (const r of chosen) { + try { + await apiV26.projectTransfers.create({ + from_warehouse_id: whId, + to_project_id: toProjectId, + material_id: r.material.id, + brand_id: r.brand?.id || null, + unit_id: r.unit?.id ?? 0, + quantity: parseNum(ptQty[r.id]), + specs: r.specs || {}, + note, + transfer_date: new Date().toISOString(), + }, user?.project_id); + ok++; + setPtSucceeded((prev) => new Set(prev).add(r.id)); + } catch (err) { + errors[r.id] = err instanceof Error ? err.message : "خطا در ثبت"; + } + } + } finally { + setPtErrors(errors); + setPtBusy(false); + } + if (ok) { + toast.success(`${faNum(ok)} انتقال بین کارگاهی ثبت شد ✓`); + notifyDataChanged("inventory"); + setNote(""); + await data.refresh(); + await loadTransfers(); + } + if (Object.keys(errors).length) { + toast.error(`ثبت ${faNum(Object.keys(errors).length)} قلم ناموفق بود — ردیف‌های ناموفق در لیست مانده‌اند`); + } + }; + + // ---------- بخش‌های لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال/شمارش) ---------- const submitList = async (t: TransactionType) => { if (t === "WITHDRAWAL" && !deliveredTo.trim()) { return toast.error("نام تحویل‌گیرنده اقلام الزامی است"); @@ -447,16 +606,36 @@ export function ChangeInventoryPage() { await postSeq(ops); }; - interface CountConfirmRow { - material: string; - unit: string; - warehouse: string; - current: number; - counted: number; - variance: number; - } - const [countConfirm, setCountConfirm] = useState(null); - const [countBusy, setCountBusy] = useState(false); + const submitTransfer = async () => { + const ops: Record[] = rows + .map((r, i): Record | null => { + const n = parseNum(r.qty); + if (!(n > 0)) return null; + const rec = formRecords[i]; + if (!rec) return null; + return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() }; + }) + .filter((x): x is Record => x !== null); + if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); + await postSeq(ops); + }; + + const submitFound = async () => { + const ops: Record[] = rows + .map((r, i): Record | null => { + const n = parseNum(r.qty); + if (!(n > 0)) return null; + const item = pendingLosses[i]; + if (!item) return null; + if (n > item.remaining) return null; + const t = item.tx; + return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date().toISOString() }; + }) + .filter((x): x is Record => x !== null); + if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است"); + await postSeq(ops); + setFoundTick((t) => t + 1); // بازخوانی مانده مفقودی‌ها پس از ثبت + }; const countOps = (): { ops: Record[]; confirm: CountConfirmRow[] } | null => { const ops: Record[] = []; @@ -464,7 +643,8 @@ export function ChangeInventoryPage() { rows.forEach((r, i) => { const n = parseNum(r.qty); const rec = formRecords[i]; - if (!(n > 0) || !rec) return; + if (!rec) return; + if (!(r.qty.trim() !== "")) return; ops.push({ transaction_type: "STOCK_COUNT", warehouse_id: whId, @@ -505,55 +685,11 @@ export function ChangeInventoryPage() { } }; - const submitReturn = async () => { - const ops: Record[] = rows - .map((r, i): Record | null => { - const n = parseNum(r.qty); - if (!(n > 0)) return null; - const t = todayWithdrawals[i]; - if (!t) return null; - return { transaction_type: "RETURN", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() }; - }) - .filter((x): x is Record => x !== null); - if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); - await postSeq(ops); - }; - - const submitTransfer = async () => { - const ops: Record[] = rows - .map((r, i): Record | null => { - const n = parseNum(r.qty); - if (!(n > 0)) return null; - const rec = formRecords[i]; - if (!rec) return null; - return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() }; - }) - .filter((x): x is Record => x !== null); - if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); - await postSeq(ops); - }; - - const submitFound = async () => { - const ops: Record[] = rows - .map((r, i): Record | null => { - const n = parseNum(r.qty); - if (!(n > 0)) return null; - const item = pendingLosses[i]; - if (!item) return null; - if (n > item.remaining) return null; - const t = item.tx; - return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() }; - }) - .filter((x): x is Record => x !== null); - if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است"); - await postSeq(ops); - }; - const doSubmit = () => { try { if (type === "PURCHASE") void submitPurchase(); - else if (type === "PROJECT_TRANSFER") void submitProjectTransfer(); - else if (type === "RETURN") void submitReturn(); + else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch(); + else if (type === "RETURN") void submitReturns(); else if (type === "TRANSFER") void submitTransfer(); else if (type === "FOUND") void submitFound(); else if (type === "STOCK_COUNT") requestCountConfirm(); @@ -565,16 +701,21 @@ export function ChangeInventoryPage() { const canSubmit = (): boolean => { if (type === "PURCHASE") { - return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete); + return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0); } if (type === "WITHDRAWAL") { return !!(whId && deliveredTo.trim() && rows.some((r) => parseNum(r.qty) > 0)); } if (type === "PROJECT_TRANSFER") { - return !!(whId && toProjectId && matTxt.trim() && unitTxt.trim() && parseNum(qty) > 0); + return !!(whId && toProjectId && ptVisible.some((r) => parseNum(ptQty[r.id] || "") > 0)); } - if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); - if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); + if (type === "RETURN") { + return !!(whId && dstWhId && visibleWithdrawals.some((w) => { + const q = parseNum(retQty[w.id] || ""); + return q > 0 && q <= (remainders.get(w.id) ?? 0) + 1e-9; + })); + } + if (type === "TRANSFER") return !!(fromId && toId && rows.some((r) => parseNum(r.qty) > 0)); if (type === "FOUND") { return !!( whId && @@ -586,23 +727,81 @@ export function ChangeInventoryPage() { rows.some((r) => parseNum(r.qty) > 0) ); } - return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); + if (type === "STOCK_COUNT") { + return !!(whId && rows.some((r) => r.qty.trim() !== "")); + } + return !!(whId && rows.some((r) => parseNum(r.qty) > 0)); }; - useEffect(() => { + /** تعداد اقلام تغییریافته (ردیف‌های آماده‌ی ثبت) برای شمارنده‌ی دکمه */ + const dirtyCount = useMemo(() => { + if (type === "PURCHASE") return canSubmit() ? 1 : 0; + if (type === "PROJECT_TRANSFER") return ptVisible.filter((r) => parseNum(ptQty[r.id] || "") > 0).length; if (type === "RETURN") { - setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" }))); - } else if (type === "FOUND") { - setRows(pendingLosses.map((x) => ({ key: x.tx.inventory_id, qty: "", note: "" }))); - } else if (type === "TRANSFER") { - setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" }))); - } else { - setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" }))); + return visibleWithdrawals.filter((w) => { + const q = parseNum(retQty[w.id] || ""); + return q > 0 && q <= (remainders.get(w.id) ?? 0) + 1e-9; + }).length; } + if (type === "STOCK_COUNT") return rows.filter((r) => r.qty.trim() !== "").length; + if (type === "FOUND") { + return rows.filter((r, i) => { + const n = parseNum(r.qty); + return n > 0 && n <= (pendingLosses[i]?.remaining ?? 0); + }).length; + } + return rows.filter((r) => parseNum(r.qty) > 0).length; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [type, whId, fromId, toId, matTxt, formRecords.length, todayWithdrawals.length, pendingLosses.length]); + }, [type, rows, ptVisible, ptQty, visibleWithdrawals, retQty, remainders, formRecords.length, pendingLosses, whId, toProjectId, dstWhId, matTxt, brandTxt, qty, unitId, deliveredTo]); const meta = FORM_TYPES.find((f) => f.t === type)!; + const isTransfer = type === "TRANSFER"; + + const submitLabel = dirtyCount > 0 + ? `ثبت ${faNum(dirtyCount)} قلم` + : meta.label === "انتقال" ? "ثبت انتقال" + : meta.label === "انتقال بین کارگاهی" ? "ثبت انتقال بین کارگاهی و کسر از انبار" + : meta.label === "برگشت" ? "ثبت برگشت" + : meta.label === "پیدا شده" ? "ثبت پیدا شدن" + : `ثبت ${meta.label}`; + + const unitItems = useMemo( + () => uniqueUnits(data.lookup.units).map((u) => ({ id: String(u.id), label: `${u.name_fa || u.code} (${u.code})` })), + [data.lookup.units], + ); + + const listEmptyMsg = useMemo(() => { + const wid = type === "TRANSFER" ? fromId : whId; + if (!wid) return type === "TRANSFER" ? "برای نمایش موجودی‌ها، ابتدا انبار مبدأ و مقصد را انتخاب کنید." : "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."; + if (!formRecords.length) { + return matTxt.trim() ? "برای این عبارت در انبار انتخابی رکوردی یافت نشد." : "هیچ کالایی در این انبار ثبت نشده است."; + } + return ""; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, fromId, whId, formRecords.length, matTxt]); + + const ptEmptyMsg = useMemo(() => { + if (!whId) return "برای نمایش اقلام، ابتدا انبار مبدأ را انتخاب کنید."; + if (!ptVisible.length) { + return matTxt.trim() ? "برای این عبارت در انبار انتخابی قلمی یافت نشد." : "هیچ کالایی با موجودی در این انبار یافت نشده است."; + } + return ""; + }, [whId, ptVisible.length, matTxt]); + + const retEmptyMsg = useMemo(() => { + if (!whId) return "برای مشاهده‌ی برداشت‌ها، ابتدا انبار مبدأ را انتخاب کنید."; + if (!filteredWithdrawals.length) { + if (wdLoading) return ""; + return matTxt.trim() ? "برداشتی مطابق این عبارت در بازه‌ی انتخابی یافت نشد." : "در این بازه برداشتی ثبت نشده است."; + } + return ""; + }, [whId, filteredWithdrawals.length, wdLoading, matTxt]); + + const foundEmptyMsg = useMemo(() => { + if (!whId) return "برای نمایش مفقودی‌ها، ابتدا انبار را انتخاب کنید."; + if (!pendingLosses.length) return "مفقودی ثبت‌نشده‌ای باقی‌مانده در این انبار وجود ندارد. ✓"; + return ""; + }, [whId, pendingLosses.length]); if (!canModify) { return ( @@ -620,10 +819,29 @@ export function ChangeInventoryPage() { } const isCount = type === "STOCK_COUNT"; - const isTransfer = type === "TRANSFER"; + + const warehouseChips = (selected: string, onSelect: (id: string) => void, extraClear?: () => void) => ( +
+ {whOpts.map((w) => ( + + ))} +
+ ); return ( -
+
{ setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }} className={cn( "rounded-lg border px-3 py-2 text-xs sm:text-[12.5px] text-start sm:text-center transition-all", - type === f.t ? "font-bold text-white shadow-xs" : "border-line bg-panel text-ink hover:bg-bg" + type === f.t ? "font-bold text-white shadow-xs" : "border-line bg-panel text-ink hover:bg-bg", )} style={type === f.t ? { background: f.color, borderColor: f.color } : undefined} > @@ -655,33 +873,17 @@ export function ChangeInventoryPage() {
- {/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */} + {/* انتخاب انبارها */} {type === "PROJECT_TRANSFER" ? (
-
- {whOpts.map((w) => ( - - ))} -
+ {warehouseChips(whId, setWhId, () => setMatTxt(""))}
- +
- ) : !isTransfer ? ( -
-
- -
- {whOpts.map((w) => ( + ) : isTransfer ? ( +
+
+ + {warehouseChips(fromId, (id) => { setFromId(id); setToId(""); setMatTxt(""); })} +
+
+ +
+ {whOpts.filter((w) => w.id !== fromId).map((w) => (
+
+ ) : type === "RETURN" ? ( +
+
+ + {warehouseChips(whId, setWhId, () => setMatTxt(""))} +
+
+ + {warehouseChips(dstWhId, setDstWhId)} +
+
+ ) : ( +
+
+ + {warehouseChips(whId, (id) => { setWhId(id); setMatTxt(""); })} +
{type === "WITHDRAWAL" && (
{ setMatTxt(e.target.value); setRows([]); }} @@ -739,7 +965,8 @@ export function ChangeInventoryPage() { تحویل به (تحویل‌گیرنده) * setDeliveredTo(e.target.value)} @@ -748,13 +975,14 @@ export function ChangeInventoryPage() {
)} - {(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && ( + {(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && (
- + { setMatTxt(e.target.value); setRows([]); }} /> @@ -764,179 +992,81 @@ export function ChangeInventoryPage() {
)}
- ) : ( -
-
- -
- {whOpts.map((w) => ( - - ))} -
-
-
- -
- {whOpts.filter((w) => w.id !== fromId).map((w) => ( - - ))} -
-
-
- - { setMatTxt(e.target.value); setRows([]); }} - /> - - {data.lookup.materials.map((m) => -
+ )} + + {/* فیلتر نام متریال برای انتقال داخلی و برگشت و پیدا شده */} + {isTransfer && ( +
+ + { setMatTxt(e.target.value); setRows([]); }} + /> + + {data.lookup.materials.map((m) =>
)} - {/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */} - {(type === "PURCHASE" || type === "PROJECT_TRANSFER") && ( + {/* ---------- موجودی اولیه: فرم ثبت پیوسته ---------- */} + {type === "PURCHASE" && (
- {/* چیپ‌های انتخاب سریع از موجودی‌های انبار مبدأ */} - {type === "PROJECT_TRANSFER" && whId && ( -
-
- - 📦 - موجودی‌های موجود در {whName(warehouses, whId)} — برای پر کردن خودکار فرم کلیک کنید: - - {faNum(whStock.length)} قلم کالا -
- {whStock.length === 0 ? ( -
هیچ کالایی با موجودی مثبت در این انبار یافت نشد.
- ) : ( -
- {whStock.map((r) => { - const isSelected = matTxt === r.material.name && (brandTxt === (r.brand?.name || "") || (!brandTxt && !r.brand)); - const specsSummary = Object.entries(r.specs || {}) - .map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, ""))) - .filter(Boolean) - .join(" · "); - return ( - - ); - })} -
- )} -
- )} -
mm.name)} allowAdd - addSuffix="به عنوان متریال جدید" + addPrefix="ثبت متریال جدید:" + inputRef={materialRef} placeholder="نام متریال — از فهرست انتخاب یا نام جدید تایپ کنید" - onInputChange={(v) => { setMatTxt(v); setRows([]); }} - onChange={(it) => { setMatTxt(it.label); setRows([]); }} + onInputChange={(v) => { setMatTxt(v); }} + onChange={(it) => { setMatTxt(it.label); }} /> {!matByName(matTxt) && matTxt.trim() && ( -
- «{matTxt.trim()}» در فهرست نیست — هنگام ثبت، به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. +
+ این متریال در کاتالوگ سراسری وجود ندارد و به عنوان کالای پایه جدید ثبت خواهد شد. +
+ )} + {sessionDuplicate && ( +
+ ⚠️ این قلم (متریال + برند + مشخصات) در همین نشست قبلاً ثبت شده است — از ثبت تکراری خودداری کنید.
)}
- + b.name)} allowAdd addSuffix="به عنوان برند جدید" - placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"} + placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید" onInputChange={setBrandTxt} onChange={(it) => setBrandTxt(it.label)} />
- + u.name_fa || u.code)} - allowAdd - addSuffix="به عنوان واحد جدید" - placeholder="واحد اندازه‌گیری — از فهرست یا نام جدید" - onInputChange={setUnitTxt} - onChange={(it) => setUnitTxt(it.label)} + value={unitLabel} + items={unitItems} + allowAdd={false} + placeholder="انتخاب از فهرست واحدها…" + onChange={(it) => { setUnitId(Number(it.id)); setUnitLabel(it.label); }} />
setQty(e.target.value)} @@ -952,21 +1082,14 @@ export function ChangeInventoryPage() { />
- - + +