feat: add platform self-operated sales inventory and collection views

This commit is contained in:
2026-10-12 20:33:39 +08:00
parent cc4856ceac
commit 533273769d
23 changed files with 1395 additions and 4 deletions
@@ -0,0 +1,218 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { analyticsApi } from '../api/analytics'
import type { Overview, Run, Task, TaskDetail, Validation } from '../types'
import { RequestFence, RetryIntent, RuntimePoller, hasActiveTask, label, beijingTime, reasonsText, unitText, progressText, quantityText, taskRetryAllowed } from '../state'
const access = useAccessPreviewStore()
const route = useRoute()
const canList = computed(() => access.can('platform:self-operated-analytics:list'))
const canSync = computed(() => access.can('platform:self-operated-analytics:sync'))
const sourceAccount = 'lingxing-primary'
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '' })
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
const overview = ref<Overview | null>(null)
const rows = ref<Task[]>([])
const current = ref(1)
const size = ref(20)
const total = ref(0)
const upperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null)
const observedAt = ref<string | null>(null)
const error = ref('')
const errorDetails = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const selected = ref<Task | null>(null)
const selectedDetail = ref<TaskDetail | null>(null)
const attempts = ref<Run[]>([])
const attemptsPage = ref(1)
const attemptsSize = ref(20)
const attemptsTotal = ref(0)
const attemptsUpperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null)
const detailLoading = ref(false)
const detailError = ref('')
const reason = ref('')
const confirming = ref(false)
const submitting = ref(false)
const actionFeedback = ref('')
const fence = new RequestFence()
const detailFence = new RequestFence()
const actionFence = new RequestFence()
const retryIntent = new RetryIntent()
const warehouses = computed(() => overview.value?.warehouses.filter(row => !warehouseCode.value || row.warehouseCode === warehouseCode.value) ?? [])
const fields = computed<QueryField[]>(() => [{ key: 'warehouseCode', label: '仓库', type: 'select', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { label: row.warehouseName || row.warehouseCode, value: row.warehouseCode }])).values()] }])
const scope = computed(() => ({ sourceAccount, warehouseCode: warehouseCode.value }))
const retryAllowed = computed(() => taskRetryAllowed(selected.value, canSync.value, scope.value, reason.value))
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ prop: 'taskNo', label: '任务号', minWidth: 230 },
{ key: 'family', label: '任务类型 / 用途', minWidth: 160, slotName: 'family' },
{ prop: 'warehouseCode', label: '仓库', minWidth: 120 },
{ key: 'range', label: '目标范围(北京时间)', minWidth: 280, slotName: 'range' },
{ key: 'status', label: '执行结果', minWidth: 130, slotName: 'status' },
{ key: 'publication', label: '发布结果', minWidth: 150, slotName: 'publication' },
{ key: 'progress', label: '阶段 / 可靠计数', minWidth: 310, slotName: 'progress' },
{ key: 'health', label: '最近进展 / 心跳', minWidth: 220, slotName: 'health' },
{ key: 'block', label: '阻断原因', minWidth: 210, slotName: 'block' },
]
const runColumns: DataTableColumn[] = [
{ prop: 'attemptNo', label: '尝试序号', width: 100 },
{ key: 'status', label: '执行结果', width: 120, slotName: 'status' },
{ key: 'startedAt', label: '开始(北京时间)', minWidth: 180, slotName: 'startedAt' }, { key: 'finishedAt', label: '结束(北京时间)', minWidth: 180, slotName: 'finishedAt' },
{ key: 'progress', label: '冻结阶段摘要', minWidth: 290, slotName: 'progress' },
{ prop: 'failureSummary', label: '失败摘要', minWidth: 210 },
]
function asTask(row: unknown) { return row as Task }
function asRun(row: unknown) { return row as Run }
function family(value: string) { return value === 'INVENTORY' ? '产品库存' : value === 'OUTBOUND' || value === 'SALES' ? '出库销量' : '任务类型待核对' }
function validation(task: Task | null | undefined): Validation | null { return task?.validation ?? null }
function budgetValue(value: unknown) { return typeof value === 'number' || typeof value === 'string' ? String(value) : '—' }
function blockReason(value: string | null | undefined) { return value ? reasonsText([value]) : '—' }
function budgetName(key: string) { return ({ pages: '分页HTTP', detailHttp: '详情HTTP', http: '全部HTTP保留额度', confirmedHttp: '已确认真实HTTP', distinctDetails: '不同详情对象', maxPages: '分页HTTP上限', maxPageSize: '每页数量上限', maxDistinctDetails: '不同详情对象上限', maxDetailHttp: '详情HTTP上限', maxInventoryPagesPerWarehouse: '每仓库存页上限', maxHttp: '全部HTTP上限' } as Record<string, string>)[key] ?? '其他保护额度' }
function limitFor(used: string, value: Validation | null) { const key = ({ pages: 'maxPages', detailHttp: 'maxDetailHttp', http: 'maxHttp', confirmedHttp: 'maxHttp', distinctDetails: 'maxDistinctDetails' } as Record<string, string>)[used]; return key ? value?.budgetLimits?.[key] : null }
async function read() {
if (!canList.value) return false
const request = fence.begin()
loading.value = !overview.value
try {
const nextOverview = await analyticsApi.overview(sourceAccount, undefined, request.signal)
const tasks = warehouseCode.value ? await analyticsApi.tasks({ ...scope.value, current: current.value, size: size.value, ...upperBound.value }, request.signal) : null
if (!request.current()) return false
overview.value = nextOverview
observedAt.value = nextOverview.observedAt
if (tasks) { rows.value = tasks.records; total.value = tasks.total; upperBound.value = { listUpperCreatedAt: tasks.listUpperCreatedAt, listUpperId: tasks.listUpperId } }
error.value = ''
errorDetails.value = null
return hasActiveTask([...rows.value, ...nextOverview.warehouses.flatMap(row => [row.currentTask, row.sharedUpstream].filter((task): task is Task => Boolean(task)))])
} catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '状态更新失败,保留最后读取数据'); error.value = errorDetails.value.message }; return hasActiveTask(rows.value) }
finally { if (request.current()) loading.value = false }
}
async function selectTask(task: Task, reset = true) {
if (!task.taskNo || task.warehouseCode !== warehouseCode.value) return
if (reset) { actionFence.cancel(); submitting.value = false; selected.value = task; selectedDetail.value = null; attempts.value = []; attemptsPage.value = 1; attemptsUpperBound.value = null; reason.value = ''; confirming.value = false; actionFeedback.value = '' }
const request = detailFence.begin()
detailLoading.value = true
detailError.value = ''
try {
const detail = await analyticsApi.task(scope.value, task.taskNo, request.signal)
const history = await analyticsApi.runs({ ...scope.value, taskNo: task.taskNo, current: attemptsPage.value, size: attemptsSize.value, ...attemptsUpperBound.value }, request.signal)
if (!request.current()) return
if (detail.task.taskNo !== task.taskNo || detail.task.warehouseCode !== warehouseCode.value) throw new Error('任务归属不匹配')
selected.value = detail.task
selectedDetail.value = detail
attempts.value = history.records
attemptsTotal.value = history.total
attemptsUpperBound.value = { listUpperCreatedAt: history.listUpperCreatedAt, listUpperId: history.listUpperId }
} catch (cause) { if (request.current()) detailError.value = getApiErrorMessage(cause, '任务详情更新失败,保留上次详情') }
finally { if (request.current()) detailLoading.value = false }
}
async function submitRetry() {
if (!confirming.value || !retryAllowed.value || submitting.value || !selected.value) return
const target = selected.value
const request = actionFence.begin()
submitting.value = true
actionFeedback.value = ''
try {
const accepted = await analyticsApi.retry(retryIntent.create(target.taskNo, warehouseCode.value, reason.value), request.signal)
if (!request.current()) return
if (accepted.taskNo !== target.taskNo) throw new Error('受理任务号不匹配')
retryIntent.accepted()
confirming.value = false
actionFeedback.value = '恢复请求已排队受理,尚未完成采集或发布。'
await read()
await selectTask(target, false)
} catch (cause) { if (request.current()) actionFeedback.value = getApiErrorMessage(cause, '恢复请求失败;再次提交相同内容将沿用原幂等键') }
finally { if (request.current()) submitting.value = false }
}
const poller = new RuntimePoller(async () => { const busy = await read(); if (selected.value && !submitting.value) await selectTask(selected.value, false); return busy }, () => { fence.cancel(); detailFence.cancel() })
function visibility() { poller.setVisible(!document.hidden) }
function refresh() { upperBound.value = null; current.value = 1; void read() }
watch(warehouseCode, () => { fence.cancel(); detailFence.cancel(); actionFence.cancel(); selected.value = null; rows.value = []; total.value = 0; attempts.value = []; upperBound.value = null; confirming.value = false; submitting.value = false; reason.value = ''; actionFeedback.value = ''; current.value = 1; void read() })
watch([current, size], () => { void read() })
watch([attemptsPage, attemptsSize], () => { if (selected.value) void selectTask(selected.value, false) })
watch(reason, () => { confirming.value = false })
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility) })
onUnmounted(() => { poller.stop(); actionFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<p v-if="!canList" role="alert">没有采集状态查看权限。</p>
<ListPageLayout v-else>
<p>来源账号:{{ sourceAccount }} · 全部业务时间为北京时间 · 最近观测 {{ beijingTime(observedAt) }}</p>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="采集仓库筛选" :loading="loading" @search="refresh" @reset="refresh" />
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error" :loading="loading" compact @retry="read"><template #details><p>状态观测时间 {{ beijingTime(observedAt) }}。任务运行失败与本页读取失败分别判断。</p><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<section v-for="item in warehouses" :key="`${item.warehouseCode}:${item.dataFamily}`" :aria-label="`${item.warehouseName || item.warehouseCode}采集状态`">
<h3>{{ item.warehouseName || item.warehouseCode }} · {{ family(item.dataFamily) }}</h3>
<el-descriptions class="density-descriptions" :column="4" border>
<el-descriptions-item label="环境 / 模式">{{ overview?.environmentLabel ?? '—' }} / {{ label(overview?.collectionMode) }}</el-descriptions-item>
<el-descriptions-item label="采集 / 发布">{{ item.executionEnabled ? '已启用' : '未启用' }} / {{ item.publicationEnabled ? '已启用' : '未启用' }}</el-descriptions-item>
<el-descriptions-item label="用途">{{ label(item.currentTask?.executionPurpose) }}</el-descriptions-item>
<el-descriptions-item label="停用原因">{{ blockReason(item.disabledReason) }}</el-descriptions-item>
<el-descriptions-item label="最近执行">{{ label(item.currentTask?.taskStatus) }}</el-descriptions-item>
<el-descriptions-item label="发布结果">{{ label(item.currentTask?.resultPublicationState) }}</el-descriptions-item>
<el-descriptions-item label="最近成功">{{ item.lastSucceededAt ? beijingTime(item.lastSucceededAt) : '尚无成功记录' }}</el-descriptions-item>
<el-descriptions-item label="结果发布时间">{{ item.lastPublishedAt ? beijingTime(item.lastPublishedAt) : '尚无可用数据' }}</el-descriptions-item>
<el-descriptions-item label="两个月目标">{{ beijingTime(item.initialFrom) }} 至 {{ beijingTime(item.initialUntil) }}</el-descriptions-item>
<el-descriptions-item label="首采完整覆盖">{{ label(item.initialCoverageStatus) }}</el-descriptions-item>
<el-descriptions-item label="连续覆盖截至">{{ beijingTime(item.salesCoveredUntil) }}</el-descriptions-item>
<el-descriptions-item label="数据新鲜度">{{ label(item.dataFreshness) }}</el-descriptions-item>
<el-descriptions-item label="库存采集区间" :span="2">{{ beijingTime(item.inventoryObservedFrom) }} 至 {{ beijingTime(item.inventoryObservedUntil) }}</el-descriptions-item>
<el-descriptions-item label="下次计划">{{ item.nextScheduledAt ? beijingTime(item.nextScheduledAt) : (item.executionEnabled ? '待确认' : '未启用') }}</el-descriptions-item>
<el-descriptions-item label="修订状态">{{ item.rebuildRequired ? '待重建' : label(item.revisionReviewStatus) }}</el-descriptions-item>
<el-descriptions-item label="账号冷却">{{ beijingTime(item.protection.accountCooldownUntil) }} {{ item.protection.accountPauseReason ?? '' }}</el-descriptions-item>
<el-descriptions-item label="验证会话">{{ validation(item.currentTask)?.validationSessionNo ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="会话截止">{{ beijingTime(validation(item.currentTask)?.deadlineAt) }}</el-descriptions-item>
<el-descriptions-item label="停止原因">{{ validation(item.currentTask)?.stopReason ?? '—' }}</el-descriptions-item>
</el-descriptions>
<p v-if="item.dataFamily === 'INVENTORY' && item.inventoryDailySamples">每日库存观测:{{ item.inventoryDailySamples.windowFrom }} 至 {{ item.inventoryDailySamples.windowUntil }}(结束边界不含,近 {{ item.inventoryDailySamples.expectedDays }} 个完整日),已留存 {{ item.inventoryDailySamples.sampleCount }} / {{ item.inventoryDailySamples.expectedDays }} 个日样本;数据积累起点 {{ item.inventoryDailySamples.accumulationStartedOn ?? '尚无日样本' }}。缺口日期:{{ item.inventoryDailySamples.missingDates.length ? item.inventoryDailySamples.missingDates.join('、') : '无' }}。日样本只表示实际观测留存,不代表真实库龄。</p>
<p v-else-if="item.dataFamily === 'INVENTORY'">每日库存观测积累状态尚无可用资料。</p>
<p v-if="item.currentTask?.taskStatus === 'FAILED' || item.currentTask?.taskStatus === 'DEAD'">本次失败,{{ item.lastPublishedAt ? '仍展示上次成功数据;请核对数据时效。' : '尚无可用数据。' }}</p>
<p v-if="item.sharedUpstream">账号共享阶段:{{ item.sharedUpstream.taskNo || '—' }} · {{ label(item.sharedUpstream.taskStatus) }} · {{ progressText(item.sharedUpstream.progress) }}。共享采集完成不代表本仓发布成功。</p>
<p>账号日预算 {{ quantityText(item.protection.dailyRequestBudget) }};已保留请求 {{ quantityText(item.protection.reservedHttpRequests) }};已确认 HTTP {{ quantityText(item.protection.confirmedHttpRequests) }};额度日期 {{ item.protection.budgetDate ?? '—' }}。</p>
<p>验证会话预算(含保守预留):<span v-for="(used, key) in validation(item.currentTask)?.budgetConsumed" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(used) }} / {{ budgetValue(limitFor(String(key), validation(item.currentTask))) }}; </span><span v-if="!validation(item.currentTask)?.budgetConsumed">尚无预算观测</span></p>
<p v-if="validation(item.currentTask)">会话限制:<span v-for="(limit, key) in validation(item.currentTask)?.budgetLimits" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(limit) }}; </span>最多 1 个在途,请求起始间隔至少 5 秒。验证样本不会发布正式结果。</p>
</section>
<p v-if="!warehouseCode">选择仓库后查看逻辑任务与尝试历史。首采完整覆盖不以单切片成功或账号目录完成推定。</p>
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" empty-text="暂无逻辑任务">
<template #toolbar-actions><el-button @click="refresh">更新任务列表</el-button></template>
<template #actions="{ row }"><el-button link :disabled="!asTask(row).taskNo" @click="selectTask(asTask(row))">详情 / 恢复</el-button></template>
<template #family="{ row }">{{ family(asTask(row).dataFamily) }} / {{ label(asTask(row).executionPurpose) }}</template>
<template #range="{ row }">{{ beijingTime(asTask(row).queryFrom) }} 至 {{ beijingTime(asTask(row).queryUntil) }}</template>
<template #status="{ row }">{{ label(asTask(row).taskStatus) }}</template>
<template #publication="{ row }">{{ label(asTask(row).resultPublicationState) }}</template>
<template #progress="{ row }">{{ progressText(asTask(row).progress) }}</template>
<template #health="{ row }">{{ beijingTime(asTask(row).latestAttempt?.progressUpdatedAt) }} / {{ label(asTask(row).latestAttempt?.executionHealth) }}</template>
<template #block="{ row }">{{ blockReason(asTask(row).actionBlockReason) }}</template>
</DataTable>
<section v-if="selected" aria-label="选中任务详情">
<h3>任务 {{ selected.taskNo || '—' }}</h3>
<p v-if="detailLoading">读取任务详情…</p><p v-if="detailError" role="alert">{{ detailError }}</p>
<p>执行:{{ label(selected.taskStatus) }};发布:{{ label(selected.resultPublicationState) }};尝试 {{ selected.attemptCount }} 次;{{ progressText(selected.progress) }}</p>
<p>目标 {{ beijingTime(selected.queryFrom) }} 至 {{ beijingTime(selected.queryUntil) }};下次重试 {{ beijingTime(selected.nextRetryAt) }};{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : (selected.latestAttempt?.failureSummary ?? '') }}</p>
<p>尝试开始 {{ beijingTime(selected.latestAttempt?.startedAt) }};结束 {{ beijingTime(selected.latestAttempt?.finishedAt) }};耗时 {{ quantityText(selected.latestAttempt?.elapsedSeconds) }} 秒;心跳 {{ beijingTime(selected.latestAttempt?.heartbeatAt) }} · {{ label(selected.latestAttempt?.executionHealth) }}。</p>
<p>数据质量:{{ label(selected.progress?.validationState) }};异常 {{ quantityText(selected.progress?.invalidCount) }},未知 {{ quantityText(selected.progress?.unknownCount) }},待核对 {{ quantityText(selected.progress?.pendingReviewCount) }}({{ unitText(selected.progress?.countUnit) }});摘要时间 {{ beijingTime(selected.progress?.summaryAsOf) }}。未计算的数量不默认为零。</p>
<p v-for="(stage, index) in selected.progress?.stages" :key="index">{{ progressText(stage) }}</p>
<p v-if="selectedDetail?.sharedUpstream">账号共享上游:{{ selectedDetail.sharedUpstream.taskNo }} · {{ label(selectedDetail.sharedUpstream.taskStatus) }} · {{ progressText(selectedDetail.sharedUpstream.progress) }}</p>
<DataTable appearance="integrated" v-model:page="attemptsPage" v-model:page-size="attemptsSize" :data="attempts" :columns="runColumns" :total="attemptsTotal" :loading="detailLoading" empty-text="尚无执行尝试">
<template #status="{ row }">{{ label(asRun(row).status) }}</template><template #progress="{ row }">{{ progressText(asRun(row).progress) }}</template><template #startedAt="{ row }">{{ beijingTime(asRun(row).startedAt) }}</template><template #finishedAt="{ row }">{{ beijingTime(asRun(row).finishedAt) }}</template>
</DataTable>
<form v-if="canSync" class="density-form" @submit.prevent="submitRetry">
<label for="retry-reason">恢复原因</label><el-input id="retry-reason" v-model="reason" type="textarea" placeholder="请说明已完成的修复及恢复原任务的原因" :maxlength="500" :disabled="submitting" />
<p v-if="!selected.allowedActions.includes('RETRY') || selected.actionBlockReason">{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : '当前任务不允许恢复' }}</p>
<el-button v-if="!confirming" :disabled="!retryAllowed || submitting" @click="confirming = true">核对恢复请求</el-button>
<template v-else><p>确认恢复任务 {{ selected.taskNo }}(仓库 {{ selected.warehouseCode }}),范围保持不变,原因:{{ reason }}。受理后仍须等待执行与发布。</p><el-button :disabled="!retryAllowed || submitting" :loading="submitting" native-type="submit" type="primary">确认提交恢复请求</el-button><el-button :disabled="submitting" @click="confirming = false">返回修改</el-button></template>
</form>
<p v-else>没有任务恢复权限。</p><p v-if="actionFeedback" role="status">{{ actionFeedback }}</p>
</section>
</ListPageLayout>
</AdminPage>
</template>
@@ -0,0 +1,4 @@
<script setup lang="ts">
import AnalyticsView from '../components/AnalyticsView.vue'
</script>
<template><AnalyticsView mode="inventory" /></template>
@@ -0,0 +1,4 @@
<script setup lang="ts">
import AnalyticsView from '../components/AnalyticsView.vue'
</script>
<template><AnalyticsView mode="sales" /></template>
@@ -0,0 +1,139 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AnalyticsView from '../components/AnalyticsView.vue'
import CollectionView from './CollectionView.vue'
import type { Metric, Overview, OwnerOverview, QueryContext, SalesSummary, Task, WarehouseOverview } from '../types'
const mocks = vi.hoisted(() => ({ api: { owners: vi.fn(), ownerOverview: vi.fn(), summary: vi.fn(), daily: vi.fn(), hot: vi.fn(), slow: vi.fn(), inventory: vi.fn(), overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn() }, canSync: true }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks.api }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') || mocks.canSync }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: { warehouseCode: 'WH-A' } }), useRouter: () => ({ push: vi.fn() }) }))
const context: QueryContext = { salesRunId: '11', inventoryRunId: '0', salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12', completeDayFrom: '2026-08-13', completeDayUntil: '2026-10-12', rangeStartDate: '2026-09-12', rangeEndDateExclusive: '2026-10-12' }
const quantity = { overallTotal: null, knownSubtotal: '128', unknownSkuCount: 1, dataStatus: 'PARTIAL', reasonCodes: ['INVENTORY_UNKNOWN'] }
const risk = { matchedSkuCount: null, matchedPhysicalQuantity: null, physicalQuantityRatio: null, knownMatchedSkuCount: 1, knownMatchedPhysicalQuantity: '128', unassessableSkuCount: 1, unassessableKnownPhysicalQuantity: null, reasonCodes: ['INVENTORY_UNKNOWN'] }
const owner: OwnerOverview = { context, customerCode: 'OWNER-A', customerName: '货主甲', skuCount: 2, physicalSkuCount: quantity, physicalQuantity: quantity, availableQuantity: quantity, frozenQuantity: quantity, defectivePhysicalQuantity: quantity, transitQuantity: quantity, outboundQuantity30d: quantity, averageDailyOutboundQuantity: quantity, noOutbound30d: risk, coverageAboveThreshold: { ...risk, reasonCodes: ['THRESHOLD_NOT_SET'] } }
const metric: Metric = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', customerCode: 'OWNER-A', sku: 'SKU-A', productName: '商品A', quantityUnit: 'PCS', outboundQuantity: null, skuOutboundOrderCount: null, physicalQuantity: '128', availableQuantity: '100', frozenQuantity: '20', defectivePhysicalQuantity: '8', transitQuantity: '30', outboundQuantity30d: null, averageDailyOutboundQuantity: null, inventoryCoverageDays: null, inventoryObservedAt: null, status: 'UNKNOWN', reasonCodes: ['INVENTORY_UNKNOWN'], attentionReasons: ['UNASSESSABLE'] }
const summary: SalesSummary = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', customerCode: 'OWNER-A', sku: 'SKU-A', productName: '商品A', quantityUnit: 'PCS', outboundQuantity: null, observedQuantity: '3', dataStatus: 'PARTIAL', reasonCodes: ['SALES_NOT_COVERED'], completeDayCount: 0, partialDayCount: 1, unknownDayCount: 0 }
const task: Task = { taskNo: 'COL-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'DEAD', applyState: 'BLOCKED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: ['RETRY'], actionBlockReason: null, progress: null, queryFrom: null, queryUntil: null }
const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'OFF', observedAt: '2026-10-12T01:01:00Z', warehouses: [] }
const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const queryStub = defineComponent({ props: ['modelValue'], emits: ['update:modelValue', 'search'], setup(props, { emit }) { return () => h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库') } })
const tableStub = defineComponent({ props: ['data'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row })]))]) } })
const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, type: props.nativeType || 'button', onClick: () => emit('click') }, slots.default?.()) } })
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 options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, 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 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 }
beforeEach(() => {
vi.clearAllMocks()
mocks.canSync = true
mocks.api.overview.mockResolvedValue(overview)
mocks.api.summary.mockResolvedValue({ context, records: [summary], total: 1 })
mocks.api.hot.mockResolvedValue({ context, records: [], total: 0 })
mocks.api.inventory.mockResolvedValue({ context, records: [metric], total: 45 })
mocks.api.ownerOverview.mockResolvedValue(owner)
mocks.api.daily.mockResolvedValue({ context, summary, points: [] })
mocks.api.tasks.mockResolvedValue({ records: [task], total: 1, listUpperCreatedAt: '2026-10-12T01:00:00', listUpperId: '10' })
mocks.api.task.mockResolvedValue({ task, sharedUpstream: null, protection: {}, observedAt: overview.observedAt })
mocks.api.runs.mockResolvedValue({ records: [], total: 0, listUpperCreatedAt: '2026-10-12T01:00:00', listUpperId: '10' })
mocks.api.retry.mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' })
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('self-operated actual workbench request state', () => {
it('keeps owner overview full-scope and fixed-version when inventory attention and pagination change', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
wrapper.findComponent({ name: 'OwnerPicker' }).vm.$emit('update:modelValue', 'OWNER-A')
await flushPromises()
expect(mocks.api.ownerOverview.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({ customerCode: 'OWNER-A', salesRunId: '11', inventoryRunId: '0', queryAsOf: context.queryAsOf }))
const selects = wrapper.findAllComponents(passthrough).filter(item => item.attributes('aria-label') === '库存关注')
selects[0]?.vm.$emit('update:modelValue', 'FROZEN_STOCK')
await findButton(wrapper, '应用库存关注').trigger('click'); await flushPromises()
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, customerCode: 'OWNER-A', attention: 'FROZEN_STOCK', queryAsOf: context.queryAsOf })
const params = mocks.api.ownerOverview.mock.calls.at(-1)?.[0]
for (const key of ['sku', 'attention', 'current', 'size', 'rangePreset', 'sortBy']) expect(params).not.toHaveProperty(key)
expect(wrapper.text()).toContain('已知有效小计(部分资料)')
expect(wrapper.text()).toContain('未设置查询阈值')
expect(wrapper.text()).toContain('— · 未完整')
})
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)
await flushPromises()
const select = wrapper.findAllComponents(passthrough).find(item => item.attributes('aria-label') === '库存关注')
select?.vm.$emit('update:modelValue', 'COVERAGE_GT_THRESHOLD')
await findButton(wrapper, '应用库存关注').trigger('click'); await flushPromises()
expect(mocks.api.inventory).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('选择超过阈值关注时必须填写')
await findButton(wrapper, 'SKU-A').trigger('click'); await flushPromises()
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', sku: 'SKU-A', rangePreset: 'CUSTOM', startDate: '2026-09-12', endDate: '2026-10-11', queryAsOf: context.queryAsOf })
expect(mocks.api.daily.mock.calls.at(-1)?.[0].startDate).not.toBe(context.completeDayFrom)
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).not.toHaveProperty('attention')
expect(wrapper.findComponent({ name: 'SkuTrend' }).exists()).toBe(true)
})
it('shows actual inventory daily sample gaps without inventing an accumulation date', async () => {
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', protection: {}, inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 0, missingDates: ['2026-09-12'], accumulationStartedOn: null } } as WarehouseOverview] })
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
await flushPromises()
expect(wrapper.text()).toContain('已留存 0 / 30 个日样本')
expect(wrapper.text()).toContain('数据积累起点 尚无日样本')
expect(wrapper.text()).toContain('缺口日期:2026-09-12')
})
it('pins versions and date across range and ranking switches, ranking omits chart parameters', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
await flushPromises()
await findButton(wrapper, '本月').trigger('click'); await flushPromises()
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'THIS_MONTH', queryAsOf: context.queryAsOf, salesRunId: '11', inventoryRunId: '0' })
await findButton(wrapper, '热销Top100').trigger('click'); await flushPromises()
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, windowDays: 30 })
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('rangePreset')
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode', '')
})
it('late first warehouse response cannot overwrite second warehouse data', async () => {
let resolveOld!: (result: object) => void
mocks.api.summary.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })).mockResolvedValue({ context, records: [{ ...summary, warehouseCode: 'WH-B', sku: 'SKU-B' }], total: 1 })
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
await flushPromises()
await wrapper.find('[data-test="switch-warehouse"]').trigger('click'); await flushPromises()
resolveOld({ context, records: [summary], total: 1 }); await flushPromises()
expect(wrapper.text()).toContain('SKU-B')
expect(wrapper.text()).not.toContain('SKU-A')
expect(mocks.api.summary.mock.calls[0]?.[1].aborted).toBe(true)
})
it('failed runtime polling preserves prior available data and leaves fixed context untouched', async () => {
mocks.api.overview.mockRejectedValue(new Error('offline'))
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
await flushPromises()
expect(wrapper.text()).toContain('SKU-A')
expect(wrapper.text()).toContain('3')
expect(wrapper.text()).toContain('状态更新失败')
expect(wrapper.text()).toContain('2026/10/12 09:00:00')
})
it('requires inline confirmation and preserves same key on ambiguous retry failure', async () => {
mocks.api.retry.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' })
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
await flushPromises()
await findButton(wrapper, '详情 / 恢复').trigger('click'); await flushPromises()
await wrapper.find('textarea').setValue('已修复,恢复原任务')
await findButton(wrapper, '核对恢复请求').trigger('click')
expect(mocks.api.retry).not.toHaveBeenCalled()
await wrapper.find('form').trigger('submit'); await flushPromises()
const first = mocks.api.retry.mock.calls[0]?.[0]
await wrapper.find('form').trigger('submit'); await flushPromises()
expect(mocks.api.retry.mock.calls[1]?.[0]).toEqual(first)
expect(first).toMatchObject({ action: 'RETRY', taskNo: 'COL-A', warehouseCode: 'WH-A', reason: '已修复,恢复原任务' })
expect(first).not.toHaveProperty('taskId')
expect(wrapper.text()).toContain('恢复请求已排队受理,尚未完成采集或发布')
})
it('read-only permission allows task details but never creates retry form', async () => {
mocks.canSync = false
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
await flushPromises()
await findButton(wrapper, '详情 / 恢复').trigger('click'); await flushPromises()
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('没有任务恢复权限')
})
})