diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 2da809e3..a7a2d866 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -479,6 +479,7 @@ export type WorkOrderCapabilities = { canResolveProblem: boolean canDeduct: boolean canConfirmFriend: boolean + canCompleteGiftCooldown: boolean canResetGiftFlow: boolean } diff --git a/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts index 1a777242..c57f863f 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts @@ -162,6 +162,82 @@ export async function confirmWorkOrderFriendAdded(input: { }) } +/** 后台人工确认赠送冷却已完成:立即开放打手提交赠送凭证,并从当前重新计算任务时限。 */ +export async function completeWorkOrderGiftCooldown(input: { + workOrderId: number + note?: string + actorName?: string + now: string +}): Promise<{ + order: WorkOrderRow | null + failureReason: + | 'work_order_not_found' + | 'work_order_mode_invalid' + | 'work_order_status_invalid' + | 'work_order_gift_phase_invalid' + | 'work_order_gift_cooldown_completed' + | null +}> { + return withTransaction(async (client) => { + const currentResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [input.workOrderId], + ) + const workOrder = currentResult.rows[0] || null + if (!workOrder) return { order: null, failureReason: 'work_order_not_found' } + if ( + normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !== + WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT + ) { + return { order: null, failureReason: 'work_order_mode_invalid' } + } + if (!['in_progress', 'problem'].includes(workOrder.status)) { + return { order: null, failureReason: 'work_order_status_invalid' } + } + if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN) { + return { order: null, failureReason: 'work_order_gift_phase_invalid' } + } + const availableAt = Date.parse(String(workOrder.gift_available_at || '')) + if (Number.isFinite(availableAt) && availableAt <= Date.parse(input.now)) { + return { order: null, failureReason: 'work_order_gift_cooldown_completed' } + } + + await client.query( + ` + UPDATE work_orders + SET + gift_available_at = $1::timestamptz, + deadline_at = CASE + WHEN timeout_minutes > 0 + THEN $1::timestamptz + (timeout_minutes * INTERVAL '1 minute') + ELSE NULL + END, + updated_at = $1::timestamptz + WHERE id = $2 + `, + [input.now, input.workOrderId], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'gift_cooldown_completed_by_admin', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + previousGiftAvailableAt: workOrder.gift_available_at, + completedAt: input.now, + note: String(input.note || '').trim(), + }), + now: input.now, + }) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + failureReason: null, + } + }) +} + /** 后台重置打手资料(资料有误或误确认好友):清空好友状态并让打手重新提交。 */ export async function resetWorkOrderGiftMaterial(input: { workOrderId: number 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 b55d4b29..aa566527 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 @@ -841,7 +841,13 @@ export function buildWorkOrderWhere({ )`) } else if (Object.values(WORK_ORDER_GIFT_PHASE).includes(normalizedGiftPhase as never)) { params.push(normalizedGiftPhase) - filters.push(`wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = $${params.length}`) + const giftPhaseFilter = `wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = $${params.length}` + // 待打手提交资料仅代表已接单后的赠送流程;待抢单/未分配模板单不应混入该筛选。 + filters.push( + normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED + ? `(${giftPhaseFilter} AND wo.assigned_worker_id IS NOT NULL AND wo.status IN ('in_progress', 'problem'))` + : giftPhaseFilter, + ) } } if (hallCandidateLimit > 0) { diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index ceffbbd6..26f47107 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -5,6 +5,7 @@ import { addAdminMockCustomShop, assignAdminWorkOrderToWorker, cancelAdminWorkOrder, + completeAdminWorkOrderGiftCooldown, confirmAdminWorkOrderFriendAdded, createAdminMockWorkOrder, deleteAdminMockCustomShop, @@ -441,6 +442,30 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/complete-gift-cooldown', + requireAdminPermission('worker_order.manage_problem'), + createJsonHandler( + (req) => + completeAdminWorkOrderGiftCooldown( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), + { + successMessage: '已手动结束赠送冷却,打手现在可以提交验收', + errorMessage: '结束赠送冷却失败', + scope: '[admin/worker-platform/orders/:workOrderId/complete-gift-cooldown]', + audit: (req, data) => ({ + action: 'work_order_gift_cooldown_completed', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.get( '/worker-platform/orders/:workOrderId/sharing', requireAdminPermission('worker_order.view'), diff --git a/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts index c92f28fc..289a57e3 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts @@ -1,4 +1,5 @@ import { + completeWorkOrderGiftCooldown, confirmWorkOrderFriendAdded, resetWorkOrderGiftMaterial, } from '../../repositories/worker-platform/index.js' @@ -39,6 +40,51 @@ export async function confirmAdminWorkOrderFriendAdded( return { order: mapWorkOrderAdmin(order) } } +/** 客服手动结束好友赠送冷却,让打手立即可以提交赠送凭证。 */ +export async function completeAdminWorkOrderGiftCooldown( + workOrderId: number | string, + payload: { note?: unknown } = {}, + actorName = '', +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + const { order, failureReason } = await completeWorkOrderGiftCooldown({ + workOrderId: Number(workOrder.id), + note: String(payload.note || '').trim(), + actorName, + now: nowIso(), + }) + if (failureReason === 'work_order_gift_phase_invalid') { + throw createHttpError('当前订单不在好友赠送冷却中,请刷新后重试', { + statusCode: 409, + errorCode: 'work_order_gift_cooldown_phase_invalid', + }) + } + if (failureReason === 'work_order_gift_cooldown_completed') { + throw createHttpError('好友赠送冷却已结束,无需手动操作', { + statusCode: 409, + errorCode: 'work_order_gift_cooldown_already_completed', + }) + } + if (failureReason === 'work_order_status_invalid') { + throw createHttpError('当前订单状态不能结束赠送冷却', { + statusCode: 409, + errorCode: 'work_order_gift_cooldown_status_invalid', + }) + } + if (!order) { + throw createHttpError('结束赠送冷却失败,订单可能已变化', { + statusCode: 409, + errorCode: 'work_order_gift_cooldown_complete_failed', + }) + } + const workerId = Number(order.assigned_worker_id || 0) + publishWorkOrderRealtimeChange({ + workOrderId: Number(order.id), + workerIds: workerId > 0 ? [workerId] : [], + }) + return { order: mapWorkOrderAdmin(order) } +} + /** 打手资料有误或误确认好友时,重置资料让打手重新提交(冷却与好友时间清零)。 */ export async function resetAdminWorkOrderGiftMaterial( workOrderId: number | string, 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 8aaff683..3b1635c3 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 @@ -1,6 +1,7 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { isFriendGiftAcceptanceMode, + isWorkOrderGiftAvailable, normalizeWorkOrderAcceptanceMode, normalizeWorkOrderGiftCooldownHours, normalizeWorkOrderGiftCooldownMinutes, @@ -185,6 +186,13 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha isFriendGift && workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS && giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED, + canCompleteGiftCooldown: + isFriendGift && + [WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes( + workOrder.status as never, + ) && + giftPhase === WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN && + !isWorkOrderGiftAvailable(workOrder), canResetGiftFlow: isFriendGift && [WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes( 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 a29a6875..ecb47995 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 @@ -971,6 +971,42 @@ test('打手订单状态筛选使用个人拼单份额状态', () => { assert.match(whereClause, /= ANY\(\$2::text\[\]\)/) }) +test('好友赠送待打手提交资料筛选排除待抢单订单', () => { + const { whereClause, params } = buildWorkOrderWhere({ giftPhase: 'material_required' }) + + assert.deepEqual(params, ['material_required']) + assert.match(whereClause, /wo\.acceptance_mode = 'friend_gift'/) + assert.match(whereClause, /wo\.gift_phase = \$1/) + assert.match(whereClause, /wo\.assigned_worker_id IS NOT NULL/) + assert.match(whereClause, /wo\.status IN \('in_progress', 'problem'\)/) +}) + +test('好友赠送冷却中才允许客服手动结束冷却', () => { + const cooling = mapWorkOrderAdmin( + buildWorkOrderRow({ + status: 'in_progress', + assigned_worker_id: 1, + acceptance_mode: 'friend_gift', + gift_phase: 'friend_countdown', + gift_available_at: new Date(Date.now() + 60_000).toISOString(), + }), + ) + assert.equal(cooling.gift?.phase, 'friend_countdown') + assert.equal(cooling.capabilities.canCompleteGiftCooldown, true) + + const completed = mapWorkOrderAdmin( + buildWorkOrderRow({ + status: 'in_progress', + assigned_worker_id: 1, + acceptance_mode: 'friend_gift', + gift_phase: 'friend_countdown', + gift_available_at: new Date(Date.now() - 60_000).toISOString(), + }), + ) + assert.equal(completed.gift?.phase, 'gift_ready') + assert.equal(completed.capabilities.canCompleteGiftCooldown, false) +}) + test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { const waitingForClaim = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'open' })) assert.equal(waitingForClaim.status, 'open') diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index 11a084b7..edc93fa3 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -157,6 +157,20 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { case 'material_updated': return { title: '更新了订单资料', color: 'cyan' } + // —— 好友赠送节点 —— + case 'gift_material_submitted': + return { title: '打手提交好友赠送资料,等待客服确认加好友', color: 'cyan' } + case 'friend_confirmed': + return { title: '客服确认已加好友,赠送冷却开始', color: 'gold' } + case 'gift_cooldown_completed_by_admin': + return { + title: note ? `客服手动结束赠送冷却:${note}` : '客服手动结束赠送冷却', + description: '打手现在可以提交赠送凭证与验收资料', + color: 'green', + } + case 'gift_material_reset': + return { title: note ? `客服重置打手好友赠送资料:${note}` : '客服重置打手好友赠送资料', color: 'orange' } + // —— 验收节点 —— case 'acceptance_submitted': { const imageCount = readPayload(event, 'imageCount') diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index a97bdeb0..00a53789 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -50,6 +50,7 @@ import { cancelAdminWorkOrder, cancelAdminWorkOrderShare, cancelAdminWorkOrderSharing, + completeAdminWorkOrderGiftCooldown, confirmAdminWorkOrderFriend, deductAdminWorkOrderPendingDeposit, deleteAdminWorkOrder, @@ -886,6 +887,22 @@ export default function WorkOrdersPanel() { }) } + function completeGiftCooldown(row: WorkOrder) { + modal.confirm({ + title: '手动结束赠送冷却?', + content: + '确认后将立即允许打手提交赠送凭证与验收资料;订单任务时限将从当前时刻重新计算。此操作会记录在订单流转记录中。', + okText: '确认结束冷却', + okButtonProps: { type: 'primary' }, + cancelText: '取消', + onOk: () => + runAction( + () => completeAdminWorkOrderGiftCooldown(row.workOrderId), + '已手动结束赠送冷却,打手现在可以提交验收', + ), + }) + } + function requestResetGiftMaterial(row: WorkOrder) { modal.confirm({ title: '重置打手好友赠送资料?', @@ -1018,7 +1035,9 @@ export default function WorkOrdersPanel() { ) : null} {row.acceptanceMode === 'friend_gift' ? 好友赠送 : null} - {row.acceptanceMode === 'friend_gift' && row.gift ? ( + {row.acceptanceMode === 'friend_gift' && + row.gift && + !['pending_material', 'unassigned', 'open'].includes(row.rawStatus || row.status) ? ( {formatGiftPhase(row.gift.phase)} @@ -1234,6 +1253,11 @@ export default function WorkOrdersPanel() { 确认加好友 ) : null} + {row.capabilities?.canCompleteGiftCooldown && canManageProblem ? ( + + ) : null} {row.capabilities?.canResetGiftFlow && canManageProblem ? ( ) : null} + {detailOrder.capabilities?.canCompleteGiftCooldown && canManageProblem ? ( + + ) : null} {detailOrder.capabilities?.canResetGiftFlow && canManageProblem ? (