Author SHA1 Message Date
wecom-project e84719f76e fix(deploy): pnpm 版本断言不一致时先 corepack 自愈,仍不一致则仍 fail-closed exit 1
ci/woodpecker/push/main Pipeline was successful
2026-10-25 16:01:03 +08:00
wecom-fullstack 63a2bac424 feat(maps): 画布聚焦减负(聚焦层/详情浮层/汇总条/目标树收起/跨层摘要)— C 方案
ci/woodpecker/push/main Pipeline was successful
- MapCanvas: 层聚焦与回全景联动;右栏停用改自绘可拖详情浮层;目标树默认收起+localStorage 持久化;底部跨层关系摘要条;聚焦态只绘聚焦层同层连线
- StrategyLayer: 层标题可点击聚焦并标「聚焦中」;非聚焦层压缩为 ≤48px 单行摘要(层名—达成率/目标数/mini 色条)
- MapSummaryBar: 汇总条压缩 ≤44px、5 项同排;窄屏无横向溢出
- MapEditBar: 新增「全景 / L1–L4」聚焦切换 chip(内联 SVG,无 emoji)
- 只读渲染层改动:不写 canvas_data / map_connections,无新增写链路调用
2026-10-24 20:03:32 +08:00
wecom-fullstack b3f31f9ac9 fix(maps): 画布连线键与目标改名解耦(objective_id 锚定)+ nodeRefs 位置兜底 + 保守反错绑断言(P3)
ci/woodpecker/push/main Pipeline failed
- map_connections 读侧以 from/to_objective_id 反查当前 dimensions 树定位节点,改名/重排不再静默断线
- 悬停/拖拽期 map_objectives 未返回时按位置兜底(仅同层同序位有效),并记录 stale_objective_id
- 新增 tests/test_conn_key_rename_stability.py(含 2 条保守反错绑负测用例)
- 零 DDL、零 health_score 改动(问3=A)
2026-10-23 15:49:36 +08:00
wecom-fullstackandHermes CI Fix 986dec516e fix(maps): 画布因果连线零绘制(节点双键注册) + 战略回顾 total_hold 未初始化 500
ci/woodpecker/push/main Pipeline was successful
- StrategyLayer.vue: setNodeRef 双键注册(渲染键 layer-index 与数据键 layer:目标名),$el 归一化为 DOM,卸载时清理数据键
- maps.py: total_hold 初始化 0 + summary.hold 透出(修复 review?level=strategic 500 UnboundLocalError)
- tests: 新增 test_maps_review_hold_20261022.py(待核/普通/空/NULL/无level 五例,负对照已验证会失败)

任务: project-20261022-cma-map-canvas-connections-not-drawn-fullstack
阶段A候选件;未 push、未部署(等阶段B放行)
2026-10-22 16:50:10 +08:00
项目Bot 4cad2b73c4 fix(lead): 客户咨询通知改走系统总线8660→任总DM,替掉已下线的 ai-cs 通道
ci/woodpecker/push/main Pipeline was successful
- ai-cs 用户/profile 已不存在,旧 runuser 调用必然失败且被 except: pass 吞掉
- 线索只落 /root/leads/leads.json,任总侧零通知(线上活缺陷)
- 新通道:POST 127.0.0.1:8660/bus/send,notice_class=fallback,收件人不变(200653946)
- 新增 /root/leads/notify.log 落痕,杜绝静默失败
2026-10-22 16:47:25 +08:00
wecom-project 1efa6b7df9 refactor(lead): 退役 notify_wecom 8800 全员群推送(只保留 notify_boss DM)
ci/woodpecker/push/main Pipeline was successful
- 删除 notify_wecom()(JSON body 打 8800/send,中继只解析 form-encoded,从未发出过消息)与吞异常的 except: pass
- 客户咨询通知唯一通道:notify_boss 推任总 DM(经 ai-cs 分流)
- 去掉随之无用的 urllib.request 导入
- 零行为变更:删的是从不生效的代码;防止后人「修成 form-encoded」把客户个人信息推进全员群

关联决策:ASK-20261021-09(任总 2026-10-22 11:16:8800 仅限 notice_class=content)
派单:finance-20261022-cma-lead-retire-8800-project(父任务 yanxue-20261022-relay-8800-koujing-finance)
2026-10-22 12:49:06 +08:00
wecom-fullstack 9bbe3001bf fix(alerts): kpi_alerts 去重键改生成侧语义键 dedupe_key(A2)— 文案改动不再重复落库
ci/woodpecker/push/main Pipeline was successful
- 列 dedupe_key VARCHAR(160) NULL + 唯一索引 (entity_id,kpi_id,alert_type,dedupe_key)
- write_alerts 三级判重:dedupe_key 命中 / 历史 NULL 行反解兼容 / 无命中插入
- 并发兜底:savepoint 捕获 IntegrityError 计跳过
- dry-run would_insert 改用 count_would_insert(修复 len(hits) 虚高)
- DDL 幂等执行器 scripts/ddl_kpi_alerts_dedupe.py(check/apply/rollback)
- tests/test_dedupe_key.py 7 passed(T1-T7b);alert/overdue 回归 173 passed
2026-10-22 11:29:54 +08:00
wecom-fullstackandHermes CI Fix adcd118498 feat(maps): P1 四层因果结构化 map_connections 表 + 迁移脚本 + analysis 只读端点
- 新表 map_connections(entity_id/map_id/from_node/to_node/from_layer/to_layer/
  from_objective_id/to_objective_id/effect/label/sort_order),唯一键 (map_id,from,to) 保幂等
- 节点双格式兼容:层:中文目标名(生产存量 31 条)与 层-序号(前端现用)
- canvas_data 保留不动(双写期);POST/DELETE 写表 + 同步 JSON,失败仅告警不阻断
- GET /connections 表优先、表空回落 canvas_data;新增 GET /connections/analysis
  (层统计/跨层分布/断链/反向告警不拦截)
- scripts/migrate_map_connections.py 四态(check/apply/idem/rollback)+ canvas 快照回滚点
- tests: 9 条(双格式解析、双写、回落、删除同步、分析统计与告警)
2026-10-22 05:19:45 +08:00
wecom-fullstack 0a6525edab feat(strategy): P0 战略基座 — strategy_charter + GET/PUT /api/cma/charter + P组入口
ci/woodpecker/push/main Pipeline was successful
- 表 strategy_charter(一账套一行,entity_id 唯一):mission/vision/vision_year/vision_measurable/values_text/value_items(JSON) 全 NOT NULL
- 迁移脚本 backend/scripts/migrate_strategy_charter_20261021.py:--check/--apply/--down(回滚先 mysqldump,有数据需 --force),零 INSERT
- API:GET 无行 → filled=false 空态(禁示例数据);PUT 字段白名单 + 账套隔离三道 + vision_year 4位年份 + value_items [{name,meaning}] 结构校验
- 前端:P 组「战略章程」入口 + MyDialog 编辑(ceo/finance 可写,其余只读);战略地图页顶部只读章程条
- 用例 43 条 + auth-surface 58 条 = 101 passed;vite build rc=0
2026-10-21 18:54:09 +08:00
wecom-fullstack 7022a05928 fix(ar): 单户逾期文案口径——账龄最老 N 天 / 逾期合计 ¥X(占该户 Y%)
ci/woodpecker/push/main Pipeline was successful
D2:%d 为最老逾期天数、%s 为 31 天以上逾期合计,旧文案「期末逾期 151 天以上 ¥300,120」
会被读成最老档即 30 万(HKJJ120 150+ 实为 65,115.00)。只改文案,判据/params/历史行零变更。
新增回归用例:HKJJ120 逐档实数 ⇒ 账龄最老 151 天,逾期合计 ¥300,120.00(占该户 85.14%)。
2026-10-21 16:01:08 +08:00
Hermes CI Fix a7a663f6cd feat(ar): 逾期预警判据 G5——90-150 前池 5pp/10万 + 责任人点名优先(阶段B 受批面)
ci/woodpecker/push/main Pipeline was successful
2026-10-21 11:21:22 +08:00
fullstack-botandHermes CI Fix 71867c4c7a feat(ar): 应收账龄看板 + 逾期预警 overdue 接入(阶段A)
T1 逾期预警(§6 财务 2026-10-20 23:23 口径):
- alert_rules 支持 rule_type=ar_overdue;阈值/责任人/排除名单全量走 params(代码零业务常量)
- 90 天占比红线 key 保留但默认 null 停用(不回落 0.25);黄线 15%-25% 保留
- 新增逐户红线「>30 天且 >5 万」+ 聚合红线「150+ 占比环比上升 = 结构恶化」
- assignee 派生:manual → 近 3 个月主业务员(zxt_masterbill emp_id → employee_dict.u_Name,
  缺表时降级同库摘要制单人姓名)→ 部门负责人(渠道 118 / 商务 114 / 直属酒窖 117);
  两路皆失败 ⇒ 该户不生成、进 skipped[],禁落空 assignee
