refactor(finance): 成本模型与履约方式正交,拆分成本中心巨型文件
后端: - 删除 services/admin/admin-cost-center-service.ts(4,248 行),拆为 services/cost-center/ 下 5 个模块: cost-center-shared(类型/枚举/归一化/映射/costModelScopeSql)、 cost-center-overview(总览与选项)、 product-catalog(商品与渠道映射)、 template-bootstrap(模板引导、自动发货同步、工单证据回填)、 cost-definition(成本项/版本/异常/预览/快照) - cost_items.carrier_type / cost_type 改为单一 cost_model;专属模型只对 对应履约方式生效,通用模型对所有履约方式生效 - cost_snapshots / cost_exceptions 删除载体列;按履约方式筛异常改用 IN 半连接(248k 异常下 EXISTS 逐行回表 2.7s -> 170ms) - sales_daily_facts.carrier_type -> fulfillment_mode - 人工支出按 cost_model 归类;履约方式只读 order_items.fulfillment_mode - 移除 syncOpenCostExceptionCarrierTypes:异常表已无载体列可同步 前端: - AdminCostCenterPage.tsx(2,887 行 -> 18 行转发),拆为 pages/admin/cost-center/ 下 8 个 Panel + shared + CostCenterContext - CostCarrierType 删除;新增 FulfillmentMode / ProductSource / CostModel - 销售毛利页 carrierType -> fulfillmentMode 同步补两个索引:idx_order_items_fulfillment_id(异常半连接)、 idx_work_categories_name(商品目录分类 lateral join)。
This commit is contained in:
@@ -180,18 +180,17 @@ export async function listManualExpenseCostItems() {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
cost_type: string
|
||||
carrier_type: string
|
||||
cost_model: string
|
||||
pricing_mode: string
|
||||
}>(
|
||||
`
|
||||
SELECT id, code, name, cost_type, carrier_type, pricing_mode
|
||||
SELECT id, code, name, cost_model, pricing_mode
|
||||
FROM cost_items
|
||||
WHERE status = 'active'
|
||||
AND code <> 'sys_work_order_reward'
|
||||
ORDER BY
|
||||
CASE WHEN code = 'sys_manual_service' THEN 0 ELSE 1 END,
|
||||
carrier_type,
|
||||
cost_model,
|
||||
id
|
||||
`,
|
||||
)
|
||||
@@ -200,8 +199,7 @@ export async function listManualExpenseCostItems() {
|
||||
id: Number(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
costType: row.cost_type,
|
||||
carrierType: row.carrier_type,
|
||||
costModel: row.cost_model,
|
||||
pricingMode: row.pricing_mode,
|
||||
})),
|
||||
}
|
||||
@@ -369,7 +367,7 @@ export async function createManualExpense(payload: JsonObject, session: AdminSes
|
||||
const expenseCategory = normalizeStatus(
|
||||
payload.expenseCategory ?? payload.expense_category,
|
||||
EXPENSE_CATEGORIES,
|
||||
inferExpenseCategory(costItem?.costType),
|
||||
inferExpenseCategory(costItem?.costModel),
|
||||
'支出分类无效',
|
||||
)
|
||||
const requestedFundAccountId = normalizeOptionalId(
|
||||
@@ -758,7 +756,7 @@ async function attachCostSnapshotForExpense(
|
||||
shop_id: string
|
||||
work_order_id: number | null
|
||||
work_product_rule_id: number | null
|
||||
carrier_type: string
|
||||
fulfillment_mode: string
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
@@ -768,10 +766,9 @@ async function attachCostSnapshotForExpense(
|
||||
o.shop_id,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
ci.carrier_type
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN cost_items ci ON ci.id = $2
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, product_rule_id
|
||||
FROM work_orders
|
||||
@@ -838,16 +835,15 @@ async function attachCostSnapshotForExpense(
|
||||
`
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
carrier_type, reason, status, detail_json, created_at
|
||||
reason, status, detail_json, created_at
|
||||
) VALUES (
|
||||
'order_item', $1, $1, $2, $3, 'manual_expense_cost_missing', 'open', $4::jsonb, NOW()
|
||||
'order_item', $1, $1, $2, 'manual_expense_cost_missing', 'open', $3::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
|
||||
`,
|
||||
[
|
||||
expense.order_item_id,
|
||||
context.work_order_id,
|
||||
context.carrier_type,
|
||||
JSON.stringify({
|
||||
expenseId: expense.id,
|
||||
expenseNo: expense.expense_no,
|
||||
@@ -862,14 +858,14 @@ async function attachCostSnapshotForExpense(
|
||||
`
|
||||
INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
carrier_type, cost_item_id, cost_version_id, canonical_product_id,
|
||||
cost_item_id, cost_version_id, canonical_product_id,
|
||||
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
|
||||
metadata_json, cost_basis_at, resolved_at, created_at
|
||||
) VALUES (
|
||||
'manual_case', $1, $2, $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
1, $9, $9, 'actual', 'manual_input',
|
||||
$10::jsonb, $11, $12, $12
|
||||
$5, $6, $7,
|
||||
1, $8, $8, 'actual', 'manual_input',
|
||||
$9::jsonb, $10, $11, $11
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, cost_item_id)
|
||||
WHERE superseded_at IS NULL
|
||||
@@ -881,7 +877,6 @@ async function attachCostSnapshotForExpense(
|
||||
context.order_id,
|
||||
expense.order_item_id,
|
||||
context.work_order_id,
|
||||
context.carrier_type,
|
||||
expense.cost_item_id,
|
||||
versionId,
|
||||
context.canonical_product_id,
|
||||
@@ -893,6 +888,7 @@ async function attachCostSnapshotForExpense(
|
||||
paidByName: expense.paid_by_name,
|
||||
payeeName: expense.payee_name,
|
||||
approvedBy: session.displayName,
|
||||
fulfillmentMode: context.fulfillment_mode,
|
||||
}),
|
||||
expense.paid_at,
|
||||
nowIso(),
|
||||
@@ -1008,12 +1004,9 @@ async function resolveCostItem(costItemId: number | null) {
|
||||
const result = await query<{
|
||||
id: number
|
||||
code: string
|
||||
cost_type: string
|
||||
carrier_type: string
|
||||
cost_model: string
|
||||
status: string
|
||||
}>(`SELECT id, code, cost_type, carrier_type, status FROM cost_items WHERE id = $1 LIMIT 1`, [
|
||||
costItemId,
|
||||
])
|
||||
}>(`SELECT id, code, cost_model, status FROM cost_items WHERE id = $1 LIMIT 1`, [costItemId])
|
||||
const row = result.rows[0]
|
||||
if (!row) {
|
||||
throw createHttpError('成本项目不存在', {
|
||||
@@ -1033,7 +1026,7 @@ async function resolveCostItem(costItemId: number | null) {
|
||||
errorCode: 'manual_expense_cost_item_archived',
|
||||
})
|
||||
}
|
||||
return { id: Number(row.id), costType: row.cost_type, carrierType: row.carrier_type }
|
||||
return { id: Number(row.id), costModel: row.cost_model }
|
||||
}
|
||||
|
||||
function mapManualExpense(row: ManualExpenseRow) {
|
||||
@@ -1085,11 +1078,11 @@ function mapManualExpense(row: ManualExpenseRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function inferExpenseCategory(costType: string | undefined) {
|
||||
if (costType === 'auto_purchase') return 'goods_purchase'
|
||||
if (costType === 'manual_service') return 'service_fee'
|
||||
if (costType === 'shipping_other') return 'freight'
|
||||
if (costType === 'payment_fee' || costType === 'platform_fee') return 'service_fee'
|
||||
function inferExpenseCategory(costModel: string | undefined) {
|
||||
if (costModel === 'affiliate_purchase') return 'goods_purchase'
|
||||
if (costModel === 'manual_actual') return 'service_fee'
|
||||
if (costModel === 'other') return 'freight'
|
||||
if (costModel === 'payment_fee' || costModel === 'platform_fee') return 'service_fee'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ type SalesFactRow = {
|
||||
canonical_product_id: number | null
|
||||
canonical_sku: string | null
|
||||
canonical_name: string | null
|
||||
carrier_type: string
|
||||
fulfillment_mode: string
|
||||
order_count: number
|
||||
item_count: number
|
||||
quantity: string | number
|
||||
@@ -64,7 +64,7 @@ export async function rebuildSalesDailyFacts(
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.canonical_product_id,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS carrier_type,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode,
|
||||
oi.order_id,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
CASE
|
||||
@@ -91,7 +91,7 @@ export async function rebuildSalesDailyFacts(
|
||||
BETWEEN $1::date AND $2::date
|
||||
)
|
||||
INSERT INTO sales_daily_facts (
|
||||
stat_date, provider, shop_id, canonical_product_id, carrier_type,
|
||||
stat_date, provider, shop_id, canonical_product_id, fulfillment_mode,
|
||||
order_count, item_count, quantity,
|
||||
gross_revenue_fen, refund_amount_fen, net_revenue_fen,
|
||||
cost_amount_fen, cost_missing_count, cost_stage, gross_profit_fen,
|
||||
@@ -102,7 +102,7 @@ export async function rebuildSalesDailyFacts(
|
||||
provider,
|
||||
shop_id,
|
||||
canonical_product_id,
|
||||
carrier_type,
|
||||
fulfillment_mode,
|
||||
COUNT(DISTINCT order_id)::int,
|
||||
COUNT(*)::int,
|
||||
SUM(quantity),
|
||||
@@ -123,7 +123,7 @@ export async function rebuildSalesDailyFacts(
|
||||
$3::timestamptz,
|
||||
$3::timestamptz
|
||||
FROM item_base
|
||||
GROUP BY stat_date, provider, shop_id, canonical_product_id, carrier_type
|
||||
GROUP BY stat_date, provider, shop_id, canonical_product_id, fulfillment_mode
|
||||
`,
|
||||
[range.fromDate, range.toDate, computedAt],
|
||||
)
|
||||
@@ -196,7 +196,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
|
||||
[range.fromDate, range.toDate],
|
||||
),
|
||||
query<{
|
||||
carrier_type: string
|
||||
fulfillment_mode: string
|
||||
item_count: number
|
||||
gross_revenue_fen: number
|
||||
cost_amount_fen: number
|
||||
@@ -205,7 +205,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
carrier_type,
|
||||
fulfillment_mode,
|
||||
SUM(item_count)::int AS item_count,
|
||||
SUM(gross_revenue_fen)::bigint AS gross_revenue_fen,
|
||||
SUM(cost_amount_fen)::bigint AS cost_amount_fen,
|
||||
@@ -213,7 +213,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
|
||||
SUM(gross_profit_fen)::bigint AS gross_profit_fen
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||||
GROUP BY carrier_type
|
||||
GROUP BY fulfillment_mode
|
||||
ORDER BY gross_revenue_fen DESC
|
||||
`,
|
||||
[range.fromDate, range.toDate],
|
||||
@@ -302,8 +302,8 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
|
||||
costMissingCount: Number(row.cost_missing_count || 0),
|
||||
grossProfitFen: Number(row.gross_profit_fen || 0),
|
||||
})),
|
||||
byCarrier: carrierResult.rows.map((row) => ({
|
||||
carrierType: row.carrier_type,
|
||||
byFulfillmentMode: carrierResult.rows.map((row) => ({
|
||||
fulfillmentMode: row.fulfillment_mode,
|
||||
itemCount: Number(row.item_count || 0),
|
||||
grossRevenueFen: Number(row.gross_revenue_fen || 0),
|
||||
costAmountFen: Number(row.cost_amount_fen || 0),
|
||||
@@ -331,7 +331,9 @@ export async function listSalesDailyFacts(input: Record<string, unknown> = {}) {
|
||||
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
|
||||
)
|
||||
const provider = String(input.provider || '').trim()
|
||||
const carrierType = String(input.carrierType || input.carrier_type || '').trim()
|
||||
const fulfillmentMode = String(
|
||||
input.fulfillmentMode || input.fulfillment_mode || input.carrierType || '',
|
||||
).trim()
|
||||
const canonicalProductId = Number(input.canonicalProductId || input.canonical_product_id || 0)
|
||||
|
||||
const conditions = ['f.stat_date BETWEEN $1::date AND $2::date']
|
||||
@@ -340,9 +342,9 @@ export async function listSalesDailyFacts(input: Record<string, unknown> = {}) {
|
||||
params.push(provider)
|
||||
conditions.push(`f.provider = $${params.length}`)
|
||||
}
|
||||
if (carrierType) {
|
||||
params.push(carrierType)
|
||||
conditions.push(`f.carrier_type = $${params.length}`)
|
||||
if (fulfillmentMode) {
|
||||
params.push(fulfillmentMode)
|
||||
conditions.push(`f.fulfillment_mode = $${params.length}`)
|
||||
}
|
||||
if (Number.isInteger(canonicalProductId) && canonicalProductId > 0) {
|
||||
params.push(canonicalProductId)
|
||||
@@ -391,7 +393,7 @@ function mapSalesFact(row: SalesFactRow) {
|
||||
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
|
||||
canonicalSku: row.canonical_sku || '',
|
||||
canonicalName: row.canonical_name || '未归一',
|
||||
carrierType: row.carrier_type,
|
||||
fulfillmentMode: row.fulfillment_mode,
|
||||
orderCount: Number(row.order_count || 0),
|
||||
itemCount: Number(row.item_count || 0),
|
||||
quantity: Number(row.quantity || 0),
|
||||
|
||||
Reference in New Issue
Block a user