feat(接单): 支持客服手动结束赠送冷却

This commit is contained in:
yml2213
2026-08-28 20:39:41 +08:00
parent e680792161
commit 5d5d63bb59
11 changed files with 255 additions and 2 deletions
@@ -479,6 +479,7 @@ export type WorkOrderCapabilities = {
canResolveProblem: boolean
canDeduct: boolean
canConfirmFriend: boolean
canCompleteGiftCooldown: boolean
canResetGiftFlow: boolean
}
@@ -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<WorkOrderRow>(
'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
@@ -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) {
@@ -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<string, unknown>) : {},
}),
},
),
)
router.get(
'/worker-platform/orders/:workOrderId/sharing',
requireAdminPermission('worker_order.view'),
@@ -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,
@@ -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(
@@ -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')