diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index a790e0e3..e9fe103f 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -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, - }), - }), -) - -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, - }), - }), -) - router.patch( '/cost-center/products/:productId', requireAdminPermission('finance.config'), diff --git a/apps/backend/src/services/cost-center/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts index fc34145c..8b6b8b43 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -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:%') )` } diff --git a/apps/backend/src/services/cost-center/cost-center-overview-service.ts b/apps/backend/src/services/cost-center/cost-center-overview-service.ts index 55dc539f..099a9e5d 100644 --- a/apps/backend/src/services/cost-center/cost-center-overview-service.ts +++ b/apps/backend/src/services/cost-center/cost-center-overview-service.ts @@ -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 diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index d1c35136..78f414b3 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -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 配置自动创建/, + ) +}) diff --git a/apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts b/apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts deleted file mode 100644 index de9faff7..00000000 --- a/apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts +++ /dev/null @@ -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() - } - }, -) diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index 27aa86ed..ba7b95c7 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -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(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 { +async function syncManualChannelRule( + config: { + id: number + sku_key: string + canonical_product_id: number | null + enabled: boolean + note: string + }, + client?: PoolClient, +): Promise { 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) } diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index 34aa9b80..ba75b9a8 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -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( - ` - 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 diff --git a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts index cf58fe96..92f86362 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-integrity.test.ts @@ -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'), diff --git a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts index d97c9b13..227cccc4 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts @@ -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 { +} + +async function upsertInternalSkuChannelWithExecutor( + client: Pick, + input: InternalSkuChannelUpsertInput, +): Promise { 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 { + return upsertInternalSkuChannelWithExecutor(client, input) +} + +export async function upsertInternalSkuChannel( + input: InternalSkuChannelUpsertInput, +): Promise { + return withTransaction((client) => upsertInternalSkuChannelWithExecutor(client, input)) } /** diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 7aea826c..fa652b83 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -484,7 +484,17 @@ function PendingPanel() { 保存渠道 {canRunScopedActions && hasConfigIssue ? ( - ) : null} diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx index 6c318303..05e23324 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx @@ -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( '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') diff --git a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx index 97eff9c0..40c0368c 100644 --- a/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ManualSkuConfigPanel.tsx @@ -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() + 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 = [ { title: '人工标准商品', @@ -276,24 +322,27 @@ export function ManualSkuConfigPanel() { key: 'actions', width: 150, fixed: 'right', - render: (_, row) => ( - - - deleteMutation.mutate(row.skuKey)} - > - - - - ), + deleteMutation.mutate(row.skuKey)} + > + + + + ) : ( + 只读 + ), }, ] @@ -303,13 +352,23 @@ export function ManualSkuConfigPanel() { } return ( - + } onClick={() => openCreateConfig()}> + 新增人工 SKU + + ) : null + } + > @@ -399,14 +458,17 @@ export function ManualSkuConfigPanel() { { 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={form} layout="vertical"> + { + const text = String(value || '').trim() + if (text && !/\p{Script=Han}/u.test(text)) { + throw new Error('内部 SKU 必须包含中文') + } + }, + }, + ]} + extra="保存后会生成 man:<内部 SKU> 标准商品,并统一用于订单渠道判定。" + > + + () // 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。 @@ -109,8 +108,6 @@ export function ProductCatalogPanel() { const [productStatusUpdatingId, setProductStatusUpdatingId] = useState(null) const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) const [bootstrapApplying, setBootstrapApplying] = useState(false) - const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false) - const [archivingSupersededManuals, setArchivingSupersededManuals] = useState(false) const bootstrapSyncOptionsRef = useRef({ 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 ? ( - {productSourceFilter === 'manual' ? ( - <> - - - - ) : null} {productSourceFilter === 'work_order' ? ( <> void productsQuery.refetch()} /> - + {productSourceFilter === 'manual' ? ( + + ) : ( + + )} ) : null } > + {productSourceFilter === 'manual' ? ( + + ) : null} {showCategoryBar ? ( <>
diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index f0dd3780..0cf20d58 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -96,32 +96,6 @@ export function createAdminProductCatalog(payload: Record) { 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', diff --git a/apps/frontend/tests/manual-sku-config-ui.test.ts b/apps/frontend/tests/manual-sku-config-ui.test.ts index 11157747..15ad7596 100644 --- a/apps/frontend/tests/manual-sku-config-ui.test.ts +++ b/apps/frontend/tests/manual-sku-config-ui.test.ts @@ -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')")) +}) diff --git a/docs/成本中心API契约.md b/docs/成本中心API契约.md index 7062d381..525cc69c 100644 --- a/docs/成本中心API契约.md +++ b/docs/成本中心API契约.md @@ -7,16 +7,16 @@ ## 一、字段替换总表 -| 旧字段 | 新字段 | 出现位置 | -| --- | --- | --- | -| `cost_items.carrier_type` | `cost_items.cost_model` | DB / 成本项接口 | -| `cost_items.cost_type` | (并入 `cost_model`) | DB / 成本项接口 | -| `cost_snapshots.carrier_type` | (删除) | DB | -| `cost_exceptions.carrier_type` | (删除) | DB | -| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 | -| `sales_daily_facts.carrier_type` | `sales_daily_facts.channel` | DB / 销售事实接口 | -| `work_product_rules.legacy_cost_source` | (删除) | DB | -| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走确认履约) | DB | +| 旧字段 | 新字段 | 出现位置 | +| -------------------------------------------------- | --------------------------------------- | ----------------- | +| `cost_items.carrier_type` | `cost_items.cost_model` | DB / 成本项接口 | +| `cost_items.cost_type` | (并入 `cost_model`) | DB / 成本项接口 | +| `cost_snapshots.carrier_type` | (删除) | DB | +| `cost_exceptions.carrier_type` | (删除) | DB | +| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 | +| `sales_daily_facts.carrier_type` | `sales_daily_facts.channel` | DB / 销售事实接口 | +| `work_product_rules.legacy_cost_source` | (删除) | DB | +| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走确认履约) | DB | ## 二、枚举映射 @@ -24,16 +24,16 @@ 成本模型与履约方式正交:专属模型只对对应履约方式生效,通用模型对所有履约方式生效。 -| 新值 | 含义 | 取代旧的 | -| --- | --- | --- | -| `affiliate_purchase` | 自动发货上游采购成本 | `cost_type=auto_purchase` | -| `work_order_reward` | 接单平台打手报酬 | `cost_type=work_order_reward` | -| `third_party_voucher_cost` | 第三方卡券采购成本 | 新增 | -| `manual_actual` | 人工客服实际支出 | `cost_type=manual_service` | -| `platform_fee` | 平台服务费 | `cost_type=platform_fee` | -| `payment_fee` | 支付通道费 | `cost_type=payment_fee` | -| `after_sales_loss` | 售后损失 | `cost_type=after_sales_loss` | -| `other` | 其他 | `cost_type=shipping_other` / `cost_type=other` | +| 新值 | 含义 | 取代旧的 | +| -------------------------- | -------------------- | ---------------------------------------------- | +| `affiliate_purchase` | 自动发货上游采购成本 | `cost_type=auto_purchase` | +| `work_order_reward` | 接单平台打手报酬 | `cost_type=work_order_reward` | +| `third_party_voucher_cost` | 第三方卡券采购成本 | 新增 | +| `manual_actual` | 人工客服实际支出 | `cost_type=manual_service` | +| `platform_fee` | 平台服务费 | `cost_type=platform_fee` | +| `payment_fee` | 支付通道费 | `cost_type=payment_fee` | +| `after_sales_loss` | 售后损失 | `cost_type=after_sales_loss` | +| `other` | 其他 | `cost_type=shipping_other` / `cost_type=other` | ### 2.2 `pricing_mode`(精简为 3 个) @@ -67,7 +67,7 @@ DELETE /api/v1/admin/cost-center/cost-items/:id "unitName": "件", "currency": "CNY", "status": "active | archived", - "note": "" + "note": "", } ``` @@ -88,7 +88,7 @@ DELETE /api/v1/admin/cost-center/cost-items/:id "note": "", "snapshotCount": 0, "createdAt": "...", - "updatedAt": "..." + "updatedAt": "...", } ``` @@ -96,13 +96,13 @@ DELETE /api/v1/admin/cost-center/cost-items/:id 商品**不再返回 `carrierType`**。前端若要按来源分组,用 `canonicalSku` 前缀: -| 前缀 | 来源 | 建议展示名 | -| --- | --- | --- | -| `aff:` | 自动发货 | 自动发货 | -| `tpl:` | 接单模板 | 接单平台 | -| `wo:` | 工单证据 | 接单平台 | -| `man:` | 人工 | 人工 | -| 其他 | 未分类 | 未分类 | +| 前缀 | 来源 | 建议展示名 | +| ------ | -------- | ---------- | +| `aff:` | 自动发货 | 自动发货 | +| `tpl:` | 接单模板 | 接单平台 | +| `wo:` | 工单证据 | 接单平台 | +| `man:` | 人工 | 人工 | +| 其他 | 未分类 | 未分类 | `GET /api/v1/admin/cost-center/products` 的 `carrierType` 查询参数**保留**, 但语义改为「按 `canonical_sku` 前缀筛选」,取值 `affiliate_dash | work_order | manual`。 @@ -118,11 +118,11 @@ AND ( ### 4.1 渠道映射诊断与未归一队列 -| 接口 | 说明 | -| --- | --- | -| `GET /api/v1/admin/cost-center/mappings/diagnostics` | 标准商品数 / 映射数 / 有映射商品数 / 未归一订单项 / 未归一 SKU 组;按 `channel` 拆分聚合 | -| `GET /api/v1/admin/cost-center/mappings/unmapped` | 未归一队列,按「来源 + 店铺 + 平台 SKU + 履约方式」聚合,支持 `keyword` / `provider` / `channel` 与分页 | -| `POST /api/v1/admin/cost-center/mappings/backfill` | 回填订单项映射;返回 `resolvedExceptionCount`(已归一的 `product_mapping_missing` 异常自动收口) | +| 接口 | 说明 | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `GET /api/v1/admin/cost-center/mappings/diagnostics` | 标准商品数 / 映射数 / 有映射商品数 / 未归一订单项 / 未归一 SKU 组;按 `channel` 拆分聚合 | +| `GET /api/v1/admin/cost-center/mappings/unmapped` | 未归一队列,按「来源 + 店铺 + 平台 SKU + 履约方式」聚合,支持 `keyword` / `provider` / `channel` 与分页 | +| `POST /api/v1/admin/cost-center/mappings/backfill` | 回填订单项映射;返回 `resolvedExceptionCount`(已归一的 `product_mapping_missing` 异常自动收口) | ### 4.2 人工商品归一(已删除) @@ -150,6 +150,30 @@ AND ( 商品 `canonical_sku = man:<内部SKU>`,`canonical_name` 优先取 `manual_sku_registry.display_name`,否则取订单里最常见的 `sku_name`。 每日 `cost_finance_sync` 任务会对最近 N 天执行同一归一(不重复收口异常)。 +### 4.3 人工 SKU 配置(现行) + +人工标准商品不允许通过通用 `POST /api/v1/admin/cost-center/products` 手工创建。 +统一入口为: + +```text +POST /api/v1/admin/cost-center/manual-configs +``` + +```jsonc +{ + "skuKey": "破片手榴弹-兔兔白日梦", + "channels": [ + { "platformKey": "nanhang", "creditQuantity": 10 }, + { "platformKey": "fund_cash", "creditQuantity": 3.5 }, + ], + "note": "长期人工商品", +} +``` + +保存时后端自动创建或复用 `man:` 标准商品,再写人工配置和 +`internal_sku_channels` 人工规则。待分类队列的“去补配置”会携带 +`scope=manual&tab=cost&manualSku=<内部SKU>` 直接打开对应配置。 + ## 五、成本版本接口 路径不变,字段不变: @@ -163,20 +187,34 @@ AND ( "costModel": "affiliate_purchase", "pricingMode": "per_item", "scopeType": "product | rule | shop | provider | global", - "provider": "", "shopId": "", - "canonicalProductId": 3, "canonicalSku": "aff:SKU-1", "canonicalName": "...", - "workProductRuleId": null, "workProductRuleName": "", "workProductRuleKey": "", - "ruleRewardAmountFen": 0, "ruleUnitPriceFen": 0, - "revenueItemCount": 0, "revenueFen": 0, "revenueAvgFen": 0, - "rewardAvgFen": 0, "rewardFen": 0, "amountKind": "unit_price | fixed", + "provider": "", + "shopId": "", + "canonicalProductId": 3, + "canonicalSku": "aff:SKU-1", + "canonicalName": "...", + "workProductRuleId": null, + "workProductRuleName": "", + "workProductRuleKey": "", + "ruleRewardAmountFen": 0, + "ruleUnitPriceFen": 0, + "revenueItemCount": 0, + "revenueFen": 0, + "revenueAvgFen": 0, + "rewardAvgFen": 0, + "rewardFen": 0, + "amountKind": "unit_price | fixed", "isSystemPlaceholder": false, - "amountFen": 1000, "rateBps": 0, - "effectiveFrom": "...", "effectiveTo": null, + "amountFen": 1000, + "rateBps": 0, + "effectiveFrom": "...", + "effectiveTo": null, "source": "manual | upstream_sync | import", "status": "draft | active | retired", "changeReason": "", - "createdByAdminUserId": null, "approvedByAdminUserId": null, - "createdAt": "...", "updatedAt": "..." + "createdByAdminUserId": null, + "approvedByAdminUserId": null, + "createdAt": "...", + "updatedAt": "...", } ``` @@ -213,7 +251,9 @@ AND ( "reason": "cost_missing", "status": "open", "detail": {}, - "createdAt": "...", "resolvedAt": null, "resolvedByAdminUserId": null + "createdAt": "...", + "resolvedAt": null, + "resolvedByAdminUserId": null, } ``` @@ -233,7 +273,7 @@ AND ( "grossRevenueFen": 1219202470, "costAmountFen": 32000, "costMissingCount": 102954, - "grossProfitFen": -1219200470 + "grossProfitFen": -1219200470, } ``` @@ -255,13 +295,13 @@ AND ( "affiliate_dash": 38, "work_order": 707, "manual": 0, - "uncategorized": 0 + "uncategorized": 0, }, "costModelCounts": { "affiliate_purchase": 1, "work_order_reward": 1, - "manual_actual": 1 - } + "manual_actual": 1, + }, } ``` @@ -272,13 +312,13 @@ AND ( 原 `apps/backend/src/services/admin/admin-cost-center-service.ts`(4,248 行)已拆为: -| 新文件 | 行数 | 负责的导出 | -| --- | ---: | --- | -| `services/cost-center/cost-center-shared.ts` | ~800 | 类型、常量、归一化函数、`mapXxx` 映射函数、provider 归一、`costModelScopeSql` | -| `services/cost-center/cost-center-overview-service.ts` | ~150 | 总览统计与选项 | -| `services/cost-center/product-catalog-service.ts` | ~1,000 | 商品与渠道映射的 CRUD、回填、选项 | -| `services/cost-center/template-bootstrap-service.ts` | ~1,380 | 接单模板引导、自动发货商品同步、工单证据回填、模板成本版本同步、`buildTemplateMappingPlan` | -| `services/cost-center/cost-definition-service.ts` | ~1,240 | 成本项 / 成本版本 / 成本异常 / 成本预览 / 成本快照写入 | +| 新文件 | 行数 | 负责的导出 | +| ------------------------------------------------------ | -----: | ------------------------------------------------------------------------------------------ | +| `services/cost-center/cost-center-shared.ts` | ~800 | 类型、常量、归一化函数、`mapXxx` 映射函数、provider 归一、`costModelScopeSql` | +| `services/cost-center/cost-center-overview-service.ts` | ~150 | 总览统计与选项 | +| `services/cost-center/product-catalog-service.ts` | ~1,000 | 商品与渠道映射的 CRUD、回填、选项 | +| `services/cost-center/template-bootstrap-service.ts` | ~1,380 | 接单模板引导、自动发货商品同步、工单证据回填、模板成本版本同步、`buildTemplateMappingPlan` | +| `services/cost-center/cost-definition-service.ts` | ~1,240 | 成本项 / 成本版本 / 成本异常 / 成本预览 / 成本快照写入 | 原文件**已删除**,路由 `apps/backend/src/routes/admin/cost-center.ts` 从新模块导入。 `routes/admin/worker-platform/config.ts` 的模板成本同步也从新模块导入。 @@ -287,19 +327,19 @@ AND ( 原 `apps/frontend/src/pages/admin/AdminCostCenterPage.tsx`(2,887 行)已拆为: -| 新文件 | 内容 | -| --- | --- | -| `pages/admin/cost-center/CostCenterShell.tsx` | 页面骨架、顶部来源切换(全部/自动发货/接单平台/人工)、Tab 定义 | -| `pages/admin/cost-center/shared.ts` | 常量、类型、标签函数 | -| `pages/admin/cost-center/CostCenterContext.ts` | 页面上下文(`useCostCenter`) | -| `pages/admin/cost-center/ProductCatalogPanel.tsx` | 标准商品 Tab | -| `pages/admin/cost-center/ChannelMappingPanel.tsx` | 渠道映射 Tab | -| `pages/admin/cost-center/CostConfigPanel.tsx` | 成本配置 Tab(按来源切换下面三个) | -| `pages/admin/cost-center/AffiliateSkuPanel.tsx` | 自动发货采购价(成本配置内) | -| `pages/admin/cost-center/CostItemPanel.tsx` | 成本项 Tab | -| `pages/admin/cost-center/CostVersionPanel.tsx` | 成本版本 Tab | -| `pages/admin/cost-center/CostExceptionPanel.tsx` | 成本异常 Tab | -| `pages/admin/cost-center/CostPreviewPanel.tsx` | 成本预览 Tab | +| 新文件 | 内容 | +| ------------------------------------------------- | --------------------------------------------------------------- | +| `pages/admin/cost-center/CostCenterShell.tsx` | 页面骨架、顶部来源切换(全部/自动发货/接单平台/人工)、Tab 定义 | +| `pages/admin/cost-center/shared.ts` | 常量、类型、标签函数 | +| `pages/admin/cost-center/CostCenterContext.ts` | 页面上下文(`useCostCenter`) | +| `pages/admin/cost-center/ProductCatalogPanel.tsx` | 标准商品 Tab | +| `pages/admin/cost-center/ChannelMappingPanel.tsx` | 渠道映射 Tab | +| `pages/admin/cost-center/CostConfigPanel.tsx` | 成本配置 Tab(按来源切换下面三个) | +| `pages/admin/cost-center/AffiliateSkuPanel.tsx` | 自动发货采购价(成本配置内) | +| `pages/admin/cost-center/CostItemPanel.tsx` | 成本项 Tab | +| `pages/admin/cost-center/CostVersionPanel.tsx` | 成本版本 Tab | +| `pages/admin/cost-center/CostExceptionPanel.tsx` | 成本异常 Tab | +| `pages/admin/cost-center/CostPreviewPanel.tsx` | 成本预览 Tab | `AdminCostCenterPage.tsx` 只保留 `export default` 转发到 `CostCenterShell`(18 行)。 @@ -322,21 +362,21 @@ export type CostModel = ## 八、总账 / 期间 / 对账接口(2026-10-26) -| 方法 | 路径 | 权限 | 说明 | -| --- | --- | --- | --- | -| GET | `/api/v1/admin/finance/ledger/accounts` | finance.view | 科目表 | -| GET | `/api/v1/admin/finance/ledger/trial-balance?dateFrom&dateTo` | finance.view | 试算平衡 | -| GET | `/api/v1/admin/finance/ledger/entries?dateFrom&dateTo&page&pageSize` | finance.view | 凭证列表(含分录) | -| POST | `/api/v1/admin/finance/ledger/sync` | finance.config | 按日期区间同步销售事实到总账 | -| POST | `/api/v1/admin/finance/ledger/manual-voucher` | finance.config | 手工凭证(借贷必须平衡) | -| GET | `/api/v1/admin/finance/periods` | finance.view | 会计期间列表 | -| POST | `/api/v1/admin/finance/periods/:periodKey/close` | finance.config | 关账(先补凭证) | -| POST | `/api/v1/admin/finance/periods/:periodKey/reopen` | finance.config | 反关账 | -| GET | `/api/v1/admin/finance/reconciliations` | finance.view | 对账批次 | -| POST | `/api/v1/admin/finance/reconciliations/import` | finance.config | 导入平台账单 JSON 并匹配 | -| GET | `/api/v1/admin/finance/reconciliations/:id` | finance.view | 对账明细 | -| POST | `/api/v1/admin/finance/reconciliations/:id/resolve` | finance.config | 标记差异已解决 | -| POST | `/api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record` | finance.config | 平台退款(当前仅快手 91) | +| 方法 | 路径 | 权限 | 说明 | +| ---- | ---------------------------------------------------------------------------- | -------------- | ---------------------------- | +| GET | `/api/v1/admin/finance/ledger/accounts` | finance.view | 科目表 | +| GET | `/api/v1/admin/finance/ledger/trial-balance?dateFrom&dateTo` | finance.view | 试算平衡 | +| GET | `/api/v1/admin/finance/ledger/entries?dateFrom&dateTo&page&pageSize` | finance.view | 凭证列表(含分录) | +| POST | `/api/v1/admin/finance/ledger/sync` | finance.config | 按日期区间同步销售事实到总账 | +| POST | `/api/v1/admin/finance/ledger/manual-voucher` | finance.config | 手工凭证(借贷必须平衡) | +| GET | `/api/v1/admin/finance/periods` | finance.view | 会计期间列表 | +| POST | `/api/v1/admin/finance/periods/:periodKey/close` | finance.config | 关账(先补凭证) | +| POST | `/api/v1/admin/finance/periods/:periodKey/reopen` | finance.config | 反关账 | +| GET | `/api/v1/admin/finance/reconciliations` | finance.view | 对账批次 | +| POST | `/api/v1/admin/finance/reconciliations/import` | finance.config | 导入平台账单 JSON 并匹配 | +| GET | `/api/v1/admin/finance/reconciliations/:id` | finance.view | 对账明细 | +| POST | `/api/v1/admin/finance/reconciliations/:id/resolve` | finance.config | 标记差异已解决 | +| POST | `/api/v1/admin/kuaishou-industry/orders/:orderId/refunds/approve-and-record` | finance.config | 平台退款(当前仅快手 91) | 确认履约接口新增代录字段: @@ -344,8 +384,8 @@ export type CostModel = { "orderItemId": 123, "costFen": 100, - "fundAccountId": 45, // 现金路径:管理员/财务指定客服备用金账户 - "creditOwnerAdminUserId": 67 // 积分路径:管理员/财务指定客服账号 + "fundAccountId": 45, // 现金路径:管理员/财务指定客服备用金账户 + "creditOwnerAdminUserId": 67, // 积分路径:管理员/财务指定客服账号 } ``` diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index a2e9ca62..751ec0a1 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -38,11 +38,11 @@ orders ──┬── order_items ──── fulfillment_channel + fulfillmen 2026-10-27 最终收敛为 3 个领域迁移(合计约 2,177 行): -| 迁移 | 内容 | 行数 | -| --- | --- | ---: | -| `103_finance_foundation.sql` | 商品目录、渠道映射、成本项/版本/快照/异常、订单项收入与履约方式、销售事实、退款单 | 861 | -| `104_finance_controls.sql` | 人工 SKU/配置、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 | 1,119 | -| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 | 197 | +| 迁移 | 内容 | 行数 | +| -------------------------------- | --------------------------------------------------------------------------------- | ----: | +| `103_finance_foundation.sql` | 商品目录、渠道映射、成本项/版本/快照/异常、订单项收入与履约方式、销售事实、退款单 | 861 | +| `104_finance_controls.sql` | 人工 SKU/配置、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 | 1,119 | +| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 | 197 | 原来 103–114 的 12 个补丁文件已删除;生产当前在 102,只需执行 103–105。 @@ -52,42 +52,44 @@ orders ──┬── order_items ──── fulfillment_channel + fulfillmen ### 1.1 字段替换对照 -| 旧字段 | 新字段 | 出现位置 | -| --- | --- | --- | -| `cost_items.carrier_type` | `cost_items.cost_model` | DB / 成本项接口 | -| `cost_items.cost_type` | (并入 `cost_model`) | DB / 成本项接口 | -| `cost_snapshots.carrier_type` | (删除) | DB | -| `cost_exceptions.carrier_type` | (删除) | DB | -| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 | -| `sales_daily_facts.carrier_type` | `sales_daily_facts.channel` | DB / 销售事实接口 | -| `work_product_rules.legacy_cost_source` | (删除) | DB | -| `cost_versions.cost_item_carrier_type` | `cost_versions.cost_item_cost_model` | 成本版本接口 | -| `pricing_mode: per_hour / fixed_daily` | (删除,只剩三种) | 成本项接口 | -| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走确认履约) | DB | +| 旧字段 | 新字段 | 出现位置 | +| -------------------------------------------------- | --------------------------------------- | ----------------- | +| `cost_items.carrier_type` | `cost_items.cost_model` | DB / 成本项接口 | +| `cost_items.cost_type` | (并入 `cost_model`) | DB / 成本项接口 | +| `cost_snapshots.carrier_type` | (删除) | DB | +| `cost_exceptions.carrier_type` | (删除) | DB | +| `product_catalog.carrier_type` | (删除,改由 `canonical_sku` 前缀推导) | DB / 商品接口 | +| `sales_daily_facts.carrier_type` | `sales_daily_facts.channel` | DB / 销售事实接口 | +| `work_product_rules.legacy_cost_source` | (删除) | DB | +| `cost_versions.cost_item_carrier_type` | `cost_versions.cost_item_cost_model` | 成本版本接口 | +| `pricing_mode: per_hour / fixed_daily` | (删除,只剩三种) | 成本项接口 | +| `manual_expense_records` / `manual_expense_events` | (删除,人工成本只走确认履约) | DB | ### 1.2 成本模型映射 -| 旧 `cost_type` | 新 `cost_model` | -| --- | --- | -| `auto_purchase` | `affiliate_purchase` | -| `work_order_reward` | `work_order_reward` | -| `manual_service` | `manual_actual` | -| `platform_fee` | `platform_fee` | -| `payment_fee` | `payment_fee` | -| `after_sales_loss` | `after_sales_loss` | -| `shipping_other` / `other` | `other` | +| 旧 `cost_type` | 新 `cost_model` | +| -------------------------- | -------------------- | +| `auto_purchase` | `affiliate_purchase` | +| `work_order_reward` | `work_order_reward` | +| `manual_service` | `manual_actual` | +| `platform_fee` | `platform_fee` | +| `payment_fee` | `payment_fee` | +| `after_sales_loss` | `after_sales_loss` | +| `shipping_other` / `other` | `other` | ### 1.3 商品来源前缀 -| 前缀 | 来源 | 对应履约方式(仅参考,不作推导依据) | -| --- | --- | --- | -| `aff:` | 自动发货 | `affiliate_dash` | -| `tpl:` | 接单模板 | `work_order` | -| `wo:` | 历史工单证据 | `work_order` | -| `man:` | 人工 | `manual` | -| 其他 | 未分类 | 不得推断 | +| 前缀 | 来源 | 对应履约方式(仅参考,不作推导依据) | +| ------ | ------------ | ------------------------------------ | +| `aff:` | 自动发货 | `affiliate_dash` | +| `tpl:` | 接单模板 | `work_order` | +| `wo:` | 历史工单证据 | `work_order` | +| `man:` | 人工 | `manual` | +| 其他 | 未分类 | 不得推断 | -本地现状:`aff:` 38 个、`tpl:` 662 个、`wo:` 45 个,`man:` 0 个(P1 待建)。 +人工标准商品 `man:<内部 SKU>` 不在商品目录手工创建。运营在「成本配置 → 人工 SKU 配置」 +新增或保存配置时,系统在同一业务流程内创建或复用标准商品,并同步人工渠道规则。 +商品目录中的 `man:` 商品因此是人工配置的派生结果。 --- @@ -188,15 +190,15 @@ normalized_sku_series -- 剥离数量并归一同义字后的名称系列 ### 3.1 成本模型与适用履约方式 -| `cost_model` | 适用履约方式 | 计价方式 | 成本来源 | -| --- | --- | --- | --- | -| `affiliate_purchase` | `affiliate_dash` | `per_item` | 上游 SKU 固定成本版本 | -| `work_order_reward` | `work_order` | `per_order` | 模板规则级版本 / 工单实际报酬 | -| `manual_actual` | `manual` | `per_order` | 订单页逐单录入的实际支出 | -| `platform_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 | -| `payment_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 | -| `after_sales_loss` | 全部 | `per_order` | 未配置 | -| `other` | 全部 | `per_order` | 未配置 | +| `cost_model` | 适用履约方式 | 计价方式 | 成本来源 | +| -------------------- | ---------------- | ---------------------------------- | ----------------------------- | +| `affiliate_purchase` | `affiliate_dash` | `per_item` | 上游 SKU 固定成本版本 | +| `work_order_reward` | `work_order` | `per_order` | 模板规则级版本 / 工单实际报酬 | +| `manual_actual` | `manual` | `per_order` | 订单页逐单录入的实际支出 | +| `platform_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 | +| `payment_fee` | 全部 | `per_order` / `percent_of_revenue` | 未配置 | +| `after_sales_loss` | 全部 | `per_order` | 未配置 | +| `other` | 全部 | `per_order` | 未配置 | 后端判定见 `cost-center-shared.ts` 的 `costModelScopeSql()`;通用模型对所有履约方式生效,专属模型只对对应履约方式生效。 @@ -228,11 +230,11 @@ cost_versions ### 3.3 系统内置成本项 -| 编码 | 成本模型 | 说明 | -| --- | --- | --- | -| `sys_work_order_reward` | `work_order_reward` | 每个可启用的接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 | -| `sys_affiliate_purchase` | `affiliate_purchase` | 上游 SKU 固定成本版本,38 个商品级 | -| `sys_manual_service` | `manual_actual` | 订单页确认履约写入 `actual` 快照;版本为 0 元占位 | +| 编码 | 成本模型 | 说明 | +| ------------------------ | -------------------- | ----------------------------------------------------------------------------- | +| `sys_work_order_reward` | `work_order_reward` | 每个可启用的接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 | +| `sys_affiliate_purchase` | `affiliate_purchase` | 上游 SKU 固定成本版本,38 个商品级 | +| `sys_manual_service` | `manual_actual` | 订单页确认履约写入 `actual` 快照;版本为 0 元占位 | 工单报酬的三种快照: @@ -286,12 +288,12 @@ created_at, resolved_at, resolved_by `order_items.revenue_amount_fen` / `revenue_basis` 在迁移 `103` 里固化: -| `revenue_basis` | 含义 | -| --- | --- | -| `order_total` | 一单一品,订单实付全部归该订单项 | -| `equal_split` | 多订单项按等额拆分,余数给最后一项 | -| `platform_item` | 有平台明细时替换 | -| `manual` / `unknown` | 未定 | +| `revenue_basis` | 含义 | +| -------------------- | ---------------------------------- | +| `order_total` | 一单一品,订单实付全部归该订单项 | +| `equal_split` | 多订单项按等额拆分,余数给最后一项 | +| `platform_item` | 有平台明细时替换 | +| `manual` / `unknown` | 未定 | 本地全量 316,585 项都是 `order_total`(当前一单一品),但模型按"订单项"设计,不把"一单一品"写死。 @@ -328,12 +330,12 @@ computed_at ### 5.2 本地重算结果(2026-10-23) -| 履约方式 | 事实行 | 订单项 | 收入 | 成本 | 缺失成本 | -| --- | ---: | ---: | ---: | ---: | ---: | -| `work_order` | 9,585 | 118,053 | ¥533.57 万 | ¥452.83 万 | 966 | -| `affiliate_dash` | 3,163 | 41,676 | ¥508.97 万 | ¥497.41 万 | 0 | -| `manual` | 1,849 | 102,955 | ¥1,219.20 万 | ¥0.32 万 | 102,954 | -| `legacy_platform` | 201 | 53,901 | ¥389.12 万 | ¥0 | 53,901 | +| 履约方式 | 事实行 | 订单项 | 收入 | 成本 | 缺失成本 | +| ----------------- | -----: | ------: | -----------: | ---------: | -------: | +| `work_order` | 9,585 | 118,053 | ¥533.57 万 | ¥452.83 万 | 966 | +| `affiliate_dash` | 3,163 | 41,676 | ¥508.97 万 | ¥497.41 万 | 0 | +| `manual` | 1,849 | 102,955 | ¥1,219.20 万 | ¥0.32 万 | 102,954 | +| `legacy_platform` | 201 | 53,901 | ¥389.12 万 | ¥0 | 53,901 | --- @@ -396,7 +398,9 @@ fulfillment_decided_at / fulfillment_reason 4. **商品来源和平台 SKU 都不能反推渠道**:判定只认统一 SKU 配置与真实载体。 5. `voucher_state` 只做展示与核销流程,不参与任何成本/履约判断。 6. 多渠道证据同时命中必须先告警人工复核,不能静默取其一。 -7. **旧人工来源清理只归档**:同名内部 SKU 已有其他非兜底主渠道时,旧 `man:` 商品从当前目录归档;历史订单、成本版本、成本快照和事实数据保持不动,人工兜底改指当前主商品。 +7. **人工商品只从人工 SKU 配置创建**:普通“新建标准商品”不再接受 `man:`; + 人工 SKU 配置保存时自动创建或复用 `man:<内部 SKU>`。历史商品清理入口已下线, + 避免运营通过商品目录维护人工履约事实。 --- @@ -404,21 +408,21 @@ fulfillment_decided_at / fulfillment_reason 财务链路已改成六条独立管道,由定时任务驱动,不再有手工同步脚本: -| 管道 | 作用 | 默认间隔 | -| --- | --- | --- | -| `channel_assign` | 规则/证据判定履约渠道 | 60s | -| `carrier_dispatch` | 挂载人工配置、发现缺失载体 | 60s | -| `product_normalize` | 模板/人工配置/前缀直达归一 | 300s | -| `cost_snapshot` | 按渠道解析成本版本写快照 | 300s | -| `sales_facts` | 按渠道重算销售事实(含未分类桶) | 600s | -| `ledger_post` | 事实过账到总账 | 600s | +| 管道 | 作用 | 默认间隔 | +| ------------------- | -------------------------------- | -------- | +| `channel_assign` | 规则/证据判定履约渠道 | 60s | +| `carrier_dispatch` | 挂载人工配置、发现缺失载体 | 60s | +| `product_normalize` | 模板/人工配置/前缀直达归一 | 300s | +| `cost_snapshot` | 按渠道解析成本版本写快照 | 300s | +| `sales_facts` | 按渠道重算销售事实(含未分类桶) | 600s | +| `ledger_post` | 事实过账到总账 | 600s | 运维命令只剩断代与重建: -| 命令 | 作用 | -| --- | --- | +| 命令 | 作用 | +| -------------------------------------------------------------- | ----------------------------- | | `npm run finance:cutover-v2 -- --execute --confirm=2026-10-31` | 断代:归档历史 + 清空派生数据 | -| `npm run finance:rebuild-cutover -- --date-from=2026-10-31` | 断代后重跑管道并生成期初分录 | +| `npm run finance:rebuild-cutover -- --date-from=2026-10-31` | 断代后重跑管道并生成期初分录 | 后台「履约渠道 → 管道状态」可查看每条管道的水位、上次成功时间与错误; 「立即执行」只用于排障补跑,日常无需干预。 @@ -427,11 +431,11 @@ fulfillment_decided_at / fulfillment_reason 生产数据库当前在 **102**。dev 已把原 103–114 收敛为 3 个领域迁移: -| 迁移 | 内容 | -| --- | --- | -| `103_finance_foundation.sql` | 商品目录、成本中心、成本项/版本/快照、销售事实、退款单 | -| `104_finance_controls.sql` | 人工履约、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 | -| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 | +| 迁移 | 内容 | +| -------------------------------- | ----------------------------------------------------------------- | +| `103_finance_foundation.sql` | 商品目录、成本中心、成本项/版本/快照、销售事实、退款单 | +| `104_finance_controls.sql` | 人工履约、备用金/积分、影子总账、对账/关账、财务控制、L1 钱包硬化 | +| `105_work_order_consignment.sql` | 拼单多次参与、大厅/店铺索引、撤份额注释、寄卖对接客服 | - 生产升级:**102 → 103 → 104 → 105**,只执行 3 个迁移文件。 - 全新库:`001 → 105` 一次跑完。 @@ -461,35 +465,35 @@ npm run revenue:backfill -- --batch-size=2000 --max-batches=100 ## 九、索引与性能 -| 索引 | 用途 | -| --- | --- | -| `idx_order_items_fulfillment_channel (fulfillment_channel, order_id)` | 按渠道筛订单项 | -| `idx_order_items_fulfillment_id (fulfillment_status, id)` | 管道按状态扫增量 | -| `idx_order_items_canonical_product` | 按标准商品查订单项 | -| `idx_order_items_normalized_sku_lookup` | 按归一名称/系列匹配映射 | -| `idx_channel_product_mappings_match_lookup` | 映射查找 | -| `uq_channel_product_mappings_match_key` | 映射唯一键 | -| `idx_cost_versions_lookup / product / rule` | 成本版本解析 | -| `uq_cost_snapshots_active_subject_item` | 每个主体 + 成本项只允许一条有效快照 | -| `idx_cost_exceptions_status_created` | 异常队列列表 | -| `idx_sales_daily_facts_date / shop / product / mode` | 销售事实各维度 | -| `idx_work_orders_rule_cover` | 成本版本页按接单规则聚合 | -| `idx_order_items_revenue_cover` | 收入聚合 | -| `idx_work_categories_name` | 商品目录按分类名 lateral join | -| `idx_cost_exceptions_archivable` | 归档任务扫描「已处理且超期」的异常(部分索引,`status <> 'open'`) | +| 索引 | 用途 | +| --------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `idx_order_items_fulfillment_channel (fulfillment_channel, order_id)` | 按渠道筛订单项 | +| `idx_order_items_fulfillment_id (fulfillment_status, id)` | 管道按状态扫增量 | +| `idx_order_items_canonical_product` | 按标准商品查订单项 | +| `idx_order_items_normalized_sku_lookup` | 按归一名称/系列匹配映射 | +| `idx_channel_product_mappings_match_lookup` | 映射查找 | +| `uq_channel_product_mappings_match_key` | 映射唯一键 | +| `idx_cost_versions_lookup / product / rule` | 成本版本解析 | +| `uq_cost_snapshots_active_subject_item` | 每个主体 + 成本项只允许一条有效快照 | +| `idx_cost_exceptions_status_created` | 异常队列列表 | +| `idx_sales_daily_facts_date / shop / product / mode` | 销售事实各维度 | +| `idx_work_orders_rule_cover` | 成本版本页按接单规则聚合 | +| `idx_order_items_revenue_cover` | 收入聚合 | +| `idx_work_categories_name` | 商品目录按分类名 lateral join | +| `idx_cost_exceptions_archivable` | 归档任务扫描「已处理且超期」的异常(部分索引,`status <> 'open'`) | ### 9.1 成本异常归档(60 天) `cost_exceptions` 只增不减:本地快照 260,253 行(resolved 151,939 / open 108,314)。已处理的行只是审计痕迹,继续留在热表会拖慢按状态+时间的分页,并让唯一索引持续膨胀。 -| 项 | 规则 | -| --- | --- | -| 归档对象 | `status <> 'open'` 且 `COALESCE(resolved_at, created_at)` 超过保留期 | -| open 行 | **永不归档**——它是待办队列,超期也要留在页面上 | -| 保留期 | `COST_EXCEPTION_RETENTION_DAYS`,默认 **60** 天 | -| 落点 | `cost_exceptions_archive`(保留原 id 与时间字段,加 `archived_at`) | -| 执行 | `data-retention` 每日任务;每批 1000 行,`DELETE ... RETURNING` + `INSERT` 同语句,单批原子 | -| 手动 | `npm run cost-exceptions:archive -- --dry-run` / `-- --days=90` | +| 项 | 规则 | +| ---------- | ---------------------------------------------------------------------------------------------- | +| 归档对象 | `status <> 'open'` 且 `COALESCE(resolved_at, created_at)` 超过保留期 | +| open 行 | **永不归档**——它是待办队列,超期也要留在页面上 | +| 保留期 | `COST_EXCEPTION_RETENTION_DAYS`,默认 **60** 天 | +| 落点 | `cost_exceptions_archive`(保留原 id 与时间字段,加 `archived_at`) | +| 执行 | `data-retention` 每日任务;每批 1000 行,`DELETE ... RETURNING` + `INSERT` 同语句,单批原子 | +| 手动 | `npm run cost-exceptions:archive -- --dry-run` / `-- --days=90` | | 一次性清理 | `npm run cost-exceptions:archive -- --days=0`(不按时间过滤,归档全部已处理行;open 仍不归档) | 归档不删除审计痕迹,但热表查询与页面上不会再出现这些历史行;需要追溯时查归档表。