feat(cashflow): 现金流量表结构分析——三大活动净额八类企业定性判定 (P1)

- 后端: cashflow_structure.py 模块 (GET rules/periods/values/analysis + POST values upsert)
- 判定: O/I/F 三净额符号 → 8类企业定性 (CMA P2 现金流量表分析, 无'现金流肖像'字样)
- 数据: cash_flow_structure_values 表 (entity_id+period 唯一), 官方现金流量表净额按期间注入
- 前端: CashFlowStructure.vue 判定横幅/瀑布图/规则表/入库记录 + MyDialog 录入弹窗
- 菜单: 财务报表分析 → 现金流量表分析 (路由 /cashflow-structure, 角色 ceo/finance/it)
- 测试: tests/test_cashflow_structure.py 7 条通过 (规则8类/判定/覆盖/博海边界6-7/非法期间)
This commit is contained in:
Hermes CI Fix
2026-09-03 01:22:46 +08:00
parent 0a6320f41f
commit e5dd6e380b
7 changed files with 1034 additions and 4 deletions
+322
View File
@@ -0,0 +1,322 @@
"""
现金流量表结构分析 — 管理会计OS (CMA P2 · 财务报表分析)
规范命名:现金流量表结构分析(不使用"现金流肖像"等民间说法)
核心方法:经营(O)/投资(I)/筹资(F) 三大活动净额正负组合 → 8类企业定性分析
O 造血能力 / I 成长代价 / F 输血管道
长期黄金法则:经营现金流必须为正,且应大于净利润(利润质量)。
数据模型:cash_flow_structure_values — 官方现金流量表三大活动净额按期间注入
- 未注入期间:无判定(不拿演示/示例数据冒充真实类型)
- 官方净额入库后类型判定自动生效(博海2026官方表注入路径)
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional, List
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import CashFlowStructureValue
router = APIRouter(
prefix="/api/cma/cashflow-structure",
tags=["现金流量表结构分析"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
# ============================================================
# 八类企业定性分析规则表(O/I/F 符号组合 → 类型)
# ============================================================
CASHFLOW_TYPE_RULES = [
{
"type_no": 1, "o_sign": "+", "i_sign": "-", "f_sign": "-",
"name": "成熟健康型", "health_emoji": "🟢", "health_label": "最优",
"lifecycle": "成熟期",
"profile": "主业盈利,自给自足;仍在投入扩张且有余力还债/分红。最健康形态(如成熟消费龙头)。",
"risk": "注意扩张效率与分红可持续性;经营现金流应大于净利润,否则利润质量存疑。",
},
{
"type_no": 2, "o_sign": "+", "i_sign": "+", "f_sign": "-",
"name": "成熟收割型", "health_emoji": "🟢", "health_label": "良好",
"lifecycle": "成熟期后期/集团收割期",
"profile": "主业赚钱+过去投资进入回报期(处置/收益回笼)+还在还债分红。",
"risk": "若投资净流入来自大量处置核心资产,需警惕「卖资产过日子」。",
},
{
"type_no": 3, "o_sign": "+", "i_sign": "-", "f_sign": "+",
"name": "高速扩张型", "health_emoji": "🟡", "health_label": "看增速",
"lifecycle": "成长期",
"profile": "主业能赚钱,但扩张更快、现金不够花,需要融资支撑资本开支。成长质量取决于经营造血增速能否追上扩张。",
"risk": "扩张节奏失控 → 现金消耗加速 → 滑向类型6/7。",
},
{
"type_no": 4, "o_sign": "+", "i_sign": "+", "f_sign": "+",
"name": "全面回笼型", "health_emoji": "🟡", "health_label": "需辨析",
"lifecycle": "重组/收缩盘点期",
"profile": "三类活动都进钱,少见。多为经营好转+资产处置+新增融资并举(可能正在重组)。",
"risk": "需具体辨析筹资来源(股权/债权)与投资流入是否靠卖资产。",
},
{
"type_no": 5, "o_sign": "-", "i_sign": "+", "f_sign": "-",
"name": "衰退变卖型", "health_emoji": "🔴", "health_label": "高危",
"lifecycle": "衰退期/清盘前奏",
"profile": "主业亏损,靠卖资产/处置投资回笼现金,同时还在还债。濒危信号。",
"risk": "资产卖完即无以为继;除非经营反转否则难持续。",
},
{
"type_no": 6, "o_sign": "-", "i_sign": "-", "f_sign": "+",
"name": "初创烧钱型", "health_emoji": "🟠", "health_label": "高风险",
"lifecycle": "初创期/高增长投入期",
"profile": "主业烧钱+持续投入,全靠融资吊命。常见于初创/高增长科技企业;也是庞氏结构的表面特征。",
"risk": "融资链条断裂即死;须区分「真成长」(收入/用户高速增长)与「假烧钱」。",
},
{
"type_no": 7, "o_sign": "-", "i_sign": "+", "f_sign": "+",
"name": "输血生存型", "health_emoji": "🔴", "health_label": "高危",
"lifecycle": "挣扎期",
"profile": "经营亏损,靠卖资产+借款输血双管齐下维持。压力极大。",
"risk": "短期可活,长期必须扭转经营现金流,否则资金链断裂。",
},
{
"type_no": 8, "o_sign": "-", "i_sign": "-", "f_sign": "-",
"name": "全面失血型", "health_emoji": "🔴", "health_label": "极危",
"lifecycle": "深度危机期",
"profile": "经营/投资/筹资三个引擎全熄火,现金只出不进。最危险状态。",
"risk": "极可能需要紧急债务重组/股东注资/断臂求生。",
},
]
# 类型 → 规则 快速索引
TYPE_RULE_MAP = {r["type_no"]: r for r in CASHFLOW_TYPE_RULES}
def _sign(value: Optional[float]) -> Optional[str]:
"""净额符号:>0 → +,<0 → -,==0 → '0'(无法落入8类组合),None → None"""
if value is None:
return None
if value > 0:
return "+"
if value < 0:
return "-"
return "0"
def classify_enterprise(operating_net: Optional[float],
investing_net: Optional[float],
financing_net: Optional[float]) -> dict:
"""O/I/F 三净额符号 → 8类企业定性判定
返回:
{classified, type_no, name, health_emoji, health_label, risk, profile, lifecycle, o_sign, i_sign, f_sign, reason}
classified=False 时 reason 说明未判定的原因(任一净额缺失或为0)
"""
o, i, f = _sign(operating_net), _sign(investing_net), _sign(financing_net)
if o is None or i is None or f is None:
missing = [name for sign, name in [(o, "经营"), (i, "投资"), (f, "筹资")] if sign is None]
return {"classified": False, "reason": "以下活动净额缺失:" + "、".join(missing) + "(需官方现金流量表数据)"}
if "0" in (o, i, f):
zero = [name for sign, name in [(o, "经营"), (i, "投资"), (f, "筹资")] if sign == "0"]
return {"classified": False, "reason": "以下活动净额为0(恰好为0无法落入8类正负组合):" + "、".join(zero) + ",请按业务实质人工判定"}
for rule in CASHFLOW_TYPE_RULES:
if rule["o_sign"] == o and rule["i_sign"] == i and rule["f_sign"] == f:
return {
"classified": True,
"type_no": rule["type_no"],
"name": rule["name"],
"health_emoji": rule["health_emoji"],
"health_label": rule["health_label"],
"lifecycle": rule["lifecycle"],
"profile": rule["profile"],
"risk": rule["risk"],
"o_sign": o, "i_sign": i, "f_sign": f,
"reason": "经营(%s) 投资(%s) 筹资(%s) → 类型%d %s" % (o, i, f, rule["type_no"], rule["name"]),
}
return {"classified": False, "reason": "未知组合,请检查数据"}
class CashFlowValueIn(BaseModel):
"""三大活动净额录入(官方现金流量表 / 人工)"""
period: str
operating_net: float
investing_net: float
financing_net: float
begin_cash: Optional[float] = None
end_cash: Optional[float] = None
source: str = "official"
remark: Optional[str] = None
def _validate_period(period: str) -> str:
"""期间格式校验 YYYY-MM"""
period = (period or "").strip()
try:
datetime.strptime(period, "%Y-%m")
except Exception:
raise HTTPException(status_code=422, detail="期间格式须为 YYYY-MM,如 2026-08")
return period
# ============================================================
# 接口
# ============================================================
@router.get("/rules")
def get_rules():
"""八类企业定性判定规则表(O/I/F 符号组合速查)"""
return {"rules": CASHFLOW_TYPE_RULES}
@router.get("/periods")
def list_periods(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""已注入净额的期间列表(倒序,供按期间查询)"""
rows = db.query(CashFlowStructureValue.period).filter(
CashFlowStructureValue.entity_id == entity_id
).order_by(CashFlowStructureValue.period.desc()).all()
return {"periods": [r[0] for r in rows]}
@router.get("/values")
def list_values(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""已注入的三大活动净额记录(按期间倒序)"""
rows = db.query(CashFlowStructureValue).filter(
CashFlowStructureValue.entity_id == entity_id
).order_by(CashFlowStructureValue.period.desc()).all()
return {"items": [
{
"id": r.id, "period": r.period,
"operating_net": r.operating_net,
"investing_net": r.investing_net,
"financing_net": r.financing_net,
"begin_cash": r.begin_cash, "end_cash": r.end_cash,
"source": r.source, "remark": r.remark,
"updated_at": r.updated_at.strftime("%Y-%m-%d %H:%M") if r.updated_at else None,
} for r in rows
]}
@router.get("/analysis")
def get_analysis(
period: str = Query(None, description="格式 YYYY-MM,缺省取最近已注入期间"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""现金流量表结构分析 — 三净额 → 八类企业判定 + 瀑布图数据"""
if period:
period = _validate_period(period)
# 1. 取官方/人工注入的净额(唯一权威判定数据)
q = db.query(CashFlowStructureValue).filter(
CashFlowStructureValue.entity_id == entity_id
)
if period:
rec = q.filter(CashFlowStructureValue.period == period).first()
else:
rec = q.order_by(CashFlowStructureValue.period.desc()).first()
if rec:
period = rec.period
if rec is None:
return {
"period": period or datetime.now().strftime("%Y-%m"),
"data_available": False,
"message": "该期间暂无官方现金流量表净额数据,请先录入三大活动净额(官方现金流量表注入后自动生效)。",
"nets": None,
"classification": None,
"waterfall": None,
}
nets = {
"operating": {"key": "operating", "name": "经营活动", "value": rec.operating_net,
"sign": _sign(rec.operating_net), "unit": "万元"},
"investing": {"key": "investing", "name": "投资活动", "value": rec.investing_net,
"sign": _sign(rec.investing_net), "unit": "万元"},
"financing": {"key": "financing", "name": "筹资活动", "value": rec.financing_net,
"sign": _sign(rec.financing_net), "unit": "万元"},
}
classification = classify_enterprise(rec.operating_net, rec.investing_net, rec.financing_net)
# 2. 瀑布图:期初现金 → +经营 → +投资 → +筹资 → 期末现金
waterfall = None
if rec.begin_cash is not None or rec.end_cash is not None:
begin = rec.begin_cash
end = rec.end_cash
steps = []
cum = begin if begin is not None else 0.0
if begin is not None:
steps.append({"step": "期初现金", "value": begin, "type": "begin"})
for net_key, net_name in [("operating", "经营活动"), ("investing", "投资活动"), ("financing", "筹资活动")]:
val = getattr(rec, net_key + "_net") or 0.0
cum += val
steps.append({"step": net_name, "value": round(val, 2), "type": "flow"})
if end is not None:
steps.append({"step": "期末现金", "value": end, "type": "end"})
elif begin is not None:
end = round(cum, 2)
steps.append({"step": "期末现金(推算)", "value": end, "type": "end"})
waterfall = {
"begin_cash": begin, "end_cash": end,
"net_increase": round((end - begin), 2) if (begin is not None and end is not None) else None,
"steps": steps,
}
return {
"period": rec.period,
"data_available": True,
"source": rec.source,
"remark": rec.remark,
"nets": nets,
"classification": classification,
"waterfall": waterfall,
}
@router.post("/values", dependencies=[Depends(require_role("ceo", "finance"))])
def upsert_value(
payload: CashFlowValueIn,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""录入/更新某期间三大活动净额(官方现金流量表注入;同期间重复提交=覆盖更新)"""
period = _validate_period(payload.period)
if payload.source not in ("official", "manual"):
raise HTTPException(status_code=422, detail="source 仅支持 official(官方报表)/manual(人工录入)")
rec = db.query(CashFlowStructureValue).filter(
CashFlowStructureValue.entity_id == entity_id,
CashFlowStructureValue.period == period,
).first()
created = rec is None
if rec is None:
rec = CashFlowStructureValue(entity_id=entity_id, period=period)
db.add(rec)
rec.operating_net = round(payload.operating_net, 2)
rec.investing_net = round(payload.investing_net, 2)
rec.financing_net = round(payload.financing_net, 2)
rec.begin_cash = round(payload.begin_cash, 2) if payload.begin_cash is not None else rec.begin_cash
rec.end_cash = round(payload.end_cash, 2) if payload.end_cash is not None else rec.end_cash
rec.source = payload.source
rec.remark = payload.remark
db.commit()
classification = classify_enterprise(rec.operating_net, rec.investing_net, rec.financing_net)
return {
"ok": True,
"id": rec.id,
"period": rec.period,
"classification": classification,
"message": "三大活动净额已%s" % ("入库" if created else "更新"),
}
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from dotenv import load_dotenv from dotenv import load_dotenv
from app.database import init_db from app.database import init_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, 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 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, 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
from app.utils.cache import clear_all as clear_cache, delete as delete_cache 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 scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth from app.auth_middleware import require_auth
@@ -59,6 +59,7 @@ app.include_router(value_sources.router)
app.include_router(zero_based.router) app.include_router(zero_based.router)
app.include_router(derivation_rules.router) app.include_router(derivation_rules.router)
app.include_router(cash_classify.router) app.include_router(cash_classify.router)
app.include_router(cashflow_structure.router)
app.include_router(cost.router) app.include_router(cost.router)
app.include_router(predict.router) app.include_router(predict.router)
app.include_router(growth_quality.router) app.include_router(growth_quality.router)
+21
View File
@@ -940,3 +940,24 @@ class AISuggestion(Base):
apply_detail = Column(JSON, nullable=True, comment="应用结果明细: [{target_type,target_id,action,before,after}]") apply_detail = Column(JSON, nullable=True, comment="应用结果明细: [{target_type,target_id,action,before,after}]")
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class CashFlowStructureValue(Base):
"""现金流量表结构分析 — 三大活动净额 (P1 2026-09-03 八类企业定性分析)
官方现金流量表三大活动净额按期间注入,支撑 O/I/F 符号组合 → 8类企业判定
单位统一为 万元(与 BH_CASH_BALANCE / ledger 口径一致)
"""
__tablename__ = "cash_flow_structure_values"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, default=1, comment="企业ID(账套,多租户隔离)")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
operating_net = Column(Float, nullable=False, comment="经营活动现金流量净额(万元)")
investing_net = Column(Float, nullable=False, comment="投资活动现金流量净额(万元)")
financing_net = Column(Float, nullable=False, comment="筹资活动现金流量净额(万元)")
begin_cash = Column(Float, nullable=True, comment="期初现金及现金等价物余额(万元)")
end_cash = Column(Float, nullable=True, comment="期末现金及现金等价物余额(万元)")
source = Column(String(50), default="official", comment="official官方报表/manual人工录入")
remark = Column(String(500), nullable=True, comment="备注/口径说明")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (UniqueConstraint("entity_id", "period", name="uq_cf_struct_entity_period"),)
+107
View File
@@ -0,0 +1,107 @@
"""现金流量表结构分析模块测试 — 八类企业定性判定 (P1 2026-09-03)
覆盖 cashflow_structure.py:
GET /api/cma/cashflow-structure/rules 八类规则表
GET /api/cma/cashflow-structure/periods 已注入期间
GET /api/cma/cashflow-structure/analysis 结构分析+类型判定+瀑布数据
POST /api/cma/cashflow-structure/values 三大活动净额注入(upsert)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from tests.conftest import create_test_user, get_token_for_user, auth_header
BASE = "/api/cma/cashflow-structure"
class TestRules:
def test_rules_8_types(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{BASE}/rules", headers=auth_header(token))
assert resp.status_code == 200
rules = resp.json()["rules"]
assert len(rules) == 8
# 判定组合必须恰好 8 种且无重复
combos = [(r["o_sign"], r["i_sign"], r["f_sign"]) for r in rules]
assert len(set(combos)) == 8
# 规范命名:不含民间叫法
assert all("现金流肖像" not in r["name"] for r in rules)
def test_rules_unauthorized(self, client: TestClient):
resp = client.get(f"{BASE}/rules")
assert resp.status_code in (401, 403)
class TestStructureAnalysis:
def _post_value(self, client, token, **kw):
payload = {
"period": "2026-08",
"operating_net": -10.0,
"investing_net": 3.0,
"financing_net": 12.0, # - + + → 类型7 输血生存型
"source": "official",
"remark": "pytest 临时数据",
}
payload.update(kw)
return client.post(f"{BASE}/values", headers=auth_header(token), json=payload)
def test_empty_analysis(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{BASE}/analysis", headers=auth_header(token))
assert resp.status_code == 200
body = resp.json()
assert body["data_available"] is False
assert body["classification"] is None
def test_upsert_and_classify_type7(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = self._post_value(client, token)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["classification"]["classified"] is True
assert body["classification"]["type_no"] == 7
assert body["classification"]["name"] == "输血生存型"
# 分析接口自动命中
r2 = client.get(f"{BASE}/analysis", headers=auth_header(token), params={"period": "2026-08"})
assert r2.status_code == 200
a = r2.json()
assert a["data_available"] is True
assert a["classification"]["type_no"] == 7
assert a["nets"]["operating"]["sign"] == "-"
assert a["nets"]["investing"]["sign"] == "+"
assert a["nets"]["financing"]["sign"] == "+"
# periods / values 可查
r3 = client.get(f"{BASE}/periods", headers=auth_header(token))
assert "2026-08" in r3.json()["periods"]
def test_upsert_overwrite_same_period(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
self._post_value(client, token)
resp2 = self._post_value(client, token, operating_net=50.0, investing_net=-20.0, financing_net=-10.0)
assert resp2.status_code == 200
assert resp2.json()["classification"]["type_no"] == 1 # + - - → 成熟健康型
rows = client.get(f"{BASE}/values", headers=auth_header(token)).json()["items"]
assert len(rows) == 1 # 同期间覆盖不重复插入
def test_type6_7_boundary_like_bohai(self, client: TestClient, db: Session):
"""博海结构判定回归:经营弱/负 + 筹资依赖 → 类型6(- - +) / 类型7(- + +)"""
create_test_user(db)
token = get_token_for_user(client)
resp = self._post_value(client, token, operating_net=-5.0, investing_net=-2.0, financing_net=9.0)
assert resp.json()["classification"]["type_no"] == 6
resp = self._post_value(client, token, operating_net=-5.0, investing_net=2.0, financing_net=9.0)
assert resp.json()["classification"]["type_no"] == 7
def test_invalid_period(self, client: TestClient, db: Session):
create_test_user(db)
token = get_token_for_user(client)
resp = self._post_value(client, token, period="2026/08")
assert resp.status_code == 422
+5 -3
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ── // ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = { export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure'],
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure'],
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'], it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure'],
} }
export const ROLE_ACTIONS: Record<string, string[]> = { export const ROLE_ACTIONS: Record<string, string[]> = {
@@ -45,6 +45,8 @@ export const MENU_ITEMS: MenuItem[] = [
{ path: '/ai-suggestions', label: 'AI建议中心', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' }, { path: '/ai-suggestions', label: 'AI建议中心', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/reports', label: '管理报表', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' }, { path: '/reports', label: '管理报表', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' },
{ path: '/dupont-analysis', label: '杜邦分析', icon: 'TrendCharts', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' }, { path: '/dupont-analysis', label: '杜邦分析', icon: 'TrendCharts', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' },
// ── GROUP: 财务报表分析 ──
{ path: '/cashflow-structure', label: '现金流量表分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'it'], group: '📊 财务报表分析' },
{ path: '/customer', label: '客户维度', icon: 'User', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' }, { path: '/customer', label: '客户维度', icon: 'User', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/learning-dashboard', label: '学习成长看板', icon: 'Reading', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' }, { path: '/learning-dashboard', label: '学习成长看板', icon: 'Reading', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' },
{ path: '/alerts', label: '预警中心', icon: 'WarningFilled', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' }, { path: '/alerts', label: '预警中心', icon: 'WarningFilled', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
+1
View File
@@ -35,6 +35,7 @@ const routes = [
{ path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } }, { path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } },
{ path: 'alignment', name: 'KPIAlignment', component: () => import('@/views/KPIAlignment.vue'), meta: { title: '战略执行看板', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, { path: 'alignment', name: 'KPIAlignment', component: () => import('@/views/KPIAlignment.vue'), meta: { title: '战略执行看板', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
{ path: 'dupont-analysis', name: 'DupontAnalysis', component: () => import('@/views/DupontAnalysis.vue'), meta: { title: '杜邦分析', roles: ['ceo', 'finance', 'it'] } }, { path: 'dupont-analysis', name: 'DupontAnalysis', component: () => import('@/views/DupontAnalysis.vue'), meta: { title: '杜邦分析', roles: ['ceo', 'finance', 'it'] } },
{ path: 'cashflow-structure', name: 'CashFlowStructure', component: () => import('@/views/CashFlowStructure.vue'), meta: { title: '现金流量表结构分析', roles: ['ceo', 'finance', 'it'] } },
{ path: 'data-quality', name: 'DataQuality', component: () => import('@/views/DataQuality.vue'), meta: { title: '数据质量', roles: ['ceo', 'finance', 'it'] } }, { path: 'data-quality', name: 'DataQuality', component: () => import('@/views/DataQuality.vue'), meta: { title: '数据质量', roles: ['ceo', 'finance', 'it'] } },
{ path: 'bi-reports', name: 'BiReports', component: () => import('@/views/BIReportCenter.vue'), meta: { title: 'BI报表', roles: ['ceo', 'finance', 'business'] } }, { path: 'bi-reports', name: 'BiReports', component: () => import('@/views/BIReportCenter.vue'), meta: { title: 'BI报表', roles: ['ceo', 'finance', 'business'] } },
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'], editable: true } }, { path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'], editable: true } },
+576
View File
@@ -0,0 +1,576 @@
<template>
<div class="cashflow-structure-page">
<div class="page-header">
<h2>💰 现金流量表结构分析</h2>
<div class="header-right">
<el-tag size="small" type="info" effect="plain">{{ entityName || '账套' }}</el-tag>
<el-select v-model="period" size="small" style="width:140px" placeholder="选择期间" @change="loadData" :loading="loadingPeriods" clearable>
<el-option v-for="p in periods" :key="p" :value="p" :label="p" />
</el-select>
<el-button size="small" :icon="Refresh" @click="reload" :loading="loading">刷新</el-button>
<el-button v-if="canWrite" size="small" type="primary" @click="openDialog()">录入三大活动净额</el-button>
</div>
</div>
<div v-loading="loading" style="min-height:200px">
<!-- 无数据空态 -->
<el-empty v-if="!dataAvailable && !loading" :description="emptyMessage" style="padding:60px 0">
<el-button v-if="canWrite" type="primary" size="small" @click="openDialog()">录入官方现金流量表净额</el-button>
</el-empty>
<template v-if="dataAvailable">
<!-- 判定结果横幅 -->
<el-row :gutter="16">
<el-col :span="24">
<el-card shadow="never" class="verdict-card">
<div class="verdict-left" v-if="classification.classified">
<div class="verdict-badge">
<span class="verdict-type">类型{{ classification.type_no }}</span>
<span class="verdict-name">{{ classification.health_emoji }} {{ classification.name }}</span>
</div>
<div class="verdict-health">
<el-tag :type="healthTagType" size="small">健康度:{{ classification.health_emoji }} {{ classification.health_label }}</el-tag>
<el-tag v-if="classification.lifecycle" size="small" type="info" effect="plain" style="margin-left:8px">生命周期:{{ classification.lifecycle }}</el-tag>
<el-tag size="small" effect="plain" style="margin-left:8px">{{ source === 'official' ? '📄 官方现金流量表' : '✍️ 人工录入' }}</el-tag>
</div>
</div>
<div class="verdict-left" v-else>
<el-alert type="warning" :closable="false" :title="classification.reason || '净额数据不足,暂无法判定类型'" show-icon />
</div>
<div class="verdict-nets">
<div class="net-item" v-for="n in netList" :key="n.key">
<div class="net-name">{{ n.name }}</div>
<div class="net-value" :style="{ color: netColor(n) }">
{{ fmt(n.value) }}
</div>
<div class="net-sign" :style="{ color: netColor(n) }">
{{ n.sign === '+' ? '进钱 ▲' : n.sign === '-' ? '出钱 ▼' : '为0' }}
</div>
<div class="net-hint">{{ n.hint }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 瀑布图 + 画像/风险 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="15">
<el-card shadow="never" header="现金流瀑布图 — 期初现金 → 经营 → 投资 → 筹资 → 期末现金">
<div ref="waterfallRef" style="height:300px"></div>
<div v-if="!waterfall" style="color:#909399;font-size:12px;text-align:center;padding:8px">
暂缺期初/期末现金余额,仅显示三大活动净额
</div>
</el-card>
</el-col>
<el-col :span="9">
<el-card shadow="never" header="判定说明(CMA P2 · 现金流量表分析)">
<template v-if="classification.classified">
<p class="desc-p">{{ classification.profile }}</p>
<el-alert type="error" :closable="false" title="风险提示" show-icon>
<span>{{ classification.risk }}</span>
</el-alert>
</template>
<div v-else style="color:#909399;font-size:13px;line-height:1.8">
八类判定需要经营(O)/投资(I)/筹资(F)三大活动净额符号:
<br />黄金法则:长期看经营现金流必须为正,且应大于净利润(利润质量)。
<br />当前账套请先录入官方现金流量表三大活动净额。
</div>
</el-card>
</el-col>
</el-row>
<!-- 八类判定规则表 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never" header="八类企业定性判定规则表(O/I/F 符号组合)">
<el-table :data="rules" size="small" stripe :row-class-name="ruleRowClass">
<el-table-column prop="type_no" label="类型" width="60" align="center">
<template #default="{ row }">
<el-tag :type="row.type_no === currentType ? 'primary' : 'info'" size="small" effect="plain">
{{ row.type_no }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="组合" width="150" align="center">
<template #default="{ row }">
<span class="sign-combo">
<b :style="{ color: row.o_sign === '+' ? '#67C23A' : '#F56C6C' }">O{{ row.o_sign }}</b>
<b :style="{ color: row.i_sign === '+' ? '#67C23A' : '#F56C6C' }">I{{ row.i_sign }}</b>
<b :style="{ color: row.f_sign === '+' ? '#67C23A' : '#F56C6C' }">F{{ row.f_sign }}</b>
</span>
</template>
</el-table-column>
<el-table-column prop="name" label="定性名称" width="120" />
<el-table-column label="健康度" width="120">
<template #default="{ row }">{{ row.health_emoji }} {{ row.health_label }}</template>
</el-table-column>
<el-table-column prop="lifecycle" label="生命周期" width="160" />
<el-table-column prop="profile" label="画像(行为特征)" min-width="300" show-overflow-tooltip />
<el-table-column prop="risk" label="风险提示" min-width="260" show-overflow-tooltip />
</el-table>
</el-card>
</el-col>
</el-row>
<!-- 已录入数据 -->
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="24">
<el-card shadow="never" header="三大活动净额入库记录(万元)">
<el-table :data="valueRows" size="small" stripe>
<el-table-column prop="period" label="期间" width="100" />
<el-table-column label="经营活动净额" width="130" align="right">
<template #default="{ row }"><span :style="{ color: row.operating_net >= 0 ? '#67C23A' : '#F56C6C' }">{{ fmt(row.operating_net) }}</span></template>
</el-table-column>
<el-table-column label="投资活动净额" width="130" align="right">
<template #default="{ row }"><span :style="{ color: row.investing_net >= 0 ? '#67C23A' : '#F56C6C' }">{{ fmt(row.investing_net) }}</span></template>
</el-table-column>
<el-table-column label="筹资活动净额" width="130" align="right">
<template #default="{ row }"><span :style="{ color: row.financing_net >= 0 ? '#67C23A' : '#F56C6C' }">{{ fmt(row.financing_net) }}</span></template>
</el-table-column>
<el-table-column label="期初现金" width="100" align="right"><template #default="{ row }">{{ row.begin_cash != null ? fmt(row.begin_cash) : '-' }}</template></el-table-column>
<el-table-column label="期末现金" width="100" align="right"><template #default="{ row }">{{ row.end_cash != null ? fmt(row.end_cash) : '-' }}</template></el-table-column>
<el-table-column prop="source" label="来源" width="130" />
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="updated_at" label="更新时间" width="150" />
<el-table-column v-if="canWrite" label="操作" width="80" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="openDialog(row)">更新</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</template>
</div>
<!-- 录入/更新弹窗(MyDialog 项目铁律) -->
<MyDialog v-model="showDialog" title="录入现金流量表三大活动净额" :width="620">
<el-form label-width="140px" size="small">
<el-form-item label="期间" required>
<el-input v-model="form.period" placeholder="YYYY-MM,如 2026-08" style="width:220px" />
<div class="form-tip">官方现金流量表所属期间(月度)</div>
</el-form-item>
<el-form-item label="经营活动净额" required>
<el-input-number v-model="form.operating_net" :precision="2" :step="1" :controls="true" style="width:220px" />
<div class="form-tip">万元;正=造血进钱,负=经营失血</div>
</el-form-item>
<el-form-item label="投资活动净额" required>
<el-input-number v-model="form.investing_net" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">万元;负=扩张投入,正=处置/回报回笼</div>
</el-form-item>
<el-form-item label="筹资活动净额" required>
<el-input-number v-model="form.financing_net" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">万元;正=融资输血,负=还债/分红</div>
</el-form-item>
<el-form-item label="期初现金余额">
<el-input-number v-model="form.begin_cash" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">可选;用于瀑布图(万元)</div>
</el-form-item>
<el-form-item label="期末现金余额">
<el-input-number v-model="form.end_cash" :precision="2" :step="1" style="width:220px" />
<div class="form-tip">可选;用于瀑布图(万元)</div>
</el-form-item>
<el-form-item label="来源">
<el-radio-group v-model="form.source">
<el-radio value="official">官方现金流量表</el-radio>
<el-radio value="manual">人工录入</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="口径说明 / 数据来源,如:2026年8月官方现金流量表" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="showDialog = false">取消</el-button>
<el-button size="small" type="primary" :loading="saving" @click="saveValue">保存</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import api from '@/api'
import MyDialog from '@/components/MyDialog.vue'
const entityName = ref(localStorage.getItem('cma_entity_name') || '')
const userRole = ref<string>((JSON.parse(localStorage.getItem('cma_user') || '{}') as any).role || '')
const canWrite = computed(() => ['ceo', 'finance'].includes(userRole.value))
const period = ref('')
const periods = ref<string[]>([])
const loading = ref(false)
const loadingPeriods = ref(false)
const dataAvailable = ref(false)
const emptyMessage = ref('该期间暂无官方现金流量表净额数据')
const source = ref('official')
const nets = ref<any>(null)
const waterfall = ref<any>(null)
const classification = ref<any>({ classified: false })
const rules = ref<any[]>([])
const valueRows = ref<any[]>([])
const showDialog = ref(false)
const saving = ref(false)
const form = ref<any>({
period: '',
operating_net: 0,
investing_net: 0,
financing_net: 0,
begin_cash: null,
end_cash: null,
source: 'official',
remark: '',
})
const waterfallRef = ref<HTMLElement>()
let waterfallChart: echarts.ECharts | null = null
const netList = computed(() => {
const n = nets.value || {}
const meta: any = {
operating: { name: '经营活动 O', hint: '造血:主业赚不赚钱' },
investing: { name: '投资活动 I', hint: '成长:扩张 or 收缩' },
financing: { name: '筹资活动 F', hint: '输血:融资 or 还债' },
}
return ['operating', 'investing', 'financing']
.filter((k) => n[k] && n[k].value != null)
.map((k) => ({ key: k, ...meta[k], ...n[k] }))
})
const currentType = computed(() => (classification.value?.classified ? classification.value.type_no : -1))
const healthTagType = computed(() => {
const label = classification.value?.health_label || ''
if (['最优', '良好'].includes(label)) return 'success'
if (['看增速', '需辨析'].includes(label)) return 'warning'
if (['高风险'].includes(label)) return 'warning'
return 'danger'
})
function fmt(v: any): string {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '-'
return Number(v).toFixed(2)
}
function netColor(n: any): string {
if (n.sign === '+') return '#67C23A'
if (n.sign === '-') return '#F56C6C'
return '#909399'
}
function ruleRowClass({ row }: any) {
return row.type_no === currentType.value ? 'rule-row-current' : ''
}
async function loadPeriods() {
loadingPeriods.value = true
try {
const r: any = await api.get('/cashflow-structure/periods')
periods.value = (r && (r.periods || [])) || []
} catch (_) { /* 静默 */ }
loadingPeriods.value = false
}
async function loadValues() {
try {
const r: any = await api.get('/cashflow-structure/values')
valueRows.value = (r && (r.items || [])) || []
} catch (_) { valueRows.value = [] }
}
async function loadRules() {
try {
const r: any = await api.get('/cashflow-structure/rules')
rules.value = (r && (r.rules || [])) || []
} catch (_) { rules.value = [] }
}
async function loadData() {
loading.value = true
try {
const params: any = {}
if (period.value) params.period = period.value
const d: any = await api.get('/cashflow-structure/analysis', { params })
dataAvailable.value = !!d?.data_available
source.value = d?.source || 'official'
nets.value = d?.nets || null
waterfall.value = d?.waterfall || null
classification.value = d?.classification || { classified: false }
if (!dataAvailable.value) {
emptyMessage.value = d?.message || '该期间暂无官方现金流量表净额数据'
}
if (!period.value && d?.period) period.value = d.period
await nextTick()
renderWaterfall()
} catch (e: any) {
console.error('load cashflow structure analysis failed', e)
dataAvailable.value = false
emptyMessage.value = '加载失败:' + (e?.message || '请稍后重试')
} finally {
loading.value = false
}
}
async function reload() {
await Promise.all([loadPeriods(), loadValues()])
if (!period.value && periods.value.length) period.value = periods.value[0]
await loadData()
}
function renderWaterfall() {
if (!waterfallRef.value) return
if (!waterfallChart) waterfallChart = echarts.init(waterfallRef.value)
const steps = waterfall.value?.steps || []
if (!steps.length) {
waterfallChart.clear()
return
}
const baseData: number[] = []
const valueData: number[] = []
const colorData: string[] = []
const labelData: string[] = []
let cum = 0
steps.forEach((s: any, idx: number) => {
if (idx === 0 && s.type === 'begin') {
baseData.push(0)
valueData.push(Number(s.value) || 0)
colorData.push('#409EFF')
labelData.push(fmt(s.value))
cum = Number(s.value) || 0
} else if (s.type === 'end') {
baseData.push(0)
valueData.push(Number(s.value) || 0)
colorData.push('#409EFF')
labelData.push(fmt(s.value))
cum = Number(s.value) || 0
} else {
const v = Number(s.value) || 0
baseData.push(cum)
valueData.push(v)
colorData.push(v >= 0 ? '#67C23A' : '#F56C6C')
labelData.push((v >= 0 ? '+' : '') + fmt(v))
cum += v
}
})
const names = steps.map((s: any) => s.step)
waterfallChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any[]) => {
const i = params[0]?.dataIndex ?? 0
const s = steps[i]
let tip = `<b>${s.step}(${period.value})</b><br/>`
if (s.type === 'flow') {
const v = Number(s.value) || 0
tip += `净额:<span style="color:${v >= 0 ? '#67C23A' : '#F56C6C'}">${v >= 0 ? '+' : ''}${v.toFixed(2)} 万元</span>`
} else {
tip += `余额:${Number(s.value).toFixed(2)} 万元`
}
return tip
},
},
grid: { left: 20, right: 20, top: 40, bottom: 30, containLabel: true },
xAxis: { type: 'category', data: names, axisLabel: { interval: 0 } },
yAxis: { type: 'value', name: '万元', axisLabel: { formatter: '{value}' } },
series: [
{
name: '底', type: 'bar', stack: 'wf',
itemStyle: { color: 'transparent' },
emphasis: { itemStyle: { color: 'transparent' } },
data: baseData,
silent: true,
},
{
name: '净变动', type: 'bar', stack: 'wf',
data: valueData,
barWidth: 46,
itemStyle: { borderRadius: 2 },
label: {
show: true, position: 'top', formatter: (p: any) => labelData[p.dataIndex],
color: '#333', fontSize: 11,
},
emphasis: { focus: 'series' },
},
],
})
}
function openDialog(row?: any) {
form.value = {
period: row?.period || '',
operating_net: row ? row.operating_net : 0,
investing_net: row ? row.investing_net : 0,
financing_net: row ? row.financing_net : 0,
begin_cash: row?.begin_cash ?? null,
end_cash: row?.end_cash ?? null,
source: row?.source || 'official',
remark: row?.remark || '',
}
showDialog.value = true
}
async function saveValue() {
const f = form.value
if (!/^\d{4}-\d{2}$/.test(f.period || '')) {
ElMessage.warning('期间格式须为 YYYY-MM')
return
}
if ([f.operating_net, f.investing_net, f.financing_net].some((v) => v === null || v === undefined || Number.isNaN(Number(v)))) {
ElMessage.warning('三大活动净额必填')
return
}
saving.value = true
try {
const payload: any = {
period: f.period,
operating_net: Number(f.operating_net),
investing_net: Number(f.investing_net),
financing_net: Number(f.financing_net),
source: f.source,
}
if (f.begin_cash !== null && f.begin_cash !== undefined && !Number.isNaN(Number(f.begin_cash))) payload.begin_cash = Number(f.begin_cash)
if (f.end_cash !== null && f.end_cash !== undefined && !Number.isNaN(Number(f.end_cash))) payload.end_cash = Number(f.end_cash)
if (f.remark) payload.remark = f.remark
const r: any = await api.post('/cashflow-structure/values', payload)
const cls = r?.classification || {}
if (cls?.classified) {
ElMessage.success(`已保存:${r.period} → 类型${cls.type_no} ${cls.name}`)
} else {
ElMessage.success(`已保存:${r.period}${cls?.reason ? '(' + cls.reason + ')' : ''}`)
}
showDialog.value = false
await reload()
} catch (e: any) {
ElMessage.error('保存失败:' + (e?.response?.data?.detail || e?.message || '未知错误'))
} finally {
saving.value = false
}
}
function resizeCharts() {
waterfallChart?.resize()
}
onMounted(async () => {
window.addEventListener('resize', resizeCharts)
await loadRules()
await loadPeriods()
if (periods.value.length && !period.value) period.value = periods.value[0]
await loadValues()
await loadData()
})
</script>
<style scoped>
.cashflow-structure-page {
padding: 4px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 8px;
}
.page-header h2 {
margin: 0;
font-size: 18px;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.verdict-card {
border-left: 4px solid #409EFF;
}
.verdict-left {
margin-bottom: 14px;
}
.verdict-badge {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
}
.verdict-type {
font-size: 26px;
font-weight: 700;
color: #409EFF;
}
.verdict-name {
font-size: 20px;
font-weight: 600;
}
.verdict-health {
display: flex;
align-items: center;
}
.verdict-nets {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.net-item {
flex: 1;
min-width: 140px;
background: #f7f9fc;
border-radius: 6px;
padding: 10px 14px;
text-align: center;
}
.net-name {
font-size: 12px;
color: #606266;
}
.net-value {
font-size: 22px;
font-weight: 700;
margin: 2px 0;
}
.net-sign {
font-size: 13px;
font-weight: 600;
}
.net-hint {
font-size: 11px;
color: #909399;
margin-top: 2px;
}
.desc-p {
font-size: 13px;
line-height: 1.8;
color: #303133;
margin: 0 0 10px;
}
.sign-combo {
display: inline-flex;
gap: 10px;
font-size: 15px;
letter-spacing: 1px;
}
.form-tip {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-left: 8px;
}
:deep(.rule-row-current td) {
background: #ecf5ff !important;
}
</style>