feat(cost-center): 接单模板商品全量同步,改价默认立即生效

This commit is contained in:
yml2213
2026-10-29 21:44:29 +08:00
parent c7d650b482
commit dc78dc92d1
6 changed files with 189 additions and 66 deletions
@@ -182,6 +182,20 @@ export async function listAdminProducts(payload: JsonObject = {}) {
AND wpr.rule_key = substring(p.canonical_sku from 5)
ORDER BY v.effective_from DESC, v.id DESC
LIMIT 1
),
-- 还没有成本版本的接单模板商品(例如刚建、还没跑「同步模板发单金额」),
-- 直接展示模板同步过来的发单金额,避免显示「未配置」让改价入口看起来不可用。
(
SELECT CASE
WHEN wpr2.unit_price_fen > 0 AND wpr2.reward_amount = 0
THEN wpr2.unit_price_fen
ELSE wpr2.reward_amount
END
FROM work_product_rules wpr2
WHERE p.canonical_sku LIKE 'tpl:%'
AND wpr2.rule_key = substring(p.canonical_sku from 5)
AND (wpr2.reward_amount > 0 OR wpr2.unit_price_fen > 0)
LIMIT 1
)
) AS active_cost_amount_fen,
COALESCE(
@@ -24,21 +24,26 @@ export async function previewAdminTemplateProductBootstrap(payload: JsonObject =
const range = resolveBootstrapDateRange(payload)
const data = await loadTemplateProductBootstrapData(range)
const plan = buildTemplateMappingPlan(data.evidenceRows)
const templateRuleKeys = new Set(data.templates.map((row) => row.rule_key))
const enabledRuleKeys = new Set(data.enabledTemplates.map((row) => row.rule_key))
const candidates = plan.candidates.filter(
(row) =>
templateRuleKeys.has(row.ruleKey) &&
enabledRuleKeys.has(row.ruleKey) &&
!data.existingMappingKeys.has(
mappingKey(row.provider, row.shopId, row.skuCode, row.matchType, row.skuNameKey),
),
)
const usedRuleIds = new Set(data.evidenceRows.map((row) => Number(row.product_rule_id)))
const candidatesByMatchType = countMappingCandidates(candidates)
const templatesWithoutWorkOrders =
data.enabledTemplates.length - data.templatesWithEvidence.length
return {
dateFrom: range.fromDate,
dateTo: range.toDate,
templateCount: data.templates.length,
productExistingCount: data.templates.length - data.productsToCreate.length,
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,
@@ -50,11 +55,10 @@ export async function previewAdminTemplateProductBootstrap(payload: JsonObject =
mappingConflictItemCount: plan.unresolvedItemCount,
multiRuleKeyCount: plan.codeConflictKeyCount,
seriesFallbackConflictCount: plan.seriesFallbackConflictCount,
templatesWithoutWorkOrders: data.templates.filter((row) => !usedRuleIds.has(Number(row.id)))
.length,
samples: candidates.slice(0, 30).map((row) => ({
ruleKey: row.ruleKey,
productName: data.templates.find((item) => item.rule_key === row.ruleKey)?.product_name || '',
productName:
data.enabledTemplates.find((item) => item.rule_key === row.ruleKey)?.product_name || '',
provider: row.provider,
shopId: row.shopId,
skuCode: row.skuCode,
@@ -79,13 +83,19 @@ export async function applyAdminTemplateProductBootstrap(
_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)
if (data.templates.length === 0) {
throw createHttpError('没有可导入的接单模板', {
// 商品是价格/成本的载体,必须全量同步:新模板还没产生工单也要能定价。
const productTemplates = includeDisabledTemplates
? [...data.enabledTemplates, ...data.disabledTemplates]
: data.enabledTemplates
if (productTemplates.length === 0) {
throw createHttpError('没有可同步的接单模板', {
statusCode: 400,
errorCode: 'cost_template_bootstrap_empty',
})
@@ -94,6 +104,7 @@ export async function applyAdminTemplateProductBootstrap(
const plan = buildTemplateMappingPlan(data.evidenceRows)
const now = nowIso()
let createdProductCount = 0
let updatedProductCount = 0
let reusedProductCount = 0
let createdMappingCount = 0
let skippedMappingCount = 0
@@ -101,21 +112,58 @@ export async function applyAdminTemplateProductBootstrap(
await withTransaction(async (client) => {
const productIdByRuleKey = new Map<string, number>()
for (const template of data.templates) {
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)
reusedProductCount += 1
continue
}
const canonicalSku = buildTemplateProductSku(template.rule_key)
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, 'active', $5::jsonb, $6, $6)
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $7)
ON CONFLICT (canonical_sku) DO NOTHING
RETURNING id
`,
@@ -124,18 +172,8 @@ export async function applyAdminTemplateProductBootstrap(
template.product_name,
template.category,
inferTemplateUnitName(template.product_name),
JSON.stringify({
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),
}),
template.enabled ? 'active' : 'archived',
JSON.stringify(attributes),
now,
],
)
@@ -208,8 +246,12 @@ export async function applyAdminTemplateProductBootstrap(
return {
dateFrom: range.fromDate,
dateTo: range.toDate,
templateCount: data.templates.length,
templateCount: productTemplates.length,
enabledTemplateCount: data.enabledTemplates.length,
disabledTemplateCount: includeDisabledTemplates ? data.disabledTemplates.length : 0,
evidenceTemplateCount: data.templatesWithEvidence.length,
createdProductCount,
updatedProductCount,
reusedProductCount,
createdMappingCount,
skippedMappingCount,
@@ -234,6 +276,7 @@ export async function applyAdminTemplateProductBootstrap(
type TemplateBootstrapTemplateRow = {
id: number
enabled: boolean
rule_key: string
product_name: string
category: string
@@ -647,6 +690,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
`
SELECT
r.id,
r.enabled,
r.rule_key,
r.product_name,
COALESCE(c.name, '') AS category,
@@ -666,7 +710,6 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
ORDER BY wm.id
LIMIT 1
) m ON TRUE
WHERE r.enabled = TRUE
ORDER BY r.id
`,
),
@@ -714,8 +757,11 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
])
const usedRuleIds = new Set(evidenceResult.rows.map((row) => Number(row.product_rule_id)))
// 只初始化时间范围内真正有工单证据的模板,避免把很早以前、已不再使用的模板商品重新带出来。
const templates = templatesResult.rows.filter((row) => usedRuleIds.has(Number(row.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)]),
)
@@ -728,7 +774,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
}
const productByRuleKey = new Map<string, number>()
const productsToCreate: TemplateBootstrapTemplateRow[] = []
for (const template of templates) {
for (const template of enabledTemplates) {
const existing =
productByCanonicalSku.get(buildTemplateProductSku(template.rule_key)) ||
productByCanonicalName.get(normalizeProductNameKey(template.product_name))
@@ -740,7 +786,9 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
}
return {
templates,
enabledTemplates,
disabledTemplates,
templatesWithEvidence,
evidenceRows: evidenceResult.rows,
productsToCreate,
productByRuleKey,