feat(selfop): select inventory sales window and cutoff date
This commit is contained in:
@@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AnalyticsView from '../components/AnalyticsView.vue'
|
||||
import CollectionView from './CollectionView.vue'
|
||||
import type { Metric, Overview, OwnerOverview, QueryContext, SalesSummary, Task, WarehouseOverview } from '../types'
|
||||
import type { InventoryMetric, 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, routeState: null as { path: string; query: Record<string, string> } | null, routeQuery: { warehouseCode: 'WH-A' } as Record<string, string>, push: vi.fn() }))
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks.api }))
|
||||
@@ -14,7 +14,7 @@ const context: QueryContext = { salesRunId: '11', inventoryRunId: '0', salesScop
|
||||
const quantity = { overallTotal: null, knownSubtotal: '128', unknownSkuCount: 1, dataStatus: 'PARTIAL', reasonCodes: ['INVENTORY_UNKNOWN'] }
|
||||
const risk = { matchedSkuCount: null, matchedPhysicalQuantity: null, physicalQuantityRatio: null, knownMatchedSkuCount: 1, knownMatchedPhysicalQuantity: '128', unassessableSkuCount: 1, unassessableKnownPhysicalQuantity: null, reasonCodes: ['INVENTORY_UNKNOWN'] }
|
||||
const owner: OwnerOverview = { context, customerCode: 'OWNER-A', customerName: '货主甲', skuCount: 2, physicalSkuCount: quantity, physicalQuantity: quantity, availableQuantity: quantity, frozenQuantity: quantity, defectivePhysicalQuantity: quantity, transitQuantity: quantity, outboundQuantity30d: quantity, averageDailyOutboundQuantity: quantity, noOutbound30d: risk, coverageAboveThreshold: { ...risk, reasonCodes: ['THRESHOLD_NOT_SET'] } }
|
||||
const metric: Metric = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', customerCode: 'OWNER-A', sku: 'SKU-A', productName: '商品A', quantityUnit: 'PCS', outboundQuantity: null, skuOutboundOrderCount: null, physicalQuantity: '128', availableQuantity: '100', frozenQuantity: '20', defectivePhysicalQuantity: '8', transitQuantity: '30', outboundQuantity30d: null, averageDailyOutboundQuantity: null, inventoryCoverageDays: null, inventoryObservedAt: null, status: 'UNKNOWN', reasonCodes: ['INVENTORY_UNKNOWN'], attentionReasons: ['UNASSESSABLE'] }
|
||||
const metric: InventoryMetric = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', customerCode: 'OWNER-A', sku: 'SKU-A', productName: '商品A', quantityUnit: 'PCS', skuOutboundOrderCount: null, physicalQuantity: '128', availableQuantity: '100', frozenQuantity: '20', defectivePhysicalQuantity: '8', transitQuantity: '30', windowOutboundQuantity: null, averageDailyOutboundQuantity: null, inventoryCoverageDays: null, inventoryObservedAt: null, status: 'UNKNOWN', reasonCodes: ['INVENTORY_UNKNOWN'], attentionReasons: ['UNASSESSABLE'] }
|
||||
const summary: SalesSummary = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', customerCode: 'OWNER-A', sku: 'SKU-A', productName: '商品A', quantityUnit: 'PCS', outboundQuantity: null, observedQuantity: '3', dataStatus: 'PARTIAL', reasonCodes: ['SALES_NOT_COVERED'], completeDayCount: 0, partialDayCount: 1, unknownDayCount: 0 }
|
||||
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: [] }
|
||||
@@ -23,7 +23,7 @@ const queryStub = defineComponent({ props: ['modelValue', 'fields'], emits: ['up
|
||||
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']?.(), slots.filters?.(), slots.notices?.(), !(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 }), slots.dailySamples?.({ row }), slots.configuration?.({ row })]))]) } })
|
||||
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), slots.filters?.(), slots.notices?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.windowOutboundQuantity?.({ row }), slots.inventoryCoverageDays?.({ row }), slots.status?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row }), slots.dailySamples?.({ row }), slots.configuration?.({ 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 loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } })
|
||||
@@ -50,6 +50,43 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
|
||||
describe('self-operated actual workbench request state', () => {
|
||||
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()
|
||||
expect(mocks.api.inventory.mock.calls[0]?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-12' })
|
||||
await setFilters(wrapper, { salesWindowEndDate: '2026-10-10', attention: 'NO_OUTBOUND_30D', sortBy: 'OUTBOUND_30D_ASC' })
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-10', attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
|
||||
})
|
||||
it('selects a complete ten-day window, preserves it with pinned pagination, and uses its dates for daily trend', async () => {
|
||||
const ten = { ...context, anchorDate: '2026-10-13', rangeStartDate: '2026-10-03', rangeEndDateExclusive: '2026-10-13', calculationWindowDays: 10 }
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
|
||||
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
|
||||
mocks.api.inventory.mockResolvedValue({ context: ten, records: [{ ...metric, windowOutboundQuantity: 50, averageDailyOutboundQuantity: 5, inventoryCoverageDays: 20 }], total: 45 })
|
||||
await setFilters(wrapper, { salesWindowDays: 10, salesWindowEndDate: '2026-10-12' })
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, salesWindowEndDate: '2026-10-12', current: 1 })
|
||||
expect(wrapper.findComponent(tableStub).props('columns').find((c: { key: string }) => c.key === 'windowOutboundQuantity').label).toBe('10天窗口出库')
|
||||
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, salesWindowEndDate: '2026-10-12', current: 2, queryAsOf: ten.queryAsOf })
|
||||
await findButton(wrapper, 'SKU-A').trigger('click'); await flushPromises()
|
||||
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ startDate: '2026-10-03', endDate: '2026-10-12' })
|
||||
expect(wrapper.text()).toContain('窗口未采齐时不以部分销量推算')
|
||||
await setFilters(wrapper, { sortBy: 'PHYSICAL_DESC' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, salesWindowEndDate: '2026-10-12', sortBy: 'PHYSICAL_DESC', queryAsOf: ten.queryAsOf, current: 1 })
|
||||
})
|
||||
it('clears incompatible 30-day predicates and never calculates missing inventory coverage locally', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
|
||||
await setFilters(wrapper, { attention: 'NO_OUTBOUND_30D', sortBy: 'OUTBOUND_30D_ASC', salesWindowDays: 10 })
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
|
||||
expect(wrapper.findComponent(tableStub).props('data')[0].inventoryCoverageDays).toBeNull()
|
||||
mocks.api.inventory.mockResolvedValue({ context, records: [{ ...metric, windowOutboundQuantity: 0, status: 'NO_OUTBOUND_IN_WINDOW' }], total: 1 })
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('所选窗口无出库')
|
||||
expect(wrapper.findComponent(tableStub).props('data')[0].inventoryCoverageDays).toBeNull()
|
||||
const calls = mocks.api.inventory.mock.calls.length
|
||||
await setFilters(wrapper, { salesWindowEndDate: '2099-10-12' })
|
||||
expect(mocks.api.inventory).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('resumes a cached collection page with the new deep-linked warehouse and stops hidden polling and late requests', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const route = reactive({ path: '/self-operated/collection', query: { warehouseCode: 'WH-A' } })
|
||||
@@ -170,7 +207,7 @@ describe('self-operated actual workbench request state', () => {
|
||||
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.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'sku', 'salesWindowDays', 'salesWindowEndDate', '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' })
|
||||
|
||||
Reference in New Issue
Block a user