refactor(product-match): 抽离接单模板匹配引擎(来源适配器/引擎/价格收口 + 后台按订单项试匹配)

新增 services/product-match:来源适配器(kuaishou-91 合成 91+快手 / pdd / douyin-mashida)+ 平台无关匹配上下文 + 条件打分引擎(并列最高=ambiguous 不猜、逐维诊断)+ 价格数量拼单唯一实现。

- 商店键命名空间化(kuaishou:/pdd:/douyin:),店铺为可选作用域:留空即可按订单信息匹配
- 抖音匹配文本只取内部 SKU,引流标题只进 promoTitle,itemTitle 恒空(修复 0.85 元误命中碎梦浮光珍宝礼盒、拼单未开)
- work-product-match-mappers 改为门面(签名不变),work-order-pricing 改为转发
- 新增后台「按订单项试匹配」接口与面板:走发单同一套链路,返回模板/数量/单价/总额/拼单
- 生产样本回放:91/PDD 6000 条决策 0 差异;抖音 5 条全为修正

设计见 docs/接单模板匹配引擎重构设计.md
This commit is contained in:
yml2213
2026-10-14 15:58:21 +08:00
parent 4b081d88c5
commit 10464fea89
26 changed files with 3218 additions and 477 deletions
@@ -38,11 +38,14 @@ import {
cleanupAdminWorkProductMatchLogs,
saveAdminWorkerProductMatchConfig,
testAdminKuaishouProductMatch,
testAdminProductMatchByItem,
type AdminProductMatchTestByItemResult,
} from '@/services/admin'
import type { KuaishouMatchSource, WorkProductMatchLog } from '@/types/worker-platform'
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatMoney } from './shared'
export default function ProductMatchPanel() {
const { message } = App.useApp()
@@ -52,6 +55,11 @@ export default function ProductMatchPanel() {
const [testResult, setTestResult] = useState<
Awaited<ReturnType<typeof testAdminKuaishouProductMatch>>['data'] | null
>(null)
const [testItemId, setTestItemId] = useState<number | null>(null)
const [itemTestResult, setItemTestResult] = useState<AdminProductMatchTestByItemResult | null>(
null,
)
const [testingByItem, setTestingByItem] = useState(false)
const [selectedLog, setSelectedLog] = useState<WorkProductMatchLog | null>(null)
const [activeTab, setActiveTab] = useState('sources')
const [logStatus, setLogStatus] = useState('')
@@ -180,6 +188,22 @@ export default function ProductMatchPanel() {
}
}
async function runItemMatchTest() {
if (!testItemId) {
message.warning('请输入订单项 ID')
return
}
setTestingByItem(true)
try {
const response = await testAdminProductMatchByItem(testItemId)
setItemTestResult(response.data)
} catch (error) {
message.error(error instanceof Error ? error.message : '试匹配失败')
} finally {
setTestingByItem(false)
}
}
async function updateLegacyFallback(legacyFallbackEnabled: boolean) {
try {
await saveAdminWorkerProductMatchConfig({ legacyFallbackEnabled })
@@ -353,6 +377,94 @@ export default function ProductMatchPanel() {
label: '匹配测试',
children: (
<div className="page-stack">
<Card size="small" title="按订单项试匹配(与发单侧同一套链路)">
<Space wrap>
<InputNumber
min={1}
style={{ width: 200 }}
placeholder="订单项 ID,如 334088"
value={testItemId}
onChange={(value) => setTestItemId(typeof value === 'number' ? value : null)}
/>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={testingByItem}
onClick={() => void runItemMatchTest()}
>
试匹配
</Button>
<Typography.Text type="secondary">
读真实订单项 → 来源适配器 → 匹配引擎 → 数量/单价/拼单;抖音、拼多多、91
都适用。
</Typography.Text>
</Space>
{itemTestResult ? (
<div className="page-stack" style={{ marginTop: 12 }}>
<Descriptions bordered size="small" column={{ xs: 1, md: 3 }}>
<Descriptions.Item label="来源">
{itemTestResult.sourceKey}
</Descriptions.Item>
<Descriptions.Item label="结果">
<MatchStatusTag status={itemTestResult.status} />
</Descriptions.Item>
<Descriptions.Item label="命中模板">
{itemTestResult.rule?.productName || itemTestResult.rule?.ruleKey || '-'}
</Descriptions.Item>
<Descriptions.Item label="匹配文本">
{itemTestResult.orderItem.matchSkuTexts.join(' / ') || '-'}
</Descriptions.Item>
<Descriptions.Item label="店铺键">
{itemTestResult.shopKeys.join(' / ') || '-'}
</Descriptions.Item>
<Descriptions.Item label="文本兜底">
{itemTestResult.allowTextFallback ? '允许' : '不允许'}
</Descriptions.Item>
<Descriptions.Item label="数量">
{itemTestResult.plan ? `${itemTestResult.plan.skuQuantity} 份` : '-'}
</Descriptions.Item>
<Descriptions.Item
label={
itemTestResult.plan?.pricingMode === 'unit' ? '按件单价' : '固定金额'
}
>
{itemTestResult.plan
? formatMoney(
itemTestResult.plan.pricingMode === 'unit'
? itemTestResult.plan.unitPriceFen
: itemTestResult.plan.rewardAmount,
)
: '-'}
</Descriptions.Item>
<Descriptions.Item label="接单总额">
<Typography.Text strong style={{ color: '#3f8600' }}>
{itemTestResult.plan
? formatMoney(itemTestResult.plan.rewardAmount)
: '-'}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="拼单">
{itemTestResult.plan?.sharingEnabled
? `开 · ${itemTestResult.plan.sharingTotalQuantity} 份 × ${formatMoney(itemTestResult.plan.sharingUnitReward)}`
: '关'}
</Descriptions.Item>
<Descriptions.Item label="命中条目">
{itemTestResult.entryKey || '-'}
</Descriptions.Item>
<Descriptions.Item label="店铺">
{itemTestResult.order.shopName || itemTestResult.order.shopId}
</Descriptions.Item>
</Descriptions>
<JsonPreview
value={{
candidates: itemTestResult.candidates,
diagnosis: itemTestResult.diagnosis,
counts: itemTestResult.counts,
}}
/>
</div>
) : null}
</Card>
<Space wrap>
<Select
showSearch
@@ -385,6 +385,55 @@ export function testAdminKuaishouProductMatch(rawPayload: string) {
}>('/api/v1/admin/worker-platform/product-match-test', { rawPayload })
}
export type AdminProductMatchTestByItemResult = {
order: {
id: number
provider: string
platform: string
shopId: string
shopName: string
platformOrderId: string
totalAmount: number
}
orderItem: {
id: number
skuCode: string
skuName: string
quantity: number
matchSkuTexts: string[]
}
sourceKey: string
hasSellerContext: boolean
allowTextFallback: boolean
shopKeys: string[]
status: 'matched' | 'unmatched' | 'ambiguous'
rule: WorkProductRule | null
mappingId: number | null
entryKey: string | null
score: number
candidates: Array<{ key: string; ruleKey: string; score: number; mappingId: number | null }>
diagnosis: Array<{ key: string; ruleKey: string; blockedBy: string[] }>
plan: {
skuQuantity: number
unitPriceFen: number
pricingMode: 'unit' | 'fixed'
rewardAmount: number
sharingEnabled: boolean
sharingTotalQuantity: number
sharingUnitReward: number
acceptanceMode: string
} | null
counts: { enabledRules: number; enabledMappings: number }
}
/** 按订单项 ID 试匹配:与发单侧完全一致的来源适配器 + 引擎 + 价格(任何来源都适用)。 */
export function testAdminProductMatchByItem(orderItemId: number) {
return apiPost<AdminProductMatchTestByItemResult>(
'/api/v1/admin/worker-platform/product-match-test-item',
{ orderItemId },
)
}
export function fetchAdminWorkProductMatchLogs(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkProductMatchLog>>(
'/api/v1/admin/worker-platform/product-match-logs',