Files
order_site/apps/backend/src/services/cost-center/cost-resolution-service.ts
T

680 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 成本解析与快照:预览、解析上下文、按成本版本计算并落快照。
*
* 口径:金额只来自 cost_versions;匹配不到成本进异常队列,不默认 0;
* 历史快照永不回写(superseded_at 只标记被实际成本替代)。
* 成本定义 CRUD 见 cost-definition-service.ts。
*/
import { query, withTransaction } from '../../db/client.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { getProductRow } from './product-catalog-service.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
import { resolveSkuNameQuantity } from '../product-match/pricing.js'
import {
FULFILLMENT_CHANNELS,
costModelScopeSql,
mapCostVersion,
mapMapping,
mapProduct,
normalizeIsoDate,
normalizeOptionalId,
normalizeOptionalText,
normalizeRequiredText,
requiredRow,
text,
} from './cost-center-shared.js'
import type {
CostItemRow,
CostLine,
CostPreview,
CostResolution,
CostVersionRow,
FulfillmentChannel,
MappingRow,
MissingCostItem,
OrderItemCostContext,
} from './cost-center-shared.js'
import { calculateManualCostFen, parseManualChannelQuantity } from './manual-credit-quantity.js'
export async function previewAdminCost(payload: JsonObject = {}) {
const context = await resolveCostContext(payload)
const mapping = await findMappingForContext(context)
const productId = context.canonicalProductId || mapping?.canonical_product_id || null
const product = productId ? mapProduct(await getProductRow(productId)) : null
const asOf =
normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || context.basisAt
const resolution: CostResolution = productId
? await resolveCostLines({
provider: context.provider,
shopId: context.shopId,
channel: context.channel,
canonicalProductId: productId,
workProductRuleId: context.workProductRuleId,
quantity: context.normalizedQuantity,
revenueFen: context.revenueFen,
asOf,
})
: { lines: [], missingCostItems: [], applicableCostItemCount: 0 }
const exceptions: CostPreview['exceptions'] = []
if (!productId) {
exceptions.push({
reason: 'product_mapping_missing',
message: `未找到 ${context.provider} / ${context.shopId || '全局'} / ${context.skuCode} 的标准商品映射`,
detail: {
provider: context.provider,
shopId: context.shopId,
skuCode: context.skuCode,
},
})
} else if (resolution.missingCostItems.length > 0) {
exceptions.push({
reason: 'cost_missing',
message: `以下成本项没有匹配到有效成本版本:${resolution.missingCostItems
.map((item) => `${item.costItemName}(${item.costItemCode})`)
.join('、')}`,
detail: {
channel: context.channel,
costItemIds: resolution.missingCostItems.map((item) => item.costItemId),
},
})
} else if (resolution.applicableCostItemCount === 0) {
exceptions.push({
reason: 'cost_missing',
message: `没有配置适用于「${context.channel}」的成本项`,
detail: { channel: context.channel },
})
}
// 人工履约的成本来自「人工 SKU 配置的消耗积分 × 平台换算率」,版本金额只是占位;
// 这里单独给出提示,并从成本行里剔掉 0 元占位,避免预览与实际扣款对不上。
const manualCost =
context.channel === 'manual' ? await resolveManualCostHint(context.orderItemId) : null
const lines = manualCost
? resolution.lines.filter((line) => line.costItemCode !== 'sys_manual_service')
: resolution.lines
return {
context: {
...context,
basisAt: asOf,
},
mapping: mapping ? mapMapping(mapping) : null,
product,
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 = parseManualChannelQuantity(row.credit_quantity, row.platform_key) ?? 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 ? calculateManualCostFen(creditQuantity, unitPriceFen) : null,
}
}
export async function createAdminCostSnapshot(payload: JsonObject = {}, _session: AdminSession) {
const preview = await previewAdminCost(payload)
const orderItemId = preview.context.orderItemId
if (!orderItemId) {
throw createHttpError('生成成本快照必须提供订单项 ID', {
statusCode: 400,
errorCode: 'cost_snapshot_order_item_required',
})
}
const now = nowIso()
const stage = payload.snapshotStage === 'actual' ? 'actual' : 'estimated'
const snapshots: Array<Record<string, unknown>> = []
let exceptionCreatedCount = 0
let supersededCount = 0
await withTransaction(async (client) => {
for (const line of preview.lines) {
const currentResult = await client.query<{
id: number
snapshot_stage: string
total_amount_fen: number
}>(
`
SELECT id, snapshot_stage, total_amount_fen
FROM cost_snapshots
WHERE subject_type = 'order_item'
AND subject_id = $1
AND cost_item_id = $2
AND superseded_at IS NULL AND reversed_at IS NULL
FOR UPDATE
`,
[orderItemId, line.costItemId],
)
const current = currentResult.rows[0]
if (current) {
// 预估快照一旦生成就代表支付时点的成本,不因后续改价重写;
// 只有实际成本允许替代当前有效行。
if (stage === 'estimated') continue
if (
current.snapshot_stage === 'actual' &&
Number(current.total_amount_fen) === line.totalAmountFen
) {
continue
}
await client.query(
`UPDATE cost_snapshots SET superseded_at = $1 WHERE id = $2 AND superseded_at IS NULL AND reversed_at IS NULL`,
[now, current.id],
)
supersededCount += 1
}
const result = await client.query<{ [column: string]: unknown }>(
`
INSERT INTO cost_snapshots (
subject_type, subject_id, order_id, order_item_id, work_order_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 (
'order_item', $1, $2, $1, $3, $4, $5, $6,
$7, $8, $9, $10, 'resolved', $11::jsonb, $12, $13, $13
)
ON CONFLICT (subject_type, subject_id, cost_item_id)
WHERE superseded_at IS NULL AND reversed_at IS NULL
DO NOTHING
RETURNING *
`,
[
orderItemId,
preview.context.orderId,
preview.context.workOrderId,
line.costItemId,
line.costVersionId,
preview.product?.id || null,
line.quantity,
line.unitAmountFen,
line.totalAmountFen,
stage,
JSON.stringify({
skuCode: preview.context.skuCode,
skuName: preview.context.skuName,
provider: preview.context.provider,
shopId: preview.context.shopId,
normalizedQuantity: preview.context.normalizedQuantity,
channel: preview.context.channel,
basisAt: preview.context.basisAt,
}),
preview.context.basisAt,
now,
],
)
if (result.rows[0]) snapshots.push(result.rows[0])
}
for (const exception of preview.exceptions) {
const result = await client.query(
`
INSERT INTO cost_exceptions (
subject_type, subject_id, order_item_id, work_order_id,
reason, status, detail_json, created_at
) VALUES ('order_item', $1, $1, $2, $3, 'open', $4::jsonb, $5)
ON CONFLICT (scope_key) WHERE status <> 'resolved' DO NOTHING
`,
[
orderItemId,
preview.context.workOrderId,
exception.reason,
JSON.stringify({
...exception.detail,
message: exception.message,
skuCode: preview.context.skuCode,
}),
now,
],
)
exceptionCreatedCount += result.rowCount || 0
}
})
return {
preview,
snapshots,
createdCount: snapshots.length,
supersededCount,
exceptionCreatedCount,
}
}
async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostContext> {
const orderItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id)
if (orderItemId) {
const result = await query<{
order_item_id: number
order_id: number
provider: string
shop_id: string
sku_code: string
sku_name: string
normalized_sku_name: string | null
normalized_sku_series: string | null
quantity: number
normalized_quantity: number | null
canonical_product_id: number | null
revenue_fen: number
basis_at: string
work_order_id: number | null
work_product_rule_id: number | null
channel: string | null
}>(
`
SELECT
oi.id AS order_item_id,
oi.order_id,
o.provider,
o.shop_id,
oi.sku_code,
oi.sku_name,
oi.normalized_sku_name,
oi.normalized_sku_series,
oi.quantity,
oi.normalized_quantity,
oi.canonical_product_id,
oi.revenue_amount_fen AS revenue_fen,
COALESCE(o.paid_at, o.created_at) AS basis_at,
wo.id AS work_order_id,
wo.product_rule_id AS work_product_rule_id,
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN LATERAL (
SELECT id, product_rule_id
FROM work_orders
WHERE order_item_id = oi.id
ORDER BY id DESC
LIMIT 1
) wo ON TRUE
WHERE oi.id = $1
LIMIT 1
`,
[orderItemId],
)
const row = result.rows[0]
if (!row) {
throw createHttpError('订单项不存在', {
statusCode: 404,
errorCode: 'cost_order_item_not_found',
})
}
return {
orderItemId: Number(row.order_item_id),
orderId: Number(row.order_id),
provider: row.provider,
shopId: row.shop_id,
skuCode: row.sku_code,
skuName: row.sku_name,
normalizedSkuName:
row.normalized_sku_name || normalizeMatchText(row.sku_name || row.sku_code),
normalizedSkuSeries:
row.normalized_sku_series || normalizeSkuSeriesEquivalence(row.sku_name || row.sku_code),
quantity: Math.max(1, Number(row.quantity) || 1),
normalizedQuantity:
row.normalized_quantity && Number(row.normalized_quantity) > 0
? Number(row.normalized_quantity)
: Math.max(1, resolveSkuNameQuantity(row.sku_name || row.sku_code)),
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
revenueFen: Math.max(0, Number(row.revenue_fen) || 0),
basisAt: new Date(row.basis_at).toISOString(),
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null,
channel: resolveFulfillmentMode(row.channel),
}
}
const provider = normalizeRequiredText(payload.provider, '来源', 64)
const skuCode = normalizeRequiredText(payload.skuCode ?? payload.sku_code, '平台 SKU', 120)
const quantity = Math.max(1, Math.trunc(Number(payload.quantity) || 1))
const skuName = normalizeOptionalText(payload.skuName ?? payload.sku_name, 200)
const channel = resolveFulfillmentMode(payload.channel ?? payload.channel)
return {
orderItemId: 0,
orderId: 0,
provider,
shopId: normalizeOptionalText(payload.shopId ?? payload.shop_id, 120),
skuCode,
skuName,
normalizedSkuName: normalizeMatchText(skuName || skuCode),
normalizedSkuSeries: normalizeSkuSeriesEquivalence(skuName || skuCode),
quantity,
normalizedQuantity: quantity,
canonicalProductId: normalizeOptionalId(
payload.canonicalProductId ?? payload.canonical_product_id,
),
revenueFen: Math.max(0, Math.trunc(Number(payload.revenueFen ?? payload.revenue_fen) || 0)),
basisAt:
normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || nowIso(),
workOrderId: null,
workProductRuleId: normalizeOptionalId(
payload.workProductRuleId ?? payload.work_product_rule_id,
),
channel,
}
}
/**
* 履约渠道只读 order_items.fulfillment_channel / fulfillment_status。
* 订单项还没判定时按人工处理,不允许用 provider 或核销码兜底。
*/
function resolveFulfillmentMode(value: unknown): FulfillmentChannel {
const normalized = text(value)
if (normalized && FULFILLMENT_CHANNELS.has(normalized)) return normalized as FulfillmentChannel
return 'manual'
}
async function findMappingForContext(context: OrderItemCostContext) {
const result = await query<MappingRow>(
`
SELECT m.*, p.canonical_sku, p.canonical_name
FROM channel_product_mappings m
JOIN product_catalog p ON p.id = m.canonical_product_id AND p.status = 'active'
WHERE m.provider = $1
AND m.enabled = TRUE
AND (m.shop_id = '' OR m.shop_id = $3)
AND (
(m.match_type = 'sku_code' AND m.platform_sku_code = $2)
OR (
m.match_type = 'sku_code_name'
AND m.platform_sku_code = $2
AND m.platform_sku_name_key = $4
)
OR (
m.match_type = 'sku_code_series'
AND m.platform_sku_code = $2
AND m.platform_sku_name_key = $5
)
OR (m.match_type = 'sku_name_series' AND m.platform_sku_name_key = $5)
)
ORDER BY
CASE
WHEN m.match_type = 'sku_code' AND m.shop_id = $3 THEN 1
WHEN m.match_type = 'sku_code' AND m.shop_id = '' THEN 2
WHEN m.match_type = 'sku_code_name' AND m.shop_id = $3 THEN 3
WHEN m.match_type = 'sku_code_name' AND m.shop_id = '' THEN 4
WHEN m.match_type = 'sku_code_series' AND m.shop_id = $3 THEN 5
WHEN m.match_type = 'sku_code_series' AND m.shop_id = '' THEN 6
WHEN m.match_type = 'sku_name_series' AND m.shop_id = $3 THEN 7
ELSE 8
END,
m.id DESC
LIMIT 1
`,
[
context.provider,
context.skuCode,
context.shopId,
context.normalizedSkuName,
context.normalizedSkuSeries,
],
)
return result.rows[0] || null
}
async function resolveCostLines(input: {
provider: string
shopId: string
channel: FulfillmentChannel
canonicalProductId: number
workProductRuleId: number | null
quantity: number
revenueFen: number
asOf?: string
}): Promise<CostResolution> {
const result = await query<
CostItemRow & {
version_id: number | null
version_scope: string | null
version_amount_fen: number | null
version_rate_bps: number | null
}
>(
`
SELECT
ci.*,
v.id AS version_id,
v.scope_type AS version_scope,
v.amount_fen AS version_amount_fen,
v.rate_bps AS version_rate_bps
FROM cost_items ci
LEFT JOIN LATERAL (
SELECT v.*
FROM cost_versions v
WHERE v.cost_item_id = ci.id
AND v.status = 'active'
AND v.effective_from <= $1::timestamptz
AND (v.effective_to IS NULL OR v.effective_to > $1::timestamptz)
AND (
(v.scope_type = 'rule' AND $5::bigint IS NOT NULL AND v.work_product_rule_id = $5)
OR (v.scope_type = 'product' AND v.canonical_product_id = $4)
OR (v.scope_type = 'shop' AND v.provider = $2 AND v.shop_id = $3)
OR (v.scope_type = 'provider' AND v.provider = $2)
OR (v.scope_type = 'global')
)
ORDER BY
CASE v.scope_type
WHEN 'rule' THEN 1
WHEN 'product' THEN 2
WHEN 'shop' THEN 3
WHEN 'provider' THEN 4
ELSE 5
END,
v.effective_from DESC,
v.id DESC
LIMIT 1
) v ON TRUE
WHERE ci.status = 'active'
AND ${costModelScopeSql('$6')}
ORDER BY ci.id
`,
[
input.asOf || nowIso(),
input.provider,
input.shopId,
input.canonicalProductId,
input.workProductRuleId,
input.channel,
],
)
const lines: CostLine[] = []
const missingCostItems: MissingCostItem[] = []
for (const row of result.rows) {
if (!row.version_id) {
missingCostItems.push({
costItemId: Number(row.id),
costItemCode: row.code,
costItemName: row.name,
costModel: row.cost_model as MissingCostItem['costModel'],
})
continue
}
const pricingMode = row.pricing_mode
const amountFen = Number(row.version_amount_fen || 0)
const rateBps = Number(row.version_rate_bps || 0)
const quantity = Math.max(0, Number(input.quantity) || 0)
const unitAmountFen = amountFen
const totalAmountFen = calculateCostTotal({
pricingMode,
amountFen,
rateBps,
quantity,
revenueFen: input.revenueFen,
})
lines.push({
costItemId: Number(row.id),
costItemCode: row.code,
costItemName: row.name,
costModel: row.cost_model as CostLine['costModel'],
pricingMode,
costVersionId: Number(row.version_id),
scopeType: String(row.version_scope || ''),
amountFen,
rateBps,
quantity,
unitAmountFen,
totalAmountFen,
})
}
return {
lines,
missingCostItems,
applicableCostItemCount: result.rows.length,
}
}
export function calculateCostTotal(input: {
pricingMode: string
amountFen: number
rateBps: number
quantity: number
revenueFen: number
}) {
const amountFen = Math.max(0, Math.trunc(Number(input.amountFen) || 0))
const rateBps = Math.max(0, Math.min(10000, Math.trunc(Number(input.rateBps) || 0)))
const quantity = Math.max(0, Number(input.quantity) || 0)
const revenueFen = Math.max(0, Math.trunc(Number(input.revenueFen) || 0))
let total = amountFen
if (input.pricingMode === 'per_item') {
total = Math.round(amountFen * quantity)
} else if (input.pricingMode === 'percent_of_revenue') {
total = Math.round((revenueFen * rateBps) / 10000)
} else if (input.pricingMode === 'per_order') {
total = amountFen
}
return Math.max(0, total)
}
export function validateCostVersionScope(
scopeType: string,
input: {
provider: string
shopId: string
canonicalProductId: number | null
workProductRuleId: number | null
},
) {
if (scopeType === 'provider' && !input.provider) {
throw createHttpError('来源范围必须填写来源', {
statusCode: 400,
errorCode: 'cost_version_scope_invalid',
})
}
if (scopeType === 'shop' && (!input.provider || !input.shopId)) {
throw createHttpError('店铺范围必须填写来源和店铺', {
statusCode: 400,
errorCode: 'cost_version_scope_invalid',
})
}
if (scopeType === 'product' && !input.canonicalProductId) {
throw createHttpError('商品范围必须选择标准商品', {
statusCode: 400,
errorCode: 'cost_version_scope_invalid',
})
}
if (scopeType === 'rule' && !input.workProductRuleId) {
throw createHttpError('规则范围必须选择接单规则', {
statusCode: 400,
errorCode: 'cost_version_scope_invalid',
})
}
}
export function normalizeCostVersionScopeFields(
scopeType: string,
input: {
provider: string
shopId: string
canonicalProductId: number | null
workProductRuleId: number | null
},
) {
const empty = {
provider: '',
shopId: '',
canonicalProductId: null as number | null,
workProductRuleId: null as number | null,
}
if (scopeType === 'provider') {
return { ...empty, provider: input.provider }
}
if (scopeType === 'shop') {
return { ...empty, provider: input.provider, shopId: input.shopId }
}
if (scopeType === 'product') {
return { ...empty, canonicalProductId: input.canonicalProductId }
}
if (scopeType === 'rule') {
return { ...empty, workProductRuleId: input.workProductRuleId }
}
return empty
}
export async function getCostItemRow(id: number) {
const result = await query<CostItemRow>('SELECT * FROM cost_items WHERE id = $1 LIMIT 1', [id])
return requiredRow(result.rows[0], '成本项不存在')
}
export async function getCostVersionById(id: number) {
const result = await query<CostVersionRow>(
`
SELECT
v.*,
ci.code AS cost_item_code,
ci.name AS cost_item_name,
ci.cost_model AS cost_item_cost_model,
ci.pricing_mode,
p.canonical_sku,
p.canonical_name
FROM cost_versions v
JOIN cost_items ci ON ci.id = v.cost_item_id
LEFT JOIN product_catalog p ON p.id = v.canonical_product_id
WHERE v.id = $1
LIMIT 1
`,
[id],
)
return mapCostVersion(requiredRow(result.rows[0], '成本版本不存在'))
}