refactor(cost-center): 精简成本中心,人工成本以 SKU 配置为唯一真源

- 删除「成本项目」「成本版本」「成本预览」三个 Tab:成本项只有 3 条系统内置,版本金额只是快照挂载点
- 新增只读「发单价格」面板,仅在接单平台来源显示,改价仍走接单模板后同步
- 成本预览改为弹窗,从成本异常行和订单详情「查成本」进入;人工单显示 SKU 配置成本并禁止生成快照
- 成本异常新增按原因聚合摘要接口,列表支持 reason/since 筛选,默认只看近 24 小时
- 迁移 111:把 sys_manual_service 的历史 ¥88 全局版本清零;预估同步与成本缺失判定排除 manual_actual
This commit is contained in:
yml2213
2026-10-24 16:07:25 +08:00
parent 9f2206430f
commit 956ef5d695
16 changed files with 904 additions and 1124 deletions
@@ -0,0 +1,31 @@
-- 人工履约成本金额的唯一真源是 manual_fulfillment_configs.credit_quantity × 平台换算率。
-- cost_versions 里的 sys_manual_service 行只是 cost_snapshots.cost_version_id 的挂载点,
-- 金额不参与计算;历史上手工填的 ¥88 全局版本会让「成本预览」显示错误金额,这里清零。
UPDATE cost_versions v
SET amount_fen = 0,
rate_bps = 0,
change_reason = '系统占位:人工成本按人工 SKU 配置的消耗积分结算,版本金额不参与计算',
updated_at = NOW()
FROM cost_items ci
WHERE ci.id = v.cost_item_id
AND ci.code = 'sys_manual_service'
AND v.scope_type = 'global'
AND v.status = 'active';
-- 若人工成本项还没有生效版本,补一条 0 元全局占位,保证快照能挂到版本上。
INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, source,
status, change_reason, created_at, updated_at
)
SELECT
ci.id, 'global', '', '', NULL, NULL, 0, 0,
'1970-01-01T00:00:00+08:00', 'manual', 'active',
'系统占位:人工成本按人工 SKU 配置的消耗积分结算', NOW(), NOW()
FROM cost_items ci
WHERE ci.code = 'sys_manual_service'
AND NOT EXISTS (
SELECT 1 FROM cost_versions v
WHERE v.cost_item_id = ci.id AND v.status = 'active'
);
@@ -17,6 +17,7 @@ import {
previewAdminCost,
resolveAdminCostException,
retireAdminCostVersion,
summarizeAdminCostExceptions,
updateAdminCostItem,
} from '../../services/cost-center/cost-definition-service.js'
import {
@@ -515,6 +516,17 @@ router.get(
}),
)
// 必须注册在 /cost-center/exceptions/:exceptionId 之前,避免 summary 被当成 ID。
router.get(
'/cost-center/exceptions/summary',
requireAdminPermission('finance.view'),
createJsonHandler((req) => summarizeAdminCostExceptions(req.query as JsonObject), {
successMessage: 'ok',
errorMessage: '读取成本异常汇总失败',
scope: '[admin/cost-center/exceptions/summary]',
}),
)
router.patch(
'/cost-center/exceptions/:exceptionId',
requireAdminPermission('finance.config'),
@@ -570,6 +570,17 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
params.push(subjectType)
clauses.push(`ce.subject_type = $${params.length}`)
}
const reason = text(payload.reason)
if (reason) {
params.push(reason)
clauses.push(`ce.reason = $${params.length}`)
}
// 时间范围:默认由前端传「近 N 天」,历史批量扫描产生的异常需要显式放开才能看到。
const since = normalizeIsoDate(payload.since, '异常起始时间', true)
if (since) {
params.push(since)
clauses.push(`ce.created_at >= $${params.length}::timestamptz`)
}
// 履约方式筛选:异常表不再存载体,改为 join order_items 读 fulfillment_mode。
// 用 IN 半连接而不是 EXISTS:248k 异常 × 316k 订单项下,EXISTS 会逐行回表,
// 半连接能一次物化候选集合(实测 2.7s -> 0.4s)。
@@ -610,6 +621,90 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
}
}
export type CostExceptionSummary = {
recentDays: number
openTotal: number
recentOpenTotal: number
historicalOpenTotal: number
reasons: Array<{ reason: string; openCount: number; recentOpenCount: number }>
}
/**
* 成本异常汇总:按原因聚合,并区分「近 N 天新增」与「历史批量」。
*
* 历史扫描会一次性写入十几万条开放异常,直接列全部没有可操作性;
* 页面先看近 N 天的量,再决定是否展开历史。
*/
export async function summarizeAdminCostExceptions(
payload: JsonObject = {},
): Promise<CostExceptionSummary> {
const rawDays = toNumber(payload.recentDays ?? payload.recent_days ?? 7)
const recentDays = Math.min(Math.max(Number.isFinite(rawDays) ? Math.round(rawDays) : 7, 1), 90)
const params: unknown[] = []
const clauses: string[] = []
const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode)
if (fulfillmentMode) {
assertOneOf(fulfillmentMode, FULFILLMENT_MODES, '履约方式无效')
params.push(fulfillmentMode)
clauses.push(
`ce.order_item_id IN (
SELECT oi.id FROM order_items oi WHERE oi.fulfillment_mode = $${params.length}
)`,
)
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''
params.push(recentDays)
const recentInterval = `NOW() - make_interval(days => $${params.length}::int)`
const totals = await query<{ open_total: number; recent_open_total: number }>(
`
SELECT
COUNT(*) FILTER (WHERE ce.status = 'open')::int AS open_total,
COUNT(*) FILTER (
WHERE ce.status = 'open' AND ce.created_at >= ${recentInterval}
)::int AS recent_open_total
FROM cost_exceptions ce
${where}
`,
params,
)
const reasons = await query<{
reason: string
open_count: number
recent_open_count: number
}>(
`
SELECT
ce.reason,
COUNT(*) FILTER (WHERE ce.status = 'open')::int AS open_count,
COUNT(*) FILTER (
WHERE ce.status = 'open' AND ce.created_at >= ${recentInterval}
)::int AS recent_open_count
FROM cost_exceptions ce
${where}
GROUP BY ce.reason
ORDER BY open_count DESC, ce.reason ASC
`,
params,
)
const openTotal = toNumber(totals.rows[0]?.open_total)
const recentOpenTotal = toNumber(totals.rows[0]?.recent_open_total)
return {
recentDays,
openTotal,
recentOpenTotal,
historicalOpenTotal: Math.max(0, openTotal - recentOpenTotal),
reasons: reasons.rows.map((row) => ({
reason: row.reason,
openCount: toNumber(row.open_count),
recentOpenCount: toNumber(row.recent_open_count),
})),
}
}
export async function resolveAdminCostException(
exceptionId: number | string,
payload: JsonObject,
@@ -691,6 +786,14 @@ export async function previewAdminCost(payload: JsonObject = {}) {
})
}
// 人工履约的成本来自「人工 SKU 配置的消耗积分 × 平台换算率」,版本金额只是占位;
// 这里单独给出提示,并从成本行里剔掉 0 元占位,避免预览与实际扣款对不上。
const manualCost =
context.fulfillmentMode === 'manual' ? await resolveManualCostHint(context.orderItemId) : null
const lines = manualCost
? resolution.lines.filter((line) => line.costItemCode !== 'sys_manual_service')
: resolution.lines
return {
context: {
...context,
@@ -698,13 +801,52 @@ export async function previewAdminCost(payload: JsonObject = {}) {
},
mapping: mapping ? mapMapping(mapping) : null,
product,
lines: resolution.lines,
missingCostItems: resolution.missingCostItems,
totalCostFen: resolution.lines.reduce((sum, line) => sum + line.totalAmountFen, 0),
lines,
missingCostItems: resolution.missingCostItems.filter(
(item) => !(manualCost && item.costItemCode === 'sys_manual_service'),
),
manualCost,
totalCostFen: lines.reduce((sum, line) => sum + line.totalAmountFen, 0),
exceptions,
}
}
/** 人工履约成本提示:直接读订单项绑定的履约配置。 */
async function resolveManualCostHint(orderItemId: number | null) {
if (!orderItemId) return null
const result = await query<{
platform_key: string
credit_quantity: string
cost_mode: string
unit_price_fen: string
}>(
`
SELECT
cfg.platform_key,
cfg.credit_quantity,
cfg.cost_mode,
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS unit_price_fen
FROM order_items oi
JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key
WHERE oi.id = $1
LIMIT 1
`,
[orderItemId],
)
const row = result.rows[0]
if (!row) return null
const creditQuantity = Number(row.credit_quantity || 0)
const unitPriceFen = Number(row.unit_price_fen || 1)
return {
platformKey: row.platform_key,
creditQuantity,
unitPriceFen,
costMode: row.cost_mode === 'fixed' ? ('fixed' as const) : ('variable' as const),
costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null,
}
}
export async function createAdminCostSnapshot(payload: JsonObject = {}, _session: AdminSession) {
const preview = await previewAdminCost(payload)
const orderItemId = preview.context.orderItemId
@@ -81,6 +81,9 @@ export async function syncEstimatedOrderItemCostSnapshots(
AND ${costModelScopeSql('s.fulfillment_mode')}
AND ci.pricing_mode IN ('per_item', 'per_order', 'percent_of_revenue')
AND ci.code <> 'sys_work_order_reward'
-- 人工成本只能在「确认履约」时按实际支出写 actual 快照,
-- 这里的版本金额是 0 元占位,不能拿来生成预估快照。
AND ci.cost_model <> 'manual_actual'
JOIN LATERAL (
SELECT v.id, v.amount_fen, v.rate_bps
FROM cost_versions v
@@ -697,6 +700,9 @@ async function insertAndResolveCostMissingExceptions(
NOW()
FROM item_scope s
WHERE s.canonical_product_id IS NOT NULL
-- 人工履约不靠成本版本结算:金额由「人工 SKU 配置 × 换算率」决定,
-- 并在客服确认履约时写 manual_case 实际快照,因此不参与成本缺失判定。
AND s.fulfillment_mode <> 'manual'
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'order_item'