From 11e348a29f649f3c98e976d54a89d359bebf6207 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 10:29:43 +0800 Subject: [PATCH] feat(selfop): simplify turnover diagnostics and add zero stock filter --- .../components/InventoryTurnoverView.test.ts | 62 ++++++++++++++++++- .../components/InventoryTurnoverView.vue | 53 +++++++++++----- src/modules/self-operated/operation-matrix.md | 8 +++ .../self-operated/turnoverNavigation.test.ts | 7 +++ .../self-operated/turnoverNavigation.ts | 12 +++- src/modules/self-operated/turnoverTypes.ts | 2 +- 6 files changed, 122 insertions(+), 22 deletions(-) diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index 1c8bc754..4a963b12 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -1,13 +1,14 @@ import { defineComponent, h, reactive } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' +import { ElCheckbox } from 'element-plus' import InventoryTurnoverView from './InventoryTurnoverView.vue' import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from '../turnoverTypes' const mocks = vi.hoisted(() => ({ route: { query: {} as Record }, allowed: true, ownerDirectory: vi.fn(), overview: vi.fn(), turnover: vi.fn(), daily: vi.fn(), push: vi.fn() })) vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) })) vi.mock('../api/analytics', () => ({ analyticsApi: mocks })) 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 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?.()) } }) @@ -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 } } const wrappers: ReturnType[] = [] -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, patch: object, search = false) { const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises() if (search) { t.vm.$emit('search'); await flushPromises() } @@ -28,6 +29,53 @@ async function change(w: ReturnType, patch: object, search = false beforeEach(() => { vi.clearAllMocks(); mocks.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }]); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() }) afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) describe('inventory turnover page fixed snapshot behavior', () => { + it('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 () => { mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { const result = response(q) @@ -225,7 +273,7 @@ describe('inventory turnover page fixed snapshot behavior', () => { const w = page(); await flushPromises() expect(w.text()).not.toContain('[object Object]') 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('暂无可排名的库存数据') 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.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 () => { let finish!: (value: object) => void mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index dcc7091a..f7b13a46 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -32,7 +32,7 @@ class TurnoverContractError extends Error {} const permitted = computed(() => access.can('platform:self-operated-analytics:list')) const runtime = ref(null), data = ref(null) const error = ref(null), inputError = ref(''), notice = ref(''), loading = ref(false) -const filters = ref({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }) +const filters = ref({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', hideZeroInventory: true, sku: '', customerCode: '', 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() @@ -67,7 +67,6 @@ const columns = computed(() => [ ...([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 }, ...(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 }] : []), ].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined }))) function columnSortDirection(prop: string) { @@ -113,6 +112,11 @@ async function refreshRuntime() { } catch { if (request.current()) runtimeError.value = '采集状态更新失败,请到数据采集查看;库存查询结果仍按其观测时间展示。' } } function castRow(row: unknown) { return row as TurnoverRow } +function historicalMetricHint(row: TurnoverRow) { + return row.qualifiedSampleDays < row.requiredSampleDays + ? '历史库存记录不足,暂不能计算期间平均库存和周转次数' + : '暂不能计算;请在统计说明中查看历史库存记录及销量覆盖原因' +} function restoreThreshold(warehouse: string) { try { 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 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'], 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 overviewKey(q: TurnoverQuery) { return [scopeKey(q), q.customerCode, q.groupBy].join('|') } async function load(refresh = false) { 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 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 @@ -168,9 +172,13 @@ async function load(refresh = false) { finally { if (request.current()) loading.value = false } } 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() { 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) } function changeFilters(next: QueryModel) { @@ -190,7 +198,7 @@ function selectOwner(code: string) { 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; 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 || '' } 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() { try { 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 } 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() } - + +
+ 隐藏 0 库存 +
应用 @@ -317,7 +339,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }

正在更新筛选结果…

- + @@ -329,15 +351,14 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() } - - - + + - + @@ -377,7 +398,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() } .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-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[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; } diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index 59b5c42d..f7d960d8 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -155,3 +155,11 @@ | 库存周转/库存与时段指标分组 | 用户明确三项当前库存不受时段影响,要求按含义优化排版 | 保持六卡一排,左组“当前库存”含实物、可售、库存体积并说明库存快照口径;右组“所选时段分析”含无出库、出库体积、待评估并显示所选天数及截止日;出库估算及未知说明保留 | 定向回归与类型检查;视觉待人工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时隐藏该行;真实异常、正数待核验、未知主数值及已知小计保留 | 页面回归及构建;视觉待人工截图确认 | diff --git a/src/modules/self-operated/turnoverNavigation.test.ts b/src/modules/self-operated/turnoverNavigation.test.ts index e3d18e6d..7c5c01d0 100644 --- a/src/modules/self-operated/turnoverNavigation.test.ts +++ b/src/modules/self-operated/turnoverNavigation.test.ts @@ -3,6 +3,13 @@ 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('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', () => { 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') diff --git a/src/modules/self-operated/turnoverNavigation.ts b/src/modules/self-operated/turnoverNavigation.ts index 1d410ba7..94a51f33 100644 --- a/src/modules/self-operated/turnoverNavigation.ts +++ b/src/modules/self-operated/turnoverNavigation.ts @@ -2,7 +2,7 @@ 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 = ['groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext'] +const turnoverOnly = ['hideZeroInventory', 'groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext'] function scalar(q: Record, key: string) { const v = q[key] 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) } 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 (!/^\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个完整自然日') @@ -38,11 +39,16 @@ export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing } export function turnoverRoute(q: Record): Partial { 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}`) - const result: Record = {} + const result: Record = {} for (const key of allowed) { 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 } return result as Partial diff --git a/src/modules/self-operated/turnoverTypes.ts b/src/modules/self-operated/turnoverTypes.ts index 62ed43b7..c6a65e65 100644 --- a/src/modules/self-operated/turnoverTypes.ts +++ b/src/modules/self-operated/turnoverTypes.ts @@ -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; customerCode?: string; sku?: string; focus: TurnoverFocus + groupBy: TurnoverGroup; hideZeroInventory?: boolean; customerCode?: string; sku?: string; focus: TurnoverFocus coverageDaysThreshold?: number; turnoverStatus?: 'INSUFFICIENT'; sortBy: TurnoverSort current: number; size: number; includeOverview: boolean; snapshotContext?: string }