fix(work-order): 问题单处置存在活跃拼单份额时禁止取消/退回大厅/重开
生产实锤 WK26101503335487:客服对份额已提交的拼单母单走问题单处置-取消,
母单整单 cancelled 后份额验收/单人撤单全部被母单状态挡死,后台无补救入口。
- 抽出 getActiveWorkOrderShareCountWithClient 统一活跃份额统计口径
- resolveProblemWorkOrder 的 cancel/cancel_deduct/reopen 先过活跃份额防线
(return_to_worker 不拦),返回结构改为 {order, failureReason}
- settleOverdueWorkOrder 复用同一统计函数,消除两份 COUNT 口径
- 服务层映射 409 work_order_problem_resolution_has_active_shares,
文案引导客服先单独验收/单人撤单
This commit is contained in:
@@ -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<number> {
|
||||
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<WorkOrderRow | null> {
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
failureReason: 'work_order_not_problem' | 'work_order_has_active_shares' | null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkOrderRow>(
|
||||
`
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user