refactor(outbound): 出库单据与领用物料合并成一张表
这两者本来就是同一件事(这台设备对应 MOM 的哪些出库单、领了哪些料),
却因为粒度不同被拆成两张表、界面上两张卡:用户要面对两个入口两个删除按钮,
还会问「我在那边挂的怎么这边看不见」。更糟的是**单据级那张没有 mom_line_id,
挂上去的料根本报不了废**。
- 新建 product_outbound_materials,统一到**明细级**(只有它带 mom_line_id,
而报废要用它定位)。单据级信息(申请单号/备注/撤回)作为冗余列落在每条明细上。
task_id 改为可空 —— 任务只是溯源信息,不再是组织维度,展示/报废/删除按设备走。
- 接口从 7 个收敛成 3 个(GET/POST/DELETE /products/{id}/outbound-materials,
外加整单删 by-order)。任务级那套连同 TaskResponse.outbound_materials 一起删掉:
保留第二个入口只会让「同一个东西两个地方」重新长出来。
- MOM 回调存档改为按 outbound_no 去 MOM **现查明细**逐行落 —— 不查的话
这台设备「领了什么料」永远是空的,也就报不了废。查不到时退化成单据级存档,
宁可显示「有这张单但看不到明细」,也不要静默丢掉这张单。
- 扫码响应补 outbound_materials(附「谁挂上去的」中文名,服务端解析)。
⚠️ 依赖 task_tree_loader 的 selectinload —— 异步 session 下懒加载会
MissingGreenlet。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
This commit is contained in:
486
frontend/src/components/scan/OutboundRecordsCard.tsx
Normal file
486
frontend/src/components/scan/OutboundRecordsCard.tsx
Normal file
@ -0,0 +1,486 @@
|
||||
/** 出库单据卡 — 这台设备对应 MOM 的哪些出库单、领了哪些料
|
||||
*
|
||||
* ⚠️ 这里曾经是**两张卡**:一张「出库单据」(读 product_outbounds,单据级)
|
||||
* 加一张「领用物料」(读 task_outbound_materials,明细级)。它们本来就是
|
||||
* 同一件事,拆成两张的后果是:用户要面对两个入口、两个删除按钮,还会问
|
||||
* 「我在那边挂的怎么这边看不见」;更糟的是**单据级那张没有 mom_line_id,
|
||||
* 挂上去的料报不了废**。
|
||||
* 后端已把两张表合并成 product_outbound_materials,本组件随之合并成一张。
|
||||
* 不要因为「单据」和「物料」听起来不同就再拆开 —— 它们是同一件事。
|
||||
*
|
||||
* 展示形态(与移动端 pages/material/index.vue 保持一致):
|
||||
* 按**出库单号**分组,一行一张单,点开看明细。不按任务分组 ——
|
||||
* 任务名当分组抬头在现场看不懂,而且任务只是溯源信息。
|
||||
*
|
||||
* 已撤回的记录**照常显示**(灰底 + 删除线 + 「已撤回」),因为「出过又撤了」
|
||||
* 本身就是要看得见的历史 —— 后端也不过滤掉。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Modal } from "antd";
|
||||
import { ChevronDown, ChevronRight, Loader2, Package, Plus, Trash2, Truck, Undo2 } from "lucide-react";
|
||||
|
||||
import MomOutboundPicker from "../admin/MomOutboundPicker";
|
||||
import { useToast } from "../ui/Toast";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import {
|
||||
getProductOutboundMaterials,
|
||||
listProductScraps,
|
||||
mountProductOutboundMaterials,
|
||||
removeProductOutboundMaterial,
|
||||
removeProductOutboundOrder,
|
||||
submitProductScrap,
|
||||
} from "../../services/productApi";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
import type { ProductOutboundMaterial, ProductScrap } from "../../types/api";
|
||||
|
||||
interface OutboundRecordsCardProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
/** 时间 → 本地可读格式。MOM 的 outbound_time 缺失时回退到本行写入时间 */
|
||||
function fmtTime(iso: string | null, fallback: string): string {
|
||||
const d = new Date(iso || fallback);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 幂等锚点:**打开弹窗时生成一次**,重试复用 —— 换新的会在 MOM 里多报一张单 */
|
||||
function makeTrackRef(): string {
|
||||
const d = new Date();
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
return `SCRAP-${ts}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** 按出库单号分组,保持后端给的顺序(已按出库时间倒序) */
|
||||
function groupByOrder(list: ProductOutboundMaterial[]) {
|
||||
const map = new Map<string, ProductOutboundMaterial[]>();
|
||||
for (const m of list) {
|
||||
const no = m.outbound_no || "(无单号)";
|
||||
if (!map.has(no)) map.set(no, []);
|
||||
map.get(no)!.push(m);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}
|
||||
|
||||
export default function OutboundRecordsCard({ productId }: OutboundRecordsCardProps) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [records, setRecords] = useState<ProductOutboundMaterial[] | null>(null);
|
||||
const [scraps, setScraps] = useState<ProductScrap[]>([]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
// 挂载不需要先选任务:任务只是溯源信息,展示/报废/删除都按设备走。
|
||||
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来 —— 够了。
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [appending, setAppending] = useState(false);
|
||||
|
||||
const [removeTarget, setRemoveTarget] = useState<ProductOutboundMaterial | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
// 整单删除:界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下
|
||||
const [removeOrder, setRemoveOrder] = useState<string | null>(null);
|
||||
|
||||
const [scrapTarget, setScrapTarget] = useState<ProductOutboundMaterial | null>(null);
|
||||
const [scrapForm, setScrapForm] = useState({ quantity: "", reason: "", confirmed: false, trackRef: "" });
|
||||
const [scrapSubmitting, setScrapSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [mats, sc] = await Promise.all([
|
||||
getProductOutboundMaterials(productId),
|
||||
listProductScraps(productId).catch(() => [] as ProductScrap[]),
|
||||
]);
|
||||
setRecords(mats);
|
||||
setScraps(sc);
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "加载出库单据失败"), "error");
|
||||
setRecords([]); // 失败也要脱离加载态,否则一直转圈
|
||||
}
|
||||
}, [productId, toast]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const list = records ?? [];
|
||||
|
||||
async function handleAppend(momLineIds: number[]) {
|
||||
if (momLineIds.length === 0) {
|
||||
setPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
setAppending(true);
|
||||
try {
|
||||
// 接口返回该设备当前**全部**出库明细,直接整体覆盖。
|
||||
// 不传 task_id —— 挂载不需要挂在某条任务上(任务只是溯源,可空)。
|
||||
setRecords(await mountProductOutboundMaterials(productId, momLineIds));
|
||||
toast("已添加出库单", "success");
|
||||
setPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "添加出库单失败"), "error");
|
||||
} finally {
|
||||
setAppending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemoveOrder() {
|
||||
if (!removeOrder) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
setRecords(await removeProductOutboundOrder(productId, removeOrder));
|
||||
toast("已删除整张出库单", "success");
|
||||
setRemoveOrder(null);
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "删除失败"), "error");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemove() {
|
||||
if (!removeTarget) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
setRecords(await removeProductOutboundMaterial(productId, removeTarget.id));
|
||||
toast("已删除", "success");
|
||||
setRemoveTarget(null);
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "删除失败"), "error");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 报废 ----
|
||||
const myName = (user?.display_name ?? "").trim();
|
||||
const openScrap = (m: ProductOutboundMaterial) => {
|
||||
setScrapTarget(m);
|
||||
setScrapForm({
|
||||
quantity: String(m.quantity ?? ""),
|
||||
reason: "",
|
||||
confirmed: false,
|
||||
trackRef: makeTrackRef(), // 打开时生成一次,重试复用
|
||||
});
|
||||
};
|
||||
|
||||
// 代报判据:这条料的领用人不是当前登录人。
|
||||
// ⚠️ 这是**防误操作**不是权限 —— 料的归属是设备不是人,后端不会因此拒绝
|
||||
const isProxy = !!scrapTarget?.consumer_name && !!myName
|
||||
&& scrapTarget.consumer_name !== myName;
|
||||
|
||||
const doScrap = async () => {
|
||||
const m = scrapTarget;
|
||||
if (!m || m.mom_line_id == null) return;
|
||||
const qty = Number(scrapForm.quantity);
|
||||
if (!qty || qty <= 0) return toast("请填写报废数量", "error");
|
||||
if (m.quantity != null && qty > m.quantity) return toast(`不能超过 ${m.quantity}`, "error");
|
||||
if (isProxy && !scrapForm.confirmed) return toast("请先勾选确认代报", "error");
|
||||
|
||||
setScrapSubmitting(true);
|
||||
try {
|
||||
await submitProductScrap(productId, {
|
||||
mom_line_id: m.mom_line_id,
|
||||
quantity: qty,
|
||||
reason: scrapForm.reason.trim() || null,
|
||||
track_ref: scrapForm.trackRef,
|
||||
});
|
||||
toast("已提交,待主管审批", "success");
|
||||
setScrapTarget(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
// 不关弹窗、不换 trackRef:改完数量重试走的是同一个幂等键
|
||||
toast(extractErrorMessage(err, "报废提交失败"), "error");
|
||||
} finally {
|
||||
setScrapSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groups = groupByOrder(list);
|
||||
const toggle = (no: string) => setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(no)) next.delete(no); else next.add(no);
|
||||
return next;
|
||||
});
|
||||
|
||||
// 直接开选择器 —— 不再先问「挂到哪条任务」。
|
||||
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来,已经够了。
|
||||
const openPicker = () => setPickerOpen(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Truck className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-800">出库单据</h3>
|
||||
{list.length > 0 && (
|
||||
<span className="text-xs text-gray-400">{groups.length} 张单 / {list.length} 条料</span>
|
||||
)}
|
||||
<button
|
||||
onClick={openPicker}
|
||||
className="ml-auto flex items-center gap-1 rounded-lg border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
添加出库单
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{records === null ? (
|
||||
<p className="flex items-center justify-center gap-2 py-4 text-xs text-gray-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />加载中…
|
||||
</p>
|
||||
) : list.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-gray-400">
|
||||
暂无关联的出库单。建档时没挂、或本功能上线前出库的设备都属于这种情况,
|
||||
可用右上角「添加出库单」补挂。
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map(([no, items]) => {
|
||||
const open = expanded.has(no);
|
||||
const revoked = items[0]?.is_revoked;
|
||||
return (
|
||||
<div key={no} className={`overflow-hidden rounded-lg border ${revoked ? "border-gray-200 bg-gray-50" : "border-gray-100"}`}>
|
||||
<div onClick={() => toggle(no)} className="cursor-pointer px-2.5 py-2 transition-colors hover:bg-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-sm font-medium break-all ${revoked ? "text-gray-400 line-through" : "text-gray-800"}`}>
|
||||
{no}
|
||||
</span>
|
||||
{/* 撤回标记:与后端「撤回只置位不删行」一致,不让它凭空消失 */}
|
||||
{revoked && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 rounded-full bg-gray-200 px-2 py-0.5 text-[10px] font-bold text-gray-600">
|
||||
<Undo2 className="h-3 w-3" />已撤回
|
||||
</span>
|
||||
)}
|
||||
{/* 不展示出库类型(用途)—— 现场只关心「这台设备挂了哪张单、
|
||||
谁挂的、谁出的库」,多一个「内部领用」徽标只是噪音 */}
|
||||
<span className="ml-auto shrink-0 text-xs text-gray-400">
|
||||
{fmtTime(items[0]?.outbound_time ?? null, items[0]?.created_at ?? "")}
|
||||
</span>
|
||||
{/* 整单删除:挂错了要能一次摘掉。只在**全部**是人工挂的时显示 ——
|
||||
含 MOM 回调存档的单不给删(后端也拦),那是系统事实 */}
|
||||
{items.every((m) => m.source === "manual") && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRemoveOrder(no); }}
|
||||
title="删除整张出库单"
|
||||
className="shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3.5 text-xs text-gray-400">
|
||||
{items[0]?.consumer_name && <span>领用 {items[0].consumer_name}</span>}
|
||||
{items[0]?.operator_name && <span>经办 {items[0].operator_name}</span>}
|
||||
{/* 谁挂上去的 —— 现场要能追责/问人;只记在库里不显示等于没记 */}
|
||||
{(items[0]?.added_by_name || items[0]?.added_by) && (
|
||||
<span className="text-gray-500">
|
||||
挂载 {items[0].added_by_name || items[0].added_by}
|
||||
</span>
|
||||
)}
|
||||
<span>{items.length} 条物料</span>
|
||||
<span className="ml-auto flex items-center gap-0.5 text-blue-600">
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
{open ? "收起明细" : "物料明细"}
|
||||
</span>
|
||||
</div>
|
||||
{items[0]?.remark && <p className="mt-1 text-xs text-gray-400">{items[0].remark}</p>}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="divide-y divide-gray-50 border-t border-gray-100 px-2.5">
|
||||
{items.map((m) => {
|
||||
// 没有明细行 id = MOM 回调只存了单据、查不到明细 → 报不了废
|
||||
const canScrap = m.mom_line_id != null;
|
||||
return (
|
||||
<div key={m.id} className="flex items-center gap-2 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-medium text-gray-800">
|
||||
{m.material_name || (canScrap ? "(未命名物料)" : "MOM 出库回调存档")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-400">
|
||||
{m.spec_model && <span>{m.spec_model} · </span>}
|
||||
{canScrap ? `×${m.quantity}` : "无明细"}
|
||||
{m.warehouse_location && <span> · 库位 {m.warehouse_location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* 只有带 mom_line_id 的才能报废 —— MOM 要用它定位到具体明细行 */}
|
||||
{canScrap && (
|
||||
<button
|
||||
onClick={() => openScrap(m)}
|
||||
className="shrink-0 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-100"
|
||||
>
|
||||
报废
|
||||
</button>
|
||||
)}
|
||||
{/* 只有人工挂的可删:webhook 存档是系统事实,要撤得去 MOM 撤回 */}
|
||||
{m.source === "manual" && (
|
||||
<button
|
||||
onClick={() => setRemoveTarget(m)}
|
||||
title="删除这条出库明细(挂错了)"
|
||||
className="shrink-0 rounded-lg border border-gray-200 p-1.5 text-gray-400 transition-colors hover:border-gray-300 hover:bg-gray-50 hover:text-gray-600"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ♻️ 报废记录:状态与金额由后端实时回查 MOM */}
|
||||
{scraps.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-100 pt-3">
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-gray-600">
|
||||
<Package className="h-3.5 w-3.5" />报废记录({scraps.length})
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{scraps.map((s) => (
|
||||
<div key={s.id} className="rounded-lg border border-gray-100 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-gray-800">
|
||||
{s.material_name || "(未命名物料)"}
|
||||
</span>
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${
|
||||
s.mom_executed ? "bg-emerald-100 text-emerald-700"
|
||||
: (s.mom_status === 2 || s.mom_status === 4) ? "bg-gray-200 text-gray-600"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}>
|
||||
{s.mom_status_label || "状态未知"}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">×{s.quantity}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3.5 text-xs text-gray-400">
|
||||
<span>报废单 {s.scrap_request_no}</span>
|
||||
{s.submitted_by && <span>提交人 {s.submitted_by}</span>}
|
||||
{/* ★ 只有执行过才有金额。未执行显示「—」不显示 0 ——
|
||||
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 */}
|
||||
{s.mom_executed && (
|
||||
<span className="text-gray-600">损失 ¥{Number(s.total_loss ?? 0).toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
{s.reason && <p className="mt-1 text-xs text-gray-400">{s.reason}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 选择器:已挂过的单据会在里面显示「已挂载」且不可再选。
|
||||
提交期间选择器保持打开(按钮转圈),成功后由 handleAppend 关闭 ——
|
||||
比先关弹窗再等结果更不容易让人以为没生效。 */}
|
||||
<MomOutboundPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={(ids) => handleAppend(ids)}
|
||||
submitting={appending}
|
||||
existingOrderNos={list.map((m) => m.outbound_no)}
|
||||
/>
|
||||
|
||||
{/* 整单删除确认 */}
|
||||
<Modal
|
||||
open={!!removeOrder}
|
||||
title="删除整张出库单"
|
||||
centered
|
||||
onCancel={() => !removing && setRemoveOrder(null)}
|
||||
onOk={doRemoveOrder}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: removing }}
|
||||
cancelButtonProps={{ disabled: removing }}
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
把出库单 <span className="font-mono font-medium">{removeOrder}</span> 从这台设备上整张摘掉?
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
只解除 Track 这边的挂载关系,<span className="font-medium">不会动 MOM 里的出库单本身</span>,
|
||||
已提交的报废记录也不受影响。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{/* 报废:数量 + 说明。分类不让人选 —— 走这条路的料按定义就是生产损耗 */}
|
||||
<Modal
|
||||
open={!!scrapTarget}
|
||||
title="报废"
|
||||
centered
|
||||
onCancel={() => !scrapSubmitting && setScrapTarget(null)}
|
||||
onOk={doScrap}
|
||||
okText="提交报废"
|
||||
cancelText="取消"
|
||||
confirmLoading={scrapSubmitting}
|
||||
okButtonProps={{ disabled: isProxy && !scrapForm.confirmed }}
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
{scrapTarget?.material_name || "(未命名物料)"}
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
{scrapTarget?.spec_model} | 原领用人 {scrapTarget?.consumer_name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">
|
||||
报废数量 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={scrapForm.quantity}
|
||||
onChange={(e) => setScrapForm((f) => ({ ...f, quantity: e.target.value }))}
|
||||
placeholder={`最多 ${scrapTarget?.quantity ?? ""}`}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
|
||||
disabled={scrapSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">原因说明</label>
|
||||
<textarea
|
||||
value={scrapForm.reason}
|
||||
onChange={(e) => setScrapForm((f) => ({ ...f, reason: e.target.value }))}
|
||||
placeholder="例如:测试时跌落,外壳磕裂"
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
|
||||
disabled={scrapSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{/* 代报确认:报的不是自己领的料时多一道(防误操作,不是权限) */}
|
||||
{isProxy && (
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scrapForm.confirmed}
|
||||
onChange={(e) => setScrapForm((f) => ({ ...f, confirmed: e.target.checked }))}
|
||||
className="mt-0.5 accent-amber-600"
|
||||
/>
|
||||
<span className="text-xs text-amber-800">
|
||||
这条料不是你领的({scrapTarget?.consumer_name} 领用),确认代报?
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Modal
|
||||
open={!!removeTarget}
|
||||
title="删除出库明细"
|
||||
centered
|
||||
onCancel={() => !removing && setRemoveTarget(null)}
|
||||
onOk={doRemove}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: removing }}
|
||||
cancelButtonProps={{ disabled: removing }}
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
把「{removeTarget?.material_name || "此物料"}」从这台设备上摘掉?
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
只解除 Track 这边的挂载关系,<span className="font-medium">不会动 MOM 里的出库单本身</span>,
|
||||
已提交的报废记录也不受影响。
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
import api from "../../services/api";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import CreateProductDialog from "./CreateProductDialog";
|
||||
import OutboundRecordsCard from "../../components/scan/OutboundRecordsCard";
|
||||
import {
|
||||
getLabelPreview, executePrint,
|
||||
} from "../../services/printApi";
|
||||
@ -353,13 +354,24 @@ export default function AdminProductsPage() {
|
||||
{editTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !editSaving && setEditTarget(null)} />
|
||||
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
{/* 放宽到 max-w-lg 并限高:下面要嵌「出库单据」卡,max-w-sm 装不下 */}
|
||||
<div className="relative z-10 mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800">编辑产品</h3><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
|
||||
<p className="mb-4 font-mono text-sm text-gray-500">产品ID: {editTarget.serial_number}</p>
|
||||
<div className="space-y-4">
|
||||
<div><label className="mb-1 block text-sm font-medium text-gray-700">订单编号</label><input value={editOrderNo} onChange={e => setEditOrderNo(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
|
||||
<div><label className="mb-1 block text-sm font-medium text-gray-700">产品序列号</label><input value={editExternalSerial} onChange={e => setEditExternalSerial(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
|
||||
</div>
|
||||
|
||||
{/* 出库单据 —— **只有这一张卡**。
|
||||
它合并了原先的「出库单据」与「领用物料」两张:那两者本来就是
|
||||
同一件事(这台设备对应 MOM 的哪些单、领了哪些料),拆开只会让人
|
||||
对着两个入口两个删除按钮发懵。卡内自带「添加出库单」入口,
|
||||
展开明细可报废/删除。 */}
|
||||
<div className="mt-5">
|
||||
<OutboundRecordsCard productId={editTarget.id} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg border px-4 py-2 text-sm text-gray-600">取消</button><button onClick={handleSaveEdit} disabled={editSaving} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{editSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}<Save className="h-3.5 w-3.5" />保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import api from "./api";
|
||||
import type { ProductScanResponse } from "../types/api";
|
||||
import type {
|
||||
ProductOutboundMaterial,
|
||||
ProductScanResponse,
|
||||
ProductScrap,
|
||||
} from "../types/api";
|
||||
|
||||
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
|
||||
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
|
||||
@ -8,3 +12,114 @@ export async function scanProduct(serialNumber: string): Promise<ProductScanResp
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 设备的 MOM 出库明细(统一后只有这一组)
|
||||
//
|
||||
// 原先这里是两组接口,对应两张表:
|
||||
// · outbound-orders —— 产品 ↔ 出库**单**(单据级 product_outbounds)
|
||||
// · materials —— 任务 ↔ 出库**明细**(明细级 task_outbound_materials)
|
||||
// 两者是同一个概念,却因为粒度不同被拆开:用户要面对两个入口两张卡,
|
||||
// 而且走单据级挂的料**没有明细行 id,报不了废**。
|
||||
// 现已合并成一张表、一组接口、界面上只有一张卡。
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 列出该设备挂载的全部 MOM 出库明细(按出库时间倒序)。
|
||||
* 对应后端 GET /api/v1/products/{productId}/outbound-materials
|
||||
*
|
||||
* 一行 = 一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
|
||||
* (MOM 回调时查不到明细),能看、能标撤回,但**不能报废**。
|
||||
*/
|
||||
export async function getProductOutboundMaterials(
|
||||
productId: string,
|
||||
): Promise<ProductOutboundMaterial[]> {
|
||||
const { data } = await api.get<ProductOutboundMaterial[]>(
|
||||
`/products/${productId}/outbound-materials`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给设备挂载 MOM 出库明细(网页端/移动端的「+ 领料」都走这里)。
|
||||
* 对应后端 POST /api/v1/products/{productId}/outbound-materials
|
||||
*
|
||||
* ⚠️ 只传 `mom_line_ids`,物料快照由后端现查 MOM —— 前端不传快照。
|
||||
* ⚠️ `taskId` 可空,仅作溯源(这条料挂在哪条任务上),不参与展示/报废/删除。
|
||||
* 幂等:已挂过的明细会被后端跳过。返回该设备当前**全部**出库明细。
|
||||
*/
|
||||
export async function mountProductOutboundMaterials(
|
||||
productId: string,
|
||||
momLineIds: number[],
|
||||
taskId?: string | null,
|
||||
): Promise<ProductOutboundMaterial[]> {
|
||||
const { data } = await api.post<ProductOutboundMaterial[]>(
|
||||
`/products/${productId}/outbound-materials`,
|
||||
{ mom_line_ids: momLineIds, task_id: taskId || null },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
|
||||
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/{materialId}
|
||||
*
|
||||
* ⚠️ 只能删 `source='manual'` 的。MOM 回调自动存档的行后端返回 409 ——
|
||||
* 那是系统事实,要撤得去 MOM 撤回。返回该设备**剩余**的全部出库明细。
|
||||
*/
|
||||
export async function removeProductOutboundMaterial(
|
||||
productId: string,
|
||||
materialId: number,
|
||||
): Promise<ProductOutboundMaterial[]> {
|
||||
const { data } = await api.delete<ProductOutboundMaterial[]>(
|
||||
`/products/${productId}/outbound-materials/${materialId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 整张出库单一起摘掉(挂错了要能撤)。
|
||||
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/by-order/{outboundNo}
|
||||
*
|
||||
* 界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
|
||||
* 规则与逐条删一致:含 webhook 存档记录的单整单删不掉(后端 409)。
|
||||
*/
|
||||
export async function removeProductOutboundOrder(
|
||||
productId: string,
|
||||
outboundNo: string,
|
||||
): Promise<ProductOutboundMaterial[]> {
|
||||
const { data } = await api.delete<ProductOutboundMaterial[]>(
|
||||
`/products/${productId}/outbound-materials/by-order/${encodeURIComponent(outboundNo)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 生产报废
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 列出该设备的报废记录(含实时回查 MOM 的状态与金额)。
|
||||
* 对应后端 GET /api/v1/products/{productId}/scraps
|
||||
*/
|
||||
export async function listProductScraps(productId: string): Promise<ProductScrap[]> {
|
||||
const { data } = await api.get<ProductScrap[]>(`/products/${productId}/scraps`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交一条生产报废(领用的料在生产中损坏)。
|
||||
* 对应后端 POST /api/v1/products/{productId}/scraps
|
||||
*
|
||||
* ⚠️ `track_ref` 必须在**打开弹窗时生成一次**并在重试时复用 ——
|
||||
* 每次提交都换新的话,用户重试会在 MOM 里多报一张报废单。
|
||||
*/
|
||||
export async function submitProductScrap(
|
||||
productId: string,
|
||||
payload: { mom_line_id: number; quantity: number; track_ref: string; reason?: string | null },
|
||||
): Promise<ProductScrap> {
|
||||
const { data } = await api.post<ProductScrap>(
|
||||
`/products/${productId}/scraps`, payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -48,6 +48,80 @@ export interface TaskResponse extends TaskSummary {
|
||||
records: TaskRecordResponse[];
|
||||
/** 🔧 任务创建人(追溯谁转交/发起该工序),如"谁转入在库" */
|
||||
created_by?: string | null;
|
||||
/** 🔧 本任务挂载的 MOM 出库物料(创建时选、之后可追加) */
|
||||
outbound_materials?: TaskOutboundMaterial[];
|
||||
}
|
||||
|
||||
/** 任务挂载的一条 MOM 出库物料明细(挂载时从 MOM 取的快照)
|
||||
* 一次挂载会展开成多行(挂一张出库单 = 该单全部明细各一行),按 outbound_no 分组展示。 */
|
||||
export interface TaskOutboundMaterial {
|
||||
id: number;
|
||||
/** 料挂在哪条任务上。按任务分组/删除都要用它(不能用任务名,同名会并组) */
|
||||
task_id: string;
|
||||
/** MOM trans_outbound.id,供反查比对 */
|
||||
mom_line_id: number;
|
||||
/** MOM 出库单号 */
|
||||
outbound_no: string;
|
||||
sku: string | null;
|
||||
material_name: string | null;
|
||||
spec_model: string | null;
|
||||
/** 出库单原值,**不是**本任务用量 */
|
||||
quantity: number | null;
|
||||
unit_price: number | null;
|
||||
/** SALES/USE/PRODUCTION/LOSS/REPAIR —— 只展示不判断(MOM 码表未冻结) */
|
||||
outbound_type: string | null;
|
||||
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
|
||||
outbound_type_label: string;
|
||||
/** 领用人/客户(MOM 侧自由填写,非可靠标识) */
|
||||
consumer_name: string | null;
|
||||
operator_name: string | null;
|
||||
warehouse_location: string | null;
|
||||
outbound_time: string | null;
|
||||
/** 挂载人(逻辑外键→MOM sys_user) */
|
||||
added_by: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- MOM 出库单(任务挂载物料时的搜索选择器用) ----
|
||||
|
||||
/** MOM 出库单的一条物料明细。line_id 即挂载时提交的 mom_line_ids 元素 */
|
||||
export interface MomOutboundLine {
|
||||
line_id: number;
|
||||
sku: string;
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
quantity: number | null;
|
||||
unit_price: number | null;
|
||||
returned_quantity: number | null;
|
||||
outbound_type: string;
|
||||
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
|
||||
outbound_type_label: string;
|
||||
consumer_name: string;
|
||||
operator_name: string;
|
||||
warehouse_location: string;
|
||||
outbound_time: string | null;
|
||||
/** ⚠️ MOM 存量单据该字段全为空(列是后加的,无从回填),空表示「无关联申请单」 */
|
||||
request_no: string;
|
||||
}
|
||||
|
||||
/** 一张 MOM 出库单(批量出库多商品共用一个单号,故带 N 条明细) */
|
||||
export interface MomOutboundOrder {
|
||||
outbound_no: string;
|
||||
outbound_time: string | null;
|
||||
outbound_type: string;
|
||||
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
|
||||
outbound_type_label: string;
|
||||
consumer_name: string;
|
||||
operator_name: string;
|
||||
line_count: number;
|
||||
total_quantity: number | null;
|
||||
lines: MomOutboundLine[];
|
||||
}
|
||||
|
||||
export interface MomOutboundSearchResponse {
|
||||
orders: MomOutboundOrder[];
|
||||
/** 命中的**单据**总数(不是明细行数) */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TaskRecordResponse {
|
||||
@ -110,6 +184,84 @@ export interface TaskTransferPayload {
|
||||
// 产品
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 设备的一条 MOM 出库明细(统一形态)
|
||||
*
|
||||
* 一行 = 设备上的一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
|
||||
* (MOM 回调时查不到明细),能看、能标撤回,但**不能报废** —— 报废要用它定位。
|
||||
*/
|
||||
export interface ProductOutboundMaterial {
|
||||
id: number;
|
||||
product_id: string;
|
||||
serial_number: string | null;
|
||||
/** 仅溯源(这条料挂在哪条任务上),不参与展示/报废/删除 */
|
||||
task_id: string | null;
|
||||
/** MOM trans_outbound.id;为空 = 无明细的存档 */
|
||||
mom_line_id: number | null;
|
||||
outbound_no: string;
|
||||
// ---- 单据级(同单内一致,冗余在每条明细上) ----
|
||||
request_no: string | null;
|
||||
applicant_name: string | null;
|
||||
remark: string | null;
|
||||
// ---- 明细级快照 ----
|
||||
sku: string | null;
|
||||
material_name: string | null;
|
||||
spec_model: string | null;
|
||||
/** 出库单原值,**不是**本设备用量 */
|
||||
quantity: number | null;
|
||||
unit_price: number | null;
|
||||
outbound_type: string | null;
|
||||
/** 服务端下发的中文名,直接展示 */
|
||||
outbound_type_label: string;
|
||||
consumer_name: string | null;
|
||||
operator_name: string | null;
|
||||
warehouse_location: string | null;
|
||||
outbound_time: string | null;
|
||||
// ---- 来源与撤回 ----
|
||||
/** manual(人工挂载,可删) | webhook(MOM 回调自动存档,不可删) */
|
||||
source: string;
|
||||
is_revoked: boolean;
|
||||
revoked_at: string | null;
|
||||
/** 谁挂上去的(Track 用户名) */
|
||||
added_by: string | null;
|
||||
/** 谁挂上去的(中文姓名,服务端解析下发),直接展示 */
|
||||
added_by_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 生产报废记录 —— Track 发起、MOM 受理的报废单 */
|
||||
export interface ProductScrap {
|
||||
id: string;
|
||||
product_id: string;
|
||||
serial_number: string | null;
|
||||
task_id: string | null;
|
||||
/** 报废对象:MOM trans_outbound.id */
|
||||
mom_line_id: number;
|
||||
/** 快照:MOM 侧数据被清理后仍要能显示「报了什么」 */
|
||||
outbound_no: string | null;
|
||||
material_name: string | null;
|
||||
spec_model: string | null;
|
||||
sku: string | null;
|
||||
/** 原领用人。前端据此判断「报别人的料要额外确认」 */
|
||||
consumer_name: string | null;
|
||||
quantity: number;
|
||||
reason_category: string;
|
||||
reason: string | null;
|
||||
scrap_request_no: string;
|
||||
defective_goods_id: number | null;
|
||||
submitted_by: string | null;
|
||||
created_at: string;
|
||||
// ---- 以下为后端实时回查 MOM 的结果 ----
|
||||
mom_status: number;
|
||||
mom_status_label: string;
|
||||
mom_approved_at: string | null;
|
||||
mom_executor_name: string;
|
||||
mom_executed: boolean;
|
||||
/** 报废损失。**未执行时是 null 不是 0** —— 0 会让人以为「这东西不值钱」 */
|
||||
total_loss: number | null;
|
||||
scrapped_quantity: number | null;
|
||||
}
|
||||
|
||||
export interface ProductScanResponse {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
@ -132,4 +284,8 @@ export interface ProductScanResponse {
|
||||
task_tree: TaskResponse[];
|
||||
/** 🔧 username→中文姓名映射 */
|
||||
assignee_names: Record<string, string>;
|
||||
/** 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。
|
||||
* 本功能上线前出库的设备这里是空数组,不是错误。
|
||||
* 可选是为了兼容尚未升级的后端。 */
|
||||
outbound_records?: ProductOutboundMaterial[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user