feat(selfop): select inventory sales window and cutoff date

This commit is contained in:
2026-10-13 12:36:45 +08:00
parent 58df0ed9e3
commit 9dcd991ddb
7 changed files with 104 additions and 34 deletions
@@ -12,10 +12,10 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { analyticsApi } from '../api/analytics'
import type { BusinessQuery, DailyResponse, InventoryAttention, InventorySort, Metric, Overview, QueryContext, RangePreset, SalesSummary } from '../types'
import type { BusinessQuery, DailyResponse, InventoryAttention, InventoryMetric, InventoryQuery, InventorySort, Metric, Overview, QueryContext, RangePreset, SalesSummary } from '../types'
import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beijingTime, reasonsText, quantityText, sameContext, shownQuantity, validateCustomRange } from '../state'
import SkuTrend from './SkuTrend.vue'
import { expired, inventoryExpired, parseDashboardDrilldown, type DashboardDrilldown } from '../dashboardNavigation'
import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation'
const props = defineProps<{ mode: 'sales' | 'inventory' }>()
const route = useRoute()
@@ -31,12 +31,16 @@ function readDrilldown() {
catch (cause) { drilldown.value = null; origin.value = null; routeProblem.value = cause instanceof Error ? cause.message : '经营总览下钻条件无效' }
}
readDrilldown()
function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'TODAY', windowDays: 30, attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customStart: drilldown.value?.startDate ?? '', customEnd: drilldown.value?.endDate ?? '' } }
function beijingToday() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) }
function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'TODAY', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customStart: drilldown.value?.startDate ?? '', customEnd: drilldown.value?.endDate ?? '' } }
const filters = ref<QueryModel>(initialFilters())
const applied = ref({ sku: String(filters.value.sku) })
const appliedWindow = ref({ salesWindowDays: 30 as 10 | 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) })
const defaultInventoryWindow = computed(() => Number(filters.value.salesWindowDays) === 30 && filters.value.salesWindowEndDate === previousDate(context.value?.anchorDate ?? beijingToday()))
const displayedWindowDays = computed(() => context.value?.calculationWindowDays ?? appliedWindow.value.salesWindowDays)
const overview = ref<Overview | null>(null)
const context = ref<QueryContext | null>(drilldown.value?.context ?? null)
const rows = ref<(SalesSummary | Metric)[]>([])
const rows = ref<(SalesSummary | Metric | InventoryMetric)[]>([])
const daily = ref<DailyResponse | null>(null)
const trendOpen = ref(false)
const trendLoading = ref(false)
@@ -68,9 +72,11 @@ const fields = computed<QueryField[]>(() => {
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
]
if (props.mode === 'inventory') result.push(
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, { value: 'NO_OUTBOUND_30D', label: '近30天无出库' }, { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
{ key: 'salesWindowDays', type: 'select', label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] },
{ key: 'salesWindowEndDate', type: 'date', label: '统计截止日(含)', width: 180, props: { clearable: false } },
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, ...(defaultInventoryWindow.value ? [{ value: 'NO_OUTBOUND_30D', label: '近30天无出库' }] : []), { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
{ key: 'threshold', type: 'input', label: '可售天数阈值', width: 160, placeholder: '可售天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } },
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] },
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, ...(defaultInventoryWindow.value ? [{ value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] : [])] },
)
else {
result.push({ key: 'analysis', type: 'select', label: '销量类别', width: 170, props: { clearable: false }, options: [{ value: 'summary', label: 'SKU销量' }, { value: 'hot', label: '热销Top100' }, { value: 'slow', label: '滞销Top100' }] })
@@ -94,31 +100,32 @@ const columns = computed<DataTableColumn[]>(() => [
{ key: 'frozenQuantity', label: '全品质冻结', width: 110, slotName: 'frozenQuantity' },
{ key: 'defectivePhysicalQuantity', label: '次品实物', width: 110, slotName: 'defectivePhysicalQuantity' },
{ key: 'transitQuantity', label: '在途', width: 110, slotName: 'transitQuantity' },
{ key: 'outboundQuantity30d', label: '近30天出库', width: 120, slotName: 'outboundQuantity30d' },
{ key: 'averageDailyOutboundQuantity', label: '30天日均出库', width: 140, slotName: 'averageDailyOutboundQuantity' },
{ key: 'windowOutboundQuantity', label: `${displayedWindowDays.value}天窗口出库`, width: 120, slotName: 'windowOutboundQuantity' },
{ key: 'averageDailyOutboundQuantity', label: `${displayedWindowDays.value}天日均出库`, width: 140, slotName: 'averageDailyOutboundQuantity' },
{ key: 'inventoryCoverageDays', label: '可售天数', width: 110, slotName: 'inventoryCoverageDays' },
{ key: 'lastOutbound', label: '最近已观测出库', minWidth: 220, slotName: 'lastOutbound' },
{ key: 'attention', label: '关注原因', minWidth: 160, slotName: 'attention' },
] : [{ key: 'quantity', label: summaryMode.value ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity' }]),
{ key: 'status', label: '数据状态', minWidth: 130, slotName: 'status' },
{ key: 'reason', label: '说明', minWidth: 220, slotName: 'reason' },
].map(column => ['quantity', 'physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'outboundQuantity30d', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'].includes(String(column.key)) ? { ...column, align: 'right' } : column))
].map(column => ['quantity', 'physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'windowOutboundQuantity', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'].includes(String(column.key)) ? { ...column, align: 'right' } : column))
const dataFence = new RequestFence()
const dailyFence = new RequestFence()
const runtimeFence = new RequestFence()
function asSummary(row: unknown) { return row as SalesSummary }
function asMetric(row: unknown) { return row as Metric }
function query(): BusinessQuery {
const result: BusinessQuery = { sourceAccount, warehouseCode: warehouseCode.value, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) }
function asInventory(row: unknown) { return row as InventoryMetric }
function query(): InventoryQuery {
const result: InventoryQuery = { sourceAccount, warehouseCode: warehouseCode.value, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) }
if (drilldown.value?.customerCode) result.customerCode = drilldown.value.customerCode
if (summaryMode.value) {
result.rangePreset = preset.value
if (preset.value === 'CUSTOM') { result.startDate = customStart.value; result.endDate = customEnd.value }
} else if (props.mode === 'sales' && tab.value === 'hot') result.windowDays = windowDays.value
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, appliedWindow.value, drilldown.value?.inventory)
return result
}
async function load(reset = false) {
async function load(reset = false, keepContext = false) {
if (!permitted.value || !warehouseCode.value || routeProblem.value) return
if (origin.value && context.value && expired(context.value)) { error.value = '原图查询已过期,请返回经营总览整体刷新'; return }
if (drilldown.value && props.mode === 'inventory' && context.value && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览'; return }
@@ -126,7 +133,7 @@ async function load(reset = false) {
const problem = validateCustomRange(customStart.value, customEnd.value, context.value?.anchorDate)
if (problem) { error.value = problem; return }
}
if (reset) { if (!origin.value) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
if (reset) { if (!origin.value && !keepContext) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
trendOpen.value = false
dailyFence.cancel()
const request = dataFence.begin()
@@ -147,7 +154,7 @@ async function load(reset = false) {
} catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '查询失败,请重新查询'); error.value = errorDetails.value.message } }
finally { if (request.current()) loading.value = false }
}
async function showTrend(row: SalesSummary | Metric) {
async function showTrend(row: SalesSummary | Metric | InventoryMetric) {
trendOpen.value = true
trendLoading.value = true
daily.value = null
@@ -186,17 +193,31 @@ const poller = new RuntimePoller(readRuntime, () => runtimeFence.cancel())
watch(trendOpen, open => { if (!open) { dailyFence.cancel(); daily.value = null; trendLoading.value = false; trendError.value = ''; trendErrorDetails.value = null } })
function visibility() { poller.setVisible(!document.hidden) }
function search() {
let nextWindow = appliedWindow.value
if (props.mode === 'inventory') {
const end = String(filters.value.salesWindowEndDate ?? '')
const days = Number(filters.value.salesWindowDays)
const problem = validateCustomRange(end, end, previousDate(origin.value?.context.anchorDate ?? beijingToday()))
if (problem || ![10, 30].includes(days)) { error.value = problem || '请选择10天或30天销量窗口'; return }
nextWindow = { salesWindowDays: days as 10 | 30, salesWindowEndDate: end }
if (!defaultInventoryWindow.value) {
if (attention.value === 'NO_OUTBOUND_30D') filters.value.attention = 'ALL'
if (sortBy.value === 'OUTBOUND_30D_ASC') filters.value.sortBy = 'AVAILABLE_DESC'
}
}
const rawThreshold = thresholdInput.value.trim()
if (props.mode === 'inventory' && ((rawThreshold && (!/^\d+$/.test(rawThreshold) || !Number.isSafeInteger(Number(rawThreshold)) || Number(rawThreshold) <= 0)) || (attention.value === 'COVERAGE_GT_THRESHOLD' && !rawThreshold))) {
error.value = '可售天数阈值须为正整数;选择超过阈值关注时必须填写'
return
}
const keepContext = props.mode === 'inventory' && nextWindow.salesWindowDays === appliedWindow.value.salesWindowDays && nextWindow.salesWindowEndDate === appliedWindow.value.salesWindowEndDate && sortBy.value !== appliedInventory.value.sortBy
appliedWindow.value = nextWindow
appliedInventory.value = { attention: attention.value, sortBy: sortBy.value, coverageDaysThreshold: rawThreshold ? Number(rawThreshold) : undefined }
applied.value = { sku: String(filters.value.sku ?? '').trim() }
current.value = 1
void load(true)
void load(true, keepContext)
}
function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } }
function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } }
function returnOverview() {
const saved = origin.value
void router.push({ path: '/self-operated/overview', query: saved ? { warehouseCode: warehouseCode.value, context: JSON.stringify(saved.context), overviewReturn: JSON.stringify(saved.returnTo) } : {} })
@@ -210,13 +231,14 @@ async function clearDrilldown() {
function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) }
let applyingRoute = false
watch(warehouseCode, () => { if (applyingRoute) return; drilldown.value = null; origin.value = null; trendOpen.value = false; dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) })
watch([() => filters.value.salesWindowDays, () => filters.value.salesWindowEndDate], () => { if (!applyingRoute && props.mode === 'inventory' && !drilldown.value) search() })
watch([preset, tab, windowDays], () => { if (applyingRoute) return; current.value = 1; if (!summaryMode.value || preset.value !== 'CUSTOM' || customStart.value && customEnd.value) void load() })
watch([current, size], () => { if (!applyingRoute) void load() })
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
watch(() => route.query, async () => {
if (route.path && route.path !== `/self-operated/${props.mode}`) return
applyingRoute = true; dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; rows.value = []; total.value = 0
readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); applied.value = { sku: String(filters.value.sku ?? '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1
readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1
await nextTick(); applyingRoute = false; void load()
})
onActivated(() => { visibility(); poller.start() })
@@ -236,10 +258,11 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
<details class="analytics-rules">
<summary>统计说明 · 北京时间/件</summary>
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
<p v-if="mode === 'inventory'">实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</p>
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
<p v-if="mode === 'inventory'">预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</p>
<p v-if="context">查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}</p>
<p v-if="context">实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)<template v-if="context.calculationWindowDays != null">;{{ context.calculationWindowDays }} 天</template>。</p>
<p v-for="state in (overview?.warehouses ?? []).filter(item => item.warehouseCode === warehouseCode)" :key="state.dataFamily">{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}数据:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。</p>
@@ -255,7 +278,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
<template #quantity="{ row }">{{ quantityText(summaryMode ? shownQuantity(asSummary(row)) : asMetric(row).outboundQuantity) }} {{ summaryMode && asSummary(row).dataStatus !== 'COMPLETE' ? '(已采集,未完整)' : '' }}</template>
<template #status="{ row }">{{ label(summaryMode ? asSummary(row).dataStatus : asMetric(row).status) }}</template>
<template #reason="{ row }">{{ reasonsText(summaryMode ? asSummary(row).reasonCodes : asMetric(row).reasonCodes) }}</template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'outboundQuantity30d', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'] as const" :key="key" #[key]="{ row }">{{ quantityText(asMetric(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'windowOutboundQuantity', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'] as const" :key="key" #[key]="{ row }">{{ quantityText(asInventory(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
</DataTable>
</ListPageLayout>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>