From 50b06dca1082ebc5c38455b35d9991efc4b2b41d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 11:14:35 +0800 Subject: [PATCH] feat(selfop): multi-owner turnover filters, CSV export and percentage --- .../self-operated/api/analytics.test.ts | 5 ++ src/modules/self-operated/api/analytics.ts | 5 +- .../components/InventoryTurnoverView.test.ts | 70 +++++++++++++++++-- .../components/InventoryTurnoverView.vue | 64 ++++++++++++----- src/modules/self-operated/operation-matrix.md | 2 + src/modules/self-operated/turnoverExport.ts | 54 ++++++++++++++ .../self-operated/turnoverNavigation.test.ts | 8 +++ .../self-operated/turnoverNavigation.ts | 16 ++++- src/modules/self-operated/turnoverTypes.ts | 2 +- 9 files changed, 196 insertions(+), 30 deletions(-) create mode 100644 src/modules/self-operated/turnoverExport.ts diff --git a/src/modules/self-operated/api/analytics.test.ts b/src/modules/self-operated/api/analytics.test.ts index f3efdeef..a54fc1e8 100644 --- a/src/modules/self-operated/api/analytics.test.ts +++ b/src/modules/self-operated/api/analytics.test.ts @@ -5,6 +5,11 @@ const http = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })) vi.mock('@/shared/api/http', () => ({ http, unwrapApiResponse: (value: { data: unknown }) => value.data })) beforeEach(() => { vi.clearAllMocks(); http.get.mockResolvedValue({ data: {} }); http.post.mockResolvedValue({ data: { taskNo: 'COL-001', status: 'QUEUED' } }) }) describe('self-operated platform transport contract', () => { + it('sends multiple turnover owners as repeated query keys, without bracket suffixes', async () => { + const params = { sourceAccount: 'primary', warehouseCode: 'W', endDate: '2026-10-14', baseWindowDays: 7, groupBy: 'SKU' as const, customerCodes: ['A', 'B'], focus: 'ALL' as const, sortBy: 'PHYSICAL_DESC' as const, current: 1, size: 20, includeOverview: true } + await analyticsApi.turnover(params) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/inventory-turnover', { params, paramsSerializer: { indexes: null }, signal: undefined }) + }) it('forwards the inventory window, inclusive end and pinned page without renaming them', async () => { const params = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', salesWindowDays: 10 as const, salesWindowEndDate: '2026-10-12', current: 2, size: 20, salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-13T00:00:00Z' } await analyticsApi.inventory(params) diff --git a/src/modules/self-operated/api/analytics.ts b/src/modules/self-operated/api/analytics.ts index 9f9a1c06..9fe72980 100644 --- a/src/modules/self-operated/api/analytics.ts +++ b/src/modules/self-operated/api/analytics.ts @@ -9,7 +9,10 @@ async function get(path: string, params: object, signal?: AbortSignal): Promi } export const analyticsApi = { ownerDirectory: (query: { sourceAccount: string; warehouseCode: string } & Partial, signal?: AbortSignal) => get('owner-directory', query, signal), - turnover: (query: TurnoverQuery, signal?: AbortSignal) => get('inventory-turnover', query, signal), + async turnover(query: TurnoverQuery, signal?: AbortSignal): Promise { + const response = await http.get, ApiEnvelope>(`${base}/inventory-turnover`, { params: query, paramsSerializer: { indexes: null }, signal }) + return unwrapApiResponse(response) + }, dashboard: (query: DashboardQuery, signal?: AbortSignal) => get('dashboard', query, signal), owners: (query: OwnerQuery, signal?: AbortSignal) => get>('owners', query, signal), ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get('owner-overview', query, signal), diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index 4a963b12..db1afa34 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -4,6 +4,9 @@ import { flushPromises, mount } from '@vue/test-utils' import { ElCheckbox } from 'element-plus' import InventoryTurnoverView from './InventoryTurnoverView.vue' import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from '../turnoverTypes' +import { collectTurnoverExport, turnoverCsv, turnoverPercent } from '../turnoverExport' +import { saveCsv } from '@/shared/downloadCsv' +vi.mock('@/shared/downloadCsv', () => ({ saveCsv: vi.fn() })) const mocks = vi.hoisted(() => ({ route: { query: {} as Record }, allowed: true, ownerDirectory: vi.fn(), overview: vi.fn(), turnover: vi.fn(), daily: vi.fn(), push: vi.fn() })) vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) })) vi.mock('../api/analytics', () => ({ analyticsApi: mocks })) @@ -29,6 +32,59 @@ async function change(w: ReturnType, patch: object, search = false beforeEach(() => { vi.clearAllMocks(); mocks.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }]); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() }) afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) describe('inventory turnover page fixed snapshot behavior', () => { + it('keeps multiple owners through paging and grouping, clears them on reset', async () => { + const w = page(); await flushPromises() + const field = w.findComponent(toolbar).props('fields').find((f: {key: string}) => f.key === 'customerCodes') + expect(field.props).toMatchObject({ multiple: true, filterable: true, collapseTags: true }) + field.props.onChange(['B', 'OWNER-A']); await flushPromises() + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['B', 'OWNER-A'], current: 1, includeOverview: true }) + expect(w.text()).toContain('所选货主概览') + w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['B', 'OWNER-A'], current: 2, snapshotContext: 'token-A', includeOverview: false }) + await change(w, { groupBy: 'OWNER' }) + await w.findAll('button').find(b => b.text() === 'OWNER-A')!.trigger('click'); await flushPromises() + await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises() + expect(w.findComponent(toolbar).props('modelValue').customerCodes).toEqual(['B', 'OWNER-A']) + w.findComponent(toolbar).vm.$emit('reset'); await flushPromises() + expect(mocks.turnover.mock.calls.at(-1)![0].customerCodes).toEqual([]) + }) + it('exports all matching pages with visible columns and percentage, without changing the table', async () => { + mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { + const r = response(q); r.total = 205 + r.rows = Array.from({ length: Math.min(q.size, Math.max(0, 205 - (q.current - 1) * q.size)) }, (_, i) => ({ ...row, sku: `S${(q.current - 1) * q.size + i}`, quantityTurnoverCount: i === 0 ? 0 : 1.25 })) + return r + }) + const w = page(); await flushPromises() + expect(w.text()).toContain('0%'); expect(w.text()).toContain('125%') + expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'quantityTurnoverRate')).toBe(true) + const exportCalls = mocks.turnover.mock.calls.length + await w.findAll('button').find(b => b.text() === '导出')!.trigger('click'); await flushPromises() + const calls = mocks.turnover.mock.calls.slice(exportCalls).map(c => c[0]) + expect(calls.map(q => q.current)).toEqual([1, 2, 3]) + expect(calls.every(q => q.size === 100 && !q.includeOverview && q.snapshotContext === 'token-A' && q.hideZeroInventory)).toBe(true) + expect(saveCsv).toHaveBeenCalledTimes(1) + expect(w.text()).toContain('已导出 205 条记录') + expect(w.findComponent(table).props('page')).toBe(1) + }) + it('preserves exported column order, notes, unknowns, zero percentages and escapes CSV text', () => { + const q = { sourceAccount: 'primary', warehouseCode: 'W', endDate: '2026-10-14', baseWindowDays: 7, groupBy: 'SKU' as const, focus: 'ALL' as const, sortBy: 'PHYSICAL_DESC' as const, current: 1, size: 20, includeOverview: true } + const csv = turnoverCsv([{ ...row, sku: '=SUM(1,2)', productName: '含"引号\n换行', quantityTurnoverCount: 1.25 }], [{ prop: 'quantityTurnoverRate', label: '期间周转率' }, { prop: 'sku', label: 'SKU' }, { prop: 'productName', label: '商品名称' }], q) + expect(csv).toContain('\uFEFF"期间周转率","SKU","商品名称"\r\n"125%","\'=SUM(1,2)"') + expect(csv).toContain('含""引号\n换行') + expect(turnoverPercent(null)).toBe('—'); expect(turnoverPercent(0)).toBe('0%') + }) + it('fails an export on changed snapshots or duplicate pages and honors cancellation', async () => { + const q = { sourceAccount: 'primary', warehouseCode: 'W', endDate: '2026-10-14', baseWindowDays: 7, groupBy: 'SKU' as const, focus: 'ALL' as const, sortBy: 'PHYSICAL_DESC' as const, current: 1, size: 20, includeOverview: false, snapshotContext: 'fixed' } + const controller = new AbortController() + const changed = async () => ({ ...response(q), total: 1, context: { ...response(q).context, snapshotContext: 'changed' } }) + await expect(collectTurnoverExport(q, 1, changed, controller.signal, vi.fn())).rejects.toThrow('版本已变化') + const duplicate = async () => ({ ...response(q), total: 2, rows: [row, row] }) + await expect(collectTurnoverExport(q, 2, duplicate, controller.signal, vi.fn())).rejects.toThrow('重复') + controller.abort() + const fetch = vi.fn() + await expect(collectTurnoverExport(q, 1, fetch, controller.signal, vi.fn())).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) it('hides zero inventory by default, applies it before pagination and restores it on reset', async () => { const w = page(); await flushPromises() const checkbox = () => w.get('.turnover-zero-filter input[type="checkbox"]') @@ -56,7 +112,7 @@ describe('inventory turnover page fixed snapshot behavior', () => { expect(w.findComponent(table).props('columns').some((column: { prop: string }) => column.prop === 'attentionReasons')).toBe(false) expect(w.text()).not.toContain('日样本 0/30') await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click') - expect(w.findAll('span[title="历史库存记录不足,暂不能计算期间平均库存和周转次数"]').map(n => n.text())).toEqual(['—', '—']) + expect(w.findAll('span[title="历史库存记录不足,暂不能计算期间平均库存和周转次数"]').map(n => n.text())).toEqual(['—', '—', '—']) // Object-valued native options retain the selected row, including its frozen evidence. const select = w.get('select[aria-label="选择历史库存记录"]') ;(select.element as HTMLSelectElement).selectedIndex = 1 @@ -99,7 +155,7 @@ describe('inventory turnover page fixed snapshot behavior', () => { await w.get('[aria-label="图表指标"] button:first-child').trigger('click') expect(mocks.turnover).toHaveBeenCalledTimes(calls) w.findComponent({ name: 'AnalyticsChart' }).vm.$emit('select', { dataIndex: 0 }); await flushPromises() - expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', groupBy: 'SKU', current: 1, snapshotContext: 'token-A' }) + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['OWNER-A'], groupBy: 'SKU', current: 1, snapshotContext: 'token-A' }) }) it('shows customer and SKU volume, missing dimensions and estimates in the selected window', async () => { @@ -122,11 +178,11 @@ describe('inventory turnover page fixed snapshot behavior', () => { it('filters turnover by directory customer and keeps the code in pagination', async () => { const w = page(); await flushPromises() - expect(w.findComponent(toolbar).props('fields').find((f: { key: string }) => f.key === 'customerCode').options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }]) - await change(w, { customerCode: 'OWNER-A' }) - expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 1, includeOverview: true }) + expect(w.findComponent(toolbar).props('fields').find((f: { key: string }) => f.key === 'customerCodes').options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }]) + await change(w, { customerCodes: ['OWNER-A'] }) + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['OWNER-A'], current: 1, includeOverview: true }) w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() - expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 2, snapshotContext: 'token-A' }) + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['OWNER-A'], current: 2, snapshotContext: 'token-A' }) }) it('sorts every numeric header on the server in both directions with the same snapshot', async () => { @@ -323,7 +379,7 @@ describe('inventory turnover page fixed snapshot behavior', () => { expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sku: undefined, sortBy: 'PHYSICAL_DESC', snapshotContext: 'token-A', includeOverview: true }) w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() await w.findAll('button').find(b => b.text() === 'OWNER-A')!.trigger('click'); await flushPromises() - expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'SKU', customerCode: 'OWNER-A', snapshotContext: 'token-A' }) + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'SKU', customerCodes: ['OWNER-A'], snapshotContext: 'token-A' }) await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises() expect(w.findComponent(table).props('page')).toBe(2) expect(w.findComponent(toolbar).props('modelValue').groupBy).toBe('OWNER') diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index f7b13a46..812412ac 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -17,13 +17,15 @@ import type { DailyResponse, Overview } from '../types' import { overviewRange, overviewRangeOptions, beijingDate } from '../overviewRange' import type { RangePreset } from '../types' import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow, TurnoverTotal, TurnoverVolume } from '../turnoverTypes' -import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation' +import { normalizeTurnoverOwners, turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation' import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting' import { totalText, turnoverTotalNote, volumeText, volumeNote, dimensionSourceText, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation' import AnalyticsChart from './AnalyticsChart.vue' import ChartDetailsDialog from './ChartDetailsDialog.vue' import SkuTrend from './SkuTrend.vue' import { useOwnerDirectory } from '../useOwnerDirectory' +import { collectTurnoverExport, turnoverCsv, turnoverPercent } from '../turnoverExport' +import { saveCsv } from '@/shared/downloadCsv' const route = useRoute(), router = useRouter(), access = useAccessPreviewStore() const ownerDirectory = useOwnerDirectory() @@ -32,13 +34,30 @@ class TurnoverContractError extends Error {} const permitted = computed(() => access.can('platform:self-operated-analytics:list')) const runtime = ref(null), data = ref(null) const error = ref(null), inputError = ref(''), notice = ref(''), loading = ref(false) -const filters = ref({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', hideZeroInventory: true, sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }) +const filters = ref({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', hideZeroInventory: true, sku: '', customerCodes: [], focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }) const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true) const rightChart = ref<'volume' | 'daily'>('volume') const fence = new RequestFence(), runtimeFence = new RequestFence(), trendFence = new RequestFence() const trendOpen = ref(false), trendLoading = ref(false), daily = ref(null), trendError = ref(null) const evidence = ref(null), evidenceOpen = ref(false) const applied = ref(null) +const exporting = ref(false), exportProgress = ref(0), exportFeedback = ref('') +let exportController: AbortController | undefined +async function exportRows() { + if (exporting.value || loading.value || !applied.value || !data.value) return + const controller = new AbortController(); exportController = controller + const q = { ...applied.value, customerCodes: [...(applied.value.customerCodes ?? [])], snapshotContext: data.value.context.snapshotContext } + const exportColumns = columns.value.map(c => ({ prop: String(c.prop), label: String(c.label) })) + const total = Number(data.value.total) + exporting.value = true; exportProgress.value = 0; exportFeedback.value = '' + try { + const rows = await collectTurnoverExport(q, total, analyticsApi.turnover, controller.signal, count => { exportProgress.value = count }) + controller.signal.throwIfAborted() + saveCsv(new Blob([turnoverCsv(rows, exportColumns, q)], { type: 'text/csv;charset=utf-8' }), `库存周转_${q.warehouseCode}_${q.groupBy}_${q.endDate}.csv`) + exportFeedback.value = `已导出 ${rows.length} 条记录` + } catch (cause) { exportFeedback.value = controller.signal.aborted ? '导出已取消' : getApiErrorDetails(cause, '导出失败,请重试').message } + finally { exporting.value = false; exportController = undefined } +} let linkedSnapshot: string | undefined let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse; filters: QueryModel } | null = null let initialized = false, active = true @@ -48,12 +67,18 @@ const fields = computed(() => [ { key: 'rangePreset', label: '统计时间范围', type: 'select', width: 180, props: { clearable: false }, options: overviewRangeOptions }, ...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'customRange', label: '日期范围', type: 'daterange' as const, width: 300, props: { disabledDate: (date: Date) => date.toLocaleDateString('en-CA') > yesterdayBeijing() } }] : []), { key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } }, - { key: 'customerCode', label: '客户', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '名称或编码搜索', props: { filterable: true, clearable: true } }, + { key: 'customerCodes', label: '货主', type: 'select', width: 260, options: ownerDirectory.options.value, placeholder: '全部货主(可多选)', props: { multiple: true, multipleLimit: 100, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 1, filterable: true, clearable: true, onChange: changeOwners } }, ...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []), { key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] }, { key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] }, { key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') && !value.startsWith('UNIT_VOLUME_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') }, ]) +const selectedOwners = computed(() => Array.isArray(filters.value.customerCodes) ? filters.value.customerCodes as string[] : []) +function ownerLabel(code: string) { return ownerDirectory.options.value.find(option => option.value === code)?.label ?? code } +function changeOwners(codes: string[]) { + filters.value = { ...filters.value, customerCodes: [...codes] } + void search() +} const ownerMode = computed(() => applied.value?.groupBy === 'OWNER') const columns = computed(() => [ ...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]), @@ -66,6 +91,7 @@ const columns = computed(() => [ { prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 }, ...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })), { prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slotName: 'daily', minWidth: 110 }, + { prop: 'quantityTurnoverRate', label: '期间周转率', slotName: 'turnoverRate', minWidth: 145 }, ...(ownerMode.value ? [{ prop: 'noOutboundInWindow', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]), ...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slotName: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slotName: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slotName: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slotName: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slotName: 'lastDate', minWidth: 180 }] : []), ].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined }))) @@ -134,12 +160,13 @@ function query(refresh = false): TurnoverQuery { const anchor = !refresh && data.value ? beijingDate(Date.parse(data.value.context.queryAsOf)) : beijingDate(Date.now()) const range = overviewRange(filters.value.rangePreset as RangePreset, anchor, String(custom[0] ?? ''), String(custom[1] ?? '')) const days = Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 86400000) + 1 - return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: range.endDate, baseWindowDays: days, groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], hideZeroInventory: filters.value.hideZeroInventory !== false, coverageDaysThreshold: !['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.value.focus)) || filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true } + return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: range.endDate, baseWindowDays: days, groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCodes: normalizeTurnoverOwners(filters.value.customerCodes), sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], hideZeroInventory: filters.value.hideZeroInventory !== false, coverageDaysThreshold: !['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.value.focus)) || filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true } } function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') } -function overviewKey(q: TurnoverQuery) { return [scopeKey(q), q.customerCode, q.groupBy].join('|') } +function overviewKey(q: TurnoverQuery) { return [scopeKey(q), JSON.stringify(q.customerCodes ?? []), q.groupBy].join('|') } async function load(refresh = false) { if (!permitted.value || !active) return + exportController?.abort() fence.cancel(); inputError.value = ''; evidenceOpen.value = false; evidence.value = null; trendOpen.value = false let q: TurnoverQuery try { q = query(refresh) } catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return } @@ -178,7 +205,7 @@ function toggleZeroInventory(checked: string | number | boolean) { } function reset() { const warehouseCode = filters.value.warehouseCode || applied.value?.warehouseCode || warehouses.value[0]?.value || '' - filters.value = { warehouseCode, endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', hideZeroInventory: true, sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' } + filters.value = { warehouseCode, endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', hideZeroInventory: true, sku: '', customerCodes: [], focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' } ownerReturn = null; page.value = 1; void load(true) } function changeFilters(next: QueryModel) { @@ -186,20 +213,20 @@ function changeFilters(next: QueryModel) { if (next.groupBy !== filters.value.groupBy) { next = { ...next, sku: '', sortBy: 'PHYSICAL_DESC' }; notice.value = '已切换分组,SKU条件及专属排序已清除' } - const changed = ['warehouseCode', 'rangePreset', 'groupBy', 'customerCode'].some(k => next[k] !== filters.value[k]) + const changed = ['warehouseCode', 'rangePreset', 'groupBy', 'customerCodes'].some(k => next[k] !== filters.value[k]) if (next.warehouseCode !== filters.value.warehouseCode) next = { ...next, coverageDaysThreshold: restoreThreshold(String(next.warehouseCode || '')) } filters.value = next if (changed) { page.value = 1; void load() } } function selectOwner(code: string) { - if (applied.value?.groupBy === 'OWNER' && data.value) ownerReturn = { query: { ...applied.value }, result: data.value, filters: { ...filters.value, customRange: [...(filters.value.customRange as string[] ?? [])] } } - filters.value = { ...filters.value, groupBy: 'SKU', customerCode: code, sku: '', sortBy: 'PHYSICAL_DESC' }; page.value = 1; void load() + if (applied.value?.groupBy === 'OWNER' && data.value) ownerReturn = { query: { ...applied.value }, result: data.value, filters: { ...filters.value, customerCodes: [...selectedOwners.value], customRange: [...(filters.value.customRange as string[] ?? [])] } } + filters.value = { ...filters.value, groupBy: 'SKU', customerCodes: [code], sku: '', sortBy: 'PHYSICAL_DESC' }; page.value = 1; void load() } function returnOwners() { if (!ownerReturn) return if (Date.parse(ownerReturn.result.context.queryExpiresAt) <= Date.now()) { inputError.value = '原货主汇总版本已过期,请整体刷新'; return } fence.cancel(); trendOpen.value = false; evidenceOpen.value = false; evidence.value = null; loading.value = false; error.value = null - const saved = ownerReturn; filters.value = { ...saved.filters, ...saved.query, sku: '', customerCode: saved.query.customerCode || '', coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' } + const saved = ownerReturn; filters.value = { ...saved.filters, ...saved.query, sku: '', customerCodes: [...(saved.query.customerCodes ?? [])], coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' } applied.value = saved.query; page.value = saved.query.current; data.value = saved.result; ownerReturn = null } async function showTrend(row: TurnoverRow) { @@ -240,9 +267,9 @@ async function initialize() { watch(() => route.query, () => { if (active && readRoute()) void load() }) watch(chartsOpen, value => { try { sessionStorage.setItem('selfop-turnover-charts', String(value)) } catch { /* optional preference */ } }) onMounted(() => { initialized = true; try { chartsOpen.value = sessionStorage.getItem('selfop-turnover-charts') !== 'false' } catch { /* optional preference */ } void initialize() }) -onDeactivated(() => { active = false; fence.cancel(); runtimeFence.cancel(); trendFence.cancel(); loading.value = false }) +onDeactivated(() => { active = false; exportController?.abort(); fence.cancel(); runtimeFence.cancel(); trendFence.cancel(); loading.value = false }) onActivated(() => { const resume = initialized && !active; active = true; if (resume) void initialize() }) -onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }) +onUnmounted(() => { exportController?.abort(); fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }) -