This commit is contained in:
2026-10-14 11:02:56 +08:00
parent 3f728cd0b2
commit e0f54edcc9
24 changed files with 352 additions and 105 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
# 自营经营分析
- 模块入口 `routes.ts`;正式页面 `pages/OverviewView.vue`、`SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。
- 经营总览唯一设计入口 `docs/自营业务/经营总览图表设计.md`;`analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选同时影响区间出库、日趋势与热销榜,热销按已采集量排名并标明覆盖不足,库存评估仍使用固定30日。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD`,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。
- 文档路径从工作区根起算;本目录代码路径从当前模块起算。总览设计变更查 `docs/自营业务/经营总览图表设计.md`,采集页面变更查 `docs/自营业务/数据采集页面设计.md`;纯文案或局部代码定位不默认加载设计全文。
- `analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选统一影响区间出库、日均、日趋势、库存分布及三个榜单,热销按已采集量排名并标明覆盖不足,库存评估按所选区间完整销量及观察资格计算;库存数量仍取固定当前版本。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD` 并从原图上下文传递 CUSTOM 起止日期,不能混用普通库存的10/30日窗口,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。
- API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。
- 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。
- 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。
@@ -6,9 +6,9 @@ import { BarChart, GaugeChart, LineChart } from 'echarts/charts'
import { AriaComponent, DataZoomComponent, GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
use([LineChart, BarChart, GaugeChart, GridComponent, TooltipComponent, DataZoomComponent, AriaComponent, CanvasRenderer])
defineProps<{ option: EChartsOption }>()
defineProps<{ option: EChartsOption; mergeUpdates?: boolean }>()
defineEmits<{ select: [event: ECElementEvent] }>()
</script>
<template>
<VChart :option="option" :init-options="{ renderer: 'canvas' }" :update-options="{ notMerge: true }" autoresize @click="$emit('select', $event)" />
<VChart :option="option" :init-options="{ renderer: 'canvas' }" :update-options="{ notMerge: !mergeUpdates }" autoresize @click="$emit('select', $event)" />
</template>
@@ -38,7 +38,6 @@ function initialFilters(): QueryModel { return { warehouseCode: typeof route.que
const filters = ref<QueryModel>(initialFilters())
const applied = ref({ sku: String(filters.value.sku) })
const appliedWindow = ref({ salesWindowDays: 30 as 10 | 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) })
const defaultInventoryWindow = computed(() => Number(filters.value.salesWindowDays) === 30 && filters.value.salesWindowEndDate === previousDate(context.value?.anchorDate ?? beijingToday()))
const displayedWindowDays = computed(() => context.value?.calculationWindowDays ?? appliedWindow.value.salesWindowDays)
const overview = ref<Overview | null>(null)
const context = ref<QueryContext | null>(drilldown.value?.context ?? null)
@@ -82,11 +81,11 @@ const fields = computed<QueryField[]>(() => {
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
]
if (props.mode === 'inventory') result.push(
{ 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: '数据不足/待观察' }] },
...(!drilldown.value ? [{ key: 'salesWindowDays', type: 'select' as const, label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] },
{ key: 'salesWindowEndDate', type: 'date' as const, label: '统计截止日(含)', width: 180, props: { clearable: false } }] : [{ key: 'customRange', type: 'daterange' as const, label: '原图统计区间', width: 300 }]),
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
{ 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天出库从低到高' }] : [])] },
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_ASC', label: '区间出库从低到高' }] },
)
else {
result.push({ key: 'analysis', type: 'select', label: '销量类别', width: 170, props: { clearable: false }, options: [{ value: 'summary', label: 'SKU销量' }, { value: 'hot', label: '热销Top100' }, { value: 'slow', label: '滞销Top100' }] })
@@ -137,7 +136,11 @@ function query(): InventoryQuery {
if (drilldown.value?.analysis === 'hot') Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
else result.windowDays = windowDays.value
}
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, appliedWindow.value, drilldown.value?.inventory)
if (props.mode === 'inventory') {
Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
if (drilldown.value) Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
else Object.assign(result, appliedWindow.value)
}
return result
}
async function load(reset = false, keepContext = false) {
@@ -214,16 +217,12 @@ function applySalesRange(anchor?: string) {
function search() {
if (summaryMode.value && !applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
let nextWindow = appliedWindow.value
if (props.mode === 'inventory') {
if (props.mode === 'inventory' && !drilldown.value) {
const end = String(filters.value.salesWindowEndDate ?? '')
const days = Number(filters.value.salesWindowDays)
const problem = validateCustomRange(end, end, previousDate(origin.value?.context.anchorDate ?? beijingToday()))
if (problem || ![10, 30].includes(days)) { error.value = problem || '请选择10天或30天销量窗口'; return }
nextWindow = { salesWindowDays: days as 10 | 30, salesWindowEndDate: end }
if (!defaultInventoryWindow.value) {
if (attention.value === 'NO_OUTBOUND_30D') filters.value.attention = 'ALL'
if (sortBy.value === 'OUTBOUND_30D_ASC') filters.value.sortBy = 'AVAILABLE_DESC'
}
}
const rawThreshold = thresholdInput.value.trim()
if (props.mode === 'inventory' && ((rawThreshold && (!/^\d+$/.test(rawThreshold) || !Number.isSafeInteger(Number(rawThreshold)) || Number(rawThreshold) <= 0)) || (attention.value === 'COVERAGE_GT_THRESHOLD' && !rawThreshold))) {
@@ -303,7 +302,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
<div class="analytics-rules">
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
<p v-if="mode === 'inventory'">预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</p>
<p v-if="context">查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}</p>
<p v-if="context">实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)<template v-if="context.calculationWindowDays != null">;{{ context.calculationWindowDays }} 天</template>。</p>
@@ -69,6 +69,7 @@ describe('single account collection monitor', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview(), stale: true } })
expect(w.text()).toContain('339'); expect(w.text()).toContain('状态更新失败')
expect(w.text()).toContain('已暂停估算')
expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false)
w.unmount()
})
})
@@ -67,7 +67,7 @@ function nodeState(phases: string[]) {
if (!p) return 'idle'
const evidence = [p, ...(p.stages ?? [])].find(s => phases.includes(s.phase ?? '') && s.attemptNo != null && s.attemptNo === p.attemptNo && s.scanRound === p.scanRound && s.sliceFrom === p.sliceFrom && s.sliceUntil === p.sliceUntil && s.progressScope === p.progressScope && s.stageFinishedAt)
if (evidence) return 'done'
return phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle'
return !props.stale && phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle'
}
</script>
<template>
@@ -82,22 +82,23 @@ function nodeState(phases: string[]) {
<p v-if="running.length > 1">同时运行:<span v-for="task in running.slice(1)" :key="task.taskNo">{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};</span></p>
</template>
<div v-else class="account-empty"><div class="standby-ring"><Pause :size="38" /><span>{{ loading ? '读取中' : !overview ? '未连接' : '待机' }}</span></div><div><span class="account-eyebrow">{{ overview ? '当前没有正在运行的采集' : '连接状态' }}</span><h3>{{ idleReason }}</h3><p>有新任务时自动更新进度。</p><div class="last-result"><Check :size="18" v-if="latestPublication" /><Clock3 :size="18" v-else /><div><strong>{{ latestPublication ? '最近发布结果' : '最近发布结果尚未提供' }}</strong><span v-if="latestPublication">{{ latestPublication.warehouseCode }} · {{ latestPublication.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<br />{{ beijingTime(latestPublication.lastPublishedAt) }}</span></div></div></div></div>
</section><aside class="account-upnext"><span class="account-eyebrow">后续安排</span><h3>{{ !overview ? '暂未取得安排' : upcoming.length ? '等待处理' : '等待新任务' }}</h3><ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b>{{ group.count }} 项</b></li></ul><p v-else>{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p><div v-if="current" class="account-next"><strong>本阶段之后</strong><span>{{ nextStep }}</span></div><div v-if="schedules.length" class="account-next account-schedules"><strong>{{ stale ? '上次自动采集计划' : '下次自动采集计划' }}</strong><span v-for="schedule in schedules" :key="schedule.family + schedule.at">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span><small>计划时间不代表当前任务完成时间。</small></div><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></div>
<section class="account-flow"><div class="account-section-title"><h4><Activity :size="16" />处理阶段</h4><span>只标记已确认的推进状态</span></div><ol :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }"><li v-for="(node, index) in flow" :key="node.name" :data-state="nodeState(node.phases)"><span class="step-index"><Check v-if="nodeState(node.phases) === 'done'" :size="16" /><template v-else>{{ index + 1 }}</template></span><strong>{{ node.name }}</strong><small>{{ nodeState(node.phases) === 'done' ? '已确认完成' : nodeState(node.phases) === 'active' ? '正在处理' : current || !overview ? '等待状态确认' : '待机' }}</small></li></ol></section>
<section class="account-results" v-if="warehouseGroups.length"><div class="account-section-title"><h4><Database :size="16" />各仓最新数据</h4><span>采集进度与报表发布分别确认</span></div><div class="warehouse-results"><article v-for="group in warehouseGroups" :key="group.code"><h4>{{ group.code }}</h4><div v-for="row in group.rows" :key="row.dataFamily" class="warehouse-result"><span>{{ row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<small>{{ row.lastPublishedAt ? beijingTime(row.lastPublishedAt) : '暂无发布结果' }}</small></span><span :class="{ 'result-ready': Boolean(row.lastPublishedAt) }">{{ row.lastPublishedAt ? '已有结果' : '尚未发布' }}<small>{{ row.lastPublishedAt ? label(row.dataFreshness) : '—' }}</small></span></div></article></div></section>
</section><aside class="account-upnext"><span class="account-eyebrow">后续安排</span><h3>{{ !overview ? '暂未取得安排' : upcoming.length ? '等待处理' : '等待新任务' }}</h3><ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b>{{ group.count }} 项</b></li></ul><p v-else>{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p><div v-if="current" class="account-next"><strong>本阶段之后</strong><span>{{ nextStep }}</span></div><div v-if="schedules.length" class="account-next account-schedules"><strong>{{ stale ? '上次自动采集计划' : '下次自动采集计划' }}</strong><span v-for="schedule in schedules" :key="schedule.family + schedule.at">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span><small>计划时间不代表当前任务完成时间。</small></div><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></div>
<footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
</div>
</section>
</template>
<style scoped>
.account-monitor { color: var(--el-text-color-primary); background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius-lg, 12px); overflow: hidden; }
.account-monitor { color: var(--el-text-color-primary); background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius-lg); overflow: hidden; }
.account-header, .account-header > div, .account-now, .account-next, .account-pending, footer { display: flex; align-items: center; gap: var(--admin-space-4); }
.account-header { justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--el-border-color-light); }
h2, h3, p { margin: 0; }
h2 { font-size: var(--admin-font-section); }
h3 { font-size: 24px; margin: var(--admin-space-2) 0; }
.account-body { padding: 24px; }
h3 { font-size: 22px; margin: var(--admin-space-2) 0; }
.account-body { padding: var(--admin-space-6); }
.account-body > p[role="status"] { margin-bottom: var(--admin-space-5); color: var(--el-color-warning-dark-2); }
.account-header span, p, footer, small { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-now { justify-content: space-between; align-items: flex-start; }
.account-eyebrow { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
@@ -107,11 +108,11 @@ h3 { font-size: 24px; margin: var(--admin-space-2) 0; }
.account-pending { flex-wrap: wrap; margin-top: var(--admin-space-5); }
.account-pending small { flex-basis: 100%; }
footer { justify-content: space-between; margin-top: var(--admin-space-5); padding-top: var(--admin-space-4); border-top: 1px solid var(--el-border-color-light); }
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 32px; }
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--admin-space-6); align-items: start; }
.account-primary { min-width: 0; }
.account-brand, .account-controls, .account-live, .account-section-title, h4 { display: flex; align-items: center; gap: 10px; }
.account-brand > svg { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-3); box-sizing: content-box; border-radius: var(--admin-radius-lg); }
.account-header { background: var(--el-bg-color); padding: var(--admin-space-5) var(--admin-space-6); }
.account-header { background: var(--el-bg-color); }
.account-header h2 { margin-bottom: 4px; }
.account-live { color: var(--el-text-color-secondary); background: var(--el-fill-color-light); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius-lg); }
[data-running="true"] .account-live { color: var(--el-color-success-dark-2); background: var(--el-color-success-light-9); }
@@ -119,19 +120,19 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi
[data-running="true"] .account-live i { background: var(--el-color-success); animation: signal 2s ease-in-out infinite; }
.account-controls :deep(.el-button) { gap: 8px; }
.account-upnext { border-left: 1px solid var(--el-border-color-light); padding-left: 24px; }
.account-upnext h3 { font-size: 20px; margin: 8px 0 18px; }
.account-upnext h3 { font-size: 20px; margin: 8px 0 12px; }
.account-upnext ul { padding: 0; margin: 0; list-style: none; }
.account-upnext li { display: flex; justify-content: space-between; padding: 12px 0; gap: 8px; border-bottom: 1px solid var(--el-border-color-light); font-size: var(--admin-font-caption); }
.account-upnext > small { display: block; margin-top: 18px; line-height: 1.7; }
.account-next { display: grid; padding: 14px; margin-top: 20px; border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-radius: var(--admin-radius); font-size: var(--admin-font-caption); line-height: 1.8; }
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: 32px; min-height: 220px; }
.account-next { display: grid; padding: 14px; margin-top: 12px; border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-radius: var(--admin-radius); font-size: var(--admin-font-caption); line-height: 1.8; }
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: var(--admin-space-6); min-height: 190px; }
.standby-ring { width: 168px; height: 168px; border: 10px solid var(--el-border-color-light); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--el-text-color-secondary); box-sizing: border-box; }
.last-result { display: flex; gap: 12px; padding: 16px; margin-top: 24px; background: var(--el-fill-color-light); border-radius: 6px; }
.last-result > svg { color: var(--el-color-success); }
.last-result strong, .last-result span { display: block; font-size: var(--admin-font-caption); line-height: 1.8; }
.last-result span { color: var(--el-text-color-secondary); }
.account-flow, .account-results { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--el-border-color-light); }
.account-section-title { justify-content: space-between; margin-bottom: 20px; }
.account-flow, .account-results { margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px solid var(--el-border-color-light); }
.account-section-title { justify-content: space-between; margin-bottom: var(--admin-space-5); }
h4 { margin: 0; font-size: var(--admin-font-body); }
.account-section-title > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-flow ol { display: grid; gap: 16px; list-style: none; margin: 0; padding: 0; }
@@ -155,7 +156,8 @@ h4 { margin: 0; font-size: var(--admin-font-body); }
.account-monitor :deep(.collection-count-grid > div) { padding-inline: var(--admin-space-5); }
.account-monitor :deep(.collection-count-grid > div + div) { border-left: 1px solid var(--el-border-color-light); }
.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; font-weight: 600; color: var(--el-text-color-primary); overflow-wrap: anywhere; }
.account-monitor :deep(.collection-count-grid > div:nth-child(2) dd) { color: var(--el-color-primary); }
.account-monitor :deep(.collection-count-grid > div:nth-child(2) dd) { color: var(--el-color-success-dark-2); }
.account-monitor :deep(.collection-count-grid > div:nth-child(3) dd) { color: var(--el-color-warning-dark-2); }
.account-monitor :deep(.collection-count-grid dd small) { display: block; margin-top: var(--admin-space-2); font-weight: 400; }
.account-monitor :deep(.collection-progress) { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--admin-space-3) var(--admin-space-6); align-items: center; }
.account-monitor :deep(.collection-stage-counts) { display: contents; }
@@ -164,6 +166,8 @@ h4 { margin: 0; font-size: var(--admin-font-body); }
.account-monitor :deep(.collection-progress > p) { grid-column: 1 / -1; }
.account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); }
.account-monitor :deep(.collection-estimate) { grid-column: 2; margin: 0; padding: var(--admin-space-4) var(--admin-space-5); border-radius: var(--admin-radius); background: var(--el-color-primary-light-9); }
.account-monitor :deep(.collection-estimate > strong) { width: 100%; color: var(--el-color-primary); }
.account-monitor :deep(.collection-estimate > span) { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
@keyframes signal { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } }
</style>
@@ -4,7 +4,7 @@ import AnalyticsChart from './AnalyticsChart.vue'
import { progressGaugeOption } from '../dashboardChartOptions'
import type { Progress } from '../types'
import { beijingTime, phaseText, quantityText, unitText } from '../state'
const props = defineProps<{ progress: Partial<Progress> | null | undefined; taskNo: string; stale?: boolean; compact?: boolean }>()
const props = defineProps<{ progress: Partial<Progress> | null | undefined; taskNo: string; stale?: boolean; compact?: boolean; running?: boolean }>()
const identity = computed(() => JSON.stringify([props.taskNo, props.progress?.progressScope, props.progress?.countBasis, props.progress?.attemptNo, props.progress?.phase, props.progress?.scanRound, props.progress?.sliceFrom, props.progress?.sliceUntil, props.progress?.unit]))
const changedTotal = ref(false)
watch([identity, () => props.progress?.total], ([id, total], [oldId, oldTotal]) => { changedTotal.value = id === oldId ? changedTotal.value || (oldTotal != null && total != null && total !== oldTotal) : false })
@@ -16,14 +16,15 @@ const counts = computed(() => {
return { remaining: total - done, percent: Number(BigInt(done) * 1000n / BigInt(total)) / 10 }
})
const scope = computed(() => props.progress?.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : props.progress?.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认')
const option = computed(() => progressGaugeOption(counts.value?.percent ?? null))
const indeterminate = computed(() => Boolean(props.running && !props.stale && !counts.value))
const option = computed(() => progressGaugeOption(counts.value?.percent ?? null, indeterminate.value))
const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTORY', 'DISCOVERY'].includes(props.progress?.phase ?? ''))
</script>
<template>
<section class="collection-stage-counts" aria-label="当前阶段数量">
<div class="collection-ring" :role="counts ? 'progressbar' : 'img'" :aria-label="`${scope} · ${phaseText(progress?.phase)} · ${counts ? '当前阶段' : '总量待确认'}`" :aria-valuenow="counts?.percent" :aria-valuemin="counts ? 0 : undefined" :aria-valuemax="counts ? 100 : undefined">
<AnalyticsChart :option="option" class="ring-chart" aria-hidden="true" />
<div class="ring-label"><strong>{{ counts ? `${counts.percent.toFixed(1)}%` : '—' }}</strong><span>{{ stale ? '上次阶段记录' : '当前阶段' }}</span></div>
<AnalyticsChart :option="option" merge-updates class="ring-chart" :class="{ 'is-indeterminate': indeterminate }" aria-hidden="true" />
<div class="ring-label"><strong>{{ counts ? `${counts.percent.toFixed(1)}%` : indeterminate ? '执行中' : '—' }}</strong><span>{{ stale ? '上次阶段记录' : '当前阶段' }}</span></div>
</div>
<div :class="{ 'compact-counts': compact }">
<p v-if="!compact">{{ phaseText(progress?.phase) }} · {{ scope }}<span v-if="progress?.scanRound != null"> · 第 {{ progress.scanRound }} 轮</span></p>
@@ -41,6 +42,9 @@ const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTO
.collection-ring { width: 156px; height: 156px; position: relative; }
.compact-counts { min-width: 0; }
.ring-chart { width: 100%; height: 100%; }
.ring-chart.is-indeterminate { animation: collection-spin 2.4s linear infinite; }
@keyframes collection-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .ring-chart.is-indeterminate { animation: none; } }
.ring-label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.ring-label strong { font-size: 28px; font-weight: 500; font-variant-numeric: tabular-nums; }
.collection-count-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); margin: var(--admin-space-4) 0; }
@@ -11,6 +11,22 @@ const detail = { task, sharedUpstream: source, observedAt: '2026-10-13T01:02:00Z
const wrappers: ReturnType<typeof mount>[] = []
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
describe('collection task progress evidence', () => {
it('animates unknown totals only during active visible execution and retains the chart across polls', async () => {
const unknown = { ...source, progress: { ...progress, totalKnown: false, total: null } }
const w = mount(CollectionTaskProgress, { props: { task: unknown, visible: true } }); wrappers.push(w)
const chart = w.get('.ring-chart').element
expect(w.get('.ring-chart').classes()).toContain('is-indeterminate')
expect(w.get('.ring-label strong').text()).toBe('执行中')
await w.setProps({ task: { ...unknown, progress: { ...unknown.progress, completed: 46 } } })
expect(w.get('.ring-chart').element).toBe(chart)
for (const state of [{ stale: true }, { stale: false, visible: false }, { visible: true, task: { ...unknown, taskStatus: 'RETRY_WAIT' } }]) {
await w.setProps(state)
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
}
await w.setProps({ task: source, visible: true, stale: false })
expect(w.get('.ring-label strong').text()).toBe('12.1%')
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
})
it('explains a confirmed normal batch continuation without hiding failures or protection blocks', async () => {
const yielded: Task = { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'RETRY_SCHEDULED', latestAttemptNo: 2,
latestAttempt: { attemptNo: 2, status: 'YIELDED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: null, failureSummary: null } }
@@ -73,7 +73,7 @@ function stageState(node: { title: string; scope: string; phases: string[] }) {
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
</template>
<p v-if="!compact && (task.blockingReason || task.latestAttempt?.failureSummary)" role="status">{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}</p>
<CollectionProgressGauge :progress="progress" :task-no="workingTask.taskNo" :stale="stale" :compact="compact" />
<CollectionProgressGauge :progress="progress" :task-no="workingTask.taskNo" :running="!stale && visible !== false && !progress?.stageFinishedAt && ['RUNNING', 'APPLYING'].includes(workingTask.taskStatus)" :stale="stale" :compact="compact" />
<div class="collection-estimate"><strong>{{ compact && !stale && visible !== false && task.blockingReason ? blockingText : estimateText }}</strong><span v-if="compact && task.latestAttempt?.failureSummary" role="status">{{ task.latestAttempt.failureSummary }}</span><span v-if="estimate">最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}</span><span>仅当前阶段;整个任务完成时间尚未估算。</span></div>
<p>任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}<small>(含排队与重试)</small></p>
<template v-if="!compact">
@@ -8,7 +8,7 @@ const explanations: Record<string, string> = {
PHYSICAL_INVENTORY_UNKNOWN: '实物库存缺失、异常或已过期,需核对库存采集结果。',
AVAILABLE_INVENTORY_UNKNOWN: '正品可售库存缺失、异常或已过期,暂不能计算消化天数。',
UNIT_UNKNOWN_OR_CONFLICT: '销量或库存的数量单位未确认或不一致。',
SALES_WINDOW_UNAVAILABLE: '近30个完整日的正式销量不可用,需核对覆盖范围、销量状态与修订核对结果。',
SALES_WINDOW_UNAVAILABLE: '所选区间的完整销量不可用,需核对覆盖范围、销量状态与修订核对结果。',
OBSERVATION_INSUFFICIENT: '缺少统计窗口起点及以前的本仓有效出库或库存观察依据;仅补齐窗口内销量不一定满足此条件。',
METRIC_DEPENDENCY_UNKNOWN: '计算所需资料仍有未知项,需进入库存分析核对具体SKU。',
}
@@ -24,12 +24,12 @@ const causes = computed(() => Object.entries(props.data.coverageDistribution.unk
<tr><th scope="row">榜单要求的销量区间</th><td>{{ data.longCoverageSkus.windowStartDate }} 至 {{ data.longCoverageSkus.windowEndDateExclusive }}(截止日不含)</td></tr>
<tr><th scope="row">已发布销量覆盖</th><td>{{ beijingTime(data.context.salesCoveredFrom) }} 至 {{ beijingTime(data.context.salesCoveredUntil) }}(截止时刻不含)</td></tr>
<tr><th scope="row">完整日覆盖</th><td v-if="data.context.completeDayFrom && data.context.completeDayUntil">{{ data.context.completeDayFrom }} 至 {{ data.context.completeDayUntil }}(截止日不含)</td><td v-else>尚无可确认的完整日覆盖范围</td></tr>
<tr><th scope="row">销量状态</th><td>{{ label(data.context.salesStatus) }};近30日日均:{{ label(data.kpis.averageDailyOutboundQuantity30d.dataStatus) }} · {{ reasonsText(data.kpis.averageDailyOutboundQuantity30d.reasonCodes) }}</td></tr>
<tr><th scope="row">销量状态</th><td>{{ label(data.context.salesStatus) }};所选时段日均:{{ label(data.kpis.averageDailyOutboundQuantity.dataStatus) }} · {{ reasonsText(data.kpis.averageDailyOutboundQuantity.reasonCodes) }}</td></tr>
<tr><th scope="row">库存采集时间</th><td>{{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</td></tr>
<tr><th scope="row">库存状态/有效期</th><td>{{ label(data.context.inventoryStatus) }};有效至 {{ beijingTime(data.context.inventoryFreshUntil) }}</td></tr>
<tr><th scope="row">本次查询提示</th><td>{{ reasonsText(data.context.reasonCodes) }}</td></tr>
<tr><th scope="row">预计消化时间最长</th><td>符合条件 {{ quantityText(data.longCoverageSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.longCoverageSkus.unassessableCount) }} SKU</td></tr>
<tr><th scope="row">近30日无出库库存</th><td>符合条件 {{ quantityText(data.noOutboundSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.noOutboundSkus.unassessableCount) }} SKU</td></tr>
<tr><th scope="row">所选时段无出库库存</th><td>符合条件 {{ quantityText(data.noOutboundSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.noOutboundSkus.unassessableCount) }} SKU</td></tr>
</tbody>
</table>
<h4>全仓库存分布的待核验原因</h4>
@@ -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-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 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.columns ?? []).map((column: { headerSlotName?: string; prop: string; label: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), ...(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: [] }
@@ -26,6 +26,94 @@ 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('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' }
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)
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
const header = w.get(`button[data-column="${prop}"]`)
for (const direction of ['DESC', 'ASC']) {
await header.trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ sortBy: `${metric}_${direction}`, current: 1, snapshotContext: 'token-A', includeOverview: false })
expect(w.findComponent(toolbar).props('modelValue').sortBy).toBe(`${metric}_${direction}`)
expect(header.attributes('aria-label')).toContain(direction === 'ASC' ? '当前升序' : '当前降序')
}
}
await change(w, { baseWindowDays: 7 })
expect(w.get('button[data-column="averageDailyOutboundQuantity"]').text()).toContain('7日日均')
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ baseWindowDays: 7, sortBy: 'AVERAGE_DAILY_DESC' })
})
it('keeps the successful sort marker and page when a header query fails', async () => {
const w = page(); await flushPromises()
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
mocks.turnover.mockRejectedValueOnce(new Error('offline'))
await w.get('button[data-column="outbound15"]').trigger('click'); await flushPromises()
expect(w.findComponent(table).props('page')).toBe(2)
expect(w.get('button[data-column="physicalQuantity"]').attributes('aria-label')).toContain('当前降序')
expect(w.get('button[data-column="outbound15"]').attributes('aria-label')).not.toContain('当前')
expect(w.findComponent(table).props('data')).toHaveLength(1)
})
it('supports owner numeric sorting and restores the default header on reset', async () => {
const w = page(); await flushPromises()
await change(w, { groupBy: 'OWNER' })
expect(w.find('button[data-column="inventoryCoverageDays"]').exists()).toBe(false)
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sortBy: 'AVERAGE_DAILY_DESC' })
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
expect(w.get('button[data-column="physicalQuantity"]').attributes('aria-label')).toContain('当前降序')
})
it('keeps published historical averages visible while explaining pending rebuild separately', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.context.analyticsContext.rebuildRequired = true
result.context.analyticsContext.salesPublishedAt = '2026-10-14T01:40:24Z'
result.charts!.averageDaily = row.windows.map(window => ({ ...window,
averageDailyOutboundQuantity: window.days === 7 ? 1234.567143 : window.days === 15 ? 2100 : null,
outboundQuantity: window.days === 30 ? null : window.outboundQuantity,
completeDayCount: Math.min(window.days, 18), unknownDayCount: window.days === 30 ? 12 : 0,
dataStatus: window.days === 30 ? 'PARTIAL' : 'COMPLETE',
}))
return result
})
const w = page(); await flushPromises()
expect(w.text()).toContain('1,234.57 件/日')
expect(w.text()).toContain('2,100 件/日')
expect(w.text()).toContain('完整 18 / 30 日')
expect(w.text()).toContain('已发布销量版本计算')
expect(w.text()).toContain('新采集数据待重算')
expect(w.findAllComponents({ name: 'AnalyticsChart' }).length).toBeGreaterThan(0)
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q))
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(w.text()).not.toContain('新采集数据待重算')
})
it('queries fast and slow tabs with a visible default threshold, preserving custom values', async () => {
const w = page(); await flushPromises()
const select = async (label: string) => { await w.findAll('.turnover-focus button').find(b => b.text() === label)!.trigger('click'); await flushPromises() }
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 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 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('消化较快')
})
it('queries the remaining focus tabs and uses owner-compatible sorting', async () => {
const w = page(); await flushPromises()
await change(w, { groupBy: 'OWNER' })
for (const [label, focus, sortBy] of [['近30日无出库', 'NO_OUTBOUND_30D', 'NO_OUTBOUND_PHYSICAL_DESC'], ['待评估', 'UNASSESSABLE', 'PHYSICAL_DESC'], ['消化较慢', 'COVERAGE_GT', 'PHYSICAL_DESC'], ['消化较快', 'COVERAGE_LE', 'PHYSICAL_DESC'], ['全部', 'ALL', 'PHYSICAL_DESC']]) {
await w.findAll('.turnover-focus button').find(b => b.text() === label)!.trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', focus, sortBy, current: 1 })
}
})
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) => {
@@ -15,7 +15,8 @@ import { analyticsApi } from '../api/analytics'
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 { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
import { totalText, turnoverTotalNote, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import AnalyticsChart from './AnalyticsChart.vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
@@ -37,7 +38,6 @@ let linkedSnapshot: string | undefined
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse } | null = null
let initialized = false, active = true
const warehouses = computed(() => [...new Map((runtime.value?.warehouses ?? []).map(w => [w.warehouseCode, { value: w.warehouseCode, label: w.warehouseName || w.warehouseCode }])).values()])
const sortLabels: Record<TurnoverSort, string> = { PHYSICAL_DESC: '实物件数最多', AVAILABLE_DESC: '可售件数最多', OUTBOUND_7_DESC: '完整7日出库最多', OUTBOUND_15_DESC: '完整15日出库最多', OUTBOUND_30_DESC: '完整30日出库最多', COVERAGE_ASC: '可售天数从少到多', COVERAGE_DESC: '可售天数从多到少', TURNOVER_DESC: '期间件数周转次数最多', NO_OUTBOUND_PHYSICAL_DESC: '已确认无出库实物最多' }
const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', label: '仓库', type: 'select', width: 170, options: warehouses.value, props: { clearable: false } },
{ key: 'endDate', label: '截止日', type: 'date', width: 150, props: { valueFormat: 'YYYY-MM-DD', clearable: false, disabledDate: (date: Date) => date.toLocaleDateString('en-CA') > yesterdayBeijing() } },
@@ -47,7 +47,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_30D', label: '近30日无出库' }, { 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: turnoverSorts.filter(s => filters.value.groupBy === 'OWNER' ? !['COVERAGE_ASC', 'COVERAGE_DESC'].includes(s) : s !== 'NO_OUTBOUND_PHYSICAL_DESC').map(value => ({ value, label: sortLabels[value] })) },
{ 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') },
])
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
@@ -60,7 +60,19 @@ const columns = computed<DataTableColumn[]>(() => [
...(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 }] : []),
])
].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined })))
function columnSortDirection(prop: string) {
const metric = turnoverSortColumns[prop]
if (!metric) return undefined
return applied.value?.sortBy === `${metric}_ASC` ? 'ASC' : applied.value?.sortBy === `${metric}_DESC` ? 'DESC' : undefined
}
async function sortColumn(prop: string) {
const metric = turnoverSortColumns[prop]
if (!metric || loading.value) return
const sortBy: TurnoverSort = `${metric}_${columnSortDirection(prop) === 'DESC' ? 'ASC' : 'DESC'}`
filters.value = { ...filters.value, sortBy }
await search()
}
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 },
@@ -84,7 +96,9 @@ function restoreThreshold(warehouse: string) {
return ''
}
function quickFocus(focus: TurnoverQuery['focus'], sortBy: TurnoverSort) {
filters.value = { ...filters.value, focus, sortBy }; void search()
const needsThreshold = ['COVERAGE_GT', 'COVERAGE_LE'].includes(focus)
const threshold = filters.value.coverageDaysThreshold
filters.value = { ...filters.value, focus, sortBy, coverageDaysThreshold: needsThreshold && (threshold == null || String(threshold).trim() === '') ? 30 : threshold }; void search()
}
function query(): TurnoverQuery {
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: String(filters.value.endDate || ''), baseWindowDays: Number(filters.value.baseWindowDays) as TurnoverQuery['baseWindowDays'], 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 }
@@ -198,6 +212,12 @@ 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 #sortHeader="{ column }">
<button type="button" class="turnover-sort" :data-column="column.property" :disabled="loading" :aria-label="`${column.label}:${columnSortDirection(column.property) === 'ASC' ? '当前升序,' : columnSortDirection(column.property) === 'DESC' ? '当前降序,' : ''}点击${columnSortDirection(column.property) === 'DESC' ? '升序' : '降序'}排列`" @click.stop="sortColumn(column.property)">
{{ column.label }}
<span class="turnover-sort-arrows" aria-hidden="true"><span :class="{ active: columnSortDirection(column.property) === 'ASC' }">▲</span><span :class="{ active: columnSortDirection(column.property) === 'DESC' }">▼</span></span>
</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>
@@ -216,12 +236,26 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<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 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>
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" 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></header><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 v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
<section><header class="turnover-chart-header"><h3>7/15/30 日日均出库</h3><ChartDetailsDialog inline button-label="查看明细" 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></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><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></section>
</div>
</template>
<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>
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_30D'" @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</button>
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</button>
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</button>
<button type="button" :aria-pressed="applied?.focus === 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</button>
</nav>
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
<label>快慢阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="快慢阈值天数" @keydown.enter.prevent="search()" /> 天</label>
<el-button @click="search()">应用</el-button>
<span>预计可售天数大于阈值为较慢,不超过阈值为较快;首次使用默认30天,可修改。数据不足的商品请查看“待评估”。</span>
</div>
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
<p v-else-if="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
<p v-else-if="error" class="turnover-filter-feedback" role="alert">筛选未更新,当前保留上次结果。{{ error.message }}</p>
</template>
<template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template>
<template #owner="{ row }"><el-button v-if="ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)">{{ castRow(row).customerName || castRow(row).customerCode }}</el-button><span v-else>{{ castRow(row).customerName || castRow(row).customerCode }}</span></template>
@@ -237,15 +271,23 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<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="关闭" 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>
<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>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。</p></template></AppDialog>
</template>
</AdminPage>
</template>
<style scoped>
.turnover-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.turnover-sort:disabled { cursor: wait; }
.turnover-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; }
.turnover-sort-arrows { display: inline-flex; flex-direction: column; color: var(--el-text-color-placeholder); font-size: 9px; line-height: 10px; }
.turnover-sort-arrows .active { color: var(--el-color-primary); }
.turnover-page :deep(.data-table--integrated > .data-table__summary) { padding: 12px 20px 0; }
.turnover-page :deep(.data-table--integrated > .el-table) { width: calc(100% - 40px); margin-inline: 20px; }
.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: 4px 16px 12px; }
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 0; 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: 4px 0 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); }
@@ -253,19 +295,30 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
.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-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 0 12px; align-items: stretch; }
.turnover-charts section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
.turnover-chart-empty { flex: 1 1 0; min-height: 0; padding: 16px; text-align: center; }
.turnover-chart-header { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.turnover-chart-header h3 { margin: 0; }
.turnover-chart-header :deep(.chart-disclosure) { flex-shrink: 0; }
.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-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-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; }
.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; }
.turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.turnover-threshold { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 0; font-size: var(--admin-font-caption); }
.turnover-threshold label { display: flex; align-items: center; gap: 8px; }
.turnover-threshold input { width: 76px; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); font: inherit; color: var(--el-text-color-primary); }
.turnover-threshold > span { color: var(--el-text-color-secondary); }
.turnover-filter-feedback { margin: 8px 0; color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
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; }
@@ -23,11 +23,12 @@ export function barChartOption(rows: BarRow[], unit: string, title: string, rank
label: { show: true, position: 'right', formatter: params => quantityText(rows[params.dataIndex]?.value) } }],
}
}
export function progressGaugeOption(percent: number | null): EChartsOption {
return { animation: false, series: [{ type: 'gauge', startAngle: 90, endAngle: -270, radius: '86%', min: 0, max: 100,
pointer: { show: false }, progress: { show: percent != null, roundCap: true, width: 10 },
export function progressGaugeOption(percent: number | null, running = false): EChartsOption {
const animate = typeof window !== 'undefined' && !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
return { animation: animate && percent != null, animationDuration: 0, animationDurationUpdate: 700, animationEasingUpdate: 'cubicOut', series: [{ id: 'collection-progress', type: 'gauge', startAngle: 90, endAngle: -270, radius: '86%', min: 0, max: 100,
pointer: { show: false }, progress: { show: percent != null || running, roundCap: true, width: 10 },
axisLine: { lineStyle: { width: 10, color: [[1, '#e8eaf5']] } }, itemStyle: { color: '#6879ee' },
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, title: { show: false }, detail: { show: false },
silent: true, data: percent == null ? [] : [{ value: percent }],
silent: true, data: percent == null && !running ? [] : [{ value: percent ?? 18 }],
}] }
}
@@ -2,13 +2,14 @@ import { describe, expect, it } from 'vitest'
import { buckets, expired, inventoryExpired, parseDashboardDrilldown, previousDate } from './dashboardNavigation'
import type { QueryContext } from './types'
const context: QueryContext = { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2026-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13', inventoryDrilldownExpiresAt: '2026-10-13T00:10:00Z' }
const context: QueryContext = { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2026-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13', rangeStartDate: '2026-09-29', rangeEndDateExclusive: '2026-10-14', inventoryDrilldownExpiresAt: '2026-10-13T00:10:00Z' }
const base = { from: 'overview', warehouseCode: 'WH-A', context: JSON.stringify(context), overviewReturn: JSON.stringify({ rangePreset: 'LAST_15_DAYS', scroll: 650 }) }
describe('dashboard exact drilldown contract', () => {
it.each(buckets)('maps $code to exact server bucket rather than an attention approximation', ({ code }) => {
const parsed = parseDashboardDrilldown({ ...base, coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' }, 'inventory')!
expect(parsed.inventory).toMatchObject({ coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', drilldownFrom: 'DASHBOARD', attention: 'ALL' })
expect(parsed.context).toEqual(context)
expect(parsed).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-09-29', endDate: '2026-10-13' })
expect(parsed.returnTo).toEqual({ rangePreset: 'LAST_15_DAYS', scroll: 650 })
})
it('preserves exact owner/SKU, maps hot enum and the independent finite candidate selector', () => {
@@ -20,10 +21,10 @@ describe('dashboard exact drilldown contract', () => {
{ coverageBucket: 'BAD', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' },
{ coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'BAD' },
{ bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' },
{ coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', attention: 'NO_OUTBOUND_30D' },
{ coverageSelection: 'FINITE_POSITIVE', attention: 'NO_OUTBOUND_30D' },
{ coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', attention: 'NO_OUTBOUND_IN_WINDOW' },
{ coverageSelection: 'FINITE_POSITIVE', attention: 'NO_OUTBOUND_IN_WINDOW' },
{ coverageSelection: 'FINITE_POSITIVE', coverageBucket: 'UNKNOWN', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' },
{ context: '{}' }, { warehouseCode: '' }, { drilldownFrom: 'OTHER' },
{ context: '{}' }, { context: JSON.stringify({ ...context, rangeStartDate: undefined }) }, { context: JSON.stringify({ ...context, rangeEndDateExclusive: '2026-09-01' }) }, { warehouseCode: '' }, { drilldownFrom: 'OTHER' },
])('rejects malformed/conflicting parameters without widening to the whole warehouse: %j', invalid => {
expect(() => parseDashboardDrilldown(Object.fromEntries(Object.entries({ ...base, ...invalid }).filter(([, value]) => value !== undefined)) as Record<string, string>, 'inventory')).toThrow()
})
@@ -5,7 +5,7 @@ import { validateCustomRange } from './state'
export const buckets: { code: CoverageBucket; label: string }[] = [
{ code: 'DAYS_0_30', label: '0~30天(含30)' }, { code: 'DAYS_30_60', label: '30~60天(含60)' },
{ code: 'DAYS_60_90', label: '60~90天(含90)' }, { code: 'DAYS_GT_90', label: '超过90天' },
{ code: 'NO_OUTBOUND_30D', label: '近30日无出库' }, { code: 'NO_AVAILABLE', label: '无可售库存' }, { code: 'UNKNOWN', label: '库存或销量待核验' },
{ code: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { code: 'NO_AVAILABLE', label: '无可售库存' }, { code: 'UNKNOWN', label: '库存或销量待核验' },
]
export const rangeOptions: { value: RangePreset; label: string }[] = [{ value: 'TODAY', label: '今日' }, { value: 'LAST_3_DAYS', label: '近3天' }, { value: 'THIS_WEEK', label: '本周' }, { value: 'LAST_15_DAYS', label: '近15天' }, { value: 'THIS_MONTH', label: '本月' }, { value: 'CUSTOM', label: '自定义' }]
export interface OverviewReturn { rangePreset: RangePreset; startDate?: string; endDate?: string; scroll: number }
@@ -81,15 +81,19 @@ export function parseDashboardDrilldown(query: LocationQuery, mode: 'sales' | 'i
} else {
if (query.drilldownFrom !== undefined && scalar(query, 'drilldownFrom') !== 'DASHBOARD') throw new Error('库存下钻来源枚举无效')
if (['analysis', 'rangePreset', 'startDate', 'endDate', 'windowDays'].some(key => query[key] !== undefined)) throw new Error('库存下钻不能携带销量日期条件')
result.startDate = context.rangeStartDate ?? ''
result.endDate = context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : ''
if (validateCustomRange(result.startDate, result.endDate, context.anchorDate)) throw new Error('库存下钻缺少有效的原图统计区间')
result.rangePreset = 'CUSTOM'
const bucket = scalar(query, 'coverageBucket')
const rule = scalar(query, 'bucketRuleVersion')
const selection = scalar(query, 'coverageSelection')
const attention = scalar(query, 'attention') || 'ALL'
const sort = scalar(query, 'sortBy') || 'AVAILABLE_DESC'
if (!['ALL', 'NO_OUTBOUND_30D'].includes(attention) || !['AVAILABLE_DESC', 'COVERAGE_DAYS_DESC', 'PHYSICAL_DESC', 'OUTBOUND_30D_ASC'].includes(sort)) throw new Error('库存下钻枚举无效')
if (!['ALL', 'NO_OUTBOUND_IN_WINDOW'].includes(attention) || !['AVAILABLE_DESC', 'COVERAGE_DAYS_DESC', 'PHYSICAL_DESC', 'OUTBOUND_ASC'].includes(sort)) throw new Error('库存下钻枚举无效')
if ((bucket && (!buckets.some(item => item.code === bucket) || rule !== 'COVERAGE_DISTRIBUTION_V1' || selection || attention !== 'ALL')) || (!bucket && rule) || (selection && (selection !== 'FINITE_POSITIVE' || attention !== 'ALL'))) throw new Error('库存分组条件无效或互斥')
result.inventory = { attention: attention as 'ALL' | 'NO_OUTBOUND_30D', sortBy: sort as BusinessQuery['sortBy'], coverageBucket: bucket ? bucket as CoverageBucket : undefined, bucketRuleVersion: bucket ? 'COVERAGE_DISTRIBUTION_V1' : undefined, coverageSelection: selection ? 'FINITE_POSITIVE' : undefined, drilldownFrom: 'DASHBOARD' }
result.summary += ` · ${buckets.find(item => item.code === bucket)?.label ?? (selection ? '有限正可售天数,从高到低' : attention === 'NO_OUTBOUND_30D' ? '近30日无出库,实物量从高到低' : '库存')}`
result.inventory = { attention: attention as 'ALL' | 'NO_OUTBOUND_IN_WINDOW', sortBy: sort as BusinessQuery['sortBy'], coverageBucket: bucket ? bucket as CoverageBucket : undefined, bucketRuleVersion: bucket ? 'COVERAGE_DISTRIBUTION_V1' : undefined, coverageSelection: selection ? 'FINITE_POSITIVE' : undefined, drilldownFrom: 'DASHBOARD' }
result.summary += ` · ${result.startDate} 至 ${result.endDate} · ${buckets.find(item => item.code === bucket)?.label ?? (selection ? '有限正可售天数,从高到低' : attention === 'NO_OUTBOUND_IN_WINDOW' ? '所选时段无出库,实物量从高到低' : '库存')}`
}
if (sku) result.summary += ` · ${customerCode ? `${customerCode} / ` : ''}${sku}`
return result
+23 -3
View File
@@ -4,6 +4,9 @@
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
| --- | --- | --- | --- |
| 库存周转 | 库存过期、采集失败与未知总量 | 2026-10-14 用户库存空白截图及本轮修复确认:沿现有同页提示展示过期时间、同仓失败原因与任务号;整体刷新同步只读采集状态,失败独立提示;指标主数值未知显示“—”,小计留辅助说明;原样本证据弹框解释库存日样本与出库历史的区别 | 保留原数据采集入口与弹框承载,不新增写入操作;回归未知/真实0、切仓和刷新恢复,待人工1920×1080、100%截图确认 |
| /self-operated/inventory | 页签与操作栏整合 | 2026-10-14 用户截图 `codex-clipboard-784338aa-b985-4eb7-9f5e-b14f87f6d5e8.png`:页签独立在外、操作栏左侧空白;两个视图的切换页签统一放入DataTable的toolbar-start,操作按钮保留右侧,筛选紧接下方;阈值提示缩短为“天数阈值(天)” | 保留视图选中态、切仓与原图下钻;不改接口与计算,待人工1920×1080、100%截图确认 |
| /self-operated/collection | 原型排版融入后台风格 | 用户截图 `codex-clipboard-9f9be8ba-792e-4f17-a07b-6ac38531e294.png`:保留左侧进度环、中部三项数量和预计时间、右侧后续安排、底部阶段流程;使用后台浅色卡片与蓝紫主色,完成/剩余采用绿/橙强调 | 压缩空态与区块间距,读取失败不显示阶段正在处理;刷新仍为原地只读操作,不新增配置或详情入口;待人工1920×1080、100%截图确认 |
| /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%截图确认 |
@@ -12,15 +15,15 @@
| /self-operated/collection | 单账号运行首屏 | 2026-10-13 最新实际页面及原型截图:首屏改为账号活动优先,环形进度、总量/完成/剩余及阶段预计时间;移除原筛选、配置、历史、详情和恢复表单,只读账号概览;待机保留阶段、最近发布及各仓结果 | 按用户原型排版,使用现有 Admin 白色卡片、主题强调色及统一按钮,无详情弹框;本行覆盖下方旧采集页面承载记录;后续流程不冒充调度顺序,有界待处理摘要不冒充完整队列;待人工1920×1080、100%截图确认 |
| 销量/库存 | 默认仓库与重置 | 2026-10-13 用户确认进入页面即按仓库显示:无传入仓库时自动选中接口返回的首个可用仓库并查询;链接带入仓库优先;重置保留当前仓库并重新查询 | 保留切仓与迟到响应隔离;没有可用仓库不发无仓库查询;自动回归覆盖两页首次进入、链接进入、缓存页返回及重置 |
| /self-operated/sales | 自定义销量日期 | 2026-10-13 用户截图 `codex-clipboard-fac03bb1-6703-467d-b357-5731ea8cfb32.png`:两个单日框分散换行,未完整选择时残留逆序错误和旧结果。原筛选区改为公共QueryToolbar单一日期范围控件,明确开始/结束日期,支持同日;不新增浮层或改变统计口径 | 日期范围整体换行,校验完整范围后查询;草稿修改不改变已查询列表的分页/趋势条件,提交失败不冒充新范围结果;补充自动回归,待人工1920×1080、100%截图确认 |
| /self-operated/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+趋势日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 |
| /self-operated/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+统计日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 |
| /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;ECharts折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 热销随时间筛选按已采集量排名并提示覆盖不足;可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 |
| /self-operated/overview | SKU趋势 | 同设计“图表交互与视觉依据”;1120px居中AppDialog,热销沿所选时段、其它榜沿固定30日,精确货主SKU | 关闭取消请求,不改现有详情承载;待人工截图确认 |
| /self-operated/overview | SKU趋势 | 同设计“图表交互与视觉依据”;1120px居中AppDialog,三个榜单均沿所选时段,精确货主SKU | 关闭取消请求,不改现有详情承载;待人工截图确认 |
| 总览→销量/库存 | 日期/榜单/分桶下钻与返回 | 同设计“精确下钻合同”;目标页验证完整版本、精确筛选及互斥,显示可清除来源摘要,固定本地返回 | 库存时效到期阻止严格下钻;返回恢复趋势范围、版本、滚动;待人工截图确认 |
| /self-operated/sales | 仓库、SKU、日期/榜单查询及分页 | 当前截图:货主控件分散筛选;本轮冻结为单一紧凑筛选区,仓库明确提示必须选择,SKU为主 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;日期/榜单切换和分页保持版本;待人工截图确认 |
| /self-operated/sales | SKU 趋势 | 用户最新截图明确要求详情不再放列表下方;使用1120px居中AppDialog,保留整个日轴、未知断线及跨页SKU入口 | 可关闭/全屏;关闭取消请求、防止迟到回写;待人工截图确认 |
| /self-operated/sales | SKU销量、热销、滞销切换 | 本轮并入统一筛选区的销量类别选择;独立完整日窗口 | 热销独立入口 7/30 日,总览下钻沿所选日期与版本并标明未采齐;滞销 30 日;待人工截图确认 |
| /self-operated/inventory | SKU 库存关注、阈值及排序 | 当前截图:库存筛选散落且货主优先;仓库、SKU、销量窗口、统计截止日、关注、阈值、排序共用 QueryToolbar;移除货主经营概览 | SKU、商品名称靠前,所属货主为一辅助列;分页与排序保留所选窗口及固定版本;待人工1920×1080、100%截图确认 |
| /self-operated/inventory | 10/30天窗口与统计截止日 | 用户确认预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口选择和含当日截止日期放在原筛选区,默认30天、北京时间昨天,可固定历史截止日;切换窗口重置查询及页码 | 窗口出库与日均列名随实际窗口变化;未采齐不以部分销量推算,零销量明确显示“所选窗口无出库”,可售天数只展示服务端结果;10天或历史截止日不提供仅适用默认30天的关注/排序;总览原图下钻仍固定30天;待人工截图确认 |
| /self-operated/inventory | 10/30天窗口与统计截止日 | 用户确认预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口选择和含当日截止日期放在原筛选区,默认30天、北京时间昨天,可固定历史截止日;切换窗口重置查询及页码 | 窗口出库与日均列名随实际窗口变化;未采齐不以部分销量推算,零销量明确显示“所选窗口无出库”,可售天数只展示服务端结果;无出库关注及出库排序跟随实际窗口;总览严格下钻使用原图CUSTOM区间;待人工截图确认 |
| /self-operated/inventory | 实际统计区间与库存观测说明 | 列表提示直接显示服务端实际销量起止日期、含截止日、窗口天数及销量状态;库存采集起止时间单独展示,明确当前库存来自一轮完整分页观测;详细公式仍在统计说明折叠区 | 截止日仅约束销量窗口,不伪装为该日历史库存;未知与零保持区分;待人工截图确认 |
| /self-operated/inventory | SKU 每日趋势 | 与销量页统一居中趋势弹框;点击SKU沿daily接口读取本行货主/SKU,使用当前响应的实际销量区间,排他上界减一天作为含当日结束参数 | 不新增操作列或菜单;保留版本、可关闭/全屏;待人工截图确认 |
| 销量/库存 | 所属货主及同SKU趋势 | 删除独立货主选择、货主翻页和经营概览,不请求owners/ownerOverview;所属货主名称优先、编码可辅助 | 同SKU跨货主保持分行,趋势仍传本行customerCode;不改后端统计键 |
@@ -94,4 +97,21 @@
| 数据采集/后续安排位置(2026-10-14) | 用户截图反馈等待处理被移至阶段栏下方;工作区改动将主区域变为单列 | 恢复左侧当前进度、右侧290px后续安排双列,处理阶段保持下方整行;保留自动采集计划和其他业务信息 | 定向测试通过;待人工1920×1080、100%截图确认 |
| 库存周转/概览与明细(2026-10-14) | 用户截图要求优化样式,并出现对象文本、英文状态、空白出库列 | 概览移出筛选区,四色指标卡区分主数值与核验提示,图表白底分组、空数据提示,快捷筛选显示选中状态;统计说明移入右上角信息气泡。修正 DataTable slotName 契约,趋势及证据弹框根部挂载 | 自动验证另列;待人工1920×1080、100%截图确认 |
| 库存分析/视图切换 | 用户示例要求切换置于搜索内 | 库存周转与库存明细共用下划线导航,经 view-tabs 插槽放入各自表格搜索区顶部;切换保留当前仓库,清除另一视图专属条件 | 待人工1920×1080、100%截图确认 |
| 数据采集/左右内容高度(2026-10-14) | 用户截图显示右侧采集安排撑高主行,左侧进度下出现大块空白 | 处理阶段及各仓结果放入左侧内容列,紧接当前进度;右侧保留等待处理和自动计划并顶部对齐,压缩计划卡间距,避免后续信息等待右栏结束才展示 | 待人工1920×1080、100%截图确认 |
| 库存周转/图表卡片高度与入口(2026-10-14) | 用户截图要求两个明细入口移至右上角、删除卡底留白 | 明细按钮与标题同行靠右;卡片按内容自然高度顶部对齐,不再等高拉伸;货主无缺失时不显示零缺失说明,全部日均未知时用覆盖提示替代空坐标轴 | 待人工1920×1080、100%截图确认 |
| 库存周转/关注筛选(2026-10-14) | 用户反馈按钮样式不统一、快慢点击无变化 | 改下划线 tabs;快捷快慢筛选缺省采用可编辑30天阈值,沿用已有自定义值,非法值在列表旁提示;保留成功查询选中态、失败不冒充已筛选。数据不足仍待评估 | SKU与货主各筛选及阈值回归;视觉待人工截图确认 |
| 库存周转/日均出库覆盖 | 用户反馈已有15天数据却显示完整0天 | 日均图按固定已发布销量版本核对7/15/30日覆盖;后台待重算单独显示发布时间与刷新提示,不混入未发布数据;SKU当前库存评估保留待重算保护 | 回归18日覆盖时7/15可算、30日仍不足及新版刷新;视觉待人工截图确认 |
| 库存周转/数值表头排序 | 用户要求实物、可售、三窗出库、日均及可售天数等可排序 | 表头点击切换降序/升序;扩展数值列同步支持,与排序下拉框共用服务端全量排序;回到第一页并保留查询版本,未知值末尾,失败保留成功排序箭头 | 两种分组、三种日均基准、升降序、跨页、空值与失败回归;视觉待人工1920×1080、100%截图确认 |
| 库存周转/表格筛选间距 | 用户反馈tabs与表格分离、左右边距不一致 | 移除重复的筛选结果独占行及叠加内边距,保留底部分页总数;概览、关注tabs、阈值和表格使用同一20px内容边界,tabs紧接表头 | 待人工1920×1080、100%截图确认 |
| 库存周转/两图固定高度及单位裁切 | 用户最新截图要求等高,并反馈件/日被截断 | 两张卡片固定360px,标题与底部摘要保持内容高度,图表填充剩余空间;货主图随卡片增高以消除底部高度差;日均图顶部预留36px显示单位,未知状态保持同高空态 | 待人工1920×1080、100%截图确认 |
| 数据采集/当前阶段圆环动效 | 用户反馈圆环跳闪、未知总量看似停止 | ECharts Canvas圆环单独启用合并更新及700ms进度过渡;未知总量仅在可见、状态有效且执行中显示旋转指示及执行中文字,不伪造比例;等待、陈旧、隐藏或阶段结束停止动效,遵循减少动态效果偏好 | 状态与轮询回归;视觉待人工截图确认 |
| 经营总览/统一统计时间(2026-10-14) | 用户要求分布、预计消化最长及无出库库存榜跟随顶部搜索时间 | 三榜、分布及日均/无出库指标统一使用所选区间;名称改为“所选时段无出库库存Top10”,明细保留原日期并锁定;库存取当前版本,缺日不填零,30/60/90天分组边界不变 | 日期、分组、排序与下钻回归;待人工1920×1080、100%截图确认 |
@@ -32,6 +32,27 @@ beforeEach(() => {
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
describe('warehouse operating dashboard', () => {
it.each(['intervalOutboundQuantity', 'averageDailyOutboundQuantity', 'physicalQuantity', 'availableQuantity', 'physicalSkuCount', 'noOutboundSkuCount'] as const)('keeps the page usable when the response omits %s and recovers after refresh', async key => {
const invalid = structuredClone(dashboardFixture)
delete (invalid.kpis as Partial<typeof invalid.kpis>)[key]
mocks.dashboard.mockResolvedValue(invalid)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('经营总览指标返回不完整')
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
expect(wrapper.findComponent(query).exists()).toBe(true)
mocks.dashboard.mockResolvedValue(dashboardFixture)
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
expect(wrapper.text()).not.toContain('经营总览指标返回不完整')
})
it('preserves the last valid result when a refreshed response has a null KPI', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity: null } })
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('经营总览指标返回不完整,请更新服务后重新查询;展示上次查询')
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
})
it('offers coverage evidence for both inventory rankings even when they are empty', async () => {
const data = structuredClone(dashboardFixture)
data.longCoverageSkus = { ...data.longCoverageSkus, items: [], eligibleCount: '0', unassessableCount: '2711', dataStatus: 'PARTIAL' }
@@ -151,7 +172,7 @@ describe('warehouse operating dashboard', () => {
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
})
it('keeps inventory present without published sales and never derives a daily average from partial sales', async () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, salesRunId: null, salesScopeHash: null }, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity30d: { ...dashboardFixture.kpis.averageDailyOutboundQuantity30d, value: null, knownSubtotal: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_UNAVAILABLE'] }, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '128', dataStatus: 'COMPLETE' } }, salesTrend: { ...dashboardFixture.salesTrend, points: [] } })
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, salesRunId: null, salesScopeHash: null }, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity: { ...dashboardFixture.kpis.averageDailyOutboundQuantity, value: null, knownSubtotal: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_UNAVAILABLE'] }, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '128', dataStatus: 'COMPLETE' } }, salesTrend: { ...dashboardFixture.salesTrend, points: [] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAll('.dashboard-kpis strong')[1]!.text()).toBe('—')
expect(wrapper.findAll('.dashboard-kpis strong')[2]!.text()).toBe('128')
@@ -167,12 +188,15 @@ describe('warehouse operating dashboard', () => {
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
expect(wrapper.findAllComponents(DashboardBars)).toHaveLength(4)
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.text()).toContain('区间已采集出库量')
expect(wrapper.text()).toContain('已知小计 128')
expect(wrapper.text()).toContain('另 1 SKU 待核验')
expect(wrapper.text()).not.toContain('lingxing-primary')
})
it('changes only trend range while pinning versions, then explicitly refreshes the whole snapshot', async () => {
it('changes the shared date range while pinning versions, then explicitly refreshes the whole snapshot', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
await choose(wrapper, { rangePreset: 'TODAY' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'TODAY', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
@@ -214,7 +238,7 @@ describe('warehouse operating dashboard', () => {
mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
wrapper.findAllComponents(DashboardBars)[1]!.vm.$emit('select', '1'); await flushPromises()
expect(mocks.daily.mock.calls[0]?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', rangePreset: 'CUSTOM', startDate: '2026-09-13', endDate: '2026-10-12', salesRunId: '11' })
expect(mocks.daily.mock.calls[0]?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-13', salesRunId: '11' })
expect(wrapper.findAllComponents(dialog).find(item => item.props('title') === 'SKU每日出库趋势')!.props('width')).toBe('1120px')
for (const modal of wrapper.findAllComponents(dialog)) expect(modal.props('appendToBody')).not.toBeUndefined()
wrapper.findAllComponents(dialog).find(item => item.props('title') === 'SKU每日出库趋势')!.vm.$emit('update:modelValue', false); await flushPromises()
@@ -256,7 +280,7 @@ describe('warehouse operating dashboard', () => {
await chart.findAll('tbody button')[0]!.trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
await findButton(wrapper, '查看Top100').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-09-13', endDate: '2026-10-12' })
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-13' })
expect(JSON.parse(mocks.push.mock.calls.at(-1)?.[0].query.context)).toEqual(dashboardFixture.context)
})
it('expires inventory before sales while retaining stock-independent SKU trends', async () => {
@@ -58,17 +58,17 @@ const contextExpired = computed(() => Boolean(context.value && expired(context.v
const stockExpired = computed(() => Boolean(context.value && inventoryExpired(context.value, now.value)))
const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 220, placeholder: '切换仓库', props: { clearable: false }, options: [...new Map((runtime.value?.warehouses ?? []).map(item => [item.warehouseCode, { value: item.warehouseCode, label: item.warehouseName || item.warehouseCode }])).values()] },
{ key: 'rangePreset', type: 'select', label: '趋势与热销范围', width: 180, props: { clearable: false }, options: rangeOptions },
{ key: 'rangePreset', type: 'select', label: '统计时间范围', width: 180, props: { clearable: false }, options: rangeOptions },
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'dateRange', type: 'daterange' as const, label: '日期范围', width: 300 }] : []),
])
const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: boolean }[] = [
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity30d', label: '近30日日均出库' },
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity', label: '所选时段日均出库' },
{ key: 'physicalQuantity', label: '实物库存', inventory: true }, { key: 'availableQuantity', label: '正品可售', inventory: true },
{ key: 'physicalSkuCount', label: '有库存SKU', inventory: true }, { key: 'noOutboundSkuCount30d', label: '近30日无出库SKU', inventory: true },
{ key: 'physicalSkuCount', label: '有库存SKU', inventory: true }, { key: 'noOutboundSkuCount', label: '所选时段无出库SKU', inventory: true },
]
const metricIcons = [ArrowUpRight, Activity, Boxes, Package, Layers, Clock3]
const metricColors = ['#5575ed', '#3aa9ba', '#8871ce', '#35a987', '#c6923e', '#d67b91']
const rankingNames = [{ key: 'hotSkus', label: '所选时段热销Top10', unit: '件', all: '查看Top100' }, { key: 'longCoverageSkus', label: '预计消化时间最长Top10', unit: '天', all: '查看全部' }, { key: 'noOutboundSkus', label: '近30日无出库库存Top10', unit: '件', all: '查看全部' }] as const
const rankingNames = [{ key: 'hotSkus', label: '所选时段热销Top10', unit: '件', all: '查看Top100' }, { key: 'longCoverageSkus', label: '预计消化时间最长Top10', unit: '天', all: '查看全部' }, { key: 'noOutboundSkus', label: '所选时段无出库库存Top10', unit: '件', all: '查看全部' }] as const
const bucketRows = computed<BarRow[]>(() => buckets.map(bucket => {
const item = data.value?.coverageDistribution.buckets.find(row => row.bucketCode === bucket.code)
return { key: bucket.code, label: bucket.label, value: item?.skuCount ?? null, detail: `已知实物小计 ${quantityText(item?.knownPhysicalQuantity)} 件;实物未知 ${quantityText(item?.unknownPhysicalSkuCount)} SKU` }
@@ -98,6 +98,13 @@ async function load(refresh = false) {
const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, rangePreset: range, ...contextParams(pinned), ...(range === 'CUSTOM' ? { startDate: start, endDate: end } : {}) }
const result = await analyticsApi.dashboard(params, request.signal)
if (!request.current()) return
if (!result?.context || metricNames.some(({ key }) => {
const metric = result?.kpis?.[key]
return metric == null || typeof metric !== 'object' || !('value' in metric)
})) {
error.value = `经营总览指标返回不完整,请更新服务后重新查询${data.value ? ';展示上次查询' : ''}`
return
}
if (pinned && !sameContext(pinned, result.context)) { error.value = '查询版本不一致,请整体刷新经营总览'; return }
data.value = result; context.value = result.context; appliedRange = { rangePreset: range, startDate: range === 'CUSTOM' ? start : undefined, endDate: range === 'CUSTOM' ? end : undefined, scroll: 0 }; newData.value = false
if (restoreScroll !== null) { await nextTick(); window.scrollTo({ top: restoreScroll }); restoreScroll = null }
@@ -135,7 +142,7 @@ function allRanking(key: typeof rankingNames[number]['key']) {
drill('sales', { analysis: 'HOT', rangePreset: 'CUSTOM', startDate: data.value.hotSkus.windowStartDate, endDate: previousDate(data.value.hotSkus.windowEndDateExclusive) })
}
else if (key === 'longCoverageSkus') drill('inventory', { coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' })
else drill('inventory', { attention: 'NO_OUTBOUND_30D', sortBy: 'PHYSICAL_DESC' })
else drill('inventory', { attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'PHYSICAL_DESC' })
}
async function showTrend(item: DashboardItem, ranking: DashboardRanking) {
if (!context.value || expired(context.value)) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
@@ -181,7 +188,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<template #toolbar-end>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间">
<template #reference><el-button circle aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
<div class="dashboard-data-notes admin-density"><p>时间筛选同时影响区间出库、日趋势与热销榜;热销按所选时段已采集量排名,未采齐会明确提示。库存消化与无出库评估仍需近30个完整日。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p><p v-else>查询仓库数据后显示对应采集时间。</p></div>
<div class="dashboard-data-notes admin-density"><p>时间筛选统一影响出库量、日均、趋势、库存消化分布和三个榜单。日均按所选区间天数计算;未采齐或含今日时,热销展示已采集量,库存消化和无出库评估保持待核验。库存使用本次查询的有效库存快照,不代表所选日期的历史库存。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p><p v-else>查询仓库数据后显示对应采集时间。</p></div>
</el-popover>
</template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template>
@@ -197,7 +204,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<template v-if="data">
<div class="dashboard-kpis" aria-label="本仓经营指标">
<section v-for="(metric, index) in metricNames" :key="metric.key" :style="{ '--metric-color': metricColors[index], '--reveal-delay': `${index * 45}ms` }"><div class="dashboard-metric-heading"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><component :is="metricIcons[index]" :size="18" aria-hidden="true" /></div><div class="dashboard-metric-value"><strong>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity30d' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span></div><p class="dashboard-metric-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
<section v-for="(metric, index) in metricNames" :key="metric.key" :style="{ '--metric-color': metricColors[index], '--reveal-delay': `${index * 45}ms` }"><div class="dashboard-metric-heading"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><component :is="metricIcons[index]" :size="18" aria-hidden="true" /></div><div class="dashboard-metric-value"><strong>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span></div><p class="dashboard-metric-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
</div>
<div class="dashboard-main-charts">
<section class="dashboard-chart">
@@ -210,7 +217,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<template #actions>
<ChartDetailsDialog inline button-label="分组说明" title="分组说明与待核验原因" width="760px" :reset-on="data"><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></ChartDetailsDialog>
</template>
<template #intro><p>有实物库存或库存待核验 SKU;{{ data.context.fixedWindowStartDate }} 至 {{ data.context.fixedWindowEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p></template>
<template #intro><p>有实物库存或库存待核验 SKU;{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p></template>
<template #empty><p>无可用库存分布资料;销售候选不作为库存总量。</p></template>
</DashboardBars>
</section>
@@ -228,7 +235,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p>
<p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p>
<p v-if="ranking.key === 'hotSkus' && data.hotSkus.dataStatus !== 'COMPLETE'">按所选时段已采集量排名,数据尚未采齐,排名可能随采集变化;未采集日期不计为零出库。</p>
<p v-else-if="ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'">本榜仍需完整近30日销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。</p>
<p v-else-if="ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'">本榜仍需所选区间完整销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。</p>
</template>
<template v-if="ranking.key === 'hotSkus'" #details="{ select }"><HotSkuDetails :ranking="data.hotSkus" :disabled="contextExpired" @select="index => select(rankingRows(data!.hotSkus)[index]!)" /></template>
</DashboardBars>
@@ -1,10 +1,10 @@
import type { Dashboard, DashboardItem, DashboardKpi, DashboardRanking } from '../types'
const kpi: DashboardKpi = { value: '8', knownSubtotal: '8', unit: 'PCS', dataStatus: 'COMPLETE', unknownSkuCount: '0', reasonCodes: [], outboundQuantity: '8', observedQuantity: '8' }
export const dashboardItem: DashboardItem = { customerCode: 'OWNER-A', sku: 'SKU-X', productName: '测试商品', value: '300', unit: 'PCS', windowOutboundQuantity: '300', skuOutboundOrderCount: '3', physicalQuantity: '3000', availableQuantity: '3000', frozenQuantity: '0', defectivePhysicalQuantity: '0', averageDailyOutboundQuantity: '10', inventoryCoverageDays: '300' }
const ranking: DashboardRanking = { windowStartDate: '2026-09-13', windowEndDateExclusive: '2026-10-13', calculationWindowDays: 30, eligibleCount: '200', unassessableCount: '1', items: [dashboardItem, { ...dashboardItem, customerCode: 'OWNER-B', value: '30' }], dataStatus: 'COMPLETE', reasonCodes: [] }
const ranking: DashboardRanking = { windowStartDate: '2026-10-10', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 4, eligibleCount: '200', unassessableCount: '1', items: [dashboardItem, { ...dashboardItem, customerCode: 'OWNER-B', value: '30' }], dataStatus: 'COMPLETE', reasonCodes: [] }
export const dashboardFixture: Dashboard = {
context: { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13', inventoryDrilldownExpiresAt: '2099-10-13T00:20:00Z', inventoryFreshUntil: '2099-10-13T00:20:00Z', rangeStartDate: '2026-10-10', rangeEndDateExclusive: '2026-10-14', fixedWindowStartDate: '2026-09-13', fixedWindowEndDateExclusive: '2026-10-13', fixedWindowDays: 30 },
kpis: { intervalOutboundQuantity: { ...kpi, value: null, outboundQuantity: null, dataStatus: 'PARTIAL' }, averageDailyOutboundQuantity30d: kpi, physicalQuantity: { ...kpi, value: null, knownSubtotal: '128', dataStatus: 'PARTIAL', unknownSkuCount: '1' }, availableQuantity: kpi, physicalSkuCount: kpi, noOutboundSkuCount30d: kpi },
context: { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13', inventoryDrilldownExpiresAt: '2099-10-13T00:20:00Z', inventoryFreshUntil: '2099-10-13T00:20:00Z', rangeStartDate: '2026-10-10', rangeEndDateExclusive: '2026-10-14' },
kpis: { intervalOutboundQuantity: { ...kpi, value: null, outboundQuantity: null, dataStatus: 'PARTIAL' }, averageDailyOutboundQuantity: kpi, physicalQuantity: { ...kpi, value: null, knownSubtotal: '128', dataStatus: 'PARTIAL', unknownSkuCount: '1' }, availableQuantity: kpi, physicalSkuCount: kpi, noOutboundSkuCount: kpi },
salesTrend: { summary: { sourceAccount: 'lingxing-primary', customerCode: '', warehouseCode: 'WH-A', sku: '', productName: null, quantityUnit: 'PCS', outboundQuantity: null, observedQuantity: '8', dataStatus: 'PARTIAL', reasonCodes: [], completeDayCount: 2, partialDayCount: 1, unknownDayCount: 1 }, points: [
{ businessDate: '2026-10-10', outboundQuantity: '2', observedQuantity: '2', dataStatus: 'COMPLETE', reasonCodes: [], quantityUnit: 'PCS' },
{ businessDate: '2026-10-11', outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_NOT_COVERED'], quantityUnit: 'PCS' },
@@ -134,12 +134,12 @@ describe('self-operated actual workbench request state', () => {
expect(request).not.toHaveProperty('queryAsOf')
})
it('defaults to Beijing yesterday and excludes rolling 30-day predicates for a historical cutoff', async () => {
it('defaults to Beijing yesterday and retains selected-window predicates for a historical cutoff', async () => {
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-12T17:00:00Z'))
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
expect(mocks.api.inventory.mock.calls[0]?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-12' })
await setFilters(wrapper, { salesWindowEndDate: '2026-10-10', attention: 'NO_OUTBOUND_30D', sortBy: 'OUTBOUND_30D_ASC' })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-10', attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
await setFilters(wrapper, { salesWindowEndDate: '2026-10-10', attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-10', attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
})
it('selects a complete ten-day window, preserves it with pinned pagination, and uses its dates for daily trend', async () => {
const ten = { ...context, anchorDate: '2026-10-13', rangeStartDate: '2026-10-03', rangeEndDateExclusive: '2026-10-13', calculationWindowDays: 10 }
@@ -157,10 +157,10 @@ describe('self-operated actual workbench request state', () => {
await setFilters(wrapper, { sortBy: 'PHYSICAL_DESC' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, salesWindowEndDate: '2026-10-12', sortBy: 'PHYSICAL_DESC', queryAsOf: ten.queryAsOf, current: 1 })
})
it('clears incompatible 30-day predicates and never calculates missing inventory coverage locally', async () => {
it('keeps selected-window predicates and never calculates missing inventory coverage locally', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
await setFilters(wrapper, { attention: 'NO_OUTBOUND_30D', sortBy: 'OUTBOUND_30D_ASC', salesWindowDays: 10 })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
await setFilters(wrapper, { attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC', salesWindowDays: 10 })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
expect(wrapper.findComponent(tableStub).props('data')[0].inventoryCoverageDays).toBeNull()
mocks.api.inventory.mockResolvedValue({ context, records: [{ ...metric, windowOutboundQuantity: 0, status: 'NO_OUTBOUND_IN_WINDOW' }], total: 1 })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
@@ -201,7 +201,7 @@ describe('self-operated actual workbench request state', () => {
})
it.each([
{ coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' },
{ attention: 'NO_OUTBOUND_30D', sortBy: 'PHYSICAL_DESC' },
{ attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'PHYSICAL_DESC' },
])('sends the complete overview ranking candidate predicate before server pagination: %j', conditions => {
const pinned = { ...context, queryExpiresAt: '2099-10-13T00:30:00Z', inventoryDrilldownExpiresAt: '2099-10-13T00:20:00Z' }
mocks.routeQuery = Object.fromEntries(Object.entries({ warehouseCode: 'WH-A', from: 'overview', context: JSON.stringify(pinned), overviewReturn: JSON.stringify({ rangePreset: 'TODAY', scroll: 0 }), ...conditions }).filter(([, value]) => value !== undefined)) as Record<string, string>
@@ -217,6 +217,9 @@ describe('self-operated actual workbench request state', () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', coverageBucket: 'DAYS_30_60', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', drilldownFrom: 'DASHBOARD', salesRunId: '11', queryAsOf: context.queryAsOf })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: pinned.rangeStartDate, endDate: '2026-10-11' })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesWindowDays')
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesWindowEndDate')
expect(wrapper.text()).toContain('来自经营总览')
await findButton(wrapper, '清除来源条件').trigger('click'); await flushPromises()
const cleared = mocks.api.inventory.mock.calls.at(-1)?.[0]
@@ -1,5 +1,6 @@
import type { TurnoverQuery, TurnoverSort } from './turnoverTypes'
export const turnoverSorts: TurnoverSort[] = ['PHYSICAL_DESC', 'AVAILABLE_DESC', 'OUTBOUND_7_DESC', 'OUTBOUND_15_DESC', 'OUTBOUND_30_DESC', 'COVERAGE_ASC', 'COVERAGE_DESC', 'TURNOVER_DESC', 'NO_OUTBOUND_PHYSICAL_DESC']
import { turnoverSortOptions } from './turnoverSorting'
export const turnoverSorts: TurnoverSort[] = turnoverSortOptions.map(option => option.value)
const detailOnly = ['salesWindowDays', 'salesWindowEndDate', 'attention', 'coverageSelection', 'coverageBucket', 'bucketRuleVersion']
const turnoverOnly = ['groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext']
function scalar(q: Record<string, unknown>, key: string) {
@@ -47,9 +47,9 @@ 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' }, grid: { left: 12, right: 24, top: 16, bottom: 20, containLabel: true },
tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 12, right: 40, top: owners ? 12 : 36, 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: '件/日' },
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) => {
const value = owners ? charts.ownerPhysicalTop[i]?.physicalQuantity : charts.averageDaily[i]?.dataStatus === 'COMPLETE' ? charts.averageDaily[i]?.averageDailyOutboundQuantity : null
return value == null ? null : Number(value)
@@ -0,0 +1,22 @@
import type { TurnoverSort, TurnoverSortMetric } from './turnoverTypes'
export const turnoverSortColumns: Record<string, TurnoverSortMetric> = {
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 metricLabels: Record<TurnoverSortMetric, string> = {
PHYSICAL: '实物件数', AVAILABLE: '正品可售', OUTBOUND_7: '完整7日出库',
OUTBOUND_15: '完整15日出库', OUTBOUND_30: '完整30日出库', AVERAGE_DAILY: '基准窗口日均出库',
COVERAGE: '预计可售天数', FROZEN: '冻结件数', DEFECTIVE: '次品实物',
AVERAGE_PHYSICAL: '期间平均实物', TURNOVER: '期间件数周转',
}
export const turnoverSortOptions: { value: TurnoverSort; label: string }[] = [
...(Object.keys(metricLabels) as TurnoverSortMetric[]).flatMap(metric => [
{ value: `${metric}_DESC` as TurnoverSort, label: `${metricLabels[metric]}从多到少` },
{ value: `${metric}_ASC` as TurnoverSort, label: `${metricLabels[metric]}从少到多` },
]),
{ value: 'NO_OUTBOUND_PHYSICAL_DESC', label: '已确认无出库实物最多' },
]
+2 -1
View File
@@ -4,7 +4,8 @@ import type { QueryContext, Quantity } from './types'
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'
export type TurnoverSort = 'PHYSICAL_DESC' | 'AVAILABLE_DESC' | 'OUTBOUND_7_DESC' | 'OUTBOUND_15_DESC' | 'OUTBOUND_30_DESC' | 'COVERAGE_ASC' | 'COVERAGE_DESC' | 'TURNOVER_DESC' | 'NO_OUTBOUND_PHYSICAL_DESC'
export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | '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
groupBy: TurnoverGroup; customerCode?: string; sku?: string; focus: TurnoverFocus
+4 -7
View File
@@ -1,8 +1,8 @@
export type Quantity = string | number | null
export type RangePreset = 'TODAY' | 'LAST_3_DAYS' | 'THIS_WEEK' | 'LAST_15_DAYS' | 'THIS_MONTH' | 'CUSTOM'
export type InventoryAttention = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT_THRESHOLD' | 'FROZEN_STOCK' | 'DEFECTIVE_STOCK' | 'UNASSESSABLE'
export type InventorySort = 'AVAILABLE_DESC' | 'COVERAGE_DAYS_DESC' | 'PHYSICAL_DESC' | 'OUTBOUND_30D_ASC'
export type CoverageBucket = 'UNKNOWN' | 'NO_AVAILABLE' | 'NO_OUTBOUND_30D' | 'DAYS_0_30' | 'DAYS_30_60' | 'DAYS_60_90' | 'DAYS_GT_90'
export type InventoryAttention = 'ALL' | 'NO_OUTBOUND_IN_WINDOW' | 'COVERAGE_GT_THRESHOLD' | 'FROZEN_STOCK' | 'DEFECTIVE_STOCK' | 'UNASSESSABLE'
export type InventorySort = 'AVAILABLE_DESC' | 'COVERAGE_DAYS_DESC' | 'PHYSICAL_DESC' | 'OUTBOUND_ASC'
export type CoverageBucket = 'UNKNOWN' | 'NO_AVAILABLE' | 'NO_OUTBOUND_IN_WINDOW' | 'DAYS_0_30' | 'DAYS_30_60' | 'DAYS_60_90' | 'DAYS_GT_90'
export interface QueryContext {
salesRunId: string | null
inventoryRunId: string | null
@@ -24,9 +24,6 @@ export interface QueryContext {
calculationWindowDays?: number | null
inventoryFreshUntil?: string | null
inventoryDrilldownExpiresAt?: string | null
fixedWindowStartDate?: string
fixedWindowEndDateExclusive?: string
fixedWindowDays?: number
salesStatus?: string
inventoryStatus?: string
rebuildRequired?: boolean
@@ -41,7 +38,7 @@ export interface DashboardRanking { windowStartDate: string; windowEndDateExclus
export interface DashboardBucket { bucketCode: CoverageBucket; skuCount: Quantity; knownPhysicalQuantity: Quantity; unknownPhysicalSkuCount: Quantity }
export interface Dashboard {
context: QueryContext
kpis: Record<'intervalOutboundQuantity' | 'averageDailyOutboundQuantity30d' | 'physicalQuantity' | 'availableQuantity' | 'physicalSkuCount' | 'noOutboundSkuCount30d', DashboardKpi>
kpis: Record<'intervalOutboundQuantity' | 'averageDailyOutboundQuantity' | 'physicalQuantity' | 'availableQuantity' | 'physicalSkuCount' | 'noOutboundSkuCount', DashboardKpi>
salesTrend: { summary: SalesSummary; points: DailyPoint[] }
coverageDistribution: { bucketRuleVersion: string; candidateSkuCount: Quantity; excludedZeroPhysicalSkuCount: Quantity; buckets: DashboardBucket[]; dataStatus: string; reasonCodes: string[]; unknownReasons: Record<string, Quantity> }
hotSkus: DashboardRanking