refactor(self-operated): focus analytics pages on SKU metrics

This commit is contained in:
2026-10-12 21:36:24 +08:00
parent 533273769d
commit 449d0c4965
4 changed files with 163 additions and 106 deletions
@@ -5,10 +5,10 @@ import AnalyticsView from '../components/AnalyticsView.vue'
import CollectionView from './CollectionView.vue'
import type { Metric, Overview, OwnerOverview, QueryContext, SalesSummary, Task, WarehouseOverview } from '../types'
const mocks = vi.hoisted(() => ({ api: { owners: vi.fn(), ownerOverview: vi.fn(), summary: vi.fn(), daily: vi.fn(), hot: vi.fn(), slow: vi.fn(), inventory: vi.fn(), overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn() }, canSync: true }))
const mocks = vi.hoisted(() => ({ api: { owners: vi.fn(), ownerOverview: vi.fn(), summary: vi.fn(), daily: vi.fn(), hot: vi.fn(), slow: vi.fn(), inventory: vi.fn(), overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn() }, canSync: true, routeQuery: { warehouseCode: 'WH-A' } as Record<string, string>, push: vi.fn() }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks.api }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') || mocks.canSync }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: { warehouseCode: 'WH-A' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.routeQuery }), useRouter: () => ({ push: mocks.push }) }))
const context: QueryContext = { salesRunId: '11', inventoryRunId: '0', salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12', completeDayFrom: '2026-08-13', completeDayUntil: '2026-10-12', rangeStartDate: '2026-09-12', rangeEndDateExclusive: '2026-10-12' }
const quantity = { overallTotal: null, knownSubtotal: '128', unknownSkuCount: 1, dataStatus: 'PARTIAL', reasonCodes: ['INVENTORY_UNKNOWN'] }
@@ -19,19 +19,26 @@ const summary: SalesSummary = { sourceAccount: 'lingxing-primary', warehouseCode
const task: Task = { taskNo: 'COL-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'DEAD', applyState: 'BLOCKED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: ['RETRY'], actionBlockReason: null, progress: null, queryFrom: null, queryUntil: null }
const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'OFF', observedAt: '2026-10-12T01:01:00Z', warehouses: [] }
const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const queryStub = defineComponent({ props: ['modelValue'], emits: ['update:modelValue', 'search'], setup(props, { emit }) { return () => h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库') } })
const tableStub = defineComponent({ props: ['data'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row })]))]) } })
const queryStub = defineComponent({ props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup(props, { emit }) { return () => h('div', [
h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库'),
h('button', { onClick: () => emit('search') }, '查询'), h('button', { onClick: () => emit('reset') }, '重置'),
]) } })
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row })]))]) } })
const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, type: props.nativeType || 'button', onClick: () => emit('click') }, slots.default?.()) } })
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 options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, 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 loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } })
const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, 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 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>) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
beforeEach(() => {
vi.clearAllMocks()
mocks.canSync = true
mocks.routeQuery = { warehouseCode: 'WH-A' }
mocks.api.overview.mockResolvedValue(overview)
mocks.api.summary.mockResolvedValue({ context, records: [summary], total: 1 })
mocks.api.hot.mockResolvedValue({ context, records: [], total: 0 })
mocks.api.slow.mockResolvedValue({ context, records: [], total: 0 })
mocks.api.inventory.mockResolvedValue({ context, records: [metric], total: 45 })
mocks.api.ownerOverview.mockResolvedValue(owner)
mocks.api.daily.mockResolvedValue({ context, summary, points: [] })
@@ -42,30 +49,30 @@ beforeEach(() => {
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('self-operated actual workbench request state', () => {
it('keeps owner overview full-scope and fixed-version when inventory attention and pagination change', async () => {
it('uses one SKU filter workspace and no owner query, keeping pagination and attention scoped to the fixed versions', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
wrapper.findComponent({ name: 'OwnerPicker' }).vm.$emit('update:modelValue', 'OWNER-A')
await flushPromises()
expect(mocks.api.ownerOverview.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({ customerCode: 'OWNER-A', salesRunId: '11', inventoryRunId: '0', queryAsOf: context.queryAsOf }))
const selects = wrapper.findAllComponents(passthrough).filter(item => item.attributes('aria-label') === '库存关注')
selects[0]?.vm.$emit('update:modelValue', 'FROZEN_STOCK')
await findButton(wrapper, '应用库存关注').trigger('click'); await flushPromises()
expect(mocks.api.owners).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'OwnerPicker' }).exists()).toBe(false)
const fields = wrapper.findComponent(queryStub).props('fields')
expect(fields.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'sku', 'attention', 'threshold', 'sortBy'])
expect(fields[0].placeholder).toBe('请选择仓库(必选)')
expect(wrapper.findComponent(tableStub).props('columns').slice(0, 3).map((column: { prop?: string; key?: string }) => column.prop || column.key)).toEqual(['sku', 'productName', 'owner'])
await setFilters(wrapper, { attention: 'FROZEN_STOCK' })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, customerCode: 'OWNER-A', attention: 'FROZEN_STOCK', queryAsOf: context.queryAsOf })
const params = mocks.api.ownerOverview.mock.calls.at(-1)?.[0]
for (const key of ['sku', 'attention', 'current', 'size', 'rangePreset', 'sortBy']) expect(params).not.toHaveProperty(key)
expect(wrapper.text()).toContain('已知有效小计(部分资料)')
expect(wrapper.text()).toContain('未设置查询阈值')
expect(wrapper.text()).toContain('— · 未完整')
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, attention: 'FROZEN_STOCK', queryAsOf: context.queryAsOf })
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()
})
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)
await flushPromises()
const select = wrapper.findAllComponents(passthrough).find(item => item.attributes('aria-label') === '库存关注')
select?.vm.$emit('update:modelValue', 'COVERAGE_GT_THRESHOLD')
await findButton(wrapper, '应用库存关注').trigger('click'); await flushPromises()
await setFilters(wrapper, { attention: 'COVERAGE_GT_THRESHOLD' })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.api.inventory).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('选择超过阈值关注时必须填写')
await findButton(wrapper, 'SKU-A').trigger('click'); await flushPromises()
@@ -74,6 +81,42 @@ describe('self-operated actual workbench request state', () => {
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).not.toHaveProperty('attention')
expect(wrapper.findComponent({ name: 'SkuTrend' }).exists()).toBe(true)
})
it('keeps identical SKUs in separate owner rows and sends the clicked owner to daily queries', async () => {
const second = { ...metric, customerCode: 'OWNER-B', customerName: '货主乙' }
mocks.api.inventory.mockResolvedValue({ context, records: [metric, second], total: 2 })
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(wrapper.findComponent(tableStub).props('data')).toHaveLength(2)
expect(wrapper.text()).toContain('货主乙')
const skuButtons = wrapper.findAll('button').filter(item => item.text() === 'SKU-A')
await skuButtons[0]?.trigger('click'); await flushPromises()
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', sku: 'SKU-A' })
mocks.api.daily.mockResolvedValue({ context, summary: { ...summary, customerCode: 'OWNER-B' }, points: [] })
await skuButtons[1]?.trigger('click'); await flushPromises()
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-A' })
await findButton(wrapper, '在销量页查看该SKU(全部货主)').trigger('click')
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/sales', query: { warehouseCode: 'WH-A', sku: 'SKU-A' } })
expect(mocks.api.owners).not.toHaveBeenCalled()
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
})
it('separates unselected warehouse, no results and failure; reset removes warehouse and query criteria', async () => {
mocks.routeQuery = {}
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(mocks.api.inventory).not.toHaveBeenCalled()
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('请选择仓库后查询SKU')
mocks.api.inventory.mockResolvedValue({ context, records: [], total: 0 })
await setFilters(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('暂无符合条件的SKU数据')
mocks.api.inventory.mockRejectedValue(new Error('读取失败'))
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('查询失败,请重试')
expect(wrapper.find('[role="alert"]').text()).toContain('查询失败,请重新查询')
await setFilters(wrapper, { sku: 'SKU-A', attention: 'FROZEN_STOCK', threshold: '10' })
await findButton(wrapper, '重置').trigger('click'); await flushPromises()
expect(wrapper.findComponent(queryStub).props('modelValue')).toMatchObject({ warehouseCode: '', sku: '', attention: 'ALL', threshold: '' })
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('请选择仓库后查询SKU')
})
it('shows actual inventory daily sample gaps without inventing an accumulation date', async () => {
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', protection: {}, inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 0, missingDates: ['2026-09-12'], accumulationStartedOn: null } } as WarehouseOverview] })
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
@@ -85,9 +128,9 @@ describe('self-operated actual workbench request state', () => {
it('pins versions and date across range and ranking switches, ranking omits chart parameters', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
await flushPromises()
await findButton(wrapper, '本月').trigger('click'); await flushPromises()
await setFilters(wrapper, { rangePreset: 'THIS_MONTH' })
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'THIS_MONTH', queryAsOf: context.queryAsOf, salesRunId: '11', inventoryRunId: '0' })
await findButton(wrapper, '热销Top100').trigger('click'); await flushPromises()
await setFilters(wrapper, { analysis: 'hot' })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, windowDays: 30 })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('rangePreset')
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode', '')
@@ -103,6 +146,18 @@ describe('self-operated actual workbench request state', () => {
expect(wrapper.text()).not.toContain('SKU-A')
expect(mocks.api.summary.mock.calls[0]?.[1].aborted).toBe(true)
})
it('loads rankings with the fixed context when an unfinished CUSTOM date selection exists', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
await flushPromises()
await setFilters(wrapper, { rangePreset: 'CUSTOM' })
expect(mocks.api.summary).toHaveBeenCalledTimes(1)
await setFilters(wrapper, { analysis: 'hot' })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, windowDays: 30 })
await setFilters(wrapper, { analysis: 'slow' })
expect(mocks.api.slow.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf })
expect(mocks.api.slow.mock.calls.at(-1)?.[0]).not.toHaveProperty('rangePreset')
expect(wrapper.find('.analytics-rules').text()).toContain('实际计算区间:2026-09-12 至 2026-10-12(截止日不含)')
})
it('failed runtime polling preserves prior available data and leaves fixed context untouched', async () => {
mocks.api.overview.mockRejectedValue(new Error('offline'))
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)