feat(finance): 内部 SKU 平台字段映射基础
- 新增 internal_sku_mappings,支持 provider/shop 级和全局映射 - 新增解析与 upsert 服务,priority/精确范围优先 - 新增纯数字内部 SKU 的中文映射回填脚本(默认 dry-run)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user