wip(strategy-map): v4 口径落码(分线营收/新式毛利率/管理指标/C1 hold/57 归档)

This commit is contained in:
Hermes CI Fix
2026-10-19 14:32:08 +08:00
parent 4d20c27de4
commit a8141e8879
7 changed files with 538 additions and 88 deletions
+4 -1
View File
@@ -108,7 +108,10 @@ def completeness_summary(
"display": "—",
}
# ---- 目标级 · 附注口径:全量 4 图,判据 = 有KPI挂接(23-KPI 挂接 或 map_objectives.kpis 非空)----
# ---- 目标级 · 附注口径:全量 4 图,判据 = 有 KPI 挂接 ----
# 判据复现(definition 件 §5 机读件 20261018-c1-spec/map-objectives-dump.csv 逐行):
# 23KPI 覆盖 26 + 非23KPI 但 map_objectives.kpis 非空 4 + 缺口 1 = 30/31。
# 即「被 23-KPI 集挂接」与「kpis 非空」取并集;与主口径判据(仅 23-KPI 挂接)不是同一判据。
# 附注与主数同属地图 65 的账套(全量 4 图);无地图 65 的账套不适用,展示「—」而非 0。
footnote_total = len(rows)
footnote_covered = sum(1 for r in rows if r["covered_23kpi"] or r["has_kpi"])
+238
View File
@@ -0,0 +1,238 @@
"""战略地图 · KPI 摘要接口(真数据 · 口径 CMA-A1 终稿,2026-10-19)
口径唯一真源:app/data/revenue_caliber.py(含科目映射与禁写清单)
本模块**不存任何金额常量**:全部对 voucher_details 现场聚合。
取数铁律:
1. 必经 `summary NOT LIKE '%结转%'` —— 否则损益双向抵销,收入恒为 0(历史踩坑);
2. 实体隔离:只取 token 绑定的 entity_id,不跨账套;
3. 无数据 ⇒ null + missing_reason,**不得兜底造数**;
4. 分业务线分列;禁净额;禁合并成单一「营业收入」(合计仅作可复算校验位)。
历史期缺同票折扣发票(四件套 0/4)⇒ 金额带 `data_status="estimated"` +
`data_tag="测算/待票"`,不得静默按新式重算冒充实账。
"""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth
from app.data import revenue_caliber as rc
from app.database import get_db
from app.deps import get_entity_id
router = APIRouter(
prefix="/api/cma/maps/summary",
tags=["战略地图摘要"],
dependencies=[Depends(require_auth)],
)
MONTH_RE = "^[0-9]{4}-[0-9]{2}$"
NOT_CARRY = "summary NOT LIKE '%结转%'"
def _wan(v) -> Optional[float]:
"""元 → 万元(2 位),None 透传。"""
if v is None:
return None
return round(float(v) / 10000.0, 2)
def _period_window(db: Session, entity_id: int):
"""取该实体最新有凭证的会计月,窗口 = 当年 01 ~ 最新月(不写死日期)。"""
latest = db.execute(text(
f"SELECT MAX(period) FROM voucher_details "
f"WHERE entity_id = :e AND period REGEXP :re AND {NOT_CARRY}"
), {"e": entity_id, "re": MONTH_RE}).scalar()
if not latest:
return None, None, None
year = str(latest)[:4]
return f"{year}-01", str(latest), year
def _sum(db: Session, sql: str, params: dict):
return db.execute(text(sql), params).scalar()
@router.get("")
def map_summary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""摘要条数据:分业务线营业收入 / 毛利率(新式)/ 管理指标 / 口径切换标识。"""
cfg = rc.SUBJECT_MAP.get(entity_id)
p0, p1, year = _period_window(db, entity_id)
period_label = f"{p0}~{p1}" if p0 else None
if cfg is None or p0 is None:
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"lines": [],
"missing_reason": "该账套无可用凭证期" if cfg else "未配置科目映射",
},
"gross_margin": {"available": False, "value": None, "missing_reason": "无数据"},
"management": [],
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
}
base = (f"entity_id = :e AND period >= :p0 AND period <= :p1 AND {NOT_CARRY}")
params = {"e": entity_id, "p0": p0, "p1": p1}
wine = rc.wine_sql("summary")
rev_prefix = cfg["revenue"] + "%"
# ── 1. 收入:主营科目按业务线分列(全额售价,禁净额)──────────────────
revenue = {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"period": period_label,
"lines": [],
}
if cfg["lines"]:
# 表 1:酒类线(keywords 命中)
wine_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": rev_prefix})
# 表 2:非酒类线(同一科目、不命中 keywords)
other_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND NOT {wine}
""", {**params, "pfx": rev_prefix})
# e1 => [酒类, 其他];e2 => [自营(IT)=非酒类, 代销(酒类)=酒类]
values = {}
if entity_id == 1:
values = {"liquor": wine_net, "other": other_net}
elif entity_id == 2:
values = {"self_it": other_net, "agency_liquor": wine_net}
lines = []
for ln in cfg["lines"]:
raw = values.get(ln["key"])
lines.append({
"key": ln["key"],
"label": ln["label"],
"amount": _wan(raw),
"unit": "万元",
"data_status": "verified" if raw is not None else "missing",
"missing_reason": None if raw is not None else "该业务线本期无入账凭证",
})
total = _wan((wine_net or 0) + (other_net or 0)) if (wine_net is not None or other_net is not None) else None
revenue = {
**revenue,
"available": any(l["amount"] is not None for l in lines),
"lines": lines,
"total": total,
"total_label": "合计(各线相加,仅作复算校验)",
"merge_forbidden": True,
"note": "各业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
# ── 2. 毛利率:新式 (收入净额 − 折扣后净进价成本) / 收入净额 ────────────
gross_margin = {"available": False, "value": None, "missing_reason": "无数据"}
rev_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": rev_prefix})
cost_total = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["cost"] + "%"})
disc_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
if rev_total and float(rev_total) != 0 and cost_total is not None:
net_cost = float(cost_total) - float(disc_total or 0)
gm = (float(rev_total) - net_cost) / float(rev_total) * 100.0
gross_margin = {
"available": True,
"value": round(gm, 2),
"unit": "%",
"caliber": "net_purchase",
"caliber_label": "新式·折扣后净进价",
"formula": "(收入净额 − 折扣后净进价成本) / 收入净额 × 100",
"period": period_label,
# 四件套 0/4 ⇒ 历史期缺同票折扣发票,只可显示现值并带标识
"data_status": "estimated",
"data_tag": "测算/待票",
"note": "旧式(含 k 的 5001/571103 组合)已作废;历史缺票期不得冒充实账",
}
# ── 3. 管理指标(非会计科目,不进损益、不进摘要条主位)───────────────
management = []
if entity_id == 2:
# 与 A1 回执可复现口径一致:分母取**折让前贷方发生额**(639,944.00),
# 而不是 6603 净额(556,279.15,已抵扣借方)——两者差 28.24% 与 24.55% 之别。
disc = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
base_amt = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE '2202%' AND summary LIKE '%采购收货%'
AND {rc.supplier_sql('summary')}
""", params)
if disc is not None and base_amt and float(base_amt) != 0:
management.append({
"key": "vendor_discount_dependency",
"label": "厂商折扣依赖度",
"value": round(abs(float(disc)) / float(base_amt) * 100.0, 2),
"unit": "%",
"numerator": round(abs(float(disc)), 2),
"numerator_label": "酒类采购折扣额",
"denominator": round(float(base_amt), 2),
"denominator_label": "酒类采购额(含税)",
"period": period_label,
"tag": "管理指标·非会计科目",
"caliber_note": "折扣额 ÷ 酒类采购额(含税);改造单 v4 §3 文字写「÷销售额」,"
"但 28.24% 的复现分母是采购额,按可复现口径披露,分母已单列可复核",
})
elif entity_id == 1 and cfg.get("purchase_gift") and rev_total:
gift = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_gift"] + "%"})
if gift is not None and float(rev_total) != 0:
val = round(abs(float(gift)) / float(rev_total) * 100.0, 2)
management.append({
"key": "vendor_gift_dependency",
"label": "厂商赠酒依赖度",
"value": val,
"unit": "%",
"numerator": round(abs(float(gift)), 2),
"numerator_label": "采购获赠额",
"denominator": round(float(rev_total), 2),
"denominator_label": "营业收入(同期)",
"period": period_label,
"tag": "管理指标·非会计科目",
"abnormal": val > 100,
"caliber_note": "获赠额 ÷ 同期营业收入;>100% 属异常,须数据侧核查(不作损益行)",
})
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": revenue,
"gross_margin": gross_margin,
"management": management,
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
"caliber_source": {
"status": rc.STATUS,
"decided_by": rc.DECIDED_BY,
"reason": rc.REASON,
},
}
+42 -6
View File
@@ -11,6 +11,8 @@ from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR,
from app.utils.kr_name import sanitize_kr_name
from app.utils.kpi_value_scope import actual_only_clause
import copy
# CMA-KPI-20261003 ③ 落码:考核型目标读路径分流(kpi_targets 优先;未命中回退哨兵在 utils 内)
from app.utils.kpi_target_reader import fetch_target_map, pick_cycle_target, target_type_for_frequency
import json
import logging
@@ -85,11 +87,10 @@ def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity
@router.get("/strategy/revenue-caliber")
def strategy_revenue_caliber(entity_id: int = Depends(get_entity_id)):
"""战略地图摘要条 · 收入口径状态(真数据铁律:未闭合不出单一汇总数)。
"""战略地图摘要条 · 收入口径状态(口径已闭合,金额取 /maps/summary)。
2026-10-18 改造单 v2 §4:A1 收入口径未闭合前,前端只显示「口径待定」+
分业务位(酒类代销 / IT自营,amount=None ⇒ 显示「—/无数据」);
后端不得擅自选总额/净额,也不得把两块合并成一个「营业收入」。
2026-10-19 A1 终稿:两家均总额法;增值税销售货物 13%。
本端点只出口径与业务线维度(不含金额);金额一律由 /maps/summary 现算。
"""
from app.data import revenue_caliber
return revenue_caliber.snapshot(entity_id)
@@ -983,6 +984,8 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
KPIDefinition.entity_id == entity_id,
).all() if all_kpi_codes else []
kpi_map = {k.kpi_code: k for k in kpi_defs}
# ③ 落码:考核型目标一次性取新路径(战略回顾当期)
_tmap = fetch_target_map(db, entity_id, [k.id for k in kpi_defs])
# 查询最新KPI实际值
kpi_ids = [k.id for k in kpi_defs]
@@ -1051,10 +1054,17 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
held = code in DATA_HOLD_KPIS
actual = None if held else lv.get("actual_value")
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
target = kpi_def.target_monthly or kpi_def.target_value
# ③ 落码:新路径命中即用;未命中回退兼容列(哨兵在 utils,不 or 0)
# COMPAT-FALLBACK 到期 2026-11-02:新表未命中 → 回退兼容列(顺序回退,不 or 0)
target = pick_cycle_target(_tmap, kpi_def.id, "monthly", kpi_def.target_monthly)
if target is None:
target = pick_cycle_target(_tmap, kpi_def.id,
target_type_for_frequency(kpi_def.frequency),
kpi_def.target_value)
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
level = "gray"
ratio_val = None
if actual is not None and target:
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
lower_better = any(k in name for k in
@@ -1065,6 +1075,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
ratio = actual / target
if ratio < 0:
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
ratio_val = ratio
if ratio >= 0.9:
level = "green"
elif ratio >= 0.6:
@@ -1074,6 +1085,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
else:
level = "gray"
# C1 数据前置:held KPI 不着色、不透出达成率(level=pending 由前端灰虚线呈现)
if held:
level = "pending"
ratio_val = None
if level == "red":
worst_level = "red"
elif level == "yellow" and worst_level != "red":
@@ -1087,14 +1103,29 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
"actual_value": actual,
"unit": kpi_def.unit,
"level": level,
# 达成率(按指标方向归一,None=无目标/无实测,调用方须显式显示"无数据")
"ratio": round(ratio_val, 4) if ratio_val is not None else None,
})
kpi_ratios = [k["ratio"] for k in obj_kpis if k.get("ratio") is not None]
obj_ratio = round(sum(kpi_ratios) / len(kpi_ratios), 4) if kpi_ratios else None
# C1 数据前置:目标含 held KPI ⇒ 整节点「数据待核」,不计入任何达成率汇总
hold_reason = next((DATA_HOLD_KPIS[c] for c in obj.get("kpis", []) if c in DATA_HOLD_KPIS), None)
obj_item = {
"name": obj.get("name", ""),
"icon": obj.get("icon", ""),
"kpis": obj_kpis,
"level": worst_level,
"level": "pending" if hold_reason else worst_level,
"has_data": len(obj_kpis) > 0,
# 目标达成率 = 有实测值 KPI 的方向归一达成率均值;None ⇒ 前端显示"无数据"
"ratio": None if hold_reason else obj_ratio,
"kpi_total": len(obj_kpis),
"kpi_with_data": 0 if hold_reason else len(kpi_ratios),
"has_actual": False if hold_reason else bool(kpi_ratios),
"data_hold": bool(hold_reason),
"hold_reason": hold_reason,
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
"action_plan_summary": {
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
@@ -1105,6 +1136,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
}
objectives.append(obj_item)
# C1 数据前置:数据待核目标单列,不进达标/关注/风险任何一档
if hold_reason:
total_hold += 1
continue
if worst_level == "green":
total_ok += 1
elif worst_level == "yellow":
+89 -43
View File
@@ -1,63 +1,109 @@
"""战略地图 · 收入口径(revenue caliber)状态 —— 单一事实源
"""战略地图 · 收入 / 毛利 / 管理指标口径(单一事实源,勿在别处写死)
口径来源:改造单 `project-20261018-cma-strategy-map-ux-revamp-fullstack` 增量 v2 §4。
截至本文件落地(2026-10-18T23:00),A1 收入口径**尚未闭合**:
- 业务称谓「代理」≠ 会计判定(总额法 total / 净额法 net / 混合 mixed 未裁);
- 财务净额件与项目侧「三问」总额判定并存,正由 A1 补充单裁决。
口径来源(引用勿改):
/srv/hermes/mailbox/finance/pending-tasks/_reports/
project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md
改造单 project-20261018-cma-strategy-map-ux-revamp-fullstack 增量 v4
因此本模块只声明**状态**与**业务线维度**,不存任何金额:
- 接口照常返回 `revenue_caliber` 与分业务位(amount=None + status=pending);
- 前端不得写死任一方法,不得把两块合并成一个「营业收入」;
- 口径闭合后只改本文件(status/caliber/decided_by/closed_at),并回填金额取数逻辑,
不需要改前端。
终稿结论(2026-10-19):
- 陕西博海、陕西酣客 **均总额法(主要责任人)**;
- 增值税 = 销售货物 13%(账内 11 张凭证实测,6% 代理服务零证据);
- 9,492.6 万 = 冲减采购成本/存货成本(唯一落位,**无「补贴收入/其他收益」行**),
本模块不产生该数、也不允许任何调用点拼出该串;
- F2 毛利率新式 = (收入净额 − 折扣后净进价成本) / 收入净额 × 100;
旧式(含 `k` 的 5001/571103 组合)作废,**不得在本文件或调用点复现**;
- 历史期缺同票折扣发票(四件套 0/4)⇒ 金额只作现值展示并带「测算/待票」标识。
铁律:未闭合前,页面与接口都**不得**输出单一汇总收入数。
本模块只放**口径与科目映射**,不存金额;金额一律由 API 对库内 voucher_details 现算
(filter: summary NOT LIKE '%结转%',否则损益双向抵销归零)。
"""
# 口径状态:unclosed / closed
STATUS = "unclosed"
CALIBER = None # closed 时取 "total" / "net" / "mixed"
CALIBER_LABEL = "口径待定"
DECIDED_BY = None # closed 时填裁决单/ASK 编号
CLOSED_AT = None # ISO 时间
REASON = (
"A1 收入口径尚未闭合(截至 2026-10-18T23:00):业务称谓「代理」≠ 会计判定,"
"总额法/净额法/混合法并存,待 A1 补充单裁决"
)
# ── 收入口径状态(v4 起为已闭合)──────────────────────────────────────────────
STATUS = "closed"
CALIBER = "total" # total / net / mixed
CALIBER_LABEL = "总额法(会计)· 增值税13%"
DECIDED_BY = "A1 终稿(project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md)"
REASON = "两家均主要责任人 ⇒ 总额法;增值税销售货物 13% 实测,无税会差异"
# 分业务(不得合并;amount 由 closed 后的取数逻辑回填)
BUSINESS_LINES = [
{"key": "liquor_agency", "label": "酒类代销"},
{"key": "it_self", "label": "IT自营"},
# ── 按实体的科目映射(各账套科目体系不同:酣客 5001/5401,博海 6001/6401)──────
SUBJECT_MAP = {
1: {
"revenue": "5001", # 主营业务收入
"cost": "5401", # 主营业务成本
"purchase_discount": "530111", # 商品折扣(采购)
"purchase_gift": "530103", # 采购获赠(捐赠收益)
"lines": [
{"key": "liquor", "label": "酒类"},
{"key": "other", "label": "其他"},
],
},
2: {
"revenue": "6001", # 主营业务收入
"cost": "6401", # 主营业务成本
"purchase_discount": "6603", # 财务费用-现金折扣(贷方=采购折扣)
"purchase_gift": None,
"lines": [
{"key": "self_it", "label": "自营(IT)"},
{"key": "agency_liquor", "label": "代销(酒类)"},
],
},
}
# 酒类判定(财务 A1 件同一口径):摘要含以下任一关键词 ⇒ 酒类业务线
WINE_KEYWORDS = ("酣客", "酒窖", "酣亲", "秦莉", "牛彦平", "横山")
# 采购来源(博海酒类采购:陕西酣客 / 酣客酒业)
LIQUOR_SUPPLIERS = ("酣客", "酒业")
def wine_sql(column: str = "summary") -> str:
"""SQL 片段:酒类业务线判定(与财务回执同口径,勿改)。"""
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in WINE_KEYWORDS) + ")"
def supplier_sql(column: str = "summary") -> str:
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in LIQUOR_SUPPLIERS) + ")"
# ── 口径切换期标识位(v4 §4:不硬编码日期;生效日以任总拍板 ASK 为准)──────────
CALIBER_SWITCH = {
"has_switch": True,
"label": "口径变更",
"effective_date": None, # 未拍板前恒为 None —— 禁止写死日期
"comparability": "口径变更·不可比",
"note": "同比/达成率/预算执行率在切换期内不可比;生效日以任总拍板(ASK)为准",
}
# ── 缺件披露(v4 §5:界面不得呈现为「已齐」)─────────────────────────────────
MISSING_DOCS = [
"经销/代理协议",
"厂家价格政策",
"逐笔折扣发票+合同审批单",
"上游退货条款",
"发票台账(invoice_check 空)",
]
def business_lines(entity_id: int) -> list:
cfg = SUBJECT_MAP.get(entity_id)
return list(cfg["lines"]) if cfg else []
def snapshot(entity_id: int) -> dict:
"""返回对外口径快照(无金额;未闭合时 amount 一律 None)。"""
"""对外口径快照(无金额;金额见 /api/cma/maps/summary)。"""
return {
"entity_id": entity_id,
"status": STATUS,
"caliber": CALIBER,
"caliber_label": CALIBER_LABEL,
"caliber_options": [
{"key": "total", "label": "总额法"},
{"key": "net", "label": "净额法"},
{"key": "mixed", "label": "混合法"},
],
"decided_by": DECIDED_BY,
"closed_at": CLOSED_AT,
"reason": REASON,
"allow_single_total": False, # 分业务线分列;不合并成单一「营业收入」
"businesses": [
{
"key": b["key"],
"label": b["label"],
"amount": None,
"unit": "万元",
"status": "pending",
"missing_reason": "收入口径未闭合" if STATUS != "closed" else None,
}
for b in BUSINESS_LINES
{"key": b["key"], "label": b["label"], "unit": "万元"}
for b in business_lines(entity_id)
],
"allow_single_total": STATUS == "closed",
"note": "未闭合前不显示单一汇总数;分业务分列,不合并为「营业收入」",
"switch": CALIBER_SWITCH,
"missing_docs": MISSING_DOCS,
"note": "分业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
+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
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.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
@@ -57,6 +57,7 @@ app.include_router(alignment.router)
app.include_router(org.router)
app.include_router(objectives.router)
app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
app.include_router(map_summary.router) # 战略地图摘要条(收入分线/新式毛利率/管理指标 · 口径 CMA-A1 终稿)
app.include_router(versions.router)
app.include_router(budget.router)
app.include_router(value_sources.router)
@@ -1,28 +1,50 @@
<template>
<section class="map-summary" aria-label="战略地图摘要">
<div class="sum-card">
<div class="sum-label">营业收入</div>
<div class="sum-value mono" :class="{ muted: revenue == null }">
<template v-if="revenue != null">{{ fmtWan(revenue) }}<span class="sum-unit">万元</span></template>
<template v-else>无数据</template>
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
<div class="sum-card sum-card--wide">
<div class="sum-head">
<span class="sum-label">营业收入</span>
<span v-if="revenue && revenue.caliber_label" class="sum-badge calm">{{ revenue.caliber_label }}</span>
</div>
<div class="sum-sub">{{ period ? period + ' 实际值' : '财务分析接口未返回期间' }}</div>
<div v-if="revenueLines.length" class="rev-lines">
<div v-for="ln in revenueLines" :key="ln.key" class="rev-line">
<span class="rev-line-label">{{ ln.label }}</span>
<span class="rev-line-value mono" :class="{ muted: ln.amount == null }">
<template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="sum-unit">万元</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</span>
</div>
<div class="rev-line rev-line--total">
<span class="rev-line-label">{{ revenueTotalLabel }}</span>
<span class="rev-line-value mono">
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="sum-unit">万元</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</span>
</div>
</div>
<div v-else class="sum-value muted">—<i class="sum-unit">无数据</i></div>
<div class="sum-sub">{{ revenuePeriod }}</div>
</div>
<!-- ② 毛利率:新式口径(折扣后净进价),历史缺票期带标识 -->
<div class="sum-card">
<div class="sum-label">毛利率</div>
<div class="sum-value mono" :class="{ muted: grossMargin == null }">
<template v-if="grossMargin != null">{{ grossMargin.toFixed(1) }}<span class="sum-unit">%</span></template>
<template v-else>无数据</template>
<div class="sum-head">
<span class="sum-label">毛利率</span>
<span v-if="grossMargin && grossMargin.data_tag" class="sum-badge warn">{{ grossMargin.data_tag }}</span>
</div>
<div class="sum-sub">{{ period ? period + ' 实际值' : '未取到财务分析数据' }}</div>
<div class="sum-value mono" :class="{ muted: gmValue == null }">
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="sum-unit">%</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</div>
<div class="sum-sub">{{ gmSub }}</div>
</div>
<!-- ③ 战略目标达成率 -->
<div class="sum-card">
<div class="sum-label">战略目标达成率</div>
<div class="sum-head"><span class="sum-label">战略目标达成率</span></div>
<div class="sum-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
<template v-if="achievement != null">{{ Math.round(achievement) }}<span class="sum-unit">%</span></template>
<template v-else>无数据</template>
<template v-if="achievement != null">{{ Math.round(achievement) }}<i class="sum-unit">%</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</div>
<div class="sum-sub">
<template v-if="achievement != null">{{ achievementCount }} 个目标有实测值 · 其余无数据</template>
@@ -30,40 +52,100 @@
</div>
</div>
<!-- ④ 风险目标数 -->
<div class="sum-card">
<div class="sum-label">风险目标</div>
<div class="sum-head"><span class="sum-label">风险目标</span></div>
<div class="sum-value mono" :class="riskCount == null ? 'muted' : (riskCount > 0 ? 'lv-red' : 'lv-green')">
<template v-if="riskCount != null">{{ riskCount }}</template>
<template v-else>无数据</template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</div>
<div class="sum-sub">
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 共 {{ summary.total }} 个目标</template>
<template v-else>战略回顾数据未加载</template>
</div>
</div>
<!-- ⑤ 口径变更标识位 + 管理指标(非会计科目,单列、不占主位) -->
<div class="sum-foot">
<span v-if="switchInfo && switchInfo.has_switch" class="sum-badge warn" :title="switchInfo.note || ''">
{{ switchInfo.label }}<template v-if="switchInfo.effective_date"> · 自 {{ switchInfo.effective_date }}</template>
<em v-if="switchInfo.comparability"> · {{ switchInfo.comparability }}</em>
</span>
<span v-for="m in management" :key="m.key" class="mgmt" :title="m.caliber_note || ''">
<span class="mgmt-label">{{ m.label }}</span>
<span class="mgmt-value mono">{{ m.value == null ? '—' : m.value.toFixed(2) + '%' }}</span>
<span class="mgmt-tag">{{ m.tag }}</span>
<span v-if="m.abnormal" class="mgmt-abnormal">异常·须核查</span>
<span class="mgmt-caliber">{{ fmtNum(m.numerator) }} ÷ {{ fmtNum(m.denominator) }}<template v-if="m.period"> · {{ m.period }}</template></span>
</span>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
type RevLine = { key: string; label: string; amount: number | null; unit?: string; missing_reason?: string | null }
const props = defineProps<{
finance: { total_sales: number | null; gross_profit_rate: number | null; period?: string } | null
revenue: {
available?: boolean
caliber_label?: string
unit?: string
period?: string | null
lines?: RevLine[]
total?: number | null
total_label?: string
} | null
grossMargin: {
available?: boolean
value: number | null
caliber_label?: string
data_tag?: string
period?: string | null
note?: string
} | null
management?: Array<{
key: string
label: string
value: number | null
unit?: string
numerator?: number | null
denominator?: number | null
period?: string | null
tag?: string
abnormal?: boolean
caliber_note?: string
}>
achievement: number | null
achievementCount: number
summary: { total: number; green: number; yellow: number; red: number; health_score?: number } | null
switchInfo?: { has_switch?: boolean; label?: string; effective_date?: string | null; comparability?: string; note?: string } | null
loading?: boolean
}>()
const revenue = computed<number | null>(() => {
const v = props.finance?.total_sales
const revenueLines = computed<RevLine[]>(() => (props.revenue?.lines || []).filter(l => !!l && !!l.key))
/** 合计 = 各线相加的复算校验位,不与分线并列成第二套口径 */
const revenueTotal = computed<number | null>(() => {
const v = props.revenue?.total
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
})
const grossMargin = computed<number | null>(() => {
const v = props.finance?.gross_profit_rate
const revenueTotalLabel = computed(() => props.revenue?.total_label || '合计(复算校验)')
const revenuePeriod = computed(() => {
const p = props.revenue?.period
if (!p) return '凭证期未取到 · 无数据'
return p + ' 实际值 · 各业务线全额售价分列(禁净额、禁合并)'
})
const gmValue = computed<number | null>(() => {
const v = props.grossMargin?.value
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
})
const period = computed(() => props.finance?.period || '')
const gmSub = computed(() => {
if (gmValue.value == null) return '财务口径未取到 · 无数据'
const p = props.grossMargin?.period || ''
const cal = props.grossMargin?.caliber_label || ''
return [p ? p + ' 实际值' : '', cal].filter(Boolean).join(' · ')
})
const riskCount = computed<number | null>(() => (props.summary ? props.summary.red : null))
const achievementLevel = computed(() => {
const a = props.achievement
@@ -73,9 +155,10 @@ const achievementLevel = computed(() => {
return 'red'
})
/** 金额统一折算为万元(保留 1 位小数),不做任何兜底造数 */
function fmtWan(v: number): string {
return (v / 10000).toLocaleString('zh-CN', { maximumFractionDigits: 1, minimumFractionDigits: 0 })
/** 千分位展示;null ⇒ 「—」,不做任何兜底造数 */
function fmtNum(v: number | null | undefined): string {
if (v == null || Number.isNaN(Number(v))) return '—'
return Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 2, minimumFractionDigits: 0 })
}
</script>
@@ -94,11 +177,26 @@ function fmtWan(v: number): string {
padding: 10px 14px;
min-width: 0;
}
.sum-card--wide { grid-column: span 2; }
.sum-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
.sum-label {
font-size: 12px;
color: var(--bh-text-secondary, #64748B);
letter-spacing: .02em;
white-space: nowrap;
}
.sum-badge {
font-size: 10px;
line-height: 1.5;
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sum-badge.calm { color: #635BFF; background: rgba(99, 91, 255, .08); }
.sum-badge.warn { color: #B45309; background: #FEF3C7; }
.sum-badge.warn em { font-style: normal; }
.sum-value {
margin-top: 4px;
font-size: 22px;
@@ -110,7 +208,7 @@ function fmtWan(v: number): string {
gap: 3px;
}
.sum-value.muted { font-size: 15px; color: var(--bh-text-muted, #94A3B8); font-weight: 500; }
.sum-unit { font-size: 11px; font-weight: 500; color: var(--bh-text-secondary, #64748B); }
.sum-unit { font-size: 11px; font-weight: 500; font-style: normal; color: var(--bh-text-secondary, #64748B); margin-left: 2px; }
.sum-sub {
margin-top: 4px;
font-size: 11px;
@@ -119,11 +217,38 @@ function fmtWan(v: number): string {
overflow: hidden;
text-overflow: ellipsis;
}
.rev-lines { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; }
.rev-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.rev-line-label { font-size: 12px; color: var(--bh-text-secondary, #64748B); }
.rev-line-value { font-size: 18px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
.rev-line-value.muted { font-size: 14px; font-weight: 500; color: var(--bh-text-muted, #94A3B8); }
.rev-line--total { border-top: 1px dashed var(--bh-border, #E5E7EB); padding-top: 3px; margin-top: 2px; }
.rev-line--total .rev-line-label,
.rev-line--total .rev-line-value { font-size: 12px; font-weight: 500; color: var(--bh-text-secondary, #64748B); }
.sum-foot {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 16px;
padding: 6px 12px;
border: 1px dashed var(--bh-border, #E5E7EB);
border-radius: 6px;
font-size: 11px;
color: var(--bh-text-muted, #94A3B8);
}
.mgmt { display: inline-flex; align-items: baseline; gap: 6px; }
.mgmt-label { color: var(--bh-text-secondary, #64748B); }
.mgmt-value { font-size: 13px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
.mgmt-tag { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: #F1F5F9; color: #475569; }
.mgmt-abnormal { color: #B91C1C; }
.mgmt-caliber { color: var(--bh-text-muted, #94A3B8); }
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
.lv-green { color: #00C896; }
.lv-yellow { color: #F59E0B; }
.lv-red { color: #EF4444; }
@media (max-width: 1200px) {
.map-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sum-card--wide { grid-column: span 2; }
}
</style>
+12 -11
View File
@@ -26,7 +26,10 @@
/>
<MapSummaryBar
:finance="financeSummary"
:revenue="summaryData ? summaryData.revenue : null"
:gross-margin="summaryData ? summaryData.gross_margin : null"
:management="summaryData && summaryData.management ? summaryData.management : []"
:switch-info="summaryData ? summaryData.switch : null"
:achievement="overallAchievement"
:achievement-count="achievementCount"
:summary="reviewSummary"
@@ -377,7 +380,7 @@ const selectedNode = ref<{ dimKey: string; index: number; obj: any } | null>(nul
const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : null))
const reviewSummary = ref<any>(null)
const financeSummary = ref<any>(null)
const summaryData = ref<any>(null)
const trendMap = reactive<Record<string, { dir: 'up' | 'down' | 'flat' | null; deltaPct: number | null; period?: string | null }>>({})
const trendLoading = ref(false)
@@ -1828,18 +1831,16 @@ function goKpiLibrary() {
window.location.href = '/kpis'
}
/** KPI 摘要条(营收/毛利率):来源 /dashboard/finance-analysis,缺失即 null(不造数) */
/**
* KPI 摘要条(分业务线营收 / 新式毛利率 / 管理指标):来源 /maps/summary
* 口径 CMA-A1 终稿;缺失即 null(不造数)。
*/
async function loadFinanceSummary() {
try {
const r: any = await api.get('/dashboard/finance-analysis')
const s = r?.summary || {}
financeSummary.value = {
total_sales: s.total_sales ?? null,
gross_profit_rate: s.gross_profit_rate ?? null,
period: r?.period || '',
}
const r: any = await api.get('/maps/summary')
summaryData.value = r || null
} catch {
financeSummary.value = null
summaryData.value = null
}
}