feat: add platform self-operated sales inventory and collection views
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
|
||||
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
|
||||
|
||||
@@ -34,6 +34,7 @@ import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
|
||||
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
|
||||
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
|
||||
import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes'
|
||||
import { selfOperatedRoutes } from '@/modules/self-operated/routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -81,6 +82,7 @@ const router = createRouter({
|
||||
...pdaReleaseRoutes,
|
||||
...sailingCalendarRoutes,
|
||||
...appointmentCalendarRoutes,
|
||||
...selfOperatedRoutes,
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
} from './adminNavigation'
|
||||
|
||||
describe('adminNavigation', () => {
|
||||
it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list']))
|
||||
expect(getNavigationLeaves(read).map(item => item.path)).toEqual([
|
||||
'/dashboard', '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection',
|
||||
])
|
||||
const sync = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:sync']))
|
||||
expect(getNavigationLeaves(sync).map(item => item.path)).toEqual(['/dashboard'])
|
||||
})
|
||||
it('offers the common container menu to overseas-only operators', () => {
|
||||
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list']))
|
||||
expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic'])
|
||||
@@ -27,11 +35,12 @@ describe('adminNavigation', () => {
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
{ title: '自营业务', children: ['销量分析', '库存分析', '数据采集'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(12)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(52)
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(55)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -73,7 +82,7 @@ describe('adminNavigation', () => {
|
||||
expect(paths).toContain('/system/permissions')
|
||||
expect(new Set(paths).size).toBe(paths.length)
|
||||
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
|
||||
expect(new Set(duplicatedPermissions)).toEqual(new Set())
|
||||
expect(new Set(duplicatedPermissions)).toEqual(new Set(['platform:self-operated-analytics:list']))
|
||||
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -181,6 +181,16 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'self-operated',
|
||||
title: '自营业务',
|
||||
icon: PrototypeSalesIcon,
|
||||
children: [
|
||||
{ key: 'self-operated-sales', title: '销量分析', icon: ClipboardList, path: '/self-operated/sales', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-inventory', title: '库存分析', icon: WarehouseIcon, path: '/self-operated/inventory', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-collection', title: '数据采集', icon: Database, path: '/self-operated/collection', permission: 'platform:self-operated-analytics:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'general',
|
||||
title: '通用',
|
||||
|
||||
@@ -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('没有任务恢复权限')
|
||||
})
|
||||
})
|
||||
@@ -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[]
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -90,7 +90,10 @@ const notificationConsumers = [
|
||||
'notifications/components/TemplateEditorDialog.vue',
|
||||
]
|
||||
const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue']
|
||||
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue', 'self-operated/pages/CollectionView.vue']
|
||||
const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue']
|
||||
const consumers = [
|
||||
...selfOperatedConsumers,
|
||||
'overseas-business/components/FleetArrangementSummary.vue',
|
||||
...notificationConsumers,
|
||||
...systemConsumers,
|
||||
@@ -131,6 +134,7 @@ const customerPages = [
|
||||
{ title: '海外仓阶梯费用', path: '/customers/overseas-storage-fees', file: 'customers/pages/OverseasStorageFeesView.vue' },
|
||||
]
|
||||
const densityPages = [...new Set([
|
||||
...selfOperatedPages,
|
||||
...notificationConsumers.filter(file => file.includes('/pages/')),
|
||||
...systemConsumers,
|
||||
...generalCatalogConsumers.filter(file => file.includes('/pages/')),
|
||||
@@ -442,7 +446,7 @@ describe('approved Admin density ownership', () => {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 1) continue
|
||||
const isActionSlot = actionSlot || (parentTag === 'DataTable' && node.props.some(prop => prop.type === 7 && prop.name === 'slot' && prop.arg?.type === 4 && prop.arg.content === 'actions'))
|
||||
if (densityPages.includes(file) && isActionSlot && node.tag === 'el-button') expect(classesOf(node), `${file}: row action`).toContain('density-action')
|
||||
if (densityPages.includes(file) && !selfOperatedPages.includes(file) && isActionSlot && node.tag === 'el-button') expect(classesOf(node), `${file}: row action`).toContain('density-action')
|
||||
if (['AppDialog', 'el-dialog'].includes(node.tag)) expect(classesOf(node), `${file}: teleported dialog`).toContain('admin-density-dialog')
|
||||
if (['AppDrawer', 'el-drawer'].includes(node.tag)) expect(classesOf(node), `${file}: teleported drawer`).toContain('admin-density-drawer')
|
||||
visit(node.children, isActionSlot, node.tag)
|
||||
@@ -452,6 +456,28 @@ describe('approved Admin density ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('opts the confirmed self-operated pages into integrated text actions without changing older outlined consumers', () => {
|
||||
expect(getNavigationLeaves(adminNavigation).map(node => node.path)).toEqual(expect.arrayContaining([
|
||||
'/self-operated/sales', '/self-operated/inventory', '/self-operated/collection',
|
||||
]))
|
||||
for (const file of selfOperatedPages) {
|
||||
const template = parse(read(`modules/${file}`)).descriptor.template!.ast!
|
||||
let tables = 0
|
||||
const visit = (nodes: typeof template.children) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 1) continue
|
||||
if (node.tag === 'DataTable') {
|
||||
expect(node.props.some(prop => prop.type === 6 && prop.name === 'appearance' && prop.value?.content === 'integrated'), `${file}: integrated text actions`).toBe(true)
|
||||
tables += 1
|
||||
}
|
||||
visit(node.children)
|
||||
}
|
||||
}
|
||||
visit(template.children)
|
||||
expect(tables).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps tokens and shared rules opt-in and free of business selectors', () => {
|
||||
expect(shared).toContain("@import './adminDensityTokens.css'")
|
||||
for (const css of [tokens, shared]) cssRoot(css).walkRules(rule => {
|
||||
|
||||
Reference in New Issue
Block a user