From 6e4758a3c750d8c97815faad25ce8cf308d5648d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 3 Nov 2026 16:01:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(fulfillment):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=BE=85=E5=88=86=E7=B1=BB=E8=AE=A2=E5=8D=95=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E6=8E=A5=E5=8D=95=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/admin/fulfillment-channel.ts | 26 +++++ .../fulfillment/channel-assign-service.ts | 2 +- .../fulfillment-routing.integration.test.ts | 73 ++++++++++++ .../pending-channel-repair-service.ts | 104 ++++++++++++++++++ .../admin/AdminFulfillmentChannelPage.tsx | 34 +++++- .../src/services/admin/fulfillment-channel.ts | 18 +++ 6 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/services/fulfillment/pending-channel-repair-service.ts diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 367fc7b7..9e93055b 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -26,6 +26,7 @@ import { type InternalSkuMappingMatchType, } from '../../services/fulfillment/internal-sku-mapping-service.js' import { listExceptionQueue } from '../../services/cost-center/cost-exception-service.js' +import { retryPendingWorkOrderRouting } from '../../services/fulfillment/pending-channel-repair-service.js' import { runFinancePipelineOnce, runSingleFinancePipeline, @@ -59,6 +60,31 @@ router.get( ), ) +router.post( + '/fulfillment/pending/retry-work-orders', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => { + const body = bodyOf(req) + return retryPendingWorkOrderRouting({ + internalSku: String(body.internalSku || ''), + limit: Number(body.limit), + }) + }, + { + successMessage: '接单模板重新匹配完成', + errorMessage: '接单模板重新匹配失败', + scope: '[admin/fulfillment/pending/retry-work-orders]', + audit: (req, data) => ({ + action: 'fulfillment.retry_pending_work_orders', + targetType: 'order_items', + targetId: String(bodyOf(req).internalSku || ''), + data: data as Record, + }), + }, + ), +) + router.get( '/fulfillment/internal-sku-mappings', requireAdminPermission('finance.view'), diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index d0c399a7..5683274b 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -398,7 +398,7 @@ export async function assignPendingOrderItemChannels( return result } -async function assignOrderItemsAfterCarrierReady(input: { +export async function assignOrderItemsAfterCarrierReady(input: { orderItemIds: number[] channel: FulfillmentChannel source: FulfillmentSource diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index 1aadc058..4b0a4c30 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -31,6 +31,7 @@ test( const { saveManualFulfillmentConfig } = await import('../cost-center/manual-fulfillment-config-service.js') const { runAffiliateFallback } = await import('./affiliate-fallback-service.js') + const { retryPendingWorkOrderRouting } = await import('./pending-channel-repair-service.js') const { deleteInternalSkuMapping, listInternalSkuMappingsPage, @@ -47,6 +48,8 @@ test( const triggerSku = `集成测试规范-${suffix}` const fallbackSku = `集成测试兜底-${suffix}` const autoManualSku = `集成测试自动人工-${suffix}` + const retryWorkOrderSku = `集成测试重试接单-${suffix}` + const retryWorkOrderRuleKey = `it-retry-${suffix}` const orderIds: number[] = [] const orderItemIds: number[] = [] const crossShopSku = `集成测试跨店-${suffix}` @@ -58,9 +61,11 @@ test( triggerSku, fallbackSku, autoManualSku, + retryWorkOrderSku, ] let generatedVoucherProductId: number | null = null let autoManualProductId: number | null = null + let retryWorkOrderRuleId: number | null = null let fallbackProfileId: number | null = null async function createPendingOrderItem( @@ -286,6 +291,56 @@ test( assert.ok(Number(autoManualItem.rows[0]?.fulfillment_config_id) > 0) assert.equal(Number(autoManualItem.rows[0]?.canonical_product_id), autoManualProductId) + const retryWorkOrderItemId = await createPendingOrderItem(retryWorkOrderSku, { + provider: 'douyin_mashida', + platform: 'doudian', + shopId: '305760014', + }) + const retryRule = await query<{ id: number }>( + `INSERT INTO work_product_rules ( + rule_key, provider, platform, shop_id, sku_code, product_name, match_type, + category_id, enabled, auto_create, reward_amount, requirement_json, sort_order, + created_at, updated_at, pricing_mode + ) VALUES ( + $1, '', '', '', '', $2, 'exact', NULL, TRUE, TRUE, 1000, '{}'::jsonb, 1, + NOW(), NOW(), 'fixed' + ) RETURNING id`, + [retryWorkOrderRuleKey, retryWorkOrderSku], + ) + retryWorkOrderRuleId = Number(retryRule.rows[0]?.id || 0) + await query( + `INSERT INTO work_product_rule_mappings ( + rule_id, seller_ids_json, rel_item_id, item_title, rel_sku_id, sku_nick, + mapping_type, enabled, created_at, updated_at, sku_match_mode + ) VALUES ( + $1, '["305760014"]'::jsonb, '', '', '', $2, + 'sku_exact', TRUE, NOW(), NOW(), 'exact' + )`, + [retryWorkOrderRuleId, retryWorkOrderSku], + ) + const retriedWorkOrders = await retryPendingWorkOrderRouting({ + internalSku: retryWorkOrderSku, + }) + assert.equal(retriedWorkOrders.scanned, 1) + assert.equal(retriedWorkOrders.createdWorkOrders, 1) + assert.equal(retriedWorkOrders.assigned, 1) + assert.equal(retriedWorkOrders.remaining, 0) + const retriedItem = await query<{ + fulfillment_status: string + fulfillment_channel: string + work_rule_key: string | null + }>( + `SELECT oi.fulfillment_status, oi.fulfillment_channel, wpr.rule_key AS work_rule_key + FROM order_items oi + LEFT JOIN work_orders wo ON wo.order_item_id = oi.id + LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id + WHERE oi.id = $1`, + [retryWorkOrderItemId], + ) + assert.equal(retriedItem.rows[0]?.fulfillment_status, 'assigned') + assert.equal(retriedItem.rows[0]?.fulfillment_channel, 'work_order') + assert.equal(retriedItem.rows[0]?.work_rule_key, retryWorkOrderRuleKey) + const crossShopA = `it-routing-a-${suffix}` const crossShopB = `it-routing-b-${suffix}` await createPendingOrderItem(crossShopSku, { shopId: crossShopA }) @@ -459,6 +514,15 @@ test( await query(`DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`, [ orderItemIds, ]) + await query( + `DELETE FROM work_order_events WHERE work_order_id IN ( + SELECT id FROM work_orders WHERE order_item_id = ANY($1::bigint[]) + )`, + [orderItemIds], + ) + await query(`DELETE FROM work_orders WHERE order_item_id = ANY($1::bigint[])`, [ + orderItemIds, + ]) await query(`DELETE FROM order_items WHERE id = ANY($1::bigint[])`, [orderItemIds]) } if (orderIds.length > 0) { @@ -507,6 +571,15 @@ test( ]) await query(`DELETE FROM product_catalog WHERE id = $1`, [autoManualProductId]) } + if (retryWorkOrderRuleId) { + await query(`DELETE FROM work_product_match_logs WHERE rule_id = $1`, [ + retryWorkOrderRuleId, + ]) + await query(`DELETE FROM cost_versions WHERE work_product_rule_id = $1`, [ + retryWorkOrderRuleId, + ]) + await query(`DELETE FROM work_product_rules WHERE id = $1`, [retryWorkOrderRuleId]) + } if (fallbackProfileId) { await query(`DELETE FROM fulfillment_profiles WHERE id = $1`, [fallbackProfileId]) } diff --git a/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts b/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts new file mode 100644 index 00000000..27015f5f --- /dev/null +++ b/apps/backend/src/services/fulfillment/pending-channel-repair-service.ts @@ -0,0 +1,104 @@ +import { query } from '../../db/client.js' +import { getOrderById } from '../../repositories/order-repo.js' +import type { OrderItemRow } from '../../types/repository/rows.js' +import { assignOrderItemsAfterCarrierReady } from './channel-assign-service.js' +import { canonicalizeInternalSku } from './internal-sku-resolver.js' +import { syncWorkerOrdersForSourceOrder } from '../worker-platform/sync-worker-orders-service.js' + +export type PendingWorkOrderRetryResult = { + scanned: number + createdWorkOrders: number + assigned: number + remaining: number + carrierFailed: number + skipReasons: Record +} + +/** + * 重试待分类订单的接单模板匹配。 + * + * 只处理仍为 pending 的订单项;匹配成功后由既有消费器创建工单、补齐统一渠道, + * 再由载体判定把订单项转为 assigned。没有匹配到模板的订单保持 pending。 + */ +export async function retryPendingWorkOrderRouting( + input: { + internalSku?: string + limit?: number + } = {}, +): Promise { + const internalSku = canonicalizeInternalSku(input.internalSku) + const limit = Math.min(500, Math.max(1, Math.trunc(Number(input.limit) || 200))) + const params: unknown[] = [limit] + const skuClause = internalSku + ? `AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $2` + : '' + if (internalSku) params.push(internalSku) + const pending = await query( + `SELECT oi.* + FROM order_items oi + WHERE oi.fulfillment_status = 'pending' + ${skuClause} + ORDER BY oi.id + LIMIT $1`, + params, + ) + const items = pending.rows + if (items.length === 0) { + return { + scanned: 0, + createdWorkOrders: 0, + assigned: 0, + remaining: 0, + carrierFailed: 0, + skipReasons: {}, + } + } + + const byOrder = new Map() + for (const item of items) { + const orderId = Number(item.order_id) + const group = byOrder.get(orderId) || [] + group.push(item) + byOrder.set(orderId, group) + } + + let createdWorkOrders = 0 + const skipReasons: Record = {} + for (const [orderId, orderItems] of byOrder) { + const order = await getOrderById(orderId) + if (!order) continue + const result = await syncWorkerOrdersForSourceOrder(order, orderItems, { + source: 'admin_pending_retry', + autoOnly: true, + skipChannelAssign: true, + }) + createdWorkOrders += result.createdCount + for (const skipped of result.skipped) { + skipReasons[skipped.reason] = (skipReasons[skipped.reason] || 0) + 1 + } + } + + const ids = items.map((item) => Number(item.id)) + const assigned = await assignOrderItemsAfterCarrierReady({ + orderItemIds: ids, + channel: 'work_order', + source: 'auto_mapping', + lock: false, + note: '运营重试接单模板匹配', + }) + const remaining = await query<{ count: number }>( + `SELECT COUNT(*)::int AS count + FROM order_items + WHERE id = ANY($1::bigint[]) AND fulfillment_status = 'pending'`, + [ids], + ) + + return { + scanned: items.length, + createdWorkOrders, + assigned: assigned.updated, + remaining: Number(remaining.rows[0]?.count || 0), + carrierFailed: assigned.carrierFailed, + skipReasons, + } +} diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 5a77b171..5c315d65 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -11,6 +11,7 @@ import { PlusOutlined, ReloadOutlined, SearchOutlined, + SyncOutlined, ThunderboltOutlined, } from '@ant-design/icons' import { @@ -44,6 +45,7 @@ import { fetchInternalSkuMappings, fetchPendingChannelGroups, fetchPipelineStatus, + retryPendingWorkOrderRouting, runFinancePipeline, type FinancePipelineStatus, type InternalSkuMapping, @@ -151,6 +153,29 @@ function PendingPanel() { onError: (error) => message.error(error instanceof Error ? error.message : '指定渠道失败'), }) + const retryWorkOrderMutation = useMutation({ + mutationFn: (inputSku?: string) => + retryPendingWorkOrderRouting(inputSku ? { internalSku: inputSku } : {}), + onSuccess: (result) => { + const data = result.data + const detail = [ + `重试 ${data.scanned} 个订单项`, + `新建工单 ${data.createdWorkOrders} 个`, + `转接单平台 ${data.assigned} 个`, + ].join(',') + if (data.remaining > 0) { + message.warning(`${detail},仍有 ${data.remaining} 个未匹配到接单模板`) + } else { + message.success(detail) + } + void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] }) + void queryClient.invalidateQueries({ queryKey: ['fulfillment-exceptions'] }) + }, + onError: (error) => + message.error(error instanceof Error ? error.message : '重新匹配接单模板失败'), + }) + const pipelineMutation = useMutation({ mutationFn: () => runFinancePipeline({ days: 3 }), onSuccess: () => { @@ -253,6 +278,13 @@ function PendingPanel() { +