Files
order_site/apps/backend/src/services/cost-center/template-bootstrap-service.ts
T

836 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 接单模板引导:预览与执行、映射计划构建、模板商品编码。
*
* 关联:
* - affiliate-product-service.ts 自动发货商品同步与 SKU 回填
* - work-order-product-service.ts 接单平台商品回填与模板发单金额同步
*/
import { query, withTransaction } from '../../db/client.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
import { candidateKey, mappingKey, mappingMatchTypeLabel } from './cost-center-shared.js'
import type { TemplateMappingMatchType } from './cost-center-shared.js'
import {
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
} from './product-catalog-service.js'
import { syncAdminWorkOrderTemplateCostVersions } from './work-order-product-service.js'
export async function previewAdminTemplateProductBootstrap(payload: JsonObject = {}) {
const range = resolveBootstrapDateRange(payload)
const data = await loadTemplateProductBootstrapData(range)
const plan = buildTemplateMappingPlan(data.evidenceRows)
const enabledRuleKeys = new Set(data.enabledTemplates.map((row) => row.rule_key))
const candidates = plan.candidates.filter(
(row) =>
enabledRuleKeys.has(row.ruleKey) &&
!data.existingMappingKeys.has(
mappingKey(row.provider, row.shopId, row.skuCode, row.matchType, row.skuNameKey),
),
)
const candidatesByMatchType = countMappingCandidates(candidates)
const templatesWithoutWorkOrders =
data.enabledTemplates.length - data.templatesWithEvidence.length
return {
dateFrom: range.fromDate,
dateTo: range.toDate,
totalTemplateCount: data.enabledTemplates.length + data.disabledTemplates.length,
templateCount: data.enabledTemplates.length,
disabledTemplateCount: data.disabledTemplates.length,
evidenceTemplateCount: data.templatesWithEvidence.length,
templatesWithoutWorkOrders,
productExistingCount: data.enabledTemplates.length - data.productsToCreate.length,
productCreateCount: data.productsToCreate.length,
mappingCandidateCount: plan.candidates.length,
mappingExistingCount: plan.candidates.length - candidates.length,
mappingCreateCount: candidates.length,
mappingCreateByMatchType: candidatesByMatchType,
evidenceItemCount: plan.evidenceItemCount,
orderItemCoverCount: Math.max(0, plan.evidenceItemCount - plan.unresolvedItemCount),
mappingConflictCount: plan.conflicts.length,
mappingConflictItemCount: plan.unresolvedItemCount,
multiRuleKeyCount: plan.codeConflictKeyCount,
seriesFallbackConflictCount: plan.seriesFallbackConflictCount,
samples: candidates.slice(0, 30).map((row) => ({
ruleKey: row.ruleKey,
productName:
data.enabledTemplates.find((item) => item.rule_key === row.ruleKey)?.product_name || '',
provider: row.provider,
shopId: row.shopId,
skuCode: row.skuCode,
skuName: row.skuName,
matchType: row.matchType,
itemCount: row.itemCount,
})),
conflicts: plan.conflicts.slice(0, 20).map((row) => ({
provider: row.provider,
shopId: row.shopId,
skuCode: row.skuCode,
skuName: row.skuName,
matchType: row.matchType,
itemCount: row.itemCount,
ruleKeys: row.ruleKeys,
})),
}
}
export async function applyAdminTemplateProductBootstrap(
payload: JsonObject = {},
_session?: AdminSession,
) {
const repair = payload.repair !== false
const includeDisabledTemplates =
payload.includeDisabledTemplates === true || payload.includeDisabled === true
const range = resolveBootstrapDateRange(payload)
const resetResult = repair
? await resetImportedTemplateProductBootstrap(range)
: { deletedMappingCount: 0, resetOrderItemCount: 0 }
const data = await loadTemplateProductBootstrapData(range)
// 商品是价格/成本的载体,必须全量同步:新模板还没产生工单也要能定价。
const productTemplates = includeDisabledTemplates
? [...data.enabledTemplates, ...data.disabledTemplates]
: data.enabledTemplates
if (productTemplates.length === 0) {
throw createHttpError('没有可同步的接单模板', {
statusCode: 400,
errorCode: 'cost_template_bootstrap_empty',
})
}
const plan = buildTemplateMappingPlan(data.evidenceRows)
const now = nowIso()
let createdProductCount = 0
let updatedProductCount = 0
let reusedProductCount = 0
let createdMappingCount = 0
let skippedMappingCount = 0
let mappedOrderItemCount = 0
await withTransaction(async (client) => {
const productIdByRuleKey = new Map<string, number>()
for (const template of productTemplates) {
const canonicalSku = buildTemplateProductSku(template.rule_key)
const attributes = {
source: 'work_product_rule',
ruleId: Number(template.id),
ruleKey: template.rule_key,
mappingId: template.mapping_id ? Number(template.mapping_id) : null,
mappingType: template.mapping_type,
skuNick: template.sku_nick,
relSkuId: template.rel_sku_id,
sellerIds: template.seller_ids_json,
unitPriceFen: Number(template.unit_price_fen || 0),
rewardAmountFen: Number(template.reward_amount || 0),
templateEnabled: template.enabled,
}
const existing = data.productByRuleKey.get(template.rule_key)
if (existing) {
// 模板改名/改价后刷新接单模板自己的商品;其他来源的同名商品只复用,不改写。
const refreshed = await client.query(
`
UPDATE product_catalog
SET canonical_name = $1,
category = $2,
unit_name = $3,
attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $4::jsonb,
updated_at = $5
WHERE id = $6 AND canonical_sku LIKE 'tpl:%'
`,
[
template.product_name,
template.category,
inferTemplateUnitName(template.product_name),
JSON.stringify(attributes),
now,
existing,
],
)
if (refreshed.rowCount) {
updatedProductCount += 1
} else {
reusedProductCount += 1
}
productIdByRuleKey.set(template.rule_key, existing)
continue
}
const inserted = await client.query<{ id: number }>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
ON CONFLICT (canonical_sku) DO NOTHING
RETURNING id
`,
[
canonicalSku,
template.product_name,
template.category,
inferTemplateUnitName(template.product_name),
template.enabled ? 'active' : 'archived',
JSON.stringify(attributes),
now,
],
)
let productId = inserted.rows[0]?.id ? Number(inserted.rows[0].id) : 0
if (productId) {
createdProductCount += 1
} else {
const existingResult = await client.query<{ id: number }>(
'SELECT id FROM product_catalog WHERE canonical_sku = $1 LIMIT 1',
[canonicalSku],
)
productId = Number(existingResult.rows[0]?.id || 0)
if (productId) reusedProductCount += 1
}
if (productId) productIdByRuleKey.set(template.rule_key, productId)
}
for (const row of plan.candidates) {
const productId = productIdByRuleKey.get(row.ruleKey)
if (!productId) continue
const inserted = await client.query<{ id: number }>(
`
INSERT INTO channel_product_mappings (
provider, shop_id, platform_sku_code, platform_sku_name,
platform_sku_name_key, match_type,
canonical_product_id, quantity_multiplier, confidence, source,
enabled, note, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, 1, 'rule', 'import', TRUE, $8, $9, $9)
ON CONFLICT (
provider, shop_id, platform_sku_code, match_type, platform_sku_name_key
) DO NOTHING
RETURNING id
`,
[
row.provider,
row.shopId,
row.skuCode,
row.skuName,
row.skuNameKey,
row.matchType,
productId,
`接单模板 ${row.ruleKey} 自动初始化(${mappingMatchTypeLabel(row.matchType)})`,
now,
],
)
if (inserted.rows[0]) {
createdMappingCount += 1
} else {
skippedMappingCount += 1
}
}
})
// 名称/系列键必须先于映射回填;所有批次都放在事务外,避免语句超时。
await backfillAdminNormalizedQuantities({
limit: 50000,
all: true,
dateFrom: range.fromDate,
dateTo: range.toDate,
})
const mappingBackfill = await backfillAdminProductMappings({
limit: 20000,
all: true,
dateFrom: range.fromDate,
dateTo: range.toDate,
})
mappedOrderItemCount = mappingBackfill.updatedCount
const templateCosts = await syncAdminWorkOrderTemplateCostVersions()
return {
dateFrom: range.fromDate,
dateTo: range.toDate,
templateCount: productTemplates.length,
enabledTemplateCount: data.enabledTemplates.length,
disabledTemplateCount: includeDisabledTemplates ? data.disabledTemplates.length : 0,
evidenceTemplateCount: data.templatesWithEvidence.length,
createdProductCount,
updatedProductCount,
reusedProductCount,
createdMappingCount,
skippedMappingCount,
mappedOrderItemCount,
deletedImportedMappingCount: resetResult.deletedMappingCount,
resetOrderItemCount: resetResult.resetOrderItemCount,
mappingConflictCount: plan.conflicts.length,
mappingConflictItemCount: plan.unresolvedItemCount,
templateCostVersionCount: templateCosts.activeVersionCount,
templateCostCreatedCount: templateCosts.createdCount,
templateCostRetiredCount: templateCosts.retiredCount,
}
}
/**
* 按自动发货履约证据同步标准商品。
*
* 只有真正走 affiliate-dash 执行器的订单项才算自动发货;渠道映射只是
* 平台 SKU 归一,不能作为自动发货依据。SKU 固定为 `aff:<skuCode>`,
* 重复同步只更新名称/证据,不会重复建商品。
*/
type TemplateBootstrapTemplateRow = {
id: number
enabled: boolean
rule_key: string
product_name: string
category: string
unit_price_fen: number
reward_amount: number
mapping_id: number | null
mapping_type: string
sku_nick: string
rel_sku_id: string
seller_ids_json: unknown
}
export type TemplateBootstrapEvidenceRow = {
provider: string
shop_id: string
sku_code: string
sku_name: string
product_rule_id: number
rule_key: string
item_count: number
}
export type TemplateMappingCandidate = {
provider: string
shopId: string
skuCode: string
skuName: string
skuNameKey: string
matchType: TemplateMappingMatchType
ruleId: number
ruleKey: string
itemCount: number
}
export type TemplateMappingConflict = {
provider: string
shopId: string
skuCode: string
skuName: string
matchType: 'sku_code_name'
itemCount: number
ruleIds: number[]
ruleKeys: string[]
}
export type TemplateMappingPlan = {
candidates: TemplateMappingCandidate[]
conflicts: TemplateMappingConflict[]
evidenceItemCount: number
unresolvedItemCount: number
codeConflictKeyCount: number
seriesFallbackConflictCount: number
}
/**
* 将历史工单的直接 rule_id 证据转换为渠道映射候选。
*
* 编码唯一时直接按编码映射;编码被复用(91 常见)时,只有
* 编码 + SKU 名称系列 / 编码 + 精确名称唯一才生成映射。
* 多层仍无法唯一归属的组合进入 conflicts,绝不按“最新工单”猜测。
*/
export function buildTemplateMappingPlan(
rows: TemplateBootstrapEvidenceRow[],
): TemplateMappingPlan {
const candidates = new Map<string, TemplateMappingCandidate & { bestSampleCount: number }>()
const conflicts = new Map<string, TemplateMappingConflict>()
const evidenceItemCount = sumItemCount(rows)
let unresolvedItemCount = 0
let codeConflictKeyCount = 0
let seriesFallbackConflictCount = 0
function addCandidate(
input: Omit<TemplateMappingCandidate, 'itemCount'> & {
itemCount: number
sampleSkuName: string
},
) {
const key = candidateKey(
input.provider,
input.shopId,
input.skuCode,
input.matchType,
input.skuNameKey,
)
const current = candidates.get(key)
if (current) {
current.itemCount += input.itemCount
if (input.itemCount > current.bestSampleCount) {
current.skuName = input.sampleSkuName
current.bestSampleCount = input.itemCount
}
return
}
candidates.set(key, {
provider: input.provider,
shopId: input.shopId,
skuCode: input.skuCode,
skuName: input.sampleSkuName,
skuNameKey: input.skuNameKey,
matchType: input.matchType,
ruleId: input.ruleId,
ruleKey: input.ruleKey,
itemCount: input.itemCount,
bestSampleCount: input.itemCount,
})
}
function addConflict(input: {
provider: string
shopId: string
skuCode: string
skuName: string
nameKey: string
itemCount: number
ruleIds: number[]
ruleKeys: string[]
}) {
const key = candidateKey(
input.provider,
input.shopId,
input.skuCode,
'sku_code_name',
input.nameKey,
)
const current = conflicts.get(key)
if (current) {
current.itemCount += input.itemCount
current.ruleIds = Array.from(new Set([...current.ruleIds, ...input.ruleIds])).sort(
(left, right) => left - right,
)
current.ruleKeys = Array.from(new Set([...current.ruleKeys, ...input.ruleKeys])).sort()
return
}
conflicts.set(key, {
provider: input.provider,
shopId: input.shopId,
skuCode: input.skuCode,
skuName: input.skuName,
matchType: 'sku_code_name',
itemCount: input.itemCount,
ruleIds: [...input.ruleIds].sort((left, right) => left - right),
ruleKeys: [...input.ruleKeys].sort(),
})
}
const codeGroups = groupRows(rows, (row) => [row.provider, row.shop_id, row.sku_code])
for (const codeRows of codeGroups.values()) {
const codeRuleIds = uniqueRuleIds(codeRows)
if (codeRuleIds.length === 1) {
const sample = representativeRow(codeRows)
addCandidate({
provider: sample.provider,
shopId: sample.shop_id,
skuCode: sample.sku_code,
skuName: sample.sku_name,
skuNameKey: '',
matchType: 'sku_code',
ruleId: Number(sample.product_rule_id),
ruleKey: sample.rule_key,
itemCount: sumItemCount(codeRows),
sampleSkuName: sample.sku_name,
})
continue
}
codeConflictKeyCount += 1
const nameGroups = groupRows(codeRows, (row) => [
normalizeSkuEvidenceName(row.sku_name, row.sku_code),
])
for (const nameRows of nameGroups.values()) {
const nameRuleIds = uniqueRuleIds(nameRows)
const sample = representativeRow(nameRows)
const nameKey = normalizeSkuEvidenceName(sample.sku_name, sample.sku_code)
if (nameRuleIds.length === 1) {
addCandidate({
provider: sample.provider,
shopId: sample.shop_id,
skuCode: sample.sku_code,
skuName: sample.sku_name,
skuNameKey: nameKey,
matchType: 'sku_code_name',
ruleId: Number(sample.product_rule_id),
ruleKey: sample.rule_key,
itemCount: sumItemCount(nameRows),
sampleSkuName: sample.sku_name,
})
} else {
const itemCount = sumItemCount(nameRows)
unresolvedItemCount += itemCount
addConflict({
provider: sample.provider,
shopId: sample.shop_id,
skuCode: sample.sku_code,
skuName: sample.sku_name,
nameKey,
itemCount,
ruleIds: nameRuleIds,
ruleKeys: uniqueRuleKeys(nameRows),
})
}
}
const seriesGroups = groupRows(codeRows, (row) => [
normalizeSkuEvidenceSeries(row.sku_name, row.sku_code),
])
for (const seriesRows of seriesGroups.values()) {
const seriesRuleIds = uniqueRuleIds(seriesRows)
if (seriesRuleIds.length !== 1) {
seriesFallbackConflictCount += 1
continue
}
const sample = representativeRow(seriesRows)
addCandidate({
provider: sample.provider,
shopId: sample.shop_id,
skuCode: sample.sku_code,
skuName: sample.sku_name,
skuNameKey: normalizeSkuEvidenceSeries(sample.sku_name, sample.sku_code),
matchType: 'sku_code_series',
ruleId: Number(sample.product_rule_id),
ruleKey: sample.rule_key,
itemCount: sumItemCount(seriesRows),
sampleSkuName: sample.sku_name,
})
}
}
// 商品名系列兜底:仅在同一来源 + 店铺内唯一归属时生成,供后续新编码使用。
const globalSeriesGroups = groupRows(rows, (row) => [
row.provider,
row.shop_id,
normalizeSkuEvidenceSeries(row.sku_name, row.sku_code),
])
for (const seriesRows of globalSeriesGroups.values()) {
const seriesRuleIds = uniqueRuleIds(seriesRows)
const sample = representativeRow(seriesRows)
const seriesKey = normalizeSkuEvidenceSeries(sample.sku_name, sample.sku_code)
if (!seriesKey) continue
if (seriesRuleIds.length !== 1) {
seriesFallbackConflictCount += 1
continue
}
addCandidate({
provider: sample.provider,
shopId: sample.shop_id,
skuCode: '',
skuName: sample.sku_name,
skuNameKey: seriesKey,
matchType: 'sku_name_series',
ruleId: Number(sample.product_rule_id),
ruleKey: sample.rule_key,
itemCount: sumItemCount(seriesRows),
sampleSkuName: sample.sku_name,
})
}
return {
candidates: Array.from(candidates.values())
.map(({ bestSampleCount: _bestSampleCount, ...row }) => row)
.sort(compareMappingCandidates),
conflicts: Array.from(conflicts.values()).sort(
(left, right) =>
right.itemCount - left.itemCount ||
left.provider.localeCompare(right.provider) ||
left.shopId.localeCompare(right.shopId) ||
left.skuCode.localeCompare(right.skuCode),
),
evidenceItemCount,
unresolvedItemCount,
codeConflictKeyCount,
seriesFallbackConflictCount,
}
}
function countMappingCandidates(rows: TemplateMappingCandidate[]) {
const counts: Record<TemplateMappingMatchType, number> = {
sku_code: 0,
sku_code_name: 0,
sku_code_series: 0,
sku_name_series: 0,
}
for (const row of rows) counts[row.matchType] += 1
return counts
}
function groupRows<T>(rows: T[], keyOf: (row: T) => Array<string | number>) {
const groups = new Map<string, T[]>()
for (const row of rows) {
const key = keyOf(row).map(String).join('\u0000')
const list = groups.get(key)
if (list) list.push(row)
else groups.set(key, [row])
}
return groups
}
function uniqueRuleIds(rows: TemplateBootstrapEvidenceRow[]) {
return Array.from(new Set(rows.map((row) => Number(row.product_rule_id)))).sort(
(left, right) => left - right,
)
}
function uniqueRuleKeys(rows: TemplateBootstrapEvidenceRow[]) {
return Array.from(new Set(rows.map((row) => row.rule_key).filter(Boolean))).sort()
}
function sumItemCount(rows: TemplateBootstrapEvidenceRow[]) {
return rows.reduce((sum, row) => sum + Math.max(0, Number(row.item_count) || 0), 0)
}
function representativeRow(rows: TemplateBootstrapEvidenceRow[]) {
return rows.reduce((best, row) => (Number(row.item_count) > Number(best.item_count) ? row : best))
}
function normalizeSkuEvidenceName(skuName: string, skuCode: string) {
return normalizeMatchText(skuName || skuCode)
}
function normalizeSkuEvidenceSeries(skuName: string, skuCode: string) {
return normalizeSkuSeriesEquivalence(skuName || skuCode)
}
function compareMappingCandidates(left: TemplateMappingCandidate, right: TemplateMappingCandidate) {
const typeOrder: Record<TemplateMappingMatchType, number> = {
sku_code: 1,
sku_code_name: 2,
sku_code_series: 3,
sku_name_series: 4,
}
return (
right.itemCount - left.itemCount ||
typeOrder[left.matchType] - typeOrder[right.matchType] ||
left.provider.localeCompare(right.provider) ||
left.shopId.localeCompare(right.shopId) ||
left.skuCode.localeCompare(right.skuCode) ||
left.skuNameKey.localeCompare(right.skuNameKey)
)
}
async function resetImportedTemplateProductBootstrap(range: { fromDate: string; toDate: string }) {
let resetOrderItemCount = 0
for (let batch = 0; batch < 20; batch += 1) {
const result = await query<{ id: number }>(
`
WITH candidates AS (
SELECT oi.id
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN channel_product_mappings m
ON m.provider = o.provider
AND (m.shop_id = '' OR m.shop_id = o.shop_id)
AND oi.canonical_product_id = m.canonical_product_id
AND m.source = 'import'
AND m.note LIKE '接单模板 %自动初始化%'
AND (
(m.match_type = 'sku_code' AND m.platform_sku_code = oi.sku_code)
OR (
m.match_type = 'sku_code_name'
AND m.platform_sku_code = oi.sku_code
AND m.platform_sku_name_key = oi.normalized_sku_name
)
OR (
m.match_type = 'sku_code_series'
AND m.platform_sku_code = oi.sku_code
AND m.platform_sku_name_key = oi.normalized_sku_series
)
OR (
m.match_type = 'sku_name_series'
AND m.platform_sku_name_key = oi.normalized_sku_series
)
)
WHERE oi.canonical_product_id IS NOT NULL
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
LIMIT 20000
)
UPDATE order_items oi
SET canonical_product_id = NULL,
updated_at = NOW()
FROM candidates c
WHERE oi.id = c.id
RETURNING oi.id
`,
[range.fromDate, range.toDate],
)
resetOrderItemCount += result.rowCount || 0
if ((result.rowCount || 0) < 20000) break
}
const deleted = await query(
`
DELETE FROM channel_product_mappings m
WHERE m.source = 'import'
AND m.note LIKE '接单模板 %自动初始化%'
AND EXISTS (
SELECT 1
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.canonical_product_id = m.canonical_product_id
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
)
`,
[range.fromDate, range.toDate],
)
return { deletedMappingCount: deleted.rowCount || 0, resetOrderItemCount }
}
async function loadTemplateProductBootstrapData(range: { fromDate: string; toDate: string }) {
const [templatesResult, evidenceResult, productsResult, mappingsResult] = await Promise.all([
query<TemplateBootstrapTemplateRow>(
`
SELECT
r.id,
r.enabled,
r.rule_key,
r.product_name,
COALESCE(c.name, '') AS category,
r.unit_price_fen,
r.reward_amount,
m.id AS mapping_id,
COALESCE(m.mapping_type, '') AS mapping_type,
COALESCE(m.sku_nick, '') AS sku_nick,
COALESCE(m.rel_sku_id, '') AS rel_sku_id,
COALESCE(m.seller_ids_json, '[]'::jsonb) AS seller_ids_json
FROM work_product_rules r
LEFT JOIN work_categories c ON c.id = r.category_id
LEFT JOIN LATERAL (
SELECT wm.*
FROM work_product_rule_mappings wm
WHERE wm.rule_id = r.id AND wm.enabled = TRUE
ORDER BY wm.id
LIMIT 1
) m ON TRUE
ORDER BY r.id
`,
),
query<TemplateBootstrapEvidenceRow>(
`
SELECT
o.provider,
o.shop_id,
oi.sku_code,
oi.sku_name,
wo.product_rule_id,
r.rule_key,
COUNT(DISTINCT oi.id)::int AS item_count
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN work_orders wo ON wo.order_item_id = oi.id
JOIN work_product_rules r ON r.id = wo.product_rule_id AND r.enabled = TRUE
WHERE wo.product_rule_id IS NOT NULL
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
GROUP BY
o.provider,
o.shop_id,
oi.sku_code,
oi.sku_name,
wo.product_rule_id,
r.rule_key
ORDER BY o.provider, o.shop_id, oi.sku_code, oi.sku_name, wo.product_rule_id
`,
[range.fromDate, range.toDate],
),
query<{ id: number; canonical_sku: string; canonical_name: string }>(
'SELECT id, canonical_sku, canonical_name FROM product_catalog',
),
query<{
provider: string
shop_id: string
platform_sku_code: string
platform_sku_name_key: string
match_type: string
}>(
`SELECT provider, shop_id, platform_sku_code, platform_sku_name_key, match_type
FROM channel_product_mappings`,
),
])
const usedRuleIds = new Set(evidenceResult.rows.map((row) => Number(row.product_rule_id)))
// 商品是「成本/价格」的载体,必须全量同步:新模板还没产生工单时也要能定价。
// 渠道映射仍然只按工单证据建(没有订单就没有平台 SKU 可映射)。
const enabledTemplates = templatesResult.rows.filter((row) => row.enabled)
const disabledTemplates = templatesResult.rows.filter((row) => !row.enabled)
const templatesWithEvidence = enabledTemplates.filter((row) => usedRuleIds.has(Number(row.id)))
const productByCanonicalSku = new Map(
productsResult.rows.map((row) => [row.canonical_sku, Number(row.id)]),
)
const productByCanonicalName = new Map<string, number>()
for (const row of productsResult.rows) {
const key = normalizeProductNameKey(row.canonical_name)
if (key && !productByCanonicalName.has(key)) {
productByCanonicalName.set(key, Number(row.id))
}
}
const productByRuleKey = new Map<string, number>()
const productsToCreate: TemplateBootstrapTemplateRow[] = []
for (const template of enabledTemplates) {
const existing =
productByCanonicalSku.get(buildTemplateProductSku(template.rule_key)) ||
productByCanonicalName.get(normalizeProductNameKey(template.product_name))
if (existing) {
productByRuleKey.set(template.rule_key, existing)
} else {
productsToCreate.push(template)
}
}
return {
enabledTemplates,
disabledTemplates,
templatesWithEvidence,
evidenceRows: evidenceResult.rows,
productsToCreate,
productByRuleKey,
existingMappingKeys: new Set(
mappingsResult.rows.map((row) =>
mappingKey(
row.provider,
row.shop_id,
row.platform_sku_code,
row.match_type,
row.platform_sku_name_key,
),
),
),
}
}
export function buildTemplateProductSku(ruleKey: string) {
return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64)
}
export function inferTemplateUnitName(productName: string) {
const name = String(productName || '').trim()
if (/钻石|颗/.test(name)) return '颗'
if (/枪|把/.test(name)) return '把'
if (/套/.test(name)) return '套'
if (/密钥|秘钥/.test(name)) return '个'
return '件'
}
function resolveBootstrapDateRange(payload: JsonObject) {
const range = resolveFinanceDateRange(
{ dateFrom: payload.dateFrom, dateTo: payload.dateTo },
{ defaultDays: 30, maxDays: 400 },
)
return { fromDate: range.fromDate, toDate: range.toDate }
}
function normalizeProductNameKey(value: unknown) {
return String(value || '')
.normalize('NFKC')
.trim()
.toLowerCase()
}