feat(cost-center): 商品初始化/回填增加时间范围,默认最近 30 天
- 接单模板初始化预览/执行支持 dateFrom/dateTo,只使用范围内工单证据; 范围外模板不再先建商品 - 按工单回填商品支持时间范围;人工商品归一 UI 传时间范围 - 模板初始化重置只清理范围内导入映射/订单项,避免误伤范围外数据 - 初始化/回填默认最近 30 天,自动发货仍固定 30 天 - 文档更新初始化口径与页面操作
This commit is contained in:
@@ -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
@@ -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 配置。
|
||||
|
||||
Reference in New Issue
Block a user