fix(inventory): envelope transfer responses and add defensive array guards in UI
This commit is contained in:
@@ -74,7 +74,7 @@ def _to_transfer_out(t) -> dict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /inventory/project-transfers — Create inter-project transfer
|
# 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(
|
async def create_transfer(
|
||||||
payload: ProjectTransferCreate,
|
payload: ProjectTransferCreate,
|
||||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
@@ -87,7 +87,7 @@ async def create_transfer(
|
|||||||
caller_role=ctx.role,
|
caller_role=ctx.role,
|
||||||
transferred_by=ctx.display_name,
|
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,
|
direction=direction,
|
||||||
status=status,
|
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
|
# 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(
|
async def get_transfer(
|
||||||
transfer_id: uuid.UUID,
|
transfer_id: uuid.UUID,
|
||||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
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 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:
|
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
|
||||||
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
|
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)
|
# 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(
|
async def update_transfer(
|
||||||
transfer_id: uuid.UUID,
|
transfer_id: uuid.UUID,
|
||||||
payload: ProjectTransferUpdate,
|
payload: ProjectTransferUpdate,
|
||||||
@@ -144,7 +144,7 @@ async def update_transfer(
|
|||||||
caller_project_id=ctx.project_id,
|
caller_project_id=ctx.project_id,
|
||||||
caller_role=ctx.role,
|
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),
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
return await project_transfer_service.delete_project_transfer(
|
res = await project_transfer_service.delete_project_transfer(
|
||||||
db=db,
|
db=db,
|
||||||
transfer_id=transfer_id,
|
transfer_id=transfer_id,
|
||||||
caller_project_id=ctx.project_id,
|
caller_project_id=ctx.project_id,
|
||||||
caller_role=ctx.role,
|
caller_role=ctx.role,
|
||||||
)
|
)
|
||||||
|
return {"data": res}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -181,13 +182,13 @@ async def pm_incoming_transfers(
|
|||||||
direction="incoming",
|
direction="incoming",
|
||||||
status="in_transit",
|
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
|
# 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(
|
async def receive_transfer(
|
||||||
transfer_id: uuid.UUID,
|
transfer_id: uuid.UUID,
|
||||||
payload: ProjectTransferReceive,
|
payload: ProjectTransferReceive,
|
||||||
@@ -202,4 +203,4 @@ async def receive_transfer(
|
|||||||
caller_role=ctx.role,
|
caller_role=ctx.role,
|
||||||
received_by=ctx.display_name,
|
received_by=ctx.display_name,
|
||||||
)
|
)
|
||||||
return _to_transfer_out(transfer)
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|||||||
+36
-11
@@ -243,20 +243,32 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getData<T>(path: string): Promise<T> {
|
async function getData<T>(path: string): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path);
|
const res = await request<any>(path);
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
async function postData<T>(path: string, body: unknown): Promise<T> {
|
async function postData<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
|
const res = await request<any>(path, { method: "POST", body: JSON.stringify(body) });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
async function patchData<T>(path: string, body: unknown): Promise<T> {
|
async function patchData<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
const res = await request<any>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
async function deleteData<T>(path: string): Promise<T> {
|
async function deleteData<T>(path: string): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path, { method: "DELETE" });
|
const res = await request<any>(path, { method: "DELETE" });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
||||||
@@ -1758,7 +1770,13 @@ export const apiV26 = {
|
|||||||
if (direction) q.set("direction", direction);
|
if (direction) q.set("direction", direction);
|
||||||
if (status) q.set("status", status);
|
if (status) q.set("status", status);
|
||||||
const qs = q.toString() ? `?${q.toString()}` : "";
|
const qs = q.toString() ? `?${q.toString()}` : "";
|
||||||
return getData<ProjectTransfer[]>(`/inventory/project-transfers${qs}`);
|
try {
|
||||||
|
const res = await getData<ProjectTransfer[]>(`/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<ProjectTransfer> =>
|
get: async (id: string, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
getData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
getData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||||
@@ -1768,8 +1786,15 @@ export const apiV26 = {
|
|||||||
patchData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch),
|
patchData<ProjectTransfer>(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 }> =>
|
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}`),
|
deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||||
onTheWay: async (projectId?: string | null): Promise<ProjectTransfer[]> =>
|
onTheWay: async (projectId?: string | null): Promise<ProjectTransfer[]> => {
|
||||||
getData<ProjectTransfer[]>(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`),
|
try {
|
||||||
|
const res = await getData<ProjectTransfer[]>(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<ProjectTransfer> =>
|
receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body),
|
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,11 +43,13 @@ export function ArrivalsPage() {
|
|||||||
apiV26.warehouses.list(activeProject),
|
apiV26.warehouses.list(activeProject),
|
||||||
apiV26.projectTransfers.onTheWay(activeProject),
|
apiV26.projectTransfers.onTheWay(activeProject),
|
||||||
]);
|
]);
|
||||||
setItems(arr);
|
setItems(Array.isArray(arr) ? arr : []);
|
||||||
setWarehouses(whs);
|
setWarehouses(Array.isArray(whs) ? whs : []);
|
||||||
setIncomingTransfers(incoming);
|
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
||||||
|
setItems([]);
|
||||||
|
setIncomingTransfers([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -290,19 +292,19 @@ export function ArrivalsPage() {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
||||||
) : !orders.length && !incomingTransfers.length ? (
|
) : !(orders || []).length && !(incomingTransfers || []).length ? (
|
||||||
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* بخش انتقالهای بین کارگاهی در راه */}
|
{/* بخش انتقالهای بین کارگاهی در راه */}
|
||||||
{incomingTransfers.length > 0 && (
|
{(incomingTransfers || []).length > 0 && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-[14px] font-bold text-foreground">🔁 انتقالهای بین کارگاهی در راه ({faNum(incomingTransfers.length)})</h3>
|
<h3 className="text-[14px] font-bold text-foreground">🔁 انتقالهای بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})</h3>
|
||||||
<span className="text-[11.5px] text-muted">— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژهها</span>
|
<span className="text-[11.5px] text-muted">— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژهها</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{incomingTransfers.map((t) => {
|
{(incomingTransfers || []).map((t) => {
|
||||||
const draftAllocs = transferDraftOf(t);
|
const draftAllocs = transferDraftOf(t);
|
||||||
const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
||||||
const targetQty = parseFloat(String(t.quantity));
|
const targetQty = parseFloat(String(t.quantity));
|
||||||
|
|||||||
@@ -216,16 +216,20 @@ export function ChangeInventoryPage() {
|
|||||||
setLoadingTransfers(true);
|
setLoadingTransfers(true);
|
||||||
try {
|
try {
|
||||||
const list = await apiV26.projectTransfers.list(user.project_id, "outgoing");
|
const list = await apiV26.projectTransfers.list(user.project_id, "outgoing");
|
||||||
setTransfers(list);
|
setTransfers(Array.isArray(list) ? list : []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load project transfers:", err);
|
console.error("Failed to load project transfers:", err);
|
||||||
|
setTransfers([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingTransfers(false);
|
setLoadingTransfers(false);
|
||||||
}
|
}
|
||||||
}, [user?.project_id]);
|
}, [user?.project_id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.projects.list().then(setAllProjects).catch(() => {});
|
api.projects
|
||||||
|
.list()
|
||||||
|
.then((res) => setAllProjects(Array.isArray(res) ? res : []))
|
||||||
|
.catch(() => setAllProjects([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -651,7 +655,7 @@ export function ChangeInventoryPage() {
|
|||||||
onChange={(e) => setToProjectId(e.target.value)}
|
onChange={(e) => setToProjectId(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">— انتخاب پروژه مقصد —</option>
|
<option value="">— انتخاب پروژه مقصد —</option>
|
||||||
{allProjects
|
{(allProjects || [])
|
||||||
.filter((p) => p.id !== user?.project_id)
|
.filter((p) => p.id !== user?.project_id)
|
||||||
.map((p) => (
|
.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
@@ -840,11 +844,11 @@ export function ChangeInventoryPage() {
|
|||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{type === "PROJECT_TRANSFER" && matchedStock && matchedStock.length > 0 && (
|
{type === "PROJECT_TRANSFER" && (matchedStock?.length ?? 0) > 0 && (
|
||||||
<div className="field md:col-span-2 rounded-lg border border-line/70 bg-bg/50 p-3 text-xs">
|
<div className="field md:col-span-2 rounded-lg border border-line/70 bg-bg/50 p-3 text-xs">
|
||||||
<div className="font-semibold text-muted mb-1.5">موجودیهای فعلی این متریال در انبار مبدأ ({whName(warehouses, whId)}):</div>
|
<div className="font-semibold text-muted mb-1.5">موجودیهای فعلی این متریال در انبار مبدأ ({whName(warehouses, whId)}):</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{matchedStock.map((s) => (
|
{(matchedStock || []).map((s) => (
|
||||||
<div key={s.id} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[11.5px] shadow-2xs">
|
<div key={s.id} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[11.5px] shadow-2xs">
|
||||||
<span className="font-medium">{s.brand?.name || "بدون برند"}</span>
|
<span className="font-medium">{s.brand?.name || "بدون برند"}</span>
|
||||||
<span className="text-muted"> — {Object.entries(s.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "بدون مشخصه"} — </span>
|
<span className="text-muted"> — {Object.entries(s.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "بدون مشخصه"} — </span>
|
||||||
@@ -940,7 +944,7 @@ export function ChangeInventoryPage() {
|
|||||||
|
|
||||||
{loadingTransfers ? (
|
{loadingTransfers ? (
|
||||||
<div className="py-8 text-center text-xs text-muted">در حال بارگذاری سوابق…</div>
|
<div className="py-8 text-center text-xs text-muted">در حال بارگذاری سوابق…</div>
|
||||||
) : !transfers.length ? (
|
) : !(transfers || []).length ? (
|
||||||
<div className="py-8 text-center text-xs text-muted">هیچ انتقال بین کارگاهی از این پروژه ثبت نشده است.</div>
|
<div className="py-8 text-center text-xs text-muted">هیچ انتقال بین کارگاهی از این پروژه ثبت نشده است.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-3 overflow-x-auto">
|
<div className="mt-3 overflow-x-auto">
|
||||||
@@ -957,7 +961,7 @@ export function ChangeInventoryPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line/50">
|
<tbody className="divide-y divide-line/50">
|
||||||
{transfers.map((t) => {
|
{(transfers || []).map((t) => {
|
||||||
const isInTransit = t.status === "in_transit";
|
const isInTransit = t.status === "in_transit";
|
||||||
return (
|
return (
|
||||||
<tr key={t.id} className="hover:bg-bg/40">
|
<tr key={t.id} className="hover:bg-bg/40">
|
||||||
@@ -1057,7 +1061,7 @@ export function ChangeInventoryPage() {
|
|||||||
value={editToProjectId}
|
value={editToProjectId}
|
||||||
onChange={(e) => setEditToProjectId(e.target.value)}
|
onChange={(e) => setEditToProjectId(e.target.value)}
|
||||||
>
|
>
|
||||||
{allProjects
|
{(allProjects || [])
|
||||||
.filter((p) => p.id !== user?.project_id)
|
.filter((p) => p.id !== user?.project_id)
|
||||||
.map((p) => (
|
.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
|
|||||||
Reference in New Issue
Block a user