feat(selfop): simplify turnover diagnostics and add zero stock filter

This commit is contained in:
2026-10-15 10:29:43 +08:00
parent 08fac66fe1
commit 11e348a29f
6 changed files with 122 additions and 22 deletions
@@ -1,13 +1,14 @@
import { defineComponent, h, reactive } from 'vue' import { defineComponent, h, reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { ElCheckbox } from 'element-plus'
import InventoryTurnoverView from './InventoryTurnoverView.vue' import InventoryTurnoverView from './InventoryTurnoverView.vue'
import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from '../turnoverTypes' import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from '../turnoverTypes'
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() })) 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('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks })) vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup: () => () => h('div') }) 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 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: () => emit('click') }, slots.default?.()) } }) const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
@@ -20,7 +21,7 @@ function response(q: TurnoverQuery): TurnoverResponse {
return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { inventoryVolumeM3: volume, outboundVolumesM3: volumes, physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 } return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { inventoryVolumeM3: volume, outboundVolumesM3: volumes, physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
} }
const wrappers: ReturnType<typeof mount>[] = [] const wrappers: ReturnType<typeof mount>[] = []
function page() { const w = mount(InventoryTurnoverView, { global: { stubs: { AdminPage: shell, DataTable: table, QueryToolbar: toolbar, AnalyticsChart: true, ChartDetailsDialog: true, AppDialog: true, ApiErrorMeta: true, 'el-button': button, 'el-tag': shell, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('div', [slots.reference?.(), slots.default?.()]) } }) } } }); wrappers.push(w); return w } function page() { const w = mount(InventoryTurnoverView, { global: { components: { ElCheckbox }, stubs: { AdminPage: shell, DataTable: table, QueryToolbar: toolbar, AnalyticsChart: true, ChartDetailsDialog: true, AppDialog: defineComponent({ name: 'AppDialog', props: ['modelValue', 'title'], setup(p, { slots }) { return () => h('div', p.modelValue ? slots.default?.() : []) } }), ApiErrorMeta: true, 'el-button': button, 'el-tag': shell, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('div', [slots.reference?.(), slots.default?.()]) } }) } } }); wrappers.push(w); return w }
async function change(w: ReturnType<typeof mount>, patch: object, search = false) { 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() const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises()
if (search) { t.vm.$emit('search'); await flushPromises() } if (search) { t.vm.$emit('search'); await flushPromises() }
@@ -28,6 +29,53 @@ 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() }) 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())) afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
describe('inventory turnover page fixed snapshot behavior', () => { describe('inventory turnover page fixed snapshot behavior', () => {
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"]')
expect((checkbox().element as HTMLInputElement).checked).toBe(true)
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: true })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
await checkbox().setValue(false); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: false, current: 1, snapshotContext: 'token-A' })
await change(w, { groupBy: 'OWNER' })
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: false, groupBy: 'OWNER' })
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
expect((checkbox().element as HTMLInputElement).checked).toBe(true)
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: true, current: 1 })
})
it('accepts an explicit show-zero route and restores the default on an unqualified route', async () => {
mocks.route.query = { endDate: '2026-10-12', hideZeroInventory: 'false' }
const w = page(); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: false })
mocks.route.query = { endDate: '2026-10-12' }; await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ hideZeroInventory: true })
})
it('keeps sample diagnostics in statistics help and missing metrics distinct from zero', async () => {
const w = page(); await flushPromises()
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(['—', '—'])
// 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
await select.trigger('change')
const calls = mocks.turnover.mock.calls.length
await w.findAll('button').find(b => b.text() === '查看记录明细')!.trigger('click')
expect(w.text()).toContain('历史库存记录 0/30天')
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.rows[0] = { ...result.rows[0]!, averagePhysicalQuantity: 0, quantityTurnoverCount: 0, qualifiedSampleDays: 30 }
return result
})
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect((w.get('select[aria-label="选择历史库存记录"]').element as HTMLSelectElement).selectedIndex).toBe(0)
expect(w.find('span[title="历史库存记录不足,暂不能计算期间平均库存和周转次数"]').exists()).toBe(false)
expect(w.text()).toContain('0 次/30日')
})
it('defaults to volume ranking, switches locally and drills into the same owner snapshot', async () => { it('defaults to volume ranking, switches locally and drills into the same owner snapshot', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q) const result = response(q)
@@ -225,7 +273,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
const w = page(); await flushPromises() const w = page(); await flushPromises()
expect(w.text()).not.toContain('[object Object]') expect(w.text()).not.toContain('[object Object]')
expect(w.text()).not.toContain('UNASSESSABLE') expect(w.text()).not.toContain('UNASSESSABLE')
expect(w.text()).toContain('数据不足/待观察') expect(w.text()).not.toContain('日样本 0/30')
expect(w.text()).toContain('300') expect(w.text()).toContain('300')
expect(w.text()).toContain('暂无可排名的库存数据') expect(w.text()).toContain('暂无可排名的库存数据')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['100', '100', '100', '100']) expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
@@ -292,6 +340,14 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(w.findComponent(table).props('data')).toHaveLength(1) expect(w.findComponent(table).props('data')).toHaveLength(1)
expect(w.text()).toContain('保留') expect(w.text()).toContain('保留')
}) })
it('does not reserve an empty overview row or claim previous results on initial failure', async () => {
mocks.turnover.mockRejectedValue(new Error('库存周转不接受其他视图的查询参数'))
const w = page(); await flushPromises()
expect(w.find('.turnover-context').exists()).toBe(false)
expect(w.get('.turnover-filter-feedback').text()).toContain('库存周转读取失败')
expect(w.text()).not.toContain('当前保留上次结果')
expect(w.get('.turnover-zero-filter input').attributes('disabled')).toBeUndefined()
})
it('passes exact SKU identity and fixed versions into trend and cancels a closed request', async () => { it('passes exact SKU identity and fixed versions into trend and cancels a closed request', async () => {
let finish!: (value: object) => void let finish!: (value: object) => void
mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
@@ -32,7 +32,7 @@ class TurnoverContractError extends Error {}
const permitted = computed(() => access.can('platform:self-operated-analytics:list')) const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
const runtime = ref<Overview | null>(null), data = ref<TurnoverResponse | null>(null) 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 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', 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: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true) const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true)
const rightChart = ref<'volume' | 'daily'>('volume') const rightChart = ref<'volume' | 'daily'>('volume')
const fence = new RequestFence(), runtimeFence = new RequestFence(), trendFence = new RequestFence() const fence = new RequestFence(), runtimeFence = new RequestFence(), trendFence = new RequestFence()
@@ -67,7 +67,6 @@ const columns = computed<DataTableColumn[]>(() => [
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })), ...([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: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slotName: 'daily', minWidth: 110 },
...(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 }]), ...(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 }]),
{ prop: 'attentionReasons', label: '状态与样本', slotName: 'status', minWidth: 180 },
...(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 }] : []), ...(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 }))) ].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined })))
function columnSortDirection(prop: string) { function columnSortDirection(prop: string) {
@@ -113,6 +112,11 @@ async function refreshRuntime() {
} catch { if (request.current()) runtimeError.value = '采集状态更新失败,请到数据采集查看;库存查询结果仍按其观测时间展示。' } } catch { if (request.current()) runtimeError.value = '采集状态更新失败,请到数据采集查看;库存查询结果仍按其观测时间展示。' }
} }
function castRow(row: unknown) { return row as TurnoverRow } function castRow(row: unknown) { return row as TurnoverRow }
function historicalMetricHint(row: TurnoverRow) {
return row.qualifiedSampleDays < row.requiredSampleDays
? '历史库存记录不足,暂不能计算期间平均库存和周转次数'
: '暂不能计算;请在统计说明中查看历史库存记录及销量覆盖原因'
}
function restoreThreshold(warehouse: string) { function restoreThreshold(warehouse: string) {
try { try {
const saved = sessionStorage.getItem(`selfop-turnover-threshold:${warehouse}`) const saved = sessionStorage.getItem(`selfop-turnover-threshold:${warehouse}`)
@@ -130,13 +134,13 @@ function query(refresh = false): TurnoverQuery {
const anchor = !refresh && data.value ? beijingDate(Date.parse(data.value.context.queryAsOf)) : beijingDate(Date.now()) 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 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 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'], 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'], 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 }
} }
function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') } 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), q.customerCode, q.groupBy].join('|') }
async function load(refresh = false) { async function load(refresh = false) {
if (!permitted.value || !active) return if (!permitted.value || !active) return
fence.cancel(); inputError.value = ''; evidenceOpen.value = false; trendOpen.value = false fence.cancel(); inputError.value = ''; evidenceOpen.value = false; evidence.value = null; trendOpen.value = false
let q: TurnoverQuery let q: TurnoverQuery
try { q = query(refresh) } catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return } try { q = query(refresh) } catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return }
if (applied.value && applied.value.warehouseCode !== q.warehouseCode) data.value = null if (applied.value && applied.value.warehouseCode !== q.warehouseCode) data.value = null
@@ -168,9 +172,13 @@ async function load(refresh = false) {
finally { if (request.current()) loading.value = false } finally { if (request.current()) loading.value = false }
} }
async function search() { page.value = 1; await load() } async function search() { page.value = 1; await load() }
function toggleZeroInventory(checked: string | number | boolean) {
filters.value = { ...filters.value, hideZeroInventory: checked === true }
void search()
}
function reset() { function reset() {
const warehouseCode = filters.value.warehouseCode || applied.value?.warehouseCode || warehouses.value[0]?.value || '' 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', 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: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }
ownerReturn = null; page.value = 1; void load(true) ownerReturn = null; page.value = 1; void load(true)
} }
function changeFilters(next: QueryModel) { function changeFilters(next: QueryModel) {
@@ -190,7 +198,7 @@ function selectOwner(code: string) {
function returnOwners() { function returnOwners() {
if (!ownerReturn) return if (!ownerReturn) return
if (Date.parse(ownerReturn.result.context.queryExpiresAt) <= Date.now()) { inputError.value = '原货主汇总版本已过期,请整体刷新'; return } if (Date.parse(ownerReturn.result.context.queryExpiresAt) <= Date.now()) { inputError.value = '原货主汇总版本已过期,请整体刷新'; return }
fence.cancel(); trendOpen.value = false; evidenceOpen.value = false; loading.value = false; error.value = null 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: '', customerCode: saved.query.customerCode || '', coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' }
applied.value = saved.query; page.value = saved.query.current; data.value = saved.result; ownerReturn = null applied.value = saved.query; page.value = saved.query.current; data.value = saved.result; ownerReturn = null
} }
@@ -213,7 +221,7 @@ watch(trendOpen, open => { if (!open) { trendFence.cancel(); daily.value = null;
function readRoute() { function readRoute() {
try { try {
const initial = turnoverRoute(route.query); linkedSnapshot = initial.snapshotContext const initial = turnoverRoute(route.query); linkedSnapshot = initial.snapshotContext
filters.value = { ...filters.value, ...initial, ...(initial.endDate ? { rangePreset: 'CUSTOM', customRange: [new Date(Date.parse(initial.endDate) - ((initial.baseWindowDays ?? 30) - 1) * 86400000).toISOString().slice(0, 10), initial.endDate] } : {}) }; page.value = initial.current ?? 1; size.value = initial.size ?? 20 filters.value = { ...filters.value, hideZeroInventory: true, ...initial, ...(initial.endDate ? { rangePreset: 'CUSTOM', customRange: [new Date(Date.parse(initial.endDate) - ((initial.baseWindowDays ?? 30) - 1) * 86400000).toISOString().slice(0, 10), initial.endDate] } : {}) }; page.value = initial.current ?? 1; size.value = initial.size ?? 20
return true return true
} catch (cause) { fence.cancel(); data.value = null; inputError.value = (cause as Error).message; return false } } catch (cause) { fence.cancel(); data.value = null; inputError.value = (cause as Error).message; return false }
} }
@@ -250,13 +258,24 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
</button> </button>
</template> </template>
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></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></div></el-popover></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>
<p>历史库存记录用于计算期间平均库存和周转次数,与已采集的销量天数不同;记录不足不代表销量采集失败。</p>
<template v-if="data?.rows.length">
<label>查看本页历史库存记录
<select v-model="evidence" aria-label="选择历史库存记录" :disabled="loading">
<option :value="null">请选择商品或货主</option>
<option v-for="row in data.rows" :key="JSON.stringify([row.customerCode, row.sku])" :value="row">{{ row.customerName || row.customerCode }} · {{ row.sku || '货主汇总' }}</option>
</select>
</label>
<el-button link :disabled="!evidence || loading" @click="evidenceOpen = true">查看记录明细</el-button>
</template>
</div></el-popover></template>
<template #filters> <template #filters>
<p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p> <p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p>
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" /> <QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
</template> </template>
<template #before-results> <template #before-results>
<div class="turnover-context"> <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="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> <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> </div>
@@ -303,6 +322,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
</section> </section>
</div> </div>
</template> </template>
<div class="turnover-list-filters">
<nav class="turnover-focus" aria-label="库存关注快捷筛选"> <nav class="turnover-focus" aria-label="库存关注快捷筛选">
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button> <button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_IN_WINDOW'" @click="quickFocus('NO_OUTBOUND_IN_WINDOW', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">所选时段无出库</button> <button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_IN_WINDOW'" @click="quickFocus('NO_OUTBOUND_IN_WINDOW', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">所选时段无出库</button>
@@ -310,6 +330,8 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</button> <button type="button" :aria-pressed="applied?.focus === 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</button>
<button type="button" :aria-pressed="applied?.focus === 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</button> <button type="button" :aria-pressed="applied?.focus === 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</button>
</nav> </nav>
<el-checkbox class="turnover-zero-filter" border aria-label="隐藏 0 库存" title="按当前实物件数筛选;库存未知仍保留" :model-value="filters.hideZeroInventory !== false" :disabled="loading" @change="toggleZeroInventory">隐藏 0 库存</el-checkbox>
</div>
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold"> <div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label> <label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
<el-button @click="search()">应用</el-button> <el-button @click="search()">应用</el-button>
@@ -317,7 +339,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
</div> </div>
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</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="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
<p v-else-if="error" class="turnover-filter-feedback" role="alert">筛选未更新,当前保留上次结果。{{ error.message }}</p> <p v-else-if="error" class="turnover-filter-feedback" role="alert">{{ data ? '筛选未更新,当前保留上次结果。' : '库存周转读取失败。' }}{{ error.message }}</p>
</template> </template>
<template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template> <template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template>
@@ -329,15 +351,14 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<template #unitVolume="{ row }">{{ volumeText(castRow(row).unitVolumeM3, 9) }}<small>{{ dimensionSourceText(castRow(row).dimensionSource) }}</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 #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template> <template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
<template #status="{ row }"><small>{{ reasonsText(castRow(row).attentionReasons) }}</small><span v-if="ownerMode">待评估 {{ castRow(row).unassessableSkuCount }} SKU</span><el-button link @click="evidence = castRow(row); evidenceOpen = true">日样本 {{ castRow(row).qualifiedSampleDays }}/{{ castRow(row).requiredSampleDays }}</el-button></template>
<template #noOutbound="{ row }">{{ turnoverNumber(castRow(row).noOutboundInWindow.matchedSkuCount) }} SKU / {{ turnoverNumber(castRow(row).noOutboundInWindow.knownPhysicalQuantity) }} 件<small>待判断 {{ castRow(row).noOutboundInWindow.unknownSkuCount }} SKU</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> <template #slow="{ row }">{{ applied?.coverageDaysThreshold ? `${turnoverNumber(castRow(row).slowCoverage.matchedSkuCount)} SKU / ${castRow(row).slowCoverage.unknownSkuCount} SKU` : '未设查询阈值' }}</template>
<template #averageStock="{ row }">{{ turnoverNumber(castRow(row).averagePhysicalQuantity) }}</template> <template #averageStock="{ row }"><span :title="castRow(row).averagePhysicalQuantity == null ? historicalMetricHint(castRow(row)) : undefined">{{ turnoverNumber(castRow(row).averagePhysicalQuantity) }}</span></template>
<template #turnover="{ row }">{{ turnoverNumber(castRow(row).quantityTurnoverCount) }} 次/{{ applied?.baseWindowDays }}日</template> <template #turnover="{ row }"><span :title="castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : undefined">{{ castRow(row).quantityTurnoverCount == null ? '—' : `${turnoverNumber(castRow(row).quantityTurnoverCount)} 次/${applied?.baseWindowDays}日` }}</span></template>
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template> <template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
</DataTable> </DataTable>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog> <AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。</p></template></AppDialog> <AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="历史库存记录与销量覆盖" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerName || evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};历史库存记录 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }}天;积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺少库存记录的日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。</p></template></AppDialog>
</template> </template>
</AdminPage> </AdminPage>
</template> </template>
@@ -377,7 +398,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
.turnover-chart-empty strong { color: var(--el-text-color-regular); } .turnover-chart-empty strong { color: var(--el-text-color-regular); }
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 0; } .turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 0; }
.turnover-window-gaps { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); } .turnover-window-gaps { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 4px 0 0; border-bottom: 1px solid var(--el-border-color-light); } .turnover-list-filters { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); border-bottom: 1px solid var(--el-border-color-light); }
.turnover-zero-filter { flex: 0 0 auto; margin: var(--admin-space-1) 0; }
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 0; }
.turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; } .turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; }
.turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; } .turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; } .turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
@@ -155,3 +155,11 @@
| 库存周转/库存与时段指标分组 | 用户明确三项当前库存不受时段影响,要求按含义优化排版 | 保持六卡一排,左组“当前库存”含实物、可售、库存体积并说明库存快照口径;右组“所选时段分析”含无出库、出库体积、待评估并显示所选天数及截止日;出库估算及未知说明保留 | 定向回归与类型检查;视觉待人工1920×1080、100%截图确认 | | 库存周转/库存与时段指标分组 | 用户明确三项当前库存不受时段影响,要求按含义优化排版 | 保持六卡一排,左组“当前库存”含实物、可售、库存体积并说明库存快照口径;右组“所选时段分析”含无出库、出库体积、待评估并显示所选天数及截止日;出库估算及未知说明保留 | 定向回归与类型检查;视觉待人工1920×1080、100%截图确认 |
| 库存周转/右侧图表切换 | 本轮用户截图及确认方案(2026-10-14) | 保留左右两栏,右侧默认货主库存立方数 Top10,标题处切换日均出库对比;复用现有图表明细框和货主下钻 | 条末显示两位小数及 m³;缺尺寸或数量未知不排名并提示数量 | 待人工 1920×1080、100% 截图确认 | | 库存周转/右侧图表切换 | 本轮用户截图及确认方案(2026-10-14) | 保留左右两栏,右侧默认货主库存立方数 Top10,标题处切换日均出库对比;复用现有图表明细框和货主下钻 | 条末显示两位小数及 m³;缺尺寸或数量未知不排名并提示数量 | 待人工 1920×1080、100% 截图确认 |
| 库存周转/隐藏状态与样本列 | 用户截图及本轮确认 | 主表移除状态与样本列;期间平均实物、期间件数周转未知只显示—并悬停说明;现有统计说明内选择本页商品/货主,复用1040px居中明细框查看历史库存记录与销量覆盖;冻结件数、筛选及后台校验保留 | 定向交互与零值回归;视觉待人工1920×1080、100%截图确认 |
| 库存周转/隐藏零库存 | 2026-10-15用户要求默认选中 | 查询区复选框“隐藏 0 库存”,变化即时查询并回第一页;按SKU/货主实物明确为零筛除,未知保留;服务端全量筛选后分页,概览图表不变,重置恢复选中 | 默认/取消/重置/路由与SQL分页回归;视觉待人工1920×1080、100%截图确认 |
| 库存周转/零库存筛选样式 | 用户截图 `codex-clipboard-c21dd284-0aed-4db0-840b-172260c2e3fb.png` | 将原生复选框从查询按钮组移到列表快捷筛选右侧,使用 Element 带边框复选框并垂直居中;无概览、无货主条件时不渲染空白概览行;首次读取失败不提示保留上次结果 | 保留默认选中、即时查询及禁用行为;视觉待人工1920×1080、100%截图确认 |
| 经营总览/指标卡说明 | 用户反馈“完整”下方破折号含义不清 | 无异常原因时不渲染说明占位符,待核验为0时隐藏该行;真实异常、正数待核验、未知主数值及已知小计保留 | 页面回归及构建;视觉待人工截图确认 |
@@ -3,6 +3,13 @@ import { inventoryView, turnoverRoute, validateTurnover, yesterdayBeijing } from
import type { TurnoverQuery } from './turnoverTypes' 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 } 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', () => { describe('inventory view and turnover query contract', () => {
it('parses only explicit booleans for the zero-inventory filter', () => {
expect(turnoverRoute({ hideZeroInventory: 'false' })).toMatchObject({ hideZeroInventory: false })
expect(turnoverRoute({ hideZeroInventory: 'true' })).toMatchObject({ hideZeroInventory: true })
for (const value of ['0', '1', 'yes', ['false']]) expect(() => turnoverRoute({ hideZeroInventory: value })).toThrow()
expect(() => inventoryView({ view: 'DETAIL', hideZeroInventory: 'true' })).toThrow()
})
it('defaults to turnover but preserves strict dashboard and legacy detail links', () => { it('defaults to turnover but preserves strict dashboard and legacy detail links', () => {
expect(inventoryView({ warehouseCode: 'W', sku: 'X' })).toBe('TURNOVER') expect(inventoryView({ warehouseCode: 'W', sku: 'X' })).toBe('TURNOVER')
for (const q of [{ from: 'overview' }, { drilldownFrom: 'DASHBOARD' }, { salesWindowDays: '10' }, { attention: 'FROZEN_STOCK' }, { view: 'DETAIL' }]) expect(inventoryView(q)).toBe('DETAIL') for (const q of [{ from: 'overview' }, { drilldownFrom: 'DASHBOARD' }, { salesWindowDays: '10' }, { attention: 'FROZEN_STOCK' }, { view: 'DETAIL' }]) expect(inventoryView(q)).toBe('DETAIL')
@@ -2,7 +2,7 @@ import type { TurnoverQuery, TurnoverSort } from './turnoverTypes'
import { turnoverSortOptions } from './turnoverSorting' import { turnoverSortOptions } from './turnoverSorting'
export const turnoverSorts: TurnoverSort[] = turnoverSortOptions.map(option => option.value) export const turnoverSorts: TurnoverSort[] = turnoverSortOptions.map(option => option.value)
const detailOnly = ['salesWindowDays', 'salesWindowEndDate', 'attention', 'coverageSelection', 'coverageBucket', 'bucketRuleVersion'] const detailOnly = ['salesWindowDays', 'salesWindowEndDate', 'attention', 'coverageSelection', 'coverageBucket', 'bucketRuleVersion']
const turnoverOnly = ['groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext'] const turnoverOnly = ['hideZeroInventory', 'groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext']
function scalar(q: Record<string, unknown>, key: string) { function scalar(q: Record<string, unknown>, key: string) {
const v = q[key] const v = q[key]
if (v == null) return undefined if (v == null) return undefined
@@ -24,6 +24,7 @@ export function yesterdayBeijing(now = new Date()) {
return new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) return new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
} }
export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing()) { export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing()) {
if (q.hideZeroInventory != null && typeof q.hideZeroInventory !== 'boolean') throw new Error('隐藏零库存参数无效')
if (!q.warehouseCode.trim()) 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('截止日必须是已结束的北京时间日期') 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('截止日必须是已结束的北京时间日期')
if (!Number.isInteger(q.baseWindowDays) || q.baseWindowDays < 1 || q.baseWindowDays > 366) throw new Error('统计区间须为1至366个完整自然日') if (!Number.isInteger(q.baseWindowDays) || q.baseWindowDays < 1 || q.baseWindowDays > 366) throw new Error('统计区间须为1至366个完整自然日')
@@ -38,11 +39,16 @@ export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing
} }
export function turnoverRoute(q: Record<string, unknown>): Partial<TurnoverQuery> { export function turnoverRoute(q: Record<string, unknown>): Partial<TurnoverQuery> {
if (inventoryView(q) !== 'TURNOVER') throw new Error('当前为库存明细入口') if (inventoryView(q) !== 'TURNOVER') throw new Error('当前为库存明细入口')
const allowed = new Set(['view', 'warehouseCode', 'sku', 'customerCode', 'endDate', 'baseWindowDays', 'groupBy', 'focus', 'coverageDaysThreshold', 'turnoverStatus', 'sortBy', 'snapshotContext', 'current', 'size']) const allowed = new Set(['view', 'warehouseCode', 'hideZeroInventory', 'sku', 'customerCode', '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}`) for (const key of Object.keys(q)) if (!allowed.has(key)) throw new Error(`库存周转不接受参数 ${key}`)
const result: Record<string, string | number> = {} const result: Record<string, string | number | boolean> = {}
for (const key of allowed) { for (const key of allowed) {
const value = scalar(q, key) const value = scalar(q, key)
if (key === 'hideZeroInventory' && value != null) {
if (!['true', 'false'].includes(value)) throw new Error('隐藏零库存参数无效')
result[key] = value === 'true'
continue
}
if (value != null && key !== 'view') result[key] = ['baseWindowDays', 'coverageDaysThreshold', 'current', 'size'].includes(key) ? Number(value) : value if (value != null && key !== 'view') result[key] = ['baseWindowDays', 'coverageDaysThreshold', 'current', 'size'].includes(key) ? Number(value) : value
} }
return result as Partial<TurnoverQuery> return result as Partial<TurnoverQuery>
+1 -1
View File
@@ -8,7 +8,7 @@ export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'TRANSIT' | 'OUTBOUN
export type TurnoverSort = `${TurnoverSortMetric}_${'ASC' | 'DESC'}` | 'NO_OUTBOUND_PHYSICAL_DESC' export type TurnoverSort = `${TurnoverSortMetric}_${'ASC' | 'DESC'}` | 'NO_OUTBOUND_PHYSICAL_DESC'
export interface TurnoverQuery { export interface TurnoverQuery {
sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow
groupBy: TurnoverGroup; customerCode?: string; sku?: string; focus: TurnoverFocus groupBy: TurnoverGroup; hideZeroInventory?: boolean; customerCode?: string; sku?: string; focus: TurnoverFocus
coverageDaysThreshold?: number; turnoverStatus?: 'INSUFFICIENT'; sortBy: TurnoverSort coverageDaysThreshold?: number; turnoverStatus?: 'INSUFFICIENT'; sortBy: TurnoverSort
current: number; size: number; includeOverview: boolean; snapshotContext?: string current: number; size: number; includeOverview: boolean; snapshotContext?: string
} }