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

@ -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)}`);
}

View File

@ -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}`;
}

View File

@ -31,6 +31,22 @@
"navigationBarTextStyle": "white" "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", "path": "pages/tasks/index",
"style": { "style": {

View File

@ -0,0 +1,505 @@
<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>

View File

@ -0,0 +1,354 @@
<template>
<view class="page">
<!-- 挂到这台设备统一后任务只是溯源信息不再需要用户先选
所以这里只提示挂到哪台设备不再显示也不要求任务名 -->
<view class="target-bar">
<text class="target-label">挂到设备</text>
<text class="target-name">{{ serial || productId }}</text>
</view>
<!-- 搜索 -->
<view class="search-bar">
<input v-model="keyword" class="search-input" confirm-type="search"
placeholder="出库单号 / 物料名称 / 规格 / SKU / 领用人"
@confirm="reload" @input="onKeywordInput" />
<text v-if="keyword" class="search-clear" @tap="clearKeyword"></text>
</view>
<!-- 筛选日期区间 + 领用人都是**收窄**条件可见范围由后端钉死 -->
<view class="filter-bar">
<picker mode="date" :value="startDate" @change="(e) => { startDate = e.detail.value; reload(); }">
<view class="date-chip">{{ startDate || '开始日期' }}</view>
</picker>
<text class="date-sep"></text>
<picker mode="date" :value="endDate" @change="(e) => { endDate = e.detail.value; reload(); }">
<view class="date-chip">{{ endDate || '结束日期' }}</view>
</picker>
<picker :range="consumerRange" @change="onConsumerChange">
<view class="date-chip">{{ consumer || '全部领用人' }}</view>
</picker>
<text v-if="hasFilter" class="filter-clear" @tap="clearFilter">清空</text>
</view>
<text class="total-line"> {{ total }} 张单据</text>
<!-- 结果 -->
<view v-if="loading" class="hint">加载中...</view>
<view v-else-if="!orders.length" class="hint">
{{ hasFilter ? '没有匹配的出库单试试放宽条件' : '没有可选的出库单' }}
</view>
<view v-else class="list">
<view v-for="o in orders" :key="o.outbound_no"
:class="['order', isMounted(o) ? 'order-mounted' : (selected[o.outbound_no] ? 'order-selected' : '')]"
@tap="toggleOrder(o)">
<view class="order-head">
<view :class="['tick', selected[o.outbound_no] ? 'tick-on' : '']">
<text v-if="selected[o.outbound_no]"></text>
</view>
<text :class="['order-no', isMounted(o) ? 'order-no-muted' : '']">{{ o.outbound_no }}</text>
<text v-if="isMounted(o)" class="tag tag-muted">已挂载</text>
<text v-else-if="o.outbound_type_label" class="tag">{{ o.outbound_type_label }}</text>
<text class="order-time">{{ fmtTime(o.outbound_time) }}</text>
</view>
<view class="order-meta">
<text v-if="o.consumer_name">领用 {{ o.consumer_name }}</text>
<text v-if="o.operator_name">经办 {{ o.operator_name }}</text>
<text>{{ o.line_count }} 条物料<text v-if="o.total_quantity != null"> · 合计 {{ o.total_quantity }}</text></text>
</view>
<view class="order-foot">
<text class="expand" @tap.stop="toggleExpand(o.outbound_no)">
{{ expanded[o.outbound_no] ? '收起明细 ' : '查看物料明细 ' }}
</text>
</view>
<view v-if="expanded[o.outbound_no]" class="lines">
<view v-for="l in o.lines" :key="l.line_id" class="line">
<text class="line-name">{{ l.material_name || '(未命名物料)' }}</text>
<text v-if="l.spec_model" class="line-spec">{{ l.spec_model }}</text>
<text class="line-qty">×{{ l.quantity }}</text>
</view>
</view>
</view>
</view>
<!-- 底部固定操作条 -->
<view class="footer">
<text class="footer-info">已选 {{ selectedCount }} 张单{{ selectedLineCount }} 条物料</text>
<button class="footer-btn" :disabled="!selectedCount || submitting" @tap="doConfirm">
{{ submitting ? '挂载中...' : '确认挂载' }}
</button>
</view>
</view>
</template>
<script>
import { searchMomOutbounds, listMomOutboundConsumers, mountProductOutboundMaterials } from "../../api/material";
export default {
data() {
return {
productId: "",
serial: "",
taskId: "", // 可空,仅作溯源
keyword: "",
startDate: "",
endDate: "",
consumer: "",
consumerOptions: [""],
// 当前登录人(定默认领用人筛选用),与 PC 端同一口径
me: null,
orders: [],
total: 0,
loading: false,
selected: {}, // { outbound_no: true }
expanded: {},
mountedNos: [], // 已挂过的单号(后端幂等会跳过,这里标出来让用户看得见)
submitting: false,
debounceTimer: null,
};
},
computed: {
consumerRange() { return ["全部领用人"].concat(this.consumerOptions.filter(Boolean)); },
hasFilter() { return !!(this.keyword.trim() || this.consumer || this.startDate || this.endDate); },
selectedOrders() { return this.orders.filter((o) => this.selected[o.outbound_no]); },
selectedCount() { return this.selectedOrders.length; },
selectedLineCount() {
return this.selectedOrders.reduce((n, o) => n + (o.lines || []).length, 0);
},
},
async onLoad(options) {
this.productId = options.productId || "";
this.serial = decodeURIComponent(options.serial || "");
// 任务可空:只有设备下恰好一条任务时上游才会带,仅作溯源
this.taskId = options.taskId || "";
// 上游把单号列表做了 encodeURIComponent逗号会变成 %2C这里必须解回来再切 ——
// 不解的话整个列表会当成**一个**单号,已挂载一个都标不出来
const rawMounted = decodeURIComponent(options.mounted || "");
this.mountedNos = rawMounted.split(",").map((s) => s.trim()).filter(Boolean);
// 当前登录人:用来定默认领用人筛选(与 PC 端 MomOutboundPicker 同一口径)
try {
const raw = uni.getStorageSync("user");
this.me = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
} catch (e) {
this.me = null;
}
// ★ 顺序不能反:必须先拿到领用人列表,才能判断「自己在不在里面」,
// 也才能带着默认条件只查一次。反过来会先查一次全部、再查一次,
// 不但多打一次 MOM界面还会先闪一屏别人的单据再被抽走。
await this.loadConsumers();
this.reload();
},
methods: {
/** 已挂载:该单全部明细都已在任务上(后端幂等会跳过,这里只是标记) */
isMounted(o) {
return this.mountedNos.indexOf(o.outbound_no) >= 0;
},
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())}`;
},
async loadConsumers() {
try {
this.consumerOptions = (await listMomOutboundConsumers()) || [];
} catch (e) {
// 拉不到名单就退回「全部领用人」——不能因为下拉挂了就卡住整个选择器
this.consumerOptions = [];
}
// ★ 默认筛成**当前账号本人**(与 PC 端 MomOutboundPicker 完全一致)。
// 一线的人绝大多数时候查的是自己领的单,默认筛上能省一次选择。
// ⚠️ 但本人在可选列表里**不存在时不硬筛**:筛了会得到一屏空白,
// 用户会以为系统坏了 —— 宁可先给他看全部。
const myName = (this.me && this.me.display_name) || "";
this.consumer = myName && this.consumerOptions.indexOf(myName) >= 0 ? myName : "";
// 兜底:万一账号里没有 display_name至少用用户名试一次
if (!this.consumer && this.me && this.me.username
&& this.consumerOptions.indexOf(this.me.username) >= 0) {
this.consumer = this.me.username;
}
},
async reload() {
this.loading = true;
try {
const res = await searchMomOutbounds({
keyword: this.keyword.trim() || undefined,
start_date: this.startDate || undefined,
end_date: this.endDate || undefined,
consumer: this.consumer || undefined,
limit: 30,
});
this.orders = res.orders || [];
this.total = res.total || 0;
} catch (e) {
uni.showToast({ title: e?.data?.detail || "加载失败", icon: "none" });
this.orders = [];
this.total = 0;
} finally {
this.loading = false;
}
},
// 输入防抖 300ms不防的话每敲一个字打一次 MOM慢且刷屏
onKeywordInput() {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.reload(), 300);
},
clearKeyword() {
this.keyword = "";
this.reload();
},
onConsumerChange(e) {
const idx = Number(e.detail.value) || 0;
this.consumer = idx === 0 ? "" : this.consumerRange[idx];
this.reload();
},
clearFilter() {
this.keyword = "";
this.startDate = "";
this.endDate = "";
this.consumer = "";
this.reload();
},
toggleOrder(o) {
if (this.isMounted(o)) return; // 已挂的不可再选(后端也会跳过)
const next = { ...this.selected };
if (next[o.outbound_no]) delete next[o.outbound_no];
else next[o.outbound_no] = true;
this.selected = next;
},
toggleExpand(no) {
this.expanded = { ...this.expanded, [no]: !this.expanded[no] };
},
/**
* 勾选里有没有**不是自己领的**单。
*
* 判据出库单的领用人MOM 侧自由填写的姓名)≠ 当前登录人姓名。
* ⚠️ 这是**提示**不是权限 —— 料的归属是设备不是人,代挂是合理操作
* (测试替生产补挂、库管代录都会发生)。拦一下只是防止「手滑勾错别人的单」。
*/
proxyOrders() {
const myName = (this.me && (this.me.display_name || this.me.username)) || "";
if (!myName) return [];
return this.selectedOrders.filter(
(o) => o.consumer_name && o.consumer_name !== myName);
},
async doConfirm() {
// 勾的是**整张单**:提交时展开成该单的全部明细行 id。
// 后端按明细行落库,且会跳过已挂过的(幂等)
const lineIds = this.selectedOrders.flatMap((o) => (o.lines || []).map((l) => l.line_id));
if (!lineIds.length) return uni.showToast({ title: "没有可挂载的明细", icon: "none" });
// ⚠️ 代挂确认:勾了不是自己领的单,先把话说清楚再提交。
// 放这里是**提交前**拦一道,用户还能取消回去改勾选。
const proxy = this.proxyOrders;
if (proxy.length) {
const who = [...new Set(proxy.map((o) => o.consumer_name).filter(Boolean))].join("、");
const ok = await new Promise((resolve) => {
uni.showModal({
title: "确认代挂",
content: `选中里有 ${proxy.length} 张不是你自己领的单(领用人:${who})。\n\n`
+ "挂上去会记在你名下(挂载人),确认继续?",
confirmText: "确认代挂",
success: (res) => resolve(res.confirm),
fail: () => resolve(false),
});
});
if (!ok) return;
}
this.submitting = true;
try {
await mountProductOutboundMaterials(this.productId, lineIds, this.taskId);
uni.showToast({ title: "已挂载", icon: "success" });
// 返回领用物料页;它的 onShow 会重新拉一次,把新挂的料显示出来。
// ⚠️ navigateBack 失败是**静默**的(只在控制台留一行),用户会以为
// 「报上去了但没反应」—— 必须弹出来,并给一条手动退路。
setTimeout(() => {
uni.navigateBack({
fail: (err) => {
console.error("[material] 返回失败:", err);
uni.showModal({
title: "已挂载,但没自动返回",
content: "请手动点左上角返回,列表会自动刷新。",
showCancel: false,
});
},
});
}, 600);
} catch (e) {
uni.showModal({
title: "挂载失败",
content: String(e?.data?.detail || e?.errMsg || "挂载失败"),
showCancel: false,
});
} finally {
this.submitting = false;
}
},
},
};
</script>
<style scoped>
/* 底部有固定操作条,留出高度避免最后一条被盖住 */
.page { padding: 12px; padding-bottom: 90px; }
.target-bar { display: flex; align-items: center; gap: 8px; background: #eff6ff; border-radius: 10px; padding: 8px 12px; margin-bottom: 10px; }
.target-label { font-size: 12px; color: #6b7280; }
.target-name { font-size: 13px; font-weight: 700; color: #2563eb; }
.search-bar { position: relative; margin-bottom: 8px; }
.search-input { width: 100%; height: 40px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 32px 0 12px; font-size: 13px; box-sizing: border-box; background: #fff; }
.search-clear { position: absolute; right: 10px; top: 12px; font-size: 14px; color: #9ca3af; }
.filter-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.date-chip { font-size: 12px; color: #4b5563; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 5px 10px; }
.date-sep { font-size: 12px; color: #9ca3af; }
.filter-clear { font-size: 12px; color: #9ca3af; padding: 5px 4px; }
.total-line { font-size: 12px; color: #9ca3af; display: block; margin-bottom: 8px; }
.hint { text-align: center; padding: 40px 16px; color: #9ca3af; font-size: 13px; }
.list { display: block; }
.order { background: #fff; border: 1px solid #f3f4f6; border-radius: 10px; padding: 10px; margin-bottom: 8px; }
.order-selected { border-color: #bfdbfe; background: #eff6ff; }
.order-mounted { background: #f9fafb; border-color: #e5e7eb; }
.order-head { display: flex; align-items: center; gap: 6px; }
.tick { width: 18px; height: 18px; border: 1px solid #d1d5db; border-radius: 4px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #fff; }
.tick-on { background: #2563eb; border-color: #2563eb; }
.order-no { font-family: monospace; font-size: 12px; font-weight: 700; color: #1f2937; }
.order-no-muted { color: #9ca3af; }
.order-time { font-size: 11px; color: #9ca3af; margin-left: auto; flex-shrink: 0; }
.tag { font-size: 10px; font-weight: 600; color: #6d28d9; background: #ede9fe; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
.tag-muted { color: #6b7280; background: #e5e7eb; }
.order-meta { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 6px; padding-left: 24px; }
.order-meta text { font-size: 11px; color: #6b7280; }
.order-foot { padding-left: 24px; margin-top: 4px; }
/* 「查看物料明细」给足点击面积:这是本页最主要的操作之一,太小点不中 */
.expand { font-size: 11px; color: #2563eb; padding: 6px 0; display: inline-block; }
.lines { margin-top: 4px; padding-left: 24px; border-top: 1px solid #f3f4f6; padding-top: 6px; }
.line { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-bottom: 3px; }
.line-name { font-size: 12px; color: #374151; font-weight: 500; }
.line-spec { font-size: 11px; color: #9ca3af; }
.line-qty { font-size: 11px; color: #6b7280; }
.footer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #fff; border-top: 1px solid #e5e7eb; }
.footer-info { flex: 1; font-size: 12px; color: #4b5563; }
.footer-btn { width: 120px; height: 40px; line-height: 40px; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; border: none; margin: 0; }
.footer-btn[disabled] { opacity: 0.5; }
</style>

View File

@ -35,6 +35,53 @@
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ formatUserName(product.current_location_id) }}</text> <text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ formatUserName(product.current_location_id) }}</text>
</view> </view>
</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>
</view> </view>
@ -231,6 +278,8 @@
import request, { get, post, patch, put, getBaseUrl } from "../../utils/request"; import request, { get, post, patch, put, getBaseUrl } from "../../utils/request";
import { uploadImages, isUploadedUrl } from "../../utils/upload"; import { uploadImages, isUploadedUrl } from "../../utils/upload";
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format"; import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
// 本页只**读**报废记录;报案(选料/填数量/提交)在 pages/material/index
import { listProductScraps } from "../../api/scrap";
import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle"; import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle";
import WorkspaceArea from "./components/WorkspaceArea.vue"; import WorkspaceArea from "./components/WorkspaceArea.vue";
import TreeCanvas from "./components/TreeCanvas.vue"; import TreeCanvas from "./components/TreeCanvas.vue";
@ -269,10 +318,27 @@ export default {
newMsgText: '', newMsgText: '',
bottomMsgId: '', bottomMsgId: '',
lastMsgSeenAt: '', lastMsgSeenAt: '',
// ♻️ 报废记录(只读展示)。状态与金额由后端**实时回查 MOM** ——
// 报废没有回调,本地存的那份会过期,而「批没批、执行没执行」正是要看的东西。
// 报案入口在「领用物料」页pages/material/index不在本页。
scrapRecords: [],
}; };
}, },
computed: { computed: {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); }, 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); }, 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 : ""; }, transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
// 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 403。 // 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 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); }, 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 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
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(); }, onUnload() { this.clearConfirm(); },
// ⚠️ 本页【刻意不开启】下拉刷新pages.json 中已移除 enablePullDownRefresh // ⚠️ 本页【刻意不开启】下拉刷新pages.json 中已移除 enablePullDownRefresh
@ -418,11 +492,66 @@ export default {
// 状态纠偏不依赖下拉刷新handleNetworkFailure 会自动静默拉取真实状态。 // 状态纠偏不依赖下拉刷新handleNetworkFailure 会自动静默拉取真实状态。
methods: { methods: {
formatUserName, formatUserAvatar, 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; }, 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; }, 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"; } }, 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(); this.fetchScrapRecords(); } 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(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
// 🚀 从 taskId 反查 product_serial → 再 doQuery // 🚀 从 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; } }, 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() { findMyImmersiveTask() {
@ -611,7 +740,12 @@ export default {
async refreshProductSilently() { async refreshProductSilently() {
const sn = this.product && this.product.serial_number; const sn = this.product && this.product.serial_number;
if (!sn) return; 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; } .value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
.sn { font-family: monospace; } .sn { font-family: monospace; }
.warehouse { color: #7c3aed; } .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; } .badge { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
.s-yellow .badge, .s-yellow { color: #b45309; } .s-yellow .badge, .s-yellow { color: #b45309; }
.s-blue .badge, .s-blue { color: #1d4ed8; } .s-blue .badge, .s-blue { color: #1d4ed8; }

View File

@ -210,3 +210,6 @@ export function get(url, params = {}) {
export function post(url, data = {}) { return request({ url, method: "POST", data }); } export function post(url, data = {}) { return request({ url, method: "POST", data }); }
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); } export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
export function put(url, data = {}) { return request({ url, method: "PUT", 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 }); }