fix: preserve turnover deep-link snapshots and clarify data status
This commit is contained in:
@@ -26,6 +26,15 @@ async function change(w: ReturnType<typeof mount>, 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() })
|
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()))
|
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||||
describe('inventory turnover page fixed snapshot behavior', () => {
|
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 () => {
|
it('loads the first warehouse once and keeps charts/context across filtered pagination', async () => {
|
||||||
const w = page(); await flushPromises()
|
const w = page(); await flushPromises()
|
||||||
expect(mocks.turnover).toHaveBeenCalledTimes(1)
|
expect(mocks.turnover).toHaveBeenCalledTimes(1)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { analyticsApi } from '../api/analytics'
|
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 { DailyResponse, Overview } from '../types'
|
||||||
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
|
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
|
||||||
import { turnoverRoute, validateTurnover, yesterdayBeijing, turnoverSorts } from '../turnoverNavigation'
|
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 AnalyticsChart from './AnalyticsChart.vue'
|
||||||
import ChartDetailsDialog from './ChartDetailsDialog.vue'
|
import ChartDetailsDialog from './ChartDetailsDialog.vue'
|
||||||
import SkuTrend from './SkuTrend.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('指定仓库不可用,请重新选择有权限的仓库')
|
if (!warehouses.value.some(w => w.value === q.warehouseCode)) throw new Error('指定仓库不可用,请重新选择有权限的仓库')
|
||||||
} catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return }
|
} catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return }
|
||||||
const sameScope = applied.value && scopeKey(applied.value) === scopeKey(q)
|
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
|
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 }
|
if (!q.snapshotContext) { q.current = 1; page.value = 1; q.includeOverview = true }
|
||||||
const previous = data.value
|
const previous = data.value
|
||||||
const request = fence.begin(); loading.value = true
|
const request = fence.begin(); loading.value = true
|
||||||
|
|||||||
@@ -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%截图确认 |
|
| 自营业务全部图表 | 统一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%截图确认 |
|
| 库存/销量 | SKU每日出库趋势连线与坐标 | 2026-10-13 用户反馈线未连接、坐标不显示,并明确要求图表组件:保留居中弹框,改用ECharts+vue-echarts(Canvas),配置日期轴、数量轴、网格、提示框、图例说明和范围缩放;相邻有效日连接,涉及未完整日用虚线,未知或缺失日期断线 | 库存/销量/总览的SKU弹框共用组件;总览主图的连线修复同步回归;不把未知补零;待人工1920×1080、100%截图确认 |
|
||||||
| /self-operated/collection | 看板配色 | 2026-10-13 用户追加当前页面与原型首页截图:采用蓝紫主色、浅色圆形装饰数量卡、绿/橙数量强调;流程与仓库结果增加色彩层次,保持原有左右布局及真实状态文字 | 仅本看板作用域内应用;颜色不代替状态或代表未知完成度;待人工1920×1080、100%截图确认 |
|
| /self-operated/collection | 看板配色 | 2026-10-13 用户追加当前页面与原型首页截图:采用蓝紫主色、浅色圆形装饰数量卡、绿/橙数量强调;流程与仓库结果增加色彩层次,保持原有左右布局及真实状态文字 | 仅本看板作用域内应用;颜色不代替状态或代表未知完成度;待人工1920×1080、100%截图确认 |
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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'
|
import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes'
|
||||||
describe('turnover honest precision and charts', () => {
|
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', () => {
|
it('keeps tiny positive values distinct from zero and unknown', () => {
|
||||||
expect(turnoverNumber(0.000001)).toBe('<0.01')
|
expect(turnoverNumber(0.000001)).toBe('<0.01')
|
||||||
expect(turnoverNumber(0.01, 1)).toBe('<0.1')
|
expect(turnoverNumber(0.01, 1)).toBe('<0.1')
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import type { Quantity } from './types'
|
import type { Quantity } from './types'
|
||||||
import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes'
|
import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes'
|
||||||
import type { EChartsOption } from 'echarts'
|
import type { EChartsOption } from 'echarts'
|
||||||
|
import { reasonsText } from './state'
|
||||||
|
const turnoverLabels: Record<string, string> = {
|
||||||
|
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) {
|
export function turnoverNumber(value: Quantity | undefined, decimals = 2) {
|
||||||
if (value == null || value === '' || !Number.isFinite(Number(value))) return '—'
|
if (value == null || value === '' || !Number.isFinite(Number(value))) return '—'
|
||||||
const n = Number(value)
|
const n = Number(value)
|
||||||
@@ -8,6 +18,7 @@ export function turnoverNumber(value: Quantity | undefined, decimals = 2) {
|
|||||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals })
|
return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals })
|
||||||
}
|
}
|
||||||
export function totalText(total: TurnoverTotal) {
|
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待核验`
|
return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验`
|
||||||
}
|
}
|
||||||
export function windowText(metric: TurnoverWindowMetric | undefined) {
|
export function windowText(metric: TurnoverWindowMetric | undefined) {
|
||||||
@@ -18,7 +29,8 @@ export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'daily'):
|
|||||||
const owners = kind === 'owners'
|
const owners = kind === 'owners'
|
||||||
const rows = owners ? charts.ownerPhysicalTop : charts.averageDaily
|
const rows = owners ? charts.ownerPhysicalTop : charts.averageDaily
|
||||||
return {
|
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 },
|
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}日`) },
|
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: '件/日' },
|
yAxis: owners ? { type: 'category', inverse: true, data: charts.ownerPhysicalTop.map(w => w.customerName || w.customerCode) } : { type: 'value', name: '件/日' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { QueryContext, Quantity } from './types'
|
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 TurnoverWindow = 7 | 15 | 30
|
||||||
export type TurnoverGroup = 'SKU' | 'OWNER'
|
export type TurnoverGroup = 'SKU' | 'OWNER'
|
||||||
export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE'
|
export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE'
|
||||||
@@ -44,5 +44,5 @@ export interface TurnoverCharts {
|
|||||||
}
|
}
|
||||||
export interface TurnoverResponse {
|
export interface TurnoverResponse {
|
||||||
context: TurnoverContext; summary: TurnoverOverview | null; charts: TurnoverCharts | null
|
context: TurnoverContext; summary: TurnoverOverview | null; charts: TurnoverCharts | null
|
||||||
rows: TurnoverRow[]; total: number
|
rows: TurnoverRow[]; total: string | number
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user