Merge branch 'main' into dev

This commit is contained in:
2026-11-01 15:53:27 +08:00
10 changed files with 165 additions and 44 deletions
@@ -539,7 +539,7 @@ export async function disposeWorkOrderShareProblem(input: {
)
}
// 母单状态保持不变:与「后台单人撤单」同口径,只处置这一份。
// 份额级问题单只处置这一份,不重算母单状态:被标记的份额不走「退回大厅」的份额释放口径。
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'admin',
@@ -99,16 +99,23 @@ const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000
const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256
const timeoutCountCache = new Map<string, TimeoutCountCacheEntry>()
/** 后台按母工单拼单参与情况计算业务状态,open 只代表大厅生命周期。 */
/**
* 后台按母工单拼单参与情况计算业务状态,open 只代表大厅生命周期。
*
* 有份额回到大厅(有效份数 < 总份数)时必须先显示「待抢单」:空位还在,
* 母单就还没到按份额状态推进的时候。满额后才按份额状态收敛:
* - 仍有 joined 份额 → in_progress(代练中)
* - 无 joined 份额 → pending_acceptance(待验收)
*/
function adminOperationalStatusExpression(alias = 'wo', shareSummaryAlias = '') {
if (shareSummaryAlias) {
return `CASE
WHEN ${alias}.status = 'open'
AND ${alias}.sharing_enabled IS TRUE
AND COALESCE(${shareSummaryAlias}.active_share_quantity, 0) > 0
THEN CASE
WHEN COALESCE(${shareSummaryAlias}.active_share_quantity, 0) >= ${alias}.sharing_total_quantity
AND COALESCE(${shareSummaryAlias}.pending_share_count, 0) = 0
WHEN COALESCE(${shareSummaryAlias}.active_share_quantity, 0) < ${alias}.sharing_total_quantity
THEN 'open'
WHEN COALESCE(${shareSummaryAlias}.pending_share_count, 0) = 0
THEN 'pending_acceptance'
ELSE 'in_progress'
END
@@ -116,13 +123,16 @@ function adminOperationalStatusExpression(alias = 'wo', shareSummaryAlias = '')
END`
}
const activeShareQuantitySql = `COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0)`
const pendingShareCountSql = `COALESCE((SELECT COUNT(*) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status = 'joined'), 0)`
return `CASE
WHEN ${alias}.status = 'open'
AND ${alias}.sharing_enabled IS TRUE
AND COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) > 0
THEN CASE
WHEN COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) >= ${alias}.sharing_total_quantity
AND COALESCE((SELECT COUNT(*) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status = 'joined'), 0) = 0
WHEN ${activeShareQuantitySql} < ${alias}.sharing_total_quantity
THEN 'open'
WHEN ${pendingShareCountSql} = 0
THEN 'pending_acceptance'
ELSE 'in_progress'
END
@@ -821,9 +821,9 @@ export async function acceptWorkOrderShareAndSettle(input: {
/**
* 后台撤销单个拼单份额:退还该打手押金、释放这一份的占用。
*
* **不改母工单状态**:后台撤单常用于剔除错误参与者/纠错,撤掉一份不应把
* 待验收(或进行中)的母单踢回大厅;需要改母单状态时走「拼单撤单」的
* 退回大厅/取消整单显式操作。
* 份额取消后空出的份数必须回到接单大厅,所以这里用统一口径重算母单状态:
* 存在空位 → open(后台显示待抢单);一份不剩 → open(同样待抢单)。
* 重算时保留原 published_at / hall_queued_at,空出的份额沿用原大厅队列位置。
*/
export async function cancelWorkOrderShare(input: {
workOrderId: number
@@ -936,22 +936,27 @@ export async function cancelWorkOrderShare(input: {
WHERE id = $3`,
[input.now, String(input.reason || '').trim(), input.shareId],
)
// 母单状态保持不变:这里只取消这一份份额,不再按剩余份额重推母单状态。
// (旧实现会 recompute:待验收的母单撤掉一份后 allocated < total → 变回 open,
// 等于整单被踢回抢单大厅,与「后台撤单只影响该份额」的预期不符。)
// 空出的份数回大厅:母单状态必须跟着剩余份额重算,不能停留在待验收。
// 保留原队列位置,避免撤掉一份就把整单排到大厅队尾。
await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now, {
preserveHallQueue: true,
})
const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'admin',
actorId: input.actorName || '',
eventType: 'sharing_share_cancelled_by_admin',
fromStatus: workOrder.status,
toStatus: workOrder.status,
toStatus: updatedOrder?.status || workOrder.status,
payloadJson: JSON.stringify({
shareId: input.shareId,
workerId,
quantity: Number(share.quantity || 0),
releasedDepositAmount,
reason: String(input.reason || '').trim(),
orderStatusAfter: updatedOrder?.status || workOrder.status,
activeShareQuantity: Number(updatedOrder?.active_share_quantity || 0),
}),
now: input.now,
})
@@ -961,7 +966,7 @@ export async function cancelWorkOrderShare(input: {
[input.shareId],
)
return {
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
order: updatedOrder || workOrder,
share: updatedShareResult.rows[0] || null,
releasedDepositAmount,
failureReason: null,
@@ -1127,8 +1132,11 @@ export async function settleOverdueWorkOrderShare(input: {
[input.now, input.shareId],
)
// 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅
await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now)
// 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅;
// 空出的份数回大厅时保留原队列位置,避免超时一次就重排到队尾。
await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now, {
preserveHallQueue: true,
})
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
@@ -497,13 +497,19 @@ export async function reduceWorkOrderShareQuantity(input: {
],
)
// 减少的份数会回到大厅,母单状态跟着剩余份额重算;保留原队列位置。
await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now, {
preserveHallQueue: true,
})
const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'admin',
actorId: input.actorName || '',
eventType: 'sharing_share_quantity_reduced',
fromStatus: workOrder.status,
toStatus: workOrder.status,
toStatus: updatedOrder?.status || workOrder.status,
payloadJson: JSON.stringify({
shareId: input.shareId,
workerId: Number(share.worker_id || 0),
@@ -515,6 +521,8 @@ export async function reduceWorkOrderShareQuantity(input: {
// 押金口径:本次保持原冻结额,不退不补
shareDeposit: Number(share.share_deposit || 0),
note: String(input.note || '').trim(),
orderStatusAfter: updatedOrder?.status || workOrder.status,
activeShareQuantity: Number(updatedOrder?.active_share_quantity || 0),
}),
now: input.now,
})
@@ -524,7 +532,6 @@ export async function reduceWorkOrderShareQuantity(input: {
[input.shareId],
)
// 必须回读聚合口径的母单(active_share_quantity 等),否则前端拿到的拼单进度会归零
const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId)
return {
order: updatedOrder || workOrder,
share: updatedShare.rows[0] || null,
@@ -619,17 +626,22 @@ export async function submitWorkOrderShareAcceptance(input: {
/**
* 按份额集合推导母单拼单状态(唯一口径,禁止各调用点各自 UPDATE work_orders.status)。
*
* allocated >= total && joined == 0 && submitted > 0 → pending_acceptance
* allocated < total 或 joined > 0 → open
* allocated == 0(全部取消) → open(重新可参与)
* allocated >= total && joined == 0 && submitted > 0 → pending_acceptance(待验收)
* allocated < total → open(后台显示待抢单:空位还在)
* allocated >= total && joined > 0 → open(后台显示代练中:还有份额在做)
* allocated == 0(全部取消) → open(待抢单,重新可参与)
* 母单 accepted / cancelled → 不动
*
* 返回更新后的母单行(供调用方回给前端)。
*
* preserveHallQueue:份额退回大厅时保留原 published_at / hall_queued_at,
* 避免因为母单状态从待验收回退到 open 而被重新排到大厅队尾。
*/
export async function recomputeWorkOrderSharingStatusWithClient(
client: PoolClient,
workOrderId: number,
now: string,
options: { preserveHallQueue?: boolean } = {},
): Promise<WorkOrderRow | null> {
const orderResult = await client.query<WorkOrderRow>(
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
@@ -676,13 +688,17 @@ export async function recomputeWorkOrderSharingStatusWithClient(
return workOrder
}
const hallQueueAssignments = options.preserveHallQueue
? ''
: `published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END,
hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,`
const updated = await client.query<WorkOrderRow>(
`
UPDATE work_orders
SET
status = $1,
published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END,
hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,
${hallQueueAssignments}
submitted_at = CASE
WHEN $1 = 'pending_acceptance' THEN COALESCE(submitted_at, $2)
ELSE submitted_at
@@ -177,7 +177,7 @@ router.post(
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '拼单参与者已撤单,押金已退还,母单状态保持不变',
successMessage: '拼单参与者已撤单,押金已退还,空出的份数已回到接单大厅',
errorMessage: '撤销拼单参与者失败',
scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/cancel]',
audit: (req, data) => ({
@@ -196,7 +196,8 @@ export async function cancelAdminWorkOrderShare(
* 后台按份数撤销某个打手的部分份额。
*
* 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额,
* 母单状态与验收资料都不动(之后仍可对该份额「单独验收」)。
* 母单状态按剩余份额重算(可能从待验收回到待抢单,保留原大厅队列位置),
* 该份额的验收资料不动(之后仍可对该份额「单独验收」)。
*/
export async function reduceAdminWorkOrderShareQuantity(
workOrderId: number | string,
@@ -198,12 +198,14 @@ export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): stri
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0))
const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
const hasActiveShares = activeShareQuantity > 0
const sharingFilled =
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
if (workOrder.status === WORK_ORDER_STATUS.OPEN && workOrder.sharing_enabled && hasActiveShares) {
return sharingFilled && pendingShareCount === 0
if (workOrder.status === WORK_ORDER_STATUS.OPEN && workOrder.sharing_enabled) {
// 有份额回到大厅(未满额)时,母单先回到「待抢单」:空位还在,就还没到按份额状态推进的时候。
// 满额后才按份额状态收敛:仍有 joined → 代练中;全部已提交 → 待验收。
if (!sharingFilled) return WORK_ORDER_STATUS.OPEN
return pendingShareCount === 0
? WORK_ORDER_STATUS.PENDING_ACCEPTANCE
: WORK_ORDER_STATUS.IN_PROGRESS
}
@@ -2046,6 +2046,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
assert.equal(waitingForClaim.status, 'open')
assert.equal(waitingForClaim.capabilities.canUpdateMaterial, true)
// 有份额回到大厅(未满额):空位还在,母单必须先显示「待抢单」。
const partial = mapWorkOrderAdmin(
buildWorkOrderRow({
status: 'open',
@@ -2055,7 +2056,7 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
pending_share_count: 1,
}),
)
assert.equal(partial.status, 'in_progress')
assert.equal(partial.status, 'open')
assert.equal(partial.rawStatus, 'open')
assert.equal(partial.hallAvailable, true)
assert.equal(partial.capabilities.canEdit, false)
@@ -2063,6 +2064,25 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
assert.equal(partial.capabilities.canAssign, false)
assert.equal(partial.capabilities.canUpdateMaterial, true)
assert.equal(partial.capabilities.canCancelSharing, true)
// 整单验收 / 整单标记问题单都要求满额且所有份额满足,不满额时不能走整单动作。
assert.equal(partial.capabilities.canAccept, false)
assert.equal(partial.capabilities.canMarkProblem, false)
// 满额但还有份额没提交:母单代练中,整单验收同样不可用。
const filledInProgress = mapWorkOrderAdmin(
buildWorkOrderRow({
status: 'open',
sharing_enabled: true,
sharing_total_quantity: 15,
active_share_quantity: 15,
pending_share_count: 1,
}),
)
assert.equal(filledInProgress.status, 'in_progress')
assert.equal(filledInProgress.rawStatus, 'open')
assert.equal(filledInProgress.hallAvailable, false)
assert.equal(filledInProgress.capabilities.canAccept, false)
assert.equal(filledInProgress.capabilities.canMarkProblem, true)
const filled = mapWorkOrderAdmin(
buildWorkOrderRow({
@@ -2080,6 +2100,38 @@ test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
assert.equal(filled.capabilities.canCancelSharing, true)
})
test('后台运营态先判空位:不满额必须显示待抢单,满额后才按份额推进', async () => {
const { readFile } = await import('node:fs/promises')
const queryRepo = await readFile(
new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url),
'utf8',
)
const start = queryRepo.indexOf('function adminOperationalStatusExpression')
assert.ok(start >= 0, '缺少 adminOperationalStatusExpression')
const end = queryRepo.indexOf('/** 工单统一搜索字段', start)
const block = queryRepo.slice(start, end > start ? end : start + 4000)
// 两个分支(带 share summary 别名 / 不带别名)都必须先判「有效份数 < 总份数」。
assert.equal(
(block.match(/< \$\{alias\}\.sharing_total_quantity/g) || []).length,
2,
'空位判定必须在两版运营态表达式里都存在',
)
assert.ok(
block.indexOf("THEN 'open'") < block.indexOf("THEN 'pending_acceptance'"),
'空位分支必须排在待验收之前,否则不满额拼单会被误判为待验收',
)
assert.ok(
block.indexOf("THEN 'open'") < block.indexOf("ELSE 'in_progress'"),
'空位分支必须排在代练中之前,否则不满额拼单会被误判为代练中',
)
assert.equal(
(block.match(/THEN 'open'/g) || []).length,
2,
'空位分支必须在两版运营态表达式里都以待抢单收口',
)
})
test('已取消工单可重新启用,恢复未分配后可编辑和发布', () => {
const cancelled = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'cancelled' }))
assert.equal(cancelled.capabilities.canReopen, true)
@@ -3510,13 +3562,21 @@ type ShareAggregate = {
async function runRecompute(
workOrder: Partial<WorkOrderRow>,
aggregate: ShareAggregate,
): Promise<{ status: string | null; updates: unknown[][]; shareQueries: number }> {
options: { preserveHallQueue?: boolean } = {},
): Promise<{
status: string | null
updates: unknown[][]
shareQueries: number
updateSqls: string[]
}> {
const updates: unknown[][] = []
const updateSqls: string[] = []
let shareQueries = 0
const client = {
query: async (sql: string, params: unknown[] = []) => {
if (sql.includes('UPDATE work_orders')) {
updates.push(params)
updateSqls.push(sql)
return { rows: [{ ...buildWorkOrderRow(workOrder), status: params[0] }] }
}
if (sql.includes('FROM work_orders')) {
@@ -3534,8 +3594,9 @@ async function runRecompute(
client,
1,
'2026-10-23T12:00:00.000Z',
options,
)
return { status: updated ? String(updated.status) : null, updates, shareQueries }
return { status: updated ? String(updated.status) : null, updates, shareQueries, updateSqls }
}
test('拼单母单状态由份额集合唯一推导:满额且无待履约份额才转待验收', async () => {
@@ -3574,7 +3635,7 @@ test('拼单母单已验收或已取消时 recompute 不回头改状态', async
}
})
test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收敛', async () => {
test('后台单人撤单重算母单状态,空出份额回大厅并保留原队列位置', async () => {
const src = fs.readFileSync(
new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url),
'utf8',
@@ -3585,14 +3646,18 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收
)
assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在')
assert.ok(
!cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
'后台单人撤单不得重算母单状态(撤一份会把待验收的母单踢回大厅)',
cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
'后台单人撤单必须重算母单状态,空出的份数才能回到大厅',
)
assert.ok(cancelBlock.includes('preserveHallQueue: true'), '后台单人撤单回大厅必须保留原队列位置')
assert.ok(
!/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(cancelBlock),
'后台单人撤单不得自己写母单 status',
)
assert.ok(cancelBlock.includes('toStatus: workOrder.status'), '撤单事件不得记录母单状态迁移')
assert.ok(
cancelBlock.includes('toStatus: updatedOrder?.status || workOrder.status'),
'撤单事件必须记录母单重算后的状态',
)
const timeoutBlock = src.slice(
src.indexOf('export async function settleOverdueWorkOrderShare('),
@@ -3603,6 +3668,7 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收
timeoutBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
'份额超时必须用 recompute 收敛母单状态',
)
assert.ok(timeoutBlock.includes('preserveHallQueue: true'), '份额超时回大厅必须保留原队列位置')
assert.ok(
!/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(timeoutBlock),
'份额超时不得自己写母单 status(只能由 recompute 推导)',
@@ -3642,6 +3708,11 @@ test('撤份额与单人撤单都把原因和份数写进份额行,列表才
shareSrc.indexOf('export async function submitWorkOrderShareAcceptance('),
)
assert.ok(reduceBlock.length > 0, 'reduceWorkOrderShareQuantity 片段必须存在')
assert.ok(
reduceBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
'撤份额必须重算母单状态,减少的份数才能回大厅',
)
assert.ok(reduceBlock.includes('preserveHallQueue: true'), '撤份额回大厅必须保留原队列位置')
assert.ok(reduceBlock.includes('cancel_note = $'), '撤份额必须把原因写进 cancel_note')
assert.ok(
reduceBlock.includes('String(share.cancel_note'),