feat(finance): 内部 SKU 平台字段映射基础

- 新增 internal_sku_mappings,支持 provider/shop 级和全局映射
- 新增解析与 upsert 服务,priority/精确范围优先
- 新增纯数字内部 SKU 的中文映射回填脚本(默认 dry-run)
This commit is contained in:
yml2213
2026-11-02 13:49:31 +08:00
parent 652d501c2c
commit a501f9f2e0
3 changed files with 229 additions and 0 deletions
@@ -0,0 +1,84 @@
/**
* 从 PDD 纯数字内部 SKU 的订单项回填中文映射(只读扫描 + 幂等写入)。
*
* 只处理 sku_name 含中文、sku_code 为纯数字的情况;
* 找不到中文名的纯数字码会列出来,等运营补映射。
*
* 用法:npx tsx scripts/backfill-internal-sku-mappings.ts [--apply]
*/
import { closeDb, query } from '../src/db/client.js'
import { upsertInternalSkuMapping } from '../src/services/fulfillment/internal-sku-mapping-service.js'
async function main() {
const apply = process.argv.includes('--apply')
const rows = await query<{
provider: string
shop_id: string
sku_code: string
sku_name: string
total: number
}>(
`
SELECT o.provider, o.shop_id, oi.sku_code, MIN(oi.sku_name) AS sku_name, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE btrim(COALESCE(oi.manual_sku_key, '')) ~ '^[0-9]+$'
AND btrim(COALESCE(oi.sku_name, '')) ~ '[一-龥]'
GROUP BY 1, 2, 3
ORDER BY 5 DESC
`,
)
const unresolved = await query<{
provider: string
shop_id: string
sku_code: string
total: number
}>(
`
SELECT o.provider, o.shop_id, oi.sku_code, count(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE btrim(COALESCE(oi.manual_sku_key, '')) ~ '^[0-9]+$'
AND btrim(COALESCE(oi.sku_name, '')) !~ '[一-龥]'
GROUP BY 1, 2, 3
ORDER BY 4 DESC
`,
)
let written = 0
for (const row of rows.rows) {
if (apply) {
await upsertInternalSkuMapping({
provider: row.provider,
shopId: row.shop_id,
matchType: 'sku_code',
matchValue: row.sku_code,
internalSku: row.sku_name,
priority: 100,
source: 'backfill',
note: `从 ${row.total} 条订单项回填`,
})
}
written += 1
}
console.log(
JSON.stringify(
{
apply,
canBackfill: rows.rows.length,
backfilled: written,
unresolved: unresolved.rows,
},
null,
2,
),
)
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(() => closeDb())
@@ -0,0 +1,30 @@
-- 117_internal_sku_mappings.sql
--
-- 平台字段 → 内部中文 SKU 的补全映射。
-- manual_sku_key 生成列优先取 skuNick/outSkuSn/sku_code/sku_name;
-- 当平台只给纯数字码、没有中文 skuNick/outSkuSn 时,用本表补成内部中文 SKU。
CREATE TABLE IF NOT EXISTS internal_sku_mappings (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL DEFAULT '',
shop_id TEXT NOT NULL DEFAULT '',
match_type TEXT NOT NULL CHECK (match_type IN ('sku_code', 'sku_nick', 'out_sku_sn', 'spec', 'sku_name')),
match_value TEXT NOT NULL,
internal_sku TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
source TEXT NOT NULL DEFAULT 'manual',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_internal_sku_mappings_key
ON internal_sku_mappings (provider, shop_id, match_type, match_value);
CREATE INDEX IF NOT EXISTS idx_internal_sku_mappings_internal
ON internal_sku_mappings (internal_sku)
WHERE enabled = TRUE;
COMMENT ON TABLE internal_sku_mappings IS
'平台字段 → 内部中文 SKU 映射;provider/shop_id 为空表示全局,priority 越小越优先';
@@ -0,0 +1,115 @@
import { query } from '../../db/client.js'
export type InternalSkuMappingMatchType =
| 'sku_code'
| 'sku_nick'
| 'out_sku_sn'
| 'spec'
| 'sku_name'
export type InternalSkuMappingCandidate = {
matchType: InternalSkuMappingMatchType
value: unknown
}
export type ResolvedInternalSkuMapping = {
id: number
internalSku: string
source: string
}
export function normalizeInternalSkuMappingValue(value: unknown): string {
return String(value ?? '')
.replace(/\s+/g, '')
.trim()
}
/**
* 按平台字段候选值查内部中文 SKU 映射。
*
* 优先级:精确 provider/shop > 全局;同范围按 priority 升序、id 升序。
* 返回 null 表示没有映射,调用方回退 manual_sku_key 生成列。
*/
export async function resolveInternalSkuMapping(input: {
provider?: string | null
shopId?: string | null
candidates: InternalSkuMappingCandidate[]
}): Promise<ResolvedInternalSkuMapping | null> {
const provider = String(input.provider || '').trim()
const shopId = String(input.shopId || '').trim()
const pairs = input.candidates
.map((candidate) => ({
matchType: candidate.matchType,
matchValue: normalizeInternalSkuMappingValue(candidate.value),
}))
.filter((candidate) => candidate.matchValue !== '')
if (pairs.length === 0) return null
const result = await query<{ id: number; internal_sku: string; source: string }>(
`
SELECT m.id, m.internal_sku, m.source
FROM internal_sku_mappings m
JOIN unnest($3::text[], $4::text[]) AS c(match_type, match_value)
ON m.match_type = c.match_type AND m.match_value = c.match_value
WHERE m.enabled = TRUE
AND (m.provider = '' OR m.provider = $1)
AND (m.shop_id = '' OR m.shop_id = $2)
ORDER BY
CASE WHEN m.provider = $1 AND m.shop_id = $2 THEN 0
WHEN m.provider = $1 THEN 1
WHEN m.shop_id = $2 THEN 2
ELSE 3 END,
m.priority ASC,
m.id ASC
LIMIT 1
`,
[provider, shopId, pairs.map((pair) => pair.matchType), pairs.map((pair) => pair.matchValue)],
)
const row = result.rows[0]
if (!row) return null
return {
id: Number(row.id),
internalSku: String(row.internal_sku || '').trim(),
source: String(row.source || 'manual'),
}
}
export async function upsertInternalSkuMapping(input: {
provider?: string | null
shopId?: string | null
matchType: InternalSkuMappingMatchType
matchValue: string
internalSku: string
priority?: number
source?: string
note?: string
}): Promise<number> {
const result = await query<{ id: number }>(
`
INSERT INTO internal_sku_mappings (
provider, shop_id, match_type, match_value, internal_sku,
priority, enabled, source, note, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, TRUE, $7, $8, NOW(), NOW())
ON CONFLICT (provider, shop_id, match_type, match_value)
DO UPDATE SET
internal_sku = EXCLUDED.internal_sku,
priority = EXCLUDED.priority,
enabled = TRUE,
source = EXCLUDED.source,
note = EXCLUDED.note,
updated_at = NOW()
RETURNING id
`,
[
String(input.provider || '').trim(),
String(input.shopId || '').trim(),
input.matchType,
normalizeInternalSkuMappingValue(input.matchValue),
String(input.internalSku || '').trim(),
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
String(input.source || 'manual').trim() || 'manual',
String(input.note || '').trim(),
],
)
return Number(result.rows[0]?.id || 0)
}