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:
2026-09-17 17:06:00 +08:00
parent ee25b8fd23
commit 42c883fe7d
2 changed files with 109 additions and 13 deletions

View File

@ -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) 是服务端已有的,

View File

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