- POST /run-ar-overdue(dry_run 默认 true)+ check-all 分支;只 INSERT 同一 message 幂等

T2 账龄看板:
- GET /api/cma/ar-aging/{meta,trend,overview,customers,customers/export};每条查询带 entity_id
- 三口径分列、caliber=all → 400;缺期 has_data=false 且金额 null(不补 0)
- ArAging.vue:口径后缀/截止期徽标/趋势断点/六档堆叠/逐户可排序导出

单测 21 项全通过(SQLite 夹具,不连生产库);vite build 通过。
阶段A 零生产写:未 push、未部署、未 --apply、未 dry_run=false。
2026-10-21 08:42:24 +08:00
wecom-fullstack 790be2d6b1 fix(strategy-map): 三处缺陷——D1 完备度/摘要路由先注册(修 /completeness/objectives 422)、D2 连线增删显式 flag_modified(修 DELETE 200 不落库)、D3 拖拽命中改 rect 判定(修窄屏知识面板遮挡导致拖拽失效)
ci/woodpecker/push/main Pipeline was successful
2026-10-20 20:35:28 +08:00
34 changed files with 6865 additions and 259 deletions
+175 -3
View File
@@ -2,7 +2,7 @@
预警规则智能化 — 任务6 预警规则智能化 — 任务6
后端组件: alert_rules 模型 + API + 预警引擎 后端组件: 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.orm import Session
from sqlalchemy import text, Column, Integer, String, Text, Float, DateTime, JSON, Boolean, func from sqlalchemy import text, Column, Integer, String, Text, Float, DateTime, JSON, Boolean, func
from typing import Optional, List from typing import Optional, List
@@ -26,6 +26,8 @@ from app.utils.cash_thresholds import (
CASH_GAP_RULE_TYPE, CASH_GAP_RULE_TYPE,
validate_params as validate_cash_gap_params, 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") logger = logging.getLogger("alert_rules")
@@ -38,6 +40,7 @@ VALID_RULE_TYPES = (
"forecast_deviation", # 预测偏差(升级2b 2026-08-25) "forecast_deviation", # 预测偏差(升级2b 2026-08-25)
"customer_concentration", # 客户集中度(P2 2026-09-10) "customer_concentration", # 客户集中度(P2 2026-09-10)
"cash_gap_threshold", # 资金缺口阈值配置(G1 2026-10-17,配置行,不进预警生成链) "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")) err = validate_cash_gap_params(data.get("params"))
if err: if err:
raise HTTPException(400, 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( rule = AlertRule(
entity_id=entity_id, 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: if err:
db.rollback() db.rollback()
raise HTTPException(400, err) 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.commit()
db.refresh(rule) db.refresh(rule)
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}} 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 会误生成预警)。 # 不进 KPIValue 比价链(否则未来若绑定到有值的 KPI 会误生成预警)。
if rule.rule_type == CASH_GAP_RULE_TYPE: if rule.rule_type == CASH_GAP_RULE_TYPE:
continue 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) kpi = kpi_cache.get(rule.kpi_id)
if kpi is None: if kpi is None:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first() 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: if not kpi or kpi.entity_id != entity_id:
continue 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,直接看客户收入占比 # 客户集中度规则(P2 2026-09-10):不依赖同期KPIValue,直接看客户收入占比
if rule.rule_type == "customer_concentration": if rule.rule_type == "customer_concentration":
@@ -655,7 +694,7 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
continue 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 params["kpi"] = kpi
for forecast in latest_forecasts: for forecast in latest_forecasts:
value = forecast.predicted_cash value = forecast.predicted_cash
@@ -979,3 +1018,136 @@ def run_customer_concentration_check(
continue continue
db.commit() db.commit()
return {"message": f"客户集中度检查完成: {generated}条", "generated": generated} 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
+467
View File
@@ -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))},
)
+227
View File
@@ -0,0 +1,227 @@
"""战略基座 — 战略章程 API(/api/cma/charter)
任务:`project-20261021-cma-strategy-charter-p0-fullstack`(P0 战略基座)
------------------------------------------------------------------
契约:
GET /api/cma/charter 读当前账套章程(无行 → 200 + filled=false 空态,**不造默认/示例值**)
PUT /api/cma/charter 写当前账套章程(upsert 单行;字段白名单 + 值域校验)
隔离(三道):
1. 账套一律取 `deps.get_entity_id` —— token 优先,显式传别的账套 → 403(平台统一语义);
2. body 里出现 entity_id 且与解析结果不一致 → 403(防 Bot/脚本越权注入);
3. 读写都带 `entity_id` 过滤条件(不靠唯一键兜底),跨账套读不到对方行。
白名单(派单要求「白名单」):
· 字段白名单 CHARTER_FIELDS —— 出现白名单外字段直接 400,不静默忽略;
· entity_id 不可改(由登录账套决定,手写一律拒绝);
· 不可空:mission / vision / vision_year / vision_measurable / values_text / value_items;
· vision_year 须 4 位年份;value_items 须为 `[{name, meaning}]` 数组(每条可作裁决依据)。
鉴权:GET 已登录即可(含 business 旁观);PUT 限 ceo / finance(战略口径归经营层)。
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth, require_role
from app.database import get_db
from app.deps import get_entity_id
from app.models import StrategyCharter, User
from app.models.strategy_charter import (
MISSION_MAX_LEN,
VALUE_ITEM_MEANING_MAX_LEN,
VALUE_ITEM_NAME_MAX_LEN,
VALUE_ITEMS_MAX,
VALUES_TEXT_MAX_LEN,
VISION_MAX_LEN,
VISION_MEASURABLE_MAX_LEN,
VISION_YEAR_RE,
)
import re
router = APIRouter(
prefix="/api/cma/charter",
tags=["战略基座"],
dependencies=[Depends(require_auth)],
)
# 可写字段白名单(entity_id / 审计字段不接受客户端输入)
CHARTER_FIELDS = (
"mission",
"vision",
"vision_year",
"vision_measurable",
"values_text",
"value_items",
)
REQUIRED_FIELDS = CHARTER_FIELDS # 六项均为必填(vision_year / vision_measurable 派单点名不可空)
def _bad(msg: str) -> HTTPException:
return HTTPException(status_code=400, detail=msg)
def _to_dict(row: StrategyCharter) -> dict:
return {
"id": row.id,
"entity_id": row.entity_id,
"mission": row.mission,
"vision": row.vision,
"vision_year": row.vision_year,
"vision_measurable": row.vision_measurable,
"values_text": row.values_text,
"value_items": row.value_items or [],
"updated_by": row.updated_by,
"updated_by_name": row.updated_by_name,
"created_at": row.created_at.isoformat(sep=" ", timespec="seconds") if row.created_at else None,
"updated_at": row.updated_at.isoformat(sep=" ", timespec="seconds") if row.updated_at else None,
}
def _empty_state(entity_id: int) -> dict:
"""空态:库里没有章程行。
明确返回 filled=false —— 前端据此显示「尚未填写」并给录入入口,
**不用任何默认/示例文案填充**(派单红线:真实数据非 Demo)。
"""
return {"entity_id": entity_id, "filled": False, "data": None}
@router.get("")
def get_charter(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""读取当前账套的战略章程(无行 → filled=false)。"""
row = (
db.query(StrategyCharter)
.filter(StrategyCharter.entity_id == entity_id)
.first()
)
if not row:
return _empty_state(entity_id)
payload = _empty_state(entity_id)
payload["filled"] = True
payload["data"] = _to_dict(row)
return payload
def _clean_text(raw, field: str, max_len: int) -> str:
if raw is None:
raise _bad(f"缺少必填字段 {field}")
if not isinstance(raw, str):
raise _bad(f"字段 {field} 必须是字符串")
val = raw.strip()
if not val:
raise _bad(f"字段 {field} 不能为空")
if len(val) > max_len:
raise _bad(f"字段 {field} 超长(最多 {max_len} 字符,当前 {len(val)})")
return val
def _clean_value_items(raw):
"""价值观条目:[{name, meaning}],每条须可作决策冲突时的裁决依据。"""
if raw is None:
raise _bad("缺少必填字段 value_items(价值观条目数组)")
if not isinstance(raw, list):
raise _bad("value_items 必须是数组 [{name, meaning}]")
if not raw:
raise _bad("value_items 至少需要 1 条(价值观须可作裁决依据)")
if len(raw) > VALUE_ITEMS_MAX:
raise _bad(f"value_items 最多 {VALUE_ITEMS_MAX} 条")
items = []
for idx, item in enumerate(raw, start=1):
if not isinstance(item, dict):
raise _bad(f"value_items 第 {idx} 条必须是对象 {{name, meaning}}")
extra = sorted(k for k in item.keys() if k not in ("name", "meaning"))
if extra:
raise _bad(f"value_items 第 {idx} 条含非白名单键 {extra};只允许 name / meaning")
name = _clean_text(item.get("name"), f"value_items[{idx}].name", VALUE_ITEM_NAME_MAX_LEN)
meaning = _clean_text(item.get("meaning"), f"value_items[{idx}].meaning",
VALUE_ITEM_MEANING_MAX_LEN)
items.append({"name": name, "meaning": meaning})
return items
@router.put("")
def put_charter(
body: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
user: User = Depends(require_role("ceo", "finance")),
):
"""写入/更新当前账套的战略章程(单行 upsert,字段白名单 + 值域校验)。"""
if not isinstance(body, dict) or not body:
raise _bad("请求体不能为空")
# ① 越权账套参数:显式 entity_id 与 token 账套不一致 → 403(不静默覆盖)
raw_entity = body.get("entity_id")
if raw_entity is not None:
try:
claimed = int(raw_entity)
except (TypeError, ValueError):
raise _bad(f"entity_id 无效: {raw_entity!r}")
if claimed != entity_id:
raise HTTPException(
status_code=403,
detail=f"无权写入企业 entity_id={claimed}(当前账套: {entity_id})",
)
# ② 字段白名单:白名单外字段一律拒绝(含 entity_id / id / updated_by 等审计字段)
unknown = sorted(k for k in body.keys() if k not in CHARTER_FIELDS)
if unknown:
raise _bad(
f"非白名单字段 {unknown};允许字段 {list(CHARTER_FIELDS)}"
f"(entity_id 由登录账套决定,不可手写)"
)
# ③ 必填校验(vision_year / vision_measurable 不可空)
missing = [f for f in REQUIRED_FIELDS if f not in body or body.get(f) is None]
if missing:
raise _bad(f"缺少必填字段 {missing};vision_year 与 vision_measurable 不可空")
mission = _clean_text(body.get("mission"), "mission", MISSION_MAX_LEN)
vision = _clean_text(body.get("vision"), "vision", VISION_MAX_LEN)
values_text = _clean_text(body.get("values_text"), "values_text", VALUES_TEXT_MAX_LEN)
# ④ vision_year:4 位年份(接受 int 2030 或 "2030")
raw_year = body.get("vision_year")
if isinstance(raw_year, bool) or not isinstance(raw_year, (int, str)):
raise _bad("vision_year 必须是年份(如 2030)")
year = str(raw_year).strip()
if not re.match(VISION_YEAR_RE, year):
raise _bad(f"vision_year 须为 4 位年份(如 2030),收到 {raw_year!r}")
measurable = _clean_text(body.get("vision_measurable"), "vision_measurable",
VISION_MEASURABLE_MAX_LEN)
value_items = _clean_value_items(body.get("value_items"))
row = (
db.query(StrategyCharter)
.filter(StrategyCharter.entity_id == entity_id)
.first()
)
if row is None:
row = StrategyCharter(entity_id=entity_id)
db.add(row)
row.mission = mission
row.vision = vision
row.vision_year = year
row.vision_measurable = measurable
row.values_text = values_text
row.value_items = value_items
row.updated_by = getattr(user, "id", None)
row.updated_by_name = getattr(user, "name", None)
db.commit()
db.refresh(row)
payload = _empty_state(entity_id)
payload["filled"] = True
payload["data"] = _to_dict(row)
payload["message"] = "战略章程已保存"
return payload
+54 -42
View File
@@ -1,16 +1,17 @@
"""AI获客 - 客户咨询接口""" """AI获客 - 客户咨询接口"""
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel 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获客"]) router = APIRouter(prefix="/api/cma/lead", tags=["AI获客"])
# ai-cs 实盘分流脚本(跨 profile 调用) # 客户咨询通知唯一通道:系统总线中继 8660 → 任总 DM。
# 必须以 ai-cs 身份 + 其 HERMES_HOME/HOME 运行:脚本内用 `hermes send` 复用该 profile 的企微凭据; # 2026-10-22 起取代旧的 ai-cs routing-live.py(该 profile/user 已下线,runuser 必然失败且异常被吞,
# cma-backend.service 的 Environment 为空,不显式传入会解析到非 ai-cs 的 profile(root 默认 profile)。 # 导致线索静默丢失通知)。中继类别按契约取 fallback(无会话兜底),company 类被中继直接拒绝。
AI_CS_USER = "ai-cs" BUS_RELAY_URL = "http://127.0.0.1:8660/bus/send"
AI_CS_HOME = "/var/lib/wecom-ai-cs/profile" BUS_KEY_FILE = "/srv/hermes/shared/.secrets/bus_relay.key"
AI_CS_ROUTING_SCRIPT = AI_CS_HOME + "/scripts/routing-live.py" BOSS_USERID = "200653946" # 任富海
NOTIFY_LOG = "/root/leads/notify.log"
class LeadRequest(BaseModel): class LeadRequest(BaseModel):
name: str name: str
@@ -18,42 +19,54 @@ class LeadRequest(BaseModel):
requirement: str = "" requirement: str = ""
source: str = "sxbh.ltd" source: str = "sxbh.ltd"
def notify_wecom(name, phone, requirement, source): def _log_notify(ok, detail):
"""推送到企微全员群""" """通知结果落痕(旧实现 except: pass 静默吞异常,导致线索漏通知无人知晓)"""
msg = {
"msgtype": "markdown",
"markdown": {
"content": f"## 🔔 新客户咨询\n**姓名**: {name}\n**电话**: {phone}\n**需求**: {requirement}\n**来源**: {source}\n**时间**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}"
}
}
try: try:
data = json.dumps(msg).encode('utf-8') os.makedirs(os.path.dirname(NOTIFY_LOG), exist_ok=True)
req = urllib.request.Request( with open(NOTIFY_LOG, "a", encoding="utf-8") as f:
"http://127.0.0.1:8800/send", f.write("%s %s %s\n" % (datetime.datetime.now().isoformat(timespec="seconds"),
data=data, "OK" if ok else "FAIL", detail))
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,
)
except Exception: except Exception:
pass 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("") @router.post("")
def create_lead(data: LeadRequest): def create_lead(data: LeadRequest):
"""接收客户咨询并保存""" """接收客户咨询并保存"""
@@ -66,9 +79,8 @@ def create_lead(data: LeadRequest):
with open(f"{log_dir}/leads.json", "a") as f: with open(f"{log_dir}/leads.json", "a") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n") f.write(json.dumps(record, ensure_ascii=False) + "\n")
# 异步推送企微通知 # 客户咨询通知唯一通道:推任总 DM(经 ai-cs 分流);不再推 8800 全员群
notify_wecom(data.name, data.phone, data.requirement, data.source) # (2026-10-22 裁定:8800 仅限内容类群发,客户线索含个人信息,不得进全员群)
# 加性:额外推给任总 DM(口径与 ai-cs 客服分流一致)
notify_boss(data.name, data.phone, data.requirement) notify_boss(data.name, data.phone, data.requirement)
return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"} return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"}
+491 -28
View File
@@ -7,12 +7,13 @@ from datetime import date, datetime, timedelta
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role 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.kr_name import sanitize_kr_name
from app.utils.kpi_value_scope import actual_only_clause from app.utils.kpi_value_scope import actual_only_clause
import copy import copy
import json import json
import logging import logging
import re
logger = logging.getLogger(__name__) 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} 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: def _get_connections(m: StrategicMap) -> list:
if not m.canvas_data: if not m.canvas_data:
@@ -522,9 +681,228 @@ def _get_connections(m: StrategicMap) -> list:
return m.canvas_data["connections"] 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") @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)): 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() m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m: if not m:
raise HTTPException(404, "战略地图不存在") 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: if from_id == to_id:
raise HTTPException(400, "不能自身连线") raise HTTPException(400, "不能自身连线")
# 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连)
from_dim = from_id.rsplit("-", 1)[0]
to_dim = to_id.rsplit("-", 1)[0]
conns = _get_connections(m) 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", ""), "label": data.get("label", ""),
}) })
m.canvas_data["connections"] = conns 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() db.commit()
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409 # 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") @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)): 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() m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m: if not m:
raise HTTPException(404, "战略地图不存在") raise HTTPException(404, "战略地图不存在")
@@ -580,9 +961,14 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_
raise HTTPException(404, "连线不存在") raise HTTPException(404, "连线不存在")
m.canvas_data["connections"] = new_conns 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() db.commit()
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}, 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): def _sync_map_objectives(m, db):
"""保存时:将dimensions JSON中的目标同步到map_objectives表""" """保存时:将dimensions JSON中的目标同步到map_objectives表(就地 upsert)
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
dims = m.dimensions 2026-10-22 cma-map-canvas-conn-key-rename-stable(P3):
if isinstance(dims, str): 原实现是 delete+insert —— MySQL 的 AUTO_INCREMENT 计数器不随 DELETE 归零,每次保存
dims = json.loads(dims) 都会给同一节点换一批新行 id,`map_connections.from/to_objective_id` 随即变成野指针
dims = dims or [] (读侧再按 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 dim in dims:
for i, obj in enumerate(dim.get("objectives", [])): dk = dim.get("key", "")
mo = MapObjective( for i, obj in enumerate(dim.get("objectives") or []):
map_id=m.id, if isinstance(obj, dict):
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11) planned.append((dk, i, obj))
dimension_key=dim.get("key", ""),
name=obj.get("name", ""), assigned = {}
description=obj.get("description", ""),
icon=obj.get("icon", "target"), # ① 显式锚点:客户端回带的 `_mo_id`(读侧 _merge_map_objectives 写入)优先级最高
kpis=obj.get("kpis", []), for idx, (dk, i, obj) in enumerate(planned):
sort_order=i, try:
) mo_id = int(obj.get("_mo_id")) if obj.get("_mo_id") is not None else None
db.add(mo) 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() db.commit()
return {"planned": len(planned), "created": created, "deleted": deleted,
"kept": len(used)}
def _merge_map_objectives(m, db): def _merge_map_objectives(m, db):
@@ -698,6 +1156,9 @@ def _merge_map_objectives(m, db):
"icon": o.icon or existing.get("icon", "target"), "icon": o.icon or existing.get("icon", "target"),
"kpis": o.kpis or existing.get("kpis", []), "kpis": o.kpis or existing.get("kpis", []),
**{k: existing[k] for k in existing if k not in ("name", "description", "icon", "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 dim["objectives"] = merged
m.dimensions = dims 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_ok = 0
total_warn = 0 total_warn = 0
total_err = 0 total_err = 0
total_hold = 0 # C1 数据待核计数(此前漏初始化 → 命中 hold_reason 即 UnboundLocalError → review 500)
total_obj_count = 0 total_obj_count = 0
focus_items = [] focus_items = []
@@ -1164,6 +1626,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
"green": total_ok, "green": total_ok,
"yellow": total_warn, "yellow": total_warn,
"red": total_err, "red": total_err,
"hold": total_hold,
"health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0, "health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0,
}, },
"focus_items": focus_items, "focus_items": focus_items,
+7 -3
View File
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv from dotenv import load_dotenv
from app.database import init_db, get_db from app.database import init_db, get_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions, ar_aging, charter
from app.utils.cache import clear_all as clear_cache, delete as delete_cache from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth, require_auth_401 from app.auth_middleware import require_auth, require_auth_401
@@ -40,6 +40,10 @@ app.include_router(auth.router)
app.include_router(kpis.router) app.include_router(kpis.router)
app.include_router(kpi_governance.router) app.include_router(kpi_governance.router)
app.include_router(templates.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(maps.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(data.router) app.include_router(data.router)
@@ -56,8 +60,6 @@ app.include_router(action_plans.router)
app.include_router(alignment.router) app.include_router(alignment.router)
app.include_router(org.router) app.include_router(org.router)
app.include_router(objectives.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(versions.router)
app.include_router(budget.router) app.include_router(budget.router)
app.include_router(value_sources.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(verify.router)
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15) app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15) app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
app.include_router(ar_aging.router) # 应收账龄看板(T2 2026-10-20:逐户·六档·三口径禁合计)
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24) app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
app.include_router(charter.router) # 战略基座:战略章程(使命/愿景/价值观,P0 2026-10-21)
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception): async def global_exception_handler(request: Request, exc: Exception):
+34
View File
@@ -9,6 +9,7 @@ from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
from app.models.product_sales import ProductSales from app.models.product_sales import ProductSales
from app.models.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger from app.models.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger
from app.models.report_opening import ReportOpeningBalance from app.models.report_opening import ReportOpeningBalance
from app.models.strategy_charter import StrategyCharter
from app.models.treasury import ( from app.models.treasury import (
TreasuryAccount, TreasuryBalance, TreasuryControlRule, TreasuryAccount, TreasuryBalance, TreasuryControlRule,
TreasuryPayment, TreasuryControlHit, TreasuryLimit, TreasuryPayment, TreasuryControlHit, TreasuryLimit,
@@ -166,6 +167,8 @@ class KPIAlert(Base):
alert_level = Column(String(20), default="yellow", comment="green/yellow/red") alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
alert_message = Column(String(500), nullable=False) alert_message = Column(String(500), nullable=False)
alert_type = Column(String(30), default="actual", comment="actual/forecast — 实际值超限/预测值超限") 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="情景建议") suggestion = Column(Text, nullable=True, comment="情景建议")
action_plan_linked_id = Column(Integer, nullable=True, comment="关联的改善计划ID") action_plan_linked_id = Column(Integer, nullable=True, comment="关联的改善计划ID")
status = Column(String(20), default="pending", comment="pending/processing/resolved") 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()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=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): class KPICausality(Base):
"""KPI因果链 — 记录KPI间的因果关系""" """KPI因果链 — 记录KPI间的因果关系"""
__tablename__ = "kpi_causality" __tablename__ = "kpi_causality"
+64
View File
@@ -0,0 +1,64 @@
"""战略基座 — 战略章程(使命 / 愿景 / 价值观)
任务:`project-20261021-cma-strategy-charter-p0-fullstack`(P0 战略基座)
依据:《2026-10-21 CMA 知识库 M0 与系统实现差距报告》§四 P0 —— 知识卡
`CMA-P1-B-009 愿景、使命与价值观`:使命=为何存在;愿景=要成为什么
(**带年份与可验证状态**);价值观=不可交易的底线,是决策冲突时的裁决依据。
单一事实源:每个账套(entity_id)**一条**章程行,唯一键 uq_strategy_charter_entity。
本模块只做「表结构 + 契约常量」;读写端点见 app/api/charter.py。
字段语义(派单表:语义不可减,类型可调整):
mission 使命 TEXT NOT NULL
vision 愿景 TEXT NOT NULL
vision_year 愿景达成年份 VARCHAR(20) NOT NULL(**不可空**;4 位年份)
vision_measurable 可验证状态 TEXT NOT NULL(**不可空**;用可核对的规模/业务口径描述)
values_text 价值观总述 TEXT NOT NULL
value_items 价值观条目 JSON NOT NULL `[{name, meaning}]`
—— 每条须能作"决策冲突时的裁决依据"
隔离:`entity_id NOT NULL` + 唯一键以 entity_id 打头(多租户隔离 + 索引可用性)。
禁止示例数据:本模块与迁移脚本**零 INSERT**;GET 无行返回 `filled=false` 空态。
"""
from sqlalchemy import Column, Integer, String, Text, JSON, DateTime, UniqueConstraint, func
from app.database import Base
# 愿景年度格式:4 位年份(不臆造业务上限;允许字符串以兼容前端输入)
VISION_YEAR_RE = r"^\d{4}$"
# 长文本上限(与列宽一致,避免 MySQL 截断静默丢字)
MISSION_MAX_LEN = 1000
VISION_MAX_LEN = 1000
VISION_MEASURABLE_MAX_LEN = 1000
VALUES_TEXT_MAX_LEN = 2000
VALUE_ITEM_NAME_MAX_LEN = 50
VALUE_ITEM_MEANING_MAX_LEN = 300
VALUE_ITEMS_MAX = 20
class StrategyCharter(Base):
"""战略章程 — 使命/愿景(年度+可验证状态)/价值观(每账套一行)"""
__tablename__ = "strategy_charter"
id = Column(Integer, primary_key=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(账套,多租户隔离;唯一键已建索引)")
mission = Column(Text, nullable=False, comment="使命:我们为何存在(做什么、为谁做)")
vision = Column(Text, nullable=False, comment="愿景:要成为什么")
vision_year = Column(String(20), nullable=False, comment="愿景达成年份 YYYY(不可空)")
vision_measurable = Column(
Text, nullable=False,
comment="可验证状态:用可核对的规模/业务口径描述(不可空)",
)
values_text = Column(Text, nullable=False, comment="价值观总述(不可交易的底线)")
value_items = Column(
JSON, nullable=False,
comment="价值观条目数组 [{name, meaning}],每条可作决策冲突时的裁决依据",
)
updated_by = Column(Integer, nullable=True, comment="最后修改人 user.id")
updated_by_name = Column(String(50), nullable=True, comment="最后修改人姓名(快照)")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("entity_id", name="uq_strategy_charter_entity"),
)
+918
View File
@@ -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}
+71
View File
@@ -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
+113
View File
@@ -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())
+524
View File
@@ -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())
+153
View File
@@ -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}"
+162
View File
@@ -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()
+166
View File
@@ -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
+10 -4
View File
@@ -56,10 +56,16 @@ assert_pnpm_version() {
fi fi
actual="$(pnpm -v 2>/dev/null | tail -1 | tr -d '[:space:]' || true)" actual="$(pnpm -v 2>/dev/null | tail -1 | tr -d '[:space:]' || true)"
if [ "$actual" != "$expect" ]; then if [ "$actual" != "$expect" ]; then
echo "❌ pnpm 版本断言失败:package.json pin=$expect,实读 pnpm -v=${actual:-<空>}" echo "⚠️ pnpm 版本断言不匹配:package.json pin=$expect,实读 pnpm -v=${actual:-<空>}"
echo " 多为 corepack lastKnownGood 漂移(/root/.cache/node/corepack/lastKnownGood.json)" echo " 尝试自愈:corepack prepare pnpm@$expect --activate(integrity 依 packageManager 的 sha512 校验)"
echo " 修法:HOME=/root corepack prepare pnpm@$expect --activate | 中止(生产未被触碰)" corepack prepare "pnpm@$expect" --activate >/dev/null 2>&1 || true
exit 1 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 fi
echo " ✓ pnpm 版本断言通过:pnpm $actual == package.json pin $expect" echo " ✓ pnpm 版本断言通过:pnpm $actual == package.json pin $expect"
} }
+1
View File
@@ -15,6 +15,7 @@ declare module 'vue' {
BotSourceTag: typeof import('./src/components/BotSourceTag.vue')['default'] BotSourceTag: typeof import('./src/components/BotSourceTag.vue')['default']
BudgetCollectPanel: typeof import('./src/components/BudgetCollectPanel.vue')['default'] BudgetCollectPanel: typeof import('./src/components/BudgetCollectPanel.vue')['default']
BulletChart: typeof import('./src/components/charts/BulletChart.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'] CashClassifyPanel: typeof import('./src/components/CashClassifyPanel.vue')['default']
ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.vue')['default'] ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.vue')['default']
ConnectionManagerDialog: typeof import('./src/components/map-canvas/ConnectionManagerDialog.vue')['default'] ConnectionManagerDialog: typeof import('./src/components/map-canvas/ConnectionManagerDialog.vue')['default']
+29 -1
View File
@@ -546,4 +546,32 @@ export const treasuryApi = {
listHits: (params?: any) => api.get('/treasury/control-hits', { params }), 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>
<el-button size="small" text @click="$emit('toggle-tree')"> <el-button size="small" text @click="$emit('toggle-tree')" :title="treeOpen ? '收起左侧目标树(画布全宽)' : '展开左侧目标树'">
{{ treeOpen ? '收起目标树' : '展开目标树' }} {{ treeOpen ? '收起目标树' : '展开目标树' }}
</el-button> </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>
</div> </div>
</template> </template>
@@ -105,6 +128,8 @@ defineProps<{
layerConfigs: { key: string; label: string; short: string; color: string }[] layerConfigs: { key: string; label: string; short: string; color: string }[]
canUndo: boolean canUndo: boolean
treeOpen: boolean treeOpen: boolean
/** C 方案聚焦层:null = 四层全景 */
focusLayer?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -117,6 +142,7 @@ const emit = defineEmits<{
(e: 'fit'): void (e: 'fit'): void
(e: 'toggle-tree'): void (e: 'toggle-tree'): void
(e: 'completeness'): void (e: 'completeness'): void
(e: 'focus-layer', key: string | null): void
}>() }>()
const filterOptions: { value: 'all' | 'risk' | 'nodata'; label: string }[] = [ const filterOptions: { value: 'all' | 'risk' | 'nodata'; label: string }[] = [
@@ -1,89 +1,150 @@
<template> <template>
<section class="map-summary" aria-label="战略地图摘要"> <section class="map-summary" aria-label="战略地图摘要">
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 --> <!-- C 方案(2026-10-23):汇总条压成单行(≤44px),5 项指标同排;明细收进「明细」展开位 -->
<div class="sum-card sum-card--wide"> <div class="ms-row">
<div class="sum-head"> <!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
<span class="sum-label">营业收入</span> <div class="ms-metric ms-metric--rev">
<span v-if="revenue && revenue.caliber_label" class="sum-badge calm">{{ revenue.caliber_label }}</span> <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>
<div v-if="revenueLines.length" class="rev-lines">
<div v-for="ln in revenueLines" :key="ln.key" class="rev-line"> <span class="ms-sep" aria-hidden="true"></span>
<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> <div class="ms-metric">
<template v-else>—<i class="sum-unit">无数据</i></template> <span class="ms-label">毛利率</span>
</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>
<div class="rev-line rev-line--total"> <div v-if="revenueLines.length" class="rev-lines">
<span class="rev-line-label">{{ revenueTotalLabel }}</span> <div v-for="ln in revenueLines" :key="ln.key" class="rev-line">
<span class="rev-line-value mono"> <span class="rev-line-label">{{ ln.label }}</span>
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="sum-unit">万元</i></template> <span class="rev-line-value mono" :class="{ muted: ln.amount == null }">
<template v-else>—<i class="sum-unit">无数据</i></template> <template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="ms-unit">万元</i></template>
</span> <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> </div>
<div v-else class="sum-value muted">—<i class="sum-unit">无数据</i></div>
<div class="sum-sub">{{ revenuePeriod }}</div>
</div>
<!-- ② 毛利率:新式口径(折扣后净进价),历史缺票期带标识 --> <div class="ms-card">
<div class="sum-card"> <div class="ms-detail-head"><span class="ms-label">口径变更 / 管理指标</span></div>
<div class="sum-head"> <div class="ms-foot">
<span class="sum-label">毛利率</span> <span v-if="switchInfo && switchInfo.has_switch" class="ms-badge warn" :title="switchInfo.note || ''">
<span v-if="grossMargin && grossMargin.data_tag" class="sum-badge warn">{{ grossMargin.data_tag }}</span> {{ 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>
<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> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <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 } 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 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 switchInfo?: { has_switch?: boolean; label?: string; effective_date?: string | null; comparability?: string; note?: string } | null
loading?: boolean 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 revenueLines = computed<RevLine[]>(() => (props.revenue?.lines || []).filter(l => !!l && !!l.key))
/** 合计 = 各线相加的复算校验位,不与分线并列成第二套口径 */ /** 合计 = 各线相加的复算校验位,不与分线并列成第二套口径 */
const revenueTotal = computed<number | null>(() => { const revenueTotal = computed<number | null>(() => {
@@ -163,92 +230,125 @@ function fmtNum(v: number | null | undefined): string {
</script> </script>
<style scoped> <style scoped>
/* C 方案:单行汇总条(行高 40px + 上下 1px 边框 ≤44px) */
.map-summary { .map-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
flex-shrink: 0; flex-shrink: 0;
margin-bottom: 10px; margin-bottom: 8px;
}
.sum-card {
background: var(--bh-bg-card, #fff); background: var(--bh-bg-card, #fff);
border: 1px solid var(--bh-border, #E5E7EB); border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px; border-radius: 8px;
padding: 10px 14px; padding: 0 10px;
}
.ms-row {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
min-width: 0; min-width: 0;
} }
.sum-card--wide { grid-column: span 2; } .ms-metric {
.sum-head { display: flex; align-items: center; gap: 8px; min-width: 0; } display: flex;
.sum-label { 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; font-size: 12px;
color: var(--bh-text-secondary, #64748B); color: var(--bh-text-secondary, #64748B);
letter-spacing: .02em; 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; font-size: 10px;
line-height: 1.5; line-height: 1.5;
padding: 1px 6px; padding: 0 5px;
border-radius: 4px; border-radius: 4px;
border: 1px solid transparent;
white-space: nowrap; white-space: nowrap;
overflow: hidden; flex-shrink: 0;
text-overflow: ellipsis;
} }
.sum-badge.calm { color: #635BFF; background: rgba(99, 91, 255, .08); } .ms-badge.calm { color: #0369A1; background: #F0F9FF; border-color: #BAE6FD; }
.sum-badge.warn { color: #B45309; background: #FEF3C7; } .ms-badge.warn { color: #B45309; background: #FFFBEB; border-color: #FDE68A; }
.sum-badge.warn em { font-style: normal; } .ms-badge.warn em { font-style: normal; }
.sum-value { .ms-detail-btn {
margin-top: 4px; margin-left: auto;
font-size: 22px; flex-shrink: 0;
font-weight: 700; display: inline-flex;
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;
align-items: center; align-items: center;
gap: 6px 16px; gap: 3px;
padding: 6px 12px; height: 24px;
border: 1px dashed var(--bh-border, #E5E7EB); padding: 0 8px;
border-radius: 6px;
font-size: 11px; 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-label { color: var(--bh-text-secondary, #64748B); }
.mgmt-value { font-size: 13px; font-weight: 700; color: var(--bh-text-primary, #0F172A); } .mgmt-value { color: var(--bh-text-primary, #0F172A); font-weight: 600; }
.mgmt-tag { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: #F1F5F9; color: #475569; } .mgmt-tag { font-size: 10px; padding: 0 4px; border-radius: 4px; background: #F1F5F9; color: #475569; }
.mgmt-abnormal { color: #B91C1C; } .mgmt-abnormal { font-size: 10px; color: #B91C1C; }
.mgmt-caliber { color: var(--bh-text-muted, #94A3B8); }
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; } .mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
.lv-green { color: #00C896; } .lv-green { color: #00C896; }
.lv-yellow { color: #F59E0B; } .lv-yellow { color: #F59E0B; }
.lv-red { color: #EF4444; } .lv-red { color: #EF4444; }
@media (max-width: 1200px) {
.map-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } /* 窄屏:单行改为可横向滚动,保留全部 5 项、不换行不截断 */
.sum-card--wide { grid-column: span 2; } @media (max-width: 1280px) {
.ms-row { overflow-x: auto; }
} }
</style> </style>
@@ -1,6 +1,17 @@
<template> <template>
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }"> <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 <button
type="button" type="button"
class="layer-collapse-btn" class="layer-collapse-btn"
@@ -14,6 +25,12 @@
</button> </button>
<span class="layer-icon">{{ icon }}</span> <span class="layer-icon">{{ icon }}</span>
<span class="layer-name">{{ label }}</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> <span class="layer-objective-count">{{ objectives.length }} 个目标</span>
<el-dropdown trigger="click" @command="onLayerAction"> <el-dropdown trigger="click" @command="onLayerAction">
<el-button class="layer-more-btn" size="small" text> <el-button class="layer-more-btn" size="small" text>
@@ -42,7 +59,7 @@
<div class="layer-node-wrapper"> <div class="layer-node-wrapper">
<MapNode <MapNode
:key="`${layerKey}-${index}`" :key="`${layerKey}-${index}`"
:ref="el => setNodeRef(`${layerKey}-${index}`, el)" :ref="el => setNodeRef(`${layerKey}-${index}`, el, element)"
:objective="element" :objective="element"
:node-key="`${layerKey}-${index}`" :node-key="`${layerKey}-${index}`"
:index="index" :index="index"
@@ -90,7 +107,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive, computed } from 'vue'
import { MoreFilled } from '@element-plus/icons-vue' import { MoreFilled } from '@element-plus/icons-vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import MapNode from './MapNode.vue' import MapNode from './MapNode.vue'
@@ -115,6 +132,12 @@ const props = defineProps<{
dimmedKeys?: string[] dimmedKeys?: string[]
/** 层折叠(仅隐藏层体,保留层头) */ /** 层折叠(仅隐藏层体,保留层头) */
collapsed?: boolean collapsed?: boolean
/** C 方案聚焦模式:其它层压成单行(层头 + 目标数 + 达成率色条) */
compact?: boolean
/** 该层达成率均值(无实测值传 null,界面显式「—」,不用示例数兜底) */
layerRatio?: number | null
/** 本层是否处于聚焦展开态 */
focused?: boolean
getNodeLevel: (key: string) => string getNodeLevel: (key: string) => string
getNodeProgress: (key: string) => any 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: '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: '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: '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) const bodyRef = ref<HTMLElement | null>(null)
// Node DOM refs for line calculation (reactive so parent can aggregate) // Node DOM refs for line calculation (reactive so parent can aggregate)
const nodeRefs = reactive<Record<string, any>>({}) 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 // Expose nodeRefs to parent for connection line calculation
@@ -218,6 +287,54 @@ export default {
letter-spacing: 0.3px; 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 { .layer-icon {
font-size: 18px; font-size: 18px;
line-height: 1; line-height: 1;
@@ -136,12 +136,18 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import { getChainForConnection, getChainForNode } from '../../utils/chainGraph' import { getChainForConnection, getChainForNode } from '../../utils/chainGraph'
import { resolveEndpoints, relRectOf } from '../../utils/connKeys'
const props = defineProps({ const props = defineProps({
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */ /** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
layerRefs: { type: Object, required: true }, layerRefs: { type: Object, required: true },
/** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */ /** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */
nodeRefs: { type: Object, default: () => ({}) }, 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'] }, layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1', effect, label}] */ /** 同层连线数据 [{from: 'financial-0', to: 'financial-1', effect, label}] */
@@ -328,26 +334,22 @@ function calcLayerConnections() {
// 节点坐标缓存(相对 svg):同一节点被多条连线引用时只 getBoundingClientRect 一次 // 节点坐标缓存(相对 svg):同一节点被多条连线引用时只 getBoundingClientRect 一次
const rectCache = {} const rectCache = {}
const origin = { left: svgRect.left, top: svgRect.top }
const getRect = (key) => { const getRect = (key) => {
if (rectCache[key]) return rectCache[key] if (!key) return null
const el = props.nodeRefs[key] if (key in rectCache) return rectCache[key]
if (!el) return null // relRectOf:缺 ref / 已卸载 / 0×0(隐藏或未布局)一律 null —— 宁可不画,不画到 (0,0)
const r = el.getBoundingClientRect() rectCache[key] = relRectOf(props.nodeRefs, key, origin) || null
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,
}
return rectCache[key] return rectCache[key]
} }
for (const conn of props.connections) { let skipped = 0
const fr = getRect(conn.from) props.connections.forEach((conn, idx) => {
const tr = getRect(conn.to) // P3:优先服务端解析键(objective_id 反查,改名不断线),缺失回落原标识串
if (!fr || !tr) continue 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 const x1 = fr.right
@@ -373,7 +375,11 @@ function calcLayerConnections() {
effect: conn.effect || 'positive', effect: conn.effect || 'positive',
label: conn.label || '', label: conn.label || '',
}) })
} })
// 悬停/拖拽期节点可能尚未注册(预取未回、改名后 DOM 重建)⇒ 有跳过就做有界重算,
// 而不是留一条静默消失的线(P2 暴露的「零绘制」同类问题)。
if (skipped > 0 && depth < 6) scheduleRetry(depth + 1)
layerConnections.value = result layerConnections.value = result
} }
+6 -4
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ── // ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = { export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'], ceo: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'], finance: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], business: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'], it: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
} }
export const ROLE_ACTIONS: Record<string, string[]> = { export const ROLE_ACTIONS: Record<string, string[]> = {
@@ -26,6 +26,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ── GROUP 1: 规划与目标(Plan)── // ── GROUP 1: 规划与目标(Plan)──
{ path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], group: '🔵 P 规划与目标' }, { path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], group: '🔵 P 规划与目标' },
{ path: '/strategy-charter', label: '战略章程', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/strategy-map', label: '战略地图看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/strategy-map', label: '战略地图看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' }, { path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
@@ -39,6 +40,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ path: '/receivables', label: '应收款催收', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🟢 D 执行与控制' }, { path: '/receivables', label: '应收款催收', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🟢 D 执行与控制' },
{ path: '/treasury-dashboard', label: '资金总览', icon: 'Money', roles: ['ceo','finance'], 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: '/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: '/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: '/product-matrix', label: '波士顿产品矩阵', icon: 'PieChart', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' }, { path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
+3
View File
@@ -19,6 +19,8 @@ const routes = [
{ path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'], zone: 'B', editable: true } }, { path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'], zone: 'B', editable: true } },
// /strategy-map: A区(只读活看板 —— 读库渲染,页内零写入口;2026-10-11 finance-20261011-strategy-map-frontend-fullstack) // /strategy-map: A区(只读活看板 —— 读库渲染,页内零写入口;2026-10-11 finance-20261011-strategy-map-frontend-fullstack)
{ path: 'strategy-map', name: 'StrategyMap', component: () => import('@/views/StrategyMap.vue'), meta: { title: '战略地图看板', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } }, { path: 'strategy-map', name: 'StrategyMap', component: () => import('@/views/StrategyMap.vue'), meta: { title: '战略地图看板', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
// /strategy-charter: P 组·战略基座入口(使命/愿景/价值观;页内含 MyDialog 写入口 ⇒ B区)
{ path: 'strategy-charter', name: 'StrategyCharter', component: () => import('@/views/StrategyCharter.vue'), meta: { title: '战略章程', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
{ path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } }, { path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } },
{ path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } }, { path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
{ path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } }, { path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
@@ -60,6 +62,7 @@ const routes = [
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'], zone: 'B' } }, { 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-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: '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: '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' } }, { path: 'product-matrix', name: 'ProductMatrix', component: () => import('@/views/ProductMatrix.vue'), meta: { title: '波士顿产品矩阵', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
// /tax-compliance: B区(页内职责=税务记录/发票/社保人工录入维护;见盘点表) // /tax-compliance: B区(页内职责=税务记录/发票/社保人工录入维护;见盘点表)
+101
View File
@@ -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 }
})
}
+478
View File
@@ -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">逾期金额(&gt;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">
红线 &gt;{{ 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">逾期户数(&gt;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="逾期额(&gt;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/>逾期额(&gt;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>
+374 -15
View File
@@ -34,6 +34,8 @@
:achievement-count="achievementCount" :achievement-count="achievementCount"
:summary="reviewSummary" :summary="reviewSummary"
:loading="loading" :loading="loading"
:objectives-total="totalObjectiveCount"
:connections-total="connections.length"
/> />
<MapEditBar <MapEditBar
@@ -43,6 +45,7 @@
:layer-configs="layerUiConfigs" :layer-configs="layerUiConfigs"
:can-undo="undoStack.length > 0" :can-undo="undoStack.length > 0"
:tree-open="treeOpen" :tree-open="treeOpen"
:focus-layer="focusLayer"
@update:search="v => searchText = v" @update:search="v => searchText = v"
@update:filter="v => filterMode = v" @update:filter="v => filterMode = v"
@toggle-layer="toggleLayer" @toggle-layer="toggleLayer"
@@ -52,6 +55,7 @@
@fit="zoomFit" @fit="zoomFit"
@toggle-tree="treeOpen = !treeOpen" @toggle-tree="treeOpen = !treeOpen"
@completeness="showCompleteness = true" @completeness="showCompleteness = true"
@focus-layer="toggleFocusLayer"
/> />
<!-- 口径标识(常驻,非 hover 临时提示):A 口径 v1.1 来自会计科目、非发票同票列明 --> <!-- 口径标识(常驻,非 hover 临时提示):A 口径 v1.1 来自会计科目、非发票同票列明 -->
@@ -80,7 +84,7 @@
<el-button size="small" @click="cancelLink">取消</el-button> <el-button size="small" @click="cancelLink">取消</el-button>
</div> </div>
<!-- 仪表盘主视图:左目标树 / 中画布 / 右详情 --> <!-- 仪表盘主视图:左目标树(默认收起,留 46px 图标条)/ 中画布 / 详情浮层 -->
<div class="map-body-row"> <div class="map-body-row">
<MapTargetTree <MapTargetTree
v-show="treeOpen" v-show="treeOpen"
@@ -98,6 +102,22 @@
@drag-end="onTreeDragEnd" @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"> <div class="map-canvas-col">
<!-- 画布主体(田字格2×2布局,transform: scale 缩放) --> <!-- 画布主体(田字格2×2布局,transform: scale 缩放) -->
<div <div
@@ -108,7 +128,7 @@
@drop.prevent="onTreeDrop" @drop.prevent="onTreeDrop"
> >
<div class="canvas-zoom-wrap" :style="{ width: zoomWrapWidth, height: zoomWrapHeight }"> <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 <div
v-for="(cfg, idx) in orderedLayerConfigs" v-for="(cfg, idx) in orderedLayerConfigs"
@@ -116,6 +136,10 @@
v-show="visibleLayers[cfg.key]" v-show="visibleLayers[cfg.key]"
:ref="el => setLayerRef(cfg.key, el)" :ref="el => setLayerRef(cfg.key, el)"
class="layer-swimlane-wrapper" class="layer-swimlane-wrapper"
:class="{
'is-focused': focusLayer === cfg.key,
'is-compact': !!focusLayer && focusLayer !== cfg.key,
}"
:style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }" :style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }"
> >
<StrategyLayer <StrategyLayer
@@ -126,6 +150,10 @@
:color="layerColor(cfg.key)" :color="layerColor(cfg.key)"
:objectives="getLayerNodes(cfg.key)" :objectives="getLayerNodes(cfg.key)"
:collapsed="!!collapsedLayers[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" :dim-active="filterActive"
:dimmed-keys="matchedKeyList" :dimmed-keys="matchedKeyList"
@toggle-collapse="toggleLayerCollapse(cfg.key)" @toggle-collapse="toggleLayerCollapse(cfg.key)"
@@ -174,10 +202,40 @@
<span class="lg-item"><i class="lg-dot lv-gray"></i>无数据</span> <span class="lg-item"><i class="lg-dot lv-gray"></i>无数据</span>
<span class="lg-note">达成率按指标方向归一,取目标关联 KPI 均值</span> <span class="lg-note">达成率按指标方向归一,取目标关联 KPI 均值</span>
</div> </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 <MapNodeDetail
v-if="selectedNode"
:obj="selectedNode.obj" :obj="selectedNode.obj"
:dim-key="selectedNode.dimKey" :dim-key="selectedNode.dimKey"
:index="selectedNode.index" :index="selectedNode.index"
@@ -196,13 +254,7 @@
@select-key="selectByKey" @select-key="selectByKey"
@go-kpi-library="goKpiLibrary" @go-kpi-library="goKpiLibrary"
/> />
<aside v-else class="map-detail-empty" aria-label="目标详情占位"> </div>
<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 -->
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) --> <!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
<ConnectionLines <ConnectionLines
@@ -210,7 +262,8 @@
:layer-refs="layerRefs" :layer-refs="layerRefs"
:node-refs="compiledNodeRefs" :node-refs="compiledNodeRefs"
:layer-keys="layerKeysOrdered" :layer-keys="layerKeysOrdered"
:connections="connections" :conn-keys="connKeys"
:connections="renderedConnections"
:temp-line="tempLine" :temp-line="tempLine"
:container-key="recalcTrigger" :container-key="recalcTrigger"
:view-direction="viewDirection" :view-direction="viewDirection"
@@ -319,6 +372,8 @@ import MapTargetTree from "../components/strategy-map/MapTargetTree.vue"
import MapNodeDetail from "../components/strategy-map/MapNodeDetail.vue" import MapNodeDetail from "../components/strategy-map/MapNodeDetail.vue"
import MapCompleteness from "../components/strategy-map/MapCompleteness.vue" import MapCompleteness from "../components/strategy-map/MapCompleteness.vue"
import ConnectionLines from "../components/strategy/ConnectionLines.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 MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
import MapDimensionFilter from "../components/map-canvas/MapDimensionFilter.vue" import MapDimensionFilter from "../components/map-canvas/MapDimensionFilter.vue"
import CaliberBadge from "../components/CaliberBadge.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 visibleLayers = reactive<Record<string, boolean>>({ finance: true, customer: true, process: true, learning: true })
const collapsedLayers = reactive<Record<string, boolean>>({}) const collapsedLayers = reactive<Record<string, boolean>>({})
const collapsedGroups = 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 selectedNode = ref<{ dimKey: string; index: number; obj: any } | null>(null)
const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : 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[]>([]) const connections = ref<any[]>([])
/** P3:服务端解析键(与 connections 逐下标对齐);空 ⇒ 回落原标识串(历史/降级) */
const connKeys = ref<ConnectionKeyEntry[]>([])
const linkingFrom = ref<{ key: string; obj: any } | null>(null) const linkingFrom = ref<{ key: string; obj: any } | null>(null)
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null) const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
const dragConnectTarget = ref<string | 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 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') const filterActive = computed(() => searchText.value.trim() !== '' || filterMode.value !== 'all')
@@ -797,6 +988,22 @@ watch(zoomLevel, () => {
nextTick(() => connectionLinesRef.value?.recalcAll()) 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() { function loadCanvas() {
caliberFacts.value = caliberGapFor(currentCaliberEntityId()) caliberFacts.value = caliberGapFor(currentCaliberEntityId())
@@ -842,6 +1049,7 @@ function loadCanvas() {
loadedDims = deepClone(dimensions) loadedDims = deepClone(dimensions)
connections.value = map?.canvas_data?.connections || [] connections.value = map?.canvas_data?.connections || []
loading.value = false loading.value = false
refreshConnKeys()
nextTick(() => { nextTick(() => {
triggerRecalc() triggerRecalc()
updateGridMode() updateGridMode()
@@ -982,6 +1190,7 @@ function undoLast() {
connections.value = JSON.parse(JSON.stringify(snap.connections)) connections.value = JSON.parse(JSON.stringify(snap.connections))
selectedNode.value = null selectedNode.value = null
markDirty() markDirty()
refreshConnKeys() // P3:撤销可能改动目标顺序/名字,解析键随之刷新
nextTick(() => { triggerRecalc(); updateZoomWrapHeight(); loadObjectiveLevels() }) nextTick(() => { triggerRecalc(); updateZoomWrapHeight(); loadObjectiveLevels() })
ElMessage.success('已撤销上一步') ElMessage.success('已撤销上一步')
} }
@@ -994,10 +1203,21 @@ function onTreeDragStart(payload: { dimKey: string; index: number; obj: any }) {
} }
function onTreeDragOver(e: DragEvent) { function onTreeDragOver(e: DragEvent) {
if (!treeDragSource.value) return if (!treeDragSource.value) return
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-node-key]') as HTMLElement | null // 命中判定改用节点 rect 包含判断:elementFromPoint 会被覆盖层(窄屏时的知识面板 .kp-panel、
const key = el?.getAttribute('data-node-key') || null // 连线 SVG 等)挡住 ⇒ 拖拽在窄屏下永远取不到目标节点。rect 判定与层级无关。
const key = hitTestNodeKey(e.clientX, e.clientY)
dragConnectTarget.value = key && key !== treeDragSource.value.key ? key : null 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) { function onTreeDragLeave(e: DragEvent) {
const related = (e as any).relatedTarget as Node | null const related = (e as any).relatedTarget as Node | null
const box = scrollWrapRef.value const box = scrollWrapRef.value
@@ -1705,6 +1925,9 @@ async function saveCanvas(silent?: boolean) {
if (res?.updated_at) baseUpdatedAt.value = res.updated_at // 采纳服务端新基准 if (res?.updated_at) baseUpdatedAt.value = res.updated_at // 采纳服务端新基准
refreshBaselines(res?.dimensions) // 基线回到服务端现值(响应体为权威) refreshBaselines(res?.dimensions) // 基线回到服务端现值(响应体为权威)
markClean() markClean()
// P3:保存后刷新解析键(改名/调序都会让 `${层}-${序号}` 变,线要跟着节点走)
if (payload.canvas_data) loadedCanvas = deepClone(connections.value)
refreshConnKeys()
if (!silent) ElMessage.success("已保存") if (!silent) ElMessage.success("已保存")
} catch (e: any) { } catch (e: any) {
if (e?.response?.status === 409) { if (e?.response?.status === 409) {
@@ -1731,6 +1954,8 @@ async function saveConnections() {
base_updated_at: currentBaseStamp(), base_updated_at: currentBaseStamp(),
}) })
if (res?.updated_at) baseUpdatedAt.value = res.updated_at if (res?.updated_at) baseUpdatedAt.value = res.updated_at
loadedCanvas = deepClone(connections.value)
refreshConnKeys() // P3:连线新增/删除后服务端重算解析键
} catch (e: any) { } catch (e: any) {
if (e?.response?.status === 409) { if (e?.response?.status === 409) {
autoSaveBlocked.value = true autoSaveBlocked.value = true
@@ -2137,6 +2362,140 @@ onUnmounted(() => {
margin-bottom: 4px; 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 { .cross-layer-arrow {
display: flex; display: flex;
+288
View File
@@ -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>
+34
View File
@@ -25,6 +25,23 @@
</div> </div>
</div> </div>
<!-- ── 战略章程(P 战略基座:地图最顶层输入,只读;空态不显示,禁示例文案)── -->
<div v-if="charter" class="charter-strip">
<div class="cs-item cs-mission">
<span class="cs-k">使命</span>
<span class="cs-v">{{ charter.mission }}</span>
</div>
<div class="cs-item">
<span class="cs-k">愿景 · {{ charter.vision_year }}</span>
<span class="cs-v">{{ charter.vision }}</span>
</div>
<div class="cs-item cs-measurable">
<span class="cs-k">可验证状态</span>
<span class="cs-v">{{ charter.vision_measurable }}</span>
</div>
<router-link class="cs-link" to="/strategy-charter">查看章程</router-link>
</div>
<!-- ── 口径/统计条 ── --> <!-- ── 口径/统计条 ── -->
<div class="meta-bar"> <div class="meta-bar">
<!-- 口径标识位(常驻,非 hover):与看板/KPI 列表共用同一组件与文案常量 --> <!-- 口径标识位(常驻,非 hover):与看板/KPI 列表共用同一组件与文案常量 -->
@@ -167,6 +184,7 @@ import {
Money, User, Setting, Reading, TrendCharts, Money, User, Setting, Reading, TrendCharts,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { mapApi } from '../api/index' import { mapApi } from '../api/index'
import { charterApi } from '../api/index'
import CaliberBadge from '../components/CaliberBadge.vue' import CaliberBadge from '../components/CaliberBadge.vue'
import { caliberGapFor, currentCaliberEntityId, isCaliberKpiCode, type CaliberGapFacts } from '../constants/caliber' import { caliberGapFor, currentCaliberEntityId, isCaliberKpiCode, type CaliberGapFacts } from '../constants/caliber'
@@ -175,6 +193,8 @@ const boardRef = ref<HTMLElement | null>(null)
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
const board = ref<any>(null) const board = ref<any>(null)
// 战略章程(P 基座最顶层输入,只读;无行 → null,不显示任何文案)
const charter = ref<any>(null)
const period = ref<string>('') const period = ref<string>('')
const periods = ref<string[]>([]) const periods = ref<string[]>([])
const boardSize = ref({ w: 0, h: 0 }) const boardSize = ref({ w: 0, h: 0 })
@@ -317,6 +337,10 @@ function onResize() {
} }
onMounted(async () => { onMounted(async () => {
// 章程与地图并行取;章程失败不阻断地图(只读装饰项)
charterApi.get()
.then((res: any) => { if (res?.filled) charter.value = res.data })
.catch(() => { charter.value = null })
await load() await load()
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
}) })
@@ -367,6 +391,16 @@ function doPrint() {
<style scoped> <style scoped>
.strategy-map-page { padding: 16px; background: #f5f7fa; min-height: 100%; } .strategy-map-page { padding: 16px; background: #f5f7fa; min-height: 100%; }
/* 战略章程条(P 基座最顶层输入,只读) */
.charter-strip { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; padding: 10px 14px; margin-bottom: 10px; background: var(--bh-bg-card, #fff); border-left: 3px solid var(--bh-primary, #409eff); border-radius: var(--bh-radius-sm, 4px); box-shadow: var(--bh-shadow-sm, 0 1px 2px rgba(0,0,0,.06)); }
.cs-item { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.cs-k { flex: 0 0 auto; font-size: 12px; color: var(--bh-text-secondary, #909399); }
.cs-v { font-size: 13px; color: var(--bh-text-primary, #303133); max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-mission .cs-v { font-weight: 600; }
.cs-measurable .cs-v { color: var(--bh-text-regular, #606266); }
.cs-link { margin-left: auto; font-size: 12px; color: var(--bh-primary, #409eff); text-decoration: none; }
.cs-link:hover { text-decoration: underline; }
.page-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; } .page-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
.page-subtitle { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; color: #64748B; margin: 0; } .page-subtitle { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; color: #64748B; margin: 0; }
.title-icon { color: #64748B; font-size: 16px; } .title-icon { color: #64748B; font-size: 16px; }