feat(cost-center): 商品初始化/回填增加时间范围,默认最近 30 天

- 接单模板初始化预览/执行支持 dateFrom/dateTo,只使用范围内工单证据;
  范围外模板不再先建商品
- 按工单回填商品支持时间范围;人工商品归一 UI 传时间范围
- 模板初始化重置只清理范围内导入映射/订单项,避免误伤范围外数据
- 初始化/回填默认最近 30 天,自动发货仍固定 30 天
- 文档更新初始化口径与页面操作
This commit is contained in:
yml2213
2026-10-26 16:26:48 +08:00
parent 288a0526ad
commit 2b967ddd4c
9 changed files with 206 additions and 53 deletions
@@ -47,7 +47,7 @@ const router = Router()
router.get(
'/cost-center/template-bootstrap/preview',
requireAdminPermission('finance.view'),
createJsonHandler(() => previewAdminTemplateProductBootstrap(), {
createJsonHandler((req) => previewAdminTemplateProductBootstrap(req.query as JsonObject), {
successMessage: 'ok',
errorMessage: '读取接单模板初始化预览失败',
scope: '[admin/cost-center/template-bootstrap/preview]',
@@ -156,7 +156,7 @@ router.post(
router.post(
'/cost-center/work-order-products/backfill',
requireAdminPermission('finance.config'),
createJsonHandler(() => backfillAdminWorkOrderProductSkus(), {
createJsonHandler((req) => backfillAdminWorkOrderProductSkus(bodyOf(req)), {
successMessage: '接单平台商品已回填',
errorMessage: '回填接单平台商品失败',
scope: '[admin/cost-center/work-order-products/backfill]',
@@ -10,6 +10,7 @@ import { query } from '../../db/client.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
@@ -978,6 +979,11 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) {
const limit = Math.min(20000, Math.max(1, Math.trunc(Number(payload.limit) || 5000)))
const all = payload.all === true || payload.all === 'true'
const maxBatches = all ? 40 : 1
const range = resolveOptionalDateRange(payload)
const dateSql = range
? `AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $4::date AND $5::date`
: ''
const rangeParams = range ? [range.fromDate, range.toDate] : []
let updatedCount = 0
let batches = 0
let lastId = 0
@@ -1015,10 +1021,11 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) {
WHERE oi.canonical_product_id IS NULL
AND oi.id > $1
AND ($2 = '' OR o.provider = $2)
${dateSql}
ORDER BY oi.id
LIMIT $3
`,
[lastId, provider, limit],
[lastId, provider, limit, ...rangeParams],
)
if (itemsResult.rows.length === 0) break
@@ -1078,8 +1085,9 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) {
JOIN orders o ON o.id = oi.order_id
WHERE oi.canonical_product_id IS NULL
AND ($1 = '' OR o.provider = $1)
${dateSql.replace('$4', '$2').replace('$5', '$3')}
`,
[provider],
[provider, ...rangeParams],
)
return {
updatedCount,
@@ -1155,6 +1163,11 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {}
const limit = Math.min(50000, Math.max(1, Math.trunc(Number(payload.limit) || 20000)))
const all = payload.all === true || payload.all === 'true'
const maxBatches = all ? 40 : 1
const range = resolveOptionalDateRange(payload)
const dateSql = range
? `AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $2::date AND $3::date`
: ''
const rangeParams = range ? [range.fromDate, range.toDate] : []
let updatedCount = 0
let batches = 0
@@ -1168,16 +1181,18 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {}
normalized_quantity: number | null
}>(
`
SELECT id, sku_name, sku_code, normalized_sku_name, normalized_sku_series,
normalized_quantity
FROM order_items
WHERE normalized_quantity IS NULL
OR normalized_sku_name IS NULL
OR normalized_sku_series IS NULL
ORDER BY id
SELECT oi.id, oi.sku_name, oi.sku_code, oi.normalized_sku_name,
oi.normalized_sku_series, oi.normalized_quantity
FROM order_items oi
${range ? 'JOIN orders o ON o.id = oi.order_id' : ''}
WHERE (oi.normalized_quantity IS NULL
OR oi.normalized_sku_name IS NULL
OR oi.normalized_sku_series IS NULL)
${dateSql.replaceAll('oi.', 'oi.')}
ORDER BY oi.id
LIMIT $1
`,
[limit],
[limit, ...rangeParams],
)
if (result.rows.length === 0) break
@@ -1218,10 +1233,13 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {}
const remainingResult = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total
FROM order_items
WHERE normalized_quantity IS NULL
OR normalized_sku_name IS NULL
OR normalized_sku_series IS NULL`,
FROM order_items oi
${range ? 'JOIN orders o ON o.id = oi.order_id' : ''}
WHERE (oi.normalized_quantity IS NULL
OR oi.normalized_sku_name IS NULL
OR oi.normalized_sku_series IS NULL)
${dateSql.replace('$2', '$1').replace('$3', '$2')}`,
rangeParams,
)
return {
updatedCount,
@@ -1231,6 +1249,16 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {}
}
}
function resolveOptionalDateRange(payload: JsonObject) {
const rawFrom = payload.dateFrom ?? payload.date_from
const rawTo = payload.dateTo ?? payload.date_to
if (!rawFrom && !rawTo) return null
return resolveFinanceDateRange(
{ dateFrom: rawFrom, dateTo: rawTo },
{ defaultDays: 30, maxDays: 400 },
)
}
export async function getProductRow(id: number) {
const result = await query<ProductRow>('SELECT * FROM product_catalog WHERE id = $1 LIMIT 1', [
id,
@@ -8,6 +8,7 @@
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'
@@ -19,8 +20,9 @@ import {
} from './product-catalog-service.js'
import { syncAdminWorkOrderTemplateCostVersions } from './work-order-product-service.js'
export async function previewAdminTemplateProductBootstrap() {
const data = await loadTemplateProductBootstrapData()
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 candidates = plan.candidates.filter(
@@ -33,6 +35,8 @@ export async function previewAdminTemplateProductBootstrap() {
const usedRuleIds = new Set(data.evidenceRows.map((row) => Number(row.product_rule_id)))
const candidatesByMatchType = countMappingCandidates(candidates)
return {
dateFrom: range.fromDate,
dateTo: range.toDate,
templateCount: data.templates.length,
productExistingCount: data.templates.length - data.productsToCreate.length,
productCreateCount: data.productsToCreate.length,
@@ -75,10 +79,11 @@ export async function applyAdminTemplateProductBootstrap(
_session?: AdminSession,
) {
const repair = payload.repair !== false
const range = resolveBootstrapDateRange(payload)
const resetResult = repair
? await resetImportedTemplateProductBootstrap()
? await resetImportedTemplateProductBootstrap(range)
: { deletedMappingCount: 0, resetOrderItemCount: 0 }
const data = await loadTemplateProductBootstrapData()
const data = await loadTemplateProductBootstrapData(range)
if (data.templates.length === 0) {
throw createHttpError('没有可导入的接单模板', {
statusCode: 400,
@@ -185,12 +190,24 @@ export async function applyAdminTemplateProductBootstrap(
})
// 名称/系列键必须先于映射回填;所有批次都放在事务外,避免语句超时。
await backfillAdminNormalizedQuantities({ limit: 50000, all: true })
const mappingBackfill = await backfillAdminProductMappings({ limit: 20000, all: true })
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: data.templates.length,
createdProductCount,
reusedProductCount,
@@ -556,7 +573,7 @@ function compareMappingCandidates(left: TemplateMappingCandidate, right: Templat
)
}
async function resetImportedTemplateProductBootstrap() {
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 }>(
@@ -589,6 +606,8 @@ async function resetImportedTemplateProductBootstrap() {
)
)
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
@@ -598,21 +617,31 @@ async function resetImportedTemplateProductBootstrap() {
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
WHERE source = 'import'
AND note LIKE '接单模板 %自动初始化%'
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() {
async function loadTemplateProductBootstrapData(range: { fromDate: string; toDate: string }) {
const [templatesResult, evidenceResult, productsResult, mappingsResult] = await Promise.all([
query<TemplateBootstrapTemplateRow>(
`
@@ -656,6 +685,8 @@ async function loadTemplateProductBootstrapData() {
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,
@@ -665,6 +696,7 @@ async function loadTemplateProductBootstrapData() {
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',
@@ -681,7 +713,9 @@ async function loadTemplateProductBootstrapData() {
),
])
const templates = templatesResult.rows
const usedRuleIds = new Set(evidenceResult.rows.map((row) => Number(row.product_rule_id)))
// 只初始化时间范围内真正有工单证据的模板,避免把很早以前、已不再使用的模板商品重新带出来。
const templates = templatesResult.rows.filter((row) => usedRuleIds.has(Number(row.id)))
const productByCanonicalSku = new Map(
productsResult.rows.map((row) => [row.canonical_sku, Number(row.id)]),
)
@@ -737,6 +771,14 @@ export function inferTemplateUnitName(productName: string) {
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')
@@ -2,6 +2,7 @@
* 接单平台(work_order)商品回填与模板发单金额同步。
*/
import { query } from '../../db/client.js'
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
import { rebuildFactsForRange } from './affiliate-product-service.js'
import { WORK_ORDER_TEMPLATE_COST_BASELINE } from './cost-center-shared.js'
@@ -14,6 +15,14 @@ type WorkOrderSkuBackfillRow = {
const WORK_ORDER_BACKFILL_BATCH_SIZE = 20000
function resolveWorkOrderRange(payload: Record<string, unknown>) {
const range = resolveFinanceDateRange(
{ dateFrom: payload.dateFrom, dateTo: payload.dateTo },
{ defaultDays: 30, maxDays: 400 },
)
return { fromDate: range.fromDate, toDate: range.toDate }
}
/**
* 回填接单平台订单项的标准商品。
*
@@ -21,8 +30,9 @@ const WORK_ORDER_BACKFILL_BATCH_SIZE = 20000
* 仍无法归一的长尾项,再按 `provider + shop_id + sku_code + normalized_sku_name`
* 补建 `wo:` 商品和 `sku_code_name` 渠道映射,保证后续同类订单能自动命中。
*/
export async function backfillAdminWorkOrderProductSkus() {
export async function backfillAdminWorkOrderProductSkus(payload: Record<string, unknown> = {}) {
const now = nowIso()
const range = resolveWorkOrderRange(payload)
// order_item_id 有唯一索引,这里可以直接 count,避免 DISTINCT ON 被
// 三个子查询反复物化后撞 15 秒语句超时。
const result = await query<WorkOrderSkuBackfillRow>(
@@ -41,7 +51,10 @@ export async function backfillAdminWorkOrderProductSkus() {
JOIN order_items oi ON oi.id = wo.order_item_id
JOIN orders o ON o.id = oi.order_id
WHERE wo.order_item_id IS NOT NULL
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
`,
[range.fromDate, range.toDate],
)
// 单条 UPDATE 覆盖十几万行会撞 15 秒语句超时;按订单项 id 分批,
@@ -57,10 +70,14 @@ export async function backfillAdminWorkOrderProductSkus() {
wo.order_item_id AS id,
wo.product_rule_id
FROM work_orders wo
JOIN order_items oi ON oi.id = wo.order_item_id
JOIN orders o ON o.id = oi.order_id
WHERE wo.order_item_id IS NOT NULL
AND wo.order_item_id > $1
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
AND wo.order_item_id > $3
ORDER BY wo.order_item_id, wo.id DESC
LIMIT $2
LIMIT $4
)
SELECT woi.id, p.id AS canonical_product_id
FROM work_order_item woi
@@ -68,7 +85,7 @@ export async function backfillAdminWorkOrderProductSkus() {
JOIN product_catalog p ON p.canonical_sku = 'tpl:' || wpr.rule_key
ORDER BY woi.id
`,
[lastRuleItemId, WORK_ORDER_BACKFILL_BATCH_SIZE],
[range.fromDate, range.toDate, lastRuleItemId, WORK_ORDER_BACKFILL_BATCH_SIZE],
)
if (targetRows.rows.length === 0) break
const lastSelectedId = Number(targetRows.rows[targetRows.rows.length - 1]?.id || 0)
@@ -103,8 +120,11 @@ export async function backfillAdminWorkOrderProductSkus() {
oi.normalized_sku_name AS name,
MIN(oi.sku_name) AS sample_name
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.canonical_product_id IS NULL
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $2::date AND $3::date
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
GROUP BY 1
),
@@ -138,7 +158,7 @@ export async function backfillAdminWorkOrderProductSkus() {
SET updated_at = $1
RETURNING (xmax = 0) AS inserted
`,
[now],
[now, range.fromDate, range.toDate],
)
const tailMappings = await query<{ id: number }>(
@@ -154,6 +174,8 @@ export async function backfillAdminWorkOrderProductSkus() {
JOIN orders o ON o.id = oi.order_id
WHERE oi.canonical_product_id IS NULL
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $2::date AND $3::date
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
GROUP BY 1, 2, 3, 5
),
@@ -183,7 +205,7 @@ export async function backfillAdminWorkOrderProductSkus() {
) DO NOTHING
RETURNING id
`,
[now],
[now, range.fromDate, range.toDate],
)
let lastTailItemId = 0
@@ -194,12 +216,15 @@ export async function backfillAdminWorkOrderProductSkus() {
WITH tail AS (
SELECT oi.id, oi.normalized_sku_name
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.canonical_product_id IS NULL
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $2::date AND $3::date
AND oi.id > $1
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
ORDER BY oi.id
LIMIT $2
LIMIT $4
)
SELECT t.id, p.id AS canonical_product_id
FROM tail t
@@ -212,7 +237,7 @@ export async function backfillAdminWorkOrderProductSkus() {
) p ON TRUE
ORDER BY t.id
`,
[lastTailItemId, WORK_ORDER_BACKFILL_BATCH_SIZE],
[lastTailItemId, range.fromDate, range.toDate, WORK_ORDER_BACKFILL_BATCH_SIZE],
)
if (tailRows.rows.length === 0) break
const lastSelectedId = Number(tailRows.rows[tailRows.rows.length - 1]?.id || 0)
@@ -248,9 +273,13 @@ export async function backfillAdminWorkOrderProductSkus() {
`
SELECT COUNT(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.canonical_product_id IS NULL
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)
`,
[range.fromDate, range.toDate],
)
const remainingCount = Number(remainingResult.rows[0]?.total || 0)
const matchedCount = Math.max(0, workOrderItemCount - remainingCount)
@@ -35,7 +35,9 @@ import type { TableColumnsType } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import type { Dayjs } from 'dayjs'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs from '@/lib/dayjs'
import {
applyAdminTemplateProductBootstrap,
@@ -68,6 +70,11 @@ export function ProductCatalogPanel() {
const queryClient = useQueryClient()
const { canConfig, productSourceFilter, refreshAll } = useCostCenter()
const [productForm] = Form.useForm<ProductFormValues>()
// 初始化/回填只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
const [backfillRange, setBackfillRange] = useState<[Dayjs, Dayjs]>([
dayjs().subtract(29, 'day').startOf('day'),
dayjs().endOf('day'),
])
const [productKeyword, setProductKeyword] = useState('')
const [productRootCategoryId, setProductRootCategoryId] = useState<number | null>(null)
@@ -133,23 +140,35 @@ export function ProductCatalogPanel() {
: null
const productSubCategories = selectedProductRoot?.children || []
function backfillPayload() {
return {
dateFrom: backfillRange[0].format('YYYY-MM-DD'),
dateTo: backfillRange[1].format('YYYY-MM-DD'),
}
}
async function runTemplateBootstrap() {
setBootstrapPreviewing(true)
try {
const response = await previewAdminTemplateProductBootstrap()
const response = await previewAdminTemplateProductBootstrap(backfillPayload())
const data = response.data
modal.confirm({
title: '从接单模板初始化商品与映射',
width: 720,
content: (
<Space direction="vertical" size={8}>
<Alert
type="info"
showIcon
message={`统计范围:${data.dateFrom} ~ ${data.dateTo}(默认最近 30 天)`}
/>
<span>
启用接单模板 <strong>{data.templateCount}</strong> 个:将新建{' '}
<strong>{data.productCreateCount}</strong> 个标准商品,复用{' '}
<strong>{data.productExistingCount}</strong> 个已有商品。
</span>
<span>
根据历史工单中已命中的模板,将建立 <strong>{data.mappingCreateCount}</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>{' '}
@@ -157,10 +176,11 @@ export function ProductCatalogPanel() {
</span>
<span>
预计可归一 <strong>{data.orderItemCoverCount}</strong> / {data.evidenceItemCount}{' '}
个有工单证据的订单项。
个范围内有工单证据的订单项。
</span>
<span>
<strong>{data.templatesWithoutWorkOrders}</strong> 个模板暂无历史工单,仍会先建商品。
<strong>{data.templatesWithoutWorkOrders}</strong>{' '}
个启用模板在范围内没有工单证据,本次不会创建。
</span>
{data.mappingConflictCount > 0 ? (
<Alert
@@ -186,7 +206,7 @@ export function ProductCatalogPanel() {
<Alert
type="success"
showIcon
message="所有可判定的历史证据都能唯一归属,没有需要人工处理的 SKU 冲突。"
message="范围内可判定的工单证据都能唯一归属,没有需要人工处理的 SKU 冲突。"
/>
)}
<Typography.Text type="secondary">
@@ -200,7 +220,7 @@ export function ProductCatalogPanel() {
onOk: async () => {
setBootstrapApplying(true)
try {
const result = await applyAdminTemplateProductBootstrap()
const result = await applyAdminTemplateProductBootstrap(backfillPayload())
const applied = result.data
const rebuildText =
applied.deletedImportedMappingCount > 0
@@ -232,7 +252,7 @@ export function ProductCatalogPanel() {
async function backfillWorkOrderProducts() {
setBackfillingWorkOrderSkus(true)
try {
const response = await backfillAdminWorkOrderProductSkus()
const response = await backfillAdminWorkOrderProductSkus(backfillPayload())
const result = response.data
const factsText = result.factsRebuild
? `;已重算 ${result.factsRebuild.fromDate} ~ ${result.factsRebuild.toDate} 的销售事实`
@@ -259,7 +279,7 @@ export function ProductCatalogPanel() {
async function normalizeManualProductCatalog() {
setNormalizingManualProducts(true)
try {
const preview = await normalizeAdminManualProducts({ dryRun: true })
const preview = await normalizeAdminManualProducts({ dryRun: true, ...backfillPayload() })
const data = preview.data
if (data.distinctSkuCount === 0) {
message.success('没有待归一的人工订单项')
@@ -283,7 +303,7 @@ export function ProductCatalogPanel() {
okText: '开始归一',
cancelText: '取消',
onOk: async () => {
const response = await normalizeAdminManualProducts({})
const response = await normalizeAdminManualProducts(backfillPayload())
const result = response.data
message.success(
`已新建 ${result.createdProductCount} 个商品、归一 ${result.updatedItemCount} 个订单项${
@@ -714,6 +734,17 @@ export function ProductCatalogPanel() {
extra={
canConfig ? (
<Space size={6} wrap>
{!productSourceFilter ||
productSourceFilter === 'work_order' ||
productSourceFilter === 'manual' ? (
<AdminRangePicker
value={backfillRange}
onChange={(value) => {
if (value?.[0] && value?.[1]) setBackfillRange([value[0], value[1]])
}}
allowClear={false}
/>
) : null}
{productSourceFilter === 'affiliate_dash' ? (
<>
<Button
@@ -41,16 +41,23 @@ export function fetchAdminCostCenterOptions() {
return apiGet<CostCenterOptions>('/api/v1/admin/cost-center/options')
}
export function previewAdminTemplateProductBootstrap() {
export function previewAdminTemplateProductBootstrap(payload?: {
dateFrom?: string
dateTo?: string
}) {
return apiGet<TemplateProductBootstrapPreview>(
'/api/v1/admin/cost-center/template-bootstrap/preview',
payload,
)
}
export function applyAdminTemplateProductBootstrap() {
export function applyAdminTemplateProductBootstrap(payload?: {
dateFrom?: string
dateTo?: string
}) {
return apiPost<TemplateProductBootstrapResult>(
'/api/v1/admin/cost-center/template-bootstrap/apply',
{ repair: true },
{ repair: true, ...payload },
{ timeout: 300_000 },
)
}
@@ -66,7 +73,10 @@ export function syncAdminWorkOrderTemplateCostVersions() {
}>('/api/v1/admin/cost-center/work-order-template-costs/sync')
}
export function backfillAdminWorkOrderProductSkus() {
export function backfillAdminWorkOrderProductSkus(payload?: {
dateFrom?: string
dateTo?: string
}) {
return apiPost<{
updatedCount: number
skippedCount: number
@@ -84,7 +94,7 @@ export function backfillAdminWorkOrderProductSkus() {
insertedCount: number
batchCount: number
} | null
}>('/api/v1/admin/cost-center/work-order-products/backfill')
}>('/api/v1/admin/cost-center/work-order-products/backfill', payload || {})
}
export function fetchAdminProductCatalog(params?: Record<string, unknown>) {
@@ -173,6 +173,8 @@ export interface CostCenterOptions {
}
export interface TemplateProductBootstrapPreview {
dateFrom: string
dateTo: string
templateCount: number
productExistingCount: number
productCreateCount: number
+3 -3
View File
@@ -170,14 +170,14 @@ normalized_sku_series -- 剥离数量并归一同义字后的名称系列
662 个接单模板不允许逐个手工建商品。后台「从接单模板初始化」按以下规则批量生成:
1. **每个启用模板生成 1 个标准商品**,内部编码 `tpl:<rule_key>`;暂无历史工单的模板也先建商品。
2. **历史工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。
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`;人工映射、成本版本、快照和真实订单/工单不删除。
8. 重跑初始化只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单不删除。
本地验证结果(2026-10-18):启用模板 662 个;分层映射 4,426 条(编码精确 2,302、编码 + 精确名称 440、编码 + 名称系列 160、名称系列兜底 1,524);可唯一归一 86,533 个有证据订单项,另有 25,918 个订单项属于 32 组真实冲突,保持未映射。
@@ -75,6 +75,17 @@ JOIN admin_users au ON au.id = pca.owner_admin_user_id
ORDER BY pca.platform_key, au.username;
```
### 2.5 商品初始化/回填只处理最近 30 天
入口:商品与成本 → 标准商品。接单平台/人工客服 Tab 顶部有日期范围,默认最近 30 天:
- 「从接单模板初始化」只使用范围内工单证据;范围外模板不再先建商品;
- 「按工单回填商品」只回填范围内订单项;
- 「归一人工商品」只归一范围内订单项;
- 自动发货的同步/回填固定最近 30 天。
原因:更早历史 SKU 噪声大,且人工历史成本本身不可追,重新带出会污染渠道映射和商品目录。需要调整时在页面改日期范围,不要默认全量。
### 3. 扫描并配置人工 SKU
入口:商品与成本 → 人工 SKU 配置。