feat(fulfillment): 增加待分类订单重试接单匹配

This commit is contained in:
yml2213
2026-11-03 16:01:44 +08:00
parent efe706e601
commit 6e4758a3c7
6 changed files with 255 additions and 2 deletions
@@ -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<string, unknown>,
}),
},
),
)
router.get(
'/fulfillment/internal-sku-mappings',
requireAdminPermission('finance.view'),
@@ -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
@@ -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])
}
@@ -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<string, number>
}
/**
* 重试待分类订单的接单模板匹配。
*
* 只处理仍为 pending 的订单项;匹配成功后由既有消费器创建工单、补齐统一渠道,
* 再由载体判定把订单项转为 assigned。没有匹配到模板的订单保持 pending。
*/
export async function retryPendingWorkOrderRouting(
input: {
internalSku?: string
limit?: number
} = {},
): Promise<PendingWorkOrderRetryResult> {
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<OrderItemRow>(
`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<number, OrderItemRow[]>()
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<string, number> = {}
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,
}
}
@@ -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() {
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
<Button
icon={<SyncOutlined />}
loading={retryWorkOrderMutation.isPending}
onClick={() => retryWorkOrderMutation.mutate(undefined)}
>
重试接单匹配
</Button>
<Button
icon={<ThunderboltOutlined />}
loading={pipelineMutation.isPending}
@@ -267,7 +299,7 @@ function PendingPanel() {
type="info"
showIcon
style={{ marginBottom: 12 }}
message="指定渠道会写入该内部 SKU 的全局配置,并影响所有平台和店铺;系统会先确认载体创建成功,再进入商品归一与成本核算。"
message="待分类来自真实订单,系统不提供直接删除,避免掩盖未履约收入。配置错误请修正模板/映射后点「重试接单匹配」,或通过「指定渠道」完成分流;系统会先确认载体创建成功,再进入商品归一与成本核算。"
/>
<PercentTable
rowKey={(row) => rowKey(row)}
@@ -90,6 +90,15 @@ export type ChannelExceptionListResult = {
export type PipelineRunResult = Record<string, unknown>
export type PendingWorkOrderRetryResult = {
scanned: number
createdWorkOrders: number
assigned: number
remaining: number
carrierFailed: number
skipReasons: Record<string, number>
}
export type FinancePipelineStatus = {
pipeline: string
label: string
@@ -130,6 +139,15 @@ export function fetchChannelExceptions(
return apiGet<ChannelExceptionListResult>('/api/v1/admin/fulfillment/exceptions', params)
}
export function retryPendingWorkOrderRouting(
payload: { internalSku?: string; limit?: number } = {},
) {
return apiPost<PendingWorkOrderRetryResult>(
'/api/v1/admin/fulfillment/pending/retry-work-orders',
payload,
)
}
export function assignChannelByInternalSku(payload: {
internalSku: string
channel: string