feat(finance): 商品归一按接单模板/人工配置直接命中,减少手工映射
- 接单平台:用工单的 product_rule_id → tpl:<rule_key> 直接归一,不再依赖渠道映射 - 人工渠道:直接使用人工 SKU 配置里指定的商品 - 成本中心「未映射订单项」排除断代归档行,避免断代后仍显示历史未映射量
This commit is contained in:
@@ -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),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user