From 584b6f52aa620b1f6a1113a5a699bee3302212e2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 15 Oct 2026 10:34:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(work-order):=20=E9=97=AE=E9=A2=98=E5=8D=95?= =?UTF-8?q?=E5=A4=84=E7=BD=AE=E5=AD=98=E5=9C=A8=E6=B4=BB=E8=B7=83=E6=8B=BC?= =?UTF-8?q?=E5=8D=95=E4=BB=BD=E9=A2=9D=E6=97=B6=E7=A6=81=E6=AD=A2=E5=8F=96?= =?UTF-8?q?=E6=B6=88/=E9=80=80=E5=9B=9E=E5=A4=A7=E5=8E=85/=E9=87=8D?= =?UTF-8?q?=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产实锤 WK26101503335487:客服对份额已提交的拼单母单走问题单处置-取消, 母单整单 cancelled 后份额验收/单人撤单全部被母单状态挡死,后台无补救入口。 - 抽出 getActiveWorkOrderShareCountWithClient 统一活跃份额统计口径 - resolveProblemWorkOrder 的 cancel/cancel_deduct/reopen 先过活跃份额防线 (return_to_worker 不拦),返回结构改为 {order, failureReason} - settleOverdueWorkOrder 复用同一统计函数,消除两份 COUNT 口径 - 服务层映射 409 work_order_problem_resolution_has_active_shares, 文案引导客服先单独验收/单人撤单 --- .../worker-platform/work-order-issue-repo.ts | 62 ++++++++++++++---- .../admin-work-order-management-service.ts | 8 ++- .../worker-platform-service.test.ts | 63 ++++++++++++++++++- 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index a02cff63..0d958721 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -1,4 +1,5 @@ import { withTransaction } from '../../db/client.js' +import type { PoolClient } from 'pg' import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js' import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js' @@ -9,6 +10,27 @@ import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' import type { ProblemWorkOrderResolutionAction, WorkOrderRow } from './types.js' +/** + * 统计工单的活跃拼单份额(除已取消外都算活跃)。 + * + * 母单被整单带走(取消 / 退回大厅 / 重开)会让份额验收与单人撤单全部被 + * 母单状态挡死,因此问题单处置与超时处置都必须先过这道防线。 + */ +export async function getActiveWorkOrderShareCountWithClient( + client: PoolClient, + workOrderId: number | string, +): Promise { + const shareResult = await client.query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_order_shares + WHERE work_order_id = $1 AND status != 'cancelled' + `, + [workOrderId], + ) + return Number(shareResult.rows[0]?.total || 0) +} + /** 问题单退回打手时,按标记前状态恢复;异常历史数据回退单人单进行中。 */ export function resolveProblemReturnToWorkerStatus( fromStatus: string, @@ -25,7 +47,10 @@ export async function resolveProblemWorkOrder(input: { note: string now: string actorName?: string -}): Promise { +}): Promise<{ + order: WorkOrderRow | null + failureReason: 'work_order_not_problem' | 'work_order_has_active_shares' | null +}> { return withTransaction(async (client) => { const currentResult = await client.query( ` @@ -38,7 +63,24 @@ export async function resolveProblemWorkOrder(input: { [input.workOrderId], ) const workOrder = currentResult.rows[0] || null - if (!workOrder) return null + if (!workOrder) { + return { order: null, failureReason: 'work_order_not_problem' } + } + + // 母单一旦被整单带走(取消 / 退回大厅 / 重开),份额验收与单人撤单都会被 + // 母单状态挡死、后台再无补救入口,因此只要还有活跃份额就禁止这三类处置; + // 已提交验收资料的份额必须先走「单独验收」或「单人撤单」。 + const targetStatusDraft = + input.action === 'return_to_worker' ? null : input.action === 'reopen' ? 'open' : 'cancelled' + if (targetStatusDraft !== null) { + const activeShareCount = await getActiveWorkOrderShareCountWithClient( + client, + input.workOrderId, + ) + if (activeShareCount > 0) { + return { order: workOrder, failureReason: 'work_order_has_active_shares' } + } + } // return_to_worker 需恢复到标记问题单前的真实状态:拼单份额已全部提交时 // 标记前是 pending_acceptance,若一律回到 in_progress 会陷入"份额已提交、 @@ -229,7 +271,10 @@ export async function resolveProblemWorkOrder(input: { now: input.now, }) - return getWorkOrderByIdWithClient(client, input.workOrderId) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + failureReason: null, + } }) } @@ -259,15 +304,8 @@ export async function settleOverdueWorkOrder(input: { return { order: null, failureReason: 'work_order_not_overdue' } } - const shareResult = await client.query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM work_order_shares - WHERE work_order_id = $1 AND status != 'cancelled' - `, - [input.workOrderId], - ) - if (Number(shareResult.rows[0]?.total || 0) > 0) { + const activeShareCount = await getActiveWorkOrderShareCountWithClient(client, input.workOrderId) + if (activeShareCount > 0) { return { order: null, failureReason: 'work_order_has_active_shares' } } diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts index 1e1b74c4..e2831c88 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts @@ -411,13 +411,19 @@ export async function resolveAdminProblemWorkOrder( }) } - const updated = await resolveProblemWorkOrder({ + const { order: updated, failureReason } = await resolveProblemWorkOrder({ workOrderId: workOrder.id, action, note, now: nowIso(), actorName, }) + if (failureReason === 'work_order_has_active_shares') { + throw createHttpError('该工单还有参与中的拼单份额,请先在拼单明细里单独验收或单人撤单', { + statusCode: 409, + errorCode: 'work_order_problem_resolution_has_active_shares', + }) + } if (!updated) { throw createHttpError('问题单处置失败,订单状态可能已变化', { statusCode: 409, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index d0e2ed6b..92899afc 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -268,7 +268,6 @@ test('问题单处置离开履约态(取消 / 退回大厅)都要先解绑 assert.ok(body.includes('return_to_worker'), '未找到退回打手重做的分支(该分支不能解绑)') // 顺序很重要:那条 UPDATE 在 open/cancelled 时会把 friend_added_at 清空,而"是否改判作废"正是看这个字段。 - // 清空之后再解绑,会把已加好友的现货退回在库池 → 二次出货。 const updateIndex = body.indexOf('UPDATE work_orders') assert.ok(updateIndex > 0, '未找到工单状态更新语句') assert.ok( @@ -277,6 +276,68 @@ test('问题单处置离开履约态(取消 / 退回大厅)都要先解绑 ) }) +test('问题单处置存在活跃拼单份额时必须拒绝取消/退回大厅/重开', async () => { + const readFile = await import('node:fs/promises') + const src = await readFile.readFile( + new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url), + 'utf8', + ) + const start = src.indexOf('export async function resolveProblemWorkOrder(') + assert.ok(start > 0, '未找到问题单处置函数') + const body = src.slice(start, src.indexOf('export async function settleOverdueWorkOrder', start)) + + // 母单一旦被整单带走(取消/退回大厅/重开),份额验收与单人撤单全部被母单状态挡死, + // 后台再无补救入口(生产实锤:WK26101503335487 被取消后无法单独验收)。 + assert.ok(body.includes('getActiveWorkOrderShareCountWithClient'), '处置前必须统计活跃拼单份额') + assert.ok( + body.includes("'work_order_has_active_shares'"), + '命中活跃份额必须以 failureReason 拒绝而不是继续处置', + ) + // return_to_worker 不拦:货还在这一单上履约(退回打手重做) + assert.ok(body.includes("input.action === 'return_to_worker'")) + + // 防线必须排在资金处置与工单状态更新之前 + const guardIndex = body.indexOf('getActiveWorkOrderShareCountWithClient(') + const updateIndex = body.indexOf('UPDATE work_orders') + assert.ok(guardIndex > 0 && updateIndex > guardIndex, '份额防线必须排在工单状态更新之前') +}) + +test('超时处置与问题单处置共用同一个活跃份额统计口径', async () => { + const readFile = await import('node:fs/promises') + const src = await readFile.readFile( + new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url), + 'utf8', + ) + const start = src.indexOf('export async function settleOverdueWorkOrder(') + assert.ok(start > 0, '未找到超时处置函数') + const body = src.slice(start) + + assert.ok( + body.includes('getActiveWorkOrderShareCountWithClient'), + '超时处置必须复用统一的活跃份额统计', + ) + assert.ok( + !body.includes('SELECT COUNT(*)::int AS total'), + '不允许再内联一份 COUNT 查询,口径分叉会再次出现防线不一致', + ) +}) + +test('问题单处置被活跃份额拦截时服务层必须给出可执行的引导文案', async () => { + const readFile = await import('node:fs/promises') + const src = await readFile.readFile( + new URL('./admin-work-order-management-service.ts', import.meta.url), + 'utf8', + ) + assert.ok( + src.includes('work_order_problem_resolution_has_active_shares'), + '缺少活跃份额拦截的 errorCode 映射', + ) + assert.ok( + src.includes('单独验收') && src.includes('单人撤单'), + '报错文案必须引导客服先走单独验收/单人撤单', + ) +}) + test('人工作废与退回复库在现货仍被交付中工单占用时不改状态', async () => { const statements: string[] = [] const client = {