2026-10-12 20:33:39 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import '@/styles/adminDensity.css'
|
2026-10-20 19:16:47 +08:00
|
|
|
|
import '@/styles/adminWorkbench.css'
|
2026-10-14 00:07:05 +08:00
|
|
|
|
import { Info } from '@lucide/vue'
|
2026-10-13 00:45:41 +08:00
|
|
|
|
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
2026-10-12 20:33:39 +08:00
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
|
|
|
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
|
|
|
|
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
|
|
|
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
2026-10-12 22:06:45 +08:00
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
2026-10-12 20:33:39 +08:00
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import { analyticsApi } from '../api/analytics'
|
2026-10-13 12:36:45 +08:00
|
|
|
|
import type { BusinessQuery, DailyResponse, InventoryAttention, InventoryMetric, InventoryQuery, InventorySort, Metric, Overview, QueryContext, RangePreset, SalesSummary } from '../types'
|
2026-10-12 20:33:39 +08:00
|
|
|
|
import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beijingTime, reasonsText, quantityText, sameContext, shownQuantity, validateCustomRange } from '../state'
|
|
|
|
|
|
import SkuTrend from './SkuTrend.vue'
|
2026-10-14 16:15:40 +08:00
|
|
|
|
import { useOwnerDirectory } from '../useOwnerDirectory'
|
2026-10-13 12:36:45 +08:00
|
|
|
|
import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation'
|
2026-10-14 12:39:16 +08:00
|
|
|
|
import { overviewRange, overviewRangeOptions } from '../overviewRange'
|
|
|
|
|
|
import { inventorySortColumns, inventorySortOptions } from '../inventorySorting'
|
|
|
|
|
|
import { turnoverNumber as dailyAverageText } from '../turnoverPresentation'
|
2026-10-12 20:33:39 +08:00
|
|
|
|
|
2026-10-14 09:23:29 +08:00
|
|
|
|
const emit = defineEmits<{ warehouseSelected: [code: string] }>()
|
2026-10-12 20:33:39 +08:00
|
|
|
|
const props = defineProps<{ mode: 'sales' | 'inventory' }>()
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
|
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
|
2026-10-13 00:45:41 +08:00
|
|
|
|
const routeProblem = ref('')
|
|
|
|
|
|
const drilldown = ref<DashboardDrilldown | null>(null)
|
|
|
|
|
|
const origin = ref<DashboardDrilldown | null>(null)
|
|
|
|
|
|
function readDrilldown() {
|
|
|
|
|
|
routeProblem.value = ''
|
|
|
|
|
|
try { drilldown.value = parseDashboardDrilldown(route.query, props.mode); origin.value = drilldown.value }
|
|
|
|
|
|
catch (cause) { drilldown.value = null; origin.value = null; routeProblem.value = cause instanceof Error ? cause.message : '经营总览下钻条件无效' }
|
|
|
|
|
|
}
|
|
|
|
|
|
readDrilldown()
|
2026-10-13 12:36:45 +08:00
|
|
|
|
function beijingToday() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) }
|
2026-10-14 16:15:40 +08:00
|
|
|
|
function initialFilters(): QueryModel { return { customerCode: drilldown.value?.customerCode ?? '', warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'LAST_30_DAYS', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customRange: drilldown.value?.startDate && drilldown.value?.endDate ? [drilldown.value.startDate, drilldown.value.endDate] : [] } }
|
|
|
|
|
|
const ownerDirectory = useOwnerDirectory()
|
2026-10-12 21:36:24 +08:00
|
|
|
|
const filters = ref<QueryModel>(initialFilters())
|
2026-10-14 16:15:40 +08:00
|
|
|
|
const applied = ref({ sku: String(filters.value.sku), customerCode: String(filters.value.customerCode || '') })
|
2026-10-14 12:39:16 +08:00
|
|
|
|
const appliedWindow = ref({ salesWindowDays: 30 as number, salesWindowEndDate: String(filters.value.salesWindowEndDate) })
|
2026-10-13 12:36:45 +08:00
|
|
|
|
const displayedWindowDays = computed(() => context.value?.calculationWindowDays ?? appliedWindow.value.salesWindowDays)
|
2026-10-12 20:33:39 +08:00
|
|
|
|
const overview = ref<Overview | null>(null)
|
2026-10-13 00:45:41 +08:00
|
|
|
|
const context = ref<QueryContext | null>(drilldown.value?.context ?? null)
|
2026-10-13 12:36:45 +08:00
|
|
|
|
const rows = ref<(SalesSummary | Metric | InventoryMetric)[]>([])
|
2026-10-12 20:33:39 +08:00
|
|
|
|
const daily = ref<DailyResponse | null>(null)
|
2026-10-12 22:06:45 +08:00
|
|
|
|
const trendOpen = ref(false)
|
|
|
|
|
|
const trendLoading = ref(false)
|
|
|
|
|
|
const trendError = ref('')
|
|
|
|
|
|
const trendErrorDetails = ref<ApiErrorDetails | null>(null)
|
2026-10-12 21:36:24 +08:00
|
|
|
|
const attention = computed(() => (filters.value.attention || 'ALL') as InventoryAttention)
|
|
|
|
|
|
const sortBy = computed(() => (filters.value.sortBy || 'AVAILABLE_DESC') as InventorySort)
|
|
|
|
|
|
const thresholdInput = computed(() => String(filters.value.threshold ?? ''))
|
2026-10-13 00:45:41 +08:00
|
|
|
|
const appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' })
|
2026-10-14 12:39:16 +08:00
|
|
|
|
const displayedInventorySort = ref<InventorySort | null>(null)
|
2026-10-12 20:33:39 +08:00
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const error = ref('')
|
|
|
|
|
|
const errorDetails = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const runtimeError = ref('')
|
|
|
|
|
|
const newData = ref(false)
|
|
|
|
|
|
const newDay = ref(false)
|
|
|
|
|
|
const current = ref(1)
|
2026-10-14 12:39:16 +08:00
|
|
|
|
let changingSortPage = false
|
|
|
|
|
|
async function setSortPage(page: number) {
|
|
|
|
|
|
changingSortPage = true
|
|
|
|
|
|
current.value = page
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
changingSortPage = false
|
|
|
|
|
|
}
|
2026-10-12 20:33:39 +08:00
|
|
|
|
const size = ref(20)
|
|
|
|
|
|
const total = ref(0)
|
2026-10-14 12:39:16 +08:00
|
|
|
|
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)
|
2026-10-13 13:43:16 +08:00
|
|
|
|
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 }
|
2026-10-14 12:39:16 +08:00
|
|
|
|
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 } }
|
|
|
|
|
|
|
|
|
|
|
|
}
|
2026-10-13 13:43:16 +08:00
|
|
|
|
const appliedSalesRange = ref<SalesRange | null>(draftSalesRange())
|
|
|
|
|
|
const salesRangePending = computed(() => props.mode === 'sales' && tab.value === 'summary' && JSON.stringify(draftSalesRange()) !== JSON.stringify(appliedSalesRange.value))
|
2026-10-12 21:36:24 +08:00
|
|
|
|
const tab = computed(() => (filters.value.analysis || 'summary') as 'summary' | 'hot' | 'slow')
|
|
|
|
|
|
const windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30)
|
2026-10-12 20:33:39 +08:00
|
|
|
|
const sourceAccount = 'lingxing-primary'
|
|
|
|
|
|
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
|
2026-10-13 16:52:09 +08:00
|
|
|
|
let selectedWarehouse = warehouseCode.value
|
|
|
|
|
|
function defaultWarehouse() { return overview.value?.warehouses.find(row => row.warehouseCode)?.warehouseCode ?? '' }
|
2026-10-14 09:23:29 +08:00
|
|
|
|
watch(warehouseCode, code => { if (code) { selectedWarehouse = code; emit('warehouseSelected', code) } }, { immediate: true })
|
2026-10-12 21:36:24 +08:00
|
|
|
|
const fields = computed<QueryField[]>(() => {
|
|
|
|
|
|
const result: QueryField[] = [
|
2026-10-13 16:52:09 +08:00
|
|
|
|
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', props: { clearable: false }, options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] },
|
2026-10-20 19:16:47 +08:00
|
|
|
|
{ key: 'customerCode', label: '货主', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '货主名称或编码', props: { filterable: true, clearable: true, disabled: !!drilldown.value } },
|
2026-10-12 21:36:24 +08:00
|
|
|
|
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
|
|
|
|
|
|
]
|
|
|
|
|
|
if (props.mode === 'inventory') result.push(
|
2026-10-14 11:02:56 +08:00
|
|
|
|
{ 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: '数据不足/待观察' }] },
|
2026-10-14 09:23:29 +08:00
|
|
|
|
{ key: 'threshold', type: 'input', label: '可售天数阈值', width: 180, placeholder: '天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } },
|
2026-10-14 12:39:16 +08:00
|
|
|
|
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: inventorySortOptions },
|
2026-10-12 21:36:24 +08:00
|
|
|
|
)
|
|
|
|
|
|
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' }] })
|
|
|
|
|
|
}
|
2026-10-14 12:39:16 +08:00
|
|
|
|
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()) } })
|
|
|
|
|
|
|
2026-10-13 00:45:41 +08:00
|
|
|
|
return drilldown.value ? result.map(field => field.key === 'warehouseCode' ? field : { ...field, props: { ...field.props, disabled: true } }) : result
|
2026-10-12 21:36:24 +08:00
|
|
|
|
})
|
2026-10-14 12:39:16 +08:00
|
|
|
|
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) }
|
|
|
|
|
|
}
|
2026-10-12 20:33:39 +08:00
|
|
|
|
const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
|
|
|
|
|
|
const columns = computed<DataTableColumn[]>(() => [
|
|
|
|
|
|
...(summaryMode.value ? [{ key: 'actions', label: '操作', width: 110, slotName: 'actions', fixed: 'left' }] : []),
|
2026-10-20 19:16:47 +08:00
|
|
|
|
{ prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku', fixed: 'left' } : {}) },
|
2026-10-12 21:36:24 +08:00
|
|
|
|
{ prop: 'productName', label: '商品名称', minWidth: 190 },
|
|
|
|
|
|
{ key: 'owner', label: '所属货主', minWidth: 140, slotName: 'owner' },
|
|
|
|
|
|
{ key: 'unit', label: '单位', width: 70, slotName: 'unit' },
|
2026-10-12 20:33:39 +08:00
|
|
|
|
...(props.mode === 'inventory' ? [
|
|
|
|
|
|
{ key: 'physicalQuantity', label: '全品质实物', width: 110, slotName: 'physicalQuantity' },
|
|
|
|
|
|
{ key: 'availableQuantity', label: '正品可售', width: 110, slotName: 'availableQuantity' },
|
|
|
|
|
|
{ key: 'frozenQuantity', label: '全品质冻结', width: 110, slotName: 'frozenQuantity' },
|
|
|
|
|
|
{ key: 'defectivePhysicalQuantity', label: '次品实物', width: 110, slotName: 'defectivePhysicalQuantity' },
|
|
|
|
|
|
{ key: 'transitQuantity', label: '在途', width: 110, slotName: 'transitQuantity' },
|
2026-10-13 12:36:45 +08:00
|
|
|
|
{ key: 'windowOutboundQuantity', label: `${displayedWindowDays.value}天窗口出库`, width: 120, slotName: 'windowOutboundQuantity' },
|
|
|
|
|
|
{ key: 'averageDailyOutboundQuantity', label: `${displayedWindowDays.value}天日均出库`, width: 140, slotName: 'averageDailyOutboundQuantity' },
|
2026-10-12 20:33:39 +08:00
|
|
|
|
{ key: 'inventoryCoverageDays', label: '可售天数', width: 110, slotName: 'inventoryCoverageDays' },
|
|
|
|
|
|
{ key: 'lastOutbound', label: '最近已观测出库', minWidth: 220, slotName: 'lastOutbound' },
|
|
|
|
|
|
{ key: 'attention', label: '关注原因', minWidth: 160, slotName: 'attention' },
|
2026-10-14 12:39:16 +08:00
|
|
|
|
] : [{ key: 'quantity', label: summaryMode.value || tab.value === 'hot' ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity', ...(summaryMode.value ? { headerSlotName: 'salesSortHeader' } : {}) }]),
|
2026-10-12 20:33:39 +08:00
|
|
|
|
{ key: 'status', label: '数据状态', minWidth: 130, slotName: 'status' },
|
|
|
|
|
|
{ key: 'reason', label: '说明', minWidth: 220, slotName: 'reason' },
|
2026-10-14 12:39:16 +08:00
|
|
|
|
].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)
|
|
|
|
|
|
}
|
2026-10-12 20:33:39 +08:00
|
|
|
|
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 }
|
2026-10-13 12:36:45 +08:00
|
|
|
|
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) }
|
2026-10-14 16:15:40 +08:00
|
|
|
|
if (drilldown.value?.customerCode || applied.value.customerCode) result.customerCode = drilldown.value?.customerCode || applied.value.customerCode
|
2026-10-12 20:33:39 +08:00
|
|
|
|
if (summaryMode.value) {
|
2026-10-14 12:39:16 +08:00
|
|
|
|
Object.assign(result, appliedSalesRange.value, { salesSort: salesSort.value })
|
2026-10-13 18:47:56 +08:00
|
|
|
|
} else if (props.mode === 'sales' && tab.value === 'hot') {
|
2026-10-14 12:39:16 +08:00
|
|
|
|
Object.assign(result, appliedSalesRange.value)
|
2026-10-13 18:47:56 +08:00
|
|
|
|
}
|
2026-10-14 12:39:16 +08:00
|
|
|
|
if (props.mode === 'sales' && tab.value === 'slow') Object.assign(result, appliedSalesRange.value)
|
2026-10-14 11:02:56 +08:00
|
|
|
|
if (props.mode === 'inventory') {
|
|
|
|
|
|
Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
|
2026-10-14 12:39:16 +08:00
|
|
|
|
result.sortBy = appliedInventory.value.sortBy
|
2026-10-14 11:02:56 +08:00
|
|
|
|
if (drilldown.value) Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
|
|
|
|
|
|
else Object.assign(result, appliedWindow.value)
|
|
|
|
|
|
}
|
2026-10-12 20:33:39 +08:00
|
|
|
|
return result
|
|
|
|
|
|
}
|
2026-10-13 12:36:45 +08:00
|
|
|
|
async function load(reset = false, keepContext = false) {
|
2026-10-13 00:45:41 +08:00
|
|
|
|
if (!permitted.value || !warehouseCode.value || routeProblem.value) return
|
2026-10-14 12:39:16 +08:00
|
|
|
|
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 }
|
2026-10-13 13:43:16 +08:00
|
|
|
|
if (summaryMode.value && !appliedSalesRange.value) return
|
2026-10-13 12:36:45 +08:00
|
|
|
|
if (reset) { if (!origin.value && !keepContext) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
|
2026-10-12 22:06:45 +08:00
|
|
|
|
trendOpen.value = false
|
2026-10-12 20:33:39 +08:00
|
|
|
|
dailyFence.cancel()
|
|
|
|
|
|
const request = dataFence.begin()
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
error.value = ''
|
|
|
|
|
|
errorDetails.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const params = query()
|
|
|
|
|
|
const result = props.mode === 'inventory' ? await analyticsApi.inventory(params, request.signal)
|
|
|
|
|
|
: tab.value === 'hot' ? await analyticsApi.hot(params, request.signal)
|
|
|
|
|
|
: tab.value === 'slow' ? await analyticsApi.slow(params, request.signal) : await analyticsApi.summary(params, request.signal)
|
|
|
|
|
|
if (!request.current()) return
|
2026-10-12 21:36:24 +08:00
|
|
|
|
if (context.value && !sameContext(context.value, result.context)) throw new Error('查询版本已变化,请重新查询')
|
2026-10-12 20:33:39 +08:00
|
|
|
|
context.value = result.context
|
|
|
|
|
|
rows.value = result.records
|
2026-10-14 16:15:40 +08:00
|
|
|
|
void ownerDirectory.load(result.context, warehouseCode.value, request.signal)
|
2026-10-12 20:33:39 +08:00
|
|
|
|
total.value = result.total
|
|
|
|
|
|
daily.value = null
|
2026-10-14 12:39:16 +08:00
|
|
|
|
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 } }
|
2026-10-12 20:33:39 +08:00
|
|
|
|
finally { if (request.current()) loading.value = false }
|
|
|
|
|
|
}
|
2026-10-13 12:36:45 +08:00
|
|
|
|
async function showTrend(row: SalesSummary | Metric | InventoryMetric) {
|
2026-10-12 22:06:45 +08:00
|
|
|
|
trendOpen.value = true
|
|
|
|
|
|
trendLoading.value = true
|
|
|
|
|
|
daily.value = null
|
|
|
|
|
|
trendError.value = ''
|
|
|
|
|
|
trendErrorDetails.value = null
|
2026-10-12 20:33:39 +08:00
|
|
|
|
const request = dailyFence.begin()
|
|
|
|
|
|
try {
|
2026-10-13 13:43:16 +08:00
|
|
|
|
const params: BusinessQuery = { sourceAccount, warehouseCode: warehouseCode.value, customerCode: row.customerCode, sku: row.sku, current: 1, size: 20, ...contextParams(context.value), rangePreset: props.mode === 'inventory' ? 'CUSTOM' : appliedSalesRange.value?.rangePreset }
|
2026-10-12 20:33:39 +08:00
|
|
|
|
if (props.mode === 'inventory' && context.value?.rangeStartDate && context.value.rangeEndDateExclusive) {
|
|
|
|
|
|
params.startDate = context.value.rangeStartDate
|
|
|
|
|
|
params.endDate = new Date(Date.parse(`${context.value.rangeEndDateExclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
|
2026-10-13 13:43:16 +08:00
|
|
|
|
} else if (params.rangePreset === 'CUSTOM') { params.startDate = appliedSalesRange.value?.startDate; params.endDate = appliedSalesRange.value?.endDate }
|
2026-10-12 20:33:39 +08:00
|
|
|
|
const result = await analyticsApi.daily(params, request.signal)
|
|
|
|
|
|
if (!request.current()) return
|
2026-10-12 21:36:24 +08:00
|
|
|
|
if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请重新查询')
|
2026-10-12 20:33:39 +08:00
|
|
|
|
daily.value = result
|
2026-10-12 22:06:45 +08:00
|
|
|
|
} catch (cause) { if (request.current()) { trendErrorDetails.value = getApiErrorDetails(cause, 'SKU趋势读取失败'); trendError.value = trendErrorDetails.value.message } }
|
|
|
|
|
|
finally { if (request.current()) trendLoading.value = false }
|
2026-10-12 20:33:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
async function readRuntime() {
|
|
|
|
|
|
if (!permitted.value) return false
|
|
|
|
|
|
const request = runtimeFence.begin()
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await analyticsApi.overview(sourceAccount, undefined, request.signal)
|
|
|
|
|
|
if (!request.current()) return false
|
|
|
|
|
|
overview.value = result
|
2026-10-13 16:52:09 +08:00
|
|
|
|
if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse()
|
2026-10-12 20:33:39 +08:00
|
|
|
|
runtimeError.value = ''
|
|
|
|
|
|
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
|
|
|
|
|
|
newDay.value = Boolean(context.value && context.value.anchorDate !== today)
|
|
|
|
|
|
const warehouse = result.warehouses.filter(row => row.warehouseCode === warehouseCode.value)
|
|
|
|
|
|
if (context.value && warehouse.some(row => row.lastPublishedAt && Date.parse(row.lastPublishedAt) > Date.parse(context.value!.queryAsOf))) newData.value = true
|
|
|
|
|
|
return hasActiveTask(result.warehouses.flatMap(row => [row.currentTask, row.sharedUpstream].filter((task): task is NonNullable<typeof task> => Boolean(task))))
|
|
|
|
|
|
} catch (cause) { if (request.current()) runtimeError.value = getApiErrorMessage(cause, '状态更新失败,保留上次观测结果'); return false }
|
|
|
|
|
|
}
|
|
|
|
|
|
const poller = new RuntimePoller(readRuntime, () => runtimeFence.cancel())
|
2026-10-12 22:06:45 +08:00
|
|
|
|
watch(trendOpen, open => { if (!open) { dailyFence.cancel(); daily.value = null; trendLoading.value = false; trendError.value = ''; trendErrorDetails.value = null } })
|
2026-10-12 20:33:39 +08:00
|
|
|
|
function visibility() { poller.setVisible(!document.hidden) }
|
2026-10-13 13:43:16 +08:00
|
|
|
|
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) {
|
2026-10-14 12:39:16 +08:00
|
|
|
|
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 }
|
2026-10-13 13:43:16 +08:00
|
|
|
|
}
|
2026-10-14 12:39:16 +08:00
|
|
|
|
|
2026-10-12 20:33:39 +08:00
|
|
|
|
function search() {
|
2026-10-14 12:39:16 +08:00
|
|
|
|
if (!applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
|
2026-10-13 12:36:45 +08:00
|
|
|
|
let nextWindow = appliedWindow.value
|
2026-10-14 11:02:56 +08:00
|
|
|
|
if (props.mode === 'inventory' && !drilldown.value) {
|
2026-10-14 12:39:16 +08:00
|
|
|
|
const range = appliedSalesRange.value!
|
|
|
|
|
|
nextWindow = { salesWindowDays: Math.round((Date.parse(range.endDate!) - Date.parse(range.startDate!)) / 86400000) + 1, salesWindowEndDate: range.endDate! }
|
2026-10-13 12:36:45 +08:00
|
|
|
|
}
|
2026-10-14 12:39:16 +08:00
|
|
|
|
|
2026-10-12 20:33:39 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-10-14 12:39:16 +08:00
|
|
|
|
const keepContext = !newData.value && !newDay.value && props.mode === 'inventory' && nextWindow.salesWindowDays === appliedWindow.value.salesWindowDays && nextWindow.salesWindowEndDate === appliedWindow.value.salesWindowEndDate && sortBy.value !== appliedInventory.value.sortBy
|
2026-10-13 12:36:45 +08:00
|
|
|
|
appliedWindow.value = nextWindow
|
2026-10-12 20:33:39 +08:00
|
|
|
|
appliedInventory.value = { attention: attention.value, sortBy: sortBy.value, coverageDaysThreshold: rawThreshold ? Number(rawThreshold) : undefined }
|
2026-10-14 16:15:40 +08:00
|
|
|
|
applied.value = { sku: String(filters.value.sku ?? '').trim(), customerCode: String(filters.value.customerCode || '') }
|
2026-10-12 20:33:39 +08:00
|
|
|
|
current.value = 1
|
2026-10-13 12:36:45 +08:00
|
|
|
|
void load(true, keepContext)
|
2026-10-12 20:33:39 +08:00
|
|
|
|
}
|
2026-10-13 16:52:09 +08:00
|
|
|
|
async function reset() {
|
2026-10-14 12:39:16 +08:00
|
|
|
|
salesSort.value = 'QUANTITY_DESC'
|
2026-10-13 16:52:09 +08:00
|
|
|
|
applyingRoute = true
|
|
|
|
|
|
drilldown.value = null; origin.value = null
|
2026-10-14 16:15:40 +08:00
|
|
|
|
filters.value = { ...initialFilters(), warehouseCode: warehouseCode.value || selectedWarehouse || defaultWarehouse(), sku: '', customerCode: '' }
|
|
|
|
|
|
appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '', customerCode: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }
|
2026-10-13 16:52:09 +08:00
|
|
|
|
await nextTick(); applyingRoute = false; void load(true)
|
|
|
|
|
|
}
|
2026-10-13 00:45:41 +08:00
|
|
|
|
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) } : {} })
|
|
|
|
|
|
}
|
|
|
|
|
|
async function clearDrilldown() {
|
|
|
|
|
|
drilldown.value = null
|
2026-10-14 16:15:40 +08:00
|
|
|
|
filters.value = { ...filters.value, sku: '', customerCode: '', analysis: 'summary', rangePreset: 'LAST_30_DAYS', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customRange: [] }
|
2026-10-13 13:43:16 +08:00
|
|
|
|
appliedSalesRange.value = draftSalesRange()
|
2026-10-14 16:15:40 +08:00
|
|
|
|
applied.value = { sku: '', customerCode: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1
|
2026-10-13 00:45:41 +08:00
|
|
|
|
await nextTick(); void load()
|
|
|
|
|
|
}
|
2026-10-12 21:36:24 +08:00
|
|
|
|
function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) }
|
2026-10-13 00:45:41 +08:00
|
|
|
|
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) })
|
2026-10-14 12:39:16 +08:00
|
|
|
|
|
|
|
|
|
|
watch([customStart, customEnd], () => { if (applyingRoute) return; error.value = ''; errorDetails.value = null })
|
|
|
|
|
|
watch([preset, tab], () => {
|
2026-10-13 13:43:16 +08:00
|
|
|
|
if (applyingRoute) return
|
2026-10-14 12:39:16 +08:00
|
|
|
|
if (props.mode === 'inventory') { search(); return }
|
2026-10-13 13:43:16 +08:00
|
|
|
|
clearSalesResults()
|
2026-10-14 12:39:16 +08:00
|
|
|
|
if (!applySalesRange(origin.value?.context.anchorDate ?? context.value?.anchorDate ?? beijingToday())) return
|
2026-10-13 13:43:16 +08:00
|
|
|
|
current.value = 1; void load()
|
|
|
|
|
|
})
|
2026-10-14 12:39:16 +08:00
|
|
|
|
watch([current, size], () => { if (!applyingRoute && !changingSortPage) void load() })
|
2026-10-12 20:33:39 +08:00
|
|
|
|
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
|
2026-10-13 00:45:41 +08:00
|
|
|
|
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
|
2026-10-14 16:15:40 +08:00
|
|
|
|
readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? ''), customerCode: String(filters.value.customerCode || '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1
|
2026-10-13 16:52:09 +08:00
|
|
|
|
if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse()
|
2026-10-13 00:45:41 +08:00
|
|
|
|
await nextTick(); applyingRoute = false; void load()
|
|
|
|
|
|
})
|
|
|
|
|
|
onActivated(() => { visibility(); poller.start() })
|
|
|
|
|
|
onDeactivated(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false })
|
2026-10-12 20:33:39 +08:00
|
|
|
|
onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<template>
|
2026-10-20 19:16:47 +08:00
|
|
|
|
<AdminPage class="admin-density admin-workbench">
|
2026-10-12 20:33:39 +08:00
|
|
|
|
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
|
|
|
|
|
|
<ListPageLayout v-else>
|
2026-10-20 19:16:47 +08:00
|
|
|
|
<div class="workbench-table" :class="{ 'is-compact-toolbar': mode === 'sales' && !$slots['view-tabs'] }">
|
2026-10-12 22:06:45 +08:00
|
|
|
|
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
|
2026-10-14 09:23:29 +08:00
|
|
|
|
<template v-if="$slots['view-tabs']" #toolbar-start><slot name="view-tabs" /></template>
|
2026-10-14 16:15:40 +08:00
|
|
|
|
<template #filters><p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
2026-10-12 22:06:45 +08:00
|
|
|
|
<template #notices>
|
2026-10-20 19:16:47 +08:00
|
|
|
|
<p v-if="mode === 'inventory' && context" class="analytics-note workbench-note" data-testid="inventory-window-summary">销量统计(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }},共 {{ displayedWindowDays }} 天。{{ origin ? '沿用经营总览所选区间;未完整的日期不推算日均。' : '不含今天;今天的出库不计入日均及可售天数。' }}库存使用最新已查询的采集值。</p>
|
2026-10-13 00:45:41 +08:00
|
|
|
|
<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>
|
2026-10-20 19:16:47 +08:00
|
|
|
|
<p v-if="runtimeError" class="analytics-note workbench-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
|
2026-10-12 20:33:39 +08:00
|
|
|
|
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
|
2026-10-20 19:16:47 +08:00
|
|
|
|
<p v-if="salesRangePending" class="analytics-note workbench-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p>
|
|
|
|
|
|
<p v-if="newData || newDay" class="analytics-note workbench-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据可用。' }}<template v-if="origin">请返回经营总览刷新原图。</template><template v-else>当前列表仍使用上次查询版本。<el-button :disabled="loading" @click="search">更新数据</el-button></template></p>
|
2026-10-14 00:07:05 +08:00
|
|
|
|
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间" teleported>
|
|
|
|
|
|
<template #reference><el-button aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
|
2026-10-20 19:16:47 +08:00
|
|
|
|
<div class="analytics-information admin-density admin-workbench">
|
|
|
|
|
|
<p v-if="mode === 'inventory' && context" class="analytics-note workbench-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 workbench-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
|
2026-10-14 00:07:05 +08:00
|
|
|
|
<div class="analytics-rules">
|
|
|
|
|
|
|
2026-10-14 12:39:16 +08:00
|
|
|
|
<p>{{ summaryMode ? '最近3/7/15/30天均截至昨日(北京时间);自定义按所选起止日期查询。完整区间显示出库量,未完整区间显示已采集量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
|
2026-10-13 12:36:45 +08:00
|
|
|
|
<p v-if="mode === 'inventory'">预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</p>
|
2026-10-12 21:36:24 +08:00
|
|
|
|
<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>
|
2026-10-14 00:07:05 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-popover>
|
2026-10-12 22:06:45 +08:00
|
|
|
|
</template>
|
2026-10-14 12:39:16 +08:00
|
|
|
|
<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>
|
2026-10-12 21:36:24 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
<template #unit="{ row }">{{ asMetric(row).quantityUnit === 'PCS' ? '件' : asMetric(row).quantityUnit || '—' }}</template>
|
2026-10-12 20:33:39 +08:00
|
|
|
|
<template #lastOutbound="{ row }">{{ asMetric(row).lastObservedOutboundDate ?? '已采集历史内未见出库' }}<br />历史覆盖起点:{{ asMetric(row).observedHistoryStartDate ?? '—' }}</template>
|
|
|
|
|
|
<template #attention="{ row }">{{ reasonsText(asMetric(row).attentionReasons) }}</template>
|
2026-10-14 11:18:47 +08:00
|
|
|
|
<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>
|
2026-10-12 20:33:39 +08:00
|
|
|
|
<template #status="{ row }">{{ label(summaryMode ? asSummary(row).dataStatus : asMetric(row).status) }}</template>
|
2026-10-14 12:39:16 +08:00
|
|
|
|
<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>
|
2026-10-12 20:33:39 +08:00
|
|
|
|
</DataTable>
|
2026-10-20 19:16:47 +08:00
|
|
|
|
</div>
|
2026-10-12 20:33:39 +08:00
|
|
|
|
</ListPageLayout>
|
2026-10-20 19:16:47 +08:00
|
|
|
|
<AppDialog v-model="trendOpen" class="admin-density-dialog admin-workbench is-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>
|
2026-10-12 22:06:45 +08:00
|
|
|
|
<div class="admin-density">
|
|
|
|
|
|
<LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError" compact><template #details><ApiErrorMeta v-if="trendErrorDetails" :details="trendErrorDetails" /></template></LoadErrorState>
|
|
|
|
|
|
<template v-if="daily">
|
|
|
|
|
|
<SkuTrend :data="daily" />
|
|
|
|
|
|
<el-button @click="router.push({ path: mode === 'inventory' ? '/self-operated/sales' : '/self-operated/inventory', query: { warehouseCode, sku: daily.summary.sku } })">{{ mode === 'inventory' ? '在销量页查看该SKU(全部货主)' : '在库存页查看该SKU(全部货主)' }}</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</AppDialog>
|
2026-10-12 20:33:39 +08:00
|
|
|
|
</AdminPage>
|
|
|
|
|
|
</template>
|
2026-10-12 21:36:24 +08:00
|
|
|
|
<style scoped>
|
2026-10-14 12:39:16 +08:00
|
|
|
|
.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); }
|
2026-10-20 19:16:47 +08:00
|
|
|
|
.analytics-rules { margin: 0; font-size: var(--admin-font-caption); }
|
2026-10-14 00:07:05 +08:00
|
|
|
|
.analytics-information { max-height: 65vh; overflow-y: auto; overflow-wrap: anywhere; }
|
|
|
|
|
|
.analytics-information > p { margin: var(--admin-space-2) 0; }
|
2026-10-12 21:36:24 +08:00
|
|
|
|
.analytics-rules p { margin: var(--admin-space-2) 0; }
|
|
|
|
|
|
.analytics-owner-code { display: block; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
|
|
|
|
|
</style>
|