From 2975ba5a7f3cbe94ab28b33ed8f4a61534c053b1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 17:48:53 +0800 Subject: [PATCH 1/7] feat(selfop): show customer and SKU volume in inventory turnover --- .../components/InventoryTurnoverView.test.ts | 34 +++++++++++++++---- .../components/InventoryTurnoverView.vue | 15 +++++++- src/modules/self-operated/operation-matrix.md | 3 ++ .../turnoverPresentation.test.ts | 10 +++++- .../self-operated/turnoverPresentation.ts | 17 +++++++++- src/modules/self-operated/turnoverTypes.ts | 8 +++++ 6 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index ef778819..68b247f0 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -13,9 +13,11 @@ const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { r const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] } const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 } -const row: TurnoverRow = { customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, transitQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutboundInWindow: risk, slowCoverage: risk, unassessableSkuCount: 0 } +const volume = { value: 1.25, knownSubtotal: 1.25, missingDimensionQuantity: 0, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0, declaredDimensionQuantity: 0, reasonCodes: [] } +const volumes = [7, 15, 30].map(days => ({ days, startDate: '2026-09-13', endDateExclusive: '2026-10-13', volume })) +const row: TurnoverRow = { inventoryVolumeM3: volume, outboundVolumesM3: volumes, unitVolumeM3: 0.0125, dimensionSource: 'WAREHOUSE_MEASURED', customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, transitQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutboundInWindow: risk, slowCoverage: risk, unassessableSkuCount: 0 } 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 ? { physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 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, 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 } @@ -26,6 +28,24 @@ 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('shows customer and SKU volume, missing dimensions and estimates in the selected window', async () => { + mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { + const result = response(q) + result.rows[0]!.inventoryVolumeM3 = { ...volume, value: null, knownSubtotal: 0.125, missingDimensionQuantity: 12, missingDimensionSkuCount: 2 } + result.rows[0]!.outboundVolumesM3 = [{ days: q.baseWindowDays, startDate: '2026-10-10', endDateExclusive: '2026-10-13', volume: { ...volume, value: 2.5, declaredDimensionQuantity: 40 } }] + return result + }) + const w = page(); await flushPromises() + expect(w.text()).toContain('缺尺寸 2 SKU / 12 件') + expect(w.text()).toContain('已知小计 0.125 m³') + expect(w.text()).toContain('其中 40 件按商品尺寸估算') + expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(true) + await change(w, { groupBy: 'OWNER', rangePreset: 'LAST_3_DAYS' }) + expect(w.findComponent(table).props('columns').find((c: {prop: string}) => c.prop === 'outboundVolumesM3').label).toContain('3日') + expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(false) + expect(w.text()).toContain('缺尺寸 2 SKU / 12 件') + }) + 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)' }]) @@ -145,14 +165,14 @@ describe('inventory turnover page fixed snapshot behavior', () => { expect(w.text()).toContain('当前库存已过期') expect(w.text()).toContain('库存分页出现重复记录') expect(w.text()).toContain('LXCOL-TEST-STOCK') - expect(w.findAll('.turnover-cards article strong').map(n => n.text())).toEqual(['—', '100', '—', '100']) + expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article strong').map(n => n.text())).toEqual(['—', '100', '—', '100']) expect(w.findAll('.turnover-cards article small')[2]!.text()).toBe('已知小计 0 · 5402 SKU待核验') mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'SUCCEEDED' } }] }) mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { const result = response(q); result.context.analyticsContext.inventoryStatus = 'VALID'; return result }) await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises() expect(mocks.overview).toHaveBeenCalledTimes(2) expect(w.text()).not.toContain('当前库存已过期'); expect(w.text()).not.toContain('库存分页出现重复记录') - expect(w.findAll('.turnover-cards article strong')[0]!.text()).toBe('100') + expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article strong')[0]!.text()).toBe('100') }) it('does not show another warehouse failure and separates runtime refresh failure from report data', async () => { mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY' }, { warehouseCode: 'B', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-OTHER', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] }) @@ -162,7 +182,7 @@ describe('inventory turnover page fixed snapshot behavior', () => { await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises() expect(w.text()).toContain('采集状态更新失败') expect(w.text()).not.toContain('库存周转读取失败') - expect(w.findAll('.turnover-cards article strong')[0]!.text()).toBe('100') + expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article strong')[0]!.text()).toBe('100') }) it('renders quantity and window cells through the shared table slot contract', async () => { mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { @@ -176,7 +196,7 @@ describe('inventory turnover page fixed snapshot behavior', () => { expect(w.text()).toContain('数据不足/待观察') expect(w.text()).toContain('300') expect(w.text()).toContain('暂无可排名的库存数据') - expect(w.findAll('.turnover-cards article strong').map(n => n.text())).toEqual(['100', '100', '100', '100']) + expect(w.findAll('.turnover-cards:not(.turnover-volume-cards) article strong').map(n => n.text())).toEqual(['100', '100', '100', '100']) expect(w.findAllComponents({ name: 'AppDialog' }).every(d => d.attributes('append-to-body') !== undefined)).toBe(true) }) it('honors an explicit new route token in the same scope and reloads its overview', async () => { @@ -196,7 +216,7 @@ describe('inventory turnover page fixed snapshot behavior', () => { expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', includeOverview: false, sku: 'SAME', current: 1 }) w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', current: 2, includeOverview: false }) - expect(w.findAll('.turnover-cards article')).toHaveLength(4) + expect(w.findAll('.turnover-cards article')).toHaveLength(6) }) it('rejects unauthorized deep links and does not query without warehouses or permission', async () => { mocks.route.query = { warehouseCode: 'NO' }; const w = page(); await flushPromises() diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index fc8aa69e..d2e3364a 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -19,7 +19,7 @@ import type { RangePreset } from '../types' import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes' import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation' import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting' -import { totalText, turnoverTotalNote, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation' +import { totalText, turnoverTotalNote, 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' @@ -59,6 +59,9 @@ const columns = computed(() => [ { prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160 }, ...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []), { prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 }, + { prop: 'inventoryVolumeM3', label: '库存立方数(m³)', slotName: 'inventoryVolume', minWidth: 200 }, + { prop: 'outboundVolumesM3', label: `${applied.value?.baseWindowDays ?? 30}日出库立方数(m³,估算)`, slotName: 'outboundVolume', minWidth: 220 }, + ...(ownerMode.value ? [] : [{ prop: 'unitVolumeM3', label: '单件体积(m³)', slotName: 'unitVolume', minWidth: 155 }]), { 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 }, @@ -83,6 +86,10 @@ const cards = computed(() => data.value?.summary ? [ { name: '所选时段无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount }, ] : []) const incompleteDailyWindows = computed(() => data.value?.charts?.averageDaily.filter(w => w.dataStatus !== 'COMPLETE' || w.averageDailyOutboundQuantity == null) ?? []) +const volumeCards = computed(() => data.value?.summary ? [ + { name: '库存立方数(m³)', value: data.value.summary.inventoryVolumeM3, outbound: false }, + { name: `所选 ${applied.value?.baseWindowDays ?? 30} 日出库立方数(m³,估算)`, value: data.value.summary.outboundVolumesM3.find(w => w.days === applied.value?.baseWindowDays)?.volume, outbound: true }, +] : []) const runtimeError = ref('') const inventoryRuntime = computed(() => runtime.value?.warehouses.find(w => w.warehouseCode === applied.value?.warehouseCode && w.dataFamily === 'INVENTORY')) const collectionNotice = computed(() => !data.value || runtimeError.value ? '' : inventoryCollectionNotice(inventoryRuntime.value)) @@ -246,6 +253,8 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }

{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。查看数据采集

{{ runtimeError }}

{{ card.name }}{{ turnoverNumber(card.value.value) }}{{ turnoverTotalNote(card.value) }}
+
{{ card.name }}{{ turnoverNumber(card.value?.value, 6) }}{{ volumeNote(card.value, card.outbound) }}
+

体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。

@@ -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 } From 50b06dca1082ebc5c38455b35d9991efc4b2b41d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 11:14:35 +0800 Subject: [PATCH 4/7] feat(selfop): multi-owner turnover filters, CSV export and percentage --- .../self-operated/api/analytics.test.ts | 5 ++ src/modules/self-operated/api/analytics.ts | 5 +- .../components/InventoryTurnoverView.test.ts | 70 +++++++++++++++++-- .../components/InventoryTurnoverView.vue | 64 ++++++++++++----- src/modules/self-operated/operation-matrix.md | 2 + src/modules/self-operated/turnoverExport.ts | 54 ++++++++++++++ .../self-operated/turnoverNavigation.test.ts | 8 +++ .../self-operated/turnoverNavigation.ts | 16 ++++- src/modules/self-operated/turnoverTypes.ts | 2 +- 9 files changed, 196 insertions(+), 30 deletions(-) create mode 100644 src/modules/self-operated/turnoverExport.ts diff --git a/src/modules/self-operated/api/analytics.test.ts b/src/modules/self-operated/api/analytics.test.ts index f3efdeef..a54fc1e8 100644 --- a/src/modules/self-operated/api/analytics.test.ts +++ b/src/modules/self-operated/api/analytics.test.ts @@ -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) diff --git a/src/modules/self-operated/api/analytics.ts b/src/modules/self-operated/api/analytics.ts index 9f9a1c06..9fe72980 100644 --- a/src/modules/self-operated/api/analytics.ts +++ b/src/modules/self-operated/api/analytics.ts @@ -9,7 +9,10 @@ async function get(path: string, params: object, signal?: AbortSignal): Promi } export const analyticsApi = { ownerDirectory: (query: { sourceAccount: string; warehouseCode: string } & Partial, signal?: AbortSignal) => get('owner-directory', query, signal), - turnover: (query: TurnoverQuery, signal?: AbortSignal) => get('inventory-turnover', query, signal), + async turnover(query: TurnoverQuery, signal?: AbortSignal): Promise { + const response = await http.get, ApiEnvelope>(`${base}/inventory-turnover`, { params: query, paramsSerializer: { indexes: null }, signal }) + return unwrapApiResponse(response) + }, dashboard: (query: DashboardQuery, signal?: AbortSignal) => get('dashboard', query, signal), owners: (query: OwnerQuery, signal?: AbortSignal) => get>('owners', query, signal), ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get('owner-overview', query, signal), diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index 4a963b12..db1afa34 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -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 }, 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, 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') diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index f7b13a46..812412ac 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -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(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', hideZeroInventory: true, 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: '', 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(null), trendError = ref(null) const evidence = ref(null), evidenceOpen = ref(false) const applied = ref(null) +const exporting = ref(false), exportProgress = ref(0), exportFeedback = ref('') +let exportController: AbortController | undefined +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(() => [ { 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(() => [ ...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]), @@ -66,6 +91,7 @@ const columns = computed(() => [ { 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() }) -