feat(strategy-map): A3 三维切片(时间/门店/产品)——切片重算 + 显式空态 + entity 隔离
ci/woodpecker/push/main Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- 新增 /api/cma/maps/{id}/dimension-options 与 /{id}/slice(只读)
- 时间:kpi_values.period 月/季/年;flow 求和、ratio 均值、balance/status 取最新
- 产品:product_sales + 逐月源一致性闸门(偏差>1% 不切片)
- 门店:库内无门店级事实 ⇒ available=false + reason,绝不回退总额
- 前端 MapDimensionFilter 挂到 MapCanvas,切换即 emit 切片,清除恢复 /review
This commit is contained in:
committed by
Hermes CI Fix
parent
c92dbab96a
commit
d85dd2a155
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="dim-filter" v-if="mapId">
|
||||
<div class="df-group">
|
||||
<span class="df-label">时间</span>
|
||||
<el-radio-group v-model="granularity" size="small" :disabled="!timeReady" @change="onGranularityChange">
|
||||
<el-radio-button value="month">月</el-radio-button>
|
||||
<el-radio-button value="quarter">季</el-radio-button>
|
||||
<el-radio-button value="year">年</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-select
|
||||
v-model="period"
|
||||
size="small"
|
||||
placeholder="选择期间"
|
||||
style="width: 132px"
|
||||
:loading="loadingOptions"
|
||||
:disabled="!timeReady"
|
||||
@change="apply"
|
||||
>
|
||||
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="df-group">
|
||||
<span class="df-label">门店</span>
|
||||
<el-select
|
||||
v-model="store"
|
||||
size="small"
|
||||
placeholder="全部门店"
|
||||
style="width: 148px"
|
||||
clearable
|
||||
:loading="loadingOptions"
|
||||
@change="apply"
|
||||
>
|
||||
<el-option v-for="s in storeValues" :key="s.name" :label="s.name" :value="s.name" />
|
||||
</el-select>
|
||||
<el-tooltip v-if="!storeAvailable && storeReason" :content="storeReason" placement="top">
|
||||
<span class="df-badge df-badge-warn">无门店级取数</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="df-group">
|
||||
<span class="df-label">产品</span>
|
||||
<el-select
|
||||
v-model="product"
|
||||
size="small"
|
||||
placeholder="全部产品"
|
||||
style="width: 148px"
|
||||
clearable
|
||||
filterable
|
||||
:loading="loadingOptions"
|
||||
:disabled="!productAvailable"
|
||||
@change="apply"
|
||||
>
|
||||
<el-option v-for="p in productValues" :key="p.name" :label="p.name" :value="p.name" />
|
||||
</el-select>
|
||||
<span v-if="productAvailable" class="df-badge" :title="productScopeText">仅 {{ productScopeText }}</span>
|
||||
<el-tooltip v-else-if="productReason" :content="productReason" placement="top">
|
||||
<span class="df-badge df-badge-warn">无产品级取数</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="df-actions">
|
||||
<span v-if="slice" class="df-active">
|
||||
已切片:{{ sliceLabel }}
|
||||
</span>
|
||||
<el-button size="small" :disabled="!slice" @click="clear">清除切片</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="slice && slice.empty" class="df-empty" role="status">
|
||||
<span class="df-empty-title">该维度暂无数据</span>
|
||||
<span class="df-empty-desc">{{ slice.empty_reason }}</span>
|
||||
<span class="df-empty-note">地图不回退总额,相关目标按「无数据」显示</span>
|
||||
</div>
|
||||
<div v-else-if="slice" class="df-summary" role="status">
|
||||
{{ summaryText }}
|
||||
</div>
|
||||
<div v-else class="df-hint">
|
||||
选择时间/门店/产品后,地图 KPI 达成率按所选切片重算(数据源:kpi_values / product_sales)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import api from '../../api/index'
|
||||
|
||||
const props = defineProps<{ mapId: number | null }>()
|
||||
const emit = defineEmits<{ (e: 'slice', payload: any | null): void }>()
|
||||
|
||||
const loadingOptions = ref(false)
|
||||
const granularity = ref<'month' | 'quarter' | 'year'>('month')
|
||||
const period = ref<string>('')
|
||||
const store = ref<string>('')
|
||||
const product = ref<string>('')
|
||||
|
||||
const options = ref<any>(null)
|
||||
const slice = ref<any>(null)
|
||||
const sliceSeq = ref(0)
|
||||
|
||||
const timeReady = computed(() => !!options.value?.time?.available)
|
||||
const storeAvailable = computed(() => !!options.value?.store?.available)
|
||||
const storeReason = computed(() => options.value?.store?.reason || '')
|
||||
const storeValues = computed<any[]>(() => options.value?.store?.values || [])
|
||||
const productAvailable = computed(() => !!options.value?.product?.available)
|
||||
const productReason = computed(() => options.value?.product?.reason || '')
|
||||
const productValues = computed<any[]>(() => options.value?.product?.values || [])
|
||||
const productScopeText = computed(() => (options.value?.product?.kpi_scope || []).join(' / ') || '部分 KPI')
|
||||
|
||||
const periodOptions = computed<string[]>(() => {
|
||||
const t = options.value?.time
|
||||
if (!t) return []
|
||||
if (granularity.value === 'year') return t.years || []
|
||||
if (granularity.value === 'quarter') return t.quarters || []
|
||||
return t.months || []
|
||||
})
|
||||
|
||||
const sliceLabel = computed(() => {
|
||||
const s = slice.value?.slice || {}
|
||||
const parts: string[] = []
|
||||
if (s.period) parts.push(s.period)
|
||||
if (s.store) parts.push(`门店:${s.store}`)
|
||||
if (s.product) parts.push(`产品:${s.product}`)
|
||||
return parts.join(' · ') || '—'
|
||||
})
|
||||
|
||||
const summaryText = computed(() => {
|
||||
const objs = slice.value?.objectives || []
|
||||
const kpis = objs.flatMap((o: any) => o.kpis || []).filter((k: any) => k.available)
|
||||
const missing = objs.flatMap((o: any) => o.kpis || []).length - kpis.length
|
||||
const partial = kpis.filter((k: any) => k.reason && k.aggregation !== 'exact')
|
||||
const bits = [`切片内 ${kpis.length} 支 KPI 有实测值`]
|
||||
if (missing > 0) bits.push(`${missing} 支无数据(已显式标注,不填总额)`)
|
||||
if (partial.length) bits.push(`${partial.length} 支为区间聚合/部分月份`)
|
||||
return bits.join(' · ')
|
||||
})
|
||||
|
||||
function pickDefaultPeriod() {
|
||||
const t = options.value?.time
|
||||
if (!t) return ''
|
||||
if (granularity.value === 'year') return (t.years || []).slice(-1)[0] || ''
|
||||
if (granularity.value === 'quarter') return (t.quarters || []).slice(-1)[0] || ''
|
||||
return (t.months || []).slice(-1)[0] || ''
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
slice.value = null
|
||||
emit('slice', null)
|
||||
period.value = ''
|
||||
store.value = ''
|
||||
product.value = ''
|
||||
options.value = null
|
||||
if (!props.mapId) return
|
||||
loadingOptions.value = true
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${props.mapId}/dimension-options`)
|
||||
options.value = r
|
||||
granularity.value = (r?.time?.months?.length ? 'month' : (r?.time?.quarters?.length ? 'quarter' : 'year'))
|
||||
period.value = pickDefaultPeriod()
|
||||
} catch {
|
||||
options.value = null
|
||||
} finally {
|
||||
loadingOptions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onGranularityChange() {
|
||||
period.value = pickDefaultPeriod()
|
||||
apply()
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (!props.mapId || !period.value) return
|
||||
const seq = ++sliceSeq.value
|
||||
loadingOptions.value = true
|
||||
try {
|
||||
const params: any = { period: period.value }
|
||||
if (store.value) params.store = store.value
|
||||
if (product.value) params.product = product.value
|
||||
const r: any = await api.get(`/maps/${props.mapId}/slice`, { params })
|
||||
if (seq !== sliceSeq.value) return // 只认最后一次选择,避免乱序回包
|
||||
slice.value = r
|
||||
emit('slice', r)
|
||||
} catch (e: any) {
|
||||
if (seq !== sliceSeq.value) return
|
||||
slice.value = { empty: true, empty_reason: e?.message || '切片请求失败', objectives: [] }
|
||||
emit('slice', slice.value)
|
||||
} finally {
|
||||
if (seq === sliceSeq.value) loadingOptions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
sliceSeq.value++
|
||||
slice.value = null
|
||||
store.value = ''
|
||||
product.value = ''
|
||||
period.value = pickDefaultPeriod()
|
||||
emit('slice', null)
|
||||
}
|
||||
|
||||
watch(() => props.mapId, loadOptions, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dim-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
padding: 8px 16px;
|
||||
margin: 0 0 6px;
|
||||
background: var(--bh-bg-card, #fff);
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.df-group { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.df-label { font-size: 12px; color: var(--bh-text-secondary, #6B7280); white-space: nowrap; }
|
||||
.df-actions { margin-left: auto; display: inline-flex; align-items: center; gap: 10px; }
|
||||
.df-active { font-size: 12px; color: var(--bh-text-secondary, #6B7280); font-variant-numeric: tabular-nums; }
|
||||
.df-badge {
|
||||
font-size: 11px; padding: 1px 6px; border-radius: 4px;
|
||||
color: var(--bh-text-secondary, #6B7280);
|
||||
background: var(--bh-bg-muted, #F3F4F6);
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.df-badge-warn { color: #B45309; background: #FFFBEB; border-color: #FDE68A; }
|
||||
.df-hint { font-size: 12px; color: var(--bh-text-secondary, #9CA3AF); }
|
||||
.df-summary { font-size: 12px; color: var(--bh-text-secondary, #6B7280); }
|
||||
.df-empty {
|
||||
flex-basis: 100%;
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 6px 10px; border-radius: 6px;
|
||||
background: #FFFBEB; border: 1px solid #FDE68A;
|
||||
font-size: 12px; color: #92400E;
|
||||
}
|
||||
.df-empty-title { font-weight: 600; }
|
||||
.df-empty-desc { color: #92400E; }
|
||||
.df-empty-note { color: #B45309; opacity: .85; }
|
||||
</style>
|
||||
@@ -25,6 +25,9 @@
|
||||
@toggle-chain-mode="toggleChainMode"
|
||||
/>
|
||||
|
||||
<!-- A3 三维切片(时间/门店/产品):切换即重算;空态显式,不回退总额 -->
|
||||
<MapDimensionFilter :map-id="selectedMap" @slice="onDimensionSlice" />
|
||||
|
||||
<!-- 因果链查看模式提示 -->
|
||||
<div v-if="chainMode" class="chain-mode-bar">
|
||||
<span>🔍 因果链查看模式 — 点击节点聚焦其相关因果链(再次点击取消),其余节点自动变淡</span>
|
||||
@@ -202,6 +205,7 @@ import { sanitizeKrName } from "../utils/krName"
|
||||
import StrategyLayer from "../components/strategy-map/StrategyLayer.vue"
|
||||
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
|
||||
import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
|
||||
import MapDimensionFilter from "../components/map-canvas/MapDimensionFilter.vue"
|
||||
import MapCanvasDialogs from "../components/map-canvas/MapCanvasDialogs.vue"
|
||||
import ConnectionManagerDialog from "../components/map-canvas/ConnectionManagerDialog.vue"
|
||||
import MyDialog from "../components/MyDialog.vue"
|
||||
@@ -329,6 +333,44 @@ interface KpiProgressItem {
|
||||
}
|
||||
const kpiProgressMap = reactive<Record<string, KpiProgressItem>>({})
|
||||
|
||||
// ── A3 维度切片(时间/门店/产品)──────────────────────────────
|
||||
// 切片激活时用它覆盖 objectiveLevels / kpiProgressMap 的展示值:
|
||||
// - available=false 的 KPI 一律 null,不用总额或其它期间冒充
|
||||
const dimSlice = ref<any | null>(null)
|
||||
|
||||
/** 切片结果 → 节点键(dimKey-index);按 目标名 对齐 map_objectives 与本地 dimensions */
|
||||
const sliceByKey = computed<Record<string, any>>(() => {
|
||||
const out: Record<string, any> = {}
|
||||
const payload = dimSlice.value
|
||||
if (!payload || !Array.isArray(payload.objectives)) return out
|
||||
for (const o of payload.objectives) {
|
||||
const dim = dimensions.find((d: any) => d.key === o.dimension_key)
|
||||
if (!dim) continue
|
||||
const idx = (dim.objectives || []).findIndex((x: any) => x.name === o.name)
|
||||
if (idx < 0) continue
|
||||
const kpis = (o.kpis || []).map((k: any) => ({
|
||||
code: k.kpi_code, name: k.kpi_name, actual: k.actual_value, target: k.target_value,
|
||||
unit: k.unit, available: k.available, reason: k.reason,
|
||||
}))
|
||||
const ratios = (o.kpis || [])
|
||||
.filter((k: any) => k.available && k.achievement_pct != null)
|
||||
.map((k: any) => Number(k.achievement_pct))
|
||||
out[`${o.dimension_key}-${idx}`] = {
|
||||
ratio: ratios.length ? Math.round(ratios.reduce((a: number, b: number) => a + b, 0) / ratios.length) : 0,
|
||||
level: o.has_data ? (o.level || 'gray') : 'gray',
|
||||
kpiList: kpis,
|
||||
slice: true,
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function onDimensionSlice(payload: any | null) {
|
||||
dimSlice.value = payload
|
||||
// 切回全景(payload=null)时恢复 /review 的原始状态
|
||||
if (!payload) loadObjectiveLevels()
|
||||
}
|
||||
|
||||
// 弹窗
|
||||
const showNodeDialog = ref(false)
|
||||
const editingNodeData = ref<any>(null)
|
||||
@@ -429,9 +471,20 @@ function setLayerRef(key: string, el: any) {
|
||||
}
|
||||
|
||||
function getNodeLevel(key: string): string {
|
||||
if (dimSlice.value) return sliceByKey.value[key]?.level || 'gray'
|
||||
return objectiveLevels[key] || 'gray'
|
||||
}
|
||||
function getNodeProgress(key: string): any {
|
||||
if (dimSlice.value) {
|
||||
const s = sliceByKey.value[key]
|
||||
return {
|
||||
ratio: s?.ratio ?? 0,
|
||||
level: s?.level || 'gray',
|
||||
kpiList: s?.kpiList || [],
|
||||
planSummary: kpiProgressMap[key]?.planSummary ?? null,
|
||||
slice: true,
|
||||
}
|
||||
}
|
||||
return kpiProgressMap[key] || null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user