feat: add platform self-operated sales inventory and collection views

This commit is contained in:
2026-10-12 20:33:39 +08:00
parent cc4856ceac
commit 533273769d
23 changed files with 1395 additions and 4 deletions
@@ -0,0 +1,224 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
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'
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, OwnerOverview, 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 OwnerPicker from './OwnerPicker.vue'
import OwnerOverviewPanel from './OwnerOverviewPanel.vue'
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'))
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', customerCode: typeof route.query.customerCode === 'string' ? route.query.customerCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '' })
const applied = ref({ customerCode: String(filters.value.customerCode), sku: String(filters.value.sku) })
const overview = ref<Overview | null>(null)
const context = ref<QueryContext | null>(null)
const rows = ref<(SalesSummary | Metric)[]>([])
const daily = ref<DailyResponse | null>(null)
const ownerOverview = ref<OwnerOverview | null>(null)
const attention = ref<InventoryAttention>('ALL')
const sortBy = ref<InventorySort>('AVAILABLE_DESC')
const thresholdInput = ref('')
const appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
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)
const size = ref(20)
const total = ref(0)
const preset = ref<RangePreset>('TODAY')
const customStart = ref('')
const customEnd = ref('')
const tab = ref<'summary' | 'hot' | 'slow'>('summary')
const windowDays = ref<7 | 30>(30)
const sourceAccount = 'lingxing-primary'
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', type: 'select', label: '仓库', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] },
{ key: 'sku', type: 'input', label: 'SKU', placeholder: '原始 SKU 精确编码' },
])
const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
const columns = computed<DataTableColumn[]>(() => [
...(summaryMode.value ? [{ key: 'actions', label: '操作', width: 110, slotName: 'actions', fixed: 'left' }] : []),
{ prop: 'customerCode', label: '货主编码', minWidth: 140 },
...(props.mode === 'inventory' ? [{ prop: 'customerName', label: '货主名称', minWidth: 140 }] : []),
{ prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku' } : {}) },
{ prop: 'productName', label: '商品名称', minWidth: 190 }, { prop: 'quantityUnit', label: '单位', width: 80 },
...(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' },
{ key: 'outboundQuantity30d', label: '近30天出库', width: 120, slotName: 'outboundQuantity30d' },
{ key: 'averageDailyOutboundQuantity', label: '30天日均出库', 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' },
])
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, customerCode: applied.value.customerCode || undefined, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) }
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)
return result
}
async function load(reset = false) {
if (!permitted.value || !warehouseCode.value) return
if (summaryMode.value && preset.value === 'CUSTOM') {
const problem = validateCustomRange(customStart.value, customEnd.value, context.value?.anchorDate)
if (problem) { error.value = problem; return }
}
if (reset) { context.value = null; daily.value = null; ownerOverview.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
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
if (context.value && !sameContext(context.value, result.context)) throw new Error('查询版本已变化,请整体刷新')
const owner = props.mode === 'inventory' && applied.value.customerCode
? await analyticsApi.ownerOverview({ sourceAccount, warehouseCode: warehouseCode.value, customerCode: applied.value.customerCode, coverageDaysThreshold: appliedInventory.value.coverageDaysThreshold, ...contextParams(result.context) }, request.signal)
: null
if (!request.current()) return
if (owner && !sameContext(result.context, owner.context)) throw new Error('货主概览版本与明细不一致,请整体刷新')
context.value = result.context
ownerOverview.value = owner
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 } }
finally { if (request.current()) loading.value = false }
}
async function showTrend(row: SalesSummary | Metric) {
const request = dailyFence.begin()
error.value = ''
try {
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' : preset.value }
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)
} else if (params.rangePreset === 'CUSTOM') { params.startDate = customStart.value; params.endDate = customEnd.value }
const result = await analyticsApi.daily(params, request.signal)
if (!request.current()) return
if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请整体刷新')
daily.value = result
} catch (cause) { if (request.current()) error.value = getApiErrorMessage(cause, 'SKU趋势读取失败') }
}
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
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())
function visibility() { poller.setVisible(!document.hidden) }
function search() {
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
}
appliedInventory.value = { attention: attention.value, sortBy: sortBy.value, coverageDaysThreshold: rawThreshold ? Number(rawThreshold) : undefined }
applied.value = { customerCode: String(filters.value.customerCode ?? ''), sku: String(filters.value.sku ?? '') }
ownerOverview.value = null
current.value = 1
void load()
}
function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, customerCode: applied.value.customerCode, sku: applied.value.sku } }) }
watch(warehouseCode, () => { dataFence.cancel(); dailyFence.cancel(); rows.value = []; ownerOverview.value = null; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) })
watch([preset, tab, windowDays], () => { current.value = 1; void load() })
watch([current, size], () => { void load() })
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<ListPageLayout v-else>
<p>来源账号:{{ sourceAccount }} · 北京时间 Asia/Shanghai · 数量为每个 SKU 的件数 PCS</p>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营分析筛选" :loading="loading" @search="search" @reset="search" />
<OwnerPicker :model-value="String(filters.customerCode ?? '')" :source-account="sourceAccount" :warehouse-code="warehouseCode" :context="context" @update:model-value="filters.customerCode = $event; search()" />
<div v-if="mode === 'inventory'" class="density-actions">
<label>库存关注 <el-select v-model="attention" aria-label="库存关注"><el-option v-for="item in [{ 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="item.value" :value="item.value" :label="item.label" /></el-select></label>
<label>可售天数阈值 <el-input v-model="thresholdInput" aria-label="可售天数阈值" placeholder="可选,正整数天" /></label>
<label>排序 <el-select v-model="sortBy" aria-label="库存排序"><el-option v-for="item in [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }]" :key="item.value" :value="item.value" :label="item.label" /></el-select></label>
<el-button :disabled="!warehouseCode || loading" @click="search">应用库存关注</el-button>
</div>
<p v-if="!warehouseCode">请先选择仓库。货主默认为全部已授权范围。</p>
<p v-if="runtimeError" 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>
<div v-if="mode === 'sales'" class="density-tabs" role="tablist" aria-label="销量分析类别">
<button v-for="item in [{ key: 'summary', name: 'SKU销量' }, { key: 'hot', name: '热销Top100' }, { key: 'slow', name: '滞销Top100' }]" :key="item.key" type="button" role="tab" :aria-selected="tab === item.key" :class="{ 'is-active': tab === item.key }" @click="tab = item.key as typeof tab">{{ item.name }}</button>
</div>
<div v-if="summaryMode" class="density-actions">
<el-button v-for="option in [{ key: 'TODAY', label: '今日' }, { key: 'LAST_3_DAYS', label: '近3天' }, { key: 'THIS_WEEK', label: '本周' }, { key: 'LAST_15_DAYS', label: '近15天' }, { key: 'THIS_MONTH', label: '本月' }, { key: 'CUSTOM', label: '自定义' }]" :key="option.key" :type="preset === option.key ? 'primary' : 'default'" @click="preset = option.key as RangePreset">{{ option.label }}</el-button>
<template v-if="preset === 'CUSTOM'"><el-date-picker v-model="customStart" type="date" value-format="YYYY-MM-DD" aria-label="开始日期" /><el-date-picker v-model="customEnd" type="date" value-format="YYYY-MM-DD" aria-label="结束日期" /><el-button @click="search">应用日期</el-button></template>
</div>
<div v-else-if="mode === 'sales' && tab === 'hot'"><label>完整日窗口 <el-select v-model="windowDays" aria-label="热销完整日窗口"><el-option :value="7" label="7个完整自然日" /><el-option :value="30" label="30个完整自然日" /></el-select></label></div>
<p v-else>固定使用截至昨日的 30 个完整自然日窗口。{{ mode === 'inventory' ? '实物含全品质,可售仅正品;冻结及次品不重复计入实物,在途不计当前占仓。日均量及可售天数由服务端计算,阈值仅用于本次查询。' : '滞销观察资格由服务端判断。' }}</p>
<p v-if="context">查询基准:{{ beijingTime(context.queryAsOf) }}(北京时间);有效至 {{ beijingTime(context.queryExpiresAt) }}。范围 {{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(结束边界不含)。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }};销量发布 {{ beijingTime(context.salesPublishedAt) }};库存采集 {{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.rebuildRequired ? '修订待重建' : '' }}</p>
<p v-if="newData" role="status">有新数据,可整体刷新。</p>
<p v-if="newDay" role="status">北京时间已跨日;当前范围仍按原查询基准日,整体刷新后取得新日期和版本。</p>
<p v-if="context && !summaryMode">指标计算窗口:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(结束边界不含);{{ context.calculationWindowDays ?? (tab === 'hot' ? windowDays : 30) }} 天。</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>
<OwnerOverviewPanel v-if="mode === 'inventory' && ownerOverview" :data="ownerOverview" />
<p v-else-if="mode === 'inventory'">{{ applied.customerCode ? '当前货主尚无可用经营概览。' : '选择一个具体货主后显示全量经营概览;当前可浏览全仓 SKU 明细。' }}</p>
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" empty-text="所选范围无记录或尚无可用数据">
<template #toolbar-actions><el-button :disabled="!warehouseCode || loading" @click="load(true)">整体刷新</el-button><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></template>
<template #actions="{ row }"><el-button link @click="showTrend(asSummary(row))">每日趋势</el-button></template>
<template #sku="{ row }"><el-button link :aria-label="`查看 ${asMetric(row).customerCode} ${asMetric(row).sku} 每日趋势`" @click="showTrend(asMetric(row))">{{ asMetric(row).sku }}</el-button></template>
<template #lastOutbound="{ row }">{{ asMetric(row).lastObservedOutboundDate ?? '已采集历史内未见出库' }}<br />历史覆盖起点:{{ asMetric(row).observedHistoryStartDate ?? '—' }}</template>
<template #attention="{ row }">{{ reasonsText(asMetric(row).attentionReasons) }}</template>
<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>
</DataTable>
<template v-if="daily && (summaryMode || mode === 'inventory')">
<el-button @click="router.push({ path: mode === 'inventory' ? '/self-operated/sales' : '/self-operated/inventory', query: { warehouseCode, customerCode: daily.summary.customerCode, sku: daily.summary.sku } })">{{ mode === 'inventory' ? '在销量页查看该 SKU' : '在库存页查看该 SKU' }}</el-button>
<SkuTrend :data="daily" />
</template>
</ListPageLayout>
</AdminPage>
</template>
@@ -0,0 +1,24 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import OwnerOverviewPanel from './OwnerOverviewPanel.vue'
import type { OwnerOverview } from '../types'
describe('owner overview calculation window', () => {
it('shows the thirty-day calculation range rather than the longer collected history', () => {
const quantity = { overallTotal: null, knownSubtotal: null, unknownSkuCount: 1, dataStatus: 'UNKNOWN', reasonCodes: [] }
const risk = { matchedSkuCount: null, matchedPhysicalQuantity: null, physicalQuantityRatio: null, knownMatchedSkuCount: null, knownMatchedPhysicalQuantity: null, unassessableSkuCount: 1, unassessableKnownPhysicalQuantity: null, reasonCodes: [] }
const data: OwnerOverview = {
context: { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12', completeDayFrom: '2026-08-13', completeDayUntil: '2026-10-12', rangeStartDate: '2026-09-12', rangeEndDateExclusive: '2026-10-12', salesStatus: 'COMPLETE', inventoryStatus: 'STALE', inventoryObservedFrom: '2026-10-10T00:00:00Z', inventoryObservedUntil: '2026-10-10T00:10:00Z' },
customerCode: 'OWNER-A', customerName: null, skuCount: 1,
physicalSkuCount: quantity, physicalQuantity: quantity, availableQuantity: quantity, frozenQuantity: quantity,
defectivePhysicalQuantity: quantity, transitQuantity: quantity, outboundQuantity30d: quantity,
averageDailyOutboundQuantity: quantity, noOutbound30d: risk, coverageAboveThreshold: risk,
}
const wrapper = mount(OwnerOverviewPanel, { props: { data } })
expect(wrapper.text()).toContain('近30完整日计算区间 2026-09-12 至 2026-10-12')
expect(wrapper.text()).not.toContain('2026-08-13')
expect(wrapper.text()).toContain('销量质量:完整;库存质量:过期')
expect(wrapper.text()).toContain('库存观测时段:2026/10/10 08:00:00 至 2026/10/10 08:10:00')
wrapper.unmount()
})
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { OwnerOverview, QuantityTotal, RiskShare } from '../types'
import { beijingTime, label, quantityText, reasonsText } from '../state'
defineProps<{ data: OwnerOverview }>()
const metrics: { key: keyof Pick<OwnerOverview, 'physicalSkuCount' | 'physicalQuantity' | 'availableQuantity' | 'frozenQuantity' | 'defectivePhysicalQuantity' | 'transitQuantity' | 'outboundQuantity30d' | 'averageDailyOutboundQuantity'>; name: string }[] = [
{ key: 'physicalSkuCount', name: '有实物库存 SKU 数' }, { key: 'physicalQuantity', name: '全品质实物件数' },
{ key: 'availableQuantity', name: '正品可售件数' }, { key: 'frozenQuantity', name: '全品质冻结件数' },
{ key: 'defectivePhysicalQuantity', name: '次品实物件数' }, { key: 'transitQuantity', name: '在途件数' },
{ key: 'outboundQuantity30d', name: '近30天出库件数' }, { key: 'averageDailyOutboundQuantity', name: '30天日均出库' },
]
const risks: { key: 'noOutbound30d' | 'coverageAboveThreshold'; name: string }[] = [{ key: 'noOutbound30d', name: '近30天无出库' }, { key: 'coverageAboveThreshold', name: '可售天数超过本次阈值' }]
function totalText(metric: QuantityTotal) { return `${quantityText(metric.overallTotal)} · ${label(metric.dataStatus)}` }
function ratioText(risk: RiskShare) { return risk.physicalQuantityRatio == null ? '—' : `${(Number(risk.physicalQuantityRatio) * 100).toFixed(2)}%` }
</script>
<template>
<section aria-label="货主经营概览">
<h3>货主 {{ data.customerCode }} {{ data.customerName ?? '' }} · 经营概览</h3>
<p>固定所选货主全量范围,不受下方 SKU、关注筛选或分页影响。SKU 候选数 {{ quantityText(data.skuCount) }}。实物件数不是空间占比,不自动评级。</p>
<p>近30完整日计算区间 {{ data.context.rangeStartDate ?? '—' }} 至 {{ data.context.rangeEndDateExclusive ?? '—' }}(结束边界不含);销量质量:{{ label(data.context.salesStatus) }};库存质量:{{ label(data.context.inventoryStatus) }}。库存观测时段:{{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}。</p>
<table class="density-table">
<thead><tr><th>指标</th><th>正式总量 / 质量</th><th>已知有效小计(部分资料)</th><th>未知 SKU 数</th><th>依据</th></tr></thead>
<tbody><tr v-for="metric in metrics" :key="metric.key"><th>{{ metric.name }}</th><td>{{ totalText(data[metric.key]) }}</td><td>{{ quantityText(data[metric.key].knownSubtotal) }}</td><td>{{ quantityText(data[metric.key].unknownSkuCount) }}</td><td>{{ reasonsText(data[metric.key].reasonCodes) }}</td></tr></tbody>
</table>
<table class="density-table">
<thead><tr><th>关注</th><th>命中 SKU 数 / 实物件数</th><th>占货主实物件数比例</th><th>已确认命中小计</th><th>待判断 SKU / 已知实物件数</th><th>依据</th></tr></thead>
<tbody><tr v-for="risk in risks" :key="risk.key"><th>{{ risk.name }}</th><td>{{ quantityText(data[risk.key].matchedSkuCount) }} / {{ quantityText(data[risk.key].matchedPhysicalQuantity) }}</td><td>{{ ratioText(data[risk.key]) }}</td><td>{{ quantityText(data[risk.key].knownMatchedSkuCount) }} SKU / {{ quantityText(data[risk.key].knownMatchedPhysicalQuantity) }} PCS</td><td>{{ quantityText(data[risk.key].unassessableSkuCount) }} / {{ quantityText(data[risk.key].unassessableKnownPhysicalQuantity) }}</td><td>{{ reasonsText(data[risk.key].reasonCodes) }}</td></tr></tbody>
</table>
<p>占比的分母为本货主全品质实物正式总量 {{ quantityText(data.physicalQuantity.overallTotal) }} PCS({{ label(data.physicalQuantity.dataStatus) }})。冻结及次品均已包含在实物中,在途不计当前占仓。{{ Number(data.physicalQuantity.overallTotal) === 0 && data.physicalQuantity.overallTotal !== null ? '本货主无实物库存,占比不适用。' : '' }}</p>
</section>
</template>
@@ -0,0 +1,41 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OwnerPicker from './OwnerPicker.vue'
import type { QueryContext } from '../types'
const owners = vi.hoisted(() => vi.fn())
vi.mock('../api/analytics', () => ({ analyticsApi: { owners } }))
const context: QueryContext = { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12' }
const select = defineComponent({ props: ['remoteMethod'], setup(props, { slots }) { return () => h('div', [h('button', { onClick: () => props.remoteMethod('Ab_') }, '前缀搜索'), slots.default?.()]) } })
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
const option = defineComponent({ props: ['label'], setup(props) { return () => h('span', props.label) } })
const global = { stubs: { 'el-select': select, 'el-button': button, 'el-option': option } }
const wrappers: ReturnType<typeof mount>[] = []
beforeEach(() => { owners.mockReset(); owners.mockResolvedValue({ context, records: [{ customerCode: 'OWNER-A', customerName: '货主甲' }], total: 25 }) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
describe('fixed-context owner selector', () => {
it('uses backend pagination and prefix search without changing the pinned context', async () => {
const wrapper = mount(OwnerPicker, { props: { modelValue: '', sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', context }, global }); wrappers.push(wrapper)
await flushPromises()
await wrapper.findAll('button').find(node => node.text() === '下一页货主')!.trigger('click'); await flushPromises()
expect(owners.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, size: 20, salesRunId: '11', inventoryRunId: '12', queryAsOf: context.queryAsOf })
await wrapper.findAll('button').find(node => node.text() === '前缀搜索')!.trigger('click'); await flushPromises()
expect(owners.mock.calls.at(-1)?.[0]).toMatchObject({ keyword: 'Ab_', current: 1, queryAsOf: context.queryAsOf })
expect(wrapper.text()).toContain('OWNER-A · 货主甲')
})
it('ignores late options from the old warehouse and rejects mixed-version choices', async () => {
let resolve!: (value: object) => void
owners.mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValue({ context, records: [{ customerCode: 'OWNER-B', customerName: null }], total: 1 })
const wrapper = mount(OwnerPicker, { props: { modelValue: '', sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', context }, global }); wrappers.push(wrapper)
await wrapper.setProps({ warehouseCode: 'WH-B' }); await flushPromises()
resolve({ context, records: [{ customerCode: 'OLD-OWNER', customerName: null }], total: 1 }); await flushPromises()
expect(wrapper.text()).toContain('OWNER-B')
expect(wrapper.text()).not.toContain('OLD-OWNER')
expect(owners.mock.calls[0]?.[1].aborted).toBe(true)
owners.mockResolvedValue({ context: { ...context, inventoryRunId: '99' }, records: [{ customerCode: 'WRONG-VERSION' }], total: 1 })
await wrapper.findAll('button').find(node => node.text() === '前缀搜索')!.trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('WRONG-VERSION')
expect(wrapper.text()).toContain('货主选项版本已变化')
})
})
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue'
import { analyticsApi } from '../api/analytics'
import type { OwnerOption, QueryContext } from '../types'
import { contextParams, RequestFence, sameContext } from '../state'
import { getApiErrorMessage } from '@/shared/api/http'
const props = defineProps<{ modelValue: string; sourceAccount: string; warehouseCode: string; context: QueryContext | null }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const options = ref<OwnerOption[]>([])
const keyword = ref('')
const current = ref(1)
const total = ref(0)
const loading = ref(false)
const error = ref('')
const fence = new RequestFence()
async function load() {
const request = fence.begin()
if (!props.warehouseCode || !props.context) { options.value = []; total.value = 0; loading.value = false; return }
loading.value = true
error.value = ''
try {
const result = await analyticsApi.owners({ sourceAccount: props.sourceAccount, warehouseCode: props.warehouseCode, ...contextParams(props.context), keyword: keyword.value || undefined, current: current.value, size: 20 }, request.signal)
if (!request.current()) return
if (!props.context || !sameContext(props.context, result.context)) {
options.value = []; total.value = 0; error.value = '货主选项版本已变化,请整体刷新'
return
}
options.value = result.records
total.value = result.total
} catch (cause) { if (request.current()) { options.value = []; total.value = 0; error.value = getApiErrorMessage(cause, '货主选项读取失败') } }
finally { if (request.current()) loading.value = false }
}
function search(value: string) { keyword.value = value; current.value = 1; void load() }
function page(delta: number) { current.value += delta; void load() }
watch(() => [props.warehouseCode, props.context?.queryAsOf, props.context?.salesRunId, props.context?.inventoryRunId], () => { current.value = 1; keyword.value = ''; void load() }, { immediate: true })
onUnmounted(() => fence.cancel())
</script>
<template>
<div class="density-actions">
<label>货主
<el-select :model-value="modelValue" :disabled="!warehouseCode || !context" clearable filterable remote :remote-method="search" :loading="loading" placeholder="全部货主;输入编码前缀查找" aria-label="货主选择" @update:model-value="emit('update:modelValue', String($event ?? ''))">
<el-option v-if="modelValue && !options.some(option => option.customerCode === modelValue)" :value="modelValue" :label="modelValue" />
<el-option v-for="option in options" :key="option.customerCode" :value="option.customerCode" :label="option.customerName ? `${option.customerCode} · ${option.customerName}` : option.customerCode" />
</el-select>
</label>
<el-button :disabled="loading || current <= 1" @click="page(-1)">上一页货主</el-button>
<span>第 {{ current }} 页 · 共 {{ total }} 个货主</span>
<el-button :disabled="loading || current * 20 >= total" @click="page(1)">下一页货主</el-button>
<span v-if="error" role="alert">{{ error }}</span>
<span v-else-if="context && !loading && !total">当前查询无可用货主资料;未发布数据不代表仓库没有货主。</span>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DailyResponse } from '../types'
import { label, quantityText, shownQuantity, trendSegments } from '../state'
const props = defineProps<{ data: DailyResponse }>()
const segments = computed(() => trendSegments(props.data.points))
const maximum = computed(() => Math.max(1, ...props.data.points.map(point => Number(shownQuantity(point) ?? 0))))
function x(index: number) { return props.data.points.length === 1 ? 400 : 30 + index / (props.data.points.length - 1) * 740 }
function y(value: string | number | null) { return 175 - Number(value ?? 0) / maximum.value * 145 }
</script>
<template>
<section aria-label="SKU每日出库趋势">
<h3>{{ data.summary.customerCode }} · {{ data.summary.sku }} · 每日出库趋势</h3>
<p>{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '该版本已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit ?? '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计,非实时值。</p>
<p v-if="!segments.length">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<svg v-else viewBox="0 0 800 220" role="img" aria-label="每日出库件数;虚线点表示未完整,缺失日期断线" class="sku-trend">
<line x1="30" y1="175" x2="770" y2="175" stroke="currentColor" />
<text x="30" y="20">{{ maximum }} PCS</text>
<path v-for="(segment, index) in segments" :key="index" :d="segment.path" fill="none" stroke="var(--el-color-primary)" stroke-width="2" :stroke-dasharray="segment.partial ? '5 4' : undefined" />
<g v-for="(point, index) in data.points" :key="point.businessDate">
<circle v-if="shownQuantity(point) != null && point.dataStatus !== 'UNKNOWN'" :cx="x(index)" :cy="y(shownQuantity(point))" r="4" :fill="point.dataStatus === 'COMPLETE' ? 'var(--el-color-primary)' : 'white'" stroke="var(--el-color-primary)">
<title>{{ point.businessDate }}:{{ quantityText(shownQuantity(point)) }} {{ point.quantityUnit ?? '单位未知' }},{{ label(point.dataStatus) }};覆盖截止 {{ point.coveredUntil ?? '未知' }}</title>
</circle>
<text v-if="data.points.length <= 15 || index === 0 || index === data.points.length - 1" :x="x(index)" y="205" text-anchor="middle">{{ point.businessDate.slice(5) }}</text>
</g>
</svg>
<details>
<summary>每日数量、完整性及覆盖证据</summary>
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
<tr v-for="point in data.points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ point.coveredUntil ?? '—' }}</td><td>{{ point.reasonCodes.join('、') || '—' }}</td></tr>
</tbody></table>
</details>
</section>
</template>
<style scoped>
.sku-trend { width: 100%; max-height: 280px; }
text { font-size: var(--admin-font-caption); }
</style>