836 lines
28 KiB
TypeScript
836 lines
28 KiB
TypeScript
/**
|
||
* 接单模板引导:预览与执行、映射计划构建、模板商品编码。
|
||
*
|
||
* 关联:
|
||
* - 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()
|
||
}
|