新增接单平台订单关闭与重新打开
This commit is contained in:
@@ -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('拼单打手汇总展示当前参与者并合并同一打手的份数', () => {
|
||||
|
||||
@@ -319,6 +319,14 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
||||
)}`,
|
||||
color: 'red',
|
||||
}
|
||||
case 'closed_by_admin':
|
||||
return {
|
||||
title: note ? `关闭订单(${note})` : '关闭订单',
|
||||
description: `订单状态:${formatStatusLabel(event.fromStatus)} → ${formatStatusLabel(
|
||||
event.toStatus,
|
||||
)}`,
|
||||
color: 'red',
|
||||
}
|
||||
|
||||
// —— 其他 ——
|
||||
case 'payment_amount_updated': {
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
cancelAdminWorkOrderShare,
|
||||
cancelAdminWorkOrderSharing,
|
||||
cancelAdminWorkOrderSharingPart,
|
||||
closeAdminWorkOrder,
|
||||
completeAdminWorkOrderGiftCooldown,
|
||||
confirmAdminWorkOrderFriend,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
@@ -72,11 +73,9 @@ import {
|
||||
fetchAdminWorkerProductNameSuffix,
|
||||
fetchAdminWorkerUsers,
|
||||
fetchAdminWorkOrders,
|
||||
executeAdminCustomerRefund,
|
||||
markAdminWorkOrderProblem,
|
||||
markAdminWorkOrderShareProblem,
|
||||
pinAdminWorkOrder,
|
||||
previewAdminCustomerRefund,
|
||||
publishAdminWorkOrder,
|
||||
reduceAdminWorkOrderShareQuantity,
|
||||
reopenAdminCancelledWorkOrder,
|
||||
@@ -89,7 +88,6 @@ import {
|
||||
updateAdminWorkOrderNote,
|
||||
updateAdminWorkOrderSharing,
|
||||
} from '@/services/admin'
|
||||
import type { CustomerRefundPreview } from '@/services/admin/worker-platform'
|
||||
import {
|
||||
checkWorkOrderConsignmentMatches,
|
||||
matchAdminConsignmentWorkOrder,
|
||||
@@ -205,11 +203,6 @@ type CancelSharingPartFormValues = {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type CustomerRefundFormValues = {
|
||||
reason: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
|
||||
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
|
||||
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
|
||||
@@ -311,9 +304,6 @@ export default function WorkOrdersPanel() {
|
||||
const canCancelOrder = sessionPermissions
|
||||
? sessionPermissions.includes('worker_order.cancel')
|
||||
: hasAdminPermission('worker_order.cancel')
|
||||
const canCustomerRefund = sessionPermissions
|
||||
? sessionPermissions.includes('worker_order.customer_refund')
|
||||
: hasAdminPermission('worker_order.customer_refund')
|
||||
const canCancelShare = sessionPermissions
|
||||
? sessionPermissions.includes('worker_order.cancel_share')
|
||||
: hasAdminPermission('worker_order.cancel_share')
|
||||
@@ -389,12 +379,6 @@ export default function WorkOrdersPanel() {
|
||||
const [noteOrder, setNoteOrder] = useState<WorkOrder | null>(null)
|
||||
const [deductOrder, setDeductOrder] = useState<WorkOrder | null>(null)
|
||||
const [cancelOrder, setCancelOrder] = useState<WorkOrder | null>(null)
|
||||
const [customerRefundOrder, setCustomerRefundOrder] = useState<WorkOrder | null>(null)
|
||||
const [customerRefundPreview, setCustomerRefundPreview] = useState<CustomerRefundPreview | null>(
|
||||
null,
|
||||
)
|
||||
const [customerRefundLoading, setCustomerRefundLoading] = useState(false)
|
||||
const [customerRefundSubmitting, setCustomerRefundSubmitting] = useState(false)
|
||||
const [problemForm] = Form.useForm()
|
||||
const [resolutionForm] = Form.useForm()
|
||||
const [materialForm] = Form.useForm<MaterialFormValues>()
|
||||
@@ -409,7 +393,6 @@ export default function WorkOrdersPanel() {
|
||||
const [cancelSharingForm] = Form.useForm<CancelSharingFormValues>()
|
||||
const [cancelSharingPartForm] = Form.useForm<CancelSharingPartFormValues>()
|
||||
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
|
||||
const [customerRefundForm] = Form.useForm<CustomerRefundFormValues>()
|
||||
const [editForm] = Form.useForm<{
|
||||
productName?: string
|
||||
platformOrderId?: string
|
||||
@@ -709,44 +692,15 @@ export default function WorkOrdersPanel() {
|
||||
cancelForm.setFieldsValue({ action: 'return_to_hall', reason: '' })
|
||||
}
|
||||
|
||||
async function openCustomerRefundModal(row: WorkOrder) {
|
||||
setCustomerRefundOrder(row)
|
||||
setCustomerRefundPreview(null)
|
||||
customerRefundForm.setFieldsValue({ reason: '客户退款', note: '' })
|
||||
setCustomerRefundLoading(true)
|
||||
try {
|
||||
const response = await previewAdminCustomerRefund(row.workOrderId)
|
||||
setCustomerRefundPreview(response.data)
|
||||
} catch (error) {
|
||||
setCustomerRefundOrder(null)
|
||||
message.error(error instanceof Error ? error.message : '客户退款预检失败')
|
||||
} finally {
|
||||
setCustomerRefundLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCustomerRefund(values: CustomerRefundFormValues) {
|
||||
if (!customerRefundOrder || !customerRefundPreview?.canExecute) return
|
||||
setCustomerRefundSubmitting(true)
|
||||
try {
|
||||
const response = await executeAdminCustomerRefund(customerRefundOrder.workOrderId, values)
|
||||
message.success(
|
||||
response.data.idempotent
|
||||
? '该客户退款已处理,无需重复操作'
|
||||
: `客户退款已完成,退款 ${formatMoney(customerRefundPreview.remainingFen)}`,
|
||||
)
|
||||
setCustomerRefundOrder(null)
|
||||
setCustomerRefundPreview(null)
|
||||
customerRefundForm.resetFields()
|
||||
await refreshAll()
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-redeem-certificates'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-kuaishou-industry-vouchers'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '客户退款失败')
|
||||
} finally {
|
||||
setCustomerRefundSubmitting(false)
|
||||
}
|
||||
function closeOrder(row: WorkOrder) {
|
||||
modal.confirm({
|
||||
title: '确认关闭该订单?',
|
||||
content: '关闭后客户将无法访问订单服务,不会发起退款;订单仍保留在后台,可随时重新打开。',
|
||||
okText: '关闭订单',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => runAction(() => closeAdminWorkOrder(row.workOrderId), '订单已关闭'),
|
||||
})
|
||||
}
|
||||
|
||||
function applyMaterialPaste(text: string) {
|
||||
@@ -2038,9 +1992,9 @@ export default function WorkOrdersPanel() {
|
||||
icon={<UndoOutlined />}
|
||||
onClick={() => {
|
||||
modal.confirm({
|
||||
title: '确认重新启用该订单?',
|
||||
content: '订单将恢复为待分配状态。请确认资料与金额后,再发布到抢单大厅。',
|
||||
okText: '重新启用',
|
||||
title: '确认重新打开该订单?',
|
||||
content: '订单将恢复为待分配状态,客户可重新访问并继续处理。',
|
||||
okText: '重新打开',
|
||||
cancelText: '取消',
|
||||
onOk: () =>
|
||||
runAction(
|
||||
@@ -2050,7 +2004,7 @@ export default function WorkOrdersPanel() {
|
||||
})
|
||||
}}
|
||||
>
|
||||
重新启用
|
||||
重新打开订单
|
||||
</Button>
|
||||
) : null}
|
||||
{row.sharing?.enabled ||
|
||||
@@ -2079,25 +2033,14 @@ export default function WorkOrdersPanel() {
|
||||
内部库存匹配
|
||||
</Button>
|
||||
) : null}
|
||||
{canCustomerRefund &&
|
||||
[
|
||||
'pending_material',
|
||||
'unassigned',
|
||||
'open',
|
||||
'in_progress',
|
||||
'pending_acceptance',
|
||||
'problem',
|
||||
].includes(String(row.rawStatus || row.status || '')) ? (
|
||||
{row.capabilities?.canCloseOrder && canCancelOrder ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<RollbackOutlined />}
|
||||
loading={
|
||||
customerRefundLoading && customerRefundOrder?.workOrderId === row.workOrderId
|
||||
}
|
||||
onClick={() => void openCustomerRefundModal(row)}
|
||||
title="同意平台用户退款,并关闭工单、资料、领取链接和待核销任务"
|
||||
onClick={() => closeOrder(row)}
|
||||
title="关闭订单后客户无法访问订单服务,可重新打开"
|
||||
>
|
||||
客户退款
|
||||
关闭订单
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canCancel && (canReturnToHall || canCancelOrder) ? (
|
||||
@@ -3226,80 +3169,6 @@ export default function WorkOrdersPanel() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`客户退款 · ${getDisplayOrderNo(customerRefundOrder)}`}
|
||||
open={Boolean(customerRefundOrder)}
|
||||
onCancel={() => {
|
||||
setCustomerRefundOrder(null)
|
||||
setCustomerRefundPreview(null)
|
||||
customerRefundForm.resetFields()
|
||||
}}
|
||||
onOk={() => customerRefundForm.submit()}
|
||||
okText="同意退款并关闭订单"
|
||||
okButtonProps={{
|
||||
danger: true,
|
||||
disabled: customerRefundLoading || !customerRefundPreview?.canExecute,
|
||||
}}
|
||||
confirmLoading={customerRefundSubmitting}
|
||||
destroyOnHidden
|
||||
width={620}
|
||||
>
|
||||
{customerRefundLoading ? (
|
||||
<Alert type="info" showIcon message="正在查询平台用户售后单..." />
|
||||
) : customerRefundPreview ? (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该操作会同意平台用户退款,并关闭订单下全部工单、资料提交、领取链接和待核销任务。"
|
||||
description="订单、退款、成本与资金流水会保留用于审计;存在已验收或已结算内容时系统会拒绝执行。"
|
||||
/>
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="平台">
|
||||
{customerRefundPreview.platform} / {customerRefundPreview.provider}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">
|
||||
{customerRefundPreview.platformOrderId}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台售后单号">
|
||||
{customerRefundPreview.externalAfterSaleId}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="退款金额">
|
||||
{formatMoney(customerRefundPreview.remainingFen)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关闭范围">
|
||||
{customerRefundPreview.workOrderCount} 个工单 / {customerRefundPreview.itemCount}{' '}
|
||||
个订单项
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{customerRefundPreview.notes.length > 0 ? (
|
||||
<Alert
|
||||
type={customerRefundPreview.canExecute ? 'warning' : 'error'}
|
||||
showIcon
|
||||
message={customerRefundPreview.canExecute ? '处理提示' : '预检未通过'}
|
||||
description={customerRefundPreview.notes.join(';')}
|
||||
/>
|
||||
) : null}
|
||||
<Form
|
||||
form={customerRefundForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => void submitCustomerRefund(values)}
|
||||
>
|
||||
<Form.Item
|
||||
label="退款原因"
|
||||
name="reason"
|
||||
rules={[{ required: true, message: '请填写退款原因' }]}
|
||||
>
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item label="处理备注" name="note">
|
||||
<Input.TextArea rows={3} maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`撤单处理 · ${getDisplayOrderNo(cancelOrder)}`}
|
||||
open={Boolean(cancelOrder)}
|
||||
|
||||
@@ -602,6 +602,12 @@ export function cancelAdminWorkOrder(
|
||||
)
|
||||
}
|
||||
|
||||
export function closeAdminWorkOrder(workOrderId: number) {
|
||||
return apiPost<{ order: WorkOrder; action: 'close' | 'cancel_order' }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/close`,
|
||||
)
|
||||
}
|
||||
|
||||
export function creditAdminWorkerWallet(
|
||||
workerId: number,
|
||||
payload: { amount: number; note?: string },
|
||||
|
||||
@@ -694,6 +694,7 @@ export type WorkOrder = {
|
||||
canMarkProblem: boolean
|
||||
canDelete: boolean
|
||||
canReopen: boolean
|
||||
canCloseOrder: boolean
|
||||
canPublish: boolean
|
||||
canCancel: boolean
|
||||
canCancelSharing?: boolean
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
test('接单工单提供客户退款入口和确认弹窗', async () => {
|
||||
const [panel, service] = await Promise.all([
|
||||
readFile(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../src/services/admin/worker-platform.ts', import.meta.url), 'utf8'),
|
||||
])
|
||||
assert.ok(panel.includes('客户退款'))
|
||||
assert.ok(panel.includes('同意退款并关闭订单'))
|
||||
assert.ok(panel.includes('worker_order.customer_refund'))
|
||||
assert.ok(panel.includes('previewAdminCustomerRefund'))
|
||||
assert.ok(panel.includes('executeAdminCustomerRefund'))
|
||||
assert.ok(service.includes('/customer-refund/preview'))
|
||||
assert.ok(service.includes('/customer-refund'))
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
test('接单工单隐藏客户退款并提供可逆关闭订单入口', async () => {
|
||||
const [panel, service] = await Promise.all([
|
||||
readFile(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../src/services/admin/worker-platform.ts', import.meta.url), 'utf8'),
|
||||
])
|
||||
assert.equal(panel.includes('客户退款'), false)
|
||||
assert.equal(panel.includes('previewAdminCustomerRefund'), false)
|
||||
assert.equal(panel.includes('executeAdminCustomerRefund'), false)
|
||||
assert.ok(panel.includes('关闭订单'))
|
||||
assert.ok(panel.includes('关闭后客户将无法访问订单服务'))
|
||||
assert.ok(panel.includes('重新打开订单'))
|
||||
assert.ok(panel.includes('canCloseOrder'))
|
||||
assert.ok(service.includes('/close'))
|
||||
})
|
||||
+14
-1
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
|
||||
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
|
||||
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
|
||||
status: authoritative
|
||||
version: 2.16
|
||||
version: 2.17
|
||||
updated_at: 2026-11-14
|
||||
---
|
||||
|
||||
@@ -565,6 +565,19 @@ has_active_cost
|
||||
凭证来源、健康状态、更新时间、CK 导出和一次性注入入口。CK 导出不依赖角色绑定、任务领取人或
|
||||
额外权限;导出动作必须留审计且不得复用普通详情接口。
|
||||
|
||||
### 7.2 接单平台订单关闭
|
||||
|
||||
接单工单支持不发起退款的运营关闭,用于人工线下处理退款或终止订单访问:
|
||||
|
||||
- “客户退款”入口在运营完成简化流程前隐藏,但退款事实、既有退款记录和后台退款接口保留。
|
||||
- “关闭订单”复用 `work_orders.status = cancelled`,只关闭未验收且不存在有效拼单份额的工单;
|
||||
正在履约且无拼单份额的工单按原有撤单规则释放/处置打手押金。
|
||||
- 关闭后 `/o/<平台订单号>` 不再返回该工单,客户不能继续访问资料提交页;原始订单、工单事件、
|
||||
履约尝试和资金流水均永久保留。
|
||||
- 后台可对无有效拼单份额的已关闭工单执行“重新打开”,恢复为 `unassigned` 后客户访问和后续编辑、
|
||||
发布流程恢复。
|
||||
- 关闭动作只记录 `closed_by_admin` 工单事件和后台审计,不修改平台支付、退款或售后事实。
|
||||
|
||||
## 八、存量数据处理方案
|
||||
|
||||
### 8.1 已被其他渠道接管的 `man:` 商品
|
||||
|
||||
Reference in New Issue
Block a user