This commit is contained in:
2026-10-14 00:07:05 +08:00
parent 4c4a3db1eb
commit 2cf9129606
16 changed files with 592 additions and 20 deletions
@@ -0,0 +1,29 @@
import { defineComponent, h, reactive } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import InventoryView from './InventoryView.vue'
const mocks = vi.hoisted(() => ({ route: { query: {} as Record<string, string> }, push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
const options = { global: { stubs: { AnalyticsView: true, InventoryTurnoverView: true, 'el-button': button } } }
beforeEach(() => { mocks.route = reactive({ query: {} }); mocks.push.mockReset() })
describe('inventory view entry', () => {
it('defaults to turnover and switches to detail without reusing turnover filters', async () => {
mocks.route.query = { warehouseCode: 'US02', focus: 'FROZEN' }
const w = mount(InventoryView, options)
expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(true)
await w.findAll('button').find(b => b.text() === '库存明细')!.trigger('click')
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { view: 'DETAIL', warehouseCode: 'US02' } })
w.unmount()
})
it('preserves original strict detail links and rejects contradictory parameters', async () => {
mocks.route.query = { from: 'overview', context: 'fixed-version' }
const w = mount(InventoryView, options)
expect(w.findComponent({ name: 'AnalyticsView' }).props('mode')).toBe('inventory')
mocks.route.query = { from: 'overview', view: 'TURNOVER' }; await flushPromises()
expect(w.find('[role="alert"]').text()).toContain('不能混用')
expect(w.findComponent({ name: 'AnalyticsView' }).exists()).toBe(false)
expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(false)
w.unmount()
})
})
@@ -1,4 +1,24 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AnalyticsView from '../components/AnalyticsView.vue'
import InventoryTurnoverView from '../components/InventoryTurnoverView.vue'
import { inventoryView } from '../turnoverNavigation'
const route = useRoute(), router = useRouter()
const retainedWarehouse = ref('')
const selected = computed(() => {
try { return { view: inventoryView(route.query), error: '' } }
catch (cause) { return { view: null, error: (cause as Error).message } }
})
function switchView(view: 'TURNOVER' | 'DETAIL') {
const warehouseCode = retainedWarehouse.value || (typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '')
void router.push({ path: '/self-operated/inventory', query: { view, ...(warehouseCode ? { warehouseCode } : {}) } })
}
</script>
<template><AnalyticsView mode="inventory" /></template>
<template>
<nav class="inventory-views" aria-label="库存分析视图"><el-button :type="selected.view === 'TURNOVER' ? 'primary' : undefined" @click="switchView('TURNOVER')">库存周转</el-button><el-button :type="selected.view === 'DETAIL' ? 'primary' : undefined" @click="switchView('DETAIL')">库存明细</el-button></nav>
<p v-if="selected.error" role="alert">{{ selected.error }}</p>
<AnalyticsView v-else-if="selected.view === 'DETAIL'" mode="inventory" />
<InventoryTurnoverView v-else @warehouse-selected="retainedWarehouse = $event" />
</template>
<style scoped>.inventory-views { display: flex; gap: 8px; padding: 16px 20px 0; }</style>
@@ -28,7 +28,7 @@ const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['cli
const input = defineComponent({ props: ['modelValue', 'id'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('textarea', { id: props.id, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value) }) } })
const loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } })
const dialog = defineComponent({ props: ['modelValue', 'title', 'confirmLoading'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'detail-dialog' }, slots.default?.()) : null } })
const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } }
const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('aside', { 'data-test': 'information-popover' }, [slots.reference?.(), slots.default?.()]) } }), 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } }
const wrappers: ReturnType<typeof mount>[] = []
function findButton(wrapper: ReturnType<typeof mount>, text: string) { const result = wrapper.findAll('button').find(item => item.text() === text); if (!result) throw new Error(`missing button ${text}`); return result }
async function setFilters(wrapper: ReturnType<typeof mount>, values: Record<string, string | number | string[]>) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
@@ -270,7 +270,8 @@ describe('self-operated actual workbench request state', () => {
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-12T02:00:00Z', inventoryObservedFrom: '2026-10-12T01:30:00Z', inventoryObservedUntil: '2026-10-12T01:45:00Z' } as WarehouseOverview] })
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
const note = wrapper.get('[data-testid="inventory-observation"]')
expect(wrapper.find('[data-test="information-popover"] [aria-label="统计口径与采集时间"]').exists()).toBe(true)
const note = wrapper.get('[data-test="information-popover"] [data-testid="inventory-observation"]')
expect(note.text()).toContain('2026/10/12 08:00:00 至 2026/10/12 08:12:00')
expect(note.text()).toContain('本仓一轮完整分页观测')
expect(note.text()).toContain('不代表同一瞬间的库存')
@@ -312,7 +313,8 @@ describe('self-operated actual workbench request state', () => {
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode')
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('货主经营')
expect(wrapper.find('details').attributes('open')).toBeUndefined()
expect(wrapper.find('details').exists()).toBe(false)
expect(wrapper.find('[aria-label="统计口径与采集时间"]').exists()).toBe(true)
})
it('rejects missing threshold and drills inventory SKU to fixed 30-day daily data', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)