feat(strategy): P0 战略基座 — strategy_charter + GET/PUT /api/cma/charter + P组入口
ci/woodpecker/push/main Pipeline was successful
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:
@@ -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
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from app.database import init_db, get_db
|
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 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, require_auth_401
|
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(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
|
||||||
app.include_router(ar_aging.router) # 应收账龄看板(T2 2026-10-20:逐户·六档·三口径禁合计)
|
app.include_router(ar_aging.router) # 应收账龄看板(T2 2026-10-20:逐户·六档·三口径禁合计)
|
||||||
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
|
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
|
||||||
|
app.include_router(charter.router) # 战略基座:战略章程(使命/愿景/价值观,P0 2026-10-21)
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
|||||||
from app.models.product_sales import ProductSales
|
from app.models.product_sales import ProductSales
|
||||||
from app.models.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger
|
from app.models.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger
|
||||||
from app.models.report_opening import ReportOpeningBalance
|
from app.models.report_opening import ReportOpeningBalance
|
||||||
|
from app.models.strategy_charter import StrategyCharter
|
||||||
from app.models.treasury import (
|
from app.models.treasury import (
|
||||||
TreasuryAccount, TreasuryBalance, TreasuryControlRule,
|
TreasuryAccount, TreasuryBalance, TreasuryControlRule,
|
||||||
TreasuryPayment, TreasuryControlHit, TreasuryLimit,
|
TreasuryPayment, TreasuryControlHit, TreasuryLimit,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -561,4 +561,17 @@ export const arAgingApi = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 战略基座:战略章程(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
|
export default api
|
||||||
@@ -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', '/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'],
|
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', '/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'],
|
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', '/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'],
|
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', '/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'],
|
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<string, string[]> = {
|
export const ROLE_ACTIONS: Record<string, string[]> = {
|
||||||
@@ -26,6 +26,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||||||
// ── GROUP 1: 规划与目标(Plan)──
|
// ── GROUP 1: 规划与目标(Plan)──
|
||||||
{ path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
{ path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||||
{ path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], 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: '/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: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||||
{ path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
{ path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||||
|
|||||||
@@ -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 } },
|
{ 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)
|
// /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' } },
|
{ 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: '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: '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 } },
|
{ path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<template>
|
||||||
|
<div class="charter-page">
|
||||||
|
<div class="charter-head">
|
||||||
|
<div class="charter-title">
|
||||||
|
<h2>战略章程</h2>
|
||||||
|
<p>P 战略基座 · 使命 / 愿景(年度 + 可验证状态)/ 价值观 —— 全公司战略口径的唯一起点</p>
|
||||||
|
</div>
|
||||||
|
<div class="charter-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="primary"
|
||||||
|
:icon="Edit"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="openEdit"
|
||||||
|
>{{ filled ? '修订章程' : '制定章程' }}</el-button>
|
||||||
|
<el-button v-else :icon="Lock" disabled title="仅经营层(总经理 / 财务部)可修订章程">只读</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-skeleton v-if="loading" :rows="6" animated />
|
||||||
|
|
||||||
|
<!-- 空态:库里没有章程行 —— 不显示任何示例文本(派单红线) -->
|
||||||
|
<div v-else-if="!filled" class="charter-empty">
|
||||||
|
<el-icon :size="40"><Opportunity /></el-icon>
|
||||||
|
<h3>尚未填写战略章程</h3>
|
||||||
|
<p>本账套还没有使命 / 愿景 / 价值观记录。请由经营层录入第一条,系统不做任何预填。</p>
|
||||||
|
<el-button v-if="canEdit" type="primary" :icon="Edit" @click="openEdit">录入章程</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="charter-cards">
|
||||||
|
<section class="charter-card">
|
||||||
|
<div class="card-label"><el-icon><Aim /></el-icon>使命<span>Mission</span></div>
|
||||||
|
<p class="card-text">{{ data?.mission }}</p>
|
||||||
|
</section>
|
||||||
|
<section class="charter-card">
|
||||||
|
<div class="card-label">
|
||||||
|
<el-icon><Sunrise /></el-icon>愿景<span>Vision</span>
|
||||||
|
<el-tag size="small" effect="plain" class="year-tag">{{ data?.vision_year }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<p class="card-text">{{ data?.vision }}</p>
|
||||||
|
<div class="measurable">
|
||||||
|
<span class="measurable-k">可验证状态</span>
|
||||||
|
<span class="measurable-v">{{ data?.vision_measurable }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="charter-card charter-card--wide">
|
||||||
|
<div class="card-label"><el-icon><Star /></el-icon>价值观<span>Core Values</span></div>
|
||||||
|
<p class="card-text">{{ data?.values_text }}</p>
|
||||||
|
<ul class="value-list">
|
||||||
|
<li v-for="(item, idx) in data?.value_items || []" :key="idx">
|
||||||
|
<span class="value-name">{{ item.name }}</span>
|
||||||
|
<span class="value-meaning">{{ item.meaning }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="charter-meta">
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-k">最后修订</span>
|
||||||
|
<span class="meta-v">{{ data?.updated_by_name || '—' }} · {{ data?.updated_at || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-k">创建时间</span>
|
||||||
|
<span class="meta-v">{{ data?.created_at || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 项目铁律:业务弹窗一律 MyDialog(禁 el-dialog / ElMessageBox 确认) -->
|
||||||
|
<MyDialog v-model="dialogVisible" :title="filled ? '修订战略章程' : '制定战略章程'" :width="760">
|
||||||
|
<el-form :model="form" label-width="110px" label-position="left" class="charter-form">
|
||||||
|
<el-form-item label="使命" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.mission"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="我们为何存在:做什么、为谁做"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="愿景" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.vision"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="要成为什么"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="愿景年度" required>
|
||||||
|
<el-input v-model="form.vision_year" maxlength="4" placeholder="如 2030" class="year-input" />
|
||||||
|
<span class="form-hint">不可空;4 位年份</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="可验证状态" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.vision_measurable"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="用可核对的规模/业务口径描述,如「2030 年服务 300 家付费企业、续约率 ≥85%」"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">不可空:没有可核对口径的愿景无法验证</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="价值观总述" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.values_text"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
maxlength="2000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="不可交易的底线,一句话总述"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="价值观条目" required>
|
||||||
|
<div class="value-editor">
|
||||||
|
<div v-for="(item, idx) in form.value_items" :key="idx" class="value-row">
|
||||||
|
<el-input v-model="item.name" maxlength="50" placeholder="名称(如 客户成功)" class="value-row-name" />
|
||||||
|
<el-input v-model="item.meaning" maxlength="300" placeholder="含义:决策冲突时如何裁决" class="value-row-meaning" />
|
||||||
|
<el-button
|
||||||
|
:icon="Delete"
|
||||||
|
text
|
||||||
|
type="danger"
|
||||||
|
:disabled="form.value_items.length <= 1"
|
||||||
|
title="删除该条"
|
||||||
|
@click="form.value_items.splice(idx, 1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Plus" text type="primary" @click="addValueItem">新增条目</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false" :disabled="saving">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</MyDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Aim, Delete, Edit, Lock, Opportunity, Plus, Star, Sunrise } from '@element-plus/icons-vue'
|
||||||
|
import MyDialog from '@/components/MyDialog.vue'
|
||||||
|
import { charterApi } from '@/api'
|
||||||
|
|
||||||
|
interface ValueItem { name: string; meaning: string }
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const filled = ref(false)
|
||||||
|
const data = ref<any>(null)
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
|
||||||
|
const userRole = (() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('cma_user') || '{}').role || '' } catch { return '' }
|
||||||
|
})()
|
||||||
|
// 与后端 PUT 角色白名单保持一致(ceo / finance)
|
||||||
|
const canEdit = computed(() => ['ceo', 'finance'].includes(userRole))
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
mission: '',
|
||||||
|
vision: '',
|
||||||
|
vision_year: '',
|
||||||
|
vision_measurable: '',
|
||||||
|
values_text: '',
|
||||||
|
value_items: [{ name: '', meaning: '' }] as ValueItem[],
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await charterApi.get()
|
||||||
|
filled.value = !!res.filled
|
||||||
|
data.value = res.data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('章程加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit() {
|
||||||
|
form.mission = data.value?.mission || ''
|
||||||
|
form.vision = data.value?.vision || ''
|
||||||
|
form.vision_year = data.value?.vision_year || ''
|
||||||
|
form.vision_measurable = data.value?.vision_measurable || ''
|
||||||
|
form.values_text = data.value?.values_text || ''
|
||||||
|
const items: ValueItem[] = (data.value?.value_items || []).map((i: ValueItem) => ({
|
||||||
|
name: i.name, meaning: i.meaning,
|
||||||
|
}))
|
||||||
|
form.value_items = items.length ? items : [{ name: '', meaning: '' }]
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function addValueItem() {
|
||||||
|
form.value_items.push({ name: '', meaning: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(): string | null {
|
||||||
|
if (!form.mission.trim()) return '使命不能为空'
|
||||||
|
if (!form.vision.trim()) return '愿景不能为空'
|
||||||
|
if (!/^\d{4}$/.test(form.vision_year.trim())) return '愿景年度须为 4 位年份(如 2030)'
|
||||||
|
if (!form.vision_measurable.trim()) return '可验证状态不能为空'
|
||||||
|
if (!form.values_text.trim()) return '价值观总述不能为空'
|
||||||
|
const items = form.value_items.filter(i => i.name.trim() || i.meaning.trim())
|
||||||
|
if (!items.length) return '价值观条目至少 1 条'
|
||||||
|
if (items.some(i => !i.name.trim() || !i.meaning.trim())) return '价值观条目的名称与含义都要填'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const err = validate()
|
||||||
|
if (err) { ElMessage.warning(err); return }
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// 只提交后端字段白名单内的字段(entity_id 由登录账套决定,不手写)
|
||||||
|
const payload = {
|
||||||
|
mission: form.mission.trim(),
|
||||||
|
vision: form.vision.trim(),
|
||||||
|
vision_year: form.vision_year.trim(),
|
||||||
|
vision_measurable: form.vision_measurable.trim(),
|
||||||
|
values_text: form.values_text.trim(),
|
||||||
|
value_items: form.value_items
|
||||||
|
.filter(i => i.name.trim() && i.meaning.trim())
|
||||||
|
.map(i => ({ name: i.name.trim(), meaning: i.meaning.trim() })),
|
||||||
|
}
|
||||||
|
const body: any = await charterApi.save(payload)
|
||||||
|
ElMessage.success('战略章程已保存')
|
||||||
|
dialogVisible.value = false
|
||||||
|
filled.value = true
|
||||||
|
data.value = body.data
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '保存失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.charter-page { padding: var(--bh-space-xl, 20px); background: var(--bh-bg-main); min-height: 100%; }
|
||||||
|
.charter-head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--bh-space-lg, 16px); margin-bottom: var(--bh-space-xl, 20px); flex-wrap: wrap; }
|
||||||
|
.charter-title h2 { margin: 0 0 6px; font-size: 20px; color: var(--bh-text-primary); font-weight: 600; }
|
||||||
|
.charter-title p { margin: 0; font-size: 13px; color: var(--bh-text-secondary); }
|
||||||
|
.charter-actions { display: flex; align-items: center; gap: var(--bh-space-md, 12px); }
|
||||||
|
|
||||||
|
.charter-empty { background: var(--bh-bg-card); border: 1px dashed var(--bh-border); border-radius: var(--bh-radius-md, 8px); padding: 56px 24px; text-align: center; color: var(--bh-text-secondary); }
|
||||||
|
.charter-empty h3 { margin: 12px 0 6px; color: var(--bh-text-primary); font-size: 16px; }
|
||||||
|
.charter-empty p { margin: 0 0 18px; font-size: 13px; }
|
||||||
|
|
||||||
|
.charter-cards { display: grid; grid-template-columns: 1fr 1fr; gap: var(--bh-space-lg, 16px); }
|
||||||
|
.charter-card { background: var(--bh-bg-card); border-radius: var(--bh-radius-md, 8px); box-shadow: var(--bh-shadow-sm); padding: var(--bh-space-xl, 20px); border-left: 3px solid var(--bh-primary); }
|
||||||
|
.charter-card--wide { grid-column: 1 / -1; border-left-color: var(--bh-success); }
|
||||||
|
.card-label { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--bh-text-regular); }
|
||||||
|
.card-label span { font-weight: 400; font-size: 12px; color: var(--bh-text-placeholder); margin-left: 4px; letter-spacing: .3px; }
|
||||||
|
.year-tag { margin-left: auto; }
|
||||||
|
.card-text { margin: 12px 0 0; font-size: 15px; line-height: 1.75; color: var(--bh-text-primary); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
|
||||||
|
.measurable { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--bh-border-light); display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.measurable-k { font-size: 12px; color: var(--bh-text-secondary); }
|
||||||
|
.measurable-v { font-size: 13px; color: var(--bh-text-regular); line-height: 1.6; word-break: break-word; }
|
||||||
|
|
||||||
|
.value-list { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.value-list li { display: flex; gap: 10px; align-items: baseline; padding: 8px 12px; background: var(--bh-bg-main); border-radius: var(--bh-radius-sm, 4px); }
|
||||||
|
.value-name { flex: 0 0 110px; font-size: 13px; font-weight: 600; color: var(--bh-text-primary); }
|
||||||
|
.value-meaning { font-size: 13px; color: var(--bh-text-regular); line-height: 1.6; }
|
||||||
|
|
||||||
|
.charter-meta { margin-top: var(--bh-space-lg, 16px); background: var(--bh-bg-card); border-radius: var(--bh-radius-md, 8px); box-shadow: var(--bh-shadow-sm); padding: var(--bh-space-lg, 16px) var(--bh-space-xl, 20px); display: flex; flex-wrap: wrap; gap: var(--bh-space-xl, 20px); }
|
||||||
|
.meta-item { display: flex; flex-direction: column; gap: 4px; min-width: 160px; }
|
||||||
|
.meta-k { font-size: 12px; color: var(--bh-text-secondary); }
|
||||||
|
.meta-v { font-size: 13px; color: var(--bh-text-primary); }
|
||||||
|
|
||||||
|
.charter-form :deep(.el-form-item) { margin-bottom: 16px; }
|
||||||
|
.form-hint { margin-left: 10px; font-size: 12px; color: var(--bh-text-placeholder); }
|
||||||
|
.year-input { width: 140px; }
|
||||||
|
.value-editor { width: 100%; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.value-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.value-row-name { width: 150px; flex: 0 0 150px; }
|
||||||
|
.value-row-meaning { flex: 1; }
|
||||||
|
</style>
|
||||||
@@ -25,6 +25,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 战略章程(P 战略基座:地图最顶层输入,只读;空态不显示,禁示例文案)── -->
|
||||||
|
<div v-if="charter" class="charter-strip">
|
||||||
|
<div class="cs-item cs-mission">
|
||||||
|
<span class="cs-k">使命</span>
|
||||||
|
<span class="cs-v">{{ charter.mission }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cs-item">
|
||||||
|
<span class="cs-k">愿景 · {{ charter.vision_year }}</span>
|
||||||
|
<span class="cs-v">{{ charter.vision }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cs-item cs-measurable">
|
||||||
|
<span class="cs-k">可验证状态</span>
|
||||||
|
<span class="cs-v">{{ charter.vision_measurable }}</span>
|
||||||
|
</div>
|
||||||
|
<router-link class="cs-link" to="/strategy-charter">查看章程</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── 口径/统计条 ── -->
|
<!-- ── 口径/统计条 ── -->
|
||||||
<div class="meta-bar">
|
<div class="meta-bar">
|
||||||
<!-- 口径标识位(常驻,非 hover):与看板/KPI 列表共用同一组件与文案常量 -->
|
<!-- 口径标识位(常驻,非 hover):与看板/KPI 列表共用同一组件与文案常量 -->
|
||||||
@@ -167,6 +184,7 @@ import {
|
|||||||
Money, User, Setting, Reading, TrendCharts,
|
Money, User, Setting, Reading, TrendCharts,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { mapApi } from '../api/index'
|
import { mapApi } from '../api/index'
|
||||||
|
import { charterApi } from '../api/index'
|
||||||
import CaliberBadge from '../components/CaliberBadge.vue'
|
import CaliberBadge from '../components/CaliberBadge.vue'
|
||||||
import { caliberGapFor, currentCaliberEntityId, isCaliberKpiCode, type CaliberGapFacts } from '../constants/caliber'
|
import { caliberGapFor, currentCaliberEntityId, isCaliberKpiCode, type CaliberGapFacts } from '../constants/caliber'
|
||||||
|
|
||||||
@@ -175,6 +193,8 @@ const boardRef = ref<HTMLElement | null>(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const board = ref<any>(null)
|
const board = ref<any>(null)
|
||||||
|
// 战略章程(P 基座最顶层输入,只读;无行 → null,不显示任何文案)
|
||||||
|
const charter = ref<any>(null)
|
||||||
const period = ref<string>('')
|
const period = ref<string>('')
|
||||||
const periods = ref<string[]>([])
|
const periods = ref<string[]>([])
|
||||||
const boardSize = ref({ w: 0, h: 0 })
|
const boardSize = ref({ w: 0, h: 0 })
|
||||||
@@ -317,6 +337,10 @@ function onResize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 章程与地图并行取;章程失败不阻断地图(只读装饰项)
|
||||||
|
charterApi.get()
|
||||||
|
.then((res: any) => { if (res?.filled) charter.value = res.data })
|
||||||
|
.catch(() => { charter.value = null })
|
||||||
await load()
|
await load()
|
||||||
window.addEventListener('resize', onResize)
|
window.addEventListener('resize', onResize)
|
||||||
})
|
})
|
||||||
@@ -367,6 +391,16 @@ function doPrint() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.strategy-map-page { padding: 16px; background: #f5f7fa; min-height: 100%; }
|
.strategy-map-page { padding: 16px; background: #f5f7fa; min-height: 100%; }
|
||||||
|
|
||||||
|
/* 战略章程条(P 基座最顶层输入,只读) */
|
||||||
|
.charter-strip { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; padding: 10px 14px; margin-bottom: 10px; background: var(--bh-bg-card, #fff); border-left: 3px solid var(--bh-primary, #409eff); border-radius: var(--bh-radius-sm, 4px); box-shadow: var(--bh-shadow-sm, 0 1px 2px rgba(0,0,0,.06)); }
|
||||||
|
.cs-item { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||||
|
.cs-k { flex: 0 0 auto; font-size: 12px; color: var(--bh-text-secondary, #909399); }
|
||||||
|
.cs-v { font-size: 13px; color: var(--bh-text-primary, #303133); max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cs-mission .cs-v { font-weight: 600; }
|
||||||
|
.cs-measurable .cs-v { color: var(--bh-text-regular, #606266); }
|
||||||
|
.cs-link { margin-left: auto; font-size: 12px; color: var(--bh-primary, #409eff); text-decoration: none; }
|
||||||
|
.cs-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.page-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
|
.page-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
|
||||||
.page-subtitle { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; color: #64748B; margin: 0; }
|
.page-subtitle { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; color: #64748B; margin: 0; }
|
||||||
.title-icon { color: #64748B; font-size: 16px; }
|
.title-icon { color: #64748B; font-size: 16px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user