287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
/**
|
|
* 成本中心共享层 · 行映射与口径判断。
|
|
*
|
|
* 把数据库行映射成接口 DTO,并提供「成本模型是否适用于履约方式」这类口径函数。
|
|
*/
|
|
import { GENERIC_COST_MODELS, PRODUCT_SOURCE_PREFIXES } from './cost-center-constants.js'
|
|
import type { FulfillmentChannel } from './cost-center-constants.js'
|
|
import { parseJsonObject } from './cost-center-normalizers.js'
|
|
import type {
|
|
CostExceptionDraft,
|
|
CostExceptionRow,
|
|
CostItemRow,
|
|
CostLine,
|
|
CostVersionRow,
|
|
MappingRow,
|
|
MissingCostItem,
|
|
ProductRow,
|
|
} from './cost-center-types.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 履约方式 / 商品来源 / 成本模型
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 由 canonical_sku 前缀推导商品来源。
|
|
* 返回空串表示未分类;未分类商品不允许反推履约方式。
|
|
*/
|
|
export function productSourceOf(canonicalSku: string): string {
|
|
const sku = String(canonicalSku || '').trim()
|
|
for (const item of PRODUCT_SOURCE_PREFIXES) {
|
|
if (sku.startsWith(item.prefix)) return item.source
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */
|
|
export function productSourceFilterSql(
|
|
paramIndex: number,
|
|
column = 'p.canonical_sku',
|
|
nameColumn = 'p.canonical_name',
|
|
): string {
|
|
return `(
|
|
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
|
|
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
|
|
OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%')
|
|
OR (
|
|
$${paramIndex} = 'manual'
|
|
AND ${column} LIKE 'man:%'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM internal_sku_channels live_channel
|
|
WHERE live_channel.enabled = TRUE
|
|
AND live_channel.is_fallback = FALSE
|
|
AND live_channel.channel <> 'manual'
|
|
AND canonicalize_internal_sku(live_channel.internal_sku)
|
|
= canonicalize_internal_sku(${nameColumn})
|
|
)
|
|
)
|
|
)`
|
|
}
|
|
|
|
/**
|
|
* 成本模型是否适用于该履约方式。
|
|
*
|
|
* 专属模型只对对应履约方式生效;通用模型(平台费/支付费/售后损失/其他)
|
|
* 对所有履约方式生效。
|
|
*/
|
|
export function costModelAppliesTo(costModel: string, channel: string) {
|
|
if ((GENERIC_COST_MODELS as readonly string[]).includes(costModel)) return true
|
|
if (costModel === 'affiliate_purchase') return channel === 'affiliate_dash'
|
|
if (costModel === 'work_order_reward') return channel === 'work_order'
|
|
if (costModel === 'third_party_voucher_cost') return channel === 'third_party_voucher'
|
|
if (costModel === 'manual_actual') return channel === 'manual' || channel === 'on_account'
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* 成本模型适用范围的 SQL 片段。
|
|
* 参数是「履约方式」的 SQL 表达式(可以是占位符 $n,也可以是列引用)。
|
|
*/
|
|
export function costModelScopeSql(channelExpression: string): string {
|
|
return `(
|
|
ci.cost_model IN (${GENERIC_COST_MODELS.map((model) => `'${model}'`).join(', ')})
|
|
OR (ci.cost_model = 'affiliate_purchase'
|
|
AND ${channelExpression} = 'affiliate_dash')
|
|
OR (ci.cost_model = 'work_order_reward'
|
|
AND ${channelExpression} = 'work_order')
|
|
OR (ci.cost_model = 'third_party_voucher_cost'
|
|
AND ${channelExpression} = 'third_party_voucher')
|
|
OR (ci.cost_model = 'manual_actual'
|
|
AND ${channelExpression} IN ('manual', 'on_account'))
|
|
)`
|
|
}
|
|
|
|
/** 人工支出分类的默认推断:按成本模型归类。 */
|
|
export 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'
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 行映射
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function mapProduct(row: ProductRow) {
|
|
return {
|
|
id: Number(row.id),
|
|
canonicalSku: row.canonical_sku,
|
|
canonicalName: row.canonical_name,
|
|
category: row.category || '',
|
|
source: productSourceOf(row.canonical_sku),
|
|
rootCategoryId: row.root_category_id ? Number(row.root_category_id) : null,
|
|
rootCategoryName: row.root_category_name || row.category || '',
|
|
subCategoryId: row.sub_category_id ? Number(row.sub_category_id) : null,
|
|
subCategoryName: row.sub_category_name || '',
|
|
unitName: row.unit_name,
|
|
status: row.status,
|
|
attributes: parseJsonObject(row.attributes_json),
|
|
mappingCount: Number(row.mapping_count || 0),
|
|
costVersionCount: Number(row.cost_version_count || 0),
|
|
activeCostAmountFen:
|
|
row.active_cost_amount_fen !== null && row.active_cost_amount_fen !== undefined
|
|
? Number(row.active_cost_amount_fen)
|
|
: null,
|
|
activeCostEffectiveFrom: row.active_cost_effective_from
|
|
? String(row.active_cost_effective_from)
|
|
: null,
|
|
templatePricingMode: row.template_pricing_mode || null,
|
|
templateEnabled:
|
|
row.template_enabled === null || row.template_enabled === undefined
|
|
? null
|
|
: row.template_enabled === true,
|
|
templateSharingEnabled:
|
|
row.template_sharing_enabled === null || row.template_sharing_enabled === undefined
|
|
? null
|
|
: row.template_sharing_enabled === true,
|
|
activePrimaryChannel: row.active_primary_channel || null,
|
|
affiliateTaskCount: Number(row.affiliate_task_count || 0),
|
|
affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}
|
|
}
|
|
|
|
export function mapMapping(row: MappingRow) {
|
|
return {
|
|
id: Number(row.id),
|
|
provider: row.provider,
|
|
shopId: row.shop_id,
|
|
platformSkuCode: row.platform_sku_code,
|
|
platformSkuName: row.platform_sku_name,
|
|
platformSkuNameKey: row.platform_sku_name_key || '',
|
|
matchType: row.match_type || 'sku_code',
|
|
canonicalProductId: Number(row.canonical_product_id),
|
|
canonicalSku: row.canonical_sku,
|
|
canonicalName: row.canonical_name,
|
|
quantityMultiplier: Number(row.quantity_multiplier),
|
|
confidence: row.confidence,
|
|
source: row.source,
|
|
enabled: row.enabled !== false,
|
|
note: row.note || '',
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}
|
|
}
|
|
|
|
export function mapCostItem(row: CostItemRow) {
|
|
return {
|
|
id: Number(row.id),
|
|
code: row.code,
|
|
name: row.name,
|
|
costModel: row.cost_model,
|
|
pricingMode: row.pricing_mode,
|
|
unitName: row.unit_name,
|
|
currency: row.currency,
|
|
status: row.status,
|
|
note: row.note || '',
|
|
versionCount: Number(row.version_count || 0),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}
|
|
}
|
|
|
|
export function mapCostVersion(row: CostVersionRow) {
|
|
const revenueItemCount = Number(row.revenue_item_count || 0)
|
|
const revenueFen = Number(row.revenue_fen || 0)
|
|
const rewardAvgFen = Number(row.reward_avg_fen || 0)
|
|
const rewardFen = Number(row.reward_fen || 0)
|
|
const ruleRewardAmountFen = Number(row.rule_reward_amount || 0)
|
|
const amountKind =
|
|
row.cost_item_code === 'sys_work_order_reward' &&
|
|
row.scope_type === 'rule' &&
|
|
row.work_product_rule_pricing_mode === 'unit'
|
|
? 'unit_price'
|
|
: 'fixed'
|
|
return {
|
|
id: Number(row.id),
|
|
costItemId: Number(row.cost_item_id),
|
|
costItemCode: row.cost_item_code,
|
|
costItemName: row.cost_item_name,
|
|
costItemCostModel: row.cost_item_cost_model || 'other',
|
|
pricingMode: row.pricing_mode,
|
|
scopeType: row.scope_type,
|
|
provider: row.provider || '',
|
|
shopId: row.shop_id || '',
|
|
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
|
|
canonicalSku: row.canonical_sku || '',
|
|
canonicalName: row.canonical_name || '',
|
|
workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null,
|
|
workProductRuleName: row.work_product_rule_name || '',
|
|
workProductRuleKey: row.work_product_rule_key || '',
|
|
ruleRewardAmountFen,
|
|
ruleUnitPriceFen: Number(row.rule_unit_price_fen || 0),
|
|
revenueItemCount,
|
|
revenueFen,
|
|
revenueAvgFen: revenueItemCount > 0 ? Math.round(revenueFen / revenueItemCount) : 0,
|
|
rewardAvgFen,
|
|
rewardFen,
|
|
amountKind,
|
|
isSystemPlaceholder:
|
|
row.cost_item_code === 'sys_work_order_reward' &&
|
|
row.scope_type === 'global' &&
|
|
Number(row.amount_fen) === 0,
|
|
amountFen: Number(row.amount_fen),
|
|
rateBps: Number(row.rate_bps),
|
|
effectiveFrom: row.effective_from,
|
|
effectiveTo: row.effective_to,
|
|
source: row.source,
|
|
status: row.status,
|
|
changeReason: row.change_reason || '',
|
|
createdByAdminUserId: row.created_by_admin_user_id
|
|
? Number(row.created_by_admin_user_id)
|
|
: null,
|
|
approvedByAdminUserId: row.approved_by_admin_user_id
|
|
? Number(row.approved_by_admin_user_id)
|
|
: null,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}
|
|
}
|
|
|
|
export function mapCostException(row: CostExceptionRow) {
|
|
return {
|
|
id: Number(row.id),
|
|
subjectType: row.subject_type,
|
|
subjectId: Number(row.subject_id),
|
|
orderItemId: row.order_item_id ? Number(row.order_item_id) : null,
|
|
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
|
|
channel: row.channel || '',
|
|
reason: row.reason,
|
|
status: row.status,
|
|
detail: parseJsonObject(row.detail_json),
|
|
createdAt: row.created_at,
|
|
resolvedAt: row.resolved_at,
|
|
resolvedByAdminUserId: row.resolved_by_admin_user_id
|
|
? Number(row.resolved_by_admin_user_id)
|
|
: null,
|
|
resolutionNote: row.resolution_note || '',
|
|
}
|
|
}
|
|
|
|
export type CostPreview = {
|
|
context: {
|
|
orderItemId: number | null
|
|
orderId: number | null
|
|
provider: string
|
|
shopId: string
|
|
skuCode: string
|
|
skuName: string
|
|
channel: FulfillmentChannel
|
|
quantity: number
|
|
normalizedQuantity: number
|
|
revenueFen: number
|
|
workOrderId: number | null
|
|
workProductRuleId: number | null
|
|
}
|
|
mapping: ReturnType<typeof mapMapping> | null
|
|
product: ReturnType<typeof mapProduct> | null
|
|
lines: CostLine[]
|
|
missingCostItems: MissingCostItem[]
|
|
totalCostFen: number
|
|
exceptions: CostExceptionDraft[]
|
|
}
|