feat(strategy): P0 战略基座 — strategy_charter + GET/PUT /api/cma/charter + P组入口
ci/woodpecker/push/main Pipeline was successful

- 表 strategy_charter(一账套一行,entity_id 唯一):mission/vision/vision_year/vision_measurable/values_text/value_items(JSON) 全 NOT NULL
- 迁移脚本 backend/scripts/migrate_strategy_charter_20261021.py:--check/--apply/--down(回滚先 mysqldump,有数据需 --force),零 INSERT
- API:GET 无行 → filled=false 空态(禁示例数据);PUT 字段白名单 + 账套隔离三道 + vision_year 4位年份 + value_items [{name,meaning}] 结构校验
- 前端:P 组「战略章程」入口 + MyDialog 编辑(ceo/finance 可写,其余只读);战略地图页顶部只读章程条
- 用例 43 条 + auth-surface 58 条 = 101 passed;vite build rc=0
This commit is contained in:
wecom-fullstack
2026-10-21 18:54:09 +08:00
parent 7022a05928
commit 0a6525edab
11 changed files with 1082 additions and 6 deletions
+227
View File
@@ -0,0 +1,227 @@
"""战略基座 — 战略章程 API(/api/cma/charter)
任务:`project-20261021-cma-strategy-charter-p0-fullstack`(P0 战略基座)
------------------------------------------------------------------
契约:
GET /api/cma/charter 读当前账套章程(无行 → 200 + filled=false 空态,**不造默认/示例值**)
PUT /api/cma/charter 写当前账套章程(upsert 单行;字段白名单 + 值域校验)
隔离(三道):
1. 账套一律取 `deps.get_entity_id` —— token 优先,显式传别的账套 → 403(平台统一语义);
2. body 里出现 entity_id 且与解析结果不一致 → 403(防 Bot/脚本越权注入);
3. 读写都带 `entity_id` 过滤条件(不靠唯一键兜底),跨账套读不到对方行。
白名单(派单要求「白名单」):
· 字段白名单 CHARTER_FIELDS —— 出现白名单外字段直接 400,不静默忽略;
· entity_id 不可改(由登录账套决定,手写一律拒绝);
· 不可空:mission / vision / vision_year / vision_measurable / values_text / value_items;
· vision_year 须 4 位年份;value_items 须为 `[{name, meaning}]` 数组(每条可作裁决依据)。
鉴权:GET 已登录即可(含 business 旁观);PUT 限 ceo / finance(战略口径归经营层)。
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth, require_role
from app.database import get_db
from app.deps import get_entity_id
from app.models import StrategyCharter, User
from app.models.strategy_charter import (
MISSION_MAX_LEN,
VALUE_ITEM_MEANING_MAX_LEN,
VALUE_ITEM_NAME_MAX_LEN,
VALUE_ITEMS_MAX,
VALUES_TEXT_MAX_LEN,
VISION_MAX_LEN,
VISION_MEASURABLE_MAX_LEN,
VISION_YEAR_RE,
)
import re
router = APIRouter(
prefix="/api/cma/charter",
tags=["战略基座"],
dependencies=[Depends(require_auth)],
)
# 可写字段白名单(entity_id / 审计字段不接受客户端输入)
CHARTER_FIELDS = (
"mission",
"vision",
"vision_year",
"vision_measurable",
"values_text",
"value_items",
)
REQUIRED_FIELDS = CHARTER_FIELDS # 六项均为必填(vision_year / vision_measurable 派单点名不可空)
def _bad(msg: str) -> HTTPException:
return HTTPException(status_code=400, detail=msg)
def _to_dict(row: StrategyCharter) -> dict:
return {
"id": row.id,
"entity_id": row.entity_id,
"mission": row.mission,
"vision": row.vision,
"vision_year": row.vision_year,
"vision_measurable": row.vision_measurable,
"values_text": row.values_text,
"value_items": row.value_items or [],
"updated_by": row.updated_by,
"updated_by_name": row.updated_by_name,
"created_at": row.created_at.isoformat(sep=" ", timespec="seconds") if row.created_at else None,
"updated_at": row.updated_at.isoformat(sep=" ", timespec="seconds") if row.updated_at else None,
}
def _empty_state(entity_id: int) -> dict:
"""空态:库里没有章程行。
明确返回 filled=false —— 前端据此显示「尚未填写」并给录入入口,
**不用任何默认/示例文案填充**(派单红线:真实数据非 Demo)。
"""
return {"entity_id": entity_id, "filled": False, "data": None}
@router.get("")
def get_charter(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""读取当前账套的战略章程(无行 → filled=false)。"""
row = (
db.query(StrategyCharter)
.filter(StrategyCharter.entity_id == entity_id)
.first()
)
if not row:
return _empty_state(entity_id)
payload = _empty_state(entity_id)
payload["filled"] = True
payload["data"] = _to_dict(row)
return payload
def _clean_text(raw, field: str, max_len: int) -> str:
if raw is None:
raise _bad(f"缺少必填字段 {field}")
if not isinstance(raw, str):
raise _bad(f"字段 {field} 必须是字符串")
val = raw.strip()
if not val:
raise _bad(f"字段 {field} 不能为空")
if len(val) > max_len:
raise _bad(f"字段 {field} 超长(最多 {max_len} 字符,当前 {len(val)})")
return val
def _clean_value_items(raw):
"""价值观条目:[{name, meaning}],每条须可作决策冲突时的裁决依据。"""
if raw is None:
raise _bad("缺少必填字段 value_items(价值观条目数组)")
if not isinstance(raw, list):
raise _bad("value_items 必须是数组 [{name, meaning}]")
if not raw:
raise _bad("value_items 至少需要 1 条(价值观须可作裁决依据)")
if len(raw) > VALUE_ITEMS_MAX:
raise _bad(f"value_items 最多 {VALUE_ITEMS_MAX} 条")
items = []
for idx, item in enumerate(raw, start=1):
if not isinstance(item, dict):
raise _bad(f"value_items 第 {idx} 条必须是对象 {{name, meaning}}")
extra = sorted(k for k in item.keys() if k not in ("name", "meaning"))
if extra:
raise _bad(f"value_items 第 {idx} 条含非白名单键 {extra};只允许 name / meaning")
name = _clean_text(item.get("name"), f"value_items[{idx}].name", VALUE_ITEM_NAME_MAX_LEN)
meaning = _clean_text(item.get("meaning"), f"value_items[{idx}].meaning",
VALUE_ITEM_MEANING_MAX_LEN)
items.append({"name": name, "meaning": meaning})
return items
@router.put("")
def put_charter(
body: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
user: User = Depends(require_role("ceo", "finance")),
):
"""写入/更新当前账套的战略章程(单行 upsert,字段白名单 + 值域校验)。"""
if not isinstance(body, dict) or not body:
raise _bad("请求体不能为空")
# ① 越权账套参数:显式 entity_id 与 token 账套不一致 → 403(不静默覆盖)
raw_entity = body.get("entity_id")
if raw_entity is not None:
try:
claimed = int(raw_entity)
except (TypeError, ValueError):
raise _bad(f"entity_id 无效: {raw_entity!r}")
if claimed != entity_id:
raise HTTPException(
status_code=403,
detail=f"无权写入企业 entity_id={claimed}(当前账套: {entity_id})",
)
# ② 字段白名单:白名单外字段一律拒绝(含 entity_id / id / updated_by 等审计字段)
unknown = sorted(k for k in body.keys() if k not in CHARTER_FIELDS)
if unknown:
raise _bad(
f"非白名单字段 {unknown};允许字段 {list(CHARTER_FIELDS)}"
f"(entity_id 由登录账套决定,不可手写)"
)
# ③ 必填校验(vision_year / vision_measurable 不可空)
missing = [f for f in REQUIRED_FIELDS if f not in body or body.get(f) is None]
if missing:
raise _bad(f"缺少必填字段 {missing};vision_year 与 vision_measurable 不可空")
mission = _clean_text(body.get("mission"), "mission", MISSION_MAX_LEN)
vision = _clean_text(body.get("vision"), "vision", VISION_MAX_LEN)
values_text = _clean_text(body.get("values_text"), "values_text", VALUES_TEXT_MAX_LEN)
# ④ vision_year:4 位年份(接受 int 2030 或 "2030")
raw_year = body.get("vision_year")
if isinstance(raw_year, bool) or not isinstance(raw_year, (int, str)):
raise _bad("vision_year 必须是年份(如 2030)")
year = str(raw_year).strip()
if not re.match(VISION_YEAR_RE, year):
raise _bad(f"vision_year 须为 4 位年份(如 2030),收到 {raw_year!r}")
measurable = _clean_text(body.get("vision_measurable"), "vision_measurable",
VISION_MEASURABLE_MAX_LEN)
value_items = _clean_value_items(body.get("value_items"))
row = (
db.query(StrategyCharter)
.filter(StrategyCharter.entity_id == entity_id)
.first()
)
if row is None:
row = StrategyCharter(entity_id=entity_id)
db.add(row)
row.mission = mission
row.vision = vision
row.vision_year = year
row.vision_measurable = measurable
row.values_text = values_text
row.value_items = value_items
row.updated_by = getattr(user, "id", None)
row.updated_by_name = getattr(user, "name", None)
db.commit()
db.refresh(row)
payload = _empty_state(entity_id)
payload["filled"] = True
payload["data"] = _to_dict(row)
payload["message"] = "战略章程已保存"
return payload
+2 -1
View File
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv
from app.database import init_db, get_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions, ar_aging
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions, ar_aging, charter
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
@@ -107,6 +107,7 @@ app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
app.include_router(ar_aging.router) # 应收账龄看板(T2 2026-10-20:逐户·六档·三口径禁合计)
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
app.include_router(charter.router) # 战略基座:战略章程(使命/愿景/价值观,P0 2026-10-21)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
+1
View File
@@ -9,6 +9,7 @@ from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
from app.models.product_sales import ProductSales
from app.models.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger
from app.models.report_opening import ReportOpeningBalance
from app.models.strategy_charter import StrategyCharter
from app.models.treasury import (
TreasuryAccount, TreasuryBalance, TreasuryControlRule,
TreasuryPayment, TreasuryControlHit, TreasuryLimit,
+64
View File
@@ -0,0 +1,64 @@
"""战略基座 — 战略章程(使命 / 愿景 / 价值观)
任务:`project-20261021-cma-strategy-charter-p0-fullstack`(P0 战略基座)
依据:《2026-10-21 CMA 知识库 M0 与系统实现差距报告》§四 P0 —— 知识卡
`CMA-P1-B-009 愿景、使命与价值观`:使命=为何存在;愿景=要成为什么
(**带年份与可验证状态**);价值观=不可交易的底线,是决策冲突时的裁决依据。
单一事实源:每个账套(entity_id)**一条**章程行,唯一键 uq_strategy_charter_entity。
本模块只做「表结构 + 契约常量」;读写端点见 app/api/charter.py。
字段语义(派单表:语义不可减,类型可调整):
mission 使命 TEXT NOT NULL
vision 愿景 TEXT NOT NULL
vision_year 愿景达成年份 VARCHAR(20) NOT NULL(**不可空**;4 位年份)
vision_measurable 可验证状态 TEXT NOT NULL(**不可空**;用可核对的规模/业务口径描述)
values_text 价值观总述 TEXT NOT NULL
value_items 价值观条目 JSON NOT NULL `[{name, meaning}]`
—— 每条须能作"决策冲突时的裁决依据"
隔离:`entity_id NOT NULL` + 唯一键以 entity_id 打头(多租户隔离 + 索引可用性)。
禁止示例数据:本模块与迁移脚本**零 INSERT**;GET 无行返回 `filled=false` 空态。
"""
from sqlalchemy import Column, Integer, String, Text, JSON, DateTime, UniqueConstraint, func
from app.database import Base
# 愿景年度格式:4 位年份(不臆造业务上限;允许字符串以兼容前端输入)
VISION_YEAR_RE = r"^\d{4}$"
# 长文本上限(与列宽一致,避免 MySQL 截断静默丢字)
MISSION_MAX_LEN = 1000
VISION_MAX_LEN = 1000
VISION_MEASURABLE_MAX_LEN = 1000
VALUES_TEXT_MAX_LEN = 2000
VALUE_ITEM_NAME_MAX_LEN = 50
VALUE_ITEM_MEANING_MAX_LEN = 300
VALUE_ITEMS_MAX = 20
class StrategyCharter(Base):
"""战略章程 — 使命/愿景(年度+可验证状态)/价值观(每账套一行)"""
__tablename__ = "strategy_charter"
id = Column(Integer, primary_key=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(账套,多租户隔离;唯一键已建索引)")
mission = Column(Text, nullable=False, comment="使命:我们为何存在(做什么、为谁做)")
vision = Column(Text, nullable=False, comment="愿景:要成为什么")
vision_year = Column(String(20), nullable=False, comment="愿景达成年份 YYYY(不可空)")
vision_measurable = Column(
Text, nullable=False,
comment="可验证状态:用可核对的规模/业务口径描述(不可空)",
)
values_text = Column(Text, nullable=False, comment="价值观总述(不可交易的底线)")
value_items = Column(
JSON, nullable=False,
comment="价值观条目数组 [{name, meaning}],每条可作决策冲突时的裁决依据",
)
updated_by = Column(Integer, nullable=True, comment="最后修改人 user.id")
updated_by_name = Column(String(50), 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", name="uq_strategy_charter_entity"),
)