diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index 460753d7..b1588dab 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -80,8 +80,12 @@ describe('inventory turnover page fixed snapshot behavior', () => { return result }) const w = page(); await flushPromises() - expect(w.text()).toContain('1,234.57 件/日') - expect(w.text()).toContain('2,100 件/日') + const chart = w.findComponent({ name: 'AnalyticsChart' }).props('option') as { series: { data: (number | null)[]; label: { show: boolean; formatter: (params: { value: number | null }) => string } }[] } + expect(chart.series[0]!.label.show).toBe(true) + expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[0]! })).toBe('1,234.57') + expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[1]! })).toBe('2,100') + expect(chart.series[0]!.data[2]).toBeNull() + expect(w.find('.turnover-window-summary').exists()).toBe(false) expect(w.text()).toContain('完整 18 / 30 日') expect(w.text()).toContain('已发布销量版本计算') expect(w.text()).toContain('新采集数据待重算') @@ -96,15 +100,19 @@ describe('inventory turnover page fixed snapshot behavior', () => { await select('消化较慢') expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_GT', coverageDaysThreshold: 30, sortBy: 'COVERAGE_DESC', current: 1 }) expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较慢') - await w.get('input[aria-label="快慢阈值天数"]').setValue('60') + await w.get('input[aria-label="可售天数阈值(天)"]').setValue('60') await select('消化较快') expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_LE', coverageDaysThreshold: 60, sortBy: 'COVERAGE_ASC' }) const calls = mocks.turnover.mock.calls.length - await w.get('input[aria-label="快慢阈值天数"]').setValue('0') + await w.get('input[aria-label="可售天数阈值(天)"]').setValue('0') await select('消化较慢') expect(mocks.turnover).toHaveBeenCalledTimes(calls) expect(w.get('.turnover-filter-feedback').text()).toContain('正整数') expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较快') + await select('全部') + expect(mocks.turnover.mock.calls.at(-1)![0].coverageDaysThreshold).toBeUndefined() + expect(w.find('.turnover-threshold').exists()).toBe(false) + expect(w.findComponent(toolbar).props('fields').some((field: { key: string }) => field.key === 'coverageDaysThreshold')).toBe(false) }) it('queries the remaining focus tabs and uses owner-compatible sorting', async () => { const w = page(); await flushPromises() diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index 69543e25..cbd43ca3 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -47,7 +47,6 @@ const fields = computed(() => [ { key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } }, ...(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: 'coverageDaysThreshold', label: '快慢阈值(天)', type: 'input', width: 145, placeholder: '正整数,可选' }, { 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 !== 'NO_OUTBOUND_PHYSICAL_DESC') }, ]) @@ -79,6 +78,7 @@ const cards = computed(() => data.value?.summary ? [ { name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity }, { 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 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)) @@ -107,7 +107,7 @@ 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: 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'], 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('|') } @@ -244,7 +244,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
{{ chartsOpen ? '收起图表' : '展开图表' }}图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变

货主实物件数 Top10

货主实物件数
{{ owner.customerName || owner.customerCode }}{{ turnoverNumber(owner.physicalQuantity) }}
暂无可排名的库存数据库存数量尚未完整确认,确认后展示货主排名

{{ data.charts.unknownOwnerCount }} 个货主未能完整计算

-

日均出库对比(含所选时段)

{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}

日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。

暂无完整窗口的日均出库数据,覆盖情况如下。

{{ window.days }}日日均{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}完整 {{ window.completeDayCount }} / {{ window.days }} 日
+

日均出库对比(含所选时段)

{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}

日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。

暂无完整窗口的日均出库数据,覆盖情况如下。

{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)
- + 应用 - 预计可售天数大于阈值为较慢,不超过阈值为较快;首次使用默认30天,可修改。数据不足的商品请查看“待评估”。 + 按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。

正在更新筛选结果…

@@ -312,11 +312,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() } .turnover-chart-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 8px; margin-top: 12px; } .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-summary { flex-shrink: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 12px 0 0; } -.turnover-window-summary > div { background: var(--el-fill-color-lighter); padding: 8px 12px; border-radius: 6px; } -.turnover-window-summary span, .turnover-window-summary strong { display: block; } -.turnover-window-summary span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin-bottom: 4px; } -.turnover-window-summary strong { font-variant-numeric: tabular-nums; } +.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-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; } diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index 5632bc60..85589ee4 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -125,3 +125,7 @@ | 库存明细/观察期独立提示 | 用户确认完整销量窗口不再被商品观察期阻断 | 完整可信窗口正常显示出库、日均及结合当前有效可售量计算的天数;观察不足仅在说明列提示,不单独归入待评估;完整零出库显示0及无出库原因,真实缺口仍未知;总览分布与下钻同步口径 | 新发现商品、有出库/零出库/未知、两位小数和总览下钻一致性回归;视觉待人工截图确认 | | 四个统计页面/日期统一 | 用户确认经营总览、销量分析、库存明细、库存周转 | 同一组最近3/7/15/30天、自定义;快捷范围截至北京时间昨日,自定义1~366天且最晚昨日;库存周转保留明确标注的7/15/30天对比,主要计算及无出库按所选时段;分页、下钻、返回使用固定区间 | 日期边界、自定义超过30天与样本缺口、查询及返回回归;视觉待人工1920×1080、100%截图确认 | + +| 库存周转/日均出库对比 | 用户截图 `codex-clipboard-16bbd875-0161-4bc1-b86b-08436e702fe6.png` 确认柱顶显示数字、去除图下重复卡片 | 保留所选时段与7/15/30日对比(去重);柱顶显示最多两位小数,单位在纵轴;图下仅展示不完整窗口覆盖提示,完整数值不重复;原查看明细入口保留 | 定向测试与类型检查;视觉待人工1920×1080、100%截图确认 | + +| 库存周转/可售天数阈值 | 用户截图 `codex-clipboard-814d8477-e5aa-4839-a6e4-12526740efd7.png` 指出搜索区裸数字30含义不明 | 删除搜索区重复阈值输入,仅在消化较慢/较快条件下保留具名的“可售天数阈值(天)”;说明与统计时间范围无关,其余关注条件不提交隐藏阈值;复用现有页内输入与应用按钮 | 定向交互与类型检查;视觉待人工1920×1080、100%截图确认 | diff --git a/src/modules/self-operated/turnoverPresentation.ts b/src/modules/self-operated/turnoverPresentation.ts index 1b9194f4..18757847 100644 --- a/src/modules/self-operated/turnoverPresentation.ts +++ b/src/modules/self-operated/turnoverPresentation.ts @@ -47,10 +47,10 @@ export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'daily'): return { aria: { enabled: true }, animation: typeof window === 'undefined' || !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, animationDuration: 450, color: ['#6c7bea'], - tooltip: { trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${owners ? '件' : '件/日'}` }, grid: { left: 12, right: 40, top: owners ? 12 : 36, bottom: 12, containLabel: true }, + tooltip: { trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${owners ? '件' : '件/日'}` }, grid: { left: 12, right: 40, top: owners ? 12 : 48, bottom: 12, containLabel: true }, xAxis: owners ? { type: 'value', name: '件' } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) }, yAxis: owners ? { type: 'category', inverse: true, data: charts.ownerPhysicalTop.map(w => w.customerName || w.customerCode) } : { type: 'value', name: '件/日', nameGap: 12, nameTextStyle: { align: 'left' } }, - series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 }, data: rows.map((_, i) => { + series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 }, label: { show: !owners, position: 'top', distance: 8, formatter: params => turnoverNumber(typeof params.value === 'number' ? params.value : null) }, data: rows.map((_, i) => { const value = owners ? charts.ownerPhysicalTop[i]?.physicalQuantity : charts.averageDaily[i]?.dataStatus === 'COMPLETE' ? charts.averageDaily[i]?.averageDailyOutboundQuantity : null return value == null ? null : Number(value) }) }],