feat: 移动端直接完结入口、权限与派发解绑
【直接完结入口(双重门槛)】
- 转交弹窗新增「🏁 直接完结」选项,与「入库」互斥
- 角色门槛:仅超管 / 主管可见可操作(与后端 ADMIN_ROLES 对齐,两个角色都判 ——
本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN 漏了 SUPERVISOR)
- 场景门槛:新增 showFinishDirect,要求产品确实是「已出库」。普通生产设备若被
直接完结,既无下游任务、又不在仓库池,会变成卡在工人名下的孤儿数据
- transferMode 的 direct 分支同步校验双门槛,防残留选中态把 finish_directly 发出去
【修复售后"鸡生蛋"死锁】
- 已出库设备的下拉工序改走售后词表:taskOptionsFor 增加 overall_status 入参,
已出库与 AFTER_SALES 同等待遇。此前只认 lifecycle_phase,而上一轮已禁止后端
因"正常已出库做质检"翻转该阶段 → 工人选不到工序 → 进不了售后 → 更选不到
【派发入口与仓库位置解绑】
- 原条件硬性要求 current_location_id === 'virtual_warehouse',直接完结后的设备
位置停在最后经手人名下,派发入口彻底消失,产品再也无法流转
- 新增豁免:产品空闲(无活跃主线任务)+ 超管/主管即可派发;新增 hasActiveMainTask
只算主干,协助分支(SPAWN)不阻塞派发
- 文案与图标按场景动态化(在仓库 / 任务已完结)
【顺带修复】
- isWarehouseTransfer 死标志位:openWarehouseTransfer 先置 true,紧接着调用的
openCreateFirstTask 又立刻重置为 false,导致弹窗标题永远显示「发起首道工序」,
仓库转出文案从未生效。改为在 openCreateFirstTask 内按产品实际位置推导
This commit is contained in:
@ -38,10 +38,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
|
<view v-if="showDispatchBanner"
|
||||||
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
|
class="warehouse-transfer-banner" @tap="openCreateFirstTask">
|
||||||
<text class="wt-icon">📤</text>
|
<text class="wt-icon">{{ dispatchBannerIcon }}</text>
|
||||||
<text class="wt-text">该产品在仓库中 — 点击此处转出并派发给指定人员</text>
|
<text class="wt-text">{{ dispatchBannerText }}</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -156,8 +156,10 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="field-label" style="margin-top:12px;">或</view>
|
<view class="field-label" style="margin-top:12px;">或</view>
|
||||||
<view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="toggleWarehouse">📦 入库 (virtual_warehouse)</view>
|
<view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="toggleWarehouse">📦 入库 (virtual_warehouse)</view>
|
||||||
<view :class="['user-grid-item', transferForm.isFinishDirect ? 'user-grid-active' : '']" style="width:100%;margin-top:8px;" @tap="toggleFinishDirect">🏁 直接完结 (不入库)</view>
|
<template v-if="showFinishDirect">
|
||||||
<text class="popup-hint">🏁 直接完结:结束本任务且不创建下游任务,产品状态保持原样(不进入「待仓库收货」)。用于售后返厂直接发走、半成品被直接提走等无需入库的场景</text>
|
<view :class="['user-grid-item', transferForm.isFinishDirect ? 'user-grid-active' : '']" style="width:100%;margin-top:8px;" @tap="toggleFinishDirect">🏁 直接完结 (不入库)</view>
|
||||||
|
<text class="popup-hint">🏁 直接完结:结束本任务且不创建下游任务,不改动产品状态(已出库的设备完结后依然是「已出库」)。用于售后返厂直接发走、半成品被直接提走等无需入库的场景</text>
|
||||||
|
</template>
|
||||||
<view class="field-label" style="margin-top:12px;">交接备注 <text class="required">*</text></view>
|
<view class="field-label" style="margin-top:12px;">交接备注 <text class="required">*</text></view>
|
||||||
<textarea v-model="transferForm.note" class="popup-textarea" placeholder="请填写交接备注(必填)" :maxlength="500" />
|
<textarea v-model="transferForm.note" class="popup-textarea" placeholder="请填写交接备注(必填)" :maxlength="500" />
|
||||||
<view v-if="transferMode" class="preview-hint">{{ transferPreview }}</view>
|
<view v-if="transferMode" class="preview-hint">{{ transferPreview }}</view>
|
||||||
@ -273,10 +275,64 @@ export default {
|
|||||||
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
|
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); },
|
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。
|
||||||
|
// ⚠️ 必须两个角色都判:本文件既有的 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' 直接完结
|
// 🏁 转交弹窗的三选一模式:'' = 未选 | '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] || '确认转交'; },
|
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 : ""; },
|
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 })); },
|
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
|
||||||
modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
|
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);
|
return overallOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
|
||||||
},
|
},
|
||||||
availableTaskOptions() {
|
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); },
|
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]; },
|
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})`; } },
|
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 }); } },
|
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; } },
|
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) 是服务端已有的,
|
// savedCount = 打开弹窗时已落库的图片张数。列表里 [0, savedCount) 是服务端已有的,
|
||||||
|
|||||||
@ -20,6 +20,16 @@ export const LIFECYCLE_PHASE = {
|
|||||||
export const STEP_SHIP_TEST = "发货测试";
|
export const STEP_SHIP_TEST = "发货测试";
|
||||||
export const STEP_AFTER_SALES_REPAIR = "售后维修";
|
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];
|
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
|
||||||
|
|
||||||
// ── 生产制造阶段合法工序(原有词表,一字未改)──
|
// ── 生产制造阶段合法工序(原有词表,一字未改)──
|
||||||
@ -40,9 +50,22 @@ export const AFTER_SALES_OVERALL_OPTIONS = [
|
|||||||
* hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**:
|
* hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**:
|
||||||
* 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。
|
* 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。
|
||||||
* 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES,之后下拉自动收窄。
|
* 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES,之后下拉自动收窄。
|
||||||
|
*
|
||||||
|
* @param phase lifecycle_phase
|
||||||
|
* @param hasHistory 是否有历史任务
|
||||||
|
* @param overallStatus 产品当前宏观状态 — 【已出库】同样走售后词表,见下方说明
|
||||||
*/
|
*/
|
||||||
export function taskOptionsFor(phase, hasHistory = true) {
|
export function taskOptionsFor(phase, hasHistory = true, overallStatus = null) {
|
||||||
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_TASK_OPTIONS;
|
// 🔧 出库设备与售后生命周期同等待遇(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];
|
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
|
||||||
return PRODUCTION_TASK_OPTIONS;
|
return PRODUCTION_TASK_OPTIONS;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user