Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e84719f76e | ||
|
|
63a2bac424 | ||
|
|
b3f31f9ac9 | ||
|
|
986dec516e | ||
|
|
4cad2b73c4 | ||
|
|
1efa6b7df9 | ||
|
|
9bbe3001bf | ||
|
|
adcd118498 | ||
|
|
0a6525edab | ||
|
|
7022a05928 | ||
|
|
a7a663f6cd | ||
|
|
71867c4c7a | ||
|
|
790be2d6b1 |
@@ -2,7 +2,7 @@
|
||||
预警规则智能化 — 任务6
|
||||
后端组件: alert_rules 模型 + API + 预警引擎
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, Column, Integer, String, Text, Float, DateTime, JSON, Boolean, func
|
||||
from typing import Optional, List
|
||||
@@ -26,6 +26,8 @@ from app.utils.cash_thresholds import (
|
||||
CASH_GAP_RULE_TYPE,
|
||||
validate_params as validate_cash_gap_params,
|
||||
)
|
||||
# 应收账龄逾期预警(T1 · 2026-10-20):计算/落库单源在服务层,阈值全走 alert_rules.params
|
||||
from app.services import ar_overdue_alert
|
||||
|
||||
logger = logging.getLogger("alert_rules")
|
||||
|
||||
@@ -38,6 +40,7 @@ VALID_RULE_TYPES = (
|
||||
"forecast_deviation", # 预测偏差(升级2b 2026-08-25)
|
||||
"customer_concentration", # 客户集中度(P2 2026-09-10)
|
||||
"cash_gap_threshold", # 资金缺口阈值配置(G1 2026-10-17,配置行,不进预警生成链)
|
||||
"ar_overdue", # 应收账龄逾期(T1 2026-10-20:90天以上占比 / 逐户逾期额 / 逾期户数环比)
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
@@ -142,6 +145,11 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
||||
err = validate_cash_gap_params(data.get("params"))
|
||||
if err:
|
||||
raise HTTPException(400, err)
|
||||
# T1(2026-10-20):ar_overdue 参数只校结构(阈值/责任人全量由 DB 行给,代码不设默认值)
|
||||
if rule_type == ar_overdue_alert.RULE_TYPE:
|
||||
err = ar_overdue_alert.validate_params(data.get("params"))
|
||||
if err:
|
||||
raise HTTPException(400, err)
|
||||
|
||||
rule = AlertRule(
|
||||
entity_id=entity_id,
|
||||
@@ -180,6 +188,12 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), e
|
||||
if err:
|
||||
db.rollback()
|
||||
raise HTTPException(400, err)
|
||||
# T1(2026-10-20):ar_overdue 改参同样只校结构(阈值可现场改,无需发版)
|
||||
if rule.rule_type == ar_overdue_alert.RULE_TYPE:
|
||||
err = ar_overdue_alert.validate_params(rule.params)
|
||||
if err:
|
||||
db.rollback()
|
||||
raise HTTPException(400, err)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||
@@ -307,6 +321,31 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
# 不进 KPIValue 比价链(否则未来若绑定到有值的 KPI 会误生成预警)。
|
||||
if rule.rule_type == CASH_GAP_RULE_TYPE:
|
||||
continue
|
||||
# T1(2026-10-20):应收账龄逾期 —— 与 POST /run-ar-overdue 同一计算函数,
|
||||
# 不走 KPIValue 比价链(F_AR_002 无本期 kpi_values 也不得报错)。
|
||||
if rule.rule_type == ar_overdue_alert.RULE_TYPE:
|
||||
rp = rule.params
|
||||
if isinstance(rp, str):
|
||||
rp = json.loads(rp) if rp else {}
|
||||
rp = rp or {}
|
||||
try:
|
||||
ar_overdue_alert.ensure_assignee_configured(rp)
|
||||
res = ar_overdue_alert.compute(db, entity_id, None, rp)
|
||||
except (ar_overdue_alert.AssigneeMissing, ar_overdue_alert.ParamsInvalid) as exc:
|
||||
logger.warning("ar_overdue 规则跳过(rule_id=%s): %s", rule.id, exc)
|
||||
continue
|
||||
if res.get("skipped_reason"):
|
||||
logger.info("ar_overdue 规则无数据跳过(rule_id=%s): %s",
|
||||
rule.id, res["skipped_reason"])
|
||||
continue
|
||||
try:
|
||||
w = ar_overdue_alert.write_alerts(db, entity_id, rule.kpi_id, res)
|
||||
except Exception: # noqa: BLE001 — 单规则失败不拖垮整轮检查
|
||||
db.rollback()
|
||||
logger.exception("ar_overdue 落库失败(rule_id=%s)", rule.id)
|
||||
continue
|
||||
alerts_generated += w["inserted"]
|
||||
continue
|
||||
kpi = kpi_cache.get(rule.kpi_id)
|
||||
if kpi is None:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||
@@ -316,7 +355,7 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
||||
if not kpi or kpi.entity_id != entity_id:
|
||||
continue
|
||||
|
||||
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
|
||||
# 客户集中度规则(P2 2026-09-10):不依赖同期KPIValue,直接看客户收入占比
|
||||
if rule.rule_type == "customer_concentration":
|
||||
@@ -655,7 +694,7 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
|
||||
continue
|
||||
|
||||
# 检查预测值是否超限
|
||||
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
|
||||
params["kpi"] = kpi
|
||||
for forecast in latest_forecasts:
|
||||
value = forecast.predicted_cash
|
||||
@@ -979,3 +1018,136 @@ def run_customer_concentration_check(
|
||||
continue
|
||||
db.commit()
|
||||
return {"message": f"客户集中度检查完成: {generated}条", "generated": generated}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# T1(2026-10-20):应收账龄逾期预警 —— 运行入口 + 手工触发
|
||||
# ============================================================
|
||||
def _resolve_ar_kpi(db: Session, entity_id: int, params: dict, rule, override_kpi_id):
|
||||
"""解析 ar_overdue 绑定的 KPI:规则行 → 显式 override(仅 dry-run)→ params.kpi_code。"""
|
||||
if rule is not None and rule.kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == rule.kpi_id, KPIDefinition.entity_id == entity_id).first()
|
||||
return (kpi.id if kpi else rule.kpi_id), (kpi.kpi_code if kpi else None), "alert_rules.kpi_id"
|
||||
if override_kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == override_kpi_id, KPIDefinition.entity_id == entity_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(400, f"KPI 不存在或不属于当前账套: kpi_id={override_kpi_id}")
|
||||
return kpi.id, kpi.kpi_code, "query.kpi_id(dry-run override)"
|
||||
code = (params or {}).get("kpi_code")
|
||||
if code:
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
|
||||
if kpi:
|
||||
return kpi.id, kpi.kpi_code, f"params.kpi_code={code}"
|
||||
return None, code, f"params.kpi_code={code}(库内不存在)"
|
||||
return None, None, "unresolved"
|
||||
|
||||
|
||||
@router.post("/run-ar-overdue")
|
||||
def run_ar_overdue_check(
|
||||
period: Optional[str] = Query(None, description="账龄基准期 YYYY-MM;缺省=表内 MAX(period)"),
|
||||
dry_run: bool = Query(True, description="默认 true:只算不写。false 走既有写通道插入 kpi_alerts"),
|
||||
kpi_id: Optional[int] = Query(None, description="dry-run 专用:临时绑定 KPI(正式以规则行 kpi_id 为准)"),
|
||||
data: Optional[dict] = Body(None, description="dry-run 专用:{'params': {...}} 覆盖规则参数(证明参数化,不碰 DB)"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""应收账龄逾期预警运行(T1)。
|
||||
|
||||
参数优先级:dry-run body 覆盖 > `alert_rules` 行(rule_type='ar_overdue')> 无规则 ⇒ 200 skipped。
|
||||
阈值/责任人/排除名单全部来自参数;`assignee` 缺失 ⇒ 400(fail-closed)。
|
||||
`dry_run=false` 时禁止任何参数覆盖(防绕过 DB 配置),且只 INSERT 不 UPDATE/DELETE。
|
||||
"""
|
||||
if is_excluded_entity(db, entity_id):
|
||||
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
|
||||
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
|
||||
|
||||
override_params = None
|
||||
if isinstance(data, dict):
|
||||
override_params = data.get("params") if isinstance(data.get("params"), dict) else data.get("params_override")
|
||||
if (override_params is not None or kpi_id is not None) and not dry_run:
|
||||
raise HTTPException(
|
||||
400, "dry_run=false 时禁止参数/KPI 覆盖:阈值与绑定必须来自 alert_rules 行(防绕过 DB 配置)")
|
||||
|
||||
rule = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.rule_type == ar_overdue_alert.RULE_TYPE,
|
||||
AlertRule.enabled == 1,
|
||||
).order_by(AlertRule.id).first()
|
||||
|
||||
params_source = "dry_run.body.params"
|
||||
if override_params is not None:
|
||||
params = override_params
|
||||
elif rule is not None:
|
||||
params = rule.params
|
||||
if isinstance(params, str):
|
||||
params = json.loads(params) if params else {}
|
||||
params = params or {}
|
||||
params_source = f"alert_rules#{rule.id}"
|
||||
else:
|
||||
return {
|
||||
"dry_run": dry_run, "entity_id": entity_id, "period": period,
|
||||
"skipped": True, "skipped_reason": "no_ar_overdue_rule",
|
||||
"hint": "无 rule_type='ar_overdue' 规则行;阶段B 用 backend/scripts/seed_ar_overdue_rule.py 播种,"
|
||||
"或 dry-run 时在 body 传 {'params': {...}} 演练",
|
||||
}
|
||||
|
||||
err = ar_overdue_alert.validate_params(params)
|
||||
if err:
|
||||
raise HTTPException(400, err)
|
||||
try:
|
||||
ar_overdue_alert.ensure_assignee_configured(params)
|
||||
except ar_overdue_alert.AssigneeMissing as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
resolved_kpi_id, kpi_code, binding = _resolve_ar_kpi(db, entity_id, params, rule, kpi_id)
|
||||
|
||||
try:
|
||||
result = ar_overdue_alert.compute(db, entity_id, period, params)
|
||||
except ar_overdue_alert.ParamsInvalid as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
hits = ar_overdue_alert.hits(result)
|
||||
blockers = []
|
||||
if resolved_kpi_id is None:
|
||||
blockers.append("kpi_id_unresolved:%s" % (kpi_code or "-"))
|
||||
|
||||
payload = {
|
||||
"dry_run": dry_run,
|
||||
"entity_id": entity_id,
|
||||
"period": result.get("period"),
|
||||
"caliber": result.get("caliber"),
|
||||
"params_source": params_source,
|
||||
"kpi_id": resolved_kpi_id,
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_binding": binding,
|
||||
"skipped_reason": result.get("skipped_reason"),
|
||||
"metrics": result.get("metrics"),
|
||||
"aggregates": result.get("aggregates"),
|
||||
"per_customer": result.get("per_customer"),
|
||||
"skipped": result.get("skipped"),
|
||||
"hit_count": len(hits),
|
||||
"notes": result.get("notes"),
|
||||
}
|
||||
if result.get("skipped_reason"):
|
||||
payload.update({"would_insert": 0, "write_blockers": blockers})
|
||||
return payload
|
||||
|
||||
if dry_run:
|
||||
payload.update({"would_insert": 0 if blockers else ar_overdue_alert.count_would_insert(
|
||||
db, entity_id, resolved_kpi_id, result), "write_blockers": blockers})
|
||||
return payload
|
||||
|
||||
if blockers:
|
||||
raise HTTPException(400, "无法落库:" + "; ".join(blockers))
|
||||
written = ar_overdue_alert.write_alerts(db, entity_id, resolved_kpi_id, result)
|
||||
log_op(db, "run_ar_overdue_alert", target_type="alert", target_id=resolved_kpi_id,
|
||||
detail={"period": result.get("period"), "caliber": result.get("caliber"),
|
||||
"hit": len(hits), **written},
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
payload.update({"would_insert": written["inserted"], "write_blockers": [], **written,
|
||||
"generated": written["inserted"]})
|
||||
return payload
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
"""应收账龄看板 API(T2 · 2026-10-20)— prefix `/api/cma/ar-aging`
|
||||
|
||||
数据源:`hanke_ar_aging`(逐户·逐期·三口径分列,财务口径见 `app.utils.ar_caliber`)。
|
||||
|
||||
纪律(全部硬约束):
|
||||
1. **每条查询都带 `entity_id` 过滤**(防两企业混报);本模块只读,无 ORM 写模型/无迁移。
|
||||
2. 三口径**禁合计**:`caliber=all` 或未知字面值 → 400;默认口径 = `借方应收户口径`。
|
||||
3. 缺期显「该期无数据」:`has_data=false` 且金额字段为 `null`(**禁补 0**)。
|
||||
4. `opening_amt`(期初)**只展示**,单独返回并标 `non_aging_base=true`。
|
||||
5. 表不存在/无权限 ⇒ `available=false` + 原因,**HTTP 200 不 500**。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.utils import ar_caliber
|
||||
|
||||
logger = logging.getLogger("ar_aging")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/ar-aging",
|
||||
tags=["应收账龄看板"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business"))],
|
||||
)
|
||||
|
||||
_CENT = Decimal("0.01")
|
||||
_TABLE = ar_caliber.AR_AGING_TABLE
|
||||
_BUCKET_COLS = ar_caliber.BUCKET_COLUMNS
|
||||
_SORTABLE = {
|
||||
"unit_code", "unit_name", "total_amt", "overdue_amt", "opening_amt", "oldest_days",
|
||||
*_BUCKET_COLS,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公共helper
|
||||
# ============================================================
|
||||
def _resolve_caliber(caliber: Optional[str]) -> str:
|
||||
try:
|
||||
return ar_caliber.validate_caliber(caliber, allow_none=True)
|
||||
except ar_caliber.CaliberError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
def _dec(v: Any) -> Decimal:
|
||||
return Decimal("0") if v is None else Decimal(str(v))
|
||||
|
||||
|
||||
def _money(v: Any) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
return float(_dec(v).quantize(_CENT, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _money_str(v: Any) -> str:
|
||||
return "{:.2f}".format(_dec(v).quantize(_CENT, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _available(db: Session) -> tuple[bool, Optional[str]]:
|
||||
try:
|
||||
db.execute(text("SELECT 1 FROM %s LIMIT 1" % _TABLE)).first()
|
||||
return True, None
|
||||
except Exception as e: # noqa: BLE001 — 表缺失/无权限一律降级
|
||||
db.rollback()
|
||||
logger.warning("hanke_ar_aging 不可用: %s", e)
|
||||
return False, "表 %s 不可用(不存在或无 SELECT 权限): %s" % (_TABLE, str(e).split("\n")[0][:180])
|
||||
|
||||
|
||||
def _periods(db: Session, entity_id: int) -> List[str]:
|
||||
rows = db.execute(
|
||||
text("SELECT DISTINCT period FROM %s WHERE entity_id = :e ORDER BY period" % _TABLE),
|
||||
{"e": entity_id},
|
||||
).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _month_add(period: str, delta: int) -> str:
|
||||
y, m = int(period[:4]), int(period[5:7])
|
||||
idx = y * 12 + (m - 1) + delta
|
||||
return "%04d-%02d" % (idx // 12, idx % 12 + 1)
|
||||
|
||||
|
||||
def _month_range(start: str, end: str) -> List[str]:
|
||||
if start > end:
|
||||
return []
|
||||
out, cur = [], start
|
||||
while cur <= end:
|
||||
out.append(cur)
|
||||
cur = _month_add(cur, 1)
|
||||
return out
|
||||
|
||||
|
||||
def _rows(db: Session, entity_id: int, caliber: str, period: str) -> List[Dict[str, Any]]:
|
||||
sql = (
|
||||
"SELECT id, period, caliber, unit_code, unit_name, opening_amt, total_amt, %s "
|
||||
"FROM %s WHERE entity_id = :e AND caliber = :c AND period = :p "
|
||||
"ORDER BY total_amt DESC, unit_code ASC"
|
||||
% (", ".join(_BUCKET_COLS), _TABLE)
|
||||
)
|
||||
return [dict(r) for r in db.execute(
|
||||
text(sql), {"e": entity_id, "c": caliber, "p": period}).mappings().all()]
|
||||
|
||||
|
||||
def _overdue_amt(row: Dict[str, Any]) -> Decimal:
|
||||
"""逾期额(>30 天)= total_amt − b_0_30;负档位不 ABS/不 COALESCE。"""
|
||||
return _dec(row.get("total_amt")) - _dec(row.get("b_0_30"))
|
||||
|
||||
|
||||
def _oldest_days(row: Dict[str, Any]) -> int:
|
||||
days = 0
|
||||
for col, lower in ar_caliber.OVERDUE_BUCKETS:
|
||||
if _dec(row.get(col)) != 0:
|
||||
days = max(days, lower)
|
||||
return days
|
||||
|
||||
|
||||
def _thresholds(db: Session, entity_id: int) -> Dict[str, Any]:
|
||||
"""看板阈值标注来源:`alert_rules(rule_type='ar_overdue')` 行(无规则 ⇒ 全 null,前端显「阈值未配置」)。
|
||||
|
||||
只读、只取展示用;判定仍以 `app.services.ar_overdue_alert` 单源为准。
|
||||
"""
|
||||
from app.api.alert_rules import AlertRule
|
||||
rule = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == entity_id,
|
||||
AlertRule.rule_type == "ar_overdue",
|
||||
AlertRule.enabled == 1,
|
||||
).order_by(AlertRule.id).first()
|
||||
if not rule:
|
||||
return {"source": None, "share_90plus_red_gt": None,
|
||||
"share_90plus_yellow_min": None, "share_90plus_yellow_max": None}
|
||||
params = rule.params or {}
|
||||
red = params.get("red") or {}
|
||||
yellow = params.get("yellow") or {}
|
||||
return {
|
||||
"source": "alert_rules#%s" % rule.id,
|
||||
"share_90plus_red_gt": red.get("share_90plus_gt"),
|
||||
"share_90plus_yellow_min": yellow.get("share_90plus_min"),
|
||||
"share_90plus_yellow_max": yellow.get("share_90plus_max"),
|
||||
}
|
||||
|
||||
|
||||
def _enrich(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
total = _dec(row.get("total_amt"))
|
||||
od = _overdue_amt(row)
|
||||
o90 = sum((_dec(row.get(c)) for c in ar_caliber.NINETY_PLUS_COLUMNS), Decimal("0"))
|
||||
out = {
|
||||
"unit_code": row.get("unit_code"),
|
||||
"unit_name": row.get("unit_name"),
|
||||
"opening_amt": _money(row.get("opening_amt")),
|
||||
"total_amt": _money(total),
|
||||
"buckets": {col: _money(row.get(col)) for col in _BUCKET_COLS},
|
||||
"overdue_amt": _money(od),
|
||||
"overdue_90plus": _money(o90),
|
||||
"oldest_days": _oldest_days(row),
|
||||
"pct_of_unit": (round(float(od / total), 6) if total > 0 else None),
|
||||
"is_overdue": od > 0,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /meta
|
||||
# ============================================================
|
||||
@router.get("/meta")
|
||||
def get_meta(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""看板元信息:可用性、口径全集、可用期间、最新期、入表时刻、来源件。"""
|
||||
ok, reason = _available(db)
|
||||
base = {
|
||||
"entity_id": entity_id,
|
||||
"table": _TABLE,
|
||||
"calibers": list(ar_caliber.CALIBERS),
|
||||
"default_caliber": ar_caliber.DEFAULT_CALIBER,
|
||||
"available": ok,
|
||||
"reason": reason,
|
||||
"periods_available": [],
|
||||
"latest_period": None,
|
||||
"case_insensitive_note": "口径字面值逐字匹配;贷方口径为 `贷方户单列(净额)`",
|
||||
}
|
||||
if not ok:
|
||||
return base
|
||||
|
||||
periods = _periods(db, entity_id)
|
||||
base["periods_available"] = periods
|
||||
base["latest_period"] = periods[-1] if periods else None
|
||||
row = db.execute(
|
||||
text("SELECT COUNT(*) n, MAX(loaded_at) loaded, MIN(period) pmin, MAX(period) pmax "
|
||||
"FROM %s WHERE entity_id = :e" % _TABLE),
|
||||
{"e": entity_id},
|
||||
).mappings().first()
|
||||
loaded = row["loaded"]
|
||||
base.update({
|
||||
"row_count": int(row["n"] or 0),
|
||||
"loaded_at": (loaded.isoformat(sep=" ") if hasattr(loaded, "isoformat") else (str(loaded) if loaded else None)),
|
||||
"period_min": row["pmin"],
|
||||
"period_max": row["pmax"],
|
||||
})
|
||||
src = db.execute(
|
||||
text("SELECT DISTINCT source_file FROM %s WHERE entity_id = :e ORDER BY source_file" % _TABLE),
|
||||
{"e": entity_id},
|
||||
).all()
|
||||
base["source_files"] = [r[0] for r in src]
|
||||
if not periods:
|
||||
base["reason"] = "表内该账套无数据"
|
||||
return base
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /trend
|
||||
# ============================================================
|
||||
@router.get("/trend")
|
||||
def get_trend(
|
||||
caliber: Optional[str] = Query(None),
|
||||
start: Optional[str] = Query(None, description="起始期 YYYY-MM"),
|
||||
end: Optional[str] = Query(None, description="结束期 YYYY-MM(缺省=表内最新期与当前月取大)"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""逐期趋势(总额/户数/六档);无数据期 has_data=false 且金额 null(禁补 0)。"""
|
||||
cal = _resolve_caliber(caliber)
|
||||
ok, reason = _available(db)
|
||||
if not ok:
|
||||
return {"available": False, "reason": reason, "entity_id": entity_id,
|
||||
"caliber": cal, "points": []}
|
||||
|
||||
periods = _periods(db, entity_id)
|
||||
if not periods:
|
||||
return {"available": True, "entity_id": entity_id, "caliber": cal,
|
||||
"periods_available": [], "points": [], "reason": "表内该账套无数据"}
|
||||
|
||||
now_period = datetime.now().strftime("%Y-%m")
|
||||
start_p = start or periods[0]
|
||||
end_p = end or max(periods[-1], now_period)
|
||||
for p in (start_p, end_p):
|
||||
if len(p) != 7 or p[4] != "-" or not (p[:4].isdigit() and p[5:].isdigit()):
|
||||
raise HTTPException(400, f"期间格式非法(要求 YYYY-MM): {p}")
|
||||
|
||||
points = []
|
||||
for p in _month_range(start_p, end_p):
|
||||
rows = _rows(db, entity_id, cal, p)
|
||||
if not rows:
|
||||
points.append({
|
||||
"period": p, "has_data": False,
|
||||
"total_amt": None, "customers": None, "overdue_amt": None,
|
||||
"buckets": {col: None for col in _BUCKET_COLS},
|
||||
"note": "该期无数据",
|
||||
})
|
||||
continue
|
||||
total = sum((_dec(r.get("total_amt")) for r in rows), Decimal("0"))
|
||||
points.append({
|
||||
"period": p,
|
||||
"has_data": True,
|
||||
"total_amt": _money(total),
|
||||
"customers": len(rows),
|
||||
"overdue_amt": _money(sum((_overdue_amt(r) for r in rows), Decimal("0"))),
|
||||
"buckets": {col: _money(sum((_dec(r.get(col)) for r in rows), Decimal("0")))
|
||||
for col in _BUCKET_COLS},
|
||||
"note": None,
|
||||
})
|
||||
return {
|
||||
"available": True, "entity_id": entity_id, "caliber": cal,
|
||||
"start": start_p, "end": end_p,
|
||||
"periods_available": periods,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /overview
|
||||
# ============================================================
|
||||
@router.get("/overview")
|
||||
def get_overview(
|
||||
period: Optional[str] = Query(None),
|
||||
caliber: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""期间概览:合计/户数/六档/逾期额/90天以上占比/环比;opening_amt 单列且标非账龄基数。"""
|
||||
cal = _resolve_caliber(caliber)
|
||||
th = _thresholds(db, entity_id)
|
||||
ok, reason = _available(db)
|
||||
if not ok:
|
||||
return {"available": False, "reason": reason, "entity_id": entity_id,
|
||||
"caliber": cal, "period": period, "has_data": False, "thresholds": th}
|
||||
|
||||
periods = _periods(db, entity_id)
|
||||
if not periods:
|
||||
return {"available": True, "entity_id": entity_id, "caliber": cal,
|
||||
"period": period, "has_data": False, "reason": "表内该账套无数据",
|
||||
"thresholds": th}
|
||||
p = period or periods[-1]
|
||||
rows = _rows(db, entity_id, cal, p)
|
||||
if not rows:
|
||||
return {
|
||||
"available": True, "entity_id": entity_id, "caliber": cal, "period": p,
|
||||
"has_data": False, "reason": "该期无数据",
|
||||
"total_amt": None, "customers": None, "overdue_amt": None,
|
||||
"overdue_90plus": None, "share_90plus": None,
|
||||
"buckets": {col: None for col in _BUCKET_COLS},
|
||||
"opening_amt": None, "opening_amt_non_aging_base": True,
|
||||
"overdue_customers": None, "prev_period": None, "prev_overdue_amt": None,
|
||||
"overdue_amt_mom_pct": None, "overdue_customers_mom_pct": None,
|
||||
"thresholds": th,
|
||||
}
|
||||
|
||||
total = sum((_dec(r.get("total_amt")) for r in rows), Decimal("0"))
|
||||
buckets = {col: sum((_dec(r.get(col)) for r in rows), Decimal("0")) for col in _BUCKET_COLS}
|
||||
od = sum((_overdue_amt(r) for r in rows), Decimal("0"))
|
||||
o90 = sum(buckets[c] for c in ar_caliber.NINETY_PLUS_COLUMNS)
|
||||
overdue_customers = sum(1 for r in rows if _overdue_amt(r) > 0)
|
||||
|
||||
idx = periods.index(p) if p in periods else -1
|
||||
prev_period = periods[idx - 1] if idx > 0 else None
|
||||
prev_rows = _rows(db, entity_id, cal, prev_period) if prev_period else []
|
||||
prev_od = sum((_overdue_amt(r) for r in prev_rows), Decimal("0")) if prev_rows else None
|
||||
prev_customers = (sum(1 for r in prev_rows if _overdue_amt(r) > 0) if prev_rows else None)
|
||||
|
||||
def _mom(cur: Optional[Decimal], prev: Optional[Decimal]) -> Optional[float]:
|
||||
if cur is None or prev is None or prev == 0:
|
||||
return None
|
||||
return round(float((cur - prev) / prev), 6)
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"entity_id": entity_id,
|
||||
"caliber": cal,
|
||||
"period": p,
|
||||
"has_data": True,
|
||||
"total_amt": _money(total),
|
||||
"customers": len(rows),
|
||||
"buckets": {col: _money(v) for col, v in buckets.items()},
|
||||
"buckets_sum": _money(sum(buckets.values())),
|
||||
"buckets_sum_matches_total": _money(sum(buckets.values())) == _money(total),
|
||||
"overdue_amt": _money(od),
|
||||
"overdue_90plus": _money(o90),
|
||||
"share_90plus": (round(float(o90 / total), 6) if total > 0 else None),
|
||||
"overdue_customers": overdue_customers,
|
||||
# 期初只展示:禁作账龄基数、禁进分子分母(财务定死)
|
||||
"opening_amt": _money(sum((_dec(r.get("opening_amt")) for r in rows), Decimal("0"))),
|
||||
"opening_amt_non_aging_base": True,
|
||||
"prev_period": prev_period,
|
||||
"prev_has_data": bool(prev_rows),
|
||||
"prev_overdue_amt": _money(prev_od) if prev_od is not None else None,
|
||||
"prev_overdue_customers": prev_customers,
|
||||
"overdue_amt_mom_pct": _mom(od, prev_od),
|
||||
"overdue_customers_mom_pct": (
|
||||
_mom(Decimal(overdue_customers), Decimal(prev_customers))
|
||||
if prev_customers is not None else None),
|
||||
"mom_note": None if prev_rows else "上期无数据,不计算环比",
|
||||
"thresholds": th,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /customers
|
||||
# ============================================================
|
||||
@router.get("/customers")
|
||||
def get_customers(
|
||||
period: Optional[str] = Query(None),
|
||||
caliber: Optional[str] = Query(None),
|
||||
overdue_only: bool = Query(False),
|
||||
sort: str = Query("overdue_amt"),
|
||||
order: str = Query("desc"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""逐户明细(六档 + 逾期额 + 最老档),服务端排序。"""
|
||||
cal = _resolve_caliber(caliber)
|
||||
if sort not in _SORTABLE:
|
||||
raise HTTPException(400, f"sort 不支持: {sort}(可选 {sorted(_SORTABLE)})")
|
||||
if order not in ("asc", "desc"):
|
||||
raise HTTPException(400, "order 仅接受 asc/desc")
|
||||
ok, reason = _available(db)
|
||||
if not ok:
|
||||
return {"available": False, "reason": reason, "entity_id": entity_id,
|
||||
"caliber": cal, "period": period, "has_data": False, "items": [], "total": 0}
|
||||
|
||||
periods = _periods(db, entity_id)
|
||||
if not periods:
|
||||
return {"available": True, "entity_id": entity_id, "caliber": cal,
|
||||
"period": period, "has_data": False, "items": [], "total": 0}
|
||||
p = period or periods[-1]
|
||||
rows = _rows(db, entity_id, cal, p)
|
||||
if not rows:
|
||||
return {"available": True, "entity_id": entity_id, "caliber": cal, "period": p,
|
||||
"has_data": False, "reason": "该期无数据", "items": [], "total": 0}
|
||||
|
||||
items = [_enrich(r) for r in rows]
|
||||
if overdue_only:
|
||||
items = [i for i in items if i["is_overdue"]]
|
||||
items.sort(key=lambda i: (i.get(sort) is None, i.get(sort)), reverse=(order == "desc"))
|
||||
return {
|
||||
"available": True, "entity_id": entity_id, "caliber": cal, "period": p,
|
||||
"has_data": True, "total": len(items), "sort": sort, "order": order,
|
||||
"overdue_only": overdue_only, "items": items,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GET /customers/export
|
||||
# ============================================================
|
||||
@router.get("/customers/export")
|
||||
def export_customers(
|
||||
period: Optional[str] = Query(None),
|
||||
caliber: Optional[str] = Query(None),
|
||||
overdue_only: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""逐户明细 CSV(UTF-8 BOM,Excel 直开);首段写「截止期/口径/导出时刻」。"""
|
||||
from urllib.parse import quote
|
||||
from starlette.responses import Response
|
||||
|
||||
cal = _resolve_caliber(caliber)
|
||||
ok, reason = _available(db)
|
||||
if not ok:
|
||||
raise HTTPException(503, reason or "数据表不可用")
|
||||
periods = _periods(db, entity_id)
|
||||
if not periods:
|
||||
raise HTTPException(404, "表内该账套无数据")
|
||||
p = period or periods[-1]
|
||||
rows = _rows(db, entity_id, cal, p)
|
||||
if not rows:
|
||||
raise HTTPException(404, f"该期无数据: {p}")
|
||||
|
||||
items = [_enrich(r) for r in rows]
|
||||
if overdue_only:
|
||||
items = [i for i in items if i["is_overdue"]]
|
||||
items.sort(key=lambda i: (i["overdue_amt"] is None, -(i["overdue_amt"] or 0)))
|
||||
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf)
|
||||
w.writerow(["截止期", p, "口径", cal,
|
||||
"导出时刻", datetime.now().strftime("%Y-%m-%d %H:%M:%S")])
|
||||
w.writerow(["单位编码", "单位名称", "期初(非账龄基数)", "期末应收合计",
|
||||
"0-30", "31-60", "61-90", "90-120", "120-150", "150+",
|
||||
"逾期额(>30天)", "90天以上", "最老逾期档(天起)", "逾期占该户%", "是否逾期"])
|
||||
for i in items:
|
||||
w.writerow([
|
||||
i["unit_code"], i["unit_name"], _money_str(i["opening_amt"]),
|
||||
_money_str(i["total_amt"]),
|
||||
*[_money_str(i["buckets"][c]) for c in _BUCKET_COLS],
|
||||
_money_str(i["overdue_amt"]), _money_str(i["overdue_90plus"]),
|
||||
i["oldest_days"],
|
||||
("" if i["pct_of_unit"] is None else "%.2f" % (i["pct_of_unit"] * 100)),
|
||||
"是" if i["is_overdue"] else "否",
|
||||
])
|
||||
|
||||
content = "\ufeff" + buf.getvalue()
|
||||
filename = "应收账龄_%s_%s.csv" % (p, cal)
|
||||
return Response(
|
||||
content=content.encode("utf-8"),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=\"ar_aging_%s.csv\"; filename*=UTF-8''%s"
|
||||
% (p, quote(filename))},
|
||||
)
|
||||
@@ -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
|
||||
+54
-42
@@ -1,16 +1,17 @@
|
||||
"""AI获客 - 客户咨询接口"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import json, os, datetime, subprocess, urllib.request
|
||||
import json, os, datetime, urllib.request
|
||||
|
||||
router = APIRouter(prefix="/api/cma/lead", tags=["AI获客"])
|
||||
|
||||
# ai-cs 实盘分流脚本(跨 profile 调用)
|
||||
# 必须以 ai-cs 身份 + 其 HERMES_HOME/HOME 运行:脚本内用 `hermes send` 复用该 profile 的企微凭据;
|
||||
# cma-backend.service 的 Environment 为空,不显式传入会解析到非 ai-cs 的 profile(root 默认 profile)。
|
||||
AI_CS_USER = "ai-cs"
|
||||
AI_CS_HOME = "/var/lib/wecom-ai-cs/profile"
|
||||
AI_CS_ROUTING_SCRIPT = AI_CS_HOME + "/scripts/routing-live.py"
|
||||
# 客户咨询通知唯一通道:系统总线中继 8660 → 任总 DM。
|
||||
# 2026-10-22 起取代旧的 ai-cs routing-live.py(该 profile/user 已下线,runuser 必然失败且异常被吞,
|
||||
# 导致线索静默丢失通知)。中继类别按契约取 fallback(无会话兜底),company 类被中继直接拒绝。
|
||||
BUS_RELAY_URL = "http://127.0.0.1:8660/bus/send"
|
||||
BUS_KEY_FILE = "/srv/hermes/shared/.secrets/bus_relay.key"
|
||||
BOSS_USERID = "200653946" # 任富海
|
||||
NOTIFY_LOG = "/root/leads/notify.log"
|
||||
|
||||
class LeadRequest(BaseModel):
|
||||
name: str
|
||||
@@ -18,42 +19,54 @@ class LeadRequest(BaseModel):
|
||||
requirement: str = ""
|
||||
source: str = "sxbh.ltd"
|
||||
|
||||
def notify_wecom(name, phone, requirement, source):
|
||||
"""推送到企微全员群"""
|
||||
msg = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": f"## 🔔 新客户咨询\n**姓名**: {name}\n**电话**: {phone}\n**需求**: {requirement}\n**来源**: {source}\n**时间**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
}
|
||||
}
|
||||
def _log_notify(ok, detail):
|
||||
"""通知结果落痕(旧实现 except: pass 静默吞异常,导致线索漏通知无人知晓)"""
|
||||
try:
|
||||
data = json.dumps(msg).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8800/send",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except:
|
||||
pass # 通知失败不影响主流程
|
||||
|
||||
def notify_boss(name, phone, requirement):
|
||||
"""额外推给任总(经 ai-cs 实盘分流脚本;失败不影响主流程)"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["runuser", "-u", AI_CS_USER, "--",
|
||||
"env", f"HOME={AI_CS_HOME}", f"HERMES_HOME={AI_CS_HOME}",
|
||||
f"USER={AI_CS_USER}", f"LOGNAME={AI_CS_USER}",
|
||||
"/usr/bin/python3", AI_CS_ROUTING_SCRIPT,
|
||||
requirement or "(未填需求)",
|
||||
f"{name} {phone}",
|
||||
"website:sxbh.ltd"],
|
||||
timeout=30, capture_output=True,
|
||||
)
|
||||
os.makedirs(os.path.dirname(NOTIFY_LOG), exist_ok=True)
|
||||
with open(NOTIFY_LOG, "a", encoding="utf-8") as f:
|
||||
f.write("%s %s %s\n" % (datetime.datetime.now().isoformat(timespec="seconds"),
|
||||
"OK" if ok else "FAIL", detail))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def notify_boss(name, phone, requirement):
|
||||
"""客户咨询通知:经系统总线中继 8660 推任总 DM。失败不阻断主流程,但必须留痕。"""
|
||||
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
content = (
|
||||
"## 新客户咨询(sxbh.ltd 官网)\n"
|
||||
f"> 姓名:{name}\n"
|
||||
f"> 电话:{phone}\n"
|
||||
f"> 需求:{requirement or '(未填)'}\n"
|
||||
f"> 时间:{now}\n\n"
|
||||
"承诺 30 分钟内联系。"
|
||||
)
|
||||
try:
|
||||
with open(BUS_KEY_FILE, encoding="utf-8") as f:
|
||||
key = f.read().strip()
|
||||
if not key:
|
||||
raise RuntimeError("bus key empty")
|
||||
payload = json.dumps({
|
||||
"touser": BOSS_USERID,
|
||||
"msgtype": "markdown",
|
||||
"content": content,
|
||||
"notice_class": "fallback",
|
||||
}, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
BUS_RELAY_URL, data=payload,
|
||||
headers={"Content-Type": "application/json",
|
||||
"X-API-Key": key,
|
||||
"X-Notice-Class": "fallback"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
body = resp.read().decode("utf-8", "ignore")[:300]
|
||||
_log_notify(True, body)
|
||||
return True
|
||||
except Exception as e:
|
||||
_log_notify(False, "%s: %s" % (type(e).__name__, e))
|
||||
return False
|
||||
|
||||
@router.post("")
|
||||
def create_lead(data: LeadRequest):
|
||||
"""接收客户咨询并保存"""
|
||||
@@ -66,9 +79,8 @@ def create_lead(data: LeadRequest):
|
||||
with open(f"{log_dir}/leads.json", "a") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
# 异步推送企微通知
|
||||
notify_wecom(data.name, data.phone, data.requirement, data.source)
|
||||
# 加性:额外推给任总 DM(口径与 ai-cs 客服分流一致)
|
||||
# 客户咨询通知唯一通道:推任总 DM(经 ai-cs 分流);不再推 8800 全员群
|
||||
# (2026-10-22 裁定:8800 仅限内容类群发,客户线索含个人信息,不得进全员群)
|
||||
notify_boss(data.name, data.phone, data.requirement)
|
||||
|
||||
return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"}
|
||||
|
||||
+491
-28
@@ -7,12 +7,13 @@ from datetime import date, datetime, timedelta
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
|
||||
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User, MapConnection
|
||||
from app.utils.kr_name import sanitize_kr_name
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -507,7 +508,165 @@ def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int
|
||||
return {"message": f"已删除 {deleted} 个地图", "deleted": deleted}
|
||||
|
||||
|
||||
# ── 连线管理 ─────────────────────────────────
|
||||
# ── 连线管理(P1 四层因果结构化:map_connections 表 ⇄ canvas_data 双写)──
|
||||
#
|
||||
# 2026-10-21 cma-map-connections-table-p1:
|
||||
# · 生产存量节点标识是 `层:中文目标名`(如 `learning:贡献率思维全员培训`),
|
||||
# 当前前端 MapCanvas 生成的是 `层-序号`(如 `learning-0`)⇒ 两种格式都必须能解析;
|
||||
# · 表是新增读模型(归因/校验用),canvas_data 保持原样供前端渲染 ⇒ 双写期;
|
||||
# · 表缺失时(迁移前/回滚后)所有写路径降级为只写 canvas_data,不阻断前端。
|
||||
|
||||
LAYER_ORDER = ("learning", "process", "customer", "finance")
|
||||
_LAYER_INDEX = {k: i for i, k in enumerate(LAYER_ORDER)}
|
||||
_NODE_COLON = re.compile(r"^([a-z_]+):(.+)$")
|
||||
_NODE_INDEX = re.compile(r"^([a-z_]+)-(\d+)$")
|
||||
|
||||
|
||||
def _norm_obj_name(s) -> str:
|
||||
return re.sub(r"\s+", " ", str(s or "").strip())
|
||||
|
||||
|
||||
def parse_node_ref(node):
|
||||
"""节点标识解析(双格式兼容)→ dict(layer, kind, value, error)
|
||||
|
||||
`learning:贡献率思维全员培训` → layer=learning, kind=name, value=中文名
|
||||
`learning-0` → layer=learning, kind=index, value=0
|
||||
"""
|
||||
if not isinstance(node, str) or not node.strip():
|
||||
return {"layer": None, "kind": None, "value": None, "error": "node_empty"}
|
||||
s = node.strip()
|
||||
m = _NODE_COLON.match(s)
|
||||
if m:
|
||||
layer, name = m.group(1), m.group(2).strip()
|
||||
if layer not in _LAYER_INDEX:
|
||||
return {"layer": None, "kind": None, "value": None, "error": f"unknown_layer:{layer}"}
|
||||
if not name:
|
||||
return {"layer": layer, "kind": None, "value": None, "error": "node_name_empty"}
|
||||
return {"layer": layer, "kind": "name", "value": name, "error": None}
|
||||
m = _NODE_INDEX.match(s)
|
||||
if m:
|
||||
layer, idx = m.group(1), int(m.group(2))
|
||||
if layer not in _LAYER_INDEX:
|
||||
return {"layer": None, "kind": None, "value": None, "error": f"unknown_layer:{layer}"}
|
||||
return {"layer": layer, "kind": "index", "value": idx, "error": None}
|
||||
return {"layer": None, "kind": None, "value": None, "error": "node_format_unrecognized"}
|
||||
|
||||
|
||||
def _objective_index(db: Session, m: StrategicMap):
|
||||
"""(名字索引, 层内顺序索引) —— 名字索引键 = (dimension_key, 归一化名称)。"""
|
||||
rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(
|
||||
MapObjective.sort_order, MapObjective.id).all()
|
||||
by_name, by_layer = {}, {}
|
||||
for o in rows:
|
||||
by_name[(o.dimension_key, _norm_obj_name(o.name))] = o.id
|
||||
by_layer.setdefault(o.dimension_key, []).append(o)
|
||||
return by_name, by_layer
|
||||
|
||||
|
||||
def resolve_node(db: Session, m: StrategicMap, node):
|
||||
"""→ (layer, objective_id|None, note|None)
|
||||
|
||||
`层-序号` 的序号语义 = 前端 MapCanvas 使用的 `dimensions[层].objectives` 数组下标;
|
||||
dimensions 缺失时退化到 map_objectives.sort_order / 层内顺序。
|
||||
"""
|
||||
ref = parse_node_ref(node)
|
||||
layer = ref["layer"]
|
||||
if ref["error"]:
|
||||
return layer, None, ref["error"]
|
||||
by_name, by_layer = _objective_index(db, m)
|
||||
if ref["kind"] == "name":
|
||||
want = _norm_obj_name(ref["value"])
|
||||
hit = by_name.get((layer, want))
|
||||
if hit:
|
||||
return layer, hit, None
|
||||
return layer, None, "objective_unresolved"
|
||||
idx = ref["value"]
|
||||
dims = _dims_list(m.dimensions)
|
||||
for d in dims:
|
||||
if d.get("key") == layer:
|
||||
objs = d.get("objectives") or []
|
||||
if 0 <= idx < len(objs):
|
||||
name = _norm_obj_name((objs[idx] or {}).get("name"))
|
||||
hit = by_name.get((layer, name))
|
||||
return layer, hit, (None if hit else "objective_unresolved")
|
||||
return layer, None, "index_out_of_range"
|
||||
cand = by_layer.get(layer) or []
|
||||
for o in cand:
|
||||
if (o.sort_order or 0) == idx:
|
||||
return layer, o.id, "resolved_by_sort_order"
|
||||
if 0 <= idx < len(cand):
|
||||
return layer, cand[idx].id, "resolved_by_position"
|
||||
return layer, None, "index_out_of_range"
|
||||
|
||||
|
||||
def _row_payload_from_canvas(db: Session, m: StrategicMap, c: dict, sort_order: int) -> dict:
|
||||
frm, to = c.get("from", ""), c.get("to", "")
|
||||
fl, f_obj, f_note = resolve_node(db, m, frm)
|
||||
tl, t_obj, t_note = resolve_node(db, m, to)
|
||||
return {
|
||||
"entity_id": m.entity_id if m.entity_id is not None else 1,
|
||||
"map_id": m.id, "from_node": frm, "to_node": to,
|
||||
"from_layer": fl, "to_layer": tl,
|
||||
"from_objective_id": f_obj, "to_objective_id": t_obj,
|
||||
"effect": c.get("effect") or "positive",
|
||||
"label": (c.get("label") or None),
|
||||
"sort_order": sort_order,
|
||||
}
|
||||
|
||||
|
||||
def _table_ready(db: Session) -> bool:
|
||||
"""新表是否可用(迁移前/回滚后为 False)——用 SAVEPOINT 隔离探测,避免污染事务。"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.query(MapConnection).limit(1).all()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _table_rows(db: Session, m: StrategicMap):
|
||||
# 账套隔离已在 map 查询处完成;这里只按 map_id 取行,避免历史 map.entity_id 为空时
|
||||
# 冗余列与 map 不一致导致「表里有行却读不到」的假回落。
|
||||
rows = db.query(MapConnection).filter(
|
||||
MapConnection.map_id == m.id,
|
||||
).order_by(MapConnection.sort_order, MapConnection.id).all()
|
||||
return rows
|
||||
|
||||
|
||||
def _table_upsert(db: Session, m: StrategicMap, payload: dict) -> bool:
|
||||
"""双写:失败只告警、不阻断 canvas_data 主路径(兼容期容错)。"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
existing = db.query(MapConnection).filter(
|
||||
MapConnection.map_id == payload["map_id"],
|
||||
MapConnection.from_node == payload["from_node"],
|
||||
MapConnection.to_node == payload["to_node"],
|
||||
).first()
|
||||
if existing:
|
||||
for k, v in payload.items():
|
||||
setattr(existing, k, v)
|
||||
else:
|
||||
db.add(MapConnection(**payload))
|
||||
db.flush()
|
||||
return True
|
||||
except Exception as exc: # 表缺失/授权不足/约束冲突 → 主路径继续
|
||||
logger.warning("map_connections 双写失败(canvas_data 已保留): %s: %s", type(exc).__name__, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _table_delete(db: Session, m: StrategicMap, from_node: str, to_node: str) -> bool:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.query(MapConnection).filter(
|
||||
MapConnection.map_id == m.id,
|
||||
MapConnection.from_node == from_node,
|
||||
MapConnection.to_node == to_node,
|
||||
).delete(synchronize_session=False)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("map_connections 删除同步失败: %s: %s", type(exc).__name__, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _get_connections(m: StrategicMap) -> list:
|
||||
if not m.canvas_data:
|
||||
@@ -522,9 +681,228 @@ def _get_connections(m: StrategicMap) -> list:
|
||||
return m.canvas_data["connections"]
|
||||
|
||||
|
||||
def _objective_key_index(db: Session, m: StrategicMap):
|
||||
"""连线解析锚点:MapObjective.id → 画布渲染键 `${dim}-${idx}`(以当前 dimensions 顺序为准)
|
||||
|
||||
返回 (id2key, name2key, dim_len):
|
||||
· id2key : 本表行 id → `${dim}-${idx}`(map_connections.from/to_objective_id 的锚点)
|
||||
· name2key: (dim, 归一化名称) → `${dim}-${idx}`(历史 `层:中文名` 的兜底,改名即失效)
|
||||
· dim_len : {dim: len(objectives)}(`层-序号` 越界判定)
|
||||
|
||||
只按名称把 dimensions 节点对回 map_objectives 行,**不读** dims JSON 里的 `_objective_id`
|
||||
—— 那是 objectives 表(实体级目标)的 id 空间,与 map_objectives 行 id 不是一回事。
|
||||
"""
|
||||
dims = _dims_list(m.dimensions)
|
||||
rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(
|
||||
MapObjective.sort_order, MapObjective.id).all()
|
||||
by_name = {}
|
||||
for o in rows:
|
||||
by_name[(o.dimension_key, _norm_obj_name(o.name))] = o.id
|
||||
id2key, name2key, dim_len = {}, {}, {}
|
||||
for d in dims:
|
||||
dk = d.get("key") or ""
|
||||
if not dk:
|
||||
continue
|
||||
objs = d.get("objectives") or []
|
||||
dim_len[dk] = len(objs)
|
||||
for idx, o in enumerate(objs):
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
key = f"{dk}-{idx}"
|
||||
nm = _norm_obj_name(o.get("name"))
|
||||
if nm:
|
||||
name2key[(dk, nm)] = key
|
||||
oid = by_name.get((dk, nm))
|
||||
if oid is not None:
|
||||
id2key[oid] = key
|
||||
return id2key, name2key, dim_len
|
||||
|
||||
|
||||
def _key_for_node(node, id2key, name2key, dim_len, objective_id=None):
|
||||
"""单端节点解析 → (key, source, reason)
|
||||
|
||||
顺序:objective_id 锚点 → `层:中文名` → `层-序号`。
|
||||
改名后 `objective_id` 仍指向同一节点 ⇒ key 不变、线不断;锚点失效(目标已删/历史脏 id/
|
||||
跨 map)才回退原名字串,仍解析不到才返回 None(调用方计入 unresolved)。
|
||||
"""
|
||||
if objective_id:
|
||||
hit = id2key.get(objective_id)
|
||||
if hit:
|
||||
return hit, "objective_id", None
|
||||
ref = parse_node_ref(node)
|
||||
if ref["error"]:
|
||||
return None, None, ref["error"]
|
||||
layer = ref["layer"]
|
||||
stale = "stale_objective_id" if objective_id else None
|
||||
if ref["kind"] == "name":
|
||||
want = _norm_obj_name(ref["value"])
|
||||
hit = name2key.get((layer, want))
|
||||
if hit:
|
||||
return hit, "name", stale
|
||||
return None, None, (stale or "objective_unresolved")
|
||||
idx = ref["value"]
|
||||
if 0 <= idx < dim_len.get(layer, 0):
|
||||
return f"{layer}-{idx}", "index", stale
|
||||
return None, None, "index_out_of_range"
|
||||
|
||||
|
||||
def _attach_keys(db: Session, m: StrategicMap, items: list):
|
||||
"""给连线条目附 `from_key/to_key`(渲染键)+ 未解析清单。
|
||||
|
||||
条目原地补字段:`{side}_key` / `{side}_key_source` / `{side}_key_reason`,
|
||||
键与中文名解耦的唯一出口(前端按 key 画线,缺失回落原值)。
|
||||
"""
|
||||
id2key, name2key, dim_len = _objective_key_index(db, m)
|
||||
unresolved = []
|
||||
for it in items:
|
||||
for side in ("from", "to"):
|
||||
key, source, reason = _key_for_node(
|
||||
it.get(side), id2key, name2key, dim_len, it.get(f"{side}_objective_id"))
|
||||
it[f"{side}_key"] = key
|
||||
it[f"{side}_key_source"] = source
|
||||
it[f"{side}_key_reason"] = reason
|
||||
if key is None:
|
||||
unresolved.append({
|
||||
"from": it.get("from"), "to": it.get("to"),
|
||||
"side": side, "node": it.get(side),
|
||||
"reason": reason, "objective_id": it.get(f"{side}_objective_id"),
|
||||
})
|
||||
return items, unresolved
|
||||
|
||||
|
||||
@router.get("/{map_id}/connections")
|
||||
def list_connections(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""读连线:表优先,表空回落 canvas_data(兼容期)。
|
||||
|
||||
P3:每条连线附 `from_key/to_key` = `${dim}-${idx}`(由 objective_id 在当前 dimensions
|
||||
树反查)+ 顶层 `unresolved` 计数与清单。前端优先按 key 画线 ⇒ 目标改名不断线。
|
||||
"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
if _table_ready(db):
|
||||
rows = _table_rows(db, m)
|
||||
if rows:
|
||||
items = [{
|
||||
"from": r.from_node, "to": r.to_node, "style": "solid",
|
||||
"effect": r.effect or "positive", "label": r.label or "",
|
||||
"from_layer": r.from_layer, "to_layer": r.to_layer,
|
||||
"from_objective_id": r.from_objective_id, "to_objective_id": r.to_objective_id,
|
||||
} for r in rows]
|
||||
items, unresolved = _attach_keys(db, m, items)
|
||||
return {
|
||||
"source": "table", "total": len(items),
|
||||
"unresolved": len(unresolved), "unresolved_items": unresolved,
|
||||
"connections": items,
|
||||
}
|
||||
|
||||
conns = _get_connections(m)
|
||||
items = [{
|
||||
"from": c.get("from"), "to": c.get("to"),
|
||||
"style": c.get("style") or "solid",
|
||||
"effect": c.get("effect") or "positive",
|
||||
"label": c.get("label") or "",
|
||||
"from_objective_id": c.get("from_objective_id"),
|
||||
"to_objective_id": c.get("to_objective_id"),
|
||||
} for c in conns if isinstance(c, dict)]
|
||||
items, unresolved = _attach_keys(db, m, items)
|
||||
return {
|
||||
"source": "canvas_data", "total": len(items),
|
||||
"unresolved": len(unresolved), "unresolved_items": unresolved,
|
||||
"connections": items,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{map_id}/connections/analysis")
|
||||
def analyze_connections(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""连线只读分析(归因/复盘用):层统计 + 跨层分布 + 断链 + 反向告警(不拦截)。
|
||||
|
||||
读路径:表内有行 → 读表;否则回落 canvas_data(并对回落数据现算解析结果)。
|
||||
"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
source = "canvas_data"
|
||||
items = []
|
||||
table_rows = _table_rows(db, m) if _table_ready(db) else []
|
||||
if table_rows:
|
||||
source = "table"
|
||||
for r in table_rows:
|
||||
items.append({
|
||||
"from": r.from_node, "to": r.to_node,
|
||||
"from_layer": r.from_layer, "to_layer": r.to_layer,
|
||||
"from_objective_id": r.from_objective_id, "to_objective_id": r.to_objective_id,
|
||||
"effect": r.effect or "positive", "label": r.label,
|
||||
})
|
||||
else:
|
||||
for i, c in enumerate(_get_connections(m)):
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
row = _row_payload_from_canvas(db, m, c, i)
|
||||
items.append({
|
||||
"from": row["from_node"], "to": row["to_node"],
|
||||
"from_layer": row["from_layer"], "to_layer": row["to_layer"],
|
||||
"from_objective_id": row["from_objective_id"],
|
||||
"to_objective_id": row["to_objective_id"],
|
||||
"effect": row["effect"], "label": row["label"],
|
||||
})
|
||||
|
||||
layer_stats = {k: {"as_from": 0, "as_to": 0} for k in LAYER_ORDER}
|
||||
cross_layer, reversed_edges, broken, unknown = {}, [], [], []
|
||||
same_layer = 0
|
||||
items, unresolved = _attach_keys(db, m, items)
|
||||
for it in items:
|
||||
fl, tl = it.get("from_layer"), it.get("to_layer")
|
||||
if fl in layer_stats:
|
||||
layer_stats[fl]["as_from"] += 1
|
||||
if tl in layer_stats:
|
||||
layer_stats[tl]["as_to"] += 1
|
||||
if fl not in _LAYER_INDEX or tl not in _LAYER_INDEX:
|
||||
unknown.append({"from": it["from"], "to": it["to"],
|
||||
"from_layer": fl, "to_layer": tl})
|
||||
else:
|
||||
pair = f"{fl}->{tl}"
|
||||
cross_layer[pair] = cross_layer.get(pair, 0) + 1
|
||||
if fl == tl:
|
||||
same_layer += 1
|
||||
elif _LAYER_INDEX[tl] < _LAYER_INDEX[fl]:
|
||||
reversed_edges.append({"from": it["from"], "to": it["to"],
|
||||
"from_layer": fl, "to_layer": tl})
|
||||
if not it.get("from_key"):
|
||||
broken.append({"from": it["from"], "to": it["to"], "side": "from",
|
||||
"node": it["from"],
|
||||
"reason": it.get("from_key_reason") or "objective_unresolved"})
|
||||
if not it.get("to_key"):
|
||||
broken.append({"from": it["from"], "to": it["to"], "side": "to",
|
||||
"node": it["to"],
|
||||
"reason": it.get("to_key_reason") or "objective_unresolved"})
|
||||
|
||||
warnings = []
|
||||
if reversed_edges:
|
||||
warnings.append(f"反向连线 {len(reversed_edges)} 条(与 learning→process→customer→finance 相反,"
|
||||
f"仅告警不拦截)")
|
||||
if broken:
|
||||
warnings.append(f"断链 {len(broken)} 处(节点未能解析到 map_objectives)")
|
||||
if unknown:
|
||||
warnings.append(f"层前缀无法识别 {len(unknown)} 条")
|
||||
|
||||
return {
|
||||
"map_id": m.id, "entity_id": m.entity_id, "source": source,
|
||||
"layer_order": list(LAYER_ORDER), "total": len(items),
|
||||
"layer_stats": layer_stats, "cross_layer": cross_layer,
|
||||
"same_layer_count": same_layer,
|
||||
"reversed": reversed_edges, "broken": broken,
|
||||
"unknown_layer": unknown, "warnings": warnings,
|
||||
"unresolved": len(unresolved), "unresolved_items": unresolved,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{map_id}/connections")
|
||||
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
|
||||
"""新增因果连线: {"from": "learning-0", "to": "process-0"}(表 + canvas_data 双写)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
@@ -539,10 +917,6 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
|
||||
if from_id == to_id:
|
||||
raise HTTPException(400, "不能自身连线")
|
||||
|
||||
# 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连)
|
||||
from_dim = from_id.rsplit("-", 1)[0]
|
||||
to_dim = to_id.rsplit("-", 1)[0]
|
||||
|
||||
conns = _get_connections(m)
|
||||
|
||||
# 校验: 不能重复
|
||||
@@ -558,14 +932,21 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
|
||||
"label": data.get("label", ""),
|
||||
})
|
||||
m.canvas_data["connections"] = conns
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
flag_modified(m, "canvas_data") # 同上:JSON 列需显式标脏才能落库
|
||||
|
||||
# 双写(表缺失/失败不阻断前端主路径)
|
||||
payload = _row_payload_from_canvas(db, m, conns[-1], len(conns) - 1)
|
||||
db_synced = _table_upsert(db, m, payload)
|
||||
|
||||
db.commit()
|
||||
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
|
||||
return {"connections": conns, "updated_at": _stamp(m.updated_at)}
|
||||
return {"connections": conns, "updated_at": _stamp(m.updated_at), "db_synced": db_synced}
|
||||
|
||||
|
||||
@router.delete("/{map_id}/connections")
|
||||
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""根据 from/to 删除连线"""
|
||||
"""根据 from/to 删除连线(表 + canvas_data 同步删)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
@@ -580,9 +961,14 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_
|
||||
raise HTTPException(404, "连线不存在")
|
||||
|
||||
m.canvas_data["connections"] = new_conns
|
||||
# JSON 列就地/换列表赋值不会被 ORM 自动标记为 dirty ⇒ 必须显式 flag_modified,
|
||||
# 否则接口返回 200 且响应列表已减 1,但 commit 不 flush,重读仍是旧值(删连线变哑弹)。
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
flag_modified(m, "canvas_data")
|
||||
db_synced = _table_delete(db, m, from_id, to_id)
|
||||
db.commit()
|
||||
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
|
||||
"updated_at": _stamp(m.updated_at)}
|
||||
"updated_at": _stamp(m.updated_at), "db_synced": db_synced}
|
||||
|
||||
|
||||
# ── 版本管理 ─────────────────────────────────
|
||||
@@ -645,26 +1031,98 @@ def m_to_dict(m, db: Session = None):
|
||||
|
||||
|
||||
def _sync_map_objectives(m, db):
|
||||
"""保存时:将dimensions JSON中的目标同步到map_objectives表"""
|
||||
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
dims = json.loads(dims)
|
||||
dims = dims or []
|
||||
"""保存时:将dimensions JSON中的目标同步到map_objectives表(就地 upsert)
|
||||
|
||||
2026-10-22 cma-map-canvas-conn-key-rename-stable(P3):
|
||||
原实现是 delete+insert —— MySQL 的 AUTO_INCREMENT 计数器不随 DELETE 归零,每次保存
|
||||
都会给同一节点换一批新行 id,`map_connections.from/to_objective_id` 随即变成野指针
|
||||
(读侧再按 id 反查就查不到),「用 objective_id 与中文名解耦」的前提被静默破坏。
|
||||
改为就地 upsert:行 id 全程稳定,连线锚点才真的能扛住改名。
|
||||
|
||||
定位顺序:dims 的 `_mo_id`(读侧由 _merge_map_objectives 回写)→ (dimension_key, 归一化名称)。
|
||||
**不做位置兜底**(口径 B,ASK-20261023-04 任总 2026-10-23 15:13「放行 4」):
|
||||
未命中名字的项一律视为新目标,未被占用的存量行一律视为已删除。理由见下方 ③。
|
||||
"""
|
||||
dims = _dims_list(m.dimensions)
|
||||
entity_id = m.entity_id if m.entity_id is not None else 1
|
||||
rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(
|
||||
MapObjective.sort_order, MapObjective.id).all()
|
||||
by_id = {r.id: r for r in rows}
|
||||
used = set()
|
||||
|
||||
# 计划项:按 dims 顺序展开
|
||||
planned = []
|
||||
for dim in dims:
|
||||
for i, obj in enumerate(dim.get("objectives", [])):
|
||||
mo = MapObjective(
|
||||
map_id=m.id,
|
||||
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
|
||||
dimension_key=dim.get("key", ""),
|
||||
name=obj.get("name", ""),
|
||||
description=obj.get("description", ""),
|
||||
icon=obj.get("icon", "target"),
|
||||
kpis=obj.get("kpis", []),
|
||||
sort_order=i,
|
||||
)
|
||||
db.add(mo)
|
||||
dk = dim.get("key", "")
|
||||
for i, obj in enumerate(dim.get("objectives") or []):
|
||||
if isinstance(obj, dict):
|
||||
planned.append((dk, i, obj))
|
||||
|
||||
assigned = {}
|
||||
|
||||
# ① 显式锚点:客户端回带的 `_mo_id`(读侧 _merge_map_objectives 写入)优先级最高
|
||||
for idx, (dk, i, obj) in enumerate(planned):
|
||||
try:
|
||||
mo_id = int(obj.get("_mo_id")) if obj.get("_mo_id") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
mo_id = None
|
||||
if mo_id is not None and mo_id in by_id and mo_id not in used:
|
||||
assigned[idx] = by_id[mo_id]
|
||||
used.add(mo_id)
|
||||
|
||||
# ② 名称批:**先全表跑完名字匹配,再做位置兜底**。
|
||||
# 逐项贪心(名→序位→空位)时,改名项在名字层落空后会立刻抢占同序位行,把
|
||||
# 「同一次保存里改名 + 同层调序」的两个目标 id 串位 —— 线静默画到别的节点上,
|
||||
# 比断线更坏(看不见)。批处理顺序把名字仍有效的项先钉住,未命中的项再按顺序配对。
|
||||
for idx, (dk, i, obj) in enumerate(planned):
|
||||
if idx in assigned:
|
||||
continue
|
||||
want = _norm_obj_name(obj.get("name"))
|
||||
cand = [r for r in rows
|
||||
if r.id not in used and r.dimension_key == dk
|
||||
and _norm_obj_name(r.name) == want]
|
||||
if cand:
|
||||
cand.sort(key=lambda r: (r.sort_order or 0, r.id)) # 同名重复取最靠前,结果确定
|
||||
assigned[idx] = cand[0]
|
||||
used.add(cand[0].id)
|
||||
|
||||
# ③ (口径 B · ASK-20261023-04 任总 2026-10-23 15:13「放行 4」)**不做位置兜底**。
|
||||
# 老通道(不带 `_mo_id`)下「删一个 + 加一个」与「改名」数据上不可区分:按位置配对
|
||||
# 在其中一种情况下必然静默错绑(新项继承被删项的锚点,线正常画着却是错的)。
|
||||
# 错绑比断线更坏 —— 断线在读侧 `unresolved` 里可见、可计数。故:
|
||||
# · 名字未命中的项 → 一律当新目标插入(新行 id);
|
||||
# · 未被占用的存量行 → 一律当已删除删除;
|
||||
# · 连线锚点由此指向被删行 ⇒ 读侧记 `stale_objective_id` 并计入 unresolved。
|
||||
# UI 通道(客户端回带 `_mo_id`)改名仍走 ① 精确保住锚点,本口径不影响它。
|
||||
# 逐项贪心(名→序位→空位)的串位反证见 tests/test_conn_key_rename_stability.py
|
||||
# 的 test_negative_greedy_matching_would_crossbind。
|
||||
|
||||
created = 0
|
||||
keep = []
|
||||
for idx, (dk, i, obj) in enumerate(planned):
|
||||
target = assigned.get(idx)
|
||||
if target is None:
|
||||
target = MapObjective(map_id=m.id, entity_id=entity_id, dimension_key=dk or "finance")
|
||||
db.add(target)
|
||||
created += 1
|
||||
target.entity_id = entity_id
|
||||
target.dimension_key = dk
|
||||
target.name = (obj.get("name") or "")[:200]
|
||||
target.description = obj.get("description") or ""
|
||||
target.icon = obj.get("icon") or "target"
|
||||
target.kpis = obj.get("kpis") or []
|
||||
target.sort_order = i
|
||||
keep.append(target)
|
||||
|
||||
db.flush() # 新增行拿到 id 后才有 id 可比
|
||||
deleted = 0
|
||||
for r in rows:
|
||||
if r.id not in used:
|
||||
db.delete(r)
|
||||
deleted += 1
|
||||
db.commit()
|
||||
return {"planned": len(planned), "created": created, "deleted": deleted,
|
||||
"kept": len(used)}
|
||||
|
||||
|
||||
def _merge_map_objectives(m, db):
|
||||
@@ -698,6 +1156,9 @@ def _merge_map_objectives(m, db):
|
||||
"icon": o.icon or existing.get("icon", "target"),
|
||||
"kpis": o.kpis or existing.get("kpis", []),
|
||||
**{k: existing[k] for k in existing if k not in ("name", "description", "icon", "kpis")},
|
||||
# P3:回写本表行 id(连线锚点的落点)。只在读响应里出现,不落 dimensions JSON;
|
||||
# 客户端原样带回保存时,_sync_map_objectives 据此就地更新、保住行 id。
|
||||
"_mo_id": o.id,
|
||||
})
|
||||
dim["objectives"] = merged
|
||||
m.dimensions = dims
|
||||
@@ -1025,6 +1486,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
total_ok = 0
|
||||
total_warn = 0
|
||||
total_err = 0
|
||||
total_hold = 0 # C1 数据待核计数(此前漏初始化 → 命中 hold_reason 即 UnboundLocalError → review 500)
|
||||
total_obj_count = 0
|
||||
focus_items = []
|
||||
|
||||
@@ -1164,6 +1626,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
"green": total_ok,
|
||||
"yellow": total_warn,
|
||||
"red": total_err,
|
||||
"hold": total_hold,
|
||||
"health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0,
|
||||
},
|
||||
"focus_items": focus_items,
|
||||
|
||||
+7
-3
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db, get_db
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions, ar_aging, charter
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth, require_auth_401
|
||||
@@ -40,6 +40,10 @@ app.include_router(auth.router)
|
||||
app.include_router(kpis.router)
|
||||
app.include_router(kpi_governance.router)
|
||||
app.include_router(templates.router)
|
||||
# ⚠️ 顺序要求:完备度/摘要条路由必须先于 maps/objectives 等「/{map_id}/...」通配路由注册,
|
||||
# 否则 /maps/completeness/objectives 会被 /{map_id}/objectives 抢先匹配(map_id="completeness" → 422)。
|
||||
app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
|
||||
app.include_router(map_summary.router) # 战略地图摘要条(收入分线/新式毛利率/管理指标 · 口径 CMA-A1 终稿)
|
||||
app.include_router(maps.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(data.router)
|
||||
@@ -56,8 +60,6 @@ app.include_router(action_plans.router)
|
||||
app.include_router(alignment.router)
|
||||
app.include_router(org.router)
|
||||
app.include_router(objectives.router)
|
||||
app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
|
||||
app.include_router(map_summary.router) # 战略地图摘要条(收入分线/新式毛利率/管理指标 · 口径 CMA-A1 终稿)
|
||||
app.include_router(versions.router)
|
||||
app.include_router(budget.router)
|
||||
app.include_router(value_sources.router)
|
||||
@@ -103,7 +105,9 @@ app.include_router(tax_compliance.router)
|
||||
app.include_router(verify.router)
|
||||
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
|
||||
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
|
||||
app.include_router(ar_aging.router) # 应收账龄看板(T2 2026-10-20:逐户·六档·三口径禁合计)
|
||||
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
|
||||
app.include_router(charter.router) # 战略基座:战略章程(使命/愿景/价值观,P0 2026-10-21)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
from app.models.product_sales import ProductSales
|
||||
from app.models.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger
|
||||
from app.models.report_opening import ReportOpeningBalance
|
||||
from app.models.strategy_charter import StrategyCharter
|
||||
from app.models.treasury import (
|
||||
TreasuryAccount, TreasuryBalance, TreasuryControlRule,
|
||||
TreasuryPayment, TreasuryControlHit, TreasuryLimit,
|
||||
@@ -166,6 +167,8 @@ class KPIAlert(Base):
|
||||
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
|
||||
alert_message = Column(String(500), nullable=False)
|
||||
alert_type = Column(String(30), default="actual", comment="actual/forecast — 实际值超限/预测值超限")
|
||||
dedupe_key = Column(String(160), nullable=True,
|
||||
comment="生成侧幂等键 period|scope|ident;仅 alert_type=overdue 填充,历史行为 NULL")
|
||||
suggestion = Column(Text, nullable=True, comment="情景建议")
|
||||
action_plan_linked_id = Column(Integer, nullable=True, comment="关联的改善计划ID")
|
||||
status = Column(String(20), default="pending", comment="pending/processing/resolved")
|
||||
@@ -363,6 +366,37 @@ class MapObjective(Base):
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
class MapConnection(Base):
|
||||
"""战略地图四层因果连线(2026-10-21 P1:canvas_data.connections → 独立表)
|
||||
|
||||
迁移来源 = `strategic_maps.canvas_data->connections`,双写期表与 JSON 并存
|
||||
(JSON 保留供前端渲染;表供归因/校验/分析)。
|
||||
|
||||
节点标识双兼容(生产实况两种格式并存):
|
||||
- `dim:中文目标名`(生产存量实际格式,如 `learning:贡献率思维全员培训`)
|
||||
- `dim-序号`(当前前端 MapCanvas 生成的格式,如 `learning-0`)
|
||||
原始串原样保存在 from_node/to_node,解析结果落 from_layer/to_layer/from_objective_id。
|
||||
"""
|
||||
__tablename__ = "map_connections"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("map_id", "from_node", "to_node", name="uk_map_conn_edge"),
|
||||
)
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, index=True, comment="企业ID(冗余,便于按账套直查)")
|
||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, index=True, comment="关联战略地图")
|
||||
from_node = Column(String(255), nullable=False, comment="源节点原始标识(原样保留)")
|
||||
to_node = Column(String(255), nullable=False, comment="目标节点原始标识(原样保留)")
|
||||
from_layer = Column(String(20), nullable=True, comment="解析层: learning/process/customer/finance")
|
||||
to_layer = Column(String(20), nullable=True, comment="解析层: learning/process/customer/finance")
|
||||
from_objective_id = Column(Integer, ForeignKey("map_objectives.id", ondelete="SET NULL"), nullable=True, comment="源目标ID(可解析时回填)")
|
||||
to_objective_id = Column(Integer, ForeignKey("map_objectives.id", ondelete="SET NULL"), nullable=True, comment="目标目标ID(可解析时回填)")
|
||||
effect = Column(String(20), default="positive", comment="positive/negative(存量缺省 positive)")
|
||||
label = Column(String(255), nullable=True, comment="连线标签")
|
||||
sort_order = Column(Integer, default=0, comment="画布内原始顺序(保持渲染一致)")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class KPICausality(Base):
|
||||
"""KPI因果链 — 记录KPI间的因果关系"""
|
||||
__tablename__ = "kpi_causality"
|
||||
|
||||
@@ -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,918 @@
|
||||
"""应收账龄逾期预警(`alert_rules.rule_type='ar_overdue'`)计算与落库
|
||||
|
||||
设计要点(任务 project-20261020-ar-overdue-alert-and-aging-board-fullstack):
|
||||
|
||||
* **阈值/责任人/排除名单全部来自 `alert_rules.params`(DB 行)**,本模块不出现任何
|
||||
业务阈值数字;缺键即该判据关闭(显式失效,不回落隐式默认值)。
|
||||
* **只 INSERT**:命中写 `kpi_alerts(alert_type='overdue')`,去重键为**生成侧语义键**
|
||||
`dedupe_key = period|scope|ident`(单户 `cust|<unit_code>`、聚合 `agg|<族名>`),
|
||||
由唯一索引兜底;文案口径变化不再产生重复行。历史行 `dedupe_key IS NULL` 走只读反解兼容。
|
||||
**零 UPDATE / 零 DELETE**。
|
||||
* `assignee` 必填(`assignee_by_unit[unit_code]` → `assignee_default`),两者皆空
|
||||
⇒ `AssigneeMissing`(调用方转 400,fail-closed)——禁落空 assignee 的行。
|
||||
* 口径:只读 `借方应收户口径`(`app.utils.ar_caliber`),负档位不抹平。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import logging
|
||||
import re
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import KPIAlert
|
||||
from app.utils import ar_caliber
|
||||
|
||||
logger = logging.getLogger("ar_overdue_alert")
|
||||
|
||||
RULE_TYPE = "ar_overdue"
|
||||
ALERT_TYPE = "overdue"
|
||||
MESSAGE_MAX = 500
|
||||
|
||||
_CENT = Decimal("0.01")
|
||||
|
||||
|
||||
class AssigneeMissing(Exception):
|
||||
"""责任人未配置(fail-closed:拒绝运行,不落空 assignee 行)。"""
|
||||
|
||||
|
||||
class ParamsInvalid(Exception):
|
||||
"""规则参数结构非法。"""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 参数校验(只校结构,不设默认阈值)
|
||||
# ============================================================
|
||||
def validate_params(params: Any) -> Optional[str]:
|
||||
"""返回错误文案;None = 合法。"""
|
||||
if params is None:
|
||||
return "params 不能为空(ar_overdue 规则参数全量走 DB,代码不设业务默认值)"
|
||||
if not isinstance(params, dict):
|
||||
return "params 必须是 JSON 对象"
|
||||
try:
|
||||
ar_caliber.validate_caliber(params.get("caliber") or ar_caliber.DEFAULT_CALIBER)
|
||||
except ar_caliber.CaliberError as e:
|
||||
return str(e)
|
||||
for key in ("red", "yellow"):
|
||||
# 2026-10-21 阶段B:G5 新参数形状为扁平键(share_90_150_* 等),red/yellow 分组可缺省;
|
||||
# 存在则必须是对象(防误传标量导致阈值静默失效)。
|
||||
if params.get(key) is not None and not isinstance(params.get(key), dict):
|
||||
return "params.%s 必须是对象(阈值分组)" % key
|
||||
for key in ("assignee_by_unit", "action_texts"):
|
||||
if key in params and params[key] is not None and not isinstance(params[key], dict):
|
||||
return "params.%s 必须是对象" % key
|
||||
if "exclude_unit_codes" in params and params["exclude_unit_codes"] is not None:
|
||||
v = params["exclude_unit_codes"]
|
||||
if not isinstance(v, (list, tuple)):
|
||||
return "params.exclude_unit_codes 必须是数组(排他名单)"
|
||||
return None
|
||||
|
||||
|
||||
def _params_caliber(params: Dict[str, Any]) -> str:
|
||||
return params.get("caliber") or ar_caliber.DEFAULT_CALIBER
|
||||
|
||||
|
||||
def _action_text(params: Dict[str, Any], level: str) -> str:
|
||||
texts = params.get("action_texts") or {}
|
||||
return str(texts.get(level) or texts.get("default") or "")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# assignee 解析(任务 §6.2 定值;全部只读 SELECT,禁止落空 assignee 行)
|
||||
# 优先级:assignee_by_unit 手工点名 → 近 3 个月主业务员 → 部门负责人
|
||||
# 两路皆失败 ⇒ 该户不生成预警,进 `skipped[]`(含原因)
|
||||
# ============================================================
|
||||
ASSIGNEE_MODES = ("salesman_then_dept", "manual_then_salesman_then_dept", "dept_only")
|
||||
|
||||
|
||||
def _source_cfg(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
src = params.get("assignee_source")
|
||||
return src if isinstance(src, dict) else {}
|
||||
|
||||
|
||||
def _assignee_by_unit(params: Dict[str, Any], unit_code: Optional[str]) -> Optional[str]:
|
||||
by_unit = params.get("assignee_by_unit") or {}
|
||||
if unit_code is None:
|
||||
return None
|
||||
v = by_unit.get(str(unit_code))
|
||||
return str(v).strip() if v else None
|
||||
|
||||
|
||||
def ensure_assignee_configured(params: Dict[str, Any]) -> None:
|
||||
"""责任人来源整体缺失 ⇒ 400 fail-closed(§4.8 负测)。
|
||||
|
||||
§6.2 允许逐户派生(主业务员 → 部门负责人),因此「配了 assignee_source.mode」
|
||||
也算已配置;派生失败的**单户**进 skipped[],不落空 assignee 行。
|
||||
完全没有任何来源(无 default / 无 by_unit / 无 mode)才拒绝整体运行。
|
||||
"""
|
||||
if str(params.get("assignee_default") or "").strip():
|
||||
return
|
||||
if params.get("assignee_by_unit"):
|
||||
return
|
||||
if str(_source_cfg(params).get("mode") or "") in ASSIGNEE_MODES:
|
||||
return
|
||||
raise AssigneeMissing(
|
||||
"ar_overdue 规则未配置任何责任人来源:params.assignee_default / assignee_by_unit / "
|
||||
"assignee_source.mode 均为空,按 fail-closed 拒绝运行(禁止落空 assignee 的预警行)"
|
||||
)
|
||||
|
||||
|
||||
def _has_table(db: Session, name: str) -> bool:
|
||||
"""表是否可读(方言无关,不依赖 information_schema)。"""
|
||||
try:
|
||||
db.execute(text("SELECT 1 FROM %s LIMIT 1" % name))
|
||||
return True
|
||||
except Exception: # noqa: BLE001 — 缺表/无权限一律降级
|
||||
return False
|
||||
|
||||
|
||||
def _employee_name(db: Session, emp_id: Any) -> Optional[str]:
|
||||
"""employee_dict.u_Name(只读)。缺表/无该 id ⇒ None(调用方继续降级)。"""
|
||||
try:
|
||||
emp = int(emp_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if emp <= 0 or not _has_table(db, ar_caliber.EMPLOYEE_DICT_TABLE):
|
||||
return None
|
||||
try:
|
||||
row = db.execute(
|
||||
text("SELECT u_Name FROM %s WHERE id = :i" % ar_caliber.EMPLOYEE_DICT_TABLE),
|
||||
{"i": emp},
|
||||
).first()
|
||||
return str(row[0]).strip() if row and row[0] else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _operator_from_abst(abst: Any) -> Optional[str]:
|
||||
"""单据摘要尾段制单人姓名(`…:姓名` / `…元;姓名`)——employee_dict 缺表时的同库只读替代源。
|
||||
|
||||
摘要里常混入金额/银行/备注(如 `…【建设银行】270000.00元;兀晨博`),因此逐段
|
||||
取尾段后做**形状校验**:纯中文姓名、长度 ≤ 20、不含数字/括号/金额符号,否则判失败。
|
||||
"""
|
||||
if not abst:
|
||||
return None
|
||||
s = str(abst).strip()
|
||||
for sep in (":", ";"):
|
||||
if sep in s:
|
||||
s = s.rsplit(sep, 1)[-1]
|
||||
s = s.strip(" \u3000;;")
|
||||
if not s or len(s) > 20:
|
||||
return None
|
||||
if any(ch.isdigit() for ch in s):
|
||||
return None
|
||||
if any(ch in s for ch in "【】[]()()元,,.。::"):
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def _period_window(period: str, months: int) -> Tuple[str, str]:
|
||||
"""账龄期 → 业务员回看窗口 [期末前 (months-1) 个月 1 日, 期末]。"""
|
||||
y, m = int(period[:4]), int(period[5:7])
|
||||
ly, lm = y, m - (months - 1)
|
||||
while lm <= 0:
|
||||
lm += 12
|
||||
ly -= 1
|
||||
end_day = calendar.monthrange(y, m)[1]
|
||||
return "%04d-%02d-01" % (ly, lm), "%04d-%02d-%02d" % (y, m, end_day)
|
||||
|
||||
|
||||
def _resolve_unit_ref(db: Session, entity_id: int, unit_code: Any, unit_name: Any,
|
||||
start: str, end: str) -> Tuple[Optional[int], str, str]:
|
||||
"""unit_code → zxt_masterbill.unit_id。返回 (unit_id, 来源, 失败原因)。"""
|
||||
code = str(unit_code or "").strip()
|
||||
if _has_table(db, ar_caliber.UNITS_DICT_TABLE):
|
||||
for code_col in ("unit_code", "u_code", "code"):
|
||||
try:
|
||||
row = db.execute(
|
||||
text("SELECT unit_id FROM %s WHERE %s = :c"
|
||||
% (ar_caliber.UNITS_DICT_TABLE, code_col)),
|
||||
{"c": code},
|
||||
).first()
|
||||
if row and row[0] is not None:
|
||||
return int(row[0]), "units_dict.%s" % code_col, ""
|
||||
except Exception: # noqa: BLE001 — 列名不匹配则试下一列
|
||||
continue
|
||||
if code.isdigit():
|
||||
return int(code), "numeric_code", ""
|
||||
if unit_name:
|
||||
try:
|
||||
row = db.execute(
|
||||
text("SELECT unit_id, COUNT(*) AS n FROM %s WHERE entity_id = :e "
|
||||
"AND abst LIKE :kw AND bill_date >= :s AND bill_date <= :d "
|
||||
"GROUP BY unit_id ORDER BY n DESC LIMIT 1" % ar_caliber.ZXT_MASTERBILL_TABLE),
|
||||
{"e": entity_id, "kw": "%%%s%%" % unit_name, "s": start, "d": end},
|
||||
).first()
|
||||
if row and row[0] is not None:
|
||||
return int(row[0]), "abst_name_match", ""
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return None, "", "unit_link_unresolved(code=%s)" % (code or "-")
|
||||
|
||||
|
||||
def _salesman_name(db: Session, entity_id: int, unit_id: int, start: str, end: str,
|
||||
allow_abst_fallback: bool) -> Tuple[Optional[str], Optional[int], str]:
|
||||
"""该户近窗主业务员(单据数最多者;同数取最近单据)。"""
|
||||
try:
|
||||
row = db.execute(
|
||||
text("SELECT emp_id, COUNT(*) AS n FROM %s WHERE entity_id = :e AND unit_id = :u "
|
||||
"AND bill_date >= :s AND bill_date <= :d AND emp_id IS NOT NULL AND emp_id > 0 "
|
||||
"GROUP BY emp_id ORDER BY n DESC, MAX(bill_date) DESC LIMIT 1"
|
||||
% ar_caliber.ZXT_MASTERBILL_TABLE),
|
||||
{"e": entity_id, "u": unit_id, "s": start, "d": end},
|
||||
).first()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, None, "salesman_query_failed:%s" % type(exc).__name__
|
||||
if not row or row[0] is None:
|
||||
return None, None, "no_recent_bill(unit_id=%s)" % unit_id
|
||||
emp_id = int(row[0])
|
||||
name = _employee_name(db, emp_id)
|
||||
if name:
|
||||
return name, emp_id, ""
|
||||
if not allow_abst_fallback:
|
||||
return None, emp_id, "salesman_name_unresolved(emp_id=%s, employee_dict 缺表)" % emp_id
|
||||
try:
|
||||
row2 = db.execute(
|
||||
text("SELECT abst FROM %s WHERE entity_id = :e AND unit_id = :u AND emp_id = :m "
|
||||
"AND bill_date >= :s AND bill_date <= :d ORDER BY bill_date DESC, id DESC LIMIT 1"
|
||||
% ar_caliber.ZXT_MASTERBILL_TABLE),
|
||||
{"e": entity_id, "u": unit_id, "m": emp_id, "s": start, "d": end},
|
||||
).first()
|
||||
name = _operator_from_abst(row2[0] if row2 else None)
|
||||
except Exception: # noqa: BLE001
|
||||
name = None
|
||||
if name:
|
||||
return name, emp_id, ""
|
||||
return None, emp_id, "salesman_name_unresolved(emp_id=%s, employee_dict 缺表且摘要无姓名)" % emp_id
|
||||
|
||||
|
||||
def _dept_assignee(db: Session, unit_code: Any,
|
||||
params: Dict[str, Any]) -> Tuple[Optional[str], str]:
|
||||
"""部门负责人兜底(§6.2:渠道 118 / 商务 114 / 直属酒窖 117)。
|
||||
|
||||
部门归属无法自动判定时**不猜**:要求 params.assignee_source.dept_by_unit /
|
||||
dept_by_prefix / dept_default 之一给出部门键,否则返回原因。
|
||||
"""
|
||||
src = _source_cfg(params)
|
||||
fb = src.get("dept_fallback") or {}
|
||||
code = str(unit_code or "")
|
||||
key = (src.get("dept_by_unit") or {}).get(code) or src.get("dept_default")
|
||||
if key is None:
|
||||
for prefix, k in (src.get("dept_by_prefix") or {}).items():
|
||||
if code.startswith(str(prefix)):
|
||||
key = k
|
||||
break
|
||||
if key is None:
|
||||
return None, "dept_unclassified"
|
||||
if key not in fb:
|
||||
return None, "dept_key_unmapped:%s" % key
|
||||
name = _employee_name(db, fb[key])
|
||||
if name:
|
||||
return name, "dept:%s(id=%s)" % (key, fb[key])
|
||||
return None, "dept_head_name_unresolved:%s(id=%s, employee_dict 缺表)" % (key, fb[key])
|
||||
|
||||
|
||||
def resolve_assignee(db: Session, entity_id: int, unit_code: Any, unit_name: Any,
|
||||
params: Dict[str, Any], period: str) -> Dict[str, Any]:
|
||||
"""单户 assignee 解析结果:{name, source, emp_id, reason}。name=None ⇒ 不生成该户。"""
|
||||
manual = _assignee_by_unit(params, str(unit_code) if unit_code is not None else None)
|
||||
if manual:
|
||||
return {"name": manual, "source": "manual:assignee_by_unit", "emp_id": None, "reason": ""}
|
||||
|
||||
src = _source_cfg(params)
|
||||
mode = str(src.get("mode") or "")
|
||||
reasons: List[str] = []
|
||||
if mode in ASSIGNEE_MODES:
|
||||
months = int(src.get("lookback_months") or 3)
|
||||
start, end = _period_window(period, months)
|
||||
if mode != "dept_only":
|
||||
unit_id, link, link_reason = _resolve_unit_ref(
|
||||
db, entity_id, unit_code, unit_name, start, end)
|
||||
if unit_id is not None:
|
||||
name, emp_id, why = _salesman_name(
|
||||
db, entity_id, unit_id, start, end,
|
||||
bool(src.get("abst_operator_fallback", True)))
|
||||
if name:
|
||||
return {"name": name, "source": "salesman:%s(emp_id=%s, %s)"
|
||||
% (link, emp_id, "employee_dict" if _has_table(db, ar_caliber.EMPLOYEE_DICT_TABLE)
|
||||
else "abst_operator"), "emp_id": emp_id, "reason": ""}
|
||||
reasons.append(why)
|
||||
else:
|
||||
reasons.append(link_reason)
|
||||
name, why = _dept_assignee(db, unit_code, params)
|
||||
if name:
|
||||
return {"name": name, "source": why, "emp_id": None, "reason": ""}
|
||||
reasons.append(why)
|
||||
return {"name": None, "source": "", "emp_id": None, "reason": "; ".join(r for r in reasons if r)}
|
||||
|
||||
default = str(params.get("assignee_default") or "").strip()
|
||||
if default:
|
||||
return {"name": default, "source": "assignee_default", "emp_id": None, "reason": ""}
|
||||
return {"name": None, "source": "", "emp_id": None, "reason": "no_assignee_source"}
|
||||
|
||||
|
||||
def aggregate_assignee(params: Dict[str, Any]) -> Tuple[Optional[str], str]:
|
||||
"""聚合预警的责任人(整体口径):assignee_default → assignee_source.aggregate_assignee。"""
|
||||
d = str(params.get("assignee_default") or "").strip()
|
||||
if d:
|
||||
return d, "assignee_default"
|
||||
a = str(_source_cfg(params).get("aggregate_assignee") or "").strip()
|
||||
if a:
|
||||
return a, "assignee_source.aggregate_assignee"
|
||||
return None, "aggregate_assignee_unresolved"
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 取数与计算
|
||||
# ============================================================
|
||||
def _dec(v: Any) -> Decimal:
|
||||
if v is None:
|
||||
return Decimal("0")
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
def _money(v: Decimal) -> float:
|
||||
return float(v.quantize(_CENT, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _fmt_money(v: Decimal) -> str:
|
||||
return "{:,.2f}".format(v.quantize(_CENT, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _fmt_pct(ratio: Optional[float]) -> str:
|
||||
if ratio is None:
|
||||
return "-"
|
||||
return "{:.2f}".format(ratio * 100.0)
|
||||
|
||||
|
||||
_SELECT_COLS = (
|
||||
"unit_code, unit_name, opening_amt, total_amt, "
|
||||
+ ", ".join(ar_caliber.BUCKET_COLUMNS)
|
||||
)
|
||||
|
||||
|
||||
def _periods(db: Session, entity_id: int, caliber: str) -> List[str]:
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT DISTINCT period FROM %s WHERE entity_id = :e AND caliber = :c ORDER BY period"
|
||||
% ar_caliber.AR_AGING_TABLE
|
||||
),
|
||||
{"e": entity_id, "c": caliber},
|
||||
).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _rows_for_period(db: Session, entity_id: int, caliber: str, period: str) -> List[Dict[str, Any]]:
|
||||
sql = (
|
||||
"SELECT %s FROM %s WHERE entity_id = :e AND caliber = :c AND period = :p "
|
||||
"ORDER BY total_amt DESC, unit_code ASC" % (_SELECT_COLS, ar_caliber.AR_AGING_TABLE)
|
||||
)
|
||||
return [dict(r) for r in db.execute(text(sql), {"e": entity_id, "c": caliber, "p": period}).mappings().all()]
|
||||
|
||||
|
||||
def _oldest_overdue_days(row: Dict[str, Any]) -> int:
|
||||
"""最老非零逾期档的下界天数(31/61/91/121/151);无逾期 = 0。"""
|
||||
days = 0
|
||||
for col, lower in ar_caliber.OVERDUE_BUCKETS:
|
||||
if _dec(row.get(col)) != 0:
|
||||
days = max(days, lower)
|
||||
return days
|
||||
|
||||
|
||||
def _level_share(share: Optional[float], params: Dict[str, Any]) -> Optional[str]:
|
||||
red = params.get("red") or {}
|
||||
yellow = params.get("yellow") or {}
|
||||
gt = red.get("share_90plus_gt")
|
||||
if gt is not None and share is not None and share > float(gt):
|
||||
return "red"
|
||||
ymin, ymax = yellow.get("share_90plus_min"), yellow.get("share_90plus_max")
|
||||
if share is not None and ymin is not None and ymax is not None:
|
||||
if float(ymin) <= share <= float(ymax):
|
||||
return "yellow"
|
||||
return None
|
||||
|
||||
|
||||
def _level_mom(mom_pct: Optional[float], params: Dict[str, Any]) -> Optional[str]:
|
||||
red = params.get("red") or {}
|
||||
gte = red.get("count_mom_pct_gte")
|
||||
if gte is not None and mom_pct is not None and mom_pct >= float(gte):
|
||||
return "red"
|
||||
return None
|
||||
|
||||
|
||||
def _level_customer(overdue_amt: Decimal, oldest_days: int, params: Dict[str, Any]) -> Optional[str]:
|
||||
red = params.get("red") or {}
|
||||
yellow = params.get("yellow") or {}
|
||||
# 阶段B G5 扁平参数:顶层 single_overdue_amt_gt 优先(未配 red 分组时仍可判红线)
|
||||
red_amt = red.get("single_overdue_amt_gt")
|
||||
if red_amt is None:
|
||||
red_amt = params.get("single_overdue_amt_gt")
|
||||
red_min_days = red.get("single_overdue_min_days")
|
||||
if red_amt is not None and overdue_amt > _dec(red_amt):
|
||||
if red_min_days is None or oldest_days >= int(red_min_days):
|
||||
return "red"
|
||||
ymin = yellow.get("single_overdue_amt_min")
|
||||
ymax = yellow.get("single_overdue_amt_max")
|
||||
if ymin is not None and ymax is not None:
|
||||
if _dec(ymin) <= overdue_amt <= _dec(ymax):
|
||||
return "yellow"
|
||||
return None
|
||||
|
||||
|
||||
def _clip(msg: str) -> str:
|
||||
return msg if len(msg) <= MESSAGE_MAX else msg[: MESSAGE_MAX - 1] + "…"
|
||||
|
||||
|
||||
def compute(
|
||||
db: Session,
|
||||
entity_id: int,
|
||||
period: Optional[str] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""计算逾期预警命中集合(纯读,不写库)。
|
||||
|
||||
返回:
|
||||
{period, caliber, metrics, aggregates[], per_customer[], skipped[], skipped_reason, notes[]}
|
||||
`skipped_reason` 非空表示本次不生成任何预警(缺期/无数据/分母非正…)。
|
||||
`skipped[]` 记录 assignee 解析失败而未生成的户/聚合项(§6.2 禁落空 assignee)。
|
||||
"""
|
||||
params = params or {}
|
||||
err = validate_params(params)
|
||||
if err:
|
||||
raise ParamsInvalid(err)
|
||||
caliber = _params_caliber(params)
|
||||
notes: List[str] = []
|
||||
out: Dict[str, Any] = {
|
||||
"period": period,
|
||||
"caliber": caliber,
|
||||
"metrics": {},
|
||||
"aggregates": [],
|
||||
"per_customer": [],
|
||||
"skipped": [],
|
||||
"skipped_reason": None,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
# ── 期间解析:默认表内 MAX(period);缺期直接 skipped(不补 0)──
|
||||
periods = _periods(db, entity_id, caliber)
|
||||
if not periods:
|
||||
out["skipped_reason"] = "no_data_in_table:%s/%s" % (entity_id, caliber)
|
||||
return out
|
||||
if not period:
|
||||
period = periods[-1]
|
||||
out["period"] = period
|
||||
notes.append("period 未指定 → 取表内 MAX(period)=%s" % period)
|
||||
if period not in periods:
|
||||
out["skipped_reason"] = "no_data_for_period:%s" % period
|
||||
return out
|
||||
|
||||
rows = _rows_for_period(db, entity_id, caliber, period)
|
||||
if not rows:
|
||||
out["skipped_reason"] = "no_data_for_period:%s" % period
|
||||
return out
|
||||
|
||||
exclude = set(params.get("exclude_unit_codes") or [])
|
||||
if exclude:
|
||||
before = len(rows)
|
||||
rows = [r for r in rows if str(r.get("unit_code")) not in exclude]
|
||||
notes.append("排除名单命中 %d 行(exclude_unit_codes)" % (before - len(rows)))
|
||||
if not rows:
|
||||
out["skipped_reason"] = "all_rows_excluded"
|
||||
return out
|
||||
|
||||
total = sum((_dec(r.get("total_amt")) for r in rows), Decimal("0"))
|
||||
overdue_90plus = sum((_dec(r.get(c)) for r in rows for c in ar_caliber.NINETY_PLUS_COLUMNS), Decimal("0"))
|
||||
share = (float(overdue_90plus / total) if total > 0 else None)
|
||||
if total <= 0:
|
||||
notes.append("分母 SUM(total_amt)=%.2f ≤ 0 → 跳过聚合占比判据(禁除零)" % float(total))
|
||||
|
||||
# 逐户:逾期额 = total_amt - b_0_30(等价 31-60..150+ 之和,负档位不抹平)
|
||||
customers: List[Dict[str, Any]] = []
|
||||
n_overdue = 0
|
||||
for r in rows:
|
||||
o_i = _dec(r.get("total_amt")) - _dec(r.get("b_0_30"))
|
||||
oldest = _oldest_overdue_days(r)
|
||||
if o_i > 0:
|
||||
n_overdue += 1
|
||||
level = _level_customer(o_i, oldest, params) if o_i > 0 else None
|
||||
customers.append({"row": r, "overdue_amt": o_i, "oldest_days": oldest, "level": level})
|
||||
|
||||
# 环比:上一个有数据期(同口径同算法);上期无数据 ⇒ 不算环比(文案写明,不得当 0)
|
||||
idx = periods.index(period)
|
||||
prev_period = periods[idx - 1] if idx > 0 else None
|
||||
n_prev = None
|
||||
prev_rows: List[Dict[str, Any]] = []
|
||||
if prev_period:
|
||||
prev_rows = _rows_for_period(db, entity_id, caliber, prev_period)
|
||||
if exclude:
|
||||
prev_rows = [r for r in prev_rows if str(r.get("unit_code")) not in exclude]
|
||||
n_prev = sum(
|
||||
1 for r in prev_rows
|
||||
if (_dec(r.get("total_amt")) - _dec(r.get("b_0_30"))) > 0
|
||||
)
|
||||
mom_pct = None
|
||||
if n_prev and n_prev > 0:
|
||||
mom_pct = (n_overdue - n_prev) / n_prev
|
||||
else:
|
||||
notes.append("上期无数据(%s)→ 不计算环比" % (prev_period or "无更早期间"))
|
||||
|
||||
metrics = {
|
||||
"period": period,
|
||||
"caliber": caliber,
|
||||
"total_amt": _money(total),
|
||||
"overdue_90plus": _money(overdue_90plus),
|
||||
"share_90plus": (round(share, 6) if share is not None else None),
|
||||
"overdue_amt": _money(sum((c["overdue_amt"] for c in customers), Decimal("0"))),
|
||||
"customers": len(rows),
|
||||
"overdue_customers": n_overdue,
|
||||
"prev_period": prev_period,
|
||||
"prev_overdue_customers": n_prev,
|
||||
"count_mom_pct": (round(mom_pct, 6) if mom_pct is not None else None),
|
||||
}
|
||||
out["metrics"] = metrics
|
||||
|
||||
# ── 聚合判据(§6.1):90 天占比(red 键 null ⇒ 停用)/ 逾期户数环比 / 150+ 结构恶化 ──
|
||||
agg_assignee, agg_src = aggregate_assignee(params)
|
||||
|
||||
def _push_agg(key: str, level: str, metrics_dict: Dict[str, Any], msg: str, action: str) -> None:
|
||||
blocked = None if agg_assignee else agg_src
|
||||
out["aggregates"].append({
|
||||
"key": key,
|
||||
"level": level,
|
||||
"metrics": metrics_dict,
|
||||
"alert_message": msg,
|
||||
"suggestion": action,
|
||||
"assignee": agg_assignee or "",
|
||||
"assignee_source": agg_src,
|
||||
"blocked": blocked,
|
||||
})
|
||||
if blocked:
|
||||
out["skipped"].append({"scope": "aggregate", "key": key, "level": level,
|
||||
"reason": blocked})
|
||||
|
||||
red_cfg = params.get("red") or {}
|
||||
yellow_cfg = params.get("yellow") or {}
|
||||
red_gt = red_cfg.get("share_90plus_gt")
|
||||
if share is not None and red_gt is None:
|
||||
notes.append("red.share_90plus_gt 为 null ⇒ 90 天占比红线停用(不回落 0.25)")
|
||||
|
||||
# 聚合判据 1:90 天以上占比(红线停用时只剩黄线 15%–25%)
|
||||
if share is not None:
|
||||
level = _level_share(share, params)
|
||||
if level:
|
||||
action = _action_text(params, level)
|
||||
thr = ("红线 >%g%%" % (float(red_gt) * 100)) if red_gt is not None else "红线停用(DB 置 null)"
|
||||
msg = _clip(
|
||||
"【应收账款逾期】%s %s 90 天以上 ¥%s,占应收总额 %s%%(%s)|建议:%s"
|
||||
% (period, caliber, _fmt_money(overdue_90plus), _fmt_pct(share), thr,
|
||||
action or "见规则配置")
|
||||
)
|
||||
_push_agg("share_90plus", level,
|
||||
{"overdue_90plus": _money(overdue_90plus), "total_amt": _money(total),
|
||||
"share_90plus": round(share, 6)},
|
||||
msg, action)
|
||||
|
||||
# 聚合判据 2:逾期户数环比
|
||||
level = _level_mom(mom_pct, params)
|
||||
if level:
|
||||
action = _action_text(params, level)
|
||||
gte = red_cfg.get("count_mom_pct_gte")
|
||||
msg = _clip(
|
||||
"【应收账款逾期】%s 逾期户数 %d 户,环比 %s%%(红线 +%s%%)|建议:%s"
|
||||
% (period, n_overdue,
|
||||
("%+.2f" % (mom_pct * 100)) if mom_pct is not None else "-",
|
||||
("%g" % (float(gte) * 100)) if gte is not None else "-",
|
||||
action or "见规则配置")
|
||||
)
|
||||
_push_agg("count_mom", level,
|
||||
{"overdue_customers": n_overdue, "prev_overdue_customers": n_prev,
|
||||
"count_mom_pct": (round(mom_pct, 6) if mom_pct is not None else None)},
|
||||
msg, action)
|
||||
|
||||
# 聚合判据 3:账龄结构恶化(G5 2026-10-21 裁定)
|
||||
# · 新判据(参数化):90–150 天「前池」占比环比上升 ≥ rise_pp_min **且** 该档余额 ≥ amt_min(AND)
|
||||
# · 兼容旧参数:未配 share_90_150_rise_pp_min 时,沿用 150+ 占比环比上升(red.structure_worsen)
|
||||
share_90_150 = None
|
||||
prev_share_90_150 = None
|
||||
amt_90_150 = None
|
||||
prev_amt_90_150 = None
|
||||
if total > 0:
|
||||
amt_90_150 = sum((_dec(r.get("b_90_120")) + _dec(r.get("b_120_150")) for r in rows),
|
||||
Decimal("0"))
|
||||
share_90_150 = float(amt_90_150 / total)
|
||||
if prev_rows:
|
||||
prev_total_90150 = sum((_dec(r.get("total_amt")) for r in prev_rows), Decimal("0"))
|
||||
prev_amt_90_150 = sum((_dec(r.get("b_90_120")) + _dec(r.get("b_120_150")) for r in prev_rows),
|
||||
Decimal("0"))
|
||||
if prev_total_90150 > 0:
|
||||
prev_share_90_150 = float(prev_amt_90_150 / prev_total_90150)
|
||||
|
||||
rise_pp_min = params.get("share_90_150_rise_pp_min")
|
||||
amt_min_90_150 = params.get("share_90_150_amt_min")
|
||||
if rise_pp_min is not None and amt_min_90_150 is not None:
|
||||
delta_pp = None
|
||||
if share_90_150 is not None and prev_share_90_150 is not None:
|
||||
delta_pp = (share_90_150 - prev_share_90_150) * 100.0
|
||||
hit_90150 = (
|
||||
delta_pp is not None and delta_pp >= float(rise_pp_min)
|
||||
and amt_90_150 is not None and amt_90_150 >= _dec(amt_min_90_150)
|
||||
)
|
||||
if hit_90150:
|
||||
action = _action_text(params, "red")
|
||||
msg = _clip(
|
||||
"【应收账款逾期】%s 90–150 天档占比 %s%%(上期 %s%%)环比 %+.2fpp ≥ %gpp,"
|
||||
"该档余额 ¥%s(≥¥%s),账龄前池恶化|建议:%s"
|
||||
% (period, _fmt_pct(share_90_150), _fmt_pct(prev_share_90_150), delta_pp,
|
||||
float(rise_pp_min), _fmt_money(amt_90_150), _fmt_money(_dec(amt_min_90_150)),
|
||||
action or "见规则配置")
|
||||
)
|
||||
_push_agg("structure_90_150", "red",
|
||||
{"share_90_150": round(share_90_150, 6),
|
||||
"prev_period": prev_period,
|
||||
"prev_share_90_150": (round(prev_share_90_150, 6)
|
||||
if prev_share_90_150 is not None else None),
|
||||
"delta_pp": round(delta_pp, 4),
|
||||
"amt_90_150": _money(amt_90_150),
|
||||
"rise_pp_min": float(rise_pp_min),
|
||||
"amt_min": float(amt_min_90_150)},
|
||||
msg, action)
|
||||
else:
|
||||
notes.append(
|
||||
"90–150 档:本期 %s%% / 上期 %s%%(Δ=%spp,门槛 ≥%gpp)|余额 ¥%s(门槛 ≥¥%s)"
|
||||
"⇒ 结构恶化判据未触发"
|
||||
% (_fmt_pct(share_90_150), _fmt_pct(prev_share_90_150),
|
||||
("%+.2f" % delta_pp) if delta_pp is not None else "-",
|
||||
float(rise_pp_min),
|
||||
_fmt_money(amt_90_150) if amt_90_150 is not None else "-",
|
||||
_fmt_money(_dec(amt_min_90_150))))
|
||||
metrics["share_90_150"] = (round(share_90_150, 6) if share_90_150 is not None else None)
|
||||
metrics["prev_share_90_150"] = (round(prev_share_90_150, 6)
|
||||
if prev_share_90_150 is not None else None)
|
||||
metrics["amt_90_150"] = (_money(amt_90_150) if amt_90_150 is not None else None)
|
||||
else:
|
||||
share_150 = None
|
||||
prev_share_150 = None
|
||||
if total > 0:
|
||||
share_150 = float(sum((_dec(r.get("b_150p")) for r in rows), Decimal("0")) / total)
|
||||
if prev_rows:
|
||||
prev_total = sum((_dec(r.get("total_amt")) for r in prev_rows), Decimal("0"))
|
||||
if prev_total > 0:
|
||||
prev_share_150 = float(
|
||||
sum((_dec(r.get("b_150p")) for r in prev_rows), Decimal("0")) / prev_total)
|
||||
structure_on = bool(red_cfg.get("structure_worsen"))
|
||||
if structure_on:
|
||||
if share_150 is not None and prev_share_150 is not None and share_150 > prev_share_150:
|
||||
action = _action_text(params, "red")
|
||||
msg = _clip(
|
||||
"【应收账款逾期】%s 150+ 档占比 %s%%(上期 %s%%)环比上升 %+.2fpp,账龄结构恶化"
|
||||
"|建议:%s" % (period, _fmt_pct(share_150), _fmt_pct(prev_share_150),
|
||||
(share_150 - prev_share_150) * 100.0, action or "见规则配置")
|
||||
)
|
||||
_push_agg("structure_150p", "red",
|
||||
{"share_150p": round(share_150, 6),
|
||||
"prev_period": prev_period,
|
||||
"prev_share_150p": (round(prev_share_150, 6)
|
||||
if prev_share_150 is not None else None),
|
||||
"delta_pp": round((share_150 - prev_share_150) * 100.0, 4)},
|
||||
msg, action)
|
||||
else:
|
||||
notes.append(
|
||||
"150+ 占比:本期 %s%% / 上期 %s%% ⇒ 结构恶化判据未触发"
|
||||
% (_fmt_pct(share_150), _fmt_pct(prev_share_150)))
|
||||
metrics["share_150p"] = (round(share_150, 6) if share_150 is not None else None)
|
||||
metrics["prev_share_150p"] = (round(prev_share_150, 6)
|
||||
if prev_share_150 is not None else None)
|
||||
|
||||
# ── 逐户:assignee 派生失败 ⇒ 不生成(进 skipped[]),禁落空 assignee ──
|
||||
for c in customers:
|
||||
if not c["level"]:
|
||||
continue
|
||||
r = c["row"]
|
||||
who = resolve_assignee(db, entity_id, r.get("unit_code"), r.get("unit_name"), params, period)
|
||||
if not who["name"]:
|
||||
out["skipped"].append({
|
||||
"scope": "customer",
|
||||
"unit_code": r.get("unit_code"),
|
||||
"unit_name": r.get("unit_name"),
|
||||
"level": c["level"],
|
||||
"overdue_amt": _money(c["overdue_amt"]),
|
||||
"oldest_days": c["oldest_days"],
|
||||
"reason": who["reason"],
|
||||
})
|
||||
continue
|
||||
action = _action_text(params, c["level"])
|
||||
unit_total = _dec(r.get("total_amt"))
|
||||
pct = (float(c["overdue_amt"] / unit_total) if unit_total > 0 else None)
|
||||
msg = _clip(
|
||||
"【应收账款逾期】%s(%s)%s 账龄最老 %d 天,逾期合计 ¥%s(占该户 %s%%)|建议:%s|责任人:%s"
|
||||
% (
|
||||
r.get("unit_name"), r.get("unit_code"), period, c["oldest_days"],
|
||||
_fmt_money(c["overdue_amt"]), _fmt_pct(pct), action or "见规则配置", who["name"],
|
||||
)
|
||||
)
|
||||
out["per_customer"].append({
|
||||
"unit_code": r.get("unit_code"),
|
||||
"unit_name": r.get("unit_name"),
|
||||
"overdue_amt": _money(c["overdue_amt"]),
|
||||
"total_amt": _money(unit_total),
|
||||
"oldest_days": c["oldest_days"],
|
||||
"pct_of_unit": (round(pct, 6) if pct is not None else None),
|
||||
"level": c["level"],
|
||||
"alert_message": msg,
|
||||
"suggestion": action,
|
||||
"assignee": who["name"],
|
||||
"assignee_source": who["source"],
|
||||
"emp_id": who["emp_id"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 落库(只 INSERT;同 message 幂等跳过)
|
||||
# ============================================================
|
||||
def hits(result: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""可落库命中项:只收 assignee 非空的(§6.2 禁落空 assignee 行)。"""
|
||||
all_hits = list(result.get("aggregates") or []) + list(result.get("per_customer") or [])
|
||||
return [h for h in all_hits if str(h.get("assignee") or "").strip()]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 去重键(生成侧语义键):period|scope|ident
|
||||
# ============================================================
|
||||
_AGG_KEY_FAMILY = {
|
||||
"share_90plus": "share_90plus",
|
||||
"count_mom": "count_mom",
|
||||
"structure_90_150": "structure", # 族名,不跟随判据代次
|
||||
"structure_150p": "structure",
|
||||
}
|
||||
|
||||
_CUST_MSG_RE = re.compile(r"(([A-Za-z0-9_\-]+))(\d{4}-\d{2})")
|
||||
_PERIOD_RE = re.compile(r"(\d{4}-\d{2})")
|
||||
|
||||
|
||||
def _agg_family(key: str) -> str:
|
||||
if key in _AGG_KEY_FAMILY:
|
||||
return _AGG_KEY_FAMILY[key]
|
||||
return str(key).split("_")[0] or "unknown"
|
||||
|
||||
|
||||
def _item_dedupe_key(item: Dict[str, Any], period: str) -> Optional[str]:
|
||||
"""生成侧幂等键;不解析文案。
|
||||
|
||||
- 单户:`<period>|cust|<unit_code>`
|
||||
- 聚合:`<period>|agg|<族名>`(structure_90_150 / structure_150p 同归 structure)
|
||||
- 无法判定 → None(调用方退回文案逐字比较,方向安全)
|
||||
"""
|
||||
p = str(item.get("period") or period or "").strip()
|
||||
if not p:
|
||||
return None
|
||||
unit_code = str(item.get("unit_code") or "").strip()
|
||||
if unit_code:
|
||||
return "%s|cust|%s" % (p, unit_code)
|
||||
key = str(item.get("key") or "").strip()
|
||||
if key:
|
||||
return "%s|agg|%s" % (p, _agg_family(key))
|
||||
return None
|
||||
|
||||
|
||||
def _key_from_message(msg: str) -> Optional[str]:
|
||||
"""历史兼容:从旧文案只读反解 dedupe_key(仅用于 dedupe_key IS NULL 的行)。
|
||||
|
||||
反解失败返回 None ⇒ 视为未命中 ⇒ 照常插入(fail-open,多插一条而非吞真实告警)。
|
||||
"""
|
||||
if not msg:
|
||||
return None
|
||||
m = _CUST_MSG_RE.search(msg)
|
||||
if m:
|
||||
return "%s|cust|%s" % (m.group(2), m.group(1))
|
||||
pm = _PERIOD_RE.search(msg)
|
||||
if not pm:
|
||||
return None
|
||||
period = pm.group(1)
|
||||
if "90 天以上" in msg and "占应收总额" in msg:
|
||||
return "%s|agg|share_90plus" % period
|
||||
if "逾期户数" in msg and "环比" in msg:
|
||||
return "%s|agg|count_mom" % period
|
||||
if "账龄" in msg and ("结构" in msg or "90–150" in msg or "150 天以上" in msg):
|
||||
return "%s|agg|structure" % period
|
||||
return None
|
||||
|
||||
|
||||
def _legacy_keys(db: Session, entity_id: int, kpi_id: int) -> List[str]:
|
||||
"""取历史行(dedupe_key IS NULL)反解出的键集合(边界层,量级=历史行数)。"""
|
||||
rows = db.query(KPIAlert.alert_message).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.kpi_id == kpi_id,
|
||||
KPIAlert.alert_type == ALERT_TYPE,
|
||||
KPIAlert.dedupe_key.is_(None),
|
||||
).all()
|
||||
keys = []
|
||||
for (m,) in rows:
|
||||
k = _key_from_message(m or "")
|
||||
if k:
|
||||
keys.append(k)
|
||||
return keys
|
||||
|
||||
|
||||
def count_would_insert(db: Session, entity_id: int, kpi_id: Optional[int],
|
||||
result: Dict[str, Any]) -> int:
|
||||
"""dry-run 用:按与 write_alerts 完全相同的判重口径,算出真正会插入的条数。"""
|
||||
if kpi_id is None:
|
||||
return 0
|
||||
period = str(result.get("period") or "")
|
||||
existing = {
|
||||
k for (k,) in db.query(KPIAlert.dedupe_key).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.kpi_id == kpi_id,
|
||||
KPIAlert.alert_type == ALERT_TYPE,
|
||||
KPIAlert.dedupe_key.isnot(None),
|
||||
).all() if k
|
||||
}
|
||||
existing.update(_legacy_keys(db, entity_id, kpi_id))
|
||||
seen = set()
|
||||
n = 0
|
||||
for h in hits(result):
|
||||
msg = h.get("alert_message") or ""
|
||||
if not msg:
|
||||
continue
|
||||
dk = _item_dedupe_key(h, period)
|
||||
if dk is None:
|
||||
# 无键 ⇒ 退回文案逐字(与旧行为一致)
|
||||
n += 1
|
||||
continue
|
||||
if dk in existing or dk in seen:
|
||||
continue
|
||||
seen.add(dk)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def write_alerts(db: Session, entity_id: int, kpi_id: int, result: Dict[str, Any]) -> Dict[str, int]:
|
||||
"""把命中集合写成 `kpi_alerts(alert_type='overdue')`;历史行零触碰。
|
||||
|
||||
判重三级:① dedupe_key 命中 ② 历史 NULL 行反解键命中 ③ 无命中 → INSERT。
|
||||
并发兜底:唯一索引 + savepoint 捕获 IntegrityError 计跳过。
|
||||
"""
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
period = str(result.get("period") or "")
|
||||
existing = {
|
||||
k for (k,) in db.query(KPIAlert.dedupe_key).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.kpi_id == kpi_id,
|
||||
KPIAlert.alert_type == ALERT_TYPE,
|
||||
KPIAlert.dedupe_key.isnot(None),
|
||||
).all() if k
|
||||
}
|
||||
existing.update(_legacy_keys(db, entity_id, kpi_id))
|
||||
for h in hits(result):
|
||||
msg = h.get("alert_message") or ""
|
||||
if not msg:
|
||||
continue
|
||||
dk = _item_dedupe_key(h, period)
|
||||
if dk is not None:
|
||||
if dk in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
else:
|
||||
# 无键(理论上不会发生):退回逐字判重(旧行为)
|
||||
dup = db.query(KPIAlert.id).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.kpi_id == kpi_id,
|
||||
KPIAlert.alert_type == ALERT_TYPE,
|
||||
KPIAlert.alert_message == msg,
|
||||
).first()
|
||||
if dup:
|
||||
skipped += 1
|
||||
continue
|
||||
row = KPIAlert(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
kpi_value_id=None,
|
||||
alert_level=h.get("level") or "yellow",
|
||||
alert_message=msg,
|
||||
alert_type=ALERT_TYPE,
|
||||
dedupe_key=dk,
|
||||
suggestion=h.get("suggestion") or None,
|
||||
status="pending",
|
||||
assignee=h.get("assignee") or None,
|
||||
)
|
||||
sp = db.begin_nested()
|
||||
try:
|
||||
db.add(row)
|
||||
db.flush()
|
||||
sp.commit()
|
||||
inserted += 1
|
||||
if dk:
|
||||
existing.add(dk)
|
||||
except IntegrityError:
|
||||
sp.rollback()
|
||||
skipped += 1
|
||||
if inserted:
|
||||
db.commit()
|
||||
return {"inserted": inserted, "skipped_duplicate": skipped}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""应收账龄口径单一事实源(`hanke_ar_aging`)
|
||||
|
||||
财务已定死的口径纪律(ERRATA-02 §五 / DQ 台账 DQ-AR-03):
|
||||
|
||||
1. **三口径分列,禁跨口径合计**:`全户口径` / `借方应收户口径` / `贷方户单列(净额)`。
|
||||
字面值逐字匹配——`贷方户单列(净额)` 写成 `贷方户单列` 会漏 42 行。
|
||||
2. **对外「超期金额」只取 `借方应收户口径`**;贷方户单列不进分母,任何 `caliber=all` 请求 400。
|
||||
3. **六档互斥**:0-30 / 31-60 / 61-90 / 90-120 / 120-150 / 150+,逐行六档和 ≡ `total_amt`。
|
||||
4. `opening_amt`(期初)**只展示**,禁作账龄基数、禁进任何分子分母。
|
||||
5. 负值保留:禁 `ABS()` / `COALESCE(...,0)` 抹平(DQ-AR-04 杨创 `b_0_30=-20`)。
|
||||
"""
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# 表名(DDL:dama/_reports/prodwrite-cma-hanke-ar-aging-20261020/mirror/03_ddl.sql)
|
||||
AR_AGING_TABLE = "hanke_ar_aging"
|
||||
|
||||
# assignee 派生依赖的只读维度/事实表(§6.2)。三者均为**只读 SELECT**,不写不建。
|
||||
ZXT_MASTERBILL_TABLE = "zxt_masterbill" # 智享通单据流(unit_id / emp_id / bill_date / abst)
|
||||
EMPLOYEE_DICT_TABLE = "employee_dict" # 员工字典(id → u_Name);当前库内**未落库**
|
||||
UNITS_DICT_TABLE = "units_dict" # 往来单位字典(unit_code → unit_id);当前库内**未落库**
|
||||
|
||||
# 三口径字面值(逐字匹配,勿改写)
|
||||
CALIBER_FULL = "全户口径"
|
||||
CALIBER_DEBIT = "借方应收户口径"
|
||||
CALIBER_CREDIT_SEPARATE = "贷方户单列(净额)"
|
||||
CALIBERS: Tuple[str, ...] = (CALIBER_FULL, CALIBER_DEBIT, CALIBER_CREDIT_SEPARATE)
|
||||
|
||||
# 对外超期口径/看板默认口径
|
||||
DEFAULT_CALIBER = CALIBER_DEBIT
|
||||
|
||||
# 跨口径合计请求字面值 —— 一律 400
|
||||
CALIBER_ALL = "all"
|
||||
|
||||
# 六档列(顺序即展示顺序):(列名, 档位标签, 档位下界天数)
|
||||
BUCKETS: Tuple[Tuple[str, str, int], ...] = (
|
||||
("b_0_30", "0-30", 0),
|
||||
("b_31_60", "31-60", 31),
|
||||
("b_61_90", "61-90", 61),
|
||||
("b_90_120", "90-120", 91),
|
||||
("b_120_150", "120-150", 121),
|
||||
("b_150p", "150+", 151),
|
||||
)
|
||||
|
||||
BUCKET_COLUMNS = tuple(b[0] for b in BUCKETS)
|
||||
# 逾期档位(>30 天)列;最老档下界天数即「逾期 N 天以上」的 N
|
||||
OVERDUE_BUCKETS: Tuple[Tuple[str, int], ...] = tuple((b[0], b[2]) for b in BUCKETS if b[2] > 0)
|
||||
# 90 天以上(含 90-120 档)
|
||||
NINETY_PLUS_COLUMNS = ("b_90_120", "b_120_150", "b_150p")
|
||||
|
||||
|
||||
class CaliberError(ValueError):
|
||||
"""口径入参非法(未知值 / 跨口径合计)。"""
|
||||
|
||||
|
||||
def validate_caliber(caliber: Optional[str], *, allow_none: bool = False) -> str:
|
||||
"""校验口径字面值;`all` 与未知值一律拒绝(禁跨口径合计)。"""
|
||||
if caliber is None or caliber == "":
|
||||
if allow_none:
|
||||
return DEFAULT_CALIBER
|
||||
raise CaliberError("caliber 不能为空")
|
||||
if caliber == CALIBER_ALL:
|
||||
raise CaliberError("禁止跨口径合计(caliber=all 不提供):三口径必须分列查询")
|
||||
if caliber not in CALIBERS:
|
||||
raise CaliberError(
|
||||
"未知口径 %r;仅接受字面值:%s" % (caliber, " / ".join(CALIBERS))
|
||||
)
|
||||
return caliber
|
||||
|
||||
|
||||
def is_credit_separate(caliber: str) -> bool:
|
||||
return caliber == CALIBER_CREDIT_SEPARATE
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""kpi_alerts 去重键结构变更(幂等执行器,A2 方案)
|
||||
|
||||
用法:
|
||||
ddl_kpi_alerts_dedupe.py --check # 只读:打印列/索引是否存在(默认)
|
||||
ddl_kpi_alerts_dedupe.py --apply # 幂等执行:缺列则加列,缺索引则建索引
|
||||
ddl_kpi_alerts_dedupe.py --rollback # 回退:删索引 + 删列(数据无需恢复:本改造无 UPDATE/DELETE)
|
||||
|
||||
特性:
|
||||
* information_schema 判存在后再执行 ⇒ 重复跑安全(第二次全 NOOP)
|
||||
* 只加可空列 + 唯一索引;历史行 dedupe_key 全 NULL ⇒ 建索引必然无冲突
|
||||
* root 本机 socket 连接(与 migrate_map_connections.py 同口径),不读 .env
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
URL = "mysql+pymysql://root@localhost/cma?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4"
|
||||
TABLE = "kpi_alerts"
|
||||
COLUMN = "dedupe_key"
|
||||
INDEX = "uk_kpi_alerts_dedupe"
|
||||
|
||||
ADD_COLUMN_SQL = (
|
||||
"ALTER TABLE kpi_alerts "
|
||||
"ADD COLUMN dedupe_key VARCHAR(160) NULL "
|
||||
"COMMENT '生成侧幂等键 period|scope|ident,仅 alert_type=overdue 填充;历史行为 NULL' "
|
||||
"AFTER alert_type"
|
||||
)
|
||||
ADD_INDEX_SQL = (
|
||||
"CREATE UNIQUE INDEX uk_kpi_alerts_dedupe "
|
||||
"ON kpi_alerts (entity_id, kpi_id, alert_type, dedupe_key)"
|
||||
)
|
||||
DROP_INDEX_SQL = "DROP INDEX uk_kpi_alerts_dedupe ON kpi_alerts"
|
||||
DROP_COLUMN_SQL = "ALTER TABLE kpi_alerts DROP COLUMN dedupe_key"
|
||||
|
||||
|
||||
def col_exists(eng) -> bool:
|
||||
with eng.connect() as c:
|
||||
n = c.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
"WHERE table_schema=DATABASE() AND table_name=:t AND column_name=:c"
|
||||
), {"t": TABLE, "c": COLUMN}).scalar()
|
||||
return int(n or 0) > 0
|
||||
|
||||
|
||||
def idx_exists(eng) -> bool:
|
||||
with eng.connect() as c:
|
||||
n = c.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.statistics "
|
||||
"WHERE table_schema=DATABASE() AND table_name=:t AND index_name=:i"
|
||||
), {"t": TABLE, "i": INDEX}).scalar()
|
||||
return int(n or 0) > 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
g = ap.add_mutually_exclusive_group()
|
||||
g.add_argument("--check", action="store_true", help="只读检查(默认)")
|
||||
g.add_argument("--apply", action="store_true", help="幂等执行")
|
||||
g.add_argument("--rollback", action="store_true", help="回退结构")
|
||||
a = ap.parse_args()
|
||||
if not (a.check or a.apply or a.rollback):
|
||||
a.check = True
|
||||
|
||||
eng = create_engine(URL, future=True)
|
||||
col, idx = col_exists(eng), idx_exists(eng)
|
||||
print("[state] %s.%s=%s index %s=%s" % (TABLE, COLUMN, col, INDEX, idx))
|
||||
|
||||
if a.check:
|
||||
print("[check] %s" % ("结构就绪(无操作)" if (col and idx) else
|
||||
("缺列" if not col else "") + ("缺索引" if not idx else "")))
|
||||
return 0
|
||||
|
||||
if a.rollback:
|
||||
with eng.begin() as c:
|
||||
if idx:
|
||||
c.execute(text(DROP_INDEX_SQL))
|
||||
print("[rollback] DROP INDEX %s ✅" % INDEX)
|
||||
if col:
|
||||
c.execute(text(DROP_COLUMN_SQL))
|
||||
print("[rollback] DROP COLUMN %s ✅" % COLUMN)
|
||||
print("[state] 回退后 col=%s idx=%s" % (col_exists(eng), idx_exists(eng)))
|
||||
return 0
|
||||
|
||||
# --apply
|
||||
done = []
|
||||
if not col:
|
||||
with eng.begin() as c:
|
||||
c.execute(text(ADD_COLUMN_SQL))
|
||||
done.append("ADD COLUMN %s" % COLUMN)
|
||||
print("[apply] ADD COLUMN %s ✅" % COLUMN)
|
||||
else:
|
||||
print("[apply] column %s 已存在 → NOOP" % COLUMN)
|
||||
if not idx:
|
||||
with eng.begin() as c:
|
||||
c.execute(text(ADD_INDEX_SQL))
|
||||
done.append("CREATE INDEX %s" % INDEX)
|
||||
print("[apply] CREATE UNIQUE INDEX %s ✅" % INDEX)
|
||||
else:
|
||||
print("[apply] index %s 已存在 → NOOP" % INDEX)
|
||||
|
||||
col2, idx2 = col_exists(eng), idx_exists(eng)
|
||||
print("[state] 执行后 col=%s idx=%s" % (col2, idx2))
|
||||
payload = {"changed": done, "column_exists": col2, "index_exists": idx2}
|
||||
print("[result] %s" % payload)
|
||||
return 0 if (col2 and idx2) else 5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,524 @@
|
||||
#!/usr/bin/env python3
|
||||
"""canvas_data.connections → map_connections 迁移(P1 四层因果结构化)
|
||||
|
||||
任务:cma-map-connections-table-p1 / project-20261021-cma-map-connections-table-p1-fullstack
|
||||
|
||||
设计要点
|
||||
--------
|
||||
1. **只搬不删**:canvas_data 原样保留(双写期,前端渲染仍读 JSON)。本脚本对
|
||||
strategic_maps 只做 SELECT,任何情况下不 UPDATE。
|
||||
2. **幂等**:目标表唯一键 (map_id, from_node, to_node)。重跑 `--apply` 只补缺,
|
||||
已存在的行 SKIP;`--idem` 为只读等价复跑(断言全部已在位),零写。
|
||||
3. **双格式兼容**(生产实况):
|
||||
`dim:中文目标名` → 按 (dimension_key, 目标名) 解析 map_objectives.id
|
||||
`dim-序号` → 按 dimensions[dim].objectives 顺序解析(缺 dimensions 时退 sort_order)
|
||||
原始串原样落 from_node/to_node,解析结果落 *_layer / *_objective_id。
|
||||
4. **四态**:--check(默认拒绝无标志调用)/ --apply / --idem / --rollback
|
||||
写库通道:生产 apply/rollback = root over unix socket(cma_api 无新表 DML 授权);
|
||||
--check 走环境变量只读账号(cma_reader/cma_api 的 SELECT)。
|
||||
|
||||
用法
|
||||
----
|
||||
python3 migrate_map_connections.py --check [--json-out PATH]
|
||||
python3 migrate_map_connections.py --show-create
|
||||
python3 migrate_map_connections.py --apply [--backup-dir DIR] [--evidence PATH]
|
||||
python3 migrate_map_connections.py --idem
|
||||
python3 migrate_map_connections.py --rollback <apply-evidence.json>
|
||||
|
||||
退出码:0 成功 | 2 用法/缺标志 | 3 表不存在(先走 db_migrate.py --apply)| 4 画布 JSON 不可解析(fail-closed)| 5 自检不一致
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
|
||||
LAYERS = ("learning", "process", "customer", "finance")
|
||||
LAYER_ORDER = {k: i for i, k in enumerate(LAYERS)}
|
||||
NODE_COLON = re.compile(r"^([a-z_]+):(.+)$")
|
||||
NODE_INDEX = re.compile(r"^([a-z_]+)-(\d+)$")
|
||||
|
||||
|
||||
# ── 连接(凭据只从 env 读,不回显)────────────────────────────────
|
||||
def _mask(url: str) -> str:
|
||||
return re.sub(r"://([^:/@]+):[^@]*@", r"://\1:***@", url)
|
||||
|
||||
|
||||
def build_engine(db_url: str | None, mode: str):
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
if db_url:
|
||||
url = db_url
|
||||
elif mode == "check" or mode == "show-create":
|
||||
user = os.getenv("CMA_DB_USER", "cma_reader")
|
||||
pwd = os.getenv("CMA_DB_PASS", "")
|
||||
host = os.getenv("CMA_DB_HOST", "127.0.0.1")
|
||||
port = os.getenv("CMA_DB_PORT", "3306")
|
||||
name = os.getenv("CMA_DB_NAME", "cma")
|
||||
if not pwd:
|
||||
print("[fail] 缺少 CMA_DB_PASS(只读预检需要 SELECT 账号凭据)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
url = f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{name}?charset=utf8mb4"
|
||||
else:
|
||||
name = os.getenv("CMA_DB_NAME", "cma")
|
||||
url = f"mysql+pymysql://root@localhost/{name}?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4"
|
||||
return create_engine(url, future=True), url
|
||||
|
||||
|
||||
def load_dotenv():
|
||||
try:
|
||||
from dotenv import load_dotenv as _ld
|
||||
_ld(os.path.join(os.path.dirname(HERE), ".env"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── JSON 规范化 ────────────────────────────────────────────────
|
||||
def as_json(v):
|
||||
"""DB JSON 列可能是 dict/list 或 str(SQLite/部分驱动)。"""
|
||||
if isinstance(v, (bytes, bytearray)):
|
||||
v = v.decode("utf-8")
|
||||
if isinstance(v, str):
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
|
||||
def canvas_md5(canvas) -> str:
|
||||
raw = json.dumps(canvas, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def norm_name(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", (s or "").strip())
|
||||
|
||||
|
||||
# ── 节点解析 ───────────────────────────────────────────────────
|
||||
def parse_node(node):
|
||||
"""→ (layer|None, ref|None, err|None)
|
||||
|
||||
ref = {"kind": "name", "name": ...} | {"kind": "index", "index": n}
|
||||
"""
|
||||
if not isinstance(node, str) or not node.strip():
|
||||
return None, None, "node_empty"
|
||||
s = node.strip()
|
||||
m = NODE_COLON.match(s)
|
||||
if m:
|
||||
layer = m.group(1)
|
||||
name = m.group(2).strip()
|
||||
if layer not in LAYERS:
|
||||
return None, None, f"unknown_layer:{layer}"
|
||||
if not name:
|
||||
return None, None, "node_name_empty"
|
||||
return layer, {"kind": "name", "name": name}, None
|
||||
m = NODE_INDEX.match(s)
|
||||
if m:
|
||||
layer = m.group(1)
|
||||
if layer not in LAYERS:
|
||||
return None, None, f"unknown_layer:{layer}"
|
||||
return layer, {"kind": "index", "index": int(m.group(2))}, None
|
||||
return None, None, "node_format_unrecognized"
|
||||
|
||||
|
||||
class ObjectiveIndex:
|
||||
"""按 map 建索引:名字 → objective_id;层 + 序号 → objective_id。"""
|
||||
|
||||
def __init__(self, dims_by_map, objs_by_map):
|
||||
self.by_name = {}
|
||||
self.by_order = {}
|
||||
self.dims = dims_by_map
|
||||
for map_id, objs in objs_by_map.items():
|
||||
name_idx, order_idx = {}, {}
|
||||
for o in objs:
|
||||
name_idx[(o["dimension_key"], norm_name(o["name"]))] = o["id"]
|
||||
order_idx.setdefault(o["dimension_key"], []).append(o)
|
||||
for k in order_idx:
|
||||
order_idx[k].sort(key=lambda o: (o.get("sort_order") or 0, o["id"]))
|
||||
self.by_name[map_id] = name_idx
|
||||
self.by_order[map_id] = order_idx
|
||||
|
||||
def resolve(self, map_id, layer, ref):
|
||||
"""→ (objective_id|None, note|None)"""
|
||||
if ref["kind"] == "name":
|
||||
hit = self.by_name.get(map_id, {}).get((layer, norm_name(ref["name"])))
|
||||
if hit:
|
||||
return hit, None
|
||||
# 仅大小写/空白差异的近似命中:报告但不静默
|
||||
want = norm_name(ref["name"]).replace(" ", "")
|
||||
for (lk, ln), oid in self.by_name.get(map_id, {}).items():
|
||||
if lk == layer and ln.replace(" ", "") == want:
|
||||
return oid, "normalized_match"
|
||||
return None, "objective_unresolved"
|
||||
# index:优先按 dimensions[dim].objectives 顺序(前端 key 就是该数组下标)
|
||||
idx = ref["index"]
|
||||
dims = self.dims.get(map_id) or []
|
||||
for d in dims if isinstance(dims, list) else []:
|
||||
if d.get("key") == layer:
|
||||
objs = d.get("objectives") or []
|
||||
if 0 <= idx < len(objs):
|
||||
name = norm_name(objs[idx].get("name"))
|
||||
hit = self.by_name.get(map_id, {}).get((layer, name))
|
||||
if hit:
|
||||
return hit, None
|
||||
return None, "objective_unresolved"
|
||||
return None, "index_out_of_range"
|
||||
# dimensions 缺失 → 退 sort_order
|
||||
cand = self.by_order.get(map_id, {}).get(layer) or []
|
||||
for o in cand:
|
||||
if (o.get("sort_order") or 0) == idx:
|
||||
return o["id"], "resolved_by_sort_order"
|
||||
if 0 <= idx < len(cand):
|
||||
return cand[idx]["id"], "resolved_by_position"
|
||||
return None, "index_out_of_range"
|
||||
|
||||
|
||||
# ── 计划构建(纯读)────────────────────────────────────────────
|
||||
def load_state(session):
|
||||
from sqlalchemy import inspect
|
||||
from app.models import StrategicMap, MapObjective, MapConnection
|
||||
|
||||
insp = inspect(session.get_bind())
|
||||
has_table = insp.has_table(MapConnection.__tablename__)
|
||||
|
||||
maps = []
|
||||
for m in session.query(StrategicMap).order_by(StrategicMap.id).all():
|
||||
maps.append({
|
||||
"id": m.id, "entity_id": m.entity_id, "title": m.title, "status": m.status,
|
||||
"canvas": as_json(m.canvas_data), "dimensions": as_json(m.dimensions),
|
||||
})
|
||||
|
||||
objs = {}
|
||||
for o in session.query(MapObjective).order_by(MapObjective.map_id, MapObjective.sort_order, MapObjective.id).all():
|
||||
objs.setdefault(o.map_id, []).append({
|
||||
"id": o.id, "dimension_key": o.dimension_key, "name": o.name, "sort_order": o.sort_order,
|
||||
})
|
||||
|
||||
existing = {}
|
||||
if has_table:
|
||||
for c in session.query(MapConnection).all():
|
||||
existing[(c.map_id, c.from_node, c.to_node)] = c.id
|
||||
return maps, objs, existing, has_table
|
||||
|
||||
|
||||
def build_plan(maps, objs):
|
||||
oi = ObjectiveIndex({m["id"]: m["dimensions"] for m in maps}, objs)
|
||||
plan, per_map, anomalies = [], [], []
|
||||
|
||||
def note(map_id, idx, kind, node, detail=""):
|
||||
anomalies.append({"map_id": map_id, "canvas_index": idx, "kind": kind,
|
||||
"node": node, "detail": detail})
|
||||
|
||||
for m in maps:
|
||||
canvas = m["canvas"]
|
||||
conns = []
|
||||
if canvas is None:
|
||||
canvas = {}
|
||||
if not isinstance(canvas, dict):
|
||||
note(m["id"], None, "canvas_not_object", None, f"type={type(canvas).__name__}")
|
||||
per_map.append({"map_id": m["id"], "entity_id": m["entity_id"], "title": m["title"],
|
||||
"canvas_connections": None, "plan_rows": 0, "anomalies": 1})
|
||||
continue
|
||||
conns = canvas.get("connections") or []
|
||||
if not isinstance(conns, list):
|
||||
note(m["id"], None, "connections_not_list", None, f"type={type(conns).__name__}")
|
||||
conns = []
|
||||
|
||||
rows, seen = [], {}
|
||||
for i, c in enumerate(conns):
|
||||
if not isinstance(c, dict):
|
||||
note(m["id"], i, "connection_not_object", None, f"type={type(c).__name__}")
|
||||
continue
|
||||
frm, to = c.get("from"), c.get("to")
|
||||
if not isinstance(frm, str) or not isinstance(to, str) or not frm or not to:
|
||||
note(m["id"], i, "endpoint_missing", f"{frm!r}->{to!r}", "from/to 缺失或非字符串")
|
||||
continue
|
||||
key = (frm, to)
|
||||
if key in seen:
|
||||
note(m["id"], i, "duplicate_edge", f"{frm} -> {to}", f"与 index={seen[key]} 重复(唯一键会合并)")
|
||||
continue
|
||||
seen[key] = i
|
||||
|
||||
fl, fref, ferr = parse_node(frm)
|
||||
tl, tref, terr = parse_node(to)
|
||||
if ferr:
|
||||
note(m["id"], i, ferr, frm, "from 解析失败")
|
||||
if terr:
|
||||
note(m["id"], i, terr, to, "to 解析失败")
|
||||
if frm == to:
|
||||
note(m["id"], i, "self_loop", frm, "自连")
|
||||
if fl and tl and LAYER_ORDER[tl] < LAYER_ORDER[fl]:
|
||||
note(m["id"], i, "reverse_direction", f"{frm} -> {to}",
|
||||
f"{fl}→{tl} 与 learning→process→customer→finance 相反(告警不拦截)")
|
||||
|
||||
f_obj = f_note = t_obj = t_note = None
|
||||
if fref:
|
||||
f_obj, f_note = oi.resolve(m["id"], fl, fref)
|
||||
if f_note and f_note != "normalized_match":
|
||||
note(m["id"], i, f_note, frm, "from 目标未解析")
|
||||
if tref:
|
||||
t_obj, t_note = oi.resolve(m["id"], tl, tref)
|
||||
if t_note and t_note != "normalized_match":
|
||||
note(m["id"], i, t_note, to, "to 目标未解析")
|
||||
|
||||
rows.append({
|
||||
"entity_id": m["entity_id"] if m["entity_id"] is not None else 1,
|
||||
"map_id": m["id"], "from_node": frm, "to_node": to,
|
||||
"from_layer": fl, "to_layer": tl,
|
||||
"from_objective_id": f_obj, "to_objective_id": t_obj,
|
||||
"effect": c.get("effect") or "positive",
|
||||
"label": c.get("label") or None,
|
||||
"sort_order": i,
|
||||
})
|
||||
|
||||
plan.extend(rows)
|
||||
per_map.append({
|
||||
"map_id": m["id"], "entity_id": m["entity_id"], "title": m["title"],
|
||||
"status": m["status"], "canvas_connections": len(conns), "plan_rows": len(rows),
|
||||
"anomalies": sum(1 for a in anomalies if a["map_id"] == m["id"]),
|
||||
})
|
||||
return plan, per_map, anomalies
|
||||
|
||||
|
||||
def summarize(plan, per_map, anomalies, existing=None):
|
||||
by_pair, by_layer = {}, {}
|
||||
for r in plan:
|
||||
pair = f"{r['from_layer'] or '?'}->{r['to_layer'] or '?'}"
|
||||
by_pair[pair] = by_pair.get(pair, 0) + 1
|
||||
for lay in (r["from_layer"], r["to_layer"]):
|
||||
if lay:
|
||||
by_layer[lay] = by_layer.get(lay, 0) + 1
|
||||
existing = existing or {}
|
||||
to_insert = sum(1 for r in plan if (r["map_id"], r["from_node"], r["to_node"]) not in existing)
|
||||
return {
|
||||
"canvas_connections": sum(p["canvas_connections"] or 0 for p in per_map),
|
||||
"plan_rows": len(plan),
|
||||
"already_in_table": len([r for r in plan if (r["map_id"], r["from_node"], r["to_node"]) in existing]),
|
||||
"to_insert": to_insert,
|
||||
"unresolved_layer": sum(1 for r in plan if not r["from_layer"] or not r["to_layer"]),
|
||||
"unresolved_objective": sum(1 for r in plan if not r["from_objective_id"] or not r["to_objective_id"]),
|
||||
"anomalies": len(anomalies),
|
||||
"layer_pair_distribution": by_pair,
|
||||
"layer_node_refs": by_layer,
|
||||
}
|
||||
|
||||
|
||||
# ── 三种模式的实现 ─────────────────────────────────────────────
|
||||
def cmd_check(engine, args):
|
||||
from sqlalchemy.orm import Session
|
||||
with Session(engine) as s:
|
||||
maps, objs, existing, has_table = load_state(s)
|
||||
plan, per_map, anomalies = build_plan(maps, objs)
|
||||
report = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"mode": "check", "read_only": True,
|
||||
"db": _mask(engine.url.render_as_string(hide_password=True).replace("***", "***")),
|
||||
"engine_dialect": engine.dialect.name,
|
||||
"table_exists": has_table,
|
||||
"totals": summarize(plan, per_map, anomalies, existing),
|
||||
"maps": per_map,
|
||||
"anomalies": anomalies,
|
||||
"sample_rows": plan[:5],
|
||||
}
|
||||
if args.json_out:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.json_out)), exist_ok=True)
|
||||
with open(args.json_out, "w", encoding="utf-8") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
print(f"[check] 库={report['db']} 方言={report['engine_dialect']} 目标表存在={has_table}")
|
||||
t = report["totals"]
|
||||
print(f"[check] 画布连线={t['canvas_connections']} 计划行={t['plan_rows']} "
|
||||
f"已在表={t['already_in_table']} 待插入={t['to_insert']}")
|
||||
print(f"[check] 层未解析={t['unresolved_layer']} 目标未解析={t['unresolved_objective']} 异常条目={t['anomalies']}")
|
||||
print(f"[check] 层对分布={t['layer_pair_distribution']}")
|
||||
for p in per_map:
|
||||
print(f"[check] map {p['map_id']} (entity={p['entity_id']}, {p['status']}): "
|
||||
f"canvas={p['canvas_connections']} → plan={p['plan_rows']} 异常={p['anomalies']}")
|
||||
for a in anomalies[:50]:
|
||||
print(f"[check] ✗ map={a['map_id']} idx={a['canvas_index']} {a['kind']} {a['node']} {a['detail']}")
|
||||
if not has_table:
|
||||
print("[check] ⚠ 目标表不存在:建表须先走 db_migrate.py --apply(受批面)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show_create():
|
||||
from sqlalchemy.schema import CreateTable
|
||||
from sqlalchemy.dialects import mysql
|
||||
from app.models import MapConnection
|
||||
print(CreateTable(MapConnection.__table__).compile(dialect=mysql.dialect()))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_apply(engine, args):
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.models import MapConnection
|
||||
|
||||
with Session(engine) as s:
|
||||
maps, objs, existing, has_table = load_state(s)
|
||||
if not has_table:
|
||||
print("[fail] map_connections 表不存在 → 先执行(受批)db_migrate.py --apply", file=sys.stderr)
|
||||
return 3
|
||||
plan, per_map, anomalies = build_plan(maps, objs)
|
||||
hard = [a for a in anomalies if a["kind"] in ("canvas_not_object", "connections_not_list", "connection_not_object")]
|
||||
if hard:
|
||||
print(f"[fail] 画布结构不可解析 {len(hard)} 处 → fail-closed,未写任何行:"
|
||||
f"{hard[:3]}", file=sys.stderr)
|
||||
return 4
|
||||
|
||||
# 回滚点:canvas_data 全量快照 + md5(只读 SELECT 得到)
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_dir = os.path.abspath(args.backup_dir or f"/srv/hermes/backups/map_connections_p1/{ts}")
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
backup_path = os.path.join(backup_dir, "canvas_data_snapshot.json")
|
||||
with open(backup_path, "w", encoding="utf-8") as f:
|
||||
json.dump([{"map_id": m["id"], "entity_id": m["entity_id"], "title": m["title"],
|
||||
"canvas_data": m["canvas"], "md5": canvas_md5(m["canvas"])} for m in maps],
|
||||
f, ensure_ascii=False, indent=2)
|
||||
pre_md5 = {m["id"]: canvas_md5(m["canvas"]) for m in maps}
|
||||
|
||||
inserted, skipped, errors = [], 0, []
|
||||
with Session(engine) as s:
|
||||
try:
|
||||
for r in plan:
|
||||
key = (r["map_id"], r["from_node"], r["to_node"])
|
||||
if key in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
obj = MapConnection(**r)
|
||||
s.add(obj)
|
||||
try:
|
||||
s.flush()
|
||||
except IntegrityError:
|
||||
s.rollback()
|
||||
skipped += 1
|
||||
continue
|
||||
inserted.append({"id": obj.id, "map_id": r["map_id"],
|
||||
"from_node": r["from_node"], "to_node": r["to_node"]})
|
||||
s.commit()
|
||||
except Exception as exc:
|
||||
s.rollback()
|
||||
print(f"[fail] 写入失败已回滚:{type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 写后自检:行数 + canvas 未变
|
||||
with Session(engine) as s:
|
||||
maps2, _, _, _ = load_state(s)
|
||||
table_rows = s.query(MapConnection).filter(
|
||||
MapConnection.map_id.in_([m["id"] for m in maps])).count() if maps else 0
|
||||
post_md5 = {m["id"]: canvas_md5(m["canvas"]) for m in maps2}
|
||||
canvas_unchanged = pre_md5 == post_md5
|
||||
expected = len({(r["map_id"], r["from_node"], r["to_node"]) for r in plan})
|
||||
|
||||
evidence = {
|
||||
"action": "apply", "status": "ok",
|
||||
"started_at": ts, "finished_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"db_engine": engine.dialect.name,
|
||||
"backup_path": backup_path, "backup_pre_md5": pre_md5, "post_md5": post_md5,
|
||||
"canvas_unchanged": canvas_unchanged,
|
||||
"plan_rows": len(plan), "inserted": len(inserted), "skipped_existing": skipped,
|
||||
"table_rows_for_scope": table_rows, "expected_distinct_edges": expected,
|
||||
"anomalies": anomalies, "maps": per_map, "inserted_ids": inserted,
|
||||
}
|
||||
ok = canvas_unchanged and table_rows == expected and not errors
|
||||
evidence["verdict"] = "PASS" if ok else "FAIL"
|
||||
ev_path = os.path.abspath(args.evidence or os.path.join(backup_dir, "apply_evidence.json"))
|
||||
os.makedirs(os.path.dirname(ev_path), exist_ok=True)
|
||||
with open(ev_path, "w", encoding="utf-8") as f:
|
||||
json.dump(evidence, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"[apply] 计划 {len(plan)} 行;新插入 {len(inserted)},已存在跳过 {skipped}")
|
||||
print(f"[apply] 表内本范围行数 {table_rows} / 期望 {expected};canvas 未改={canvas_unchanged}")
|
||||
print(f"[apply] 备份 {backup_path}")
|
||||
print(f"[apply] 证据 {ev_path}")
|
||||
print(f"[apply] verdict={evidence['verdict']}")
|
||||
return 0 if ok else 5
|
||||
|
||||
|
||||
def cmd_idem(engine, args):
|
||||
from sqlalchemy.orm import Session
|
||||
with Session(engine) as s:
|
||||
maps, objs, existing, has_table = load_state(s)
|
||||
if not has_table:
|
||||
print("[fail] 表不存在", file=sys.stderr)
|
||||
return 3
|
||||
plan, per_map, anomalies = build_plan(maps, objs)
|
||||
mismatches = []
|
||||
for r in plan:
|
||||
key = (r["map_id"], r["from_node"], r["to_node"])
|
||||
cid = existing.get(key)
|
||||
if not cid:
|
||||
mismatches.append({"kind": "missing", "key": list(key)})
|
||||
continue
|
||||
row = s.get(__import__("app.models", fromlist=["MapConnection"]).MapConnection, cid)
|
||||
for f in ("from_layer", "to_layer", "from_objective_id", "to_objective_id"):
|
||||
if (getattr(row, f) or None) != (r[f] or None):
|
||||
mismatches.append({"kind": "value_diff", "key": list(key), "field": f,
|
||||
"db": getattr(row, f), "plan": r[f]})
|
||||
print(f"[idem] 计划 {len(plan)} 行,已在位 {len(plan) - len([m for m in mismatches if m['kind'] == 'missing'])};"
|
||||
f"差异 {len(mismatches)}")
|
||||
for m in mismatches[:20]:
|
||||
print(f"[idem] ✗ {m}")
|
||||
print(f"[idem] verdict={'PASS' if not mismatches else 'FAIL'}")
|
||||
return 0 if not mismatches else 5
|
||||
|
||||
|
||||
def cmd_rollback(engine, args):
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import MapConnection
|
||||
path = args.rollback
|
||||
if not path or not os.path.exists(path):
|
||||
print(f"[fail] 回滚证据不存在:{path}", file=sys.stderr)
|
||||
return 2
|
||||
with open(path, encoding="utf-8") as f:
|
||||
ev = json.load(f)
|
||||
ids = [x["id"] for x in ev.get("inserted_ids", [])]
|
||||
if not ids:
|
||||
print("[rollback] 证据里没有 inserted_ids,无需回滚")
|
||||
return 0
|
||||
with Session(engine) as s:
|
||||
n = s.query(MapConnection).filter(MapConnection.id.in_(ids)).delete(synchronize_session=False)
|
||||
s.commit()
|
||||
left = s.query(MapConnection).filter(MapConnection.id.in_(ids)).count()
|
||||
print(f"[rollback] 删除 {n} 行,残留 {left}(证据 {path});canvas_data 本脚本从未写入,无需还原")
|
||||
return 0 if left == 0 else 5
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="canvas_data.connections → map_connections(四态幂等迁移)")
|
||||
g = ap.add_mutually_exclusive_group()
|
||||
g.add_argument("--check", action="store_true", help="只读预检(dry-run):行数/异常/待插入")
|
||||
g.add_argument("--apply", action="store_true", help="唯一写:幂等插入(需表已存在)")
|
||||
g.add_argument("--idem", action="store_true", help="只读幂等复跑:断言全部已在位")
|
||||
g.add_argument("--rollback", metavar="EVIDENCE_JSON", help="按 apply 证据删除本批插入行")
|
||||
g.add_argument("--show-create", action="store_true", help="打印模型编译出的 CREATE TABLE")
|
||||
ap.add_argument("--db-url", help="覆盖连接串(演练副本/SQLite 用)")
|
||||
ap.add_argument("--json-out", help="--check 的机器可读报告路径")
|
||||
ap.add_argument("--backup-dir", help="--apply 回滚点目录")
|
||||
ap.add_argument("--evidence", help="--apply 证据 JSON 路径")
|
||||
args = ap.parse_args()
|
||||
|
||||
load_dotenv()
|
||||
|
||||
if args.show_create:
|
||||
return cmd_show_create()
|
||||
if not any([args.check, args.apply, args.idem, args.rollback]):
|
||||
print("拒绝无标志调用:请显式给 --check / --apply / --idem / --rollback", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
mode = "check" if args.check else "apply" if args.apply else "idem" if args.idem else "rollback"
|
||||
engine, url = build_engine(args.db_url, mode)
|
||||
print(f"[{mode}] 通道 {_mask(url)}")
|
||||
if args.check:
|
||||
return cmd_check(engine, args)
|
||||
if args.apply:
|
||||
return cmd_apply(engine, args)
|
||||
if args.idem:
|
||||
return cmd_idem(engine, args)
|
||||
return cmd_rollback(engine, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""播种应收账龄逾期预警规则(`alert_rules.rule_type='ar_overdue'`)— 阶段B 专用
|
||||
|
||||
用法:
|
||||
# 只打印将写入的行(默认 dry-run,阶段A 唯一允许的形式)
|
||||
python3 backend/scripts/seed_ar_overdue_rule.py --entity-id 1
|
||||
|
||||
# 真正写库(阶段B,须另铸门/执行件授权)
|
||||
python3 backend/scripts/seed_ar_overdue_rule.py --entity-id 1 --apply
|
||||
|
||||
# 把参数 JSON 打到 stdout/文件,供阶段A `POST /run-ar-overdue` 的 dry-run body 复演
|
||||
python3 backend/scripts/seed_ar_overdue_rule.py --entity-id 1 --emit-params /tmp/ar_params.json
|
||||
|
||||
阈值与责任人**只存在于本脚本**(业务数字唯一定义处);运行期一律读 DB 行,
|
||||
代码侧不含任何阈值常量(任务硬约束 §3.4)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv # noqa: E402
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env"))
|
||||
|
||||
from app.database import get_session_local # noqa: E402
|
||||
from app.models import KPIDefinition # noqa: E402
|
||||
from app.utils import ar_caliber # noqa: E402
|
||||
|
||||
RULE_TYPE = "ar_overdue"
|
||||
DEFAULT_KPI_CODE = "F_AR_002" # 逾期应收账款率(财务口径);库内暂缺时须由财务定绑定
|
||||
|
||||
# ── 业务数字唯一定义处(阶段B G5 2026-10-21 裁定:停用 150+ 占比,改 90–150 前池)──
|
||||
DEFAULT_PARAMS = {
|
||||
"kpi_code": DEFAULT_KPI_CODE,
|
||||
"caliber": ar_caliber.CALIBER_DEBIT,
|
||||
"share_90plus_gt": None,
|
||||
"share_150p_gt": None,
|
||||
"share_90_150_rise_pp_min": 5.0,
|
||||
"share_90_150_amt_min": 100000,
|
||||
"overdue_amt_display_only": True,
|
||||
"single_overdue_amt_gt": 50000,
|
||||
"assignee_by_unit": {},
|
||||
"assignee_source": {"aggregate_assignee": "", "escalate": "任富海"},
|
||||
}
|
||||
|
||||
|
||||
def resolve_kpi(db, entity_id: int, kpi_code: str, kpi_id: int | None):
|
||||
if kpi_id:
|
||||
return db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id).first()
|
||||
return db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code, KPIDefinition.entity_id == entity_id).first()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="播种 ar_overdue 预警规则")
|
||||
ap.add_argument("--entity-id", type=int, required=True)
|
||||
ap.add_argument("--kpi-code", default=DEFAULT_KPI_CODE)
|
||||
ap.add_argument("--kpi-id", type=int, default=None, help="显式绑定 KPI(优先于 kpi-code)")
|
||||
ap.add_argument("--assignee", default=None, help="覆盖 assignee_default(财务指定责任人)")
|
||||
ap.add_argument("--apply", action="store_true", help="真正写库(阶段B 才允许)")
|
||||
ap.add_argument("--emit-params", default=None, help="把最终 params JSON 写入指定文件")
|
||||
ap.add_argument("--params-file", default=None,
|
||||
help="整份 params JSON(与 DB 行逐字一致;给了就覆盖 DEFAULT_PARAMS)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.params_file:
|
||||
with open(args.params_file, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
params = raw.get("params", raw) if isinstance(raw, dict) else raw
|
||||
if not isinstance(params, dict):
|
||||
print("[BLOCK] --params-file 必须是 JSON 对象或 {params:{...}}", file=sys.stderr)
|
||||
return 3
|
||||
params = json.loads(json.dumps(params, ensure_ascii=False)) # deep copy
|
||||
else:
|
||||
params = json.loads(json.dumps(DEFAULT_PARAMS)) # deep copy
|
||||
if args.assignee is not None:
|
||||
params["assignee_default"] = args.assignee
|
||||
if args.emit_params:
|
||||
with open(args.emit_params, "w", encoding="utf-8") as f:
|
||||
json.dump({"params": params}, f, ensure_ascii=False, indent=2)
|
||||
print("params 已写出: %s" % args.emit_params)
|
||||
|
||||
db = get_session_local()()
|
||||
try:
|
||||
kpi = resolve_kpi(db, args.entity_id, args.kpi_code, args.kpi_id)
|
||||
if not kpi:
|
||||
print("[BLOCK] entity=%s 内找不到 KPI code=%r(kpi_id=%s)——无法绑定,未写入任何行"
|
||||
% (args.entity_id, args.kpi_code, args.kpi_id), file=sys.stderr)
|
||||
print("[INFO] 该账套现存 AR 相关 KPI:", file=sys.stderr)
|
||||
rows = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == args.entity_id,
|
||||
KPIDefinition.kpi_code.like("%AR%"),
|
||||
).order_by(KPIDefinition.id).all()
|
||||
for r in rows:
|
||||
print(" id=%s code=%s name=%s" % (r.id, r.kpi_code, r.kpi_name), file=sys.stderr)
|
||||
print("[INFO] 处理:--kpi-id 显式指定,或财务先在 kpi_definitions 建 F_AR_002。",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
from app.api.alert_rules import AlertRule
|
||||
exist = db.query(AlertRule).filter(
|
||||
AlertRule.entity_id == args.entity_id,
|
||||
AlertRule.rule_type == RULE_TYPE,
|
||||
).all()
|
||||
print("== 将写入的行(%s)==" % ("APPLY" if args.apply else "DRY-RUN"))
|
||||
print(json.dumps({
|
||||
"entity_id": args.entity_id,
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"rule_type": RULE_TYPE,
|
||||
"trigger_on": "actual",
|
||||
"enabled": 1,
|
||||
"params": params,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
src = params.get("assignee_source") or {}
|
||||
if not (params.get("assignee_default") or params.get("assignee_by_unit")
|
||||
or src.get("mode") or src.get("aggregate_assignee")):
|
||||
print("[WARN] 责任人均未配置 → 运行期 fail-closed 400(阶段B 前须由财务给值)")
|
||||
|
||||
if exist:
|
||||
print("[SKIP] entity=%s 已有 %d 条 ar_overdue 规则(id=%s),不重复播种"
|
||||
% (args.entity_id, len(exist), [r.id for r in exist]))
|
||||
return 0
|
||||
if not args.apply:
|
||||
print("[DRY-RUN] 未写库。阶段B 授权后加 --apply。")
|
||||
return 0
|
||||
|
||||
rule = AlertRule(
|
||||
entity_id=args.entity_id,
|
||||
kpi_id=kpi.id,
|
||||
rule_type=RULE_TYPE,
|
||||
trigger_on="actual",
|
||||
enabled=1,
|
||||
params=params,
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
print("[APPLIED] 已写入 alert_rules.id=%s" % rule.id)
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,510 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""应收账龄看板 + 逾期预警(T1/T2 · 2026-10-20 R2)单测
|
||||
|
||||
任务:`project-20261020-ar-overdue-alert-and-aging-board-fullstack`(阶段A)
|
||||
|
||||
口径与边界(全部为硬断言):
|
||||
· 六档互斥:逐行 `b_0_30+…+b_150p == total_amt`;
|
||||
· 逾期额 `o_i = total_amt − b_0_30`,负档位**不** ABS/COALESCE(DQ-AR-04 杨创 -20);
|
||||
· 三口径分列、禁跨口径合计(`caliber=all` → 400);
|
||||
· 缺期(2026-10)⇒ `has_data=false` 且金额 null(禁补 0);
|
||||
· §6 裁决:`red.share_90plus_gt=null` ⇒ 90 天占比红线停用(不回落 0.25);
|
||||
新增「单户 >30 天且 >5 万」逐户红线 + 「150+ 占比环比上升」结构恶化红线;
|
||||
· §6.2 assignee:手工点名 → 近 3 个月主业务员(employee_dict / 同库摘要降级)
|
||||
→ 部门负责人(渠道 118 / 商务 114 / 直属酒窖 117);两路皆失败 ⇒ skipped[],不落空行;
|
||||
· 阈值/责任人全部走 `alert_rules.params`(本套件在本地 SQLite 夹具里改参数复跑,
|
||||
证明「改参数不改代码」);
|
||||
· 写通道只 INSERT,历史行零 UPDATE/DELETE,同 `alert_message` 幂等跳过。
|
||||
|
||||
本套件**不连任何生产库**:`tests/conftest.py` 已把引擎替换为内存 SQLite;
|
||||
`hanke_ar_aging` 非 ORM 模型,故用原始 DDL 在夹具里建表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.alert_rules import AlertRule
|
||||
from app.models import KPIAlert
|
||||
from app.services import ar_overdue_alert
|
||||
from app.utils import ar_caliber
|
||||
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header
|
||||
|
||||
ENT = 1
|
||||
DDL = """
|
||||
CREATE TABLE hanke_ar_aging (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER NOT NULL,
|
||||
period VARCHAR(7) NOT NULL,
|
||||
caliber VARCHAR(24) NOT NULL,
|
||||
unit_code VARCHAR(20) NOT NULL,
|
||||
unit_name VARCHAR(100) NOT NULL,
|
||||
opening_amt NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
total_amt NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_0_30 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_31_60 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_61_90 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_90_120 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_120_150 NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
b_150p NUMERIC(18,2) NOT NULL DEFAULT 0,
|
||||
source_file VARCHAR(160) NOT NULL DEFAULT 'fixture.csv',
|
||||
source_sha256 VARCHAR(64) NOT NULL DEFAULT 'fixture',
|
||||
loaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
# §6.1 参数最终形状(含 aggregate_assignee,仅测试用显式给值以验证聚合可落库)
|
||||
BASE_PARAMS = {
|
||||
"kpi_code": "F_AR_002",
|
||||
"caliber": ar_caliber.CALIBER_DEBIT,
|
||||
"red": {"share_90plus_gt": None, "single_overdue_amt_gt": 50000,
|
||||
"single_overdue_min_days": 30, "count_mom_pct_gte": 0.20,
|
||||
"structure_worsen": True},
|
||||
"yellow": {"share_90plus_min": 0.15, "share_90plus_max": 0.25,
|
||||
"single_overdue_amt_min": 10000, "single_overdue_amt_max": 50000},
|
||||
"assignee_source": {
|
||||
"mode": "salesman_then_dept",
|
||||
"lookback_months": 3,
|
||||
"dept_fallback": {"渠道": 118, "商务": 114, "直属酒窖": 117},
|
||||
"dept_by_unit": {"U3": "商务"},
|
||||
"aggregate_assignee": "财务应收会计",
|
||||
},
|
||||
"assignee_by_unit": {},
|
||||
"exclude_unit_codes": [],
|
||||
"action_texts": {"red": "催收+停供评估", "yellow": "对账+催收"},
|
||||
}
|
||||
|
||||
ZXT_DDL = """
|
||||
CREATE TABLE zxt_masterbill (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER,
|
||||
unit_id INTEGER,
|
||||
emp_id INTEGER,
|
||||
bill_type INTEGER DEFAULT 1,
|
||||
bill_date DATE,
|
||||
sum_money NUMERIC(18,2) DEFAULT 0,
|
||||
abst VARCHAR(500)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _reset_table(db: Session) -> None:
|
||||
db.execute(text("DROP TABLE IF EXISTS hanke_ar_aging"))
|
||||
db.execute(text(DDL))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _add(db: Session, period: str, code: str, name: str, total: float, buckets: list,
|
||||
opening: float = 0.0, caliber: str = ar_caliber.CALIBER_DEBIT, entity_id: int = ENT) -> None:
|
||||
assert abs(sum(buckets) - total) < 0.005, "夹具必须满足六档和 = 合计"
|
||||
db.execute(text(
|
||||
"INSERT INTO hanke_ar_aging (entity_id, period, caliber, unit_code, unit_name, opening_amt,"
|
||||
" total_amt, b_0_30, b_31_60, b_61_90, b_90_120, b_120_150, b_150p)"
|
||||
" VALUES (:e,:p,:c,:code,:name,:op,:tot,:b0,:b1,:b2,:b3,:b4,:b5)"
|
||||
), {"e": entity_id, "p": period, "c": caliber, "code": code, "name": name, "op": opening,
|
||||
"tot": total, "b0": buckets[0], "b1": buckets[1], "b2": buckets[2],
|
||||
"b3": buckets[3], "b4": buckets[4], "b5": buckets[5]})
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def aging(db: Session):
|
||||
"""两期夹具(2026-08 / 2026-09),含负档位行 + 未逾期户 + 贷方口径干扰行。
|
||||
|
||||
2026-09:合计 173,159;90+ = 63,179(36.48%);逾期户 = U1/U3/U4 共 3 户。
|
||||
2026-08:合计 163,159;90+ = 43,179(26.46%);逾期户 3 户 ⇒ 环比 0%。
|
||||
150+ 占比:2026-08 = 14.21% → 2026-09 = 16.27%(上升 ⇒ 结构恶化红)。
|
||||
"""
|
||||
_reset_table(db)
|
||||
_add(db, "2026-09", "U1", "甲公司", 100000, [20000, 10000, 10000, 20000, 15000, 25000], opening=5000)
|
||||
_add(db, "2026-09", "U2", "乙公司", 40000, [40000, 0, 0, 0, 0, 0])
|
||||
_add(db, "2026-09", "U3", "丙公司", 30000, [0, 30000, 0, 0, 0, 0])
|
||||
# 负档位(DQ-AR-04 同形):total=3159, b_0_30=-20, b_150p=3179 ⇒ o_i=3179(不得 ABS 抹成 3139/3159)
|
||||
_add(db, "2026-09", "U4", "杨创", 3159, [-20, 0, 0, 0, 0, 3179])
|
||||
# 干扰行:若查询漏 caliber 过滤 → 会污染借方口径合计
|
||||
_add(db, "2026-09", "C1", "贷方户", -34010, [0, 0, 0, 0, 0, -34010],
|
||||
caliber=ar_caliber.CALIBER_CREDIT_SEPARATE)
|
||||
_add(db, "2026-08", "U1", "甲公司", 90000, [30000, 10000, 10000, 10000, 10000, 20000])
|
||||
_add(db, "2026-08", "U2", "乙公司", 50000, [50000, 0, 0, 0, 0, 0])
|
||||
_add(db, "2026-08", "U3", "丙公司", 20000, [0, 20000, 0, 0, 0, 0])
|
||||
_add(db, "2026-08", "U4", "杨创", 3159, [-20, 0, 0, 0, 0, 3179])
|
||||
return db
|
||||
|
||||
|
||||
def _staff(db: Session, *, with_employee_dict: bool = True, with_units_dict: bool = True,
|
||||
with_zxt: bool = True) -> None:
|
||||
"""维度夹具:units_dict(U1/U3/U4) + employee_dict(14/32/114) + zxt_masterbill。"""
|
||||
for t in ("units_dict", "employee_dict", "zxt_masterbill"):
|
||||
db.execute(text("DROP TABLE IF EXISTS %s" % t))
|
||||
if with_units_dict:
|
||||
db.execute(text("CREATE TABLE units_dict (unit_code VARCHAR(20), unit_id INTEGER)"))
|
||||
for code, uid in (("U1", 101), ("U3", 103), ("U4", 104)):
|
||||
db.execute(text("INSERT INTO units_dict VALUES (:c,:u)"), {"c": code, "u": uid})
|
||||
if with_employee_dict:
|
||||
db.execute(text("CREATE TABLE employee_dict (id INTEGER, u_Name VARCHAR(64))"))
|
||||
for i, n in ((14, "李世昌"), (32, "兀晨博"), (114, "王经理"), (118, "渠道负责人"),
|
||||
(117, "酒窖负责人")):
|
||||
db.execute(text("INSERT INTO employee_dict VALUES (:i,:n)"), {"i": i, "n": n})
|
||||
if with_zxt:
|
||||
db.execute(text(ZXT_DDL))
|
||||
rows = [
|
||||
(ENT, 101, 14, "2026-08-10", "向【甲公司】出售商品:李世昌"),
|
||||
(ENT, 101, 14, "2026-08-11", "向【甲公司】出售商品:李世昌"),
|
||||
(ENT, 101, 14, "2026-09-05", "向【甲公司】出售商品:李世昌"),
|
||||
(ENT, 101, 32, "2026-09-20", "向【甲公司】出售商品:兀晨博"),
|
||||
]
|
||||
for e, u, m, d, a in rows:
|
||||
db.execute(text("INSERT INTO zxt_masterbill (entity_id, unit_id, emp_id, bill_date, abst)"
|
||||
" VALUES (:e,:u,:m,:d,:a)"),
|
||||
{"e": e, "u": u, "m": m, "d": d, "a": a})
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def staff(aging: Session):
|
||||
_staff(aging)
|
||||
return aging
|
||||
|
||||
|
||||
# ─────────────────────── 数据口径 ───────────────────────
|
||||
|
||||
def test_six_buckets_equal_total_per_row(aging: Session):
|
||||
rows = aging.execute(text(
|
||||
"SELECT period, unit_code, total_amt, b_0_30+b_31_60+b_61_90+b_90_120+b_120_150+b_150p AS s"
|
||||
" FROM hanke_ar_aging WHERE entity_id=1 AND caliber=:c"
|
||||
), {"c": ar_caliber.CALIBER_DEBIT}).mappings().all()
|
||||
assert len(rows) == 8
|
||||
for r in rows:
|
||||
assert abs(float(r["s"]) - float(r["total_amt"])) < 0.005, r
|
||||
|
||||
|
||||
def test_caliber_literals_are_exact():
|
||||
assert ar_caliber.CALIBERS == ("全户口径", "借方应收户口径", "贷方户单列(净额)")
|
||||
with pytest.raises(ar_caliber.CaliberError):
|
||||
ar_caliber.validate_caliber("贷方户单列") # 少「(净额)」⇒ 会漏 42 行
|
||||
with pytest.raises(ar_caliber.CaliberError):
|
||||
ar_caliber.validate_caliber("all")
|
||||
assert ar_caliber.validate_caliber(None, allow_none=True) == ar_caliber.CALIBER_DEBIT
|
||||
|
||||
|
||||
# ─────────────────────── 计算与分级(§6 阈值) ───────────────────────
|
||||
|
||||
def test_share_90plus_red_disabled_and_structure_worsen(staff: Session):
|
||||
"""§6.1:90 天占比红线置 null ⇒ 不生成该聚合项;150+ 环比上升 ⇒ 结构恶化红。"""
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", BASE_PARAMS)
|
||||
assert res["skipped_reason"] is None
|
||||
m = res["metrics"]
|
||||
assert m["total_amt"] == pytest.approx(173159.0, abs=0.01) # 贷方口径行未被混入
|
||||
assert m["overdue_90plus"] == pytest.approx(63179.0, abs=0.01)
|
||||
assert m["share_90plus"] == pytest.approx(63179 / 173159, rel=1e-6)
|
||||
assert m["share_150p"] == pytest.approx(28179 / 173159, abs=1e-6) # metrics 保留 6 位
|
||||
assert m["prev_share_150p"] == pytest.approx(23179 / 163159, abs=1e-6)
|
||||
|
||||
keys = [a["key"] for a in res["aggregates"]]
|
||||
assert "share_90plus" not in keys, "红线停用后 36.48% 不应再出聚合项(无区分度)"
|
||||
assert "structure_150p" in keys
|
||||
agg = [a for a in res["aggregates"] if a["key"] == "structure_150p"][0]
|
||||
assert agg["level"] == "red"
|
||||
assert agg["assignee"] == "财务应收会计"
|
||||
for token in ("2026-09", "150+ 档占比", "16.27%", "14.21%", "结构恶化", "催收+停供评估"):
|
||||
assert token in agg["alert_message"], token
|
||||
assert len(agg["alert_message"]) <= 500
|
||||
assert any("90 天占比红线停用" in n for n in res["notes"])
|
||||
|
||||
|
||||
def test_single_overdue_red_and_yellow_households(staff: Session):
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", BASE_PARAMS)
|
||||
by_code = {c["unit_code"]: c for c in res["per_customer"]}
|
||||
# 单户 >30 天且 >5 万 ⇒ 红
|
||||
assert by_code["U1"]["level"] == "red"
|
||||
assert by_code["U1"]["overdue_amt"] == pytest.approx(80000.0)
|
||||
assert by_code["U1"]["oldest_days"] == 151
|
||||
# 单户 1–5 万 ⇒ 黄
|
||||
assert by_code["U3"]["level"] == "yellow" and by_code["U3"]["oldest_days"] == 31
|
||||
# U4 逾期 3,179 < 1 万 ⇒ 不生成;U2 全额 0-30 ⇒ 不生成
|
||||
assert "U4" not in by_code and "U2" not in by_code
|
||||
for c in res["per_customer"]:
|
||||
assert c["assignee"], "禁落空 assignee"
|
||||
assert c["alert_message"].count("|建议:") == 1
|
||||
assert "|责任人:" in c["alert_message"]
|
||||
|
||||
|
||||
def test_single_customer_message_wording_hkjj120_20261021(aging: Session):
|
||||
"""D2(2026-10-21):单户文案须把「最老账龄」与「逾期合计」分开写清。
|
||||
|
||||
样例 = 生产 HKJJ120 逐档实数(借方应收户口径,只读复核):
|
||||
total 352,494.00 = 52,374 + 34,549 + 35,799 + 80,191 + 84,466 + 65,115
|
||||
31 天以上逾期合计 = 300,120.00(占该户 85.14%),150+ 档 = 65,115.00。
|
||||
旧文案「期末逾期 151 天以上 ¥300,120.00」会被读成「最老档就是 30 万」,
|
||||
故本用例硬断言旧措辞不再出现、新措辞逐字出现,且判据字段(金额/天数/占比)零变化。
|
||||
"""
|
||||
_reset_table(aging)
|
||||
_add(aging, "2026-09", "HKJJ120", "西安酣君品创贸易有限责任公司", 352494.00,
|
||||
[52374.00, 34549.00, 35799.00, 80191.00, 84466.00, 65115.00])
|
||||
p = json.loads(json.dumps(BASE_PARAMS))
|
||||
p["assignee_by_unit"] = {"HKJJ120": "李世昌"}
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-09", p)
|
||||
c = {x["unit_code"]: x for x in res["per_customer"]}["HKJJ120"]
|
||||
# 判据字段(禁随文案改动)
|
||||
assert c["level"] == "red"
|
||||
assert c["oldest_days"] == 151
|
||||
assert c["overdue_amt"] == pytest.approx(300120.00)
|
||||
assert c["total_amt"] == pytest.approx(352494.00)
|
||||
assert c["pct_of_unit"] == pytest.approx(0.851419, abs=1e-6)
|
||||
# 文案口径
|
||||
assert "账龄最老 151 天,逾期合计 ¥300,120.00(占该户 85.14%)" in c["alert_message"]
|
||||
assert "期末逾期" not in c["alert_message"]
|
||||
assert c["alert_message"].count("|建议:") == 1
|
||||
assert c["alert_message"].endswith("|责任人:李世昌")
|
||||
assert len(c["alert_message"]) <= 500
|
||||
|
||||
|
||||
def test_assignee_chain_salesman_then_dept(staff: Session):
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", BASE_PARAMS)
|
||||
by_code = {c["unit_code"]: c for c in res["per_customer"]}
|
||||
# U1:units_dict → unit_id=101 → 主业务员 emp14 单据最多(3 vs 1)→ 李世昌
|
||||
assert by_code["U1"]["assignee"] == "李世昌"
|
||||
assert "salesman" in by_code["U1"]["assignee_source"]
|
||||
assert by_code["U1"]["emp_id"] == 14
|
||||
# U3:近 3 个月无单据 → 部门负责人 商务 114 → 王经理
|
||||
assert by_code["U3"]["assignee"] == "王经理"
|
||||
assert by_code["U3"]["assignee_source"].startswith("dept:商务")
|
||||
|
||||
|
||||
def test_assignee_manual_map_has_priority(staff: Session):
|
||||
p = json.loads(json.dumps(BASE_PARAMS))
|
||||
p["assignee_by_unit"] = {"U1": "财务点名人甲"}
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", p)
|
||||
by_code = {c["unit_code"]: c for c in res["per_customer"]}
|
||||
assert by_code["U1"]["assignee"] == "财务点名人甲"
|
||||
assert by_code["U1"]["assignee_source"] == "manual:assignee_by_unit"
|
||||
|
||||
|
||||
def test_assignee_abst_fallback_when_employee_dict_absent(aging: Session):
|
||||
"""employee_dict 缺表 ⇒ 同库单据摘要尾段制单人姓名降级(仍非空、可审计)。"""
|
||||
_staff(aging, with_employee_dict=False)
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-09", BASE_PARAMS)
|
||||
by_code = {c["unit_code"]: c for c in res["per_customer"]}
|
||||
assert by_code["U1"]["assignee"] == "李世昌"
|
||||
assert "abst_operator" in by_code["U1"]["assignee_source"]
|
||||
|
||||
|
||||
def test_assignee_unresolved_goes_to_skipped(aging: Session):
|
||||
"""维度表全缺 + 部门无法归类 ⇒ 该户不生成,skipped[] 写原因(禁落空 assignee)。"""
|
||||
_staff(aging, with_employee_dict=False, with_units_dict=False, with_zxt=False)
|
||||
p = json.loads(json.dumps(BASE_PARAMS))
|
||||
p["assignee_source"].pop("dept_by_unit")
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-09", p)
|
||||
assert res["per_customer"] == []
|
||||
skipped = {s["unit_code"]: s for s in res["skipped"] if s["scope"] == "customer"}
|
||||
assert set(skipped) == {"U1", "U3"}
|
||||
assert "unit_link_unresolved" in skipped["U1"]["reason"]
|
||||
assert "dept_unclassified" in skipped["U1"]["reason"]
|
||||
assert [h for h in ar_overdue_alert.hits(res) if h.get("unit_code")] == []
|
||||
|
||||
|
||||
def test_assignee_missing_is_fail_closed(aging: Session):
|
||||
p = {"red": {}, "yellow": {}, "assignee_default": "", "assignee_by_unit": {}}
|
||||
with pytest.raises(ar_overdue_alert.AssigneeMissing):
|
||||
ar_overdue_alert.ensure_assignee_configured(p)
|
||||
# 配了逐户派生模式即视为已配置(派生失败按户 skipped,不整体 400)
|
||||
p["assignee_source"] = {"mode": "salesman_then_dept"}
|
||||
ar_overdue_alert.ensure_assignee_configured(p)
|
||||
|
||||
|
||||
def test_level_boundaries_are_param_driven(aging: Session):
|
||||
"""改 DB 参数复跑 ⇒ 命中集合变化,代码零改动(§6.3 第 11 项:single_overdue_amt_gt)。"""
|
||||
_staff(aging)
|
||||
p = json.loads(json.dumps(BASE_PARAMS))
|
||||
assert {c["unit_code"] for c in ar_overdue_alert.compute(aging, ENT, "2026-09", p)["per_customer"]} == {"U1", "U3"}
|
||||
p["red"]["single_overdue_amt_gt"] = 300000 # 5 万 → 30 万 ⇒ U1(8 万) 掉出红线且高于黄线上限
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-09", p)
|
||||
assert {c["unit_code"] for c in res["per_customer"]} == {"U3"}
|
||||
p["red"]["single_overdue_amt_gt"] = 50000
|
||||
p["yellow"]["single_overdue_amt_min"] = 100000 # 黄线下限 10 万 ⇒ U3 消失
|
||||
assert {c["unit_code"] for c in ar_overdue_alert.compute(aging, ENT, "2026-09", p)["per_customer"]} == {"U1"}
|
||||
|
||||
|
||||
def test_missing_period_is_skipped_not_zero(aging: Session):
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-10", BASE_PARAMS)
|
||||
assert res["skipped_reason"] == "no_data_for_period:2026-10"
|
||||
assert res["aggregates"] == [] and res["per_customer"] == []
|
||||
|
||||
|
||||
def test_default_period_is_max_and_mom_computed(staff: Session):
|
||||
res = ar_overdue_alert.compute(staff, ENT, None, BASE_PARAMS)
|
||||
assert res["period"] == "2026-09"
|
||||
assert res["metrics"]["prev_period"] == "2026-08"
|
||||
assert res["metrics"]["prev_overdue_customers"] == 3
|
||||
assert res["metrics"]["overdue_customers"] == 3
|
||||
assert res["metrics"]["count_mom_pct"] == pytest.approx(0.0)
|
||||
assert "count_mom" not in {a["key"] for a in res["aggregates"]}
|
||||
|
||||
|
||||
def test_no_previous_period_no_mom(aging: Session):
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-08", BASE_PARAMS)
|
||||
assert res["metrics"]["prev_period"] is None
|
||||
assert res["metrics"]["count_mom_pct"] is None
|
||||
assert any("不计算环比" in n for n in res["notes"])
|
||||
|
||||
|
||||
def test_exclude_unit_codes(aging: Session):
|
||||
_staff(aging)
|
||||
p = json.loads(json.dumps(BASE_PARAMS))
|
||||
p["exclude_unit_codes"] = ["U1"]
|
||||
res = ar_overdue_alert.compute(aging, ENT, "2026-09", p)
|
||||
assert "U1" not in {c["unit_code"] for c in res["per_customer"]}
|
||||
assert res["metrics"]["total_amt"] < 173159
|
||||
|
||||
|
||||
# ─────────────────────── 落库(只 INSERT) ───────────────────────
|
||||
|
||||
def test_write_alerts_idempotent_and_history_intact(staff: Session):
|
||||
kpi = create_test_kpi(staff, kpi_code="F_AR_002", kpi_name="逾期应收账款率")
|
||||
hist = KPIAlert(entity_id=ENT, kpi_id=kpi.id, alert_level="red",
|
||||
alert_message="历史行-不得被改写", alert_type="actual", status="pending",
|
||||
assignee="old-owner")
|
||||
staff.add(hist)
|
||||
staff.commit()
|
||||
hist_id = hist.id
|
||||
|
||||
res = ar_overdue_alert.compute(staff, ENT, "2026-09", BASE_PARAMS)
|
||||
first = ar_overdue_alert.write_alerts(staff, ENT, kpi.id, res)
|
||||
assert first["inserted"] == len(ar_overdue_alert.hits(res)) > 0
|
||||
second = ar_overdue_alert.write_alerts(staff, ENT, kpi.id, res)
|
||||
assert second == {"inserted": 0, "skipped_duplicate": first["inserted"]}
|
||||
|
||||
rows = staff.query(KPIAlert).filter(KPIAlert.alert_type == "overdue").all()
|
||||
assert len(rows) == first["inserted"]
|
||||
assert all(r.assignee and r.status == "pending" and r.kpi_value_id is None for r in rows)
|
||||
assert all(len(r.alert_message) <= 500 for r in rows)
|
||||
old = staff.get(KPIAlert, hist_id)
|
||||
assert old.alert_message == "历史行-不得被改写" and old.assignee == "old-owner"
|
||||
|
||||
|
||||
# ─────────────────────── HTTP 面(TestClient + 内存库) ───────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def fin_headers(client, db):
|
||||
create_test_user(db, username="fin_probe", role="finance")
|
||||
return auth_header(get_token_for_user(client, username="fin_probe"))
|
||||
|
||||
|
||||
def test_api_meta_trend_overview(staff, client, fin_headers):
|
||||
r = client.get("/api/cma/ar-aging/meta", headers=fin_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["available"] is True
|
||||
assert body["calibers"] == list(ar_caliber.CALIBERS)
|
||||
assert body["latest_period"] == "2026-09"
|
||||
assert body["row_count"] == 9 # 8 借方 + 1 贷方(三口径不混算,仅计数)
|
||||
|
||||
r = client.get("/api/cma/ar-aging/trend", headers=fin_headers,
|
||||
params={"caliber": ar_caliber.CALIBER_DEBIT})
|
||||
assert r.status_code == 200
|
||||
pts = {p["period"]: p for p in r.json()["points"]}
|
||||
assert pts["2026-09"]["has_data"] is True
|
||||
assert pts["2026-10"]["has_data"] is False
|
||||
assert pts["2026-10"]["total_amt"] is None and pts["2026-10"]["customers"] is None
|
||||
|
||||
r = client.get("/api/cma/ar-aging/overview", headers=fin_headers,
|
||||
params={"period": "2026-09", "caliber": ar_caliber.CALIBER_DEBIT})
|
||||
ov = r.json()
|
||||
assert ov["has_data"] is True
|
||||
assert ov["buckets_sum_matches_total"] is True
|
||||
assert ov["opening_amt"] == pytest.approx(5000.0)
|
||||
assert ov["opening_amt_non_aging_base"] is True
|
||||
assert ov["total_amt"] == pytest.approx(173159.0, abs=0.01)
|
||||
|
||||
|
||||
def test_api_caliber_all_rejected(aging, client, fin_headers):
|
||||
for ep in ("overview", "trend", "customers"):
|
||||
r = client.get(f"/api/cma/ar-aging/{ep}", headers=fin_headers, params={"caliber": "all"})
|
||||
assert r.status_code == 400, (ep, r.text)
|
||||
assert "禁止跨口径合计" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_api_customers_sort_and_export(aging, client, fin_headers):
|
||||
r = client.get("/api/cma/ar-aging/customers", headers=fin_headers,
|
||||
params={"period": "2026-09", "caliber": ar_caliber.CALIBER_DEBIT,
|
||||
"overdue_only": "true", "sort": "overdue_amt", "order": "desc"})
|
||||
items = r.json()["items"]
|
||||
assert [i["unit_code"] for i in items][:3] == ["U1", "U3", "U4"]
|
||||
assert items[0]["overdue_amt"] == pytest.approx(80000.0)
|
||||
|
||||
r = client.get("/api/cma/ar-aging/customers/export", headers=fin_headers,
|
||||
params={"period": "2026-09", "caliber": ar_caliber.CALIBER_DEBIT})
|
||||
assert r.status_code == 200
|
||||
assert r.content[:3] == b"\xef\xbb\xbf" # UTF-8 BOM(Excel 直开)
|
||||
lines = r.content.decode("utf-8-sig").splitlines()
|
||||
assert lines[0].startswith("截止期,2026-09,口径,借方应收户口径,导出时刻,")
|
||||
assert "U1" in lines[2] or "U1" in lines[3]
|
||||
|
||||
|
||||
def test_api_run_ar_overdue_rule_params_parameterized(staff, client, db, fin_headers):
|
||||
"""端到端:规则参数来自 DB 行 ⇒ 改 `single_overdue_amt_gt` 复跑命中变化,代码零改动。"""
|
||||
kpi = create_test_kpi(db, kpi_code="F_AR_002", kpi_name="逾期应收账款率")
|
||||
rule = AlertRule(entity_id=ENT, kpi_id=kpi.id, rule_type=ar_overdue_alert.RULE_TYPE,
|
||||
trigger_on="actual", enabled=1, params=BASE_PARAMS)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
|
||||
r = client.post("/api/cma/alert-rules/run-ar-overdue",
|
||||
headers=fin_headers, params={"period": "2026-09", "dry_run": "true"})
|
||||
assert r.status_code == 200, r.text
|
||||
j = r.json()
|
||||
assert j["params_source"] == "alert_rules#%s" % rule.id
|
||||
assert j["kpi_id"] == kpi.id
|
||||
assert j["write_blockers"] == []
|
||||
assert j["hit_count"] == 3 # 结构恶化聚合红 + U1 红 + U3 黄
|
||||
assert {c["unit_code"] for c in j["per_customer"]} == {"U1", "U3"}
|
||||
assert all(c["assignee"] for c in j["per_customer"])
|
||||
assert j["skipped"] == []
|
||||
assert "structure_150p" in {a["key"] for a in j["aggregates"]}
|
||||
|
||||
params2 = json.loads(json.dumps(BASE_PARAMS))
|
||||
params2["red"]["single_overdue_amt_gt"] = 300000 # 5 万 → 30 万
|
||||
rule.params = params2
|
||||
db.commit()
|
||||
r = client.post("/api/cma/alert-rules/run-ar-overdue",
|
||||
headers=fin_headers, params={"period": "2026-09", "dry_run": "true"})
|
||||
assert r.status_code == 200
|
||||
j2 = r.json()
|
||||
assert {c["unit_code"] for c in j2["per_customer"]} == {"U3"}
|
||||
assert j2["hit_count"] == 2 and j2["hit_count"] < j["hit_count"] # 聚合红 + U3 黄
|
||||
|
||||
|
||||
def test_api_run_ar_overdue_assignee_empty_400(aging, client, db, fin_headers):
|
||||
kpi = create_test_kpi(db, kpi_code="F_AR_002x", kpi_name="逾期应收账款率")
|
||||
p = {"red": {}, "yellow": {}, "assignee_default": "", "assignee_by_unit": {}}
|
||||
db.add(AlertRule(entity_id=ENT, kpi_id=kpi.id, rule_type=ar_overdue_alert.RULE_TYPE,
|
||||
trigger_on="actual", enabled=1, params=p))
|
||||
db.commit()
|
||||
r = client.post("/api/cma/alert-rules/run-ar-overdue",
|
||||
headers=fin_headers, params={"period": "2026-09", "dry_run": "true"})
|
||||
assert r.status_code == 400
|
||||
assert "责任人" in r.json()["detail"]
|
||||
|
||||
# dry_run=false + 参数覆盖 → 400(防绕过 DB 配置)
|
||||
r = client.post("/api/cma/alert-rules/run-ar-overdue", headers=fin_headers,
|
||||
params={"period": "2026-09", "dry_run": "false"},
|
||||
json={"params": BASE_PARAMS})
|
||||
assert r.status_code == 400
|
||||
assert "禁止参数/KPI 覆盖" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_api_meta_degrades_when_table_missing(client, db, fin_headers, monkeypatch):
|
||||
_reset_table(db)
|
||||
db.execute(text("DROP TABLE hanke_ar_aging"))
|
||||
db.commit()
|
||||
import app.api.ar_aging as aa
|
||||
monkeypatch.setattr(aa, "_TABLE", "hanke_ar_aging_missing_zzz")
|
||||
r = client.get("/api/cma/ar-aging/meta", headers=fin_headers)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["available"] is False and "不可用" in body["reason"]
|
||||
r = client.get("/api/cma/ar-aging/trend", headers=fin_headers)
|
||||
assert r.status_code == 200 and r.json()["available"] is False
|
||||
@@ -0,0 +1,415 @@
|
||||
"""P3 阶段A · 连线键解耦(读侧解析键)验收测试 —— 沙箱,不连生产。
|
||||
|
||||
判据(对应 P3 单验收标准 2/3):
|
||||
1. 目标改名后 `from_key/to_key` 跟随**同一节点**(key 由 objective_id 反查,不随中文名变);
|
||||
2. 索引位移(删掉前一个目标)后 key 跟随节点到新位置;
|
||||
3. 负测:拿掉 `objective_id` 锚点(等价于"未实现解析键")且名字已改 ⇒ 必须解析失败
|
||||
并被计入 `unresolved`,证明判据有区分度、不是空过。
|
||||
"""
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import StrategicMap, MapObjective, MapConnection
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
DIMS = [
|
||||
{"key": "finance", "name": "财务层", "icon": "F", "color": "#F56C6C",
|
||||
"objectives": [{"name": "营业收入"}, {"name": "营业利润"}]},
|
||||
{"key": "customer", "name": "客户层", "icon": "C", "color": "#409EFF",
|
||||
"objectives": [{"name": "客户留存"}]},
|
||||
{"key": "process", "name": "流程层", "icon": "P", "color": "#67C23A",
|
||||
"objectives": [{"name": "交付效率"}, {"name": "质量闭环"}]},
|
||||
{"key": "learning", "name": "学习层", "icon": "L", "color": "#E6A23C",
|
||||
"objectives": [{"name": "全员培训"}, {"name": "能力认证"}]},
|
||||
]
|
||||
|
||||
|
||||
def _seed(db: Session) -> StrategicMap:
|
||||
m = StrategicMap(entity_id=1, title="p3-conn-key", status="draft",
|
||||
dimensions=copy.deepcopy(DIMS), canvas_data={"connections": []})
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
for d in DIMS:
|
||||
for i, o in enumerate(d["objectives"]):
|
||||
db.add(MapObjective(entity_id=1, map_id=m.id, dimension_key=d["key"],
|
||||
name=o["name"], sort_order=i))
|
||||
db.commit()
|
||||
return m
|
||||
|
||||
|
||||
def _save_dims(client, headers, db: Session, m: StrategicMap, dims) -> None:
|
||||
"""整表 PUT(legacy 通道,批处理/脚本真实会走这条)"""
|
||||
db.refresh(m)
|
||||
body = {"dimensions": dims,
|
||||
"canvas_data": copy.deepcopy(m.canvas_data),
|
||||
"base_updated_at": m.updated_at.isoformat() if m.updated_at else None}
|
||||
r = client.put(f"/api/cma/maps/{m.id}", json=body, headers=headers)
|
||||
assert r.status_code == 200, f"整表 PUT 失败: {r.status_code} {r.text[:300]}"
|
||||
|
||||
|
||||
def _conn(client, headers, map_id: int) -> dict:
|
||||
r = client.get(f"/api/cma/maps/{map_id}/connections", headers=headers)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
return r.json()
|
||||
|
||||
|
||||
def _mk_conn(client, headers, map_id: int, frm: str, to: str) -> None:
|
||||
r = client.post(f"/api/cma/maps/{map_id}/connections", headers=headers,
|
||||
json={"from": frm, "to": to})
|
||||
assert r.status_code == 200, f"建连线失败: {r.status_code} {r.text[:300]}"
|
||||
|
||||
|
||||
def test_rename_keeps_render_key(client: TestClient, db: Session):
|
||||
"""UI 通道(带回 `_mo_id`)改名 → 连线仍挂同一节点(key 不变、unresolved=0)
|
||||
|
||||
口径 B(ASK-20261023-04,任总 2026-10-23 15:13「放行 4」):只有带回 `_mo_id` 的
|
||||
通道能精确保住锚点;不带 `_mo_id` 的老通道改名会断线但可见,
|
||||
见 test_rename_without_moid_breaks_visibly。
|
||||
"""
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
m = _seed(db)
|
||||
_mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率")
|
||||
|
||||
base = _conn(client, h, m.id)
|
||||
assert base["total"] == 1 and base["unresolved"] == 0, base
|
||||
c0 = base["connections"][0]
|
||||
assert c0["from_key"] == "learning-0" and c0["to_key"] == "process-0", c0
|
||||
assert c0["from_key_source"] == "objective_id"
|
||||
|
||||
# 真实 UI 通道:先读(读侧 m_to_dict→_merge_map_objectives 回写 `_mo_id`),改名后整表回传
|
||||
r = client.get("/api/cma/maps", headers=h)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
got_map = next(x for x in r.json()["data"] if x["id"] == m.id)
|
||||
dims = got_map["dimensions"]
|
||||
assert dims[3]["objectives"][0].get("_mo_id"), "读侧未回写 _mo_id,UI 通道无法保锚点"
|
||||
dims[3]["objectives"][0]["name"] = "全员培训A"
|
||||
_save_dims(client, h, db, m, dims)
|
||||
|
||||
after = _conn(client, h, m.id)
|
||||
assert after["total"] == 1, f"连线数减少: {after}"
|
||||
assert after["unresolved"] == 0, after["unresolved_items"]
|
||||
a0 = after["connections"][0]
|
||||
# 原始串仍是旧名(禁回填),但渲染键已由锚点解析到新节点
|
||||
assert a0["from"] == "learning:全员培训", a0
|
||||
assert a0["from_key"] == "learning-0", a0
|
||||
assert a0["from_key_source"] == "objective_id" and a0["from_key_reason"] is None, a0
|
||||
|
||||
|
||||
def test_index_shift_follows_node(client: TestClient, db: Session):
|
||||
"""删除同层前一个目标 ⇒ 节点索引位移,key 跟随节点而非冻结在旧序号"""
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
m = _seed(db)
|
||||
_mk_conn(client, h, m.id, "learning:能力认证", "process:交付效率")
|
||||
|
||||
base = _conn(client, h, m.id)["connections"][0]
|
||||
assert base["from_key"] == "learning-1", base
|
||||
|
||||
dims = copy.deepcopy(m.dimensions)
|
||||
dims[3]["objectives"] = dims[3]["objectives"][1:] # 删掉「全员培训」
|
||||
_save_dims(client, h, db, m, dims)
|
||||
|
||||
after = _conn(client, h, m.id)
|
||||
assert after["unresolved"] == 0, after["unresolved_items"]
|
||||
a0 = after["connections"][0]
|
||||
assert a0["from_key"] == "learning-0", f"key 未跟随节点位移: {a0}"
|
||||
assert a0["from_key_source"] == "objective_id", a0
|
||||
|
||||
|
||||
def test_negative_without_anchor_must_fail(client: TestClient, db: Session):
|
||||
"""负测:拿掉 objective_id 锚点 + 名字已改 ⇒ 必须解析失败并计入 unresolved
|
||||
|
||||
证明判据有区分度:若解析键能力缺失(=改造前行为),这条必然断线且被计数,
|
||||
不会静默画到错节点上。
|
||||
"""
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
m = _seed(db)
|
||||
_mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率")
|
||||
|
||||
dims = copy.deepcopy(m.dimensions)
|
||||
dims[3]["objectives"][0]["name"] = "全员培训A"
|
||||
_save_dims(client, h, db, m, dims)
|
||||
|
||||
# 模拟"无锚点"(历史脏行 / 未回填):抹掉 from_objective_id
|
||||
row = db.query(MapConnection).filter(MapConnection.map_id == m.id).first()
|
||||
row.from_objective_id = None
|
||||
db.commit()
|
||||
|
||||
broken = _conn(client, h, m.id)
|
||||
b0 = broken["connections"][0]
|
||||
assert b0["from_key"] is None, f"无锚点 + 旧名不应解析成功: {b0}"
|
||||
assert b0["from_key_reason"] == "objective_unresolved", b0
|
||||
assert broken["unresolved"] == 1, broken["unresolved_items"]
|
||||
assert broken["unresolved_items"][0]["side"] == "from"
|
||||
assert broken["unresolved_items"][0]["reason"] == "objective_unresolved"
|
||||
# 另一端不受影响
|
||||
assert b0["to_key"] == "process-0" and b0["to_key_source"] == "objective_id", b0
|
||||
|
||||
|
||||
def test_fallback_name_source_still_works(client: TestClient, db: Session):
|
||||
"""兼容期:历史行无 objective_id 但名字未改 ⇒ 仍按 `层:名字` 解析成功(不误报未解析)"""
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
m = _seed(db)
|
||||
_mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率")
|
||||
|
||||
row = db.query(MapConnection).filter(MapConnection.map_id == m.id).first()
|
||||
row.from_objective_id = None
|
||||
db.commit()
|
||||
|
||||
got = _conn(client, h, m.id)
|
||||
assert got["unresolved"] == 0, got["unresolved_items"]
|
||||
c0 = got["connections"][0]
|
||||
assert c0["from_key"] == "learning-0" and c0["from_key_source"] == "name", c0
|
||||
|
||||
|
||||
# ── project 2026-10-22 23:25 裁定的 3 项准入条件(阶段B 开跑前补测)─────────────
|
||||
# 条件2-a:连续两次保存(不改名,不带 _mo_id)⇒ map_objectives 行 id 集合不变(幂等);
|
||||
# 条件2-b:同一次保存内「改名 + 同层调序」且不带 _mo_id ⇒ 两个目标的 id 不得串位;
|
||||
# 条件3 :删除面 —— dims 少传整层 ⇒ 该层行才删;被删目标的连线端计入 unresolved,不得静默消失。
|
||||
# 另:hold 口径断言 total = green + yellow + red + hold(hold 不得二次计入 miss)。
|
||||
|
||||
def _row_ids(db: Session, map_id: int) -> dict:
|
||||
"""{(层, 名字): 行 id}"""
|
||||
rows = db.query(MapObjective).filter(MapObjective.map_id == map_id).all()
|
||||
return {(r.dimension_key, r.name): r.id for r in rows}
|
||||
|
||||
|
||||
def test_save_twice_is_idempotent(client: TestClient, db: Session):
|
||||
"""连续两次保存不改名 ⇒ 行 id 集合逐字不变(否则锚点必成野指针)"""
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
m = _seed(db)
|
||||
_mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率")
|
||||
|
||||
first = _row_ids(db, m.id)
|
||||
dims = copy.deepcopy(m.dimensions)
|
||||
_save_dims(client, h, db, m, dims) # 第 1 次保存(结构未变)
|
||||
second = _row_ids(db, m.id)
|
||||
_save_dims(client, h, db, m, dims) # 第 2 次保存
|
||||
third = _row_ids(db, m.id)
|
||||
|
||||
assert first == second, f"首次保存换了行 id: {first} -> {second}"
|
||||
assert second == third, f"二次保存换了行 id: {second} -> {third}"
|
||||
got = _conn(client, h, m.id)
|
||||
assert got["unresolved"] == 0, got["unresolved_items"]
|
||||
assert got["connections"][0]["from_key"] == "learning-0"
|
||||
|
||||
|
||||
def test_rename_plus_reorder_without_moid_no_crossbind(client: TestClient, db: Session):
|
||||
"""口径 B:同一次保存内「改名 + 同层调序」且不带 _mo_id ⇒ 不得串位
|
||||
|
||||
名字层先把未改名项钉住原行;改名项不再按位置兜底 ⇒ 当新行(旧行删除),
|
||||
连线锚点指向旧行 ⇒ unresolved + stale_objective_id(断线可见,绝不静默错绑)。
|
||||
"""
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
m = _seed(db)
|
||||
_mk_conn(client, h, m.id, "learning:能力认证", "process:交付效率")
|
||||
before = _row_ids(db, m.id)
|
||||
assert _conn(client, h, m.id)["connections"][0]["from_key"] == "learning-1"
|
||||
|
||||
# 调序(能力认证升到第 0 位)+ 改名(能力认证→能力认证A),整表 PUT,不带 _mo_id
|
||||
dims = copy.deepcopy(m.dimensions)
|
||||
dims[3]["objectives"] = [{"name": "能力认证A"}, {"name": "全员培训"}]
|
||||
_save_dims(client, h, db, m, dims)
|
||||
|
||||
after = _row_ids(db, m.id)
|
||||
assert after[("learning", "全员培训")] == before[("learning", "全员培训")], \
|
||||
f"未改名的「全员培训」行 id 变了(串位): {before} -> {after}"
|
||||
assert after[("learning", "能力认证A")] != before[("learning", "能力认证")], \
|
||||
f"口径 B 下老通道改名应新建行(不做位置兜底): {before} -> {after}"
|
||||
|
||||
got = _conn(client, h, m.id)
|
||||
assert got["total"] == 1, got
|
||||
c0 = got["connections"][0]
|
||||
assert got["unresolved"] == 1, got["unresolved_items"]
|
||||
assert c0["from"] == "learning:能力认证", "原始串被改写(禁回填)"
|
||||
assert c0["from_key"] is None and c0["from_key_reason"] == "stale_objective_id", c0
|
||||
# 关键:锚点不得被改指到别的节点(旧行为会把线静默挂到「全员培训」上)
|
||||
row = db.query(MapConnection).filter(MapConnection.map_id == m.id).first()
|
||||
assert row.from_objective_id == before[("learning", "能力认证")], "连线锚点被静默改指(错绑)"
|
||||
|
||||
|
||||
def test_rename_without_moid_breaks_visibly(client: TestClient, db: Session):
|
||||
"""口径 B 单点验证:不带 `_mo_id` 的纯改名 ⇒ 新行 + 连线进 unresolved(断线可见)
|
||||
|
||||
同通道下「删A加B」与「A改名为B」数据上不可区分,位置兜底会让前者静默错绑;
|
||||
放弃兜底的代价就是这里:改名会断线,但断线在接口里可见、可计数。
|
||||
"""
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
m = _seed(db)
|
||||
_mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率")
|
||||
before = _row_ids(db, m.id)
|
||||
|
||||
dims = copy.deepcopy(m.dimensions)
|
||||
assert not any("_mo_id" in o for o in dims[3]["objectives"]), "fixture 不应带 _mo_id(老通道)"
|
||||
dims[3]["objectives"][0]["name"] = "全员培训A"
|
||||
_save_dims(client, h, db, m, dims)
|
||||
|
||||
after = _row_ids(db, m.id)
|
||||
assert after[("learning", "全员培训A")] != before[("learning", "全员培训")], \
|
||||
f"老通道改名竟保住锚点(口径 B 下应为新行): {before} -> {after}"
|
||||
assert ("learning", "全员培训") not in after, f"旧行未删: {after}"
|
||||
|
||||
got = _conn(client, h, m.id)
|
||||
assert got["total"] == 1, f"连线行被静默丢弃: {got}"
|
||||
c0 = got["connections"][0]
|
||||
assert c0["from"] == "learning:全员培训", "原始串被改写(禁回填)"
|
||||
assert c0["from_key"] is None and c0["from_key_reason"] == "stale_objective_id", c0
|
||||
assert got["unresolved"] == 1 and got["unresolved_items"][0]["reason"] == "stale_objective_id"
|
||||
assert c0["to_key"] == "process-0", "另一端不应受影响"
|
||||
row = db.query(MapConnection).filter(MapConnection.map_id == m.id).first()
|
||||
assert row.from_objective_id == before[("learning", "全员培训")], "连线锚点被静默改指(错绑)"
|
||||
|
||||
|
||||
def test_delete_face_unresolved_not_silent(client: TestClient, db: Session):
|
||||
"""删除面:dims 少传整层 ⇒ 该层行删;被删目标的连线端计入 unresolved 且原始串保留"""
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
m = _seed(db)
|
||||
_mk_conn(client, h, m.id, "learning:全员培训", "process:交付效率")
|
||||
|
||||
dims = copy.deepcopy(m.dimensions)
|
||||
dims = dims[:3] # 整层去掉 learning
|
||||
_save_dims(client, h, db, m, dims)
|
||||
|
||||
left = db.query(MapObjective).filter(MapObjective.map_id == m.id,
|
||||
MapObjective.dimension_key == "learning").all()
|
||||
assert left == [], f"该层已不在 dims,行未删: {[r.id for r in left]}"
|
||||
|
||||
got = _conn(client, h, m.id)
|
||||
assert got["total"] == 1, f"连线被静默丢弃: {got}" # 行仍在,不消失
|
||||
c0 = got["connections"][0]
|
||||
assert c0["from"] == "learning:全员培训", "原始串被改写(禁回填)"
|
||||
# 锚点指向已删行 ⇒ reason 是更精确的 `stale_objective_id`(区别于「无锚点」的 objective_unresolved)
|
||||
assert c0["from_key"] is None and c0["from_key_reason"] == "stale_objective_id", c0
|
||||
assert got["unresolved"] == 1, got["unresolved_items"]
|
||||
assert got["unresolved_items"][0]["side"] == "from"
|
||||
assert c0["to_key"] == "process-0", "另一端不应受影响"
|
||||
|
||||
|
||||
def test_review_hold_total_invariant(client: TestClient, db: Session):
|
||||
"""hold 口径:total = green + yellow + red + hold;hold 不得二次计入 miss(红/黄)"""
|
||||
from app.models import KPIDefinition, KPIValue
|
||||
from app.api.maps import DATA_HOLD_KPIS
|
||||
from tests.conftest import create_test_kpi
|
||||
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
hold_code = next(iter(DATA_HOLD_KPIS))
|
||||
red_code = "P3_TOTAL_INVARIANT_RED"
|
||||
db.query(KPIDefinition).filter(KPIDefinition.kpi_code == red_code).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
red_kpi = create_test_kpi(db, kpi_code=red_code, kpi_name="P3 红项", dimension="customer",
|
||||
target_value=100.0, kpi_level="strategic", unit="%")
|
||||
db.add(KPIValue(kpi_id=red_kpi.id, period="2026-09", actual_value=10.0, source_type="actual"))
|
||||
db.commit()
|
||||
|
||||
dims = [{"key": "customer", "name": "客户层", "icon": "", "color": "#409EFF", "objectives": [
|
||||
{"name": "待核项", "kpis": [hold_code]},
|
||||
{"name": "红项", "kpis": [red_code]},
|
||||
{"name": "无指标项"},
|
||||
]}]
|
||||
m = StrategicMap(entity_id=1, title="p3-hold-invariant", status="active",
|
||||
dimensions=dims, canvas_data={"connections": []})
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
|
||||
r = client.get(f"/api/cma/maps/{m.id}/review", params={"level": "strategic"}, headers=h)
|
||||
assert r.status_code == 200, r.text
|
||||
s = r.json()["summary"]
|
||||
assert s["total"] == 3, s
|
||||
assert s["hold"] == 1, s
|
||||
assert s["red"] == 1, s
|
||||
assert s["green"] == 1, s
|
||||
assert s["total"] == s["green"] + s["yellow"] + s["red"] + s["hold"], \
|
||||
f"分母不守恒(hold 被二次计入?): {s}"
|
||||
|
||||
db.query(KPIValue).filter(KPIValue.kpi_id == red_kpi.id).delete(synchronize_session=False)
|
||||
db.query(KPIDefinition).filter(KPIDefinition.kpi_code == red_code).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
|
||||
def test_negative_greedy_matching_would_crossbind(client: TestClient, db: Session, monkeypatch):
|
||||
"""负测(判据有效性的反证):把定位算法换回历史「逐项贪心(名→序位→空位)」,
|
||||
则 rename+reorder 场景下两个目标的 id 必然交换 —— 证明
|
||||
test_rename_plus_reorder_without_moid_no_crossbind 有区分度,不是空过。"""
|
||||
from app.api import maps as maps_mod
|
||||
|
||||
def legacy_greedy(m, db_session=None):
|
||||
sess = db_session or db
|
||||
dims = maps_mod._dims_list(m.dimensions)
|
||||
ent = m.entity_id if m.entity_id is not None else 1
|
||||
rows = sess.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(
|
||||
MapObjective.sort_order, MapObjective.id).all()
|
||||
used, keep = set(), []
|
||||
for dim in dims:
|
||||
dk = dim.get("key", "")
|
||||
for i, obj in enumerate(dim.get("objectives") or []):
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
target = None
|
||||
want = maps_mod._norm_obj_name(obj.get("name"))
|
||||
for r in rows: # ① 名字
|
||||
if r.id in used or r.dimension_key != dk:
|
||||
continue
|
||||
if maps_mod._norm_obj_name(r.name) == want:
|
||||
target = r
|
||||
break
|
||||
if target is None:
|
||||
for r in rows: # ② 同层同序位
|
||||
if r.id in used or r.dimension_key != dk:
|
||||
continue
|
||||
if (r.sort_order or 0) == i:
|
||||
target = r
|
||||
break
|
||||
if target is None:
|
||||
for r in rows: # ③ 同层空位
|
||||
if r.id in used or r.dimension_key != dk:
|
||||
continue
|
||||
target = r
|
||||
break
|
||||
if target is None:
|
||||
target = MapObjective(map_id=m.id, entity_id=ent, dimension_key=dk or "finance")
|
||||
sess.add(target)
|
||||
else:
|
||||
used.add(target.id)
|
||||
target.entity_id = ent
|
||||
target.dimension_key = dk
|
||||
target.name = (obj.get("name") or "")[:200]
|
||||
target.icon = obj.get("icon") or "target"
|
||||
target.kpis = obj.get("kpis") or []
|
||||
target.sort_order = i
|
||||
keep.append(target)
|
||||
sess.flush()
|
||||
for r in rows:
|
||||
if r.id not in used:
|
||||
sess.delete(r)
|
||||
sess.commit()
|
||||
|
||||
create_test_user(db)
|
||||
h = auth_header(get_token_for_user(client))
|
||||
m = _seed(db)
|
||||
before = _row_ids(db, m.id)
|
||||
|
||||
monkeypatch.setattr(maps_mod, "_sync_map_objectives", legacy_greedy)
|
||||
dims = copy.deepcopy(m.dimensions)
|
||||
dims[3]["objectives"] = [{"name": "能力认证A"}, {"name": "全员培训"}]
|
||||
_save_dims(client, h, db, m, dims)
|
||||
after = _row_ids(db, m.id)
|
||||
|
||||
assert after[("learning", "全员培训")] != before[("learning", "全员培训")], \
|
||||
f"旧算法竟未串位,则该负测无法证明新判据有效: {before} -> {after}"
|
||||
assert after[("learning", "能力认证A")] == before[("learning", "全员培训")], \
|
||||
f"预期旧算法把「能力认证A」绑到「全员培训」的旧行 id(串位): {before} -> {after}"
|
||||
@@ -0,0 +1,162 @@
|
||||
"""A2 幂等键:kpi_alerts.dedupe_key(任务 project-20261021-ar-overdue-dedupe-key-fullstack)
|
||||
|
||||
覆盖:
|
||||
T1 历史旧文案行(dedupe_key=NULL)零改动 + 同语义口径重跑 → 全跳过
|
||||
T2 重复跑不增行(幂等)
|
||||
T3 负测:新单户 → 插入 1 条(证明不是「一律跳过」)
|
||||
T4 负测:同户金额变化 → 不插(语义幂等,键不含金额)
|
||||
T5 同批两条同键 → 1 插 1 跳(批内去重 + 唯一索引兜底)
|
||||
T6 dry-run count_would_insert 与真实 inserted 一致
|
||||
T7 键生成/反解单测(结构 + 聚合族归一化)
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models import KPIAlert
|
||||
from app.services import ar_overdue_alert as A
|
||||
|
||||
EID, KPI = 1, 517
|
||||
P = "2026-09"
|
||||
|
||||
LEGACY = [
|
||||
# 1 条聚合(旧文案,能被反解成 agg|share_90plus)
|
||||
"【应收账款逾期】2026-09 借方应收户口径 90 天以上 ¥1,000.00,占应收总额 15.56%(红线 >25.00%)|建议:见规则配置",
|
||||
# 7 条单户(旧文案,反解成 cust|<code>)
|
||||
"【应收账款逾期】王敏(HKJJ053)2026-09 账龄最老 151 天,逾期合计 ¥666,537.00(占该户 100.00%)|建议:催收|责任人:李世昌",
|
||||
"【应收账款逾期】西安酣君品创(HKJJ120)2026-09 账龄最老 151 天,逾期合计 ¥300,120.00(占该户 85.14%)|建议:催收|责任人:李世昌",
|
||||
"【应收账款逾期】吉林酣客(HKJJ156)2026-09 账龄最老 151 天,逾期合计 ¥75,640.00(占该户 100.00%)|建议:催收|责任人:李世昌",
|
||||
"【应收账款逾期】荣升酒窖(HKJJ166)2026-09 账龄最老 151 天,逾期合计 ¥36,586.00(占该户 100.00%)|建议:催收|责任人:兀晨博",
|
||||
"【应收账款逾期】宝鸡裕腾(HKJJ175)2026-09 账龄最老 91 天,逾期合计 ¥32,560.00(占该户 100.00%)|建议:催收|责任人:兀晨博",
|
||||
"【应收账款逾期】酣聚酒窖(HKJJ122)2026-09 账龄最老 151 天,逾期合计 ¥32,538.54(占该户 100.00%)|建议:催收|责任人:兀晨博",
|
||||
"【应收账款逾期】孙飞龙客户(007)2026-09 账龄最老 45 天,逾期合计 ¥10,000.00(占该户 50.00%)|建议:催收|责任人:孙飞龙",
|
||||
]
|
||||
|
||||
|
||||
def _seed_legacy(db):
|
||||
for m in LEGACY:
|
||||
db.add(KPIAlert(entity_id=EID, kpi_id=KPI, alert_level="red", alert_message=m,
|
||||
alert_type="overdue", suggestion="催收", status="pending",
|
||||
assignee="李世昌"))
|
||||
db.commit()
|
||||
return len(LEGACY)
|
||||
|
||||
|
||||
def _cust(code, amt="1.00", name="客户", assignee="李世昌"):
|
||||
return {"unit_code": code, "unit_name": name, "overdue_amt": amt, "total_amt": "1.00",
|
||||
"oldest_days": 151, "pct_of_unit": 1.0, "level": "red",
|
||||
"alert_message": "【应收账款逾期】%s(%s)%s 账龄最老 151 天,逾期合计 ¥%s(占该户 100.00%%)"
|
||||
"|建议:催收|责任人:%s" % (name, code, P, amt, assignee),
|
||||
"suggestion": "催收", "assignee": assignee, "assignee_source": "unit", "emp_id": "E1"}
|
||||
|
||||
|
||||
def _agg(key="share_90plus", msg=None):
|
||||
return {"key": key, "level": "red",
|
||||
"metrics": {"share_90plus": 0.1556},
|
||||
"alert_message": msg or ("【应收账款逾期】%s 借方应收户口径 90 天以上 ¥1,000.00,"
|
||||
"占应收总额 15.56%%(红线 >25.00%%)|建议:见规则配置" % P),
|
||||
"suggestion": "见规则配置", "assignee": "李世昌", "assignee_source": "aggregate"}
|
||||
|
||||
|
||||
def _result(customers, aggs=None):
|
||||
return {"period": P, "aggregates": aggs or [], "per_customer": customers}
|
||||
|
||||
|
||||
def _total(db):
|
||||
return db.execute(text("select count(*) from kpi_alerts")).scalar()
|
||||
|
||||
|
||||
# ── T7 键生成/反解(纯函数)────────────────────────────────────────────
|
||||
def test_t7_key_gen_and_parse():
|
||||
assert A._item_dedupe_key({"unit_code": "HKJJ053"}, P) == "2026-09|cust|HKJJ053"
|
||||
assert A._item_dedupe_key({"key": "share_90plus"}, P) == "2026-09|agg|share_90plus"
|
||||
assert A._item_dedupe_key({"key": "count_mom"}, P) == "2026-09|agg|count_mom"
|
||||
assert A._item_dedupe_key({"key": "structure_90_150"}, P) == "2026-09|agg|structure"
|
||||
assert A._item_dedupe_key({"key": "structure_150p"}, P) == "2026-09|agg|structure"
|
||||
assert A._item_dedupe_key({}, P) is None
|
||||
# 反解(历史兼容)
|
||||
assert A._key_from_message(LEGACY[0]) == "2026-09|agg|share_90plus"
|
||||
for m in LEGACY[1:]:
|
||||
code = m.split("(")[1].split(")")[0]
|
||||
assert A._key_from_message(m) == "2026-09|cust|%s" % code
|
||||
assert A._key_from_message("完全无关的文案") is None
|
||||
|
||||
|
||||
# ── T1/T2 历史行零改 + 重跑幂等 ────────────────────────────────────────
|
||||
def test_t1_t2_legacy_untouched_and_idempotent(db):
|
||||
n0 = _seed_legacy(db)
|
||||
ids_before = [r[0] for r in db.execute(text("select id from kpi_alerts order by id")).all()]
|
||||
snap_before = [tuple(r) for r in db.execute(text(
|
||||
"select id, alert_message, dedupe_key, status, coalesce(assignee,'') from kpi_alerts order by id")).all()]
|
||||
|
||||
# 同语义口径 8 条(1 聚合 + 7 单户),文案由新代码生成(措辞与历史略有差异也可)
|
||||
res = _result([_cust(c) for c in ("HKJJ053", "HKJJ120", "HKJJ156", "HKJJ166", "HKJJ175", "HKJJ122", "007")],
|
||||
[_agg()])
|
||||
w1 = A.write_alerts(db, EID, KPI, res)
|
||||
assert w1 == {"inserted": 0, "skipped_duplicate": 8}, w1
|
||||
|
||||
# 再跑一次(T2)
|
||||
w2 = A.write_alerts(db, EID, KPI, res)
|
||||
assert w2 == {"inserted": 0, "skipped_duplicate": 8}, w2
|
||||
|
||||
# 历史行零改动(R1 红线)
|
||||
snap_after = [tuple(r) for r in db.execute(text(
|
||||
"select id, alert_message, dedupe_key, status, coalesce(assignee,'') from kpi_alerts order by id")).all()]
|
||||
assert snap_after == snap_before
|
||||
assert _total(db) == n0
|
||||
assert [r[0] for r in db.execute(text("select id from kpi_alerts order by id")).all()] == ids_before
|
||||
|
||||
|
||||
# ── T3 新户插入(负测)────────────────────────────────────────────────
|
||||
def test_t3_new_customer_inserts(db):
|
||||
_seed_legacy(db)
|
||||
n0 = _total(db)
|
||||
res = _result([_cust("HKJJ999", amt="88,888.00", name="新户")])
|
||||
w = A.write_alerts(db, EID, KPI, res)
|
||||
assert w == {"inserted": 1, "skipped_duplicate": 0}, w
|
||||
assert _total(db) == n0 + 1
|
||||
row = db.query(KPIAlert).filter(KPIAlert.dedupe_key == "2026-09|cust|HKJJ999").one()
|
||||
assert row.alert_type == "overdue" and row.assignee == "李世昌"
|
||||
|
||||
|
||||
# ── T4 同户金额变化不插(语义幂等)────────────────────────────────────
|
||||
def test_t4_amount_change_does_not_insert(db):
|
||||
_seed_legacy(db)
|
||||
res = _result([_cust("HKJJ053", amt="700,000.00", name="王敏")])
|
||||
w = A.write_alerts(db, EID, KPI, res)
|
||||
assert w == {"inserted": 0, "skipped_duplicate": 1}, w
|
||||
|
||||
|
||||
# ── T5 同批两条同键 → 1 插 1 跳 ───────────────────────────────────────
|
||||
def test_t5_same_key_in_one_batch(db):
|
||||
n0 = _total(db)
|
||||
res = _result([_cust("HKJJ888", amt="1.00"), _cust("HKJJ888", amt="2.00")])
|
||||
w = A.write_alerts(db, EID, KPI, res)
|
||||
assert w == {"inserted": 1, "skipped_duplicate": 1}, w
|
||||
assert _total(db) == n0 + 1
|
||||
|
||||
|
||||
# ── T6 dry-run 与实插一致 ─────────────────────────────────────────────
|
||||
def test_t6_dry_run_matches_insert(db):
|
||||
_seed_legacy(db)
|
||||
res = _result([_cust("HKJJ053"), _cust("HKJJ777", amt="3.00")], [_agg()])
|
||||
n_pred = A.count_would_insert(db, EID, KPI, res)
|
||||
w = A.write_alerts(db, EID, KPI, res)
|
||||
assert n_pred == w["inserted"] == 1, (n_pred, w)
|
||||
# 写后再算 → 0
|
||||
assert A.count_would_insert(db, EID, KPI, res) == 0
|
||||
|
||||
|
||||
# ── T7b 唯一索引语义:手工插同键第二次必须失败(并发兜底)─────────────
|
||||
def test_t7b_unique_index_enforced(db):
|
||||
# conftest 的 create_all 不含 DDL 索引 ⇒ 测试内补建(生产由 ddl 脚本建)
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uk_kpi_alerts_dedupe_test "
|
||||
"ON kpi_alerts (entity_id, kpi_id, alert_type, dedupe_key)"))
|
||||
db.commit()
|
||||
db.add(KPIAlert(entity_id=EID, kpi_id=KPI, alert_message="a", alert_type="overdue",
|
||||
dedupe_key="2026-09|cust|DUP"))
|
||||
db.commit()
|
||||
db.add(KPIAlert(entity_id=EID, kpi_id=KPI, alert_message="b", alert_type="overdue",
|
||||
dedupe_key="2026-09|cust|DUP"))
|
||||
with pytest.raises(Exception):
|
||||
db.commit()
|
||||
db.rollback()
|
||||
@@ -0,0 +1,166 @@
|
||||
"""P1 四层因果结构化:map_connections 表 + /connections 读/写/analysis 回归
|
||||
|
||||
任务 project-20261021-cma-map-connections-table-p1-fullstack
|
||||
覆盖:双格式解析(层:中文名 / 层-序号)、双写、读表回落 canvas、分析与告警不拦截。
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import StrategicMap, MapObjective, MapConnection
|
||||
from app.api.maps import parse_node_ref, resolve_node
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
DIMS = [
|
||||
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
|
||||
"objectives": [{"name": "营业收入"}, {"name": "营业利润"}]},
|
||||
{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF",
|
||||
"objectives": [{"name": "客户留存"}]},
|
||||
{"key": "process", "name": "流程层", "icon": "⚙️", "color": "#67C23A",
|
||||
"objectives": [{"name": "交付效率"}, {"name": "质量闭环"}]},
|
||||
{"key": "learning", "name": "学习层", "icon": "📚", "color": "#E6A23C",
|
||||
"objectives": [{"name": "全员培训"}, {"name": "能力认证"}]},
|
||||
]
|
||||
|
||||
|
||||
def _seed_map(db: Session, title="P1连线测试", canvas=None) -> StrategicMap:
|
||||
m = StrategicMap(entity_id=1, title=title, status="draft", dimensions=DIMS,
|
||||
canvas_data=canvas or {"connections": []})
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
for d in DIMS:
|
||||
for i, o in enumerate(d["objectives"]):
|
||||
db.add(MapObjective(entity_id=1, map_id=m.id, dimension_key=d["key"],
|
||||
name=o["name"], sort_order=i))
|
||||
db.commit()
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(db: Session):
|
||||
db.query(MapConnection).delete()
|
||||
db.query(MapObjective).filter(MapObjective.map_id >= 900000).delete()
|
||||
db.query(StrategicMap).filter(StrategicMap.id >= 900000).delete()
|
||||
db.commit()
|
||||
yield
|
||||
|
||||
|
||||
class TestNodeParse:
|
||||
def test_colon_format(self):
|
||||
r = parse_node_ref("learning:全员培训")
|
||||
assert (r["layer"], r["kind"], r["value"], r["error"]) == ("learning", "name", "全员培训", None)
|
||||
|
||||
def test_index_format(self):
|
||||
r = parse_node_ref("process-1")
|
||||
assert (r["layer"], r["kind"], r["value"], r["error"]) == ("process", "index", 1, None)
|
||||
|
||||
def test_unknown_formats(self):
|
||||
assert parse_node_ref("nonsense")["error"] == "node_format_unrecognized"
|
||||
assert parse_node_ref("hr:某目标")["error"] == "unknown_layer:hr"
|
||||
assert parse_node_ref("")["error"] == "node_empty"
|
||||
|
||||
def test_resolve_both_formats(self, db: Session):
|
||||
m = _seed_map(db)
|
||||
# 冒号中文名
|
||||
layer, oid, note = resolve_node(db, m, "learning:能力认证")
|
||||
assert (layer, note) == ("learning", None) and oid is not None
|
||||
# 序号(dimensions 数组下标)
|
||||
layer, oid2, note = resolve_node(db, m, "process-1")
|
||||
assert (layer, note) == ("process", None)
|
||||
obj = db.query(MapObjective).filter(MapObjective.id == oid2).first()
|
||||
assert obj.name == "质量闭环"
|
||||
|
||||
|
||||
class TestConnectionApi:
|
||||
def test_post_dual_write_and_get_table(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
m = _seed_map(db, title="P1双写")
|
||||
mid = m.id
|
||||
|
||||
resp = client.post(f"/api/cma/maps/{mid}/connections", headers=auth_header(token),
|
||||
json={"from": "learning:全员培训", "to": "process:交付效率"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["db_synced"] is True
|
||||
assert body["connections"][0]["from"] == "learning:全员培训"
|
||||
|
||||
# canvas_data 保留(前端渲染主路径)
|
||||
db.expire_all()
|
||||
m2 = db.query(StrategicMap).filter(StrategicMap.id == mid).first()
|
||||
assert m2.canvas_data["connections"][0]["to"] == "process:交付效率"
|
||||
# 表内落行且层/目标解析正确
|
||||
row = db.query(MapConnection).filter(MapConnection.map_id == mid).one()
|
||||
assert (row.from_layer, row.to_layer) == ("learning", "process")
|
||||
assert row.from_objective_id and row.to_objective_id
|
||||
|
||||
got = client.get(f"/api/cma/maps/{mid}/connections", headers=auth_header(token))
|
||||
assert got.status_code == 200
|
||||
assert got.json()["source"] == "table"
|
||||
assert got.json()["total"] == 1
|
||||
|
||||
def test_index_format_persisted(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
m = _seed_map(db, title="P1序号格式")
|
||||
resp = client.post(f"/api/cma/maps/{m.id}/connections", headers=auth_header(token),
|
||||
json={"from": "learning-0", "to": "process-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
row = db.query(MapConnection).filter(MapConnection.map_id == m.id).one()
|
||||
assert (row.from_node, row.from_layer) == ("learning-0", "learning")
|
||||
assert row.from_objective_id and row.to_objective_id
|
||||
|
||||
def test_get_falls_back_to_canvas(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
m = _seed_map(db, title="P1回落",
|
||||
canvas={"connections": [{"from": "learning:全员培训", "to": "process:质量闭环"}]})
|
||||
got = client.get(f"/api/cma/maps/{m.id}/connections", headers=auth_header(token))
|
||||
assert got.status_code == 200
|
||||
assert got.json()["source"] == "canvas_data"
|
||||
assert got.json()["total"] == 1
|
||||
|
||||
def test_delete_removes_both(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
m = _seed_map(db, title="P1删除")
|
||||
client.post(f"/api/cma/maps/{m.id}/connections", headers=auth_header(token),
|
||||
json={"from": "learning:全员培训", "to": "process:交付效率"})
|
||||
resp = client.request("DELETE", f"/api/cma/maps/{m.id}/connections", headers=auth_header(token),
|
||||
json={"from": "learning:全员培训", "to": "process:交付效率"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["db_synced"] is True
|
||||
assert db.query(MapConnection).filter(MapConnection.map_id == m.id).count() == 0
|
||||
db.expire_all()
|
||||
m2 = db.query(StrategicMap).filter(StrategicMap.id == m.id).first()
|
||||
assert m2.canvas_data["connections"] == []
|
||||
|
||||
def test_analysis_stats_reverse_and_broken(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
m = _seed_map(db, title="P1分析")
|
||||
pairs = [
|
||||
("learning:全员培训", "process:交付效率"), # 正常
|
||||
("process:质量闭环", "customer:客户留存"), # 正常
|
||||
("customer:客户留存", "finance:营业收入"), # 正常
|
||||
("finance:营业利润", "customer:客户留存"), # 反向(告警不拦截)
|
||||
("finance:营业收入", "finance:营业利润"), # 同层
|
||||
("learning:不存在目标", "process:交付效率"), # 断链(照样可写)
|
||||
]
|
||||
for f, t in pairs:
|
||||
r = client.post(f"/api/cma/maps/{m.id}/connections", headers=auth_header(token),
|
||||
json={"from": f, "to": t})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
a = client.get(f"/api/cma/maps/{m.id}/connections/analysis", headers=auth_header(token))
|
||||
assert a.status_code == 200, a.text
|
||||
d = a.json()
|
||||
assert d["source"] == "table" and d["total"] == 6
|
||||
assert d["layer_stats"]["learning"]["as_from"] == 2
|
||||
assert d["layer_stats"]["finance"]["as_to"] == 2 # customer→finance 1 + finance→finance 1
|
||||
assert d["cross_layer"]["learning->process"] == 2 # 正常 1 + 断链 1(层可解析、目标不可解析)
|
||||
assert d["same_layer_count"] == 1
|
||||
assert len(d["reversed"]) == 1 and d["reversed"][0]["from_layer"] == "finance"
|
||||
assert any(b["side"] == "from" for b in d["broken"])
|
||||
assert any("反向连线" in w for w in d["warnings"])
|
||||
@@ -0,0 +1,137 @@
|
||||
"""P2 回归:GET /api/cma/maps/{id}/review?level=strategic 不再 500
|
||||
|
||||
任务 project-20261022-cma-map-canvas-connections-not-drawn-fullstack(缺陷2)
|
||||
根因:maps.py 的 total_hold 只在「目标含 DATA_HOLD_KPIS」分支被 += 累加、从未初始化,
|
||||
首个待核目标即 UnboundLocalError → 500(线上 map65 栈顶实测)。
|
||||
修复:初始化 total_hold = 0,并在 summary.hold 透出计数(纯增量,消费方 green/yellow/red/health_score 不受影响)。
|
||||
|
||||
覆盖:
|
||||
① 含待核 KPI 的地图 → 200,summary.hold=1,该目标 data_hold=True 且 actual 不外泄
|
||||
② 普通地图 → 200,summary.hold=0
|
||||
③ dimensions 缺失(None)/ 空数组 → 200,不抛(total=0,health_score=0)
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue
|
||||
from app.api.maps import DATA_HOLD_KPIS
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
|
||||
HOLD_CODE = "C_CUST_CONCENTRATION"
|
||||
PLAIN_CODE = "TEST_REVIEW_PLAIN"
|
||||
|
||||
|
||||
def _seed_review_map(db: Session, dims, title="P2 review 回归", map_id=None) -> StrategicMap:
|
||||
kwargs = dict(entity_id=1, title=title, status="active", dimensions=dims,
|
||||
canvas_data={"connections": []})
|
||||
if map_id is not None:
|
||||
kwargs["id"] = map_id
|
||||
m = StrategicMap(**kwargs)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(db: Session):
|
||||
yield
|
||||
db.rollback()
|
||||
db.query(KPIValue).filter(KPIValue.kpi_id.in_(
|
||||
[k.id for k in db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code.in_([HOLD_CODE, PLAIN_CODE])).all()]
|
||||
)).delete(synchronize_session=False)
|
||||
db.query(KPIDefinition).filter(KPIDefinition.kpi_code.in_([HOLD_CODE, PLAIN_CODE])).delete(
|
||||
synchronize_session=False)
|
||||
db.query(MapObjective).filter(MapObjective.map_id >= 900000).delete(synchronize_session=False)
|
||||
db.query(StrategicMap).filter(StrategicMap.id >= 900000).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _hold_dims():
|
||||
# 一个待核目标(含 C_CUST_CONCENTRATION)+ 一个普通目标
|
||||
return [{
|
||||
"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF",
|
||||
"objectives": [
|
||||
{"name": "客户集中度", "kpis": [HOLD_CODE]},
|
||||
{"name": "客户满意", "kpis": [PLAIN_CODE]},
|
||||
],
|
||||
}]
|
||||
|
||||
|
||||
def _seed_kpis(db: Session):
|
||||
hold = create_test_kpi(db, kpi_code=HOLD_CODE, kpi_name="客户集中度", dimension="customer",
|
||||
target_value=60.0, kpi_level="strategic", unit="%")
|
||||
plain = create_test_kpi(db, kpi_code=PLAIN_CODE, kpi_name="客户满意", dimension="customer",
|
||||
target_value=90.0, kpi_level="strategic", unit="分")
|
||||
return hold, plain
|
||||
|
||||
|
||||
class TestReviewNo500:
|
||||
def test_data_hold_kpi_returns_200_and_counts_hold(self, client: TestClient, db: Session):
|
||||
"""含待核 KPI:修复前此处 UnboundLocalError → 500;修复后 200 且 summary.hold=1。"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
hold_kpi, plain_kpi = _seed_kpis(db)
|
||||
db.add(KPIValue(kpi_id=hold_kpi.id, period="2026-09", actual_value=72.0, source_type="actual"))
|
||||
db.add(KPIValue(kpi_id=plain_kpi.id, period="2026-09", actual_value=93.0, source_type="actual"))
|
||||
db.commit()
|
||||
m = _seed_review_map(db, _hold_dims(), map_id=900101)
|
||||
|
||||
resp = client.get(f"/api/cma/maps/{m.id}/review", params={"level": "strategic"},
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["map_id"] == m.id
|
||||
s = body["summary"]
|
||||
assert s["hold"] == 1, s
|
||||
assert s["total"] == 2, s
|
||||
assert s["green"] == 1, s # 待核目标不进任何档
|
||||
assert isinstance(s["health_score"], (int, float))
|
||||
|
||||
objs = {o["name"]: o for o in body["dimensions"][0]["objectives"]}
|
||||
held = objs["客户集中度"]
|
||||
assert held["data_hold"] is True and held["hold_reason"]
|
||||
assert held["level"] == "pending"
|
||||
# 数据前置:待核目标的实测值不得外泄
|
||||
assert all(k["actual_value"] is None for k in held["kpis"])
|
||||
assert objs["客户满意"]["data_hold"] is False
|
||||
|
||||
def test_plain_map_returns_200_hold_zero(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
_seed_kpis(db)
|
||||
m = _seed_review_map(db, _hold_dims(), title="P2 普通地图", map_id=900102)
|
||||
# 去掉待核目标,只留普通目标
|
||||
m.dimensions = [{"key": "customer", "name": "客户层", "icon": "", "color": "#409EFF",
|
||||
"objectives": [{"name": "客户满意", "kpis": [PLAIN_CODE]}]}]
|
||||
db.commit()
|
||||
|
||||
resp = client.get(f"/api/cma/maps/{m.id}/review", params={"level": "strategic"},
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["summary"]["hold"] == 0
|
||||
|
||||
@pytest.mark.parametrize("dims", [None, []])
|
||||
def test_empty_dimensions_returns_200(self, client: TestClient, db: Session, dims):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
m = _seed_review_map(db, dims, title="P2 空地图", map_id=900103)
|
||||
resp = client.get(f"/api/cma/maps/{m.id}/review", params={"level": "strategic"},
|
||||
headers=auth_header(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
s = resp.json()["summary"]
|
||||
assert s["total"] == 0 and s["hold"] == 0 and s["health_score"] == 0
|
||||
|
||||
def test_level_none_still_200(self, client: TestClient, db: Session):
|
||||
"""不传 level(向后兼容路径)同样不得 500。"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
hold_kpi, _ = _seed_kpis(db)
|
||||
db.add(KPIValue(kpi_id=hold_kpi.id, period="2026-09", actual_value=72.0, source_type="actual"))
|
||||
db.commit()
|
||||
m = _seed_review_map(db, _hold_dims(), title="P2 无 level", map_id=900104)
|
||||
resp = client.get(f"/api/cma/maps/{m.id}/review", headers=auth_header(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["summary"]["hold"] == 1
|
||||
@@ -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
|
||||
@@ -56,10 +56,16 @@ assert_pnpm_version() {
|
||||
fi
|
||||
actual="$(pnpm -v 2>/dev/null | tail -1 | tr -d '[:space:]' || true)"
|
||||
if [ "$actual" != "$expect" ]; then
|
||||
echo "❌ pnpm 版本断言失败:package.json pin=$expect,实读 pnpm -v=${actual:-<空>}"
|
||||
echo " 多为 corepack lastKnownGood 漂移(/root/.cache/node/corepack/lastKnownGood.json)"
|
||||
echo " 修法:HOME=/root corepack prepare pnpm@$expect --activate | 中止(生产未被触碰)"
|
||||
exit 1
|
||||
echo "⚠️ pnpm 版本断言不匹配:package.json pin=$expect,实读 pnpm -v=${actual:-<空>}"
|
||||
echo " 尝试自愈:corepack prepare pnpm@$expect --activate(integrity 依 packageManager 的 sha512 校验)"
|
||||
corepack prepare "pnpm@$expect" --activate >/dev/null 2>&1 || true
|
||||
actual="$(pnpm -v 2>/dev/null | tail -1 | tr -d '[:space:]' || true)"
|
||||
if [ "$actual" != "$expect" ]; then
|
||||
echo "❌ pnpm 版本断言失败(自愈未成功):pin=$expect,实读=${actual:-<空>}"
|
||||
echo " 多为 corepack lastKnownGood 漂移或离线/缓存缺失;中止(生产未被触碰)"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ 自愈成功:pnpm $actual == package.json pin $expect"
|
||||
fi
|
||||
echo " ✓ pnpm 版本断言通过:pnpm $actual == package.json pin $expect"
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -15,6 +15,7 @@ declare module 'vue' {
|
||||
BotSourceTag: typeof import('./src/components/BotSourceTag.vue')['default']
|
||||
BudgetCollectPanel: typeof import('./src/components/BudgetCollectPanel.vue')['default']
|
||||
BulletChart: typeof import('./src/components/charts/BulletChart.vue')['default']
|
||||
CaliberBadge: typeof import('./src/components/CaliberBadge.vue')['default']
|
||||
CashClassifyPanel: typeof import('./src/components/CashClassifyPanel.vue')['default']
|
||||
ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.vue')['default']
|
||||
ConnectionManagerDialog: typeof import('./src/components/map-canvas/ConnectionManagerDialog.vue')['default']
|
||||
|
||||
@@ -546,4 +546,32 @@ export const treasuryApi = {
|
||||
listHits: (params?: any) => api.get('/treasury/control-hits', { params }),
|
||||
}
|
||||
|
||||
export default api
|
||||
// ── 应收账龄看板(T2 2026-10-20:逐户·六档·三口径禁合计,缺期不补 0)──
|
||||
export const arAgingApi = {
|
||||
meta: (params?: any) => api.get('/ar-aging/meta', { params }),
|
||||
trend: (params?: any) => api.get('/ar-aging/trend', { params }),
|
||||
overview: (params?: any) => api.get('/ar-aging/overview', { params }),
|
||||
customers: (params?: any) => api.get('/ar-aging/customers', { params }),
|
||||
exportUrl: (params: { period?: string; caliber?: string; overdue_only?: boolean }) => {
|
||||
const q = new URLSearchParams()
|
||||
if (params.period) q.set('period', params.period)
|
||||
if (params.caliber) q.set('caliber', params.caliber)
|
||||
if (params.overdue_only) q.set('overdue_only', 'true')
|
||||
return `/api/cma/ar-aging/customers/export?${q.toString()}`
|
||||
},
|
||||
}
|
||||
|
||||
// ── 战略基座:战略章程(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
|
||||
@@ -90,9 +90,32 @@
|
||||
回全景
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text @click="$emit('toggle-tree')">
|
||||
<el-button size="small" text @click="$emit('toggle-tree')" :title="treeOpen ? '收起左侧目标树(画布全宽)' : '展开左侧目标树'">
|
||||
{{ treeOpen ? '收起目标树' : '展开目标树' }}
|
||||
</el-button>
|
||||
|
||||
<!-- C 方案:聚焦开关(全景 = 默认四层等宽) -->
|
||||
<div class="eb-chips eb-focus" role="group" aria-label="聚焦层">
|
||||
<button
|
||||
type="button"
|
||||
class="eb-chip"
|
||||
:class="{ on: !focusLayer }"
|
||||
title="四层全景(默认)"
|
||||
@click="$emit('focus-layer', null)"
|
||||
>全景</button>
|
||||
<button
|
||||
v-for="cfg in layerConfigs"
|
||||
:key="'focus-' + cfg.key"
|
||||
type="button"
|
||||
class="eb-chip"
|
||||
:class="{ on: focusLayer === cfg.key }"
|
||||
:title="focusLayer === cfg.key ? '再次点击退出聚焦' : '聚焦' + cfg.label"
|
||||
@click="$emit('focus-layer', focusLayer === cfg.key ? null : cfg.key)"
|
||||
>
|
||||
<span class="layer-dot" :style="{ background: cfg.color }"></span>
|
||||
{{ cfg.short }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -105,6 +128,8 @@ defineProps<{
|
||||
layerConfigs: { key: string; label: string; short: string; color: string }[]
|
||||
canUndo: boolean
|
||||
treeOpen: boolean
|
||||
/** C 方案聚焦层:null = 四层全景 */
|
||||
focusLayer?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -117,6 +142,7 @@ const emit = defineEmits<{
|
||||
(e: 'fit'): void
|
||||
(e: 'toggle-tree'): void
|
||||
(e: 'completeness'): void
|
||||
(e: 'focus-layer', key: string | null): void
|
||||
}>()
|
||||
|
||||
const filterOptions: { value: 'all' | 'risk' | 'nodata'; label: string }[] = [
|
||||
|
||||
@@ -1,89 +1,150 @@
|
||||
<template>
|
||||
<section class="map-summary" aria-label="战略地图摘要">
|
||||
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
|
||||
<div class="sum-card sum-card--wide">
|
||||
<div class="sum-head">
|
||||
<span class="sum-label">营业收入</span>
|
||||
<span v-if="revenue && revenue.caliber_label" class="sum-badge calm">{{ revenue.caliber_label }}</span>
|
||||
<!-- C 方案(2026-10-23):汇总条压成单行(≤44px),5 项指标同排;明细收进「明细」展开位 -->
|
||||
<div class="ms-row">
|
||||
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
|
||||
<div class="ms-metric ms-metric--rev">
|
||||
<span class="ms-label">营业收入</span>
|
||||
<span class="ms-value mono" :class="{ muted: revenueTotal == null }">
|
||||
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="ms-unit">万元</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
<span v-if="revenue && revenue.caliber_label" class="ms-badge calm">{{ revenue.caliber_label }}</span>
|
||||
</div>
|
||||
<div v-if="revenueLines.length" class="rev-lines">
|
||||
<div v-for="ln in revenueLines" :key="ln.key" class="rev-line">
|
||||
<span class="rev-line-label">{{ ln.label }}</span>
|
||||
<span class="rev-line-value mono" :class="{ muted: ln.amount == null }">
|
||||
<template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="sum-unit">万元</i></template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
</span>
|
||||
|
||||
<span class="ms-sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- ② 毛利率 -->
|
||||
<div class="ms-metric">
|
||||
<span class="ms-label">毛利率</span>
|
||||
<span class="ms-value mono" :class="{ muted: gmValue == null }">
|
||||
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="ms-unit">%</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
<span v-if="grossMargin && grossMargin.data_tag" class="ms-badge warn">{{ grossMargin.data_tag }}</span>
|
||||
</div>
|
||||
|
||||
<span class="ms-sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- ③ 战略目标达成率 -->
|
||||
<div class="ms-metric">
|
||||
<span class="ms-label">战略目标达成率</span>
|
||||
<span class="ms-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
|
||||
<template v-if="achievement != null">{{ Math.round(achievement) }}<i class="ms-unit">%</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="ms-sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- ④ 风险目标数 -->
|
||||
<div class="ms-metric">
|
||||
<span class="ms-label">风险目标</span>
|
||||
<span class="ms-value mono" :class="riskCount == null ? 'muted' : (riskCount > 0 ? 'lv-red' : 'lv-green')">
|
||||
<template v-if="riskCount != null">{{ riskCount }}</template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="ms-sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- ⑤ 目标·连线(当前画布规模) -->
|
||||
<div class="ms-metric ms-metric--scale">
|
||||
<span class="ms-label">目标·连线</span>
|
||||
<span class="ms-value mono">
|
||||
<template v-if="objectivesTotal != null">{{ objectivesTotal }}</template>
|
||||
<template v-else>—</template>
|
||||
<i class="ms-unit">个目标</i>
|
||||
<span class="ms-div">/</span>
|
||||
<template v-if="connectionsTotal != null">{{ connectionsTotal }}</template>
|
||||
<template v-else>—</template>
|
||||
<i class="ms-unit">条连线</i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ms-detail-btn"
|
||||
:aria-expanded="detailOpen ? 'true' : 'false'"
|
||||
@click="detailOpen = !detailOpen"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path :d="detailOpen ? 'M6 15l6-6 6 6' : 'M6 9l6 6 6-6'" />
|
||||
</svg>
|
||||
{{ detailOpen ? '收起' : '明细' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 明细(默认收起;展开后保留分线金额 / 口径 / 管理指标全部信息,不丢信息) -->
|
||||
<div v-if="detailOpen" class="ms-detail">
|
||||
<div class="ms-card">
|
||||
<div class="ms-detail-head">
|
||||
<span class="ms-label">营业收入 · 分业务线</span>
|
||||
<span v-if="revenue && revenue.caliber_label" class="ms-badge calm">{{ revenue.caliber_label }}</span>
|
||||
</div>
|
||||
<div class="rev-line rev-line--total">
|
||||
<span class="rev-line-label">{{ revenueTotalLabel }}</span>
|
||||
<span class="rev-line-value mono">
|
||||
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="sum-unit">万元</i></template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
</span>
|
||||
<div v-if="revenueLines.length" class="rev-lines">
|
||||
<div v-for="ln in revenueLines" :key="ln.key" class="rev-line">
|
||||
<span class="rev-line-label">{{ ln.label }}</span>
|
||||
<span class="rev-line-value mono" :class="{ muted: ln.amount == null }">
|
||||
<template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="ms-unit">万元</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rev-line rev-line--total">
|
||||
<span class="rev-line-label">{{ revenueTotalLabel }}</span>
|
||||
<span class="rev-line-value mono">
|
||||
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="ms-unit">万元</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="ms-value muted">—<i class="ms-unit">无数据</i></div>
|
||||
<div class="ms-sub">{{ revenuePeriod }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-card">
|
||||
<div class="ms-detail-head"><span class="ms-label">毛利率口径</span></div>
|
||||
<div class="ms-value mono" :class="{ muted: gmValue == null }">
|
||||
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="ms-unit">%</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</div>
|
||||
<div class="ms-sub">{{ gmSub }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-card">
|
||||
<div class="ms-detail-head"><span class="ms-label">达成率 / 风险分布</span></div>
|
||||
<div class="ms-sub">
|
||||
<template v-if="achievement != null">{{ achievementCount }} 个目标有实测值 · 其余无数据</template>
|
||||
<template v-else>所有目标均无可用 KPI 实测值</template>
|
||||
</div>
|
||||
<div class="ms-sub">
|
||||
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 风险 {{ summary.red }} · 共 {{ summary.total }} 个目标</template>
|
||||
<template v-else>战略回顾数据未加载</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="sum-value muted">—<i class="sum-unit">无数据</i></div>
|
||||
<div class="sum-sub">{{ revenuePeriod }}</div>
|
||||
</div>
|
||||
|
||||
<!-- ② 毛利率:新式口径(折扣后净进价),历史缺票期带标识 -->
|
||||
<div class="sum-card">
|
||||
<div class="sum-head">
|
||||
<span class="sum-label">毛利率</span>
|
||||
<span v-if="grossMargin && grossMargin.data_tag" class="sum-badge warn">{{ grossMargin.data_tag }}</span>
|
||||
<div class="ms-card">
|
||||
<div class="ms-detail-head"><span class="ms-label">口径变更 / 管理指标</span></div>
|
||||
<div class="ms-foot">
|
||||
<span v-if="switchInfo && switchInfo.has_switch" class="ms-badge warn" :title="switchInfo.note || ''">
|
||||
{{ switchInfo.label }}<template v-if="switchInfo.effective_date"> · 自 {{ switchInfo.effective_date }}</template>
|
||||
<em v-if="switchInfo.comparability"> · {{ switchInfo.comparability }}</em>
|
||||
</span>
|
||||
<span v-for="m in management" :key="m.key" class="mgmt" :title="m.caliber_note || ''">
|
||||
<span class="mgmt-label">{{ m.label }}</span>
|
||||
<span class="mgmt-value mono">{{ m.value == null ? '—' : m.value.toFixed(2) + '%' }}</span>
|
||||
<span class="mgmt-tag">{{ m.tag }}</span>
|
||||
<span v-if="m.abnormal" class="mgmt-abnormal">异常·须核查</span>
|
||||
</span>
|
||||
<span v-if="!(switchInfo && switchInfo.has_switch) && management.length === 0" class="ms-sub">无口径变更与管理指标</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sum-value mono" :class="{ muted: gmValue == null }">
|
||||
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="sum-unit">%</i></template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
</div>
|
||||
<div class="sum-sub">{{ gmSub }}</div>
|
||||
</div>
|
||||
|
||||
<!-- ③ 战略目标达成率 -->
|
||||
<div class="sum-card">
|
||||
<div class="sum-head"><span class="sum-label">战略目标达成率</span></div>
|
||||
<div class="sum-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
|
||||
<template v-if="achievement != null">{{ Math.round(achievement) }}<i class="sum-unit">%</i></template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
</div>
|
||||
<div class="sum-sub">
|
||||
<template v-if="achievement != null">{{ achievementCount }} 个目标有实测值 · 其余无数据</template>
|
||||
<template v-else>所有目标均无可用 KPI 实测值</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ④ 风险目标数 -->
|
||||
<div class="sum-card">
|
||||
<div class="sum-head"><span class="sum-label">风险目标</span></div>
|
||||
<div class="sum-value mono" :class="riskCount == null ? 'muted' : (riskCount > 0 ? 'lv-red' : 'lv-green')">
|
||||
<template v-if="riskCount != null">{{ riskCount }}</template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
</div>
|
||||
<div class="sum-sub">
|
||||
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 共 {{ summary.total }} 个目标</template>
|
||||
<template v-else>战略回顾数据未加载</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⑤ 口径变更标识位 + 管理指标(非会计科目,单列、不占主位) -->
|
||||
<div class="sum-foot">
|
||||
<span v-if="switchInfo && switchInfo.has_switch" class="sum-badge warn" :title="switchInfo.note || ''">
|
||||
{{ switchInfo.label }}<template v-if="switchInfo.effective_date"> · 自 {{ switchInfo.effective_date }}</template>
|
||||
<em v-if="switchInfo.comparability"> · {{ switchInfo.comparability }}</em>
|
||||
</span>
|
||||
<span v-for="m in management" :key="m.key" class="mgmt" :title="m.caliber_note || ''">
|
||||
<span class="mgmt-label">{{ m.label }}</span>
|
||||
<span class="mgmt-value mono">{{ m.value == null ? '—' : m.value.toFixed(2) + '%' }}</span>
|
||||
<span class="mgmt-tag">{{ m.tag }}</span>
|
||||
<span v-if="m.abnormal" class="mgmt-abnormal">异常·须核查</span>
|
||||
<span class="mgmt-caliber">{{ fmtNum(m.numerator) }} ÷ {{ fmtNum(m.denominator) }}<template v-if="m.period"> · {{ m.period }}</template></span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
type RevLine = { key: string; label: string; amount: number | null; unit?: string; missing_reason?: string | null }
|
||||
|
||||
@@ -122,8 +183,14 @@ const props = defineProps<{
|
||||
summary: { total: number; green: number; yellow: number; red: number; health_score?: number } | null
|
||||
switchInfo?: { has_switch?: boolean; label?: string; effective_date?: string | null; comparability?: string; note?: string } | null
|
||||
loading?: boolean
|
||||
/** ⑤ 目标·连线:当前地图目标数(null ⇒ 显式「—」) */
|
||||
objectivesTotal?: number | null
|
||||
/** ⑤ 目标·连线:当前地图连线数(null ⇒ 显式「—」) */
|
||||
connectionsTotal?: number | null
|
||||
}>()
|
||||
|
||||
const detailOpen = ref(false)
|
||||
|
||||
const revenueLines = computed<RevLine[]>(() => (props.revenue?.lines || []).filter(l => !!l && !!l.key))
|
||||
/** 合计 = 各线相加的复算校验位,不与分线并列成第二套口径 */
|
||||
const revenueTotal = computed<number | null>(() => {
|
||||
@@ -163,92 +230,125 @@ function fmtNum(v: number | null | undefined): string {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* C 方案:单行汇总条(行高 40px + 上下 1px 边框 ≤44px) */
|
||||
.map-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sum-card {
|
||||
margin-bottom: 8px;
|
||||
background: var(--bh-bg-card, #fff);
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.ms-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sum-card--wide { grid-column: span 2; }
|
||||
.sum-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.sum-label {
|
||||
.ms-metric {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ms-metric--rev { flex: 0 1 auto; overflow: hidden; }
|
||||
.ms-label {
|
||||
font-size: 12px;
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
letter-spacing: .02em;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sum-badge {
|
||||
.ms-value {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--bh-text-primary, #0F172A);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ms-value.muted { font-size: 13px; color: var(--bh-text-muted, #94A3B8); font-weight: 500; }
|
||||
.ms-unit {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.ms-div { color: var(--bh-text-muted, #94A3B8); margin: 0 2px; }
|
||||
.ms-sep { width: 1px; height: 18px; background: var(--bh-border, #E5E7EB); flex-shrink: 0; }
|
||||
.ms-badge {
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
padding: 1px 6px;
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sum-badge.calm { color: #635BFF; background: rgba(99, 91, 255, .08); }
|
||||
.sum-badge.warn { color: #B45309; background: #FEF3C7; }
|
||||
.sum-badge.warn em { font-style: normal; }
|
||||
.sum-value {
|
||||
margin-top: 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--bh-text-primary, #0F172A);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
}
|
||||
.sum-value.muted { font-size: 15px; color: var(--bh-text-muted, #94A3B8); font-weight: 500; }
|
||||
.sum-unit { font-size: 11px; font-weight: 500; font-style: normal; color: var(--bh-text-secondary, #64748B); margin-left: 2px; }
|
||||
.sum-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--bh-text-muted, #94A3B8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rev-lines { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.rev-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.rev-line-label { font-size: 12px; color: var(--bh-text-secondary, #64748B); }
|
||||
.rev-line-value { font-size: 18px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
|
||||
.rev-line-value.muted { font-size: 14px; font-weight: 500; color: var(--bh-text-muted, #94A3B8); }
|
||||
.rev-line--total { border-top: 1px dashed var(--bh-border, #E5E7EB); padding-top: 3px; margin-top: 2px; }
|
||||
.rev-line--total .rev-line-label,
|
||||
.rev-line--total .rev-line-value { font-size: 12px; font-weight: 500; color: var(--bh-text-secondary, #64748B); }
|
||||
.sum-foot {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.ms-badge.calm { color: #0369A1; background: #F0F9FF; border-color: #BAE6FD; }
|
||||
.ms-badge.warn { color: #B45309; background: #FFFBEB; border-color: #FDE68A; }
|
||||
.ms-badge.warn em { font-style: normal; }
|
||||
.ms-detail-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px 16px;
|
||||
padding: 6px 12px;
|
||||
border: 1px dashed var(--bh-border, #E5E7EB);
|
||||
border-radius: 6px;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
color: var(--bh-text-muted, #94A3B8);
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
background: transparent;
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mgmt { display: inline-flex; align-items: baseline; gap: 6px; }
|
||||
.ms-detail-btn:hover { color: #0F172A; border-color: #CBD5E1; }
|
||||
.ms-detail-btn svg { width: 12px; height: 12px; }
|
||||
|
||||
/* 明细展开区 */
|
||||
.ms-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
.ms-card {
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
min-width: 0;
|
||||
background: var(--bh-bg-subtle, #FCFCFD);
|
||||
}
|
||||
.ms-detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rev-lines { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rev-line { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
|
||||
.rev-line-label { font-size: 11px; color: var(--bh-text-secondary, #64748B); }
|
||||
.rev-line-value { font-size: 12px; color: var(--bh-text-primary, #0F172A); }
|
||||
.rev-line-value.muted { color: var(--bh-text-muted, #94A3B8); }
|
||||
.rev-line--total { border-top: 1px dashed var(--bh-border, #E5E7EB); margin-top: 2px; padding-top: 2px; }
|
||||
.ms-sub { margin-top: 4px; font-size: 11px; color: var(--bh-text-muted, #94A3B8); line-height: 1.5; }
|
||||
.ms-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
|
||||
.mgmt { display: inline-flex; align-items: baseline; gap: 4px; font-size: 11px; }
|
||||
.mgmt-label { color: var(--bh-text-secondary, #64748B); }
|
||||
.mgmt-value { font-size: 13px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
|
||||
.mgmt-tag { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: #F1F5F9; color: #475569; }
|
||||
.mgmt-abnormal { color: #B91C1C; }
|
||||
.mgmt-caliber { color: var(--bh-text-muted, #94A3B8); }
|
||||
.mgmt-value { color: var(--bh-text-primary, #0F172A); font-weight: 600; }
|
||||
.mgmt-tag { font-size: 10px; padding: 0 4px; border-radius: 4px; background: #F1F5F9; color: #475569; }
|
||||
.mgmt-abnormal { font-size: 10px; color: #B91C1C; }
|
||||
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
|
||||
.lv-green { color: #00C896; }
|
||||
.lv-yellow { color: #F59E0B; }
|
||||
.lv-red { color: #EF4444; }
|
||||
@media (max-width: 1200px) {
|
||||
.map-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sum-card--wide { grid-column: span 2; }
|
||||
|
||||
/* 窄屏:单行改为可横向滚动,保留全部 5 项、不换行不截断 */
|
||||
@media (max-width: 1280px) {
|
||||
.ms-row { overflow-x: auto; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<template>
|
||||
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
|
||||
<div class="layer-header" :style="{ background: color }">
|
||||
<div
|
||||
class="layer-header"
|
||||
:class="{ 'is-focusable': true, 'is-focused': focused }"
|
||||
:style="{ background: color }"
|
||||
:title="focused ? '再次点击退出聚焦,恢复四层等宽' : '点击聚焦该层(其余三层压成单行摘要)'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-pressed="focused ? 'true' : 'false'"
|
||||
@click="$emit('focus')"
|
||||
@keydown.enter.prevent="$emit('focus')"
|
||||
@keydown.space.prevent="$emit('focus')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="layer-collapse-btn"
|
||||
@@ -14,6 +25,12 @@
|
||||
</button>
|
||||
<span class="layer-icon">{{ icon }}</span>
|
||||
<span class="layer-name">{{ label }}</span>
|
||||
<!-- 压缩态(C 方案聚焦模式):保「目标数 + 达成率色条」,整层信息不全隐 -->
|
||||
<span v-if="compact" class="layer-mini" :aria-label="'该层达成率 ' + ratioText">
|
||||
<span class="mini-track"><i class="mini-fill" :style="{ width: ratioBarWidth }"></i></span>
|
||||
<span class="mini-ratio mono">{{ ratioText }}</span>
|
||||
</span>
|
||||
<span v-if="focused" class="layer-focus-flag">聚焦中</span>
|
||||
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
|
||||
<el-dropdown trigger="click" @command="onLayerAction">
|
||||
<el-button class="layer-more-btn" size="small" text>
|
||||
@@ -42,7 +59,7 @@
|
||||
<div class="layer-node-wrapper">
|
||||
<MapNode
|
||||
:key="`${layerKey}-${index}`"
|
||||
:ref="el => setNodeRef(`${layerKey}-${index}`, el)"
|
||||
:ref="el => setNodeRef(`${layerKey}-${index}`, el, element)"
|
||||
:objective="element"
|
||||
:node-key="`${layerKey}-${index}`"
|
||||
:index="index"
|
||||
@@ -90,7 +107,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { MoreFilled } from '@element-plus/icons-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import MapNode from './MapNode.vue'
|
||||
@@ -115,6 +132,12 @@ const props = defineProps<{
|
||||
dimmedKeys?: string[]
|
||||
/** 层折叠(仅隐藏层体,保留层头) */
|
||||
collapsed?: boolean
|
||||
/** C 方案聚焦模式:其它层压成单行(层头 + 目标数 + 达成率色条) */
|
||||
compact?: boolean
|
||||
/** 该层达成率均值(无实测值传 null,界面显式「—」,不用示例数兜底) */
|
||||
layerRatio?: number | null
|
||||
/** 本层是否处于聚焦展开态 */
|
||||
focused?: boolean
|
||||
getNodeLevel: (key: string) => string
|
||||
getNodeProgress: (key: string) => any
|
||||
}>()
|
||||
@@ -138,14 +161,60 @@ const emit = defineEmits<{
|
||||
(e: 'edit-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void
|
||||
(e: 'delete-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void
|
||||
(e: 'add-kr', payload: { dimKey: string; idx: number; obj: any }): void
|
||||
(e: 'focus'): void
|
||||
}>()
|
||||
|
||||
/** 压缩态达成率文案:无实测值显式「—」,禁止示例数字兜底 */
|
||||
const ratioText = computed(() => {
|
||||
const r = props.layerRatio
|
||||
return r == null || Number.isNaN(Number(r)) ? '—' : `${Math.round(Number(r))}%`
|
||||
})
|
||||
/** 压缩态色条宽度(0–100%,无数据时 0 宽 + 灰色轨道) */
|
||||
const ratioBarWidth = computed(() => {
|
||||
const r = Number(props.layerRatio)
|
||||
if (props.layerRatio == null || Number.isNaN(r)) return '0%'
|
||||
return `${Math.max(0, Math.min(100, Math.round(r)))}%`
|
||||
})
|
||||
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Node DOM refs for line calculation (reactive so parent can aggregate)
|
||||
const nodeRefs = reactive<Record<string, any>>({})
|
||||
function setNodeRef(key: string, el: any) {
|
||||
if (el) nodeRefs[key] = el
|
||||
/**
|
||||
* 双键注册:`${layerKey}-${index}`(渲染键,历史契约)+ `${layerKey}:${目标名}`(数据键)。
|
||||
* canvas_data.connections / map_connections 里的节点标识形如 `learning:贡献率思维全员培训`,
|
||||
* 只注册渲染键会导致 ConnectionLines 全部取不到 DOM → 一条线都不画(P2 缺陷)。
|
||||
* 同层同名目标:先到先得(与数据侧 `dim:name` 本身的歧义语义保持一致)。
|
||||
* 卸载(el=null)时按 key 反查清理自己注册的数据键,避免残留指向已卸载 DOM(线画到 0,0)。
|
||||
*/
|
||||
const dataKeyOwner: Record<string, string> = {} // dataKey → 渲染键(谁注册的)
|
||||
const ownerDataKey: Record<string, string> = {} // 渲染键 → dataKey
|
||||
function setNodeRef(key: string, el: any, element?: any) {
|
||||
// 组件 ref 给的是实例(不是 DOM),ConnectionLines 直接调 getBoundingClientRect
|
||||
// ⇒ 统一归一化为 DOM 元素($el);取不到 DOM 视同卸载,走清理分支。
|
||||
const dom: HTMLElement | null = el
|
||||
? (el.nodeType === 1 ? el : (el.$el && el.$el.nodeType === 1 ? el.$el : null))
|
||||
: null
|
||||
if (!dom) {
|
||||
const dk = ownerDataKey[key]
|
||||
if (dk && dataKeyOwner[dk] === key) {
|
||||
delete nodeRefs[dk]
|
||||
delete dataKeyOwner[dk]
|
||||
}
|
||||
delete ownerDataKey[key]
|
||||
delete nodeRefs[key]
|
||||
return
|
||||
}
|
||||
nodeRefs[key] = dom
|
||||
const nm = typeof element?.name === 'string' ? element.name : ''
|
||||
if (nm) {
|
||||
const dataKey = `${props.layerKey}:${nm}`
|
||||
if (!nodeRefs[dataKey]) {
|
||||
nodeRefs[dataKey] = dom
|
||||
dataKeyOwner[dataKey] = key
|
||||
ownerDataKey[key] = dataKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose nodeRefs to parent for connection line calculation
|
||||
@@ -218,6 +287,54 @@ export default {
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* C 方案:层头可点击聚焦 */
|
||||
.layer-header.is-focusable { cursor: pointer; }
|
||||
.layer-header.is-focusable:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
.layer-header.is-focused { box-shadow: inset 0 -2px 0 rgba(255, 255, 255, 0.55); }
|
||||
|
||||
.layer-focus-flag {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .04em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 压缩单行态:达成率色条 + 百分比(保信息不全隐) */
|
||||
.layer-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
min-width: 96px;
|
||||
}
|
||||
.mini-track {
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
.mini-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
transition: width .2s ease;
|
||||
}
|
||||
.mini-ratio {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: .95;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
|
||||
@@ -136,12 +136,18 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { getChainForConnection, getChainForNode } from '../../utils/chainGraph'
|
||||
import { resolveEndpoints, relRectOf } from '../../utils/connKeys'
|
||||
|
||||
const props = defineProps({
|
||||
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
|
||||
layerRefs: { type: Object, required: true },
|
||||
/** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */
|
||||
nodeRefs: { type: Object, default: () => ({}) },
|
||||
/**
|
||||
* 服务端解析键(P3):与 connections 逐下标对齐的 [{from_key,to_key}]。
|
||||
* key = `${层}-${序号}`,由 objective_id 反查而来 ⇒ 目标改名后线仍指向同一节点。
|
||||
*/
|
||||
connKeys: { type: Array, default: () => [] },
|
||||
/** 层顺序列表 */
|
||||
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
|
||||
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1', effect, label}] */
|
||||
@@ -328,26 +334,22 @@ function calcLayerConnections() {
|
||||
|
||||
// 节点坐标缓存(相对 svg):同一节点被多条连线引用时只 getBoundingClientRect 一次
|
||||
const rectCache = {}
|
||||
const origin = { left: svgRect.left, top: svgRect.top }
|
||||
const getRect = (key) => {
|
||||
if (rectCache[key]) return rectCache[key]
|
||||
const el = props.nodeRefs[key]
|
||||
if (!el) return null
|
||||
const r = el.getBoundingClientRect()
|
||||
rectCache[key] = {
|
||||
left: r.left - svgRect.left,
|
||||
top: r.top - svgRect.top,
|
||||
right: r.right - svgRect.left,
|
||||
bottom: r.bottom - svgRect.top,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
}
|
||||
if (!key) return null
|
||||
if (key in rectCache) return rectCache[key]
|
||||
// relRectOf:缺 ref / 已卸载 / 0×0(隐藏或未布局)一律 null —— 宁可不画,不画到 (0,0)
|
||||
rectCache[key] = relRectOf(props.nodeRefs, key, origin) || null
|
||||
return rectCache[key]
|
||||
}
|
||||
|
||||
for (const conn of props.connections) {
|
||||
const fr = getRect(conn.from)
|
||||
const tr = getRect(conn.to)
|
||||
if (!fr || !tr) continue
|
||||
let skipped = 0
|
||||
props.connections.forEach((conn, idx) => {
|
||||
// P3:优先服务端解析键(objective_id 反查,改名不断线),缺失回落原标识串
|
||||
const ep = resolveEndpoints(conn, idx, props.connKeys)
|
||||
const fr = getRect(ep.from)
|
||||
const tr = getRect(ep.to)
|
||||
if (!fr || !tr) { skipped += 1; return }
|
||||
|
||||
// 源节点右侧中点 → 目标节点左侧中点
|
||||
const x1 = fr.right
|
||||
@@ -373,7 +375,11 @@ function calcLayerConnections() {
|
||||
effect: conn.effect || 'positive',
|
||||
label: conn.label || '',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 悬停/拖拽期节点可能尚未注册(预取未回、改名后 DOM 重建)⇒ 有跳过就做有界重算,
|
||||
// 而不是留一条静默消失的线(P2 暴露的「零绘制」同类问题)。
|
||||
if (skipped > 0 && depth < 6) scheduleRetry(depth + 1)
|
||||
layerConnections.value = result
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ interface MenuItem {
|
||||
|
||||
// ── 角色路由映射 ──
|
||||
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'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
|
||||
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
|
||||
ceo: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
|
||||
finance: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
|
||||
business: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||
it: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
|
||||
}
|
||||
|
||||
export const ROLE_ACTIONS: Record<string, string[]> = {
|
||||
@@ -26,6 +26,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
// ── GROUP 1: 规划与目标(Plan)──
|
||||
{ path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||
{ path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], group: '🔵 P 规划与目标' },
|
||||
{ path: '/strategy-charter', label: '战略章程', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||
{ path: '/strategy-map', label: '战略地图看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||
{ path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||
{ path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||
@@ -39,6 +40,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
{ path: '/receivables', label: '应收款催收', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/treasury-dashboard', label: '资金总览', icon: 'Money', roles: ['ceo','finance'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/treasury-payment', label: '资金内控', icon: 'Lock', roles: ['ceo','finance'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/ar-aging', label: '应收账龄', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' },
|
||||
{ path: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||
{ path: '/product-matrix', label: '波士顿产品矩阵', icon: 'PieChart', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
|
||||
@@ -19,6 +19,8 @@ const routes = [
|
||||
{ path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'], zone: 'B', editable: true } },
|
||||
// /strategy-map: A区(只读活看板 —— 读库渲染,页内零写入口;2026-10-11 finance-20261011-strategy-map-frontend-fullstack)
|
||||
{ path: 'strategy-map', name: 'StrategyMap', component: () => import('@/views/StrategyMap.vue'), meta: { title: '战略地图看板', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||
// /strategy-charter: P 组·战略基座入口(使命/愿景/价值观;页内含 MyDialog 写入口 ⇒ B区)
|
||||
{ path: 'strategy-charter', name: 'StrategyCharter', component: () => import('@/views/StrategyCharter.vue'), meta: { title: '战略章程', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
|
||||
{ path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } },
|
||||
{ path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||
{ path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
|
||||
@@ -60,6 +62,7 @@ const routes = [
|
||||
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'], zone: 'B' } },
|
||||
{ path: 'treasury-dashboard', name: 'TreasuryDashboard', component: () => import('@/views/TreasuryDashboard.vue'), meta: { title: '资金总览', roles: ['ceo','finance'], zone: 'B', editable: true } },
|
||||
{ path: 'treasury-payment', name: 'TreasuryPayment', component: () => import('@/views/TreasuryPayment.vue'), meta: { title: '资金内控', roles: ['ceo','finance'], zone: 'B', editable: true } },
|
||||
{ path: 'ar-aging', name: 'ArAging', component: () => import('@/views/ArAging.vue'), meta: { title: '应收账龄', roles: ['ceo', 'finance', 'business'], zone: 'A' } },
|
||||
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||
{ path: 'product-matrix', name: 'ProductMatrix', component: () => import('@/views/ProductMatrix.vue'), meta: { title: '波士顿产品矩阵', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||
// /tax-compliance: B区(页内职责=税务记录/发票/社保人工录入维护;见盘点表)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 连线端点解析(P3 cma-map-canvas-conn-key-rename-stable)
|
||||
*
|
||||
* 为什么需要它:
|
||||
* `canvas_data.connections[].from/to` 存的是**写入时刻**的节点标识串
|
||||
* (`层:中文名` 或 `层-序号`)。目标一改名,名字串指向不到任何节点 ⇒ 该线静默跳过。
|
||||
* 服务端 `/api/cma/maps/{id}/connections` 现在为每条连接附 `from_key/to_key`
|
||||
* (= `${dim}-${idx}`,由 `from/to_objective_id` 在当前 dimensions 树反查得出),
|
||||
* 渲染按 key 取节点 ⇒ 与中文名解耦;key 缺失(目标已删/历史脏 id)才回落到原值。
|
||||
*
|
||||
* 同时收口两类"悬停/拖拽期取不到节点"的兜底:
|
||||
* · 节点 ref 未注册(预取未返回/刚改名重建)⇒ 返回 null,交给调用方重试,绝不画到 (0,0);
|
||||
* · 元素已卸载或隐藏(0×0 矩形)⇒ 视同缺失,同样不画。
|
||||
*/
|
||||
|
||||
export type ConnectionKeyEntry = { from_key?: string | null; to_key?: string | null }
|
||||
|
||||
export type ConnEndpoints = {
|
||||
from: string | null
|
||||
to: string | null
|
||||
fromSource: 'key' | 'raw' | null
|
||||
toSource: 'key' | 'raw' | null
|
||||
}
|
||||
|
||||
/** 画布渲染键(`层-序号`):只有这种键可以参与命中/吸附/合法目标判定 */
|
||||
export function isCanonicalNodeKey(key: unknown): boolean {
|
||||
return typeof key === 'string' && /^[a-z_]+-\d+$/.test(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条连线两端取什么键去查 DOM。
|
||||
* 优先服务端解析键(改名不断线),缺失回落数据里的原值(历史/降级路径)。
|
||||
*/
|
||||
export function resolveEndpoints(
|
||||
conn: any,
|
||||
idx: number,
|
||||
resolved?: ConnectionKeyEntry[] | null,
|
||||
): ConnEndpoints {
|
||||
const hit: ConnectionKeyEntry = (resolved && resolved[idx]) || {}
|
||||
const pick = (side: 'from' | 'to') => {
|
||||
const key = side === 'from' ? hit.from_key : hit.to_key
|
||||
if (typeof key === 'string' && key) return { key, source: 'key' as const }
|
||||
const raw = conn?.[side]
|
||||
if (typeof raw === 'string' && raw) return { key: raw, source: 'raw' as const }
|
||||
return { key: null, source: null }
|
||||
}
|
||||
const f = pick('from')
|
||||
const t = pick('to')
|
||||
return { from: f.key, to: t.key, fromSource: f.source, toSource: t.source }
|
||||
}
|
||||
|
||||
export type RelRect = { left: number; top: number; right: number; bottom: number; width: number; height: number }
|
||||
|
||||
/**
|
||||
* 取节点矩形(相对容器原点)。
|
||||
* 缺 ref / 已卸载 / 0×0(隐藏、未布局)一律 null —— 上层据此跳过该线并安排一次重算,
|
||||
* 这条判据是「线不画到左上角 (0,0)」的唯一关卡。
|
||||
*/
|
||||
export function relRectOf(
|
||||
nodeRefs: Record<string, any> | null | undefined,
|
||||
key: string | null,
|
||||
origin: { left: number; top: number },
|
||||
): RelRect | null {
|
||||
if (!key || !nodeRefs) return null
|
||||
const el: any = nodeRefs[key]
|
||||
if (!el) return null
|
||||
if (el.isConnected === false) return null
|
||||
if (typeof el.getBoundingClientRect !== 'function') return null
|
||||
const r = el.getBoundingClientRect()
|
||||
if (!r || (!r.width && !r.height)) return null
|
||||
return {
|
||||
left: r.left - origin.left,
|
||||
top: r.top - origin.top,
|
||||
right: r.right - origin.left,
|
||||
bottom: r.bottom - origin.top,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析键索引(服务端 /connections → 与本地 connections 数组逐下标对齐)。
|
||||
* 按 `from→to` 配对消费(同名同向重复连线按出现顺序配对),配不上的留空 ⇒ 回落原值。
|
||||
*/
|
||||
export function buildKeyIndex(
|
||||
localConnections: any[],
|
||||
serverConnections: any[],
|
||||
): ConnectionKeyEntry[] {
|
||||
const pool = new Map<string, any[]>()
|
||||
for (const it of serverConnections || []) {
|
||||
const pk = `${it?.from}\u0000${it?.to}`
|
||||
const arr = pool.get(pk) || []
|
||||
arr.push(it)
|
||||
pool.set(pk, arr)
|
||||
}
|
||||
return (localConnections || []).map((c: any) => {
|
||||
const arr = pool.get(`${c?.from}\u0000${c?.to}`) || []
|
||||
const hit = arr.shift()
|
||||
return { from_key: hit?.from_key ?? null, to_key: hit?.to_key ?? null }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<div class="ar-aging-page">
|
||||
<!-- 页头:标题 + 口径后缀(强制显示)+ 数据截止期徽标 + 来源说明 -->
|
||||
<header class="page-head">
|
||||
<div class="head-main">
|
||||
<h2 class="page-title">
|
||||
应收账龄看板<span class="caliber-suffix">({{ caliberLabel }})</span>
|
||||
</h2>
|
||||
<div class="badge-row">
|
||||
<el-tag size="small" effect="plain" :type="overview.has_data === false ? 'warning' : 'success'">
|
||||
数据截止期 {{ displayPeriod || '无' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="meta.loaded_at" size="small" effect="plain">入表 {{ meta.loaded_at }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="primary">{{ entityLabel }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="info">口径分列 · 禁跨口径合计</el-tag>
|
||||
</div>
|
||||
<p class="source-note">
|
||||
来源:智享通「往来单位未收款账期统计」逐户导出;六档互斥(0-30/31-60/61-90/90-120/120-150/150+),
|
||||
逐期六档和 = 期末应收合计;期初只展示,不作账龄基数。
|
||||
</p>
|
||||
</div>
|
||||
<div class="head-controls">
|
||||
<el-select v-model="caliber" size="small" style="width:190px" @change="onCaliberChange">
|
||||
<el-option v-for="c in calibers" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="period" size="small" style="width:120px" :loading="loading"
|
||||
placeholder="选择期间" @change="loadPeriodData"
|
||||
>
|
||||
<el-option v-for="p in periodOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
<el-button size="small" :loading="exporting" @click="doExport">
|
||||
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 21h16" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
导出逐户 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 表不可用/无权限:降级可读,不留白、不补 0 -->
|
||||
<el-alert
|
||||
v-if="metaLoaded && meta.available === false"
|
||||
type="warning" :closable="false" show-icon
|
||||
title="该期无数据(表未就绪/无权限)"
|
||||
:description="meta.reason || 'hanke_ar_aging 不可用'"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 卡片区 -->
|
||||
<el-row :gutter="16" v-loading="loading">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card shadow="never" class="metric">
|
||||
<div class="metric-label">应收总额(期末)</div>
|
||||
<div class="metric-value">{{ money(overview.total_amt) }}</div>
|
||||
<div class="metric-sub">
|
||||
{{ overview.has_data ? `往来单位 ${overview.customers} 户` : '该期无数据' }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card shadow="never" class="metric">
|
||||
<div class="metric-label">逾期金额(>30 天)</div>
|
||||
<div class="metric-value">{{ money(overview.overdue_amt) }}</div>
|
||||
<div class="metric-sub">
|
||||
环比
|
||||
<span :class="momClass(overview.overdue_amt_mom_pct)">
|
||||
{{ pctSigned(overview.overdue_amt_mom_pct) }}
|
||||
</span>
|
||||
<span class="dim">{{ overview.mom_note || (overview.prev_period ? `上期 ${overview.prev_period}` : '') }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card shadow="never" class="metric">
|
||||
<div class="metric-label">90 天以上占比</div>
|
||||
<div class="metric-value" :style="{ color: shareColor }">{{ pct(overview.share_90plus) }}</div>
|
||||
<div class="metric-sub">
|
||||
<span v-if="thresholds.share_90plus_red_gt != null" class="th-red">
|
||||
红线 >{{ pct(thresholds.share_90plus_red_gt) }}
|
||||
</span>
|
||||
<span v-if="thresholds.share_90plus_yellow_min != null" class="th-yellow">
|
||||
黄线 {{ pct(thresholds.share_90plus_yellow_min) }}–{{ pct(thresholds.share_90plus_yellow_max) }}
|
||||
</span>
|
||||
<span v-if="thresholds.share_90plus_red_gt == null" class="dim">阈值未配置(阶段B 播种后显示)</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card shadow="never" class="metric">
|
||||
<div class="metric-label">逾期户数(>30 天)</div>
|
||||
<div class="metric-value">{{ overview.overdue_customers ?? '该期无数据' }}</div>
|
||||
<div class="metric-sub">
|
||||
环比
|
||||
<span :class="momClass(overview.overdue_customers_mom_pct)">
|
||||
{{ pctSigned(overview.overdue_customers_mom_pct) }}
|
||||
</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图区 -->
|
||||
<el-row :gutter="16" class="chart-row">
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>逐期趋势 · 应收总额 / 逾期户数</span>
|
||||
<span class="dim small">{{ trendRange }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="trendRef" class="chart-box"></div>
|
||||
<p class="chart-note">缺数据期以灰带标注「该期无数据」,曲线断点、不补 0。</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>六档账龄堆叠</span>
|
||||
<el-tag size="small" :type="bucketsBalanced ? 'success' : 'danger'" effect="plain">
|
||||
六档和=合计 {{ balancedCount }}/{{ dataPointCount }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="stackRef" class="chart-box"></div>
|
||||
<p class="chart-note">tooltip 内断言六档和与合计一致;不一致会标红。</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 逐户明细 -->
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span>逐户逾期清单 · {{ displayPeriod || '—' }}({{ caliberLabel }})</span>
|
||||
<div class="table-tools">
|
||||
<el-switch v-model="overdueOnly" size="small" active-text="仅逾期" @change="loadCustomers" />
|
||||
<span class="dim small">共 {{ customers.length }} 户</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
:data="customers" size="small" stripe border
|
||||
:default-sort="{ prop: 'overdue_amt', order: 'descending' }"
|
||||
@sort-change="onSortChange"
|
||||
empty-text="该期无数据(不补 0)"
|
||||
>
|
||||
<el-table-column prop="unit_code" label="单位编码" width="170" sortable="custom" />
|
||||
<el-table-column prop="unit_name" label="单位名称" min-width="130" sortable="custom" />
|
||||
<el-table-column prop="total_amt" label="期末应收合计" width="130" align="right" sortable="custom">
|
||||
<template #default="{ row }">{{ money(row.total_amt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="overdue_amt" label="逾期额(>30天)" width="135" align="right" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'neg': row.overdue_amt > 0 }">{{ money(row.overdue_amt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pct_of_unit" label="逾期占该户" width="110" align="right">
|
||||
<template #default="{ row }">{{ pct(row.pct_of_unit) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="oldest_days" label="最老逾期档" width="110" align="center" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.oldest_days">{{ row.oldest_days }} 天以上</span>
|
||||
<span v-else class="dim">未逾期</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-for="b in buckets" :key="b.col" :prop="`buckets.${b.col}`"
|
||||
:label="b.label" width="108" align="right">
|
||||
<template #default="{ row }">{{ money(row.buckets[b.col]) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api, { arAgingApi } from '@/api'
|
||||
import { sessionEntityName } from '@/utils/entity'
|
||||
|
||||
const CALIBERS = ['借方应收户口径', '全户口径', '贷方户单列(净额)']
|
||||
const BUCKETS = [
|
||||
{ col: 'b_0_30', label: '0-30' },
|
||||
{ col: 'b_31_60', label: '31-60' },
|
||||
{ col: 'b_61_90', label: '61-90' },
|
||||
{ col: 'b_90_120', label: '90-120' },
|
||||
{ col: 'b_120_150', label: '120-150' },
|
||||
{ col: 'b_150p', label: '150+' },
|
||||
]
|
||||
|
||||
const entityLabel = ref(sessionEntityName())
|
||||
const calibers = ref<string[]>(CALIBERS)
|
||||
const caliber = ref(CALIBERS[0])
|
||||
const period = ref('')
|
||||
const periodOptions = ref<string[]>([])
|
||||
const meta = ref<any>({ available: null })
|
||||
const metaLoaded = ref(false)
|
||||
const overview = ref<any>({})
|
||||
const thresholds = computed<any>(() => overview.value.thresholds || {})
|
||||
const trend = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const overdueOnly = ref(false)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const sortState = ref<{ sort: string; order: string }>({ sort: 'overdue_amt', order: 'desc' })
|
||||
|
||||
const caliberLabel = computed(() => caliber.value)
|
||||
const displayPeriod = computed(() => overview.value.period || period.value || '')
|
||||
const buckets = BUCKETS
|
||||
|
||||
let trendChart: any = null
|
||||
let stackChart: any = null
|
||||
|
||||
/** 金额格式化:null/undefined ⇒ 该期无数据(绝不显示 0.00) */
|
||||
function money(v: any): string {
|
||||
if (v === null || v === undefined || v === '') return '该期无数据'
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) return '该期无数据'
|
||||
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function pct(v: any): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
|
||||
return `${(Number(v) * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function pctSigned(v: any): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
|
||||
const n = Number(v) * 100
|
||||
return `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`
|
||||
}
|
||||
|
||||
function momClass(v: any): string {
|
||||
if (v === null || v === undefined) return 'dim'
|
||||
return Number(v) > 0 ? 'up' : Number(v) < 0 ? 'down' : 'dim'
|
||||
}
|
||||
|
||||
const shareColor = computed(() => {
|
||||
const s = overview.value?.share_90plus
|
||||
const th: any = thresholds.value
|
||||
if (s === null || s === undefined) return 'inherit'
|
||||
if (th.share_90plus_red_gt != null && Number(s) > Number(th.share_90plus_red_gt)) return '#f56c6c'
|
||||
if (th.share_90plus_yellow_min != null && Number(s) >= Number(th.share_90plus_yellow_min)) return '#e6a23c'
|
||||
return '#67c23a'
|
||||
})
|
||||
|
||||
const dataPoints = computed(() => trend.value.filter(p => p.has_data))
|
||||
const dataPointCount = computed(() => dataPoints.value.length)
|
||||
const balancedCount = computed(() => dataPoints.value.filter(p => {
|
||||
const sum = BUCKETS.reduce((acc, b) => acc + Number(p.buckets?.[b.col] ?? 0), 0)
|
||||
return Math.abs(sum - Number(p.total_amt ?? 0)) < 0.01
|
||||
}).length)
|
||||
const bucketsBalanced = computed(() => dataPointCount.value > 0 && balancedCount.value === dataPointCount.value)
|
||||
const trendRange = computed(() => {
|
||||
if (!trend.value.length) return ''
|
||||
return `${trend.value[0].period} ~ ${trend.value[trend.value.length - 1].period}(${trend.value.length} 期)`
|
||||
})
|
||||
|
||||
async function loadMeta() {
|
||||
const r: any = await arAgingApi.meta()
|
||||
meta.value = r || {}
|
||||
metaLoaded.value = true
|
||||
if (Array.isArray(r?.calibers) && r.calibers.length) calibers.value = r.calibers
|
||||
const periods: string[] = Array.isArray(r?.periods_available) ? r.periods_available : []
|
||||
// 期间候选 = 表内可用期 + 当前月(缺期也要能选中并显「该期无数据」)
|
||||
const nowPeriod = new Date().toISOString().slice(0, 7)
|
||||
const opts = [...periods]
|
||||
if (!opts.includes(nowPeriod)) opts.push(nowPeriod)
|
||||
periodOptions.value = opts.sort().reverse()
|
||||
if (!period.value) period.value = r?.latest_period || opts[0] || ''
|
||||
}
|
||||
|
||||
async function loadTrend() {
|
||||
const r: any = await arAgingApi.trend({ caliber: caliber.value })
|
||||
trend.value = Array.isArray(r?.points) ? r.points : []
|
||||
}
|
||||
|
||||
async function loadPeriodData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [ov, cu] = await Promise.all([
|
||||
arAgingApi.overview({ period: period.value, caliber: caliber.value }),
|
||||
arAgingApi.customers({
|
||||
period: period.value, caliber: caliber.value,
|
||||
overdue_only: overdueOnly.value, sort: sortState.value.sort, order: sortState.value.order,
|
||||
}),
|
||||
])
|
||||
overview.value = (ov as any) || {}
|
||||
customers.value = Array.isArray((cu as any)?.items) ? (cu as any).items : []
|
||||
if (!overview.value.period && period.value) overview.value.period = period.value
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '账龄数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
const r: any = await arAgingApi.customers({
|
||||
period: period.value, caliber: caliber.value,
|
||||
overdue_only: overdueOnly.value, sort: sortState.value.sort, order: sortState.value.order,
|
||||
})
|
||||
customers.value = Array.isArray(r?.items) ? r.items : []
|
||||
}
|
||||
|
||||
function onSortChange({ prop, order }: any) {
|
||||
if (!prop || !order) {
|
||||
sortState.value = { sort: 'overdue_amt', order: 'desc' }
|
||||
} else {
|
||||
sortState.value = { sort: prop, order: order === 'ascending' ? 'asc' : 'desc' }
|
||||
}
|
||||
loadCustomers()
|
||||
}
|
||||
|
||||
async function onCaliberChange() {
|
||||
await Promise.all([loadTrend(), loadPeriodData()])
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
const periods = trend.value.map(p => p.period)
|
||||
const missing = trend.value.filter(p => !p.has_data).map(p => p.period)
|
||||
|
||||
if (trendRef.value) {
|
||||
trendChart = trendChart || echarts.init(trendRef.value)
|
||||
trendChart.setOption({
|
||||
grid: { left: 60, right: 60, top: 40, bottom: 40 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (ps: any[]) => {
|
||||
const p = ps[0]?.axisValue
|
||||
const pt = trend.value.find(t => t.period === p)
|
||||
if (!pt || !pt.has_data) return `${p}<br/>该期无数据`
|
||||
return `${p}<br/>应收总额:${money(pt.total_amt)}<br/>逾期额(>30天):${money(pt.overdue_amt)}<br/>往来单位:${pt.customers} 户`
|
||||
},
|
||||
},
|
||||
legend: { data: ['应收总额', '往来单位户数'], top: 0 },
|
||||
xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 11 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额(元)', axisLabel: { formatter: (v: number) => `${(v / 10000).toFixed(0)}万` } },
|
||||
{ type: 'value', name: '户数', minInterval: 1 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '应收总额', type: 'line', smooth: false, connectNulls: false,
|
||||
data: trend.value.map(p => (p.has_data ? p.total_amt : null)),
|
||||
itemStyle: { color: '#409EFF' }, areaStyle: { opacity: 0.08 },
|
||||
markArea: missing.length ? {
|
||||
silent: true,
|
||||
itemStyle: { color: 'rgba(144,147,153,0.12)' },
|
||||
label: { show: true, position: 'insideTop', color: '#909399', fontSize: 10, formatter: '该期无数据' },
|
||||
data: missing.map(p => [{ xAxis: p }, { xAxis: p }]),
|
||||
} : undefined,
|
||||
},
|
||||
{
|
||||
name: '往来单位户数', type: 'bar', yAxisIndex: 1,
|
||||
data: trend.value.map(p => (p.has_data ? p.customers : null)),
|
||||
itemStyle: { color: '#c8d6e5' }, barMaxWidth: 18,
|
||||
},
|
||||
],
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (stackRef.value) {
|
||||
stackChart = stackChart || echarts.init(stackRef.value)
|
||||
stackChart.setOption({
|
||||
grid: { left: 60, right: 24, top: 40, bottom: 40 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (ps: any[]) => {
|
||||
const p = ps[0]?.axisValue
|
||||
const pt = trend.value.find(t => t.period === p)
|
||||
if (!pt || !pt.has_data) return `${p}<br/>该期无数据`
|
||||
const sum = BUCKETS.reduce((acc, b) => acc + Number(pt.buckets?.[b.col] ?? 0), 0)
|
||||
const diff = sum - Number(pt.total_amt ?? 0)
|
||||
const ok = Math.abs(diff) < 0.01
|
||||
const lines = BUCKETS.map((b, i) => `${b.label}:${money(ps[i]?.value)}`)
|
||||
return [
|
||||
`${p}(${caliber.value})`,
|
||||
...lines,
|
||||
`六档之和:${money(sum)}`,
|
||||
`期末合计:${money(pt.total_amt)}`,
|
||||
ok ? '校验:六档和 = 合计' : `校验异常:差额 ${diff.toFixed(2)}`,
|
||||
].join('<br/>')
|
||||
},
|
||||
},
|
||||
legend: { data: BUCKETS.map(b => b.label), top: 0, itemWidth: 12, itemHeight: 8, textStyle: { fontSize: 11 } },
|
||||
xAxis: { type: 'category', data: periods, axisLabel: { fontSize: 11 } },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: (v: number) => `${(v / 10000).toFixed(0)}万` } },
|
||||
series: BUCKETS.map((b, i) => ({
|
||||
name: b.label, type: 'bar', stack: 'aging', barMaxWidth: 26,
|
||||
data: trend.value.map(p => (p.has_data ? p.buckets?.[b.col] : null)),
|
||||
itemStyle: { color: ['#a3d0a3', '#e8c07d', '#e5a45f', '#e0765f', '#c74b4b', '#8f2f2f'][i] },
|
||||
})),
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const res: any = await api.get('/ar-aging/customers/export', {
|
||||
params: {
|
||||
period: period.value, caliber: caliber.value,
|
||||
overdue_only: overdueOnly.value,
|
||||
},
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = window.URL.createObjectURL(res)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `应收账龄_${displayPeriod.value || 'noperiod'}_${caliber.value}.csv`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
ElMessage.success('逐户清单已导出')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('导出失败(需 finance/ceo/business 权限且该期有数据)')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
trendChart?.resize()
|
||||
stackChart?.resize()
|
||||
}
|
||||
|
||||
const trendRef = ref<HTMLElement | null>(null)
|
||||
const stackRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', onResize)
|
||||
await loadMeta()
|
||||
await loadTrend()
|
||||
await loadPeriodData()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
trendChart?.dispose()
|
||||
stackChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ar-aging-page { max-width: 1440px; margin: 0 auto; padding: 16px; }
|
||||
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 16px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
|
||||
.caliber-suffix { color: #409eff; font-size: 16px; font-weight: 500; }
|
||||
.badge-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
||||
.source-note { font-size: 12px; color: #909399; margin: 0; line-height: 1.5; max-width: 720px; }
|
||||
.head-controls { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
||||
.metric { min-height: 108px; }
|
||||
.metric-label { font-size: 13px; color: #909399; }
|
||||
.metric-value { font-size: 26px; font-weight: 600; margin: 6px 0; word-break: break-all; }
|
||||
.metric-sub { font-size: 12px; color: #909399; }
|
||||
.chart-row { margin-top: 16px; }
|
||||
.chart-box { height: 320px; }
|
||||
.chart-note { font-size: 12px; color: #909399; margin: 6px 0 0; }
|
||||
.card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.table-card { margin-top: 16px; }
|
||||
.table-tools { display: flex; align-items: center; gap: 12px; }
|
||||
.small { font-size: 12px; }
|
||||
.dim { color: #909399; }
|
||||
.up { color: #f56c6c; font-weight: 600; }
|
||||
.down { color: #67c23a; font-weight: 600; }
|
||||
.neg { color: #f56c6c; }
|
||||
.th-red { color: #f56c6c; margin-right: 8px; }
|
||||
.th-yellow { color: #e6a23c; }
|
||||
.ico { width: 14px; height: 14px; margin-right: 4px; vertical-align: -2px; }
|
||||
</style>
|
||||
@@ -34,6 +34,8 @@
|
||||
:achievement-count="achievementCount"
|
||||
:summary="reviewSummary"
|
||||
:loading="loading"
|
||||
:objectives-total="totalObjectiveCount"
|
||||
:connections-total="connections.length"
|
||||
/>
|
||||
|
||||
<MapEditBar
|
||||
@@ -43,6 +45,7 @@
|
||||
:layer-configs="layerUiConfigs"
|
||||
:can-undo="undoStack.length > 0"
|
||||
:tree-open="treeOpen"
|
||||
:focus-layer="focusLayer"
|
||||
@update:search="v => searchText = v"
|
||||
@update:filter="v => filterMode = v"
|
||||
@toggle-layer="toggleLayer"
|
||||
@@ -52,6 +55,7 @@
|
||||
@fit="zoomFit"
|
||||
@toggle-tree="treeOpen = !treeOpen"
|
||||
@completeness="showCompleteness = true"
|
||||
@focus-layer="toggleFocusLayer"
|
||||
/>
|
||||
|
||||
<!-- 口径标识(常驻,非 hover 临时提示):A 口径 v1.1 来自会计科目、非发票同票列明 -->
|
||||
@@ -80,7 +84,7 @@
|
||||
<el-button size="small" @click="cancelLink">取消</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 仪表盘主视图:左目标树 / 中画布 / 右详情 -->
|
||||
<!-- 仪表盘主视图:左目标树(默认收起,留 46px 图标条)/ 中画布 / 详情浮层 -->
|
||||
<div class="map-body-row">
|
||||
<MapTargetTree
|
||||
v-show="treeOpen"
|
||||
@@ -98,6 +102,22 @@
|
||||
@drag-end="onTreeDragEnd"
|
||||
/>
|
||||
|
||||
<!-- 目标树收起后的 46px 图标条(C 方案:默认收起,一键展开) -->
|
||||
<div v-show="!treeOpen" class="tree-rail" aria-label="目标树已收起">
|
||||
<button
|
||||
type="button"
|
||||
class="rail-btn"
|
||||
title="展开目标树"
|
||||
aria-label="展开目标树"
|
||||
@click="treeOpen = true"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path d="M4 6h6M4 12h6M4 18h6" /><path d="M14 6h6M14 12h6M14 18h6" opacity=".45" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="rail-count mono" :title="'当前可见目标 / 总目标'">{{ visibleObjectiveCount }}</span>
|
||||
</div>
|
||||
|
||||
<div class="map-canvas-col">
|
||||
<!-- 画布主体(田字格2×2布局,transform: scale 缩放) -->
|
||||
<div
|
||||
@@ -108,7 +128,7 @@
|
||||
@drop.prevent="onTreeDrop"
|
||||
>
|
||||
<div class="canvas-zoom-wrap" :style="{ width: zoomWrapWidth, height: zoomWrapHeight }">
|
||||
<div class="canvas-body" :class="{ 'grid-mode': gridMode }" ref="bodyRef" :style="bodyZoomStyle">
|
||||
<div class="canvas-body" :class="{ 'grid-mode': gridMode, 'focus-active': !!focusLayer }" ref="bodyRef" :style="bodyZoomStyle">
|
||||
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
|
||||
<div
|
||||
v-for="(cfg, idx) in orderedLayerConfigs"
|
||||
@@ -116,6 +136,10 @@
|
||||
v-show="visibleLayers[cfg.key]"
|
||||
:ref="el => setLayerRef(cfg.key, el)"
|
||||
class="layer-swimlane-wrapper"
|
||||
:class="{
|
||||
'is-focused': focusLayer === cfg.key,
|
||||
'is-compact': !!focusLayer && focusLayer !== cfg.key,
|
||||
}"
|
||||
:style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }"
|
||||
>
|
||||
<StrategyLayer
|
||||
@@ -126,6 +150,10 @@
|
||||
:color="layerColor(cfg.key)"
|
||||
:objectives="getLayerNodes(cfg.key)"
|
||||
:collapsed="!!collapsedLayers[cfg.key]"
|
||||
:compact="!!focusLayer && focusLayer !== cfg.key"
|
||||
:focused="focusLayer === cfg.key"
|
||||
:layer-ratio="layerStats[cfg.key] ? layerStats[cfg.key].ratio : null"
|
||||
@focus="toggleFocusLayer(cfg.key)"
|
||||
:dim-active="filterActive"
|
||||
:dimmed-keys="matchedKeyList"
|
||||
@toggle-collapse="toggleLayerCollapse(cfg.key)"
|
||||
@@ -174,10 +202,40 @@
|
||||
<span class="lg-item"><i class="lg-dot lv-gray"></i>无数据</span>
|
||||
<span class="lg-note">达成率按指标方向归一,取目标关联 KPI 均值</span>
|
||||
</div>
|
||||
</div><!-- end map-canvas-col -->
|
||||
|
||||
<!-- 跨层关系摘要条(C 方案):数字由 connections 的 from/to 现算,不写死 -->
|
||||
<div class="cross-summary" aria-label="跨层关系摘要">
|
||||
<span class="cs-title">跨层关系摘要</span>
|
||||
<span v-for="item in crossSummary" :key="item.key" class="cs-item">
|
||||
<i class="cs-dot" :style="{ background: item.color }"></i>
|
||||
<span class="cs-label">{{ item.label }}</span>
|
||||
<b class="cs-num mono">{{ item.count }}</b>
|
||||
</span>
|
||||
<span class="cs-total">
|
||||
合计 <b class="mono">{{ connections.length }}</b> 条 · 跨层 <b class="mono">{{ crossLayerCount }}</b> 条 · 同层 <b class="mono">{{ sameLayerCount }}</b> 条
|
||||
</span>
|
||||
</div>
|
||||
</div><!-- end map-canvas-col -->
|
||||
</div><!-- end map-body-row -->
|
||||
|
||||
<!-- 详情浮层(C 方案):右栏不再常驻占位,画布按视口 100% 宽;浮层可拖动、可关闭 -->
|
||||
<div
|
||||
v-if="selectedNode"
|
||||
class="detail-float"
|
||||
:style="{ left: floatPos.x + 'px', top: floatPos.y + 'px' }"
|
||||
role="dialog"
|
||||
aria-label="目标详情浮层"
|
||||
>
|
||||
<div class="float-bar" @mousedown="startFloatDrag">
|
||||
<span class="float-bar-grip" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 8h.01M8 12h.01M8 16h.01M16 8h.01M16 12h.01M16 16h.01" /></svg>
|
||||
</span>
|
||||
<span class="float-bar-title">拖动可移动</span>
|
||||
<button type="button" class="float-bar-close" aria-label="关闭详情浮层" @click="closeDetail">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<MapNodeDetail
|
||||
v-if="selectedNode"
|
||||
:obj="selectedNode.obj"
|
||||
:dim-key="selectedNode.dimKey"
|
||||
:index="selectedNode.index"
|
||||
@@ -196,13 +254,7 @@
|
||||
@select-key="selectByKey"
|
||||
@go-kpi-library="goKpiLibrary"
|
||||
/>
|
||||
<aside v-else class="map-detail-empty" aria-label="目标详情占位">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="M7 9h10M7 13h7M7 17h4" />
|
||||
</svg>
|
||||
<p>选中左侧目标或画布节点<br />查看达成率、KPI 与因果上下游</p>
|
||||
</aside>
|
||||
</div><!-- end map-body-row -->
|
||||
</div>
|
||||
|
||||
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
|
||||
<ConnectionLines
|
||||
@@ -210,7 +262,8 @@
|
||||
:layer-refs="layerRefs"
|
||||
:node-refs="compiledNodeRefs"
|
||||
:layer-keys="layerKeysOrdered"
|
||||
:connections="connections"
|
||||
:conn-keys="connKeys"
|
||||
:connections="renderedConnections"
|
||||
:temp-line="tempLine"
|
||||
:container-key="recalcTrigger"
|
||||
:view-direction="viewDirection"
|
||||
@@ -319,6 +372,8 @@ import MapTargetTree from "../components/strategy-map/MapTargetTree.vue"
|
||||
import MapNodeDetail from "../components/strategy-map/MapNodeDetail.vue"
|
||||
import MapCompleteness from "../components/strategy-map/MapCompleteness.vue"
|
||||
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
|
||||
// P3:连线端点解析键(`层-序号`)—— 目标改名后线仍指向同一节点
|
||||
import { buildKeyIndex, type ConnectionKeyEntry } from "../utils/connKeys"
|
||||
import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
|
||||
import MapDimensionFilter from "../components/map-canvas/MapDimensionFilter.vue"
|
||||
import CaliberBadge from "../components/CaliberBadge.vue"
|
||||
@@ -387,7 +442,35 @@ const filterMode = ref<'all' | 'risk' | 'nodata'>('all')
|
||||
const visibleLayers = reactive<Record<string, boolean>>({ finance: true, customer: true, process: true, learning: true })
|
||||
const collapsedLayers = reactive<Record<string, boolean>>({})
|
||||
const collapsedGroups = reactive<Record<string, boolean>>({})
|
||||
const treeOpen = ref(true)
|
||||
|
||||
/* ── C 方案:目标树默认收起 + 记住上次选择(localStorage 键名 cma.map.canvas.treeOpen)── */
|
||||
const TREE_OPEN_STORAGE_KEY = 'cma.map.canvas.treeOpen'
|
||||
function readTreeOpenPref(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(TREE_OPEN_STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const treeOpen = ref<boolean>(readTreeOpenPref())
|
||||
watch(treeOpen, (v) => {
|
||||
try {
|
||||
localStorage.setItem(TREE_OPEN_STORAGE_KEY, v ? '1' : '0')
|
||||
} catch {
|
||||
/* 隐私模式/配额满:静默降级为不记忆,不影响画布功能 */
|
||||
}
|
||||
})
|
||||
|
||||
/* ── C 方案:聚焦模式(null = 四层全景;非 null = 该层展开、其余三层压成单行)── */
|
||||
const focusLayer = ref<string | null>(null)
|
||||
function toggleFocusLayer(key: string | null) {
|
||||
focusLayer.value = !key || focusLayer.value === key ? null : key
|
||||
nextTick(() => {
|
||||
updateGridMode()
|
||||
triggerRecalc()
|
||||
updateZoomWrapHeight()
|
||||
})
|
||||
}
|
||||
|
||||
const selectedNode = ref<{ dimKey: string; index: number; obj: any } | null>(null)
|
||||
const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : null))
|
||||
@@ -403,6 +486,8 @@ const undoStack = ref<{ dimensions: any; connections: any[] }[]>([])
|
||||
|
||||
// 连线
|
||||
const connections = ref<any[]>([])
|
||||
/** P3:服务端解析键(与 connections 逐下标对齐);空 ⇒ 回落原标识串(历史/降级) */
|
||||
const connKeys = ref<ConnectionKeyEntry[]>([])
|
||||
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
|
||||
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
|
||||
const dragConnectTarget = ref<string | null>(null)
|
||||
@@ -520,6 +605,112 @@ const achievementCount = computed(() =>
|
||||
Object.values(kpiProgressMap).filter((p: any) => p.hasData && p.ratio != null).length
|
||||
)
|
||||
|
||||
/** 各层达成率均值(聚焦压缩态色条用):仅统计有实测值的目标,无数据 ⇒ null(界面显式「—」) */
|
||||
const layerStats = computed<Record<string, { ratio: number | null; count: number }>>(() => {
|
||||
const out: Record<string, { ratio: number | null; count: number }> = {}
|
||||
for (const cfg of orderedLayerConfigs) {
|
||||
const objs = getLayerNodes(cfg.key)
|
||||
const vals: number[] = []
|
||||
objs.forEach((_: any, i: number) => {
|
||||
const p: any = kpiProgressMap[`${cfg.key}-${i}`]
|
||||
if (p && p.hasData && p.ratio != null && !Number.isNaN(Number(p.ratio))) vals.push(Number(p.ratio))
|
||||
})
|
||||
out[cfg.key] = { ratio: vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null, count: objs.length }
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/* ── C 方案:跨层关系摘要(全部由 connections 的 from/to 现算,禁写死)──
|
||||
* 连线端点兼容两种键:渲染键 `finance-0` 与数据键 `learning:目标名`(StrategyLayer 双键注册)。
|
||||
*/
|
||||
function layerOfNodeKey(key: string | null | undefined): string | null {
|
||||
if (!key || typeof key !== 'string') return null
|
||||
const sep = key.includes(':') ? ':' : '-'
|
||||
const layer = key.split(sep)[0]
|
||||
return LAYER_KEYS.includes(layer as any) ? layer : null
|
||||
}
|
||||
const layerHue = (key: string) => LAYER_VISUAL[key]?.color || LAYER_CONFIG[key]?.color || '#635BFF'
|
||||
/** 聚焦态只绘制聚焦层内部的同层连线(跨层线跨向被压缩层时无锚点);全景态全量绘制 */
|
||||
const renderedConnections = computed<any[]>(() => {
|
||||
const f = focusLayer.value
|
||||
if (!f) return connections.value
|
||||
return connections.value.filter(
|
||||
(c: any) => layerOfNodeKey(c?.from) === f && layerOfNodeKey(c?.to) === f
|
||||
)
|
||||
})
|
||||
const crossLayerCount = computed(() =>
|
||||
connections.value.filter((c: any) => {
|
||||
const f = layerOfNodeKey(c?.from)
|
||||
const t = layerOfNodeKey(c?.to)
|
||||
return !!f && !!t && f !== t
|
||||
}).length
|
||||
)
|
||||
const sameLayerCount = computed(() => connections.value.length - crossLayerCount.value)
|
||||
/** BSC 三段因果 + 「其他跨层」兜底桶 + 同层;合计必须等于 connections.length */
|
||||
const crossSummary = computed(() => {
|
||||
const dirs: { key: string; label: string; from: string; to: string }[] = [
|
||||
{ key: 'learn2proc', label: '学习→流程', from: 'learning', to: 'process' },
|
||||
{ key: 'proc2cust', label: '流程→客户', from: 'process', to: 'customer' },
|
||||
{ key: 'cust2fin', label: '客户→财务', from: 'customer', to: 'finance' },
|
||||
]
|
||||
const counts: Record<string, number> = {}
|
||||
let other = 0
|
||||
for (const c of connections.value as any[]) {
|
||||
const f = layerOfNodeKey(c?.from)
|
||||
const t = layerOfNodeKey(c?.to)
|
||||
if (!f || !t || f === t) continue
|
||||
const hit = dirs.find(d => d.from === f && d.to === t)
|
||||
if (hit) {
|
||||
counts[hit.key] = (counts[hit.key] || 0) + 1
|
||||
} else {
|
||||
other++
|
||||
}
|
||||
}
|
||||
const items = dirs.map(d => ({
|
||||
key: d.key,
|
||||
label: d.label,
|
||||
count: counts[d.key] || 0,
|
||||
color: layerHue(d.to),
|
||||
}))
|
||||
if (other > 0) items.push({ key: 'other', label: '其他跨层', count: other, color: '#94A3B8' })
|
||||
return items
|
||||
})
|
||||
|
||||
/* ── C 方案:详情浮层位置与拖动(自绘 div + transform,非 el-dialog)── */
|
||||
const floatPos = ref<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const floatDragging = ref(false)
|
||||
let floatDragOffset = { x: 0, y: 0 }
|
||||
const FLOAT_W = 360
|
||||
function clampFloat(x: number, y: number) {
|
||||
const maxX = Math.max(8, window.innerWidth - FLOAT_W - 12)
|
||||
const maxY = Math.max(8, window.innerHeight - 120)
|
||||
return { x: Math.min(Math.max(8, x), maxX), y: Math.min(Math.max(8, y), maxY) }
|
||||
}
|
||||
/** 默认落位:右侧靠上(不遮挡左侧层名与中部卡片) */
|
||||
function placeFloatDefault() {
|
||||
floatPos.value = clampFloat(window.innerWidth - FLOAT_W - 24, 132)
|
||||
}
|
||||
function startFloatDrag(e: MouseEvent) {
|
||||
floatDragging.value = true
|
||||
floatDragOffset = { x: e.clientX - floatPos.value.x, y: e.clientY - floatPos.value.y }
|
||||
window.addEventListener('mousemove', onFloatDragMove)
|
||||
window.addEventListener('mouseup', stopFloatDrag)
|
||||
e.preventDefault()
|
||||
}
|
||||
function onFloatDragMove(e: MouseEvent) {
|
||||
if (!floatDragging.value) return
|
||||
floatPos.value = clampFloat(e.clientX - floatDragOffset.x, e.clientY - floatDragOffset.y)
|
||||
}
|
||||
function stopFloatDrag() {
|
||||
floatDragging.value = false
|
||||
window.removeEventListener('mousemove', onFloatDragMove)
|
||||
window.removeEventListener('mouseup', stopFloatDrag)
|
||||
}
|
||||
watch(selectedNode, (v) => {
|
||||
if (v && !floatPos.value.x && !floatPos.value.y) placeFloatDefault()
|
||||
})
|
||||
onUnmounted(() => stopFloatDrag())
|
||||
|
||||
/** 搜索/筛选是否激活(激活时画布非匹配节点变淡) */
|
||||
const filterActive = computed(() => searchText.value.trim() !== '' || filterMode.value !== 'all')
|
||||
|
||||
@@ -797,6 +988,22 @@ watch(zoomLevel, () => {
|
||||
nextTick(() => connectionLinesRef.value?.recalcAll())
|
||||
})
|
||||
|
||||
// ── P3:服务端解析键 ──
|
||||
// 为什么必须取服务端:canvas_data 里存的是写入时刻的标识串(`层:中文名`),
|
||||
// 目标改名后名字串失效;服务端能由 from/to_objective_id 反查当前 `${层}-${序号}`。
|
||||
// 取不到(接口异常/离线)⇒ 置空,画布回落原标识串,行为与改造前一致(不更坏)。
|
||||
async function refreshConnKeys() {
|
||||
const id = selectedMap.value
|
||||
if (!id) { connKeys.value = []; return }
|
||||
try {
|
||||
const r: any = await api.get(`/maps/${id}/connections`)
|
||||
const serverConns = r?.data?.connections || []
|
||||
connKeys.value = buildKeyIndex(loadedCanvas || connections.value || [], serverConns)
|
||||
} catch (e) {
|
||||
connKeys.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ── 画布加载 ──
|
||||
function loadCanvas() {
|
||||
caliberFacts.value = caliberGapFor(currentCaliberEntityId())
|
||||
@@ -842,6 +1049,7 @@ function loadCanvas() {
|
||||
loadedDims = deepClone(dimensions)
|
||||
connections.value = map?.canvas_data?.connections || []
|
||||
loading.value = false
|
||||
refreshConnKeys()
|
||||
nextTick(() => {
|
||||
triggerRecalc()
|
||||
updateGridMode()
|
||||
@@ -982,6 +1190,7 @@ function undoLast() {
|
||||
connections.value = JSON.parse(JSON.stringify(snap.connections))
|
||||
selectedNode.value = null
|
||||
markDirty()
|
||||
refreshConnKeys() // P3:撤销可能改动目标顺序/名字,解析键随之刷新
|
||||
nextTick(() => { triggerRecalc(); updateZoomWrapHeight(); loadObjectiveLevels() })
|
||||
ElMessage.success('已撤销上一步')
|
||||
}
|
||||
@@ -994,10 +1203,21 @@ function onTreeDragStart(payload: { dimKey: string; index: number; obj: any }) {
|
||||
}
|
||||
function onTreeDragOver(e: DragEvent) {
|
||||
if (!treeDragSource.value) return
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-node-key]') as HTMLElement | null
|
||||
const key = el?.getAttribute('data-node-key') || null
|
||||
// 命中判定改用节点 rect 包含判断:elementFromPoint 会被覆盖层(窄屏时的知识面板 .kp-panel、
|
||||
// 连线 SVG 等)挡住 ⇒ 拖拽在窄屏下永远取不到目标节点。rect 判定与层级无关。
|
||||
const key = hitTestNodeKey(e.clientX, e.clientY)
|
||||
dragConnectTarget.value = key && key !== treeDragSource.value.key ? key : null
|
||||
}
|
||||
function hitTestNodeKey(x: number, y: number): string | null {
|
||||
const nodes = document.querySelectorAll('[data-node-key]') as NodeListOf<HTMLElement>
|
||||
for (const el of Array.from(nodes)) {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width > 0 && r.height > 0 && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
|
||||
return el.getAttribute('data-node-key')
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
function onTreeDragLeave(e: DragEvent) {
|
||||
const related = (e as any).relatedTarget as Node | null
|
||||
const box = scrollWrapRef.value
|
||||
@@ -1705,6 +1925,9 @@ async function saveCanvas(silent?: boolean) {
|
||||
if (res?.updated_at) baseUpdatedAt.value = res.updated_at // 采纳服务端新基准
|
||||
refreshBaselines(res?.dimensions) // 基线回到服务端现值(响应体为权威)
|
||||
markClean()
|
||||
// P3:保存后刷新解析键(改名/调序都会让 `${层}-${序号}` 变,线要跟着节点走)
|
||||
if (payload.canvas_data) loadedCanvas = deepClone(connections.value)
|
||||
refreshConnKeys()
|
||||
if (!silent) ElMessage.success("已保存")
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
@@ -1731,6 +1954,8 @@ async function saveConnections() {
|
||||
base_updated_at: currentBaseStamp(),
|
||||
})
|
||||
if (res?.updated_at) baseUpdatedAt.value = res.updated_at
|
||||
loadedCanvas = deepClone(connections.value)
|
||||
refreshConnKeys() // P3:连线新增/删除后服务端重算解析键
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 409) {
|
||||
autoSaveBlocked.value = true
|
||||
@@ -2137,6 +2362,140 @@ onUnmounted(() => {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── C 方案:聚焦模式 ── */
|
||||
/* 聚焦层:田字格下占满整行,卡片网格展开 */
|
||||
.canvas-body.grid-mode .layer-swimlane-wrapper.is-focused {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
/* 其余三层:压成单行(≤48px,仅层头 + 目标数 + 达成率色条) */
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact {
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
max-height: 48px;
|
||||
overflow: hidden;
|
||||
align-self: start;
|
||||
}
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact :deep(.strategy-layer) {
|
||||
min-height: 0;
|
||||
height: 48px;
|
||||
}
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact :deep(.layer-body) {
|
||||
display: none;
|
||||
}
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact :deep(.layer-header) {
|
||||
padding: 6px 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-focused :deep(.strategy-layer) {
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
/* ── 目标树收起后的 46px 图标条 ── */
|
||||
.tree-rail {
|
||||
width: 46px;
|
||||
flex: 0 0 46px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 8px;
|
||||
background: var(--bh-bg-card, #fff);
|
||||
}
|
||||
.rail-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 6px;
|
||||
background: #F8FAFC;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rail-btn:hover { background: #EEF2F7; color: #0F172A; }
|
||||
.rail-btn svg { width: 16px; height: 16px; }
|
||||
.rail-count {
|
||||
font-size: 11px;
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── 详情浮层(自绘 div,可拖动,非 el-dialog) ── */
|
||||
.detail-float {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
width: 360px;
|
||||
max-height: calc(100vh - 180px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bh-bg-card, #fff);
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16), 0 2px 6px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-float :deep(.node-detail) {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.float-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
background: #F8FAFC;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.float-bar-grip { display: inline-flex; color: #94A3B8; }
|
||||
.float-bar-grip svg { width: 14px; height: 14px; }
|
||||
.float-bar-title { font-size: 11px; color: #94A3B8; flex: 1; }
|
||||
.float-bar-close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748B;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.float-bar-close:hover { background: #E2E8F0; color: #0F172A; }
|
||||
.float-bar-close svg { width: 12px; height: 12px; }
|
||||
|
||||
/* ── 跨层关系摘要条 ── */
|
||||
.cross-summary {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding: 6px 2px 0;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
border-top: 1px dashed var(--bh-border, #E5E7EB);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cs-title { color: #94A3B8; flex-shrink: 0; }
|
||||
.cs-item { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
.cs-dot { width: 7px; height: 7px; border-radius: 2px; display: inline-block; }
|
||||
.cs-label { color: #64748B; }
|
||||
.cs-num { color: #0F172A; font-variant-numeric: tabular-nums; }
|
||||
.cs-total { margin-left: auto; color: #64748B; flex-shrink: 0; }
|
||||
.cs-total b { color: #0F172A; }
|
||||
|
||||
/* ── 跨层箭头指示器 ── */
|
||||
.cross-layer-arrow {
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- ── 战略章程(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">
|
||||
<!-- 口径标识位(常驻,非 hover):与看板/KPI 列表共用同一组件与文案常量 -->
|
||||
@@ -167,6 +184,7 @@ import {
|
||||
Money, User, Setting, Reading, TrendCharts,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { mapApi } from '../api/index'
|
||||
import { charterApi } from '../api/index'
|
||||
import CaliberBadge from '../components/CaliberBadge.vue'
|
||||
import { caliberGapFor, currentCaliberEntityId, isCaliberKpiCode, type CaliberGapFacts } from '../constants/caliber'
|
||||
|
||||
@@ -175,6 +193,8 @@ const boardRef = ref<HTMLElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const board = ref<any>(null)
|
||||
// 战略章程(P 基座最顶层输入,只读;无行 → null,不显示任何文案)
|
||||
const charter = ref<any>(null)
|
||||
const period = ref<string>('')
|
||||
const periods = ref<string[]>([])
|
||||
const boardSize = ref({ w: 0, h: 0 })
|
||||
@@ -317,6 +337,10 @@ function onResize() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 章程与地图并行取;章程失败不阻断地图(只读装饰项)
|
||||
charterApi.get()
|
||||
.then((res: any) => { if (res?.filled) charter.value = res.data })
|
||||
.catch(() => { charter.value = null })
|
||||
await load()
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
@@ -367,6 +391,16 @@ function doPrint() {
|
||||
<style scoped>
|
||||
.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-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; }
|
||||
|
||||
Reference in New Issue
Block a user