From e56001827351e85aaca37a4e65baea99390504f7 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Wed, 23 Sep 2026 22:46:07 +0330 Subject: [PATCH] fix(inventory): envelope transfer responses and add defensive array guards in UI --- backend/app/routers/project_transfers.py | 23 ++++----- frontend/src/lib/api.ts | 47 ++++++++++++++----- frontend/src/pages/Inventory/ArrivalsPage.tsx | 16 ++++--- .../pages/Inventory/ChangeInventoryPage.tsx | 20 ++++---- 4 files changed, 69 insertions(+), 37 deletions(-) diff --git a/backend/app/routers/project_transfers.py b/backend/app/routers/project_transfers.py index e9f9e83..d297584 100644 --- a/backend/app/routers/project_transfers.py +++ b/backend/app/routers/project_transfers.py @@ -74,7 +74,7 @@ def _to_transfer_out(t) -> dict: # --------------------------------------------------------------------------- # POST /inventory/project-transfers — Create inter-project transfer # --------------------------------------------------------------------------- -@router.post("/inventory/project-transfers", status_code=201, response_model=ProjectTransferOut) +@router.post("/inventory/project-transfers", status_code=201) async def create_transfer( payload: ProjectTransferCreate, ctx: ArrivalScope = Depends(arrivals_scope), @@ -87,7 +87,7 @@ async def create_transfer( caller_role=ctx.role, transferred_by=ctx.display_name, ) - return _to_transfer_out(transfer) + return {"data": _to_transfer_out(transfer)} # --------------------------------------------------------------------------- @@ -108,13 +108,13 @@ async def list_transfers( direction=direction, status=status, ) - return [_to_transfer_out(t) for t in transfers] + return {"data": [_to_transfer_out(t) for t in transfers]} # --------------------------------------------------------------------------- # GET /inventory/project-transfers/{id} — Single transfer details # --------------------------------------------------------------------------- -@router.get("/inventory/project-transfers/{transfer_id}", response_model=ProjectTransferOut) +@router.get("/inventory/project-transfers/{transfer_id}") async def get_transfer( transfer_id: uuid.UUID, ctx: ArrivalScope = Depends(arrivals_scope), @@ -124,13 +124,13 @@ async def get_transfer( if ctx.role == "pm" and ctx.project_id is not None: if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id: raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید") - return _to_transfer_out(transfer) + return {"data": _to_transfer_out(transfer)} # --------------------------------------------------------------------------- # PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only) # --------------------------------------------------------------------------- -@router.patch("/inventory/project-transfers/{transfer_id}", response_model=ProjectTransferOut) +@router.patch("/inventory/project-transfers/{transfer_id}") async def update_transfer( transfer_id: uuid.UUID, payload: ProjectTransferUpdate, @@ -144,7 +144,7 @@ async def update_transfer( caller_project_id=ctx.project_id, caller_role=ctx.role, ) - return _to_transfer_out(transfer) + return {"data": _to_transfer_out(transfer)} # --------------------------------------------------------------------------- @@ -156,12 +156,13 @@ async def delete_transfer( ctx: ArrivalScope = Depends(arrivals_scope), db: AsyncSession = Depends(get_db), ): - return await project_transfer_service.delete_project_transfer( + res = await project_transfer_service.delete_project_transfer( db=db, transfer_id=transfer_id, caller_project_id=ctx.project_id, caller_role=ctx.role, ) + return {"data": res} # --------------------------------------------------------------------------- @@ -181,13 +182,13 @@ async def pm_incoming_transfers( direction="incoming", status="in_transit", ) - return [_to_transfer_out(t) for t in transfers] + return {"data": [_to_transfer_out(t) for t in transfers]} # --------------------------------------------------------------------------- # POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival # --------------------------------------------------------------------------- -@router.post("/inventory/project-transfers/{transfer_id}/receive", response_model=ProjectTransferOut) +@router.post("/inventory/project-transfers/{transfer_id}/receive") async def receive_transfer( transfer_id: uuid.UUID, payload: ProjectTransferReceive, @@ -202,4 +203,4 @@ async def receive_transfer( caller_role=ctx.role, received_by=ctx.display_name, ) - return _to_transfer_out(transfer) + return {"data": _to_transfer_out(transfer)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fc08b31..b65532a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -243,20 +243,32 @@ async function request(path: string, init?: RequestInit): Promise { } async function getData(path: string): Promise { - const res = await request<{ data: T }>(path); - return res.data; + const res = await request(path); + if (res && typeof res === "object" && "data" in res) { + return res.data as T; + } + return res as T; } async function postData(path: string, body: unknown): Promise { - const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) }); - return res.data; + const res = await request(path, { method: "POST", body: JSON.stringify(body) }); + if (res && typeof res === "object" && "data" in res) { + return res.data as T; + } + return res as T; } async function patchData(path: string, body: unknown): Promise { - const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); - return res.data; + const res = await request(path, { method: "PATCH", body: JSON.stringify(body) }); + if (res && typeof res === "object" && "data" in res) { + return res.data as T; + } + return res as T; } async function deleteData(path: string): Promise { - const res = await request<{ data: T }>(path, { method: "DELETE" }); - return res.data; + const res = await request(path, { method: "DELETE" }); + if (res && typeof res === "object" && "data" in res) { + return res.data as T; + } + return res as T; } // آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر @@ -1758,7 +1770,13 @@ export const apiV26 = { if (direction) q.set("direction", direction); if (status) q.set("status", status); const qs = q.toString() ? `?${q.toString()}` : ""; - return getData(`/inventory/project-transfers${qs}`); + try { + const res = await getData(`/inventory/project-transfers${qs}`); + return Array.isArray(res) ? res : []; + } catch (err) { + console.error("Failed to fetch project transfers:", err); + return []; + } }, get: async (id: string, projectId?: string | null): Promise => getData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`), @@ -1768,8 +1786,15 @@ export const apiV26 = { patchData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch), delete: async (id: string, projectId?: string | null): Promise<{ ok: boolean; message: string }> => deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`), - onTheWay: async (projectId?: string | null): Promise => - getData(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`), + onTheWay: async (projectId?: string | null): Promise => { + try { + const res = await getData(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`); + return Array.isArray(res) ? res : []; + } catch (err) { + console.error("Failed to fetch on-the-way transfers:", err); + return []; + } + }, receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise => postData(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body), }, diff --git a/frontend/src/pages/Inventory/ArrivalsPage.tsx b/frontend/src/pages/Inventory/ArrivalsPage.tsx index 8b4a722..b8a0ad7 100644 --- a/frontend/src/pages/Inventory/ArrivalsPage.tsx +++ b/frontend/src/pages/Inventory/ArrivalsPage.tsx @@ -43,11 +43,13 @@ export function ArrivalsPage() { apiV26.warehouses.list(activeProject), apiV26.projectTransfers.onTheWay(activeProject), ]); - setItems(arr); - setWarehouses(whs); - setIncomingTransfers(incoming); + setItems(Array.isArray(arr) ? arr : []); + setWarehouses(Array.isArray(whs) ? whs : []); + setIncomingTransfers(Array.isArray(incoming) ? incoming : []); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه"); + setItems([]); + setIncomingTransfers([]); } finally { setLoading(false); } @@ -290,19 +292,19 @@ export function ArrivalsPage() { {loading ? (
در حال بارگذاری…
- ) : !orders.length && !incomingTransfers.length ? ( + ) : !(orders || []).length && !(incomingTransfers || []).length ? ( ) : ( <> {/* بخش انتقال‌های بین کارگاهی در راه */} - {incomingTransfers.length > 0 && ( + {(incomingTransfers || []).length > 0 && (
-

🔁 انتقال‌های بین کارگاهی در راه ({faNum(incomingTransfers.length)})

+

🔁 انتقال‌های بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})

— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژه‌ها
- {incomingTransfers.map((t) => { + {(incomingTransfers || []).map((t) => { const draftAllocs = transferDraftOf(t); const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0)); const targetQty = parseFloat(String(t.quantity)); diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index 7109033..6bdf628 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -216,16 +216,20 @@ export function ChangeInventoryPage() { setLoadingTransfers(true); try { const list = await apiV26.projectTransfers.list(user.project_id, "outgoing"); - setTransfers(list); + setTransfers(Array.isArray(list) ? list : []); } catch (err) { console.error("Failed to load project transfers:", err); + setTransfers([]); } finally { setLoadingTransfers(false); } }, [user?.project_id]); useEffect(() => { - api.projects.list().then(setAllProjects).catch(() => {}); + api.projects + .list() + .then((res) => setAllProjects(Array.isArray(res) ? res : [])) + .catch(() => setAllProjects([])); }, []); useEffect(() => { @@ -651,7 +655,7 @@ export function ChangeInventoryPage() { onChange={(e) => setToProjectId(e.target.value)} > - {allProjects + {(allProjects || []) .filter((p) => p.id !== user?.project_id) .map((p) => (
- {type === "PROJECT_TRANSFER" && matchedStock && matchedStock.length > 0 && ( + {type === "PROJECT_TRANSFER" && (matchedStock?.length ?? 0) > 0 && (
موجودی‌های فعلی این متریال در انبار مبدأ ({whName(warehouses, whId)}):
- {matchedStock.map((s) => ( + {(matchedStock || []).map((s) => (
{s.brand?.name || "بدون برند"} — {Object.entries(s.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "بدون مشخصه"} — @@ -940,7 +944,7 @@ export function ChangeInventoryPage() { {loadingTransfers ? (
در حال بارگذاری سوابق…
- ) : !transfers.length ? ( + ) : !(transfers || []).length ? (
هیچ انتقال بین کارگاهی از این پروژه ثبت نشده است.
) : (
@@ -957,7 +961,7 @@ export function ChangeInventoryPage() { - {transfers.map((t) => { + {(transfers || []).map((t) => { const isInTransit = t.status === "in_transit"; return ( @@ -1057,7 +1061,7 @@ export function ChangeInventoryPage() { value={editToProjectId} onChange={(e) => setEditToProjectId(e.target.value)} > - {allProjects + {(allProjects || []) .filter((p) => p.id !== user?.project_id) .map((p) => (