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
+8
View File
@@ -0,0 +1,8 @@
# 自营经营分析
- 模块入口 `routes.ts`;正式页面 `pages/SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。
- API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。
- 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。
- 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。
- 任务编号仅用 taskNo,不以数据库 ID 回退。重试需独立 sync 权限与 allowedActions,沿 POST sync-tasks 的 RETRY 和 requestKey。
- 功能和视觉边界见 `operation-matrix.md`;未确认承载不新增浮层或独立详情。视觉待人工 1920×1080、100% 截图。
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { analyticsApi } from './analytics'
const http = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('@/shared/api/http', () => ({ http, unwrapApiResponse: (value: { data: unknown }) => value.data }))
beforeEach(() => { vi.clearAllMocks(); http.get.mockResolvedValue({ data: {} }); http.post.mockResolvedValue({ data: { taskNo: 'COL-001', status: 'QUEUED' } }) })
describe('self-operated platform transport contract', () => {
it('uses local fixed-version owners, owner overview and inventory attention endpoints', async () => {
const fixed = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-12T01:00:00Z' }
await analyticsApi.owners({ ...fixed, keyword: 'OWNER_', current: 2, size: 20 })
await analyticsApi.ownerOverview({ ...fixed, customerCode: 'OWNER_A', coverageDaysThreshold: 60 })
await analyticsApi.inventory({ ...fixed, customerCode: 'OWNER_A', attention: 'COVERAGE_GT_THRESHOLD', sortBy: 'PHYSICAL_DESC', coverageDaysThreshold: 60, current: 2, size: 20 })
expect(http.get.mock.calls.map(call => call[0])).toEqual(['owners', 'owner-overview', 'inventory-coverage'].map(path => `/platform/v1/last-leg/self-operated-analytics/${path}`))
expect(http.get.mock.calls[1]?.[1].params).toEqual({ ...fixed, customerCode: 'OWNER_A', coverageDaysThreshold: 60 })
expect(http.get.mock.calls[2]?.[1].params).toMatchObject({ attention: 'COVERAGE_GT_THRESHOLD', sortBy: 'PHYSICAL_DESC', current: 2 })
})
it('uses unified gateway domain with fixed context and abort signal, never supplier APIs', async () => {
const abort = new AbortController()
const query = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', current: 2, size: 20, salesRunId: '9007199254740999', inventoryRunId: '0', queryAsOf: '2026-10-12T01:00:00Z', salesScopeHash: 'hash' }
await analyticsApi.summary(query, abort.signal)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/sku-sales-summary', { params: query, signal: abort.signal })
})
it('reads task details by escaped taskNo with independent runtime list upper pair', async () => {
await analyticsApi.task({ sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A' }, 'COL/001')
expect(http.get.mock.calls[0]?.[0]).toBe('/platform/v1/last-leg/self-operated-analytics/sync-tasks/COL%2F001')
const params = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', current: 2, size: 20, listUpperCreatedAt: '2026-10-12T01:00:00', listUpperId: '9007199254740999' }
await analyticsApi.tasks(params)
expect(http.get.mock.calls[1]?.[1].params).toEqual(params)
})
it('posts one RETRY command with business task number and exact idempotency key', async () => {
const command = { action: 'RETRY' as const, taskNo: 'COL-001', warehouseCode: 'WH-A', requestKey: 'request-001', reason: '恢复原任务' }
expect(await analyticsApi.retry(command)).toEqual({ taskNo: 'COL-001', status: 'QUEUED' })
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/sync-tasks', command, { signal: undefined })
})
})
@@ -0,0 +1,25 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { BusinessPage, BusinessQuery, DailyResponse, 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'
async function get<T>(path: string, params: object, signal?: AbortSignal): Promise<T> {
const response = await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(`${base}/${path}`, { params, signal })
return unwrapApiResponse(response)
}
export const analyticsApi = {
owners: (query: OwnerQuery, signal?: AbortSignal) => get<BusinessPage<OwnerOption>>('owners', query, signal),
ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get<OwnerOverview | null>('owner-overview', query, signal),
summary: (query: BusinessQuery, signal?: AbortSignal) => get<BusinessPage<SalesSummary>>('sku-sales-summary', query, signal),
daily: (query: BusinessQuery, signal?: AbortSignal) => get<DailyResponse>('sku-daily-sales', query, signal),
hot: (query: BusinessQuery, signal?: AbortSignal) => get<BusinessPage<Metric>>('hot-skus', query, signal),
slow: (query: BusinessQuery, signal?: AbortSignal) => get<BusinessPage<Metric>>('slow-skus', query, signal),
inventory: (query: BusinessQuery, signal?: AbortSignal) => get<BusinessPage<Metric>>('inventory-coverage', query, signal),
overview: (sourceAccount: string, warehouseCode?: string, signal?: AbortSignal) => get<Overview>('sync-overview', { sourceAccount, warehouseCode }, signal),
tasks: (query: RuntimeQuery, signal?: AbortSignal) => get<RuntimePage<TaskDetail['task']>>('sync-tasks', query, signal),
task: (scope: Scope, taskNo: string, signal?: AbortSignal) => get<TaskDetail>(`sync-tasks/${encodeURIComponent(taskNo)}`, scope, signal),
runs: (query: RuntimeQuery, signal?: AbortSignal) => get<RuntimePage<Run>>('sync-runs', query, signal),
async retry(command: RetryCommand, signal?: AbortSignal): Promise<RetryResult> {
const response = await http.post<ApiEnvelope<RetryResult>, ApiEnvelope<RetryResult>>(`${base}/sync-tasks`, command, { signal })
return unwrapApiResponse(response)
},
}
@@ -0,0 +1,224 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { analyticsApi } from '../api/analytics'
import type { BusinessQuery, DailyResponse, InventoryAttention, InventorySort, Metric, Overview, OwnerOverview, QueryContext, RangePreset, SalesSummary } from '../types'
import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beijingTime, reasonsText, quantityText, sameContext, shownQuantity, validateCustomRange } from '../state'
import SkuTrend from './SkuTrend.vue'
import OwnerPicker from './OwnerPicker.vue'
import OwnerOverviewPanel from './OwnerOverviewPanel.vue'
const props = defineProps<{ mode: 'sales' | 'inventory' }>()
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', customerCode: typeof route.query.customerCode === 'string' ? route.query.customerCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '' })
const applied = ref({ customerCode: String(filters.value.customerCode), sku: String(filters.value.sku) })
const overview = ref<Overview | null>(null)
const context = ref<QueryContext | null>(null)
const rows = ref<(SalesSummary | Metric)[]>([])
const daily = ref<DailyResponse | null>(null)
const ownerOverview = ref<OwnerOverview | null>(null)
const attention = ref<InventoryAttention>('ALL')
const sortBy = ref<InventorySort>('AVAILABLE_DESC')
const thresholdInput = ref('')
const appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
const loading = ref(false)
const error = ref('')
const errorDetails = ref<ApiErrorDetails | null>(null)
const runtimeError = ref('')
const newData = ref(false)
const newDay = ref(false)
const current = ref(1)
const size = ref(20)
const total = ref(0)
const preset = ref<RangePreset>('TODAY')
const customStart = ref('')
const customEnd = ref('')
const tab = ref<'summary' | 'hot' | 'slow'>('summary')
const windowDays = ref<7 | 30>(30)
const sourceAccount = 'lingxing-primary'
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', type: 'select', label: '仓库', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] },
{ key: 'sku', type: 'input', label: 'SKU', placeholder: '原始 SKU 精确编码' },
])
const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
const columns = computed<DataTableColumn[]>(() => [
...(summaryMode.value ? [{ key: 'actions', label: '操作', width: 110, slotName: 'actions', fixed: 'left' }] : []),
{ prop: 'customerCode', label: '货主编码', minWidth: 140 },
...(props.mode === 'inventory' ? [{ prop: 'customerName', label: '货主名称', minWidth: 140 }] : []),
{ prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku' } : {}) },
{ prop: 'productName', label: '商品名称', minWidth: 190 }, { prop: 'quantityUnit', label: '单位', width: 80 },
...(props.mode === 'inventory' ? [
{ key: 'physicalQuantity', label: '全品质实物', width: 110, slotName: 'physicalQuantity' },
{ key: 'availableQuantity', label: '正品可售', width: 110, slotName: 'availableQuantity' },
{ key: 'frozenQuantity', label: '全品质冻结', width: 110, slotName: 'frozenQuantity' },
{ key: 'defectivePhysicalQuantity', label: '次品实物', width: 110, slotName: 'defectivePhysicalQuantity' },
{ key: 'transitQuantity', label: '在途', width: 110, slotName: 'transitQuantity' },
{ key: 'outboundQuantity30d', label: '近30天出库', width: 120, slotName: 'outboundQuantity30d' },
{ key: 'averageDailyOutboundQuantity', label: '30天日均出库', width: 140, slotName: 'averageDailyOutboundQuantity' },
{ key: 'inventoryCoverageDays', label: '可售天数', width: 110, slotName: 'inventoryCoverageDays' },
{ key: 'lastOutbound', label: '最近已观测出库', minWidth: 220, slotName: 'lastOutbound' },
{ key: 'attention', label: '关注原因', minWidth: 160, slotName: 'attention' },
] : [{ key: 'quantity', label: summaryMode.value ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity' }]),
{ key: 'status', label: '数据状态', minWidth: 130, slotName: 'status' },
{ key: 'reason', label: '说明', minWidth: 220, slotName: 'reason' },
])
const dataFence = new RequestFence()
const dailyFence = new RequestFence()
const runtimeFence = new RequestFence()
function asSummary(row: unknown) { return row as SalesSummary }
function asMetric(row: unknown) { return row as Metric }
function query(): BusinessQuery {
const result: BusinessQuery = { sourceAccount, warehouseCode: warehouseCode.value, customerCode: applied.value.customerCode || undefined, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) }
if (summaryMode.value) {
result.rangePreset = preset.value
if (preset.value === 'CUSTOM') { result.startDate = customStart.value; result.endDate = customEnd.value }
} else if (props.mode === 'sales' && tab.value === 'hot') result.windowDays = windowDays.value
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value)
return result
}
async function load(reset = false) {
if (!permitted.value || !warehouseCode.value) return
if (summaryMode.value && preset.value === 'CUSTOM') {
const problem = validateCustomRange(customStart.value, customEnd.value, context.value?.anchorDate)
if (problem) { error.value = problem; return }
}
if (reset) { context.value = null; daily.value = null; ownerOverview.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
dailyFence.cancel()
const request = dataFence.begin()
loading.value = true
error.value = ''
errorDetails.value = null
try {
const params = query()
const result = props.mode === 'inventory' ? await analyticsApi.inventory(params, request.signal)
: tab.value === 'hot' ? await analyticsApi.hot(params, request.signal)
: tab.value === 'slow' ? await analyticsApi.slow(params, request.signal) : await analyticsApi.summary(params, request.signal)
if (!request.current()) return
if (context.value && !sameContext(context.value, result.context)) throw new Error('查询版本已变化,请整体刷新')
const owner = props.mode === 'inventory' && applied.value.customerCode
? await analyticsApi.ownerOverview({ sourceAccount, warehouseCode: warehouseCode.value, customerCode: applied.value.customerCode, coverageDaysThreshold: appliedInventory.value.coverageDaysThreshold, ...contextParams(result.context) }, request.signal)
: null
if (!request.current()) return
if (owner && !sameContext(result.context, owner.context)) throw new Error('货主概览版本与明细不一致,请整体刷新')
context.value = result.context
ownerOverview.value = owner
rows.value = result.records
total.value = result.total
daily.value = null
} catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '查询失败,保留上次成功数据;可整体刷新重新获取版本'); error.value = errorDetails.value.message } }
finally { if (request.current()) loading.value = false }
}
async function showTrend(row: SalesSummary | Metric) {
const request = dailyFence.begin()
error.value = ''
try {
const params: BusinessQuery = { sourceAccount, warehouseCode: warehouseCode.value, customerCode: row.customerCode, sku: row.sku, current: 1, size: 20, ...contextParams(context.value), rangePreset: props.mode === 'inventory' ? 'CUSTOM' : preset.value }
if (props.mode === 'inventory' && context.value?.rangeStartDate && context.value.rangeEndDateExclusive) {
params.startDate = context.value.rangeStartDate
params.endDate = new Date(Date.parse(`${context.value.rangeEndDateExclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
} else if (params.rangePreset === 'CUSTOM') { params.startDate = customStart.value; params.endDate = customEnd.value }
const result = await analyticsApi.daily(params, request.signal)
if (!request.current()) return
if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请整体刷新')
daily.value = result
} catch (cause) { if (request.current()) error.value = getApiErrorMessage(cause, 'SKU趋势读取失败') }
}
async function readRuntime() {
if (!permitted.value) return false
const request = runtimeFence.begin()
try {
const result = await analyticsApi.overview(sourceAccount, undefined, request.signal)
if (!request.current()) return false
overview.value = result
runtimeError.value = ''
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
newDay.value = Boolean(context.value && context.value.anchorDate !== today)
const warehouse = result.warehouses.filter(row => row.warehouseCode === warehouseCode.value)
if (context.value && warehouse.some(row => row.lastPublishedAt && Date.parse(row.lastPublishedAt) > Date.parse(context.value!.queryAsOf))) newData.value = true
return hasActiveTask(result.warehouses.flatMap(row => [row.currentTask, row.sharedUpstream].filter((task): task is NonNullable<typeof task> => Boolean(task))))
} catch (cause) { if (request.current()) runtimeError.value = getApiErrorMessage(cause, '状态更新失败,保留上次观测结果'); return false }
}
const poller = new RuntimePoller(readRuntime, () => runtimeFence.cancel())
function visibility() { poller.setVisible(!document.hidden) }
function search() {
const rawThreshold = thresholdInput.value.trim()
if (props.mode === 'inventory' && ((rawThreshold && (!/^\d+$/.test(rawThreshold) || !Number.isSafeInteger(Number(rawThreshold)) || Number(rawThreshold) <= 0)) || (attention.value === 'COVERAGE_GT_THRESHOLD' && !rawThreshold))) {
error.value = '可售天数阈值须为正整数;选择超过阈值关注时必须填写'
return
}
appliedInventory.value = { attention: attention.value, sortBy: sortBy.value, coverageDaysThreshold: rawThreshold ? Number(rawThreshold) : undefined }
applied.value = { customerCode: String(filters.value.customerCode ?? ''), sku: String(filters.value.sku ?? '') }
ownerOverview.value = null
current.value = 1
void load()
}
function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, customerCode: applied.value.customerCode, sku: applied.value.sku } }) }
watch(warehouseCode, () => { dataFence.cancel(); dailyFence.cancel(); rows.value = []; ownerOverview.value = null; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) })
watch([preset, tab, windowDays], () => { current.value = 1; void load() })
watch([current, size], () => { void load() })
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<ListPageLayout v-else>
<p>来源账号:{{ sourceAccount }} · 北京时间 Asia/Shanghai · 数量为每个 SKU 的件数 PCS</p>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营分析筛选" :loading="loading" @search="search" @reset="search" />
<OwnerPicker :model-value="String(filters.customerCode ?? '')" :source-account="sourceAccount" :warehouse-code="warehouseCode" :context="context" @update:model-value="filters.customerCode = $event; search()" />
<div v-if="mode === 'inventory'" class="density-actions">
<label>库存关注 <el-select v-model="attention" aria-label="库存关注"><el-option v-for="item in [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_30D', label: '近30天无出库' }, { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }]" :key="item.value" :value="item.value" :label="item.label" /></el-select></label>
<label>可售天数阈值 <el-input v-model="thresholdInput" aria-label="可售天数阈值" placeholder="可选,正整数天" /></label>
<label>排序 <el-select v-model="sortBy" aria-label="库存排序"><el-option v-for="item in [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }]" :key="item.value" :value="item.value" :label="item.label" /></el-select></label>
<el-button :disabled="!warehouseCode || loading" @click="search">应用库存关注</el-button>
</div>
<p v-if="!warehouseCode">请先选择仓库。货主默认为全部已授权范围。</p>
<p v-if="runtimeError" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<div v-if="mode === 'sales'" class="density-tabs" role="tablist" aria-label="销量分析类别">
<button v-for="item in [{ key: 'summary', name: 'SKU销量' }, { key: 'hot', name: '热销Top100' }, { key: 'slow', name: '滞销Top100' }]" :key="item.key" type="button" role="tab" :aria-selected="tab === item.key" :class="{ 'is-active': tab === item.key }" @click="tab = item.key as typeof tab">{{ item.name }}</button>
</div>
<div v-if="summaryMode" class="density-actions">
<el-button v-for="option in [{ key: 'TODAY', label: '今日' }, { key: 'LAST_3_DAYS', label: '近3天' }, { key: 'THIS_WEEK', label: '本周' }, { key: 'LAST_15_DAYS', label: '近15天' }, { key: 'THIS_MONTH', label: '本月' }, { key: 'CUSTOM', label: '自定义' }]" :key="option.key" :type="preset === option.key ? 'primary' : 'default'" @click="preset = option.key as RangePreset">{{ option.label }}</el-button>
<template v-if="preset === 'CUSTOM'"><el-date-picker v-model="customStart" type="date" value-format="YYYY-MM-DD" aria-label="开始日期" /><el-date-picker v-model="customEnd" type="date" value-format="YYYY-MM-DD" aria-label="结束日期" /><el-button @click="search">应用日期</el-button></template>
</div>
<div v-else-if="mode === 'sales' && tab === 'hot'"><label>完整日窗口 <el-select v-model="windowDays" aria-label="热销完整日窗口"><el-option :value="7" label="7个完整自然日" /><el-option :value="30" label="30个完整自然日" /></el-select></label></div>
<p v-else>固定使用截至昨日的 30 个完整自然日窗口。{{ mode === 'inventory' ? '实物含全品质,可售仅正品;冻结及次品不重复计入实物,在途不计当前占仓。日均量及可售天数由服务端计算,阈值仅用于本次查询。' : '滞销观察资格由服务端判断。' }}</p>
<p v-if="context">查询基准:{{ beijingTime(context.queryAsOf) }}(北京时间);有效至 {{ beijingTime(context.queryExpiresAt) }}。范围 {{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(结束边界不含)。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }};销量发布 {{ beijingTime(context.salesPublishedAt) }};库存采集 {{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.rebuildRequired ? '修订待重建' : '' }}</p>
<p v-if="newData" role="status">有新数据,可整体刷新。</p>
<p v-if="newDay" role="status">北京时间已跨日;当前范围仍按原查询基准日,整体刷新后取得新日期和版本。</p>
<p v-if="context && !summaryMode">指标计算窗口:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(结束边界不含);{{ context.calculationWindowDays ?? (tab === 'hot' ? windowDays : 30) }} 天。</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>
<OwnerOverviewPanel v-if="mode === 'inventory' && ownerOverview" :data="ownerOverview" />
<p v-else-if="mode === 'inventory'">{{ applied.customerCode ? '当前货主尚无可用经营概览。' : '选择一个具体货主后显示全量经营概览;当前可浏览全仓 SKU 明细。' }}</p>
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" empty-text="所选范围无记录或尚无可用数据">
<template #toolbar-actions><el-button :disabled="!warehouseCode || loading" @click="load(true)">整体刷新</el-button><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 link @click="showTrend(asSummary(row))">每日趋势</el-button></template>
<template #sku="{ row }"><el-button link :aria-label="`查看 ${asMetric(row).customerCode} ${asMetric(row).sku} 每日趋势`" @click="showTrend(asMetric(row))">{{ asMetric(row).sku }}</el-button></template>
<template #lastOutbound="{ row }">{{ asMetric(row).lastObservedOutboundDate ?? '已采集历史内未见出库' }}<br />历史覆盖起点:{{ asMetric(row).observedHistoryStartDate ?? '—' }}</template>
<template #attention="{ row }">{{ reasonsText(asMetric(row).attentionReasons) }}</template>
<template #quantity="{ row }">{{ quantityText(summaryMode ? shownQuantity(asSummary(row)) : asMetric(row).outboundQuantity) }} {{ summaryMode && asSummary(row).dataStatus !== 'COMPLETE' ? '(已采集,未完整)' : '' }}</template>
<template #status="{ row }">{{ label(summaryMode ? asSummary(row).dataStatus : asMetric(row).status) }}</template>
<template #reason="{ row }">{{ reasonsText(summaryMode ? asSummary(row).reasonCodes : asMetric(row).reasonCodes) }}</template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'outboundQuantity30d', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'] as const" :key="key" #[key]="{ row }">{{ quantityText(asMetric(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
</DataTable>
<template v-if="daily && (summaryMode || mode === 'inventory')">
<el-button @click="router.push({ path: mode === 'inventory' ? '/self-operated/sales' : '/self-operated/inventory', query: { warehouseCode, customerCode: daily.summary.customerCode, sku: daily.summary.sku } })">{{ mode === 'inventory' ? '在销量页查看该 SKU' : '在库存页查看该 SKU' }}</el-button>
<SkuTrend :data="daily" />
</template>
</ListPageLayout>
</AdminPage>
</template>
@@ -0,0 +1,24 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import OwnerOverviewPanel from './OwnerOverviewPanel.vue'
import type { OwnerOverview } from '../types'
describe('owner overview calculation window', () => {
it('shows the thirty-day calculation range rather than the longer collected history', () => {
const quantity = { overallTotal: null, knownSubtotal: null, unknownSkuCount: 1, dataStatus: 'UNKNOWN', reasonCodes: [] }
const risk = { matchedSkuCount: null, matchedPhysicalQuantity: null, physicalQuantityRatio: null, knownMatchedSkuCount: null, knownMatchedPhysicalQuantity: null, unassessableSkuCount: 1, unassessableKnownPhysicalQuantity: null, reasonCodes: [] }
const data: OwnerOverview = {
context: { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', 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', salesStatus: 'COMPLETE', inventoryStatus: 'STALE', inventoryObservedFrom: '2026-10-10T00:00:00Z', inventoryObservedUntil: '2026-10-10T00:10:00Z' },
customerCode: 'OWNER-A', customerName: null, skuCount: 1,
physicalSkuCount: quantity, physicalQuantity: quantity, availableQuantity: quantity, frozenQuantity: quantity,
defectivePhysicalQuantity: quantity, transitQuantity: quantity, outboundQuantity30d: quantity,
averageDailyOutboundQuantity: quantity, noOutbound30d: risk, coverageAboveThreshold: risk,
}
const wrapper = mount(OwnerOverviewPanel, { props: { data } })
expect(wrapper.text()).toContain('近30完整日计算区间 2026-09-12 至 2026-10-12')
expect(wrapper.text()).not.toContain('2026-08-13')
expect(wrapper.text()).toContain('销量质量:完整;库存质量:过期')
expect(wrapper.text()).toContain('库存观测时段:2026/10/10 08:00:00 至 2026/10/10 08:10:00')
wrapper.unmount()
})
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { OwnerOverview, QuantityTotal, RiskShare } from '../types'
import { beijingTime, label, quantityText, reasonsText } from '../state'
defineProps<{ data: OwnerOverview }>()
const metrics: { key: keyof Pick<OwnerOverview, 'physicalSkuCount' | 'physicalQuantity' | 'availableQuantity' | 'frozenQuantity' | 'defectivePhysicalQuantity' | 'transitQuantity' | 'outboundQuantity30d' | 'averageDailyOutboundQuantity'>; name: string }[] = [
{ key: 'physicalSkuCount', name: '有实物库存 SKU 数' }, { key: 'physicalQuantity', name: '全品质实物件数' },
{ key: 'availableQuantity', name: '正品可售件数' }, { key: 'frozenQuantity', name: '全品质冻结件数' },
{ key: 'defectivePhysicalQuantity', name: '次品实物件数' }, { key: 'transitQuantity', name: '在途件数' },
{ key: 'outboundQuantity30d', name: '近30天出库件数' }, { key: 'averageDailyOutboundQuantity', name: '30天日均出库' },
]
const risks: { key: 'noOutbound30d' | 'coverageAboveThreshold'; name: string }[] = [{ key: 'noOutbound30d', name: '近30天无出库' }, { key: 'coverageAboveThreshold', name: '可售天数超过本次阈值' }]
function totalText(metric: QuantityTotal) { return `${quantityText(metric.overallTotal)} · ${label(metric.dataStatus)}` }
function ratioText(risk: RiskShare) { return risk.physicalQuantityRatio == null ? '—' : `${(Number(risk.physicalQuantityRatio) * 100).toFixed(2)}%` }
</script>
<template>
<section aria-label="货主经营概览">
<h3>货主 {{ data.customerCode }} {{ data.customerName ?? '' }} · 经营概览</h3>
<p>固定所选货主全量范围,不受下方 SKU、关注筛选或分页影响。SKU 候选数 {{ quantityText(data.skuCount) }}。实物件数不是空间占比,不自动评级。</p>
<p>近30完整日计算区间 {{ data.context.rangeStartDate ?? '—' }} 至 {{ data.context.rangeEndDateExclusive ?? '—' }}(结束边界不含);销量质量:{{ label(data.context.salesStatus) }};库存质量:{{ label(data.context.inventoryStatus) }}。库存观测时段:{{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}。</p>
<table class="density-table">
<thead><tr><th>指标</th><th>正式总量 / 质量</th><th>已知有效小计(部分资料)</th><th>未知 SKU 数</th><th>依据</th></tr></thead>
<tbody><tr v-for="metric in metrics" :key="metric.key"><th>{{ metric.name }}</th><td>{{ totalText(data[metric.key]) }}</td><td>{{ quantityText(data[metric.key].knownSubtotal) }}</td><td>{{ quantityText(data[metric.key].unknownSkuCount) }}</td><td>{{ reasonsText(data[metric.key].reasonCodes) }}</td></tr></tbody>
</table>
<table class="density-table">
<thead><tr><th>关注</th><th>命中 SKU 数 / 实物件数</th><th>占货主实物件数比例</th><th>已确认命中小计</th><th>待判断 SKU / 已知实物件数</th><th>依据</th></tr></thead>
<tbody><tr v-for="risk in risks" :key="risk.key"><th>{{ risk.name }}</th><td>{{ quantityText(data[risk.key].matchedSkuCount) }} / {{ quantityText(data[risk.key].matchedPhysicalQuantity) }}</td><td>{{ ratioText(data[risk.key]) }}</td><td>{{ quantityText(data[risk.key].knownMatchedSkuCount) }} SKU / {{ quantityText(data[risk.key].knownMatchedPhysicalQuantity) }} PCS</td><td>{{ quantityText(data[risk.key].unassessableSkuCount) }} / {{ quantityText(data[risk.key].unassessableKnownPhysicalQuantity) }}</td><td>{{ reasonsText(data[risk.key].reasonCodes) }}</td></tr></tbody>
</table>
<p>占比的分母为本货主全品质实物正式总量 {{ quantityText(data.physicalQuantity.overallTotal) }} PCS({{ label(data.physicalQuantity.dataStatus) }})。冻结及次品均已包含在实物中,在途不计当前占仓。{{ Number(data.physicalQuantity.overallTotal) === 0 && data.physicalQuantity.overallTotal !== null ? '本货主无实物库存,占比不适用。' : '' }}</p>
</section>
</template>
@@ -0,0 +1,41 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OwnerPicker from './OwnerPicker.vue'
import type { QueryContext } from '../types'
const owners = vi.hoisted(() => vi.fn())
vi.mock('../api/analytics', () => ({ analyticsApi: { owners } }))
const context: QueryContext = { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12' }
const select = defineComponent({ props: ['remoteMethod'], setup(props, { slots }) { return () => h('div', [h('button', { onClick: () => props.remoteMethod('Ab_') }, '前缀搜索'), slots.default?.()]) } })
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
const option = defineComponent({ props: ['label'], setup(props) { return () => h('span', props.label) } })
const global = { stubs: { 'el-select': select, 'el-button': button, 'el-option': option } }
const wrappers: ReturnType<typeof mount>[] = []
beforeEach(() => { owners.mockReset(); owners.mockResolvedValue({ context, records: [{ customerCode: 'OWNER-A', customerName: '货主甲' }], total: 25 }) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
describe('fixed-context owner selector', () => {
it('uses backend pagination and prefix search without changing the pinned context', async () => {
const wrapper = mount(OwnerPicker, { props: { modelValue: '', sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', context }, global }); wrappers.push(wrapper)
await flushPromises()
await wrapper.findAll('button').find(node => node.text() === '下一页货主')!.trigger('click'); await flushPromises()
expect(owners.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, size: 20, salesRunId: '11', inventoryRunId: '12', queryAsOf: context.queryAsOf })
await wrapper.findAll('button').find(node => node.text() === '前缀搜索')!.trigger('click'); await flushPromises()
expect(owners.mock.calls.at(-1)?.[0]).toMatchObject({ keyword: 'Ab_', current: 1, queryAsOf: context.queryAsOf })
expect(wrapper.text()).toContain('OWNER-A · 货主甲')
})
it('ignores late options from the old warehouse and rejects mixed-version choices', async () => {
let resolve!: (value: object) => void
owners.mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValue({ context, records: [{ customerCode: 'OWNER-B', customerName: null }], total: 1 })
const wrapper = mount(OwnerPicker, { props: { modelValue: '', sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', context }, global }); wrappers.push(wrapper)
await wrapper.setProps({ warehouseCode: 'WH-B' }); await flushPromises()
resolve({ context, records: [{ customerCode: 'OLD-OWNER', customerName: null }], total: 1 }); await flushPromises()
expect(wrapper.text()).toContain('OWNER-B')
expect(wrapper.text()).not.toContain('OLD-OWNER')
expect(owners.mock.calls[0]?.[1].aborted).toBe(true)
owners.mockResolvedValue({ context: { ...context, inventoryRunId: '99' }, records: [{ customerCode: 'WRONG-VERSION' }], total: 1 })
await wrapper.findAll('button').find(node => node.text() === '前缀搜索')!.trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('WRONG-VERSION')
expect(wrapper.text()).toContain('货主选项版本已变化')
})
})
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue'
import { analyticsApi } from '../api/analytics'
import type { OwnerOption, QueryContext } from '../types'
import { contextParams, RequestFence, sameContext } from '../state'
import { getApiErrorMessage } from '@/shared/api/http'
const props = defineProps<{ modelValue: string; sourceAccount: string; warehouseCode: string; context: QueryContext | null }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const options = ref<OwnerOption[]>([])
const keyword = ref('')
const current = ref(1)
const total = ref(0)
const loading = ref(false)
const error = ref('')
const fence = new RequestFence()
async function load() {
const request = fence.begin()
if (!props.warehouseCode || !props.context) { options.value = []; total.value = 0; loading.value = false; return }
loading.value = true
error.value = ''
try {
const result = await analyticsApi.owners({ sourceAccount: props.sourceAccount, warehouseCode: props.warehouseCode, ...contextParams(props.context), keyword: keyword.value || undefined, current: current.value, size: 20 }, request.signal)
if (!request.current()) return
if (!props.context || !sameContext(props.context, result.context)) {
options.value = []; total.value = 0; error.value = '货主选项版本已变化,请整体刷新'
return
}
options.value = result.records
total.value = result.total
} catch (cause) { if (request.current()) { options.value = []; total.value = 0; error.value = getApiErrorMessage(cause, '货主选项读取失败') } }
finally { if (request.current()) loading.value = false }
}
function search(value: string) { keyword.value = value; current.value = 1; void load() }
function page(delta: number) { current.value += delta; void load() }
watch(() => [props.warehouseCode, props.context?.queryAsOf, props.context?.salesRunId, props.context?.inventoryRunId], () => { current.value = 1; keyword.value = ''; void load() }, { immediate: true })
onUnmounted(() => fence.cancel())
</script>
<template>
<div class="density-actions">
<label>货主
<el-select :model-value="modelValue" :disabled="!warehouseCode || !context" clearable filterable remote :remote-method="search" :loading="loading" placeholder="全部货主;输入编码前缀查找" aria-label="货主选择" @update:model-value="emit('update:modelValue', String($event ?? ''))">
<el-option v-if="modelValue && !options.some(option => option.customerCode === modelValue)" :value="modelValue" :label="modelValue" />
<el-option v-for="option in options" :key="option.customerCode" :value="option.customerCode" :label="option.customerName ? `${option.customerCode} · ${option.customerName}` : option.customerCode" />
</el-select>
</label>
<el-button :disabled="loading || current <= 1" @click="page(-1)">上一页货主</el-button>
<span>第 {{ current }} 页 · 共 {{ total }} 个货主</span>
<el-button :disabled="loading || current * 20 >= total" @click="page(1)">下一页货主</el-button>
<span v-if="error" role="alert">{{ error }}</span>
<span v-else-if="context && !loading && !total">当前查询无可用货主资料;未发布数据不代表仓库没有货主。</span>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DailyResponse } from '../types'
import { label, quantityText, shownQuantity, trendSegments } from '../state'
const props = defineProps<{ data: DailyResponse }>()
const segments = computed(() => trendSegments(props.data.points))
const maximum = computed(() => Math.max(1, ...props.data.points.map(point => Number(shownQuantity(point) ?? 0))))
function x(index: number) { return props.data.points.length === 1 ? 400 : 30 + index / (props.data.points.length - 1) * 740 }
function y(value: string | number | null) { return 175 - Number(value ?? 0) / maximum.value * 145 }
</script>
<template>
<section aria-label="SKU每日出库趋势">
<h3>{{ data.summary.customerCode }} · {{ data.summary.sku }} · 每日出库趋势</h3>
<p>{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '该版本已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit ?? '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计,非实时值。</p>
<p v-if="!segments.length">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<svg v-else viewBox="0 0 800 220" role="img" aria-label="每日出库件数;虚线点表示未完整,缺失日期断线" class="sku-trend">
<line x1="30" y1="175" x2="770" y2="175" stroke="currentColor" />
<text x="30" y="20">{{ maximum }} PCS</text>
<path v-for="(segment, index) in segments" :key="index" :d="segment.path" fill="none" stroke="var(--el-color-primary)" stroke-width="2" :stroke-dasharray="segment.partial ? '5 4' : undefined" />
<g v-for="(point, index) in data.points" :key="point.businessDate">
<circle v-if="shownQuantity(point) != null && point.dataStatus !== 'UNKNOWN'" :cx="x(index)" :cy="y(shownQuantity(point))" r="4" :fill="point.dataStatus === 'COMPLETE' ? 'var(--el-color-primary)' : 'white'" stroke="var(--el-color-primary)">
<title>{{ point.businessDate }}:{{ quantityText(shownQuantity(point)) }} {{ point.quantityUnit ?? '单位未知' }},{{ label(point.dataStatus) }};覆盖截止 {{ point.coveredUntil ?? '未知' }}</title>
</circle>
<text v-if="data.points.length <= 15 || index === 0 || index === data.points.length - 1" :x="x(index)" y="205" text-anchor="middle">{{ point.businessDate.slice(5) }}</text>
</g>
</svg>
<details>
<summary>每日数量、完整性及覆盖证据</summary>
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
<tr v-for="point in data.points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ point.coveredUntil ?? '—' }}</td><td>{{ point.reasonCodes.join('、') || '—' }}</td></tr>
</tbody></table>
</details>
</section>
</template>
<style scoped>
.sku-trend { width: 100%; max-height: 280px; }
text { font-size: var(--admin-font-caption); }
</style>
@@ -0,0 +1,19 @@
# 自营分析逐操作矩阵
依据:`docs/自营业务/领星一件代发数据统计一期方案.md` 的“Admin 菜单与采集页面”“SKU 销量列表与趋势图”“页面交付和视觉边界”。本模块没有已确认视觉原型。
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
| --- | --- | --- | --- |
| /self-operated/sales | 仓库、货主、SKU、周期查询及分页 | 已确认主页面;服务端固定版本与北京时间日期 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;待人工截图确认 |
| /self-operated/sales | SKU 趋势 | 主页面内嵌单 SKU 日趋势,保留整个日轴,未知断线 | 不新建详情路由或浮层;待人工截图确认 |
| /self-operated/sales | 热销、滞销页签 | 主页面页签;独立完整日窗口 | 热销 7/30 日,滞销 30 日;待人工截图确认 |
| /self-operated/inventory | 货主概览、SKU 占仓关注及排序 | 方案“货主概览与库存关注”“Admin 菜单与采集页面”已确认同页联动;概览保持货主全量,SKU/关注/分页仅作用明细 | 复用连续表格,未知与部分小计分开;件数不是空间;待人工截图确认 |
| /self-operated/inventory | SKU 每日趋势 | 方案“货主概览与库存关注”确认点击 SKU 沿 daily 接口同页下钻 | 点击 SKU 展示内嵌趋势,不新增操作列、菜单或浮层;待人工截图确认 |
| 销量/库存 | 货主选择 | 方案“货主概览与库存关注接口合同”确认本仓固定版本服务器分页选项 | 编码前缀检索、翻页、精确选择;待人工截图确认 |
| /self-operated/collection | 每日库存观测积累 | 方案“每日库存观测留存”确认采集页显示近30完整日样本、缺口、起点 | 服务端结果直出,不计算周转率;待人工截图确认 |
| /self-operated/collection | 运行概览、逻辑任务列表与分页 | 已确认主页面,执行/发布/覆盖独立展示 | 不提供整体百分比;待人工截图确认 |
| /self-operated/collection | 任务详情和历史尝试 | 用户本轮确认采集主页面内嵌只读详情和历史尝试;总控已冻结 | 不新增弹框、抽屉、独立详情;待人工截图确认 |
| /self-operated/collection | 恢复任务确认 | 用户本轮确认内嵌原因表单+显式确认步骤;RETRY 单一命令 | 不通过前端推断资格;受理不是采集成功;待人工截图确认 |
| 三页 | 新增/编辑/审核/删除/文件预览 | 不在一期页面业务范围 | 不创建操作入口 |
自动测试与人工视觉验收分别记录;无真实截图不宣称视觉通过。
@@ -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('没有任务恢复权限')
})
})
+7
View File
@@ -0,0 +1,7 @@
import type { RouteRecordRaw } from 'vue-router'
export const selfOperatedRoutes = [
{ path: 'self-operated/sales', name: 'self-operated-sales', component: () => import('./pages/SalesView.vue'), meta: { title: '销量分析', permission: 'platform:self-operated-analytics:list' } },
{ path: 'self-operated/inventory', name: 'self-operated-inventory', component: () => import('./pages/InventoryView.vue'), meta: { title: '库存分析', permission: 'platform:self-operated-analytics:list' } },
{ path: 'self-operated/collection', name: 'self-operated-collection', component: () => import('./pages/CollectionView.vue'), meta: { title: '数据采集', permission: 'platform:self-operated-analytics:list' } },
] satisfies RouteRecordRaw[]
+97
View File
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RequestFence, RetryIntent, RuntimePoller, contextParams, progressText, sameContext, shownQuantity, taskRetryAllowed, trendSegments, validateCustomRange } from './state'
import type { DailyPoint, QueryContext, Task } from './types'
const context: QueryContext = { salesRunId: '9007199254740999', inventoryRunId: null, salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T15:00:00+08:00', queryExpiresAt: '2026-10-12T15:30:00+08:00', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12' }
const task: Task = { taskNo: 'COL-TEST-001', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'FAILED', applyState: 'BLOCKED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: ['RETRY'], actionBlockReason: null, progress: null, queryFrom: null, queryUntil: null }
describe('self-operated query and command protection', () => {
it('aborts old warehouse request and rejects late completion even if adapter ignores abort', () => {
const fence = new RequestFence()
const oldWarehouse = fence.begin()
const newWarehouse = fence.begin()
expect(oldWarehouse.signal.aborted).toBe(true)
expect(oldWarehouse.current()).toBe(false)
expect(newWarehouse.current()).toBe(true)
fence.cancel()
expect(newWarehouse.current()).toBe(false)
})
it('preserves unavailable family and long string IDs in version context across ranges', () => {
expect(contextParams(context)).toEqual({ salesRunId: '9007199254740999', inventoryRunId: '0', salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: context.queryAsOf })
expect(sameContext(context, { ...context, rangeStartDate: '2026-10-01' })).toBe(true)
expect(sameContext(context, { ...context, inventoryRunId: 'new' })).toBe(false)
expect(sameContext(context, { ...context, queryAsOf: '2026-10-13T00:01:00+08:00' })).toBe(false)
})
it('validates real dates, inclusive 366-day maximum and frozen anchor date', () => {
expect(validateCustomRange('2026-02-30', '2026-03-01')).toBeTruthy()
expect(validateCustomRange('2025-10-12', '2026-10-12', '2026-10-12')).toBe('')
expect(validateCustomRange('2025-10-11', '2026-10-12', '2026-10-12')).toContain('366')
expect(validateCustomRange('2026-10-12', '2026-10-13', '2026-10-12')).toContain('基准日')
})
it('shows partial observed quantity and keeps unknown null distinct from reliable zero', () => {
expect(shownQuantity({ outboundQuantity: null, observedQuantity: '2.75', dataStatus: 'PARTIAL' })).toBe('2.75')
expect(shownQuantity({ outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN' })).toBeNull()
expect(shownQuantity({ outboundQuantity: '0', observedQuantity: '0', dataStatus: 'COMPLETE' })).toBe('0')
})
it('requires permission, server eligibility, correct warehouse and reason for retry', () => {
const scope = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A' }
expect(taskRetryAllowed(task, true, scope, '修复后恢复')).toBe(true)
expect(taskRetryAllowed(task, false, scope, '修复后恢复')).toBe(false)
expect(taskRetryAllowed({ ...task, allowedActions: [] }, true, scope, '修复后恢复')).toBe(false)
expect(taskRetryAllowed({ ...task, actionBlockReason: '账号冷却' }, true, scope, '修复后恢复')).toBe(false)
expect(taskRetryAllowed(task, true, { ...scope, warehouseCode: 'WH-B' }, '修复后恢复')).toBe(false)
expect(taskRetryAllowed(task, true, scope, ' ')).toBe(false)
})
it('reuses exact retry request after ambiguous failure, changes key only for changed intent or accepted request', () => {
const intent = new RetryIntent()
let counter = 0
const key = () => `key-${++counter}`
const first = intent.create(task.taskNo, 'WH-A', ' 修复 ', key)
expect(intent.create(task.taskNo, 'WH-A', '修复', key)).toBe(first)
expect(intent.create(task.taskNo, 'WH-A', '新的原因', key).requestKey).toBe('key-2')
intent.accepted()
expect(intent.create(task.taskNo, 'WH-A', '新的原因', key).requestKey).toBe('key-3')
expect(first).not.toHaveProperty('taskId')
})
it('breaks trend at unknown days and retains a single reliable zero point', () => {
const point = (businessDate: string, value: string | null, status: string): DailyPoint => ({ businessDate, outboundQuantity: value, observedQuantity: value, dataStatus: status, reasonCodes: [], quantityUnit: 'PCS' })
const segments = trendSegments([point('2026-10-10', '0', 'COMPLETE'), point('2026-10-11', null, 'UNKNOWN'), point('2026-10-12', '4', 'PARTIAL')])
expect(segments).toHaveLength(2)
expect(segments[0]?.path).not.toContain(' L')
expect(segments[1]?.partial).toBe(true)
expect(trendSegments([point('2026-10-12', '0', 'COMPLETE')])).toHaveLength(1)
expect(trendSegments([point('2026-10-12', null, 'UNKNOWN')])).toEqual([])
})
it('does not invent progress denominator or combine warehouse and account counts', () => {
const text = progressText({ progressScope: 'ACCOUNT_SHARED', countBasis: 'ATTEMPT_ONLY', attemptNo: 2, scanRound: 2, phase: 'DIRECTORY', completed: 3, total: null, totalKnown: false, unit: 'PAGE', sliceFrom: null, sliceUntil: null })
expect(text).toContain('账号共享')
expect(text).toContain('本次尝试')
expect(text).toContain('总量待确认')
expect(text).not.toContain('%')
})
})
describe('self-operated polling lifecycle', () => {
afterEach(() => vi.useRealTimers())
it('uses 10s active and 60s idle, stops while hidden and after leave', async () => {
vi.useFakeTimers()
const read = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false)
const cancel = vi.fn()
const poller = new RuntimePoller(read, cancel)
poller.start()
await vi.advanceTimersByTimeAsync(9999)
expect(read).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(read).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(59999)
expect(read).toHaveBeenCalledTimes(2)
poller.setVisible(false)
await vi.advanceTimersByTimeAsync(120000)
expect(read).toHaveBeenCalledTimes(2)
poller.setVisible(true)
await vi.advanceTimersByTimeAsync(0)
expect(read).toHaveBeenCalledTimes(3)
poller.stop()
await vi.advanceTimersByTimeAsync(120000)
expect(read).toHaveBeenCalledTimes(3)
expect(cancel).toHaveBeenCalled()
})
})
+160
View File
@@ -0,0 +1,160 @@
import type { BusinessQuery, DailyPoint, Progress, QueryContext, RetryCommand, Scope, Task } from './types'
// Aborting saves work; generation fencing also protects against adapters that return after abort.
export class RequestFence {
private generation = 0
private controller: AbortController | null = null
begin() {
this.cancel()
const generation = this.generation
const controller = new AbortController()
this.controller = controller
return { signal: controller.signal, current: () => generation === this.generation && !controller.signal.aborted }
}
cancel() { this.controller?.abort(); this.controller = null; this.generation += 1 }
}
export function contextParams(context: QueryContext | null): Partial<BusinessQuery> {
if (!context) return {}
return {
salesRunId: context.salesRunId ?? '0', inventoryRunId: context.inventoryRunId ?? '0',
salesScopeHash: context.salesScopeHash, inventoryScopeHash: context.inventoryScopeHash,
queryAsOf: context.queryAsOf,
}
}
export function sameContext(a: QueryContext, b: QueryContext) {
return a.salesRunId === b.salesRunId && a.inventoryRunId === b.inventoryRunId
&& a.salesScopeHash === b.salesScopeHash && a.inventoryScopeHash === b.inventoryScopeHash
&& a.queryAsOf === b.queryAsOf && a.queryExpiresAt === b.queryExpiresAt
}
export function validateCustomRange(start: string, end: string, anchor?: string) {
const validDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value)
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value
if (!validDate(start) || !validDate(end)) return '请选择有效的起止日期'
if (start > end) return '开始日期不得晚于结束日期'
if (anchor && end > anchor) return '结束日期不得晚于本次查询基准日'
if ((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000 + 1 > 366) return '最多查询 366 个自然日'
return ''
}
export function shownQuantity(row: { outboundQuantity: string | number | null; observedQuantity: string | number | null; dataStatus: string }) {
return row.dataStatus === 'COMPLETE' ? row.outboundQuantity : row.observedQuantity
}
export function quantityText(value: string | number | null | undefined) { return value === null || value === undefined ? '—' : String(value) }
export function beijingTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '时间待核对'
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).format(date)
}
export const statusLabels: Record<string, string> = {
COMPLETE: '完整', PARTIAL: '未完整', UNKNOWN: '未知', QUEUED: '排队中', RUNNING: '执行中',
WAITING: '等待中', WAITING_UPSTREAM: '等待上游', RETRY_WAIT: '待重试', YIELDED: '预算等待',
FAILED: '失败', DEAD: '待处理', SUCCEEDED: '执行成功', ROUTED: '账号共享采集完成',
SAMPLE_DONE: '测试样本结束', PUBLISHED: '已发布', REUSED: '沿用已有结果', NOT_PUBLISHED: '未发布',
PENDING: '待处理', BLOCKED: '修订阻断', OFF: '关闭', VALIDATION: '验证采样', BUSINESS: '正式采集',
FRESH: '有效', STALE: '过期', INVALID: '异常', OBSERVING: '观察中', HEALTHY: '正常',
STALE_HEARTBEAT: '心跳过期,执行状态待确认',
UNCONFIRMED: '执行状态待确认', TERMINATED: '尝试已结束', ABORTED: '已停止',
NOT_STARTED: '尚未开始', FETCHING: '采集中', FETCHED: '采集完成', VALIDATING: '校验中',
BUILDING: '计算中', READY: '待发布', INITIALIZING: '首采中', COVERED: '覆盖完整',
ACCOUNT_DISCOVERY: '账号共享出库目录', WAREHOUSE_SYNC: '本仓采集',
VALID: '有效', CURRENT: '已核对', LAGGING: '核对滞后', MISSING: '尚无可用数据',
UNCOVERED: '覆盖不足', INCOMPLETE: '未完整',
NO_AVAILABLE_INVENTORY: '无可售库存', UNIT_UNKNOWN_OR_CONFLICT: '数量单位待核对',
SALES_UNTRUSTED: '销量待核对', COVERAGE_INSUFFICIENT: '覆盖不足', OBSERVATION_INSUFFICIENT: '观察期不足',
SALES_UNKNOWN: '销量未知', NO_OUTBOUND_30_DAYS: '近30天无出库', INVENTORY_INVALID: '库存异常',
INVENTORY_STALE: '库存过期', INVENTORY_UNKNOWN: '库存未知',
}
export const reasonLabels: Record<string, string> = {
NO_OUTBOUND_30D: '近30天无出库', COVERAGE_GT_THRESHOLD: '可售天数超过本次阈值',
FROZEN_STOCK: '存在冻结库存', DEFECTIVE_STOCK: '存在次品实物库存', UNASSESSABLE: '数据不足/待观察',
THRESHOLD_NOT_SET: '未设置查询阈值', NO_PHYSICAL_INVENTORY: '无实物库存',
METRIC_DEPENDENCY_UNKNOWN: '该指标依赖资料未知', SALES_WINDOW_UNAVAILABLE: '完整30日销量不可用',
RISK_DEPENDENCY_UNKNOWN: '部分SKU关注判断未知', NO_OUTBOUND_IN_COLLECTED_HISTORY: '已采集历史内未见出库',
SALES_NOT_COVERED: '销量未覆盖', SKU_HISTORY_UNKNOWN: 'SKU历史未知', QUANTITY_UNIT_UNKNOWN: '数量单位未知',
INVENTORY_NOT_PUBLISHED: '库存尚未发布', SALES_NOT_PUBLISHED: '销量尚未发布', INVENTORY_STALE: '库存快照过期',
INVENTORY_UNKNOWN: '库存未知', INVENTORY_INVALID: '库存异常', OBSERVATION_INCOMPLETE: '观察期不足',
SCOPE_CHANGED: '配置范围已变化', UPSTREAM_NOT_READY: '账号共享上游未就绪', TASK_NOT_RETRYABLE: '任务当前不可恢复',
COLLECTION_MODE_NOT_BUSINESS: '当前不是正式采集模式', VALIDATION_NOT_RETRYABLE: '验证会话不可恢复',
ACCOUNT_SHARED_TASK: '账号共享任务由协调器恢复', SOURCE_TIMEZONE_UNKNOWN: '来源时区未确认',
UNIT_UNKNOWN_OR_CONFLICT: '数量单位未知或不一致', SALES_DAY_INCOMPLETE: '销量日期未完整',
SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN: '销量未覆盖或SKU历史未知', SCOPE_LEASE_BUSY: '本仓任务正在执行',
COLLECTION_OFF: '采集模式关闭', COLLECTION_MODE_INVALID: '采集模式配置无效', BUSINESS_ENVIRONMENT_REJECTED: '正式采集只允许生产环境',
EXECUTION_DISABLED: '采集执行未启用', WAREHOUSE_NOT_CONFIGURED: '仓库未配置', TIMEZONE_OR_UNIT_NOT_CONFIRMED: '时间或件数口径配置不符合确认值',
ACCOUNT_PAUSED: '账号采集已暂停', ACCOUNT_COOLDOWN: '账号处于冷却期', EXCLUSIVE_GRANT_NOT_VALID: '账号独占窗口授权无效',
DAILY_BUDGET_NOT_CONFIGURED: '账号日额度未配置', DAILY_BUDGET_EXHAUSTED: '账号日额度已用完',
SALES_MISSING: '尚无销量发布版本', INVENTORY_MISSING: '尚无库存发布版本', SALES_BLOCKED: '销量修订阻断',
SALES_STALE: '销量数据过期', REVISION_REVIEW_UNKNOWN: '修订核对状态未知', REVISION_REVIEW_LAGGING: '修订核对滞后',
}
export function reasonsText(reasons: readonly string[] | null | undefined) { return reasons?.map(reason => reasonLabels[reason] ?? statusLabels[reason] ?? '存在待核对项').join('、') || '—' }
export function phaseText(phase: string | null | undefined) {
const phases: Record<string, string> = { DIRECTORY: '获取出库目录', DISCOVERY: '获取出库目录', DETAIL: '获取出库详情', DETAILS: '获取出库详情', INVENTORY: '获取产品库存', VALIDATE: '校验', VALIDATION: '校验', MERGE: '归并', BUILD: '统计计算', PUBLISH: '发布', WAIT_UPSTREAM: '等待账号共享上游', WAIT_BUDGET: '预算等待', WAITING_SOURCE: '等待来源准备', WAITING: '等待执行', SAMPLE_DONE: '样本结束', FAILED: '执行失败', FETCH: '获取数据', PLANNED: '已规划', FETCHED: '数据已取完', VALIDATED: '已校验', BUILDING: '统计计算', BUILT: '计算完成', PUBLISHED: '已发布' }
return phase ? phases[phase] ?? '阶段待核对' : '阶段待确认'
}
export function label(value: string | null | undefined) { return value ? statusLabels[value] ?? '待核对' : '—' }
const unitLabels: Record<string, string> = { PAGE: '页', ORDER: '单', INVENTORY_ROW: '库存行', BUSINESS_KEY: '业务键', SKU: 'SKU', BATCH: '批', NONE: '无计数' }
export function unitText(unit: string | null | undefined) { return unitLabels[unit ?? ''] ?? '计数单位待确认' }
export function progressText(progress: Partial<Progress> | null | undefined) {
if (!progress) return '阶段进度待确认'
const scope = progress.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : progress.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认'
const basis = progress.countBasis === 'TASK_CUMULATIVE' ? '任务累计' : progress.countBasis === 'ATTEMPT_ONLY' ? '本次尝试' : '计数口径待确认'
if (progress.unit === 'NONE') return `${scope} · ${phaseText(progress.phase)} · 无对象计数,保留已提交阶段摘要`
const total = progress.totalKnown && progress.total != null ? ` / ${progress.total}` : ',总量待确认'
return `${scope} · ${phaseText(progress.phase)} · ${basis}${progress.scanRound == null ? '' : ` · 第 ${progress.scanRound} 轮`} · ${quantityText(progress.completed)}${total} ${unitLabels[progress.unit ?? ''] ?? '单位待确认'}`
}
export function taskRetryAllowed(task: Task | null, canSync: boolean, scope: Scope, reason: string) {
return Boolean(canSync && task?.taskNo && task.warehouseCode === scope.warehouseCode
&& task.allowedActions.includes('RETRY') && !task.actionBlockReason && reason.trim())
}
export class RetryIntent {
private pending: RetryCommand | null = null
create(taskNo: string, warehouseCode: string, reason: string, key: () => string = () => crypto.randomUUID()): RetryCommand {
if (this.pending?.taskNo === taskNo && this.pending.warehouseCode === warehouseCode && this.pending.reason === reason.trim()) return this.pending
this.pending = { action: 'RETRY', requestKey: key(), taskNo, warehouseCode, reason: reason.trim() }
return this.pending
}
accepted() { this.pending = null }
}
export function hasActiveTask(tasks: readonly Task[]) {
return tasks.some(task => ['QUEUED', 'RUNNING', 'WAITING', 'WAITING_UPSTREAM', 'RETRY_WAIT', 'YIELDED'].includes(task.taskStatus))
}
export class RuntimePoller {
private timer: ReturnType<typeof setTimeout> | null = null
private active = false
private visible = true
private generation = 0
constructor(private readonly read: () => Promise<boolean>, private readonly cancel: () => void) {}
start() { this.active = true; this.restart() }
setVisible(visible: boolean) { this.visible = visible; this.restart() }
stop() { this.active = false; this.restart() }
private restart() {
this.generation += 1
if (this.timer) clearTimeout(this.timer)
this.timer = null
this.cancel()
if (this.active && this.visible) void this.tick(this.generation)
}
private async tick(generation: number) {
let busy = false
try { busy = await this.read() } catch { /* Caller records failure without replacing last data. */ }
if (generation === this.generation && this.active && this.visible) this.timer = setTimeout(() => void this.tick(generation), busy ? 10000 : 60000)
}
}
export function trendSegments(points: DailyPoint[]) {
const max = Math.max(1, ...points.map(point => Number(shownQuantity(point) ?? 0)))
const segments: { path: string; partial: boolean }[] = []
let path = ''
let partial = false
const flush = () => { if (path) segments.push({ path, partial }); path = '' }
points.forEach((point, index) => {
const quantity = shownQuantity(point)
if (quantity == null || point.dataStatus === 'UNKNOWN') { flush(); return }
const x = points.length === 1 ? 400 : 30 + index / (points.length - 1) * 740
const y = 175 - Number(quantity) / max * 145
const pointPartial = point.dataStatus !== 'COMPLETE'
if (path && partial !== pointPartial) { flush() }
path += `${path ? ' L' : 'M'}${x},${y}`
partial = pointPartial
})
flush()
return segments
}
+217
View File
@@ -0,0 +1,217 @@
export type Quantity = string | number | null
export type RangePreset = 'TODAY' | 'LAST_3_DAYS' | 'THIS_WEEK' | 'LAST_15_DAYS' | 'THIS_MONTH' | 'CUSTOM'
export type InventoryAttention = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT_THRESHOLD' | 'FROZEN_STOCK' | 'DEFECTIVE_STOCK' | 'UNASSESSABLE'
export type InventorySort = 'AVAILABLE_DESC' | 'COVERAGE_DAYS_DESC' | 'PHYSICAL_DESC' | 'OUTBOUND_30D_ASC'
export interface QueryContext {
salesRunId: string | null
inventoryRunId: string | null
salesScopeHash: string | null
inventoryScopeHash: string | null
queryAsOf: string
queryExpiresAt: string
businessTimezone: string
anchorDate: string
rangeStartDate?: string
rangeEndDateExclusive?: string
salesCoveredFrom?: string | null
salesCoveredUntil?: string | null
inventoryObservedFrom?: string | null
inventoryObservedUntil?: string | null
salesPublishedAt?: string | null
completeDayFrom?: string | null
completeDayUntil?: string | null
calculationWindowDays?: number | null
salesStatus?: string
inventoryStatus?: string
rebuildRequired?: boolean
revisionReviewStatus?: string
reasonCodes?: string[]
}
export interface Scope { sourceAccount: string; warehouseCode: string }
export interface BusinessQuery extends Scope {
customerCode?: string
sku?: string
current: number
size: number
rangePreset?: RangePreset
startDate?: string
endDate?: string
windowDays?: 7 | 30
attention?: InventoryAttention
sortBy?: InventorySort
coverageDaysThreshold?: number
salesRunId?: string | null
inventoryRunId?: string | null
salesScopeHash?: string | null
inventoryScopeHash?: string | null
queryAsOf?: string
}
export interface SalesSummary {
sourceAccount: string
customerCode: string
warehouseCode: string
sku: string
productName: string | null
quantityUnit: string | null
outboundQuantity: Quantity
observedQuantity: Quantity
dataStatus: string
reasonCodes: string[]
completeDayCount: number
partialDayCount: number
unknownDayCount: number
}
export interface DailyPoint {
businessDate: string
outboundQuantity: Quantity
observedQuantity: Quantity
dataStatus: string
reasonCodes: string[]
quantityUnit: string | null
coveredFrom?: string | null
coveredUntil?: string | null
}
export interface DailyResponse { context: QueryContext; summary: SalesSummary; points: DailyPoint[] }
export interface Metric {
sourceAccount: string
customerCode: string
warehouseCode: string
sku: string
productName: string | null
quantityUnit: string | null
outboundQuantity: Quantity
skuOutboundOrderCount: number | null
physicalQuantity: Quantity
availableQuantity: Quantity
frozenQuantity: Quantity
transitQuantity: Quantity
averageDailyOutboundQuantity: Quantity
inventoryCoverageDays: Quantity
inventoryObservedAt: string | null
status: string
reasonCodes: string[]
calculationWindowDays?: number
customerName?: string | null
defectivePhysicalQuantity?: Quantity
outboundQuantity30d?: Quantity
lastObservedOutboundDate?: string | null
observedHistoryStartDate?: string | null
attentionReasons?: string[]
quantityStatuses?: Record<string, string>
}
export interface OwnerOption { customerCode: string; customerName: string | null }
export type OwnerQuery = Omit<BusinessQuery, 'customerCode' | 'sku' | 'attention' | 'sortBy' | 'rangePreset' | 'coverageDaysThreshold'> & { keyword?: string }
export type OwnerOverviewQuery = Omit<BusinessQuery, 'current' | 'size' | 'sku' | 'attention' | 'sortBy' | 'rangePreset'> & { customerCode: string }
export interface QuantityTotal { overallTotal: Quantity; knownSubtotal: Quantity; unknownSkuCount: Quantity; dataStatus: string; reasonCodes: string[] }
export interface RiskShare { matchedSkuCount: Quantity; matchedPhysicalQuantity: Quantity; physicalQuantityRatio: Quantity; knownMatchedSkuCount: Quantity; knownMatchedPhysicalQuantity: Quantity; unassessableSkuCount: Quantity; unassessableKnownPhysicalQuantity: Quantity; reasonCodes: string[] }
export interface OwnerOverview {
context: QueryContext
customerCode: string
customerName: string | null
skuCount: Quantity
physicalSkuCount: QuantityTotal
physicalQuantity: QuantityTotal
availableQuantity: QuantityTotal
frozenQuantity: QuantityTotal
defectivePhysicalQuantity: QuantityTotal
transitQuantity: QuantityTotal
outboundQuantity30d: QuantityTotal
averageDailyOutboundQuantity: QuantityTotal
noOutbound30d: RiskShare
coverageAboveThreshold: RiskShare
}
export interface BusinessPage<T> { context: QueryContext; records: T[]; total: number; current: number; size: number; excludedReasons?: Record<string, number> }
export interface Progress {
progressScope: string
countBasis: string
attemptNo: number | null
scanRound: number | null
phase: string | null
completed: number | null
total: number | null
totalKnown: boolean
unit: string | null
sliceFrom: string | null
sliceUntil: string | null
progressUpdatedAt?: string | null
stages?: Partial<Progress>[]
validationState?: string | null
invalidCount?: number | null
unknownCount?: number | null
pendingReviewCount?: number | null
countUnit?: string | null
summaryAsOf?: string | null
}
export interface Run {
attemptNo: number | null
status: string
phase: string | null
startedAt: string | null
finishedAt: string | null
progressUpdatedAt: string | null
heartbeatAt: string | null
executionHealth: string | null
elapsedSeconds: number | null
errorCode: string | null
failureSummary: string | null
progress?: Partial<Progress> | null
}
export interface Task {
taskNo: string
taskType: string
dataFamily: string
warehouseCode: string
queryFrom: string | null
queryUntil: string | null
taskStatus: string
applyState: string | null
attemptCount: number
latestAttemptNo: number | null
resultPublicationState: string | null
nextRetryAt: string | null
allowedActions: string[]
actionBlockReason: string | null
progress: Partial<Progress> | null
latestAttempt?: Run | null
failureSummary?: string | null
executionPurpose?: string | null
progressUpdatedAt?: string | null
validation?: Validation | null
}
export interface RuntimePage<T> { records: T[]; total: number; current: number; size: number; listUpperCreatedAt: string | null; listUpperId: string | null; observedAt: string }
export interface RuntimeQuery extends Scope { current: number; size: number; taskNo?: string; listUpperCreatedAt?: string | null; listUpperId?: string | null }
export interface Protection { accountCooldownUntil: string | null; accountPauseReason: string | null; dailyRequestBudget: number | null; reservedHttpRequests: number | null; confirmedHttpRequests: number | null; budgetDate: string | null }
export interface Validation { validationSessionNo: string | null; budgetLimits: Record<string, unknown> | null; budgetConsumed: Record<string, unknown> | null; deadlineAt: string | null; stopReason: string | null }
export interface TaskDetail { task: Task; sharedUpstream: Task | null; protection: Protection; observedAt: string }
export interface WarehouseOverview {
warehouseCode: string
warehouseName: string | null
dataFamily: string
executionEnabled: boolean
publicationEnabled: boolean
disabledReason: string | null
nextScheduledAt: string | null
observedAt: string
initialFrom: string | null
initialUntil: string | null
initialCoverageStatus: string | null
salesCoveredFrom: string | null
salesCoveredUntil: string | null
lastSuccessfulTaskNo: string | null
lastSucceededAt: string | null
lastPublishedAt: string | null
inventoryObservedFrom: string | null
inventoryObservedUntil: string | null
dataFreshness: string | null
rebuildRequired: boolean
revisionReviewStatus: string | null
protection: Protection
currentTask?: Task | null
sharedUpstream?: Task | null
salesRunId?: string | null
inventoryRunId?: string | null
inventoryDailySamples?: { windowFrom: string; windowUntil: string; expectedDays: number; sampleCount: number; missingDates: string[]; accumulationStartedOn: string | null } | null
}
export interface Overview { sourceAccount: string; environmentLabel: string; collectionMode: string; observedAt: string; warehouses: WarehouseOverview[] }
export interface RetryCommand { action: 'RETRY'; requestKey: string; warehouseCode: string; taskNo: string; reason: string }
export interface RetryResult { taskNo: string; status: string }