feat(cost-center): 接单模板商品全量同步,改价默认立即生效
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
setChecked(event.target.checked)
|
||||
onChange(event.target.checked)
|
||||
}}
|
||||
>
|
||||
<strong>同时为停用模板建商品(归档状态,不参与成本统计)</strong>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
默认只同步启用模板;停用模板一般是历史遗留,建出来的商品会以「归档」出现在商品列表里。
|
||||
</div>
|
||||
</Checkbox>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductCatalogPanel() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -82,6 +109,7 @@ export function ProductCatalogPanel() {
|
||||
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
|
||||
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
|
||||
const [bootstrapApplying, setBootstrapApplying] = useState(false)
|
||||
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ 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: (
|
||||
<Space direction="vertical" size={8}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`统计范围:${data.dateFrom} ~ ${data.dateTo}(默认最近 30 天)`}
|
||||
message={`统计范围:${data.dateFrom} ~ ${data.dateTo}(商品全量同步,不依赖订单;映射证据按该范围)`}
|
||||
/>
|
||||
<span>
|
||||
启用接单模板 <strong>{data.templateCount}</strong> 个:将新建{' '}
|
||||
<strong>{data.productCreateCount}</strong> 个标准商品,复用{' '}
|
||||
<strong>{data.productExistingCount}</strong> 个已有商品。
|
||||
接单模板共 <strong>{data.totalTemplateCount}</strong> 个(启用{' '}
|
||||
<strong>{data.templateCount}</strong> / 停用{' '}
|
||||
<strong>{data.disabledTemplateCount}</strong>)。
|
||||
</span>
|
||||
<span>
|
||||
根据所选时间范围内工单命中的模板,将建立 <strong>{data.mappingCreateCount}</strong>{' '}
|
||||
条渠道映射:编码精确 <strong>{data.mappingCreateByMatchType.sku_code}</strong>{' '}
|
||||
条、编码 + 名称系列 <strong>{data.mappingCreateByMatchType.sku_code_series}</strong>{' '}
|
||||
条、 编码 + 精确名称 <strong>{data.mappingCreateByMatchType.sku_code_name}</strong>{' '}
|
||||
条、名称系列兜底 <strong>{data.mappingCreateByMatchType.sku_name_series}</strong> 条。
|
||||
启用模板全部建标准商品:将新建 <strong>{data.productCreateCount}</strong> 个、复用{' '}
|
||||
<strong>{data.productExistingCount}</strong>{' '}
|
||||
个(已有模板商品会同步刷新名称、分类、发单金额)。
|
||||
</span>
|
||||
<span>
|
||||
其中 <strong>{data.evidenceTemplateCount}</strong> 个模板在范围内有工单证据,将建立{' '}
|
||||
<strong>{data.mappingCreateCount}</strong> 条渠道映射:编码精确{' '}
|
||||
<strong>{data.mappingCreateByMatchType.sku_code}</strong> 条、编码 + 名称系列{' '}
|
||||
<strong>{data.mappingCreateByMatchType.sku_code_series}</strong> 条、 编码 + 精确名称{' '}
|
||||
<strong>{data.mappingCreateByMatchType.sku_code_name}</strong> 条、名称系列兜底{' '}
|
||||
<strong>{data.mappingCreateByMatchType.sku_name_series}</strong> 条。
|
||||
</span>
|
||||
<span>
|
||||
<strong>{data.templatesWithoutWorkOrders}</strong> 个启用模板在范围内没有工单证据:
|
||||
只建商品(可在「商品与成本中心」直接改价),不建渠道映射。
|
||||
</span>
|
||||
<span>
|
||||
预计可归一 <strong>{data.orderItemCoverCount}</strong> / {data.evidenceItemCount}{' '}
|
||||
个范围内有工单证据的订单项。
|
||||
</span>
|
||||
<span>
|
||||
<strong>{data.templatesWithoutWorkOrders}</strong>{' '}
|
||||
个启用模板在范围内没有工单证据,本次不会创建。
|
||||
</span>
|
||||
{data.disabledTemplateCount > 0 ? (
|
||||
<DisabledTemplateSyncOption
|
||||
initialValue={false}
|
||||
onChange={(value) => {
|
||||
bootstrapSyncOptionsRef.current = { includeDisabledTemplates: value }
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{data.mappingConflictCount > 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
@@ -215,12 +258,15 @@ export function ProductCatalogPanel() {
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
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: '请选择生效时间范围' }]}
|
||||
>
|
||||
<Radio.Group style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Radio value="retroactive">
|
||||
<strong>追溯至历史订单(推荐)</strong>
|
||||
<Radio value="immediate">
|
||||
<strong>自当前时间立即生效(推荐,仅对后续订单生效)</strong>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
自近一个月业务起点(30 天前)生效,直接更新基线并消除近一个月 cost_missing 异常
|
||||
保留旧版本历史,自新的价格版本创建时刻起计算后续履约结算
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="immediate">
|
||||
<strong>自当前时间立即生效(仅对后续订单生效)</strong>
|
||||
<Radio value="retroactive">
|
||||
<strong>追溯至历史订单</strong>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
保留旧版本历史,自新版本创建时刻起计算后续履约结算
|
||||
自近一个月业务起点(30 天前)生效,直接更新基线并消除近一个月 cost_missing 异常
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="custom">
|
||||
|
||||
@@ -55,6 +55,7 @@ export function previewAdminTemplateProductBootstrap(payload?: {
|
||||
export function applyAdminTemplateProductBootstrap(payload?: {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
includeDisabledTemplates?: boolean
|
||||
}) {
|
||||
return apiPost<TemplateProductBootstrapResult>(
|
||||
'/api/v1/admin/cost-center/template-bootstrap/apply',
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-9
@@ -152,19 +152,22 @@ normalized_sku_series -- 剥离数量并归一同义字后的名称系列
|
||||
|
||||
### 2.4 接单模板批量初始化
|
||||
|
||||
662 个接单模板不允许逐个手工建商品。后台「从接单模板初始化」按以下规则批量生成:
|
||||
接单模板不允许逐个手工建商品。后台「从接单模板全量同步商品与映射」按以下规则批量生成:
|
||||
|
||||
1. **只处理时间范围内有工单证据的启用模板**,内部编码 `tpl:<rule_key>`;默认最近 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:<rule_key>`;模板改名、改分类、改发单金额都会刷新到商品上。新模板还没有工单也能在「商品与成本中心」直接定价。停用模板默认不建商品,勾选后可一并建成「归档」商品。
|
||||
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:<skuCode>`,**不写 `channel_product_mappings`**。渠道映射只代表"平台 SKU 能归一到标准商品",不代表自动发货。
|
||||
|
||||
Reference in New Issue
Block a user