diff --git a/apps/backend/scripts/backfill-internal-sku-mappings.ts b/apps/backend/scripts/backfill-internal-sku-mappings.ts new file mode 100644 index 00000000..044a6ed2 --- /dev/null +++ b/apps/backend/scripts/backfill-internal-sku-mappings.ts @@ -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()) diff --git a/apps/backend/src/db/migrations/117_internal_sku_mappings.sql b/apps/backend/src/db/migrations/117_internal_sku_mappings.sql new file mode 100644 index 00000000..7f241f0c --- /dev/null +++ b/apps/backend/src/db/migrations/117_internal_sku_mappings.sql @@ -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 越小越优先'; diff --git a/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts b/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts new file mode 100644 index 00000000..abc9a07e --- /dev/null +++ b/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts @@ -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 { + 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 { + 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) +}