Files
admin-web/src/modules/self-operated/turnoverPresentation.ts
T

82 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)) }],
}
}