2026-10-14 00:07:05 +08:00
|
|
|
import type { TurnoverQuery, TurnoverSort } from './turnoverTypes'
|
2026-10-14 11:02:56 +08:00
|
|
|
import { turnoverSortOptions } from './turnoverSorting'
|
|
|
|
|
export const turnoverSorts: TurnoverSort[] = turnoverSortOptions.map(option => option.value)
|
2026-10-14 00:07:05 +08:00
|
|
|
const detailOnly = ['salesWindowDays', 'salesWindowEndDate', 'attention', 'coverageSelection', 'coverageBucket', 'bucketRuleVersion']
|
2026-10-15 11:14:35 +08:00
|
|
|
const turnoverOnly = ['customerCodes', 'hideZeroInventory', 'groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext']
|
|
|
|
|
export function normalizeTurnoverOwners(value: unknown): string[] {
|
|
|
|
|
if (value == null) return []
|
|
|
|
|
if (!Array.isArray(value) || value.length > 100 || value.some(code => typeof code !== 'string' || !code.trim() || code.length > 64)) throw new Error('请选择有效货主,最多100个')
|
|
|
|
|
return [...new Set(value as string[])].sort()
|
|
|
|
|
}
|
2026-10-14 00:07:05 +08:00
|
|
|
function scalar(q: Record<string, unknown>, key: string) {
|
|
|
|
|
const v = q[key]
|
|
|
|
|
if (v == null) return undefined
|
|
|
|
|
if (typeof v !== 'string' || !v.trim()) throw new Error(`参数 ${key} 无效`)
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
export function inventoryView(q: Record<string, unknown>): 'TURNOVER' | 'DETAIL' {
|
|
|
|
|
const view = scalar(q, 'view')
|
|
|
|
|
if (view && view !== 'TURNOVER' && view !== 'DETAIL') throw new Error('库存视图参数无效')
|
|
|
|
|
const strict = q.from === 'overview' || q.drilldownFrom === 'DASHBOARD'
|
|
|
|
|
const detail = strict || detailOnly.some(k => q[k] != null)
|
|
|
|
|
if (view === 'TURNOVER' && detail) throw new Error('库存周转不能混用总览下钻或库存明细条件')
|
|
|
|
|
const selected = detail || view === 'DETAIL' ? 'DETAIL' : 'TURNOVER'
|
|
|
|
|
if (selected === 'DETAIL' && turnoverOnly.some(k => q[k] != null)) throw new Error('库存明细不能携带周转专属条件')
|
|
|
|
|
return selected
|
|
|
|
|
}
|
|
|
|
|
export function yesterdayBeijing(now = new Date()) {
|
|
|
|
|
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now)
|
|
|
|
|
return new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
|
|
|
|
|
}
|
|
|
|
|
export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing()) {
|
2026-10-15 11:14:35 +08:00
|
|
|
normalizeTurnoverOwners(q.customerCodes)
|
2026-10-15 10:29:43 +08:00
|
|
|
if (q.hideZeroInventory != null && typeof q.hideZeroInventory !== 'boolean') throw new Error('隐藏零库存参数无效')
|
2026-10-14 00:07:05 +08:00
|
|
|
if (!q.warehouseCode.trim()) throw new Error('请选择可用仓库')
|
|
|
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(q.endDate) || !Number.isFinite(Date.parse(q.endDate)) || new Date(q.endDate).toISOString().slice(0, 10) !== q.endDate || q.endDate > latestDate) throw new Error('截止日必须是已结束的北京时间日期')
|
2026-10-14 12:39:16 +08:00
|
|
|
if (!Number.isInteger(q.baseWindowDays) || q.baseWindowDays < 1 || q.baseWindowDays > 366) throw new Error('统计区间须为1至366个完整自然日')
|
2026-10-14 00:07:05 +08:00
|
|
|
if (!['SKU', 'OWNER'].includes(q.groupBy)) throw new Error('分组无效')
|
|
|
|
|
if (q.groupBy === 'OWNER' && q.sku) throw new Error('货主汇总不能携带SKU搜索')
|
2026-10-14 12:39:16 +08:00
|
|
|
if (!['ALL', 'NO_OUTBOUND_IN_WINDOW', 'COVERAGE_GT', 'COVERAGE_LE', 'FROZEN', 'DEFECTIVE', 'UNASSESSABLE'].includes(q.focus)) throw new Error('关注条件无效')
|
2026-10-14 00:07:05 +08:00
|
|
|
if (q.coverageDaysThreshold != null && (!Number.isSafeInteger(q.coverageDaysThreshold) || q.coverageDaysThreshold <= 0)) throw new Error('阈值必须为正整数天数')
|
|
|
|
|
if (['COVERAGE_GT', 'COVERAGE_LE'].includes(q.focus) && q.coverageDaysThreshold == null) throw new Error('请填写快慢判断的天数阈值')
|
|
|
|
|
if (q.turnoverStatus && q.turnoverStatus !== 'INSUFFICIENT') throw new Error('周转样本条件无效')
|
2026-10-14 18:19:27 +08:00
|
|
|
if (!turnoverSorts.includes(q.sortBy) || q.groupBy === 'OWNER' && ['COVERAGE_ASC', 'COVERAGE_DESC', 'UNIT_VOLUME_ASC', 'UNIT_VOLUME_DESC'].includes(q.sortBy) || q.groupBy === 'SKU' && q.sortBy === 'NO_OUTBOUND_PHYSICAL_DESC') throw new Error('排序不适用于当前分组')
|
2026-10-14 00:07:05 +08:00
|
|
|
if (!Number.isSafeInteger(q.current) || q.current < 1 || !Number.isSafeInteger(q.size) || q.size < 1 || q.size > 100) throw new Error('分页参数无效')
|
|
|
|
|
}
|
|
|
|
|
export function turnoverRoute(q: Record<string, unknown>): Partial<TurnoverQuery> {
|
|
|
|
|
if (inventoryView(q) !== 'TURNOVER') throw new Error('当前为库存明细入口')
|
2026-10-15 11:14:35 +08:00
|
|
|
const allowed = new Set(['view', 'warehouseCode', 'hideZeroInventory', 'sku', 'customerCodes', 'endDate', 'baseWindowDays', 'groupBy', 'focus', 'coverageDaysThreshold', 'turnoverStatus', 'sortBy', 'snapshotContext', 'current', 'size'])
|
2026-10-14 00:07:05 +08:00
|
|
|
for (const key of Object.keys(q)) if (!allowed.has(key)) throw new Error(`库存周转不接受参数 ${key}`)
|
2026-10-15 11:14:35 +08:00
|
|
|
const result: Record<string, string | number | boolean | string[]> = {}
|
2026-10-14 00:07:05 +08:00
|
|
|
for (const key of allowed) {
|
2026-10-15 11:14:35 +08:00
|
|
|
if (key === 'customerCodes') {
|
|
|
|
|
result[key] = normalizeTurnoverOwners(q[key] == null ? [] : Array.isArray(q[key]) ? q[key] : [q[key]])
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-10-14 00:07:05 +08:00
|
|
|
const value = scalar(q, key)
|
2026-10-15 10:29:43 +08:00
|
|
|
if (key === 'hideZeroInventory' && value != null) {
|
|
|
|
|
if (!['true', 'false'].includes(value)) throw new Error('隐藏零库存参数无效')
|
|
|
|
|
result[key] = value === 'true'
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-10-14 00:07:05 +08:00
|
|
|
if (value != null && key !== 'view') result[key] = ['baseWindowDays', 'coverageDaysThreshold', 'current', 'size'].includes(key) ? Number(value) : value
|
|
|
|
|
}
|
|
|
|
|
return result as Partial<TurnoverQuery>
|
|
|
|
|
}
|