feat(mobile): 移动端补齐出库单据页(领料 / 报废 / 删除)
PC 端早就能挂出库物料,但移动端一直没有入口 —— 只能看,一线的人(生产领料、
测试补料)反而够不着。
- 产品详情页的产品信息卡底部加「出库单据 N 张单 / M 条料」入口,常显不隐藏:
以前没内容时整块消失,用户根本不知道有这功能。
- 新增「出库单据」页与「选择 MOM 出库物料」页,与 PC 端同一套数据源、
同一套接口、同一形态(按出库单号分组 + 点开展开明细)。
- 挂载不需要先选任务:任务只是溯源(记 added_by),展示/报废/删除一律按设备走。
- 代挂确认:勾了不是自己领的单时先拦一道。★ 这是**提示**不是权限 ——
料的归属是设备不是人,代挂是合理操作(测试替生产补挂、库管代录)。
⚠️ 判据是 MOM 的 consumer_name 与登录人姓名比对,比错也只是多让用户勾一下。
- navigateTo / navigateBack 失败在 uni 里是**静默**的(只留一行 warning),
用户看到的就是「点了没反应」。全部补 fail 回调弹窗。
This commit is contained in:
@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user