新增接单平台订单关闭与重新打开

This commit is contained in:
yml
2026-11-14 09:40:36 +08:00
parent 81a7721ee1
commit a0c7c90edf
19 changed files with 383 additions and 170 deletions
@@ -580,6 +580,8 @@ export type WorkOrderCapabilities = {
canMarkProblem: boolean
canDelete: boolean
canReopen: boolean
/** 运营关闭订单:不发起退款,关闭后客户订单服务不可访问,可重新打开。 */
canCloseOrder: boolean
canPublish: boolean
canCancel: boolean
canCancelSharing: boolean
@@ -477,6 +477,86 @@ export async function cancelAssignedWorkOrder(input: {
})
}
const CLOSEABLE_IDLE_STATUSES = new Set(['pending_material', 'unassigned', 'open', 'problem'])
/** 未指派、无有效拼单份额时直接关闭工单;不处理退款和真实资金流水。 */
export async function closeIdleWorkOrder(input: {
workOrderId: number
now: string
reason?: string
actorName?: string
}): Promise<{
order: WorkOrderRow | null
failureReason:
| 'work_order_not_found'
| 'work_order_status_invalid'
| 'work_order_has_worker'
| 'work_order_has_active_shares'
| 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 (workOrder.status === 'cancelled') return { order: workOrder, failureReason: null }
if (!CLOSEABLE_IDLE_STATUSES.has(workOrder.status)) {
return { order: workOrder, failureReason: 'work_order_status_invalid' }
}
if (Number(workOrder.assigned_worker_id || 0) > 0) {
return { order: workOrder, failureReason: 'work_order_has_worker' }
}
const activeShares = 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(activeShares.rows[0]?.total || 0) > 0) {
return { order: workOrder, failureReason: 'work_order_has_active_shares' }
}
await releaseConsignmentInventoryBindingForWorkOrderWithClient(client, input.workOrderId, {
now: input.now,
reason: '后台关闭订单',
})
await client.query(
`
UPDATE work_orders
SET status = 'cancelled',
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
published_at = NULL,
hall_queued_at = NULL,
pinned_at = NULL,
${CLEAR_DELIVERY_RESIDUE_SQL},
${RESET_GIFT_FLOW_SQL},
updated_at = $1
WHERE id = $2
`,
[input.now, input.workOrderId],
)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'admin',
actorId: input.actorName || '',
eventType: 'closed_by_admin',
fromStatus: workOrder.status,
toStatus: 'cancelled',
payloadJson: JSON.stringify({ reason: String(input.reason || '').trim() }),
now: input.now,
})
return {
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
failureReason: null,
}
})
}
/** 将已取消工单恢复为未分配状态,不变更历史押金流水。 */
export async function reopenCancelledWorkOrder(input: {
workOrderId: number
@@ -55,6 +55,7 @@ export const WORK_ORDER_SELECT = `
AND wo.status = 'cancelled'
AND ev.event_type IN (
'cancelled_by_admin',
'closed_by_admin',
'problem_cancel_release',
'problem_cancel_deduct',
'timeout_cancel_release',
@@ -6,6 +6,7 @@ import {
addAdminMockCustomShop,
assignAdminWorkOrderToWorker,
cancelAdminWorkOrder,
closeAdminWorkOrder,
completeAdminWorkOrderGiftCooldown,
confirmAdminWorkOrderFriendAdded,
confirmAdminWorkOrderShareFriendAdded,
@@ -364,6 +365,30 @@ router.post(
),
)
router.post(
'/worker-platform/orders/:workOrderId/close',
requireAdminPermission('worker_order.cancel'),
createJsonHandler(
(req) =>
closeAdminWorkOrder(
String(req.params.workOrderId || ''),
req.adminSession?.displayName || req.adminSession?.username || '',
req.adminSession?.permissions || [],
),
{
successMessage: '订单已关闭,客户暂时无法访问',
errorMessage: '关闭订单失败',
scope: '[admin/worker-platform/orders/:workOrderId/close]',
audit: (req, data) => ({
action: 'work_order_closed',
targetType: 'work_order',
targetId: String(req.params.workOrderId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.post(
'/worker-platform/orders/:workOrderId/customer-refund/preview',
requireAdminPermission('worker_order.customer_refund'),
@@ -8,6 +8,7 @@ export {
export { assignAdminWorkOrderToWorker } from './admin-work-order-assignment-service.js'
export {
cancelAdminWorkOrder,
closeAdminWorkOrder,
unassignAdminWorkOrder,
} from './admin-work-order-cancellation-service.js'
export { deductAdminWorkOrderPendingDeposit } from './admin-work-order-deposit-service.js'
@@ -1,6 +1,7 @@
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
cancelAssignedWorkOrder,
closeIdleWorkOrder,
returnAssignedWorkOrderToHall,
unassignWorkOrder,
} from '../../repositories/worker-platform/index.js'
@@ -127,3 +128,78 @@ export async function cancelAdminWorkOrder(
if (workerId) publishWorkerWalletRealtimeChange(workerId)
return { order: mapWorkOrderAdmin(finalOrder), action }
}
/**
* 运营关闭订单。
*
* 不发起平台退款、不改变订单来源事实;只关闭工单并让其客户订单服务不可访问。
* 已验收订单、存在有效拼单份额或处于不可逆交付阶段的订单必须走原有业务流程。
*/
export async function closeAdminWorkOrder(
workOrderId: number | string,
actorName = '',
actorPermissions: string[] = [],
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (workOrder.status === WORK_ORDER_STATUS.CANCELLED) {
return { order: mapWorkOrderAdmin(workOrder), action: 'close' }
}
if (!actorPermissions.includes('worker_order.cancel')) {
throw createHttpError('当前账号没有关闭订单权限', {
statusCode: 403,
errorCode: 'admin_permission_denied',
})
}
if (!mapWorkOrderAdmin(workOrder).capabilities.canCloseOrder) {
throw createHttpError('当前订单状态不能直接关闭,请先处理拼单或完成原业务流程', {
statusCode: 409,
errorCode: 'work_order_close_status_invalid',
})
}
if (workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS) {
return cancelAdminWorkOrder(
workOrder.id,
{ action: 'cancel_order', reason: '后台关闭订单' },
actorName,
actorPermissions,
)
}
const { order: updated, failureReason } = await closeIdleWorkOrder({
workOrderId: Number(workOrder.id),
now: nowIso(),
reason: '后台关闭订单',
actorName,
})
if (failureReason === 'work_order_not_found') {
throw createHttpError('订单不存在', {
statusCode: 404,
errorCode: 'work_order_not_found',
})
}
if (failureReason === 'work_order_has_active_shares') {
throw createHttpError('该订单仍有有效拼单份额,不能直接关闭', {
statusCode: 409,
errorCode: 'work_order_close_has_active_shares',
})
}
if (failureReason === 'work_order_has_worker') {
throw createHttpError('该订单已指派打手,不能直接关闭', {
statusCode: 409,
errorCode: 'work_order_close_has_worker',
})
}
if (!updated || failureReason) {
throw createHttpError('当前订单状态不能直接关闭', {
statusCode: 409,
errorCode: 'work_order_close_status_invalid',
})
}
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrder.id),
hallChanged: workOrder.status === WORK_ORDER_STATUS.OPEN,
})
return { order: mapWorkOrderAdmin(updated), action: 'close' }
}
@@ -46,6 +46,7 @@ export * from './admin-work-order-assignment-service.js'
export * from './admin-work-order-deposit-service.js'
export {
cancelAdminWorkOrder,
closeAdminWorkOrder,
unassignAdminWorkOrder,
} from './admin-work-order-cancellation-service.js'
export {
@@ -425,6 +425,7 @@ async function listWorkOrdersByPlatformOrderId(platformOrderId: string) {
WHERE share.work_order_id = wo.id AND share.status <> 'cancelled'
) share ON TRUE
WHERE wo.platform_order_id = $1
AND wo.status <> 'cancelled'
ORDER BY wo.id ASC
`,
[platformOrderId],
@@ -450,7 +451,13 @@ async function resolveTarget(platformOrderId: string, workOrderIdInput: unknown)
[workOrderId],
)
const order = result.rows[0]
if (!order || String(order.platform_order_id || '').trim() !== platformOrderId) throw notFound()
if (
!order ||
String(order.platform_order_id || '').trim() !== platformOrderId ||
order.status === WORK_ORDER_STATUS.CANCELLED
) {
throw notFound()
}
return order
}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(process.env.WORK_ORDER_CLOSE_TEST_DATABASE_URL || '').trim()
test(
'关闭订单后客户订单服务不可访问,重新打开后恢复',
{ skip: !TEST_DATABASE_URL ? '未设置 WORK_ORDER_CLOSE_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
process.env.ENABLE_DEV_MOCK = '1'
const { closeDb, query, withTransaction } = await import('../../db/client.js')
const { closeAdminWorkOrder } = await import('./admin-work-order-cancellation-service.js')
const { createAdminMockWorkOrder } = await import('./admin-mock-work-order-service.js')
const { reopenAdminCancelledWorkOrder } =
await import('./admin-work-order-management-service.js')
const { getOrderServiceByPlatformOrderId } = await import('./order-service-public-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const platformOrderId = `IT-CLOSE-${suffix}`
let workOrderId = 0
try {
const created = await createAdminMockWorkOrder({
platformOrderId,
productName: `关闭订单集成测试-${suffix}`,
sellerId: '关闭订单集成测试店铺',
rewardAmount: 10,
paymentAmount: 12,
materialComplete: false,
})
workOrderId = Number(created.order.workOrderId)
assert.ok(workOrderId > 0)
const closed = await closeAdminWorkOrder(workOrderId, '集成测试管理员', [
'worker_order.cancel',
])
assert.equal(closed.order.rawStatus, 'cancelled')
await assert.rejects(() => getOrderServiceByPlatformOrderId(platformOrderId))
const stored = await query<{ status: string; event_type: string }>(
`
SELECT wo.status, ev.event_type
FROM work_orders wo
JOIN work_order_events ev ON ev.work_order_id = wo.id
WHERE wo.id = $1 AND ev.event_type = 'closed_by_admin'
ORDER BY ev.id DESC
LIMIT 1
`,
[workOrderId],
)
assert.equal(stored.rows[0]?.status, 'cancelled')
assert.equal(stored.rows[0]?.event_type, 'closed_by_admin')
const reopened = await reopenAdminCancelledWorkOrder(workOrderId, '集成测试管理员')
assert.equal(reopened.order.rawStatus, 'unassigned')
const publicOrder = await getOrderServiceByPlatformOrderId(platformOrderId)
assert.equal(publicOrder.items[0]?.workOrderId, workOrderId)
} finally {
if (workOrderId > 0) {
await withTransaction(async (client) => {
await client.query("SET LOCAL session_replication_role = 'replica'")
await client.query('DELETE FROM work_order_events WHERE work_order_id = $1', [
workOrderId,
])
await client.query('DELETE FROM work_orders WHERE id = $1', [workOrderId])
})
}
delete process.env.ENABLE_DEV_MOCK
await closeDb()
}
},
)
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
test('关闭订单走独立动作并让客户订单服务立即不可访问', async () => {
const [route, service, repository, publicService] = await Promise.all([
readFile(new URL('../../routes/admin/worker-platform/orders.ts', import.meta.url), 'utf8'),
readFile(new URL('./admin-work-order-cancellation-service.ts', import.meta.url), 'utf8'),
readFile(
new URL('../../repositories/worker-platform/work-order-cancel-repo.ts', import.meta.url),
'utf8',
),
readFile(new URL('./order-service-public-service.ts', import.meta.url), 'utf8'),
])
assert.match(route, /orders\/:workOrderId\/close/)
assert.match(route, /work_order_closed/)
assert.match(service, /export async function closeAdminWorkOrder/)
assert.match(service, /requestReason|后台关闭订单/)
assert.match(repository, /export async function closeIdleWorkOrder/)
assert.match(repository, /status = 'cancelled'/)
assert.match(repository, /closed_by_admin/)
assert.match(publicService, /AND wo\.status <> 'cancelled'/)
assert.match(publicService, /order\.status === WORK_ORDER_STATUS\.CANCELLED/)
})
@@ -273,6 +273,7 @@ export function mapWorkOrderSharingWorkers(shares: WorkOrderShareRow[]) {
function resolveWorkOrderCancelSource(cancelEventType: string | null | undefined) {
const eventType = String(cancelEventType || '').trim()
if (eventType === 'cancelled_by_admin') return '后台撤单'
if (eventType === 'closed_by_admin') return '后台关闭订单'
if (eventType.startsWith('problem_')) return '问题单处置'
if (eventType.startsWith('timeout_')) return '超时自动取消'
return eventType || '未知来源'
@@ -371,6 +372,17 @@ export function mapWorkOrderAdmin(
WORK_ORDER_STATUS.CANCELLED,
].includes(workOrder.status as never),
canReopen: workOrder.status === WORK_ORDER_STATUS.CANCELLED && !hasActiveShares,
canCloseOrder:
!hasActiveShares &&
((workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
Boolean(workOrder.assigned_worker_id)) ||
(!workOrder.assigned_worker_id &&
[
WORK_ORDER_STATUS.PENDING_MATERIAL,
WORK_ORDER_STATUS.UNASSIGNED,
WORK_ORDER_STATUS.OPEN,
WORK_ORDER_STATUS.PROBLEM,
].includes(workOrder.status as never))),
canPublish:
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0,
canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id),
@@ -473,10 +473,10 @@ test('工单终态链路全部接上寄卖解绑,避免现货永久 consumed',
`${name} 缺少寄卖解绑调用`,
)
}
// 撤单仓库内 5 条终态链路:打手撤单 / 取消指派 / 退回大厅 / 撤单取消 / 重新启用
// 撤单仓库内 6 条终态链路:打手撤单 / 取消指派 / 退回大厅 / 撤单取消 / 关闭订单 / 重新启用
assert.equal(
cancelRepo.match(/releaseConsignmentInventoryBindingForWorkOrderWithClient\(/g)?.length,
5,
6,
)
})
@@ -2193,14 +2193,27 @@ test('后台运营态先判空位:不满额必须显示待抢单,满额后
test('已取消工单可重新启用,恢复未分配后可编辑和发布', () => {
const cancelled = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'cancelled' }))
assert.equal(cancelled.capabilities.canReopen, true)
assert.equal(cancelled.capabilities.canCloseOrder, false)
assert.equal(cancelled.capabilities.canEdit, false)
assert.equal(cancelled.capabilities.canPublish, false)
const restored = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'unassigned' }))
assert.equal(restored.capabilities.canReopen, false)
assert.equal(restored.capabilities.canCloseOrder, true)
assert.equal(restored.capabilities.canEdit, true)
assert.equal(restored.capabilities.canPublish, true)
const pendingMaterial = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'pending_material' }))
assert.equal(pendingMaterial.capabilities.canCloseOrder, true)
const inProgress = mapWorkOrderAdmin(
buildWorkOrderRow({ status: 'in_progress', assigned_worker_id: 1 }),
)
assert.equal(inProgress.capabilities.canCloseOrder, true)
const accepted = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'accepted' }))
assert.equal(accepted.capabilities.canCloseOrder, false)
const cancelledSharing = mapWorkOrderAdmin(
buildWorkOrderRow({
status: 'cancelled',
@@ -2209,6 +2222,7 @@ test('已取消工单可重新启用,恢复未分配后可编辑和发布', ()
}),
)
assert.equal(cancelledSharing.capabilities.canReopen, false)
assert.equal(cancelledSharing.capabilities.canCloseOrder, false)
})
test('拼单打手汇总展示当前参与者并合并同一打手的份数', () => {