feat(selfop): add volume ranking and refine turnover presentation
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user