生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #20
@@ -5,6 +5,11 @@ 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('forwards the inventory window, inclusive end and pinned page without renaming them', async () => {
|
||||
const params = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', salesWindowDays: 10 as const, salesWindowEndDate: '2026-10-12', current: 2, size: 20, salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-13T00:00:00Z' }
|
||||
await analyticsApi.inventory(params)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/inventory-coverage', { params, signal: undefined })
|
||||
})
|
||||
it('keeps dashboard business aggregation separate from sync-overview runtime polling', async () => {
|
||||
const params = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' as const, salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-13T00:00:00Z' }
|
||||
await analyticsApi.dashboard(params)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { BusinessPage, BusinessQuery, Dashboard, DashboardQuery, DailyResponse, Metric, Overview, OwnerOption, OwnerOverview, OwnerOverviewQuery, OwnerQuery, RetryCommand, RetryResult, Run, RuntimePage, RuntimeQuery, SalesSummary, Scope, TaskDetail } from '../types'
|
||||
import type { BusinessPage, BusinessQuery, Dashboard, DashboardQuery, DailyResponse, InventoryMetric, InventoryQuery, Metric, Overview, OwnerOption, OwnerOverview, OwnerOverviewQuery, OwnerQuery, RetryCommand, RetryResult, Run, RuntimePage, RuntimeQuery, SalesSummary, Scope, TaskDetail } from '../types'
|
||||
|
||||
const base = '/platform/v1/last-leg/self-operated-analytics'
|
||||
async function get<T>(path: string, params: object, signal?: AbortSignal): Promise<T> {
|
||||
@@ -14,7 +14,7 @@ export const analyticsApi = {
|
||||
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),
|
||||
inventory: (query: InventoryQuery, signal?: AbortSignal) => get<BusinessPage<InventoryMetric>>('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),
|
||||
|
||||
@@ -12,10 +12,10 @@ import AppDialog from '@/shared/components/AppDialog.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, QueryContext, RangePreset, SalesSummary } from '../types'
|
||||
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 { expired, inventoryExpired, parseDashboardDrilldown, type DashboardDrilldown } from '../dashboardNavigation'
|
||||
import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation'
|
||||
|
||||
const props = defineProps<{ mode: 'sales' | 'inventory' }>()
|
||||
const route = useRoute()
|
||||
@@ -31,12 +31,16 @@ function readDrilldown() {
|
||||
catch (cause) { drilldown.value = null; origin.value = null; routeProblem.value = cause instanceof Error ? cause.message : '经营总览下钻条件无效' }
|
||||
}
|
||||
readDrilldown()
|
||||
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 ?? 'TODAY', windowDays: 30, attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customStart: drilldown.value?.startDate ?? '', customEnd: drilldown.value?.endDate ?? '' } }
|
||||
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 ?? 'TODAY', 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: '', customStart: drilldown.value?.startDate ?? '', customEnd: drilldown.value?.endDate ?? '' } }
|
||||
const filters = ref<QueryModel>(initialFilters())
|
||||
const applied = ref({ sku: String(filters.value.sku) })
|
||||
const appliedWindow = ref({ salesWindowDays: 30 as 10 | 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) })
|
||||
const defaultInventoryWindow = computed(() => Number(filters.value.salesWindowDays) === 30 && filters.value.salesWindowEndDate === previousDate(context.value?.anchorDate ?? beijingToday()))
|
||||
const displayedWindowDays = computed(() => context.value?.calculationWindowDays ?? appliedWindow.value.salesWindowDays)
|
||||
const overview = ref<Overview | null>(null)
|
||||
const context = ref<QueryContext | null>(drilldown.value?.context ?? null)
|
||||
const rows = ref<(SalesSummary | Metric)[]>([])
|
||||
const rows = ref<(SalesSummary | Metric | InventoryMetric)[]>([])
|
||||
const daily = ref<DailyResponse | null>(null)
|
||||
const trendOpen = ref(false)
|
||||
const trendLoading = ref(false)
|
||||
@@ -68,9 +72,11 @@ const fields = computed<QueryField[]>(() => {
|
||||
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
|
||||
]
|
||||
if (props.mode === 'inventory') result.push(
|
||||
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ 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: 'salesWindowDays', type: 'select', label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] },
|
||||
{ key: 'salesWindowEndDate', type: 'date', label: '统计截止日(含)', width: 180, props: { clearable: false } },
|
||||
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, ...(defaultInventoryWindow.value ? [{ value: 'NO_OUTBOUND_30D', label: '近30天无出库' }] : []), { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
|
||||
{ key: 'threshold', type: 'input', label: '可售天数阈值', width: 160, placeholder: '可售天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } },
|
||||
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] },
|
||||
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, ...(defaultInventoryWindow.value ? [{ value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] : [])] },
|
||||
)
|
||||
else {
|
||||
result.push({ key: 'analysis', type: 'select', label: '销量类别', width: 170, props: { clearable: false }, options: [{ value: 'summary', label: 'SKU销量' }, { value: 'hot', label: '热销Top100' }, { value: 'slow', label: '滞销Top100' }] })
|
||||
@@ -94,31 +100,32 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ 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: 'windowOutboundQuantity', label: `${displayedWindowDays.value}天窗口出库`, width: 120, slotName: 'windowOutboundQuantity' },
|
||||
{ key: 'averageDailyOutboundQuantity', label: `${displayedWindowDays.value}天日均出库`, 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' },
|
||||
].map(column => ['quantity', 'physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'outboundQuantity30d', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'].includes(String(column.key)) ? { ...column, align: 'right' } : column))
|
||||
].map(column => ['quantity', 'physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'windowOutboundQuantity', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'].includes(String(column.key)) ? { ...column, align: 'right' } : column))
|
||||
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, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) }
|
||||
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 (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, drilldown.value?.inventory)
|
||||
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, appliedWindow.value, drilldown.value?.inventory)
|
||||
return result
|
||||
}
|
||||
async function load(reset = false) {
|
||||
async function load(reset = false, keepContext = false) {
|
||||
if (!permitted.value || !warehouseCode.value || routeProblem.value) return
|
||||
if (origin.value && context.value && expired(context.value)) { error.value = '原图查询已过期,请返回经营总览整体刷新'; return }
|
||||
if (drilldown.value && props.mode === 'inventory' && context.value && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览'; return }
|
||||
@@ -126,7 +133,7 @@ async function load(reset = false) {
|
||||
const problem = validateCustomRange(customStart.value, customEnd.value, context.value?.anchorDate)
|
||||
if (problem) { error.value = problem; return }
|
||||
}
|
||||
if (reset) { if (!origin.value) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
|
||||
if (reset) { if (!origin.value && !keepContext) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
|
||||
trendOpen.value = false
|
||||
dailyFence.cancel()
|
||||
const request = dataFence.begin()
|
||||
@@ -147,7 +154,7 @@ async function load(reset = false) {
|
||||
} 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) {
|
||||
async function showTrend(row: SalesSummary | Metric | InventoryMetric) {
|
||||
trendOpen.value = true
|
||||
trendLoading.value = true
|
||||
daily.value = null
|
||||
@@ -186,17 +193,31 @@ const poller = new RuntimePoller(readRuntime, () => runtimeFence.cancel())
|
||||
watch(trendOpen, open => { if (!open) { dailyFence.cancel(); daily.value = null; trendLoading.value = false; trendError.value = ''; trendErrorDetails.value = null } })
|
||||
function visibility() { poller.setVisible(!document.hidden) }
|
||||
function search() {
|
||||
let nextWindow = appliedWindow.value
|
||||
if (props.mode === 'inventory') {
|
||||
const end = String(filters.value.salesWindowEndDate ?? '')
|
||||
const days = Number(filters.value.salesWindowDays)
|
||||
const problem = validateCustomRange(end, end, previousDate(origin.value?.context.anchorDate ?? beijingToday()))
|
||||
if (problem || ![10, 30].includes(days)) { error.value = problem || '请选择10天或30天销量窗口'; return }
|
||||
nextWindow = { salesWindowDays: days as 10 | 30, salesWindowEndDate: end }
|
||||
if (!defaultInventoryWindow.value) {
|
||||
if (attention.value === 'NO_OUTBOUND_30D') filters.value.attention = 'ALL'
|
||||
if (sortBy.value === 'OUTBOUND_30D_ASC') filters.value.sortBy = 'AVAILABLE_DESC'
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
const keepContext = 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() }
|
||||
current.value = 1
|
||||
void load(true)
|
||||
void load(true, keepContext)
|
||||
}
|
||||
function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } }
|
||||
function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } }
|
||||
function returnOverview() {
|
||||
const saved = origin.value
|
||||
void router.push({ path: '/self-operated/overview', query: saved ? { warehouseCode: warehouseCode.value, context: JSON.stringify(saved.context), overviewReturn: JSON.stringify(saved.returnTo) } : {} })
|
||||
@@ -210,13 +231,14 @@ async function clearDrilldown() {
|
||||
function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) }
|
||||
let applyingRoute = false
|
||||
watch(warehouseCode, () => { if (applyingRoute) return; drilldown.value = null; origin.value = null; trendOpen.value = false; dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) })
|
||||
watch([() => filters.value.salesWindowDays, () => filters.value.salesWindowEndDate], () => { if (!applyingRoute && props.mode === 'inventory' && !drilldown.value) search() })
|
||||
watch([preset, tab, windowDays], () => { if (applyingRoute) return; current.value = 1; if (!summaryMode.value || preset.value !== 'CUSTOM' || customStart.value && customEnd.value) void load() })
|
||||
watch([current, size], () => { if (!applyingRoute) void load() })
|
||||
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
|
||||
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(); 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(); 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
|
||||
await nextTick(); applyingRoute = false; void load()
|
||||
})
|
||||
onActivated(() => { visibility(); poller.start() })
|
||||
@@ -236,10 +258,11 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
|
||||
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p>
|
||||
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p>
|
||||
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
|
||||
<details class="analytics-rules">
|
||||
<summary>统计说明 · 北京时间/件</summary>
|
||||
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
|
||||
<p v-if="mode === 'inventory'">实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</p>
|
||||
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
|
||||
<p v-if="mode === 'inventory'">预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</p>
|
||||
<p v-if="context">查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}</p>
|
||||
<p v-if="context">实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)<template v-if="context.calculationWindowDays != null">;{{ context.calculationWindowDays }} 天</template>。</p>
|
||||
<p v-for="state in (overview?.warehouses ?? []).filter(item => item.warehouseCode === warehouseCode)" :key="state.dataFamily">{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}数据:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。</p>
|
||||
@@ -255,7 +278,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<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>
|
||||
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'windowOutboundQuantity', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'] as const" :key="key" #[key]="{ row }">{{ quantityText(asInventory(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
|
||||
</DataTable>
|
||||
</ListPageLayout>
|
||||
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
| /self-operated/sales | 仓库、SKU、日期/榜单查询及分页 | 当前截图:货主控件分散筛选;本轮冻结为单一紧凑筛选区,仓库明确提示必须选择,SKU为主 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;日期/榜单切换和分页保持版本;待人工截图确认 |
|
||||
| /self-operated/sales | SKU 趋势 | 用户最新截图明确要求详情不再放列表下方;使用1120px居中AppDialog,保留整个日轴、未知断线及跨页SKU入口 | 可关闭/全屏;关闭取消请求、防止迟到回写;待人工截图确认 |
|
||||
| /self-operated/sales | SKU销量、热销、滞销切换 | 本轮并入统一筛选区的销量类别选择;独立完整日窗口 | 热销 7/30 日,滞销 30 日;待人工截图确认 |
|
||||
| /self-operated/inventory | SKU 库存关注、阈值及排序 | 当前截图:库存筛选散落且货主优先;本轮冻结为仓库、SKU、关注、阈值、排序同一筛选区;移除货主经营概览 | SKU、商品名称靠前,所属货主为一辅助列;零与未知保持区分;待人工截图确认 |
|
||||
| /self-operated/inventory | SKU 每日趋势 | 与销量页统一居中趋势弹框;点击SKU仍沿daily接口读取本行货主/SKU | 不新增操作列或菜单;可关闭/全屏;待人工截图确认 |
|
||||
| /self-operated/inventory | SKU 库存关注、阈值及排序 | 当前截图:库存筛选散落且货主优先;仓库、SKU、销量窗口、统计截止日、关注、阈值、排序共用 QueryToolbar;移除货主经营概览 | SKU、商品名称靠前,所属货主为一辅助列;分页与排序保留所选窗口及固定版本;待人工1920×1080、100%截图确认 |
|
||||
| /self-operated/inventory | 10/30天窗口与统计截止日 | 用户确认预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口选择和含当日截止日期放在原筛选区,默认30天、北京时间昨天,可固定历史截止日;切换窗口重置查询及页码 | 窗口出库与日均列名随实际窗口变化;未采齐不以部分销量推算,零销量明确显示“所选窗口无出库”,可售天数只展示服务端结果;10天或历史截止日不提供仅适用默认30天的关注/排序;总览原图下钻仍固定30天;待人工截图确认 |
|
||||
| /self-operated/inventory | 实际统计区间与库存观测说明 | 列表提示直接显示服务端实际销量起止日期、含截止日、窗口天数及销量状态;库存采集起止时间单独展示,明确当前库存来自一轮完整分页观测;详细公式仍在统计说明折叠区 | 截止日仅约束销量窗口,不伪装为该日历史库存;未知与零保持区分;待人工截图确认 |
|
||||
| /self-operated/inventory | SKU 每日趋势 | 与销量页统一居中趋势弹框;点击SKU沿daily接口读取本行货主/SKU,使用当前响应的实际销量区间,排他上界减一天作为含当日结束参数 | 不新增操作列或菜单;保留版本、可关闭/全屏;待人工截图确认 |
|
||||
| 销量/库存 | 所属货主及同SKU趋势 | 删除独立货主选择、货主翻页和经营概览,不请求owners/ownerOverview;所属货主名称优先、编码可辅助 | 同SKU跨货主保持分行,趋势仍传本行customerCode;不改后端统计键 |
|
||||
| 销量/库存 | 规则说明及数据状态 | 当前截图:来源账号、技术时区和长段规则占主页面;本轮冻结为短说明和原生details折叠 | 主文案仅北京时间/件;未选仓库、暂无数据、查询失败分别显示;不新增浮层 |
|
||||
| /self-operated/collection | 每日库存观测积累 | 本轮用户采集页截图确认长串缺口日期占用正文,要求优化;近30日样本数量保留主表,积累起点在配置弹框,缺口日期在弹框内折叠 | 服务端结果直出,不计算周转率;待人工截图确认 |
|
||||
| /self-operated/collection | 运行概览、逻辑任务列表与分页 | 本轮截图确认各仓20字段描述表重复展开;用户进一步确认“展示列表,配置项直接做成弹框”。主表集中仓库/类型/开关/执行/发布/覆盖/时效,各行查看配置打开只读AppDialog,保留预算、验证和覆盖字段 | 操作列首列;不新增配置写接口;关闭采集与已有结果分别表达,合成测试仓明确提示;待人工截图确认 |
|
||||
| /self-operated/collection | 任务详情和历史尝试 | 用户最新要求检查三页列表下方详情;改为1200px居中任务详情AppDialog,包含尝试历史 | 可关闭/全屏;保留任务业务号和分页;待人工截图确认 |
|
||||
| /self-operated/collection | 本仓/共享上游执行时间与校验摘要 | 2026-10-13 用户截图:本仓尝试0次、共享上游执行中。本仓明确说明等待来源且尚未产生执行时间;上游单独显示接口已有的 latestAttempt 时间/心跳,质量未生成时明确说明,不冒充本仓尝试 | WAITING_SOURCE 显示等待来源数据并维持活跃轮询;真实0与未知区分;前端修改,不发布后端;待人工截图确认 |
|
||||
| /self-operated/collection | 恢复任务确认 | 原因表单与显式确认步骤移入任务弹框,RETRY单一命令不变 | 提交中锁定关闭;独立sync权限、资格及幂等不变;待人工截图确认 |
|
||||
| 三页 | 新增/编辑/审核/删除/文件预览 | 不在一期页面业务范围 | 不创建操作入口 |
|
||||
| 三页 | 公共搜索栏样式 | 用户提供异常订单公共搜索栏截图并要求自营统一;QueryToolbar放入集成DataTable的filters插槽 | 复用公共灰底筛选带、控件及查询/重置按钮,启用筛选布局设置;不复制CSS,不改查询和权限语义;待人工截图确认 |
|
||||
|
||||
@@ -3,7 +3,7 @@ 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'
|
||||
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() }))
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks.api }))
|
||||
@@ -14,7 +14,7 @@ const context: QueryContext = { salesRunId: '11', inventoryRunId: '0', salesScop
|
||||
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 metric: InventoryMetric = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', customerCode: 'OWNER-A', sku: 'SKU-A', productName: '商品A', quantityUnit: 'PCS', skuOutboundOrderCount: null, physicalQuantity: '128', availableQuantity: '100', frozenQuantity: '20', defectivePhysicalQuantity: '8', transitQuantity: '30', windowOutboundQuantity: 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: [] }
|
||||
@@ -23,7 +23,7 @@ const queryStub = defineComponent({ props: ['modelValue', 'fields'], emits: ['up
|
||||
h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库'),
|
||||
h('button', { onClick: () => emit('search') }, '查询'), h('button', { onClick: () => emit('reset') }, '重置'),
|
||||
]) } })
|
||||
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), slots.filters?.(), slots.notices?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row }), slots.dailySamples?.({ row }), slots.configuration?.({ row })]))]) } })
|
||||
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), slots.filters?.(), slots.notices?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.windowOutboundQuantity?.({ row }), slots.inventoryCoverageDays?.({ row }), slots.status?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row }), slots.dailySamples?.({ row }), slots.configuration?.({ 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 loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } })
|
||||
@@ -50,6 +50,43 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
|
||||
describe('self-operated actual workbench request state', () => {
|
||||
it('defaults to Beijing yesterday and excludes rolling 30-day predicates for a historical cutoff', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-12T17:00:00Z'))
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
|
||||
expect(mocks.api.inventory.mock.calls[0]?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-12' })
|
||||
await setFilters(wrapper, { salesWindowEndDate: '2026-10-10', attention: 'NO_OUTBOUND_30D', sortBy: 'OUTBOUND_30D_ASC' })
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-10', attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
|
||||
})
|
||||
it('selects a complete ten-day window, preserves it with pinned pagination, and uses its dates for daily trend', async () => {
|
||||
const ten = { ...context, anchorDate: '2026-10-13', rangeStartDate: '2026-10-03', rangeEndDateExclusive: '2026-10-13', calculationWindowDays: 10 }
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
|
||||
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
|
||||
mocks.api.inventory.mockResolvedValue({ context: ten, records: [{ ...metric, windowOutboundQuantity: 50, averageDailyOutboundQuantity: 5, inventoryCoverageDays: 20 }], total: 45 })
|
||||
await setFilters(wrapper, { salesWindowDays: 10, salesWindowEndDate: '2026-10-12' })
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, salesWindowEndDate: '2026-10-12', current: 1 })
|
||||
expect(wrapper.findComponent(tableStub).props('columns').find((c: { key: string }) => c.key === 'windowOutboundQuantity').label).toBe('10天窗口出库')
|
||||
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, salesWindowEndDate: '2026-10-12', current: 2, queryAsOf: ten.queryAsOf })
|
||||
await findButton(wrapper, 'SKU-A').trigger('click'); await flushPromises()
|
||||
expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ startDate: '2026-10-03', endDate: '2026-10-12' })
|
||||
expect(wrapper.text()).toContain('窗口未采齐时不以部分销量推算')
|
||||
await setFilters(wrapper, { sortBy: 'PHYSICAL_DESC' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, salesWindowEndDate: '2026-10-12', sortBy: 'PHYSICAL_DESC', queryAsOf: ten.queryAsOf, current: 1 })
|
||||
})
|
||||
it('clears incompatible 30-day predicates and never calculates missing inventory coverage locally', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
|
||||
await setFilters(wrapper, { attention: 'NO_OUTBOUND_30D', sortBy: 'OUTBOUND_30D_ASC', salesWindowDays: 10 })
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
|
||||
expect(wrapper.findComponent(tableStub).props('data')[0].inventoryCoverageDays).toBeNull()
|
||||
mocks.api.inventory.mockResolvedValue({ context, records: [{ ...metric, windowOutboundQuantity: 0, status: 'NO_OUTBOUND_IN_WINDOW' }], total: 1 })
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('所选窗口无出库')
|
||||
expect(wrapper.findComponent(tableStub).props('data')[0].inventoryCoverageDays).toBeNull()
|
||||
const calls = mocks.api.inventory.mock.calls.length
|
||||
await setFilters(wrapper, { salesWindowEndDate: '2099-10-12' })
|
||||
expect(mocks.api.inventory).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('resumes a cached collection page with the new deep-linked warehouse and stops hidden polling and late requests', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const route = reactive({ path: '/self-operated/collection', query: { warehouseCode: 'WH-A' } })
|
||||
@@ -170,7 +207,7 @@ describe('self-operated actual workbench request state', () => {
|
||||
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', 'attention', 'threshold', 'sortBy'])
|
||||
expect(fields.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'sku', 'salesWindowDays', 'salesWindowEndDate', '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' })
|
||||
|
||||
@@ -47,7 +47,7 @@ export function beijingTime(value: string | null | undefined) {
|
||||
}
|
||||
export const statusLabels: Record<string, string> = {
|
||||
COMPLETE: '完整', PARTIAL: '未完整', UNKNOWN: '未知', QUEUED: '排队中', RUNNING: '执行中',
|
||||
WAITING: '等待中', WAITING_UPSTREAM: '等待上游', RETRY_WAIT: '待重试', YIELDED: '预算等待',
|
||||
WAITING: '等待中', WAITING_UPSTREAM: '等待上游', WAITING_SOURCE: '等待来源数据', RETRY_WAIT: '待重试', YIELDED: '预算等待',
|
||||
FAILED: '失败', DEAD: '待处理', SUCCEEDED: '执行成功', ROUTED: '账号共享采集完成',
|
||||
SAMPLE_DONE: '测试样本结束', PUBLISHED: '已发布', REUSED: '沿用已有结果', NOT_PUBLISHED: '未发布',
|
||||
PENDING: '待处理', BLOCKED: '修订阻断', OFF: '关闭', VALIDATION: '验证采样', BUSINESS: '正式采集',
|
||||
@@ -61,17 +61,17 @@ export const statusLabels: Record<string, string> = {
|
||||
UNCOVERED: '覆盖不足', INCOMPLETE: '未完整',
|
||||
NO_AVAILABLE_INVENTORY: '无可售库存', UNIT_UNKNOWN_OR_CONFLICT: '数量单位待核对',
|
||||
SALES_UNTRUSTED: '销量待核对', COVERAGE_INSUFFICIENT: '覆盖不足', OBSERVATION_INSUFFICIENT: '观察期不足',
|
||||
SALES_UNKNOWN: '销量未知', NO_OUTBOUND_30_DAYS: '近30天无出库', INVENTORY_INVALID: '库存异常',
|
||||
SALES_UNKNOWN: '销量未知', NO_OUTBOUND_IN_WINDOW: '所选窗口无出库', NO_OUTBOUND_30_DAYS: '近30天无出库', INVENTORY_INVALID: '库存异常',
|
||||
INVENTORY_STALE: '库存过期', INVENTORY_UNKNOWN: '库存未知',
|
||||
}
|
||||
export const reasonLabels: Record<string, string> = {
|
||||
UNASSESSABLE_SKUS: '存在待评估SKU', INVENTORY_UNAVAILABLE: '库存资料不可用', SALES_UNAVAILABLE: '销量资料不可用',
|
||||
PHYSICAL_INVENTORY_UNKNOWN: '实物库存待核验', AVAILABLE_INVENTORY_UNKNOWN: '可售库存待核验',
|
||||
SALES_QUANTITY_OR_UNIT_INVALID: '出库数量或单位异常', NO_ELIGIBLE_SKUS: '暂无符合条件的SKU',
|
||||
NO_OUTBOUND_30D: '近30天无出库', COVERAGE_GT_THRESHOLD: '可售天数超过本次阈值',
|
||||
NO_OUTBOUND_IN_WINDOW: '所选窗口无出库', 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日销量不可用',
|
||||
METRIC_DEPENDENCY_UNKNOWN: '该指标依赖资料未知', SALES_WINDOW_UNAVAILABLE: '所选窗口完整销量不可用',
|
||||
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: '库存快照过期',
|
||||
@@ -118,7 +118,7 @@ export class RetryIntent {
|
||||
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))
|
||||
return tasks.some(task => ['QUEUED', 'RUNNING', 'WAITING', 'WAITING_UPSTREAM', 'WAITING_SOURCE', 'RETRY_WAIT', 'YIELDED'].includes(task.taskStatus))
|
||||
}
|
||||
export class RuntimePoller {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -123,6 +123,8 @@ export interface Metric {
|
||||
attentionReasons?: string[]
|
||||
quantityStatuses?: Record<string, string>
|
||||
}
|
||||
export interface InventoryQuery extends BusinessQuery { salesWindowDays?: 10 | 30; salesWindowEndDate?: string }
|
||||
export type InventoryMetric = Omit<Metric, 'outboundQuantity' | 'outboundQuantity30d'> & { windowOutboundQuantity: Quantity }
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user