merge: origin/main (A3 三维切片) 合入战略地图改造分支

This commit is contained in:
Hermes CI Fix
2026-10-19 14:32:33 +08:00
12 changed files with 986 additions and 1 deletions
+600
View File
@@ -0,0 +1,600 @@
# -*- coding: utf-8 -*-
"""战略地图 · 维度切片(时间 / 门店 / 产品)—— A3
目的:让战略地图的 KPI 能按维度切片重算,而不是永远只显示总额/最新值。
口径(与既有读路径保持一致,不另造口径):
- 实测区:复用 `app.utils.kpi_value_scope.actual_only_clause()`,预估行不得进入切片。
- 指标方向:优先 `kpi_definitions.direction`;缺失时退回
`app.utils.strategy_board.INVERSE_KPI_IDS` 显式白名单(不用 NULL 推断)。
- 状态类:`unit == '状态'` ⇒ 1 = 达标 / 0 = 未达标,不做除法。
- 达成率阈值沿用地图现有语义:≥90% success / ≥60% warning / <60% danger。
三维可用性(以库内事实为准,不假装有数据):
- 时间:`kpi_values.period`(YYYY / YYYY-Hn / YYYY-MM)真实存在,可切。
- 产品:`product_sales`(仅 entity 1,2026-01…2026-08,按 `product_name`)真实存在;
只对「产品级可取数」的 KPI(PRODUCT_SCOPED_CODES)生效,其余 KPI 显式不可用。
- 门店:库内**无**门店级事实表(无 store/dept 字段,`voucher_details` 无辅助核算),
仅能从 `subjects.subject_name` 反推门店名称 ⇒ 提供候选值但标记不可用;
切片结果一律 `available=false`,**绝不回退总额**。
空态契约:所选维度下没有某 KPI 的实测值时,返回 `actual_value=null` +
`available=false` + `reason`;绝不把总额/其它期间的值填进来。
"""
from __future__ import annotations
import json
import re
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.auth_middleware import require_role
from app.database import get_db
from app.deps import get_entity_id
from app.models import KPIDefinition, KPIValue, MapObjective, ProductSales, StrategicMap
from app.utils.kpi_value_scope import actual_only_clause
from app.utils.strategy_board import INVERSE_KPI_IDS, STATUS_UNIT
router = APIRouter(
prefix="/api/cma/maps",
tags=["战略地图维度切片"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
#: 目前只有营业收入的月度总额来自 product_sales(已对拍:2026-01…07 与 KPI 值逐月相等)
PRODUCT_SCOPED_CODES = frozenset({"F_REVENUE"})
#: 流量型单位:季度/年度切片按「月份求和」
FLOW_UNITS = frozenset({"万元", "元", "个", "次", "轮", "件", "人"})
#: 达成率 → 色阶(与地图现有 legend 一致)
SUCCESS_RATE = 0.9
WARNING_RATE = 0.6
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
_QUARTER_RE = re.compile(r"^\d{4}-Q[1-4]$")
_YEAR_RE = re.compile(r"^\d{4}$")
_STORE_SUFFIX_RE = re.compile(r"(吴忠地标店|吴忠隆泰店|新区大店|地标店|一店|二店)")
_STORE_DISPLAY_ORDER = ["一店", "二店", "吴忠地标店", "新区大店", "吴忠隆泰店"]
#: 存量/余额型指标:区间切片取「最新一期」,禁止按月求和(否则余额被累加)
BALANCE_HINTS = ("余额", "库存", "去化", "货币资金", "借款", "应收", "应付", "资产", "负债", "占用", "头寸")
#: 产品源与 KPI 实测值的允许偏差(超过即判源未对齐,不参与切片)
PRODUCT_SOURCE_TOLERANCE = 0.01
# ── 小工具 ────────────────────────────────────────────────
def _obj_kpi_codes(raw: Any) -> list[str]:
"""map_objectives.kpis 兼容四种存储形态:list[str] / json 字符串 / list[dict] / NULL。"""
if raw is None:
return []
data = raw
if isinstance(raw, str):
try:
data = json.loads(raw)
except (TypeError, ValueError):
return []
if not isinstance(data, list):
return []
out: list[str] = []
for item in data:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict):
code = item.get("kpi_code") or item.get("code")
if code:
out.append(str(code))
return out
def _map_or_404(db: Session, map_id: int, entity_id: int) -> StrategicMap:
m = db.query(StrategicMap).filter(
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id,
).first()
if not m:
raise HTTPException(404, "战略地图不存在")
return m
def _map_kpis(db: Session, map_id: int, entity_id: int):
"""返回 (map_objectives, {kpi_code: KPIDefinition}),KPI 只取本实体(账套隔离)。"""
objs = db.query(MapObjective).filter(
MapObjective.map_id == map_id, MapObjective.entity_id == entity_id,
).order_by(MapObjective.sort_order, MapObjective.id).all()
codes: set[str] = set()
for o in objs:
codes.update(_obj_kpi_codes(o.kpis))
defs: dict[str, KPIDefinition] = {}
if codes:
for k in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id, KPIDefinition.kpi_code.in_(codes),
).all():
defs[k.kpi_code] = k
return objs, defs
def _parse_period(period: Optional[str]) -> tuple[Optional[str], Optional[str]]:
"""返回 (粒度, 归一值):year / quarter / month;非法或空返回 (None, None)。"""
if not period:
return None, None
p = period.strip()
if _MONTH_RE.match(p):
return "month", p
if _QUARTER_RE.match(p):
return "quarter", p
if _YEAR_RE.match(p):
return "year", p
return None, None
def _months_of(period_type: str, period: str) -> list[str]:
if period_type == "month":
return [period]
year = period[:4]
if period_type == "quarter":
q = int(period[-1])
start = (q - 1) * 3 + 1
return [f"{year}-{m:02d}" for m in range(start, start + 3)]
return [f"{year}-{m:02d}" for m in range(1, 13)]
def _unit_kind(unit: Optional[str], kpi: Optional[KPIDefinition] = None) -> str:
u = (unit or "").strip()
if u == STATUS_UNIT:
return "status"
name = (getattr(kpi, "kpi_name", "") or "") + " " + (getattr(kpi, "kpi_code", "") or "")
if any(h in name for h in BALANCE_HINTS):
return "balance"
if u in FLOW_UNITS:
return "flow"
return "ratio"
def _is_lower_better(kpi: KPIDefinition) -> bool:
direction = (getattr(kpi, "direction", None) or "").strip().lower()
if direction in ("lower_better", "lower", "desc", "down"):
return True
if direction in ("higher_better", "higher", "asc", "up"):
return False
return kpi.id in INVERSE_KPI_IDS
def _level_from_ratio(ratio: Optional[float]) -> str:
if ratio is None:
return "gray"
if ratio >= SUCCESS_RATE:
return "green"
if ratio >= WARNING_RATE:
return "yellow"
return "red"
def _base_target(kpi: KPIDefinition) -> Optional[float]:
for attr in ("target_monthly", "target_value"):
v = getattr(kpi, attr, None)
if v is not None:
try:
return float(v)
except (TypeError, ValueError):
continue
return None
def _scaled_target(kpi: KPIDefinition, period_type: str, year_period: Optional[str], db: Session, entity_id: int) -> tuple[Optional[float], str]:
"""目标值:年切片优先取 kpi_targets 的年度行;否则按单位缩放月度目标。
返回 (target, target_basis)。
"""
if year_period:
row = db.execute(text(
"SELECT target_value FROM kpi_targets WHERE entity_id=:e AND kpi_id=:k "
"AND period=:p AND target_type IN ('yearly','annual') ORDER BY target_type='yearly' DESC LIMIT 1"
), {"e": entity_id, "k": kpi.id, "p": year_period}).fetchone()
if row and row[0] is not None:
return float(row[0]), f"kpi_targets.{year_period}.yearly"
base = _base_target(kpi)
if base is None:
return None, "no_target"
kind = _unit_kind(kpi.unit, kpi)
if kind == "status":
return None, "status_no_target"
if kind == "balance":
return base, "target_balance"
if period_type == "month":
return base, "target_monthly"
if period_type == "quarter":
return (base * 3 if kind == "flow" else base), "target_monthly×3"
return (base * 12 if kind == "flow" else base), "target_monthly×12"
def _aggregate(rows: list[KPIValue], unit: Optional[str], kpi: Optional[KPIDefinition] = None) -> tuple[Optional[float], str, list[str], bool]:
"""把区间内的实测行聚合成一个切片值。
返回 (value, aggregation, months_used, partial)。
- 状态类 / 存量余额类:取最新一期(不求和)
- 流量型:求和
- 比率/天数:均值
"""
if not rows:
return None, "none", [], True
ordered = sorted(rows, key=lambda v: (str(v.period), v.id))
months = [str(v.period) for v in ordered]
kind = _unit_kind(unit, kpi)
if kind in ("status", "balance"):
last = ordered[-1]
return (float(last.actual_value) if last.actual_value is not None else None), "latest", [str(last.period)], False
vals = [float(v.actual_value) for v in ordered if v.actual_value is not None]
if not vals:
return None, "none", months, True
if kind == "flow":
return round(sum(vals), 4), "sum_of_months", months, False
return round(sum(vals) / len(vals), 4), "mean_of_months", months, False
# ── 维度候选值 ────────────────────────────────────────────
def _time_options(db: Session, kpi_ids: list[int], entity_id: int) -> dict:
if not kpi_ids:
return {"available": False, "reason": "该地图未绑定 KPI", "years": [], "quarters": [], "months": []}
rows = db.query(KPIValue.period).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id.in_(kpi_ids),
actual_only_clause(),
).distinct().all()
periods = [str(r[0]) for r in rows if r[0]]
months = sorted(p for p in periods if _MONTH_RE.match(p))
quarters = sorted(p for p in periods if _QUARTER_RE.match(p))
years = sorted({p[:4] for p in periods})
if months:
for y in sorted({m[:4] for m in months}):
for q in range(1, 5):
qq = f"{y}-Q{q}"
if qq not in quarters and any(m in _months_of("quarter", qq) for m in months):
quarters.append(qq)
quarters.sort()
return {
"available": bool(periods),
"reason": None if periods else "该地图 KPI 无实测值(kpi_values 为空)",
"years": years,
"quarters": quarters,
"months": months,
"granularity": ["year", "quarter", "month"],
}
def _store_options(db: Session, entity_id: int) -> dict:
rows = db.execute(text(
"SELECT DISTINCT subject_code, subject_name FROM subjects WHERE entity_id=:e AND subject_name LIKE '%店%'"
), {"e": entity_id}).fetchall()
found: set[str] = set()
for _code, name in rows:
for hit in _STORE_SUFFIX_RE.findall(str(name or "")):
found.add("吴忠地标店" if hit == "地标店" else hit)
values = [{"code": f"BR-{i+1:02d}", "name": n} for i, n in enumerate(
[n for n in _STORE_DISPLAY_ORDER if n in found] or sorted(found)
)]
return {
"available": False,
"reason": "该维度暂无数据:库内无门店级事实表(无 store/dept 字段,凭证无辅助核算),"
"门店名称仅能从费用类科目名称反推,无法按门店切 KPI;不回退总额",
"values": values,
"source": "subjects.subject_name 反推(仅费用类科目)",
"coverage": "expense_subjects_only",
}
def _product_options(db: Session, entity_id: int) -> dict:
rows = db.query(ProductSales.product_name).filter(
ProductSales.entity_id == entity_id,
).distinct().all()
names = sorted({str(r[0]) for r in rows if r[0]})
if not names:
return {"available": False, "reason": "该实体无产品级销售数据(product_sales 为空)", "values": [], "kpi_scope": sorted(PRODUCT_SCOPED_CODES)}
span = db.query(ProductSales.period_month).filter(
ProductSales.entity_id == entity_id,
).distinct().all()
months = sorted({str(r[0]) for r in span if r[0]})
return {
"available": True,
"reason": None,
"values": [{"code": n, "name": n} for n in names],
"kpi_scope": sorted(PRODUCT_SCOPED_CODES),
"period_coverage": {"min": months[0] if months else None, "max": months[-1] if months else None, "months": months},
"source": "product_sales.product_name(按 period_month 聚合,元→万元)",
}
# ── 切片计算 ──────────────────────────────────────────────
def _slice_kpi_month(db: Session, kpi: KPIDefinition, entity_id: int, period: str) -> tuple[Optional[float], Optional[str]]:
rows = db.query(KPIValue).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
actual_only_clause(),
).order_by(KPIValue.id.desc()).all()
if not rows:
return None, f"{period} 无实测值"
# 同期多行:优先 verified/calculated(strategy_board 的同口径排序)
rank = {"verified": 3, "calculated": 2, "pending": 1}
best = sorted(rows, key=lambda v: rank.get((v.data_status or "").lower(), 0), reverse=True)[0]
return (float(best.actual_value) if best.actual_value is not None else None), None
def _slice_kpi_period(db: Session, kpi: KPIDefinition, entity_id: int, ptype: str, period: str) -> tuple[Optional[float], str, list[str], Optional[str]]:
"""时间切片:月=精确值;季/年=区间聚合(年优先取 YYYY 行)。"""
if ptype == "month":
val, err = _slice_kpi_month(db, kpi, entity_id, period)
return val, "exact", [period], err
if ptype == "year":
val, err = _slice_kpi_month(db, kpi, entity_id, period)
if val is not None:
return val, "exact", [period], None
months = _months_of(ptype, period)
rows = db.query(KPIValue).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id == kpi.id,
KPIValue.period.in_(months),
actual_only_clause(),
).all()
val, agg, used, _ = _aggregate(rows, kpi.unit, kpi)
expected = 3 if ptype == "quarter" else 12
if val is None:
return None, "none", [], f"{period} 无实测值(区间内无月度数据)"
partial = len(used) < expected and agg != "latest"
note = f"{period} 仅 {len(used)}/{expected} 个月有实测值" if partial else None
return val, agg, used, note
def _slice_kpi_product(db: Session, kpi: KPIDefinition, entity_id: int, product: str, ptype: str, period: str) -> tuple[Optional[float], str, list[str], Optional[str]]:
"""产品切片:仅对 PRODUCT_SCOPED_CODES 生效,并按「逐月源一致性」闸门。
`product_sales` 月合计与 KPI 同月实测值必须吻合(±1%)才纳入;不吻合的月份
视为源未对齐(例如 2026-08 产品表 8.08 万 vs KPI 14.45 万),
不做缩放、不并入,避免用产品表冒充 KPI 总额。
"""
if kpi.kpi_code not in PRODUCT_SCOPED_CODES:
return None, "unsupported", [], "该 KPI 无产品级取数(维度未落库),不回退总额"
months = _months_of(ptype, period)
# 逐月源一致性:product_sales 全量合计(元) vs KPI 实测值(万元)
totals = db.query(ProductSales.period_month, ProductSales.sales_amount).filter(
ProductSales.entity_id == entity_id, ProductSales.period_month.in_(months),
).all()
by_month: dict[str, float] = {}
for pm, amt in totals:
by_month[str(pm)] = by_month.get(str(pm), 0.0) + float(amt or 0)
kpi_month: dict[str, float] = {}
for m in months:
v, _err = _slice_kpi_month(db, kpi, entity_id, m)
if v is not None:
kpi_month[m] = float(v) * 10000.0 # 万元 → 元
covered = []
misaligned = []
for m in months:
if m not in by_month or m not in kpi_month:
continue
base = kpi_month[m]
if base == 0:
continue
if abs(by_month[m] - base) / abs(base) <= PRODUCT_SOURCE_TOLERANCE:
covered.append(m)
else:
misaligned.append(m)
if not covered:
detail = ""
if misaligned:
detail = "(源未对齐月份:" + "、".join(misaligned) + ")"
return None, "uncovered", [], f"产品表与 KPI 实测值不一致{detail},该期间不做产品切片、不回退总额"
rows = db.query(ProductSales).filter(
ProductSales.entity_id == entity_id,
ProductSales.product_name == product,
ProductSales.period_month.in_(covered),
).all()
if not rows:
return None, "none", [], f"产品「{product}」在 {'、'.join(covered)} 无销售数据"
used = sorted({r.period_month for r in rows})
total = sum(float(r.sales_amount or 0) for r in rows) / 10000.0 # 元 → 万元
note = None
if len(covered) < len(months):
note = f"仅 {'、'.join(covered)} 通过源一致性校验" + (f";未对齐:{'、'.join(misaligned)}" if misaligned else "")
return round(total, 4), "sum_of_product_sales", used, note
def build_slice(db: Session, entity_id: int, map_id: int, period: Optional[str], store: Optional[str], product: Optional[str]) -> dict:
_map_or_404(db, map_id, entity_id)
objs, defs = _map_kpis(db, map_id, entity_id)
ptype, pnorm = _parse_period(period)
if period and not ptype:
raise HTTPException(400, "period 格式应为 YYYY / YYYY-Qn / YYYY-MM")
if not pnorm:
# 未选时间:取该地图 KPI 的最新实测期(沿用现有「最新值」语义,并显式标注)
latest = None
ids = [k.id for k in defs.values()]
if ids:
rows = db.query(KPIValue.period).filter(
KPIValue.entity_id == entity_id, KPIValue.kpi_id.in_(ids), actual_only_clause(),
).all()
cand = [str(r[0]) for r in rows if r[0]]
months = sorted(p for p in cand if _MONTH_RE.match(p))
pnorm = months[-1] if months else (sorted(cand)[-1] if cand else None)
if pnorm:
ptype = "month" if _MONTH_RE.match(pnorm) else ("quarter" if _QUARTER_RE.match(pnorm) else "year")
if latest is None and not pnorm:
latest = None
ptype = ptype or "month"
pnorm = pnorm or ""
dims_meta = {
"time": {"available": bool(pnorm), "period": pnorm, "type": ptype,
"reason": None if pnorm else "该地图 KPI 无实测值"},
"store": _store_options(db, entity_id) if store else {"available": True, "selected": None},
"product": _product_options(db, entity_id) if product else {"available": True, "selected": None},
}
if store:
dims_meta["store"]["selected"] = store
if product:
dims_meta["product"]["selected"] = product
objectives: list[dict] = []
any_available = False
empty_reasons: list[str] = []
for obj in objs:
codes = [c for c in _obj_kpi_codes(obj.kpis) if c in defs]
kpi_rows: list[dict] = []
worst = "green"
for code in codes:
kpi = defs[code]
target = None
basis = "no_period"
actual = None
agg = "none"
used: list[str] = []
reason: Optional[str] = None
if store:
reason = "该维度暂无数据:无门店级 KPI 事实表,不回退总额"
elif not pnorm:
reason = "无可用实测期间"
elif product:
actual, agg, used, reason = _slice_kpi_product(db, kpi, entity_id, product, ptype, pnorm)
target, basis = _scaled_target(kpi, ptype, pnorm[:4] if ptype == "year" else None, db, entity_id)
# 产品切片只覆盖通过源一致性校验的月份 ⇒ 流量型目标按覆盖月数缩放
if actual is not None and _unit_kind(kpi.unit, kpi) == "flow" and used:
base = _base_target(kpi)
if base is not None:
target = round(base * len(used), 4)
basis = f"target_monthly×{len(used)}(覆盖月数)"
else:
actual, agg, used, note = _slice_kpi_period(db, kpi, entity_id, ptype, pnorm)
target, basis = _scaled_target(kpi, ptype, pnorm[:4] if ptype == "year" else None, db, entity_id)
reason = note
available = actual is not None
act = float(actual) if actual is not None else None
tgt = float(target) if target is not None else None
ratio = None
level = "gray"
if act is not None and _unit_kind(kpi.unit) == "status":
ratio = 1.0 if act >= 1 else 0.0
level = "green" if ratio == 1.0 else "red"
elif act is not None and tgt:
if _is_lower_better(kpi):
ratio = (tgt / act) if act > 0 else None
else:
ratio = act / tgt if tgt != 0 else None
level = _level_from_ratio(ratio)
if reason and available and agg != "exact":
# 部分月份可用:值可用但必须带口径说明
pass
if level == "red":
worst = "red"
elif level == "yellow" and worst != "red":
worst = "yellow"
elif level == "gray" and worst == "green" and not available:
worst = "gray"
if not available:
empty_reasons.append(reason or f"{code} 无数据")
any_available = any_available or available
kpi_rows.append({
"kpi_id": kpi.id,
"kpi_code": code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit,
"actual_value": actual,
"target_value": target,
"target_basis": basis,
"achievement_pct": (round(ratio * 100, 1) if ratio is not None else None),
"level": level,
"available": available,
"reason": reason,
"aggregation": agg,
"periods_used": used,
})
objectives.append({
"objective_id": obj.id,
"dimension_key": obj.dimension_key,
"name": obj.name,
"kpis": kpi_rows,
"level": worst if kpi_rows else "gray",
"has_data": any(k["available"] for k in kpi_rows),
})
empty = not any_available
empty_reason = None
if empty:
if store:
empty_reason = "该维度暂无数据:库内无门店级 KPI 事实表(G4 未落地),不回退总额"
elif product:
specific = next((r for r in empty_reasons if "产品" in r), None)
empty_reason = specific or "该维度暂无数据:所选产品未覆盖该地图 KPI,不回退总额"
elif not pnorm:
empty_reason = "该地图 KPI 无实测值"
else:
empty_reason = empty_reasons[0] if empty_reasons else "所选切片无实测值"
return {
"map_id": map_id,
"entity_id": entity_id,
"slice": {"period": pnorm, "period_type": ptype, "store": store, "product": product},
"empty": empty,
"empty_reason": empty_reason,
"dims": dims_meta,
"objectives": objectives,
"caliber": {
"actual_scope": "实测区(预估行排除)",
"achievement_thresholds": {"success": SUCCESS_RATE, "warning": WARNING_RATE},
"aggregation": {"flow": "sum_of_months", "ratio": "mean_of_months", "status": "latest"},
"no_fallback": "维度无数据时返回 null,不用总额/其它期间冒充",
},
}
# ── 路由 ──────────────────────────────────────────────────
@router.get("/{map_id}/dimension-options")
def get_dimension_options(
map_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""战略地图三维候选值(时间真实可切;门店/产品按库内事实显式标注可用性)。"""
_map_or_404(db, map_id, entity_id)
_objs, defs = _map_kpis(db, map_id, entity_id)
ids = [k.id for k in defs.values()]
return {
"map_id": map_id,
"entity_id": entity_id,
"time": _time_options(db, ids, entity_id),
"store": _store_options(db, entity_id),
"product": _product_options(db, entity_id),
"kpi_count": len(defs),
}
@router.get("/{map_id}/slice")
def get_map_slice(
map_id: int,
period: Optional[str] = Query(None, description="YYYY / YYYY-Qn / YYYY-MM;空=最新实测期"),
store: Optional[str] = Query(None, description="门店名称;库内无门店级事实时显式空态"),
product: Optional[str] = Query(None, description="产品名称(product_sales)"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""按 时间/门店/产品 切片重算地图 KPI(只读;无数据 ⇒ null + 原因,绝不回退总额)。"""
return build_slice(db, entity_id, map_id, period, store, product)
+2 -1
View File
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv
from app.database import init_db, get_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth, require_auth_401
@@ -69,6 +69,7 @@ app.include_router(cashflow_structure.compat_router)
app.include_router(cost.router)
app.include_router(predict.router)
app.include_router(growth_quality.router)
app.include_router(map_dimensions.router)
app.include_router(products.router)
app.include_router(reports.router)
app.include_router(security.router)
+25
View File
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""A3 产品切片正例:2026-07(源一致性通过)逐产品核对。"""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for line in open('/root/cma-management/backend/.env'):
line = line.strip()
if '=' in line and not line.startswith('#') and line.split('=')[0].startswith('CMA_DB'):
k, v = line.split('=', 1); os.environ.setdefault(k, v)
from app.database import get_session_local
from app.api.map_dimensions import build_slice
db = get_session_local()()
for prod in ("酣客酱酒", "禧酱", "标准版"):
r = build_slice(db, 1, 65, "2026-07", None, prod)
rev = [k for o in r["objectives"] for k in o["kpis"] if k["kpi_code"] == "F_REVENUE"][0]
print(prod, "| empty:", r["empty"], "|", r["empty_reason"], "| rev:", rev["actual_value"], "/", rev["target_value"], rev["achievement_pct"], rev["aggregation"], rev["periods_used"], "| note:", rev["reason"])
r = build_slice(db, 1, 65, "2026-Q3", None, "酣客酱酒")
rev = [k for o in r["objectives"] for k in o["kpis"] if k["kpi_code"] == "F_REVENUE"][0]
print("Q3 酣客酱酒 | empty:", r["empty"], "| rev:", rev["actual_value"], "/", rev["target_value"], rev["achievement_pct"], rev["aggregation"], rev["periods_used"], "| note:", rev["reason"])
r = build_slice(db, 1, 65, None, None, None)
print("默认(最新期):", r["slice"], [ (k["kpi_code"],k["actual_value"]) for o in r["objectives"] for k in o["kpis"] if k["available"]][:6])
print("store 空态:", build_slice(db, 1, 65, "2026-07", "一店", None)["empty_reason"])
db.close()
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""A3 自测:直接调 build_slice(不启服务),校验真实库切片与空态。"""
import os, sys, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for line in open('/root/cma-management/backend/.env'):
line = line.strip()
if '=' in line and not line.startswith('#') and line.split('=')[0].startswith(('CMA_DB', 'ERP_DB')):
k, v = line.split('=', 1); os.environ.setdefault(k, v)
from app.database import get_session_local
from app.api.map_dimensions import build_slice, _time_options, _store_options, _product_options
db = get_session_local()()
def brief(r):
print("slice:", r["slice"], "| empty:", r["empty"], "|", r["empty_reason"])
for o in r["objectives"]:
ks = [f'{k["kpi_code"]}={k["actual_value"]}/{k["target_value"]}@{k["achievement_pct"]}%({k["level"]},{k["aggregation"]}){"" if k["available"] else "[空:"+str(k["reason"])[:36]+"]"}' for k in o["kpis"]]
if ks:
print(" ", o["dimension_key"], o["name"], "|", "; ".join(ks))
print("=== 1) 2026-08 月切片 (map 65)")
brief(build_slice(db, 1, 65, "2026-08", None, None))
print("\n=== 2) 2026-Q3 季切片")
brief(build_slice(db, 1, 65, "2026-Q3", None, None))
print("\n=== 3) 2026 年切片")
brief(build_slice(db, 1, 65, "2026", None, None))
print("\n=== 4) 产品切片 酣客酱酒 @2026-08")
brief(build_slice(db, 1, 65, "2026-08", None, "酣客酱酒"))
print("\n=== 5) 产品切片 不存在产品 @2026-08")
brief(build_slice(db, 1, 65, "2026-08", None, "不存在的产品X"))
print("\n=== 6) 门店切片 一店 @2026-08")
brief(build_slice(db, 1, 65, "2026-08", "一店", None))
print("\n=== 7) entity 隔离: map65 + entity2")
try:
build_slice(db, 2, 65, "2026-08", None, None)
print("FAIL 未拦截")
except Exception as e:
print("OK 拦截:", type(e).__name__, getattr(e, "status_code", ""), getattr(e, "detail", e))
print("\n=== 8) options map65")
o = build_slice(db, 1, 65, None, None, None)
print("time dims:", json.dumps(o["dims"]["time"], ensure_ascii=False)[:300])
print("store:", json.dumps(o["dims"]["store"], ensure_ascii=False)[:300])
print("product:", json.dumps(o["dims"]["product"], ensure_ascii=False)[:300])
db.close()
@@ -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>
+3
View File
@@ -360,6 +360,8 @@
<el-button type="primary" @click="savePlan" :loading="savingPlan">保存</el-button>
</template>
</MyDialog>
<!-- KBFF-v1.1: 按 related_page 关联知识接线 · /alerts -->
<KnowledgePanel related-page="/alerts" />
</div>
</template>
@@ -372,6 +374,7 @@ import BotSourceTag from '../components/BotSourceTag.vue'
import RiskCell from '../components/charts/RiskCell.vue'
import { sessionEntityName } from '../utils/entity'
import MyDialog from '../components/MyDialog.vue'
import KnowledgePanel from '../components/KnowledgePanel.vue'
const route = useRoute()
const activeTab = ref((route.meta?.tab as string) || 'alerts')
+3
View File
@@ -769,6 +769,8 @@
</el-form>
<template #footer><el-button @click="showDerivationRuleDialog = false">取消</el-button><el-button type="primary" @click="saveDerivationRule">保存</el-button></template>
</MyDialog>
<!-- KBFF-v1.1: 按 related_page 关联知识接线 · /budget -->
<KnowledgePanel related-page="/budget" />
</div>
</div>
</template>
@@ -778,6 +780,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
import DriverFactorBudget from '../components/DriverFactorBudget.vue'
import KnowledgePanel from '../components/KnowledgePanel.vue'
const activeTab = ref('input')
const currentYear = new Date().getFullYear()
@@ -184,6 +184,8 @@
<!-- 无数据提示 -->
<el-empty v-if="!loading && hierarchyData.length === 0 && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
<!-- KBFF-v1.1: 按 related_page 关联知识接线 · /deviations -->
<KnowledgePanel related-page="/deviations" />
</div>
</template>
@@ -191,6 +193,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { budgetApi, kpiApi } from '../api/index'
import KnowledgePanel from '../components/KnowledgePanel.vue'
const currentYear = new Date().getFullYear()
const yearOptions = computed(() => {
+3
View File
@@ -398,6 +398,8 @@
<el-button type="primary" :disabled="!selectedTemplate" @click="doInstantiate">从模板创建</el-button>
</template>
</MyDialog>
<!-- KBFF-v1.1: 按 related_page 关联知识接线 · /kpis -->
<KnowledgePanel related-page="/kpis" />
</div>
</template>
@@ -407,6 +409,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { kpiApi, templateApi, dashboardApi, userApi, orgApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
import { useFormGuard, trackDialogForm } from '../composables/useFormGuard'
import KnowledgePanel from '../components/KnowledgePanel.vue'
// ── 数据 ──
const kpis = ref<any[]>([])
+53
View File
@@ -54,6 +54,9 @@
@completeness="showCompleteness = true"
/>
<!-- A3 三维切片(时间/门店/产品):切换即重算;空态显式,不回退总额 -->
<MapDimensionFilter :map-id="selectedMap" @slice="onDimensionSlice" />
<!-- 因果链查看模式提示 -->
<div v-if="chainMode" class="chain-mode-bar">
<span class="chain-ico">
@@ -312,6 +315,7 @@ import MapNodeDetail from "../components/strategy-map/MapNodeDetail.vue"
import MapCompleteness from "../components/strategy-map/MapCompleteness.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"
@@ -573,6 +577,44 @@ const visibleObjectiveCount = computed(() =>
treeGroups.value.reduce((n: number, g: any) => n + g.objectives.length, 0)
)
// ── 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)
@@ -679,9 +721,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
}
+3
View File
@@ -340,6 +340,8 @@
</el-timeline-item>
</el-timeline>
</MyDialog>
<!-- KBFF-v1.1: 按 related_page 关联知识接线 · /maps-review -->
<KnowledgePanel related-page="/maps-review" />
</div>
</template>
@@ -349,6 +351,7 @@ import { useRoute, useRouter } from "vue-router"
import { ElMessage } from "element-plus"
import api from "../api/index"
import MyDialog from '../components/MyDialog.vue'
import KnowledgePanel from '../components/KnowledgePanel.vue'
const route = useRoute()
const $router = useRouter()
+3
View File
@@ -434,6 +434,8 @@
<CostIntelligence />
</el-tab-pane>
</el-tabs>
<!-- KBFF-v1.1: 按 related_page 关联知识接线 · /predict -->
<KnowledgePanel related-page="/predict" />
</div>
</template>
@@ -442,6 +444,7 @@ import { ref, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { predictApi } from '../api/index'
import CostIntelligence from './CostIntelligence.vue'
import KnowledgePanel from '../components/KnowledgePanel.vue'
const activeTab = ref('cvp')