feat(selfop): show customer and SKU volume in inventory turnover

This commit is contained in:
2026-10-14 17:48:53 +08:00
parent 46364d3f89
commit 2975ba5a7f
6 changed files with 77 additions and 10 deletions
@@ -13,9 +13,11 @@ const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { r
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] }
const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 }
const row: TurnoverRow = { 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 }
const volume = { value: 1.25, knownSubtotal: 1.25, missingDimensionQuantity: 0, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0, declaredDimensionQuantity: 0, reasonCodes: [] }
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 ? { 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, 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 }
@@ -26,6 +28,24 @@ 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('shows customer and SKU volume, missing dimensions and estimates in the selected window', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.rows[0]!.inventoryVolumeM3 = { ...volume, value: null, knownSubtotal: 0.125, missingDimensionQuantity: 12, missingDimensionSkuCount: 2 }
result.rows[0]!.outboundVolumesM3 = [{ days: q.baseWindowDays, startDate: '2026-10-10', endDateExclusive: '2026-10-13', volume: { ...volume, value: 2.5, declaredDimensionQuantity: 40 } }]
return result
})
const w = page(); await flushPromises()
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
expect(w.text()).toContain('已知小计 0.125 m³')
expect(w.text()).toContain('其中 40 件按商品尺寸估算')
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(true)
await change(w, { groupBy: 'OWNER', rangePreset: 'LAST_3_DAYS' })
expect(w.findComponent(table).props('columns').find((c: {prop: string}) => c.prop === 'outboundVolumesM3').label).toContain('3日')
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(false)
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
})
it('filters turnover by directory customer and keeps the code in pagination', async () => {
const w = page(); await flushPromises()
expect(w.findComponent(toolbar).props('fields').find((f: { key: string }) => f.key === 'customerCode').options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
@@ -145,14 +165,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 article strong').map(n => n.text())).toEqual(['—', '100', '—', '100'])
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待核验')
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 article strong')[0]!.text()).toBe('100')
expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article 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' } } }] })
@@ -162,7 +182,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 article strong')[0]!.text()).toBe('100')
expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article strong')[0]!.text()).toBe('100')
})
it('renders quantity and window cells through the shared table slot contract', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
@@ -176,7 +196,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 article strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article 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 () => {
@@ -196,7 +216,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', includeOverview: false, sku: 'SAME', current: 1 })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', current: 2, includeOverview: false })
expect(w.findAll('.turnover-cards article')).toHaveLength(4)
expect(w.findAll('.turnover-cards article')).toHaveLength(6)
})
it('rejects unauthorized deep links and does not query without warehouses or permission', async () => {
mocks.route.query = { warehouseCode: 'NO' }; const w = page(); await flushPromises()
@@ -19,7 +19,7 @@ import type { RangePreset } from '../types'
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
import { totalText, turnoverTotalNote, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import { totalText, turnoverTotalNote, 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'
@@ -59,6 +59,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: '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 },
@@ -83,6 +86,10 @@ const cards = computed(() => data.value?.summary ? [
{ 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 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))
@@ -246,6 +253,8 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<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>
<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">
@@ -274,6 +283,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 #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>
@@ -301,6 +313,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
.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); }
@@ -4,6 +4,7 @@
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
| --- | --- | --- | --- |
| 库存周转 | 库存/出库立方数与客户汇总 | 2026-10-14 用户确认:仓库实测尺寸优先,商品尺寸补充并标估算;历史出库按当前尺寸估算。沿现有客户筛选、SKU/货主分组和客户下钻,增加两项概览、体积列与单件尺寸来源,不新增弹框 | 正式总量未知显示“—”;小计、缺尺寸件数/SKU和数量待确认分开显示;出库体积跟随所选时间;待人工1920×1080、100%截图确认 |
| 库存周转 | 库存过期、采集失败与未知总量 | 2026-10-14 用户库存空白截图及本轮修复确认:沿现有同页提示展示过期时间、同仓失败原因与任务号;整体刷新同步只读采集状态,失败独立提示;指标主数值未知显示“—”,小计留辅助说明;原样本证据弹框解释库存日样本与出库历史的区别 | 保留原数据采集入口与弹框承载,不新增写入操作;回归未知/真实0、切仓和刷新恢复,待人工1920×1080、100%截图确认 |
| /self-operated/inventory | 页签与操作栏整合 | 2026-10-14 用户截图 `codex-clipboard-784338aa-b985-4eb7-9f5e-b14f87f6d5e8.png`:页签独立在外、操作栏左侧空白;两个视图的切换页签统一放入DataTable的toolbar-start,操作按钮保留右侧,筛选紧接下方;阈值提示缩短为“天数阈值(天)” | 保留视图选中态、切仓与原图下钻;不改接口与计算,待人工1920×1080、100%截图确认 |
| /self-operated/collection | 原型排版融入后台风格 | 用户截图 `codex-clipboard-9f9be8ba-792e-4f17-a07b-6ac38531e294.png`:保留左侧进度环、中部三项数量和预计时间、右侧后续安排、底部阶段流程;使用后台浅色卡片与蓝紫主色,完成/剩余采用绿/橙强调 | 压缩空态与区块间距,读取失败不显示阶段正在处理;刷新仍为原地只读操作,不新增配置或详情入口;待人工1920×1080、100%截图确认 |
@@ -142,3 +143,5 @@
|---|---|---|---|
| 销量分析、库存分析 | 名称或编码选客户 | 本轮确认;复用现有 QueryToolbar 可搜索下拉框,标签为名称及编码,提交仍使用 customerCode;服务端先筛选再排名分页 | 客户筛选、固定分页、名称展示定向测试及类型检查通过;视觉待人工1920×1080、100%截图 |
| 总览、销量、库存周转 | 货主归属展示 | 本轮客户截图与名称映射确认;名称优先、编码辅助或名称缺失时显示编码 | 待人工截图 |
| 经营总览/趋势与库存分布精简 | 用户截图要求与Top10一致 | 日期、线型图例、候选与排除数量、分组口径收进各图右上角“说明”;趋势每日数据入口简称“明细”;保留图轴、空态、点击下钻及明细 | 交互回归及构建;视觉待人工截图确认 |
@@ -1,7 +1,15 @@
import { describe, expect, it } from 'vitest'
import { turnoverChart, turnoverNumber, windowText, turnoverReasonsText } from './turnoverPresentation'
import { turnoverChart, turnoverNumber, 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', () => {
const value = { value: null, knownSubtotal: 0, missingDimensionQuantity: 15, missingDimensionSkuCount: 2, unknownQuantitySkuCount: 1, declaredDimensionQuantity: 0, reasonCodes: [] }
expect(volumeNote(value)).toContain('已知小计 0 m³')
expect(volumeNote(value)).toContain('缺尺寸 2 SKU / 15 件')
expect(volumeNote(value)).toContain('数量待确认 1 SKU')
expect(volumeNote({ ...value, value: 0, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0 })).toBe('已知体积为0')
expect(volumeNote(value, true)).toContain('按当前商品尺寸估算')
})
it('explains missing samples and zero average stock without exposing internal statuses', () => {
expect(turnoverReasonsText(['INSUFFICIENT'])).toBe('合格日库存样本不足')
expect(turnoverReasonsText(['ZERO_AVERAGE_STOCK'])).toContain('每日观测未捕获存量')
@@ -1,5 +1,5 @@
import type { Quantity, WarehouseOverview } from './types'
import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes'
import type { TurnoverCharts, TurnoverTotal, TurnoverVolume, TurnoverWindowMetric } from './turnoverTypes'
import type { EChartsOption } from 'echarts'
import { reasonsText } from './state'
const turnoverLabels: Record<string, string> = {
@@ -26,6 +26,21 @@ 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) {
if (!volume) return '数量或尺寸待确认'
const notes: string[] = []
if (volume.value == null) notes.push(volume.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${turnoverNumber(volume.knownSubtotal, 6)} 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 ''
@@ -19,6 +19,11 @@ export interface TurnoverWindowMetric {
dataStatus: string; reasonCodes: string[]
}
export interface TurnoverTotal { value: Quantity; knownSubtotal: Quantity; unknownSkuCount: number; reasonCodes: string[] }
export interface TurnoverVolume {
value: Quantity; knownSubtotal: Quantity; missingDimensionQuantity: Quantity; missingDimensionSkuCount: number
unknownQuantitySkuCount: number; declaredDimensionQuantity: Quantity; reasonCodes: string[]
}
export interface TurnoverVolumeWindow { days: number; startDate: string; endDateExclusive: string; volume: TurnoverVolume }
export interface TurnoverRisk { matchedSkuCount: Quantity; knownPhysicalQuantity: Quantity; unknownSkuCount: number; physicalQuantityRatio: Quantity }
export interface TurnoverRow {
customerCode: string; customerName: string | null; sku: string | null; productName: string | null
@@ -28,6 +33,8 @@ export interface TurnoverRow {
quantityTurnoverCount: Quantity; turnoverStatus: string; qualifiedSampleDays: number; requiredSampleDays: number
missingSampleDates: string[]; sampleReasonCodes: string[]; lastObservedOutboundDate: string | null
attentionReasons: string[]; noOutboundInWindow: TurnoverRisk; slowCoverage: TurnoverRisk; unassessableSkuCount: number
unitVolumeM3: Quantity; dimensionSource: 'WAREHOUSE_MEASURED' | 'PRODUCT_DECLARED' | 'MISSING' | null
inventoryVolumeM3: TurnoverVolume; outboundVolumesM3: TurnoverVolumeWindow[]
}
export interface TurnoverContext {
snapshotContext: string; sourceAccount: string; warehouseCode: string; endDate: string
@@ -38,6 +45,7 @@ export interface TurnoverContext {
}
export interface TurnoverOverview {
physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; noOutboundSkuCount: TurnoverTotal; unassessableSkuCount: TurnoverTotal
inventoryVolumeM3: TurnoverVolume; outboundVolumesM3: TurnoverVolumeWindow[]
}
export interface TurnoverCharts {
ownerPhysicalTop: { customerCode: string; customerName: string | null; physicalQuantity: Quantity }[]