diff --git a/src/modules/self-operated/AGENTS.md b/src/modules/self-operated/AGENTS.md index f6a48f2a..cdd9bd6a 100644 --- a/src/modules/self-operated/AGENTS.md +++ b/src/modules/self-operated/AGENTS.md @@ -1,7 +1,8 @@ # 自营经营分析 - 模块入口 `routes.ts`;正式页面 `pages/OverviewView.vue`、`SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。 -- 经营总览唯一设计入口 `docs/自营业务/经营总览图表设计.md`;`analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选同时影响区间出库、日趋势与热销榜,热销按已采集量排名并标明覆盖不足,库存评估仍使用固定30日。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD`,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。 +- 文档路径从工作区根起算;本目录代码路径从当前模块起算。总览设计变更查 `docs/自营业务/经营总览图表设计.md`,采集页面变更查 `docs/自营业务/数据采集页面设计.md`;纯文案或局部代码定位不默认加载设计全文。 +- `analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选统一影响区间出库、日均、日趋势、库存分布及三个榜单,热销按已采集量排名并标明覆盖不足,库存评估按所选区间完整销量及观察资格计算;库存数量仍取固定当前版本。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD` 并从原图上下文传递 CUSTOM 起止日期,不能混用普通库存的10/30日窗口,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。 - API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。 - 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。 - 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。 diff --git a/src/modules/self-operated/components/AnalyticsChart.vue b/src/modules/self-operated/components/AnalyticsChart.vue index a06d99e5..7d9fe572 100644 --- a/src/modules/self-operated/components/AnalyticsChart.vue +++ b/src/modules/self-operated/components/AnalyticsChart.vue @@ -6,9 +6,9 @@ import { BarChart, GaugeChart, LineChart } from 'echarts/charts' import { AriaComponent, DataZoomComponent, GridComponent, TooltipComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' use([LineChart, BarChart, GaugeChart, GridComponent, TooltipComponent, DataZoomComponent, AriaComponent, CanvasRenderer]) -defineProps<{ option: EChartsOption }>() +defineProps<{ option: EChartsOption; mergeUpdates?: boolean }>() defineEmits<{ select: [event: ECElementEvent] }>() diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index 39a6e834..cb0c7342 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -38,7 +38,6 @@ function initialFilters(): QueryModel { return { warehouseCode: typeof route.que const filters = ref(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(null) const context = ref(drilldown.value?.context ?? null) @@ -82,11 +81,11 @@ const fields = computed(() => { { 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

销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。

-

{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。

+

{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。

预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。

查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}

实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)。

diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts index 2e0a09b7..e1de39e5 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts +++ b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts @@ -69,6 +69,7 @@ describe('single account collection monitor', () => { const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview(), stale: true } }) expect(w.text()).toContain('339'); expect(w.text()).toContain('状态更新失败') expect(w.text()).toContain('已暂停估算') + expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false) w.unmount() }) }) diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.vue b/src/modules/self-operated/components/CollectionAccountMonitor.vue index a63aba87..834abffd 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.vue +++ b/src/modules/self-operated/components/CollectionAccountMonitor.vue @@ -67,7 +67,7 @@ function nodeState(phases: string[]) { if (!p) return 'idle' const evidence = [p, ...(p.stages ?? [])].find(s => phases.includes(s.phase ?? '') && s.attemptNo != null && s.attemptNo === p.attemptNo && s.scanRound === p.scanRound && s.sliceFrom === p.sliceFrom && s.sliceUntil === p.sliceUntil && s.progressScope === p.progressScope && s.stageFinishedAt) if (evidence) return 'done' - return phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle' + return !props.stale && phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle' } -
+
最近推进 {{ beijingTime(current?.progressUpdatedAt) }}状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间
diff --git a/src/modules/self-operated/components/CollectionProgressGauge.vue b/src/modules/self-operated/components/CollectionProgressGauge.vue index 24815aaf..37a88994 100644 --- a/src/modules/self-operated/components/CollectionProgressGauge.vue +++ b/src/modules/self-operated/components/CollectionProgressGauge.vue @@ -4,7 +4,7 @@ import AnalyticsChart from './AnalyticsChart.vue' import { progressGaugeOption } from '../dashboardChartOptions' import type { Progress } from '../types' import { beijingTime, phaseText, quantityText, unitText } from '../state' -const props = defineProps<{ progress: Partial | null | undefined; taskNo: string; stale?: boolean; compact?: boolean }>() +const props = defineProps<{ progress: Partial | null | undefined; taskNo: string; stale?: boolean; compact?: boolean; running?: boolean }>() const identity = computed(() => JSON.stringify([props.taskNo, props.progress?.progressScope, props.progress?.countBasis, props.progress?.attemptNo, props.progress?.phase, props.progress?.scanRound, props.progress?.sliceFrom, props.progress?.sliceUntil, props.progress?.unit])) const changedTotal = ref(false) watch([identity, () => props.progress?.total], ([id, total], [oldId, oldTotal]) => { changedTotal.value = id === oldId ? changedTotal.value || (oldTotal != null && total != null && total !== oldTotal) : false }) @@ -16,14 +16,15 @@ const counts = computed(() => { return { remaining: total - done, percent: Number(BigInt(done) * 1000n / BigInt(total)) / 10 } }) const scope = computed(() => props.progress?.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : props.progress?.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认') -const option = computed(() => progressGaugeOption(counts.value?.percent ?? null)) +const indeterminate = computed(() => Boolean(props.running && !props.stale && !counts.value)) +const option = computed(() => progressGaugeOption(counts.value?.percent ?? null, indeterminate.value)) const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTORY', 'DISCOVERY'].includes(props.progress?.phase ?? ''))

{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}

- +
{{ compact && !stale && visible !== false && task.blockingReason ? blockingText : estimateText }}{{ task.latestAttempt.failureSummary }}最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}仅当前阶段;整个任务完成时间尚未估算。

任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}(含排队与重试)