diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index ef778819..68b247f0 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -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[] = [] 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, 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() diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index fc8aa69e..d2e3364a 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -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(() => [ { 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() }

{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。查看数据采集

{{ runtimeError }}

{{ card.name }}{{ turnoverNumber(card.value.value) }}{{ turnoverTotalNote(card.value) }}
+
{{ card.name }}{{ turnoverNumber(card.value?.value, 6) }}{{ volumeNote(card.value, card.outbound) }}
+

体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。