Files
track/track-uniapp/src/pages/material/index.vue
duxingchen 0b982d192c feat(mobile): 移动端补齐出库单据页(领料 / 报废 / 删除)
PC 端早就能挂出库物料,但移动端一直没有入口 —— 只能看,一线的人(生产领料、
测试补料)反而够不着。

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

506 lines
24 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page">
<view v-if="loading" class="hint">加载中...</view>
<template v-else>
<!-- 设备抬头先明确这是哪台设备的料避免看串设备 -->
<view class="card">
<view class="head-line">
<text class="head-sn">{{ product.serial_number }}</text>
<text class="head-name">{{ product.material_name || '—' }}</text>
</view>
<text v-if="product.spec_model" class="head-spec">{{ product.spec_model }}</text>
</view>
<!-- 🚚 出库单据**按出库单号分组**一行一张单点开看明细
与网页端同一形态同一张表同一个接口单号是主信息
领了什么收在展开区里
显示这台设备的**全部**出库明细不按我领的过滤
料是领给设备的不是领给某个人的生产领的外壳装在这台设备上
测试时摔坏了就该由测试来报不能让测试看不见它
不展示出库类型用途也不问挂到哪条任务
现场只需要知道挂了哪张单谁挂的谁出的库 -->
<view class="card">
<view class="card-header">
<text class="card-title">🚚 出库单据</text>
<text class="card-add" @tap="goPick">+ 领料</text>
</view>
<text v-if="materials.length" class="count">
{{ orders.length }} 张单 / {{ materials.length }} 条料
</text>
<view v-if="!materials.length" class="empty">
<text>暂无关联的出库单</text>
<text class="empty-sub">点右上角+ 领料选对应的出库单挂到这台设备上</text>
</view>
<view v-for="o in orders" :key="o.outbound_no" class="order">
<view class="order-head" @tap="toggleExpand(o.outbound_no)">
<!-- 不展示出库类型用途 现场只关心这台设备挂了哪张单
谁挂的谁出的库多一个内部领用徽标只是噪音 -->
<view class="order-line1">
<text class="order-no">{{ o.outbound_no }}</text>
</view>
<view class="order-line2">
<text class="order-count">{{ o.items.length }} 条物料</text>
<!-- 谁挂上去的 现场要能追责/问人只记在库里不显示等于没记 -->
<text v-if="o.addedByName" class="order-by">挂载 {{ o.addedByName }}</text>
<text class="order-time">{{ fmtTime(o.outbound_time) }}</text>
<!-- 整单删除挂错了要能一次摘掉只在**全部**是人工挂的时显示
MOM 回调存档的单不给删后端也拦
做成小按钮而不是裸文字裸文字在窄屏上会被时间和箭头挤没
现场根本看不出这里能点 -->
<view v-if="o.allManual" class="order-del" @tap.stop="confirmRemoveOrder(o)">
<text>删除</text>
</view>
<text class="chev">{{ expanded[o.outbound_no] ? '▲' : '▼' }}</text>
</view>
</view>
<view v-if="expanded[o.outbound_no]" class="lines">
<view v-for="m in o.items" :key="m.mom_line_id" class="line">
<view class="line-info">
<text class="line-name">{{ m.material_name || '(未命名物料)' }}</text>
<text class="line-meta">
<text v-if="m.spec_model">{{ m.spec_model }} · </text>×{{ m.quantity }}
<text v-if="m.consumer_name"> · 领用 {{ formatName(m.consumer_name) }}</text>
</text>
</view>
<view class="row-btns">
<view class="btn-scrap" @tap.stop="openScrapDialog(m)"><text>报废</text></view>
<view class="btn-del" @tap.stop="confirmRemove(m)"><text>删除</text></view>
</view>
</view>
</view>
</view>
</view>
<!-- 报废记录状态与金额由后端实时回查 MOM -->
<view class="card" v-if="scrapRecords.length">
<view class="card-header">
<text class="card-title"> 报废记录</text>
<text class="count"> {{ scrapRecords.length }} </text>
</view>
<view v-for="s in scrapRecords" :key="s.id" class="scrap-row">
<view class="row-line1">
<text class="scrap-name">{{ s.material_name || '(未命名物料)' }}</text>
<text :class="['badge', badgeClass(s)]">{{ s.mom_status_label || '状态未知' }}</text>
<text class="scrap-qty">×{{ s.quantity }}</text>
</view>
<view class="row-line2">
<text class="scrap-meta">报废单 {{ s.scrap_request_no }}</text>
<text v-if="s.submitted_by" class="scrap-meta">提交人 {{ formatName(s.submitted_by) }}</text>
<!-- 只有执行过才有金额未执行显示不显示 0
0 会让人以为这东西不值钱其实是还没扫码执行 -->
<text v-if="s.mom_executed" class="scrap-meta">损失 ¥{{ Number(s.total_loss).toFixed(2) }}</text>
</view>
<text v-if="s.reason" class="scrap-reason">{{ s.reason }}</text>
</view>
</view>
</template>
<!-- 报废弹层数量 + 说明分类不让人选
走这条路进来的料都是已领用到产线按定义就是生产损耗
后端固定按 PRODUCTION 提交少一个会选错的地方 -->
<view v-if="dlg.visible" class="overlay" @tap="closeDlg">
<view class="popup" @tap.stop>
<text class="popup-title">报废 · {{ dlg.materialName }}</text>
<text class="popup-hint">{{ dlg.specModel || '—' }} 原领用人 {{ dlg.consumerName || '—' }}</text>
<view class="field-label">报废数量 <text class="required">*</text></view>
<input v-model="dlg.quantity" type="digit" class="popup-input" :placeholder="'最多 ' + dlg.maxQty" />
<view class="field-label">原因说明</view>
<textarea v-model="dlg.reason" class="popup-textarea" placeholder="例如:测试时跌落,外壳磕裂" maxlength="200" />
<!-- 代报确认报的不是自己领的料时多一道这是**防误操作**不是权限
后端不会因为这条拒绝料的归属是设备不是人谁发现谁报
真正的把关在 MOM 侧主管审批 -->
<view v-if="dlg.isProxy" class="proxy" @tap="dlg.confirmed = !dlg.confirmed">
<text class="proxy-icon">{{ dlg.confirmed ? '☑' : '☐' }}</text>
<text class="proxy-text">这条料不是你领的{{ dlg.consumerName }} 领用确认代报</text>
</view>
<view class="popup-btns">
<button class="btn-cancel" @tap="closeDlg">取消</button>
<button class="btn-primary" :disabled="submitting || !dlgReady" @tap="doSubmit">
{{ submitting ? '提交中...' : '提交报废' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { get } from "../../utils/request";
import { formatUserName } from "../../utils/format";
import { listProductScraps, submitProductScrap, makeTrackRef } from "../../api/scrap";
import { removeProductOutboundMaterial, removeProductOutboundOrder } from "../../api/material";
export default {
data() {
return {
productId: "",
serial: "",
product: {},
loading: true,
scrapRecords: [],
submitting: false,
// 展开的单据号集合:{ outbound_no: true }。
// 默认全收起 —— 一台设备可能领了很多单,全摊开要滑很久才看得完
expanded: {},
dlg: {
visible: false, momLineId: null, materialName: "", specModel: "",
consumerName: "", maxQty: 0, quantity: "", reason: "",
isProxy: false, confirmed: false, trackRef: "",
},
// 当前登录人姓名,用于判断「代报」
me: null,
};
},
computed: {
/** 这台设备挂载的全部 MOM 出库明细 */
materials() {
// 读**统一后**的设备出库明细outbound_records
// 以前读 task_tree[].outbound_materials —— 那是被合并掉的第二个数据源,
// 也正是「网页端看不到移动端挂的料」的根因。
return (this.product && this.product.outbound_records) || [];
},
/** 按**出库单号**分组,与网页端同一形态:一行一张单,点开看明细 */
orders() {
const map = {};
const out = [];
this.materials.forEach((m) => {
const no = m.outbound_no || "(无单号)";
if (!map[no]) {
map[no] = {
outbound_no: no,
outbound_time: m.outbound_time,
// 谁挂的:同一张单的明细是同一次挂载写入的,取第一条即可
addedByName: m.added_by_name || m.added_by || "",
items: [],
// 整单是否全部人工挂的 —— 决定「整单删除」按不按得动
allManual: true,
};
out.push(map[no]);
}
map[no].items.push(m);
if (m.source !== "manual") map[no].allManual = false;
});
return out;
},
/** 代报时必须勾选确认才能提交 */
dlgReady() {
return !this.dlg.isProxy || this.dlg.confirmed;
},
},
onLoad(options) {
this.productId = options.productId || "";
this.serial = options.serial || "";
// 登录用户存在 "user" 里JSON 字符串),与 profile / login 页同一口径。
// 只用它来判断「这条料是不是我领的」——判错也只是多让用户勾一下确认,
// 真正的防线在 MOM 侧主管审批
try {
const raw = uni.getStorageSync("user");
this.me = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
} catch (e) {
this.me = null;
}
this.load();
},
onShow() {
// 从「选择出库物料」页返回时重新拉一次,把刚挂上的料显示出来
if (!this.loading) this.load();
},
methods: {
formatName: formatUserName,
async load() {
this.loading = true;
try {
// 用扫码接口:它一次带回 product.outbound_records设备出库明细
// 统一后的唯一来源)与 task_tree只在「+ 领料」时用来带个默认任务作溯源)。
// 没另开专用接口:这份数据产品详情页本来就在拉,语义一致,复用即可
this.product = await get(`/products/scan/${this.serial}`);
await this.fetchScrapRecords();
} catch (e) {
uni.showToast({ title: e?.data?.detail || "加载失败", icon: "none" });
} finally {
this.loading = false;
}
},
async fetchScrapRecords() {
try {
this.scrapRecords = (await listProductScraps(this.productId)) || [];
} catch (e) {
// 静默:报废记录是附加信息,拉不到不该挡住物料列表
console.warn("[material] 拉报废记录失败:", e?.data?.detail || e);
this.scrapRecords = [];
}
},
/** 展开/收起某张出库单的明细 */
toggleExpand(no) {
this.expanded = { ...this.expanded, [no]: !this.expanded[no] };
},
fmtTime(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
},
/**
* 领料:先定「挂到哪条任务」,再进选择器。
*
* ★ 为什么必须先选任务:料在 Track 侧是挂在**任务**上的
* `product_outbound_materials`),只有它带 `mom_line_id`
* 而报废必须靠它定位到具体哪条出库明细。所以挂载动作离不开任务。
* 但任务不该像之前那样当成列表分组抬头(现场看不懂「生产·小龙虾」),
* 所以改成点「+ 领料」时才选一次。
*/
goPick() {
// 统一到**设备级**后任务变成可选(只作溯源),所以不再让用户先选任务 ——
// 少一步操作,也少一处「到底挂到哪条任务」的困惑
this.toPick();
},
toPick() {
// 把**这台设备已挂过的单号**带给选择器,让那边把「已挂载」标出来并禁止再选。
// 后端本身是幂等的(已挂的明细会跳过),标出来只是省得用户白勾一遍
const mounted = [...new Set(this.materials.map((m) => m.outbound_no).filter(Boolean))];
// 不带任务 —— 挂载不需要挂到某条任务上(任务只是溯源,可空)。
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来,够了。
uni.navigateTo({
url: `/pages/material/pick?productId=${this.productId}`
+ `&serial=${encodeURIComponent(this.serial || "")}`
+ `&mounted=${encodeURIComponent(mounted.join(","))}`,
// navigateTo 失败是静默的(只在控制台留一行),必须弹出来,
// 多半是 pages.json 没重新读 —— HBuilderX 只认启动时的那份
fail: (err) => {
console.error("[material] 打不开选择器:", err);
uni.showModal({
title: "打不开「选择出库物料」",
content: "页面未注册或未编译:" + (err && err.errMsg ? err.errMsg : err)
+ "\n\n请完全关闭并重启 HBuilderX 后重新运行",
showCancel: false,
});
},
});
},
openScrapDialog(material) {
const myName = (this.me && (this.me.display_name || this.me.username)) || "";
const consumer = material.consumer_name || "";
this.dlg = {
visible: true,
momLineId: material.mom_line_id,
materialName: material.material_name || "(未命名物料)",
specModel: material.spec_model || "",
consumerName: consumer,
maxQty: material.quantity,
quantity: String(material.quantity || ""),
reason: "",
isProxy: !!consumer && !!myName && consumer !== myName,
confirmed: false,
// ★ 打开时生成一次,重试复用 —— 每次提交都换新的话,
// 用户重试会在 MOM 里多报一张报废单
trackRef: makeTrackRef(),
};
},
closeDlg() {
if (this.submitting) return; // 提交中不许关,避免用户以为没提交
this.dlg.visible = false;
},
/** 整张出库单一起摘掉(挂错了要能一次撤)。规则与逐条删一致:含 MOM
* 回调存档的单删不掉(后端 409那是系统事实 */
confirmRemoveOrder(order) {
uni.showModal({
title: '删除整张出库单',
content: '把出库单「' + order.outbound_no + '」从这台设备上整张摘掉?\n\n'
+ '只解除 Track 这边的挂载关系,不会动 MOM 里的出库单本身,'
+ '已提交的报废记录也不受影响。',
confirmText: '删除',
confirmColor: '#dc2626',
success: (res) => { if (res.confirm) this.doRemoveOrder(order.outbound_no); },
});
},
async doRemoveOrder(outboundNo) {
try {
await removeProductOutboundOrder(this.productId, outboundNo);
uni.showToast({ title: '已删除整张出库单', icon: 'success' });
await this.load();
} catch (e) {
uni.showModal({
title: '删除失败',
content: String(e?.data?.detail || e?.errMsg || '删除失败'),
showCancel: false,
});
}
},
/** 摘掉一条挂错的领用物料(与 PC 端「出库单据」卡的删除同一语义) */
confirmRemove(material) {
// 二次确认必须把「删的是什么、不删什么」说清楚 ——
// 用户最怕的是「我删了会不会把 MOM 里的出库单也搞没了」
uni.showModal({
title: '删除出库明细',
content: '把「' + (material.material_name || '此物料') + '」从这台设备上摘掉?\n\n'
+ '只解除 Track 这边的挂载关系,不会动 MOM 里的出库单本身,'
+ '已提交的报废记录也不受影响。',
confirmText: '删除',
confirmColor: '#dc2626',
success: (res) => { if (res.confirm) this.doRemove(material); },
});
},
async doRemove(material) {
try {
// ⚠️ 传的是**记录 id**material.idTrack 侧主键),不是 mom_line_id
await removeProductOutboundMaterial(this.productId, material.id);
uni.showToast({ title: '已删除', icon: 'success' });
await this.load();
} catch (e) {
uni.showModal({
title: '删除失败',
content: String(e?.data?.detail || e?.errMsg || '删除失败'),
showCancel: false,
});
}
},
async doSubmit() {
const d = this.dlg;
const qty = Number(d.quantity);
if (!qty || qty <= 0) return uni.showToast({ title: "请填写报废数量", icon: "none" });
if (qty > Number(d.maxQty)) {
return uni.showToast({ title: `不能超过 ${d.maxQty}`, icon: "none" });
}
if (d.isProxy && !d.confirmed) {
return uni.showToast({ title: "请先确认代报", icon: "none" });
}
this.submitting = true;
try {
await submitProductScrap(this.productId, {
mom_line_id: d.momLineId,
quantity: qty,
reason: (d.reason || "").trim() || null,
track_ref: d.trackRef,
});
uni.showToast({ title: "已提交,待主管审批", icon: "success" });
d.visible = false;
await this.fetchScrapRecords();
} catch (e) {
// ★ 不关弹层、不换 trackRef用户改完数量或稍后重试走的是同一个幂等键
// 不会在 MOM 里多报一张单
uni.showModal({
title: "报废提交失败",
content: String(e?.data?.detail || e?.errMsg || "提交失败"),
showCancel: false,
});
} finally {
this.submitting = false;
}
},
badgeClass(s) {
if (s.mom_executed) return "badge-done";
if (s.mom_status === 2 || s.mom_status === 4) return "badge-off";
return "badge-wait";
},
},
};
</script>
<style scoped>
.page { padding: 12px; padding-bottom: 40px; }
.hint { text-align: center; padding: 40px 0; color: #6b7280; font-size: 13px; }
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-title { font-size: 15px; font-weight: 700; }
.count { font-size: 12px; color: #9ca3af; }
.head-line { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.head-sn { font-family: monospace; font-size: 14px; font-weight: 700; color: #1f2937; }
.head-name { font-size: 13px; color: #4b5563; }
.head-spec { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
.empty { padding: 16px 0; text-align: center; }
.empty text { display: block; font-size: 13px; color: #6b7280; }
.empty-sub { font-size: 11px; color: #9ca3af; margin-top: 4px; }
/* 领料按钮:给足点击面积(工地上戴手套点,小了容易点不中) */
.card-add { font-size: 12px; font-weight: 600; color: #2563eb; padding: 4px 10px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; flex-shrink: 0; }
/* 出库单:一行一张单,点标题行展开明细 */
.order { border: 1px solid #f3f4f6; border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
.order:last-child { margin-bottom: 0; }
.order-head { padding: 10px; background: #fafafa; }
.order-line1 { display: flex; align-items: center; gap: 6px; }
.order-no { font-family: monospace; font-size: 13px; font-weight: 700; color: #1f2937; word-break: break-all; }
/* flex-wrap窄屏上「条数 + 时间 + 删除 + 箭头」可能放不下,
换行总比把删除按钮挤没强 */
.order-line2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
.order-count { font-size: 11px; color: #6b7280; }
.order-time { font-size: 11px; color: #9ca3af; }
/* 谁挂上去的:比时间略深一点,便于一眼看到 */
.order-by { font-size: 11px; color: #6b7280; }
/* 整单删除:做成与明细行删除同样的小按钮 —— 裸文字在窄屏上会被时间和箭头
挤没现场看不出这里能点。flex-shrink:0 保证再挤也不会消失 */
.order-del { margin-left: auto; flex-shrink: 0; padding: 3px 10px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2; color: #dc2626; font-size: 12px; }
.chev { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
/* 展开区:明细行 + 每条自己的操作按钮 */
.lines { padding: 8px 10px; border-top: 1px solid #f3f4f6; }
.line { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #f9fafb; }
.line:last-child { border-bottom: none; padding-bottom: 0; }
.line-info { flex: 1; min-width: 0; }
.line-name { font-size: 13px; font-weight: 600; color: #1f2937; display: block; }
.line-meta { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
/* 两个操作并排。都给足点击面积 —— 工地上戴手套点,小了容易点不中 */
.row-btns { display: flex; gap: 6px; flex-shrink: 0; }
.btn-scrap { padding: 6px 12px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2; color: #dc2626; font-size: 12px; font-weight: 600; }
.btn-del { padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: #6b7280; font-size: 12px; }
.scrap-row { border: 1px solid #f3f4f6; border-radius: 8px; padding: 8px; margin-bottom: 6px; }
.scrap-row:last-child { margin-bottom: 0; }
.row-line1 { display: flex; align-items: center; gap: 6px; }
.scrap-name { font-size: 13px; font-weight: 600; color: #1f2937; flex: 1; min-width: 0; }
.scrap-qty { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
.row-line2 { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; }
.scrap-meta { font-size: 11px; color: #6b7280; }
.scrap-reason { font-size: 11px; color: #9ca3af; margin-top: 3px; display: block; }
.badge { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
.badge-wait { color: #b45309; background: #fef3c7; }
.badge-done { color: #047857; background: #d1fae5; }
.badge-off { color: #6b7280; background: #e5e7eb; }
.overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
.popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0; padding: 20px 16px 32px; max-height: 80vh; overflow-y: auto; }
.popup-title { font-size: 16px; font-weight: 700; display: block; text-align: center; margin-bottom: 6px; }
.popup-hint { font-size: 12px; color: #9ca3af; display: block; text-align: center; }
.popup-input { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-size: 14px; margin: 8px 0; box-sizing: border-box; }
.popup-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; margin: 10px 0; box-sizing: border-box; }
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
.required { color: #ef4444; }
.popup-btns { display: flex; gap: 10px; margin-top: 16px; }
.btn-cancel { flex: 1; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; color: #6b7280; font-size: 14px; line-height: 42px; }
.btn-primary { flex: 1; height: 42px; border: none; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
.btn-primary[disabled] { opacity: 0.5; }
.proxy { display: flex; align-items: flex-start; gap: 6px; margin-top: 12px; padding: 8px 10px; border: 1px solid #fde68a; border-radius: 8px; background: #fffbeb; }
.proxy-icon { font-size: 14px; color: #b45309; flex-shrink: 0; }
.proxy-text { font-size: 12px; color: #92400e; line-height: 1.4; }
</style>