680 lines
22 KiB
TypeScript
680 lines
22 KiB
TypeScript
/**
|
||
* 成本解析与快照:预览、解析上下文、按成本版本计算并落快照。
|
||
*
|
||
* 口径:金额只来自 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], '成本版本不存在'))
|
||
}
|