refactor(cost-center): 收敛人工 SKU 自动创建流程
This commit is contained in:
@@ -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 配置自动创建/,
|
||||
)
|
||||
})
|
||||
|
||||
-84
@@ -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')"))
|
||||
})
|
||||
|
||||
+126
-86
@@ -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:<skuKey>` 标准商品,再写人工配置和
|
||||
`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, // 积分路径:管理员/财务指定客服账号
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+105
-101
@@ -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 仍不归档) |
|
||||
|
||||
归档不删除审计痕迹,但热表查询与页面上不会再出现这些历史行;需要追溯时查归档表。
|
||||
|
||||
Reference in New Issue
Block a user