diff --git a/backend/app/routers/pm.py b/backend/app/routers/pm.py index 4785297..29726e8 100644 --- a/backend/app/routers/pm.py +++ b/backend/app/routers/pm.py @@ -1,15 +1,16 @@ """PM arrival flow (v2.6) — on-the-way list + confirm arrival + warehouse split. Flow: proc manager purchases (item_decisions.status='chosen' + project_id) -→ item appears on the PM dash as on-the-way → PM confirms each delivery +→ item appears on the arrivals dash as on-the-way → the PM (or the admin / +procurement manager, who is the same person) confirms each delivery (POST /pm/receipts) and splits it across warehouses (free count, typically 3) → inventory posts PURCHASE transactions (idempotent via receipt_allocation_id UNIQUE) → the procurement dashboard flips to arrived once every chosen item is fully received. Rules enforced here: -- Only the PM of the item's purchased project can confirm (role 'pm' + - projects.pm_user_id). +- Callers: role 'pm' (scoped to their single project via projects.pm_user_id) + or 'admin' / 'procurement_manager' (all projects — full visibility). - Partial deliveries allowed (1 item : N receipts); three derived states: on-the-way (sum=0) / partially received / arrived (sum >= ordered). - SUM(allocations) == received_qty; every warehouse belongs to the @@ -18,9 +19,9 @@ Rules enforced here: the canonical unit for auto-posted purchases. - quote.status flips to 'arrived' when ALL chosen items are fully received. -Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm + -X-Project-Id) when no Authorization header — same pattern as the inventory -router. +Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm|admin + +X-Project-Id for pm) when no Authorization header — same pattern as the +inventory router. """ import base64 import uuid @@ -42,6 +43,7 @@ from app.models.procurement import ( LineItem, Project, Quote, + Supplier, ) from app.schemas.procurement import PMReceiptCreate from app.services.inventory_service import canonical_specs_hash @@ -54,33 +56,43 @@ NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000" @dataclass -class PMScope: +class ArrivalScope: + role: str # "pm" | "admin" (admin = admin or procurement_manager) user_id: uuid.UUID | None - project_id: uuid.UUID | None + project_id: uuid.UUID | None # PM's single project; None for admin display_name: str -async def pm_scope(request: Request, db: AsyncSession = Depends(get_db)) -> PMScope: - """Resolve the PM caller — Casdoor JWT first, dev headers as fallback.""" +async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -> ArrivalScope: + """Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback. + + PM → scoped to his own project. Admin / procurement manager → all projects. + """ if request.headers.get("authorization"): ctx: UserContext | None = await get_optional_user(request, db) if ctx is not None: - if ctx.role != "pm": - raise ForbiddenError("این بخش فقط برای مدیر پروژه است") - if ctx.project_id is None: - raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") - return PMScope(user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name) - # Dev fallback: X-User-Role: pm + X-Project-Id + if ctx.role == "pm": + if ctx.project_id is None: + raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") + return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name) + if ctx.role in ("admin", "procurement_manager"): + return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=None, display_name=ctx.display_name) + raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است") + # Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin) role = (request.headers.get("x-user-role") or "").strip().lower() - if role != "pm": - raise ForbiddenError("این بخش فقط برای مدیر پروژه است — هدر X-User-Role: pm را بفرستید") - raw_project = request.headers.get("x-project-id") - if not raw_project: - raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است") - try: - project_id = uuid.UUID(str(raw_project)) - except ValueError: - raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id") + if role == "procurement_manager": + role = "admin" + if role not in ("pm", "admin"): + raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید") + project_id = None + if role == "pm": + raw_project = request.headers.get("x-project-id") + if not raw_project: + raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است") + try: + project_id = uuid.UUID(str(raw_project)) + except ValueError: + raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id") actor = request.headers.get("x-user-name") b64 = request.headers.get("x-user-name-b64") if b64: @@ -95,7 +107,7 @@ async def pm_scope(request: Request, db: AsyncSession = Depends(get_db)) -> PMSc dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm") db.add(dev) await db.flush() - return PMScope(user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی") + return ArrivalScope(role=role, user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی") # --------------------------------------------------------------------------- @@ -126,32 +138,30 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str: # --------------------------------------------------------------------------- -# GET /pm/on-the-way — chosen items not fully received (PM's project) +# GET /pm/on-the-way — chosen items not fully received +# (PM: own project — Admin: all projects) # --------------------------------------------------------------------------- @router.get("/on-the-way") async def pm_on_the_way( - ctx: PMScope = Depends(pm_scope), + ctx: ArrivalScope = Depends(arrivals_scope), db: AsyncSession = Depends(get_db), ): - if ctx.project_id is None: - raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") - decisions = ( - ( - await db.execute( - select(ItemDecision, LineItem, Quote, Material) - .join(LineItem, LineItem.id == ItemDecision.line_item_id) - .join(Quote, Quote.id == LineItem.quote_id) - .join(Material, Material.id == LineItem.material_id) - .where( - ItemDecision.status == "chosen", - ItemDecision.project_id == ctx.project_id, - ) - .order_by(ItemDecision.decided_at.desc().nulls_last()) - ) - ) - .all() + stmt = ( + select(ItemDecision, LineItem, Quote, Material, Supplier, Project) + .join(LineItem, LineItem.id == ItemDecision.line_item_id) + .join(Quote, Quote.id == LineItem.quote_id) + .join(Material, Material.id == LineItem.material_id) + .join(Supplier, Supplier.id == Quote.supplier_id) + .outerjoin(Project, Project.id == ItemDecision.project_id) + .where(ItemDecision.status == "chosen") ) + if ctx.role == "pm": + if ctx.project_id is None: + raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") + stmt = stmt.where(ItemDecision.project_id == ctx.project_id) + decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all() + received_map = await _receipted_quantities(db, [d.ItemDecision.id for d in decisions]) # unit / category lookups for display @@ -170,13 +180,17 @@ async def pm_on_the_way( cats = {m_id: c for m_id, c in rows} out = [] - for d, li, q, m in decisions: + for d, li, q, m, sup, proj in decisions: received = received_map.get(d.id) out.append({ "item_decision_id": str(d.id), "line_item_id": str(li.id), "quote_id": str(q.id), "quote_status": q.status, + "quote_date": to_iso(q.quote_date), + "supplier": {"id": str(sup.id), "name": sup.name}, + "project": {"id": str(proj.id), "name": proj.name} if proj else None, + "decided_at": to_iso(d.decided_at), "material": {"id": str(m.id), "name": m.name, "category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None}, "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, @@ -190,29 +204,36 @@ async def pm_on_the_way( # --------------------------------------------------------------------------- # POST /pm/receipts — confirm arrival + split into warehouses + post PURCHASE +# (PM: own project only — Admin: any project) # --------------------------------------------------------------------------- @router.post("/receipts", status_code=201) async def pm_confirm_receipt( payload: PMReceiptCreate, - ctx: PMScope = Depends(pm_scope), + ctx: ArrivalScope = Depends(arrivals_scope), db: AsyncSession = Depends(get_db), ): - if ctx.project_id is None: - raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") - async with db.begin(): - # --- decision belongs to the PM's project --- + # --- decision must be chosen --- decision = await db.get(ItemDecision, payload.item_decision_id) if decision is None or decision.status != "chosen": raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id") - if decision.project_id != ctx.project_id: + if ctx.role == "pm" and decision.project_id != ctx.project_id: raise ForbiddenError("این قلم متعلق به پروژه شما نیست") li = await db.get(LineItem, decision.line_item_id) quote = await db.get(Quote, li.quote_id) material = await db.get(Material, li.material_id) + # --- limit: total received (previous receipts + this one) must never + # exceed the ordered quantity --- + already_received = (await _receipted_quantities(db, [decision.id])).get(decision.id) or Decimal("0") + if already_received + payload.received_qty > li.quantity: + raise ValidationError( + "تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید", + field="received_qty", + ) + # --- allocations sum + warehouse ownership --- total_alloc = sum((a.quantity for a in payload.allocations), Decimal("0")) if total_alloc != payload.received_qty: @@ -226,8 +247,8 @@ async def pm_confirm_receipt( wh = warehouses.get(a.warehouse_id) if wh is None: raise NotFoundError("انبار یافت نشد", field="allocations.warehouse_id") - if wh.project_id != ctx.project_id: - raise ForbiddenError("انبار انتخاب‌شده متعلق به پروژه شما نیست", field="allocations.warehouse_id") + if wh.project_id != decision.project_id: + raise ForbiddenError("انبار انتخاب‌شده متعلق به پروژه این قلم نیست", field="allocations.warehouse_id") # --- create the receipt (1:N partial deliveries allowed) --- receipt = ItemReceipt( @@ -378,29 +399,27 @@ async def pm_confirm_receipt( # --------------------------------------------------------------------------- -# GET /pm/receipts — delivery history for the PM's project +# GET /pm/receipts — delivery history (PM: own project — Admin: all projects) # --------------------------------------------------------------------------- @router.get("/receipts") async def pm_receipts( - ctx: PMScope = Depends(pm_scope), + ctx: ArrivalScope = Depends(arrivals_scope), db: AsyncSession = Depends(get_db), ): - if ctx.project_id is None: - raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") - rows = ( - ( - await db.execute( - select(ItemReceipt, ItemDecision, LineItem, Material) - .join(ItemDecision, ItemDecision.id == ItemReceipt.item_decision_id) - .join(LineItem, LineItem.id == ItemDecision.line_item_id) - .join(Material, Material.id == LineItem.material_id) - .where(ItemDecision.project_id == ctx.project_id) - .order_by(ItemReceipt.received_at.desc()) - ) - ) - .all() + stmt = ( + select(ItemReceipt, ItemDecision, LineItem, Material, Quote, Project) + .join(ItemDecision, ItemDecision.id == ItemReceipt.item_decision_id) + .join(LineItem, LineItem.id == ItemDecision.line_item_id) + .join(Material, Material.id == LineItem.material_id) + .join(Quote, Quote.id == LineItem.quote_id) + .join(Project, Project.id == ItemDecision.project_id) ) + if ctx.role == "pm": + if ctx.project_id is None: + raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") + stmt = stmt.where(ItemDecision.project_id == ctx.project_id) + rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all() receipts = [r.ItemReceipt for r in rows] alloc_rows = ( ( @@ -420,7 +439,9 @@ async def pm_receipts( return {"data": [ { "receipt_id": str(r.ItemReceipt.id), + "quote_id": str(r.Quote.id), "line_item_id": str(r.LineItem.id), + "project": {"id": str(r.Project.id), "name": r.Project.name}, "material": {"id": str(r.Material.id), "name": r.Material.name}, "received_at": to_iso(r.ItemReceipt.received_at), "received_qty": str(r.ItemReceipt.received_qty), diff --git a/docs/NewERD.txt b/docs/NewERD.txt index 9c90055..025949d 100644 --- a/docs/NewERD.txt +++ b/docs/NewERD.txt @@ -18,7 +18,9 @@ // 4. Offers may span several projects per line item (line_item_projects), // but a PURCHASED item has exactly ONE project, picked by the // procurement manager at purchase → item_decisions.project_id. -// 5. Arrival confirmation belongs to the PM: proc manager purchases → +// 5. Arrival confirmation belongs to the PM (and to the admin / +// procurement manager — the same person — who can also receive and +// monitor all projects): proc manager purchases → // item appears on PM dash as "on the way" → PM confirms arrival and // splits the goods into warehouses (free count, typically 3) → // inventory posts PURCHASE automatically → proc dash updates. diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 52d95f6..a6de9a8 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -184,6 +184,10 @@ export interface PendingArrival { line_item_id: string; quote_id: string; quote_status: string; + quote_date: string; + supplier: { id: string; name: string }; + project: { id: string; name: string } | null; + decided_at: string | null; material: { id: string; name: string; category: { id: string; name: string } | null }; unit: { id: number; code: string; name_fa: string | null } | null; ordered_qty: string; @@ -193,7 +197,9 @@ export interface PendingArrival { export interface ConfirmedReceipt { receipt_id: string; + quote_id: string; line_item_id: string; + project: { id: string; name: string }; material: { id: string; name: string }; received_at: string; received_qty: string; diff --git a/frontend/src/pages/Inventory/ArrivalsPage.tsx b/frontend/src/pages/Inventory/ArrivalsPage.tsx index 733cf29..6538491 100644 --- a/frontend/src/pages/Inventory/ArrivalsPage.tsx +++ b/frontend/src/pages/Inventory/ArrivalsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation } from "react-router-dom"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; @@ -7,96 +7,188 @@ import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { useAuthStore } from "../../store/auth"; import { PanelMenu } from "../../components/PanelMenu"; -import { panelMenuItems, useLogout } from "./panelMenu"; +import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu"; +import { InventoryTopNav } from "./InventoryTopNav"; import { apiV26 } from "../../lib/api"; import type { PendingArrival, Warehouse } from "../../lib/types"; -import { faNum } from "../../lib/formatters"; +import { faNum, fmtDate } from "../../lib/formatters"; + +// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید) +// مدیر پروژه و مدیر تدارکات (ادمین) هر دو دسترسی دارند. +// هر رکورد یک «سفارش» (پیشنهاد خرید) است؛ با «دریافت»، زیررکوردها (اقلام) +// باز می‌شوند و برای هر قلم: تعداد رسیده + ردیف‌های تخصیص (تعداد + انبار پروژه) +// تا مانده تخصیص‌نشده به صفر برسد. +interface AllocRow { key: number; qty: string; whId: string; } +interface ItemDraft { received: string; allocs: AllocRow[]; } -// رسید کالاهای در راه — مدیر پروژه تأیید و بین انبارها تقسیم می‌کند (v2.6) export function ArrivalsPage() { const location = useLocation(); const logout = useLogout(); const user = useAuthStore((s) => s.user); + const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; + const isPM = user?.role === "pm"; + const [items, setItems] = useState([]); const [warehouses, setWarehouses] = useState([]); - const [selected, setSelected] = useState>({}); // decisionId -> warehouseIds - const [qty, setQty] = useState>({}); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); + const [saving, setSaving] = useState(null); // quoteId + const [expanded, setExpanded] = useState>(new Set()); + const [drafts, setDrafts] = useState>({}); + + const load = useCallback(async () => { + try { + const [arr, whs] = await Promise.all([ + apiV26.pm.onTheWay(), + apiV26.warehouses.list(isPM ? user?.project_id || undefined : undefined), + ]); + setItems(arr); + setWarehouses(whs); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه"); + } finally { + setLoading(false); + } + }, [isPM, user?.project_id]); useEffect(() => { - if (user?.role !== "pm") return; - let alive = true; - void (async () => { - try { - const [arr, whs] = await Promise.all([ - apiV26.pm.onTheWay(), - apiV26.warehouses.list(user.project_id || undefined), - ]); - if (!alive) return; - setItems(arr); - setWarehouses(whs); - } catch (err) { - if (alive) toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه"); - } finally { - if (alive) setLoading(false); - } - })(); - return () => { - alive = false; - }; - }, [user?.role, user?.project_id]); + if (!isAdmin && !isPM) return; + setLoading(true); + void load(); + }, [isAdmin, isPM, load]); - const pending = useMemo(() => items.filter((i) => i.state !== "arrived"), [items]); + // سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص) + const orders = useMemo(() => { + const map = new Map(); + for (const it of items) { + if (it.state === "arrived") continue; + const list = map.get(it.quote_id) || []; + list.push(it); + map.set(it.quote_id, list); + } + return [...map.entries()]; + }, [items]); - const toggleWarehouse = (decisionId: string, whId: string) => { - setSelected((s) => { - const cur = s[decisionId] || []; - return { ...s, [decisionId]: cur.includes(whId) ? cur.filter((x) => x !== whId) : [...cur, whId] }; + const orderItems = (quoteId: string) => items.filter((i) => i.quote_id === quoteId); + + const whsOfProject = (projectId: string | null | undefined) => + warehouses.filter((w) => String(w.project_id) === String(projectId)); + + // روزهای گذشته از تأیید (تصمیم خرید) — ستون «روز گذشته» + const daysElapsed = (iso: string | null): number | null => { + if (!iso) return null; + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return null; + return Math.max(0, Math.floor((Date.now() - t) / 86400000)); + }; + + const orderDaysElapsed = (its: PendingArrival[]): number | null => { + const days = its.map((i) => daysElapsed(i.decided_at)).filter((d): d is number => d !== null); + return days.length ? Math.min(...days) : null; + }; + + const toggle = (quoteId: string) => { + setExpanded((s) => { + const next = new Set(s); + if (next.has(quoteId)) next.delete(quoteId); + else next.add(quoteId); + return next; }); }; - const confirm = async (it: PendingArrival) => { - const qtyStr = qty[it.item_decision_id]?.trim(); - const total = parseFloat(qtyStr || ""); - if (!total || total <= 0) { - toast.error("تعداد رسیده را وارد کنید."); + // ---------- draft helpers ---------- + + const draftOf = (it: PendingArrival): ItemDraft => + drafts[it.item_decision_id] || { received: "", allocs: [{ key: Date.now(), qty: "", whId: "" }] }; + + const setReceived = (decisionId: string, received: string) => + setDrafts((d) => ({ ...d, [decisionId]: { ...(d[decisionId] || { allocs: [] }), received } })); + + const addAlloc = (decisionId: string) => + setDrafts((d) => { + const cur = d[decisionId] || { received: "", allocs: [] }; + return { ...d, [decisionId]: { ...cur, allocs: [...cur.allocs, { key: Date.now() + Math.random(), qty: "", whId: "" }] } }; + }); + + const setAlloc = (decisionId: string, key: number, patch: Partial) => + setDrafts((d) => { + const cur = d[decisionId] || { received: "", allocs: [] }; + return { ...d, [decisionId]: { ...cur, allocs: cur.allocs.map((a) => (a.key === key ? { ...a, ...patch } : a)) } }; + }); + + const removeAlloc = (decisionId: string, key: number) => + setDrafts((d) => { + const cur = d[decisionId] || { received: "", allocs: [] }; + return { ...d, [decisionId]: { ...cur, allocs: cur.allocs.filter((a) => a.key !== key) } }; + }); + + const remainingOf = (it: PendingArrival): number => { + const d = draftOf(it); + const received = parseFloat(d.received) || 0; + const allocated = d.allocs.reduce((s, a) => s + ((parseFloat(a.qty) || 0) > 0 ? parseFloat(a.qty) || 0 : 0), 0); + return Math.round((received - allocated) * 10000) / 10000; + }; + + const itemReady = (it: PendingArrival): boolean => { + const d = draftOf(it); + const received = parseFloat(d.received); + if (!(received > 0)) return false; + // محدودیت: تعداد رسیده نمی‌تواند از تعداد سفارش‌شده بیشتر باشد + if (received > parseFloat(it.ordered_qty)) return false; + if (d.allocs.some((a) => !a.whId)) return false; + return remainingOf(it) === 0; + }; + + const orderReady = (quoteId: string): boolean => { + const its = orderItems(quoteId); + return its.length > 0 && its.every((it) => it.state === "arrived" || itemReady(it)); + }; + + // ---------- submit ---------- + + const submitOrder = async (quoteId: string) => { + const its = orderItems(quoteId).filter((it) => it.state !== "arrived"); + if (!orderReady(quoteId)) { + toast.error("برای هر قلم: تعداد رسیده را وارد کنید و تا صفر شدن «مانده تخصیص» ردیف‌ها را پر کنید."); return; } - const allocIds = selected[it.item_decision_id] || []; - if (!allocIds.length) { - toast.error("حداقل یک انبار را برای تقسیم انتخاب کنید."); - return; - } - // تقسیم مساوی بین انبارهای انتخاب‌شده (به‌صورت پیش‌فرض؛ کاربر می‌تواند بعداً اصلاح کند) - const per = Math.round((total / allocIds.length) * 10000) / 10000; - const allocations = allocIds.map((whId, idx) => ({ - warehouse_id: whId, - quantity: idx === allocIds.length - 1 ? +(total - per * (allocIds.length - 1)).toFixed(4) : per, - })); - setSaving(true); + setSaving(quoteId); try { - const res = await apiV26.pm.confirmReceipt({ - item_decision_id: it.item_decision_id, - received_qty: total, - allocations, + for (const it of its) { + const d = draftOf(it); + const allocations = d.allocs + .filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId) + .map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) })); + await apiV26.pm.confirmReceipt({ + item_decision_id: it.item_decision_id, + received_qty: parseFloat(d.received), + allocations, + }); + } + toast.success("رسید سفارش ثبت شد و به انبارها وارد شد ✓"); + setDrafts((d) => { + const next = { ...d }; + its.forEach((it) => delete next[it.item_decision_id]); + return next; }); - toast.success(`رسید ثبت شد — ${res.posted_transactions.length} تراکنش خرید ثبت شد.`); - setItems((list) => list.filter((x) => x.item_decision_id !== it.item_decision_id)); + setExpanded((s) => { + const next = new Set(s); + next.delete(quoteId); + return next; + }); + await load(); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در ثبت رسید"); } finally { - setSaving(false); + setSaving(null); } }; - if (loading) return
; - if (user?.role !== "pm") { + if (!isAdmin && !isPM) { return (
-
} /> +
} /> - +
); @@ -105,62 +197,164 @@ export function ArrivalsPage() { return (
} + title="کالاهای در راه" + sub={isPM ? user?.project_name || "" : "همه پروژه‌ها — مدیر تدارکات"} + menu={isAdmin ? : undefined} + actions={isAdmin || isPM ? : undefined} /> - {!pending.length ? ( - +
+ 🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و + اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. هر سفارش را باز کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید + و آن را بین انبارهای پروژه تقسیم کنید — تا «مانده تخصیص» به صفر برسد. +
+ + {loading ? ( +
در حال بارگذاری…
+ ) : !orders.length ? ( + ) : ( - pending.map((it) => ( -
-
- {it.material.name} - - {it.state === "on_the_way" ? "در راه" : "رسید ناقص"} - - - سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""} — رسیده: {faNum(parseFloat(it.received_qty))} - -
-
-
- - setQty((q) => ({ ...q, [it.item_decision_id]: e.target.value }))} - placeholder={it.ordered_qty} - /> + orders.map(([quoteId, its]) => { + const first = its[0]; + const open = expanded.has(quoteId); + const ready = orderReady(quoteId); + const days = orderDaysElapsed(its); + return ( +
+ {/* سربرگ سفارش */} +
+
+
+ 🧾 {first.supplier.name} + {faNum(its.length)} قلم + + {first.state === "partially_received" ? "رسید ناقص" : "در راه"} + + {days !== null && ( + = 7 ? "offered" : "muted"}> + روز گذشته: {faNum(days)} روز + + )} +
+
+ تاریخ پیشنهاد: {fmtDate(first.quote_date)} + پروژه: {first.project?.name || "—"} +
+
+
-
- -
- {warehouses.map((w) => { - const on = (selected[it.item_decision_id] || []).includes(w.id); + + {/* اقلام سفارش */} + {open && ( +
+ {its.map((it) => { + const d = draftOf(it); + const remaining = remainingOf(it); + const whs = whsOfProject(it.project?.id); + const itemDays = daysElapsed(it.decided_at); return ( - +
+
+ {it.material.name} + + سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""} + + 0 ? "offered" : "outline"}> + رسیده: {faNum(parseFloat(it.received_qty))} + + {itemDays !== null && ( + روز گذشته: {faNum(itemDays)} + )} +
+ +
+
+ + setReceived(it.item_decision_id, e.target.value)} + placeholder={it.ordered_qty} + /> +
+
+ +
+ {d.allocs.map((a) => ( +
+ setAlloc(it.item_decision_id, a.key, { qty: e.target.value })} + /> + + +
+ ))} + +
+
+
+ +
+ + مانده تخصیص:{" "} + + {faNum(remaining)} {it.unit?.name_fa || it.unit?.code || ""} + + + {parseFloat(d.received) > parseFloat(it.ordered_qty) && ( + تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید + )} + {remaining < 0 && تخصیص بیش از تعداد رسیده است!} + {remaining > 0 && با ردیف‌های تخصیص، مانده را به صفر برسانید.} +
+
); })} + +
+ + برای ثبت رسید کل سفارش، همه اقلام باید «مانده تخصیص صفر» داشته باشند. + + +
-
+ )}
-
- -
-
- )) + ); + }) )}