From a8141e8879c90ca2aa37a298f902b89d73522402 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Mon, 19 Oct 2026 14:32:08 +0800 Subject: [PATCH] =?UTF-8?q?wip(strategy-map):=20v4=20=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E8=90=BD=E7=A0=81=EF=BC=88=E5=88=86=E7=BA=BF=E8=90=A5=E6=94=B6?= =?UTF-8?q?/=E6=96=B0=E5=BC=8F=E6=AF=9B=E5=88=A9=E7=8E=87/=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8C=87=E6=A0=87/C1=20hold/57=20=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/map_completeness.py | 5 +- backend/app/api/map_summary.py | 238 ++++++++++++++++++ backend/app/api/maps.py | 48 +++- backend/app/data/revenue_caliber.py | 132 ++++++---- backend/app/main.py | 3 +- .../components/strategy-map/MapSummaryBar.vue | 177 +++++++++++-- frontend/src/views/MapCanvas.vue | 23 +- 7 files changed, 538 insertions(+), 88 deletions(-) create mode 100644 backend/app/api/map_summary.py diff --git a/backend/app/api/map_completeness.py b/backend/app/api/map_completeness.py index 75f1c37a..1a5c800e 100644 --- a/backend/app/api/map_completeness.py +++ b/backend/app/api/map_completeness.py @@ -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"]) diff --git a/backend/app/api/map_summary.py b/backend/app/api/map_summary.py new file mode 100644 index 00000000..d7716380 --- /dev/null +++ b/backend/app/api/map_summary.py @@ -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, + }, + } diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index b2dfb688..41885004 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -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": diff --git a/backend/app/data/revenue_caliber.py b/backend/app/data/revenue_caliber.py index 4dd8e2b9..916c7f3a 100644 --- a/backend/app/data/revenue_caliber.py +++ b/backend/app/data/revenue_caliber.py @@ -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": "分业务线全额售价分列;禁净额、禁合并为单一「营业收入」", } diff --git a/backend/app/main.py b/backend/app/main.py index bae6684d..9e22e3df 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/frontend/src/components/strategy-map/MapSummaryBar.vue b/frontend/src/components/strategy-map/MapSummaryBar.vue index acf4ef72..efe683fa 100644 --- a/frontend/src/components/strategy-map/MapSummaryBar.vue +++ b/frontend/src/components/strategy-map/MapSummaryBar.vue @@ -1,28 +1,50 @@ @@ -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; } } diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue index b3b47476..776e69fa 100644 --- a/frontend/src/views/MapCanvas.vue +++ b/frontend/src/views/MapCanvas.vue @@ -26,7 +26,10 @@ /> (nul const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : null)) const reviewSummary = ref(null) -const financeSummary = ref(null) +const summaryData = ref(null) const trendMap = reactive>({}) 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 } }