This commit is contained in:
2026-10-14 11:02:56 +08:00
parent 3f728cd0b2
commit e0f54edcc9
24 changed files with 352 additions and 105 deletions
@@ -38,7 +38,6 @@ function initialFilters(): QueryModel { return { warehouseCode: typeof route.que
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)
@@ -82,11 +81,11 @@ const fields = computed<QueryField[]>(() => {
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
]
if (props.mode === 'inventory') result.push(
{ 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: '数据不足/待观察' }] },
...(!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: '全品质实物从高到低' }, ...(defaultInventoryWindow.value ? [{ 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: '全品质实物从高到低' }, { value: 'OUTBOUND_ASC', label: '区间出库从低到高' }] },
)
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' }] })
@@ -137,7 +136,11 @@ function query(): InventoryQuery {
if (drilldown.value?.analysis === 'hot') Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
else result.windowDays = windowDays.value
}
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, appliedWindow.value, drilldown.value?.inventory)
if (props.mode === 'inventory') {
Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
if (drilldown.value) Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
else Object.assign(result, appliedWindow.value)
}
return result
}
async function load(reset = false, keepContext = false) {
@@ -214,16 +217,12 @@ function applySalesRange(anchor?: string) {
function search() {
if (summaryMode.value && !applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
let nextWindow = appliedWindow.value
if (props.mode === 'inventory') {
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 }
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))) {
@@ -303,7 +302,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' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : 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>