feat(selfop): add volume ranking and refine turnover presentation

This commit is contained in:
2026-10-14 18:19:27 +08:00
parent 8663a829e2
commit 08fac66fe1
13 changed files with 177 additions and 60 deletions
@@ -10,6 +10,17 @@ function point(date: string, value: number | null, dataStatus = 'COMPLETE'): Dai
return { businessDate: date, outboundQuantity: dataStatus === 'COMPLETE' ? value : null, observedQuantity: value, dataStatus, reasonCodes: [], quantityUnit: 'PCS' }
}
describe('dashboard chart integrity and interaction', () => {
it('keeps dates and line explanations behind the description action', async () => {
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2)] }, slots: { explanation: '<p>统计日期示例</p>' } })
expect(wrapper.text()).not.toContain('统计日期示例')
expect(wrapper.text()).not.toContain('实线/实心点')
await wrapper.findAll('button').find(button => button.text() === '说明')!.trigger('click')
expect(wrapper.text()).toContain('统计日期示例')
expect(wrapper.text()).toContain('实线/实心点')
await wrapper.setProps({ points: [point('2026-10-02', 3)] })
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
wrapper.unmount()
})
it('fills only contiguous known segments and keeps unknown days unavailable for drilldown', async () => {
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2), point('2026-10-02', 4), point('2026-10-03', null, 'UNKNOWN'), point('2026-10-04', 1, 'PARTIAL')] } })
const chart = wrapper.findComponent({ name: 'VChart' })
@@ -19,10 +19,12 @@ function selectChart(event: ECElementEvent) { const point = props.points[event.d
<template>
<div>
<DashboardChartHeading title="出库趋势">
<ChartDetailsDialog v-model="detailsOpen" inline button-label="每日数值与覆盖原因" title="查看每日数值与覆盖原因" :reset-on="points"><table class="density-table"><thead><tr><th>日期(点击查看)</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td><button type="button" :disabled="!available(point)" @click="select(point)">{{ point.businessDate }}</button></td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></ChartDetailsDialog>
</DashboardChartHeading>
<slot name="intro" />
<ChartDetailsDialog v-model="detailsOpen" inline button-label="明细" title="查看每日数值与覆盖原因" :reset-on="points"><table class="density-table"><thead><tr><th>日期(点击查看)</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td><button type="button" :disabled="!available(point)" @click="select(point)">{{ point.businessDate }}</button></td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></ChartDetailsDialog>
<ChartDetailsDialog inline button-label="说明" title="出库趋势 · 统计说明" width="760px" :reset-on="points">
<slot name="explanation" />
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 未知及缺失日期断线;今日为已采集累计</p>
</ChartDetailsDialog>
</DashboardChartHeading>
<AnalyticsChart :option="option" class="dashboard-trend" aria-label="全仓每日出库趋势" @select="selectChart" />
<p v-if="!hasData">所选日期暂无可靠出库资料,未知不按零显示。</p>
@@ -17,7 +17,7 @@ const volume = { value: 1.25, knownSubtotal: 1.25, missingDimensionQuantity: 0,
const volumes = [7, 15, 30].map(days => ({ days, startDate: '2026-09-13', endDateExclusive: '2026-10-13', volume }))
const row: TurnoverRow = { inventoryVolumeM3: volume, outboundVolumesM3: volumes, unitVolumeM3: 0.0125, dimensionSource: 'WAREHOUSE_MEASURED', customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, transitQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutboundInWindow: risk, slowCoverage: risk, unassessableSkuCount: 0 }
function response(q: TurnoverQuery): TurnoverResponse {
return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { inventoryVolumeM3: volume, outboundVolumesM3: volumes, physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { inventoryVolumeM3: volume, outboundVolumesM3: volumes, physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
}
const wrappers: ReturnType<typeof mount>[] = []
function page() { const w = mount(InventoryTurnoverView, { global: { stubs: { AdminPage: shell, DataTable: table, QueryToolbar: toolbar, AnalyticsChart: true, ChartDetailsDialog: true, AppDialog: true, ApiErrorMeta: true, 'el-button': button, 'el-tag': shell, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('div', [slots.reference?.(), slots.default?.()]) } }) } } }); wrappers.push(w); return w }
@@ -28,6 +28,32 @@ async function change(w: ReturnType<typeof mount>, patch: object, search = false
beforeEach(() => { vi.clearAllMocks(); mocks.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }]); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() })
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
describe('inventory turnover page fixed snapshot behavior', () => {
it('defaults to volume ranking, switches locally and drills into the same owner snapshot', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
if (result.charts) {
result.charts.ownerVolumeTop = [{ customerCode: 'OWNER-A', customerName: '货主甲', inventoryVolumeM3: { ...volume, value: 1234.56789 } }]
result.charts.unknownVolumeOwnerCount = 2
}
return result
})
const w = page(); await flushPromises()
expect(w.get('[aria-label="图表指标"] button[aria-pressed="true"]').text()).toBe('货主库存立方数 Top10')
expect(w.text()).toContain('2 个货主因缺尺寸或库存数量未知,未参与排名')
const chart = w.findComponent({ name: 'AnalyticsChart' })
const option = chart.props('option') as { series: { data: number[]; label: { position: string; formatter: (p: {value: number}) => string } }[] }
expect(option.series[0]!.data).toEqual([1234.56789])
expect(option.series[0]!.label.position).toBe('right')
expect(option.series[0]!.label.formatter({ value: 1234.56789 })).toBe('1,234.57 m³')
const calls = mocks.turnover.mock.calls.length
await w.get('[aria-label="图表指标"] button:last-child').trigger('click')
expect(w.get('[aria-label="图表指标"] button[aria-pressed="true"]').text()).toBe('日均出库对比')
await w.get('[aria-label="图表指标"] button:first-child').trigger('click')
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
w.findComponent({ name: 'AnalyticsChart' }).vm.$emit('select', { dataIndex: 0 }); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', groupBy: 'SKU', current: 1, snapshotContext: 'token-A' })
})
it('shows customer and SKU volume, missing dimensions and estimates in the selected window', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
@@ -58,7 +84,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
it('sorts every numeric header on the server in both directions with the same snapshot', async () => {
const w = page(); await flushPromises()
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
const metrics = { physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
const metrics = { inventoryVolumeM3: 'INVENTORY_VOLUME', outboundVolumesM3: 'OUTBOUND_VOLUME', unitVolumeM3: 'UNIT_VOLUME', physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
for (const [prop, metric] of Object.entries(metrics)) {
// Start on a later page and with another sort to prove that a header resets pagination.
await change(w, { sortBy: metric === 'PHYSICAL' ? 'AVAILABLE_DESC' : 'PHYSICAL_DESC' }, true)
@@ -90,6 +116,11 @@ describe('inventory turnover page fixed snapshot behavior', () => {
const w = page(); await flushPromises()
await change(w, { groupBy: 'OWNER' })
expect(w.find('button[data-column="inventoryCoverageDays"]').exists()).toBe(false)
expect(w.find('button[data-column="unitVolumeM3"]').exists()).toBe(false)
for (const [prop, metric] of [['inventoryVolumeM3', 'INVENTORY_VOLUME'], ['outboundVolumesM3', 'OUTBOUND_VOLUME']]) {
await w.get(`button[data-column="${prop}"]`).trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sortBy: `${metric}_DESC` })
}
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sortBy: 'AVERAGE_DAILY_DESC' })
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
@@ -109,6 +140,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
return result
})
const w = page(); await flushPromises()
await w.get('[aria-label="图表指标"] button:last-child').trigger('click')
const chart = w.findComponent({ name: 'AnalyticsChart' }).props('option') as { series: { data: (number | null)[]; label: { show: boolean; formatter: (params: { value: number | null }) => string } }[] }
expect(chart.series[0]!.label.show).toBe(true)
expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[0]! })).toBe('1,234.57')
@@ -165,14 +197,14 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(w.text()).toContain('当前库存已过期')
expect(w.text()).toContain('库存分页出现重复记录')
expect(w.text()).toContain('LXCOL-TEST-STOCK')
expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article strong').map(n => n.text())).toEqual(['—', '100', '—', '100'])
expect(w.findAll('.turnover-cards article small')[2]!.text()).toBe('已知小计 0 · 5402 SKU待核验')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['—', '100', '—', '100'])
expect(w.get('article[data-metric="no-outbound"] small').text()).toBe('已知小计 0 · 5402 SKU待核验')
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'SUCCEEDED' } }] })
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { const result = response(q); result.context.analyticsContext.inventoryStatus = 'VALID'; return result })
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(mocks.overview).toHaveBeenCalledTimes(2)
expect(w.text()).not.toContain('当前库存已过期'); expect(w.text()).not.toContain('库存分页出现重复记录')
expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article strong')[0]!.text()).toBe('100')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong')[0]!.text()).toBe('100')
})
it('does not show another warehouse failure and separates runtime refresh failure from report data', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY' }, { warehouseCode: 'B', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-OTHER', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] })
@@ -182,7 +214,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(w.text()).toContain('采集状态更新失败')
expect(w.text()).not.toContain('库存周转读取失败')
expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article strong')[0]!.text()).toBe('100')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong')[0]!.text()).toBe('100')
})
it('renders quantity and window cells through the shared table slot contract', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
@@ -196,7 +228,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(w.text()).toContain('数据不足/待观察')
expect(w.text()).toContain('300')
expect(w.text()).toContain('暂无可排名的库存数据')
expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
expect(w.findAllComponents({ name: 'AppDialog' }).every(d => d.attributes('append-to-body') !== undefined)).toBe(true)
})
it('honors an explicit new route token in the same scope and reloads its overview', async () => {
@@ -16,10 +16,10 @@ import { RequestFence, beijingTime, contextParams } from '../state'
import type { DailyResponse, Overview } from '../types'
import { overviewRange, overviewRangeOptions, beijingDate } from '../overviewRange'
import type { RangePreset } from '../types'
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow, TurnoverTotal, TurnoverVolume } from '../turnoverTypes'
import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
import { totalText, turnoverTotalNote, volumeNote, dimensionSourceText, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import { totalText, turnoverTotalNote, volumeText, volumeNote, dimensionSourceText, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import AnalyticsChart from './AnalyticsChart.vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import SkuTrend from './SkuTrend.vue'
@@ -34,6 +34,7 @@ const runtime = ref<Overview | null>(null), data = ref<TurnoverResponse | null>(
const error = ref<ApiErrorDetails | null>(null), inputError = ref(''), notice = ref(''), loading = ref(false)
const filters = ref<QueryModel>({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true)
const rightChart = ref<'volume' | 'daily'>('volume')
const fence = new RequestFence(), runtimeFence = new RequestFence(), trendFence = new RequestFence()
const trendOpen = ref(false), trendLoading = ref(false), daily = ref<DailyResponse | null>(null), trendError = ref<ApiErrorDetails | null>(null)
const evidence = ref<TurnoverRow | null>(null), evidenceOpen = ref(false)
@@ -51,7 +52,7 @@ const fields = computed<QueryField[]>(() => [
...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []),
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') && !value.startsWith('UNIT_VOLUME_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
])
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
@@ -59,9 +60,9 @@ const columns = computed<DataTableColumn[]>(() => [
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160 },
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
{ prop: 'inventoryVolumeM3', label: '库存立方数(m³)', slotName: 'inventoryVolume', minWidth: 200 },
{ prop: 'outboundVolumesM3', label: `${applied.value?.baseWindowDays ?? 30}日出库立方数(m³,估算)`, slotName: 'outboundVolume', minWidth: 220 },
...(ownerMode.value ? [] : [{ prop: 'unitVolumeM3', label: '单件体积(m³)', slotName: 'unitVolume', minWidth: 155 }]),
{ prop: 'inventoryVolumeM3', label: '库存立方数', slotName: 'inventoryVolume', minWidth: 145 },
{ prop: 'outboundVolumesM3', label: `${applied.value?.baseWindowDays ?? 30}日出库立方数`, slotName: 'outboundVolume', minWidth: 170 },
...(ownerMode.value ? [] : [{ prop: 'unitVolumeM3', label: '单件体积', slotName: 'unitVolume', minWidth: 135 }]),
{ prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 },
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })),
{ prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slotName: 'daily', minWidth: 110 },
@@ -81,15 +82,26 @@ async function sortColumn(prop: string) {
filters.value = { ...filters.value, sortBy }
await search()
}
const cards = computed(() => data.value?.summary ? [
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
{ name: '所选时段无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
] : [])
const incompleteDailyWindows = computed(() => data.value?.charts?.averageDaily.filter(w => w.dataStatus !== 'COMPLETE' || w.averageDailyOutboundQuantity == null) ?? [])
const volumeCards = computed(() => data.value?.summary ? [
{ name: '库存立方数(m³)', value: data.value.summary.inventoryVolumeM3, outbound: false },
{ name: `所选 ${applied.value?.baseWindowDays ?? 30} 日出库立方数(m³,估算)`, value: data.value.summary.outboundVolumesM3.find(w => w.days === applied.value?.baseWindowDays)?.volume, outbound: true },
] : [])
const summaryGroups = computed(() => {
const summary = data.value?.summary
if (!summary) return []
const quantity = (key: string, name: string, value: TurnoverTotal, tone: string) => ({ key, name, kind: 'quantity', tone, text: turnoverNumber(value.value), note: turnoverTotalNote(value), title: undefined as string | undefined })
const volume = (key: string, name: string, value: TurnoverVolume | undefined, outbound: boolean, tone: string) => ({ key, name, kind: 'volume', tone, text: turnoverNumber(value?.value, 2), note: volumeNote(value, outbound, 2), title: value?.value != null ? volumeText(value.value) : undefined })
const days = applied.value?.baseWindowDays ?? 30
return [
{ key: 'stock', name: '当前库存', note: '使用当前库存快照,不随统计时段变化', cards: [
quantity('physical', '实物件数', summary.physicalQuantity, 'primary'),
quantity('available', '正品可售件数', summary.availableQuantity, 'green'),
volume('inventory-volume', '库存立方数(m³)', summary.inventoryVolumeM3, false, 'purple'),
] },
{ key: 'period', name: '所选时段分析', note: `${days}天 · 截至 ${applied.value?.endDate ?? '—'}`, cards: [
quantity('no-outbound', '所选时段无出库SKU', summary.noOutboundSkuCount, 'orange'),
volume('outbound-volume', `${days}日出库立方数(m³)`, summary.outboundVolumesM3.find(w => w.days === days)?.volume, true, 'green'),
quantity('unassessable', '待评估SKU', summary.unassessableSkuCount, 'purple'),
] },
]
})
const runtimeError = ref('')
const inventoryRuntime = computed(() => runtime.value?.warehouses.find(w => w.warehouseCode === applied.value?.warehouseCode && w.dataFamily === 'INVENTORY'))
const collectionNotice = computed(() => !data.value || runtimeError.value ? '' : inventoryCollectionNotice(inventoryRuntime.value))
@@ -252,14 +264,43 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<p v-else-if="data?.context.analyticsContext.inventoryStatus === 'UNKNOWN'" class="turnover-context" role="status">当前尚无可用库存版本,请查看数据采集。</p>
<p v-if="collectionNotice" class="turnover-context" role="status">{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。<el-button link @click="router.push('/self-operated/collection')">查看数据采集</el-button></p>
<p v-if="runtimeError" class="turnover-context" role="status">{{ runtimeError }}</p>
<div v-if="cards.length" class="turnover-cards"><article v-for="card in cards" :key="card.name"><span>{{ card.name }}</span><strong>{{ turnoverNumber(card.value.value) }}</strong><small>{{ turnoverTotalNote(card.value) }}</small></article></div>
<div v-if="volumeCards.length" class="turnover-cards turnover-volume-cards"><article v-for="card in volumeCards" :key="card.name"><span>{{ card.name }}</span><strong>{{ turnoverNumber(card.value?.value, 6) }}</strong><small>{{ volumeNote(card.value, card.outbound) }}</small></article></div>
<div v-if="summaryGroups.length" class="turnover-summary">
<section v-for="group in summaryGroups" :key="group.key" class="turnover-summary-group" :data-group="group.key" :aria-label="group.name">
<header><h4>{{ group.name }}</h4><span>{{ group.note }}</span></header>
<div class="turnover-cards">
<article v-for="card in group.cards" :key="card.key" :data-metric="card.key" :data-kind="card.kind" :data-tone="card.tone">
<span>{{ card.name }}</span><strong :title="card.title">{{ card.text }}</strong><small>{{ card.note }}</small>
</article>
</div>
</section>
</div>
<p v-if="data" class="turnover-context">体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。</p>
<template v-if="data?.charts">
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
<div v-if="chartsOpen" class="turnover-charts">
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
<section><header class="turnover-chart-header"><h3>日均出库对比(含所选时段)</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div></section>
<section aria-label="库存体积与出库对比">
<header class="turnover-chart-header">
<div class="density-status-tabs" role="group" aria-label="图表指标">
<button type="button" :class="{ 'is-active': rightChart === 'volume' }" :aria-pressed="rightChart === 'volume'" @click="rightChart = 'volume'">货主库存立方数 Top10</button>
<button type="button" :class="{ 'is-active': rightChart === 'daily' }" :aria-pressed="rightChart === 'daily'" @click="rightChart = 'daily'">日均出库对比</button>
</div>
<ChartDetailsDialog v-if="rightChart === 'volume'" key="volume" inline button-label="查看明细" title="货主库存立方数明细" :reset-on="data.context.snapshotContext">
<p>当前库存,不随统计时段变化;按完整体积排名,不含在途及库位空隙。</p>
<p v-if="data.charts.unknownVolumeOwnerCount">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</p>
<table class="density-table"><thead><tr><th>货主</th><th>库存立方数</th><th>计算说明</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerVolumeTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td :title="volumeText(owner.inventoryVolumeM3.value)">{{ volumeText(owner.inventoryVolumeM3.value, 2) }}</td><td>{{ volumeNote(owner.inventoryVolumeM3) }}</td></tr></tbody></table>
</ChartDetailsDialog>
<ChartDetailsDialog v-else key="daily" inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog>
</header>
<template v-if="rightChart === 'volume'">
<AnalyticsChart v-if="data.charts.ownerVolumeTop.length" key="volume" class="turnover-chart" :option="turnoverChart(data.charts, 'volume')" @select="event => { const owner = data?.charts?.ownerVolumeTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" />
<div v-else class="turnover-chart-empty"><strong>暂无可排名的库存体积数据</strong><span>需有可用库存及有效尺寸;未知体积不按零参与排名</span></div>
<p class="turnover-chart-note">当前库存,不随统计时段变化;尺寸口径见明细。<span v-if="data.charts.unknownVolumeOwnerCount">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</span></p>
</template>
<template v-else>
<p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div>
</template>
</section>
</div>
</template>
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
@@ -283,9 +324,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<template #owner="{ row }"><el-button v-if="ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)">{{ castRow(row).customerName || castRow(row).customerCode }}</el-button><span v-else>{{ castRow(row).customerName || castRow(row).customerCode }}</span></template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'transitQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }">{{ totalText(castRow(row)[key]) }}</template>
<template v-for="n in [7, 15, 30] as TurnoverWindow[]" :key="n" #[`outbound${n}`]="{ row }">{{ windowText(castRow(row).windows.find(w => w.days === n)) }}</template>
<template #inventoryVolume="{ row }">{{ turnoverNumber(castRow(row).inventoryVolumeM3.value, 6) }}<small>{{ volumeNote(castRow(row).inventoryVolumeM3) }}</small></template>
<template #outboundVolume="{ row }">{{ turnoverNumber(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume.value, 6) }}<small>{{ volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true) }}</small></template>
<template #unitVolume="{ row }">{{ turnoverNumber(castRow(row).unitVolumeM3, 9) }}<small>{{ dimensionSourceText(castRow(row).dimensionSource) }}</small></template>
<template #inventoryVolume="{ row }">{{ volumeText(castRow(row).inventoryVolumeM3.value) }}<small>{{ volumeNote(castRow(row).inventoryVolumeM3) }}</small></template>
<template #outboundVolume="{ row }">{{ volumeText(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume.value) }}<small>{{ volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true) }}</small></template>
<template #unitVolume="{ row }">{{ volumeText(castRow(row).unitVolumeM3, 9) }}<small>{{ dimensionSourceText(castRow(row).dimensionSource) }}</small></template>
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
<template #status="{ row }"><small>{{ reasonsText(castRow(row).attentionReasons) }}</small><span v-if="ownerMode">待评估 {{ castRow(row).unassessableSkuCount }} SKU</span><el-button link @click="evidence = castRow(row); evidenceOpen = true">日样本 {{ castRow(row).qualifiedSampleDays }}/{{ castRow(row).requiredSampleDays }}</el-button></template>
@@ -312,15 +353,19 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
.turnover-title { margin: 0; font-size: var(--admin-font-section); display: flex; align-items: center; gap: 16px; }
.turnover-title span { font-size: var(--admin-font-caption); font-weight: 400; color: var(--el-text-color-secondary); }
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 4px 0 12px; }
.turnover-volume-cards { grid-template-columns: repeat(2,minmax(0,1fr)); }
.turnover-cards article { position: relative; overflow: hidden; padding: 16px 20px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
.turnover-cards article:nth-child(2) { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
.turnover-cards article:nth-child(3) { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
.turnover-cards article:nth-child(4) { color: #8a62bf; border-color: #e9e0f5; background: linear-gradient(120deg, #fff, #f6f0fc); }
.turnover-summary { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 4px 0 12px; }
.turnover-summary-group { min-width: 0; display: flex; flex-direction: column; }
.turnover-summary-group > header { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
.turnover-summary-group h4 { margin: 0; font-size: var(--admin-font-body); }
.turnover-summary-group > header span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-cards { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; flex: 1; }
.turnover-cards article { position: relative; overflow: hidden; min-width: 0; padding: 12px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
.turnover-cards article[data-tone="green"] { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
.turnover-cards article[data-tone="orange"] { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
.turnover-cards article[data-tone="purple"] { color: #8a62bf; border-color: #e9e0f5; background: linear-gradient(120deg, #fff, #f6f0fc); }
.turnover-cards article::after { content: ''; position: absolute; width: 88px; height: 88px; border-radius: 50%; right: -22px; top: -30px; background: currentColor; opacity: .07; pointer-events: none; }
.turnover-cards article > span { color: var(--el-text-color-regular); }
.turnover-cards strong { display: block; font-size: 28px; margin: 8px 0 4px; font-variant-numeric: tabular-nums; }
.turnover-cards strong { display: block; font-size: 24px; margin: 6px 0 4px; font-variant-numeric: tabular-nums; }
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 0 12px; align-items: stretch; }
.turnover-charts section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
@@ -145,3 +145,13 @@
| 总览、销量、库存周转 | 货主归属展示 | 本轮客户截图与名称映射确认;名称优先、编码辅助或名称缺失时显示编码 | 待人工截图 |
| 经营总览/趋势与库存分布精简 | 用户截图要求与Top10一致 | 日期、线型图例、候选与排除数量、分组口径收进各图右上角“说明”;趋势每日数据入口简称“明细”;保留图轴、空态、点击下钻及明细 | 交互回归及构建;视觉待人工截图确认 |
| 库存周转/体积列紧凑展示与排序 | 用户截图 `codex-clipboard-e7ad0387-165e-4c53-b4d6-eff3d357888c.png` 要求单位随值、缩短表头并排序 | 三列表头移除单位,数值后带m³,未知只显示—;列宽收紧,估算及缺尺寸说明保留;库存/所选窗口出库体积支持SKU和货主全量排序,单件体积仅SKU;未知末尾,切换回第一页 | 前后端排序、分页和格式回归;视觉待人工1920×1080、100%截图确认 |
| 库存周转/体积汇总精度 | 用户截图 `codex-clipboard-d53f8b33-b441-4f46-9ed0-1ac931757d44.png` 指出6位小数冗长 | 两张体积卡片及已知小计最多2位小数,省略末尾零,极小正数显示<0.01;主值悬停保留6位精度,未知仍为—;明细精度、计算及排序不变 | 定向回归与类型检查;视觉待人工截图确认 |
| 库存周转/六项概览单排 | 用户要求六张卡片压紧放一排 | 1920×1080基线下件数/SKU四项与体积两项共用六列网格,收紧内边距及数字字号;体积最多两位小数,出库估算说明及待核验信息保留 | 定向回归与类型检查;视觉待人工截图确认 |
| 库存周转/库存与时段指标分组 | 用户明确三项当前库存不受时段影响,要求按含义优化排版 | 保持六卡一排,左组“当前库存”含实物、可售、库存体积并说明库存快照口径;右组“所选时段分析”含无出库、出库体积、待评估并显示所选天数及截止日;出库估算及未知说明保留 | 定向回归与类型检查;视觉待人工1920×1080、100%截图确认 |
| 库存周转/右侧图表切换 | 本轮用户截图及确认方案(2026-10-14) | 保留左右两栏,右侧默认货主库存立方数 Top10,标题处切换日均出库对比;复用现有图表明细框和货主下钻 | 条末显示两位小数及 m³;缺尺寸或数量未知不排名并提示数量 | 待人工 1920×1080、100% 截图确认 |
@@ -93,8 +93,8 @@ describe('warehouse operating dashboard', () => {
data.noOutboundSkus = { ...data.noOutboundSkus, items: [], eligibleCount: '0', unassessableCount: '2741', dataStatus: 'PARTIAL' }
mocks.dashboard.mockResolvedValue(data)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')).toHaveLength(3)
await wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')[1]!.trigger('click'); await flushPromises()
expect(wrapper.findAll('.dashboard-rankings .dashboard-chart-actions button').filter(node => node.text() === '说明')).toHaveLength(3)
await wrapper.findAll('.dashboard-rankings .dashboard-chart-actions button').filter(node => node.text() === '说明')[1]!.trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('全仓库存分布的待核验原因')
expect(wrapper.text()).toContain('待评估 2711 SKU')
expect(wrapper.text()).toContain('待评估 2741 SKU')
@@ -218,6 +218,7 @@ describe('warehouse operating dashboard', () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: null }, coverageDistribution: { ...dashboardFixture.coverageDistribution, candidateSkuCount: null, buckets: [], dataStatus: 'UNKNOWN', reasonCodes: ['INVENTORY_UNAVAILABLE'] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('无可用库存分布资料')
await wrapper.findAll('.dashboard-main-charts button').filter(node => node.text() === '说明')[1]!.trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('库存资料不可用')
expect(wrapper.findComponent(DashboardTrend).exists()).toBe(true)
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
@@ -302,7 +303,7 @@ describe('warehouse operating dashboard', () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAllComponents(DashboardBars)[1]!.props('rows')).toEqual([])
expect(wrapper.find('.dashboard-rankings').text()).not.toContain('未采集日期不计为零出库')
await findButton(wrapper, '说明').trigger('click'); await flushPromises()
await wrapper.findAll('.dashboard-rankings button').find(node => node.text() === '说明')!.trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('未采集日期不计为零出库')
await findButton(wrapper, '查看Top100').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ startDate: '2026-10-13', endDate: '2026-10-13', rangePreset: 'CUSTOM' })
@@ -217,15 +217,15 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<div class="dashboard-main-charts">
<section class="dashboard-chart">
<DashboardTrend :points="data.salesTrend.points" :disabled="contextExpired" @select="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })">
<template #intro><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p></template>
<template #explanation><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p></template>
</DashboardTrend>
</section>
<section class="dashboard-chart">
<DashboardBars :rows="data.coverageDistribution.candidateSkuCount == null ? [] : bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })">
<template #actions>
<ChartDetailsDialog inline button-label="分组说明" title="分组说明与待核验原因" width="760px" :reset-on="data"><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></ChartDetailsDialog>
<ChartDetailsDialog inline button-label="说明" title="库存消化天数分布 · 统计说明" width="760px" :reset-on="data"><p>有实物库存或库存待核验 SKU;{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></ChartDetailsDialog>
</template>
<template #intro><p>有实物库存或库存待核验 SKU;{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p></template>
<template #empty><p>无可用库存分布资料;销售候选不作为库存总量。</p></template>
</DashboardBars>
</section>
@@ -14,7 +14,7 @@ describe('inventory view and turnover query contract', () => {
})
it('validates independent focus, threshold, group, date and pagination boundaries', () => {
expect(() => validateTurnover(valid, '2026-10-12')).not.toThrow()
for (const patch of [{ endDate: '2026-02-30' }, { endDate: '2026-10-13' }, { groupBy: 'OWNER', sku: 'X' }, { groupBy: 'OWNER', sortBy: 'COVERAGE_DESC' }, { focus: 'COVERAGE_GT' }, { coverageDaysThreshold: 1.5 }, { size: 101 }, { current: 0 }, { baseWindowDays: 367 }]) expect(() => validateTurnover({ ...valid, ...patch } as TurnoverQuery, '2026-10-12')).toThrow()
for (const patch of [{ endDate: '2026-02-30' }, { endDate: '2026-10-13' }, { groupBy: 'OWNER', sku: 'X' }, { groupBy: 'OWNER', sortBy: 'COVERAGE_DESC' }, { groupBy: 'OWNER', sortBy: 'UNIT_VOLUME_DESC' }, { focus: 'COVERAGE_GT' }, { coverageDaysThreshold: 1.5 }, { size: 101 }, { current: 0 }, { baseWindowDays: 367 }]) expect(() => validateTurnover({ ...valid, ...patch } as TurnoverQuery, '2026-10-12')).toThrow()
expect(() => validateTurnover({ ...valid, focus: 'COVERAGE_LE', coverageDaysThreshold: 45 }, '2026-10-12')).not.toThrow()
})
it('uses Beijing yesterday across UTC day boundaries', () => {
@@ -33,7 +33,7 @@ export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing
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('周转样本条件无效')
if (!turnoverSorts.includes(q.sortBy) || q.groupBy === 'OWNER' && ['COVERAGE_ASC', 'COVERAGE_DESC'].includes(q.sortBy) || q.groupBy === 'SKU' && q.sortBy === 'NO_OUTBOUND_PHYSICAL_DESC') throw new Error('排序不适用于当前分组')
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('排序不适用于当前分组')
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> {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { turnoverChart, turnoverNumber, windowText, turnoverReasonsText, volumeNote } from './turnoverPresentation'
import { turnoverChart, turnoverNumber, volumeText, windowText, turnoverReasonsText, volumeNote } from './turnoverPresentation'
import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes'
describe('turnover honest precision and charts', () => {
it('keeps unknown volume distinct from zero and labels historical volume as estimated', () => {
@@ -19,10 +19,14 @@ describe('turnover honest precision and charts', () => {
expect(turnoverNumber(0.000001)).toBe('<0.01')
expect(turnoverNumber(0.01, 1)).toBe('<0.1')
expect(turnoverNumber(0)).toBe('0'); expect(turnoverNumber(null)).toBe('—')
expect(volumeText(1.25)).toBe('1.25 m³')
expect(volumeText(0)).toBe('0 m³')
expect(volumeText(null)).toBe('—')
expect(volumeText(0.0000000001, 9)).toBe('<0.000000001 m³')
})
it('does not draw partial-window totals as daily averages or zero bars', () => {
const partial: TurnoverWindowMetric = { days: 15, startDate: '2026-09-28', endDateExclusive: '2026-10-13', dataStatus: 'PARTIAL', observedQuantity: 150, outboundQuantity: null, averageDailyOutboundQuantity: null, completeDayCount: 7, partialDayCount: 1, unknownDayCount: 7, reasonCodes: [] }
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: [{ ...partial, days: 7, dataStatus: 'COMPLETE', averageDailyOutboundQuantity: 10 }, partial] }
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: [{ ...partial, days: 7, dataStatus: 'COMPLETE', averageDailyOutboundQuantity: 10 }, partial] }
const option = turnoverChart(charts, 'daily')
const tooltip = option.tooltip as { valueFormatter: (value: number | null) => string }
expect(tooltip.valueFormatter(2165.133333)).toBe('2,165.13 件/日')
@@ -17,6 +17,10 @@ export function turnoverNumber(value: Quantity | undefined, decimals = 2) {
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待核验`
@@ -26,10 +30,10 @@ export function turnoverTotalNote(total: TurnoverTotal) {
const known = total.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${turnoverNumber(total.knownSubtotal)}`
return `${known} · ${total.unknownSkuCount} SKU待核验`
}
export function volumeNote(volume: TurnoverVolume | undefined, outbound = false) {
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, 6)} m³`)
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)} 件按商品尺寸估算`)
@@ -56,18 +60,22 @@ 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' | 'daily'): EChartsOption {
const owners = kind === 'owners'
const rows = owners ? charts.ownerPhysicalTop : charts.averageDaily
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)} ${owners ? '件' : '件/日'}` }, grid: { left: 12, right: 40, top: owners ? 12 : 48, bottom: 12, containLabel: true },
xAxis: owners ? { type: 'value', name: '件' } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) },
yAxis: owners ? { type: 'category', inverse: true, data: charts.ownerPhysicalTop.map(w => w.customerName || w.customerCode) } : { type: 'value', name: '件/日', nameGap: 12, nameTextStyle: { align: 'left' } },
series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 }, label: { show: !owners, position: 'top', distance: 8, formatter: params => turnoverNumber(typeof params.value === 'number' ? params.value : null) }, data: rows.map((_, i) => {
const value = owners ? charts.ownerPhysicalTop[i]?.physicalQuantity : charts.averageDaily[i]?.dataStatus === 'COMPLETE' ? charts.averageDaily[i]?.averageDailyOutboundQuantity : null
return value == null ? null : Number(value)
}) }],
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)) }],
}
}
@@ -1,6 +1,7 @@
import type { TurnoverSort, TurnoverSortMetric } from './turnoverTypes'
export const turnoverSortColumns: Record<string, TurnoverSortMetric> = {
inventoryVolumeM3: 'INVENTORY_VOLUME', outboundVolumesM3: 'OUTBOUND_VOLUME', unitVolumeM3: 'UNIT_VOLUME',
physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT',
outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30',
averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE',
@@ -8,6 +9,7 @@ export const turnoverSortColumns: Record<string, TurnoverSortMetric> = {
averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER',
}
const metricLabels: Record<TurnoverSortMetric, string> = {
INVENTORY_VOLUME: '库存立方数', OUTBOUND_VOLUME: '所选时段出库立方数(估算)', UNIT_VOLUME: '单件体积',
PHYSICAL: '实物件数', AVAILABLE: '正品可售', TRANSIT: '在途', OUTBOUND_7: '完整7日出库',
OUTBOUND_15: '完整15日出库', OUTBOUND_30: '完整30日出库', AVERAGE_DAILY: '基准窗口日均出库',
COVERAGE: '预计可售天数', FROZEN: '冻结件数', DEFECTIVE: '次品实物',
+3 -1
View File
@@ -4,7 +4,7 @@ import type { QueryContext, Quantity } from './types'
export type TurnoverWindow = number
export type TurnoverGroup = 'SKU' | 'OWNER'
export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_IN_WINDOW' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE'
export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'TRANSIT' | 'OUTBOUND_7' | 'OUTBOUND_15' | 'OUTBOUND_30' | 'AVERAGE_DAILY' | 'COVERAGE' | 'FROZEN' | 'DEFECTIVE' | 'AVERAGE_PHYSICAL' | 'TURNOVER'
export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'TRANSIT' | 'OUTBOUND_7' | 'OUTBOUND_15' | 'OUTBOUND_30' | 'AVERAGE_DAILY' | 'COVERAGE' | 'FROZEN' | 'DEFECTIVE' | 'AVERAGE_PHYSICAL' | 'TURNOVER' | 'INVENTORY_VOLUME' | 'OUTBOUND_VOLUME' | 'UNIT_VOLUME'
export type TurnoverSort = `${TurnoverSortMetric}_${'ASC' | 'DESC'}` | 'NO_OUTBOUND_PHYSICAL_DESC'
export interface TurnoverQuery {
sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow
@@ -49,6 +49,8 @@ export interface TurnoverOverview {
}
export interface TurnoverCharts {
ownerPhysicalTop: { customerCode: string; customerName: string | null; physicalQuantity: Quantity }[]
ownerVolumeTop: { customerCode: string; customerName: string | null; inventoryVolumeM3: TurnoverVolume }[]
unknownVolumeOwnerCount: number
unknownOwnerCount: number; averageDaily: TurnoverWindowMetric[]
}
export interface TurnoverResponse {