feat(selfop): submit turnover exports to backend download center
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -34,6 +34,7 @@ const exportTypeMeta: Record<string, { label: string; retryPermission: string }>
|
||||
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' },
|
||||
|
||||
@@ -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\(/)
|
||||
|
||||
@@ -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<string, string> }, 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<string, string> }, 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<typeof mount>, 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()
|
||||
|
||||
@@ -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<DailyResponse | null>(null), trendError = ref<ApiErrorDetails | null>(null)
|
||||
const evidence = ref<TurnoverRow | null>(null), evidenceOpen = ref(false)
|
||||
const applied = ref<TurnoverQuery | null>(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() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density turnover-page">
|
||||
@@ -297,7 +294,7 @@ onUnmounted(() => { exportController?.abort(); fence.cancel(); runtimeFence.canc
|
||||
</span>
|
||||
</template>
|
||||
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
|
||||
<template #toolbar-actions><el-button :disabled="loading || !data || exporting" title="导出当前查询的全部记录与当前显示列(CSV)" @click="exportRows">{{ exporting ? `导出中 ${exportProgress} 条` : '导出' }}</el-button><el-button v-if="exporting" @click="exportController?.abort()">取消导出</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷流水还原并经库存锚点核对的每日末实物库存均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p>
|
||||
<template #toolbar-actions><el-button v-if="canExport" :disabled="loading || !data || exporting" title="后台导出全部匹配记录与当前显示列,在下载中心下载 Excel" @click="exportRows">{{ exporting ? '提交中…' : '导出' }}</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷流水还原并经库存锚点核对的每日末实物库存均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p>
|
||||
<p>历史库存记录用于计算期间平均库存和周转次数,与已采集的销量天数不同;记录不足不代表销量采集失败。</p>
|
||||
<template v-if="data?.rows.length">
|
||||
<label>查看本页历史库存记录
|
||||
|
||||
@@ -162,7 +162,9 @@
|
||||
|
||||
| 库存周转/零库存筛选样式 | 用户截图 `codex-clipboard-c21dd284-0aed-4db0-840b-172260c2e3fb.png` | 将原生复选框从查询按钮组移到列表快捷筛选右侧,使用 Element 带边框复选框并垂直居中;无概览、无货主条件时不渲染空白概览行;首次读取失败不提示保留上次结果 | 保留默认选中、即时查询及禁用行为;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 库存周转/多选货主、导出及周转率 | 用户截图 `codex-clipboard-b295bb06-d54f-45a8-9d9f-d7562da01ac8.png` 与本轮追加要求 | 原货主下拉改为可搜索多选,选项折叠并可清空,SKU/货主两视图及概览、图表同集合查询;列表顶部导出当前查询全部匹配记录为CSV,包含当前显示列与扩展列,显示进度、支持取消;默认显示期间周转率=期间件数周转次数×100%,未知保留—,悬停说明原因及口径 | 多选范围、跨页导出、CSV转义、空值与零值回归;视觉待人工1920×1080、100%截图确认 |
|
||||
| 库存周转/多选货主、导出及周转率 | 用户截图 `codex-clipboard-b295bb06-d54f-45a8-9d9f-d7562da01ac8.png` 与本轮追加要求 | 原货主下拉改为可搜索多选,选项折叠并可清空,SKU/货主两视图及概览、图表同集合查询;列表顶部通过独立导出权限提交后台XLSX任务,包含当前显示列与扩展列;下载中心查看进度、下载及重试,离开页面不取消已提交任务;默认显示期间周转率=期间件数周转次数×100%,未知保留—,悬停说明原因及口径 | 多选范围、后台任务固定筛选与列顺序、独立权限、空值与零值回归;视觉待人工1920×1080、100%截图确认 |
|
||||
| 库存周转/期间周转率表头 | 用户截图 `codex-clipboard-94ff8645-7783-42b1-a82d-3170295b823a.png` 要求计算口径与排序直接放在默认百分比列 | 默认百分比列接入 TURNOVER 服务端全量升降序,与扩展周转次数及排序下拉同步;保留货主、日期、固定版本、隐藏零库存并回到第一页。列名旁信息按钮独立展示公式、日末均值、货主汇总与未知口径,不触发查询 | 默认列实际点击、两种分组、双向排序与说明按钮回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 经营总览/指标卡说明 | 用户反馈“完整”下方破折号含义不清 | 无异常原因时不渲染说明占位符,待核验为0时隐藏该行;真实异常、正数待核验、未知主数值及已知小计保留 | 页面回归及构建;视觉待人工截图确认 |
|
||||
|
||||
| 库存周转/后台导出 | 用户指出前端拼文件,要求改后端导出 | 复用统一下载中心成功提示;提交期间按钮禁用,成功可进入下载中心;任务固定筛选/排序/版本/列顺序,页面不再拉取全量明细或提供前端取消。失败保留原查询结果 | 单次提交、无额外查询、权限隐藏、失败提示及下载中心映射回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from './turnoverTypes'
|
||||
import { dimensionSourceText, totalText, turnoverNumber, turnoverReasonsText, volumeNote, volumeText, windowText } from './turnoverPresentation'
|
||||
|
||||
export interface TurnoverExportColumn { prop: string; label: string }
|
||||
export function turnoverPercent(value: TurnoverRow['quantityTurnoverCount']) {
|
||||
return value == null ? '—' : `${turnoverNumber(Number(value) * 100)}%`
|
||||
}
|
||||
function cell(row: TurnoverRow, key: string, query: TurnoverQuery): string {
|
||||
switch (key) {
|
||||
case 'period': return `截至 ${query.endDate},最近 ${query.baseWindowDays} 天`
|
||||
case 'periodOutbound': return windowText(row.windows.find(w => w.days === query.baseWindowDays))
|
||||
case 'historySource': return '库存流水还原(日末实物库存)'
|
||||
case 'historyDays': return `${row.qualifiedSampleDays}/${row.requiredSampleDays}`
|
||||
case 'historyReason': return turnoverReasonsText(row.sampleReasonCodes)
|
||||
case 'sku': return row.sku ?? '—'
|
||||
case 'productName': return row.productName ?? '—'
|
||||
case 'customerCode': return row.customerName || row.customerCode
|
||||
case 'physicalQuantity': case 'availableQuantity': case 'transitQuantity': case 'frozenQuantity': case 'defectiveQuantity': return totalText(row[key])
|
||||
case 'physicalSkuCount': return turnoverNumber(row.physicalSkuCount)
|
||||
case 'inventoryVolumeM3': return `${volumeText(row.inventoryVolumeM3.value)};${volumeNote(row.inventoryVolumeM3)}`
|
||||
case 'outboundVolumesM3': { const v = row.outboundVolumesM3.find(w => w.days === query.baseWindowDays)?.volume; return `${volumeText(v?.value)};${volumeNote(v, true)}` }
|
||||
case 'unitVolumeM3': return `${volumeText(row.unitVolumeM3, 9)};${dimensionSourceText(row.dimensionSource)}`
|
||||
case 'outbound7': case 'outbound15': case 'outbound30': return windowText(row.windows.find(w => w.days === Number(key.slice(8))))
|
||||
case 'averageDailyOutboundQuantity': return turnoverNumber(row.averageDailyOutboundQuantity)
|
||||
case 'inventoryCoverageDays': return `${turnoverNumber(row.inventoryCoverageDays, 1)};${turnoverReasonsText([row.coverageStatus])}`
|
||||
case 'noOutboundInWindow': return `${turnoverNumber(row.noOutboundInWindow.matchedSkuCount)} SKU / ${turnoverNumber(row.noOutboundInWindow.knownPhysicalQuantity)} 件;待判断 ${row.noOutboundInWindow.unknownSkuCount} SKU`
|
||||
case 'slowCoverage': return query.coverageDaysThreshold ? `${turnoverNumber(row.slowCoverage.matchedSkuCount)} SKU / ${row.slowCoverage.unknownSkuCount} SKU` : '未设查询阈值'
|
||||
case 'averagePhysicalQuantity': return turnoverNumber(row.averagePhysicalQuantity)
|
||||
case 'quantityTurnoverRate': return turnoverPercent(row.quantityTurnoverCount)
|
||||
case 'quantityTurnoverCount': return row.quantityTurnoverCount == null ? '—' : `${turnoverNumber(row.quantityTurnoverCount)} 次/${query.baseWindowDays}日`
|
||||
case 'lastObservedOutboundDate': return row.lastObservedOutboundDate || '已采集历史内未见出库'
|
||||
default: throw new Error(`未配置导出列:${key}`)
|
||||
}
|
||||
}
|
||||
function csvCell(value: string) {
|
||||
// Neutralize spreadsheet formulas, including values with leading whitespace.
|
||||
const safe = /^[\s]*[=+@-]/.test(value) ? `'${value}` : value
|
||||
return `"${safe.replaceAll('"', '""')}"`
|
||||
}
|
||||
export function turnoverCsv(rows: TurnoverRow[], columns: TurnoverExportColumn[], query: TurnoverQuery) {
|
||||
const required: TurnoverExportColumn[] = [
|
||||
{ prop: 'period', label: '统计期间' }, { prop: 'periodOutbound', label: '期间出库件数' },
|
||||
{ prop: 'averagePhysicalQuantity', label: '期间平均日末库存' }, { prop: 'quantityTurnoverRate', label: '期间周转率' },
|
||||
{ prop: 'historySource', label: '历史库存来源' }, { prop: 'historyDays', label: '合格历史库存天数' }, { prop: 'historyReason', label: '历史库存说明' },
|
||||
]
|
||||
columns = [...columns, ...required.filter(c => !columns.some(existing => existing.prop === c.prop))]
|
||||
return '\uFEFF' + [columns.map(c => csvCell(c.label)).join(','), ...rows.map(row => columns.map(c => csvCell(cell(row, c.prop, query))).join(','))].join('\r\n')
|
||||
}
|
||||
export async function collectTurnoverExport(query: TurnoverQuery, total: number, fetchPage: (q: TurnoverQuery, signal: AbortSignal) => Promise<TurnoverResponse>, signal: AbortSignal, progress: (count: number) => void) {
|
||||
if (!query.snapshotContext || !Number.isSafeInteger(total) || total < 0) throw new Error('请先查询有效结果再导出')
|
||||
const rows: TurnoverRow[] = [], seen = new Set<string>()
|
||||
for (let current = 1; rows.length < total; current++) {
|
||||
signal.throwIfAborted()
|
||||
const result = await fetchPage({ ...query, current, size: 100, includeOverview: false }, signal)
|
||||
signal.throwIfAborted()
|
||||
if (result.context.snapshotContext !== query.snapshotContext || Number(result.total) !== total || result.rows.length !== Math.min(100, total - rows.length)) throw new Error('导出数据范围或版本已变化,请刷新后重试')
|
||||
for (const row of result.rows) {
|
||||
const key = JSON.stringify([row.customerCode, row.sku])
|
||||
if (seen.has(key)) throw new Error('导出分页出现重复记录,请重试')
|
||||
seen.add(key); rows.push(row)
|
||||
}
|
||||
progress(rows.length)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -29,6 +29,9 @@ export function turnoverNumber(value: Quantity | undefined, decimals = 2) {
|
||||
if (n > 0 && n < 10 ** -decimals) return `<${(10 ** -decimals).toFixed(decimals)}`
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals })
|
||||
}
|
||||
export function turnoverPercent(value: Quantity | undefined) {
|
||||
return value == null ? '—' : `${turnoverNumber(Number(value) * 100)}%`
|
||||
}
|
||||
export function volumeText(value: Quantity | undefined, decimals = 6) {
|
||||
const text = turnoverNumber(value, decimals)
|
||||
return text === '—' ? text : `${text} m³`
|
||||
|
||||
Reference in New Issue
Block a user