This commit is contained in:
2026-10-14 00:07:05 +08:00
parent 4c4a3db1eb
commit 2cf9129606
16 changed files with 592 additions and 20 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ describe('adminNavigation', () => {
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
{ title: '客户服务', children: ['异常订单'] }, { title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '自营业务', children: ['经营总览', '销量分析', '库存分析', '数据采集'] }, { title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] }, { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
]) ])
+1 -1
View File
@@ -183,7 +183,7 @@ export const adminNavigation: AdminNavigationNode[] = [
}, },
{ {
key: 'self-operated', key: 'self-operated',
title: '自营业务', title: '海外仓',
icon: PrototypeSalesIcon, icon: PrototypeSalesIcon,
children: [ children: [
{ key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' }, { key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' },
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { TurnoverQuery, TurnoverResponse } from '../turnoverTypes'
import type { BusinessPage, BusinessQuery, Dashboard, DashboardQuery, DailyResponse, InventoryMetric, InventoryQuery, Metric, Overview, OwnerOption, OwnerOverview, OwnerOverviewQuery, OwnerQuery, RetryCommand, RetryResult, Run, RuntimePage, RuntimeQuery, SalesSummary, Scope, TaskDetail } from '../types' import type { BusinessPage, BusinessQuery, Dashboard, DashboardQuery, DailyResponse, InventoryMetric, InventoryQuery, Metric, Overview, OwnerOption, OwnerOverview, OwnerOverviewQuery, OwnerQuery, RetryCommand, RetryResult, Run, RuntimePage, RuntimeQuery, SalesSummary, Scope, TaskDetail } from '../types'
const base = '/platform/v1/last-leg/self-operated-analytics' const base = '/platform/v1/last-leg/self-operated-analytics'
@@ -7,6 +8,7 @@ async function get<T>(path: string, params: object, signal?: AbortSignal): Promi
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export const analyticsApi = { export const analyticsApi = {
turnover: (query: TurnoverQuery, signal?: AbortSignal) => get<TurnoverResponse>('inventory-turnover', query, signal),
dashboard: (query: DashboardQuery, signal?: AbortSignal) => get<Dashboard>('dashboard', query, signal), dashboard: (query: DashboardQuery, signal?: AbortSignal) => get<Dashboard>('dashboard', query, signal),
owners: (query: OwnerQuery, signal?: AbortSignal) => get<BusinessPage<OwnerOption>>('owners', query, signal), owners: (query: OwnerQuery, signal?: AbortSignal) => get<BusinessPage<OwnerOption>>('owners', query, signal),
ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get<OwnerOverview | null>('owner-overview', query, signal), ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get<OwnerOverview | null>('owner-overview', query, signal),
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { Info } from '@lucide/vue'
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
@@ -290,18 +291,25 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState> <LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<p v-if="salesRangePending" class="analytics-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p> <p v-if="salesRangePending" class="analytics-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p>
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p> <p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p>
</template>
<template #toolbar-actions><el-button :disabled="!warehouseCode" @click="link('/self-operated/collection')">查看采集状态</el-button><el-button :disabled="!warehouseCode" @click="link(mode === 'sales' ? '/self-operated/inventory' : '/self-operated/sales')">{{ mode === 'sales' ? '查看库存' : '查看销量' }}</el-button>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间" teleported>
<template #reference><el-button aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
<div class="analytics-information admin-density">
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p> <p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p>
<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> <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>
<details class="analytics-rules"> <div class="analytics-rules">
<summary>统计说明 · 北京时间/件</summary>
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p> <p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
<p v-if="mode === 'inventory'">预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</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">查询时间:{{ 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> <p v-if="context">实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)<template v-if="context.calculationWindowDays != null">;{{ context.calculationWindowDays }} 天</template>。</p>
<p v-for="state in (overview?.warehouses ?? []).filter(item => item.warehouseCode === warehouseCode)" :key="state.dataFamily">{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}数据:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。</p> <p v-for="state in (overview?.warehouses ?? []).filter(item => item.warehouseCode === warehouseCode)" :key="state.dataFamily">{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}数据:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。</p>
</details> </div>
</div>
</el-popover>
</template> </template>
<template #toolbar-actions><el-button :disabled="!warehouseCode" @click="link('/self-operated/collection')">查看采集状态</el-button><el-button :disabled="!warehouseCode" @click="link(mode === 'sales' ? '/self-operated/inventory' : '/self-operated/sales')">{{ mode === 'sales' ? '查看库存' : '查看销量' }}</el-button></template>
<template #actions="{ row }"><el-button class="density-action" @click="showTrend(asSummary(row))">每日趋势</el-button></template> <template #actions="{ row }"><el-button class="density-action" @click="showTrend(asSummary(row))">每日趋势</el-button></template>
<template #sku="{ row }"><el-button class="density-action" :aria-label="`查看 ${asMetric(row).customerCode} ${asMetric(row).sku} 每日趋势`" @click="showTrend(asMetric(row))">{{ asMetric(row).sku }}</el-button></template> <template #sku="{ row }"><el-button class="density-action" :aria-label="`查看 ${asMetric(row).customerCode} ${asMetric(row).sku} 每日趋势`" @click="showTrend(asMetric(row))">{{ asMetric(row).sku }}</el-button></template>
<template #owner="{ row }"><span>{{ asMetric(row).customerName || asMetric(row).customerCode || '—' }}</span><small v-if="asMetric(row).customerName && asMetric(row).customerCode" class="analytics-owner-code">{{ asMetric(row).customerCode }}</small></template> <template #owner="{ row }"><span>{{ asMetric(row).customerName || asMetric(row).customerCode || '—' }}</span><small v-if="asMetric(row).customerName && asMetric(row).customerCode" class="analytics-owner-code">{{ asMetric(row).customerCode }}</small></template>
@@ -327,7 +335,8 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
</template> </template>
<style scoped> <style scoped>
.analytics-note, .analytics-rules { margin: 0; font-size: var(--admin-font-caption); } .analytics-note, .analytics-rules { margin: 0; font-size: var(--admin-font-caption); }
.analytics-rules summary { cursor: pointer; } .analytics-information { max-height: 65vh; overflow-y: auto; overflow-wrap: anywhere; }
.analytics-information > p { margin: var(--admin-space-2) 0; }
.analytics-rules p { margin: var(--admin-space-2) 0; } .analytics-rules p { margin: var(--admin-space-2) 0; }
.analytics-owner-code { display: block; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); } .analytics-owner-code { display: block; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
</style> </style>
@@ -84,6 +84,7 @@ function nodeState(phases: string[]) {
<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> <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><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-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 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>
<footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer> <footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
</div> </div>
@@ -110,7 +111,7 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi
.account-primary { min-width: 0; } .account-primary { min-width: 0; }
.account-brand, .account-controls, .account-live, .account-section-title, h4 { display: flex; align-items: center; gap: 10px; } .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-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: linear-gradient(105deg, var(--el-color-primary-light-9), var(--el-bg-color) 65%); padding: 16px 24px; border-top: 3px solid var(--el-color-primary); } .account-header { background: var(--el-bg-color); padding: var(--admin-space-5) var(--admin-space-6); }
.account-header h2 { margin-bottom: 4px; } .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); } .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); } [data-running="true"] .account-live { color: var(--el-color-success-dark-2); background: var(--el-color-success-light-9); }
@@ -151,15 +152,18 @@ h4 { margin: 0; font-size: var(--admin-font-body); }
.warehouse-result small { display: block; margin-top: 4px; } .warehouse-result small { display: block; margin-top: 4px; }
.warehouse-result > span:last-child { text-align: right; } .warehouse-result > span:last-child { text-align: right; }
.result-ready { color: var(--el-color-success); } .result-ready { color: var(--el-color-success); }
.account-monitor :deep(.collection-count-grid > div) { --metric-color: var(--el-color-primary); --metric-tint: var(--el-color-primary-light-9); position: relative; overflow: hidden; padding: var(--admin-space-4); border: 1px solid var(--metric-tint); border-radius: var(--admin-radius-lg); background: linear-gradient(135deg, var(--el-bg-color), var(--metric-tint)); } .account-monitor :deep(.collection-count-grid > div) { padding-inline: var(--admin-space-5); }
.account-monitor :deep(.collection-count-grid > div:nth-child(2)) { --metric-color: var(--el-color-success-dark-2); --metric-tint: var(--el-color-success-light-9); } .account-monitor :deep(.collection-count-grid > div + div) { border-left: 1px solid var(--el-border-color-light); }
.account-monitor :deep(.collection-count-grid > div:nth-child(3)) { --metric-color: var(--el-color-warning-dark-2); --metric-tint: var(--el-color-warning-light-9); } .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::before) { content: ''; position: absolute; right: -18px; top: -24px; width: 86px; height: 86px; border-radius: 50%; background: var(--metric-color); opacity: .1; pointer-events: none; } .account-monitor :deep(.collection-count-grid > div:nth-child(2) dd) { color: var(--el-color-primary); }
.account-monitor :deep(.collection-count-grid dt), .account-monitor :deep(.collection-count-grid dd) { position: relative; } .account-monitor :deep(.collection-count-grid dd small) { display: block; margin-top: var(--admin-space-2); font-weight: 400; }
.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; color: var(--metric-color); white-space: nowrap; } .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-ring) { background: var(--el-color-primary-light-9); border-radius: 50%; } .account-monitor :deep(.collection-stage-counts) { display: contents; }
.account-monitor :deep(.collection-ring) { width: 180px; height: 180px; grid-row: 1 / 3; }
.account-monitor :deep(.compact-counts) { grid-column: 2; }
.account-monitor :deep(.collection-progress > p) { grid-column: 1 / -1; }
.account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); } .account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); }
.account-monitor :deep(.collection-estimate) { padding: 16px; border-radius: 6px; } .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); }
@keyframes signal { 50% { opacity: .45; } } @keyframes signal { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } } @media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } }
</style> </style>
@@ -0,0 +1,98 @@
import { defineComponent, h, reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import InventoryTurnoverView from './InventoryTurnoverView.vue'
import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from '../turnoverTypes'
const mocks = vi.hoisted(() => ({ route: { query: {} as Record<string, string> }, allowed: true, overview: vi.fn(), turnover: vi.fn(), daily: vi.fn(), push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup: () => () => h('div') })
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-actions']?.(), ...(p.data ?? []).flatMap((row: TurnoverRow) => [slots.owner?.({ row }), slots.sku?.({ row }), slots.status?.({ row })])]) } })
const 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: [] }
const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 }
const row: TurnoverRow = { customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutbound30d: risk, slowCoverage: risk, unassessableSkuCount: 0 }
function response(q: TurnoverQuery): TurnoverResponse {
return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
}
const wrappers: ReturnType<typeof mount>[] = []
function page() { const w = mount(InventoryTurnoverView, { global: { stubs: { AdminPage: shell, DataTable: table, QueryToolbar: toolbar, AnalyticsChart: true, ChartDetailsDialog: true, AppDialog: true, ApiErrorMeta: true, 'el-button': button, 'el-tag': shell } } }); wrappers.push(w); return w }
async function change(w: ReturnType<typeof mount>, patch: object, search = false) {
const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises()
if (search) { t.vm.$emit('search'); await flushPromises() }
}
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('loads the first warehouse once and keeps charts/context across filtered pagination', async () => {
const w = page(); await flushPromises()
expect(mocks.turnover).toHaveBeenCalledTimes(1)
expect(mocks.turnover.mock.calls[0]![0]).toMatchObject({ warehouseCode: 'A', groupBy: 'SKU', baseWindowDays: 30, includeOverview: true })
await change(w, { sku: 'SAME' }, true)
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', includeOverview: false, sku: 'SAME', current: 1 })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', current: 2, includeOverview: false })
expect(w.findAll('.turnover-cards article')).toHaveLength(4)
})
it('rejects unauthorized deep links and does not query without warehouses or permission', async () => {
mocks.route.query = { warehouseCode: 'NO' }; const w = page(); await flushPromises()
expect(w.text()).toContain('指定仓库不可用'); expect(mocks.turnover).not.toHaveBeenCalled()
mocks.route.query = {}; mocks.overview.mockResolvedValue({ warehouses: [] }); page(); await flushPromises(); expect(mocks.turnover).not.toHaveBeenCalled()
mocks.allowed = false; mocks.overview.mockClear(); page(); await flushPromises(); expect(mocks.overview).not.toHaveBeenCalled()
})
it('cancels old warehouse reads and never restores their late response', async () => {
let finish!: (value: TurnoverResponse) => void
mocks.turnover.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const w = page(); await flushPromises(); const old = mocks.turnover.mock.calls[0]!
await change(w, { warehouseCode: 'B' })
expect((old[1] as AbortSignal).aborted).toBe(true)
finish(response(old[0])); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].warehouseCode).toBe('B')
expect(w.text()).toContain('本仓概览')
await change(w, { sku: 'X' }, true)
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBe('token-B')
})
it('clears SKU sorting for owners and preserves original owner page on return', async () => {
const w = page(); await flushPromises()
await change(w, { sku: 'SAME', sortBy: 'COVERAGE_DESC' }, true)
await change(w, { groupBy: 'OWNER' })
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sku: undefined, sortBy: 'PHYSICAL_DESC', snapshotContext: 'token-A', includeOverview: true })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
await w.findAll('button').find(b => b.text() === 'OWNER-A')!.trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'SKU', customerCode: 'OWNER-A', snapshotContext: 'token-A' })
await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises()
expect(w.findComponent(table).props('page')).toBe(2)
expect(w.findComponent(toolbar).props('modelValue').groupBy).toBe('OWNER')
})
it('blocks invalid thresholds and preserves known data on same-scope refresh failure', async () => {
const w = page(); await flushPromises()
await change(w, { focus: 'COVERAGE_GT', coverageDaysThreshold: '' }, true)
expect(mocks.turnover).toHaveBeenCalledTimes(1); expect(w.text()).toContain('请填写')
mocks.turnover.mockRejectedValue(new Error('offline'))
await change(w, { coverageDaysThreshold: 60 }, true)
expect(w.findComponent(table).props('data')).toHaveLength(1)
expect(w.text()).toContain('保留')
})
it('passes exact SKU identity and fixed versions into trend and cancels a closed request', async () => {
let finish!: (value: object) => void
mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const w = page(); await flushPromises()
await w.findAll('button').find(b => b.text() === 'SAME')!.trigger('click'); await flushPromises()
const [q, signal] = mocks.daily.mock.calls[0]!
expect(q).toMatchObject({ customerCode: 'OWNER-A', sku: 'SAME', warehouseCode: 'A', salesRunId: '11', inventoryRunId: '22', queryAsOf: '2026-10-13T00:00:00Z', startDate: '2026-09-13', endDate: '2026-10-12' })
const dialog = w.findAllComponents({ name: 'AppDialog' }).find(d => d.props('title') === 'SKU每日出库趋势')!
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect((signal as AbortSignal).aborted).toBe(true)
finish({}); await flushPromises()
expect(w.findComponent({ name: 'SkuTrend' }).exists()).toBe(false)
})
it('does not combine rows from a changed token with prior charts', async () => {
const w = page(); await flushPromises()
mocks.turnover.mockImplementationOnce(async (q: TurnoverQuery) => { const r = response(q); r.context.snapshotContext = 'different'; r.rows = []; return r })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(w.text()).toContain('查询版本已变化')
expect(w.findComponent(table).props('data')).toHaveLength(1)
})
})
@@ -0,0 +1,236 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { analyticsApi } from '../api/analytics'
import { RequestFence, beijingTime, contextParams, reasonsText } from '../state'
import type { DailyResponse, Overview } from '../types'
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
import { turnoverRoute, validateTurnover, yesterdayBeijing, turnoverSorts } from '../turnoverNavigation'
import { totalText, turnoverChart, turnoverNumber, windowText } from '../turnoverPresentation'
import AnalyticsChart from './AnalyticsChart.vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import SkuTrend from './SkuTrend.vue'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const emit = defineEmits<{ warehouseSelected: [code: string] }>()
class TurnoverContractError extends Error {}
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
const runtime = ref<Overview | null>(null), data = ref<TurnoverResponse | null>(null)
const error = ref<ApiErrorDetails | null>(null), inputError = ref(''), notice = ref(''), loading = ref(false)
const filters = ref<QueryModel>({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true)
const fence = new RequestFence(), runtimeFence = new RequestFence(), trendFence = new RequestFence()
const trendOpen = ref(false), trendLoading = ref(false), daily = ref<DailyResponse | null>(null), trendError = ref<ApiErrorDetails | null>(null)
const evidence = ref<TurnoverRow | null>(null), evidenceOpen = ref(false)
const applied = ref<TurnoverQuery | null>(null)
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() } },
{ key: 'baseWindowDays', label: '日均及可售基准', type: 'select', width: 160, options: [7, 15, 30].map(n => ({ value: n, label: `${n}天` })), props: { clearable: false } },
{ key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } },
...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []),
{ 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] })) },
])
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slot: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slot: 'owner', minWidth: 160 },
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slot: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slot: 'availableQuantity', minWidth: 145 },
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slot: `outbound${n}`, minWidth: 160 })),
{ prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slot: 'daily', minWidth: 110 },
...(ownerMode.value ? [{ prop: 'noOutbound30d', label: '无出库SKU / 实物', slot: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slot: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slot: 'coverage', minWidth: 150 }]),
{ prop: 'attentionReasons', label: '状态与样本', slot: 'status', minWidth: 180 },
...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slot: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slot: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slot: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slot: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slot: 'lastDate', minWidth: 180 }] : []),
])
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 },
] : [])
function castRow(row: unknown) { return row as TurnoverRow }
function restoreThreshold(warehouse: string) {
try {
const saved = sessionStorage.getItem(`selfop-turnover-threshold:${warehouse}`)
if (saved && /^\d+$/.test(saved) && Number.isSafeInteger(Number(saved)) && Number(saved) > 0) return saved
} catch { /* optional preference */ }
return ''
}
function quickFocus(focus: TurnoverQuery['focus'], sortBy: TurnoverSort) {
filters.value = { ...filters.value, focus, sortBy }; 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 }
}
function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') }
function overviewKey(q: TurnoverQuery) { return [scopeKey(q), q.customerCode, q.groupBy].join('|') }
async function load(refresh = false) {
if (!permitted.value || !active) return
fence.cancel(); inputError.value = ''; evidenceOpen.value = false; trendOpen.value = false
const q = query()
if (applied.value && applied.value.warehouseCode !== q.warehouseCode) data.value = null
try {
validateTurnover(q)
if (!warehouses.value.some(w => w.value === q.warehouseCode)) throw new Error('指定仓库不可用,请重新选择有权限的仓库')
} catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return }
const sameScope = applied.value && scopeKey(applied.value) === scopeKey(q)
q.snapshotContext = refresh ? undefined : sameScope ? data.value?.context.snapshotContext : linkedSnapshot
linkedSnapshot = undefined
q.includeOverview = refresh || !data.value?.summary || !applied.value || overviewKey(applied.value) !== overviewKey(q)
if (!q.snapshotContext) { q.current = 1; page.value = 1; q.includeOverview = true }
const previous = data.value
const request = fence.begin(); loading.value = true
try {
const result = await analyticsApi.turnover(q, request.signal)
if (!request.current()) return
if (result.context.warehouseCode !== q.warehouseCode || result.context.endDate !== q.endDate || result.context.baseWindowDays !== q.baseWindowDays) throw new TurnoverContractError('返回的查询范围不一致,请整体刷新')
if (q.snapshotContext && result.context.snapshotContext !== q.snapshotContext) throw new TurnoverContractError('查询版本已变化,请整体刷新')
if (q.includeOverview && (!result.summary || !result.charts)) throw new TurnoverContractError('本次概览未完整返回,请整体刷新')
data.value = { ...result, summary: q.includeOverview ? result.summary : previous?.summary ?? null, charts: q.includeOverview ? result.charts : previous?.charts ?? null }
applied.value = q; error.value = null; emit('warehouseSelected', q.warehouseCode)
try {
if (q.coverageDaysThreshold != null) sessionStorage.setItem(`selfop-turnover-threshold:${q.warehouseCode}`, String(q.coverageDaysThreshold))
} catch { /* optional preference */ }
} catch (cause) { if (request.current()) { error.value = getApiErrorDetails(cause, cause instanceof TurnoverContractError ? cause.message : '库存周转读取失败;请重试'); if (data.value && applied.value) page.value = applied.value.current } }
finally { if (request.current()) loading.value = false }
}
async function search() { page.value = 1; await load() }
function reset() {
const warehouseCode = filters.value.warehouseCode || applied.value?.warehouseCode || warehouses.value[0]?.value || ''
filters.value = { warehouseCode, endDate: yesterdayBeijing(), baseWindowDays: 30, groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }
ownerReturn = null; page.value = 1; void load(true)
}
function changeFilters(next: QueryModel) {
notice.value = ''
if (next.groupBy !== filters.value.groupBy) {
next = { ...next, sku: '', sortBy: 'PHYSICAL_DESC' }; notice.value = '已切换分组,SKU条件及专属排序已清除'
}
const changed = ['warehouseCode', 'endDate', 'baseWindowDays', 'groupBy'].some(k => next[k] !== filters.value[k])
if (next.warehouseCode !== filters.value.warehouseCode) next = { ...next, coverageDaysThreshold: restoreThreshold(String(next.warehouseCode || '')) }
filters.value = next
if (changed) { page.value = 1; void load() }
}
function selectOwner(code: string) {
if (applied.value?.groupBy === 'OWNER' && data.value) ownerReturn = { query: { ...applied.value }, result: data.value }
filters.value = { ...filters.value, groupBy: 'SKU', customerCode: code, sku: '', sortBy: 'PHYSICAL_DESC' }; page.value = 1; void load()
}
function returnOwners() {
if (!ownerReturn) return
if (Date.parse(ownerReturn.result.context.queryExpiresAt) <= Date.now()) { inputError.value = '原货主汇总版本已过期,请整体刷新'; return }
fence.cancel(); trendOpen.value = false; evidenceOpen.value = false; loading.value = false; error.value = null
const saved = ownerReturn; filters.value = { ...saved.query, sku: '', customerCode: saved.query.customerCode || '', coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' }
applied.value = saved.query; page.value = saved.query.current; data.value = saved.result; ownerReturn = null
}
async function showTrend(row: TurnoverRow) {
if (!row.sku || !data.value || !applied.value) return
const metric = row.windows.find(w => w.days === applied.value!.baseWindowDays)
if (!metric) return
trendOpen.value = true; daily.value = null; trendError.value = null; trendLoading.value = true
const request = trendFence.begin(), context = data.value.context
try {
const end = new Date(Date.parse(`${metric.endDateExclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
const result = await analyticsApi.daily({ sourceAccount: applied.value.sourceAccount, warehouseCode: applied.value.warehouseCode, customerCode: row.customerCode, sku: row.sku, rangePreset: 'CUSTOM', startDate: metric.startDate, endDate: end, current: 1, size: 100, ...contextParams(context.analyticsContext) }, request.signal)
if (!request.current()) return
if (result.context.salesRunId !== context.analyticsContext.salesRunId || result.context.inventoryRunId !== context.analyticsContext.inventoryRunId || result.context.queryAsOf !== context.queryAsOf) throw new Error('趋势版本与周转列表不一致,请整体刷新')
daily.value = result
} catch (cause) { if (request.current()) trendError.value = getApiErrorDetails(cause, '趋势读取失败') }
finally { if (request.current()) trendLoading.value = false }
}
watch(trendOpen, open => { if (!open) { trendFence.cancel(); daily.value = null; trendLoading.value = false } })
function readRoute() {
try {
const initial = turnoverRoute(route.query); linkedSnapshot = initial.snapshotContext
filters.value = { ...filters.value, ...initial }; page.value = initial.current ?? 1; size.value = initial.size ?? 20
return true
} catch (cause) { fence.cancel(); data.value = null; inputError.value = (cause as Error).message; return false }
}
async function initialize() {
if (!permitted.value || !readRoute()) return
const request = runtimeFence.begin()
try {
const result = await analyticsApi.overview('lingxing-primary', undefined, request.signal)
if (!request.current()) return
runtime.value = result
if (!filters.value.warehouseCode) filters.value.warehouseCode = warehouses.value[0]?.value ?? ''
if (route.query.coverageDaysThreshold == null) filters.value.coverageDaysThreshold = restoreThreshold(String(filters.value.warehouseCode || ''))
if (filters.value.warehouseCode) await load()
} catch (cause) { if (request.current()) error.value = getApiErrorDetails(cause, '仓库列表读取失败') }
}
watch(() => route.query, () => { if (active && readRoute()) void load() })
watch(chartsOpen, value => { try { sessionStorage.setItem('selfop-turnover-charts', String(value)) } catch { /* optional preference */ } })
onMounted(() => { initialized = true; try { chartsOpen.value = sessionStorage.getItem('selfop-turnover-charts') !== 'false' } catch { /* optional preference */ } void initialize() })
onDeactivated(() => { active = false; fence.cancel(); runtimeFence.cancel(); trendFence.cancel(); loading.value = false })
onActivated(() => { const resume = initialized && !active; active = true; if (resume) void initialize() })
onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() })
</script>
<template>
<AdminPage class="admin-density turnover-page">
<p v-if="!permitted" role="alert">没有库存分析查看权限。</p>
<template v-else>
<LoadErrorState v-if="error" title="库存周转读取失败" :message="error.message" compact @retry="runtime ? load() : initialize()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p>
<DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()">
<template #toolbar-actions><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button></template>
<template #filters>
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
<div class="turnover-context">
<template v-if="filters.customerCode"><el-tag closable @close="filters.customerCode = ''; search()">货主:{{ filters.customerCode }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
<template v-if="data"><span>{{ applied?.customerCode ? '本货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
</div>
<div v-if="cards.length" class="turnover-cards"><article v-for="card in cards" :key="card.name"><span>{{ card.name }}</span><strong>{{ totalText(card.value) }}</strong></article></div>
<template v-if="data?.charts">
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
<div v-if="chartsOpen" class="turnover-charts">
<section><h3>货主实物件数 Top10</h3><AnalyticsChart class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><p>{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p><ChartDetailsDialog title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></section>
<section><h3>7/15/30 日日均出库</h3><AnalyticsChart class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : `覆盖不足:完整${window.completeDayCount}日,部分${window.partialDayCount}日,未知${window.unknownDayCount}日` }}</p><ChartDetailsDialog title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></section>
</div>
</template>
<nav class="turnover-context" aria-label="库存关注快捷筛选"><el-button link @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部/实物件数最多</el-button><el-button link @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</el-button><el-button link @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</el-button><el-button link @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</el-button><el-button link @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</el-button></nav><details class="turnover-rules"><summary>统计说明</summary><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></details>
<p class="turnover-context">筛选结果:{{ data?.total ?? 0 }} {{ ownerMode ? '个货主' : '条SKU' }}</p>
</template>
<template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template>
<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>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }">{{ totalText(castRow(row)[key]) }}</template>
<template v-for="n in [7, 15, 30] as TurnoverWindow[]" :key="n" #[`outbound${n}`]="{ row }">{{ windowText(castRow(row).windows.find(w => w.days === n)) }}</template>
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
<template #status="{ row }"><small>{{ reasonsText(castRow(row).attentionReasons) }}</small><span v-if="ownerMode">待评估 {{ castRow(row).unassessableSkuCount }} SKU</span><el-button link @click="evidence = castRow(row); evidenceOpen = true">日样本 {{ castRow(row).qualifiedSampleDays }}/{{ castRow(row).requiredSampleDays }}</el-button></template>
<template #noOutbound="{ row }">{{ turnoverNumber(castRow(row).noOutbound30d.matchedSkuCount) }} SKU / {{ turnoverNumber(castRow(row).noOutbound30d.knownPhysicalQuantity) }} 件<small>待判断 {{ castRow(row).noOutbound30d.unknownSkuCount }} SKU</small></template>
<template #slow="{ row }">{{ applied?.coverageDaysThreshold ? `${turnoverNumber(castRow(row).slowCoverage.matchedSkuCount)} SKU / ${castRow(row).slowCoverage.unknownSkuCount} SKU` : '未设查询阈值' }}</template>
<template #averageStock="{ row }">{{ turnoverNumber(castRow(row).averagePhysicalQuantity) }}</template>
<template #turnover="{ row }">{{ turnoverNumber(castRow(row).quantityTurnoverCount) }} 次/{{ applied?.baseWindowDays }}日</template>
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
</DataTable>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p></template></AppDialog>
</template>
</AdminPage>
</template>
<style scoped>
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 16px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 12px 16px; }
.turnover-cards article { padding: 16px; background: var(--el-fill-color-light); border-top: 3px solid var(--el-color-primary); border-radius: 8px; }
.turnover-cards strong { display: block; font-size: 22px; margin-top: 10px; font-variant-numeric: tabular-nums; }
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 16px 16px; }
.turnover-charts section { border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 8px; }
.turnover-chart { height: 240px; }
h3 { margin: 0; font-size: var(--admin-font-body); }
small { display: block; color: var(--el-text-color-secondary); }
.turnover-rules { padding: 10px 16px; font-size: var(--admin-font-caption); }
</style>
@@ -85,3 +85,9 @@
| 图表完整明细遮罩与定位 | 用户截图显示被卡片裁剪 | 所有新增图表弹框 append-to-body;不受卡片 transform 和滚动区域限制 | 待人工1920×1080、100%截图确认 | | 图表完整明细遮罩与定位 | 用户截图显示被卡片裁剪 | 所有新增图表弹框 append-to-body;不受卡片 transform 和滚动区域限制 | 待人工1920×1080、100%截图确认 |
| 热销Top10/完整明细字段去重 | 用户截图中SKU、货主、出库量重复 | 热销专用1280px弹框表格,身份与各指标独立列,统计区间与覆盖提醒移至表头上方;其他榜单字段保留 | 同SKU不同货主、零/未知值、下钻回归;待人工截图确认 | | 热销Top10/完整明细字段去重 | 用户截图中SKU、货主、出库量重复 | 热销专用1280px弹框表格,身份与各指标独立列,统计区间与覆盖提醒移至表头上方;其他榜单字段保留 | 同SKU不同货主、零/未知值、下钻回归;待人工截图确认 |
| 库存分析/统计说明与采集时间 | 2026-10-13用户截图要求参考经营总览右上角信息按钮,原提示占据筛选与表格之间空间 | AnalyticsView共用库存/销量工作台将常规口径和采集时间移入右侧460px点击信息气泡,根部挂载、长内容内部滚动;保留页面查询失败、过期/新数据、日期未应用与来源条件提示 | 说明字段与查询逻辑不变;待人工1920×1080、100%截图确认 |
| 数据采集/账号运行看板排版 | 用户本轮截图及此前原型:环图在左,三项数量与预计时间在右,下方阶段流程;当前右侧安排栏挤占进度区 | 主进度改为通栏,三项数字连续排列、预计时间对齐数字区域;后续安排移至阶段下方。使用现有 Admin 白底、蓝紫强调和统一间距,移除数量卡片渐变装饰;刷新仍在标题右侧,接口错误与未知数据如实保留 | 仅前端布局;待人工1920×1080、100%截图确认 |
| 数据采集/后续安排位置(2026-10-14) | 用户截图反馈等待处理被移至阶段栏下方;工作区改动将主区域变为单列 | 恢复左侧当前进度、右侧290px后续安排双列,处理阶段保持下方整行;保留自动采集计划和其他业务信息 | 定向测试通过;待人工1920×1080、100%截图确认 |
@@ -0,0 +1,29 @@
import { defineComponent, h, reactive } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import InventoryView from './InventoryView.vue'
const mocks = vi.hoisted(() => ({ route: { query: {} as Record<string, string> }, push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
const options = { global: { stubs: { AnalyticsView: true, InventoryTurnoverView: true, 'el-button': button } } }
beforeEach(() => { mocks.route = reactive({ query: {} }); mocks.push.mockReset() })
describe('inventory view entry', () => {
it('defaults to turnover and switches to detail without reusing turnover filters', async () => {
mocks.route.query = { warehouseCode: 'US02', focus: 'FROZEN' }
const w = mount(InventoryView, options)
expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(true)
await w.findAll('button').find(b => b.text() === '库存明细')!.trigger('click')
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { view: 'DETAIL', warehouseCode: 'US02' } })
w.unmount()
})
it('preserves original strict detail links and rejects contradictory parameters', async () => {
mocks.route.query = { from: 'overview', context: 'fixed-version' }
const w = mount(InventoryView, options)
expect(w.findComponent({ name: 'AnalyticsView' }).props('mode')).toBe('inventory')
mocks.route.query = { from: 'overview', view: 'TURNOVER' }; await flushPromises()
expect(w.find('[role="alert"]').text()).toContain('不能混用')
expect(w.findComponent({ name: 'AnalyticsView' }).exists()).toBe(false)
expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(false)
w.unmount()
})
})
@@ -1,4 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AnalyticsView from '../components/AnalyticsView.vue' import AnalyticsView from '../components/AnalyticsView.vue'
import InventoryTurnoverView from '../components/InventoryTurnoverView.vue'
import { inventoryView } from '../turnoverNavigation'
const route = useRoute(), router = useRouter()
const retainedWarehouse = ref('')
const selected = computed(() => {
try { return { view: inventoryView(route.query), error: '' } }
catch (cause) { return { view: null, error: (cause as Error).message } }
})
function switchView(view: 'TURNOVER' | 'DETAIL') {
const warehouseCode = retainedWarehouse.value || (typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '')
void router.push({ path: '/self-operated/inventory', query: { view, ...(warehouseCode ? { warehouseCode } : {}) } })
}
</script> </script>
<template><AnalyticsView mode="inventory" /></template> <template>
<nav class="inventory-views" aria-label="库存分析视图"><el-button :type="selected.view === 'TURNOVER' ? 'primary' : undefined" @click="switchView('TURNOVER')">库存周转</el-button><el-button :type="selected.view === 'DETAIL' ? 'primary' : undefined" @click="switchView('DETAIL')">库存明细</el-button></nav>
<p v-if="selected.error" role="alert">{{ selected.error }}</p>
<AnalyticsView v-else-if="selected.view === 'DETAIL'" mode="inventory" />
<InventoryTurnoverView v-else @warehouse-selected="retainedWarehouse = $event" />
</template>
<style scoped>.inventory-views { display: flex; gap: 8px; padding: 16px 20px 0; }</style>
@@ -28,7 +28,7 @@ const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['cli
const input = defineComponent({ props: ['modelValue', 'id'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('textarea', { id: props.id, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value) }) } }) const input = defineComponent({ props: ['modelValue', 'id'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('textarea', { id: props.id, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value) }) } })
const loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } }) const loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } })
const dialog = defineComponent({ props: ['modelValue', 'title', 'confirmLoading'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'detail-dialog' }, slots.default?.()) : null } }) const dialog = defineComponent({ props: ['modelValue', 'title', 'confirmLoading'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'detail-dialog' }, slots.default?.()) : null } })
const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } } const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('aside', { 'data-test': 'information-popover' }, [slots.reference?.(), slots.default?.()]) } }), 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } }
const wrappers: ReturnType<typeof mount>[] = [] const wrappers: ReturnType<typeof mount>[] = []
function findButton(wrapper: ReturnType<typeof mount>, text: string) { const result = wrapper.findAll('button').find(item => item.text() === text); if (!result) throw new Error(`missing button ${text}`); return result } function findButton(wrapper: ReturnType<typeof mount>, text: string) { const result = wrapper.findAll('button').find(item => item.text() === text); if (!result) throw new Error(`missing button ${text}`); return result }
async function setFilters(wrapper: ReturnType<typeof mount>, values: Record<string, string | number | string[]>) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() } async function setFilters(wrapper: ReturnType<typeof mount>, values: Record<string, string | number | string[]>) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
@@ -270,7 +270,8 @@ describe('self-operated actual workbench request state', () => {
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-12T02:00:00Z', inventoryObservedFrom: '2026-10-12T01:30:00Z', inventoryObservedUntil: '2026-10-12T01:45:00Z' } as WarehouseOverview] }) mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-12T02:00:00Z', inventoryObservedFrom: '2026-10-12T01:30:00Z', inventoryObservedUntil: '2026-10-12T01:45:00Z' } as WarehouseOverview] })
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper) const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises() await flushPromises()
const note = wrapper.get('[data-testid="inventory-observation"]') expect(wrapper.find('[data-test="information-popover"] [aria-label="统计口径与采集时间"]').exists()).toBe(true)
const note = wrapper.get('[data-test="information-popover"] [data-testid="inventory-observation"]')
expect(note.text()).toContain('2026/10/12 08:00:00 至 2026/10/12 08:12:00') expect(note.text()).toContain('2026/10/12 08:00:00 至 2026/10/12 08:12:00')
expect(note.text()).toContain('本仓一轮完整分页观测') expect(note.text()).toContain('本仓一轮完整分页观测')
expect(note.text()).toContain('不代表同一瞬间的库存') expect(note.text()).toContain('不代表同一瞬间的库存')
@@ -312,7 +313,8 @@ describe('self-operated actual workbench request state', () => {
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode') expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode')
expect(mocks.api.ownerOverview).not.toHaveBeenCalled() expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('货主经营') expect(wrapper.text()).not.toContain('货主经营')
expect(wrapper.find('details').attributes('open')).toBeUndefined() expect(wrapper.find('details').exists()).toBe(false)
expect(wrapper.find('[aria-label="统计口径与采集时间"]').exists()).toBe(true)
}) })
it('rejects missing threshold and drills inventory SKU to fixed 30-day daily data', async () => { it('rejects missing threshold and drills inventory SKU to fixed 30-day daily data', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper) const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { inventoryView, turnoverRoute, validateTurnover, yesterdayBeijing } from './turnoverNavigation'
import type { TurnoverQuery } from './turnoverTypes'
const valid: TurnoverQuery = { sourceAccount: 'primary', warehouseCode: 'W', groupBy: 'SKU', endDate: '2026-10-12', baseWindowDays: 30, focus: 'ALL', sortBy: 'PHYSICAL_DESC', current: 1, size: 20, includeOverview: true }
describe('inventory view and turnover query contract', () => {
it('defaults to turnover but preserves strict dashboard and legacy detail links', () => {
expect(inventoryView({ warehouseCode: 'W', sku: 'X' })).toBe('TURNOVER')
for (const q of [{ from: 'overview' }, { drilldownFrom: 'DASHBOARD' }, { salesWindowDays: '10' }, { attention: 'FROZEN_STOCK' }, { view: 'DETAIL' }]) expect(inventoryView(q)).toBe('DETAIL')
})
it('rejects conflicting views and unknown or duplicate parameters', () => {
for (const q of [{ view: 'TURNOVER', from: 'overview' }, { view: 'TURNOVER', attention: 'ALL' }, { view: 'DETAIL', groupBy: 'SKU' }, { view: ['TURNOVER'] }, { view: 'OTHER' }]) expect(() => inventoryView(q)).toThrow()
expect(() => turnoverRoute({ salesRunId: 'invented' })).toThrow()
expect(() => turnoverRoute({ groupBy: ['OWNER', 'SKU'] })).toThrow()
})
it('validates independent focus, threshold, group, date and pagination boundaries', () => {
expect(() => validateTurnover(valid, '2026-10-12')).not.toThrow()
for (const patch of [{ endDate: '2026-02-30' }, { endDate: '2026-10-13' }, { groupBy: 'OWNER', sku: 'X' }, { groupBy: 'OWNER', sortBy: 'COVERAGE_DESC' }, { focus: 'COVERAGE_GT' }, { coverageDaysThreshold: 1.5 }, { size: 101 }, { current: 0 }, { baseWindowDays: 10 }]) expect(() => validateTurnover({ ...valid, ...patch } as TurnoverQuery, '2026-10-12')).toThrow()
expect(() => validateTurnover({ ...valid, focus: 'COVERAGE_LE', coverageDaysThreshold: 45 }, '2026-10-12')).not.toThrow()
})
it('uses Beijing yesterday across UTC day boundaries', () => {
expect(yesterdayBeijing(new Date('2026-10-12T16:01:00Z'))).toBe('2026-10-12')
})
})
@@ -0,0 +1,48 @@
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']
const detailOnly = ['salesWindowDays', 'salesWindowEndDate', 'attention', 'coverageSelection', 'coverageBucket', 'bucketRuleVersion']
const turnoverOnly = ['groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext']
function scalar(q: Record<string, unknown>, key: string) {
const v = q[key]
if (v == null) return undefined
if (typeof v !== 'string' || !v.trim()) throw new Error(`参数 ${key} 无效`)
return v
}
export function inventoryView(q: Record<string, unknown>): 'TURNOVER' | 'DETAIL' {
const view = scalar(q, 'view')
if (view && view !== 'TURNOVER' && view !== 'DETAIL') throw new Error('库存视图参数无效')
const strict = q.from === 'overview' || q.drilldownFrom === 'DASHBOARD'
const detail = strict || detailOnly.some(k => q[k] != null)
if (view === 'TURNOVER' && detail) throw new Error('库存周转不能混用总览下钻或库存明细条件')
const selected = detail || view === 'DETAIL' ? 'DETAIL' : 'TURNOVER'
if (selected === 'DETAIL' && turnoverOnly.some(k => q[k] != null)) throw new Error('库存明细不能携带周转专属条件')
return selected
}
export function yesterdayBeijing(now = new Date()) {
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now)
return new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
}
export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing()) {
if (!q.warehouseCode.trim()) throw new Error('请选择可用仓库')
if (!/^\d{4}-\d{2}-\d{2}$/.test(q.endDate) || !Number.isFinite(Date.parse(q.endDate)) || new Date(q.endDate).toISOString().slice(0, 10) !== q.endDate || q.endDate > latestDate) throw new Error('截止日必须是已结束的北京时间日期')
if (![7, 15, 30].includes(q.baseWindowDays)) throw new Error('计算基准仅支持7、15、30天')
if (!['SKU', 'OWNER'].includes(q.groupBy)) throw new Error('分组无效')
if (q.groupBy === 'OWNER' && q.sku) throw new Error('货主汇总不能携带SKU搜索')
if (!['ALL', 'NO_OUTBOUND_30D', 'COVERAGE_GT', 'COVERAGE_LE', 'FROZEN', 'DEFECTIVE', 'UNASSESSABLE'].includes(q.focus)) throw new Error('关注条件无效')
if (q.coverageDaysThreshold != null && (!Number.isSafeInteger(q.coverageDaysThreshold) || q.coverageDaysThreshold <= 0)) throw new Error('阈值必须为正整数天数')
if (['COVERAGE_GT', 'COVERAGE_LE'].includes(q.focus) && q.coverageDaysThreshold == null) throw new Error('请填写快慢判断的天数阈值')
if (q.turnoverStatus && q.turnoverStatus !== 'INSUFFICIENT') throw new Error('周转样本条件无效')
if (!turnoverSorts.includes(q.sortBy) || q.groupBy === 'OWNER' && ['COVERAGE_ASC', 'COVERAGE_DESC'].includes(q.sortBy) || q.groupBy === 'SKU' && q.sortBy === 'NO_OUTBOUND_PHYSICAL_DESC') throw new Error('排序不适用于当前分组')
if (!Number.isSafeInteger(q.current) || q.current < 1 || !Number.isSafeInteger(q.size) || q.size < 1 || q.size > 100) throw new Error('分页参数无效')
}
export function turnoverRoute(q: Record<string, unknown>): Partial<TurnoverQuery> {
if (inventoryView(q) !== 'TURNOVER') throw new Error('当前为库存明细入口')
const allowed = new Set(['view', 'warehouseCode', 'sku', 'customerCode', 'endDate', 'baseWindowDays', 'groupBy', 'focus', 'coverageDaysThreshold', 'turnoverStatus', 'sortBy', 'snapshotContext', 'current', 'size'])
for (const key of Object.keys(q)) if (!allowed.has(key)) throw new Error(`库存周转不接受参数 ${key}`)
const result: Record<string, string | number> = {}
for (const key of allowed) {
const value = scalar(q, key)
if (value != null && key !== 'view') result[key] = ['baseWindowDays', 'coverageDaysThreshold', 'current', 'size'].includes(key) ? Number(value) : value
}
return result as Partial<TurnoverQuery>
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { turnoverChart, turnoverNumber, windowText } from './turnoverPresentation'
import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes'
describe('turnover honest precision and charts', () => {
it('keeps tiny positive values distinct from zero and unknown', () => {
expect(turnoverNumber(0.000001)).toBe('<0.01')
expect(turnoverNumber(0.01, 1)).toBe('<0.1')
expect(turnoverNumber(0)).toBe('0'); expect(turnoverNumber(null)).toBe('—')
})
it('does not draw partial-window totals as daily averages or zero bars', () => {
const partial: TurnoverWindowMetric = { days: 15, startDate: '2026-09-28', endDateExclusive: '2026-10-13', dataStatus: 'PARTIAL', observedQuantity: 150, outboundQuantity: null, averageDailyOutboundQuantity: null, completeDayCount: 7, partialDayCount: 1, unknownDayCount: 7, reasonCodes: [] }
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: [{ ...partial, days: 7, dataStatus: 'COMPLETE', averageDailyOutboundQuantity: 10 }, partial] }
const option = turnoverChart(charts, 'daily')
expect(JSON.stringify(option.series)).toContain('"data":[10,null]')
expect(windowText(partial)).toBe('已采集 150,覆盖不足')
})
})
@@ -0,0 +1,30 @@
import type { Quantity } from './types'
import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes'
import type { EChartsOption } from 'echarts'
export function turnoverNumber(value: Quantity | undefined, decimals = 2) {
if (value == null || value === '' || !Number.isFinite(Number(value))) return '—'
const n = Number(value)
if (n > 0 && n < 10 ** -decimals) return `<${(10 ** -decimals).toFixed(decimals)}`
return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals })
}
export function totalText(total: TurnoverTotal) {
return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验`
}
export function windowText(metric: TurnoverWindowMetric | undefined) {
if (!metric) return '—'
return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足`
}
export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'daily'): EChartsOption {
const owners = kind === 'owners'
const rows = owners ? charts.ownerPhysicalTop : charts.averageDaily
return {
aria: { enabled: true }, animationDuration: 450, color: ['#6c7bea'],
tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 12, right: 24, top: 16, bottom: 20, containLabel: true },
xAxis: owners ? { type: 'value', name: '件' } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) },
yAxis: owners ? { type: 'category', inverse: true, data: charts.ownerPhysicalTop.map(w => w.customerName || w.customerCode) } : { type: 'value', name: '件/日' },
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,48 @@
import type { QueryContext, Quantity } from './types'
// Inventory-turnover integration contract. Backend implementation and HTTP acceptance are pending.
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 interface TurnoverQuery {
sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow
groupBy: TurnoverGroup; customerCode?: string; sku?: string; focus: TurnoverFocus
coverageDaysThreshold?: number; turnoverStatus?: 'INSUFFICIENT'; sortBy: TurnoverSort
current: number; size: number; includeOverview: boolean; snapshotContext?: string
}
export interface TurnoverWindowMetric {
days: TurnoverWindow; startDate: string; endDateExclusive: string
outboundQuantity: Quantity; observedQuantity: Quantity; averageDailyOutboundQuantity: Quantity
completeDayCount: number; partialDayCount: number; unknownDayCount: number
dataStatus: string; reasonCodes: string[]
}
export interface TurnoverTotal { value: Quantity; knownSubtotal: Quantity; unknownSkuCount: number; reasonCodes: string[] }
export interface TurnoverRisk { matchedSkuCount: Quantity; knownPhysicalQuantity: Quantity; unknownSkuCount: number; physicalQuantityRatio: Quantity }
export interface TurnoverRow {
customerCode: string; customerName: string | null; sku: string | null; productName: string | null
physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; frozenQuantity: TurnoverTotal; defectiveQuantity: TurnoverTotal
physicalSkuCount: Quantity; windows: TurnoverWindowMetric[]; averageDailyOutboundQuantity: Quantity
inventoryCoverageDays: Quantity; coverageStatus: string; averagePhysicalQuantity: Quantity
quantityTurnoverCount: Quantity; turnoverStatus: string; qualifiedSampleDays: number; requiredSampleDays: number
missingSampleDates: string[]; sampleReasonCodes: string[]; lastObservedOutboundDate: string | null
attentionReasons: string[]; noOutbound30d: TurnoverRisk; slowCoverage: TurnoverRisk; unassessableSkuCount: number
}
export interface TurnoverContext {
snapshotContext: string; sourceAccount: string; warehouseCode: string; endDate: string
baseWindowDays: TurnoverWindow; queryAsOf: string; queryExpiresAt: string; analyticsContext: QueryContext
inventoryObservedFrom: string | null; inventoryObservedUntil: string | null
sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1'; dailySamples: { date: string; runId: string | null }[]
missingSampleDates: string[]; accumulationStartedOn: string | null
}
export interface TurnoverOverview {
physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; noOutboundSkuCount: TurnoverTotal; unassessableSkuCount: TurnoverTotal
}
export interface TurnoverCharts {
ownerPhysicalTop: { customerCode: string; customerName: string | null; physicalQuantity: Quantity }[]
unknownOwnerCount: number; averageDaily: TurnoverWindowMetric[]
}
export interface TurnoverResponse {
context: TurnoverContext; summary: TurnoverOverview | null; charts: TurnoverCharts | null
rows: TurnoverRow[]; total: number
}