diff --git a/src/modules/downloads/api/platformExportTasks.ts b/src/modules/downloads/api/platformExportTasks.ts index 101305ec..2d004b27 100644 --- a/src/modules/downloads/api/platformExportTasks.ts +++ b/src/modules/downloads/api/platformExportTasks.ts @@ -11,6 +11,7 @@ export type KnownExportTaskType = | 'VEHICLE_LIST' | 'FLEET_QUOTE_LIST' | 'TRIP_OPERATIONS_LIST' + | 'SELFOP_INVENTORY_TURNOVER' | 'CUSTOMER_LIST' | 'DOMESTIC_WAREHOUSE_ORDER_LIST' | 'FINANCE_CUSTOMER_ACCOUNT_LIST' diff --git a/src/modules/downloads/pages/DownloadCenterView.vue b/src/modules/downloads/pages/DownloadCenterView.vue index 888d5541..0b727d0e 100644 --- a/src/modules/downloads/pages/DownloadCenterView.vue +++ b/src/modules/downloads/pages/DownloadCenterView.vue @@ -34,6 +34,7 @@ const exportTypeMeta: Record VEHICLE_LIST: { label: '车辆', retryPermission: 'platform:overseas-master-data:vehicle:export' }, FLEET_QUOTE_LIST: { label: '车队报价', retryPermission: 'platform:fleet:quote:export' }, TRIP_OPERATIONS_LIST: { label: '出车记录', retryPermission: 'platform:fleet:operations:export' }, + SELFOP_INVENTORY_TURNOVER: { label: '库存周转', retryPermission: 'platform:self-operated-analytics:export' }, FINANCE_RECEIVABLE_STATISTICS: { label: '应收统计', retryPermission: 'platform:finance:receivable-statistics:export' }, LOADING_LIST_BUY_DECLARATION_ITEMS: { label: '买单报关明细', retryPermission: 'platform:firstleg:customs:manage' }, CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' }, diff --git a/src/modules/downloads/useExportTask.test.ts b/src/modules/downloads/useExportTask.test.ts index 1cf0a9ae..de7abc6e 100644 --- a/src/modules/downloads/useExportTask.test.ts +++ b/src/modules/downloads/useExportTask.test.ts @@ -36,7 +36,7 @@ describe('download center export submission', () => { expect(showExportTaskCreatedDialog).not.toHaveBeenCalled() expect(push).not.toHaveBeenCalled() }) - it.each([["overseas-warehouse/pages/LastMileOrdersView.vue","LAST_MILE_ORDER_LIST","platform:overseas-warehouse:last-mile-order:export"],["overseas-warehouse/pages/InventoryView.vue","OVERSEAS_INVENTORY_BALANCE","platform:overseas-warehouse:inventory:export"],["overseas-warehouse/pages/InventoryTransactionsView.vue","OVERSEAS_INVENTORY_LEDGER","platform:overseas-warehouse:inventory:export"],["overseas-master-data/pages/OverseasQuotesView.vue","OVERSEAS_QUOTE_LIST","platform:overseas-master-data:quote:export"],["overseas-master-data/pages/ExchangeRatesView.vue","MONTHLY_EXCHANGE_RATE_LIST","platform:overseas-master-data:exchange-rate:export"],["overseas-master-data/pages/VehiclesView.vue","VEHICLE_LIST","platform:overseas-master-data:vehicle:export"],["overseas-master-data/pages/FleetQuotesView.vue","FLEET_QUOTE_LIST","platform:fleet:quote:export"],["overseas-master-data/pages/TripOperationsView.vue","TRIP_OPERATIONS_LIST","platform:fleet:operations:export"]])('connects %s with the correct type and download-center retry permission', (path, type, permission) => { + it.each([["self-operated/components/InventoryTurnoverView.vue","SELFOP_INVENTORY_TURNOVER","platform:self-operated-analytics:export"],["overseas-warehouse/pages/LastMileOrdersView.vue","LAST_MILE_ORDER_LIST","platform:overseas-warehouse:last-mile-order:export"],["overseas-warehouse/pages/InventoryView.vue","OVERSEAS_INVENTORY_BALANCE","platform:overseas-warehouse:inventory:export"],["overseas-warehouse/pages/InventoryTransactionsView.vue","OVERSEAS_INVENTORY_LEDGER","platform:overseas-warehouse:inventory:export"],["overseas-master-data/pages/OverseasQuotesView.vue","OVERSEAS_QUOTE_LIST","platform:overseas-master-data:quote:export"],["overseas-master-data/pages/ExchangeRatesView.vue","MONTHLY_EXCHANGE_RATE_LIST","platform:overseas-master-data:exchange-rate:export"],["overseas-master-data/pages/VehiclesView.vue","VEHICLE_LIST","platform:overseas-master-data:vehicle:export"],["overseas-master-data/pages/FleetQuotesView.vue","FLEET_QUOTE_LIST","platform:fleet:quote:export"],["overseas-master-data/pages/TripOperationsView.vue","TRIP_OPERATIONS_LIST","platform:fleet:operations:export"]])('connects %s with the correct type and download-center retry permission', (path, type, permission) => { const page = readFileSync(new URL('../' + path, import.meta.url), 'utf8') expect(page).toContain("await submitExport('" + type + "'") expect(page).not.toMatch(/saveCsv\(|saveFleetCsv\(/) diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index 50a46570..c36b8911 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -4,13 +4,12 @@ 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() })) +import { turnoverPercent } from '../turnoverPresentation' +vi.mock('@/modules/downloads', () => ({ useExportTask: () => mocks.exportTask })) +const mocks = vi.hoisted(() => ({ route: { query: {} as Record }, allowed: true, exportAllowed: true, exportTask: vi.fn(), 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 })) -vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':export') ? mocks.exportAllowed : mocks.allowed }) })) const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup(_, { slots }) { return () => h('div', slots.actions?.()) } }) const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.columns ?? []).map((column: { headerSlotName?: string; prop: string; label: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } }) const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: (event: MouseEvent) => emit('click', event) }, slots.default?.()) } }) @@ -29,7 +28,7 @@ async function change(w: ReturnType, patch: object, search = false const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises() if (search) { t.vm.$emit('search'); await flushPromises() } } -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() }) +beforeEach(() => { vi.clearAllMocks(); mocks.exportAllowed = true; mocks.exportTask.mockResolvedValue(undefined); 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 () => { @@ -48,44 +47,36 @@ describe('inventory turnover page fixed snapshot behavior', () => { 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 - }) + it('submits one backend export with applied filters, visible columns and fixed version without paging in the browser', async () => { 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 change(w, { customerCodes: ['OWNER-A'], sortBy: 'TURNOVER_DESC' }, true) + await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click') + w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() + const before = 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) + expect(mocks.turnover).toHaveBeenCalledTimes(before) + expect(mocks.exportTask).toHaveBeenCalledTimes(1) + const [type, , parameters] = mocks.exportTask.mock.calls[0]! + expect(type).toBe('SELFOP_INVENTORY_TURNOVER') + expect(parameters).toMatchObject({ customerCodes: ['OWNER-A'], sortBy: 'TURNOVER_DESC', snapshotContext: 'token-A', hideZeroInventory: true, expectedTotal: 40 }) + expect(parameters.columns).toEqual(w.findComponent(table).props('columns').map((c: { prop: string }) => c.prop)) + expect(parameters.columns).toContain('quantityTurnoverRate') + expect(parameters.columns).toContain('averagePhysicalQuantity') + for (const key of ['current', 'size', 'includeOverview']) expect(parameters).not.toHaveProperty(key) + expect(w.findComponent(table).props('page')).toBe(2) + expect(w.text()).toContain('导出任务已提交') + expect(turnoverPercent(null)).toBe('—'); expect(turnoverPercent(0)).toBe('0%'); expect(turnoverPercent(1.25)).toBe('125%') }) - 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","商品名称","统计期间"') - expect(csv).toContain('\r\n"125%","\'=SUM(1,2)"') - expect(csv).toContain('库存流水还原(日末实物库存)') - 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('requires independent export permission and displays submission failures', async () => { + mocks.exportAllowed = false + const denied = page(); await flushPromises() + expect(denied.findAll('button').some(b => b.text() === '导出')).toBe(false) + mocks.exportAllowed = true + const w = page(); await flushPromises() + mocks.exportTask.mockRejectedValueOnce(new Error('export unavailable')) + await w.findAll('button').find(b => b.text() === '导出')!.trigger('click'); await flushPromises() + expect(w.text()).toContain('导出任务提交失败,请重试') + expect(w.text()).not.toContain('导出任务已提交') }) it('hides zero inventory by default, applies it before pagination and restores it on reset', async () => { const w = page(); await flushPromises() diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index 0255212f..f124c6ce 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -24,8 +24,8 @@ 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' +import { turnoverPercent } from '../turnoverPresentation' +import { useExportTask } from '@/modules/downloads' const route = useRoute(), router = useRouter(), access = useAccessPreviewStore() const ownerDirectory = useOwnerDirectory() @@ -41,22 +41,20 @@ const fence = new RequestFence(), runtimeFence = new RequestFence(), trendFence 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 +const exporting = ref(false), exportFeedback = ref('') +const submitExport = useExportTask() +const canExport = computed(() => access.can('platform:self-operated-analytics:export')) 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 = '' + if (exporting.value || loading.value || !canExport.value || !applied.value || !data.value) return + const { current: _current, size: _size, includeOverview: _overview, ...query } = applied.value + const parameters = { ...query, customerCodes: [...(query.customerCodes ?? [])], snapshotContext: data.value.context.snapshotContext, + columns: columns.value.map(c => String(c.prop)), expectedTotal: Number(data.value.total) } + exporting.value = true; 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 } + await submitExport('SELFOP_INVENTORY_TURNOVER', `库存周转_${query.warehouseCode}_${query.endDate}`, parameters) + exportFeedback.value = '导出任务已提交,请到下载中心查看进度和下载 Excel' + } catch (cause) { exportFeedback.value = getApiErrorDetails(cause, '导出任务提交失败,请重试').message } + finally { exporting.value = false } } let linkedSnapshot: string | undefined let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse; filters: QueryModel } | null = null @@ -166,7 +164,6 @@ function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.base 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 } @@ -267,9 +264,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; exportController?.abort(); fence.cancel(); runtimeFence.cancel(); trendFence.cancel(); loading.value = false }) +onDeactivated(() => { active = false; fence.cancel(); runtimeFence.cancel(); trendFence.cancel(); loading.value = false }) onActivated(() => { const resume = initialized && !active; active = true; if (resume) void initialize() }) -onUnmounted(() => { exportController?.abort(); fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }) +onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }) -