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