fix: keep analytics date ranges together and apply atomically
This commit is contained in:
@@ -12,7 +12,7 @@ vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.permitted }) }))
|
||||
const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const query = defineComponent({ props: ['modelValue'], emits: ['update:modelValue', 'search'], setup(_, { emit }) { return () => h('button', { onClick: () => emit('search') }, '查询') } })
|
||||
const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], setup(_, { emit }) { return () => h('button', { onClick: () => emit('search') }, '查询') } })
|
||||
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const dialog = defineComponent({ props: ['modelValue', 'width'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } })
|
||||
const error = defineComponent({ props: ['message'], setup(props) { return () => h('p', props.message) } })
|
||||
@@ -20,7 +20,7 @@ const options = { global: { directives: { loading: () => undefined }, stubs: { A
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper }
|
||||
function findButton(wrapper: ReturnType<typeof mount>, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
async function choose(wrapper: ReturnType<typeof mount>, values: Record<string, string>) { const toolbar = wrapper.findComponent(query); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
|
||||
async function choose(wrapper: ReturnType<typeof mount>, values: Record<string, string | string[] | null>) { const toolbar = wrapper.findComponent(query); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); mocks.query = {}; mocks.permitted = true
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
|
||||
@@ -30,6 +30,43 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
|
||||
describe('warehouse operating dashboard', () => {
|
||||
it('uses one date range field, blocks incomplete ranges and clears obsolete validation errors', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
|
||||
const toolbar = wrapper.findComponent(query)
|
||||
expect(toolbar.props('fields').filter((field: { type: string }) => field.type.includes('date'))).toEqual([{ key: 'dateRange', type: 'daterange', label: '日期范围', width: 300 }])
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
await choose(wrapper, { dateRange: ['2026-10-10'] })
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent(error).exists()).toBe(true)
|
||||
await choose(wrapper, { dateRange: ['2026-10-10', '2026-10-10'] })
|
||||
expect(wrapper.findComponent(error).exists()).toBe(false)
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard).toHaveBeenCalledOnce()
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('dateRange')
|
||||
await choose(wrapper, { dateRange: null })
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard).toHaveBeenCalledOnce()
|
||||
await choose(wrapper, { rangePreset: 'TODAY' })
|
||||
expect(wrapper.findComponent(error).exists()).toBe(false)
|
||||
})
|
||||
it('restores a custom range and keeps the applied dates when an unsubmitted range is edited', async () => {
|
||||
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10', scroll: 123 }) }
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(wrapper.findComponent(query).props('modelValue').dateRange).toEqual(['2026-10-10', '2026-10-10'])
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ startDate: '2026-10-10', endDate: '2026-10-10', salesRunId: '11' })
|
||||
await choose(wrapper, { dateRange: ['2026-10-11', '2026-10-12'] })
|
||||
await findButton(wrapper, '查看Top100').trigger('click')
|
||||
expect(JSON.parse(mocks.push.mock.calls.at(-1)?.[0].query.overviewReturn)).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
|
||||
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
|
||||
expect(wrapper.findComponent(query).props('modelValue').dateRange).toEqual([])
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('startDate')
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('endDate')
|
||||
})
|
||||
|
||||
it('does not replace an invalid warehouse-less return with the default warehouse', async () => {
|
||||
mocks.query = { context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'THIS_MONTH', scroll: 760 }) }
|
||||
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: 'A仓' }], observedAt: '2026-10-13T00:00:00Z' })
|
||||
|
||||
@@ -24,13 +24,14 @@ const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
|
||||
const sourceAccount = 'lingxing-primary'
|
||||
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' })
|
||||
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', dateRange: [] })
|
||||
let selectedWarehouse = String(filters.value.warehouseCode)
|
||||
const context = ref<QueryContext | null>(null)
|
||||
const data = ref<Dashboard | null>(null)
|
||||
const runtime = ref<Overview | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rangeValidationError = ref('')
|
||||
const errorDetails = ref<ApiErrorDetails | null>(null)
|
||||
const runtimeError = ref('')
|
||||
const routeError = ref('')
|
||||
@@ -45,7 +46,7 @@ try {
|
||||
if (typeof route.query.context !== 'string' || typeof route.query.overviewReturn !== 'string' || !filters.value.warehouseCode) throw new Error('经营总览返回参数无效')
|
||||
context.value = parseContext(route.query.context)
|
||||
appliedRange = parseOverviewReturn(route.query.overviewReturn)
|
||||
Object.assign(filters.value, { rangePreset: appliedRange.rangePreset, startDate: appliedRange.startDate ?? '', endDate: appliedRange.endDate ?? '' })
|
||||
Object.assign(filters.value, { rangePreset: appliedRange.rangePreset, dateRange: appliedRange.startDate && appliedRange.endDate ? [appliedRange.startDate, appliedRange.endDate] : [] })
|
||||
restoreScroll = appliedRange.scroll
|
||||
}
|
||||
} catch (cause) { routeError.value = cause instanceof Error ? cause.message : '经营总览返回参数无效' }
|
||||
@@ -55,7 +56,7 @@ const stockExpired = computed(() => Boolean(context.value && inventoryExpired(co
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 220, placeholder: '切换仓库', props: { clearable: false }, options: [...new Map((runtime.value?.warehouses ?? []).map(item => [item.warehouseCode, { value: item.warehouseCode, label: item.warehouseName || item.warehouseCode }])).values()] },
|
||||
{ key: 'rangePreset', type: 'select', label: '出库趋势范围', width: 180, props: { clearable: false }, options: rangeOptions },
|
||||
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'startDate', type: 'date' as const, label: '开始日期', width: 160 }, { key: 'endDate', type: 'date' as const, label: '结束日期', width: 160 }] : []),
|
||||
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'dateRange', type: 'daterange' as const, label: '日期范围', width: 300 }] : []),
|
||||
])
|
||||
const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: boolean }[] = [
|
||||
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity30d', label: '近30日日均出库' },
|
||||
@@ -82,13 +83,14 @@ async function load(refresh = false) {
|
||||
if (!refresh && contextExpired.value) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
|
||||
const range = String(filters.value.rangePreset) as RangePreset
|
||||
if (!rangeOptions.some(option => option.value === range)) { error.value = '请选择有效的出库趋势范围'; return }
|
||||
const start = String(filters.value.startDate ?? '')
|
||||
const end = String(filters.value.endDate ?? '')
|
||||
if (range === 'CUSTOM') { const problem = validateCustomRange(start, end, context.value?.anchorDate); if (problem) { error.value = problem; return } }
|
||||
const selected = Array.isArray(filters.value.dateRange) ? filters.value.dateRange : []
|
||||
const start = typeof selected[0] === 'string' ? selected[0] : ''
|
||||
const end = typeof selected[1] === 'string' ? selected[1] : ''
|
||||
if (range === 'CUSTOM') { const problem = validateCustomRange(start, end, context.value?.anchorDate); if (problem) { rangeValidationError.value = problem; error.value = problem; errorDetails.value = null; return } }
|
||||
const pinned = refresh ? null : context.value
|
||||
const request = dataFence.begin()
|
||||
trendOpen.value = false
|
||||
loading.value = true; error.value = ''; errorDetails.value = null
|
||||
loading.value = true; error.value = ''; rangeValidationError.value = ''; errorDetails.value = null
|
||||
try {
|
||||
const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, rangePreset: range, ...contextParams(pinned), ...(range === 'CUSTOM' ? { startDate: start, endDate: end } : {}) }
|
||||
const result = await analyticsApi.dashboard(params, request.signal)
|
||||
@@ -144,7 +146,7 @@ async function showTrend(item: DashboardItem) {
|
||||
finally { if (request.current()) trendLoading.value = false }
|
||||
}
|
||||
async function reset() {
|
||||
filters.value = { warehouseCode: warehouseCode.value || selectedWarehouse || runtime.value?.warehouses.find(item => item.warehouseCode)?.warehouseCode || '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' }
|
||||
filters.value = { warehouseCode: warehouseCode.value || selectedWarehouse || runtime.value?.warehouses.find(item => item.warehouseCode)?.warehouseCode || '', rangePreset: 'LAST_15_DAYS', dateRange: [] }
|
||||
routeError.value = ''
|
||||
await nextTick()
|
||||
void load(true)
|
||||
@@ -153,6 +155,12 @@ watch(warehouseCode, code => {
|
||||
dataFence.cancel(); trendFence.cancel(); data.value = null; context.value = null; trendOpen.value = false; loading.value = false; error.value = ''; errorDetails.value = null; newData.value = false; routeError.value = ''; restoreScroll = null
|
||||
if (code) { selectedWarehouse = code; void load(true) }
|
||||
})
|
||||
watch(() => [filters.value.rangePreset, filters.value.dateRange], () => {
|
||||
if (rangeValidationError.value) {
|
||||
if (error.value === rangeValidationError.value) error.value = ''
|
||||
rangeValidationError.value = ''
|
||||
}
|
||||
}, { deep: true })
|
||||
watch(trendOpen, open => { if (!open) { trendFence.cancel(); trend.value = null; trendLoading.value = false } })
|
||||
onMounted(() => { visibility(); poller.start(); timer = setInterval(() => { now.value = Date.now() }, 1000); document.addEventListener('visibilitychange', visibility); if (context.value && !routeError.value) void load() })
|
||||
onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clearInterval(timer); document.removeEventListener('visibilitychange', visibility) })
|
||||
|
||||
@@ -31,7 +31,7 @@ const dialog = defineComponent({ props: ['modelValue', 'title', 'confirmLoading'
|
||||
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 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() }
|
||||
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() }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.canSync = true; mocks.routeState = null
|
||||
@@ -50,6 +50,64 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
|
||||
describe('self-operated actual workbench request state', () => {
|
||||
it('uses one date range field, allows a same-day query, and isolates draft dates from pagination and trends', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM' })
|
||||
expect(wrapper.findComponent(tableStub).props('data')).toEqual([])
|
||||
const fields = wrapper.findComponent(queryStub).props('fields')
|
||||
expect(fields.filter((f: { type: string }) => f.type === 'daterange')).toHaveLength(1)
|
||||
expect(fields.some((f: { key: string }) => ['customStart', 'customEnd'].includes(f.key))).toBe(false)
|
||||
const calls = mocks.api.summary.mock.calls.length
|
||||
await setFilters(wrapper, { customRange: ['2026-10-10', '2026-10-10'] })
|
||||
expect(mocks.api.summary).toHaveBeenCalledTimes(calls)
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
|
||||
await setFilters(wrapper, { customRange: ['2026-10-11', '2026-10-11'] })
|
||||
expect(wrapper.text()).toContain('当前列表及每日趋势仍使用上次已应用的统计区间')
|
||||
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, startDate: '2026-10-10', endDate: '2026-10-10', queryAsOf: context.queryAsOf })
|
||||
await findButton(wrapper, '每日趋势').trigger('click'); await flushPromises()
|
||||
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10', queryAsOf: context.queryAsOf })
|
||||
})
|
||||
it('clears obsolete reverse-date errors and blocks incomplete submissions without retaining old results', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-11', '2026-10-10'] })
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('开始日期不得晚于结束日期')
|
||||
const calls = mocks.api.summary.mock.calls.length
|
||||
await setFilters(wrapper, { customRange: ['2026-10-10'] })
|
||||
expect(wrapper.text()).not.toContain('开始日期不得晚于结束日期')
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('请选择有效的起止日期')
|
||||
expect(wrapper.findComponent(tableStub).props('data')).toEqual([])
|
||||
expect(mocks.api.summary).toHaveBeenCalledTimes(calls)
|
||||
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.api.summary).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
it('cancels an old preset response when custom dates are incomplete and clears stale results during the next request', async () => {
|
||||
let resolveOld!: (value: unknown) => void
|
||||
mocks.api.summary.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
|
||||
const signal = mocks.api.summary.mock.calls[0]?.[1] as AbortSignal
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM' })
|
||||
expect(signal.aborted).toBe(true)
|
||||
resolveOld({ context, records: [summary], total: 1 }); await flushPromises()
|
||||
expect(wrapper.findComponent(tableStub).props('data')).toEqual([])
|
||||
await setFilters(wrapper, { rangePreset: 'THIS_MONTH' })
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0].rangePreset).toBe('THIS_MONTH')
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).not.toHaveProperty('startDate')
|
||||
expect(wrapper.text()).not.toContain('请选择有效的起止日期')
|
||||
})
|
||||
it('validates an explicit fresh query against Beijing today instead of yesterday’s pinned context', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-13T01:00:00Z'))
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-13', '2026-10-13'] })
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
const request = mocks.api.summary.mock.calls.at(-1)?.[0]
|
||||
expect(request).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-13', endDate: '2026-10-13' })
|
||||
expect(request).not.toHaveProperty('queryAsOf')
|
||||
})
|
||||
|
||||
it('defaults to Beijing yesterday and excludes rolling 30-day predicates for a historical cutoff', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-12T17:00:00Z'))
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
|
||||
|
||||
Reference in New Issue
Block a user