fix(selfop): render inventory turnover cells and data status

This commit is contained in:
2026-10-14 09:23:29 +08:00
parent 3291d34fb8
commit 3f728cd0b2
7 changed files with 158 additions and 36 deletions
@@ -18,6 +18,7 @@ import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beiji
import SkuTrend from './SkuTrend.vue'
import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation'
const emit = defineEmits<{ warehouseSelected: [code: string] }>()
const props = defineProps<{ mode: 'sales' | 'inventory' }>()
const route = useRoute()
const router = useRouter()
@@ -74,7 +75,7 @@ const sourceAccount = 'lingxing-primary'
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
let selectedWarehouse = warehouseCode.value
function defaultWarehouse() { return overview.value?.warehouses.find(row => row.warehouseCode)?.warehouseCode ?? '' }
watch(warehouseCode, code => { if (code) selectedWarehouse = code })
watch(warehouseCode, code => { if (code) { selectedWarehouse = code; emit('warehouseSelected', code) } }, { immediate: true })
const fields = computed<QueryField[]>(() => {
const result: QueryField[] = [
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', props: { clearable: false }, options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] },
@@ -84,7 +85,7 @@ const fields = computed<QueryField[]>(() => {
{ key: 'salesWindowDays', type: 'select', label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] },
{ key: 'salesWindowEndDate', type: 'date', label: '统计截止日(含)', width: 180, props: { clearable: false } },
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, ...(defaultInventoryWindow.value ? [{ value: 'NO_OUTBOUND_30D', label: '近30天无出库' }] : []), { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
{ key: 'threshold', type: 'input', label: '可售天数阈值', width: 160, placeholder: '可售天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } },
{ key: 'threshold', type: 'input', label: '可售天数阈值', width: 180, placeholder: '天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } },
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, ...(defaultInventoryWindow.value ? [{ value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] : [])] },
)
else {
@@ -283,6 +284,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<ListPageLayout v-else>
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
<template v-if="$slots['view-tabs']" #toolbar-start><slot name="view-tabs" /></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices>
<p v-if="routeProblem" role="alert">{{ routeProblem }};已停止查询,不能按全仓条件继续。<el-button @click="returnOverview">返回经营总览</el-button></p>
@@ -8,7 +8,7 @@ vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup: () => () => h('div') })
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-actions']?.(), ...(p.data ?? []).flatMap((row: TurnoverRow) => [slots.owner?.({ row }), slots.sku?.({ row }), slots.status?.({ row })])]) } })
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } })
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] }
@@ -18,7 +18,7 @@ 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 }
}
const wrappers: ReturnType<typeof mount>[] = []
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 } } }); wrappers.push(w); return w }
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 }
async function change(w: ReturnType<typeof mount>, patch: object, search = false) {
const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises()
if (search) { t.vm.$emit('search'); await flushPromises() }
@@ -26,6 +26,54 @@ 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() })
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
describe('inventory turnover page fixed snapshot behavior', () => {
it('shows stale inventory and failed collection without presenting unknown totals as zero', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', observedAt: '2026-10-14T00:48:00Z', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] })
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.context.analyticsContext.inventoryStatus = 'STALE'
result.context.inventoryObservedUntil = '2026-10-12T16:15:00Z'
result.summary!.physicalQuantity = { value: null, knownSubtotal: null, unknownSkuCount: 5402, reasonCodes: ['INVENTORY_UNKNOWN_OR_STALE'] }
result.summary!.noOutboundSkuCount = { value: null, knownSubtotal: 0, unknownSkuCount: 5402, reasonCodes: ['SALES_OR_STOCK_UNASSESSABLE'] }
return result
})
const w = page(); await flushPromises()
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 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')
})
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' } } }] })
const w = page(); await flushPromises()
expect(w.text()).not.toContain('LXCOL-OTHER')
mocks.overview.mockRejectedValue(new Error('unavailable'))
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')
})
it('renders quantity and window cells through the shared table slot contract', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.rows[0]!.attentionReasons = ['UNASSESSABLE']
return result
})
const w = page(); await flushPromises()
expect(w.text()).not.toContain('[object Object]')
expect(w.text()).not.toContain('UNASSESSABLE')
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.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 () => {
const w = page(); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBeUndefined()
@@ -2,6 +2,7 @@
import '@/styles/adminDensity.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Info } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -15,7 +16,7 @@ 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, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import { totalText, turnoverTotalNote, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import AnalyticsChart from './AnalyticsChart.vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import SkuTrend from './SkuTrend.vue'
@@ -50,20 +51,30 @@ const fields = computed<QueryField[]>(() => [
])
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slot: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slot: 'owner', minWidth: 160 },
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160 },
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slot: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slot: 'availableQuantity', minWidth: 145 },
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slot: `outbound${n}`, minWidth: 160 })),
{ prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slot: 'daily', minWidth: 110 },
...(ownerMode.value ? [{ prop: 'noOutbound30d', label: '无出库SKU / 实物', slot: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slot: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slot: 'coverage', minWidth: 150 }]),
{ prop: 'attentionReasons', label: '状态与样本', slot: 'status', minWidth: 180 },
...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slot: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slot: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slot: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slot: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slot: 'lastDate', minWidth: 180 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
...([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: 'noOutbound30d', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]),
{ prop: 'attentionReasons', label: '状态与样本', slotName: 'status', minWidth: 180 },
...(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 }] : []),
])
const cards = computed(() => data.value?.summary ? [
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
{ name: '近30日无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
] : [])
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))
async function refreshRuntime() {
const request = runtimeFence.begin()
try {
const result = await analyticsApi.overview('lingxing-primary', undefined, request.signal)
if (request.current()) { runtime.value = result; runtimeError.value = '' }
} catch { if (request.current()) runtimeError.value = '采集状态更新失败,请到数据采集查看;库存查询结果仍按其观测时间展示。' }
}
function castRow(row: unknown) { return row as TurnoverRow }
function restoreThreshold(warehouse: string) {
try {
@@ -96,6 +107,7 @@ async function load(refresh = false) {
if (!q.snapshotContext) { q.current = 1; page.value = 1; q.includeOverview = true }
const previous = data.value
const request = fence.begin(); loading.value = true
if (refresh) void refreshRuntime()
try {
const result = await analyticsApi.turnover(q, request.signal)
if (!request.current()) return
@@ -166,7 +178,7 @@ async function initialize() {
try {
const result = await analyticsApi.overview('lingxing-primary', undefined, request.signal)
if (!request.current()) return
runtime.value = result
runtime.value = result; runtimeError.value = ''
if (!filters.value.warehouseCode) filters.value.warehouseCode = warehouses.value[0]?.value ?? ''
if (route.query.coverageDaysThreshold == null) filters.value.coverageDaysThreshold = restoreThreshold(String(filters.value.warehouseCode || ''))
if (filters.value.warehouseCode) await load()
@@ -186,22 +198,29 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<LoadErrorState v-if="error" title="库存周转读取失败" :message="error.message" compact @retry="runtime ? load() : initialize()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p>
<DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()">
<template #toolbar-actions><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button></template>
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
<template #toolbar-actions><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></div></el-popover></template>
<template #filters>
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
</template>
<template #before-results>
<div class="turnover-context">
<template v-if="filters.customerCode"><el-tag closable @close="filters.customerCode = ''; search()">货主:{{ filters.customerCode }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
<template v-if="data"><span>{{ applied?.customerCode ? '本货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
</div>
<div v-if="cards.length" class="turnover-cards"><article v-for="card in cards" :key="card.name"><span>{{ card.name }}</span><strong>{{ totalText(card.value) }}</strong></article></div>
<p v-if="data?.context.analyticsContext.inventoryStatus === 'STALE'" class="turnover-context" role="status">当前库存已过期,实物、可售、货主排名及预计可售天数暂不可用;最近库存观测完成于 {{ beijingTime(data.context.inventoryObservedUntil) }}。采集成功后请整体刷新。</p>
<p v-else-if="data?.context.analyticsContext.inventoryStatus === 'UNKNOWN'" class="turnover-context" role="status">当前尚无可用库存版本,请查看数据采集。</p>
<p v-if="collectionNotice" class="turnover-context" role="status">{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。<el-button link @click="router.push('/self-operated/collection')">查看数据采集</el-button></p>
<p v-if="runtimeError" class="turnover-context" role="status">{{ runtimeError }}</p>
<div v-if="cards.length" class="turnover-cards"><article v-for="card in cards" :key="card.name"><span>{{ card.name }}</span><strong>{{ turnoverNumber(card.value.value) }}</strong><small>{{ turnoverTotalNote(card.value) }}</small></article></div>
<template v-if="data?.charts">
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
<div v-if="chartsOpen" class="turnover-charts">
<section><h3>货主实物件数 Top10</h3><AnalyticsChart class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><p>{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p><ChartDetailsDialog title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></section>
<section><h3>7/15/30 日日均出库</h3><AnalyticsChart class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : `覆盖不足:完整${window.completeDayCount}日,部分${window.partialDayCount}日,未知${window.unknownDayCount}日` }}</p><ChartDetailsDialog title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></section>
<section><h3>货主实物件数 Top10</h3><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p><ChartDetailsDialog title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></section>
<section><h3>7/15/30 日日均出库</h3><AnalyticsChart class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><div class="turnover-window-summary"><div v-for="window in data.charts.averageDaily" :key="window.days"><span>{{ window.days }}日日均</span><strong>{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}</strong><small v-if="window.dataStatus !== 'COMPLETE'">完整 {{ window.completeDayCount }} / {{ window.days }} 日</small></div></div><ChartDetailsDialog title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></section>
</div>
</template>
<nav class="turnover-context" aria-label="库存关注快捷筛选"><el-button link @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部/实物件数最多</el-button><el-button link @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</el-button><el-button link @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</el-button><el-button link @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</el-button><el-button link @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</el-button></nav><details class="turnover-rules"><summary>统计说明</summary><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></details>
<nav class="turnover-context turnover-focus" aria-label="库存关注快捷筛选"><el-button :type="applied?.focus === 'ALL' ? 'primary' : 'default'" :plain="applied?.focus !== 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部/实物件数最多</el-button><el-button :type="applied?.focus === 'NO_OUTBOUND_30D' ? 'primary' : 'default'" :plain="applied?.focus !== 'NO_OUTBOUND_30D'" @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</el-button><el-button :type="applied?.focus === 'COVERAGE_GT' ? 'primary' : 'default'" :plain="applied?.focus !== 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</el-button><el-button :type="applied?.focus === 'COVERAGE_LE' ? 'primary' : 'default'" :plain="applied?.focus !== 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</el-button><el-button :type="applied?.focus === 'UNASSESSABLE' ? 'primary' : 'default'" :plain="applied?.focus !== 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</el-button></nav>
<p class="turnover-context">筛选结果:{{ data?.total ?? 0 }} {{ ownerMode ? '个货主' : '条SKU' }}</p>
</template>
<template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template>
@@ -217,20 +236,37 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<template #turnover="{ row }">{{ turnoverNumber(castRow(row).quantityTurnoverCount) }} 次/{{ applied?.baseWindowDays }}日</template>
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
</DataTable>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p></template></AppDialog>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天成功的计划库存,要在截止日包含今天时才进入窗口,历史缺日不能补零。</p></template></AppDialog>
</template>
</AdminPage>
</template>
<style scoped>
.turnover-title { margin: 0; font-size: var(--admin-font-section); display: flex; align-items: center; gap: 16px; }
.turnover-title span { font-size: var(--admin-font-caption); font-weight: 400; color: var(--el-text-color-secondary); }
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 16px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 12px 16px; }
.turnover-cards article { padding: 16px; background: var(--el-fill-color-light); border-top: 3px solid var(--el-color-primary); border-radius: 8px; }
.turnover-cards strong { display: block; font-size: 22px; margin-top: 10px; font-variant-numeric: tabular-nums; }
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 16px 16px; }
.turnover-charts section { border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 8px; }
.turnover-chart { height: 240px; }
h3 { margin: 0; font-size: var(--admin-font-body); }
small { display: block; color: var(--el-text-color-secondary); }
.turnover-rules { padding: 10px 16px; font-size: var(--admin-font-caption); }
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 4px 16px 12px; }
.turnover-cards article { position: relative; overflow: hidden; padding: 16px 20px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
.turnover-cards article:nth-child(2) { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
.turnover-cards article:nth-child(3) { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
.turnover-cards article:nth-child(4) { color: #8a62bf; border-color: #e9e0f5; background: linear-gradient(120deg, #fff, #f6f0fc); }
.turnover-cards article::after { content: ''; position: absolute; width: 88px; height: 88px; border-radius: 50%; right: -22px; top: -30px; background: currentColor; opacity: .07; pointer-events: none; }
.turnover-cards article > span { color: var(--el-text-color-regular); }
.turnover-cards strong { display: block; font-size: 28px; margin: 8px 0 4px; font-variant-numeric: tabular-nums; }
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 16px 12px; }
.turnover-charts section { background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
.turnover-chart, .turnover-chart-empty { height: 220px; }
.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); }
.turnover-window-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 8px 0 12px; }
.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-focus { border-top: 1px solid var(--el-border-color-lighter); padding-top: 16px; gap: 8px; }
.turnover-focus :deep(.el-button + .el-button) { margin-left: 0; }
h3 { margin: 0 0 12px; font-size: var(--admin-font-body); border-left: 3px solid var(--el-color-primary); padding-left: 10px; }
small { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-rules { font-size: var(--admin-font-caption); line-height: 1.7; }
</style>
@@ -92,3 +92,6 @@
| 数据采集/账号运行看板排版 | 用户本轮截图及此前原型:环图在左,三项数量与预计时间在右,下方阶段流程;当前右侧安排栏挤占进度区 | 主进度改为通栏,三项数字连续排列、预计时间对齐数字区域;后续安排移至阶段下方。使用现有 Admin 白底、蓝紫强调和统一间距,移除数量卡片渐变装饰;刷新仍在标题右侧,接口错误与未知数据如实保留 | 仅前端布局;待人工1920×1080、100%截图确认 |
| 数据采集/后续安排位置(2026-10-14) | 用户截图反馈等待处理被移至阶段栏下方;工作区改动将主区域变为单列 | 恢复左侧当前进度、右侧290px后续安排双列,处理阶段保持下方整行;保留自动采集计划和其他业务信息 | 定向测试通过;待人工1920×1080、100%截图确认 |
| 库存周转/概览与明细(2026-10-14) | 用户截图要求优化样式,并出现对象文本、英文状态、空白出库列 | 概览移出筛选区,四色指标卡区分主数值与核验提示,图表白底分组、空数据提示,快捷筛选显示选中状态;统计说明移入右上角信息气泡。修正 DataTable slotName 契约,趋势及证据弹框根部挂载 | 自动验证另列;待人工1920×1080、100%截图确认 |
| 库存分析/视图切换 | 用户示例要求切换置于搜索内 | 库存周转与库存明细共用下划线导航,经 view-tabs 插槽放入各自表格搜索区顶部;切换保留当前仓库,清除另一视图专属条件 | 待人工1920×1080、100%截图确认 |
@@ -5,15 +5,22 @@ import InventoryView from './InventoryView.vue'
const mocks = vi.hoisted(() => ({ route: { query: {} as Record<string, string> }, push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
const options = { global: { stubs: { AnalyticsView: true, InventoryTurnoverView: true, 'el-button': button } } }
const options = { global: { stubs: { AnalyticsView: defineComponent({ name: 'AnalyticsView', props: ['mode'], setup(_, { slots }) { return () => h('section', { class: 'search-region' }, slots['view-tabs']?.()) } }), InventoryTurnoverView: defineComponent({ name: 'InventoryTurnoverView', setup(_, { slots }) { return () => h('section', { class: 'search-region' }, slots['view-tabs']?.()) } }), 'el-button': button } } }
beforeEach(() => { mocks.route = reactive({ query: {} }); mocks.push.mockReset() })
describe('inventory view entry', () => {
it('defaults to turnover and switches to detail without reusing turnover filters', async () => {
mocks.route.query = { warehouseCode: 'US02', focus: 'FROZEN' }
const w = mount(InventoryView, options)
expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(true)
expect(w.find('.search-region .inventory-views').exists()).toBe(true)
expect(w.find('button[aria-pressed="true"]').text()).toBe('库存周转')
await w.findAll('button').find(b => b.text() === '库存明细')!.trigger('click')
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { view: 'DETAIL', warehouseCode: 'US02' } })
mocks.route.query = { view: 'DETAIL', warehouseCode: 'US02' }; await flushPromises()
expect(w.find('button[aria-pressed="true"]').text()).toBe('库存明细')
w.findComponent({ name: 'AnalyticsView' }).vm.$emit('warehouseSelected', 'US01')
await w.findAll('button').find(b => b.text() === '库存周转')!.trigger('click')
expect(mocks.push).toHaveBeenLastCalledWith({ path: '/self-operated/inventory', query: { view: 'TURNOVER', warehouseCode: 'US01' } })
w.unmount()
})
it('preserves original strict detail links and rejects contradictory parameters', async () => {
@@ -16,9 +16,19 @@ function switchView(view: 'TURNOVER' | 'DETAIL') {
}
</script>
<template>
<nav class="inventory-views" aria-label="库存分析视图"><el-button :type="selected.view === 'TURNOVER' ? 'primary' : undefined" @click="switchView('TURNOVER')">库存周转</el-button><el-button :type="selected.view === 'DETAIL' ? 'primary' : undefined" @click="switchView('DETAIL')">库存明细</el-button></nav>
<p v-if="selected.error" role="alert">{{ selected.error }}</p>
<AnalyticsView v-else-if="selected.view === 'DETAIL'" mode="inventory" />
<InventoryTurnoverView v-else @warehouse-selected="retainedWarehouse = $event" />
<component v-else :is="selected.view === 'DETAIL' ? AnalyticsView : InventoryTurnoverView" v-bind="selected.view === 'DETAIL' ? { mode: 'inventory' as const } : {}" @warehouse-selected="retainedWarehouse = $event">
<template #view-tabs>
<nav class="inventory-views" aria-label="库存分析视图">
<button type="button" :aria-pressed="selected.view === 'TURNOVER'" @click="switchView('TURNOVER')">库存周转</button>
<button type="button" :aria-pressed="selected.view === 'DETAIL'" @click="switchView('DETAIL')">库存明细</button>
</nav>
</template>
</component>
</template>
<style scoped>.inventory-views { display: flex; gap: 8px; padding: 16px 20px 0; }</style>
<style scoped>
.inventory-views { display: flex; align-items: center; gap: var(--admin-space-4); }
.inventory-views button { border: 0; border-bottom: 2px solid transparent; background: transparent; min-height: var(--admin-control-height); padding: var(--admin-space-1) var(--admin-space-2); color: var(--el-text-color-regular); font: inherit; cursor: pointer; white-space: nowrap; }
.inventory-views button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.inventory-views button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
</style>
@@ -1,4 +1,4 @@
import type { Quantity } from './types'
import type { Quantity, WarehouseOverview } from './types'
import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes'
import type { EChartsOption } from 'echarts'
import { reasonsText } from './state'
@@ -21,6 +21,22 @@ 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 turnoverTotalNote(total: TurnoverTotal) {
if (total.value != null) return '已确认'
const known = total.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${turnoverNumber(total.knownSubtotal)}`
return `${known} · ${total.unknownSkuCount} SKU待核验`
}
export function inventoryCollectionNotice(warehouse: WarehouseOverview | undefined) {
const task = warehouse?.currentTask
if (!task || !['DEAD', 'RETRY_WAIT'].includes(task.taskStatus) || task.latestAttempt?.status !== 'FAILED') return ''
const reasons: Record<string, string> = {
SELFOP_STOCK_DUPLICATE: '库存分页出现重复记录',
SELFOP_STOCK_COUNT_MISMATCH: '库存扫描条数与来源总数不符',
SELFOP_PAGE_TOTAL_CHANGED: '库存扫描过程中分页总数变化',
}
const reason = reasons[task.latestAttempt.errorCode ?? ''] ?? '库存采集未成功'
return `${task.taskStatus === 'RETRY_WAIT' ? '库存采集失败,等待重试' : '库存采集失败,已停止自动重试'}:${reason}。任务 ${task.taskNo}`
}
export function windowText(metric: TurnoverWindowMetric | undefined) {
if (!metric) return '—'
return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足`