重构:硬切商品来源并移除旧运维入口

This commit is contained in:
yml2213
2026-11-08 19:44:27 +08:00
parent f8cbb645fe
commit 2ebc4a0ec7
24 changed files with 273 additions and 734 deletions
@@ -110,7 +110,7 @@ export async function syncAdminAffiliateDashProducts() {
GROUP BY sku_code, sku_name
)
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
)
SELECT
@@ -118,6 +118,7 @@ export async function syncAdminAffiliateDashProducts() {
ranked.sku_name,
'',
CASE WHEN ranked.sku_name LIKE '%个' THEN '个' ELSE '件' END,
'affiliate_dash',
'active',
jsonb_build_object(
'source', 'affiliate_dash_task',
@@ -133,6 +134,7 @@ export async function syncAdminAffiliateDashProducts() {
ON CONFLICT (canonical_sku) DO UPDATE
SET canonical_name = CASE WHEN product_catalog.canonical_name = '' THEN EXCLUDED.canonical_name ELSE product_catalog.canonical_name END,
unit_name = EXCLUDED.unit_name,
source_kind = EXCLUDED.source_kind,
attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json,
updated_at = $1
RETURNING (xmax = 0) AS inserted
@@ -319,12 +321,15 @@ export async function setAdminAffiliateProductPrice(
id: number
canonical_sku: string
canonical_name: string
}>('SELECT id, canonical_sku, canonical_name FROM product_catalog WHERE id = $1', [productId])
source_kind: string
}>('SELECT id, canonical_sku, canonical_name, source_kind FROM product_catalog WHERE id = $1', [
productId,
])
const product = productResult.rows[0]
if (!product) {
throw createHttpError('商品不存在', { statusCode: 404 })
}
if (!product.canonical_sku.startsWith('aff:')) {
if (product.source_kind !== 'affiliate_dash') {
throw createHttpError('该商品不是自动发货商品,无法设置自动发货采购成本', { statusCode: 400 })
}
@@ -501,16 +506,17 @@ export async function importAdminAffiliateDashPricesBaseline(session: AdminSessi
const productInsert = await query<{ id: number; inserted: boolean }>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
) VALUES (
$1, $2, $3, $4, 'active',
$1, $2, $3, $4, 'affiliate_dash', 'active',
jsonb_build_object('source', 'affiliate_dash_price_import', 'skuCode', $5::text),
$6, $6
)
ON CONFLICT (canonical_sku) DO UPDATE
SET canonical_name = CASE WHEN product_catalog.canonical_name = '' THEN EXCLUDED.canonical_name ELSE product_catalog.canonical_name END,
category = CASE WHEN product_catalog.category = '' THEN EXCLUDED.category ELSE product_catalog.category END,
source_kind = EXCLUDED.source_kind,
updated_at = $6
RETURNING id, (xmax = 0) AS inserted
`,
@@ -103,8 +103,8 @@ export const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida']
export const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00'
/**
* 商品来源前缀。商品表不再存履约载体,来源只由 canonical_sku 前缀表达。
* 前缀之外的 SKU 归入「未分类」,不允许反推履约方式。
* 商品创建时使用的编码前缀。运行期来源统一读取 product_catalog.source_kind,
* 不能再用 canonical_sku 前缀反推商品来源或履约方式。
*/
export const PRODUCT_SOURCE_PREFIXES: Array<{
prefix: string
@@ -3,7 +3,7 @@
*
* 把数据库行映射成接口 DTO,并提供「成本模型是否适用于履约方式」这类口径函数。
*/
import { GENERIC_COST_MODELS, PRODUCT_SOURCE_PREFIXES } from './cost-center-constants.js'
import { GENERIC_COST_MODELS } from './cost-center-constants.js'
import type { FulfillmentChannel } from './cost-center-constants.js'
import { parseJsonObject } from './cost-center-normalizers.js'
import type {
@@ -33,26 +33,9 @@ export const PRODUCT_CURRENT_CHANNEL_FILTERS = [
export type ProductCurrentChannelFilter = (typeof PRODUCT_CURRENT_CHANNEL_FILTERS)[number]
/**
* 由 canonical_sku 前缀推导商品来源。
* 返回空串表示未分类;未分类商品不允许反推履约方式。
*/
export function productSourceOf(canonicalSku: string): string {
const sku = String(canonicalSku || '').trim()
for (const item of PRODUCT_SOURCE_PREFIXES) {
if (sku.startsWith(item.prefix)) return item.source
}
return ''
}
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string {
return `(
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%')
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
)`
/** 商品来源筛选的唯一 SQL 口径:读取 source_kind,不解析 canonical_sku。 */
export function productSourceFilterSql(paramIndex: number, column = 'p.source_kind'): string {
return `${column} = $${paramIndex}`
}
/** 当前渠道筛选:标准商品页签的唯一筛选口径。 */
@@ -121,8 +104,8 @@ export function mapProduct(row: ProductRow) {
canonicalSku: row.canonical_sku,
canonicalName: row.canonical_name,
category: row.category || '',
source: String(row.source_kind || productSourceOf(row.canonical_sku)),
sourceKind: String(row.source_kind || productSourceOf(row.canonical_sku)),
source: String(row.source_kind || 'unclassified'),
sourceKind: String(row.source_kind || 'unclassified'),
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
rootCategoryName: row.root_category_name || row.category || '',
subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null,
@@ -4,7 +4,7 @@
* 口径(2026-10-23 重写):
* 1. 履约渠道只有 fulfillments.channel/status 一个真源;
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
* 3. 商品来源读取 product_catalog.source_kind,商品表不再存履约载体;
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
*
* 2026-10-25 拆分:804 行的共享层按职责拆成四个模块,本文件只做统一出口,
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { costModelAppliesTo } from './cost-center-mappers.js'
import { costModelAppliesTo, mapProduct } from './cost-center-mappers.js'
import {
normalizeMappingMatchType,
normalizeProviderKey,
@@ -288,8 +288,9 @@ test('接单模板改价必须在同一事务中同步成本版本与模板投
test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'third_party_voucher'), true)
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'manual'), false)
assert.ok(productSourceFilterSql(1).includes("'third_party_voucher'"))
assert.ok(productSourceFilterSql(1).includes("'tpv:%'"))
const sourceSql = productSourceFilterSql(1)
assert.equal(sourceSql, 'p.source_kind = $1')
assert.ok(!sourceSql.includes('canonical_sku'))
const channelSql = productChannelFilterSql(1)
assert.ok(channelSql.includes('active_primary_channel'))
assert.ok(channelSql.includes("$1 = 'unrouted'"))
@@ -351,11 +352,35 @@ test('运行期停止写入已废弃的 product_channel_capabilities', async ()
}
})
test('tpv: 商品来源归为第三方卡券', async () => {
const { productSourceOf } = await import('./cost-center-mappers.js')
const assert = (await import('node:assert/strict')).default
assert.equal(productSourceOf('tpv:模拟卡券'), 'third_party_voucher')
assert.equal(productSourceOf('man:模拟人工'), 'manual')
test('商品来源只读 source_kind,不再按 canonical_sku 前缀回退', () => {
const product = mapProduct({
id: 1,
canonical_sku: 'man:编码与来源故意不一致',
canonical_name: '测试商品',
category: '',
unit_name: '件',
status: 'active',
root_category_id: null,
root_category_name: '',
sub_category_id: null,
sub_category_name: '',
attributes_json: {},
created_at: '',
updated_at: '',
source_kind: 'third_party_voucher',
})
assert.equal(product.source, 'third_party_voucher')
assert.equal(product.sourceKind, 'third_party_voucher')
})
test('阶段 6 不再保留会写旧履约列的运维脚本', async () => {
const packageJson = JSON.parse(
await readFile(new URL('../../../package.json', import.meta.url), 'utf8'),
) as { scripts?: Record<string, string> }
assert.equal(packageJson.scripts?.['finance:sync'], undefined)
assert.equal(packageJson.scripts?.['finance:rebuild-cutover'], undefined)
assert.equal(packageJson.scripts?.['products:merge-duplicates'], undefined)
assert.equal(packageJson.scripts?.['products:reclassify'], undefined)
})
test('人工标准商品禁止绕过人工 SKU 配置手工创建', async () => {
@@ -247,7 +247,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
const platformKey = String(input.platformKey || input.platform_key || '').trim()
const provider = String(input.provider || '').trim()
const conditions: string[] = [`p.status = 'active'`, `p.canonical_sku LIKE 'man:%'`]
const conditions: string[] = [`p.status = 'active'`]
const params: unknown[] = ['manual']
conditions.push(productSourceFilterSql(params.length))
@@ -277,8 +277,14 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
AND o.provider = $${params.length}
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
= canonicalize_internal_sku(substring(p.canonical_sku from 5))
AND p.internal_sku_id = (
SELECT sku.id
FROM internal_skus sku
WHERE sku.internal_sku_key = resolve_internal_sku(
oi.manual_sku_key, oi.sku_name, oi.sku_code
)
LIMIT 1
)
)`)
}
@@ -287,11 +293,11 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
`
SELECT COUNT(*)::int AS total
FROM product_catalog p
JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id
LEFT JOIN LATERAL (
SELECT *
FROM manual_fulfillment_configs cfg
WHERE canonicalize_internal_sku(cfg.sku_key)
= canonicalize_internal_sku(substring(p.canonical_sku from 5))
WHERE canonicalize_internal_sku(cfg.sku_key) = product_sku.internal_sku_key
ORDER BY cfg.id
LIMIT 1
) cfg ON TRUE
@@ -322,7 +328,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
)
SELECT
p.id,
substring(p.canonical_sku from 5) AS sku_key,
product_sku.internal_sku_key AS sku_key,
p.canonical_name AS display_name,
COALESCE(manual_stats.shop_count, 0)::int AS shop_count,
COALESCE(manual_stats.order_count, 0)::int AS order_count,
@@ -340,16 +346,16 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
cfg.enabled AS config_enabled,
cfg.channels_json AS config_channels_json
FROM product_catalog p
JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id
LEFT JOIN LATERAL (
SELECT *
FROM manual_fulfillment_configs cfg
WHERE canonicalize_internal_sku(cfg.sku_key)
= canonicalize_internal_sku(substring(p.canonical_sku from 5))
WHERE canonicalize_internal_sku(cfg.sku_key) = product_sku.internal_sku_key
ORDER BY cfg.id
LIMIT 1
) cfg ON TRUE
LEFT JOIN manual_stats
ON manual_stats.sku_key = canonicalize_internal_sku(substring(p.canonical_sku from 5))
ON manual_stats.sku_key = product_sku.internal_sku_key
WHERE ${where}
ORDER BY COALESCE(manual_stats.item_count, 0) DESC, p.canonical_name ASC, p.id ASC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
@@ -388,18 +394,22 @@ export async function getManualSkuOverview() {
GROUP BY 1
),
manual_scope AS (
SELECT p.id, p.canonical_sku, p.canonical_name, cfg.id AS config_id
SELECT
p.id,
p.canonical_sku,
p.canonical_name,
product_sku.internal_sku_key,
cfg.id AS config_id
FROM product_catalog p
JOIN internal_skus product_sku ON product_sku.id = p.internal_sku_id
LEFT JOIN LATERAL (
SELECT cfg.id
FROM manual_fulfillment_configs cfg
WHERE canonicalize_internal_sku(cfg.sku_key)
= canonicalize_internal_sku(substring(p.canonical_sku from 5))
WHERE canonicalize_internal_sku(cfg.sku_key) = product_sku.internal_sku_key
ORDER BY cfg.id
LIMIT 1
) cfg ON TRUE
WHERE p.status = 'active'
AND p.canonical_sku LIKE 'man:%'
AND ${productSourceFilterSql(1)}
)
SELECT
@@ -414,9 +424,7 @@ export async function getManualSkuOverview() {
MAX(manual_stats.last_seen_date)::text AS last_scan_at
FROM manual_scope
LEFT JOIN manual_stats
ON manual_stats.sku_key = canonicalize_internal_sku(
substring(manual_scope.canonical_sku from 5)
)
ON manual_stats.sku_key = manual_scope.internal_sku_key
`,
['manual'],
)
@@ -469,8 +477,12 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
let resolvedProductId = canonicalProductId
if (resolvedProductId) {
const product = await client.query<{ canonical_sku: string; status: string }>(
`SELECT canonical_sku, status
const product = await client.query<{
canonical_sku: string
source_kind: string
status: string
}>(
`SELECT canonical_sku, source_kind, status
FROM product_catalog
WHERE id = $1
LIMIT 1`,
@@ -479,9 +491,9 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
if (
!product.rows[0] ||
product.rows[0].status !== 'active' ||
!String(product.rows[0].canonical_sku || '').startsWith('man:')
product.rows[0].source_kind !== 'manual'
) {
throw createHttpError('人工履约配置只能关联有效的 man: 标准商品', {
throw createHttpError('人工履约配置只能关联来源为人工的有效标准商品', {
statusCode: 400,
errorCode: 'manual_config_product_source_invalid',
})
@@ -510,10 +522,10 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
.slice(0, 120) || skuKey.slice(0, 120)
const product = await client.query<{ id: number }>(
`INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
) VALUES (
$1, $2, '人工', '件', 'active',
$1, $2, '人工', '件', 'manual', 'active',
'{"source":"manual_config","auto_created":true}'::jsonb, $3, $3
)
ON CONFLICT (canonical_sku) DO UPDATE SET
@@ -789,8 +801,8 @@ export async function deleteManualSkuClassification(
),
updated_at = $2::timestamptz
WHERE status = 'active'
AND canonical_sku LIKE 'man:%'
AND canonicalize_internal_sku(substring(canonical_sku from 5)) = $1
AND source_kind = 'manual'
AND internal_sku_id = (SELECT id FROM internal_skus WHERE internal_sku_key = $1 LIMIT 1)
RETURNING id
`,
[key, nowIso()],
@@ -67,13 +67,10 @@ import {
/** 标准商品页签的当前渠道筛选取值。 */
const PRODUCT_CHANNEL_FILTERS = new Set<string>(PRODUCT_CURRENT_CHANNEL_FILTERS)
/**
* 校验新建商品的 SKU 前缀。
* 前缀是商品来源的唯一表达,不允许出现无法归类的商品。
*/
function assertKnownSourcePrefix(canonicalSku: string) {
/** 创建商品时把编码前缀固化为 source_kind,运行期不再反推来源。 */
function resolveProductSourceKind(canonicalSku: string) {
for (const item of PRODUCT_SOURCE_PREFIXES) {
if (canonicalSku.startsWith(item.prefix)) return
if (canonicalSku.startsWith(item.prefix)) return item.source
}
throw createHttpError(
`标准 SKU 必须以下列前缀开头:${PRODUCT_SOURCE_PREFIXES.map((item) => item.prefix).join(' / ')}`,
@@ -225,9 +222,9 @@ export async function listAdminProducts(payload: JsonObject = {}) {
ORDER BY
p.status ASC,
CASE
WHEN p.canonical_sku LIKE 'aff:%' THEN 1
WHEN p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%' THEN 2
WHEN p.canonical_sku LIKE 'man:%' THEN 3
WHEN p.source_kind = 'affiliate_dash' THEN 1
WHEN p.source_kind = 'work_order' THEN 2
WHEN p.source_kind = 'manual' THEN 3
ELSE 4
END,
p.canonical_name ASC,
@@ -323,7 +320,7 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes
errorCode: 'cost_product_sku_invalid',
})
}
assertKnownSourcePrefix(canonicalSku)
const sourceKind = resolveProductSourceKind(canonicalSku)
const canonicalName = normalizeRequiredText(
payload.canonicalName ?? payload.canonical_name,
'商品名称',
@@ -338,13 +335,22 @@ export async function createAdminProduct(payload: JsonObject, _session: AdminSes
const result = await query<ProductRow>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)
ON CONFLICT (canonical_sku) DO NOTHING
RETURNING *
`,
[canonicalSku, canonicalName, category, unitName, status, JSON.stringify(attributes), now],
[
canonicalSku,
canonicalName,
category,
unitName,
sourceKind,
status,
JSON.stringify(attributes),
now,
],
)
const inserted = result.rows[0]
if (!inserted) {
@@ -449,7 +455,7 @@ export async function deleteAdminVoucherProduct(productId: number | string) {
[id],
)
const current = requiredRow(currentResult.rows[0], '商品不存在')
if (!current.canonical_sku.startsWith('tpv:')) {
if (current.source_kind !== 'third_party_voucher') {
throw createHttpError('该商品不是第三方卡券商品', {
statusCode: 400,
errorCode: 'voucher_product_type_invalid',
@@ -1498,9 +1504,10 @@ export async function setAdminProductPrice(
id: number
canonical_sku: string
canonical_name: string
source_kind: string
attributes_json: unknown
}>(
'SELECT id, canonical_sku, canonical_name, attributes_json FROM product_catalog WHERE id = $1',
'SELECT id, canonical_sku, canonical_name, source_kind, attributes_json FROM product_catalog WHERE id = $1',
[productId],
)
const product = productResult.rows[0]
@@ -1509,7 +1516,7 @@ export async function setAdminProductPrice(
}
// 1. 如果是自动发货商品,委托给 setAdminAffiliateProductPrice
if (product.canonical_sku.startsWith('aff:')) {
if (product.source_kind === 'affiliate_dash') {
return setAdminAffiliateProductPrice(productId, payload, session)
}
@@ -1523,7 +1530,7 @@ export async function setAdminProductPrice(
const changeReason = String(payload.changeReason || '在商品与成本中心统一调整价格').trim()
// 2. 如果是接单模板商品 (tpl:)
if (product.canonical_sku.startsWith('tpl:')) {
if (product.source_kind === 'work_order' && product.canonical_sku.startsWith('tpl:')) {
const ruleKey = product.canonical_sku.slice(4)
const ruleResult = await query<{
id: number
@@ -1729,12 +1736,13 @@ export async function setAdminProductPrice(
}
}
// 3. 其他标准商品 (wo: / tpv: / man:)
const costItemCode = product.canonical_sku.startsWith('wo:')
? 'sys_work_order_reward'
: product.canonical_sku.startsWith('tpv:')
? 'sys_third_party_voucher_cost'
: 'sys_manual_service'
// 3. 其他标准商品:成本项由 source_kind 决定,canonical_sku 只保留载体键。
const costItemCode =
product.source_kind === 'work_order'
? 'sys_work_order_reward'
: product.source_kind === 'third_party_voucher'
? 'sys_third_party_voucher_cost'
: 'sys_manual_service'
const costItemResult = await query<{ id: number }>(
'SELECT id FROM cost_items WHERE code = $1 LIMIT 1',
[costItemCode],
@@ -1911,13 +1919,16 @@ export async function syncAdminProductPrice(productIdInput: unknown, session: Ad
id: number
canonical_sku: string
canonical_name: string
}>('SELECT id, canonical_sku, canonical_name FROM product_catalog WHERE id = $1', [productId])
source_kind: string
}>('SELECT id, canonical_sku, canonical_name, source_kind FROM product_catalog WHERE id = $1', [
productId,
])
const product = productResult.rows[0]
if (!product) {
throw createHttpError('商品不存在', { statusCode: 404 })
}
if (product.canonical_sku.startsWith('tpl:')) {
if (product.source_kind === 'work_order' && product.canonical_sku.startsWith('tpl:')) {
const ruleKey = product.canonical_sku.slice(4)
const ruleResult = await query<{ id: number }>(
'SELECT id FROM work_product_rules WHERE rule_key = $1 LIMIT 1',
@@ -1937,7 +1948,7 @@ export async function syncAdminProductPrice(productIdInput: unknown, session: Ad
}
}
if (product.canonical_sku.startsWith('aff:')) {
if (product.source_kind === 'affiliate_dash') {
// 成本快照由成本快照管道按缺补算,这里不再同步触发。
return {
success: true,
@@ -68,7 +68,7 @@ function item(patch: Record<string, unknown> = {}) {
canonical_product_id: null as number | null,
work_rule_key: null as string | null,
manual_config_product_id: null as number | null,
manual_config_product_sku: null as string | null,
manual_config_product_source_kind: null as string | null,
...patch,
}
}
@@ -126,7 +126,7 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => {
fulfillment_channel: 'manual',
sku_code: 'unknown',
manual_config_product_id: 44,
manual_config_product_sku: 'man:配置商品',
manual_config_product_source_kind: 'manual',
}),
index,
),
@@ -177,13 +177,13 @@ test('商品归一:其他已确定渠道也不能借用同名其他来源商
}
})
test('商品归一:人工配置也只能挂载 man: 商品', () => {
test('商品归一:人工配置只能挂载来源为人工的标准商品', () => {
assert.equal(
resolveProductId(
item({
fulfillment_channel: 'manual',
manual_config_product_id: 99,
manual_config_product_sku: 'aff:错误商品',
manual_config_product_source_kind: 'affiliate_dash',
}),
index,
),
@@ -194,7 +194,7 @@ test('商品归一:人工配置也只能挂载 man: 商品', () => {
item({
fulfillment_channel: 'on_account',
manual_config_product_id: 99,
manual_config_product_sku: 'man:正确商品',
manual_config_product_source_kind: 'manual',
}),
index,
),
@@ -240,10 +240,11 @@ test('三方卡券自动建商品时优先使用内部中文 SKU', () => {
test('商品归一扫描会纠正所有渠道的错误来源商品', async () => {
const source = await readFile(new URL('./product-normalize-service.ts', import.meta.url), 'utf8')
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'aff:%'"))
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'man:%'"))
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpl:%'"))
assert.ok(source.includes("current_product.canonical_sku NOT LIKE 'tpv:%'"))
assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'affiliate_dash'"))
assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'manual'"))
assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'work_order'"))
assert.ok(source.includes("current_product.source_kind IS DISTINCT FROM 'third_party_voucher'"))
assert.ok(!source.includes("current_product.canonical_sku NOT LIKE 'man:%'"))
assert.ok(source.includes('canonicalProductId: null'))
assert.ok(source.includes('onlyWhenCanonicalProductDifferent: true'))
})
@@ -57,7 +57,15 @@ type ItemRow = {
canonical_product_id?: number | null
work_rule_key: string | null
manual_config_product_id: number | null
manual_config_product_sku?: string | null
manual_config_product_source_kind?: string | null
}
const EXPECTED_PRODUCT_SOURCE: Record<string, string> = {
affiliate_dash: 'affiliate_dash',
work_order: 'work_order',
third_party_voucher: 'third_party_voucher',
manual: 'manual',
on_account: 'manual',
}
function key(...parts: string[]): string {
@@ -179,10 +187,13 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise<number | null
const created = await query<{ id: number }>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
)
VALUES ($1, $2, '卡券', '张', 'active', '{"source":"third_party_voucher","auto_created":true}'::jsonb, NOW(), NOW())
VALUES (
$1, $2, '卡券', '张', 'third_party_voucher', 'active',
'{"source":"third_party_voucher","auto_created":true}'::jsonb, NOW(), NOW()
)
ON CONFLICT (canonical_sku) DO NOTHING
RETURNING id
`,
@@ -235,7 +246,7 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n
// 已确定渠道必须命中本渠道商品,不能借用同名的其他来源商品。
if (item.fulfillment_channel === 'manual' || item.fulfillment_channel === 'on_account') {
return item.manual_config_product_id &&
String(item.manual_config_product_sku || '').startsWith('man:')
item.manual_config_product_source_kind === EXPECTED_PRODUCT_SOURCE.manual
? Number(item.manual_config_product_id)
: null
}
@@ -338,7 +349,7 @@ export async function normalizeOrderItemProducts(
oi.canonical_product_id,
wpr.rule_key AS work_rule_key,
cfg.canonical_product_id AS manual_config_product_id,
cfg_product.canonical_sku AS manual_config_product_sku
cfg_product.source_kind AS manual_config_product_source_kind
FROM v_order_item_current oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
@@ -349,36 +360,22 @@ export async function normalizeOrderItemProducts(
WHERE oi.fulfillment_status = 'assigned'
AND (
oi.canonical_product_id IS NULL
OR current_product.id IS NULL
OR (
oi.fulfillment_channel = 'third_party_voucher'
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'tpv:%'
)
AND current_product.source_kind IS DISTINCT FROM 'third_party_voucher'
)
OR (
oi.fulfillment_channel = 'affiliate_dash'
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'aff:%'
)
AND current_product.source_kind IS DISTINCT FROM 'affiliate_dash'
)
OR (
oi.fulfillment_channel IN ('manual', 'on_account')
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'man:%'
)
AND current_product.source_kind IS DISTINCT FROM 'manual'
)
OR (
oi.fulfillment_channel = 'work_order'
AND (
current_product.id IS NULL
OR (
current_product.canonical_sku NOT LIKE 'tpl:%'
AND current_product.canonical_sku NOT LIKE 'wo:%'
)
)
AND current_product.source_kind IS DISTINCT FROM 'work_order'
)
)
AND oi.id > $1
@@ -450,7 +447,7 @@ async function loadMappingIndex(): Promise<MappingIndex> {
`
SELECT id, canonical_sku
FROM product_catalog
WHERE status = 'active' AND canonical_sku LIKE 'aff:%'
WHERE status = 'active' AND source_kind = 'affiliate_dash'
`,
)
@@ -458,7 +455,9 @@ async function loadMappingIndex(): Promise<MappingIndex> {
`
SELECT id, canonical_sku
FROM product_catalog
WHERE status = 'active' AND canonical_sku LIKE 'tpl:%'
WHERE status = 'active'
AND source_kind = 'work_order'
AND canonical_sku LIKE 'tpl:%'
`,
)
@@ -466,7 +465,7 @@ async function loadMappingIndex(): Promise<MappingIndex> {
`
SELECT id, canonical_sku
FROM product_catalog
WHERE status = 'active' AND canonical_sku LIKE 'tpv:%'
WHERE status = 'active' AND source_kind = 'third_party_voucher'
`,
)
@@ -528,7 +527,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
oi.canonical_product_id,
wpr.rule_key AS work_rule_key,
cfg.canonical_product_id AS manual_config_product_id,
cfg_product.canonical_sku AS manual_config_product_sku
cfg_product.source_kind AS manual_config_product_source_kind
FROM v_order_item_current oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
@@ -539,36 +538,22 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
WHERE oi.fulfillment_status = 'assigned'
AND (
oi.canonical_product_id IS NULL
OR current_product.id IS NULL
OR (
oi.fulfillment_channel = 'third_party_voucher'
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'tpv:%'
)
AND current_product.source_kind IS DISTINCT FROM 'third_party_voucher'
)
OR (
oi.fulfillment_channel = 'affiliate_dash'
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'aff:%'
)
AND current_product.source_kind IS DISTINCT FROM 'affiliate_dash'
)
OR (
oi.fulfillment_channel IN ('manual', 'on_account')
AND (
current_product.id IS NULL
OR current_product.canonical_sku NOT LIKE 'man:%'
)
AND current_product.source_kind IS DISTINCT FROM 'manual'
)
OR (
oi.fulfillment_channel = 'work_order'
AND (
current_product.id IS NULL
OR (
current_product.canonical_sku NOT LIKE 'tpl:%'
AND current_product.canonical_sku NOT LIKE 'wo:%'
)
)
AND current_product.source_kind IS DISTINCT FROM 'work_order'
)
)
AND oi.id = ANY($1::bigint[])
@@ -168,9 +168,9 @@ export async function applyAdminTemplateProductBootstrap(
const inserted = await client.query<{ id: number }>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
) VALUES ($1, $2, $3, $4, 'work_order', $5, $6::jsonb, $7, $7)
ON CONFLICT (canonical_sku) DO NOTHING
RETURNING id
`,
@@ -60,14 +60,15 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad
const productResult = await client.query<ProductRow>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
) VALUES (
$1, $2, '卡券', '张', 'active',
$1, $2, '卡券', '张', 'third_party_voucher', 'active',
$3::jsonb, $4, $4
)
ON CONFLICT (canonical_sku) DO UPDATE SET
status = 'active',
source_kind = EXCLUDED.source_kind,
canonical_name = EXCLUDED.canonical_name,
attributes_json = product_catalog.attributes_json || EXCLUDED.attributes_json,
updated_at = EXCLUDED.updated_at
@@ -141,7 +141,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
)
)
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
)
SELECT
@@ -153,6 +153,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
WHEN m.sample_name LIKE '%个%' THEN '个'
ELSE '件'
END,
'work_order',
'active',
jsonb_build_object('source', 'work_order_sku_name', 'normalizedName', m.name),
$1,
@@ -109,13 +109,14 @@ export async function syncWorkOrderTemplateProduct(
client,
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
canonical_sku, canonical_name, category, unit_name, source_kind, status,
attributes_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6)
) VALUES ($1, $2, $3, $4, 'work_order', 'active', $5::jsonb, $6, $6)
ON CONFLICT (canonical_sku) DO UPDATE SET
canonical_name = EXCLUDED.canonical_name,
category = EXCLUDED.category,
unit_name = EXCLUDED.unit_name,
source_kind = EXCLUDED.source_kind,
attributes_json = COALESCE(product_catalog.attributes_json, '{}'::jsonb) || EXCLUDED.attributes_json,
updated_at = EXCLUDED.updated_at
RETURNING id, status
@@ -156,7 +156,12 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
const listBody = configSource.slice(listStart, listEnd)
assert.ok(
listBody.includes('FROM product_catalog p') && listBody.includes('productSourceFilterSql'),
'人工成本配置必须以 man: 标准商品为唯一清单来源',
'人工成本配置必须以 source_kind=manual 的标准商品为唯一清单来源',
)
assert.ok(
listBody.includes('JOIN internal_skus product_sku') &&
!listBody.includes('substring(p.canonical_sku'),
'人工成本配置必须通过 internal_sku_id 读取内部 SKU,不能解析 man: 前缀',
)
assert.ok(
reconcileMigration.includes('tmp_manual_product_merge') &&
@@ -2112,11 +2112,21 @@ async function ensureDraftProductForChannel(
const key = canonicalizeInternalSku(internalSku)
if (!key) return null
const definition = {
affiliate_dash: { prefix: 'aff:', category: '自动发货', unit: '件' },
work_order: { prefix: 'tpl:', category: '接单', unit: '件' },
manual: { prefix: 'man:', category: '人工', unit: '件' },
on_account: { prefix: 'man:', category: '统一结算', unit: '件' },
third_party_voucher: { prefix: 'tpv:', category: '卡券', unit: '张' },
affiliate_dash: {
prefix: 'aff:',
category: '自动发货',
unit: '件',
sourceKind: 'affiliate_dash',
},
work_order: { prefix: 'tpl:', category: '接单', unit: '件', sourceKind: 'work_order' },
manual: { prefix: 'man:', category: '人工', unit: '件', sourceKind: 'manual' },
on_account: { prefix: 'man:', category: '统一结算', unit: '件', sourceKind: 'manual' },
third_party_voucher: {
prefix: 'tpv:',
category: '卡券',
unit: '张',
sourceKind: 'third_party_voucher',
},
}[channel]
const canonicalSku = `${definition.prefix}${key}`
if (canonicalSku.length > 64) return null
@@ -2129,16 +2139,18 @@ async function ensureDraftProductForChannel(
canonical_name,
category,
unit_name,
source_kind,
status,
attributes_json,
created_at,
updated_at
) VALUES (
$1, $2, $3, $4, 'active',
$5::jsonb, $6, $6
$1, $2, $3, $4, $5, 'active',
$6::jsonb, $7, $7
)
ON CONFLICT (canonical_sku) DO UPDATE
SET status = 'active',
source_kind = EXCLUDED.source_kind,
canonical_name = CASE
WHEN BTRIM(product_catalog.canonical_name) = '' THEN EXCLUDED.canonical_name
ELSE product_catalog.canonical_name
@@ -2157,6 +2169,7 @@ async function ensureDraftProductForChannel(
key.slice(0, 120),
definition.category,
definition.unit,
definition.sourceKind,
JSON.stringify({
source: channel,
auto_created: true,