diff --git a/track-uniapp/src/api/material.js b/track-uniapp/src/api/material.js
new file mode 100644
index 0000000..c32c5bf
--- /dev/null
+++ b/track-uniapp/src/api/material.js
@@ -0,0 +1,74 @@
+/**
+ * 领用物料 API —— 「产品详情 → 领用物料 → + 领料」
+ *
+ * 背景:PC 端早就能挂出库物料(`MomOutboundPicker`),但**移动端一直没有入口** ——
+ * 只能在别处挂好、这边看。一线的人(生产领料、测试补料)反而够不着,
+ * 所以补上这条链。
+ *
+ * ⚠️ 挂载粒度是**任务**,不是产品:
+ * `product_outbound_materials` 才带 `mom_line_id`(MOM trans_outbound.id),
+ * 而产品级那张 `product_outbounds` 是单据级、没有行 id。
+ * 报废必须靠 mom_line_id 定位到具体哪条出库明细,所以这里的入口一律挂任务。
+ * 这也贴合业务:生产领生产任务的料,测试领测试任务的料,各挂各的。
+ */
+import { get, post, del } from "../utils/request";
+
+/**
+ * 搜索 MOM 出库单(按单据分页,带回每张单的明细)。
+ *
+ * ⚠️ 可见范围(公司隔离 + 跨部门例外)由后端钉死,这里传什么都放不大 ——
+ * 界面筛选只能收窄。
+ */
+export function searchMomOutbounds(params = {}) {
+ return get("/mom-outbounds", params);
+}
+
+/** 本部门出库单里出现过的领用人姓名(后端已按可见范围过滤) */
+export function listMomOutboundConsumers() {
+ return get("/mom-outbounds/consumers");
+}
+
+/**
+ * 把选中的 MOM 出库**明细行**挂到设备上。
+ *
+ * @param {string} productId
+ * @param {number[]} momLineIds - MOM trans_outbound.id 列表
+ * (勾的是一整张出库单,提交时展开成该单的全部明细行 id)
+ * @param {string} [taskId] - 可选,仅作溯源
+ * ⚠️ 只传 id,物料名/数量由后端现查 MOM —— 前端传快照会被后端拒绝。
+ * 幂等:已挂过的明细会被后端跳过。返回该设备当前**全部**出库明细。
+ */
+export function mountProductOutboundMaterials(productId, momLineIds, taskId) {
+ return post(`/products/${productId}/outbound-materials`, {
+ mom_line_ids: momLineIds,
+ // 任务只是**溯源信息**(这条料挂在哪条任务上),可以不给 ——
+ // 展示、报废、删除一律按设备走
+ task_id: taskId || null,
+ });
+}
+
+/**
+ * 从设备上摘掉一条出库明细(挂错了要能撤)。
+ *
+ * @param {string} productId
+ * @param {number} materialId - **Track 侧那条记录的 id**(不是 mom_line_id)
+ * ⚠️ 别传错:`id` 是本表主键、只在 Track 库里有;`mom_line_id` 是 MOM
+ * 那边的出库明细行 id。传反了会删掉另一条料。
+ *
+ * 只摘掉 Track 这边的挂载关系,**不动 MOM 里的出库单本身**。
+ * 已提交的报废记录也不受影响(它的物料信息是快照,独立存在)。
+ * ⚠️ MOM 回调自动存档的行删不掉(后端 409),那是系统事实。
+ */
+export function removeProductOutboundMaterial(productId, materialId) {
+ return del(`/products/${productId}/outbound-materials/${materialId}`);
+}
+
+/**
+ * 整张出库单一起摘掉(挂错了要能一次撤)。
+ *
+ * 界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
+ * 规则与逐条删**一致**:含 MOM 回调自动存档记录的单整单删不掉(后端 409)。
+ */
+export function removeProductOutboundOrder(productId, outboundNo) {
+ return del(`/products/${productId}/outbound-materials/by-order/${encodeURIComponent(outboundNo)}`);
+}
diff --git a/track-uniapp/src/api/scrap.js b/track-uniapp/src/api/scrap.js
new file mode 100644
index 0000000..d13d81f
--- /dev/null
+++ b/track-uniapp/src/api/scrap.js
@@ -0,0 +1,48 @@
+/**
+ * 生产报废 API —— 「产品详情 → 领用物料 → 报废」
+ *
+ * 链路:本文件 → Track 后端 /products/{id}/scraps
+ * → MOM 内部接口(退回不良品 + 建报废申请)
+ * → MOM 里主管审批 → 库管扫码执行 → 才算得出损失金额
+ *
+ * ⚠️ 金额与状态是**后端实时回查 MOM** 的,前端不要缓存、不要自己算:
+ * `mom_executed=false` 时 `total_loss` 是 null(还没执行),
+ * 而不是 0 —— 显示成「损失 0 元」会让用户以为东西没价值。
+ */
+import { get, post } from "../utils/request";
+
+/** 列出该产品的生产报废记录(按提交时间倒序,含实时 MOM 状态与金额) */
+export function listProductScraps(productId) {
+ return get(`/products/${productId}/scraps`);
+}
+
+/**
+ * 提交一条生产报废。
+ *
+ * @param {string} productId
+ * @param {object} payload
+ * @param {number} payload.mom_line_id - 报废对象:MOM 出库明细行 id
+ * (就是任务挂载的 outbound_materials[].mom_line_id)
+ * @param {number} payload.quantity - 报废数量
+ * @param {string} payload.track_ref - 幂等锚点,**弹层打开时生成一次**,
+ * 重试复用同一个;换了它就会在 MOM 里多出一张报废单
+ * @param {string} [payload.reason] - 原因说明
+ */
+export function submitProductScrap(productId, payload) {
+ return post(`/products/${productId}/scraps`, payload);
+}
+
+/**
+ * 生成幂等锚点。
+ *
+ * ⚠️ 必须在**打开弹层时**生成一次并保存在弹层状态里,提交失败重试要复用同一个 ——
+ * 每次提交都新生成的话,用户重试就会在 MOM 里多报一张单(重复报废)。
+ * 用时间戳 + 随机串,在单机范围内足够唯一,且人能看懂大概是什么时候报的。
+ */
+export function makeTrackRef() {
+ const d = new Date();
+ const p = (n) => 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())}`;
+ const rand = Math.random().toString(36).slice(2, 8);
+ return `SCRAP-${ts}-${rand}`;
+}
diff --git a/track-uniapp/src/pages.json b/track-uniapp/src/pages.json
index c42b0f1..3e8766c 100644
--- a/track-uniapp/src/pages.json
+++ b/track-uniapp/src/pages.json
@@ -31,6 +31,22 @@
"navigationBarTextStyle": "white"
}
},
+ {
+ "path": "pages/material/index",
+ "style": {
+ "navigationBarTitleText": "领用物料",
+ "navigationBarBackgroundColor": "#2563EB",
+ "navigationBarTextStyle": "white"
+ }
+ },
+ {
+ "path": "pages/material/pick",
+ "style": {
+ "navigationBarTitleText": "选择 MOM 出库物料",
+ "navigationBarBackgroundColor": "#2563EB",
+ "navigationBarTextStyle": "white"
+ }
+ },
{
"path": "pages/tasks/index",
"style": {
diff --git a/track-uniapp/src/pages/material/index.vue b/track-uniapp/src/pages/material/index.vue
new file mode 100644
index 0000000..42363d5
--- /dev/null
+++ b/track-uniapp/src/pages/material/index.vue
@@ -0,0 +1,505 @@
+
+
+ 加载中...
+
+
+
+
+
+ {{ product.serial_number }}
+ {{ product.material_name || '—' }}
+
+ {{ product.spec_model }}
+
+
+
+
+
+
+ 共 {{ orders.length }} 张单 / {{ materials.length }} 条料
+
+
+
+ 暂无关联的出库单。
+ 点右上角「+ 领料」,选对应的出库单挂到这台设备上。
+
+
+
+
+
+
+ {{ o.outbound_no }}
+
+
+ {{ o.items.length }} 条物料
+
+ 挂载 {{ o.addedByName }}
+ {{ fmtTime(o.outbound_time) }}
+
+
+ 删除
+
+ {{ expanded[o.outbound_no] ? '▲' : '▼' }}
+
+
+
+
+
+
+ {{ m.material_name || '(未命名物料)' }}
+
+ {{ m.spec_model }} · ×{{ m.quantity }}
+ · 领用 {{ formatName(m.consumer_name) }}
+
+
+
+ 报废
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+ {{ s.material_name || '(未命名物料)' }}
+ {{ s.mom_status_label || '状态未知' }}
+ ×{{ s.quantity }}
+
+
+ 报废单 {{ s.scrap_request_no }}
+ 提交人 {{ formatName(s.submitted_by) }}
+
+ 损失 ¥{{ Number(s.total_loss).toFixed(2) }}
+
+ {{ s.reason }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/track-uniapp/src/pages/material/pick.vue b/track-uniapp/src/pages/material/pick.vue
new file mode 100644
index 0000000..8a3332a
--- /dev/null
+++ b/track-uniapp/src/pages/material/pick.vue
@@ -0,0 +1,354 @@
+
+
+
+
+ 挂到设备
+ {{ serial || productId }}
+
+
+
+
+
+ ✕
+
+
+
+
+ { startDate = e.detail.value; reload(); }">
+ {{ startDate || '开始日期' }}
+
+ →
+ { endDate = e.detail.value; reload(); }">
+ {{ endDate || '结束日期' }}
+
+
+ {{ consumer || '全部领用人' }}
+
+ 清空
+
+
+ 共 {{ total }} 张单据
+
+
+ 加载中...
+
+ {{ hasFilter ? '没有匹配的出库单,试试放宽条件' : '没有可选的出库单' }}
+
+
+
+
+
+
+ ✓
+
+ {{ o.outbound_no }}
+ 已挂载
+ {{ o.outbound_type_label }}
+ {{ fmtTime(o.outbound_time) }}
+
+
+ 领用 {{ o.consumer_name }}
+ 经办 {{ o.operator_name }}
+ {{ o.line_count }} 条物料 · 合计 {{ o.total_quantity }}
+
+
+
+
+ {{ l.material_name || '(未命名物料)' }}
+ {{ l.spec_model }}
+ ×{{ l.quantity }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/track-uniapp/src/pages/scan/detail.vue b/track-uniapp/src/pages/scan/detail.vue
index e8a2e07..d61602c 100644
--- a/track-uniapp/src/pages/scan/detail.vue
+++ b/track-uniapp/src/pages/scan/detail.vue
@@ -35,6 +35,53 @@
{{ formatUserName(product.current_location_id) }}
+
+
+
+ 🚚
+ 出库单据
+
+
+ {{ mountedOrderCount }} 张单 / {{ mountedMaterials.length }} 条料
+
+ 未挂载
+ ›
+
+
+
+
+
+
+
+
+
+
+ {{ s.material_name || '(未命名物料)' }}
+ {{ s.mom_status_label || '状态未知' }}
+ ×{{ s.quantity }}
+
+
+ 报废单 {{ s.scrap_request_no }}
+ 提交人 {{ formatName(s.submitted_by) }}
+
+ 损失 {{ formatLoss(s.total_loss) }}
+
+
+
@@ -231,6 +278,8 @@
import request, { get, post, patch, put, getBaseUrl } from "../../utils/request";
import { uploadImages, isUploadedUrl } from "../../utils/upload";
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
+// 本页只**读**报废记录;报案(选料/填数量/提交)在 pages/material/index
+import { listProductScraps } from "../../api/scrap";
import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle";
import WorkspaceArea from "./components/WorkspaceArea.vue";
import TreeCanvas from "./components/TreeCanvas.vue";
@@ -269,10 +318,27 @@ export default {
newMsgText: '',
bottomMsgId: '',
lastMsgSeenAt: '',
+ // ♻️ 报废记录(只读展示)。状态与金额由后端**实时回查 MOM** ——
+ // 报废没有回调,本地存的那份会过期,而「批没批、执行没执行」正是要看的东西。
+ // 报案入口在「领用物料」页(pages/material/index),不在本页。
+ scrapRecords: [],
};
},
computed: {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
+ // 🚚 这台设备挂的出库明细条数 —— 只用来在入口按钮上显示数量。
+ // 具体清单/报废/领料都在「出库单据」页里(pages/material/index)。
+ // ⚠️ 读的是 `outbound_records`(统一后的设备级出库明细)。
+ // 以前读 `task_tree[].outbound_materials` —— 那个字段连同它那张表
+ // 一起被合并掉了,后端已经不再返回,照着读**恒为 0 条**
+ // (界面上就表现为「明明有料却显示 0 条,点进去又看得见」)。
+ mountedMaterials() {
+ return (this.product && this.product.outbound_records) || [];
+ },
+ /** 挂了**几张单**(按出库单号去重)—— 与条数一起显示 */
+ mountedOrderCount() {
+ return new Set(this.mountedMaterials.map(m => m.outbound_no).filter(Boolean)).size;
+ },
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
// 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 403。
@@ -407,7 +473,15 @@ export default {
},
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
- onShow() { if (this.product?.id) { this.fetchMessages(); } },
+ onShow() {
+ if (!this.product?.id) return;
+ this.fetchMessages();
+ // ⚠️ 必须**重新拉产品**,不只是刷新报废记录:
+ // 用户刚在「出库单据」页挂完料返回,入口上的「N 张单 / M 条料」靠的是
+ // product.outbound_records。只刷其它卡片的话计数一直是旧的,
+ // 用户会以为「我刚才那一下没挂上」。
+ this.refreshProductSilently();
+ },
// 🚀 页面卸载:清理确认倒计时定时器,避免泄漏
onUnload() { this.clearConfirm(); },
// ⚠️ 本页【刻意不开启】下拉刷新(pages.json 中已移除 enablePullDownRefresh)。
@@ -418,11 +492,66 @@ export default {
// 状态纠偏不依赖下拉刷新:handleNetworkFailure 会自动静默拉取真实状态。
methods: {
formatUserName, formatUserAvatar,
+
+ // ==================== ♻️ 生产报废 ====================
+ /** 拉本设备的报废记录(状态与金额由后端实时回查 MOM) */
+ async fetchScrapRecords() {
+ if (!this.product?.id) return;
+ try {
+ this.scrapRecords = (await listProductScraps(this.product.id)) || [];
+ } catch (e) {
+ // 静默失败:报废记录是「附加信息」,拉不到不该挡住产品详情的主流程
+ console.warn('[scrap] 拉取报废记录失败:', e?.data?.detail || e);
+ this.scrapRecords = [];
+ }
+ },
+
+ /** 进「领用物料」页:看这台设备的料、报废、补领 */
+ goMaterialPage() {
+ // ⚠️ 不要写 `if (!id) return` —— 静默返回在界面上就是「点了没反应」,
+ // 现场根本没法判断是没加载完、还是页面没注册。有情况都要说出来
+ if (!this.product || !this.product.id) {
+ uni.showToast({ title: '产品还没加载完,稍后再试', icon: 'none' });
+ return;
+ }
+ uni.navigateTo({
+ url: `/pages/material/index?productId=${this.product.id}`
+ + `&serial=${encodeURIComponent(this.product.serial_number || '')}`,
+ // ★ navigateTo 失败时 uni 是**静默**的(只在控制台留一行 warning),
+ // 用户只会觉得「点了没反应」。这里必须弹出来。
+ // 最常见的原因:新页面没进 pages.json —— HBuilderX 会缓存它,
+ // 必须**重启 HBuilderX** 才会重新读取,光重新运行不够。
+ fail: (err) => {
+ console.error('[material] 跳转失败:', err);
+ uni.showModal({
+ title: '打不开「领用物料」',
+ content: '页面未注册或未编译:' + (err && err.errMsg ? err.errMsg : err)
+ + '\n\n请完全关闭并重启 HBuilderX 后重新运行',
+ showCancel: false,
+ });
+ },
+ });
+ },
+
+ /** 报废状态 → 徽标配色。已完成绿色、被驳回/撤回灰色、其余蓝色 */
+ scrapBadgeClass(s) {
+ if (s.mom_executed) return 'sc-badge-done';
+ if (s.mom_status === 2 || s.mom_status === 4) return 'sc-badge-off';
+ return 'sc-badge-wait';
+ },
+
+ /** 金额展示。未执行时后端给 null → 显示「—」,不显示 0 */
+ formatLoss(v) {
+ if (v === null || v === undefined) return '—';
+ return '¥' + Number(v).toFixed(2);
+ },
+
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
+ // 出库时间 → 可读格式。MOM 的 outbound_time 缺失时回退到本行写入时间,
+ // 避免这一列空白(后端返回的是带 +00:00 偏移的 ISO 串,Date 能正确解析)
statusLabel(s) { return STATUS_MAP[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
-
- async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
+ async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); this.fetchScrapRecords(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
// 🚀 从 taskId 反查 product_serial → 再 doQuery
async doQueryByTask(tid) { try { const task = await get(`/tasks/${tid}`); const sn = task?.product_sn || ""; if (sn) { this.doQuery(sn); } else { this.error = "未找到关联产品"; this.loading = false; } } catch { this.error = "任务查询失败"; this.loading = false; } },
findMyImmersiveTask() {
@@ -611,7 +740,12 @@ export default {
async refreshProductSilently() {
const sn = this.product && this.product.serial_number;
if (!sn) return;
- try { this.product = await get(`/products/scan/${sn}`); } catch (e) { console.error("[refresh] 静默刷新失败:", e); }
+ try {
+ this.product = await get(`/products/scan/${sn}`);
+ // 顺带刷新报废记录:MOM 里主管审批 / 库管扫码执行后状态与金额会变,
+ // 而报废没有回调,只能靠这类「顺手拉一次」让用户看到最新进度
+ this.fetchScrapRecords();
+ } catch (e) { console.error("[refresh] 静默刷新失败:", e); }
},
// ═══ 双重确认倒计时(防误触) ═══
@@ -773,6 +907,36 @@ export default {
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
.sn { font-family: monospace; }
.warehouse { color: #7c3aed; }
+/* 🚚 出库单据(MOM 出库回调存档) */
+.ob-count { font-size: 12px; color: #9ca3af; flex-shrink: 0; }
+.ob-row { border: 1px solid #f3f4f6; border-radius: 8px; padding: 8px; margin-bottom: 6px; }
+.ob-row:last-child { margin-bottom: 0; }
+/* 已撤回:整行降调 + 单号删除线,但**不隐藏** ——「出过又撤了」也是历史 */
+.ob-revoked { background: #f9fafb; border-color: #e5e7eb; }
+.ob-line1 { display: flex; align-items: center; gap: 6px; }
+.ob-no { font-family: monospace; font-size: 13px; font-weight: 700; color: #1f2937; word-break: break-all; }
+.ob-no-revoked { color: #9ca3af; text-decoration: line-through; }
+.ob-badge { font-size: 10px; font-weight: 700; color: #6b7280; background: #e5e7eb; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
+.ob-time { font-size: 11px; color: #9ca3af; margin-left: auto; flex-shrink: 0; }
+.ob-line2 { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; }
+.ob-meta { font-size: 11px; color: #6b7280; }
+.ob-remark { font-size: 11px; color: #9ca3af; margin-top: 3px; display: block; }
+
+/* 📦 领用物料入口(产品信息卡底部)。
+ 常显:以前这里没内容时整块消失,用户根本不知道有这功能 */
+.mat-entry { display: flex; align-items: center; gap: 6px; margin-top: 12px; padding-top: 10px; border-top: 1px solid #f3f4f6; }
+.mat-entry-icon { font-size: 15px; }
+.mat-entry-label { font-size: 14px; font-weight: 600; color: #2563eb; }
+.mat-entry-count { font-size: 12px; color: #9ca3af; }
+.mat-entry-arrow { font-size: 16px; color: #9ca3af; margin-left: auto; }
+
+/* ♻️ 报废记录状态徽标 */
+.sc-badge { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
+.sc-badge-wait { color: #b45309; background: #fef3c7; } /* 待审批:琥珀 */
+.sc-badge-done { color: #047857; background: #d1fae5; } /* 已执行:绿 */
+.sc-badge-off { color: #6b7280; background: #e5e7eb; } /* 驳回/撤回:灰 */
+
+/* 代报确认条的样式已随报废弹层移到 pages/material/index.vue */
.badge { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
.s-yellow .badge, .s-yellow { color: #b45309; }
.s-blue .badge, .s-blue { color: #1d4ed8; }
diff --git a/track-uniapp/src/utils/request.js b/track-uniapp/src/utils/request.js
index 57edc6c..16bb8dc 100644
--- a/track-uniapp/src/utils/request.js
+++ b/track-uniapp/src/utils/request.js
@@ -210,3 +210,6 @@ export function get(url, params = {}) {
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }
+// DELETE 原先漏了没封装:本模块 get/post/patch/put 都齐了就差它,
+// 补上省得调用方各自用默认导出的 request() 去拼。命名用 del —— delete 是保留字。
+export function del(url, data = {}) { return request({ url, method: "DELETE", data }); }