diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index cdd60818..dda3225c 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -580,6 +580,8 @@ export type WorkOrderCapabilities = { canMarkProblem: boolean canDelete: boolean canReopen: boolean + /** 运营关闭订单:不发起退款,关闭后客户订单服务不可访问,可重新打开。 */ + canCloseOrder: boolean canPublish: boolean canCancel: boolean canCancelSharing: boolean diff --git a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts index 8df97d13..0324594b 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts @@ -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( + `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 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 7d7b3198..83e839c0 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 @@ -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', diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index b7f9de34..4abfc839 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -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) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/customer-refund/preview', requireAdminPermission('worker_order.customer_refund'), diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index a8366b8e..85c49515 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -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' diff --git a/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts index 196f68bc..0bae6169 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts @@ -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' } +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 7dd2c6ab..b14780a2 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -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 { diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.ts b/apps/backend/src/services/worker-platform/order-service-public-service.ts index bcabd258..a71a362b 100644 --- a/apps/backend/src/services/worker-platform/order-service-public-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-public-service.ts @@ -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 } diff --git a/apps/backend/src/services/worker-platform/work-order-close.integration.test.ts b/apps/backend/src/services/worker-platform/work-order-close.integration.test.ts new file mode 100644 index 00000000..6be1e153 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-close.integration.test.ts @@ -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() + } + }, +) diff --git a/apps/backend/src/services/worker-platform/work-order-close.test.ts b/apps/backend/src/services/worker-platform/work-order-close.test.ts new file mode 100644 index 00000000..c7ce46ee --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-close.test.ts @@ -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/) +}) 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 b380b473..5de13b46 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 @@ -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), 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 0bd4b0fe..8453bd6b 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 @@ -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('拼单打手汇总展示当前参与者并合并同一打手的份数', () => { diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index 5b5991fd..c84e4ed0 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -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': { diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 792baf7a..35ffb66c 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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 | 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(null) const [deductOrder, setDeductOrder] = useState(null) const [cancelOrder, setCancelOrder] = useState(null) - const [customerRefundOrder, setCustomerRefundOrder] = useState(null) - const [customerRefundPreview, setCustomerRefundPreview] = useState( - null, - ) - const [customerRefundLoading, setCustomerRefundLoading] = useState(false) - const [customerRefundSubmitting, setCustomerRefundSubmitting] = useState(false) const [problemForm] = Form.useForm() const [resolutionForm] = Form.useForm() const [materialForm] = Form.useForm() @@ -409,7 +393,6 @@ export default function WorkOrdersPanel() { const [cancelSharingForm] = Form.useForm() const [cancelSharingPartForm] = Form.useForm() const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() - const [customerRefundForm] = Form.useForm() 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={} onClick={() => { modal.confirm({ - title: '确认重新启用该订单?', - content: '订单将恢复为待分配状态。请确认资料与金额后,再发布到抢单大厅。', - okText: '重新启用', + title: '确认重新打开该订单?', + content: '订单将恢复为待分配状态,客户可重新访问并继续处理。', + okText: '重新打开', cancelText: '取消', onOk: () => runAction( @@ -2050,7 +2004,7 @@ export default function WorkOrdersPanel() { }) }} > - 重新启用 + 重新打开订单 ) : null} {row.sharing?.enabled || @@ -2079,25 +2033,14 @@ export default function WorkOrdersPanel() { 内部库存匹配 ) : null} - {canCustomerRefund && - [ - 'pending_material', - 'unassigned', - 'open', - 'in_progress', - 'pending_acceptance', - 'problem', - ].includes(String(row.rawStatus || row.status || '')) ? ( + {row.capabilities?.canCloseOrder && canCancelOrder ? ( ) : null} {row.capabilities?.canCancel && (canReturnToHall || canCancelOrder) ? ( @@ -3226,80 +3169,6 @@ export default function WorkOrdersPanel() { - { - setCustomerRefundOrder(null) - setCustomerRefundPreview(null) - customerRefundForm.resetFields() - }} - onOk={() => customerRefundForm.submit()} - okText="同意退款并关闭订单" - okButtonProps={{ - danger: true, - disabled: customerRefundLoading || !customerRefundPreview?.canExecute, - }} - confirmLoading={customerRefundSubmitting} - destroyOnHidden - width={620} - > - {customerRefundLoading ? ( - - ) : customerRefundPreview ? ( - - - - - {customerRefundPreview.platform} / {customerRefundPreview.provider} - - - {customerRefundPreview.platformOrderId} - - - {customerRefundPreview.externalAfterSaleId} - - - {formatMoney(customerRefundPreview.remainingFen)} - - - {customerRefundPreview.workOrderCount} 个工单 / {customerRefundPreview.itemCount}{' '} - 个订单项 - - - {customerRefundPreview.notes.length > 0 ? ( - - ) : null} -
void submitCustomerRefund(values)} - > - - - - - - -
-
- ) : null} -
- ( + `/api/v1/admin/worker-platform/orders/${workOrderId}/close`, + ) +} + export function creditAdminWorkerWallet( workerId: number, payload: { amount: number; note?: string }, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index ca7581ca..740e2597 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -694,6 +694,7 @@ export type WorkOrder = { canMarkProblem: boolean canDelete: boolean canReopen: boolean + canCloseOrder: boolean canPublish: boolean canCancel: boolean canCancelSharing?: boolean diff --git a/apps/frontend/tests/customer-refund-ui.test.ts b/apps/frontend/tests/customer-refund-ui.test.ts deleted file mode 100644 index 4b819ab1..00000000 --- a/apps/frontend/tests/customer-refund-ui.test.ts +++ /dev/null @@ -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')) -}) diff --git a/apps/frontend/tests/work-order-close-ui.test.ts b/apps/frontend/tests/work-order-close-ui.test.ts new file mode 100644 index 00000000..77484469 --- /dev/null +++ b/apps/frontend/tests/work-order-close-ui.test.ts @@ -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')) +}) diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index 00abc269..657f6238 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -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:` 商品