refactor(cost-center): 收敛人工 SKU 自动创建流程

This commit is contained in:
yml2213
2026-11-06 16:37:29 +08:00
parent eef4cc1673
commit 974044a4c6
17 changed files with 601 additions and 731 deletions
@@ -1,7 +1,7 @@
/**
* 成本中心 · 商品与映射路由。
*
* 覆盖:接单模板初始化、自动发货/接单平台商品同步与回填、人工商品归一、
* 覆盖:接单模板初始化、自动发货/接单平台商品同步与回填、人工商品自动投影、
* 标准商品 CRUD、渠道映射 CRUD、未归一队列、归一数量回填。
*/
import { Router } from 'express'
@@ -9,8 +9,6 @@ import { Router } from 'express'
import { ensureVoucherChannelRules } from '../../services/fulfillment/channel-assign-service.js'
import {
archiveHistoricalManualProducts,
archiveSupersededManualProducts,
backfillAdminNormalizedQuantities,
backfillAdminProductMappings,
createAdminChannelMapping,
@@ -164,38 +162,6 @@ router.post(
}),
)
router.post(
'/cost-center/products/archive-historical-manual',
requireAdminPermission('finance.config'),
createJsonHandler((req) => archiveHistoricalManualProducts(bodyOf(req)), {
successMessage: '历史人工商品已归档',
errorMessage: '归档历史人工商品失败',
scope: '[admin/cost-center/products/archive-historical-manual]',
audit: (_req, data) => ({
action: 'cost_historical_manual_products_archived',
targetType: 'product_catalog',
targetId: '',
data: data as Record<string, unknown>,
}),
}),
)
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,28 +34,12 @@ export function productSourceOf(canonicalSku: string): string {
}
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
export function productSourceFilterSql(
paramIndex: number,
column = 'p.canonical_sku',
nameColumn = 'p.canonical_name',
): string {
export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): 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:%'
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 canonicalize_internal_sku(live_channel.internal_sku)
= canonicalize_internal_sku(${nameColumn})
)
)
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
)`
}
@@ -6,6 +6,7 @@
* 2. 成本项统计按 cost_model 分组,不再读 cost_items.carrier_type。
*/
import { query } from '../../db/client.js'
import { productSourceFilterSql } from './cost-center-mappers.js'
import { PROVIDERS, productSourceOf, toNumber } from './cost-center-shared.js'
export type CostCenterScope = 'affiliate_dash' | 'work_order' | 'manual'
@@ -37,25 +38,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
SELECT p.id
FROM product_catalog p
WHERE p.status = 'active'
AND (
$1::text IS NULL
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:%'
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 canonicalize_internal_sku(live_channel.internal_sku)
= canonicalize_internal_sku(p.canonical_name)
)
)
)
AND ($1::text IS NULL OR ${productSourceFilterSql(1)})
),
scoped_cost_models AS (
SELECT CASE $1
@@ -8,7 +8,7 @@ import {
resolveMappingNameKey,
} from './cost-center-shared.js'
import { calculateCostTotal, validateCostVersionScope } from './cost-resolution-service.js'
import { resolveMappingProductId } from './product-catalog-service.js'
import { createAdminProduct, resolveMappingProductId } from './product-catalog-service.js'
import { productSourceFilterSql } from './cost-center-mappers.js'
import {
buildTemplateMappingPlan,
@@ -310,14 +310,11 @@ test('第三方卡券删除:无历史引用硬删除,有历史引用自动
assert.ok(routeSource.includes("'/cost-center/voucher-products/:productId'"))
})
test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => {
test('人工客服筛选保留所有可维护的 man 商品和显式兜底配置', () => {
const sql = productSourceFilterSql(1)
assert.ok(sql.includes(`$1 = 'manual'`))
assert.ok(sql.includes("live_channel.channel <> 'manual'"))
assert.ok(
sql.includes('canonicalize_internal_sku(live_channel.internal_sku)') &&
sql.includes('canonicalize_internal_sku(p.canonical_name)'),
)
assert.ok(sql.includes("LIKE 'man:%'"))
assert.ok(!sql.includes('live_channel'))
})
test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => {
@@ -328,6 +325,7 @@ test('成本中心总览异常统计必须走异常表关联,不能全表扫
)
assert.ok(source.includes('JOIN order_items oi ON oi.id = ce.order_item_id'))
assert.ok(!source.includes('ce.order_item_id IN ('))
assert.ok(source.includes('productSourceFilterSql(1)'))
})
test('tpv: 商品来源归为第三方卡券', async () => {
@@ -336,3 +334,16 @@ test('tpv: 商品来源归为第三方卡券', async () => {
assert.equal(productSourceOf('tpv:模拟卡券'), 'third_party_voucher')
assert.equal(productSourceOf('man:模拟人工'), 'manual')
})
test('人工标准商品禁止绕过人工 SKU 配置手工创建', async () => {
await assert.rejects(
createAdminProduct(
{
canonicalSku: 'man:破片手榴弹-兔兔白日梦',
canonicalName: '破片手榴弹-兔兔白日梦',
},
{} as never,
),
/人工 SKU 配置自动创建/,
)
})
@@ -1,84 +0,0 @@
/** 旧人工归一商品清理集成测试(真实 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()
}
},
)
@@ -1,10 +1,15 @@
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import { resolveExceptionsForItemsByReason } from './cost-exception-service.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
import { nowIso } from '../../utils/time.js'
import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js'
import {
upsertInternalSkuChannel,
upsertInternalSkuChannelWithClient,
} from '../fulfillment/internal-sku-channel-service.js'
import { reconcilePendingCarriers } from '../fulfillment/carrier-reconciliation-service.js'
import {
attachFulfillmentConfigurations,
@@ -543,14 +548,14 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
now,
],
)
return requiredRow(result.rows[0], '保存人工履约配置失败')
const saved = requiredRow(result.rows[0], '保存人工履约配置失败')
await syncRegistryConfigId(saved.sku_key, saved.id, client)
await upsertManualRegistryConfig(saved, client)
// 标准商品、人工配置和渠道规则必须一起提交,避免保存成功后仍未生效。
await syncManualChannelRule(saved, client)
return saved
})
await syncRegistryConfigId(config.sku_key, config.id)
await upsertManualRegistryConfig(config)
// 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明,
// 少了这一步,新配的 SKU 会一直停在待分类。
await syncManualChannelRule(config)
// 配置补全后,失败的 pending 会因 locked=true 无法被定时任务重试,这里主动解锁并重试。
const pendingAssigned = await retryPendingManualItems(config.sku_key)
// 配置保存后立刻回填该 SKU 最近 30 天的订单项,避免必须再点一次「重新匹配」。
@@ -938,26 +943,33 @@ async function attachCostVersionsForManualConfigs() {
}
/** 配置保存/删除后同步登记表上的 config_id。 */
async function syncRegistryConfigId(skuKey: string, configId: number | null) {
await query(
`UPDATE manual_sku_registry
SET config_id = $2, updated_at = $3
WHERE canonicalize_internal_sku(sku_key) = $1`,
[canonicalizeInternalSku(skuKey), configId, nowIso()],
)
async function syncRegistryConfigId(skuKey: string, configId: number | null, client?: PoolClient) {
const sql = `
UPDATE manual_sku_registry
SET config_id = $2, updated_at = $3
WHERE canonicalize_internal_sku(sku_key) = $1
`
const params = [canonicalizeInternalSku(skuKey), configId, nowIso()]
if (client) {
await client.query(sql, params)
return
}
await query(sql, params)
}
/** 配置保存后确保登记表仍有同一 SKU 的记录,兼容旧扫描清单和运维查询。 */
async function upsertManualRegistryConfig(config: {
id: number
sku_key: string
canonical_product_id: number | null
}) {
async function upsertManualRegistryConfig(
config: {
id: number
sku_key: string
canonical_product_id: number | null
},
client?: PoolClient,
) {
const key = canonicalizeInternalSku(config.sku_key)
if (!key) return
const now = nowIso()
await query(
`
const sql = `
INSERT INTO manual_sku_registry (
sku_key, display_name, config_id, created_at, updated_at
)
@@ -976,9 +988,13 @@ async function upsertManualRegistryConfig(config: {
END,
config_id = EXCLUDED.config_id,
updated_at = EXCLUDED.updated_at
`,
[key, config.id, now, config.canonical_product_id],
)
`
const params = [key, config.id, now, config.canonical_product_id]
if (client) {
await client.query(sql, params)
return
}
await query(sql, params)
}
/** 读取单个 SKU 的配置详情(含平台标签与成本折算)。 */
@@ -1299,29 +1315,32 @@ function requiredRow<T>(row: T | undefined, message: string): T {
/**
* 人工配置 → internal_sku_channels(内部 SKU 精确匹配)。
*/
async function syncManualChannelRule(config: {
id: number
sku_key: string
canonical_product_id: number | null
enabled: boolean
note: string
}): Promise<void> {
async function syncManualChannelRule(
config: {
id: number
sku_key: string
canonical_product_id: number | null
enabled: boolean
note: string
},
client?: PoolClient,
): Promise<void> {
const skuKey = String(config.sku_key || '').trim()
if (!skuKey) return
const otherChannel = await query<{ exists: boolean }>(
`
const otherChannelSql = `
SELECT EXISTS (
SELECT 1 FROM internal_sku_channels
WHERE canonicalize_internal_sku(internal_sku) = $1
AND enabled = TRUE
AND channel <> 'manual'
) AS exists
`,
[skuKey],
)
`
const otherChannel = client
? await client.query<{ exists: boolean }>(otherChannelSql, [skuKey])
: await query<{ exists: boolean }>(otherChannelSql, [skuKey])
const hasOtherChannel = otherChannel.rows[0]?.exists === true
await upsertInternalSkuChannel({
const channelInput = {
internalSku: skuKey,
channel: 'manual',
priority: hasOtherChannel ? 90 : 10,
@@ -1331,5 +1350,10 @@ async function syncManualChannelRule(config: {
source: 'manual_config',
configJson: { manualConfigId: config.id, note: String(config.note || '').slice(0, 200) },
replacePriority: true,
})
} as const
if (client) {
await upsertInternalSkuChannelWithClient(client, channelInput)
return
}
await upsertInternalSkuChannel(channelInput)
}
@@ -319,6 +319,12 @@ export async function listAdminProducts(payload: JsonObject = {}) {
export async function createAdminProduct(payload: JsonObject, _session: AdminSession) {
const canonicalSku = normalizeRequiredText(payload.canonicalSku, '标准 SKU', 64)
if (canonicalSku.startsWith('man:')) {
throw createHttpError('人工商品由人工 SKU 配置自动创建,请从人工 SKU 配置入口新增', {
statusCode: 409,
errorCode: 'cost_manual_product_managed_by_config',
})
}
if (!/^[A-Za-z0-9._:-]+$/.test(canonicalSku)) {
throw createHttpError('标准 SKU 只能包含字母、数字、点、下划线、冒号和短横线', {
statusCode: 400,
@@ -586,164 +592,6 @@ export async function deleteAdminVoucherProduct(productId: number | string) {
})
}
/**
* 批量归档旧人工归一商品。
*
* 只处理 attributes_json.source=manual_normalization、当前为 active,
* 且所有关联订单项都已 archived 的商品。只改 status,不删除任何数据。
*/
export async function archiveHistoricalManualProducts(payload: JsonObject = {}) {
const execute = payload.execute === true || payload.execute === 'true'
const candidates = await query<{ id: number; canonical_sku: string; canonical_name: string }>(
`
SELECT p.id, p.canonical_sku, p.canonical_name
FROM product_catalog p
WHERE p.status = 'active'
AND p.canonical_sku LIKE 'man:%'
AND p.attributes_json->>'source' = 'manual_normalization'
AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.canonical_product_id = p.id)
AND NOT EXISTS (
SELECT 1 FROM order_items oi
WHERE oi.canonical_product_id = p.id
AND oi.fulfillment_status <> 'archived'
)
ORDER BY p.id
`,
)
const ids = candidates.rows.map((row) => Number(row.id))
if (!execute || ids.length === 0) {
return {
dryRun: !execute,
candidateCount: ids.length,
archivedCount: 0,
sample: candidates.rows.slice(0, 20),
}
}
const updated = await query(
`UPDATE product_catalog
SET status = 'archived', updated_at = NOW()
WHERE id = ANY($1::bigint[]) AND status = 'active'`,
[ids],
)
return {
dryRun: false,
candidateCount: ids.length,
archivedCount: updated.rowCount || 0,
sample: candidates.rows.slice(0, 20),
}
}
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 canonicalize_internal_sku(c.internal_sku)
= canonicalize_internal_sku(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 canonicalize_internal_sku(c.internal_sku)
= canonicalize_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
@@ -115,6 +115,14 @@ test('人工确认前必须自动匹配履约配置,配置保存/定时任务
configSource.includes('dateFrom: shiftDate(today, -29)'),
'保存人工 SKU 配置后必须立即回填最近 30 天订单项',
)
const saveStart = configSource.indexOf('export async function saveManualFulfillmentConfig')
const saveEnd = configSource.indexOf('async function retryPendingManualItems', saveStart)
const saveBody = configSource.slice(saveStart, saveEnd)
assert.ok(
saveBody.indexOf('withTransaction(') <
saveBody.indexOf('await syncManualChannelRule(saved, client)'),
'标准商品、人工配置和人工渠道规则必须同一事务提交',
)
assert.ok(
jobSource.includes("fulfillment_channel = 'manual'") &&
jobSource.includes('fulfillment_config_id'),
@@ -1,3 +1,5 @@
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import type { FulfillmentChannel } from './channel-types.js'
import { canonicalizeInternalSku, compareFulfillmentChannel } from './internal-sku-resolver.js'
@@ -127,7 +129,7 @@ export function planInternalSkuChannels(input: InternalSkuChannelRow[]): Interna
}
}
export async function upsertInternalSkuChannel(input: {
type InternalSkuChannelUpsertInput = {
internalSku: string
channel: FulfillmentChannel
priority?: number
@@ -139,25 +141,29 @@ export async function upsertInternalSkuChannel(input: {
note?: string | undefined
adminUserId?: number | null | undefined
replacePriority?: boolean
}): Promise<number> {
}
async function upsertInternalSkuChannelWithExecutor(
client: Pick<PoolClient, 'query'>,
input: InternalSkuChannelUpsertInput,
): Promise<number> {
const internalSku = canonicalizeInternalSku(input.internalSku)
if (!internalSku) return 0
return withTransaction(async (client) => {
if (input.enabled !== false && input.isFallback !== true) {
await client.query(
`UPDATE internal_sku_channels
SET is_fallback = TRUE,
priority = GREATEST(priority, 90),
updated_at = NOW()
WHERE canonicalize_internal_sku(internal_sku) = $1
AND channel <> $2
AND enabled = TRUE
AND is_fallback = FALSE`,
[internalSku, input.channel],
)
}
const result = await client.query<{ id: number }>(
`
if (input.enabled !== false && input.isFallback !== true) {
await client.query(
`UPDATE internal_sku_channels
SET is_fallback = TRUE,
priority = GREATEST(priority, 90),
updated_at = NOW()
WHERE canonicalize_internal_sku(internal_sku) = $1
AND channel <> $2
AND enabled = TRUE
AND is_fallback = FALSE`,
[internalSku, input.channel],
)
}
const result = await client.query<{ id: number }>(
`
INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled,
canonical_product_id, config_json, source, note, created_by_admin_user_id,
@@ -177,23 +183,35 @@ export async function upsertInternalSkuChannel(input: {
created_by_admin_user_id = COALESCE(EXCLUDED.created_by_admin_user_id, internal_sku_channels.created_by_admin_user_id),
updated_at = NOW()
RETURNING id
`,
[
internalSku,
input.channel,
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
input.isFallback === true,
input.enabled !== false,
input.canonicalProductId ?? null,
JSON.stringify(input.configJson || {}),
String(input.source || 'auto').trim() || 'auto',
String(input.note || '').slice(0, 300),
input.adminUserId ?? null,
input.replacePriority === true,
],
)
return Number(result.rows[0]?.id || 0)
})
`,
[
internalSku,
input.channel,
Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100,
input.isFallback === true,
input.enabled !== false,
input.canonicalProductId ?? null,
JSON.stringify(input.configJson || {}),
String(input.source || 'auto').trim() || 'auto',
String(input.note || '').slice(0, 300),
input.adminUserId ?? null,
input.replacePriority === true,
],
)
return Number(result.rows[0]?.id || 0)
}
export async function upsertInternalSkuChannelWithClient(
client: PoolClient,
input: InternalSkuChannelUpsertInput,
): Promise<number> {
return upsertInternalSkuChannelWithExecutor(client, input)
}
export async function upsertInternalSkuChannel(
input: InternalSkuChannelUpsertInput,
): Promise<number> {
return withTransaction((client) => upsertInternalSkuChannelWithExecutor(client, input))
}
/**
@@ -484,7 +484,17 @@ function PendingPanel() {
保存渠道
</Button>
{canRunScopedActions && hasConfigIssue ? (
<Button size="small" onClick={() => void navigate('/admin/cost-center')}>
<Button
size="small"
disabled={!row.internal_sku}
onClick={() => {
const sku = String(row.internal_sku || '').trim()
if (!sku) return
void navigate(
`/admin/cost-center?scope=manual&tab=cost&manualSku=${encodeURIComponent(sku)}&returnTo=${encodeURIComponent('/admin/fulfillment-channel')}`,
)
}}
>
去补配置({row.config_issue_item_count})
</Button>
) : null}
@@ -23,7 +23,8 @@ import {
Typography,
} from 'antd'
import { keepPreviousData, useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import { usePersistedState } from '@/hooks/usePersistedState'
@@ -73,7 +74,11 @@ function isCostScope(value: unknown): value is CostScope {
export default function CostCenterShell() {
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
const canConfig = hasAdminPermission('finance.config')
const requestedScope = String(searchParams.get('scope') || '').trim()
const requestedTab = String(searchParams.get('tab') || '').trim()
const handledRouteRequestRef = useRef('')
const [scope, setScope] = usePersistedState<CostScope>(
'admin-cost-center:scope',
@@ -87,6 +92,20 @@ export default function CostCenterShell() {
)
const mappingsApplicable = scope === 'all' || scope === 'work_order'
useEffect(() => {
const routeRequest = `${requestedScope}\u0000${requestedTab}`
if (routeRequest === '\u0000' || handledRouteRequestRef.current === routeRequest) return
handledRouteRequestRef.current = routeRequest
if (isCostScope(requestedScope) && requestedScope !== scope) {
setScope(requestedScope)
}
const requestedTabAllowed =
isCostCenterTab(requestedTab) && (requestedScope !== 'manual' || requestedTab !== 'mappings')
if (requestedTabAllowed && requestedTab !== activeTab) {
setActiveTab(requestedTab)
}
}, [activeTab, requestedScope, requestedTab, scope, setActiveTab, setScope])
useEffect(() => {
if (!mappingsApplicable && activeTab === 'mappings') {
setActiveTab('products')
@@ -38,7 +38,8 @@ import {
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router'
import {
deleteAdminManualSku,
@@ -55,7 +56,10 @@ import {
formatCreditUnitPriceYuan,
} from '@/utils/credit-unit-price'
import { useCostCenter } from './CostCenterContext'
type ConfigFormValues = {
skuKey: string
channels: Array<{
platformKey: string
creditQuantity: number
@@ -87,8 +91,15 @@ function platformOptionLabel(platformKey: string, label: string, creditUnitPrice
export function ManualSkuConfigPanel() {
const { message } = App.useApp()
const { canConfig } = useCostCenter()
const queryClient = useQueryClient()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [form] = Form.useForm<ConfigFormValues>()
const handledDeepLinkRef = useRef('')
const deepLinkSku = String(searchParams.get('manualSku') || searchParams.get('sku') || '').trim()
const manualNewRequested = searchParams.get('manualNew') === '1'
const returnTo = String(searchParams.get('returnTo') || '').trim()
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState<'all' | 'configured' | 'unconfigured'>('unconfigured')
@@ -125,7 +136,7 @@ export function ManualSkuConfigPanel() {
const saveMutation = useMutation({
mutationFn: (values: ConfigFormValues) =>
saveAdminManualConfig({
skuKey: editing?.skuKey || '',
skuKey: String(values.skuKey || editing?.skuKey || '').trim(),
channels: values.channels.map((channel) => ({
platformKey: channel.platformKey,
creditQuantity: Number(channel.creditQuantity || 0),
@@ -139,10 +150,14 @@ export function ManualSkuConfigPanel() {
: ''
message.success(`配置已保存${syncHint}`)
setModalOpen(false)
setEditing(null)
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center'] })
void queryClient.invalidateQueries({ queryKey: ['cost-center-voucher-products'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
if (returnTo.startsWith('/admin/fulfillment-channel')) {
void navigate(returnTo)
}
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '保存失败')
@@ -180,6 +195,7 @@ export function ManualSkuConfigPanel() {
]
: [{ platformKey: '', creditQuantity: 0 }]
form.setFieldsValue({
skuKey: item.skuKey,
channels: configuredChannels.map((channel) => ({
platformKey: channel.platformKey,
creditQuantity: channel.creditQuantity,
@@ -189,6 +205,36 @@ export function ManualSkuConfigPanel() {
setModalOpen(true)
}
function openCreateConfig(skuKey = '') {
setEditing(null)
form.resetFields()
form.setFieldsValue({
skuKey: skuKey.trim(),
channels: [{ platformKey: '', creditQuantity: 0 }],
note: '',
})
setModalOpen(true)
}
useEffect(() => {
if (!canConfig) return
const deepLinkKey = deepLinkSku || (manualNewRequested ? '__new__' : '')
if (!deepLinkKey || handledDeepLinkRef.current === deepLinkKey) return
handledDeepLinkRef.current = deepLinkKey
if (deepLinkSku) {
setKeyword(deepLinkSku)
setStatus('all')
}
setEditing(null)
form.resetFields()
form.setFieldsValue({
skuKey: deepLinkSku,
channels: [{ platformKey: '', creditQuantity: 0 }],
note: '',
})
setModalOpen(true)
}, [canConfig, deepLinkSku, manualNewRequested, form])
const columns: TableColumnsType<ManualSkuItem> = [
{
title: '人工标准商品',
@@ -276,24 +322,27 @@ export function ManualSkuConfigPanel() {
key: 'actions',
width: 150,
fixed: 'right',
render: (_, row) => (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
{row.configured ? '编辑' : '配置'}
</Button>
<Popconfirm
title="归档该人工标准商品?"
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认归档"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
归档
render: (_, row) =>
canConfig ? (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openConfig(row)}>
{row.configured ? '编辑' : '配置'}
</Button>
</Popconfirm>
</Space>
),
<Popconfirm
title="归档该人工标准商品?"
description="会归档标准商品、删除人工配置与渠道规则、清除已生成的人工成本快照,并把关联订单项退回待分类;原始订单不会删除。"
okText="确认归档"
cancelText="取消"
onConfirm={() => deleteMutation.mutate(row.skuKey)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
归档
</Button>
</Popconfirm>
</Space>
) : (
<Typography.Text type="secondary">只读</Typography.Text>
),
},
]
@@ -303,13 +352,23 @@ export function ManualSkuConfigPanel() {
}
return (
<Card bordered={false} title="人工 SKU 配置">
<Card
bordered={false}
title="人工 SKU 配置"
extra={
canConfig ? (
<Button type="primary" icon={<PlusOutlined />} onClick={() => openCreateConfig()}>
新增人工 SKU
</Button>
) : null
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="人工标准商品与成本配置一一对应"
description="这里直接列出人工客服范围内的 man: 标准商品。点“配置”选择实际履约渠道并填写每单成本即可;保存后会同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
message="人工标准商品由人工配置自动创建"
description="新增时只需填写内部 SKU、实际人工渠道和每单成本,系统会自动创建或复用 man: 标准商品。保存后同步渠道规则、回填最近 30 天订单并重试待处理订单。消耗积分填 0 表示成本不固定,由客服确认时手填。"
/>
<Row gutter={12} style={{ marginBottom: 12 }}>
@@ -399,14 +458,17 @@ export function ManualSkuConfigPanel() {
<Modal
open={modalOpen}
title={`配置人工履约 · ${editing?.skuKey || ''}`}
title={editing ? `配置人工履约 · ${editing.skuKey}` : '新增人工履约 SKU'}
okText="保存配置"
cancelText="取消"
confirmLoading={saveMutation.isPending}
onOk={() => {
void form.validateFields().then((values) => saveMutation.mutate(values))
}}
onCancel={() => setModalOpen(false)}
onCancel={() => {
setModalOpen(false)
setEditing(null)
}}
destroyOnHidden
width={560}
>
@@ -414,10 +476,37 @@ export function ManualSkuConfigPanel() {
type="warning"
showIcon
style={{ marginBottom: 14 }}
message={`商品名称:${editing?.displayName || '(无)'}(仅供识别)`}
message={
editing
? `商品名称:${editing.displayName || '(无)'}(仅供识别)`
: '保存时自动创建标准商品,不需要先去商品目录手工新建'
}
description="一个 SKU 可以配置多个渠道,客服确认履约时选择本次实际渠道。积分渠道填整数积分;备用金现金和统一结算填金额,最多两位小数。填 0 表示成本不固定,由客服确认时填写。"
/>
<Form<ConfigFormValues> form={form} layout="vertical">
<Form.Item
name="skuKey"
label="内部 SKU"
rules={[
{ required: true, message: '请输入内部 SKU' },
{
validator: async (_, value) => {
const text = String(value || '').trim()
if (text && !/\p{Script=Han}/u.test(text)) {
throw new Error('内部 SKU 必须包含中文')
}
},
},
]}
extra="保存后会生成 man:<内部 SKU> 标准商品,并统一用于订单渠道判定。"
>
<Input
disabled={Boolean(editing)}
placeholder="例如:破片手榴弹-兔兔白日梦"
maxLength={60}
showCount
/>
</Form.Item>
<Form.List
name="channels"
rules={[
@@ -5,7 +5,6 @@
* aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。
*/
import {
ClearOutlined,
DeleteOutlined,
DollarOutlined,
EditOutlined,
@@ -37,13 +36,12 @@ import PercentTable from '@/components/common/PercentTable'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import type { Dayjs } from 'dayjs'
import { useNavigate } from 'react-router'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs from '@/lib/dayjs'
import {
applyAdminTemplateProductBootstrap,
archiveAdminHistoricalManualProducts,
archiveAdminSupersededManualProducts,
createAdminProductCatalog,
deleteAdminProductCatalog,
fetchAdminProductCatalog,
@@ -95,6 +93,7 @@ function DisabledTemplateSyncOption({
export function ProductCatalogPanel() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const navigate = useNavigate()
const { canConfig, productSourceFilter, refreshAll, setScope, setActiveTab } = useCostCenter()
const [productForm] = Form.useForm<ProductFormValues>()
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
@@ -109,8 +108,6 @@ export function ProductCatalogPanel() {
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
const [bootstrapApplying, setBootstrapApplying] = useState(false)
const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false)
const [archivingSupersededManuals, setArchivingSupersededManuals] = useState(false)
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ includeDisabledTemplates: false })
const [productPage, setProductPage] = useState(1)
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
@@ -452,76 +449,6 @@ export function ProductCatalogPanel() {
}
}
async function archiveHistoricalManuals() {
setArchivingHistoricalManuals(true)
try {
const preview = (await archiveAdminHistoricalManualProducts()).data
if (preview.candidateCount === 0) {
message.info('没有需要归档的历史人工商品')
return
}
modal.confirm({
title: '归档历史人工商品',
content: `将归档 ${preview.candidateCount} 个仅被已归档订单引用的历史人工商品。不会删除订单、订单项、成本或工单数据,商品只是从当前目录隐藏。`,
okText: '确认归档',
cancelText: '取消',
onOk: async () => {
setArchivingHistoricalManuals(true)
try {
const result = (await archiveAdminHistoricalManualProducts({ execute: true })).data
message.success(`已归档 ${result.archivedCount} 个历史人工商品`)
await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '归档历史人工商品失败')
} finally {
setArchivingHistoricalManuals(false)
}
},
})
} catch (error) {
message.error(error instanceof Error ? error.message : '读取历史人工商品失败')
} finally {
setArchivingHistoricalManuals(false)
}
}
async function archiveSupersededManuals() {
setArchivingSupersededManuals(true)
try {
const preview = (await archiveAdminSupersededManualProducts()).data
if (preview.candidateCount === 0) {
message.info('没有已被其他渠道接管的人工归一商品')
return
}
modal.confirm({
title: '归档跨渠道重复的人工归一商品',
content: `发现 ${preview.candidateCount} 个 man: 商品已有自动发货、接单平台或卡券主渠道。将归档这些旧商品,并把仍指向它们的人工兜底改指当前主商品;不会删除商品行、订单、订单项、成本版本或成本快照。`,
okText: '确认归档',
cancelText: '取消',
onOk: async () => {
setArchivingSupersededManuals(true)
try {
const result = (await archiveAdminSupersededManualProducts({ execute: true })).data
message.success(
`已归档 ${result.archivedCount} 个重复商品,修正 ${result.repointedChannelCount} 条人工兜底`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '归档跨渠道重复商品失败')
} finally {
setArchivingSupersededManuals(false)
}
},
})
} catch (error) {
message.error(error instanceof Error ? error.message : '读取跨渠道重复商品失败')
} finally {
setArchivingSupersededManuals(false)
}
}
function deleteProduct(item: ProductCatalogItem) {
const affiliateHint =
item.source === 'affiliate_dash'
@@ -777,24 +704,6 @@ export function ProductCatalogPanel() {
extra={
canConfig ? (
<Space size={6} wrap>
{productSourceFilter === 'manual' ? (
<>
<Button
icon={<ClearOutlined />}
loading={archivingSupersededManuals}
onClick={() => void archiveSupersededManuals()}
>
归档跨渠道重复商品
</Button>
<Button
icon={<ClearOutlined />}
loading={archivingHistoricalManuals}
onClick={() => void archiveHistoricalManuals()}
>
归档纯历史人工商品
</Button>
</>
) : null}
{productSourceFilter === 'work_order' ? (
<>
<AdminRangePicker
@@ -818,13 +727,34 @@ export function ProductCatalogPanel() {
loading={productsQuery.isFetching}
onClick={() => void productsQuery.refetch()}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
新建商品
</Button>
{productSourceFilter === 'manual' ? (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() =>
void navigate('/admin/cost-center?scope=manual&tab=cost&manualNew=1')
}
>
新增人工 SKU
</Button>
) : (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateProduct}>
新建商品
</Button>
)}
</Space>
) : null
}
>
{productSourceFilter === 'manual' ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="人工标准商品由人工 SKU 配置自动创建"
description="请到“成本配置 → 人工 SKU 配置”新增内部 SKU 并填写人工渠道和成本;保存时会自动创建或复用 man: 商品。"
/>
) : null}
{showCategoryBar ? (
<>
<div className="product-rules-category-tabs-bar">
@@ -96,32 +96,6 @@ export function createAdminProductCatalog(payload: Record<string, unknown>) {
return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload)
}
export function archiveAdminHistoricalManualProducts(payload: { execute?: boolean } = {}) {
return apiPost<{
dryRun: boolean
candidateCount: number
archivedCount: number
sample: Array<{ id: number; canonical_sku: string; canonical_name: string }>
}>('/api/v1/admin/cost-center/products/archive-historical-manual', payload)
}
export function archiveAdminSupersededManualProducts(payload: { execute?: boolean } = {}) {
return apiPost<{
dryRun: boolean
candidateCount: number
archivedCount: number
repointedChannelCount: number
sample: Array<{
id: number
canonical_sku: string
canonical_name: string
primary_channel: string
target_product_id: number
target_product_sku: string
}>
}>('/api/v1/admin/cost-center/products/archive-superseded-manual', payload)
}
export function syncAdminAffiliateDashProducts() {
return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>(
'/api/v1/admin/cost-center/affiliate-products/sync',
@@ -8,10 +8,56 @@ test('人工成本配置直接展示标准商品,不再要求按日期扫描
'utf8',
)
assert.ok(source.includes('人工标准商品与成本配置一一对应'))
assert.ok(source.includes('人工标准商品由人工配置自动创建'))
assert.ok(source.includes("title: '人工标准商品'"))
assert.ok(source.includes('SKU:{row.skuKey}'))
assert.ok(!source.includes('扫描内部 SKU'))
assert.ok(!source.includes('DatePicker.RangePicker'))
assert.ok(!source.includes('rematchAdminManualFulfillments'))
})
test('新增人工 SKU 直接保存配置,由后端自动创建标准商品', async () => {
const source = await readFile(
new URL('../src/pages/admin/cost-center/ManualSkuConfigPanel.tsx', import.meta.url),
'utf8',
)
assert.ok(source.includes('新增人工 SKU'))
assert.ok(source.includes('openCreateConfig'))
assert.ok(source.includes('name="skuKey"'))
assert.ok(source.includes('保存时自动创建标准商品'))
assert.ok(source.includes('manualSku'))
assert.ok(source.includes("returnTo.startsWith('/admin/fulfillment-channel')"))
})
test('人工商品不再允许手工创建或执行旧批量归档', async () => {
const [productSource, serviceSource, queueSource, shellSource] = await Promise.all([
readFile(
new URL('../src/pages/admin/cost-center/ProductCatalogPanel.tsx', import.meta.url),
'utf8',
),
readFile(
new URL('../../backend/src/services/cost-center/product-catalog-service.ts', import.meta.url),
'utf8',
),
readFile(
new URL('../src/pages/admin/AdminFulfillmentChannelPage.tsx', import.meta.url),
'utf8',
),
readFile(
new URL('../src/pages/admin/cost-center/CostCenterShell.tsx', import.meta.url),
'utf8',
),
])
assert.ok(!productSource.includes('归档跨渠道重复商品'))
assert.ok(!productSource.includes('归档纯历史人工商品'))
assert.ok(productSource.includes("productSourceFilter === 'manual'"))
assert.ok(productSource.includes('manualNew=1'))
assert.ok(productSource.includes('人工标准商品由人工 SKU 配置自动创建'))
assert.ok(serviceSource.includes('cost_manual_product_managed_by_config'))
assert.ok(queueSource.includes('manualSku=${encodeURIComponent(sku)}'))
assert.ok(queueSource.includes("returnTo=${encodeURIComponent('/admin/fulfillment-channel')}"))
assert.ok(shellSource.includes("searchParams.get('scope')"))
assert.ok(shellSource.includes("searchParams.get('tab')"))
})