82 lines
6.0 KiB
TypeScript
82 lines
6.0 KiB
TypeScript
import type { Quantity, WarehouseOverview } from './types'
|
||
import type { TurnoverCharts, TurnoverTotal, TurnoverVolume, TurnoverWindowMetric } from './turnoverTypes'
|
||
import type { EChartsOption } from 'echarts'
|
||
import { reasonsText } from './state'
|
||
const turnoverLabels: Record<string, string> = {
|
||
INSUFFICIENT: '合格日库存样本不足', ZERO_AVERAGE_STOCK: '每日观测未捕获存量,无法计算周转',
|
||
INVENTORY_UNKNOWN_OR_STALE: '当前库存未知、异常或已过期', NOT_APPLICABLE: '货主不计算整体可售天数',
|
||
SALES_OR_STOCK_UNASSESSABLE: '部分商品的销量或库存不足以判断',
|
||
SALES_WINDOW_UNAVAILABLE: '同期销量或商品观察证据不足',
|
||
}
|
||
export function turnoverReasonsText(reasons: readonly string[] | null | undefined) {
|
||
return reasons?.map(reason => turnoverLabels[reason] ?? reasonsText([reason])).join('、') || '—'
|
||
}
|
||
export function turnoverNumber(value: Quantity | undefined, decimals = 2) {
|
||
if (value == null || value === '' || !Number.isFinite(Number(value))) return '—'
|
||
const n = Number(value)
|
||
if (n > 0 && n < 10 ** -decimals) return `<${(10 ** -decimals).toFixed(decimals)}`
|
||
return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals })
|
||
}
|
||
export function volumeText(value: Quantity | undefined, decimals = 6) {
|
||
const text = turnoverNumber(value, decimals)
|
||
return text === '—' ? text : `${text} m³`
|
||
}
|
||
export function totalText(total: TurnoverTotal) {
|
||
if (total.value == null && total.knownSubtotal == null && total.unknownSkuCount === 0) return turnoverReasonsText(total.reasonCodes)
|
||
return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验`
|
||
}
|
||
export function turnoverTotalNote(total: TurnoverTotal) {
|
||
if (total.value != null) return '已确认'
|
||
const known = total.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${turnoverNumber(total.knownSubtotal)}`
|
||
return `${known} · ${total.unknownSkuCount} SKU待核验`
|
||
}
|
||
export function volumeNote(volume: TurnoverVolume | undefined, outbound = false, decimals = 6) {
|
||
if (!volume) return '数量或尺寸待确认'
|
||
const notes: string[] = []
|
||
if (volume.value == null) notes.push(volume.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${turnoverNumber(volume.knownSubtotal, decimals)} m³`)
|
||
if (volume.missingDimensionSkuCount) notes.push(`缺尺寸 ${volume.missingDimensionSkuCount} SKU / ${turnoverNumber(volume.missingDimensionQuantity)} 件`)
|
||
if (volume.unknownQuantitySkuCount) notes.push(`数量待确认 ${volume.unknownQuantitySkuCount} SKU`)
|
||
if (Number(volume.declaredDimensionQuantity) > 0) notes.push(`其中 ${turnoverNumber(volume.declaredDimensionQuantity)} 件按商品尺寸估算`)
|
||
if (outbound) notes.push('按当前商品尺寸估算')
|
||
else if (volume.value != null && Number(volume.value) === 0) notes.push('已知体积为0')
|
||
else if (volume.value != null && !Number(volume.declaredDimensionQuantity)) notes.push('按仓库实测尺寸计算')
|
||
return notes.join(';') || '数量或尺寸待确认'
|
||
}
|
||
export function dimensionSourceText(source: string | null) {
|
||
return source === 'WAREHOUSE_MEASURED' ? '仓库实测尺寸' : source === 'PRODUCT_DECLARED' ? '商品尺寸估算' : '缺少有效尺寸'
|
||
}
|
||
export function inventoryCollectionNotice(warehouse: WarehouseOverview | undefined) {
|
||
const task = warehouse?.currentTask
|
||
if (!task || !['DEAD', 'RETRY_WAIT'].includes(task.taskStatus) || task.latestAttempt?.status !== 'FAILED') return ''
|
||
const reasons: Record<string, string> = {
|
||
SELFOP_STOCK_DUPLICATE: '库存分页出现重复记录',
|
||
SELFOP_STOCK_COUNT_MISMATCH: '库存扫描条数与来源总数不符',
|
||
SELFOP_PAGE_TOTAL_CHANGED: '库存扫描过程中分页总数变化',
|
||
}
|
||
const reason = reasons[task.latestAttempt.errorCode ?? ''] ?? '库存采集未成功'
|
||
return `${task.taskStatus === 'RETRY_WAIT' ? '库存采集失败,等待重试' : '库存采集失败,已停止自动重试'}:${reason}。任务 ${task.taskNo}`
|
||
}
|
||
export function windowText(metric: TurnoverWindowMetric | undefined) {
|
||
if (!metric) return '—'
|
||
return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足`
|
||
}
|
||
export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'volume' | 'daily'): EChartsOption {
|
||
const owners = kind !== 'daily', volume = kind === 'volume'
|
||
const ownerRows = volume ? charts.ownerVolumeTop : charts.ownerPhysicalTop
|
||
const unit = volume ? 'm³' : owners ? '件' : '件/日'
|
||
const values = volume ? charts.ownerVolumeTop.map(w => w.inventoryVolumeM3.value)
|
||
: owners ? charts.ownerPhysicalTop.map(w => w.physicalQuantity)
|
||
: charts.averageDaily.map(w => w.dataStatus === 'COMPLETE' ? w.averageDailyOutboundQuantity : null)
|
||
return {
|
||
aria: { enabled: true }, animation: typeof window === 'undefined' || !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
||
animationDuration: 450, color: ['#6c7bea'],
|
||
tooltip: { trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${unit}` },
|
||
grid: { left: 12, right: volume ? 110 : 40, top: owners ? 12 : 48, bottom: 12, containLabel: true },
|
||
xAxis: owners ? { type: 'value', name: unit } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) },
|
||
yAxis: owners ? { type: 'category', inverse: true, data: ownerRows.map(w => w.customerName || w.customerCode) } : { type: 'value', name: unit, nameGap: 12, nameTextStyle: { align: 'left' } },
|
||
series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 },
|
||
label: { show: kind !== 'owners', position: volume ? 'right' : 'top', distance: 8, formatter: params => volume ? volumeText(typeof params.value === 'number' ? params.value : null, 2) : turnoverNumber(typeof params.value === 'number' ? params.value : null) },
|
||
data: values.map(value => value == null ? null : Number(value)) }],
|
||
}
|
||
}
|