feat(selfop): multi-owner turnover filters, CSV export and percentage
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -9,7 +9,10 @@ async function get<T>(path: string, params: object, signal?: AbortSignal): Promi
|
||||
}
|
||||
export const analyticsApi = {
|
||||
ownerDirectory: (query: { sourceAccount: string; warehouseCode: string } & Partial<BusinessQuery>, signal?: AbortSignal) => get<OwnerOption[]>('owner-directory', query, signal),
|
||||
turnover: (query: TurnoverQuery, signal?: AbortSignal) => get<TurnoverResponse>('inventory-turnover', query, signal),
|
||||
async turnover(query: TurnoverQuery, signal?: AbortSignal): Promise<TurnoverResponse> {
|
||||
const response = await http.get<ApiEnvelope<TurnoverResponse>, ApiEnvelope<TurnoverResponse>>(`${base}/inventory-turnover`, { params: query, paramsSerializer: { indexes: null }, signal })
|
||||
return unwrapApiResponse(response)
|
||||
},
|
||||
dashboard: (query: DashboardQuery, signal?: AbortSignal) => get<Dashboard>('dashboard', query, signal),
|
||||
owners: (query: OwnerQuery, signal?: AbortSignal) => get<BusinessPage<OwnerOption>>('owners', query, signal),
|
||||
ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get<OwnerOverview | null>('owner-overview', query, signal),
|
||||
|
||||
@@ -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<string, string> }, 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<typeof mount>, 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')
|
||||
|
||||
@@ -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<Overview | null>(null), data = ref<TurnoverResponse | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null), inputError = ref(''), notice = ref(''), loading = ref(false)
|
||||
const filters = ref<QueryModel>({ 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<QueryModel>({ 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<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
|
||||
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<QueryField[]>(() => [
|
||||
{ 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<DataTableColumn[]>(() => [
|
||||
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
|
||||
@@ -66,6 +91,7 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ 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() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density turnover-page">
|
||||
@@ -258,7 +285,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
</button>
|
||||
</template>
|
||||
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
|
||||
<template #toolbar-actions><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 :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>
|
||||
<p>历史库存记录用于计算期间平均库存和周转次数,与已采集的销量天数不同;记录不足不代表销量采集失败。</p>
|
||||
<template v-if="data?.rows.length">
|
||||
<label>查看本页历史库存记录
|
||||
@@ -275,9 +302,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
|
||||
</template>
|
||||
<template #before-results>
|
||||
<div v-if="data || filters.customerCode" class="turnover-context">
|
||||
<template v-if="filters.customerCode"><el-tag closable @close="filters.customerCode = ''; search()">货主:{{ filters.customerCode }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
|
||||
<template v-if="data"><span>{{ applied?.customerCode ? '本货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
|
||||
<div v-if="data || selectedOwners.length" class="turnover-context">
|
||||
<template v-if="selectedOwners.length"><el-tag v-for="code in selectedOwners" :key="code" closable @close="changeOwners(selectedOwners.filter(item => item !== code))">{{ ownerLabel(code) }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
|
||||
<template v-if="data"><span>{{ applied?.customerCodes?.length ? '所选货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
|
||||
</div>
|
||||
<p v-if="data?.context.analyticsContext.inventoryStatus === 'STALE'" class="turnover-context" role="status">当前库存已过期,实物、可售、货主排名及预计可售天数暂不可用;最近库存观测完成于 {{ beijingTime(data.context.inventoryObservedUntil) }}。采集成功后请整体刷新。</p>
|
||||
<p v-else-if="data?.context.analyticsContext.inventoryStatus === 'UNKNOWN'" class="turnover-context" role="status">当前尚无可用库存版本,请查看数据采集。</p>
|
||||
@@ -295,7 +322,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
</div>
|
||||
<p v-if="data" class="turnover-context">体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。</p>
|
||||
<template v-if="data?.charts">
|
||||
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
|
||||
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCodes?.length ? '所选货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
|
||||
<div v-if="chartsOpen" class="turnover-charts">
|
||||
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
|
||||
<section aria-label="库存体积与出库对比">
|
||||
@@ -337,7 +364,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<el-button @click="search()">应用</el-button>
|
||||
<span>按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。</span>
|
||||
</div>
|
||||
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
|
||||
<p v-if="exportFeedback" class="turnover-context" role="status">{{ exportFeedback }}</p><p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
|
||||
<p v-else-if="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
|
||||
<p v-else-if="error" class="turnover-filter-feedback" role="alert">{{ data ? '筛选未更新,当前保留上次结果。' : '库存周转读取失败。' }}{{ error.message }}</p>
|
||||
|
||||
@@ -350,6 +377,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<template #outboundVolume="{ row }">{{ volumeText(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume.value) }}<small>{{ volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true) }}</small></template>
|
||||
<template #unitVolume="{ row }">{{ volumeText(castRow(row).unitVolumeM3, 9) }}<small>{{ dimensionSourceText(castRow(row).dimensionSource) }}</small></template>
|
||||
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
|
||||
<template #turnoverRate="{ row }"><span :title="castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : `所选${applied?.baseWindowDays}天出库量 ÷ 期间平均实物库存 × 100%`">{{ turnoverPercent(castRow(row).quantityTurnoverCount) }}</span></template>
|
||||
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
|
||||
<template #noOutbound="{ row }">{{ turnoverNumber(castRow(row).noOutboundInWindow.matchedSkuCount) }} SKU / {{ turnoverNumber(castRow(row).noOutboundInWindow.knownPhysicalQuantity) }} 件<small>待判断 {{ castRow(row).noOutboundInWindow.unknownSkuCount }} SKU</small></template>
|
||||
<template #slow="{ row }">{{ applied?.coverageDaysThreshold ? `${turnoverNumber(castRow(row).slowCoverage.matchedSkuCount)} SKU / ${castRow(row).slowCoverage.unknownSkuCount} SKU` : '未设查询阈值' }}</template>
|
||||
|
||||
@@ -162,4 +162,6 @@
|
||||
|
||||
| 库存周转/零库存筛选样式 | 用户截图 `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%截图确认 |
|
||||
|
||||
| 经营总览/指标卡说明 | 用户反馈“完整”下方破折号含义不清 | 无异常原因时不渲染说明占位符,待核验为0时隐藏该行;真实异常、正数待核验、未知主数值及已知小计保留 | 页面回归及构建;视觉待人工截图确认 |
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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 '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) {
|
||||
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
|
||||
}
|
||||
@@ -3,6 +3,14 @@ import { inventoryView, turnoverRoute, validateTurnover, yesterdayBeijing } from
|
||||
import type { TurnoverQuery } from './turnoverTypes'
|
||||
const valid: TurnoverQuery = { sourceAccount: 'primary', warehouseCode: 'W', groupBy: 'SKU', endDate: '2026-10-12', baseWindowDays: 30, focus: 'ALL', sortBy: 'PHYSICAL_DESC', current: 1, size: 20, includeOverview: true }
|
||||
describe('inventory view and turnover query contract', () => {
|
||||
it('normalizes repeated owner keys and validates the bounded selection', () => {
|
||||
expect(turnoverRoute({ customerCodes: ['B', 'A', 'A'] }).customerCodes).toEqual(['A', 'B'])
|
||||
expect(turnoverRoute({ customerCodes: 'A' }).customerCodes).toEqual(['A'])
|
||||
expect(turnoverRoute({}).customerCodes).toEqual([])
|
||||
for (const value of ['', [null], Array(101).fill('A')]) expect(() => turnoverRoute({ customerCodes: value })).toThrow()
|
||||
expect(() => turnoverRoute({ customerCode: 'A' })).toThrow()
|
||||
expect(() => inventoryView({ view: 'DETAIL', customerCodes: ['A'] })).toThrow()
|
||||
})
|
||||
it('parses only explicit booleans for the zero-inventory filter', () => {
|
||||
expect(turnoverRoute({ hideZeroInventory: 'false' })).toMatchObject({ hideZeroInventory: false })
|
||||
expect(turnoverRoute({ hideZeroInventory: 'true' })).toMatchObject({ hideZeroInventory: true })
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { TurnoverQuery, TurnoverSort } from './turnoverTypes'
|
||||
import { turnoverSortOptions } from './turnoverSorting'
|
||||
export const turnoverSorts: TurnoverSort[] = turnoverSortOptions.map(option => option.value)
|
||||
const detailOnly = ['salesWindowDays', 'salesWindowEndDate', 'attention', 'coverageSelection', 'coverageBucket', 'bucketRuleVersion']
|
||||
const turnoverOnly = ['hideZeroInventory', 'groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext']
|
||||
const turnoverOnly = ['customerCodes', 'hideZeroInventory', 'groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext']
|
||||
export function normalizeTurnoverOwners(value: unknown): string[] {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value) || value.length > 100 || value.some(code => typeof code !== 'string' || !code.trim() || code.length > 64)) throw new Error('请选择有效货主,最多100个')
|
||||
return [...new Set(value as string[])].sort()
|
||||
}
|
||||
function scalar(q: Record<string, unknown>, key: string) {
|
||||
const v = q[key]
|
||||
if (v == null) return undefined
|
||||
@@ -24,6 +29,7 @@ export function yesterdayBeijing(now = new Date()) {
|
||||
return new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
|
||||
}
|
||||
export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing()) {
|
||||
normalizeTurnoverOwners(q.customerCodes)
|
||||
if (q.hideZeroInventory != null && typeof q.hideZeroInventory !== 'boolean') throw new Error('隐藏零库存参数无效')
|
||||
if (!q.warehouseCode.trim()) throw new Error('请选择可用仓库')
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(q.endDate) || !Number.isFinite(Date.parse(q.endDate)) || new Date(q.endDate).toISOString().slice(0, 10) !== q.endDate || q.endDate > latestDate) throw new Error('截止日必须是已结束的北京时间日期')
|
||||
@@ -39,10 +45,14 @@ export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing
|
||||
}
|
||||
export function turnoverRoute(q: Record<string, unknown>): Partial<TurnoverQuery> {
|
||||
if (inventoryView(q) !== 'TURNOVER') throw new Error('当前为库存明细入口')
|
||||
const allowed = new Set(['view', 'warehouseCode', 'hideZeroInventory', 'sku', 'customerCode', 'endDate', 'baseWindowDays', 'groupBy', 'focus', 'coverageDaysThreshold', 'turnoverStatus', 'sortBy', 'snapshotContext', 'current', 'size'])
|
||||
const allowed = new Set(['view', 'warehouseCode', 'hideZeroInventory', 'sku', 'customerCodes', 'endDate', 'baseWindowDays', 'groupBy', 'focus', 'coverageDaysThreshold', 'turnoverStatus', 'sortBy', 'snapshotContext', 'current', 'size'])
|
||||
for (const key of Object.keys(q)) if (!allowed.has(key)) throw new Error(`库存周转不接受参数 ${key}`)
|
||||
const result: Record<string, string | number | boolean> = {}
|
||||
const result: Record<string, string | number | boolean | string[]> = {}
|
||||
for (const key of allowed) {
|
||||
if (key === 'customerCodes') {
|
||||
result[key] = normalizeTurnoverOwners(q[key] == null ? [] : Array.isArray(q[key]) ? q[key] : [q[key]])
|
||||
continue
|
||||
}
|
||||
const value = scalar(q, key)
|
||||
if (key === 'hideZeroInventory' && value != null) {
|
||||
if (!['true', 'false'].includes(value)) throw new Error('隐藏零库存参数无效')
|
||||
|
||||
@@ -8,7 +8,7 @@ export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'TRANSIT' | 'OUTBOUN
|
||||
export type TurnoverSort = `${TurnoverSortMetric}_${'ASC' | 'DESC'}` | 'NO_OUTBOUND_PHYSICAL_DESC'
|
||||
export interface TurnoverQuery {
|
||||
sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow
|
||||
groupBy: TurnoverGroup; hideZeroInventory?: boolean; customerCode?: string; sku?: string; focus: TurnoverFocus
|
||||
groupBy: TurnoverGroup; hideZeroInventory?: boolean; customerCodes?: string[]; sku?: string; focus: TurnoverFocus
|
||||
coverageDaysThreshold?: number; turnoverStatus?: 'INSUFFICIENT'; sortBy: TurnoverSort
|
||||
current: number; size: number; includeOverview: boolean; snapshotContext?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user