feat(fulfillment): 增加待分类订单重试接单匹配
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user