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 测试通过。
249 lines
7.4 KiB
TypeScript
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
|
|
}
|