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)