feat(selfop): show customer names and filter sales and turnover

This commit is contained in:
2026-10-14 16:15:40 +08:00
parent 2a9c23ebc6
commit 46364d3f89
17 changed files with 125 additions and 28 deletions
@@ -77,7 +77,7 @@ const bucketRows = computed<BarRow[]>(() => buckets.map(bucket => {
const item = data.value?.coverageDistribution.buckets.find(row => row.bucketCode === bucket.code)
return { key: bucket.code, label: bucket.label, value: item?.skuCount ?? null, detail: `已知实物小计 ${quantityText(item?.knownPhysicalQuantity)} 件;实物未知 ${quantityText(item?.unknownPhysicalSkuCount)} SKU` }
}))
function rankingRows(ranking: DashboardRanking): BarRow[] { return ranking.items.map((item, index) => ({ key: String(index), label: item.sku, secondary: `${item.productName || '—'} · 货主 ${item.customerCode}`, value: item.value, detail: `SKU ${item.sku};货主 ${item.customerCode};实物 ${quantityText(item.physicalQuantity)} 件,可售 ${quantityText(item.availableQuantity)} 件,${ranking.calculationWindowDays}天窗口${ranking.dataStatus === 'COMPLETE' ? '出库' : '已采集出库'} ${quantityText(item.windowOutboundQuantity)} 件,窗口日均 ${quantityText(item.averageDailyOutboundQuantity)} 件,可售 ${quantityText(item.inventoryCoverageDays)} 天` })) }
function rankingRows(ranking: DashboardRanking): BarRow[] { return ranking.items.map((item, index) => ({ key: String(index), label: item.sku, secondary: `${item.productName || '—'} · 货主 ${item.customerName || item.customerCode}`, value: item.value, detail: `SKU ${item.sku};货主 ${item.customerName || item.customerCode};实物 ${quantityText(item.physicalQuantity)} 件,可售 ${quantityText(item.availableQuantity)} 件,${ranking.calculationWindowDays}天窗口${ranking.dataStatus === 'COMPLETE' ? '出库' : '已采集出库'} ${quantityText(item.windowOutboundQuantity)} 件,窗口日均 ${quantityText(item.averageDailyOutboundQuantity)} 件,可售 ${quantityText(item.inventoryCoverageDays)} 天` })) }
const dataFence = new RequestFence()
const runtimeFence = new RequestFence()
const trendFence = new RequestFence()
@@ -5,7 +5,7 @@ import AnalyticsView from '../components/AnalyticsView.vue'
import CollectionView from './CollectionView.vue'
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() }))
const mocks = vi.hoisted(() => ({ api: { ownerDirectory: vi.fn(), 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 }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') || mocks.canSync }) }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.routeState ?? ({ query: mocks.routeQuery }), useRouter: () => ({ push: mocks.push }) }))
@@ -36,6 +36,7 @@ beforeEach(() => {
vi.clearAllMocks()
mocks.canSync = true; mocks.routeState = null
mocks.routeQuery = { warehouseCode: 'WH-A' }
mocks.api.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }])
mocks.api.overview.mockResolvedValue(overview)
mocks.api.summary.mockResolvedValue({ context, records: [summary], total: 1 })
mocks.api.hot.mockResolvedValue({ context, records: [], total: 0 })
@@ -50,6 +51,16 @@ beforeEach(() => {
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
describe('self-operated actual workbench request state', () => {
it('selects customers by name and freezes the submitted owner across pagination', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
const field = wrapper.findComponent(queryStub).props('fields').find((f: { key: string }) => f.key === 'customerCode')
expect(field.options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
await setFilters(wrapper, { customerCode: 'OWNER-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', current: 1 })
await setFilters(wrapper, { customerCode: 'OWNER-B' }); wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', current: 2, queryAsOf: context.queryAsOf })
})
it('sorts inventory numeric headers in both directions using the pinned full-result query', async () => {
const wrapper=mount(AnalyticsView,{...options,props:{mode:'inventory'}});wrappers.push(wrapper);await flushPromises()
const columns={physicalQuantity:'PHYSICAL',availableQuantity:'AVAILABLE',frozenQuantity:'FROZEN',defectivePhysicalQuantity:'DEFECTIVE',transitQuantity:'TRANSIT',windowOutboundQuantity:'OUTBOUND',averageDailyOutboundQuantity:'AVERAGE_DAILY',inventoryCoverageDays:'COVERAGE_DAYS'}
@@ -387,14 +398,14 @@ describe('self-operated actual workbench request state', () => {
expect(toolbar.attributes('preference-key')).toBe(`self-operated-${mode}-query`)
expect(wrapper.findAllComponents(queryStub)).toHaveLength(1)
})
it('uses one SKU filter workspace and no owner query, keeping pagination and attention scoped to the fixed versions', async () => {
it('uses one filter workspace with optional customer selection and fixed-version pagination', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
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', 'rangePreset', 'attention', 'threshold', 'sortBy'])
expect(fields.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'customerCode', 'rangePreset', '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' })