feat(selfop): show customer names and filter sales and turnover
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
- 模块入口 `routes.ts`;正式页面 `pages/OverviewView.vue`、`SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。
|
||||
- 文档路径从工作区根起算;本目录代码路径从当前模块起算。总览设计变更查 `docs/自营业务/经营总览图表设计.md`,采集页面变更查 `docs/自营业务/数据采集页面设计.md`;纯文案或局部代码定位不默认加载设计全文。
|
||||
- `analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选统一影响区间出库、日均、日趋势、库存分布及三个榜单,热销按已采集量排名并标明覆盖不足,库存评估按所选区间完整销量及观察资格计算;库存数量仍取固定当前版本。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD` 并从原图上下文传递 CUSTOM 起止日期,不能混用普通库存的10/30日窗口,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。
|
||||
- `analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选统一影响区间出库、日均、日趋势、库存分布及三个榜单,热销按已采集量排名并标明覆盖不足,库存评估按所选区间完整销量及观察资格计算;库存数量仍取固定当前版本。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD` 并从原图上下文传递 CUSTOM 起止日期,不能混用普通库存的10/30日窗口,过期禁止冒充原图结果。经营总览不增加货主筛选或面板;销量、库存明细和库存周转支持按客户名称/编码选择货主,名称取 queryAsOf 时可见的完整领星资料批次,按账号+货主编码关联。
|
||||
- API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。
|
||||
- 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。
|
||||
- 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。
|
||||
|
||||
@@ -8,6 +8,7 @@ async function get<T>(path: string, params: object, signal?: AbortSignal): Promi
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export const analyticsApi = {
|
||||
ownerDirectory: (query: { sourceAccount: string; warehouseCode: string } & Partial<BusinessQuery>, signal?: AbortSignal) => get<OwnerOption[]>('owner-directory', query, signal),
|
||||
turnover: (query: TurnoverQuery, signal?: AbortSignal) => get<TurnoverResponse>('inventory-turnover', query, signal),
|
||||
dashboard: (query: DashboardQuery, signal?: AbortSignal) => get<Dashboard>('dashboard', query, signal),
|
||||
owners: (query: OwnerQuery, signal?: AbortSignal) => get<BusinessPage<OwnerOption>>('owners', query, signal),
|
||||
|
||||
@@ -16,6 +16,7 @@ import { analyticsApi } from '../api/analytics'
|
||||
import type { BusinessQuery, DailyResponse, InventoryAttention, InventoryMetric, InventoryQuery, InventorySort, Metric, Overview, 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 { useOwnerDirectory } from '../useOwnerDirectory'
|
||||
import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation'
|
||||
import { overviewRange, overviewRangeOptions } from '../overviewRange'
|
||||
import { inventorySortColumns, inventorySortOptions } from '../inventorySorting'
|
||||
@@ -37,9 +38,10 @@ function readDrilldown() {
|
||||
}
|
||||
readDrilldown()
|
||||
function beijingToday() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) }
|
||||
function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'LAST_30_DAYS', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customRange: drilldown.value?.startDate && drilldown.value?.endDate ? [drilldown.value.startDate, drilldown.value.endDate] : [] } }
|
||||
function initialFilters(): QueryModel { return { customerCode: drilldown.value?.customerCode ?? '', warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'LAST_30_DAYS', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customRange: drilldown.value?.startDate && drilldown.value?.endDate ? [drilldown.value.startDate, drilldown.value.endDate] : [] } }
|
||||
const ownerDirectory = useOwnerDirectory()
|
||||
const filters = ref<QueryModel>(initialFilters())
|
||||
const applied = ref({ sku: String(filters.value.sku) })
|
||||
const applied = ref({ sku: String(filters.value.sku), customerCode: String(filters.value.customerCode || '') })
|
||||
const appliedWindow = ref({ salesWindowDays: 30 as number, salesWindowEndDate: String(filters.value.salesWindowEndDate) })
|
||||
const displayedWindowDays = computed(() => context.value?.calculationWindowDays ?? appliedWindow.value.salesWindowDays)
|
||||
const overview = ref<Overview | null>(null)
|
||||
@@ -94,6 +96,7 @@ watch(warehouseCode, code => { if (code) { selectedWarehouse = code; emit('wareh
|
||||
const fields = computed<QueryField[]>(() => {
|
||||
const result: QueryField[] = [
|
||||
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', props: { clearable: false }, options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] },
|
||||
{ key: 'customerCode', label: '客户', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '名称或编码搜索', props: { filterable: true, clearable: true, disabled: !!drilldown.value } },
|
||||
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
|
||||
]
|
||||
if (props.mode === 'inventory') result.push(
|
||||
@@ -185,7 +188,7 @@ function asMetric(row: unknown) { return row as Metric }
|
||||
function asInventory(row: unknown) { return row as InventoryMetric }
|
||||
function query(): InventoryQuery {
|
||||
const result: InventoryQuery = { sourceAccount, warehouseCode: warehouseCode.value, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) }
|
||||
if (drilldown.value?.customerCode) result.customerCode = drilldown.value.customerCode
|
||||
if (drilldown.value?.customerCode || applied.value.customerCode) result.customerCode = drilldown.value?.customerCode || applied.value.customerCode
|
||||
if (summaryMode.value) {
|
||||
Object.assign(result, appliedSalesRange.value, { salesSort: salesSort.value })
|
||||
} else if (props.mode === 'sales' && tab.value === 'hot') {
|
||||
@@ -221,6 +224,7 @@ async function load(reset = false, keepContext = false) {
|
||||
if (context.value && !sameContext(context.value, result.context)) throw new Error('查询版本已变化,请重新查询')
|
||||
context.value = result.context
|
||||
rows.value = result.records
|
||||
void ownerDirectory.load(result.context, warehouseCode.value, request.signal)
|
||||
total.value = result.total
|
||||
daily.value = null
|
||||
if (props.mode === 'inventory') displayedInventorySort.value = params.sortBy ?? 'AVAILABLE_DESC'
|
||||
@@ -289,7 +293,7 @@ function search() {
|
||||
const keepContext = !newData.value && !newDay.value && props.mode === 'inventory' && nextWindow.salesWindowDays === appliedWindow.value.salesWindowDays && nextWindow.salesWindowEndDate === appliedWindow.value.salesWindowEndDate && sortBy.value !== appliedInventory.value.sortBy
|
||||
appliedWindow.value = nextWindow
|
||||
appliedInventory.value = { attention: attention.value, sortBy: sortBy.value, coverageDaysThreshold: rawThreshold ? Number(rawThreshold) : undefined }
|
||||
applied.value = { sku: String(filters.value.sku ?? '').trim() }
|
||||
applied.value = { sku: String(filters.value.sku ?? '').trim(), customerCode: String(filters.value.customerCode || '') }
|
||||
current.value = 1
|
||||
void load(true, keepContext)
|
||||
}
|
||||
@@ -297,8 +301,8 @@ async function reset() {
|
||||
salesSort.value = 'QUANTITY_DESC'
|
||||
applyingRoute = true
|
||||
drilldown.value = null; origin.value = null
|
||||
filters.value = { ...initialFilters(), warehouseCode: warehouseCode.value || selectedWarehouse || defaultWarehouse(), sku: '' }
|
||||
appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }
|
||||
filters.value = { ...initialFilters(), warehouseCode: warehouseCode.value || selectedWarehouse || defaultWarehouse(), sku: '', customerCode: '' }
|
||||
appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '', customerCode: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }
|
||||
await nextTick(); applyingRoute = false; void load(true)
|
||||
}
|
||||
function returnOverview() {
|
||||
@@ -307,9 +311,9 @@ function returnOverview() {
|
||||
}
|
||||
async function clearDrilldown() {
|
||||
drilldown.value = null
|
||||
filters.value = { ...filters.value, sku: '', analysis: 'summary', rangePreset: 'LAST_30_DAYS', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customRange: [] }
|
||||
filters.value = { ...filters.value, sku: '', customerCode: '', analysis: 'summary', rangePreset: 'LAST_30_DAYS', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customRange: [] }
|
||||
appliedSalesRange.value = draftSalesRange()
|
||||
applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1
|
||||
applied.value = { sku: '', customerCode: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1
|
||||
await nextTick(); void load()
|
||||
}
|
||||
function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) }
|
||||
@@ -329,7 +333,7 @@ onMounted(() => { visibility(); poller.start(); document.addEventListener('visib
|
||||
watch(() => route.query, async () => {
|
||||
if (route.path && route.path !== `/self-operated/${props.mode}`) return
|
||||
applyingRoute = true; dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; rows.value = []; total.value = 0
|
||||
readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1
|
||||
readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? ''), customerCode: String(filters.value.customerCode || '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1
|
||||
if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse()
|
||||
await nextTick(); applyingRoute = false; void load()
|
||||
})
|
||||
@@ -343,7 +347,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<ListPageLayout v-else>
|
||||
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
|
||||
<template v-if="$slots['view-tabs']" #toolbar-start><slot name="view-tabs" /></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template #filters><p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template #notices>
|
||||
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window-summary">销量统计(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }},共 {{ displayedWindowDays }} 天。{{ origin ? '沿用经营总览所选区间;未完整的日期不推算日均。' : '不含今天;今天的出库不计入日均及可售天数。' }}库存使用最新已查询的采集值。</p>
|
||||
<p v-if="routeProblem" role="alert">{{ routeProblem }};已停止查询,不能按全仓条件继续。<el-button @click="returnOverview">返回经营总览</el-button></p>
|
||||
|
||||
@@ -13,6 +13,12 @@ function overview(account: Task | null = source): Overview {
|
||||
}
|
||||
const options = { global: { stubs: { 'el-button': true } } }
|
||||
describe('single account collection monitor', () => {
|
||||
it('identifies account master-data work without calling it outbound detail fetching', () => {
|
||||
const task = { ...source, dataFamily: 'MASTER_DATA', taskType: 'MASTER_DATA', progress: { ...source.progress!, phase: 'PRODUCTS', unit: 'PAGE', completed: 2, total: 86 } }
|
||||
const data = { ...overview(task), warehouses: [] }
|
||||
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
|
||||
expect(w.text()).toContain('客户/商品资料'); expect(w.text()).toContain('获取商品资料'); expect(w.text()).not.toContain('获取出库目录'); w.unmount()
|
||||
})
|
||||
it('shows server schedules once per family and time, excluding disabled scopes', async () => {
|
||||
const data = overview()
|
||||
data.warehouses.forEach(row => { row.executionEnabled = true; row.nextScheduledAt = '2026-10-13T16:05:00Z' })
|
||||
|
||||
@@ -22,7 +22,7 @@ const waiting = computed(() => tasks.value.filter(t => t.taskNo !== current.valu
|
||||
const upcoming = computed(() => {
|
||||
const groups = new Map<string, number>()
|
||||
for (const task of waiting.value) {
|
||||
const name = `${task.warehouseCode || '账号'} · ${task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`
|
||||
const name = `${task.warehouseCode || '账号'} · ${task.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`
|
||||
groups.set(name, (groups.get(name) ?? 0) + 1)
|
||||
}
|
||||
return [...groups].map(([name, count]) => ({ name, count }))
|
||||
@@ -41,6 +41,8 @@ const schedules = computed(() => {
|
||||
})
|
||||
const nextStep = computed(() => {
|
||||
const phase = current.value?.progress?.phase
|
||||
if (phase === 'CUSTOMERS') return '获取商品资料 → 校验完整批次 → 发布资料'
|
||||
if (phase === 'PRODUCTS') return '校验完整批次 → 发布资料'
|
||||
if (['DIRECTORY', 'DISCOVERY'].includes(phase ?? '')) return '处理出库订单 → 更新各仓出库数据 → 统计计算 → 发布结果'
|
||||
if (['DETAIL', 'DETAILS'].includes(phase ?? '')) return '更新各仓出库数据 → 统计计算 → 发布结果'
|
||||
if (['INVENTORY', 'FETCH'].includes(phase ?? '')) return '库存完整性校验 → 统计计算 → 发布结果'
|
||||
@@ -49,7 +51,7 @@ const nextStep = computed(() => {
|
||||
if (['PUBLISH', 'PUBLISHED'].includes(phase ?? '')) return '本次发布完成后,由调度继续安排后续任务'
|
||||
return '等待调度确认下一步'
|
||||
})
|
||||
const scopeText = computed(() => current.value?.taskNo === props.overview?.accountActivity?.taskNo || current.value?.progress?.progressScope === 'ACCOUNT_SHARED' ? '领星账号 · 出库数据' : `${current.value?.warehouseCode || '领星账号'} · ${current.value?.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`)
|
||||
const scopeText = computed(() => current.value?.dataFamily === 'MASTER_DATA' ? '领星账号 · 客户/商品资料' : current.value?.taskNo === props.overview?.accountActivity?.taskNo || current.value?.progress?.progressScope === 'ACCOUNT_SHARED' ? '领星账号 · 出库数据' : `${current.value?.warehouseCode || '领星账号'} · ${current.value?.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : current.value?.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`)
|
||||
const warehouseGroups = computed(() => [...new Set(props.overview?.warehouses.map(w => w.warehouseCode) ?? [])].map(code => ({ code, rows: props.overview!.warehouses.filter(w => w.warehouseCode === code) })))
|
||||
const latestPublication = computed(() => (props.overview?.warehouses ?? []).filter(w => w.lastPublishedAt).sort((a, b) => Date.parse(b.lastPublishedAt!) - Date.parse(a.lastPublishedAt!))[0])
|
||||
const idleReason = computed(() => {
|
||||
@@ -59,7 +61,7 @@ const idleReason = computed(() => {
|
||||
if (props.overview.warehouses.some(w => ['DEAD', 'FAILED', 'ABORTED'].includes(w.currentTask?.taskStatus ?? ''))) return '暂无活动任务,最近有任务停止'
|
||||
return '等待下一次采集'
|
||||
})
|
||||
const flow = computed(() => current.value?.dataFamily === 'INVENTORY' ? [
|
||||
const flow = computed(() => current.value?.dataFamily === 'MASTER_DATA' ? [{ name: '获取客户资料', phases: ['CUSTOMERS'] }, { name: '获取商品资料', phases: ['PRODUCTS'] }, { name: '发布资料', phases: ['PUBLISH', 'PUBLISHED'] }] : current.value?.dataFamily === 'INVENTORY' ? [
|
||||
{ name: '读取库存', phases: ['INVENTORY', 'FETCH'] }, { name: '完整性校验', phases: ['VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] },
|
||||
] : [{ name: '获取出库目录', phases: ['DIRECTORY', 'DISCOVERY'] }, { name: '处理出库订单', phases: ['DETAIL', 'DETAILS'] }, { name: '更新各仓出库数据', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] }])
|
||||
function nodeState(phases: string[]) {
|
||||
|
||||
@@ -45,13 +45,13 @@ const taskElapsed = computed(() => {
|
||||
const seconds = Number(value)
|
||||
return Number.isSafeInteger(seconds) && seconds >= 0 ? seconds : null
|
||||
})
|
||||
const flow = computed(() => props.task.dataFamily === 'INVENTORY' ? [
|
||||
const flow = computed(() => props.task.dataFamily === 'MASTER_DATA' ? [{ title: '客户资料', scope: '账号共享', phases: ['CUSTOMERS'] }, { title: '商品资料', scope: '账号共享', phases: ['PRODUCTS'] }, { title: '结果发布', scope: '账号共享', phases: ['PUBLISH', 'PUBLISHED'] }] : props.task.dataFamily === 'INVENTORY' ? [
|
||||
{ title: '本仓库存分页', scope: '本仓', phases: ['INVENTORY', 'FETCH'] }, { title: '完整性校验', scope: '本仓', phases: ['VALIDATE', 'VALIDATION'] }, { title: '统计计算', scope: '本仓', phases: ['BUILD', 'BUILDING', 'BUILT'] }, { title: '结果发布', scope: '本仓', phases: ['PUBLISH', 'PUBLISHED'] },
|
||||
] : [
|
||||
{ title: '来源目录', scope: '账号共享', phases: ['DIRECTORY', 'DISCOVERY'] }, { title: '来源详情', scope: '账号共享', phases: ['DETAIL', 'DETAILS'] }, { title: '更新本仓出库数据', scope: '本仓', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { title: '统计计算', scope: '本仓', phases: ['BUILD', 'BUILDING', 'BUILT'] }, { title: '结果发布', scope: '本仓', phases: ['PUBLISH', 'PUBLISHED'] },
|
||||
])
|
||||
function stageState(node: { title: string; scope: string; phases: string[] }) {
|
||||
const task = node.scope === '账号共享' ? confirmedSource.value : props.task
|
||||
const task = node.scope === '账号共享' && props.task.dataFamily !== 'MASTER_DATA' ? confirmedSource.value : props.task
|
||||
if (!task) return '状态未确认'
|
||||
if (node.title === '结果发布' && task.resultPublicationState === 'PUBLISHED') return '✓ 已发布'
|
||||
if (node.title === '结果发布' && task.resultPublicationState === 'REUSED') return '复用已有结果'
|
||||
@@ -67,7 +67,7 @@ function stageState(node: { title: string; scope: string; phases: string[] }) {
|
||||
<template>
|
||||
<section class="collection-progress" aria-label="关注任务进展">
|
||||
<template v-if="!compact">
|
||||
<div class="density-actions"><strong>{{ task.warehouseCode }} · {{ task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}</strong><span>{{ taskStateText(task) }}</span></div>
|
||||
<div class="density-actions"><strong>{{ task.warehouseCode }} · {{ task.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}</strong><span>{{ taskStateText(task) }}</span></div>
|
||||
<p class="collection-caption">数据范围 {{ beijingTime(task.queryFrom) }} 至 {{ beijingTime(task.queryUntil) }}(结束边界不含)</p>
|
||||
<p v-if="task.dependencyState === 'WAITING_UNCONFIRMED'">本任务等待来源;关联进度尚未确认。</p>
|
||||
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
|
||||
|
||||
@@ -4,11 +4,12 @@ import HotSkuDetails from './HotSkuDetails.vue'
|
||||
import type { DashboardRanking, DashboardItem } from '../types'
|
||||
it('shows each SKU identity and ranked quantity once, preserves unknown and owner-specific drilldown', async () => {
|
||||
const item: DashboardItem = { sku: 'SKU-X', customerCode: 'OWNER-A', productName: 'SKU-X 商品', value: 164, physicalQuantity: 891, availableQuantity: 0, averageDailyOutboundQuantity: null, inventoryCoverageDays: null, unit: 'PCS', windowOutboundQuantity: 164, skuOutboundOrderCount: 1, frozenQuantity: 0, defectivePhysicalQuantity: 0 }
|
||||
const ranking: DashboardRanking = { windowStartDate: '2026-10-01', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 13, eligibleCount: 2, unassessableCount: 0, dataStatus: 'PARTIAL', reasonCodes: [], items: [item, { ...item, customerCode: 'OWNER-B', productName: 'SKU-X' }] }
|
||||
const ranking: DashboardRanking = { windowStartDate: '2026-10-01', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 13, eligibleCount: 2, unassessableCount: 0, dataStatus: 'PARTIAL', reasonCodes: [], items: [item, { ...item, customerCode: 'OWNER-B', customerName: '货主乙', productName: 'SKU-X' }] }
|
||||
const wrapper = mount(HotSkuDetails, { props: { ranking } })
|
||||
const cells = wrapper.findAll('tbody tr')[0]!.findAll('td').map(cell => cell.text())
|
||||
expect(cells).toEqual(['1', 'SKU-X', '商品', 'OWNER-A', '164', '891', '0', '—', '—'])
|
||||
expect(wrapper.findAll('tbody tr')[1]!.findAll('td')[2]!.text()).toBe('—')
|
||||
expect(wrapper.findAll('tbody tr')[1]!.findAll('td')[3]!.text()).toBe('货主乙')
|
||||
expect(wrapper.text()).toContain('时段已采集出库量')
|
||||
await wrapper.findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('select')).toEqual([[1]])
|
||||
|
||||
@@ -14,7 +14,7 @@ function productName(sku: string, name: string | null) {
|
||||
<p>{{ ranking.windowStartDate }} 至 {{ ranking.windowEndDateExclusive }}(截止日不含) · {{ label(ranking.dataStatus) }}<template v-if="ranking.reasonCodes.length"> · {{ reasonsText(ranking.reasonCodes) }}</template></p>
|
||||
<p>按所选时段{{ ranking.dataStatus === 'COMPLETE' ? '出库量' : '已采集出库量' }}排名;点击 SKU 查看每日趋势。<template v-if="ranking.dataStatus !== 'COMPLETE'">数据尚未采齐,排名可能变化;未采集日期不计为零出库。</template>“—”表示暂无可靠数据。</p>
|
||||
<table class="density-table"><thead><tr><th class="rank">排名</th><th class="sku">SKU</th><th>商品名称</th><th class="owner">货主</th><th class="number">{{ ranking.dataStatus === 'COMPLETE' ? '时段出库量' : '时段已采集出库量' }}(件)</th><th class="number">实物库存(件)</th><th class="number">可售库存(件)</th><th class="number">窗口日均(件)</th><th class="number">预计可售(天)</th></tr></thead><tbody>
|
||||
<tr v-for="(item, index) in ranking.items" :key="`${item.customerCode}:${item.sku}`"><td>{{ index + 1 }}</td><td><button type="button" :disabled="disabled || item.value == null" @click="emit('select', index)">{{ item.sku }}</button></td><td>{{ productName(item.sku, item.productName) }}</td><td>{{ item.customerCode }}</td><td class="number">{{ quantityText(item.value) }}</td><td class="number">{{ quantityText(item.physicalQuantity) }}</td><td class="number">{{ quantityText(item.availableQuantity) }}</td><td class="number">{{ quantityText(item.averageDailyOutboundQuantity) }}</td><td class="number">{{ quantityText(item.inventoryCoverageDays) }}</td></tr>
|
||||
<tr v-for="(item, index) in ranking.items" :key="`${item.customerCode}:${item.sku}`"><td>{{ index + 1 }}</td><td><button type="button" :disabled="disabled || item.value == null" @click="emit('select', index)">{{ item.sku }}</button></td><td>{{ productName(item.sku, item.productName) }}</td><td>{{ item.customerName || item.customerCode }}</td><td class="number">{{ quantityText(item.value) }}</td><td class="number">{{ quantityText(item.physicalQuantity) }}</td><td class="number">{{ quantityText(item.availableQuantity) }}</td><td class="number">{{ quantityText(item.averageDailyOutboundQuantity) }}</td><td class="number">{{ quantityText(item.inventoryCoverageDays) }}</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import InventoryTurnoverView from './InventoryTurnoverView.vue'
|
||||
import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from '../turnoverTypes'
|
||||
const mocks = vi.hoisted(() => ({ route: { query: {} as Record<string, string> }, allowed: true, overview: vi.fn(), turnover: vi.fn(), daily: vi.fn(), push: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => ({ route: { query: {} as Record<string, string> }, allowed: true, ownerDirectory: vi.fn(), overview: vi.fn(), turnover: vi.fn(), daily: vi.fn(), push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
|
||||
@@ -23,9 +23,18 @@ async function change(w: ReturnType<typeof mount>, patch: object, search = false
|
||||
const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises()
|
||||
if (search) { t.vm.$emit('search'); await flushPromises() }
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() })
|
||||
beforeEach(() => { vi.clearAllMocks(); mocks.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }]); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() })
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
it('filters turnover by directory customer and keeps the code in pagination', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
expect(w.findComponent(toolbar).props('fields').find((f: { key: string }) => f.key === 'customerCode').options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
|
||||
await change(w, { customerCode: 'OWNER-A' })
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 1, includeOverview: true })
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 2, snapshotContext: 'token-A' })
|
||||
})
|
||||
|
||||
it('sorts every numeric header on the server in both directions with the same snapshot', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
|
||||
|
||||
@@ -23,8 +23,10 @@ import { totalText, turnoverTotalNote, inventoryCollectionNotice, turnoverChart,
|
||||
import AnalyticsChart from './AnalyticsChart.vue'
|
||||
import ChartDetailsDialog from './ChartDetailsDialog.vue'
|
||||
import SkuTrend from './SkuTrend.vue'
|
||||
import { useOwnerDirectory } from '../useOwnerDirectory'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const ownerDirectory = useOwnerDirectory()
|
||||
const emit = defineEmits<{ warehouseSelected: [code: string] }>()
|
||||
class TurnoverContractError extends Error {}
|
||||
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
|
||||
@@ -45,6 +47,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'rangePreset', label: '统计时间范围', type: 'select', width: 180, props: { clearable: false }, options: overviewRangeOptions },
|
||||
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'customRange', label: '日期范围', type: 'daterange' as const, width: 300, props: { disabledDate: (date: Date) => date.toLocaleDateString('en-CA') > yesterdayBeijing() } }] : []),
|
||||
{ key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } },
|
||||
{ key: 'customerCode', label: '客户', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '名称或编码搜索', props: { filterable: true, clearable: true } },
|
||||
...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []),
|
||||
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
|
||||
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
|
||||
@@ -138,6 +141,7 @@ async function load(refresh = false) {
|
||||
if (q.includeOverview && (!result.summary || !result.charts)) throw new TurnoverContractError('本次概览未完整返回,请整体刷新')
|
||||
data.value = { ...result, summary: q.includeOverview ? result.summary : previous?.summary ?? null, charts: q.includeOverview ? result.charts : previous?.charts ?? null }
|
||||
applied.value = q; error.value = null; emit('warehouseSelected', q.warehouseCode)
|
||||
void ownerDirectory.load(result.context.analyticsContext, q.warehouseCode, request.signal)
|
||||
try {
|
||||
if (q.coverageDaysThreshold != null) sessionStorage.setItem(`selfop-turnover-threshold:${q.warehouseCode}`, String(q.coverageDaysThreshold))
|
||||
} catch { /* optional preference */ }
|
||||
@@ -155,7 +159,7 @@ function changeFilters(next: QueryModel) {
|
||||
if (next.groupBy !== filters.value.groupBy) {
|
||||
next = { ...next, sku: '', sortBy: 'PHYSICAL_DESC' }; notice.value = '已切换分组,SKU条件及专属排序已清除'
|
||||
}
|
||||
const changed = ['warehouseCode', 'rangePreset', 'groupBy'].some(k => next[k] !== filters.value[k])
|
||||
const changed = ['warehouseCode', 'rangePreset', 'groupBy', 'customerCode'].some(k => next[k] !== filters.value[k])
|
||||
if (next.warehouseCode !== filters.value.warehouseCode) next = { ...next, coverageDaysThreshold: restoreThreshold(String(next.warehouseCode || '')) }
|
||||
filters.value = next
|
||||
if (changed) { page.value = 1; void load() }
|
||||
@@ -229,6 +233,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
|
||||
<template #toolbar-actions><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></div></el-popover></template>
|
||||
<template #filters>
|
||||
<p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p>
|
||||
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
|
||||
</template>
|
||||
<template #before-results>
|
||||
|
||||
@@ -133,3 +133,12 @@
|
||||
| 经营总览/三张榜单精简 | 用户截图确认说明过多占位 | 日期、资格数量、完整性详情及统计口径收进右上角“说明”;卡片仅留一行必要的未采齐/待评估/过期提示,三个图表顶部对齐;消化天数图上最多两位小数,明细保留原值 | 构建与交互回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 库存周转/在途列 | 用户要求在库存周转表格添加在途 | 正品可售后默认展示在途,SKU及货主汇总共用固定库存版本;表头及筛选区支持全量升降序,未知末尾;全品质独立汇总,缺失显示已知小计与待核验数,不计入实物、可售或周转分母 | 字段映射、品质聚合、未知传播及排序回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
## 2026-10-14 货主名称与客户查询
|
||||
|
||||
本轮用户确认补齐客户名称、按客户查询销量与周转。该确认覆盖此前移除独立货主筛选的局部规则;经营总览继续保留现有筛选,只更新名称展示。
|
||||
|
||||
| 页面 | 操作 | 承载依据与实现 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 销量分析、库存分析 | 名称或编码选客户 | 本轮确认;复用现有 QueryToolbar 可搜索下拉框,标签为名称及编码,提交仍使用 customerCode;服务端先筛选再排名分页 | 客户筛选、固定分页、名称展示定向测试及类型检查通过;视觉待人工1920×1080、100%截图 |
|
||||
| 总览、销量、库存周转 | 货主归属展示 | 本轮客户截图与名称映射确认;名称优先、编码辅助或名称缺失时显示编码 | 待人工截图 |
|
||||
|
||||
@@ -77,7 +77,7 @@ const bucketRows = computed<BarRow[]>(() => buckets.map(bucket => {
|
||||
const item = data.value?.coverageDistribution.buckets.find(row => row.bucketCode === bucket.code)
|
||||
return { key: bucket.code, label: bucket.label, value: item?.skuCount ?? null, detail: `已知实物小计 ${quantityText(item?.knownPhysicalQuantity)} 件;实物未知 ${quantityText(item?.unknownPhysicalSkuCount)} SKU` }
|
||||
}))
|
||||
function rankingRows(ranking: DashboardRanking): BarRow[] { return ranking.items.map((item, index) => ({ key: String(index), label: item.sku, secondary: `${item.productName || '—'} · 货主 ${item.customerCode}`, value: item.value, detail: `SKU ${item.sku};货主 ${item.customerCode};实物 ${quantityText(item.physicalQuantity)} 件,可售 ${quantityText(item.availableQuantity)} 件,${ranking.calculationWindowDays}天窗口${ranking.dataStatus === 'COMPLETE' ? '出库' : '已采集出库'} ${quantityText(item.windowOutboundQuantity)} 件,窗口日均 ${quantityText(item.averageDailyOutboundQuantity)} 件,可售 ${quantityText(item.inventoryCoverageDays)} 天` })) }
|
||||
function rankingRows(ranking: DashboardRanking): BarRow[] { return ranking.items.map((item, index) => ({ key: String(index), label: item.sku, secondary: `${item.productName || '—'} · 货主 ${item.customerName || item.customerCode}`, value: item.value, detail: `SKU ${item.sku};货主 ${item.customerName || item.customerCode};实物 ${quantityText(item.physicalQuantity)} 件,可售 ${quantityText(item.availableQuantity)} 件,${ranking.calculationWindowDays}天窗口${ranking.dataStatus === 'COMPLETE' ? '出库' : '已采集出库'} ${quantityText(item.windowOutboundQuantity)} 件,窗口日均 ${quantityText(item.averageDailyOutboundQuantity)} 件,可售 ${quantityText(item.inventoryCoverageDays)} 天` })) }
|
||||
const dataFence = new RequestFence()
|
||||
const runtimeFence = new RequestFence()
|
||||
const trendFence = new RequestFence()
|
||||
|
||||
@@ -5,7 +5,7 @@ import AnalyticsView from '../components/AnalyticsView.vue'
|
||||
import CollectionView from './CollectionView.vue'
|
||||
import type { InventoryMetric, 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, routeState: null as { path: string; query: Record<string, string> } | null, routeQuery: { warehouseCode: 'WH-A' } as Record<string, string>, push: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => ({ api: { ownerDirectory: vi.fn(), 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, routeState: null as { path: string; query: Record<string, string> } | null, routeQuery: { warehouseCode: 'WH-A' } as Record<string, string>, push: vi.fn() }))
|
||||
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: () => mocks.routeState ?? ({ query: mocks.routeQuery }), useRouter: () => ({ push: mocks.push }) }))
|
||||
@@ -36,6 +36,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.canSync = true; mocks.routeState = null
|
||||
mocks.routeQuery = { warehouseCode: 'WH-A' }
|
||||
mocks.api.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }])
|
||||
mocks.api.overview.mockResolvedValue(overview)
|
||||
mocks.api.summary.mockResolvedValue({ context, records: [summary], total: 1 })
|
||||
mocks.api.hot.mockResolvedValue({ context, records: [], total: 0 })
|
||||
@@ -50,6 +51,16 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
|
||||
describe('self-operated actual workbench request state', () => {
|
||||
it('selects customers by name and freezes the submitted owner across pagination', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
|
||||
const field = wrapper.findComponent(queryStub).props('fields').find((f: { key: string }) => f.key === 'customerCode')
|
||||
expect(field.options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
|
||||
await setFilters(wrapper, { customerCode: 'OWNER-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', current: 1 })
|
||||
await setFilters(wrapper, { customerCode: 'OWNER-B' }); wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', current: 2, queryAsOf: context.queryAsOf })
|
||||
})
|
||||
|
||||
it('sorts inventory numeric headers in both directions using the pinned full-result query', async () => {
|
||||
const wrapper=mount(AnalyticsView,{...options,props:{mode:'inventory'}});wrappers.push(wrapper);await flushPromises()
|
||||
const columns={physicalQuantity:'PHYSICAL',availableQuantity:'AVAILABLE',frozenQuantity:'FROZEN',defectivePhysicalQuantity:'DEFECTIVE',transitQuantity:'TRANSIT',windowOutboundQuantity:'OUTBOUND',averageDailyOutboundQuantity:'AVERAGE_DAILY',inventoryCoverageDays:'COVERAGE_DAYS'}
|
||||
@@ -387,14 +398,14 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(toolbar.attributes('preference-key')).toBe(`self-operated-${mode}-query`)
|
||||
expect(wrapper.findAllComponents(queryStub)).toHaveLength(1)
|
||||
})
|
||||
it('uses one SKU filter workspace and no owner query, keeping pagination and attention scoped to the fixed versions', async () => {
|
||||
it('uses one filter workspace with optional customer selection and fixed-version pagination', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
|
||||
expect(mocks.api.owners).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'OwnerPicker' }).exists()).toBe(false)
|
||||
const fields = wrapper.findComponent(queryStub).props('fields')
|
||||
expect(fields.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'sku', 'rangePreset', 'attention', 'threshold', 'sortBy'])
|
||||
expect(fields.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'customerCode', 'rangePreset', 'sku', 'attention', 'threshold', 'sortBy'])
|
||||
expect(fields[0].placeholder).toBe('请选择仓库(必选)')
|
||||
expect(wrapper.findComponent(tableStub).props('columns').slice(0, 3).map((column: { prop?: string; key?: string }) => column.prop || column.key)).toEqual(['sku', 'productName', 'owner'])
|
||||
await setFilters(wrapper, { attention: 'FROZEN_STOCK' })
|
||||
|
||||
@@ -92,7 +92,7 @@ export const reasonLabels: Record<string, string> = {
|
||||
}
|
||||
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: '已发布' }
|
||||
const phases: Record<string, string> = { CUSTOMERS: '获取客户资料', PRODUCTS: '获取商品资料', 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] ?? '待核对' : '—' }
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface QueryContext {
|
||||
export interface Scope { sourceAccount: string; warehouseCode: string }
|
||||
export type DashboardQuery = Omit<BusinessQuery, 'current' | 'size' | 'sku' | 'customerCode'>
|
||||
export interface DashboardKpi { value?: Quantity; knownSubtotal?: Quantity; unit: string; dataStatus: string; unknownSkuCount?: Quantity; reasonCodes: string[]; outboundQuantity?: Quantity; observedQuantity?: Quantity }
|
||||
export interface DashboardItem { customerCode: string; sku: string; productName: string | null; value: Quantity; unit: string; windowOutboundQuantity: Quantity; skuOutboundOrderCount: Quantity; physicalQuantity: Quantity; availableQuantity: Quantity; frozenQuantity: Quantity; defectivePhysicalQuantity: Quantity; averageDailyOutboundQuantity: Quantity; inventoryCoverageDays: Quantity }
|
||||
export interface DashboardItem { customerName?: string | null; customerCode: string; sku: string; productName: string | null; value: Quantity; unit: string; windowOutboundQuantity: Quantity; skuOutboundOrderCount: Quantity; physicalQuantity: Quantity; availableQuantity: Quantity; frozenQuantity: Quantity; defectivePhysicalQuantity: Quantity; averageDailyOutboundQuantity: Quantity; inventoryCoverageDays: Quantity }
|
||||
export interface DashboardRanking { windowStartDate: string; windowEndDateExclusive: string; calculationWindowDays: number; eligibleCount: Quantity; unassessableCount: Quantity; items: DashboardItem[]; dataStatus: string; reasonCodes: string[] }
|
||||
export interface DashboardBucket { bucketCode: CoverageBucket; skuCount: Quantity; knownPhysicalQuantity: Quantity; unknownPhysicalSkuCount: Quantity }
|
||||
export interface Dashboard {
|
||||
@@ -70,6 +70,7 @@ export interface BusinessQuery extends Scope {
|
||||
queryAsOf?: string
|
||||
}
|
||||
export interface SalesSummary {
|
||||
customerName?: string | null
|
||||
sourceAccount: string
|
||||
customerCode: string
|
||||
warehouseCode: string
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { useOwnerDirectory } from './useOwnerDirectory'
|
||||
import type { QueryContext } from './types'
|
||||
const api = vi.hoisted(() => ({ ownerDirectory: vi.fn() }))
|
||||
vi.mock('./api/analytics', () => ({ analyticsApi: api }))
|
||||
const context = { queryAsOf: '2026-10-14T08:00:00Z', salesRunId: '11', inventoryRunId: '12' } as QueryContext
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('uses fixed local context and names while preserving code identity', async () => {
|
||||
api.ownerDirectory.mockResolvedValue([{ customerCode: 'A', customerName: '客户名称' }, { customerCode: 'B', customerName: null }])
|
||||
const directory = useOwnerDirectory(); await directory.load(context, 'W')
|
||||
expect(directory.options.value).toEqual([{ value: 'A', label: '客户名称 (A)' }, { value: 'B', label: 'B' }])
|
||||
expect(api.ownerDirectory.mock.calls[0]![0]).toMatchObject({ warehouseCode: 'W', queryAsOf: context.queryAsOf, salesRunId: '11' })
|
||||
await directory.load(context, 'W'); expect(api.ownerDirectory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('ignores an older response and offers retry after a failed read', async () => {
|
||||
let resolve!: (value: unknown) => void
|
||||
api.ownerDirectory.mockReturnValueOnce(new Promise(r => { resolve = r })).mockResolvedValueOnce([{ customerCode: 'B', customerName: '新名称' }])
|
||||
const directory = useOwnerDirectory(), old = directory.load(context, 'A')
|
||||
await directory.load(context, 'B'); resolve([{ customerCode: 'A', customerName: '旧名称' }]); await old
|
||||
expect(directory.options.value[0]?.value).toBe('B')
|
||||
api.ownerDirectory.mockRejectedValueOnce(new Error('offline')); await directory.load(context, 'C')
|
||||
expect(directory.options.value).toEqual([]); expect(directory.error.value).toContain('读取失败')
|
||||
api.ownerDirectory.mockResolvedValueOnce([]); await directory.load(context, 'C'); expect(directory.error.value).toBe('')
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { analyticsApi } from './api/analytics'
|
||||
import { contextParams } from './state'
|
||||
import type { OwnerOption, QueryContext } from './types'
|
||||
|
||||
export function useOwnerDirectory() {
|
||||
const owners = ref<OwnerOption[]>([]), error = ref('')
|
||||
let loaded = '', generation = 0
|
||||
const options = computed(() => owners.value.map(owner => ({ value: owner.customerCode, label: owner.customerName ? `${owner.customerName} (${owner.customerCode})` : owner.customerCode })))
|
||||
async function load(context: QueryContext, warehouseCode: string, signal?: AbortSignal) {
|
||||
const key = `${warehouseCode}|${context.queryAsOf}`
|
||||
if (key === loaded) return
|
||||
const request = ++generation
|
||||
try {
|
||||
const result = await analyticsApi.ownerDirectory({ sourceAccount: 'lingxing-primary', warehouseCode, ...contextParams(context) }, signal)
|
||||
if (request !== generation || signal?.aborted) return
|
||||
owners.value = result; loaded = key; error.value = ''
|
||||
} catch {
|
||||
if (request !== generation || signal?.aborted) return
|
||||
owners.value = []; loaded = ''; error.value = '客户名称选项读取失败,请刷新重试'
|
||||
}
|
||||
}
|
||||
return { options, error, load }
|
||||
}
|
||||
Reference in New Issue
Block a user