diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index b1588dab..903d7c23 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -13,7 +13,7 @@ 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, 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 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 } 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 } } @@ -29,7 +29,7 @@ describe('inventory turnover page fixed snapshot behavior', () => { it('sorts every numeric header on the server in both directions with the same snapshot', async () => { const w = page(); await flushPromises() await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click') - const metrics = { physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' } + const metrics = { physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' } for (const [prop, metric] of Object.entries(metrics)) { // Start on a later page and with another sort to prove that a header resets pagination. await change(w, { sortBy: metric === 'PHYSICAL' ? 'AVAILABLE_DESC' : 'PHYSICAL_DESC' }, true) diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index cbd43ca3..8a295a95 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -56,6 +56,7 @@ 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: '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 }, ...(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 }]), @@ -266,7 +267,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() } - + diff --git a/src/modules/self-operated/dashboardChartOptions.ts b/src/modules/self-operated/dashboardChartOptions.ts index c3f7d7ab..6ab5fa6d 100644 --- a/src/modules/self-operated/dashboardChartOptions.ts +++ b/src/modules/self-operated/dashboardChartOptions.ts @@ -6,6 +6,7 @@ export interface BarRow { key: string; label: string; secondary?: string; value: const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]!)) const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf'] export function barChartOption(rows: BarRow[], unit: string, title: string, ranked = false): EChartsOption { + const valueText = (value: Quantity | undefined) => unit === '天' && value != null && Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2, useGrouping: false }) : quantityText(value) return { animation: false, grid: { left: 12, right: 48, top: 12, bottom: 24, containLabel: true }, @@ -16,11 +17,11 @@ export function barChartOption(rows: BarRow[], unit: string, title: string, rank tooltip: { trigger: 'item', renderMode: 'html', confine: true, extraCssText: 'max-width: min(300px, 80vw); white-space: normal; overflow-wrap: anywhere;', formatter: params => { const item = Array.isArray(params) ? params[0] : params const row = item ? rows[item.dataIndex] : undefined - return row ? `${escapeHtml(row.label)}
${row.secondary ? `${escapeHtml(row.secondary)}
` : ''}${escapeHtml(quantityText(row.value))} ${escapeHtml(unit)}` : '' + return row ? `${escapeHtml(row.label)}
${row.secondary ? `${escapeHtml(row.secondary)}
` : ''}${escapeHtml(valueText(row.value))} ${escapeHtml(unit)}` : '' } }, aria: { enabled: true, description: `${title},横轴为${unit}。未知不作为零值;详细数据和键盘操作在下方完整明细弹框。` }, series: [{ type: 'bar', barMaxWidth: ranked ? 14 : 18, data: rows.map((row, index) => ({ value: row.value == null ? null : Number(row.value), itemStyle: { color: palette[ranked ? 0 : index % palette.length], borderRadius: [0, 4, 4, 0] } })), - label: { show: true, position: 'right', formatter: params => quantityText(rows[params.dataIndex]?.value) } }], + label: { show: true, position: 'right', formatter: params => valueText(rows[params.dataIndex]?.value) } }], } } export function progressGaugeOption(percent: number | null, running = false): EChartsOption { diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index 85589ee4..3979ff68 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -129,3 +129,7 @@ | 库存周转/日均出库对比 | 用户截图 `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%截图确认 | + +| 经营总览/三张榜单精简 | 用户截图确认说明过多占位 | 日期、资格数量、完整性详情及统计口径收进右上角“说明”;卡片仅留一行必要的未采齐/待评估/过期提示,三个图表顶部对齐;消化天数图上最多两位小数,明细保留原值 | 构建与交互回归;视觉待人工1920×1080、100%截图确认 | + +| 库存周转/在途列 | 用户要求在库存周转表格添加在途 | 正品可售后默认展示在途,SKU及货主汇总共用固定库存版本;表头及筛选区支持全量升降序,未知末尾;全品质独立汇总,缺失显示已知小计与待核验数,不计入实物、可售或周转分母 | 字段映射、品质聚合、未知传播及排序回归;视觉待人工1920×1080、100%截图确认 | diff --git a/src/modules/self-operated/pages/OverviewView.test.ts b/src/modules/self-operated/pages/OverviewView.test.ts index d70255e0..2112e680 100644 --- a/src/modules/self-operated/pages/OverviewView.test.ts +++ b/src/modules/self-operated/pages/OverviewView.test.ts @@ -93,8 +93,8 @@ describe('warehouse operating dashboard', () => { data.noOutboundSkus = { ...data.noOutboundSkus, items: [], eligibleCount: '0', unassessableCount: '2741', dataStatus: 'PARTIAL' } mocks.dashboard.mockResolvedValue(data) const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }) - expect(wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '待评估原因')).toHaveLength(2) - await findButton(wrapper, '待评估原因').trigger('click'); await flushPromises() + expect(wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')).toHaveLength(3) + await wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')[1]!.trigger('click'); await flushPromises() expect(wrapper.text()).toContain('全仓库存分布的待核验原因') expect(wrapper.text()).toContain('待评估 2711 SKU') expect(wrapper.text()).toContain('待评估 2741 SKU') @@ -241,7 +241,7 @@ describe('warehouse operating dashboard', () => { expect(wrapper.findAllComponents(DashboardTrend)).toHaveLength(1) expect(wrapper.text()).toContain('所选时段无出库库存Top10') expect(wrapper.text()).not.toContain('近30日无出库') - expect(wrapper.findAllComponents(DashboardBars).slice(1).every(chart => chart.text().includes('2026-10-10 至 2026-10-14'))).toBe(true) + expect(wrapper.findAllComponents(DashboardBars).slice(1).every(chart => !chart.text().includes('2026-10-10 至 2026-10-14'))).toBe(true) expect(wrapper.text()).toContain('区间已采集出库量') expect(wrapper.text()).toContain('已知小计 128') expect(wrapper.text()).toContain('另 1 SKU 待核验') @@ -301,6 +301,8 @@ describe('warehouse operating dashboard', () => { mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-13', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 1, eligibleCount: '0', items: [], dataStatus: 'UNKNOWN', reasonCodes: ['SALES_WINDOW_INCOMPLETE'] } }) const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }) expect(wrapper.findAllComponents(DashboardBars)[1]!.props('rows')).toEqual([]) + expect(wrapper.find('.dashboard-rankings').text()).not.toContain('未采集日期不计为零出库') + await findButton(wrapper, '说明').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('未采集日期不计为零出库') await findButton(wrapper, '查看Top100').trigger('click') expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ startDate: '2026-10-13', endDate: '2026-10-13', rangePreset: 'CUSTOM' }) @@ -309,7 +311,7 @@ describe('warehouse operating dashboard', () => { mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-01', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 13, dataStatus: 'PARTIAL', reasonCodes: ['SALES_WINDOW_INCOMPLETE'], items: [{ ...dashboardFixture.hotSkus.items[0], windowOutboundQuantity: '37', value: '37', averageDailyOutboundQuantity: null, inventoryCoverageDays: null }] } }) const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }) expect(wrapper.text()).toContain('所选时段热销Top10') - expect(wrapper.text()).toContain('按所选时段已采集量排名') + expect(wrapper.text()).toContain('数据未采齐,排名仅供参考') const hot = wrapper.findAllComponents(DashboardBars)[1]! expect(hot.props('rows')[0]).toMatchObject({ value: '37' }) expect(hot.props('rows')[0].detail).toContain('13天窗口已采集出库 37 件') diff --git a/src/modules/self-operated/pages/OverviewView.vue b/src/modules/self-operated/pages/OverviewView.vue index 8e4a3be6..4c28ec00 100644 --- a/src/modules/self-operated/pages/OverviewView.vue +++ b/src/modules/self-operated/pages/OverviewView.vue @@ -234,16 +234,20 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
- + @@ -273,6 +277,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea .dashboard-metric-status { color: var(--metric-color); } .dashboard-kpis p { margin: var(--admin-space-1) 0; } .dashboard-main-charts { display: grid; grid-template-columns: 2fr 1fr; gap: var(--admin-space-4); } +.dashboard-ranking-status { min-height: 24px; margin-bottom: var(--admin-space-2); color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); } .dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); } .dashboard-chart { min-width: 0; padding: 18px; background: var(--admin-surface); border: 1px solid #e5eaf4; border-radius: 8px; animation: dashboard-reveal .6s ease-out both; } .dashboard-chart p { color: #76849c; font-size: var(--admin-font-caption); line-height: 1.7; } diff --git a/src/modules/self-operated/turnoverSorting.ts b/src/modules/self-operated/turnoverSorting.ts index 014d3d6f..504cd92d 100644 --- a/src/modules/self-operated/turnoverSorting.ts +++ b/src/modules/self-operated/turnoverSorting.ts @@ -1,14 +1,14 @@ import type { TurnoverSort, TurnoverSortMetric } from './turnoverTypes' export const turnoverSortColumns: Record = { - physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', + physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER', } const metricLabels: Record = { - PHYSICAL: '实物件数', AVAILABLE: '正品可售', OUTBOUND_7: '完整7日出库', + PHYSICAL: '实物件数', AVAILABLE: '正品可售', TRANSIT: '在途', OUTBOUND_7: '完整7日出库', OUTBOUND_15: '完整15日出库', OUTBOUND_30: '完整30日出库', AVERAGE_DAILY: '基准窗口日均出库', COVERAGE: '预计可售天数', FROZEN: '冻结件数', DEFECTIVE: '次品实物', AVERAGE_PHYSICAL: '期间平均实物', TURNOVER: '期间件数周转', diff --git a/src/modules/self-operated/turnoverTypes.ts b/src/modules/self-operated/turnoverTypes.ts index be4b27fe..27f2f0bf 100644 --- a/src/modules/self-operated/turnoverTypes.ts +++ b/src/modules/self-operated/turnoverTypes.ts @@ -4,7 +4,7 @@ import type { QueryContext, Quantity } from './types' export type TurnoverWindow = number export type TurnoverGroup = 'SKU' | 'OWNER' export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_IN_WINDOW' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE' -export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'OUTBOUND_7' | 'OUTBOUND_15' | 'OUTBOUND_30' | 'AVERAGE_DAILY' | 'COVERAGE' | 'FROZEN' | 'DEFECTIVE' | 'AVERAGE_PHYSICAL' | 'TURNOVER' +export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'TRANSIT' | 'OUTBOUND_7' | 'OUTBOUND_15' | 'OUTBOUND_30' | 'AVERAGE_DAILY' | 'COVERAGE' | 'FROZEN' | 'DEFECTIVE' | 'AVERAGE_PHYSICAL' | 'TURNOVER' export type TurnoverSort = `${TurnoverSortMetric}_${'ASC' | 'DESC'}` | 'NO_OUTBOUND_PHYSICAL_DESC' export interface TurnoverQuery { sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow @@ -22,7 +22,7 @@ export interface TurnoverTotal { value: Quantity; knownSubtotal: Quantity; unkno export interface TurnoverRisk { matchedSkuCount: Quantity; knownPhysicalQuantity: Quantity; unknownSkuCount: number; physicalQuantityRatio: Quantity } export interface TurnoverRow { customerCode: string; customerName: string | null; sku: string | null; productName: string | null - physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; frozenQuantity: TurnoverTotal; defectiveQuantity: TurnoverTotal + physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; frozenQuantity: TurnoverTotal; defectiveQuantity: TurnoverTotal; transitQuantity: TurnoverTotal physicalSkuCount: Quantity; windows: TurnoverWindowMetric[]; averageDailyOutboundQuantity: Quantity inventoryCoverageDays: Quantity; coverageStatus: string; averagePhysicalQuantity: Quantity quantityTurnoverCount: Quantity; turnoverStatus: string; qualifiedSampleDays: number; requiredSampleDays: number