411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { readFile } from 'node:fs/promises'
|
|
import test from 'node:test'
|
|
|
|
import { costModelAppliesTo, mapProduct } from './cost-center-mappers.js'
|
|
import {
|
|
normalizeMappingMatchType,
|
|
normalizeProviderKey,
|
|
resolveMappingNameKey,
|
|
} from './cost-center-shared.js'
|
|
import { calculateCostTotal, validateCostVersionScope } from './cost-resolution-service.js'
|
|
import { createAdminProduct, resolveMappingProductId } from './product-catalog-service.js'
|
|
import { productChannelFilterSql, productSourceFilterSql } from './cost-center-mappers.js'
|
|
import {
|
|
buildTemplateMappingPlan,
|
|
buildTemplateProductSku,
|
|
inferTemplateUnitName,
|
|
} from './template-bootstrap-service.js'
|
|
|
|
test('calculateCostTotal supports per_item, per_order and percent_of_revenue', () => {
|
|
assert.equal(
|
|
calculateCostTotal({
|
|
pricingMode: 'per_item',
|
|
amountFen: 250,
|
|
rateBps: 0,
|
|
quantity: 5,
|
|
revenueFen: 10000,
|
|
}),
|
|
1250,
|
|
)
|
|
assert.equal(
|
|
calculateCostTotal({
|
|
pricingMode: 'per_order',
|
|
amountFen: 500,
|
|
rateBps: 0,
|
|
quantity: 5,
|
|
revenueFen: 10000,
|
|
}),
|
|
500,
|
|
)
|
|
assert.equal(
|
|
calculateCostTotal({
|
|
pricingMode: 'percent_of_revenue',
|
|
amountFen: 0,
|
|
rateBps: 500,
|
|
quantity: 1,
|
|
revenueFen: 10000,
|
|
}),
|
|
500,
|
|
)
|
|
})
|
|
|
|
test('calculateCostTotal clamps invalid rate and negative amounts', () => {
|
|
assert.equal(
|
|
calculateCostTotal({
|
|
pricingMode: 'percent_of_revenue',
|
|
amountFen: -1,
|
|
rateBps: 20000,
|
|
quantity: 1,
|
|
revenueFen: 10000,
|
|
}),
|
|
10000,
|
|
)
|
|
assert.equal(
|
|
calculateCostTotal({
|
|
pricingMode: 'per_item',
|
|
amountFen: -100,
|
|
rateBps: 0,
|
|
quantity: 3,
|
|
revenueFen: 0,
|
|
}),
|
|
0,
|
|
)
|
|
})
|
|
|
|
test('validateCostVersionScope requires the fields that identify the scope', () => {
|
|
assert.doesNotThrow(() =>
|
|
validateCostVersionScope('global', {
|
|
provider: '',
|
|
shopId: '',
|
|
canonicalProductId: null,
|
|
workProductRuleId: null,
|
|
}),
|
|
)
|
|
assert.throws(() =>
|
|
validateCostVersionScope('shop', {
|
|
provider: 'kuaishou-91',
|
|
shopId: '',
|
|
canonicalProductId: null,
|
|
workProductRuleId: null,
|
|
}),
|
|
)
|
|
assert.throws(() =>
|
|
validateCostVersionScope('product', {
|
|
provider: '',
|
|
shopId: '',
|
|
canonicalProductId: null,
|
|
workProductRuleId: null,
|
|
}),
|
|
)
|
|
assert.throws(() =>
|
|
validateCostVersionScope('rule', {
|
|
provider: '',
|
|
shopId: '',
|
|
canonicalProductId: null,
|
|
workProductRuleId: null,
|
|
}),
|
|
)
|
|
})
|
|
|
|
test('normalizeProviderKey keeps mappings aligned with orders.provider', () => {
|
|
assert.equal(normalizeProviderKey('91kaquan'), '91kaquan')
|
|
assert.equal(normalizeProviderKey('91'), '91kaquan')
|
|
assert.equal(normalizeProviderKey('open_91'), '91kaquan')
|
|
assert.equal(normalizeProviderKey('kuaishou-91'), '91kaquan')
|
|
assert.equal(normalizeProviderKey('douyin-mashida'), 'douyin_mashida')
|
|
assert.equal(normalizeProviderKey('pdd'), 'pdd')
|
|
})
|
|
|
|
test('template bootstrap keeps rule keys stable and infers reasonable units', () => {
|
|
assert.equal(buildTemplateProductSku('bb'), 'tpl:bb')
|
|
assert.equal(buildTemplateProductSku('rule-123'), 'tpl:rule-123')
|
|
assert.equal(inferTemplateUnitName('指挥官密钥5个'), '个')
|
|
assert.equal(inferTemplateUnitName('苏尔南钻石100颗'), '颗')
|
|
assert.equal(inferTemplateUnitName('10把指定红枪'), '把')
|
|
assert.equal(inferTemplateUnitName('10套橙色七级套'), '套')
|
|
assert.equal(inferTemplateUnitName('碎梦浮光珍宝礼盒'), '件')
|
|
})
|
|
|
|
test('template mapping plan uses exact SKU code when it is unambiguous', () => {
|
|
const plan = buildTemplateMappingPlan([
|
|
{
|
|
provider: 'pdd',
|
|
shop_id: 'feimeng',
|
|
sku_code: 'A-1',
|
|
sku_name: '指挥官密钥1个',
|
|
product_rule_id: 9,
|
|
rule_key: 'bb',
|
|
item_count: 8,
|
|
},
|
|
{
|
|
provider: 'pdd',
|
|
shop_id: 'feimeng',
|
|
sku_code: 'A-2',
|
|
sku_name: '指挥官密钥5个',
|
|
product_rule_id: 9,
|
|
rule_key: 'bb',
|
|
item_count: 3,
|
|
},
|
|
])
|
|
assert.equal(plan.conflicts.length, 0)
|
|
assert.equal(plan.codeConflictKeyCount, 0)
|
|
assert.equal(plan.candidates.filter((row) => row.matchType === 'sku_code').length, 2)
|
|
assert.equal(plan.evidenceItemCount, 11)
|
|
assert.equal(plan.unresolvedItemCount, 0)
|
|
})
|
|
|
|
test('template mapping plan disambiguates reused 91 SKU codes by name and series', () => {
|
|
const plan = buildTemplateMappingPlan([
|
|
{
|
|
provider: '91kaquan',
|
|
shop_id: '141242642',
|
|
sku_code: '25873410102642',
|
|
sku_name: '指挥官密钥1个',
|
|
product_rule_id: 9,
|
|
rule_key: 'bb',
|
|
item_count: 10,
|
|
},
|
|
{
|
|
provider: '91kaquan',
|
|
shop_id: '141242642',
|
|
sku_code: '25873410102642',
|
|
sku_name: '指挥官密钥5个',
|
|
product_rule_id: 9,
|
|
rule_key: 'bb',
|
|
item_count: 5,
|
|
},
|
|
{
|
|
provider: '91kaquan',
|
|
shop_id: '141242642',
|
|
sku_code: '25873410102642',
|
|
sku_name: '1个精致荣耀专属礼盒',
|
|
product_rule_id: 16,
|
|
rule_key: 'dd',
|
|
item_count: 8,
|
|
},
|
|
])
|
|
assert.equal(plan.codeConflictKeyCount, 1)
|
|
assert.equal(plan.conflicts.length, 0)
|
|
assert.equal(plan.unresolvedItemCount, 0)
|
|
assert.ok(
|
|
plan.candidates.some(
|
|
(row) =>
|
|
row.matchType === 'sku_code_series' &&
|
|
row.skuNameKey === '指挥官密钥' &&
|
|
row.ruleKey === 'bb',
|
|
),
|
|
)
|
|
assert.ok(
|
|
plan.candidates.some(
|
|
(row) =>
|
|
row.matchType === 'sku_code_series' &&
|
|
row.skuNameKey === '精致荣耀专属礼盒' &&
|
|
row.ruleKey === 'dd',
|
|
),
|
|
)
|
|
assert.ok(
|
|
plan.candidates.some(
|
|
(row) =>
|
|
row.matchType === 'sku_code_name' &&
|
|
row.skuNameKey === '指挥官密钥5个' &&
|
|
row.ruleKey === 'bb',
|
|
),
|
|
)
|
|
})
|
|
|
|
test('template mapping plan reports same code and name mapping to multiple rules', () => {
|
|
const plan = buildTemplateMappingPlan([
|
|
{
|
|
provider: '91kaquan',
|
|
shop_id: '3676797936',
|
|
sku_code: '185470033055936',
|
|
sku_name: '指挥官密钥1个',
|
|
product_rule_id: 9,
|
|
rule_key: 'bb',
|
|
item_count: 20,
|
|
},
|
|
{
|
|
provider: '91kaquan',
|
|
shop_id: '3676797936',
|
|
sku_code: '185470033055936',
|
|
sku_name: '指挥官密钥1个',
|
|
product_rule_id: 20,
|
|
rule_key: 'ab',
|
|
item_count: 5,
|
|
},
|
|
])
|
|
assert.equal(plan.codeConflictKeyCount, 1)
|
|
assert.equal(plan.unresolvedItemCount, 25)
|
|
assert.equal(plan.conflicts.length, 1)
|
|
assert.deepEqual(plan.conflicts[0]?.ruleKeys, ['ab', 'bb'])
|
|
assert.equal(
|
|
plan.candidates.some((row) => row.matchType === 'sku_code_name'),
|
|
false,
|
|
)
|
|
})
|
|
|
|
test('mapping match keys normalize name and quantity series', () => {
|
|
assert.equal(normalizeMappingMatchType('sku_code_series'), 'sku_code_series')
|
|
assert.equal(resolveMappingNameKey('sku_code_name', ' 指挥官密钥5个 '), '指挥官密钥5个')
|
|
assert.equal(resolveMappingNameKey('sku_code_series', '指挥官秘钥5个'), '指挥官密钥')
|
|
assert.equal(resolveMappingNameKey('sku_name_series', '指挥官密钥10个'), '指挥官密钥')
|
|
})
|
|
|
|
test('mapping lookup follows specificity and concrete-shop priority', () => {
|
|
const key = (...parts: string[]) => parts.join('\u0000')
|
|
const byKey = new Map<string, number>([
|
|
[key('91kaquan', 'shop-a', 'C-1', 'sku_code', ''), 10],
|
|
[key('91kaquan', '', 'C-1', 'sku_code', ''), 20],
|
|
[key('91kaquan', '', 'C-1', 'sku_code_name', '指挥官密钥1个'), 30],
|
|
[key('91kaquan', '', 'C-1', 'sku_code_series', '指挥官密钥'), 40],
|
|
[key('91kaquan', '', '', 'sku_name_series', '指挥官密钥'), 50],
|
|
])
|
|
const item = {
|
|
id: 1,
|
|
provider: '91kaquan',
|
|
shop_id: 'shop-a',
|
|
sku_code: 'C-1',
|
|
normalized_sku_name: '指挥官密钥1个',
|
|
normalized_sku_series: '指挥官密钥',
|
|
}
|
|
assert.equal(resolveMappingProductId({ byKey }, item), 10)
|
|
assert.equal(
|
|
resolveMappingProductId({ byKey }, { ...item, shop_id: 'shop-b', sku_code: 'C-2' }),
|
|
50,
|
|
)
|
|
})
|
|
|
|
test('接单模板改价必须在同一事务中同步成本版本与模板投影', async () => {
|
|
const { readFile } = await import('node:fs/promises')
|
|
const source = await readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8')
|
|
assert.ok(source.includes('const costVersionId = await withTransaction'))
|
|
assert.ok(source.includes('reward_amount = $1::bigint'))
|
|
assert.ok(source.includes('unit_price_fen = $2::bigint'))
|
|
assert.ok(source.includes('cost_work_order_future_price_not_supported'))
|
|
})
|
|
|
|
test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
|
|
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'third_party_voucher'), true)
|
|
assert.equal(costModelAppliesTo('third_party_voucher_cost', 'manual'), false)
|
|
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'"))
|
|
assert.ok(!channelSql.includes('canonical_sku'))
|
|
})
|
|
|
|
test('第三方卡券删除:无历史引用硬删除,有历史引用自动归档并停用规则', async () => {
|
|
const { readFile } = await import('node:fs/promises')
|
|
const [serviceSource, routeSource] = await Promise.all([
|
|
readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8'),
|
|
readFile(new URL('../../routes/admin/cost-center-catalog.ts', import.meta.url), 'utf8'),
|
|
])
|
|
const start = serviceSource.indexOf('export async function deleteAdminVoucherProduct')
|
|
const end = serviceSource.indexOf('/**\n * 批量归档旧人工归一商品', start)
|
|
const body = serviceSource.slice(start, end)
|
|
|
|
assert.ok(body.includes('withTransaction'), '卡券删除必须在事务内完成')
|
|
assert.ok(body.includes("action: 'archived'"), '有历史引用时必须自动归档')
|
|
assert.ok(body.includes("status = 'archived'"), '归档必须保留商品和历史成本')
|
|
assert.ok(body.includes('UPDATE channel_product_mappings'), '归档后必须停用旧映射,避免继续命中')
|
|
assert.ok(body.includes('DELETE FROM cost_versions'), '无历史引用时应清理采购价版本')
|
|
assert.ok(body.includes("channel = 'third_party_voucher'"), '必须停用对应卡券渠道规则')
|
|
assert.ok(routeSource.includes("'/cost-center/voucher-products/:productId'"))
|
|
})
|
|
|
|
test('人工客服页签只按当前主渠道筛选,不能再按 man 前缀放回人工页', async () => {
|
|
const source = await readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8')
|
|
const sql = productChannelFilterSql(1)
|
|
assert.ok(sql.includes('active_primary_channel'))
|
|
assert.ok(sql.includes("$1 <> 'unrouted'"))
|
|
assert.ok(sql.includes('active_primary_channel = $1'))
|
|
assert.ok(!sql.includes('canonical_sku'))
|
|
assert.ok(source.includes('FROM v_product_current p'))
|
|
assert.ok(source.includes('productChannelFilterSql'))
|
|
})
|
|
|
|
test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => {
|
|
const { readFile } = await import('node:fs/promises')
|
|
const source = await readFile(
|
|
new URL('./cost-center-overview-service.ts', import.meta.url),
|
|
'utf8',
|
|
)
|
|
assert.ok(source.includes('JOIN v_order_item_current oi ON oi.id = ce.order_item_id'))
|
|
assert.ok(!source.includes('ce.order_item_id IN ('))
|
|
assert.ok(source.includes('productChannelFilterSql(1)'))
|
|
})
|
|
|
|
test('运行期停止写入已废弃的 product_channel_capabilities', async () => {
|
|
const files = await Promise.all(
|
|
[
|
|
'./voucher-product-service.ts',
|
|
'./product-normalize-service.ts',
|
|
'./product-catalog-service.ts',
|
|
'../fulfillment/channel-assign-service.ts',
|
|
].map((path) => readFile(new URL(path, import.meta.url), 'utf8')),
|
|
)
|
|
for (const source of files) {
|
|
assert.ok(!source.includes('product_channel_capabilities'))
|
|
}
|
|
})
|
|
|
|
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 () => {
|
|
await assert.rejects(
|
|
createAdminProduct(
|
|
{
|
|
canonicalSku: 'man:破片手榴弹-兔兔白日梦',
|
|
canonicalName: '破片手榴弹-兔兔白日梦',
|
|
},
|
|
{} as never,
|
|
),
|
|
/人工 SKU 配置自动创建/,
|
|
)
|
|
})
|
|
|
|
test('第三方卡券商品禁止绕过卡券 SKU 配置手工创建', async () => {
|
|
await assert.rejects(
|
|
createAdminProduct(
|
|
{
|
|
canonicalSku: 'tpv:11月改名卡',
|
|
canonicalName: '11月改名卡',
|
|
},
|
|
{} as never,
|
|
),
|
|
/卡券 SKU 配置自动创建/,
|
|
)
|
|
})
|