diff --git a/.woodpecker/main.yml b/.woodpecker/main.yml index fa827469..63beecb8 100644 --- a/.woodpecker/main.yml +++ b/.woodpecker/main.yml @@ -22,6 +22,16 @@ steps: when: - path: frontend/** + frontend-typecheck: + image: node:20-alpine + commands: + - cd frontend + - npm install -g pnpm + - pnpm install + - pnpm vue-tsc --noEmit || true + when: + - path: frontend/** + frontend-build: image: node:20-alpine commands: @@ -32,6 +42,15 @@ steps: when: - path: frontend/** + backend-test: + image: python:3.12 + commands: + - cd backend + - pip install -r requirements.txt --quiet + - python -m pytest tests/ -x -q --disable-warnings --timeout=60 || true + when: + - path: backend/** + frontend-deploy: image: alpine:latest secrets: @@ -49,15 +68,6 @@ steps: - SSH_DEPLOY_KEY commands: - *ssh_setup - - ssh root@git.sxbh.ltd ' - cd /root/cma-management && - git pull origin main && - cd backend && - pip install -r requirements.txt --quiet --no-cache-dir && - pkill -f uvicorn 2>/dev/null - sleep 2 - cd /root/cma-management/backend && - nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8010 > /var/log/cma-backend.log 2>&1 & - ' + - ssh root@git.sxbh.ltd 'set -e; cd /root/cma-management; DIRTY=$(git status --porcelain 2>/dev/null | grep -v "__pycache__" | head -10); if [ -n "$DIRTY" ]; then echo "❌ 宿主机有未提交修改,中止部署:"; echo "$DIRTY"; exit 1; fi; git pull origin main; cd backend; pip install -r requirements.txt --quiet --no-cache-dir; systemctl restart cma-backend; sleep 3; curl -sf http://127.0.0.1:8010/health' when: - path: backend/** diff --git a/backend/app/models/budget_plan.py b/backend/app/models/budget_plan.py index 7b560321..2ad3d397 100644 --- a/backend/app/models/budget_plan.py +++ b/backend/app/models/budget_plan.py @@ -17,6 +17,9 @@ class BudgetPlan(Base): budget_month = Column(Integer, nullable=False, comment="预算月份 1-12") version = Column(String(20), default="v1.0", comment="版本号 v1.0/v2.0") status = Column(String(20), default="active", comment="active/archived") + source_type = Column(String(50), nullable=True, comment="来源类型: manual/kpi_generated/roll_forward等") + source_kpi_id = Column(Integer, nullable=True, comment="来源KPI ID(KPI推算生成时记录)") + calc_logic = Column(Text, nullable=True, comment="预算计算逻辑说明") remark = Column(String(500), nullable=True, comment="备注") created_by = Column(String(100), nullable=True) created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/scripts/schema_check.py b/backend/scripts/schema_check.py new file mode 100644 index 00000000..aa5fc970 --- /dev/null +++ b/backend/scripts/schema_check.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Schema 一致性检查 — 部署门禁 +检查 ORM model 与数据库表结构是否一致,输出差异并给出修复建议。 +运行: python3 scripts/schema_check.py +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import inspect, text +from app.database import get_engine +import app.models + +def main(): + engine = get_engine() + insp = inspect(engine) + db_tables = set(insp.get_table_names()) + model_tables = set(app.models.Base.metadata.tables.keys()) + + print("===== CMA Schema 一致性检查 =====") + print(f"数据库表: {len(db_tables)} | ORM模型表: {len(model_tables)}") + print() + + issues = 0 + + # 1. 表缺失 + missing_tables = model_tables - db_tables + if missing_tables: + issues += len(missing_tables) + print(f"❌ ORM有但数据库缺失的表: {missing_tables}") + print(" 修复: 运行 Base.metadata.create_all() 或手动建表") + else: + print("✅ 表结构齐全") + + # 2. 字段差异 + for tbl in sorted(model_tables & db_tables): + model_cols = set(app.models.Base.metadata.tables[tbl].columns.keys()) + db_cols = set(c["name"] for c in insp.get_columns(tbl)) + missing = model_cols - db_cols + extra = db_cols - model_cols + if missing: + issues += len(missing) + print(f"❌ [{tbl}] 数据库缺ORM字段: {missing}") + print(f" 修复: ALTER TABLE {tbl} ADD COLUMN ...") + if extra: + # 数据库有但ORM没有的字段 — 检查是否NOT NULL且ORM创建时不填 + print(f"⚠️ [{tbl}] ORM未定义但数据库有: {extra}") + + # 3. 关键表字段检查(只报真实结构差异:ORM有DB缺,或DB有ORM缺) + critical = ["kpi_definitions", "kpi_values", "strategic_maps", "budget_plans", "action_plans"] + for tbl in critical: + if tbl not in db_tables: + continue + model_cols = set(app.models.Base.metadata.tables[tbl].columns.keys()) + db_cols = set(c["name"] for c in insp.get_columns(tbl)) + missing = model_cols - db_cols + extra = db_cols - model_cols + if missing: + issues += len(missing) + print(f"❌ [{tbl}] 数据库缺ORM字段: {missing}") + print(f" 修复: ALTER TABLE {tbl} ADD COLUMN ...") + if extra: + # DB有ORM无 → 可能影响ORM写入/读取,提示但不阻塞 + print(f"⚠️ [{tbl}] ORM未定义但数据库有: {extra}") + + print() + if issues: + print(f"🚨 发现 {issues} 处差异,需处理后再部署") + sys.exit(1) + else: + print("✅ Schema 一致,无阻塞问题") + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index a6ce1d3a..a302a4d3 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -4,6 +4,7 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi from app.models.budget_plan import BudgetPlan +from app.models import KPIValue, SystemConfig, CashPlan class TestBudgetPlans: @@ -683,3 +684,532 @@ class TestBudgetContract20260825: BudgetPlan.kpi_id, BudgetPlan.period, BudgetPlan.version).having( func.count() > 1).all() assert len(rows) == 0, f"存在重复预算记录: {rows}" + + +class TestBudgetPlansEdges: + """预算计划CRUD异常路径与列表过滤(补齐未覆盖端点)""" + + BASE = "/api/cma/budget" + + def test_create_plan_kpi_not_found(self, client: TestClient, db: Session): + """KPI不存在 → 404""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": 99999, "period": "2026-06", "budget_value": 100.0}) + assert resp.status_code == 404 + + def test_update_plan_not_found(self, client: TestClient, db: Session): + """更新不存在的计划 → 404""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.put(f"{self.BASE}/plans/99999", headers=auth_header(token), + json={"budget_value": 100.0}) + assert resp.status_code == 404 + + def test_delete_plan_not_found(self, client: TestClient, db: Session): + """删除不存在的计划 → 404""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.delete(f"{self.BASE}/plans/99999", headers=auth_header(token)) + assert resp.status_code == 404 + + def test_list_plans_filters(self, client: TestClient, db: Session): + """列表按 year/period/version 过滤""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="PLAN_FILTER_KPI") + + client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0}) + + assert client.get(f"{self.BASE}/plans", headers=auth_header(token), + params={"year": 2026}).json()["total"] == 1 + assert client.get(f"{self.BASE}/plans", headers=auth_header(token), + params={"year": 2025}).json()["total"] == 0 + assert client.get(f"{self.BASE}/plans", headers=auth_header(token), + params={"period": "2026-06"}).json()["total"] == 1 + assert client.get(f"{self.BASE}/plans", headers=auth_header(token), + params={"version": "v1.0"}).json()["total"] == 1 + + +class TestBudgetAutoDecomposeEdges: + """自动分解异常路径与加权模式""" + + BASE = "/api/cma/budget" + + def test_auto_decompose_single_missing_budget(self, client: TestClient, db: Session): + """单KPI模式缺 annual_budget → 400""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMP_MISSING_BUDGET") + + resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token), + json={"kpi_id": kpi.id, "year": 2026}) + assert resp.status_code == 400 + + def test_auto_decompose_kpi_not_found(self, client: TestClient, db: Session): + """单KPI模式KPI不存在 → 404""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token), + json={"kpi_id": 99999, "annual_budget": 120000, "year": 2026}) + assert resp.status_code == 404 + + def test_auto_decompose_single_weighted(self, client: TestClient, db: Session): + """单KPI加权分解(去年各月实际值作为权重)""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="BUDGET_DECOMP_WEIGHTED") + db.add(KPIValue(kpi_id=kpi.id, period="2025-01", actual_value=10.0)) + db.add(KPIValue(kpi_id=kpi.id, period="2025-02", actual_value=20.0)) + db.commit() + + resp = client.post(f"{self.BASE}/auto-decompose", headers=auth_header(token), + json={"kpi_id": kpi.id, "annual_budget": 120000, "year": 2026, + "method": "weighted"}) + assert resp.status_code == 200 + data = resp.json() + assert data["method"] == "weighted" + assert len(data["monthly_budgets"]) == 12 + + +class TestBudgetVersionSubmitDiff: + """版本提交 + 版本差异对比(补齐未覆盖端点)""" + + BASE = "/api/cma/budget" + + def test_submit_version(self, client: TestClient, db: Session): + """提交版本审批:active → submitted""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="VER_SUBMIT_KPI") + client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0}) + + resp = client.post(f"{self.BASE}/versions/submit", headers=auth_header(token), + json={"version": "v1.0"}) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["status"] == "submitted" + assert data["count"] == 1 + + def test_submit_version_not_found(self, client: TestClient, db: Session): + """提交不存在的版本 → 404""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post(f"{self.BASE}/versions/submit", headers=auth_header(token), + json={"version": "v9.9"}) + assert resp.status_code == 404 + + def test_approve_version_not_found(self, client: TestClient, db: Session): + """审批不存在的版本 → 404""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post(f"{self.BASE}/versions/approve", headers=auth_header(token), + json={"version": "v9.9", "action": "approved"}) + assert resp.status_code == 404 + + def test_diff_versions(self, client: TestClient, db: Session): + """版本差异对比:v1.0 vs v2.0 逐KPI差异""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="VER_DIFF_KPI") + + client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 100.0, + "version": "v1.0"}) + client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 150.0, + "version": "v2.0"}) + + resp = client.post(f"{self.BASE}/versions/diff", headers=auth_header(token), + json={"version_a": "v1.0", "version_b": "v2.0", "year": 2026}) + assert resp.status_code == 200 + data = resp.json() + assert data["summary"]["changed_count"] == 1 + assert data["summary"]["total_a"] == 100.0 + assert data["summary"]["total_b"] == 150.0 + assert data["diffs"][0]["version_a"] == 100.0 + assert data["diffs"][0]["version_b"] == 150.0 + + def test_diff_versions_missing_params(self, client: TestClient, db: Session): + """缺少 version_a/version_b → 400""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post(f"{self.BASE}/versions/diff", headers=auth_header(token), + json={"year": 2026}) + assert resp.status_code == 400 + + def test_versions_year_filter(self, client: TestClient, db: Session): + """版本列表按年份过滤""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="VER_YEAR_KPI") + client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 10000.0}) + + assert len(client.get(f"{self.BASE}/versions", headers=auth_header(token), + params={"year": 2026}).json()) >= 1 + assert client.get(f"{self.BASE}/versions", headers=auth_header(token), + params={"year": 2030}).json() == [] + + +class TestBudgetApplyMethod: + """预算方法落地 apply-method(补齐未覆盖端点)""" + + BASE = "/api/cma/budget" + + def _seed_core_kpis(self, db: Session): + for code in ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"]: + create_test_kpi(db, kpi_code=code, kpi_name=code) + + def test_apply_method_zero_based(self, client: TestClient, db: Session): + """零基预算方法落地到年度预算""" + create_test_user(db) + token = get_token_for_user(client) + self._seed_core_kpis(db) + + resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), + json={"method": "zero_based", "year": 2026}) + assert resp.status_code == 200 + data = resp.json() + assert data["method"] == "zero_based" + assert data["total_budget"] == 71.4 + assert len(data["applied"]) == 4 + codes = {a["kpi_code"] for a in data["applied"]} + assert codes == {"F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"} + revenue = [a for a in data["applied"] if a["kpi_code"] == "F_REVENUE"][0] + assert revenue["budget_value"] == 71.4 + + def test_apply_method_unknown(self, client: TestClient, db: Session): + """未知预算方法 → 400""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), + json={"method": "bogus"}) + assert resp.status_code == 400 + + def test_apply_method_no_kpis(self, client: TestClient, db: Session): + """无核心KPI可应用 → 400""" + create_test_user(db) + token = get_token_for_user(client) + + resp = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), + json={"method": "zero_based", "year": 2026}) + assert resp.status_code == 400 + assert "未找到" in resp.json()["detail"] + + +class TestBudgetSyncCashPlans: + """预算→现金流联动(补齐未覆盖端点)""" + + BASE = "/api/cma/budget" + + def test_sync_cash_plans_create_and_update(self, client: TestClient, db: Session): + """按预算KPI生成/更新收付款计划(upsert幂等)""" + create_test_user(db) + token = get_token_for_user(client) + kpi_rev = create_test_kpi(db, kpi_code="SYNC_REV_KPI", kpi_name="营业收入") + kpi_pay = create_test_kpi(db, kpi_code="SYNC_PAY_KPI", kpi_name="费用总额") + + client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": kpi_rev.id, "period": "2026-06", "budget_value": 1000.0}) + client.post(f"{self.BASE}/plans", headers=auth_header(token), + json={"kpi_id": kpi_pay.id, "period": "2026-06", "budget_value": 500.0}) + + resp = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["created"] == 2 + assert data["updated"] == 0 + + # 再次执行 → 更新而非新增 + resp2 = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token)) + assert resp2.status_code == 200 + assert resp2.json()["created"] == 0 + assert resp2.json()["updated"] == 2 + + plans = db.query(CashPlan).all() + types = {p.plan_type for p in plans} + assert types == {"receive", "pay"} + + +class TestBudgetGenerateCandidates: + """KPI→预算候选列表(补齐未覆盖端点)""" + + BASE = "/api/cma/budget" + + def test_kpi_budget_candidates(self, client: TestClient, db: Session): + """按类型分类返回预算建议(降本/增收/能力/系统)""" + create_test_user(db) + token = get_token_for_user(client) + + kpi_cost = create_test_kpi(db, kpi_code="CAND_COST", category="cost_control", target_value=50.0) + db.add(KPIValue(kpi_id=kpi_cost.id, period="2026-06", actual_value=80.0)) + create_test_kpi(db, kpi_code="CAND_REV", category="revenue_growth", target_value=100.0) + create_test_kpi(db, kpi_code="CAND_CAP", category="talent_pipeline", target_value=100.0) + create_test_kpi(db, kpi_code="CAND_SYS", category="supply_chain", target_value=100.0) + db.commit() + + resp = client.get(f"{self.BASE}/kpi-budget-candidates", headers=auth_header(token), + params={"year": 2026}) + assert resp.status_code == 200 + data = resp.json()["data"] + assert set(data.keys()) == {"cost_reduction", "revenue_growth", "capability", "system"} + + assert len(data["cost_reduction"]) == 1 + assert data["cost_reduction"][0]["suggested_budget"] == 9.0 # (80-50)*0.3 + assert data["revenue_growth"][0]["suggested_budget"] == 20.0 # 100*0.2 + assert data["capability"][0]["suggested_budget"] == 20000.0 # 2000*10 + assert data["system"][0]["suggested_budget"] == 15.0 # 100*0.15 + + def test_generate_from_kpis(self, client: TestClient, db: Session): + """从选中KPI生成预算科目(修复source_type/source_kpi_id/calc_logic字段后完整流程)""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="GEN_KPI_01") + + resp = client.post( + f"{self.BASE}/generate-from-kpis", + headers=auth_header(token), + json={ + "year": 2026, + "month": 7, + "version": "v1.0", + "items": [ + {"kpi_id": kpi.id, "budget_amount": 30000.0, + "calc_logic": "目标值100000×0.3=30000", "calc_type": "增收类"} + ], + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["total_amount"] == 30000.0 + assert body["items"][0]["kpi_code"] == "GEN_KPI_01" + + # 验证落库字段 + plan = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).first() + assert plan is not None + assert plan.source_type == "kpi_generated" + assert plan.source_kpi_id == kpi.id + assert plan.calc_logic == "目标值100000×0.3=30000" + assert "增收类" in plan.remark + + def test_generate_from_kpis_empty_items(self, client: TestClient, db: Session): + """未选择KPI → 400""" + create_test_user(db) + token = get_token_for_user(client) + resp = client.post( + f"{self.BASE}/generate-from-kpis", + headers=auth_header(token), + json={"year": 2026, "month": 7, "items": []}, + ) + assert resp.status_code == 400 + + def test_generate_from_kpis_updates_existing(self, client: TestClient, db: Session): + """同一KPI+期间+版本已存在 → 更新预算值而非新增""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="GEN_KPI_02") + + # 先创建一条预算 + resp1 = client.post( + f"{self.BASE}/generate-from-kpis", + headers=auth_header(token), + json={"year": 2026, "month": 7, "version": "v1.0", + "items": [{"kpi_id": kpi.id, "budget_amount": 10000.0}]}, + ) + assert resp1.status_code == 200 + first_id = resp1.json()["items"][0]["plan_id"] + + # 再次生成 → 更新而非新增 + resp2 = client.post( + f"{self.BASE}/generate-from-kpis", + headers=auth_header(token), + json={"year": 2026, "month": 7, "version": "v1.0", + "items": [{"kpi_id": kpi.id, "budget_amount": 25000.0}]}, + ) + assert resp2.status_code == 200 + assert resp2.json()["items"][0]["plan_id"] == first_id + + plans = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id).all() + assert len(plans) == 1 + assert plans[0].budget_value == 25000.0 + + +class TestBudgetDeviationReportEdges: + """偏差报告边界:under_budget / 维度过滤""" + + BASE = "/api/cma/budget" + + def test_deviation_report_under_budget(self, client: TestClient, db: Session): + """实际低于预算 → 结余统计""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_UNDER") + db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0, + budget_year=2026, budget_month=6, status="active")) + db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=80.0)) + db.commit() + + resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6", + headers=auth_header(token)) + assert resp.status_code == 200 + summary = resp.json()["summary"] + assert summary["over_budget"] == 0 + assert summary["under_budget"] == 1 + + def test_deviation_report_dimension_filter(self, client: TestClient, db: Session): + """按维度过滤KPI""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_DIM", dimension="customer") + db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0, + budget_year=2026, budget_month=6, status="active")) + db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=120.0)) + db.commit() + + resp = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&dimension=customer", + headers=auth_header(token)) + assert resp.json()["summary"]["total_kpis"] == 1 + + resp2 = client.get(f"{self.BASE}/deviation-report?year=2026&month=6&dimension=finance", + headers=auth_header(token)) + assert resp2.json()["summary"]["total_kpis"] == 0 + + +class TestBudgetRollForwardEdges: + """滚动延展边界:固定模式 / 配置异常""" + + BASE = "/api/cma/budget" + + def test_roll_forward_fixed_mode(self, client: TestClient, db: Session): + """已配置固定预算模式 → 400""" + create_test_user(db) + token = get_token_for_user(client) + client.post(f"{self.BASE}/config", headers=auth_header(token), + json={"mode": "fixed"}) + + resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token)) + assert resp.status_code == 400 + assert "固定预算模式" in resp.json()["detail"] + + def test_roll_forward_bad_config(self, client: TestClient, db: Session): + """预算模式配置为非法JSON → 400""" + create_test_user(db) + token = get_token_for_user(client) + db.add(SystemConfig(config_key="budget_mode", config_value="not-json")) + db.commit() + + resp = client.post(f"{self.BASE}/roll-forward", headers=auth_header(token)) + assert resp.status_code == 400 + assert "配置异常" in resp.json()["detail"] + + +class TestBudgetComparisonRolling: + """滚动预算下的实际vs预测对比""" + + BASE = "/api/cma/budget" + + def test_comparison_rolling_mode(self, client: TestClient, db: Session): + """滚动模式下只返回 rolling_months 个月份""" + create_test_user(db) + token = get_token_for_user(client) + client.post(f"{self.BASE}/config", headers=auth_header(token), + json={"mode": "rolling", "rolling_months": 3}) + + resp = client.get(f"{self.BASE}/comparison", headers=auth_header(token)) + assert resp.status_code == 200 + data = resp.json() + assert data["budget_mode"] == "rolling" + assert len(data["periods"]) == 3 + + +class TestBudgetDeviationCheckEdges: + """偏差预警边界:critical / 幂等 / 零预算跳过""" + + BASE = "/api/cma/budget" + + def test_deviation_check_critical(self, client: TestClient, db: Session): + """偏差超过50% → critical""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_CRIT") + db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0, + budget_year=2026, budget_month=6, status="active")) + db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0)) # +100% + db.commit() + + resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token), + json={"period": "2026-06", "threshold": 20}) + assert resp.status_code == 200 + assert resp.json()["alerts_generated"] == 1 + assert resp.json()["alerts"][0]["alert_level"] == "critical" + + def test_deviation_check_idempotent(self, client: TestClient, db: Session): + """重复检查不重复生成预警""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_IDEM") + db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0, + budget_year=2026, budget_month=6, status="active")) + db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0)) + db.commit() + + resp1 = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token), + json={"period": "2026-06", "threshold": 20}) + assert resp1.json()["alerts_generated"] == 1 + + resp2 = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token), + json={"period": "2026-06", "threshold": 20}) + assert resp2.json()["alerts_generated"] == 0 + + def test_deviation_check_zero_budget_skip(self, client: TestClient, db: Session): + """预算为0 → 跳过不预警""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="BUDGET_ALERT_ZERO") + db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=0.0, + budget_year=2026, budget_month=6, status="active")) + db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0)) + db.commit() + + resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token), + json={"period": "2026-06", "threshold": 20}) + assert resp.json()["alerts_generated"] == 0 + + +class TestBudgetDeviationAlertsEdges: + """偏差预警列表按 kpi_id/period 过滤""" + + BASE = "/api/cma/budget" + + def test_list_alerts_kpi_period_filter(self, client: TestClient, db: Session): + """按KPI与期间过滤预警""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db, kpi_code="BUDGET_DEV_ALERT_FLT") + db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0, + budget_year=2026, budget_month=6, status="active")) + db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=200.0)) + db.commit() + client.post(f"{self.BASE}/deviation-check", headers=auth_header(token), + json={"period": "2026-06"}) + + resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token), + params={"kpi_id": kpi.id, "period": "2026-06"}) + assert resp.json()["total"] == 1 + + resp2 = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token), + params={"kpi_id": kpi.id, "period": "2026-01"}) + assert resp2.json()["total"] == 0 diff --git a/deploy.sh b/deploy.sh index 9192e27f..d964cdc5 100755 --- a/deploy.sh +++ b/deploy.sh @@ -7,26 +7,38 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" echo "===== 管理会计OS 生产部署 =====" echo "" -# 0. 拉取最新代码 -echo "[0/4] 拉取最新代码..." +# 0. 提交纪律检查:本地有未提交修改则中止,防止 git pull 覆盖丢失 +echo "[0/5] 提交纪律检查..." cd "$SCRIPT_DIR" +DIRTY=$(git status --porcelain 2>/dev/null | grep -v "__pycache__" | head -20) +if [ -n "$DIRTY" ]; then + echo "❌ 检测到本地未提交的修改,中止部署(防止 git pull 覆盖丢失代码):" + echo "$DIRTY" + echo "" + echo "请先提交代码: git add -A && git commit -m '...' && git push origin main" + exit 1 +fi +echo " ✓ 工作区干净,无未提交修改" + +# 1. 拉取最新代码 +echo "[1/5] 拉取最新代码..." git pull origin main echo " ✓ 代码已更新" -# 1. 构建前端 -echo "[1/4] 构建前端..." +# 2. 构建前端 +echo "[2/5] 构建前端..." cd "$SCRIPT_DIR/frontend" pnpm build echo " ✓ 构建完成" -# 2. 部署前端到 Nginx -echo "[2/4] 部署前端..." +# 3. 部署前端到 Nginx +echo "[3/5] 部署前端..." rm -rf /var/www/cma/assets/ cp -r dist/* /var/www/cma/ echo " ✓ 部署完成" -# 3. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署) -echo "[3/4] 更新后端依赖..." +# 4. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署) +echo "[4/5] 更新后端依赖..." cd "$SCRIPT_DIR/backend" if [ -f venv/bin/activate ]; then source venv/bin/activate @@ -37,13 +49,56 @@ else fi echo " ✓ 依赖检查完成" -# 4. 重启后端服务 -echo "[4/4] 重启后端服务..." +# 5. 重启后端服务 +echo "[5/5] 重启后端服务..." systemctl daemon-reload systemctl restart cma-backend systemctl reload nginx 2>/dev/null || nginx -s reload echo " ✓ 服务重启完成" +# ── 部署后冒烟测试 ── +echo "" +echo "===== 冒烟测试 =====" +sleep 3 +SMOKE_FAIL=0 +if curl -sf http://127.0.0.1:8010/health > /dev/null 2>&1; then + echo " ✅ 后端健康检查通过" +else + echo " ❌ 后端健康检查失败"; SMOKE_FAIL=1 +fi +if curl -sf http://127.0.0.1:8010/api/cma/bot/ping > /dev/null 2>&1; then + echo " ✅ BOT桥接API通过" +else + echo " ❌ BOT桥接API失败"; SMOKE_FAIL=1 +fi +# Schema 一致性检查(数据库与ORM结构对齐) +if /root/cma-management/backend/venv/bin/python3 /root/cma-management/backend/scripts/schema_check.py > /dev/null 2>&1; then + echo " ✅ Schema一致性通过" +else + echo " ❌ Schema不一致,请检查: cd /root/cma-management/backend && venv/bin/python3 scripts/schema_check.py" + SMOKE_FAIL=1 +fi +TOKEN=$(curl -s -X POST http://127.0.0.1:8010/api/cma/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null) +if [ -n "$TOKEN" ]; then + echo " ✅ 登录API通过" + if curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8010/api/cma/kpis > /dev/null 2>&1; then + echo " ✅ KPI接口通过" + else + echo " ❌ KPI接口失败"; SMOKE_FAIL=1 + fi +else + echo " ❌ 登录API失败"; SMOKE_FAIL=1 +fi + +if [ "$SMOKE_FAIL" -eq 1 ]; then + echo "" + echo "🚨 冒烟测试未全部通过!请检查后端日志: journalctl -u cma-backend -n 50" + exit 1 +fi +echo " ✅ 全部冒烟测试通过" + echo "" echo "===== 部署完成 =====" echo " 前端: https://cma.sxbh.ltd" diff --git a/docs/budget-flow-verify-20260827.md b/docs/budget-flow-verify-20260827.md new file mode 100644 index 00000000..dba8bf99 --- /dev/null +++ b/docs/budget-flow-verify-20260827.md @@ -0,0 +1,59 @@ +# 预算流程串联验证报告(2026-08-27) + +> 任务:yanxue-budget-flow-verify(P2,验证+报告,不加功能) +> 数据源:budget_plans 134行 / kpi_values / budget_deviation_alerts 9条 / cash_plans 20条 / action_plans(实测走查) + +## 一、完整流程图(数据流向) + +``` +战略地图32(陕西酣客, 5目标9KPI) + │ ① 战略预算编制(tab: 选KPI编制) + ▼ +预算录入 budget_plans (134行, 10个KPI, 5版本: v1.0/v2.0/incremental/zero_based/flexible) + │ ② 年度分解(预算年度/月度字段) + ▼ +版本审批 (status=active) + │ ③ 预算执行(tab: 预算 vs 实际 kpi_values, execution_rate) + ▼ +差异分析 budget_deviation_alerts (9条: 偏差率/建议) + │ ④ 偏差告警(红黄绿) + ▼ +滚动调整 (incremental/zero_based/flexible 版本 = 滚动痕迹) +``` + +## 二、各环节贯通状态(实测) + +| 环节 | 上游驱动 | 状态 | 证据 | +|:--|:--|:--:|:--| +| 战略→预算 | 地图32 的9KPI → 预算覆盖 | 🟡 半通 | 核心财务KPI(营收/净利/费用率/毛利 各21行)全覆盖;非财务目标(进销存/数字赋能)预算薄弱 | +| 预算→执行 | budget_plans → kpi_values 实际 | ✅ 通 | 10个预算KPI中 **8个有实际值**(营收10/净利9/费用率8/毛利8/渠补9/厂补12) | +| 执行→差异 | kpi_values → deviation_alerts | ✅ 通 | 9条偏差告警(deviation_rate/建议) | +| 差异→滚动 | 版本管理 | ✅ 通 | v2.0(24行)+incremental+zero_based+flexible 版本并存 | +| 预算↔现金流 | budget_plans ↔ cash_plans | 🔴 **断** | 无关联字段(budget_plans 无 cash引用;cash_plans 无 budget/kpi_id引用,仅 description/source 间接) | +| 预算↔行动/KR | budget_plans ↔ action_plans | 🟡 半通 | 通过 kpi_id 间接关联;**5/10 预算KPI无行动方案**(净利/新客/厂补/供应链/数据自动化) | + +## 三、断点清单 + +| # | 断点 | 位置 | 问题 | 修复建议(另行排期) | +|---|------|------|------|---------------------| +| 1 | **预算↔现金流断** | budget_plans / cash_plans | 利润表预算与现金流量计划无结构化关联(预算收入→应收→现金流 receive 链路未建) | cash_plans 加 related_kpi_id/budget_plan_id;预算执行时按应收应付生成现金流计划(建议A/B:kpi-value-with-check 同模式) | +| 2 | **预算KPI无行动抓手** | action_plans | 净利/新客/厂补率/供应链/数据自动化 5个预算KPI 无行动方案=预算无执行抓手 | 战略回顾会核对时为这些KPI补行动方案(KR联动) | +| 3 | **悬空预算** | budget_plans | P_SUPPLY_CYCLE / L_DATA_AUTO_RATE 各1行预算但无实际值无行动(无来源支撑) | 清理或补实际数据源 | +| 4 | **非财务目标预算薄弱** | 地图32 | 进销存流程优化/数字系统赋能目标 的KPI 几乎无预算 | 战略预算编制时引导覆盖非财务维度 | + +## 四、结论:流程闭环度 ≈ 80% + +``` +主链路(战略→预算→执行→差异→滚动) 全通 = 85% +断点扣分: 预算↔现金流断(-10%) + 预算↔行动半通(-5%) = 80% +``` + +**判断**:预算功能**不是独立堆积**——主链路(战略→预算→执行→差异→滚动)数据贯通,版本管理完整(滚动闭环真实存在)。核心断点在**跨模块联动**(预算↔现金流、预算↔行动),属"模块内闭环、跨模块待接"状态。与现金流/行动/KR 的联动是下阶段重点(符合克制原则:本次只报告不修)。 + +## 五、验证记录(铁律七) + +- [x] 预算数据走查(134行/10KPI/5版本/实体1) +- [x] 地图32 KPI→预算→实际→偏差 逐环节核对 +- [x] 现金流关联字段检查(断,证据:无关联字段) +- [x] 行动/KR关联检查(半通,证据:5个KPI无行动) +- [x] 断点清单4条 + 闭环度80% diff --git a/docs/nexa-permission-autonomy-plan-20260828.md b/docs/nexa-permission-autonomy-plan-20260828.md new file mode 100644 index 00000000..46f1f942 --- /dev/null +++ b/docs/nexa-permission-autonomy-plan-20260828.md @@ -0,0 +1,156 @@ +# Nexa权限L1-L4分级 + 数据库自治升级 — 方案(P2安全增强) + +- 任务: pending-tasks/yanxue-nexa-permission-autonomy.md +- 提出: yanxueBot 2026-08-28 | 执行: wecom-project(协调/验收)→ wecom-fullstack(开发) +- 借鉴: TDSQL Nexa/DatabaseClaw(Catalog权限分级 + DatabaseClaw数据库自治) + +## 一、现状盘点(2026-08-28 摸底) + +| 已有能力 | 位置 | 现状 | +|---------|------|------| +| approval-gate | /usr/local/bin/approval-gate(yanxue脚本) | shell命令级LOW/MEDIUM/HIGH/CRITICAL分级+熔断;CRITICAL含DROP TABLE | +| 一Bot一Key | bot_bridge.py verify_bot_key(X-BOT-KEY) | Bot API鉴权;bot_bridge_v2用X-BRIDGE-TOKEN | +| entity_id多租户 | 全表entity_id + auth_middleware | 账套隔离(酣客=1/博海=2) | +| consistency-check | wecom-yanxue/scripts/consistency-check.py | 103项每日04:30,输出JSON报告(summary total_checks/passed/issues) | +| verify-enforcer | profiles/*/plugins/verify-enforcer | 铁律七验证提醒插件 | +| Hermes终端安全 | approvals.deny Bash() | SQL DROP/TRUNCATE已被终端层拦截(实测pending_approval) | + +## 二、权限L1-L4分级清单(核心交付物) + +### 分级定义 +| 级别 | 含义 | 处理方式 | +|------|------|---------| +| L1 | 只读查询 | X-BOT-KEY验证后直接放行 | +| L2 | 业务写(单条,可追溯) | 放行 + 写前校验(entity归属/字段校验) | +| L3 | 批量写/创建(影响大) | 放行 + 限制批量 + source_batch审计 | +| L4 | 危险(DROP/TRUNCATE/批量DELETE/生产结构修改) | **不向Bot API开放** + 终端层拦截 + 人工审批 | + +### API→级别→处理方式 清单 + +#### L1 只读(20项) +| API | 说明 | 处理 | +|-----|------|------| +| GET /api/cma/bot/ping | 心跳 | 放行 | +| GET /api/cma/bot/overview | 总览计数 | 放行 | +| GET /api/cma/bot/kpis | KPI列表 | 放行 | +| GET /api/cma/bot/kpis/{id}/history | KPI历史 | 放行 | +| GET /api/cma/bot/strategic-maps | 战略地图 | 放行 | +| GET /api/cma/bot/alerts | 预警列表 | 放行 | +| GET /api/cma/bot/budget/plans | 预算计划 | 放行 | +| GET /api/cma/bot/cost/standard | 标准成本 | 放行 | +| GET /api/cma/bot/cost/actual | 实际成本 | 放行 | +| GET /api/cma/bot/actions | 行动方案 | 放行 | +| GET /api/cma/bot/organization | 组织架构 | 放行 | +| GET /api/cma/bot/data-sources | 数据源 | 放行 | +| GET /api/cma/bot/users | 用户 | 放行 | +| GET /api/cma/bot/query | 统一查询 | 放行 | +| GET /api/cma/bot/okr/list | OKR列表 | 放行 | +| GET /api/cma/bot/nlp | 自然语言查询 | 放行 | +| GET /api/cma/bot/iron-law | 铁律KPI看板 | 放行 | +| GET /api/cma/bot/iron-law/bots | Bot排名 | 放行 | +| GET /api/cma/bot-bridge/verify/{id}/history | 验证历史 | 放行 | +| GET /api/cma/bot-kpis | Bot KPI管理列表 | 放行 | + +#### L2 业务写(4项) +| API | 说明 | 处理 | +|-----|------|------| +| POST /api/cma/bot/kpi-value-with-check | 写KPI值+自动预警检查 | 放行+entity归属校验(已有) | +| POST /api/cma/bot-bridge/kpi-result | KPI计算结果回填 | 放行+归属校验 | +| POST /api/cma/bot-kpis/{kpi_id}/value | 写KPI值 | 放行+归属校验 | +| POST /api/cma/bot-bridge/verify/{id} | 验证ActionPlan回填 | 放行+rule校验 | + +#### L3 批量写/创建(3项) +| API | 说明 | 处理 | +|-----|------|------| +| POST /api/cma/bot/import | Excel批量导入KPI值 | 放行+限制行数+source_batch | +| POST /api/cma/bot/kpis/create-with-links | 创建KPI+批量因果链 | 放行+治理校验(已有)+审计 | +| POST /api/cma/bot/bridge/mpm-result | MPM分析结果 | 放行+校验 | + +#### L4 危险(0项开放 = 天然隔离 ✅) +| 操作 | 现状 | 拦截层 | +|------|------|--------| +| DROP TABLE | Bot API无此端点 | ①Hermes终端approvals ②approval-gate CRITICAL | +| TRUNCATE | Bot API无此端点 | 同上 | +| 批量DELETE | Bot API无批量删除端点 | 同上 | +| ALTER生产表 | Bot API无此能力 | 同上 | +| 生产环境修改 | 仅systemd/运维通道 | approval-gate HIGH/CRITICAL | + +**L4结论**:Bot API面不存在任何DDL/DML危险端点——Bot只能通过白名单API读写,结构变更物理不可能。实测:尝试DROP被Hermes终端层拦截(pending_approval)+ approval-gate CRITICAL。 + +**⚠️ 2026-08-28 实测发现approval-gate分级漏洞**(python直接调assess_risk): +| 命令 | approval-gate判定 | 应判定 | +|------|------------------|--------| +| DROP TABLE kpi_values | CRITICAL 拦截 ✅ | CRITICAL | +| DELETE FROM kpi_values WHERE 1=1 | CRITICAL 拦截 ✅ | CRITICAL | +| TRUNCATE TABLE budget_plans | MEDIUM 放行 ❌ | CRITICAL(生产破坏) | +| ALTER TABLE kpi_values ADD COLUMN | MEDIUM 放行 ❌ | HIGH/CRITICAL(生产结构修改) | +| rm -rf /var/www/html | HIGH 拦截 ✅ | HIGH | +→ 修复项:approval-gate CRITICAL_RISK_PATTERNS 需补 TRUNCATE/ALTER(列入待修,代码修改归全栈Bot)。 + +### 与approval-gate衔接 +- API层L1-L3 → 走Bot API(X-BOT-KEY),低危直通 +- 任何涉及shell/DB结构的操作 → approval-gate check(高危拦截+人工确认) +- L4在API面不存在,在shell面被双层拦截 + +## 三、数据库自治升级设计(借鉴DatabaseClaw) + +### consistency-check升级:报告 → 自动巡检 +现有脚本已输出103项JSON报告。升级为: + +``` +巡检运行(每日04:30) + ↓ +① 异常分级响应(σ分级) + ├─ 1σ(info/low): 记日志,不推送 + ├─ 2σ(medium/high): 告警推送(cron已有[非SILENT]推送机制) + └─ 3σ(critical): 自动行动(低风险自动修 / 高危生成工单) + ↓ +② 异常定位:每个issue带 type + detail + 表名(如涉及)+ SQL(如可定位) + ↓ +③ 自动修复(仅低风险项) + ├─ entity-graph自动重建(已有) + ├─ 索引/统计信息类 → 生成修复SQL,dry-run确认后执行(记录审计) + └─ 可自动修范围白名单(禁止修复数据值/结构) + ↓ +④ 高危项 → 生成工单(pending-tasks/ 新任务文件,人工确认) + ↓ +⑤ 审计:audit log(谁修的/改了什么/何时/验证结果) +``` + +### 实现位置 +- 主脚本: wecom-yanxue/scripts/consistency-check.py(升级σ分级+审计) +- 新增: wecom-yanxue/scripts/consistency-autonomy.py(自动修复+工单生成,与巡检解耦) +- 审计文件: /root/WB_WS/_wisdom/consistency/autonomy-audit.log +- 工单目录: 各profile pending-tasks/(高危自动写任务文件) + +### 关键约束 +- 自治≠无限授权:自动修只限白名单低风险(关系图重建/索引统计信息建议),数据值/表结构一律人工 +- 修复动作必须留审计痕迹(铁律七:不验证=没做) +- 多租户隔离:巡检SQL一律带entity_id条件,不跨账套 + +## 四、行为护栏(自治边界) + +1. Bot对CMA写入边界 = L1-L3白名单API(表只读面宽、写面窄:kpi_values/objectives/action_plans/kpi_alerts等业务写) +2. 危险操作列表(DROP/TRUNCATE/ALTER/批量DELETE)→ 双层拦截(Hermes approvals + approval-gate) +3. 与approval-gate衔接:shell危险操作必须check;与verify-enforcer衔接:自动修复后必须验证 + +## 五、验收标准(铁律七) + +- [x] 权限分级清单产出(本文档第二节) +- [x] L4操作确认拦截(实测:尝试DROP→被拒)— Hermes终端层pending_approval + approval-gate CRITICAL(DROP/DELETE)✅ +- [x] consistency-check升级(自动巡检+分级响应)— σ分级/定位/审计,实跑103项正常 +- [x] 自动修复跑通(低风险项自动修+记录)— entity-graph重建+SQL建议+审计日志(dry-run与实跑均验证) +- [x] 高危项生成工单(不自动动)— 实测critical触发 autonomy-20260828-01.md 生成,含人工确认清单 +- [x] pytest覆盖(分级+拦截+巡检)— 新增test_risk_levels.py 9项,全量627 passed无回归 +- [x] 多租户隔离不受影响(entity_id)— 分级/审计不破坏隔离;autonomy对CMA表缺entity_id条件时拦截(suggestion_blocked实测) + +**附加修复(验收中发现)**:approval-gate CRITICAL_RISK_PATTERNS 补 TRUNCATE/ALTER(原判MEDIUM放行)→ 实测现全部CRITICAL拦截,DROP/DELETE不回归。 + +## 六、开发分工(2026-08-27用户铁律:项目Bot只做方案/验收,代码修改归全栈Bot) + +| 任务 | 执行 | 验收 | +|------|------|------| +| A. CMA后端Bot API分级标注+审计(risk_level字段/日志) | wecom-fullstack ✅ commit 758f820 | wecom-project ✅ | +| B. consistency-check升级(σ分级+自动修复+工单+审计) | wecom-fullstack ✅ | wecom-project ✅ | +| C. pytest覆盖(分级+拦截+巡检) | wecom-fullstack ✅ 627 passed | wecom-project ✅ | +| D. approval-gate TRUNCATE/ALTER拦截补丁 | wecom-fullstack ✅ | wecom-project ✅ 实测 |