diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index cca1d79f..d4ee272e 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -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( diff --git a/apps/backend/src/services/cost-center/template-bootstrap-service.ts b/apps/backend/src/services/cost-center/template-bootstrap-service.ts index 4f57be21..65781afa 100644 --- a/apps/backend/src/services/cost-center/template-bootstrap-service.ts +++ b/apps/backend/src/services/cost-center/template-bootstrap-service.ts @@ -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() - 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() 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, diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index 869ca293..87250e8c 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -34,7 +34,7 @@ import { import type { TableColumnsType } from 'antd' import PercentTable from '@/components/common/PercentTable' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { Dayjs } from 'dayjs' import { AdminRangePicker } from '@/components/admin/AdminDatePicker' @@ -65,6 +65,33 @@ import { type ProductFormValues, } from './shared' +/** 全量同步选项:停用模板默认不建商品,勾选后建成「归档」商品。 */ +type BootstrapSyncOptions = { includeDisabledTemplates: boolean } + +function DisabledTemplateSyncOption({ + initialValue, + onChange, +}: { + initialValue: boolean + onChange: (value: boolean) => void +}) { + const [checked, setChecked] = useState(initialValue) + return ( + { + setChecked(event.target.checked) + onChange(event.target.checked) + }} + > + 同时为停用模板建商品(归档状态,不参与成本统计) +
+ 默认只同步启用模板;停用模板一般是历史遗留,建出来的商品会以「归档」出现在商品列表里。 +
+
+ ) +} + export function ProductCatalogPanel() { const { message, modal } = App.useApp() const queryClient = useQueryClient() @@ -82,6 +109,7 @@ export function ProductCatalogPanel() { const [productStatusUpdatingId, setProductStatusUpdatingId] = useState(null) const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) const [bootstrapApplying, setBootstrapApplying] = useState(false) + const bootstrapSyncOptionsRef = useRef({ includeDisabledTemplates: false }) const [backfillingWorkOrderSkus, setBackfillingWorkOrderSkus] = useState(false) const [normalizingManualProducts, setNormalizingManualProducts] = useState(false) const [syncingAffiliate, setSyncingAffiliate] = useState(false) @@ -152,36 +180,51 @@ export function ProductCatalogPanel() { try { const response = await previewAdminTemplateProductBootstrap(backfillPayload()) const data = response.data + bootstrapSyncOptionsRef.current.includeDisabledTemplates = false modal.confirm({ - title: '从接单模板初始化商品与映射', + title: '从接单模板全量同步商品与映射', width: 720, content: ( - 启用接单模板 {data.templateCount} 个:将新建{' '} - {data.productCreateCount} 个标准商品,复用{' '} - {data.productExistingCount} 个已有商品。 + 接单模板共 {data.totalTemplateCount} 个(启用{' '} + {data.templateCount} / 停用{' '} + {data.disabledTemplateCount})。 - 根据所选时间范围内工单命中的模板,将建立 {data.mappingCreateCount}{' '} - 条渠道映射:编码精确 {data.mappingCreateByMatchType.sku_code}{' '} - 条、编码 + 名称系列 {data.mappingCreateByMatchType.sku_code_series}{' '} - 条、 编码 + 精确名称 {data.mappingCreateByMatchType.sku_code_name}{' '} - 条、名称系列兜底 {data.mappingCreateByMatchType.sku_name_series} 条。 + 启用模板全部建标准商品:将新建 {data.productCreateCount} 个、复用{' '} + {data.productExistingCount}{' '} + 个(已有模板商品会同步刷新名称、分类、发单金额)。 + + + 其中 {data.evidenceTemplateCount} 个模板在范围内有工单证据,将建立{' '} + {data.mappingCreateCount} 条渠道映射:编码精确{' '} + {data.mappingCreateByMatchType.sku_code} 条、编码 + 名称系列{' '} + {data.mappingCreateByMatchType.sku_code_series} 条、 编码 + 精确名称{' '} + {data.mappingCreateByMatchType.sku_code_name} 条、名称系列兜底{' '} + {data.mappingCreateByMatchType.sku_name_series} 条。 + + + {data.templatesWithoutWorkOrders} 个启用模板在范围内没有工单证据: + 只建商品(可在「商品与成本中心」直接改价),不建渠道映射。 预计可归一 {data.orderItemCoverCount} / {data.evidenceItemCount}{' '} 个范围内有工单证据的订单项。 - - {data.templatesWithoutWorkOrders}{' '} - 个启用模板在范围内没有工单证据,本次不会创建。 - + {data.disabledTemplateCount > 0 ? ( + { + bootstrapSyncOptionsRef.current = { includeDisabledTemplates: value } + }} + /> + ) : null} {data.mappingConflictCount > 0 ? ( ), - okText: '开始初始化并修复旧导入', + okText: '开始全量同步', cancelText: '取消', onOk: async () => { setBootstrapApplying(true) try { - const result = await applyAdminTemplateProductBootstrap(backfillPayload()) + const result = await applyAdminTemplateProductBootstrap({ + ...backfillPayload(), + includeDisabledTemplates: bootstrapSyncOptionsRef.current.includeDisabledTemplates, + }) const applied = result.data const rebuildText = applied.deletedImportedMappingCount > 0 @@ -230,8 +276,12 @@ export function ProductCatalogPanel() { applied.mappingConflictCount > 0 ? `;保留 ${applied.mappingConflictCount} 组冲突待人工处理` : '' + const disabledText = + applied.disabledTemplateCount > 0 + ? `(含停用模板 ${applied.disabledTemplateCount} 个,已建为归档商品)` + : '' message.success( - `已新建 ${applied.createdProductCount} 个商品、${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`, + `已同步 ${applied.templateCount} 个模板商品:新建 ${applied.createdProductCount} 个、刷新 ${applied.updatedProductCount} 个、复用 ${applied.reusedProductCount} 个${disabledText};新增 ${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`, ) refreshAll() } catch (error) { @@ -411,7 +461,7 @@ export function ProductCatalogPanel() { item.activeCostAmountFen !== null && item.activeCostAmountFen !== undefined ? (item.activeCostAmountFen / 100).toFixed(2) : undefined, - effectiveScope: 'retroactive', + effectiveScope: 'immediate', changeReason: '在商品与成本中心统一调整价格', syncOpenOrders: true, syncSnapshots: true, @@ -1065,16 +1115,16 @@ export function ProductCatalogPanel() { rules={[{ required: true, message: '请选择生效时间范围' }]} > - - 追溯至历史订单(推荐) + + 自当前时间立即生效(推荐,仅对后续订单生效)
- 自近一个月业务起点(30 天前)生效,直接更新基线并消除近一个月 cost_missing 异常 + 保留旧版本历史,自新的价格版本创建时刻起计算后续履约结算
- - 自当前时间立即生效(仅对后续订单生效) + + 追溯至历史订单
- 保留旧版本历史,自新版本创建时刻起计算后续履约结算 + 自近一个月业务起点(30 天前)生效,直接更新基线并消除近一个月 cost_missing 异常
diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index d35fa362..c00dcc23 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -55,6 +55,7 @@ export function previewAdminTemplateProductBootstrap(payload?: { export function applyAdminTemplateProductBootstrap(payload?: { dateFrom?: string dateTo?: string + includeDisabledTemplates?: boolean }) { return apiPost( '/api/v1/admin/cost-center/template-bootstrap/apply', diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index 0bf63a51..be991a04 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -175,7 +175,10 @@ export interface CostCenterOptions { export interface TemplateProductBootstrapPreview { dateFrom: string dateTo: string + totalTemplateCount: number templateCount: number + disabledTemplateCount: number + evidenceTemplateCount: number productExistingCount: number productCreateCount: number mappingCandidateCount: number @@ -215,7 +218,11 @@ export interface TemplateProductBootstrapPreview { export interface TemplateProductBootstrapResult { templateCount: number + enabledTemplateCount: number + disabledTemplateCount: number + evidenceTemplateCount: number createdProductCount: number + updatedProductCount: number reusedProductCount: number createdMappingCount: number skippedMappingCount: number diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index fb0eddb7..c489f10f 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -152,19 +152,22 @@ normalized_sku_series -- 剥离数量并归一同义字后的名称系列 ### 2.4 接单模板批量初始化 -662 个接单模板不允许逐个手工建商品。后台「从接单模板初始化」按以下规则批量生成: +接单模板不允许逐个手工建商品。后台「从接单模板全量同步商品与映射」按以下规则批量生成: -1. **只处理时间范围内有工单证据的启用模板**,内部编码 `tpl:`;默认最近 30 天,可在页面日期筛选里调整。范围外、暂无工单的模板不再先建商品,避免很早以前的模板 SKU 重新污染目录。 -2. **范围内的工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。 -3. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。 -4. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。 -5. 同一编码 + 同一名称仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单。 -6. 来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,永远排在编码类映射之后。 -7. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 `normalized_quantity`,**不能把"5个"再写成 `quantity_multiplier=5`**,否则会重复乘算。 -8. 重跑初始化只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单不删除。 +1. **商品全量同步,不依赖订单**:所有启用模板都建/刷新标准商品,内部编码 `tpl:`;模板改名、改分类、改发单金额都会刷新到商品上。新模板还没有工单也能在「商品与成本中心」直接定价。停用模板默认不建商品,勾选后可一并建成「归档」商品。 +2. **渠道映射仍只按工单证据建**:默认最近 30 天,可在页面日期筛选里调整;范围内没有工单的模板只建商品、不建映射——没有订单就没有平台 SKU 可映射。 +3. **范围内的工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。 +4. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。 +5. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。 +6. 同一编码 + 同一名称仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单。 +7. 来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,永远排在编码类映射之后。 +8. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 `normalized_quantity`,**不能把"5个"再写成 `quantity_multiplier=5`**,否则会重复乘算。 +9. 重跑初始化只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单、已建商品都不删除。 本地验证结果(2026-10-18):启用模板 662 个;分层映射 4,426 条(编码精确 2,302、编码 + 精确名称 440、编码 + 名称系列 160、名称系列兜底 1,524);可唯一归一 86,533 个有证据订单项,另有 25,918 个订单项属于 32 组真实冲突,保持未映射。 +全量同步改版后(2026-10-29,本地库预览 09-30 ~ 10-29):模板 710 个全部启用、范围内有工单证据 388 个;商品新建 704 个 / 复用 6 个,候选映射 3,198 条。之前被跳过的 322 个无单模板,现在也会有商品(可定价),只是暂时没有渠道映射。 + ### 2.5 自动发货商品 自动发货只有 affiliate-dash 一个上游渠道,SKU 固定归一为 `aff:`,**不写 `channel_product_mappings`**。渠道映射只代表"平台 SKU 能归一到标准商品",不代表自动发货。