fix(cost-center): 归档已被其他渠道接管的人工商品

This commit is contained in:
yml2213
2026-11-02 17:07:29 +08:00
parent 290890e848
commit 31b6d2d65f
11 changed files with 361 additions and 16 deletions
@@ -10,6 +10,7 @@ import { ensureVoucherChannelRules } from '../../services/fulfillment/channel-as
import {
archiveHistoricalManualProducts,
archiveSupersededManualProducts,
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
createAdminChannelMapping,
@@ -178,6 +179,22 @@ router.post(
}),
)
router.post(
'/cost-center/products/archive-superseded-manual',
requireAdminPermission('finance.config'),
createJsonHandler((req) => archiveSupersededManualProducts(bodyOf(req)), {
successMessage: '重复人工归一商品已归档',
errorMessage: '归档重复人工归一商品失败',
scope: '[admin/cost-center/products/archive-superseded-manual]',
audit: (_req, data) => ({
action: 'cost_superseded_manual_products_archived',
targetType: 'product_catalog',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
router.patch(
'/cost-center/products/:productId',
requireAdminPermission('finance.config'),
@@ -34,12 +34,27 @@ export function productSourceOf(canonicalSku: string): string {
}
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string {
export function productSourceFilterSql(
paramIndex: number,
column = 'p.canonical_sku',
nameColumn = 'p.canonical_name',
): string {
return `(
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%')
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
OR (
$${paramIndex} = 'manual'
AND ${column} LIKE 'man:%'
AND NOT EXISTS (
SELECT 1
FROM internal_sku_channels live_channel
WHERE live_channel.enabled = TRUE
AND live_channel.is_fallback = FALSE
AND live_channel.channel <> 'manual'
AND BTRIM(live_channel.internal_sku) = BTRIM(${nameColumn})
)
)
)`
}
@@ -121,6 +136,7 @@ export function mapProduct(row: ProductRow) {
row.template_sharing_enabled === null || row.template_sharing_enabled === undefined
? null
: row.template_sharing_enabled === true,
activePrimaryChannel: row.active_primary_channel || null,
affiliateTaskCount: Number(row.affiliate_task_count || 0),
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
createdAt: row.created_at,
@@ -42,7 +42,18 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
OR ($1 = 'affiliate_dash' AND p.canonical_sku LIKE 'aff:%')
OR ($1 = 'work_order' AND (p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%'))
OR ($1 = 'third_party_voucher' AND p.canonical_sku LIKE 'tpv:%')
OR ($1 = 'manual' AND p.canonical_sku LIKE 'man:%')
OR (
$1 = 'manual'
AND p.canonical_sku LIKE 'man:%'
AND NOT EXISTS (
SELECT 1
FROM internal_sku_channels live_channel
WHERE live_channel.enabled = TRUE
AND live_channel.is_fallback = FALSE
AND live_channel.channel <> 'manual'
AND BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)
)
)
)
),
scoped_cost_models AS (
@@ -99,11 +110,21 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
)
const row = result.rows[0]
const [productSourceResult, costModelResult] = await Promise.all([
query<{ canonical_sku: string; total: number }>(
`SELECT canonical_sku, COUNT(*)::int AS total
FROM product_catalog
WHERE status = 'active'
GROUP BY canonical_sku`,
query<{ canonical_sku: string; active_primary_channel: string | null; total: number }>(
`SELECT p.canonical_sku,
(
SELECT c.channel
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) AS active_primary_channel,
COUNT(*)::int AS total
FROM product_catalog p
WHERE p.status = 'active'
GROUP BY p.canonical_sku, p.canonical_name`,
),
query<{ cost_model: string; total: number }>(
`SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`,
@@ -137,7 +158,9 @@ function normalizeScope(raw: unknown): CostCenterScope | null {
}
/** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */
function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: number }>) {
function productSourceCountsOf(
rows: Array<{ canonical_sku: string; active_primary_channel: string | null; total: number }>,
) {
const counts: Record<string, number> = {
affiliate_dash: 0,
work_order: 0,
@@ -146,6 +169,14 @@ function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: numbe
}
for (const row of rows) {
const source = productSourceOf(row.canonical_sku)
// man: 只是旧来源;已有其他主渠道的商品不再计入当前人工客服。
if (
source === 'manual' &&
row.active_primary_channel &&
row.active_primary_channel !== 'manual'
) {
continue
}
const key = source || 'uncategorized'
counts[key] = (counts[key] || 0) + toNumber(row.total)
}
@@ -29,6 +29,7 @@ export type ProductRow = {
template_pricing_mode?: string | null
template_enabled?: boolean | null
template_sharing_enabled?: boolean | null
active_primary_channel?: string | null
affiliate_task_count?: number
affiliate_last_fulfilled_at?: string | null
}
@@ -291,6 +291,13 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => {
assert.ok(productSourceFilterSql(1).includes("'tpv:%'"))
})
test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => {
const sql = productSourceFilterSql(1)
assert.ok(sql.includes(`$1 = 'manual'`))
assert.ok(sql.includes("live_channel.channel <> 'manual'"))
assert.ok(sql.includes('BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)'))
})
test('tpv: 商品来源归为第三方卡券', async () => {
const { productSourceOf } = await import('./cost-center-mappers.js')
const assert = (await import('node:assert/strict')).default
@@ -0,0 +1,84 @@
/** 旧人工归一商品清理集成测试(真实 PostgreSQL)。 */
import assert from 'node:assert/strict'
import test from 'node:test'
const TEST_DATABASE_URL = String(
process.env.LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL || '',
).trim()
test(
'已被其他主渠道接管的历史 man 商品只归档并修正兜底指向',
{ skip: !TEST_DATABASE_URL ? '未设置 LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { closeDb, query } = await import('../../db/client.js')
const { archiveSupersededManualProducts } = await import('./product-catalog-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const internalSku = `清理测试人工商品-${suffix}`
const manSku = `man:${internalSku}`
const liveSku = `aff:cleanup-${suffix}`
let manProductId = 0
let liveProductId = 0
try {
const inserted = await query<{ id: number; canonical_sku: string }>(
`
INSERT INTO product_catalog (
canonical_sku, canonical_name, category, unit_name, status,
attributes_json, created_at, updated_at
) VALUES
($1, $3, '人工客服', '件', 'active', '{"source":"manual_normalization"}'::jsonb, NOW(), NOW()),
($2, $3, '自动发货', '件', 'active', '{}'::jsonb, NOW(), NOW())
RETURNING id, canonical_sku
`,
[manSku, liveSku, internalSku],
)
manProductId = Number(inserted.rows.find((row) => row.canonical_sku === manSku)?.id)
liveProductId = Number(inserted.rows.find((row) => row.canonical_sku === liveSku)?.id)
assert.ok(manProductId > 0)
assert.ok(liveProductId > 0)
await query(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, created_at, updated_at
) VALUES
($1, 'affiliate_dash', 10, FALSE, TRUE, $3, '{}'::jsonb, 'test', NOW(), NOW()),
($1, 'manual', 90, TRUE, TRUE, $2, '{}'::jsonb, 'test', NOW(), NOW())
`,
[internalSku, manProductId, liveProductId],
)
const preview = await archiveSupersededManualProducts()
assert.equal(preview.candidateCount, 1)
assert.ok(preview.sample.some((row) => Number(row.id) === manProductId))
const result = await archiveSupersededManualProducts({ execute: true })
assert.equal(result.archivedCount, 1)
assert.equal(result.repointedChannelCount, 1)
const products = await query<{ canonical_sku: string; status: string }>(
'SELECT canonical_sku, status FROM product_catalog WHERE id = ANY($1::bigint[])',
[[manProductId, liveProductId]],
)
assert.equal(products.rows.find((row) => row.canonical_sku === manSku)?.status, 'archived')
assert.equal(products.rows.find((row) => row.canonical_sku === liveSku)?.status, 'active')
const fallback = await query<{ canonical_product_id: number }>(
`SELECT canonical_product_id
FROM internal_sku_channels
WHERE internal_sku = $1 AND channel = 'manual'`,
[internalSku],
)
assert.equal(Number(fallback.rows[0]?.canonical_product_id), liveProductId)
} finally {
if (manProductId > 0 || liveProductId > 0) {
await query('DELETE FROM internal_sku_channels WHERE internal_sku = $1', [internalSku])
await query('DELETE FROM product_catalog WHERE id = ANY($1::bigint[])', [
[manProductId, liveProductId],
])
}
await closeDb()
}
},
)
@@ -160,6 +160,15 @@ export async function listAdminProducts(payload: JsonObject = {}) {
END AS template_pricing_mode,
tpl_rule.enabled AS template_enabled,
tpl_rule.sharing_enabled AS template_sharing_enabled,
(
SELECT c.channel
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) AS active_primary_channel,
CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id,
CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name,
root.id AS root_category_id,
@@ -481,6 +490,114 @@ export async function archiveHistoricalManualProducts(payload: JsonObject = {})
}
}
type SupersededManualProductRow = {
id: number
canonical_sku: string
canonical_name: string
primary_channel: string
target_product_id: number
target_product_sku: string
}
/**
* 归档已被其他渠道主规则接管的旧人工归一商品。
*
* `man:` 只表示旧人工来源,不代表当前主渠道。同名内部 SKU 已有自动发货、
* 接单平台或卡券主渠道后,旧 man: 商品不再参与新路由,只需从当前目录归档。
* 历史订单、成本版本和成本快照全部保留;仍指向旧商品的手动兜底改指当前主商品。
*/
export async function archiveSupersededManualProducts(payload: JsonObject = {}) {
const execute = payload.execute === true || payload.execute === 'true'
return withTransaction(async (client) => {
const candidates = await client.query<SupersededManualProductRow>(
`
SELECT
p.id,
p.canonical_sku,
p.canonical_name,
live.channel AS primary_channel,
target.id AS target_product_id,
target.canonical_sku AS target_product_sku
FROM product_catalog p
JOIN LATERAL (
SELECT c.channel, c.canonical_product_id
FROM internal_sku_channels c
WHERE c.enabled = TRUE
AND c.is_fallback = FALSE
AND c.channel <> 'manual'
AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name)
ORDER BY c.priority, c.channel
LIMIT 1
) live ON TRUE
JOIN product_catalog target
ON target.id = live.canonical_product_id
AND target.status = 'active'
WHERE p.status = 'active'
AND p.canonical_sku LIKE 'man:%'
AND p.attributes_json->>'source' = 'manual_normalization'
ORDER BY p.id
FOR UPDATE OF p
`,
)
const rows = candidates.rows.map((row) => ({
...row,
id: Number(row.id),
target_product_id: Number(row.target_product_id),
}))
if (!execute || rows.length === 0) {
return {
dryRun: !execute,
candidateCount: rows.length,
archivedCount: 0,
repointedChannelCount: 0,
sample: rows.slice(0, 20),
}
}
const repointed = await client.query(
`
UPDATE internal_sku_channels c
SET canonical_product_id = input.target_product_id,
updated_at = NOW()
FROM unnest($1::text[], $2::bigint[], $3::bigint[])
AS input(internal_sku, source_product_id, target_product_id)
WHERE c.internal_sku = input.internal_sku
AND c.channel = 'manual'
AND c.canonical_product_id = input.source_product_id
`,
[
rows.map((row) => row.canonical_name),
rows.map((row) => row.id),
rows.map((row) => row.target_product_id),
],
)
const archived = await client.query(
`
UPDATE product_catalog
SET status = 'archived',
attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb,
updated_at = NOW()
WHERE id = ANY($2::bigint[]) AND status = 'active'
`,
[
JSON.stringify({
legacyManualArchived: true,
legacyManualArchiveReason: 'superseded_by_active_primary_channel',
legacyManualArchivedAt: nowIso(),
}),
rows.map((row) => row.id),
],
)
return {
dryRun: false,
candidateCount: rows.length,
archivedCount: archived.rowCount || 0,
repointedChannelCount: repointed.rowCount || 0,
sample: rows.slice(0, 20),
}
})
}
type MappingProductGroupRow = {
canonical_product_id: number
canonical_sku: string