diff --git a/track-uniapp/src/pages/scan/detail.vue b/track-uniapp/src/pages/scan/detail.vue
index 94e160a..e8a2e07 100644
--- a/track-uniapp/src/pages/scan/detail.vue
+++ b/track-uniapp/src/pages/scan/detail.vue
@@ -38,10 +38,10 @@
-
- 📤
- 该产品在仓库中 — 点击此处转出并派发给指定人员
+
+ {{ dispatchBannerIcon }}
+ {{ dispatchBannerText }}
@@ -156,8 +156,10 @@
或
📦 入库 (virtual_warehouse)
- 🏁 直接完结 (不入库)
-
+
+ 🏁 直接完结 (不入库)
+
+
交接备注 *
{{ transferPreview }}
@@ -273,10 +275,64 @@ export default {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
+ // 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 403。
+ // ⚠️ 必须两个角色都判:本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN、
+ // 漏了 SUPERVISOR,导致主管被前端误挡。此处与后端 ADMIN_ROLES 对齐。
+ // 🔒 管理角色判定(超管 / 主管)—— 与后端 ADMIN_ROLES 对齐,作为各处权限判断的唯一入口
+ isAdminUser() { const r = (this.currentUser && this.currentUser.role) || this.currentUserRole || ''; return r === 'SUPER_ADMIN' || r === 'SUPERVISOR'; },
+ canFinishDirectly() { return this.isAdminUser; },
+ // 📤 产品是否空闲:没有任何活跃的【主线】任务(WIP/PENDING)。
+ // 只算主干(无父任务 或 TRANSFER/RECOVERY):协助分支(SPAWN)不阻塞派发,
+ // 否则一个挂着的协助分支会把产品永久锁死。
+ hasActiveMainTask() {
+ const walk = (tasks) => {
+ if (!tasks) return false;
+ for (const t of tasks) {
+ const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
+ if (isMain && (t.status === 'WIP' || t.status === 'PENDING')) return true;
+ if (walk(t.child_tasks)) return true;
+ }
+ return false;
+ };
+ return this.product ? walk(this.product.task_tree) : false;
+ },
+ // 📤 派发新任务入口的显示条件 —— 2026-09-17 解绑「派发权限」与「仓库位置」。
+ //
+ // 原实现硬性要求 current_location_id === 'virtual_warehouse',于是
+ // 【直接完结】后的设备(位置停在最后经手人名下、又不在仓库池)派发入口
+ // 彻底消失,产品变成再也无法流转的孤儿数据。
+ // 但物理上除非设备报废,永远存在派发新任务的需求,故增加豁免:
+ // · 在仓库且我本人没有待办 → 转出派发(原有行为,保持不动)
+ // · 产品空闲(无活跃主线任务)且我是超管/主管 → 直接派发新任务
+ // (已出库设备尤其如此:位置在工人名下也不该挡住派发)
+ showDispatchBanner() {
+ if (!this.product) return false;
+ if (this.product.current_location_id === 'virtual_warehouse' && !this.hasMyActiveTask) return true;
+ return this.isAdminUser && !this.hasActiveMainTask;
+ },
+ dispatchBannerText() {
+ return this.product && this.product.current_location_id === 'virtual_warehouse'
+ ? '该产品在仓库中 — 点击此处转出并派发给指定人员'
+ : '当前任务已完结 — 点击此处直接派发新任务';
+ },
+ dispatchBannerIcon() {
+ return this.product && this.product.current_location_id === 'virtual_warehouse' ? '📤' : '🚀';
+ },
+ // 🛡️ 直接完结的【场景】门槛:角色够 + 产品确实是「已出库」。
+ // 为什么必须卡状态:普通生产中的设备若被直接完结,产品既无下游任务、
+ // 又不在仓库池中,会变成卡在工人名下的**孤儿数据**;而且售后往往要多步
+ // 流转(发货测试 → 维修 → 入库 → …),提前完结会把任务流彻底切断。
+ // 生产中的设备只能走「入库」或「转交个人」,回归正常流转。
+ showFinishDirect() {
+ return this.canFinishDirectly
+ && !!this.product
+ && this.product.overall_status === '已出库';
+ },
// 🏁 转交弹窗的三选一模式:'' = 未选 | 'user' 转交个人 | 'warehouse' 入库 | 'direct' 直接完结
- transferMode() { const f = this.transferForm; if (f.isFinishDirect) return 'direct'; if (f.isWarehouse) return 'warehouse'; return f.selectedUserId ? 'user' : ''; },
+ // direct 分支额外校验 角色 + 场景 双门槛,防御残留选中态把 finish_directly 发出去
+ transferMode() { const f = this.transferForm; if (f.isFinishDirect && this.showFinishDirect) return 'direct'; if (f.isWarehouse) return 'warehouse'; return f.selectedUserId ? 'user' : ''; },
transferSubmitLabel() { return { direct: '🏁 确认直接完结', warehouse: '📦 确认入库', user: '确认转交' }[this.transferMode] || '确认转交'; },
- transferPreview() { return { direct: '任务将直接完结,不创建下游任务;产品状态保持不变,不会进入「待仓库收货」', warehouse: '产品将入库并从个人待办中移除', user: '将创建新任务指派给 ' + (this.transferUserName || '—') }[this.transferMode] || ''; },
+ transferPreview() { return { direct: '任务将直接完结,不创建下游任务;不改动产品状态(已出库的设备完结后依然是「已出库」)', warehouse: '产品将入库并从个人待办中移除', user: '将创建新任务指派给 ' + (this.transferUserName || '—') }[this.transferMode] || ''; },
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
@@ -340,7 +396,13 @@ export default {
return overallOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
},
availableTaskOptions() {
- return taskOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
+ // 🔧 第三个参数 overall_status:已出库设备同样要走售后词表(发货测试 / 售后维修),
+ // 否则出库后补做质检的工人永远选不到对应工序(鸡生蛋死锁)。
+ return taskOptionsFor(
+ this.product && this.product.lifecycle_phase,
+ this.hasHistory,
+ this.product && this.product.overall_status,
+ );
},
},
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); },
@@ -398,9 +460,20 @@ export default {
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = this.availableTaskOptions[idx]; },
onAssigneeChange(e) { const idx = e.detail.value; const u = this.users[idx]; if (u) { this.firstForm.assigneeIdx = idx; this.firstForm.assignee_id = u.username; this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`; } },
- openCreateFirstTask() { this.isWarehouseTransfer = false; if (this.currentMode === 'tree') this.currentMode = 'workspace'; this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" }; this.createFirstVisible = true; },
+ // 🚀 打开「派发新任务」弹窗。标题由 isWarehouseTransfer 决定,
+ // 而该标志【必须在此处按产品实际位置重新推导】——
+ // 原先 openWarehouseTransfer 先置 true、再调用本方法被立刻重置为 false,
+ // 导致弹窗标题永远显示「发起首道工序」,仓库转出场景的文案从未生效。
+ openCreateFirstTask() {
+ this.isWarehouseTransfer = !!(this.product && this.product.current_location_id === 'virtual_warehouse');
+ if (this.currentMode === 'tree') this.currentMode = 'workspace';
+ this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" };
+ this.createFirstVisible = true;
+ },
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else if (this.canEditOverallStatus) { this.showStatusPicker = true; } else { uni.showToast({ title: '仅超级管理员或当前主线负责人可修改状态', icon: 'none', duration: 2500 }); } },
- openWarehouseTransfer() { this.isWarehouseTransfer = true; this.openCreateFirstTask(); },
+ // 注:原 openWarehouseTransfer() 已移除 —— 它的 isWarehouseTransfer=true 会被
+ // openCreateFirstTask 立刻覆盖(死代码)。现在 banner 直接调用 openCreateFirstTask,
+ // 由后者按产品实际位置推导标题。
async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
// savedCount = 打开弹窗时已落库的图片张数。列表里 [0, savedCount) 是服务端已有的,
diff --git a/track-uniapp/src/utils/lifecycle.js b/track-uniapp/src/utils/lifecycle.js
index b6b7e29..2d9295c 100644
--- a/track-uniapp/src/utils/lifecycle.js
+++ b/track-uniapp/src/utils/lifecycle.js
@@ -20,6 +20,16 @@ export const LIFECYCLE_PHASE = {
export const STEP_SHIP_TEST = "发货测试";
export const STEP_AFTER_SALES_REPAIR = "售后维修";
+/**
+ * 绝对物理终态之一:设备已发货出库。
+ *
+ * 🔧 出库设备也要按售后环节给选项:设备出库后仍可能被叫回来补做
+ * 「发货测试 / 售后维修」,但后端已不再因「正常已出库做质检」而把
+ * lifecycle_phase 翻成 AFTER_SALES(避免不可回退的售后烙印)。
+ * 若选项渲染仍只认 phase,工人就永远选不到那两个工序 —— 鸡生蛋死锁。
+ */
+export const OUTBOUND_OVERALL = "已出库";
+
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
// ── 生产制造阶段合法工序(原有词表,一字未改)──
@@ -40,9 +50,22 @@ export const AFTER_SALES_OVERALL_OPTIONS = [
* hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**:
* 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。
* 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES,之后下拉自动收窄。
+ *
+ * @param phase lifecycle_phase
+ * @param hasHistory 是否有历史任务
+ * @param overallStatus 产品当前宏观状态 — 【已出库】同样走售后词表,见下方说明
*/
-export function taskOptionsFor(phase, hasHistory = true) {
- if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_TASK_OPTIONS;
+export function taskOptionsFor(phase, hasHistory = true, overallStatus = null) {
+ // 🔧 出库设备与售后生命周期同等待遇(2026-09-17 修复「鸡生蛋」死锁):
+ // 后端为消除不可回退的售后烙印,已不再把「正常已出库做质检」的设备
+ // 翻成 AFTER_SALES。若这里仍只认 phase,工人就永远选不到
+ // 「发货测试 / 售后维修」—— 而选不到就无法进入售后,phase 也就永远不变。
+ if (
+ phase === LIFECYCLE_PHASE.AFTER_SALES ||
+ String(overallStatus || "").trim() === OUTBOUND_OVERALL
+ ) {
+ return AFTER_SALES_TASK_OPTIONS;
+ }
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
return PRODUCTION_TASK_OPTIONS;
}