diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 5d64caeb..177da82c 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -99,16 +99,23 @@ const TIMEOUT_COUNT_CACHE_TTL_MS = 5_000 const TIMEOUT_COUNT_CACHE_MAX_ENTRIES = 256 const timeoutCountCache = new Map() -/** 后台按母工单拼单参与情况计算业务状态,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 diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 84ae7f23..a24f0a45 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -822,7 +822,7 @@ export async function acceptWorkOrderShareAndSettle(input: { * 后台撤销单个拼单份额:退还该打手押金、释放这一份的占用。 * * 份额取消后空出的份数必须回到接单大厅,所以这里用统一口径重算母单状态: - * 还有其它有效份额 → open(后台按份额推导为代练中);一份不剩 → open(待抢单)。 + * 存在空位 → open(后台显示待抢单);一份不剩 → open(同样待抢单)。 * 重算时保留原 published_at / hall_queued_at,空出的份额沿用原大厅队列位置。 */ export async function cancelWorkOrderShare(input: { diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 8db212d1..895f4b48 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -626,9 +626,10 @@ 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 → 不动 * * 返回更新后的母单行(供调用方回给前端)。 diff --git a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts index 59547dbd..0fb128f7 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts @@ -196,7 +196,7 @@ export async function cancelAdminWorkOrderShare( * 后台按份数撤销某个打手的部分份额。 * * 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额, - * 母单状态按剩余份额重算(可能从待验收回到代练中,保留原大厅队列位置), + * 母单状态按剩余份额重算(可能从待验收回到待抢单,保留原大厅队列位置), * 该份额的验收资料不动(之后仍可对该份额「单独验收」)。 */ export async function reduceAdminWorkOrderShareQuantity( diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index c267a924..c0f18d70 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -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 } 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 190b875f..a5ac8ad6 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 @@ -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) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index fb1a9b68..cb819745 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -3776,7 +3776,7 @@ export default function WorkOrdersPanel() { showIcon type="warning" message={`将撤销该打手承接的 ${cancelShare.quantity} 份拼单`} - description="撤单只释放这一份份额并退还该份额冻结的押金;空出的份数会回到接单大厅,母单状态按剩余份额重新计算;该份额已提交的验收资料会被清空,仅保留撤单记录。" + description="撤单只释放这一份份额并退还该份额冻结的押金;空出的份数会回到接单大厅,母单显示待抢单;重新拼满后再按各份额状态推进。该份额已提交的验收资料会被清空,仅保留撤单记录。" style={{ marginBottom: 16 }} /> @@ -3810,7 +3810,7 @@ export default function WorkOrdersPanel() { showIcon type="warning" message={`将从该打手的 ${reduceShare.quantity} 份中撤掉指定份数`} - description="撤掉的份数会立刻回到抢单大厅;押金保持原冻结额不退还;验收资料保留,之后仍可单独验收剩余份数。" + description="撤掉的份数会立刻回到抢单大厅,母单显示待抢单;押金保持原冻结额不退还;验收资料保留,之后仍可单独验收剩余份数。" style={{ marginBottom: 16 }} />