fix(selfop): render inventory turnover cells and data status

This commit is contained in:
2026-10-14 09:23:29 +08:00
parent 3291d34fb8
commit 3f728cd0b2
7 changed files with 158 additions and 36 deletions
@@ -8,7 +8,7 @@ vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup: () => () => h('div') })
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-actions']?.(), ...(p.data ?? []).flatMap((row: TurnoverRow) => [slots.owner?.({ row }), slots.sku?.({ row }), slots.status?.({ row })])]) } })
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } })
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] }
@@ -18,7 +18,7 @@ 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 }
}
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 } } }); wrappers.push(w); return w }
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 }
async function change(w: ReturnType<typeof mount>, patch: object, search = false) {
const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises()
if (search) { t.vm.$emit('search'); await flushPromises() }
@@ -26,6 +26,54 @@ async function change(w: ReturnType<typeof mount>, patch: object, search = false
beforeEach(() => { vi.clearAllMocks(); 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 stale inventory and failed collection without presenting unknown totals as zero', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', observedAt: '2026-10-14T00:48:00Z', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] })
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.context.analyticsContext.inventoryStatus = 'STALE'
result.context.inventoryObservedUntil = '2026-10-12T16:15:00Z'
result.summary!.physicalQuantity = { value: null, knownSubtotal: null, unknownSkuCount: 5402, reasonCodes: ['INVENTORY_UNKNOWN_OR_STALE'] }
result.summary!.noOutboundSkuCount = { value: null, knownSubtotal: 0, unknownSkuCount: 5402, reasonCodes: ['SALES_OR_STOCK_UNASSESSABLE'] }
return result
})
const w = page(); await flushPromises()
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 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')
})
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' } } }] })
const w = page(); await flushPromises()
expect(w.text()).not.toContain('LXCOL-OTHER')
mocks.overview.mockRejectedValue(new Error('unavailable'))
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')
})
it('renders quantity and window cells through the shared table slot contract', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.rows[0]!.attentionReasons = ['UNASSESSABLE']
return result
})
const w = page(); await flushPromises()
expect(w.text()).not.toContain('[object Object]')
expect(w.text()).not.toContain('UNASSESSABLE')
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.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 () => {
const w = page(); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBeUndefined()