refactor(cost-center): 共享层按职责拆为四个模块
cost-center-shared.ts 804 行同时装枚举、行类型、映射、归一化,任何一处改动都会 让 8 个引用方重新编译。按职责拆开,本文件退化为统一出口(export *): - cost-center-constants.ts 131 枚举、常量、SQL 片段 - cost-center-types.ts 220 行结构与 DTO 类型 - cost-center-mappers.ts 250 行映射与口径判断(含 CostPreview) - cost-center-normalizers.ts 254 入参归一化与断言 同时消掉一个类型环:CostPreview 用 ReturnType<typeof mapProduct> 反推 mapper 的 返回类型,导致 types → mappers → types 互指;CostPreview 随 mapper 一起放进 mappers,依赖恢复单向。 出口保持向后兼容,8 个引用方无需改动;71 个导出逐一比对无缺失。 验证:typecheck / lint / 773 测试通过。
This commit is contained in:
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* 成本中心共享层 · 枚举、常量与 SQL 片段。
|
||||||
|
*
|
||||||
|
* 这些值是数据库约束与接口枚举的唯一真源,改动前先看迁移与 API 契约。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 枚举
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const PRODUCT_STATUSES = new Set(['active', 'archived'])
|
||||||
|
export const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual'])
|
||||||
|
export const MAPPING_SOURCES = new Set(['manual', 'import', 'rule'])
|
||||||
|
export const MAPPING_MATCH_TYPES = new Set([
|
||||||
|
'sku_code',
|
||||||
|
'sku_code_name',
|
||||||
|
'sku_code_series',
|
||||||
|
'sku_name_series',
|
||||||
|
])
|
||||||
|
|
||||||
|
export type TemplateMappingMatchType =
|
||||||
|
| 'sku_code'
|
||||||
|
| 'sku_code_name'
|
||||||
|
| 'sku_code_series'
|
||||||
|
| 'sku_name_series'
|
||||||
|
|
||||||
|
/** 成本模型:只表达「这笔成本怎么算」,不再复用履约载体。 */
|
||||||
|
export type CostModel =
|
||||||
|
| 'affiliate_purchase'
|
||||||
|
| 'work_order_reward'
|
||||||
|
| 'manual_actual'
|
||||||
|
| 'platform_fee'
|
||||||
|
| 'payment_fee'
|
||||||
|
| 'after_sales_loss'
|
||||||
|
| 'other'
|
||||||
|
|
||||||
|
export const COST_MODELS = new Set<string>([
|
||||||
|
'affiliate_purchase',
|
||||||
|
'work_order_reward',
|
||||||
|
'manual_actual',
|
||||||
|
'platform_fee',
|
||||||
|
'payment_fee',
|
||||||
|
'after_sales_loss',
|
||||||
|
'other',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 成本项自动编码的基础名:按成本模型生成,重复时追加 _2、_3。 */
|
||||||
|
export const COST_MODEL_CODE_BASE: Record<string, string> = {
|
||||||
|
affiliate_purchase: 'affiliate_purchase',
|
||||||
|
work_order_reward: 'work_order_reward',
|
||||||
|
manual_actual: 'manual_actual',
|
||||||
|
platform_fee: 'platform_fee',
|
||||||
|
payment_fee: 'payment_fee',
|
||||||
|
after_sales_loss: 'after_sales_loss',
|
||||||
|
other: 'other_cost',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 履约方式:唯一真源是 order_items.fulfillment_mode。 */
|
||||||
|
export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
|
||||||
|
|
||||||
|
export const FULFILLMENT_MODES = new Set<string>([
|
||||||
|
'work_order',
|
||||||
|
'affiliate_dash',
|
||||||
|
'legacy_platform',
|
||||||
|
'manual',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与履约方式无关的成本模型:对所有履约方式生效。
|
||||||
|
* 专属模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效。
|
||||||
|
*/
|
||||||
|
export const GENERIC_COST_MODELS = [
|
||||||
|
'platform_fee',
|
||||||
|
'payment_fee',
|
||||||
|
'after_sales_loss',
|
||||||
|
'other',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const PRICING_MODES = new Set(['per_item', 'per_order', 'percent_of_revenue'])
|
||||||
|
export const COST_VERSION_SCOPES = new Set(['global', 'provider', 'shop', 'product', 'rule'])
|
||||||
|
export const COST_VERSION_SOURCES = new Set(['manual', 'upstream_sync', 'import'])
|
||||||
|
export const COST_VERSION_STATUSES = new Set(['draft', 'active', 'retired'])
|
||||||
|
export const COST_EXCEPTION_STATUSES = new Set(['open', 'resolved', 'ignored'])
|
||||||
|
export const COST_EXCEPTION_SUBJECT_TYPES = new Set(['order_item', 'work_order', 'manual_case'])
|
||||||
|
export const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida']
|
||||||
|
|
||||||
|
/** 首次把接单模板发单金额落成成本版本时使用的基线时间。 */
|
||||||
|
export const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品来源前缀。商品表不再存履约载体,来源只由 canonical_sku 前缀表达。
|
||||||
|
* 前缀之外的 SKU 归入「未分类」,不允许反推履约方式。
|
||||||
|
*/
|
||||||
|
export const PRODUCT_SOURCE_PREFIXES: Array<{
|
||||||
|
prefix: string
|
||||||
|
source: 'affiliate_dash' | 'work_order' | 'manual'
|
||||||
|
}> = [
|
||||||
|
{ prefix: 'aff:', source: 'affiliate_dash' },
|
||||||
|
{ prefix: 'tpl:', source: 'work_order' },
|
||||||
|
{ prefix: 'wo:', source: 'work_order' },
|
||||||
|
{ prefix: 'man:', source: 'manual' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PRODUCT_CATEGORY_JOINS = `
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT wc.id, wc.name, wc.parent_id
|
||||||
|
FROM work_categories wc
|
||||||
|
WHERE wc.name = p.category
|
||||||
|
ORDER BY (wc.parent_id IS NOT NULL) DESC, wc.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) c ON TRUE
|
||||||
|
LEFT JOIN work_categories root ON root.id = COALESCE(c.parent_id, c.id)
|
||||||
|
`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动发货的履约证据按上游 SKU 聚合一次,再挂到 aff:<skuCode> 商品上。
|
||||||
|
* 商品表里的 attributes_json 会被价格导入覆盖,不能作为统计源。
|
||||||
|
*/
|
||||||
|
export const AFFILIATE_TASK_AGG_JOIN = `
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
btrim(context_json->>'skuCode') AS sku_code,
|
||||||
|
COUNT(*)::int AS task_count,
|
||||||
|
MAX(created_at) AS last_fulfilled_at
|
||||||
|
FROM fulfillment_tasks
|
||||||
|
WHERE executor_key = 'affiliate_dash'
|
||||||
|
AND btrim(COALESCE(context_json->>'skuCode', '')) <> ''
|
||||||
|
GROUP BY 1
|
||||||
|
) aff ON p.canonical_sku LIKE 'aff:%'
|
||||||
|
AND aff.sku_code = substring(p.canonical_sku from 5)
|
||||||
|
`
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* 成本中心共享层 · 行映射与口径判断。
|
||||||
|
*
|
||||||
|
* 把数据库行映射成接口 DTO,并提供「成本模型是否适用于履约方式」这类口径函数。
|
||||||
|
*/
|
||||||
|
import { GENERIC_COST_MODELS, PRODUCT_SOURCE_PREFIXES } from './cost-center-constants.js'
|
||||||
|
import type { FulfillmentMode } 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'): string {
|
||||||
|
return `(
|
||||||
|
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
|
||||||
|
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
|
||||||
|
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
|
||||||
|
)`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成本模型是否适用于该履约方式。
|
||||||
|
*
|
||||||
|
* 专属模型只对对应履约方式生效;通用模型(平台费/支付费/售后损失/其他)
|
||||||
|
* 对所有履约方式生效。
|
||||||
|
*/
|
||||||
|
export function costModelAppliesTo(costModel: string, fulfillmentMode: string) {
|
||||||
|
if ((GENERIC_COST_MODELS as readonly string[]).includes(costModel)) return true
|
||||||
|
if (costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash'
|
||||||
|
if (costModel === 'work_order_reward') return fulfillmentMode === 'work_order'
|
||||||
|
if (costModel === 'manual_actual') return fulfillmentMode === 'manual'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成本模型适用范围的 SQL 片段。
|
||||||
|
* 参数是「履约方式」的 SQL 表达式(可以是占位符 $n,也可以是列引用)。
|
||||||
|
*/
|
||||||
|
export function costModelScopeSql(fulfillmentModeExpression: string): string {
|
||||||
|
return `(
|
||||||
|
ci.cost_model IN (${GENERIC_COST_MODELS.map((model) => `'${model}'`).join(', ')})
|
||||||
|
OR (ci.cost_model = 'affiliate_purchase'
|
||||||
|
AND ${fulfillmentModeExpression} = 'affiliate_dash')
|
||||||
|
OR (ci.cost_model = 'work_order_reward'
|
||||||
|
AND ${fulfillmentModeExpression} = 'work_order')
|
||||||
|
OR (ci.cost_model = 'manual_actual'
|
||||||
|
AND ${fulfillmentModeExpression} = 'manual')
|
||||||
|
)`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人工支出分类的默认推断:按成本模型归类。 */
|
||||||
|
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),
|
||||||
|
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 ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0)
|
||||||
|
const ruleRewardAmountFen = Number(row.rule_reward_amount || 0)
|
||||||
|
const amountKind =
|
||||||
|
row.cost_item_code === 'sys_work_order_reward' &&
|
||||||
|
row.scope_type === 'rule' &&
|
||||||
|
ruleUnitPriceFen > 0 &&
|
||||||
|
ruleRewardAmountFen === 0
|
||||||
|
? '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,
|
||||||
|
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,
|
||||||
|
fulfillmentMode: row.fulfillment_mode || '',
|
||||||
|
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
|
||||||
|
fulfillmentMode: FulfillmentMode
|
||||||
|
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[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* 成本中心共享层 · 入参归一化与断言。
|
||||||
|
*/
|
||||||
|
import { createHttpError } from '../../utils/http.js'
|
||||||
|
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
|
||||||
|
import { MAPPING_MATCH_TYPES, PROVIDERS } from './cost-center-constants.js'
|
||||||
|
import type { TemplateMappingMatchType } from './cost-center-constants.js'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 归一化
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function normalizePage(value: unknown) {
|
||||||
|
return Math.max(1, Math.trunc(Number(value) || 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePageSize(value: unknown) {
|
||||||
|
return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeId(value: unknown, label: string) {
|
||||||
|
const id = Number(value)
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
throw createHttpError(`${label}无效`, {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cost_center_id_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOptionalId(value: unknown) {
|
||||||
|
if (value === undefined || value === null || value === '') return null
|
||||||
|
const id = Number(value)
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCategoryFilter(value: unknown) {
|
||||||
|
if (value === undefined || value === null || value === '') return null
|
||||||
|
const id = Number(value)
|
||||||
|
if (!Number.isInteger(id) || (id <= 0 && id !== -1)) {
|
||||||
|
throw createHttpError('分类筛选无效', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cost_center_category_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRequiredText(value: unknown, label: string, maxLength: number) {
|
||||||
|
const normalized = text(value)
|
||||||
|
if (!normalized) {
|
||||||
|
throw createHttpError(`${label}不能为空`, {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cost_center_field_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (normalized.length > maxLength) {
|
||||||
|
throw createHttpError(`${label}最多 ${maxLength} 个字符`, {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cost_center_field_too_long',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOptionalText(value: unknown, maxLength: number) {
|
||||||
|
return text(value).slice(0, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||||
|
const parsed = Math.trunc(Number(value) || fallback)
|
||||||
|
return parsed > 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNonNegativeInteger(value: unknown, fallback: number) {
|
||||||
|
const parsed = Math.trunc(Number(value) || fallback)
|
||||||
|
return parsed >= 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBoolean(value: unknown) {
|
||||||
|
return value === true || value === 'true' || value === 1 || value === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStatus(
|
||||||
|
value: unknown,
|
||||||
|
allowed: Set<string>,
|
||||||
|
fallback: string | null,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
const normalized = text(value)
|
||||||
|
if (!normalized) {
|
||||||
|
if (fallback !== null) return fallback
|
||||||
|
throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' })
|
||||||
|
}
|
||||||
|
assertOneOf(normalized, allowed, message)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertOneOf(value: string, allowed: Set<string>, message: string) {
|
||||||
|
if (!allowed.has(value)) {
|
||||||
|
throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeIsoDate(value: unknown, label: string, allowEmpty = false) {
|
||||||
|
const raw = text(value)
|
||||||
|
if (!raw) {
|
||||||
|
if (allowEmpty) return null
|
||||||
|
throw createHttpError(`${label}不能为空`, {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cost_center_date_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let normalized = raw.includes('T') ? raw : raw.replace(' ', 'T')
|
||||||
|
if (!normalized.includes('T')) {
|
||||||
|
normalized = `${normalized}T00:00:00`
|
||||||
|
}
|
||||||
|
// 后台表单传的是北京时间墙上时间,不带时区;显式补 +08:00,避免服务器时区不同导致偏移。
|
||||||
|
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) {
|
||||||
|
normalized = `${normalized}+08:00`
|
||||||
|
}
|
||||||
|
const parsed = new Date(normalized)
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw createHttpError(`${label}格式无效`, {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cost_center_date_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return parsed.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeJsonObject(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJsonObject(value: unknown): Record<string, unknown> {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function text(value: unknown) {
|
||||||
|
return String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 映射唯一键:来源 + 店铺 + 平台 SKU + 匹配方式 + 名称键。 */
|
||||||
|
export function candidateKey(
|
||||||
|
provider: string,
|
||||||
|
shopId: string,
|
||||||
|
skuCode: string,
|
||||||
|
matchType: string,
|
||||||
|
skuNameKey: string,
|
||||||
|
) {
|
||||||
|
return `${provider}\u0000${shopId}\u0000${skuCode}\u0000${matchType}\u0000${skuNameKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mappingKey(
|
||||||
|
provider: string,
|
||||||
|
shopId: string,
|
||||||
|
skuCode: string,
|
||||||
|
matchType = 'sku_code',
|
||||||
|
skuNameKey = '',
|
||||||
|
) {
|
||||||
|
return candidateKey(provider, shopId, skuCode, matchType, skuNameKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mappingMatchTypeLabel(matchType: string) {
|
||||||
|
switch (matchType) {
|
||||||
|
case 'sku_code':
|
||||||
|
return 'SKU 编码'
|
||||||
|
case 'sku_code_name':
|
||||||
|
return 'SKU 编码 + 精确名称'
|
||||||
|
case 'sku_code_series':
|
||||||
|
return 'SKU 编码 + 名称系列'
|
||||||
|
case 'sku_name_series':
|
||||||
|
return 'SKU 名称系列'
|
||||||
|
default:
|
||||||
|
return matchType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMappingMatchType(
|
||||||
|
value: unknown,
|
||||||
|
fallback: TemplateMappingMatchType = 'sku_code',
|
||||||
|
): TemplateMappingMatchType {
|
||||||
|
const normalized = text(value)
|
||||||
|
if (!normalized) return fallback
|
||||||
|
if (!MAPPING_MATCH_TYPES.has(normalized)) {
|
||||||
|
throw createHttpError('映射匹配方式无效', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cost_mapping_match_type_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return normalized as TemplateMappingMatchType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由匹配方式决定使用精确名称键还是数量系列键。 */
|
||||||
|
export function resolveMappingNameKey(
|
||||||
|
matchType: TemplateMappingMatchType,
|
||||||
|
platformSkuName: string,
|
||||||
|
) {
|
||||||
|
if (matchType === 'sku_code') return ''
|
||||||
|
if (matchType === 'sku_code_name') return normalizeMatchText(platformSkuName)
|
||||||
|
return normalizeSkuSeriesEquivalence(platformSkuName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成本映射的 provider 必须与 orders.provider 一致;兼容产品匹配层的来源别名。 */
|
||||||
|
export function normalizeProviderKey(value: unknown) {
|
||||||
|
const normalized = text(value)
|
||||||
|
if (normalized === '91' || normalized === 'open_91' || normalized === 'kuaishou-91') {
|
||||||
|
return '91kaquan'
|
||||||
|
}
|
||||||
|
if (normalized === 'douyin-mashida') {
|
||||||
|
return 'douyin_mashida'
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertKnownProvider(provider: string) {
|
||||||
|
if (provider && !PROVIDERS.includes(provider)) {
|
||||||
|
throw createHttpError('来源无效', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cost_center_provider_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNumber(value: unknown) {
|
||||||
|
return Number(value || 0) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiredRow<T>(row: T | undefined | null, message: string): T {
|
||||||
|
if (!row) {
|
||||||
|
throw createHttpError(message, { statusCode: 404, errorCode: 'cost_center_not_found' })
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
@@ -1,804 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* 成本中心共享层:枚举、类型、归一化函数与行映射。
|
* 成本中心共享层入口。
|
||||||
*
|
*
|
||||||
* 口径(2026-10-23 重写):
|
* 口径(2026-10-23 重写):
|
||||||
* 1. 履约方式只有 order_items.fulfillment_mode 一个真源;
|
* 1. 履约方式只有 order_items.fulfillment_mode 一个真源;
|
||||||
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
|
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
|
||||||
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
|
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
|
||||||
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
|
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
|
||||||
*/
|
|
||||||
import { createHttpError } from '../../utils/http.js'
|
|
||||||
import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-match/normalize.js'
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 枚举
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const PRODUCT_STATUSES = new Set(['active', 'archived'])
|
|
||||||
export const MAPPING_CONFIDENCES = new Set(['exact', 'rule', 'manual'])
|
|
||||||
export const MAPPING_SOURCES = new Set(['manual', 'import', 'rule'])
|
|
||||||
export const MAPPING_MATCH_TYPES = new Set([
|
|
||||||
'sku_code',
|
|
||||||
'sku_code_name',
|
|
||||||
'sku_code_series',
|
|
||||||
'sku_name_series',
|
|
||||||
])
|
|
||||||
|
|
||||||
export type TemplateMappingMatchType =
|
|
||||||
| 'sku_code'
|
|
||||||
| 'sku_code_name'
|
|
||||||
| 'sku_code_series'
|
|
||||||
| 'sku_name_series'
|
|
||||||
|
|
||||||
/** 成本模型:只表达「这笔成本怎么算」,不再复用履约载体。 */
|
|
||||||
export type CostModel =
|
|
||||||
| 'affiliate_purchase'
|
|
||||||
| 'work_order_reward'
|
|
||||||
| 'manual_actual'
|
|
||||||
| 'platform_fee'
|
|
||||||
| 'payment_fee'
|
|
||||||
| 'after_sales_loss'
|
|
||||||
| 'other'
|
|
||||||
|
|
||||||
export const COST_MODELS = new Set<string>([
|
|
||||||
'affiliate_purchase',
|
|
||||||
'work_order_reward',
|
|
||||||
'manual_actual',
|
|
||||||
'platform_fee',
|
|
||||||
'payment_fee',
|
|
||||||
'after_sales_loss',
|
|
||||||
'other',
|
|
||||||
])
|
|
||||||
|
|
||||||
/** 成本项自动编码的基础名:按成本模型生成,重复时追加 _2、_3。 */
|
|
||||||
export const COST_MODEL_CODE_BASE: Record<string, string> = {
|
|
||||||
affiliate_purchase: 'affiliate_purchase',
|
|
||||||
work_order_reward: 'work_order_reward',
|
|
||||||
manual_actual: 'manual_actual',
|
|
||||||
platform_fee: 'platform_fee',
|
|
||||||
payment_fee: 'payment_fee',
|
|
||||||
after_sales_loss: 'after_sales_loss',
|
|
||||||
other: 'other_cost',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 履约方式:唯一真源是 order_items.fulfillment_mode。 */
|
|
||||||
export type FulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
|
|
||||||
|
|
||||||
export const FULFILLMENT_MODES = new Set<string>([
|
|
||||||
'work_order',
|
|
||||||
'affiliate_dash',
|
|
||||||
'legacy_platform',
|
|
||||||
'manual',
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 与履约方式无关的成本模型:对所有履约方式生效。
|
|
||||||
* 专属模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效。
|
|
||||||
*/
|
|
||||||
export const GENERIC_COST_MODELS = [
|
|
||||||
'platform_fee',
|
|
||||||
'payment_fee',
|
|
||||||
'after_sales_loss',
|
|
||||||
'other',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export const PRICING_MODES = new Set(['per_item', 'per_order', 'percent_of_revenue'])
|
|
||||||
export const COST_VERSION_SCOPES = new Set(['global', 'provider', 'shop', 'product', 'rule'])
|
|
||||||
export const COST_VERSION_SOURCES = new Set(['manual', 'upstream_sync', 'import'])
|
|
||||||
export const COST_VERSION_STATUSES = new Set(['draft', 'active', 'retired'])
|
|
||||||
export const COST_EXCEPTION_STATUSES = new Set(['open', 'resolved', 'ignored'])
|
|
||||||
export const COST_EXCEPTION_SUBJECT_TYPES = new Set(['order_item', 'work_order', 'manual_case'])
|
|
||||||
export const PROVIDERS = ['91kaquan', 'pdd', 'douyin_mashida']
|
|
||||||
|
|
||||||
/** 首次把接单模板发单金额落成成本版本时使用的基线时间。 */
|
|
||||||
export const WORK_ORDER_TEMPLATE_COST_BASELINE = '2026-07-10T00:00:00+08:00'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 商品来源前缀。商品表不再存履约载体,来源只由 canonical_sku 前缀表达。
|
|
||||||
* 前缀之外的 SKU 归入「未分类」,不允许反推履约方式。
|
|
||||||
*/
|
|
||||||
export const PRODUCT_SOURCE_PREFIXES: Array<{
|
|
||||||
prefix: string
|
|
||||||
source: 'affiliate_dash' | 'work_order' | 'manual'
|
|
||||||
}> = [
|
|
||||||
{ prefix: 'aff:', source: 'affiliate_dash' },
|
|
||||||
{ prefix: 'tpl:', source: 'work_order' },
|
|
||||||
{ prefix: 'wo:', source: 'work_order' },
|
|
||||||
{ prefix: 'man:', source: 'manual' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const PRODUCT_CATEGORY_JOINS = `
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT wc.id, wc.name, wc.parent_id
|
|
||||||
FROM work_categories wc
|
|
||||||
WHERE wc.name = p.category
|
|
||||||
ORDER BY (wc.parent_id IS NOT NULL) DESC, wc.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) c ON TRUE
|
|
||||||
LEFT JOIN work_categories root ON root.id = COALESCE(c.parent_id, c.id)
|
|
||||||
`
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自动发货的履约证据按上游 SKU 聚合一次,再挂到 aff:<skuCode> 商品上。
|
|
||||||
* 商品表里的 attributes_json 会被价格导入覆盖,不能作为统计源。
|
|
||||||
*/
|
|
||||||
export const AFFILIATE_TASK_AGG_JOIN = `
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT
|
|
||||||
btrim(context_json->>'skuCode') AS sku_code,
|
|
||||||
COUNT(*)::int AS task_count,
|
|
||||||
MAX(created_at) AS last_fulfilled_at
|
|
||||||
FROM fulfillment_tasks
|
|
||||||
WHERE executor_key = 'affiliate_dash'
|
|
||||||
AND btrim(COALESCE(context_json->>'skuCode', '')) <> ''
|
|
||||||
GROUP BY 1
|
|
||||||
) aff ON p.canonical_sku LIKE 'aff:%'
|
|
||||||
AND aff.sku_code = substring(p.canonical_sku from 5)
|
|
||||||
`
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 行类型
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type ProductRow = {
|
|
||||||
[column: string]: unknown
|
|
||||||
id: number
|
|
||||||
canonical_sku: string
|
|
||||||
canonical_name: string
|
|
||||||
category: string
|
|
||||||
unit_name: string
|
|
||||||
status: string
|
|
||||||
root_category_id: number | null
|
|
||||||
root_category_name: string
|
|
||||||
sub_category_id: number | null
|
|
||||||
sub_category_name: string
|
|
||||||
attributes_json: unknown
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
mapping_count?: number
|
|
||||||
cost_version_count?: number
|
|
||||||
affiliate_task_count?: number
|
|
||||||
affiliate_last_fulfilled_at?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProductCategoryStatRow = {
|
|
||||||
category_id: number | null
|
|
||||||
name: string | null
|
|
||||||
parent_id: number | null
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkCategoryRow = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
parent_id: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProductCategoryNode = {
|
|
||||||
categoryId: number
|
|
||||||
name: string
|
|
||||||
total: number
|
|
||||||
directTotal: number
|
|
||||||
children: Array<{
|
|
||||||
categoryId: number
|
|
||||||
name: string
|
|
||||||
total: number
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MappingRow = {
|
|
||||||
[column: string]: unknown
|
|
||||||
id: number
|
|
||||||
provider: string
|
|
||||||
shop_id: string
|
|
||||||
platform_sku_code: string
|
|
||||||
platform_sku_name: string
|
|
||||||
platform_sku_name_key: string
|
|
||||||
match_type: string
|
|
||||||
canonical_product_id: number
|
|
||||||
canonical_sku: string
|
|
||||||
canonical_name: string
|
|
||||||
quantity_multiplier: number
|
|
||||||
confidence: string
|
|
||||||
source: string
|
|
||||||
enabled: boolean
|
|
||||||
note: string
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CostItemRow = {
|
|
||||||
[column: string]: unknown
|
|
||||||
id: number
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
cost_model: string
|
|
||||||
pricing_mode: string
|
|
||||||
unit_name: string
|
|
||||||
currency: string
|
|
||||||
status: string
|
|
||||||
note: string
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
version_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CostVersionRow = {
|
|
||||||
[column: string]: unknown
|
|
||||||
id: number
|
|
||||||
cost_item_id: number
|
|
||||||
cost_item_code: string
|
|
||||||
cost_item_name: string
|
|
||||||
cost_item_cost_model: string
|
|
||||||
pricing_mode: string
|
|
||||||
scope_type: string
|
|
||||||
provider: string
|
|
||||||
shop_id: string
|
|
||||||
canonical_product_id: number | null
|
|
||||||
canonical_sku: string | null
|
|
||||||
canonical_name: string | null
|
|
||||||
work_product_rule_id: number | null
|
|
||||||
work_product_rule_name: string | null
|
|
||||||
work_product_rule_key: string | null
|
|
||||||
rule_reward_amount: number | null
|
|
||||||
rule_unit_price_fen: number | null
|
|
||||||
revenue_item_count: number | null
|
|
||||||
revenue_fen: number | null
|
|
||||||
reward_avg_fen: number | null
|
|
||||||
reward_fen: number | null
|
|
||||||
amount_fen: number
|
|
||||||
rate_bps: number
|
|
||||||
effective_from: string
|
|
||||||
effective_to: string | null
|
|
||||||
source: string
|
|
||||||
status: string
|
|
||||||
change_reason: string
|
|
||||||
created_by_admin_user_id: number | null
|
|
||||||
approved_by_admin_user_id: number | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CostExceptionRow = {
|
|
||||||
[column: string]: unknown
|
|
||||||
id: number
|
|
||||||
subject_type: string
|
|
||||||
subject_id: number
|
|
||||||
order_item_id: number | null
|
|
||||||
work_order_id: number | null
|
|
||||||
fulfillment_mode: string | null
|
|
||||||
reason: string
|
|
||||||
status: string
|
|
||||||
detail_json: unknown
|
|
||||||
created_at: string
|
|
||||||
resolved_at: string | null
|
|
||||||
resolved_by_admin_user_id: number | null
|
|
||||||
resolution_note: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OrderItemCostContext = {
|
|
||||||
orderItemId: number
|
|
||||||
orderId: number
|
|
||||||
provider: string
|
|
||||||
shopId: string
|
|
||||||
skuCode: string
|
|
||||||
skuName: string
|
|
||||||
normalizedSkuName: string
|
|
||||||
normalizedSkuSeries: string
|
|
||||||
quantity: number
|
|
||||||
normalizedQuantity: number
|
|
||||||
canonicalProductId: number | null
|
|
||||||
revenueFen: number
|
|
||||||
basisAt: string
|
|
||||||
workOrderId: number | null
|
|
||||||
workProductRuleId: number | null
|
|
||||||
fulfillmentMode: FulfillmentMode
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CostLine = {
|
|
||||||
costItemId: number
|
|
||||||
costItemCode: string
|
|
||||||
costItemName: string
|
|
||||||
costModel: CostModel
|
|
||||||
pricingMode: string
|
|
||||||
costVersionId: number
|
|
||||||
scopeType: string
|
|
||||||
amountFen: number
|
|
||||||
rateBps: number
|
|
||||||
quantity: number
|
|
||||||
unitAmountFen: number
|
|
||||||
totalAmountFen: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CostExceptionDraft = {
|
|
||||||
reason: string
|
|
||||||
message: string
|
|
||||||
detail: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MissingCostItem = {
|
|
||||||
costItemId: number
|
|
||||||
costItemCode: string
|
|
||||||
costItemName: string
|
|
||||||
costModel: CostModel
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CostResolution = {
|
|
||||||
lines: CostLine[]
|
|
||||||
missingCostItems: MissingCostItem[]
|
|
||||||
applicableCostItemCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CostPreview = {
|
|
||||||
context: {
|
|
||||||
orderItemId: number | null
|
|
||||||
orderId: number | null
|
|
||||||
provider: string
|
|
||||||
shopId: string
|
|
||||||
skuCode: string
|
|
||||||
skuName: string
|
|
||||||
fulfillmentMode: FulfillmentMode
|
|
||||||
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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 履约方式 / 商品来源 / 成本模型
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 由 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'): string {
|
|
||||||
return `(
|
|
||||||
($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%')
|
|
||||||
OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%'))
|
|
||||||
OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%')
|
|
||||||
)`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 成本模型是否适用于该履约方式。
|
|
||||||
*
|
*
|
||||||
* 专属模型只对对应履约方式生效;通用模型(平台费/支付费/售后损失/其他)
|
* 2026-10-25 拆分:804 行的共享层按职责拆成四个模块,本文件只做统一出口,
|
||||||
* 对所有履约方式生效。
|
* 既有 import 保持不变:
|
||||||
|
* - cost-center-constants.ts 枚举、常量、SQL 片段
|
||||||
|
* - cost-center-types.ts 行结构与 DTO 类型
|
||||||
|
* - cost-center-mappers.ts 行映射与口径判断
|
||||||
|
* - cost-center-normalizers.ts 入参归一化与断言
|
||||||
|
*
|
||||||
|
* 新代码可以直接从对应模块导入;跨模块批量引用时才用本入口。
|
||||||
*/
|
*/
|
||||||
export function costModelAppliesTo(costModel: string, fulfillmentMode: string) {
|
export * from './cost-center-constants.js'
|
||||||
if ((GENERIC_COST_MODELS as readonly string[]).includes(costModel)) return true
|
export * from './cost-center-mappers.js'
|
||||||
if (costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash'
|
export * from './cost-center-normalizers.js'
|
||||||
if (costModel === 'work_order_reward') return fulfillmentMode === 'work_order'
|
export * from './cost-center-types.js'
|
||||||
if (costModel === 'manual_actual') return fulfillmentMode === 'manual'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 成本模型适用范围的 SQL 片段。
|
|
||||||
* 参数是「履约方式」的 SQL 表达式(可以是占位符 $n,也可以是列引用)。
|
|
||||||
*/
|
|
||||||
export function costModelScopeSql(fulfillmentModeExpression: string): string {
|
|
||||||
return `(
|
|
||||||
ci.cost_model IN (${GENERIC_COST_MODELS.map((model) => `'${model}'`).join(', ')})
|
|
||||||
OR (ci.cost_model = 'affiliate_purchase'
|
|
||||||
AND ${fulfillmentModeExpression} = 'affiliate_dash')
|
|
||||||
OR (ci.cost_model = 'work_order_reward'
|
|
||||||
AND ${fulfillmentModeExpression} = 'work_order')
|
|
||||||
OR (ci.cost_model = 'manual_actual'
|
|
||||||
AND ${fulfillmentModeExpression} = 'manual')
|
|
||||||
)`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 人工支出分类的默认推断:按成本模型归类。 */
|
|
||||||
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),
|
|
||||||
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 ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0)
|
|
||||||
const ruleRewardAmountFen = Number(row.rule_reward_amount || 0)
|
|
||||||
const amountKind =
|
|
||||||
row.cost_item_code === 'sys_work_order_reward' &&
|
|
||||||
row.scope_type === 'rule' &&
|
|
||||||
ruleUnitPriceFen > 0 &&
|
|
||||||
ruleRewardAmountFen === 0
|
|
||||||
? '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,
|
|
||||||
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,
|
|
||||||
fulfillmentMode: row.fulfillment_mode || '',
|
|
||||||
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 function normalizePage(value: unknown) {
|
|
||||||
return Math.max(1, Math.trunc(Number(value) || 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePageSize(value: unknown) {
|
|
||||||
return Math.min(100, Math.max(1, Math.trunc(Number(value) || 20)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeId(value: unknown, label: string) {
|
|
||||||
const id = Number(value)
|
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
|
||||||
throw createHttpError(`${label}无效`, {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'cost_center_id_invalid',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeOptionalId(value: unknown) {
|
|
||||||
if (value === undefined || value === null || value === '') return null
|
|
||||||
const id = Number(value)
|
|
||||||
return Number.isInteger(id) && id > 0 ? id : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCategoryFilter(value: unknown) {
|
|
||||||
if (value === undefined || value === null || value === '') return null
|
|
||||||
const id = Number(value)
|
|
||||||
if (!Number.isInteger(id) || (id <= 0 && id !== -1)) {
|
|
||||||
throw createHttpError('分类筛选无效', {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'cost_center_category_invalid',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeRequiredText(value: unknown, label: string, maxLength: number) {
|
|
||||||
const normalized = text(value)
|
|
||||||
if (!normalized) {
|
|
||||||
throw createHttpError(`${label}不能为空`, {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'cost_center_field_required',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (normalized.length > maxLength) {
|
|
||||||
throw createHttpError(`${label}最多 ${maxLength} 个字符`, {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'cost_center_field_too_long',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeOptionalText(value: unknown, maxLength: number) {
|
|
||||||
return text(value).slice(0, maxLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePositiveInteger(value: unknown, fallback: number) {
|
|
||||||
const parsed = Math.trunc(Number(value) || fallback)
|
|
||||||
return parsed > 0 ? parsed : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeNonNegativeInteger(value: unknown, fallback: number) {
|
|
||||||
const parsed = Math.trunc(Number(value) || fallback)
|
|
||||||
return parsed >= 0 ? parsed : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeBoolean(value: unknown) {
|
|
||||||
return value === true || value === 'true' || value === 1 || value === '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeStatus(
|
|
||||||
value: unknown,
|
|
||||||
allowed: Set<string>,
|
|
||||||
fallback: string | null,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
const normalized = text(value)
|
|
||||||
if (!normalized) {
|
|
||||||
if (fallback !== null) return fallback
|
|
||||||
throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' })
|
|
||||||
}
|
|
||||||
assertOneOf(normalized, allowed, message)
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertOneOf(value: string, allowed: Set<string>, message: string) {
|
|
||||||
if (!allowed.has(value)) {
|
|
||||||
throw createHttpError(message, { statusCode: 400, errorCode: 'cost_center_status_invalid' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeIsoDate(value: unknown, label: string, allowEmpty = false) {
|
|
||||||
const raw = text(value)
|
|
||||||
if (!raw) {
|
|
||||||
if (allowEmpty) return null
|
|
||||||
throw createHttpError(`${label}不能为空`, {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'cost_center_date_required',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let normalized = raw.includes('T') ? raw : raw.replace(' ', 'T')
|
|
||||||
if (!normalized.includes('T')) {
|
|
||||||
normalized = `${normalized}T00:00:00`
|
|
||||||
}
|
|
||||||
// 后台表单传的是北京时间墙上时间,不带时区;显式补 +08:00,避免服务器时区不同导致偏移。
|
|
||||||
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) {
|
|
||||||
normalized = `${normalized}+08:00`
|
|
||||||
}
|
|
||||||
const parsed = new Date(normalized)
|
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
|
||||||
throw createHttpError(`${label}格式无效`, {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'cost_center_date_invalid',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return parsed.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeJsonObject(value: unknown): Record<string, unknown> {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
||||||
return value as Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseJsonObject(value: unknown): Record<string, unknown> {
|
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
return value as Record<string, unknown>
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value)
|
|
||||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function text(value: unknown) {
|
|
||||||
return String(value ?? '').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 映射唯一键:来源 + 店铺 + 平台 SKU + 匹配方式 + 名称键。 */
|
|
||||||
export function candidateKey(
|
|
||||||
provider: string,
|
|
||||||
shopId: string,
|
|
||||||
skuCode: string,
|
|
||||||
matchType: string,
|
|
||||||
skuNameKey: string,
|
|
||||||
) {
|
|
||||||
return `${provider}\u0000${shopId}\u0000${skuCode}\u0000${matchType}\u0000${skuNameKey}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mappingKey(
|
|
||||||
provider: string,
|
|
||||||
shopId: string,
|
|
||||||
skuCode: string,
|
|
||||||
matchType = 'sku_code',
|
|
||||||
skuNameKey = '',
|
|
||||||
) {
|
|
||||||
return candidateKey(provider, shopId, skuCode, matchType, skuNameKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mappingMatchTypeLabel(matchType: string) {
|
|
||||||
switch (matchType) {
|
|
||||||
case 'sku_code':
|
|
||||||
return 'SKU 编码'
|
|
||||||
case 'sku_code_name':
|
|
||||||
return 'SKU 编码 + 精确名称'
|
|
||||||
case 'sku_code_series':
|
|
||||||
return 'SKU 编码 + 名称系列'
|
|
||||||
case 'sku_name_series':
|
|
||||||
return 'SKU 名称系列'
|
|
||||||
default:
|
|
||||||
return matchType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeMappingMatchType(
|
|
||||||
value: unknown,
|
|
||||||
fallback: TemplateMappingMatchType = 'sku_code',
|
|
||||||
): TemplateMappingMatchType {
|
|
||||||
const normalized = text(value)
|
|
||||||
if (!normalized) return fallback
|
|
||||||
if (!MAPPING_MATCH_TYPES.has(normalized)) {
|
|
||||||
throw createHttpError('映射匹配方式无效', {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'cost_mapping_match_type_invalid',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return normalized as TemplateMappingMatchType
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 由匹配方式决定使用精确名称键还是数量系列键。 */
|
|
||||||
export function resolveMappingNameKey(
|
|
||||||
matchType: TemplateMappingMatchType,
|
|
||||||
platformSkuName: string,
|
|
||||||
) {
|
|
||||||
if (matchType === 'sku_code') return ''
|
|
||||||
if (matchType === 'sku_code_name') return normalizeMatchText(platformSkuName)
|
|
||||||
return normalizeSkuSeriesEquivalence(platformSkuName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 成本映射的 provider 必须与 orders.provider 一致;兼容产品匹配层的来源别名。 */
|
|
||||||
export function normalizeProviderKey(value: unknown) {
|
|
||||||
const normalized = text(value)
|
|
||||||
if (normalized === '91' || normalized === 'open_91' || normalized === 'kuaishou-91') {
|
|
||||||
return '91kaquan'
|
|
||||||
}
|
|
||||||
if (normalized === 'douyin-mashida') {
|
|
||||||
return 'douyin_mashida'
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertKnownProvider(provider: string) {
|
|
||||||
if (provider && !PROVIDERS.includes(provider)) {
|
|
||||||
throw createHttpError('来源无效', {
|
|
||||||
statusCode: 400,
|
|
||||||
errorCode: 'cost_center_provider_invalid',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toNumber(value: unknown) {
|
|
||||||
return Number(value || 0) || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requiredRow<T>(row: T | undefined | null, message: string): T {
|
|
||||||
if (!row) {
|
|
||||||
throw createHttpError(message, { statusCode: 404, errorCode: 'cost_center_not_found' })
|
|
||||||
}
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 成本中心共享层 · 行结构与 DTO 类型。
|
||||||
|
*/
|
||||||
|
import type { CostModel, FulfillmentMode } from './cost-center-constants.js'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 行类型
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ProductRow = {
|
||||||
|
[column: string]: unknown
|
||||||
|
id: number
|
||||||
|
canonical_sku: string
|
||||||
|
canonical_name: string
|
||||||
|
category: string
|
||||||
|
unit_name: string
|
||||||
|
status: string
|
||||||
|
root_category_id: number | null
|
||||||
|
root_category_name: string
|
||||||
|
sub_category_id: number | null
|
||||||
|
sub_category_name: string
|
||||||
|
attributes_json: unknown
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
mapping_count?: number
|
||||||
|
cost_version_count?: number
|
||||||
|
affiliate_task_count?: number
|
||||||
|
affiliate_last_fulfilled_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductCategoryStatRow = {
|
||||||
|
category_id: number | null
|
||||||
|
name: string | null
|
||||||
|
parent_id: number | null
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkCategoryRow = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
parent_id: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductCategoryNode = {
|
||||||
|
categoryId: number
|
||||||
|
name: string
|
||||||
|
total: number
|
||||||
|
directTotal: number
|
||||||
|
children: Array<{
|
||||||
|
categoryId: number
|
||||||
|
name: string
|
||||||
|
total: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MappingRow = {
|
||||||
|
[column: string]: unknown
|
||||||
|
id: number
|
||||||
|
provider: string
|
||||||
|
shop_id: string
|
||||||
|
platform_sku_code: string
|
||||||
|
platform_sku_name: string
|
||||||
|
platform_sku_name_key: string
|
||||||
|
match_type: string
|
||||||
|
canonical_product_id: number
|
||||||
|
canonical_sku: string
|
||||||
|
canonical_name: string
|
||||||
|
quantity_multiplier: number
|
||||||
|
confidence: string
|
||||||
|
source: string
|
||||||
|
enabled: boolean
|
||||||
|
note: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CostItemRow = {
|
||||||
|
[column: string]: unknown
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
cost_model: string
|
||||||
|
pricing_mode: string
|
||||||
|
unit_name: string
|
||||||
|
currency: string
|
||||||
|
status: string
|
||||||
|
note: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
version_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CostVersionRow = {
|
||||||
|
[column: string]: unknown
|
||||||
|
id: number
|
||||||
|
cost_item_id: number
|
||||||
|
cost_item_code: string
|
||||||
|
cost_item_name: string
|
||||||
|
cost_item_cost_model: string
|
||||||
|
pricing_mode: string
|
||||||
|
scope_type: string
|
||||||
|
provider: string
|
||||||
|
shop_id: string
|
||||||
|
canonical_product_id: number | null
|
||||||
|
canonical_sku: string | null
|
||||||
|
canonical_name: string | null
|
||||||
|
work_product_rule_id: number | null
|
||||||
|
work_product_rule_name: string | null
|
||||||
|
work_product_rule_key: string | null
|
||||||
|
rule_reward_amount: number | null
|
||||||
|
rule_unit_price_fen: number | null
|
||||||
|
revenue_item_count: number | null
|
||||||
|
revenue_fen: number | null
|
||||||
|
reward_avg_fen: number | null
|
||||||
|
reward_fen: number | null
|
||||||
|
amount_fen: number
|
||||||
|
rate_bps: number
|
||||||
|
effective_from: string
|
||||||
|
effective_to: string | null
|
||||||
|
source: string
|
||||||
|
status: string
|
||||||
|
change_reason: string
|
||||||
|
created_by_admin_user_id: number | null
|
||||||
|
approved_by_admin_user_id: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CostExceptionRow = {
|
||||||
|
[column: string]: unknown
|
||||||
|
id: number
|
||||||
|
subject_type: string
|
||||||
|
subject_id: number
|
||||||
|
order_item_id: number | null
|
||||||
|
work_order_id: number | null
|
||||||
|
fulfillment_mode: string | null
|
||||||
|
reason: string
|
||||||
|
status: string
|
||||||
|
detail_json: unknown
|
||||||
|
created_at: string
|
||||||
|
resolved_at: string | null
|
||||||
|
resolved_by_admin_user_id: number | null
|
||||||
|
resolution_note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderItemCostContext = {
|
||||||
|
orderItemId: number
|
||||||
|
orderId: number
|
||||||
|
provider: string
|
||||||
|
shopId: string
|
||||||
|
skuCode: string
|
||||||
|
skuName: string
|
||||||
|
normalizedSkuName: string
|
||||||
|
normalizedSkuSeries: string
|
||||||
|
quantity: number
|
||||||
|
normalizedQuantity: number
|
||||||
|
canonicalProductId: number | null
|
||||||
|
revenueFen: number
|
||||||
|
basisAt: string
|
||||||
|
workOrderId: number | null
|
||||||
|
workProductRuleId: number | null
|
||||||
|
fulfillmentMode: FulfillmentMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CostLine = {
|
||||||
|
costItemId: number
|
||||||
|
costItemCode: string
|
||||||
|
costItemName: string
|
||||||
|
costModel: CostModel
|
||||||
|
pricingMode: string
|
||||||
|
costVersionId: number
|
||||||
|
scopeType: string
|
||||||
|
amountFen: number
|
||||||
|
rateBps: number
|
||||||
|
quantity: number
|
||||||
|
unitAmountFen: number
|
||||||
|
totalAmountFen: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CostExceptionDraft = {
|
||||||
|
reason: string
|
||||||
|
message: string
|
||||||
|
detail: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MissingCostItem = {
|
||||||
|
costItemId: number
|
||||||
|
costItemCode: string
|
||||||
|
costItemName: string
|
||||||
|
costModel: CostModel
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CostResolution = {
|
||||||
|
lines: CostLine[]
|
||||||
|
missingCostItems: MissingCostItem[]
|
||||||
|
applicableCostItemCount: number
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user