diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 018ff34..5dacd28 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -247,6 +247,7 @@ _V26_DDL = [ physical_qty NUMERIC(18,6), transfer_pair_id UUID, performed_by TEXT, + delivered_to TEXT, transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(), note TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -258,6 +259,9 @@ _V26_DDL = [ """ CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date) """, + """ + ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT + """, # suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it. """ ALTER TABLE suppliers DROP COLUMN IF EXISTS categories diff --git a/backend/app/models/inventory.py b/backend/app/models/inventory.py index 74a3606..3f82d4e 100644 --- a/backend/app/models/inventory.py +++ b/backend/app/models/inventory.py @@ -156,6 +156,7 @@ class InventoryTransaction(Base): # NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row. transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True)) performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder) + delivered_to: Mapped[str | None] = mapped_column(Text) transaction_date: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 468998e..8aa9370 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -197,6 +197,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) - "physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None, "transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None, "performed_by": tx.performed_by, + "delivered_to": tx.delivered_to, "transaction_date": to_iso(tx.transaction_date), "note": tx.note, } diff --git a/backend/app/schemas/inventory.py b/backend/app/schemas/inventory.py index 57e69b0..7fd712e 100644 --- a/backend/app/schemas/inventory.py +++ b/backend/app/schemas/inventory.py @@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel): unit_id: int | None = None specs: dict | None = None note: str | None = None + delivered_to: str | None = None transaction_date: datetime | None = None @model_validator(mode="after") diff --git a/backend/app/services/inventory_service.py b/backend/app/services/inventory_service.py index fd1a009..f335d62 100644 --- a/backend/app/services/inventory_service.py +++ b/backend/app/services/inventory_service.py @@ -290,6 +290,7 @@ async def apply_transaction( transaction_type=t, quantity=quantity, performed_by=performed_by, + delivered_to=payload.delivered_to, transaction_date=payload.transaction_date, note=payload.note, ) @@ -319,6 +320,7 @@ async def apply_transaction( transaction_type=t, quantity=-quantity, performed_by=performed_by, + delivered_to=payload.delivered_to, transaction_date=payload.transaction_date, note=payload.note, ) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e5f6ca..6627259 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1678,7 +1678,7 @@ export const apiV26 = { transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null; material_id: string; brand_id?: string | null; quantity?: number | null; physical_qty?: number | null; unit_id?: number | null; specs?: Record | null; - note?: string | null; transaction_date?: string | null; + note?: string | null; delivered_to?: string | null; transaction_date?: string | null; }): Promise => { return postData(`/inventory/transactions${projectQuery()}`, body); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index dad95b0..c1e7a21 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -163,6 +163,7 @@ export interface InventoryTransaction { physical_qty?: number | null; transfer_pair_id?: string | null; performed_by?: string | null; + delivered_to?: string | null; transaction_date: string; note?: string | null; material?: { id: string; name: string } | null; diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index 8f40188..93a6d7e 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -62,6 +62,7 @@ export function ChangeInventoryPage() { 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); @@ -86,10 +87,20 @@ export function ChangeInventoryPage() { ); const formRecords = useMemo(() => { - const txt = matTxt.trim(); + 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; }); @@ -186,6 +197,7 @@ export function ChangeInventoryPage() { setQty(""); setSpecsChips([]); setNote(""); + setDeliveredTo(""); await data.refresh(); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در ثبت"); @@ -235,6 +247,9 @@ export function ChangeInventoryPage() { }; const submitList = async (t: TransactionType) => { + if (t === "WITHDRAWAL" && !deliveredTo.trim()) { + return toast.error("نام تحویل‌گیرنده اقلام الزامی است"); + } const ops: Record[] = rows .map((r, i): Record | null => { const n = parseNum(r.qty); @@ -244,7 +259,18 @@ export function ChangeInventoryPage() { if (t === "STOCK_COUNT") { return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() }; } - return { transaction_type: t, warehouse_id: whId, 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() }; + return { + transaction_type: t, + warehouse_id: whId, + 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, + delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined, + transaction_date: toServerIso(), + }; }) .filter((x): x is Record => x !== null); if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); @@ -370,6 +396,9 @@ export function ChangeInventoryPage() { if (type === "PURCHASE") { return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete); } + if (type === "WITHDRAWAL") { + return !!(whId && deliveredTo.trim() && rows.some((r) => parseNum(r.qty) > 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 === "FOUND") { @@ -486,7 +515,36 @@ export function ChangeInventoryPage() { - {(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && ( + {type === "WITHDRAWAL" && ( +
+
+ + { setMatTxt(e.target.value); setRows([]); }} + /> + + {data.lookup.materials.map((m) => +
+
+ + setDeliveredTo(e.target.value)} + /> +
+
+ )} + + {(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
unitName(data.lookup.units, id)} + showMaterial={type === "WITHDRAWAL" || !matTxt.trim()} emptyMsg={ !whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید." - : !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود." - : !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید." + : (type !== "WITHDRAWAL" && !matTxt.trim()) ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود." + : !formRecords.length ? (matTxt.trim() ? "برای این عبارت در انبار انتخابی رکوردی یافت نشد." : "هیچ کالای دارای موجودی در این انبار یافت نشد.") : "" } /> @@ -673,6 +732,7 @@ export function ChangeInventoryPage() { setRows={setRows} records={formRecords} unitNameFn={(id) => unitName(data.lookup.units, id)} + showMaterial={!matTxt.trim()} emptyMsg={ !fromId || !toId ? "برای نمایش موجودی‌ها، انبار مبدأ، مقصد و نام متریال را وارد کنید." : !matTxt.trim() ? "نام متریال را تایپ کنید…" @@ -778,57 +838,69 @@ export function ChangeInventoryPage() { ); } -function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: { +function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg, showMaterial = true }: { isCount: boolean; rows: RowDraft[]; setRows: (r: RowDraft[]) => void; - records: { id: string; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record }[]; + records: { id: string; material?: { id: string; name: string }; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record }[]; unitNameFn: (id: number | null | undefined) => string; emptyMsg: string; + showMaterial?: boolean; }) { if (emptyMsg) return
{emptyMsg}
; return ( -
- - - - - - - - - - - - - {records.map((r, i) => ( - - - - - - - +
+
+
مشخصه‌هابرندواحدموجودی فعلی{isCount ? "شمارش‌شده" : "تعداد"}یادداشت
{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}{r.brand?.name || "—"}{unitNameFn(r.unit?.id)}{faNum(r.quantity_number)} - setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))} - /> - - setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))} - /> -
+ + + {showMaterial && } + + + + + + - ))} - -
متریالمشخصه‌هابرندواحدموجودی فعلی{isCount ? "شمارش‌شده" : "تعداد"}یادداشت
-
ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.
+ + + {records.map((r, i) => ( + + {showMaterial && ( + + {r.material?.name || "—"} + + )} + {Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"} + {r.brand?.name || "—"} + {unitNameFn(r.unit?.id)} + {faNum(r.quantity_number)} + + setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))} + /> + + + setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))} + /> + + + ))} + + +
+
+ ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید. + {faNum(records.length)} قلم کالا +
); } diff --git a/frontend/src/pages/Inventory/DailyControlPage.tsx b/frontend/src/pages/Inventory/DailyControlPage.tsx index 560dad9..c87aa54 100644 --- a/frontend/src/pages/Inventory/DailyControlPage.tsx +++ b/frontend/src/pages/Inventory/DailyControlPage.tsx @@ -129,9 +129,10 @@ export function DailyControlPage() { const wGroups = useMemo(() => { const gmap = new Map(); withdrawals.forEach((t) => { - const g = gmap.get(t.inventory_id); + const gKey = `${t.inventory_id}__${t.delivered_to || ""}`; + const g = gmap.get(gKey); if (!g) { - gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t }); + gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t }); } else { g.q += Math.abs(t.quantity_number || 0); if (t.transaction_date > g.last) g.last = t.transaction_date; @@ -324,6 +325,12 @@ export function DailyControlPage() { whName(data.warehouses, g.tx.warehouse_id) ].filter(Boolean).join(" · ")} + {g.tx.delivered_to && ( + + تحویل به: + {g.tx.delivered_to} + + )} {faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)} diff --git a/sql/migration.sql b/sql/migration.sql index f842c5d..e6d2316 100644 --- a/sql/migration.sql +++ b/sql/migration.sql @@ -455,6 +455,7 @@ CREATE TABLE inventory_transactions ( physical_qty NUMERIC(18,6), transfer_pair_id UUID, performed_by TEXT, + delivered_to TEXT, transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(), note TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now()