feat(finance): 商品归一按接单模板/人工配置直接命中,减少手工映射

- 接单平台:用工单的 product_rule_id → tpl:<rule_key> 直接归一,不再依赖渠道映射
- 人工渠道:直接使用人工 SKU 配置里指定的商品
- 成本中心「未映射订单项」排除断代归档行,避免断代后仍显示历史未映射量
This commit is contained in:
yml2213
2026-10-31 19:05:02 +08:00
parent 6d86d4df7e
commit 2ba2656d58
3 changed files with 70 additions and 2 deletions
@@ -88,6 +88,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
SELECT COUNT(*)::int
FROM order_items oi
WHERE oi.canonical_product_id IS NULL
AND oi.fulfillment_status <> 'archived'
AND ($1::text IS NULL OR oi.fulfillment_mode = $1)
) AS unmapped_order_item_count
`,
@@ -42,7 +42,11 @@ const mappings = [
},
]
const index = buildMappingIndex(mappings, [{ sku: 'aff-sku', productId: 21 }])
const index = buildMappingIndex(
mappings,
[{ sku: 'aff-sku', productId: 21 }],
[{ ruleKey: 'rule-abc', productId: 31 }],
)
function item(patch: Record<string, unknown> = {}) {
return {
@@ -54,6 +58,8 @@ function item(patch: Record<string, unknown> = {}) {
normalized_sku_name: null as string | null,
normalized_sku_series: null as string | null,
fulfillment_channel: 'work_order' as string | null,
work_rule_key: null as string | null,
manual_config_product_id: null as number | null,
...patch,
}
}
@@ -93,3 +99,27 @@ test('商品归一:名称未命中时按名称系列匹配', () => {
test('商品归一:全部未命中返回 null,不自动建商品', () => {
assert.equal(resolveProductId(item({ sku_code: 'nope' }), index), null)
})
test('商品归一:接单平台按模板 rule_key 直接命中 tpl 商品,无需渠道映射', () => {
assert.equal(
resolveProductId(
item({ fulfillment_channel: 'work_order', sku_code: 'unknown', work_rule_key: 'rule-abc' }),
index,
),
31,
)
})
test('商品归一:人工渠道直接使用配置里指定的商品', () => {
assert.equal(
resolveProductId(
item({
fulfillment_channel: 'manual',
sku_code: 'unknown',
manual_config_product_id: 44,
}),
index,
),
44,
)
})
@@ -32,6 +32,7 @@ export type MappingIndex = {
bySkuSeries: Map<string, number>
byNameSeries: Map<string, number>
affSkus: Map<string, number>
templateRules: Map<string, number>
}
type ItemRow = {
@@ -43,6 +44,8 @@ type ItemRow = {
normalized_sku_name: string | null
normalized_sku_series: string | null
fulfillment_channel: string | null
work_rule_key: string | null
manual_config_product_id: number | null
}
function key(...parts: string[]): string {
@@ -52,6 +55,7 @@ function key(...parts: string[]): string {
export function buildMappingIndex(
mappings: MappingRow[],
affiliateProducts: Array<{ sku: string; productId: number }>,
templateProducts: Array<{ ruleKey: string; productId: number }> = [],
): MappingIndex {
const index: MappingIndex = {
bySku: new Map(),
@@ -59,6 +63,7 @@ export function buildMappingIndex(
bySkuSeries: new Map(),
byNameSeries: new Map(),
affSkus: new Map(),
templateRules: new Map(),
}
for (const mapping of mappings) {
@@ -90,6 +95,10 @@ export function buildMappingIndex(
index.affSkus.set(String(product.sku).trim(), product.productId)
}
for (const product of templateProducts) {
index.templateRules.set(String(product.ruleKey).trim(), product.productId)
}
return index
}
@@ -101,6 +110,17 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
const nameKey = String(item.normalized_sku_name || '').trim()
const seriesKey = String(item.normalized_sku_series || '').trim()
// 人工渠道:配置里已经指定商品,直接用,不需要再建映射。
if (item.fulfillment_channel === 'manual' && item.manual_config_product_id) {
return Number(item.manual_config_product_id)
}
// 接单平台:模板即商品来源(tpl:<rule_key>),无需渠道映射。
if (item.fulfillment_channel === 'work_order' && item.work_rule_key) {
const templateProduct = index.templateRules.get(String(item.work_rule_key).trim())
if (templateProduct) return templateProduct
}
if (item.fulfillment_channel === 'affiliate_dash' && skuCode) {
const aff = index.affSkus.get(skuCode)
if (aff) return aff
@@ -161,9 +181,14 @@ export async function normalizeOrderItemProducts(
oi.sku_name,
oi.normalized_sku_name,
oi.normalized_sku_series,
oi.fulfillment_channel
oi.fulfillment_channel,
wpr.rule_key AS work_rule_key,
cfg.canonical_product_id AS manual_config_product_id
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
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
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
WHERE oi.fulfillment_status = 'assigned'
AND oi.canonical_product_id IS NULL
AND oi.id > $1
@@ -232,11 +257,23 @@ async function loadMappingIndex(): Promise<MappingIndex> {
`,
)
const templateProducts = await query<{ canonical_sku: string; id: number }>(
`
SELECT id, canonical_sku
FROM product_catalog
WHERE status = 'active' AND canonical_sku LIKE 'tpl:%'
`,
)
return buildMappingIndex(
mappings.rows,
affiliateProducts.rows.map((row) => ({
sku: String(row.canonical_sku).slice(4),
productId: Number(row.id),
})),
templateProducts.rows.map((row) => ({
ruleKey: String(row.canonical_sku).slice(4),
productId: Number(row.id),
})),
)
}