feat(self-operated): unify completed-day ranges and analytics sorting
This commit is contained in:
@@ -17,6 +17,9 @@ import type { BusinessQuery, DailyResponse, InventoryAttention, InventoryMetric,
|
||||
import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beijingTime, reasonsText, quantityText, sameContext, shownQuantity, validateCustomRange } from '../state'
|
||||
import SkuTrend from './SkuTrend.vue'
|
||||
import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation'
|
||||
import { overviewRange, overviewRangeOptions } from '../overviewRange'
|
||||
import { inventorySortColumns, inventorySortOptions } from '../inventorySorting'
|
||||
import { turnoverNumber as dailyAverageText } from '../turnoverPresentation'
|
||||
|
||||
const emit = defineEmits<{ warehouseSelected: [code: string] }>()
|
||||
const props = defineProps<{ mode: 'sales' | 'inventory' }>()
|
||||
@@ -34,10 +37,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 ?? '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: '', customRange: drilldown.value?.startDate && drilldown.value?.endDate ? [drilldown.value.startDate, drilldown.value.endDate] : [] } }
|
||||
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] : [] } }
|
||||
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 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)
|
||||
const context = ref<QueryContext | null>(drilldown.value?.context ?? null)
|
||||
@@ -51,6 +54,7 @@ const attention = computed(() => (filters.value.attention || 'ALL') as Inventory
|
||||
const sortBy = computed(() => (filters.value.sortBy || 'AVAILABLE_DESC') as InventorySort)
|
||||
const thresholdInput = computed(() => String(filters.value.threshold ?? ''))
|
||||
const appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' })
|
||||
const displayedInventorySort = ref<InventorySort | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const errorDetails = ref<ApiErrorDetails | null>(null)
|
||||
@@ -58,14 +62,26 @@ const runtimeError = ref('')
|
||||
const newData = ref(false)
|
||||
const newDay = ref(false)
|
||||
const current = ref(1)
|
||||
let changingSortPage = false
|
||||
async function setSortPage(page: number) {
|
||||
changingSortPage = true
|
||||
current.value = page
|
||||
await nextTick()
|
||||
changingSortPage = false
|
||||
}
|
||||
const size = ref(20)
|
||||
const total = ref(0)
|
||||
const preset = computed(() => (filters.value.rangePreset || 'TODAY') as RangePreset)
|
||||
type SalesPreset = RangePreset | 'LAST_3_DAYS' | 'LAST_7_DAYS' | 'LAST_15_DAYS' | 'LAST_30_DAYS'
|
||||
const preset = computed(() => (filters.value.rangePreset || 'LAST_30_DAYS') as SalesPreset)
|
||||
const customRange = computed(() => Array.isArray(filters.value.customRange) ? filters.value.customRange : [])
|
||||
const customStart = computed(() => String(customRange.value[0] ?? ''))
|
||||
const customEnd = computed(() => String(customRange.value[1] ?? ''))
|
||||
type SalesRange = { rangePreset: RangePreset; startDate?: string; endDate?: string }
|
||||
function draftSalesRange(): SalesRange { return { rangePreset: preset.value, ...(preset.value === 'CUSTOM' ? { startDate: customStart.value, endDate: customEnd.value } : {}) } }
|
||||
function draftSalesRange(): SalesRange {
|
||||
try { return overviewRange(preset.value, origin.value?.context.anchorDate ?? beijingToday(), customStart.value, customEnd.value) }
|
||||
catch { return { rangePreset: 'CUSTOM', startDate: customStart.value, endDate: customEnd.value } }
|
||||
|
||||
}
|
||||
const appliedSalesRange = ref<SalesRange | null>(draftSalesRange())
|
||||
const salesRangePending = computed(() => props.mode === 'sales' && tab.value === 'summary' && JSON.stringify(draftSalesRange()) !== JSON.stringify(appliedSalesRange.value))
|
||||
const tab = computed(() => (filters.value.analysis || 'summary') as 'summary' | 'hot' | 'slow')
|
||||
@@ -81,24 +97,26 @@ const fields = computed<QueryField[]>(() => {
|
||||
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
|
||||
]
|
||||
if (props.mode === 'inventory') result.push(
|
||||
...(!drilldown.value ? [{ key: 'salesWindowDays', type: 'select' as const, label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] },
|
||||
{ key: 'salesWindowEndDate', type: 'date' as const, label: '统计截止日(含)', width: 180, props: { clearable: false } }] : [{ key: 'customRange', type: 'daterange' as const, label: '原图统计区间', width: 300 }]),
|
||||
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
|
||||
{ key: 'threshold', type: 'input', label: '可售天数阈值', width: 180, 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_ASC', label: '区间出库从低到高' }] },
|
||||
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: inventorySortOptions },
|
||||
)
|
||||
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' }] })
|
||||
if (tab.value === 'summary') {
|
||||
result.push({ key: 'rangePreset', type: 'select', label: '销量日期', width: 150, props: { clearable: false }, options: [{ value: 'TODAY', label: '今日' }, { value: 'LAST_3_DAYS', label: '近3天' }, { value: 'THIS_WEEK', label: '本周' }, { value: 'LAST_15_DAYS', label: '近15天' }, { value: 'THIS_MONTH', label: '本月' }, { value: 'CUSTOM', label: '自定义' }] })
|
||||
if (preset.value === 'CUSTOM') result.push({ key: 'customRange', type: 'daterange', label: '销量日期范围', width: 300, props: { 'start-placeholder': '开始日期', 'end-placeholder': '结束日期', 'range-separator': '至' } })
|
||||
} else if (tab.value === 'hot') {
|
||||
if (drilldown.value?.analysis === 'hot') result.push({ key: 'customRange', type: 'daterange', label: '热销日期范围', width: 300 })
|
||||
else result.push({ key: 'windowDays', type: 'select', label: '热销窗口', width: 180, props: { clearable: false }, options: [{ value: 7, label: '近7个完整自然日' }, { value: 30, label: '近30个完整自然日' }] })
|
||||
}
|
||||
}
|
||||
result.splice(2, 0, { key: 'rangePreset', type: 'select', label: '统计时间范围', width: 180, props: { clearable: false }, options: overviewRangeOptions })
|
||||
if (preset.value === 'CUSTOM') result.splice(3, 0, { key: 'customRange', type: 'daterange', label: '日期范围', width: 300, props: { disabledDate: (date: Date) => date.toLocaleDateString('en-CA') >= (origin.value?.context.anchorDate ?? beijingToday()) } })
|
||||
|
||||
return drilldown.value ? result.map(field => field.key === 'warehouseCode' ? field : { ...field, props: { ...field.props, disabled: true } }) : result
|
||||
})
|
||||
const salesSort = ref<'QUANTITY_ASC' | 'QUANTITY_DESC'>('QUANTITY_DESC')
|
||||
async function sortSalesQuantity() {
|
||||
if (loading.value || !summaryMode.value) return
|
||||
const previous = salesSort.value, previousPage = current.value
|
||||
salesSort.value = previous === 'QUANTITY_DESC' ? 'QUANTITY_ASC' : 'QUANTITY_DESC'
|
||||
await setSortPage(1)
|
||||
if (await load() === false) { salesSort.value = previous; await setSortPage(previousPage) }
|
||||
}
|
||||
const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
...(summaryMode.value ? [{ key: 'actions', label: '操作', width: 110, slotName: 'actions', fixed: 'left' }] : []),
|
||||
@@ -117,19 +135,51 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ 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 || tab.value === 'hot' ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity' }]),
|
||||
] : [{ key: 'quantity', label: summaryMode.value || tab.value === 'hot' ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity', ...(summaryMode.value ? { headerSlotName: 'salesSortHeader' } : {}) }]),
|
||||
{ key: 'status', label: '数据状态', minWidth: 130, slotName: 'status' },
|
||||
{ key: 'reason', label: '说明', minWidth: 220, slotName: 'reason' },
|
||||
].map(column => ['quantity', 'physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'windowOutboundQuantity', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'].includes(String(column.key)) ? { ...column, align: 'right' } : column))
|
||||
].map(column => ['quantity', ...Object.keys(inventorySortColumns)].includes(String(column.key)) ? {
|
||||
...column, align: 'right',
|
||||
...(props.mode === 'inventory' && inventorySortColumns[String(column.key)] ? { prop: String(column.key), headerSlotName: 'inventorySortHeader', width: Math.max(Number(column.width ?? 0), 140) } : {}),
|
||||
} : column))
|
||||
function inventorySortDirection(prop: string) {
|
||||
const metric = inventorySortColumns[prop]
|
||||
return displayedInventorySort.value === `${metric}_ASC` ? 'ASC' : displayedInventorySort.value === `${metric}_DESC` ? 'DESC' : undefined
|
||||
}
|
||||
async function sortInventoryColumn(prop: string) {
|
||||
const metric = inventorySortColumns[prop]
|
||||
if (!metric || props.mode !== 'inventory' || loading.value) return
|
||||
const previousSort = appliedInventory.value.sortBy, previousPage = current.value
|
||||
const nextSort: InventorySort = `${metric}_${inventorySortDirection(prop) === 'DESC' ? 'ASC' : 'DESC'}`
|
||||
appliedInventory.value = { ...appliedInventory.value, sortBy: nextSort }
|
||||
filters.value = { ...filters.value, sortBy: nextSort }
|
||||
await setSortPage(1)
|
||||
if (await load() === false) {
|
||||
appliedInventory.value = { ...appliedInventory.value, sortBy: previousSort }
|
||||
filters.value = { ...filters.value, sortBy: previousSort }
|
||||
await setSortPage(previousPage)
|
||||
}
|
||||
}
|
||||
function inventorySalesGap(row: InventoryMetric) {
|
||||
const status = row.quantityStatuses?.windowOutboundQuantity
|
||||
if (status === 'UNIT_UNKNOWN_OR_CONFLICT' || row.status === 'UNIT_UNKNOWN_OR_CONFLICT') return '数量单位待核验'
|
||||
if (status === 'SALES_UNTRUSTED' || context.value?.rebuildRequired) return '新采集数据待发布'
|
||||
if (status === 'COVERAGE_INSUFFICIENT' || row.salesCoverage === 'COVERAGE_INSUFFICIENT') return '销量窗口未采齐'
|
||||
return '暂无完整窗口数据'
|
||||
}
|
||||
function inventoryReasonText(row: InventoryMetric) {
|
||||
const reasons = row.reasonCodes?.filter(reason => reason !== 'OBSERVATION_INSUFFICIENT') ?? []
|
||||
const observationNote = row.observationEligible === false ? '商品历史观察不足(仅提示,不影响完整窗口统计)' : ''
|
||||
return [reasons.length ? reasonsText(reasons) : '', observationNote].filter(Boolean).join('、') || '—'
|
||||
}
|
||||
function inventoryMissingReason(row: InventoryMetric, coverage = false) {
|
||||
if (coverage && row.status === 'NO_OUTBOUND_IN_WINDOW') return '所选时段无出库'
|
||||
if (coverage && row.status?.startsWith('INVENTORY_')) return '当前可售库存待核验'
|
||||
return inventorySalesGap(row)
|
||||
}
|
||||
const dataFence = new RequestFence()
|
||||
const dailyFence = new RequestFence()
|
||||
const runtimeFence = new RequestFence()
|
||||
function showRecentCompleteDays() {
|
||||
const end = previousDate(context.value?.anchorDate ?? beijingToday())
|
||||
const start = new Date(Date.parse(`${end}T00:00:00Z`) - 29 * 86400000).toISOString().slice(0, 10)
|
||||
filters.value = { ...filters.value, rangePreset: 'CUSTOM', customRange: [start, end] }
|
||||
void search()
|
||||
}
|
||||
function asSummary(row: unknown) { return row as SalesSummary }
|
||||
function asMetric(row: unknown) { return row as Metric }
|
||||
function asInventory(row: unknown) { return row as InventoryMetric }
|
||||
@@ -137,13 +187,14 @@ 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) {
|
||||
Object.assign(result, appliedSalesRange.value)
|
||||
Object.assign(result, appliedSalesRange.value, { salesSort: salesSort.value })
|
||||
} else if (props.mode === 'sales' && tab.value === 'hot') {
|
||||
if (drilldown.value?.analysis === 'hot') Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
|
||||
else result.windowDays = windowDays.value
|
||||
Object.assign(result, appliedSalesRange.value)
|
||||
}
|
||||
if (props.mode === 'sales' && tab.value === 'slow') Object.assign(result, appliedSalesRange.value)
|
||||
if (props.mode === 'inventory') {
|
||||
Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
|
||||
result.sortBy = appliedInventory.value.sortBy
|
||||
if (drilldown.value) Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
|
||||
else Object.assign(result, appliedWindow.value)
|
||||
}
|
||||
@@ -151,8 +202,8 @@ function query(): InventoryQuery {
|
||||
}
|
||||
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 }
|
||||
if (origin.value && context.value && expired(context.value)) { error.value = '原图查询已过期,请返回经营总览整体刷新'; return false }
|
||||
if (drilldown.value && props.mode === 'inventory' && context.value && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览'; return false }
|
||||
if (summaryMode.value && !appliedSalesRange.value) return
|
||||
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
|
||||
@@ -172,7 +223,9 @@ async function load(reset = false, keepContext = false) {
|
||||
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 } }
|
||||
if (props.mode === 'inventory') displayedInventorySort.value = params.sortBy ?? 'AVAILABLE_DESC'
|
||||
return true
|
||||
} catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '查询失败,请重新查询'); error.value = errorDetails.value.message; return false } }
|
||||
finally { if (request.current()) loading.value = false }
|
||||
}
|
||||
async function showTrend(row: SalesSummary | Metric | InventoryMetric) {
|
||||
@@ -216,26 +269,24 @@ watch(trendOpen, open => { if (!open) { dailyFence.cancel(); daily.value = null;
|
||||
function visibility() { poller.setVisible(!document.hidden) }
|
||||
function clearSalesResults() { dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; daily.value = null; rows.value = []; total.value = 0; loading.value = false; error.value = ''; errorDetails.value = null }
|
||||
function applySalesRange(anchor?: string) {
|
||||
const problem = preset.value === 'CUSTOM' ? validateCustomRange(customStart.value, customEnd.value, anchor) : ''
|
||||
if (problem) { clearSalesResults(); appliedSalesRange.value = null; error.value = problem; return false }
|
||||
appliedSalesRange.value = draftSalesRange(); return true
|
||||
try { appliedSalesRange.value = overviewRange(preset.value, anchor ?? beijingToday(), customStart.value, customEnd.value); return true }
|
||||
catch (cause) { clearSalesResults(); appliedSalesRange.value = null; error.value = (cause as Error).message; return false }
|
||||
}
|
||||
|
||||
function search() {
|
||||
if (summaryMode.value && !applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
|
||||
if (!applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
|
||||
let nextWindow = appliedWindow.value
|
||||
if (props.mode === 'inventory' && !drilldown.value) {
|
||||
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 }
|
||||
const range = appliedSalesRange.value!
|
||||
nextWindow = { salesWindowDays: Math.round((Date.parse(range.endDate!) - Date.parse(range.startDate!)) / 86400000) + 1, salesWindowEndDate: range.endDate! }
|
||||
}
|
||||
|
||||
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
|
||||
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() }
|
||||
@@ -243,6 +294,7 @@ function search() {
|
||||
void load(true, keepContext)
|
||||
}
|
||||
async function reset() {
|
||||
salesSort.value = 'QUANTITY_DESC'
|
||||
applyingRoute = true
|
||||
drilldown.value = null; origin.value = null
|
||||
filters.value = { ...initialFilters(), warehouseCode: warehouseCode.value || selectedWarehouse || defaultWarehouse(), sku: '' }
|
||||
@@ -255,7 +307,7 @@ function returnOverview() {
|
||||
}
|
||||
async function clearDrilldown() {
|
||||
drilldown.value = null
|
||||
filters.value = { ...filters.value, sku: '', analysis: 'summary', rangePreset: 'TODAY', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customRange: [] }
|
||||
filters.value = { ...filters.value, sku: '', 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
|
||||
await nextTick(); void load()
|
||||
@@ -263,15 +315,16 @@ 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([customStart, customEnd], () => { if (applyingRoute || !summaryMode.value) return; error.value = ''; errorDetails.value = null })
|
||||
watch([preset, tab, windowDays], () => {
|
||||
|
||||
watch([customStart, customEnd], () => { if (applyingRoute) return; error.value = ''; errorDetails.value = null })
|
||||
watch([preset, tab], () => {
|
||||
if (applyingRoute) return
|
||||
if (props.mode === 'inventory') { search(); return }
|
||||
clearSalesResults()
|
||||
if (summaryMode.value && !applySalesRange(context.value?.anchorDate ?? beijingToday())) return
|
||||
if (!applySalesRange(origin.value?.context.anchorDate ?? context.value?.anchorDate ?? beijingToday())) return
|
||||
current.value = 1; void load()
|
||||
})
|
||||
watch([current, size], () => { if (!applyingRoute) void load() })
|
||||
watch([current, size], () => { if (!applyingRoute && !changingSortPage) 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
|
||||
@@ -292,13 +345,13 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<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 #notices>
|
||||
<p v-if="summaryMode && !drilldown && appliedSalesRange?.rangePreset === 'TODAY' && rows.length && rows.every(row => shownQuantity(asSummary(row)) == null)" class="analytics-note" role="status">当前仅查询今日,所列商品暂无可用的今日数量;历史采集量不计入今日。<el-button link type="primary" @click="showRecentCompleteDays">查看近30个完整日</el-button></p>
|
||||
<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>
|
||||
<div v-if="origin" class="density-actions"><span>{{ drilldown?.summary ?? '来自经营总览 · 来源筛选已清除,保留原有效版本' }}</span><el-button v-if="drilldown" @click="clearDrilldown">清除来源条件</el-button><el-button @click="returnOverview">返回经营总览</el-button></div>
|
||||
<p v-if="runtimeError" class="analytics-note" 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>
|
||||
<p v-if="salesRangePending" class="analytics-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p>
|
||||
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p>
|
||||
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据可用。' }}<template v-if="origin">请返回经营总览刷新原图。</template><template v-else>当前列表仍使用上次查询版本。<el-button :disabled="loading" @click="search">更新数据</el-button></template></p>
|
||||
|
||||
</template>
|
||||
<template #toolbar-actions><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>
|
||||
@@ -309,7 +362,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<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>
|
||||
<div class="analytics-rules">
|
||||
|
||||
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
|
||||
<p>{{ summaryMode ? '最近3/7/15/30天均截至昨日(北京时间);自定义按所选起止日期查询。完整区间显示出库量,未完整区间显示已采集量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一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>
|
||||
@@ -318,6 +371,16 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
<template #salesSortHeader="{ column }">
|
||||
<button type="button" class="inventory-sort" data-column="salesQuantity" :disabled="loading" :aria-label="`${column.label}:当前${salesSort === 'QUANTITY_ASC' ? '升序' : '降序'},点击切换排序`" @click.stop="sortSalesQuantity">
|
||||
{{ column.label }}<span class="inventory-sort-arrows" aria-hidden="true"><span :class="{ active: salesSort === 'QUANTITY_ASC' }">▲</span><span :class="{ active: salesSort === 'QUANTITY_DESC' }">▼</span></span>
|
||||
</button>
|
||||
</template>
|
||||
<template #inventorySortHeader="{ column }">
|
||||
<button type="button" class="inventory-sort" :data-column="column.property" :disabled="loading" :aria-label="`${column.label}:${inventorySortDirection(column.property) === 'ASC' ? '当前升序,' : inventorySortDirection(column.property) === 'DESC' ? '当前降序,' : ''}点击${inventorySortDirection(column.property) === 'DESC' ? '升序' : '降序'}排列`" @click.stop="sortInventoryColumn(column.property)">
|
||||
{{ column.label }}<span class="inventory-sort-arrows" aria-hidden="true"><span :class="{ active: inventorySortDirection(column.property) === 'ASC' }">▲</span><span :class="{ active: inventorySortDirection(column.property) === 'DESC' }">▼</span></span>
|
||||
</button>
|
||||
</template>
|
||||
<template #actions="{ row }"><el-button class="density-action" @click="showTrend(asSummary(row))">每日趋势</el-button></template>
|
||||
<template #sku="{ row }"><el-button class="density-action" :aria-label="`查看 ${asMetric(row).customerCode} ${asMetric(row).sku} 每日趋势`" @click="showTrend(asMetric(row))">{{ asMetric(row).sku }}</el-button></template>
|
||||
<template #owner="{ row }"><span>{{ asMetric(row).customerName || asMetric(row).customerCode || '—' }}</span><small v-if="asMetric(row).customerName && asMetric(row).customerCode" class="analytics-owner-code">{{ asMetric(row).customerCode }}</small></template>
|
||||
@@ -326,8 +389,15 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<template #attention="{ row }">{{ reasonsText(asMetric(row).attentionReasons) }}</template>
|
||||
<template #quantity="{ row }">{{ quantityText(summaryMode ? shownQuantity(asSummary(row)) : asMetric(row).outboundQuantity) }} {{ summaryMode && shownQuantity(asSummary(row)) == null ? '(暂无可用数据)' : (summaryMode ? asSummary(row).dataStatus !== 'COMPLETE' : tab === 'hot' && asMetric(row).salesCoverage !== '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', '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>
|
||||
<template #reason="{ row }">{{ mode === 'inventory' ? inventoryReasonText(asInventory(row)) : reasonsText(summaryMode ? asSummary(row).reasonCodes : asMetric(row).reasonCodes) }}</template>
|
||||
<template #windowOutboundQuantity="{ row }">
|
||||
<template v-if="asInventory(row).windowOutboundQuantity != null">{{ quantityText(asInventory(row).windowOutboundQuantity) }}</template>
|
||||
<template v-else><span>{{ asInventory(row).observedWindowOutboundQuantity != null ? `已采集 ${quantityText(asInventory(row).observedWindowOutboundQuantity)}` : '—' }}</span><small class="inventory-missing" :title="asInventory(row).earliestReliableEvidenceAt ? `商品最早可靠记录(北京时间):${beijingTime(asInventory(row).earliestReliableEvidenceAt)}` : undefined">{{ inventorySalesGap(asInventory(row)) }}</small></template>
|
||||
</template>
|
||||
<template v-for="key in ['averageDailyOutboundQuantity', 'inventoryCoverageDays'] as const" :key="key" #[key]="{ row }">
|
||||
{{ key === 'averageDailyOutboundQuantity' ? dailyAverageText(asInventory(row)[key]) : quantityText(asInventory(row)[key]) }}<small v-if="asInventory(row)[key] == null" class="inventory-missing">{{ inventoryMissingReason(asInventory(row), key === 'inventoryCoverageDays') }}</small>
|
||||
</template>
|
||||
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity'] 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>
|
||||
@@ -342,6 +412,12 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.inventory-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
||||
.inventory-sort:disabled { cursor: wait; }
|
||||
.inventory-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; }
|
||||
.inventory-sort-arrows { display: inline-flex; flex-direction: column; color: var(--el-text-color-placeholder); font-size: 9px; line-height: 10px; }
|
||||
.inventory-sort-arrows .active { color: var(--el-color-primary); }
|
||||
.inventory-missing { display: block; color: var(--el-text-color-secondary); }
|
||||
.analytics-note, .analytics-rules { margin: 0; font-size: var(--admin-font-caption); }
|
||||
.analytics-information { max-height: 65vh; overflow-y: auto; overflow-wrap: anywhere; }
|
||||
.analytics-information > p { margin: var(--admin-space-2) 0; }
|
||||
|
||||
@@ -13,7 +13,7 @@ const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { r
|
||||
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] }
|
||||
const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 }
|
||||
const row: TurnoverRow = { customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutbound30d: risk, slowCoverage: risk, unassessableSkuCount: 0 }
|
||||
const row: TurnoverRow = { customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutboundInWindow: risk, slowCoverage: risk, unassessableSkuCount: 0 }
|
||||
function response(q: TurnoverQuery): TurnoverResponse {
|
||||
return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
|
||||
}
|
||||
@@ -42,7 +42,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
expect(header.attributes('aria-label')).toContain(direction === 'ASC' ? '当前升序' : '当前降序')
|
||||
}
|
||||
}
|
||||
await change(w, { baseWindowDays: 7 })
|
||||
await change(w, { rangePreset: 'LAST_7_DAYS' })
|
||||
expect(w.get('button[data-column="averageDailyOutboundQuantity"]').text()).toContain('7日日均')
|
||||
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ baseWindowDays: 7, sortBy: 'AVERAGE_DAILY_DESC' })
|
||||
@@ -109,7 +109,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
it('queries the remaining focus tabs and uses owner-compatible sorting', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
await change(w, { groupBy: 'OWNER' })
|
||||
for (const [label, focus, sortBy] of [['近30日无出库', 'NO_OUTBOUND_30D', 'NO_OUTBOUND_PHYSICAL_DESC'], ['待评估', 'UNASSESSABLE', 'PHYSICAL_DESC'], ['消化较慢', 'COVERAGE_GT', 'PHYSICAL_DESC'], ['消化较快', 'COVERAGE_LE', 'PHYSICAL_DESC'], ['全部', 'ALL', 'PHYSICAL_DESC']]) {
|
||||
for (const [label, focus, sortBy] of [['所选时段无出库', 'NO_OUTBOUND_IN_WINDOW', 'NO_OUTBOUND_PHYSICAL_DESC'], ['待评估', 'UNASSESSABLE', 'PHYSICAL_DESC'], ['消化较慢', 'COVERAGE_GT', 'PHYSICAL_DESC'], ['消化较快', 'COVERAGE_LE', 'PHYSICAL_DESC'], ['全部', 'ALL', 'PHYSICAL_DESC']]) {
|
||||
await w.findAll('.turnover-focus button').find(b => b.text() === label)!.trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', focus, sortBy, current: 1 })
|
||||
}
|
||||
@@ -210,6 +210,9 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises()
|
||||
expect(w.findComponent(table).props('page')).toBe(2)
|
||||
expect(w.findComponent(toolbar).props('modelValue').groupBy).toBe('OWNER')
|
||||
expect(w.findComponent(toolbar).props('modelValue')).toMatchObject({ rangePreset: 'CUSTOM', customRange: ['2026-09-13', '2026-10-12'] })
|
||||
w.findComponent(table).vm.$emit('update:page', 1); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ endDate: '2026-10-12', baseWindowDays: 30, snapshotContext: 'token-A' })
|
||||
})
|
||||
it('blocks invalid thresholds and preserves known data on same-scope refresh failure', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { analyticsApi } from '../api/analytics'
|
||||
import { RequestFence, beijingTime, contextParams } from '../state'
|
||||
import type { DailyResponse, Overview } from '../types'
|
||||
import { overviewRange, overviewRangeOptions, beijingDate } from '../overviewRange'
|
||||
import type { RangePreset } from '../types'
|
||||
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
|
||||
import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
|
||||
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
|
||||
@@ -28,23 +30,23 @@ class TurnoverContractError extends Error {}
|
||||
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
|
||||
const runtime = ref<Overview | null>(null), data = ref<TurnoverResponse | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null), inputError = ref(''), notice = ref(''), loading = ref(false)
|
||||
const filters = ref<QueryModel>({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
|
||||
const filters = ref<QueryModel>({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
|
||||
const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true)
|
||||
const fence = new RequestFence(), runtimeFence = new RequestFence(), trendFence = new RequestFence()
|
||||
const trendOpen = ref(false), trendLoading = ref(false), daily = ref<DailyResponse | null>(null), trendError = ref<ApiErrorDetails | null>(null)
|
||||
const evidence = ref<TurnoverRow | null>(null), evidenceOpen = ref(false)
|
||||
const applied = ref<TurnoverQuery | null>(null)
|
||||
let linkedSnapshot: string | undefined
|
||||
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse } | null = null
|
||||
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse; filters: QueryModel } | null = null
|
||||
let initialized = false, active = true
|
||||
const warehouses = computed(() => [...new Map((runtime.value?.warehouses ?? []).map(w => [w.warehouseCode, { value: w.warehouseCode, label: w.warehouseName || w.warehouseCode }])).values()])
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'warehouseCode', label: '仓库', type: 'select', width: 170, options: warehouses.value, props: { clearable: false } },
|
||||
{ key: 'endDate', label: '截止日', type: 'date', width: 150, props: { valueFormat: 'YYYY-MM-DD', clearable: false, disabledDate: (date: Date) => date.toLocaleDateString('en-CA') > yesterdayBeijing() } },
|
||||
{ key: 'baseWindowDays', label: '日均及可售基准', type: 'select', width: 160, options: [7, 15, 30].map(n => ({ value: n, label: `${n}天` })), props: { clearable: false } },
|
||||
{ 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 } },
|
||||
...(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_30D', label: '近30日无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
|
||||
{ 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: 'coverageDaysThreshold', label: '快慢阈值(天)', type: 'input', width: 145, placeholder: '正整数,可选' },
|
||||
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
|
||||
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
|
||||
@@ -57,7 +59,7 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
|
||||
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })),
|
||||
{ prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slotName: 'daily', minWidth: 110 },
|
||||
...(ownerMode.value ? [{ prop: 'noOutbound30d', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]),
|
||||
...(ownerMode.value ? [{ prop: 'noOutboundInWindow', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]),
|
||||
{ prop: 'attentionReasons', label: '状态与样本', slotName: 'status', minWidth: 180 },
|
||||
...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slotName: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slotName: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slotName: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slotName: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slotName: 'lastDate', minWidth: 180 }] : []),
|
||||
].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined })))
|
||||
@@ -75,7 +77,7 @@ async function sortColumn(prop: string) {
|
||||
}
|
||||
const cards = computed(() => data.value?.summary ? [
|
||||
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
|
||||
{ name: '近30日无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
|
||||
{ name: '所选时段无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
|
||||
] : [])
|
||||
const runtimeError = ref('')
|
||||
const inventoryRuntime = computed(() => runtime.value?.warehouses.find(w => w.warehouseCode === applied.value?.warehouseCode && w.dataFamily === 'INVENTORY'))
|
||||
@@ -100,15 +102,20 @@ function quickFocus(focus: TurnoverQuery['focus'], sortBy: TurnoverSort) {
|
||||
const threshold = filters.value.coverageDaysThreshold
|
||||
filters.value = { ...filters.value, focus, sortBy, coverageDaysThreshold: needsThreshold && (threshold == null || String(threshold).trim() === '') ? 30 : threshold }; void search()
|
||||
}
|
||||
function query(): TurnoverQuery {
|
||||
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: String(filters.value.endDate || ''), baseWindowDays: Number(filters.value.baseWindowDays) as TurnoverQuery['baseWindowDays'], groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
|
||||
function query(refresh = false): TurnoverQuery {
|
||||
const custom = Array.isArray(filters.value.customRange) ? filters.value.customRange : []
|
||||
const anchor = !refresh && data.value ? beijingDate(Date.parse(data.value.context.queryAsOf)) : beijingDate(Date.now())
|
||||
const range = overviewRange(filters.value.rangePreset as RangePreset, anchor, String(custom[0] ?? ''), String(custom[1] ?? ''))
|
||||
const days = Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 86400000) + 1
|
||||
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: range.endDate, baseWindowDays: days, groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
|
||||
}
|
||||
function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') }
|
||||
function overviewKey(q: TurnoverQuery) { return [scopeKey(q), q.customerCode, q.groupBy].join('|') }
|
||||
async function load(refresh = false) {
|
||||
if (!permitted.value || !active) return
|
||||
fence.cancel(); inputError.value = ''; evidenceOpen.value = false; trendOpen.value = false
|
||||
const q = query()
|
||||
let q: TurnoverQuery
|
||||
try { q = query(refresh) } catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return }
|
||||
if (applied.value && applied.value.warehouseCode !== q.warehouseCode) data.value = null
|
||||
try {
|
||||
validateTurnover(q)
|
||||
@@ -139,7 +146,7 @@ async function load(refresh = false) {
|
||||
async function search() { page.value = 1; await load() }
|
||||
function reset() {
|
||||
const warehouseCode = filters.value.warehouseCode || applied.value?.warehouseCode || warehouses.value[0]?.value || ''
|
||||
filters.value = { warehouseCode, endDate: yesterdayBeijing(), baseWindowDays: 30, groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }
|
||||
filters.value = { warehouseCode, endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }
|
||||
ownerReturn = null; page.value = 1; void load(true)
|
||||
}
|
||||
function changeFilters(next: QueryModel) {
|
||||
@@ -147,20 +154,20 @@ function changeFilters(next: QueryModel) {
|
||||
if (next.groupBy !== filters.value.groupBy) {
|
||||
next = { ...next, sku: '', sortBy: 'PHYSICAL_DESC' }; notice.value = '已切换分组,SKU条件及专属排序已清除'
|
||||
}
|
||||
const changed = ['warehouseCode', 'endDate', 'baseWindowDays', 'groupBy'].some(k => next[k] !== filters.value[k])
|
||||
const changed = ['warehouseCode', 'rangePreset', 'groupBy'].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() }
|
||||
}
|
||||
function selectOwner(code: string) {
|
||||
if (applied.value?.groupBy === 'OWNER' && data.value) ownerReturn = { query: { ...applied.value }, result: data.value }
|
||||
if (applied.value?.groupBy === 'OWNER' && data.value) ownerReturn = { query: { ...applied.value }, result: data.value, filters: { ...filters.value, customRange: [...(filters.value.customRange as string[] ?? [])] } }
|
||||
filters.value = { ...filters.value, groupBy: 'SKU', customerCode: code, sku: '', sortBy: 'PHYSICAL_DESC' }; page.value = 1; void load()
|
||||
}
|
||||
function returnOwners() {
|
||||
if (!ownerReturn) return
|
||||
if (Date.parse(ownerReturn.result.context.queryExpiresAt) <= Date.now()) { inputError.value = '原货主汇总版本已过期,请整体刷新'; return }
|
||||
fence.cancel(); trendOpen.value = false; evidenceOpen.value = false; loading.value = false; error.value = null
|
||||
const saved = ownerReturn; filters.value = { ...saved.query, sku: '', customerCode: saved.query.customerCode || '', coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' }
|
||||
const saved = ownerReturn; filters.value = { ...saved.filters, ...saved.query, sku: '', customerCode: saved.query.customerCode || '', coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' }
|
||||
applied.value = saved.query; page.value = saved.query.current; data.value = saved.result; ownerReturn = null
|
||||
}
|
||||
async function showTrend(row: TurnoverRow) {
|
||||
@@ -182,7 +189,7 @@ watch(trendOpen, open => { if (!open) { trendFence.cancel(); daily.value = null;
|
||||
function readRoute() {
|
||||
try {
|
||||
const initial = turnoverRoute(route.query); linkedSnapshot = initial.snapshotContext
|
||||
filters.value = { ...filters.value, ...initial }; page.value = initial.current ?? 1; size.value = initial.size ?? 20
|
||||
filters.value = { ...filters.value, ...initial, ...(initial.endDate ? { rangePreset: 'CUSTOM', customRange: [new Date(Date.parse(initial.endDate) - ((initial.baseWindowDays ?? 30) - 1) * 86400000).toISOString().slice(0, 10), initial.endDate] } : {}) }; page.value = initial.current ?? 1; size.value = initial.size ?? 20
|
||||
return true
|
||||
} catch (cause) { fence.cancel(); data.value = null; inputError.value = (cause as Error).message; return false }
|
||||
}
|
||||
@@ -237,12 +244,12 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
|
||||
<div v-if="chartsOpen" class="turnover-charts">
|
||||
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
|
||||
<section><header class="turnover-chart-header"><h3>7/15/30 日日均出库</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div class="turnover-window-summary"><div v-for="window in data.charts.averageDaily" :key="window.days"><span>{{ window.days }}日日均</span><strong>{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}</strong><small v-if="window.dataStatus !== 'COMPLETE'">完整 {{ window.completeDayCount }} / {{ window.days }} 日</small></div></div></section>
|
||||
<section><header class="turnover-chart-header"><h3>日均出库对比(含所选时段)</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div class="turnover-window-summary"><div v-for="window in data.charts.averageDaily" :key="window.days"><span>{{ window.days }}日日均</span><strong>{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}</strong><small v-if="window.dataStatus !== 'COMPLETE'">完整 {{ window.completeDayCount }} / {{ window.days }} 日</small></div></div></section>
|
||||
</div>
|
||||
</template>
|
||||
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
|
||||
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_30D'" @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_IN_WINDOW'" @click="quickFocus('NO_OUTBOUND_IN_WINDOW', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">所选时段无出库</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</button>
|
||||
@@ -264,7 +271,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
|
||||
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
|
||||
<template #status="{ row }"><small>{{ reasonsText(castRow(row).attentionReasons) }}</small><span v-if="ownerMode">待评估 {{ castRow(row).unassessableSkuCount }} SKU</span><el-button link @click="evidence = castRow(row); evidenceOpen = true">日样本 {{ castRow(row).qualifiedSampleDays }}/{{ castRow(row).requiredSampleDays }}</el-button></template>
|
||||
<template #noOutbound="{ row }">{{ turnoverNumber(castRow(row).noOutbound30d.matchedSkuCount) }} SKU / {{ turnoverNumber(castRow(row).noOutbound30d.knownPhysicalQuantity) }} 件<small>待判断 {{ castRow(row).noOutbound30d.unknownSkuCount }} SKU</small></template>
|
||||
<template #noOutbound="{ row }">{{ turnoverNumber(castRow(row).noOutboundInWindow.matchedSkuCount) }} SKU / {{ turnoverNumber(castRow(row).noOutboundInWindow.knownPhysicalQuantity) }} 件<small>待判断 {{ castRow(row).noOutboundInWindow.unknownSkuCount }} SKU</small></template>
|
||||
<template #slow="{ row }">{{ applied?.coverageDaysThreshold ? `${turnoverNumber(castRow(row).slowCoverage.matchedSkuCount)} SKU / ${castRow(row).slowCoverage.unknownSkuCount} SKU` : '未设查询阈值' }}</template>
|
||||
<template #averageStock="{ row }">{{ turnoverNumber(castRow(row).averagePhysicalQuantity) }}</template>
|
||||
<template #turnover="{ row }">{{ turnoverNumber(castRow(row).quantityTurnoverCount) }} 次/{{ applied?.baseWindowDays }}日</template>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import type { BusinessQuery, CoverageBucket, QueryContext, RangePreset } from './types'
|
||||
import { validateCustomRange } from './state'
|
||||
import { inventorySortOptions } from './inventorySorting'
|
||||
|
||||
export const buckets: { code: CoverageBucket; label: string }[] = [
|
||||
{ code: 'DAYS_0_30', label: '0~30天(含30)' }, { code: 'DAYS_30_60', label: '30~60天(含60)' },
|
||||
{ code: 'DAYS_60_90', label: '60~90天(含90)' }, { code: 'DAYS_GT_90', label: '超过90天' },
|
||||
{ code: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { code: 'NO_AVAILABLE', label: '无可售库存' }, { code: 'UNKNOWN', label: '库存或销量待核验' },
|
||||
]
|
||||
export const rangeOptions: { value: RangePreset; label: string }[] = [{ value: 'TODAY', label: '今日' }, { value: 'LAST_3_DAYS', label: '近3天' }, { value: 'THIS_WEEK', label: '本周' }, { value: 'LAST_15_DAYS', label: '近15天' }, { value: 'THIS_MONTH', label: '本月' }, { value: 'CUSTOM', label: '自定义' }]
|
||||
export const rangeOptions: { value: RangePreset; label: string }[] = [{ value: 'TODAY', label: '今日' }, { value: 'LAST_3_DAYS', label: '最近3天' }, { value: 'LAST_7_DAYS', label: '最近7天' }, { value: 'LAST_30_DAYS', label: '最近30天' }, { value: 'THIS_WEEK', label: '本周' }, { value: 'LAST_15_DAYS', label: '近15天' }, { value: 'THIS_MONTH', label: '本月' }, { value: 'CUSTOM', label: '自定义' }]
|
||||
export interface OverviewReturn { rangePreset: RangePreset; startDate?: string; endDate?: string; scroll: number }
|
||||
export interface DashboardDrilldown {
|
||||
context: QueryContext
|
||||
@@ -90,7 +91,7 @@ export function parseDashboardDrilldown(query: LocationQuery, mode: 'sales' | 'i
|
||||
const selection = scalar(query, 'coverageSelection')
|
||||
const attention = scalar(query, 'attention') || 'ALL'
|
||||
const sort = scalar(query, 'sortBy') || 'AVAILABLE_DESC'
|
||||
if (!['ALL', 'NO_OUTBOUND_IN_WINDOW'].includes(attention) || !['AVAILABLE_DESC', 'COVERAGE_DAYS_DESC', 'PHYSICAL_DESC', 'OUTBOUND_ASC'].includes(sort)) throw new Error('库存下钻枚举无效')
|
||||
if (!['ALL', 'NO_OUTBOUND_IN_WINDOW'].includes(attention) || !inventorySortOptions.some(option => option.value === sort)) throw new Error('库存下钻枚举无效')
|
||||
if ((bucket && (!buckets.some(item => item.code === bucket) || rule !== 'COVERAGE_DISTRIBUTION_V1' || selection || attention !== 'ALL')) || (!bucket && rule) || (selection && (selection !== 'FINITE_POSITIVE' || attention !== 'ALL'))) throw new Error('库存分组条件无效或互斥')
|
||||
result.inventory = { attention: attention as 'ALL' | 'NO_OUTBOUND_IN_WINDOW', sortBy: sort as BusinessQuery['sortBy'], coverageBucket: bucket ? bucket as CoverageBucket : undefined, bucketRuleVersion: bucket ? 'COVERAGE_DISTRIBUTION_V1' : undefined, coverageSelection: selection ? 'FINITE_POSITIVE' : undefined, drilldownFrom: 'DASHBOARD' }
|
||||
result.summary += ` · ${result.startDate} 至 ${result.endDate} · ${buckets.find(item => item.code === bucket)?.label ?? (selection ? '有限正可售天数,从高到低' : attention === 'NO_OUTBOUND_IN_WINDOW' ? '所选时段无出库,实物量从高到低' : '库存')}`
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InventorySort, InventorySortMetric } from './types'
|
||||
|
||||
export const inventorySortColumns: Record<string, InventorySortMetric> = {
|
||||
physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', frozenQuantity: 'FROZEN',
|
||||
defectivePhysicalQuantity: 'DEFECTIVE', transitQuantity: 'TRANSIT',
|
||||
windowOutboundQuantity: 'OUTBOUND', averageDailyOutboundQuantity: 'AVERAGE_DAILY',
|
||||
inventoryCoverageDays: 'COVERAGE_DAYS',
|
||||
}
|
||||
const names: Record<InventorySortMetric, string> = {
|
||||
AVAILABLE: '正品可售', PHYSICAL: '全品质实物', FROZEN: '全品质冻结', DEFECTIVE: '次品实物',
|
||||
TRANSIT: '在途', OUTBOUND: '完整窗口出库', AVERAGE_DAILY: '完整窗口日均', COVERAGE_DAYS: '可售天数',
|
||||
}
|
||||
export const inventorySortOptions: { value: InventorySort; label: string }[] = (Object.keys(names) as InventorySortMetric[]).flatMap(metric => [
|
||||
{ value: `${metric}_DESC` as InventorySort, label: `${names[metric]}从高到低` },
|
||||
{ value: `${metric}_ASC` as InventorySort, label: `${names[metric]}从低到高` },
|
||||
])
|
||||
@@ -116,4 +116,12 @@
|
||||
|
||||
| 经营总览/统一统计时间(2026-10-14) | 用户要求分布、预计消化最长及无出库库存榜跟随顶部搜索时间 | 三榜、分布及日均/无出库指标统一使用所选区间;名称改为“所选时段无出库库存Top10”,明细保留原日期并锁定;库存取当前版本,缺日不填零,30/60/90天分组边界不变 | 日期、分组、排序与下钻回归;待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 销量分析/今日空数量及日均精度 | 用户反馈历史采齐但今日为空、日均悬浮小数过长 | 今日全无可用数量时明确范围并提供近30完整日查询;空值不标已采集。日均悬浮与摘要统一最多2位小数、件/日单位,底层精度不变 | 日期请求与精度回归;实际服务覆盖需返回值核对 |
|
||||
| 销量分析/今日空数量及日均精度 | 用户反馈历史采齐但今日为空、日均悬浮小数过长 | 销量日期统一最近3/7/15/30天及自定义,默认最近30天,快捷范围截至北京时间昨日;每日趋势沿用已查询区间,原图下钻保持原日期;空值不标已采集。日均悬浮与摘要统一最多2位小数、件/日单位,底层精度不变 | 日期请求与精度回归;实际服务覆盖需返回值核对 |
|
||||
| 库存明细/数值排序与缺失原因 | 用户要求明细排序、补齐缺失数据,并明确今天未结束的统计处理 | 八个数值表头支持全量升降序,保留版本、第一页、失败恢复;未知正式出库显示已采集小计与原因,未完整日不推算日均;显示实际统计区间及排除今天说明,新版提供更新数据入口 | 全量排序、空值、跨页、刷新及截至昨天回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| /self-operated/overview | 仅查询已结束自然日 | 2026-10-14用户截图 `codex-clipboard-9ba776f6-4c0a-4f00-9ca9-2c86748fdd36.png` 确认去掉今日;保留原顶部QueryToolbar,快捷范围标注截至昨日,默认完整15天,自定义禁选今日及未来 | 六项指标、五张图与下钻共用实际起止日期;四个统计页面共用日期选择规则;自动检查与人工1920×1080、100%截图分别验收,视觉待人工确认 |
|
||||
|
||||
| 销量分析/数量排序 | 用户要求支持排序 | 区间出库量/已采集量表头切换全量升降序,未知末尾、同值按SKU及货主稳定排序;保持日期及版本,回到第一页;失败恢复原排序 | 前后端排序回归,待人工截图确认 |
|
||||
| 库存明细/观察期独立提示 | 用户确认完整销量窗口不再被商品观察期阻断 | 完整可信窗口正常显示出库、日均及结合当前有效可售量计算的天数;观察不足仅在说明列提示,不单独归入待评估;完整零出库显示0及无出库原因,真实缺口仍未知;总览分布与下钻同步口径 | 新发现商品、有出库/零出库/未知、两位小数和总览下钻一致性回归;视觉待人工截图确认 |
|
||||
|
||||
| 四个统计页面/日期统一 | 用户确认经营总览、销量分析、库存明细、库存周转 | 同一组最近3/7/15/30天、自定义;快捷范围截至北京时间昨日,自定义1~366天且最晚昨日;库存周转保留明确标注的7/15/30天对比,主要计算及无出库按所选时段;分页、下钻、返回使用固定区间 | 日期边界、自定义超过30天与样本缺口、查询及返回回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beijingDate, overviewRange, overviewRangeOptions } from './overviewRange'
|
||||
|
||||
describe('overview completed calendar days', () => {
|
||||
it('excludes today and uses exactly fifteen or three days through yesterday', () => {
|
||||
expect(overviewRangeOptions.some(option => option.value === 'TODAY')).toBe(false)
|
||||
expect(overviewRange('LAST_15_DAYS', '2026-10-14')).toEqual({ rangePreset: 'CUSTOM', startDate: '2026-09-29', endDate: '2026-10-13' })
|
||||
expect(overviewRange('LAST_3_DAYS', '2026-01-02')).toMatchObject({ startDate: '2025-12-30', endDate: '2026-01-01' })
|
||||
expect(() => overviewRange('TODAY', '2026-10-14')).toThrow()
|
||||
})
|
||||
it('offers the same four complete-day shortcuts and custom range', () => {
|
||||
expect(overviewRangeOptions.map(option => option.label)).toEqual(['最近3天', '最近7天', '最近15天', '最近30天', '自定义'])
|
||||
expect(overviewRange('LAST_7_DAYS', '2026-10-14')).toMatchObject({ startDate: '2026-10-07', endDate: '2026-10-13' })
|
||||
expect(overviewRange('LAST_30_DAYS', '2026-10-14')).toMatchObject({ startDate: '2026-09-14', endDate: '2026-10-13' })
|
||||
})
|
||||
it('rejects custom today and future, allows yesterday and follows Beijing midnight', () => {
|
||||
expect(() => overviewRange('CUSTOM', '2026-10-14', '2026-10-01', '2026-10-14')).toThrow('最晚为昨日')
|
||||
expect(() => overviewRange('CUSTOM', '2026-10-14', '2026-10-01', '2026-10-15')).toThrow('最晚为昨日')
|
||||
expect(overviewRange('CUSTOM', '2026-10-14', '2026-10-13', '2026-10-13')).toMatchObject({ startDate: '2026-10-13', endDate: '2026-10-13' })
|
||||
expect(beijingDate(Date.parse('2026-10-13T15:59:59Z'))).toBe('2026-10-13')
|
||||
expect(beijingDate(Date.parse('2026-10-13T16:00:00Z'))).toBe('2026-10-14')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { RangePreset } from './types'
|
||||
import { previousDate } from './dashboardNavigation'
|
||||
import { validateCustomRange } from './state'
|
||||
|
||||
export const overviewRangeOptions: { value: RangePreset; label: string }[] = [3, 7, 15, 30].map(days => ({ value: `LAST_${days}_DAYS` as RangePreset, label: `最近${days}天` })).concat([{ value: 'CUSTOM', label: '自定义' }])
|
||||
export function beijingDate(now: number) {
|
||||
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now)
|
||||
}
|
||||
export function overviewRange(preset: RangePreset, anchor: string, start = '', end = '') {
|
||||
const yesterday = previousDate(anchor)
|
||||
if (preset === 'CUSTOM') {
|
||||
const problem = validateCustomRange(start, end)
|
||||
if (problem) throw new Error(problem)
|
||||
if (end > yesterday) throw new Error('仅查询已结束的自然日,结束日期最晚为昨日')
|
||||
} else {
|
||||
end = yesterday
|
||||
const day = new Date(`${anchor}T00:00:00Z`)
|
||||
const windows: Partial<Record<RangePreset, number>> = { LAST_3_DAYS: 3, LAST_7_DAYS: 7, LAST_15_DAYS: 15, LAST_30_DAYS: 30 }
|
||||
const days = windows[preset]
|
||||
if (!days) throw new Error('请选择有效的统计时间范围')
|
||||
day.setUTCDate(day.getUTCDate() - days)
|
||||
start = day.toISOString().slice(0, 10)
|
||||
if (start > end) throw new Error('当前周期还没有已结束的自然日,请选择其他时间范围')
|
||||
}
|
||||
return { rangePreset: 'CUSTOM' as const, startDate: start, endDate: end }
|
||||
}
|
||||
@@ -70,6 +70,23 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('explains today and pending publication using the returned query context', async () => {
|
||||
const data = structuredClone(dashboardFixture)
|
||||
data.context.rebuildRequired = true
|
||||
data.context.salesStatus = 'REBUILD_REQUIRED'
|
||||
mocks.dashboard.mockResolvedValue(data)
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.text()).toContain('所选时段包含今日(2026-10-13)')
|
||||
expect(wrapper.text()).toContain('库存评估等待新版销量发布')
|
||||
const refreshed = structuredClone(data)
|
||||
refreshed.context.rangeEndDateExclusive = refreshed.context.anchorDate
|
||||
refreshed.context.rebuildRequired = false
|
||||
refreshed.context.salesStatus = 'VALID'
|
||||
mocks.dashboard.mockResolvedValue(refreshed)
|
||||
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('所选时段包含今日(')
|
||||
expect(wrapper.text()).not.toContain('库存评估等待新版销量发布')
|
||||
})
|
||||
it('offers coverage evidence for both inventory rankings even when they are empty', async () => {
|
||||
const data = structuredClone(dashboardFixture)
|
||||
data.longCoverageSkus = { ...data.longCoverageSkus, items: [], eligibleCount: '0', unassessableCount: '2711', dataStatus: 'PARTIAL' }
|
||||
@@ -83,10 +100,27 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(wrapper.text()).toContain('待评估 2741 SKU')
|
||||
expect(mocks.dashboard).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('removes today, blocks custom today before querying and drills into the displayed dates', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
const toolbar = wrapper.findComponent(query)
|
||||
const options = toolbar.props('fields').find((field: { key: string }) => field.key === 'rangePreset').options
|
||||
expect(options.some((option: { value: string }) => option.value === 'TODAY')).toBe(false)
|
||||
expect(options.find((option: { value: string }) => option.value === 'LAST_15_DAYS').label).toBe('最近15天')
|
||||
const calls = mocks.dashboard.mock.calls.length
|
||||
await choose(wrapper, { rangePreset: 'CUSTOM', dateRange: ['2026-10-01', '2026-10-13'] })
|
||||
const disabled = toolbar.props('fields').find((field: { key: string }) => field.key === 'dateRange').props.disabledDate
|
||||
expect(disabled(new Date(2026, 9, 13))).toBe(true)
|
||||
expect(disabled(new Date(2026, 9, 12))).toBe(false)
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(calls)
|
||||
expect(wrapper.text()).toContain('结束日期最晚为昨日')
|
||||
await findButton(wrapper, '查看销量').trigger('click')
|
||||
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ rangePreset: 'CUSTOM', startDate: dashboardFixture.context.rangeStartDate, endDate: '2026-10-13' })
|
||||
})
|
||||
it('uses one date range field, blocks incomplete ranges and clears obsolete validation errors', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
|
||||
const toolbar = wrapper.findComponent(query)
|
||||
expect(toolbar.props('fields').filter((field: { type: string }) => field.type.includes('date'))).toEqual([{ key: 'dateRange', type: 'daterange', label: '日期范围', width: 300 }])
|
||||
expect(toolbar.props('fields').filter((field: { type: string }) => field.type.includes('date'))).toMatchObject([{ key: 'dateRange', type: 'daterange', label: '日期范围', width: 300 }])
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
@@ -103,7 +137,7 @@ describe('warehouse operating dashboard', () => {
|
||||
await choose(wrapper, { dateRange: null })
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard).toHaveBeenCalledOnce()
|
||||
await choose(wrapper, { rangePreset: 'TODAY' })
|
||||
await choose(wrapper, { rangePreset: 'LAST_3_DAYS' })
|
||||
expect(wrapper.findComponent(error).exists()).toBe(false)
|
||||
})
|
||||
it('restores a custom range and keeps the applied dates when an unsubmitted range is edited', async () => {
|
||||
@@ -116,12 +150,12 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(JSON.parse(mocks.push.mock.calls.at(-1)?.[0].query.overviewReturn)).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
|
||||
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
|
||||
expect(wrapper.findComponent(query).props('modelValue').dateRange).toEqual([])
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('startDate')
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('endDate')
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toHaveProperty('startDate')
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toHaveProperty('endDate')
|
||||
})
|
||||
|
||||
it('does not replace an invalid warehouse-less return with the default warehouse', async () => {
|
||||
mocks.query = { context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'THIS_MONTH', scroll: 760 }) }
|
||||
mocks.query = { context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'LAST_7_DAYS', scroll: 760 }) }
|
||||
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: 'A仓' }], observedAt: '2026-10-13T00:00:00Z' })
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('经营总览返回参数无效')
|
||||
@@ -148,9 +182,9 @@ describe('warehouse operating dashboard', () => {
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-A')
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
|
||||
expect(wrapper.find('.dashboard-data-notes').exists()).toBe(false)
|
||||
await choose(wrapper, { rangePreset: 'TODAY' })
|
||||
await choose(wrapper, { rangePreset: 'LAST_3_DAYS' })
|
||||
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
|
||||
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('queryAsOf')
|
||||
@@ -165,14 +199,14 @@ describe('warehouse operating dashboard', () => {
|
||||
})
|
||||
it('restores the saved warehouse, range, fixed context and scroll when returning from a drilldown', async () => {
|
||||
const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
|
||||
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'THIS_MONTH', scroll: 760 }) }
|
||||
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'LAST_7_DAYS', scroll: 760 }) }
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'THIS_MONTH', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
|
||||
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'THIS_MONTH' })
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'CUSTOM', startDate: '2026-10-06', endDate: '2026-10-12', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
|
||||
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_7_DAYS' })
|
||||
expect(scroll).toHaveBeenCalledWith({ top: 760 })
|
||||
})
|
||||
it('does not silently refresh an expired return context', async () => {
|
||||
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify({ ...dashboardFixture.context, queryExpiresAt: '2026-10-13T00:01:00Z' }), overviewReturn: JSON.stringify({ rangePreset: 'TODAY', scroll: 0 }) }
|
||||
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify({ ...dashboardFixture.context, queryExpiresAt: '2026-10-13T00:01:00Z' }), overviewReturn: JSON.stringify({ rangePreset: 'LAST_3_DAYS', scroll: 0 }) }
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); vi.setSystemTime(new Date('2026-10-13T00:02:00Z'))
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
@@ -201,7 +235,7 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
|
||||
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
|
||||
expect(wrapper.findAllComponents(DashboardBars)).toHaveLength(4)
|
||||
expect(wrapper.findAllComponents(DashboardTrend)).toHaveLength(1)
|
||||
@@ -215,8 +249,8 @@ describe('warehouse operating dashboard', () => {
|
||||
})
|
||||
it('changes the shared date range while pinning versions, then explicitly refreshes the whole snapshot', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
await choose(wrapper, { rangePreset: 'TODAY' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'TODAY', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
|
||||
await choose(wrapper, { rangePreset: 'LAST_3_DAYS' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-12', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
|
||||
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesRunId')
|
||||
})
|
||||
@@ -230,7 +264,7 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
|
||||
expect(wrapper.find('.dashboard-placeholder').exists()).toBe(true)
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.dashboard.mock.calls[1]?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(mocks.dashboard.mock.calls[1]?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'CUSTOM' })
|
||||
expect(mocks.dashboard.mock.calls[1]?.[0]).not.toHaveProperty('queryAsOf')
|
||||
finishNew(dashboardFixture); await flushPromises()
|
||||
expect(wrapper.find('.dashboard-kpis').exists()).toBe(true)
|
||||
@@ -248,7 +282,7 @@ describe('warehouse operating dashboard', () => {
|
||||
toolbar.vm.$emit('reset'); await flushPromises()
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(3)
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'CUSTOM' })
|
||||
})
|
||||
it('preserves same-SKU owner identity in the 1120 dialog and cancels closed requests', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
|
||||
@@ -13,8 +13,9 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { analyticsApi } from '../api/analytics'
|
||||
import type { Dashboard, DashboardItem, DashboardRanking, DailyResponse, DashboardQuery, Overview, QueryContext, RangePreset } from '../types'
|
||||
import { beijingTime, contextParams, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext, validateCustomRange } from '../state'
|
||||
import { buckets, expired, inventoryExpired, parseContext, parseOverviewReturn, previousDate, rangeOptions, type OverviewReturn } from '../dashboardNavigation'
|
||||
import { beijingTime, contextParams, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext } from '../state'
|
||||
import { buckets, expired, inventoryExpired, parseContext, parseOverviewReturn, previousDate, type OverviewReturn } from '../dashboardNavigation'
|
||||
import { beijingDate, overviewRange, overviewRangeOptions } from '../overviewRange'
|
||||
import HotSkuDetails from '../components/HotSkuDetails.vue'
|
||||
import InventoryRankingDiagnostics from '../components/InventoryRankingDiagnostics.vue'
|
||||
import ChartDetailsDialog from '../components/ChartDetailsDialog.vue'
|
||||
@@ -56,10 +57,13 @@ try {
|
||||
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
|
||||
const contextExpired = computed(() => Boolean(context.value && expired(context.value, now.value)))
|
||||
const stockExpired = computed(() => Boolean(context.value && inventoryExpired(context.value, now.value)))
|
||||
const rangeAnchor = computed(() => context.value?.anchorDate ?? beijingDate(now.value))
|
||||
const includesToday = computed(() => Boolean(data.value?.context.rangeStartDate && data.value.context.rangeEndDateExclusive
|
||||
&& data.value.context.rangeStartDate <= data.value.context.anchorDate && data.value.context.rangeEndDateExclusive > data.value.context.anchorDate))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 220, placeholder: '切换仓库', props: { clearable: false }, options: [...new Map((runtime.value?.warehouses ?? []).map(item => [item.warehouseCode, { value: item.warehouseCode, label: item.warehouseName || item.warehouseCode }])).values()] },
|
||||
{ key: 'rangePreset', type: 'select', label: '统计时间范围', width: 180, props: { clearable: false }, options: rangeOptions },
|
||||
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'dateRange', type: 'daterange' as const, label: '日期范围', width: 300 }] : []),
|
||||
{ key: 'rangePreset', type: 'select', label: '统计时间范围', width: 220, props: { clearable: false }, options: overviewRangeOptions.map(option => ({ ...option, disabled: option.value !== 'CUSTOM' && (() => { try { overviewRange(option.value, rangeAnchor.value); return false } catch { return true } })() })) },
|
||||
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'dateRange', type: 'daterange' as const, label: '日期范围', width: 300, props: { disabledDate: (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` >= rangeAnchor.value } }] : []),
|
||||
])
|
||||
const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: boolean }[] = [
|
||||
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity', label: '所选时段日均出库' },
|
||||
@@ -85,17 +89,19 @@ async function load(refresh = false) {
|
||||
if (!permitted.value || !warehouseCode.value || routeError.value) return
|
||||
if (!refresh && contextExpired.value) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
|
||||
const range = String(filters.value.rangePreset) as RangePreset
|
||||
if (!rangeOptions.some(option => option.value === range)) { error.value = '请选择有效的出库趋势范围'; return }
|
||||
if (!overviewRangeOptions.some(option => option.value === range)) { error.value = '请选择有效的统计时间范围'; return }
|
||||
const selected = Array.isArray(filters.value.dateRange) ? filters.value.dateRange : []
|
||||
const start = typeof selected[0] === 'string' ? selected[0] : ''
|
||||
const end = typeof selected[1] === 'string' ? selected[1] : ''
|
||||
if (range === 'CUSTOM') { const problem = validateCustomRange(start, end, context.value?.anchorDate); if (problem) { rangeValidationError.value = problem; error.value = problem; errorDetails.value = null; return } }
|
||||
const pinned = refresh ? null : context.value
|
||||
let dates: ReturnType<typeof overviewRange>
|
||||
try { dates = overviewRange(range, pinned?.anchorDate ?? beijingDate(now.value), start, end) }
|
||||
catch (cause) { const problem = (cause as Error).message; rangeValidationError.value = problem; error.value = problem; errorDetails.value = null; return }
|
||||
const request = dataFence.begin()
|
||||
trendOpen.value = false
|
||||
loading.value = true; error.value = ''; rangeValidationError.value = ''; errorDetails.value = null
|
||||
try {
|
||||
const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, rangePreset: range, ...contextParams(pinned), ...(range === 'CUSTOM' ? { startDate: start, endDate: end } : {}) }
|
||||
const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, ...dates, ...contextParams(pinned) }
|
||||
const result = await analyticsApi.dashboard(params, request.signal)
|
||||
if (!request.current()) return
|
||||
if (!result?.context || metricNames.some(({ key }) => {
|
||||
@@ -185,11 +191,11 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
<template v-else>
|
||||
<DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide">
|
||||
<template #toolbar-start><div class="dashboard-title"><h2>经营总览</h2><span class="dashboard-snapshot">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : error ? '查询未完成' : '正在准备数据' }}</span></div></template>
|
||||
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
|
||||
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: data!.context.rangeStartDate!, endDate: previousDate(data!.context.rangeEndDateExclusive!) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
|
||||
<template #toolbar-end>
|
||||
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间">
|
||||
<template #reference><el-button circle aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
|
||||
<div class="dashboard-data-notes admin-density"><p>时间筛选统一影响出库量、日均、趋势、库存消化分布和三个榜单。日均按所选区间天数计算;未采齐或含今日时,热销展示已采集量,库存消化和无出库评估保持待核验。库存使用本次查询的有效库存快照,不代表所选日期的历史库存。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p><p v-else>查询仓库数据后显示对应采集时间。</p></div>
|
||||
<div class="dashboard-data-notes admin-density"><p>时间筛选统一影响出库量、日均、趋势、库存消化分布和三个榜单。日期最晚截至昨日,日均按所选完整自然日数计算;未采齐时,热销展示已采集量,库存消化和无出库评估保持待核验。库存使用本次查询的有效库存快照,不代表所选日期的历史库存。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p><p v-else>查询仓库数据后显示对应采集时间。</p></div>
|
||||
</el-popover>
|
||||
</template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template>
|
||||
@@ -203,7 +209,8 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
<p v-else-if="stockExpired && data" role="status">原图库存已过期或不可用,库存指标为历史/待刷新资料,严格库存下钻已停用;出库趋势与热销仍可查看。</p>
|
||||
<div v-if="loading && !data" class="dashboard-placeholder" role="status" aria-busy="true"><span>正在读取六项指标、日趋势、库存分布和三个榜单…</span><div class="dashboard-skeleton-kpis" aria-hidden="true"><i v-for="index in 6" :key="index" /></div><div class="dashboard-skeleton-charts" aria-hidden="true"><i /><i /></div></div>
|
||||
<template v-if="data">
|
||||
|
||||
<p v-if="includesToday" role="status">所选时段包含今日({{ data.context.anchorDate }}),尚不满足完整自然日要求,库存消化天数与两个库存榜单暂不能完成评估。可选择截至昨日的自定义时段;仍需满足销量覆盖、库存及观察期条件。</p>
|
||||
<p v-if="data.context.rebuildRequired || data.context.salesStatus === 'REBUILD_REQUIRED'" role="status">销量有变动,待重新统计发布。出库趋势展示已发布版本的历史覆盖结果;库存评估等待新版销量发布。</p>
|
||||
<div class="dashboard-kpis" aria-label="本仓经营指标">
|
||||
<section v-for="(metric, index) in metricNames" :key="metric.key" :style="{ '--metric-color': metricColors[index], '--reveal-delay': `${index * 45}ms` }"><div class="dashboard-metric-heading"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><component :is="metricIcons[index]" :size="18" aria-hidden="true" /></div><div class="dashboard-metric-value"><strong>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span></div><p class="dashboard-metric-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
|
||||
</div>
|
||||
|
||||
@@ -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.windowOutboundQuantity?.({ row }), slots.inventoryCoverageDays?.({ row }), slots.status?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row }), slots.dailySamples?.({ row }), slots.configuration?.({ row })]))]) } })
|
||||
const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText', 'page'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), slots.filters?.(), slots.notices?.(), ...(props.columns ?? []).map((column: { prop?: string; headerSlotName?: string; label?: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), !(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 }), h('span', { class: 'daily-cell' }, slots.averageDailyOutboundQuantity?.({ row })), h('span', { class: 'reason-cell' }, slots.reason?.({ 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,14 +50,89 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
|
||||
describe('self-operated actual workbench request state', () => {
|
||||
it('explains unknown today values and queries the preceding 30 complete days explicitly', async () => {
|
||||
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'}
|
||||
for(const [prop,metricName] of Object.entries(columns)) {
|
||||
wrapper.findComponent(tableStub).vm.$emit('update:page',2);await flushPromises()
|
||||
const header=wrapper.get(`button[data-column="${prop}"]`)
|
||||
const directions=['DESC','ASC']
|
||||
for(const direction of directions) {
|
||||
const previousCalls=mocks.api.inventory.mock.calls.length
|
||||
await header.trigger('click');await flushPromises()
|
||||
expect(mocks.api.inventory.mock.calls.length).toBe(previousCalls+1)
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({sortBy:`${metricName}_${direction}`,current:1,queryAsOf:context.queryAsOf,salesRunId:context.salesRunId})
|
||||
expect(header.attributes('aria-label')).toContain(direction==='ASC'?'当前升序':'当前降序')
|
||||
}
|
||||
}
|
||||
})
|
||||
it('preserves the successful header and page on sorting failure', async () => {
|
||||
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.mockRejectedValueOnce(new Error('offline'))
|
||||
await wrapper.get('button[data-column="transitQuantity"]').trigger('click');await flushPromises()
|
||||
expect(wrapper.findComponent(tableStub).props('page')).toBe(2)
|
||||
expect(wrapper.findComponent(tableStub).props('data')).toHaveLength(1)
|
||||
expect(wrapper.get('button[data-column="availableQuantity"]').attributes('aria-label')).toContain('当前降序')
|
||||
expect(wrapper.get('button[data-column="transitQuantity"]').attributes('aria-label')).not.toContain('当前')
|
||||
})
|
||||
it('shows known sales subtotals with specific gap reasons while keeping unknown metrics empty', async () => {
|
||||
mocks.api.inventory.mockResolvedValue({context,records:[{...metric,observedWindowOutboundQuantity:92,observationEligible:false,status:'COVERAGE_INSUFFICIENT',quantityStatuses:{windowOutboundQuantity:'COVERAGE_INSUFFICIENT'}}],total:1})
|
||||
const wrapper=mount(AnalyticsView,{...options,props:{mode:'inventory'}});wrappers.push(wrapper);await flushPromises()
|
||||
expect(wrapper.text()).toContain('已采集 92')
|
||||
expect(wrapper.findAll('.inventory-missing').map(e=>e.text())).toContain('销量窗口未采齐')
|
||||
expect(wrapper.findComponent(tableStub).props('data')[0].inventoryCoverageDays).toBeNull()
|
||||
mocks.api.inventory.mockResolvedValue({context,records:[{...metric,observedWindowOutboundQuantity:0,quantityStatuses:{windowOutboundQuantity:'COVERAGE_INSUFFICIENT'}}],total:1})
|
||||
await findButton(wrapper,'查询').trigger('click');await flushPromises()
|
||||
expect(wrapper.text()).toContain('已采集 0');expect(wrapper.text()).toContain('销量窗口未采齐')
|
||||
})
|
||||
it('shows complete window metrics and a separate observation warning for recently discovered SKUs', async () => {
|
||||
const row = { ...metric, windowOutboundQuantity: 92, averageDailyOutboundQuantity: '6.133333333333', inventoryCoverageDays: 48.9, observationEligible: false, status: 'VALID', reasonCodes: ['OBSERVATION_INSUFFICIENT'], quantityStatuses: { windowOutboundQuantity: 'VALID' } }
|
||||
mocks.api.inventory.mockResolvedValue({ context: { ...context, calculationWindowDays: 15 }, records: [row], total: 1 })
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()
|
||||
expect(wrapper.get('.daily-cell').text()).toBe('6.13')
|
||||
expect(wrapper.get('.reason-cell').text()).toBe('商品历史观察不足(仅提示,不影响完整窗口统计)')
|
||||
expect(wrapper.findAll('.inventory-missing')).toHaveLength(0)
|
||||
expect(wrapper.findComponent(tableStub).props('data')[0]).toMatchObject({ windowOutboundQuantity: 92, averageDailyOutboundQuantity: '6.133333333333', inventoryCoverageDays: 48.9 })
|
||||
})
|
||||
it('updates to new published data even when the draft sort was changed', async () => {
|
||||
vi.useFakeTimers();vi.setSystemTime(new Date('2026-10-12T01:05:00Z'))
|
||||
const wrapper=mount(AnalyticsView,{...options,props:{mode:'inventory'}});wrappers.push(wrapper);await flushPromises()
|
||||
mocks.api.overview.mockResolvedValue({...overview,warehouses:[{warehouseCode:'WH-A',lastPublishedAt:'2026-10-12T01:03:00Z'}]})
|
||||
await vi.advanceTimersByTimeAsync(60000);await flushPromises()
|
||||
await setFilters(wrapper,{sortBy:'FROZEN_DESC'})
|
||||
const nextContext={...context,salesRunId:'12',queryAsOf:'2026-10-12T01:06:00Z'}
|
||||
mocks.api.inventory.mockResolvedValue({context:nextContext,records:[{...metric,windowOutboundQuantity:300,averageDailyOutboundQuantity:10,inventoryCoverageDays:10}],total:1})
|
||||
await findButton(wrapper,'更新数据').trigger('click');await flushPromises()
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0].salesRunId).toBeUndefined()
|
||||
expect(wrapper.findComponent(tableStub).props('data')[0].windowOutboundQuantity).toBe(300)
|
||||
expect(wrapper.text()).not.toContain('有新数据可用')
|
||||
})
|
||||
it('defaults to the preceding 30 complete days and preserves unknown quantities', async () => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-12T01:00:00Z'))
|
||||
mocks.api.summary.mockResolvedValue({ context, records: [{ ...summary, observedQuantity: null, dataStatus: 'UNKNOWN' }], total: 1 })
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
|
||||
expect(wrapper.text()).toContain('暂无可用数据')
|
||||
expect(wrapper.text()).not.toContain('(已采集,未完整)')
|
||||
await findButton(wrapper, '查看近30个完整日').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent(queryStub).props('modelValue').rangePreset).toBe('LAST_30_DAYS')
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-09-12', endDate: '2026-10-11' })
|
||||
})
|
||||
it.each([[3, '2026-09-28'], [7, '2026-09-24'], [15, '2026-09-16'], [30, '2026-09-01']])('queries %s complete days across month boundaries', async (days, startDate) => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-30T16:30:00Z'))
|
||||
mocks.api.summary.mockResolvedValue({ context: { ...context, anchorDate: '2026-10-01' }, records: [summary], total: 1 })
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
|
||||
await setFilters(wrapper, { rangePreset: `LAST_${days}_DAYS` })
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate, endDate: '2026-09-30' })
|
||||
})
|
||||
it('sorts sales through the API, returns to page one, and restores sorting after failure', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
|
||||
wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
|
||||
await wrapper.get('[data-column="salesQuantity"]').trigger('click'); await flushPromises()
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ current: 1, salesSort: 'QUANTITY_ASC', salesRunId: context.salesRunId, queryAsOf: context.queryAsOf })
|
||||
mocks.api.summary.mockRejectedValueOnce(new Error('排序失败'))
|
||||
await wrapper.get('[data-column="salesQuantity"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-column="salesQuantity"]').attributes('aria-label')).toContain('当前升序')
|
||||
})
|
||||
it.each(['sales', 'inventory'] as const)('automatically loads the first available warehouse on %s entry', async mode => {
|
||||
mocks.routeQuery = {}
|
||||
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-B' }, { warehouseCode: 'WH-A' }] })
|
||||
@@ -127,18 +202,18 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(signal.aborted).toBe(true)
|
||||
resolveOld({ context, records: [summary], total: 1 }); await flushPromises()
|
||||
expect(wrapper.findComponent(tableStub).props('data')).toEqual([])
|
||||
await setFilters(wrapper, { rangePreset: 'THIS_MONTH' })
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0].rangePreset).toBe('THIS_MONTH')
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).not.toHaveProperty('startDate')
|
||||
await setFilters(wrapper, { rangePreset: 'LAST_7_DAYS' })
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0].rangePreset).toBe('CUSTOM')
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toHaveProperty('startDate')
|
||||
expect(wrapper.text()).not.toContain('请选择有效的起止日期')
|
||||
})
|
||||
it('validates an explicit fresh query against Beijing today instead of yesterday’s pinned context', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-13T01:00:00Z'))
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-13', '2026-10-13'] })
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-12', '2026-10-12'] })
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
const request = mocks.api.summary.mock.calls.at(-1)?.[0]
|
||||
expect(request).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-13', endDate: '2026-10-13' })
|
||||
expect(request).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-12', endDate: '2026-10-12' })
|
||||
expect(request).not.toHaveProperty('queryAsOf')
|
||||
})
|
||||
|
||||
@@ -146,15 +221,18 @@ describe('self-operated actual workbench request state', () => {
|
||||
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_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
|
||||
expect(wrapper.get('[data-testid="inventory-window-summary"]').text()).toContain('不含今天')
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-09-11', '2026-10-10'], attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 30, salesWindowEndDate: '2026-10-10', attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
|
||||
})
|
||||
it('selects a complete ten-day window, preserves it with pinned pagination, and uses its dates for daily trend', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-13T01:00:00Z'))
|
||||
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' })
|
||||
vi.setSystemTime(new Date('2026-10-13T01:00:00Z'))
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-03', '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()
|
||||
@@ -167,15 +245,15 @@ describe('self-operated actual workbench request state', () => {
|
||||
})
|
||||
it('keeps selected-window 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_IN_WINDOW', sortBy: 'OUTBOUND_ASC', salesWindowDays: 10 })
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 10, attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
|
||||
await setFilters(wrapper, { attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC', rangePreset: 'LAST_7_DAYS' })
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ salesWindowDays: 7, attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'OUTBOUND_ASC' })
|
||||
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' })
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2099-10-01', '2099-10-12'] })
|
||||
expect(mocks.api.inventory).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
@@ -229,6 +307,8 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesWindowDays')
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesWindowEndDate')
|
||||
expect(wrapper.text()).toContain('来自经营总览')
|
||||
await wrapper.get('button[data-column="frozenQuantity"]').trigger('click'); await flushPromises()
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ sortBy: 'FROZEN_DESC', customerCode: 'OWNER-B', sku: 'SKU-X', coverageBucket: 'DAYS_30_60', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', drilldownFrom: 'DASHBOARD', salesRunId: '11', queryAsOf: context.queryAsOf, rangePreset: 'CUSTOM', startDate: pinned.rangeStartDate, endDate: '2026-10-11' })
|
||||
await findButton(wrapper, '清除来源条件').trigger('click'); await flushPromises()
|
||||
const cleared = mocks.api.inventory.mock.calls.at(-1)?.[0]
|
||||
expect(cleared).toMatchObject({ salesRunId: '11', inventoryRunId: '0', queryAsOf: context.queryAsOf, attention: 'ALL' })
|
||||
@@ -314,7 +394,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', 'salesWindowDays', 'salesWindowEndDate', 'attention', 'threshold', 'sortBy'])
|
||||
expect(fields.map((field: { key: string }) => field.key)).toEqual(['warehouseCode', 'sku', 'rangePreset', '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' })
|
||||
@@ -385,11 +465,11 @@ describe('self-operated actual workbench request state', () => {
|
||||
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 setFilters(wrapper, { rangePreset: 'THIS_MONTH' })
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'THIS_MONTH', queryAsOf: context.queryAsOf, salesRunId: '11', inventoryRunId: '0' })
|
||||
await setFilters(wrapper, { rangePreset: 'LAST_7_DAYS' })
|
||||
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', queryAsOf: context.queryAsOf, salesRunId: '11', inventoryRunId: '0' })
|
||||
await setFilters(wrapper, { analysis: 'hot' })
|
||||
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]).toMatchObject({ queryAsOf: context.queryAsOf, rangePreset: 'CUSTOM' })
|
||||
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('windowDays')
|
||||
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode', '')
|
||||
})
|
||||
it('late first warehouse response cannot overwrite second warehouse data', async () => {
|
||||
@@ -403,17 +483,14 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(wrapper.text()).not.toContain('SKU-A')
|
||||
expect(mocks.api.summary.mock.calls[0]?.[1].aborted).toBe(true)
|
||||
})
|
||||
it('loads rankings with the fixed context when an unfinished CUSTOM date selection exists', async () => {
|
||||
it('blocks both rankings when custom dates are incomplete', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
await setFilters(wrapper, { rangePreset: 'CUSTOM' })
|
||||
expect(mocks.api.summary).toHaveBeenCalledTimes(1)
|
||||
await setFilters(wrapper, { analysis: 'hot' })
|
||||
expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, windowDays: 30 })
|
||||
expect(mocks.api.hot).not.toHaveBeenCalled()
|
||||
await setFilters(wrapper, { analysis: 'slow' })
|
||||
expect(mocks.api.slow.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf })
|
||||
expect(mocks.api.slow.mock.calls.at(-1)?.[0]).not.toHaveProperty('rangePreset')
|
||||
expect(wrapper.find('.analytics-rules').text()).toContain('实际计算区间:2026-09-12 至 2026-10-12(截止日不含)')
|
||||
expect(mocks.api.slow).not.toHaveBeenCalled()
|
||||
})
|
||||
it('failed runtime polling preserves prior available data and leaves fixed context untouched', async () => {
|
||||
mocks.api.overview.mockRejectedValue(new Error('offline'))
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RequestFence, RetryIntent, RuntimePoller, contextParams, progressText, sameContext, shownQuantity, taskRetryAllowed, validateCustomRange } from './state'
|
||||
import { RequestFence, RetryIntent, RuntimePoller, contextParams, label, reasonsText, progressText, sameContext, shownQuantity, taskRetryAllowed, validateCustomRange } from './state'
|
||||
import type { 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('explains pending publication and unfinished days instead of a generic unknown reason', () => {
|
||||
expect(label('REBUILD_REQUIRED')).toBe('销量待重新统计发布')
|
||||
expect(reasonsText(['SALES_REBUILD_REQUIRED', 'TODAY_INCOMPLETE', 'SALES_SOURCE_TIMEZONE_UNKNOWN'])).toBe('销量有变动,待重新统计发布、今日尚未结束,不能作为完整自然日、销量来源时区未确认')
|
||||
})
|
||||
it('aborts old warehouse request and rejects late completion even if adapter ignores abort', () => {
|
||||
const fence = new RequestFence()
|
||||
const oldWarehouse = fence.begin()
|
||||
|
||||
@@ -58,7 +58,7 @@ export const statusLabels: Record<string, string> = {
|
||||
BUILDING: '计算中', READY: '待发布', INITIALIZING: '首采中', COVERED: '覆盖完整',
|
||||
ACCOUNT_DISCOVERY: '账号共享出库目录', WAREHOUSE_SYNC: '本仓采集',
|
||||
VALID: '有效', CURRENT: '已核对', LAGGING: '核对滞后', MISSING: '尚无可用数据',
|
||||
UNCOVERED: '覆盖不足', INCOMPLETE: '未完整',
|
||||
UNCOVERED: '覆盖不足', INCOMPLETE: '未完整', REBUILD_REQUIRED: '销量待重新统计发布',
|
||||
NO_AVAILABLE_INVENTORY: '无可售库存', UNIT_UNKNOWN_OR_CONFLICT: '数量单位待核对',
|
||||
SALES_UNTRUSTED: '销量待核对', COVERAGE_INSUFFICIENT: '覆盖不足', OBSERVATION_INSUFFICIENT: '观察期不足',
|
||||
SALES_UNKNOWN: '销量未知', NO_OUTBOUND_IN_WINDOW: '所选窗口无出库', NO_OUTBOUND_30_DAYS: '近30天无出库', INVENTORY_INVALID: '库存异常',
|
||||
@@ -87,6 +87,8 @@ export const reasonLabels: Record<string, string> = {
|
||||
DAILY_BUDGET_NOT_CONFIGURED: '账号日额度未配置', DAILY_BUDGET_EXHAUSTED: '账号日额度已用完',
|
||||
SALES_MISSING: '尚无销量发布版本', INVENTORY_MISSING: '尚无库存发布版本', SALES_BLOCKED: '销量修订阻断',
|
||||
SALES_STALE: '销量数据过期', REVISION_REVIEW_UNKNOWN: '修订核对状态未知', REVISION_REVIEW_LAGGING: '修订核对滞后',
|
||||
SALES_REBUILD_REQUIRED: '销量有变动,待重新统计发布', TODAY_INCOMPLETE: '今日尚未结束,不能作为完整自然日',
|
||||
SALES_SOURCE_TIMEZONE_UNKNOWN: '销量来源时区未确认',
|
||||
}
|
||||
export function reasonsText(reasons: readonly string[] | null | undefined) { return reasons?.map(reason => reasonLabels[reason] ?? statusLabels[reason] ?? '存在待核对项').join('、') || '—' }
|
||||
export function phaseText(phase: string | null | undefined) {
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('inventory view and turnover query contract', () => {
|
||||
})
|
||||
it('validates independent focus, threshold, group, date and pagination boundaries', () => {
|
||||
expect(() => validateTurnover(valid, '2026-10-12')).not.toThrow()
|
||||
for (const patch of [{ endDate: '2026-02-30' }, { endDate: '2026-10-13' }, { groupBy: 'OWNER', sku: 'X' }, { groupBy: 'OWNER', sortBy: 'COVERAGE_DESC' }, { focus: 'COVERAGE_GT' }, { coverageDaysThreshold: 1.5 }, { size: 101 }, { current: 0 }, { baseWindowDays: 10 }]) expect(() => validateTurnover({ ...valid, ...patch } as TurnoverQuery, '2026-10-12')).toThrow()
|
||||
for (const patch of [{ endDate: '2026-02-30' }, { endDate: '2026-10-13' }, { groupBy: 'OWNER', sku: 'X' }, { groupBy: 'OWNER', sortBy: 'COVERAGE_DESC' }, { focus: 'COVERAGE_GT' }, { coverageDaysThreshold: 1.5 }, { size: 101 }, { current: 0 }, { baseWindowDays: 367 }]) expect(() => validateTurnover({ ...valid, ...patch } as TurnoverQuery, '2026-10-12')).toThrow()
|
||||
expect(() => validateTurnover({ ...valid, focus: 'COVERAGE_LE', coverageDaysThreshold: 45 }, '2026-10-12')).not.toThrow()
|
||||
})
|
||||
it('uses Beijing yesterday across UTC day boundaries', () => {
|
||||
|
||||
@@ -26,10 +26,10 @@ export function yesterdayBeijing(now = new Date()) {
|
||||
export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing()) {
|
||||
if (!q.warehouseCode.trim()) throw new Error('请选择可用仓库')
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(q.endDate) || !Number.isFinite(Date.parse(q.endDate)) || new Date(q.endDate).toISOString().slice(0, 10) !== q.endDate || q.endDate > latestDate) throw new Error('截止日必须是已结束的北京时间日期')
|
||||
if (![7, 15, 30].includes(q.baseWindowDays)) throw new Error('计算基准仅支持7、15、30天')
|
||||
if (!Number.isInteger(q.baseWindowDays) || q.baseWindowDays < 1 || q.baseWindowDays > 366) throw new Error('统计区间须为1至366个完整自然日')
|
||||
if (!['SKU', 'OWNER'].includes(q.groupBy)) throw new Error('分组无效')
|
||||
if (q.groupBy === 'OWNER' && q.sku) throw new Error('货主汇总不能携带SKU搜索')
|
||||
if (!['ALL', 'NO_OUTBOUND_30D', 'COVERAGE_GT', 'COVERAGE_LE', 'FROZEN', 'DEFECTIVE', 'UNASSESSABLE'].includes(q.focus)) throw new Error('关注条件无效')
|
||||
if (!['ALL', 'NO_OUTBOUND_IN_WINDOW', 'COVERAGE_GT', 'COVERAGE_LE', 'FROZEN', 'DEFECTIVE', 'UNASSESSABLE'].includes(q.focus)) throw new Error('关注条件无效')
|
||||
if (q.coverageDaysThreshold != null && (!Number.isSafeInteger(q.coverageDaysThreshold) || q.coverageDaysThreshold <= 0)) throw new Error('阈值必须为正整数天数')
|
||||
if (['COVERAGE_GT', 'COVERAGE_LE'].includes(q.focus) && q.coverageDaysThreshold == null) throw new Error('请填写快慢判断的天数阈值')
|
||||
if (q.turnoverStatus && q.turnoverStatus !== 'INSUFFICIENT') throw new Error('周转样本条件无效')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { QueryContext, Quantity } from './types'
|
||||
|
||||
// Fixed-version read contract; Long identifiers and totals use the service's string serializer.
|
||||
export type TurnoverWindow = 7 | 15 | 30
|
||||
export type TurnoverWindow = number
|
||||
export type TurnoverGroup = 'SKU' | 'OWNER'
|
||||
export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE'
|
||||
export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_IN_WINDOW' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE'
|
||||
export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'OUTBOUND_7' | 'OUTBOUND_15' | 'OUTBOUND_30' | 'AVERAGE_DAILY' | 'COVERAGE' | 'FROZEN' | 'DEFECTIVE' | 'AVERAGE_PHYSICAL' | 'TURNOVER'
|
||||
export type TurnoverSort = `${TurnoverSortMetric}_${'ASC' | 'DESC'}` | 'NO_OUTBOUND_PHYSICAL_DESC'
|
||||
export interface TurnoverQuery {
|
||||
@@ -27,7 +27,7 @@ export interface TurnoverRow {
|
||||
inventoryCoverageDays: Quantity; coverageStatus: string; averagePhysicalQuantity: Quantity
|
||||
quantityTurnoverCount: Quantity; turnoverStatus: string; qualifiedSampleDays: number; requiredSampleDays: number
|
||||
missingSampleDates: string[]; sampleReasonCodes: string[]; lastObservedOutboundDate: string | null
|
||||
attentionReasons: string[]; noOutbound30d: TurnoverRisk; slowCoverage: TurnoverRisk; unassessableSkuCount: number
|
||||
attentionReasons: string[]; noOutboundInWindow: TurnoverRisk; slowCoverage: TurnoverRisk; unassessableSkuCount: number
|
||||
}
|
||||
export interface TurnoverContext {
|
||||
snapshotContext: string; sourceAccount: string; warehouseCode: string; endDate: string
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export type Quantity = string | number | null
|
||||
export type RangePreset = 'TODAY' | 'LAST_3_DAYS' | 'THIS_WEEK' | 'LAST_15_DAYS' | 'THIS_MONTH' | 'CUSTOM'
|
||||
export type RangePreset = 'TODAY' | 'LAST_3_DAYS' | 'LAST_7_DAYS' | 'LAST_30_DAYS' | 'THIS_WEEK' | 'LAST_15_DAYS' | 'THIS_MONTH' | 'CUSTOM'
|
||||
export type InventoryAttention = 'ALL' | 'NO_OUTBOUND_IN_WINDOW' | 'COVERAGE_GT_THRESHOLD' | 'FROZEN_STOCK' | 'DEFECTIVE_STOCK' | 'UNASSESSABLE'
|
||||
export type InventorySort = 'AVAILABLE_DESC' | 'COVERAGE_DAYS_DESC' | 'PHYSICAL_DESC' | 'OUTBOUND_ASC'
|
||||
export type InventorySortMetric = 'AVAILABLE' | 'COVERAGE_DAYS' | 'PHYSICAL' | 'FROZEN' | 'DEFECTIVE' | 'TRANSIT' | 'OUTBOUND' | 'AVERAGE_DAILY'
|
||||
export type InventorySort = `${InventorySortMetric}_${'ASC' | 'DESC'}`
|
||||
export type CoverageBucket = 'UNKNOWN' | 'NO_AVAILABLE' | 'NO_OUTBOUND_IN_WINDOW' | 'DAYS_0_30' | 'DAYS_30_60' | 'DAYS_60_90' | 'DAYS_GT_90'
|
||||
export interface QueryContext {
|
||||
salesRunId: string | null
|
||||
@@ -46,6 +47,7 @@ export interface Dashboard {
|
||||
noOutboundSkus: DashboardRanking
|
||||
}
|
||||
export interface BusinessQuery extends Scope {
|
||||
salesSort?: 'QUANTITY_ASC' | 'QUANTITY_DESC'
|
||||
customerCode?: string
|
||||
sku?: string
|
||||
current: number
|
||||
@@ -121,8 +123,13 @@ 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 InventoryQuery extends BusinessQuery { salesWindowDays?: number; salesWindowEndDate?: string }
|
||||
export type InventoryMetric = Omit<Metric, 'outboundQuantity' | 'outboundQuantity30d'> & {
|
||||
windowOutboundQuantity: Quantity
|
||||
observedWindowOutboundQuantity?: Quantity
|
||||
observationEligible?: boolean
|
||||
earliestReliableEvidenceAt?: string | null
|
||||
}
|
||||
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