Files
order_site/apps/backend/src/services/cost-center/cost-center-normalizers.ts
T
yml2213 ceb786f32e fix(cost-center): 禁止追溯改价,改价只能当前或未来生效
- 后端统一校验:retroactive 请求直接拒绝,effectiveFrom 早于当前时间拒绝
- 去掉「追溯至历史订单」入口,改价只影响之后的新订单
- 历史订单成本与成交价由快照固化,不再受改价影响
2026-11-01 14:53:25 +08:00

278 lines
8.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
}
/**
* 成本改价的生效时间:只允许当前或未来,不允许追溯。
* 历史订单的成本与成交价已固化在快照里,改价不得回写过去。
*/
export function resolvePriceEffectiveFrom(payload: Record<string, unknown>): string {
if (payload.retroactive === true || payload.retroactive === 'true') {
throw createHttpError('成本改价不支持追溯历史订单;历史订单成本已固化', {
statusCode: 400,
errorCode: 'cost_retroactive_not_allowed',
})
}
const raw = payload.effectiveFrom ?? payload.effective_from
if (!raw) return new Date().toISOString()
const time = Date.parse(String(raw))
if (!Number.isFinite(time)) {
throw createHttpError('生效时间格式无效', {
statusCode: 400,
errorCode: 'cost_effective_from_invalid',
})
}
if (time < Date.now() - 60_000) {
throw createHttpError('生效时间不能早于当前时间;历史订单成本已固化', {
statusCode: 400,
errorCode: 'cost_effective_from_in_past',
})
}
return new Date(time).toISOString()
}