refactor(cost-center): 精简成本中心,人工成本以 SKU 配置为唯一真源
- 删除「成本项目」「成本版本」「成本预览」三个 Tab:成本项只有 3 条系统内置,版本金额只是快照挂载点 - 新增只读「发单价格」面板,仅在接单平台来源显示,改价仍走接单模板后同步 - 成本预览改为弹窗,从成本异常行和订单详情「查成本」进入;人工单显示 SKU 配置成本并禁止生成快照 - 成本异常新增按原因聚合摘要接口,列表支持 reason/since 筛选,默认只看近 24 小时 - 迁移 111:把 sys_manual_service 的历史 ¥88 全局版本清零;预估同步与成本缺失判定排除 manual_actual
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user