diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index 1bd76667..46eaf0e9 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -26,6 +26,15 @@ async function change(w: ReturnType, patch: object, search = false beforeEach(() => { vi.clearAllMocks(); 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('honors an explicit new route token in the same scope and reloads its overview', async () => { + const w = page(); await flushPromises() + expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBeUndefined() + mocks.route.query = { endDate: '2026-10-12', warehouseCode: 'A', snapshotContext: 'token-T2' } + await flushPromises() + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ warehouseCode: 'A', snapshotContext: 'token-T2', includeOverview: true }) + w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-T2', includeOverview: false, current: 2 }) + }) it('loads the first warehouse once and keeps charts/context across filtered pagination', async () => { const w = page(); await flushPromises() expect(mocks.turnover).toHaveBeenCalledTimes(1) diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index f8ba9f6f..56514983 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -11,11 +11,11 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { analyticsApi } from '../api/analytics' -import { RequestFence, beijingTime, contextParams, reasonsText } from '../state' +import { RequestFence, beijingTime, contextParams } from '../state' import type { DailyResponse, Overview } from '../types' import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes' import { turnoverRoute, validateTurnover, yesterdayBeijing, turnoverSorts } from '../turnoverNavigation' -import { totalText, turnoverChart, turnoverNumber, windowText } from '../turnoverPresentation' +import { totalText, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation' import AnalyticsChart from './AnalyticsChart.vue' import ChartDetailsDialog from './ChartDetailsDialog.vue' import SkuTrend from './SkuTrend.vue' @@ -90,9 +90,9 @@ async function load(refresh = false) { if (!warehouses.value.some(w => w.value === q.warehouseCode)) throw new Error('指定仓库不可用,请重新选择有权限的仓库') } catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return } const sameScope = applied.value && scopeKey(applied.value) === scopeKey(q) - q.snapshotContext = refresh ? undefined : sameScope ? data.value?.context.snapshotContext : linkedSnapshot + q.snapshotContext = refresh ? undefined : linkedSnapshot ?? (sameScope ? data.value?.context.snapshotContext : undefined) linkedSnapshot = undefined - q.includeOverview = refresh || !data.value?.summary || !applied.value || overviewKey(applied.value) !== overviewKey(q) + q.includeOverview = refresh || q.snapshotContext !== data.value?.context.snapshotContext || !data.value?.summary || !applied.value || overviewKey(applied.value) !== overviewKey(q) if (!q.snapshotContext) { q.current = 1; page.value = 1; q.includeOverview = true } const previous = data.value const request = fence.begin(); loading.value = true diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index 7f6d85a9..0afd7f85 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -4,6 +4,7 @@ | 页面 | 操作 | 依据与承载结论 | 布局与验证 | | --- | --- | --- | --- | +| /self-operated/inventory | 库存周转/库存明细、SKU/货主 | 当前维护入口 `docs/自营业务/库存周转视图设计.md`:默认周转、默认合法仓及SKU;可选货主仅限本视图。三窗同屏、四指标、两张240px图表、关注过滤和分页;原总览严格下钻继续进入库存明细 | 概览与列表共用固定上下文,显式深链换版同时换概览;SKU趋势1120px居中弹框,样本证据1040px居中弹框;未知不补零,待人工1920×1080、100%截图确认 | | 自营业务全部图表 | 统一ECharts | 2026-10-13 用户要求全面替换手写图表:经营总览日趋势、库存七桶与三个榜单、SKU日趋势、采集进度环统一通过AnalyticsChart使用ECharts Canvas;移除SVG路径生成与CSS条形绘图,保留真实数量、未知/零区分及原有下钻边界 | 日期点/分类轴/柱体点击及展开表格键盘操作同资格;未知进度仅空环;坐标、tooltip、缩放及尺寸由组件配置;待人工1920×1080、100%截图确认 | | 库存/销量 | SKU每日出库趋势连线与坐标 | 2026-10-13 用户反馈线未连接、坐标不显示,并明确要求图表组件:保留居中弹框,改用ECharts+vue-echarts(Canvas),配置日期轴、数量轴、网格、提示框、图例说明和范围缩放;相邻有效日连接,涉及未完整日用虚线,未知或缺失日期断线 | 库存/销量/总览的SKU弹框共用组件;总览主图的连线修复同步回归;不把未知补零;待人工1920×1080、100%截图确认 | | /self-operated/collection | 看板配色 | 2026-10-13 用户追加当前页面与原型首页截图:采用蓝紫主色、浅色圆形装饰数量卡、绿/橙数量强调;流程与仓库结果增加色彩层次,保持原有左右布局及真实状态文字 | 仅本看板作用域内应用;颜色不代替状态或代表未知完成度;待人工1920×1080、100%截图确认 | diff --git a/src/modules/self-operated/turnoverPresentation.test.ts b/src/modules/self-operated/turnoverPresentation.test.ts index b9a8bdfe..e7e0d3a6 100644 --- a/src/modules/self-operated/turnoverPresentation.test.ts +++ b/src/modules/self-operated/turnoverPresentation.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' -import { turnoverChart, turnoverNumber, windowText } from './turnoverPresentation' +import { turnoverChart, turnoverNumber, windowText, turnoverReasonsText } from './turnoverPresentation' import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes' describe('turnover honest precision and charts', () => { + it('explains missing samples and zero average stock without exposing internal statuses', () => { + expect(turnoverReasonsText(['INSUFFICIENT'])).toBe('合格日库存样本不足') + expect(turnoverReasonsText(['ZERO_AVERAGE_STOCK'])).toContain('每日观测未捕获存量') + expect(turnoverReasonsText(['INVENTORY_UNKNOWN_OR_STALE'])).toContain('已过期') + }) it('keeps tiny positive values distinct from zero and unknown', () => { expect(turnoverNumber(0.000001)).toBe('<0.01') expect(turnoverNumber(0.01, 1)).toBe('<0.1') diff --git a/src/modules/self-operated/turnoverPresentation.ts b/src/modules/self-operated/turnoverPresentation.ts index 8da906c9..f1e828a0 100644 --- a/src/modules/self-operated/turnoverPresentation.ts +++ b/src/modules/self-operated/turnoverPresentation.ts @@ -1,6 +1,16 @@ import type { Quantity } from './types' import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes' import type { EChartsOption } from 'echarts' +import { reasonsText } from './state' +const turnoverLabels: Record = { + INSUFFICIENT: '合格日库存样本不足', ZERO_AVERAGE_STOCK: '每日观测未捕获存量,无法计算周转', + INVENTORY_UNKNOWN_OR_STALE: '当前库存未知、异常或已过期', NOT_APPLICABLE: '货主不计算整体可售天数', + SALES_OR_STOCK_UNASSESSABLE: '部分商品的销量或库存不足以判断', + SALES_WINDOW_UNAVAILABLE: '同期销量或商品观察证据不足', +} +export function turnoverReasonsText(reasons: readonly string[] | null | undefined) { + return reasons?.map(reason => turnoverLabels[reason] ?? reasonsText([reason])).join('、') || '—' +} export function turnoverNumber(value: Quantity | undefined, decimals = 2) { if (value == null || value === '' || !Number.isFinite(Number(value))) return '—' const n = Number(value) @@ -8,6 +18,7 @@ export function turnoverNumber(value: Quantity | undefined, decimals = 2) { return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals }) } export function totalText(total: TurnoverTotal) { + if (total.value == null && total.knownSubtotal == null && total.unknownSkuCount === 0) return turnoverReasonsText(total.reasonCodes) return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验` } export function windowText(metric: TurnoverWindowMetric | undefined) { @@ -18,7 +29,8 @@ export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'daily'): const owners = kind === 'owners' const rows = owners ? charts.ownerPhysicalTop : charts.averageDaily return { - aria: { enabled: true }, animationDuration: 450, color: ['#6c7bea'], + aria: { enabled: true }, animation: typeof window === 'undefined' || !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, + animationDuration: 450, color: ['#6c7bea'], tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 12, right: 24, top: 16, bottom: 20, 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: '件/日' }, diff --git a/src/modules/self-operated/turnoverTypes.ts b/src/modules/self-operated/turnoverTypes.ts index d89c385d..0c16c2ff 100644 --- a/src/modules/self-operated/turnoverTypes.ts +++ b/src/modules/self-operated/turnoverTypes.ts @@ -1,6 +1,6 @@ import type { QueryContext, Quantity } from './types' -// Inventory-turnover integration contract. Backend implementation and HTTP acceptance are pending. +// Fixed-version read contract; Long identifiers and totals use the service's string serializer. export type TurnoverWindow = 7 | 15 | 30 export type TurnoverGroup = 'SKU' | 'OWNER' export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE' @@ -44,5 +44,5 @@ export interface TurnoverCharts { } export interface TurnoverResponse { context: TurnoverContext; summary: TurnoverOverview | null; charts: TurnoverCharts | null - rows: TurnoverRow[]; total: number + rows: TurnoverRow[]; total: string | number }