feat(mobile): 移动端补齐出库单据页(领料 / 报废 / 删除)

PC 端早就能挂出库物料,但移动端一直没有入口 —— 只能看,一线的人(生产领料、
测试补料)反而够不着。

- 产品详情页的产品信息卡底部加「出库单据 N 张单 / M 条料」入口,常显不隐藏:
  以前没内容时整块消失,用户根本不知道有这功能。
- 新增「出库单据」页与「选择 MOM 出库物料」页,与 PC 端同一套数据源、
  同一套接口、同一形态(按出库单号分组 + 点开展开明细)。
- 挂载不需要先选任务:任务只是溯源(记 added_by),展示/报废/删除一律按设备走。
- 代挂确认:勾了不是自己领的单时先拦一道。★ 这是**提示**不是权限 ——
  料的归属是设备不是人,代挂是合理操作(测试替生产补挂、库管代录)。
  ⚠️ 判据是 MOM 的 consumer_name 与登录人姓名比对,比错也只是多让用户勾一下。
- navigateTo / navigateBack 失败在 uni 里是**静默**的(只留一行 warning),
  用户看到的就是「点了没反应」。全部补 fail 回调弹窗。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent 5d5aea1015
commit 0b982d192c
7 changed files with 1168 additions and 4 deletions

View File

@ -35,6 +35,53 @@
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ formatUserName(product.current_location_id) }}</text>
</view>
</view>
<!-- 🚚 出库单据入口。
★ 出库单是挂在**这台设备**上的、不是挂在某个人身上的,所以入口放在
「这台设备」的信息卡里最自然。点进去能看全部出库明细、能报废、能补挂。
★ 常显不隐藏:以前这里什么都没有时整块消失,用户根本不知道有这功能。
没料时也要看得见入口。 -->
<view class="mat-entry" @tap="goMaterialPage">
<text class="mat-entry-icon">🚚</text>
<text class="mat-entry-label">出库单据</text>
<!-- 张数与条数都给:只显示「N 条」看不出挂了几张单,反过来也一样。
没料时不显示计数,只留入口 -->
<text class="mat-entry-count" v-if="mountedMaterials.length">
{{ mountedOrderCount }} 张单 / {{ mountedMaterials.length }} 条料
</text>
<text class="mat-entry-count" v-else>未挂载</text>
<text class="mat-entry-arrow">›</text>
</view>
</view>
<!-- 注:原先这里还有一张独立的「出库单据」卡,与上面产品信息卡里的
入口按钮**重复**(两处都叫「出库单据」、说的是同一件事)。
详情页只留入口按钮,单据清单与物料明细都在那一页里 ——
详情页已经很长,没必要再铺一遍。 -->
<!-- ♻️ 报废记录:本设备报过的废。状态与金额由后端实时回查 MOM。
这里只**展示结果**;报案本身(选料、填数量)在「领用物料」页里做 ——
详情页已经很长,把操作挪出去,这里留一眼能看懂的进度。 -->
<view class="card" v-if="scrapRecords.length">
<view class="card-header">
<text class="card-title">♻️ 报废记录</text>
<text class="ob-count">共 {{ scrapRecords.length }} 条</text>
</view>
<view v-for="s in scrapRecords" :key="s.id" class="ob-row">
<view class="ob-line1">
<text class="ob-no">{{ s.material_name || '(未命名物料)' }}</text>
<text :class="['sc-badge', scrapBadgeClass(s)]">{{ s.mom_status_label || '状态未知' }}</text>
<text class="ob-time">×{{ s.quantity }}</text>
</view>
<view class="ob-line2">
<text class="ob-meta">报废单 {{ s.scrap_request_no }}</text>
<text v-if="s.submitted_by" class="ob-meta">提交人 {{ formatName(s.submitted_by) }}</text>
<!-- ★ 只有执行过才有金额。未执行显示「—」,不显示 0 ——
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 -->
<text class="ob-meta" v-if="s.mom_executed">损失 {{ formatLoss(s.total_loss) }}</text>
</view>
<text v-if="s.reason" class="ob-remark">{{ s.reason }}</text>
</view>
</view>
</view>
@ -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; }