diff --git a/backend/app/api/charter.py b/backend/app/api/charter.py new file mode 100644 index 00000000..515da22e --- /dev/null +++ b/backend/app/api/charter.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 05470e9a..1ce1cd9a 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, 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): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c6e6ca47..d3e19c1e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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, diff --git a/backend/app/models/strategy_charter.py b/backend/app/models/strategy_charter.py new file mode 100644 index 00000000..56389e6f --- /dev/null +++ b/backend/app/models/strategy_charter.py @@ -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"), + ) diff --git a/backend/scripts/migrate_strategy_charter_20261021.py b/backend/scripts/migrate_strategy_charter_20261021.py new file mode 100644 index 00000000..f82b5cb0 --- /dev/null +++ b/backend/scripts/migrate_strategy_charter_20261021.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""战略章程表迁移(strategy_charter)— 幂等建表 + 回滚,零示例数据 + +任务:`project-20261021-cma-strategy-charter-p0-fullstack`(P0 战略基座) + +用法: + python3 migrate_strategy_charter_20261021.py --check # 只读预检(默认):表/列/唯一键是否齐 + python3 migrate_strategy_charter_20261021.py --apply # 建表(CREATE TABLE IF NOT EXISTS)+ 回读断言 + python3 migrate_strategy_charter_20261021.py --down # 回滚(先 mysqldump 备份,再 DROP;有数据需 --force) + +连接(与 db_migrate.py / treasury_migrate.py 同口径): + env CMA_MIGRATE_DB_USER / CMA_MIGRATE_DB_PASS / CMA_MIGRATE_DB_HOST / CMA_MIGRATE_DB_PORT 优先(TCP); + 否则本机 unix socket + root(/var/run/mysqld/mysqld.sock)。 + **显式拒绝**应用运行账号 cma_api(无 DDL/GRANT 权限)。 + +纪律: + · 迁移脚本**零 INSERT** —— 不写任何示例/默认章程文案(任总红线:真实数据非 Demo); + · 幂等:二跑 created=0;列级差异只报告,不改既有列; + · 授权面不在本脚本:由部署链 [4b-2] grant_surface_sync 按白名单授予 cma_api INSERT,UPDATE。 +""" +import argparse +import json +import os +import subprocess +import sys +from datetime import datetime + +TABLE = "strategy_charter" +NEW_COLUMNS = [ + "id", "entity_id", "mission", "vision", "vision_year", "vision_measurable", + "values_text", "value_items", "updated_by", "updated_by_name", "created_at", "updated_at", +] +UNIQUE_KEY = "uq_strategy_charter_entity" + +DDL = """ +CREATE TABLE IF NOT EXISTS `strategy_charter` ( + `id` INT NOT NULL AUTO_INCREMENT, + `entity_id` INT NOT NULL COMMENT '企业ID(账套,多租户隔离)', + `mission` TEXT NOT NULL COMMENT '使命:我们为何存在(做什么、为谁做)', + `vision` TEXT NOT NULL COMMENT '愿景:要成为什么', + `vision_year` VARCHAR(20) NOT NULL COMMENT '愿景达成年份 YYYY(不可空)', + `vision_measurable` TEXT NOT NULL COMMENT '可验证状态:可核对规模/业务口径描述(不可空)', + `values_text` TEXT NOT NULL COMMENT '价值观总述(不可交易的底线)', + `value_items` JSON NOT NULL COMMENT '价值观条目数组 [{name, meaning}]', + `updated_by` INT NULL COMMENT '最后修改人 user.id', + `updated_by_name` VARCHAR(50) NULL COMMENT '最后修改人姓名(快照)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_strategy_charter_entity` (`entity_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='战略章程(使命/愿景/价值观,一账套一行)' +""" + +DB_NAME = os.environ.get("CMA_MIGRATE_DB_NAME", "cma") +APP_ACCOUNT = os.environ.get("CMA_APP_ACCOUNT", "cma_api") + + +def connect(args): + import pymysql + if os.environ.get("CMA_MIGRATE_DB_USER") or args.user: + user = args.user or os.environ.get("CMA_MIGRATE_DB_USER") + if user == APP_ACCOUNT: + raise SystemExit("[FAIL] 迁移身份不得为应用运行账号 cma_api(无 DDL 权限)") + return pymysql.connect( + host=args.host or os.environ.get("CMA_MIGRATE_DB_HOST", "127.0.0.1"), + port=int(args.port or os.environ.get("CMA_MIGRATE_DB_PORT", 3306)), + user=user, + password=args.password if args.password is not None else os.environ.get("CMA_MIGRATE_DB_PASS", ""), + database=DB_NAME, charset="utf8mb4", autocommit=True, + ) + socket = os.environ.get("CMA_MIGRATE_DB_SOCKET", "/var/run/mysqld/mysqld.sock") + if not os.path.exists(socket): + raise SystemExit(f"[FAIL] 无迁移账号环境变量且 socket 不存在: {socket}") + return pymysql.connect(unix_socket=socket, user="root", database=DB_NAME, + charset="utf8mb4", autocommit=True) + + +def table_columns(cur): + cur.execute( + "SELECT COLUMN_NAME, IS_NULLABLE, DATA_TYPE FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s ORDER BY ORDINAL_POSITION", + (DB_NAME, TABLE)) + return {r[0]: {"nullable": r[1], "type": r[2]} for r in cur.fetchall()} + + +def unique_keys(cur): + cur.execute( + "SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS " + "WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s AND NON_UNIQUE=0", + (DB_NAME, TABLE)) + keys = {} + for idx, col in cur.fetchall(): + keys.setdefault(idx, []).append(col) + return keys + + +def rel(*parts): + return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), *parts) + + +def main(): + ap = argparse.ArgumentParser() + g = ap.add_mutually_exclusive_group() + g.add_argument("--check", action="store_true") + g.add_argument("--apply", action="store_true") + g.add_argument("--down", action="store_true") + ap.add_argument("--force", action="store_true", help="--down 时表内有数据仍删(先 dump)") + ap.add_argument("--host") + ap.add_argument("--port", type=int) + ap.add_argument("--user") + ap.add_argument("--password", default=None) + args = ap.parse_args() + mode = "apply" if args.apply else ("down" if args.down else "check") + + conn = connect(args) + out: dict = {"script": "migrate_strategy_charter_20261021.py", "mode": mode, "db": DB_NAME, + "table": TABLE, "started_at": datetime.now().isoformat(timespec="seconds")} + try: + with conn.cursor() as cur: + cur.execute("SHOW TABLES LIKE %s", (TABLE,)) + exists = cur.fetchone() is not None + out["table_exists_before"] = exists + + if mode == "check": + cols = table_columns(cur) if exists else {} + keys = unique_keys(cur) if exists else {} + missing = [c for c in NEW_COLUMNS if c not in cols] + out.update({ + "columns": sorted(cols.keys()), + "missing_columns": missing, + "unique_keys": keys, + "unique_ok": keys.get(UNIQUE_KEY) == ["entity_id"], + "ready": exists and not missing and keys.get(UNIQUE_KEY) == ["entity_id"], + "note": "只读预检,零写入", + }) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 0 + + if mode == "apply": + cur.execute(DDL) + cols = table_columns(cur) + keys = unique_keys(cur) + missing = [c for c in NEW_COLUMNS if c not in cols] + cur.execute(f"SELECT COUNT(*) FROM `{TABLE}`") + rows = cur.fetchone()[0] + ok = (not missing) and keys.get(UNIQUE_KEY) == ["entity_id"] + out.update({ + "created": 0 if exists else 1, + "columns": sorted(cols.keys()), + "missing_columns": missing, + "unique_keys": keys, + "rows": rows, + "verified": ok, + "note": "零示例数据:本脚本不含任何 INSERT", + }) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 0 if ok else 1 + + # --down + if not exists: + out.update({"action": "NOOP", "note": "表不存在,无需回滚"}) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 0 + cur.execute(f"SELECT COUNT(*) FROM `{TABLE}`") + rows = cur.fetchone()[0] + out["rows"] = rows + if rows > 0 and not args.force: + out.update({"action": "REFUSED", "reason": "表内有数据;先确认再 --force"}) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 3 + + backup_dir = rel("backups", "strategy-charter-rollback") + os.makedirs(backup_dir, exist_ok=True) + dump = os.path.join(backup_dir, f"{TABLE}-{datetime.now():%Y%m%d-%H%M%S}.sql") + cmd = ["mysqldump", "-u", "root", "--single-transaction", "--no-tablespaces", + DB_NAME, TABLE] + with open(dump, "wb") as fh: + rc = subprocess.call(cmd, stdout=fh, stderr=subprocess.PIPE) + if rc != 0 or os.path.getsize(dump) == 0: + out.update({"action": "REFUSED", "reason": f"mysqldump 失败 rc={rc}(fail-closed)"}) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 4 + out["dump"] = dump + + cur.execute(f"DROP TABLE `{TABLE}`") + cur.execute("SHOW TABLES LIKE %s", (TABLE,)) + out.update({"action": "DROPPED", "table_exists_after": cur.fetchone() is not None}) + print(json.dumps(out, ensure_ascii=False, indent=2)) + return 0 + finally: + conn.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/test_strategy_charter_20261021.py b/backend/tests/test_strategy_charter_20261021.py new file mode 100644 index 00000000..bc8de6b6 --- /dev/null +++ b/backend/tests/test_strategy_charter_20261021.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +"""战略基座(strategy_charter + /api/cma/charter)验收用例 — 2026-10-21 + +任务:`project-20261021-cma-strategy-charter-p0-fullstack`(P0 战略基座) + +覆盖(每条都是硬断言,不做“大概通过”): + A. 禁止示例数据:库内零行时 GET 200 + filled=false + data=null,且表内 rowcount=0; + B. 正常写入:PUT 落一行,六字段逐字回读(含 value_items 数组);GET 再读 filled=true; + C. 账套隔离:e1/e2 各写各的,互相 GET 不到对方;token=e1 显式 body.entity_id=2 → 403; + GET ?entity_id=<对方> → 403(平台统一语义); + D. 不可空:缺 vision_year / vision_measurable / mission / vision / values_text / value_items → 400; + 显式 null → 400;空串/空白 → 400; + E. 值域:vision_year 非 4 位年份 → 400(int 2030 归一为 "2030"); + value_items 非数组 / 条目缺 name|meaning / 含非白名单键 → 400; + F. 字段白名单:非白名单字段(含 id/updated_by/entity_id 手写审计字段)→ 400; + G. 鉴权/角色:无 token → 401;business 角色 PUT → 403(GET 仍 200)。 + +本套件**不连生产库**(conftest 已换内存 SQLite),也不插入任何“示例章程”。 +""" +import hashlib + +import pytest + +from app.models import Entity, StrategyCharter, User, UserEntity +from tests.conftest import ( + TEST_USER_PASSWORD, + auth_header, + create_test_user, +) + +CHARTER = "/api/cma/charter" + +VALID = { + "mission": "让每一家中国企业的经营决策都有据可依", + "vision": "成为西北最被信任的管理会计数字化伙伴", + "vision_year": "2030", + "vision_measurable": "2030 年服务 300 家付费企业、续约率 ≥85%(年报复核)", + "values_text": "客户成功|数据说话|长期主义", + "value_items": [ + {"name": "客户成功", "meaning": "客户指标不改善的方案,内部再漂亮也不上"}, + {"name": "数据说话", "meaning": "决策冲突时以可复核口径为准,不以职级定输赢"}, + ], +} + + +def _ensure_entity(db, eid: int, name: str) -> Entity: + ent = db.query(Entity).filter(Entity.id == eid).first() + if not ent: + ent = Entity(id=eid, name=name, short_name=name, status="active") + db.add(ent) + db.commit() + db.refresh(ent) + return ent + + +def _user_with_entities(db, username: str, role: str = "ceo", entity_ids=(1,)) -> User: + user = db.query(User).filter(User.username == username).first() + if not user: + user = create_test_user( + db, username=username, role=role, + password_hash=hashlib.sha256(TEST_USER_PASSWORD.encode()).hexdigest()) + have = {ue.entity_id for ue in db.query(UserEntity).filter(UserEntity.user_id == user.id).all()} + for eid in entity_ids: + if eid not in have: + db.add(UserEntity(user_id=user.id, entity_id=eid)) + db.commit() + return user + + +def _login(client, username: str, entity_id: int) -> str: + resp = client.post("/api/cma/auth/login", json={ + "username": username, "password": TEST_USER_PASSWORD, "entity_id": entity_id, + }) + assert resp.status_code == 200, resp.text + return resp.json()["token"] + + +@pytest.fixture +def ctx(client, db): + """两账套 + 两个角色用户(ceo@e1+e2 / business@e1)""" + _ensure_entity(db, 1, "测试酣客") + _ensure_entity(db, 2, "测试博海") + _user_with_entities(db, "ceo1", "ceo", (1, 2)) + _user_with_entities(db, "biz1", "business", (1,)) + return { + "e1": auth_header(_login(client, "ceo1", 1)), + "e2": auth_header(_login(client, "ceo1", 2)), + "biz": auth_header(_login(client, "biz1", 1)), + } + + +# ── A. 禁止示例数据 + 空态契约 ──────────────────────────────────────────── + +def test_get_empty_state_no_seed_rows(client, db, ctx): + resp = client.get(CHARTER, headers=ctx["e1"]) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["filled"] is False + assert body["data"] is None + assert body["entity_id"] == 1 + # 派单红线:禁止示例/默认文案 —— 表内零行 + assert db.query(StrategyCharter).count() == 0 + + +# ── B. 正常写入 + 回读 ─────────────────────────────────────────────────── + +def test_put_then_get_roundtrip(client, db, ctx): + resp = client.put(CHARTER, headers=ctx["e1"], json=VALID) + assert resp.status_code == 200, resp.text + data = resp.json()["data"] + for k, v in VALID.items(): + assert data[k] == v, k + assert data["entity_id"] == 1 + assert data["updated_by_name"] == "测试管理员" + + row = db.query(StrategyCharter).filter(StrategyCharter.entity_id == 1).one() + assert row.vision_year == "2030" + assert row.vision_measurable == VALID["vision_measurable"] + assert row.value_items == VALID["value_items"] + + got = client.get(CHARTER, headers=ctx["e1"]).json() + assert got["filled"] is True + assert got["data"]["mission"] == VALID["mission"] + assert got["data"]["vision_year"] == "2030" + + +def test_put_is_upsert_single_row_per_entity(client, db, ctx): + assert client.put(CHARTER, headers=ctx["e1"], json=VALID).status_code == 200 + changed = dict(VALID, vision="改过的愿景", vision_year="2035") + assert client.put(CHARTER, headers=ctx["e1"], json=changed).status_code == 200 + rows = db.query(StrategyCharter).filter(StrategyCharter.entity_id == 1).all() + assert len(rows) == 1, "每账套必须单行(唯一键 uq_strategy_charter_entity)" + assert rows[0].vision == "改过的愿景" + assert rows[0].vision_year == "2035" + + +def test_int_vision_year_normalized_to_string(client, db, ctx): + resp = client.put(CHARTER, headers=ctx["e1"], json=dict(VALID, vision_year=2030)) + assert resp.status_code == 200, resp.text + assert resp.json()["data"]["vision_year"] == "2030" + + +# ── C. 账套隔离 ───────────────────────────────────────────────────────── + +def test_entity_isolation_both_directions(client, db, ctx): + e2_payload = dict(VALID, mission="博海使命", vision="博海愿景") + assert client.put(CHARTER, headers=ctx["e1"], json=VALID).status_code == 200 + assert client.put(CHARTER, headers=ctx["e2"], json=e2_payload).status_code == 200 + + d1 = client.get(CHARTER, headers=ctx["e1"]).json()["data"] + d2 = client.get(CHARTER, headers=ctx["e2"]).json()["data"] + assert d1["entity_id"] == 1 and d1["mission"] == VALID["mission"] + assert d2["entity_id"] == 2 and d2["mission"] == "博海使命" + assert d1["mission"] != d2["mission"] + assert db.query(StrategyCharter).count() == 2 + + +def test_put_with_foreign_entity_id_in_body_forbidden(client, db, ctx): + resp = client.put(CHARTER, headers=ctx["e1"], json=dict(VALID, entity_id=2)) + assert resp.status_code == 403, resp.text + assert "无权写入" in resp.json()["detail"] + assert db.query(StrategyCharter).count() == 0, "403 必须零写入" + + +def test_get_with_explicit_other_entity_query_forbidden(client, ctx): + resp = client.get(f"{CHARTER}?entity_id=2", headers=ctx["e1"]) + assert resp.status_code == 403, resp.text + + +def test_entity_without_charter_sees_empty_state(client, db, ctx): + """e1 有章程、e2 没有 ⇒ e2 读到 filled=false(不串 e1 数据)""" + assert client.put(CHARTER, headers=ctx["e1"], json=VALID).status_code == 200 + got = client.get(CHARTER, headers=ctx["e2"]).json() + assert got["filled"] is False and got["data"] is None and got["entity_id"] == 2 + + +# ── D. 不可空约束 ─────────────────────────────────────────────────────── + +@pytest.mark.parametrize("field", ["vision_year", "vision_measurable", "mission", "vision", + "values_text", "value_items"]) +def test_required_fields_cannot_be_missing(client, ctx, field): + payload = {k: v for k, v in VALID.items() if k != field} + resp = client.put(CHARTER, headers=ctx["e1"], json=payload) + assert resp.status_code == 400, resp.text + assert field in resp.json()["detail"] + + +@pytest.mark.parametrize("field", ["vision_year", "vision_measurable", "mission", "vision", + "values_text", "value_items"]) +def test_required_fields_cannot_be_null(client, ctx, field): + resp = client.put(CHARTER, headers=ctx["e1"], json=dict(VALID, **{field: None})) + assert resp.status_code == 400, resp.text + + +@pytest.mark.parametrize("field", ["mission", "vision", "vision_measurable", "values_text"]) +def test_blank_text_rejected(client, ctx, field): + resp = client.put(CHARTER, headers=ctx["e1"], json=dict(VALID, **{field: " "})) + assert resp.status_code == 400, resp.text + assert field in resp.json()["detail"] + + +def test_empty_value_items_rejected(client, ctx): + resp = client.put(CHARTER, headers=ctx["e1"], json=dict(VALID, value_items=[])) + assert resp.status_code == 400, resp.text + assert "value_items" in resp.json()["detail"] + + +# ── E. 值域 ───────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("year", ["", "203", "20300", "abcd", 2030.5, True, " "]) +def test_vision_year_must_be_four_digits(client, ctx, year): + resp = client.put(CHARTER, headers=ctx["e1"], json=dict(VALID, vision_year=year)) + assert resp.status_code == 400, (year, resp.text) + + +@pytest.mark.parametrize("items", [ + "客户成功", # 非数组 + [{"name": "客户成功"}], # 缺 meaning + [{"name": "", "meaning": "x"}], # name 空 + [{"name": "客户成功", "meaning": "x", "extra": 1}], # 非白名单键 + [1, 2], # 条目非对象 +]) +def test_value_items_shape(client, ctx, items): + resp = client.put(CHARTER, headers=ctx["e1"], json=dict(VALID, value_items=items)) + assert resp.status_code == 400, (items, resp.text) + + +# ── F. 字段白名单 ─────────────────────────────────────────────────────── + +@pytest.mark.parametrize("bad", [{"entity_id_x": 2}, {"id": 99}, {"updated_by": 7}, + {"verification_status": "verified"}]) +def test_unknown_fields_rejected(client, ctx, bad): + resp = client.put(CHARTER, headers=ctx["e1"], json=dict(VALID, **bad)) + assert resp.status_code == 400, resp.text + assert "非白名单字段" in resp.json()["detail"] + + +# ── G. 鉴权 / 角色 ───────────────────────────────────────────────────── + +def test_no_token_401(client, ctx): + assert client.get(CHARTER).status_code == 401 + assert client.put(CHARTER, json=VALID).status_code == 401 + + +def test_business_role_can_read_but_not_write(client, db, ctx): + assert client.get(CHARTER, headers=ctx["biz"]).status_code == 200 + resp = client.put(CHARTER, headers=ctx["biz"], json=VALID) + assert resp.status_code == 403, resp.text + assert db.query(StrategyCharter).count() == 0 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f93ea481..69146fc6 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -561,4 +561,17 @@ export const arAgingApi = { }, } -export default api +// ── 战略基座:战略章程(P0 2026-10-21)── +export const charterApi = { + get: () => api.get('/charter'), + save: (payload: { + mission: string + vision: string + vision_year: string + vision_measurable: string + values_text: string + value_items: Array<{ name: string; meaning: string }> + }) => api.put('/charter', payload), +} + +export default api \ No newline at end of file diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts index 189aeeca..5918552d 100644 --- a/frontend/src/permission.ts +++ b/frontend/src/permission.ts @@ -9,10 +9,10 @@ interface MenuItem { // ── 角色路由映射 ── export const ROLE_ROUTES: Record = { - ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/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', '/treasury-dashboard', '/treasury-payment'], - finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/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', '/treasury-dashboard', '/treasury-payment'], - business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/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', '/strategy-map', '/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', '/treasury-payment'], + ceo: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/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', '/treasury-dashboard', '/treasury-payment'], + finance: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/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', '/treasury-dashboard', '/treasury-payment'], + business: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], + it: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/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', '/treasury-payment'], } export const ROLE_ACTIONS: Record = { @@ -26,6 +26,7 @@ export const MENU_ITEMS: MenuItem[] = [ // ── GROUP 1: 规划与目标(Plan)── { path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], group: '🔵 P 规划与目标' }, + { path: '/strategy-charter', label: '战略章程', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/strategy-map', label: '战略地图看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 843ebed0..6359d7db 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -19,6 +19,8 @@ const routes = [ { path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'], zone: 'B', editable: true } }, // /strategy-map: A区(只读活看板 —— 读库渲染,页内零写入口;2026-10-11 finance-20261011-strategy-map-frontend-fullstack) { path: 'strategy-map', name: 'StrategyMap', component: () => import('@/views/StrategyMap.vue'), meta: { title: '战略地图看板', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } }, + // /strategy-charter: P 组·战略基座入口(使命/愿景/价值观;页内含 MyDialog 写入口 ⇒ B区) + { path: 'strategy-charter', name: 'StrategyCharter', component: () => import('@/views/StrategyCharter.vue'), meta: { title: '战略章程', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } }, { path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } }, { path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } }, { path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } }, diff --git a/frontend/src/views/StrategyCharter.vue b/frontend/src/views/StrategyCharter.vue new file mode 100644 index 00000000..45e4945f --- /dev/null +++ b/frontend/src/views/StrategyCharter.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/frontend/src/views/StrategyMap.vue b/frontend/src/views/StrategyMap.vue index 8feaa14c..aaaeb744 100644 --- a/frontend/src/views/StrategyMap.vue +++ b/frontend/src/views/StrategyMap.vue @@ -25,6 +25,23 @@ + +
+
+ 使命 + {{ charter.mission }} +
+
+ 愿景 · {{ charter.vision_year }} + {{ charter.vision }} +
+
+ 可验证状态 + {{ charter.vision_measurable }} +
+ 查看章程 +
+
@@ -167,6 +184,7 @@ import { Money, User, Setting, Reading, TrendCharts, } from '@element-plus/icons-vue' import { mapApi } from '../api/index' +import { charterApi } from '../api/index' import CaliberBadge from '../components/CaliberBadge.vue' import { caliberGapFor, currentCaliberEntityId, isCaliberKpiCode, type CaliberGapFacts } from '../constants/caliber' @@ -175,6 +193,8 @@ const boardRef = ref(null) const loading = ref(false) const errorMsg = ref('') const board = ref(null) +// 战略章程(P 基座最顶层输入,只读;无行 → null,不显示任何文案) +const charter = ref(null) const period = ref('') const periods = ref([]) const boardSize = ref({ w: 0, h: 0 }) @@ -317,6 +337,10 @@ function onResize() { } onMounted(async () => { + // 章程与地图并行取;章程失败不阻断地图(只读装饰项) + charterApi.get() + .then((res: any) => { if (res?.filled) charter.value = res.data }) + .catch(() => { charter.value = null }) await load() window.addEventListener('resize', onResize) }) @@ -367,6 +391,16 @@ function doPrint() {