Files
order_site/apps/backend/src/services/cost-center/cost-center-normalizers.ts
T
yml2213 fcd9b607d4 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 测试通过。
2026-10-25 12:02:36 +08:00

249 lines
7.4 KiB
TypeScript

/**
* 成本中心共享层 · 入参归一化与断言。
*/
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
}