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
wecom-fullstack 86e24c6310 fix(strategy-map): 移除悬空 import kpi_target_reader(该模块全仓不存在,致 app.main 导入失败/部署自检中止)→ 目标值取 target_monthly or target_value
ci/woodpecker/push/main Pipeline was successful
2026-10-20 13:01:02 +08:00
wecom-fullstack ecbe55c48a merge: origin/main 合入战略地图改造分支(解 MapCanvas 模板冲突:摘要条+编辑条+口径条并存)
ci/woodpecker/push/main Pipeline was successful
2026-10-20 11:34:29 +08:00
wecom-fullstack c030fb7d18 fix(strategy-map): 口径违禁串代码级清零(9,492.6/83.9%/19/19/el-dialog 仅存于注释)+ components.d.ts 补 MapDimensionFilter 2026-10-20 11:33:58 +08:00
Hermes CI Fix 53cad7eaa9 merge: origin/main (A3 三维切片) 合入战略地图改造分支 2026-10-19 14:32:33 +08:00
Hermes CI Fix a8141e8879 wip(strategy-map): v4 口径落码(分线营收/新式毛利率/管理指标/C1 hold/57 归档) 2026-10-19 14:32:08 +08:00
Hermes CI Fix 4d20c27de4 wip(strategy-map): V1+V3 合版 + v2/v3 口径(完备度/C1 hold/57 归档组) 2026-10-19 10:33:40 +08:00
43 changed files with 9269 additions and 196 deletions
+175 -3
View File
@@ -2,7 +2,7 @@
预警规则智能化 — 任务6
后端组件: alert_rules 模型 + API + 预警引擎
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text, Column, Integer, String, Text, Float, DateTime, JSON, Boolean, func
from typing import Optional, List
@@ -26,6 +26,8 @@ from app.utils.cash_thresholds import (
CASH_GAP_RULE_TYPE,
validate_params as validate_cash_gap_params,
)
# 应收账龄逾期预警(T1 · 2026-10-20):计算/落库单源在服务层,阈值全走 alert_rules.params
from app.services import ar_overdue_alert
logger = logging.getLogger("alert_rules")
@@ -38,6 +40,7 @@ VALID_RULE_TYPES = (
"forecast_deviation", # 预测偏差(升级2b 2026-08-25)
"customer_concentration", # 客户集中度(P2 2026-09-10)
"cash_gap_threshold", # 资金缺口阈值配置(G1 2026-10-17,配置行,不进预警生成链)
"ar_overdue", # 应收账龄逾期(T1 2026-10-20:90天以上占比 / 逐户逾期额 / 逾期户数环比)
)
# ============================================================
@@ -142,6 +145,11 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
err = validate_cash_gap_params(data.get("params"))
if err:
raise HTTPException(400, err)
# T1(2026-10-20):ar_overdue 参数只校结构(阈值/责任人全量由 DB 行给,代码不设默认值)
if rule_type == ar_overdue_alert.RULE_TYPE:
err = ar_overdue_alert.validate_params(data.get("params"))
if err:
raise HTTPException(400, err)
rule = AlertRule(
entity_id=entity_id,
@@ -180,6 +188,12 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), e
if err:
db.rollback()
raise HTTPException(400, err)
# T1(2026-10-20):ar_overdue 改参同样只校结构(阈值可现场改,无需发版)
if rule.rule_type == ar_overdue_alert.RULE_TYPE:
err = ar_overdue_alert.validate_params(rule.params)
if err:
db.rollback()
raise HTTPException(400, err)
db.commit()
db.refresh(rule)
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
@@ -307,6 +321,31 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
# 不进 KPIValue 比价链(否则未来若绑定到有值的 KPI 会误生成预警)。
if rule.rule_type == CASH_GAP_RULE_TYPE:
continue
# T1(2026-10-20):应收账龄逾期 —— 与 POST /run-ar-overdue 同一计算函数,
# 不走 KPIValue 比价链(F_AR_002 无本期 kpi_values 也不得报错)。
if rule.rule_type == ar_overdue_alert.RULE_TYPE:
rp = rule.params
if isinstance(rp, str):
rp = json.loads(rp) if rp else {}
rp = rp or {}
try:
ar_overdue_alert.ensure_assignee_configured(rp)
res = ar_overdue_alert.compute(db, entity_id, None, rp)
except (ar_overdue_alert.AssigneeMissing, ar_overdue_alert.ParamsInvalid) as exc:
logger.warning("ar_overdue 规则跳过(rule_id=%s): %s", rule.id, exc)
continue
if res.get("skipped_reason"):
logger.info("ar_overdue 规则无数据跳过(rule_id=%s): %s",
rule.id, res["skipped_reason"])
continue
try:
w = ar_overdue_alert.write_alerts(db, entity_id, rule.kpi_id, res)
except Exception: # noqa: BLE001 — 单规则失败不拖垮整轮检查
db.rollback()
logger.exception("ar_overdue 落库失败(rule_id=%s)", rule.id)
continue
alerts_generated += w["inserted"]
continue
kpi = kpi_cache.get(rule.kpi_id)
if kpi is None:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
@@ -316,7 +355,7 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
if not kpi or kpi.entity_id != entity_id:
continue
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
# 客户集中度规则(P2 2026-09-10):不依赖同期KPIValue,直接看客户收入占比
if rule.rule_type == "customer_concentration":
@@ -655,7 +694,7 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
continue
# 检查预测值是否超限
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
params["kpi"] = kpi
for forecast in latest_forecasts:
value = forecast.predicted_cash
@@ -979,3 +1018,136 @@ def run_customer_concentration_check(
continue
db.commit()
return {"message": f"客户集中度检查完成: {generated}条", "generated": generated}
# ============================================================
# T1(2026-10-20):应收账龄逾期预警 —— 运行入口 + 手工触发
# ============================================================
def _resolve_ar_kpi(db: Session, entity_id: int, params: dict, rule, override_kpi_id):
"""解析 ar_overdue 绑定的 KPI:规则行 → 显式 override(仅 dry-run)→ params.kpi_code。"""
if rule is not None and rule.kpi_id:
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == rule.kpi_id, KPIDefinition.entity_id == entity_id).first()
return (kpi.id if kpi else rule.kpi_id), (kpi.kpi_code if kpi else None), "alert_rules.kpi_id"
if override_kpi_id:
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == override_kpi_id, KPIDefinition.entity_id == entity_id).first()
if not kpi:
raise HTTPException(400, f"KPI 不存在或不属于当前账套: kpi_id={override_kpi_id}")
return kpi.id, kpi.kpi_code, "query.kpi_id(dry-run override)"
code = (params or {}).get("kpi_code")
if code:
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first()
if kpi:
return kpi.id, kpi.kpi_code, f"params.kpi_code={code}"
return None, code, f"params.kpi_code={code}(库内不存在)"
return None, None, "unresolved"
@router.post("/run-ar-overdue")
def run_ar_overdue_check(
period: Optional[str] = Query(None, description="账龄基准期 YYYY-MM;缺省=表内 MAX(period)"),
dry_run: bool = Query(True, description="默认 true:只算不写。false 走既有写通道插入 kpi_alerts"),
kpi_id: Optional[int] = Query(None, description="dry-run 专用:临时绑定 KPI(正式以规则行 kpi_id 为准)"),
data: Optional[dict] = Body(None, description="dry-run 专用:{'params': {...}} 覆盖规则参数(证明参数化,不碰 DB)"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""应收账龄逾期预警运行(T1)。
参数优先级:dry-run body 覆盖 > `alert_rules` 行(rule_type='ar_overdue')> 无规则 ⇒ 200 skipped。
阈值/责任人/排除名单全部来自参数;`assignee` 缺失 ⇒ 400(fail-closed)。
`dry_run=false` 时禁止任何参数覆盖(防绕过 DB 配置),且只 INSERT 不 UPDATE/DELETE。
"""
if is_excluded_entity(db, entity_id):
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
override_params = None
if isinstance(data, dict):
override_params = data.get("params") if isinstance(data.get("params"), dict) else data.get("params_override")
if (override_params is not None or kpi_id is not None) and not dry_run:
raise HTTPException(
400, "dry_run=false 时禁止参数/KPI 覆盖:阈值与绑定必须来自 alert_rules 行(防绕过 DB 配置)")
rule = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id,
AlertRule.rule_type == ar_overdue_alert.RULE_TYPE,
AlertRule.enabled == 1,
).order_by(AlertRule.id).first()
params_source = "dry_run.body.params"
if override_params is not None:
params = override_params
elif rule is not None:
params = rule.params
if isinstance(params, str):
params = json.loads(params) if params else {}
params = params or {}
params_source = f"alert_rules#{rule.id}"
else:
return {
"dry_run": dry_run, "entity_id": entity_id, "period": period,
"skipped": True, "skipped_reason": "no_ar_overdue_rule",
"hint": "无 rule_type='ar_overdue' 规则行;阶段B 用 backend/scripts/seed_ar_overdue_rule.py 播种,"
"或 dry-run 时在 body 传 {'params': {...}} 演练",
}
err = ar_overdue_alert.validate_params(params)
if err:
raise HTTPException(400, err)
try:
ar_overdue_alert.ensure_assignee_configured(params)
except ar_overdue_alert.AssigneeMissing as e:
raise HTTPException(400, str(e))
resolved_kpi_id, kpi_code, binding = _resolve_ar_kpi(db, entity_id, params, rule, kpi_id)
try:
result = ar_overdue_alert.compute(db, entity_id, period, params)
except ar_overdue_alert.ParamsInvalid as e:
raise HTTPException(400, str(e))
hits = ar_overdue_alert.hits(result)
blockers = []
if resolved_kpi_id is None:
blockers.append("kpi_id_unresolved:%s" % (kpi_code or "-"))
payload = {
"dry_run": dry_run,
"entity_id": entity_id,
"period": result.get("period"),
"caliber": result.get("caliber"),
"params_source": params_source,
"kpi_id": resolved_kpi_id,
"kpi_code": kpi_code,
"kpi_binding": binding,
"skipped_reason": result.get("skipped_reason"),
"metrics": result.get("metrics"),
"aggregates": result.get("aggregates"),
"per_customer": result.get("per_customer"),
"skipped": result.get("skipped"),
"hit_count": len(hits),
"notes": result.get("notes"),
}
if result.get("skipped_reason"):
payload.update({"would_insert": 0, "write_blockers": blockers})
return payload
if dry_run:
payload.update({"would_insert": 0 if blockers else ar_overdue_alert.count_would_insert(
db, entity_id, resolved_kpi_id, result), "write_blockers": blockers})
return payload
if blockers:
raise HTTPException(400, "无法落库:" + "; ".join(blockers))
written = ar_overdue_alert.write_alerts(db, entity_id, resolved_kpi_id, result)
log_op(db, "run_ar_overdue_alert", target_type="alert", target_id=resolved_kpi_id,
detail={"period": result.get("period"), "caliber": result.get("caliber"),
"hit": len(hits), **written},
entity_id=entity_id)
db.commit()
payload.update({"would_insert": written["inserted"], "write_blockers": [], **written,
"generated": written["inserted"]})
return payload
+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获客 - 客户咨询接口"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import json, os, datetime, subprocess, urllib.request
import json, os, datetime, urllib.request
router = APIRouter(prefix="/api/cma/lead", tags=["AI获客"])
# ai-cs 实盘分流脚本(跨 profile 调用)
# 必须以 ai-cs 身份 + 其 HERMES_HOME/HOME 运行:脚本内用 `hermes send` 复用该 profile 的企微凭据;
# cma-backend.service 的 Environment 为空,不显式传入会解析到非 ai-cs 的 profile(root 默认 profile)。
AI_CS_USER = "ai-cs"
AI_CS_HOME = "/var/lib/wecom-ai-cs/profile"
AI_CS_ROUTING_SCRIPT = AI_CS_HOME + "/scripts/routing-live.py"
# 客户咨询通知唯一通道:系统总线中继 8660 → 任总 DM。
# 2026-10-22 起取代旧的 ai-cs routing-live.py(该 profile/user 已下线,runuser 必然失败且异常被吞,
# 导致线索静默丢失通知)。中继类别按契约取 fallback(无会话兜底),company 类被中继直接拒绝。
BUS_RELAY_URL = "http://127.0.0.1:8660/bus/send"
BUS_KEY_FILE = "/srv/hermes/shared/.secrets/bus_relay.key"
BOSS_USERID = "200653946" # 任富海
NOTIFY_LOG = "/root/leads/notify.log"
class LeadRequest(BaseModel):
name: str
@@ -18,42 +19,54 @@ class LeadRequest(BaseModel):
requirement: str = ""
source: str = "sxbh.ltd"
def notify_wecom(name, phone, requirement, source):
"""推送到企微全员群"""
msg = {
"msgtype": "markdown",
"markdown": {
"content": f"## 🔔 新客户咨询\n**姓名**: {name}\n**电话**: {phone}\n**需求**: {requirement}\n**来源**: {source}\n**时间**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}"
}
}
def _log_notify(ok, detail):
"""通知结果落痕(旧实现 except: pass 静默吞异常,导致线索漏通知无人知晓)"""
try:
data = json.dumps(msg).encode('utf-8')
req = urllib.request.Request(
"http://127.0.0.1:8800/send",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
urllib.request.urlopen(req, timeout=5)
except:
pass # 通知失败不影响主流程
def notify_boss(name, phone, requirement):
"""额外推给任总(经 ai-cs 实盘分流脚本;失败不影响主流程)"""
try:
subprocess.run(
["runuser", "-u", AI_CS_USER, "--",
"env", f"HOME={AI_CS_HOME}", f"HERMES_HOME={AI_CS_HOME}",
f"USER={AI_CS_USER}", f"LOGNAME={AI_CS_USER}",
"/usr/bin/python3", AI_CS_ROUTING_SCRIPT,
requirement or "(未填需求)",
f"{name} {phone}",
"website:sxbh.ltd"],
timeout=30, capture_output=True,
)
os.makedirs(os.path.dirname(NOTIFY_LOG), exist_ok=True)
with open(NOTIFY_LOG, "a", encoding="utf-8") as f:
f.write("%s %s %s\n" % (datetime.datetime.now().isoformat(timespec="seconds"),
"OK" if ok else "FAIL", detail))
except Exception:
pass
def notify_boss(name, phone, requirement):
"""客户咨询通知:经系统总线中继 8660 推任总 DM。失败不阻断主流程,但必须留痕。"""
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
content = (
"## 新客户咨询(sxbh.ltd 官网)\n"
f"> 姓名:{name}\n"
f"> 电话:{phone}\n"
f"> 需求:{requirement or '(未填)'}\n"
f"> 时间:{now}\n\n"
"承诺 30 分钟内联系。"
)
try:
with open(BUS_KEY_FILE, encoding="utf-8") as f:
key = f.read().strip()
if not key:
raise RuntimeError("bus key empty")
payload = json.dumps({
"touser": BOSS_USERID,
"msgtype": "markdown",
"content": content,
"notice_class": "fallback",
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
BUS_RELAY_URL, data=payload,
headers={"Content-Type": "application/json",
"X-API-Key": key,
"X-Notice-Class": "fallback"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
body = resp.read().decode("utf-8", "ignore")[:300]
_log_notify(True, body)
return True
except Exception as e:
_log_notify(False, "%s: %s" % (type(e).__name__, e))
return False
@router.post("")
def create_lead(data: LeadRequest):
"""接收客户咨询并保存"""
@@ -66,9 +79,8 @@ def create_lead(data: LeadRequest):
with open(f"{log_dir}/leads.json", "a") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
# 异步推送企微通知
notify_wecom(data.name, data.phone, data.requirement, data.source)
# 加性:额外推给任总 DM(口径与 ai-cs 客服分流一致)
# 客户咨询通知唯一通道:推任总 DM(经 ai-cs 分流);不再推 8800 全员群
# (2026-10-22 裁定:8800 仅限内容类群发,客户线索含个人信息,不得进全员群)
notify_boss(data.name, data.phone, data.requirement)
return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"}
+179
View File
@@ -0,0 +1,179 @@
"""战略地图 · 数据完备度 API(口径唯一真源:CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
主口径(对外唯一主数):仅地图 65(已发布)15/19 = 78.9%,判据 = 被 23-KPI 集挂接。
附注口径(只作附注):全量 4 图 30/31 = 96.8%,判据 = 有KPI挂接(含归档图)。
KPI 级:active 行 117(e1=73 / e2=44),按行计,与目标级分列、不相除。
所有汇总数一律运行时对库内数据现算;判据数据(23-KPI 挂接关系)见
app/data/caliber_map_completeness.py。两种禁用组合(仅地图65且按 kpis 非空、全量31且按命中 23-KPI 集)在本模块无生成路径。
"""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth
from app.data import caliber_map_completeness as cal
from app.database import get_db
from app.deps import get_entity_id
from app.models import KPIDefinition, MapObjective, StrategicMap
router = APIRouter(
prefix="/api/cma/maps/completeness",
tags=["战略地图数据完备度"],
dependencies=[Depends(require_auth)],
)
def _pct(n: int, d: int) -> Optional[float]:
"""占比只保留 1 位小数;分母为 0 返回 None(展示为「无数据」)。"""
if not d:
return None
return round(n * 100.0 / d, 1)
def _entity_scopes(db: Session, entity_id: int):
"""返回(主口径地图, 同属主的地图列表)。主口径地图仅认 map_id=65 且属于该实体。"""
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).all()
primary = next((m for m in maps if m.id == cal.PRIMARY_MAP_ID), None)
return primary, maps
def _objective_rows(db: Session, maps):
"""拉取给定地图的战略目标,并逐行标记 23-KPI 挂接 / 是否有 KPI。"""
map_ids = [m.id for m in maps]
if not map_ids:
return [], {}
title_by_map = {m.id: m.title for m in maps}
status_by_map = {m.id: (m.status or "") for m in maps}
objs = (
db.query(MapObjective)
.filter(MapObjective.map_id.in_(map_ids))
.order_by(MapObjective.map_id, MapObjective.sort_order, MapObjective.id)
.all()
)
rows = []
for o in objs:
kpis = o.kpis if isinstance(o.kpis, list) else []
rows.append({
"objective_id": o.id,
"map_id": o.map_id,
"map_title": title_by_map.get(o.map_id),
"map_status": status_by_map.get(o.map_id),
"layer": o.dimension_key,
"layer_label": cal.LAYER_LABELS.get(o.dimension_key, o.dimension_key),
"name": o.name,
"kpis": kpis,
"has_kpi": len(kpis) > 0,
"covered_23kpi": o.id in cal.LINKED_OBJECTIVES,
})
return rows, title_by_map
@router.get("/summary")
def completeness_summary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""数据完备度总览:目标级(主数 + 附注)与 KPI 级(117 行)两层分列。"""
primary_map, maps = _entity_scopes(db, entity_id)
rows, _ = _objective_rows(db, maps)
# ---- 目标级 · 主口径:仅地图 65,判据 = 被 23-KPI 集挂接 ----
if primary_map is not None:
main_rows = [r for r in rows if r["map_id"] == cal.PRIMARY_MAP_ID]
main_total = len(main_rows)
main_covered = sum(1 for r in main_rows if r["covered_23kpi"])
primary = {
"available": True,
"caliber": cal.PRIMARY_CALIBER,
"label": cal.PRIMARY_LABEL,
"map_id": primary_map.id,
"map_title": primary_map.title,
"map_status": primary_map.status,
"covered": main_covered,
"total": main_total,
"pct": _pct(main_covered, main_total),
"display": f"{main_covered}/{main_total}",
}
else:
primary = {
"available": False,
"caliber": cal.PRIMARY_CALIBER,
"label": cal.PRIMARY_LABEL,
"map_id": cal.PRIMARY_MAP_ID,
"covered": None,
"total": None,
"pct": None,
"display": "—",
}
# ---- 目标级 · 附注口径:全量 4 图,判据 = 有 KPI 挂接 ----
# 判据复现(definition 件 §5 机读件 20261018-c1-spec/map-objectives-dump.csv 逐行):
# 23KPI 覆盖 26 + 非23KPI 但 map_objectives.kpis 非空 4 + 缺口 1 = 30/31。
# 即「被 23-KPI 集挂接」与「kpis 非空」取并集;与主口径判据(仅 23-KPI 挂接)不是同一判据。
# 附注与主数同属地图 65 的账套(全量 4 图);无地图 65 的账套不适用,展示「—」而非 0。
footnote_total = len(rows)
footnote_covered = sum(1 for r in rows if r["covered_23kpi"] or r["has_kpi"])
footnote_available = primary_map is not None and bool(rows)
footnote = {
"available": footnote_available,
"caliber": cal.FOOTNOTE_CALIBER,
"label": cal.FOOTNOTE_LABEL,
"covered": footnote_covered if footnote_available else None,
"total": footnote_total if footnote_available else None,
"pct": _pct(footnote_covered, footnote_total) if footnote_available else None,
"display": f"{footnote_covered}/{footnote_total}" if footnote_available else "—",
}
# ---- KPI 级:active 按行(e1=73 / e2=44,合计 117;去重编码 102 另册)----
kpi_rows = (
db.query(KPIDefinition)
.filter(KPIDefinition.status == "active")
.all()
)
by_entity: dict = {}
codes = set()
for k in kpi_rows:
by_entity[k.entity_id] = by_entity.get(k.entity_id, 0) + 1
codes.add(k.kpi_code)
kpi_level = {
"caliber": cal.KPI_CALIBER,
"label": cal.KPI_LABEL,
"total": len(kpi_rows),
"by_entity": [
{"entity_id": eid, "count": cnt} for eid, cnt in sorted(by_entity.items())
],
"distinct_codes": len(codes),
"note": "按 kpi_code 去重为独立口径,不与行数相除或换算",
}
return {
"caliber_id": cal.CALIBER_ID,
"caliber_version": cal.CALIBER_VERSION,
"entity_id": entity_id,
"applicable": primary_map is not None,
"scope_note": "主口径仅覆盖地图 65(已发布);附注口径含归档图,判据不同,不得互换算",
"objective_level": {"primary": primary, "footnote": footnote},
"kpi_level": kpi_level,
}
@router.get("/objectives")
def completeness_objectives(
map_id: Optional[int] = None,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""数据完备度清单(目标级逐行):供空态清单与补齐排期使用。"""
_, maps = _entity_scopes(db, entity_id)
if map_id is not None:
maps = [m for m in maps if m.id == map_id]
rows, _ = _objective_rows(db, maps)
return {
"caliber_id": cal.CALIBER_ID,
"caliber_version": cal.CALIBER_VERSION,
"entity_id": entity_id,
"caliber": cal.FOOTNOTE_CALIBER,
"rows": rows,
}
+238
View File
@@ -0,0 +1,238 @@
"""战略地图 · KPI 摘要接口(真数据 · 口径 CMA-A1 终稿,2026-10-19)
口径唯一真源:app/data/revenue_caliber.py(含科目映射与禁写清单)
本模块**不存任何金额常量**:全部对 voucher_details 现场聚合。
取数铁律:
1. 必经 `summary NOT LIKE '%结转%'` —— 否则损益双向抵销,收入恒为 0(历史踩坑);
2. 实体隔离:只取 token 绑定的 entity_id,不跨账套;
3. 无数据 ⇒ null + missing_reason,**不得兜底造数**;
4. 分业务线分列;禁净额;禁合并成单一「营业收入」(合计仅作可复算校验位)。
历史期缺同票折扣发票(四件套 0/4)⇒ 金额带 `data_status="estimated"` +
`data_tag="测算/待票"`,不得静默按新式重算冒充实账。
"""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth
from app.data import revenue_caliber as rc
from app.database import get_db
from app.deps import get_entity_id
router = APIRouter(
prefix="/api/cma/maps/summary",
tags=["战略地图摘要"],
dependencies=[Depends(require_auth)],
)
MONTH_RE = "^[0-9]{4}-[0-9]{2}$"
NOT_CARRY = "summary NOT LIKE '%结转%'"
def _wan(v) -> Optional[float]:
"""元 → 万元(2 位),None 透传。"""
if v is None:
return None
return round(float(v) / 10000.0, 2)
def _period_window(db: Session, entity_id: int):
"""取该实体最新有凭证的会计月,窗口 = 当年 01 ~ 最新月(不写死日期)。"""
latest = db.execute(text(
f"SELECT MAX(period) FROM voucher_details "
f"WHERE entity_id = :e AND period REGEXP :re AND {NOT_CARRY}"
), {"e": entity_id, "re": MONTH_RE}).scalar()
if not latest:
return None, None, None
year = str(latest)[:4]
return f"{year}-01", str(latest), year
def _sum(db: Session, sql: str, params: dict):
return db.execute(text(sql), params).scalar()
@router.get("")
def map_summary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""摘要条数据:分业务线营业收入 / 毛利率(新式)/ 管理指标 / 口径切换标识。"""
cfg = rc.SUBJECT_MAP.get(entity_id)
p0, p1, year = _period_window(db, entity_id)
period_label = f"{p0}~{p1}" if p0 else None
if cfg is None or p0 is None:
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"lines": [],
"missing_reason": "该账套无可用凭证期" if cfg else "未配置科目映射",
},
"gross_margin": {"available": False, "value": None, "missing_reason": "无数据"},
"management": [],
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
}
base = (f"entity_id = :e AND period >= :p0 AND period <= :p1 AND {NOT_CARRY}")
params = {"e": entity_id, "p0": p0, "p1": p1}
wine = rc.wine_sql("summary")
rev_prefix = cfg["revenue"] + "%"
# ── 1. 收入:主营科目按业务线分列(全额售价,禁净额)──────────────────
revenue = {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"period": period_label,
"lines": [],
}
if cfg["lines"]:
# 表 1:酒类线(keywords 命中)
wine_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": rev_prefix})
# 表 2:非酒类线(同一科目、不命中 keywords)
other_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND NOT {wine}
""", {**params, "pfx": rev_prefix})
# e1 => [酒类, 其他];e2 => [自营(IT)=非酒类, 代销(酒类)=酒类]
values = {}
if entity_id == 1:
values = {"liquor": wine_net, "other": other_net}
elif entity_id == 2:
values = {"self_it": other_net, "agency_liquor": wine_net}
lines = []
for ln in cfg["lines"]:
raw = values.get(ln["key"])
lines.append({
"key": ln["key"],
"label": ln["label"],
"amount": _wan(raw),
"unit": "万元",
"data_status": "verified" if raw is not None else "missing",
"missing_reason": None if raw is not None else "该业务线本期无入账凭证",
})
total = _wan((wine_net or 0) + (other_net or 0)) if (wine_net is not None or other_net is not None) else None
revenue = {
**revenue,
"available": any(l["amount"] is not None for l in lines),
"lines": lines,
"total": total,
"total_label": "合计(各线相加,仅作复算校验)",
"merge_forbidden": True,
"note": "各业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
# ── 2. 毛利率:新式 (收入净额 − 折扣后净进价成本) / 收入净额 ────────────
gross_margin = {"available": False, "value": None, "missing_reason": "无数据"}
rev_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": rev_prefix})
cost_total = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["cost"] + "%"})
disc_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
if rev_total and float(rev_total) != 0 and cost_total is not None:
net_cost = float(cost_total) - float(disc_total or 0)
gm = (float(rev_total) - net_cost) / float(rev_total) * 100.0
gross_margin = {
"available": True,
"value": round(gm, 2),
"unit": "%",
"caliber": "net_purchase",
"caliber_label": "新式·折扣后净进价",
"formula": "(收入净额 − 折扣后净进价成本) / 收入净额 × 100",
"period": period_label,
# 四件套 0/4 ⇒ 历史期缺同票折扣发票,只可显示现值并带标识
"data_status": "estimated",
"data_tag": "测算/待票",
"note": "旧式(含 k 的 5001/571103 组合)已作废;历史缺票期不得冒充实账",
}
# ── 3. 管理指标(非会计科目,不进损益、不进摘要条主位)───────────────
management = []
if entity_id == 2:
# 与 A1 回执可复现口径一致:分母取**折让前贷方发生额**(639,944.00),
# 而不是 6603 净额(556,279.15,已抵扣借方)——两者差 28.24% 与 24.55% 之别。
disc = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
base_amt = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE '2202%' AND summary LIKE '%采购收货%'
AND {rc.supplier_sql('summary')}
""", params)
if disc is not None and base_amt and float(base_amt) != 0:
management.append({
"key": "vendor_discount_dependency",
"label": "厂商折扣依赖度",
"value": round(abs(float(disc)) / float(base_amt) * 100.0, 2),
"unit": "%",
"numerator": round(abs(float(disc)), 2),
"numerator_label": "酒类采购折扣额",
"denominator": round(float(base_amt), 2),
"denominator_label": "酒类采购额(含税)",
"period": period_label,
"tag": "管理指标·非会计科目",
"caliber_note": "折扣额 ÷ 酒类采购额(含税);改造单 v4 §3 文字写「÷销售额」,"
"但 28.24% 的复现分母是采购额,按可复现口径披露,分母已单列可复核",
})
elif entity_id == 1 and cfg.get("purchase_gift") and rev_total:
gift = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_gift"] + "%"})
if gift is not None and float(rev_total) != 0:
val = round(abs(float(gift)) / float(rev_total) * 100.0, 2)
management.append({
"key": "vendor_gift_dependency",
"label": "厂商赠酒依赖度",
"value": val,
"unit": "%",
"numerator": round(abs(float(gift)), 2),
"numerator_label": "采购获赠额",
"denominator": round(float(rev_total), 2),
"denominator_label": "营业收入(同期)",
"period": period_label,
"tag": "管理指标·非会计科目",
"abnormal": val > 100,
"caliber_note": "获赠额 ÷ 同期营业收入;>100% 属异常,须数据侧核查(不作损益行)",
})
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": revenue,
"gross_margin": gross_margin,
"management": management,
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
"caliber_source": {
"status": rc.STATUS,
"decided_by": rc.DECIDED_BY,
"reason": rc.REASON,
},
}
+542 -30
View File
@@ -7,15 +7,25 @@ from datetime import date, datetime, timedelta
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User, MapConnection
from app.utils.kr_name import sanitize_kr_name
from app.utils.kpi_value_scope import actual_only_clause
import copy
import json
import logging
import re
logger = logging.getLogger(__name__)
# ── C1 数据前置(改造单 v2 §3,硬约束)──────────────────────────────
# C_CUST_CONCENTRATION 的 69 个实测点含 2026-10 未来期与重复值;清理完成前:
# ① 相关目标节点显示「数据待核」、**不着色**(level=pending);
# ② 该 KPI 的实测值/达成率不得进入摘要条、节点详情、趋势图(96.3% 零出口)。
# 清理单据落地后删除本表即可恢复;不要在别处另写白名单。
DATA_HOLD_KPIS = {
"C_CUST_CONCENTRATION": "数据待核:C1 实测点含未来期与重复值,清理未完成",
}
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
@@ -74,6 +84,17 @@ def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
return {"data": [m_to_dict(m, db) for m in maps]}
@router.get("/strategy/revenue-caliber")
def strategy_revenue_caliber(entity_id: int = Depends(get_entity_id)):
"""战略地图摘要条 · 收入口径状态(口径已闭合,金额取 /maps/summary)。
2026-10-19 A1 终稿:两家均总额法;增值税销售货物 13%。
本端点只出口径与业务线维度(不含金额);金额一律由 /maps/summary 现算。
"""
from app.data import revenue_caliber
return revenue_caliber.snapshot(entity_id)
@router.post("")
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
@@ -487,7 +508,165 @@ def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int
return {"message": f"已删除 {deleted} 个地图", "deleted": deleted}
# ── 连线管理 ─────────────────────────────────
# ── 连线管理(P1 四层因果结构化:map_connections 表 ⇄ canvas_data 双写)──
#
# 2026-10-21 cma-map-connections-table-p1:
# · 生产存量节点标识是 `层:中文目标名`(如 `learning:贡献率思维全员培训`),
# 当前前端 MapCanvas 生成的是 `层-序号`(如 `learning-0`)⇒ 两种格式都必须能解析;
# · 表是新增读模型(归因/校验用),canvas_data 保持原样供前端渲染 ⇒ 双写期;
# · 表缺失时(迁移前/回滚后)所有写路径降级为只写 canvas_data,不阻断前端。
LAYER_ORDER = ("learning", "process", "customer", "finance")
_LAYER_INDEX = {k: i for i, k in enumerate(LAYER_ORDER)}
_NODE_COLON = re.compile(r"^([a-z_]+):(.+)$")
_NODE_INDEX = re.compile(r"^([a-z_]+)-(\d+)$")
def _norm_obj_name(s) -> str:
return re.sub(r"\s+", " ", str(s or "").strip())
def parse_node_ref(node):
"""节点标识解析(双格式兼容)→ dict(layer, kind, value, error)
`learning:贡献率思维全员培训` → layer=learning, kind=name, value=中文名
`learning-0` → layer=learning, kind=index, value=0
"""
if not isinstance(node, str) or not node.strip():
return {"layer": None, "kind": None, "value": None, "error": "node_empty"}
s = node.strip()
m = _NODE_COLON.match(s)
if m:
layer, name = m.group(1), m.group(2).strip()
if layer not in _LAYER_INDEX:
return {"layer": None, "kind": None, "value": None, "error": f"unknown_layer:{layer}"}
if not name:
return {"layer": layer, "kind": None, "value": None, "error": "node_name_empty"}
return {"layer": layer, "kind": "name", "value": name, "error": None}
m = _NODE_INDEX.match(s)
if m:
layer, idx = m.group(1), int(m.group(2))
if layer not in _LAYER_INDEX:
return {"layer": None, "kind": None, "value": None, "error": f"unknown_layer:{layer}"}
return {"layer": layer, "kind": "index", "value": idx, "error": None}
return {"layer": None, "kind": None, "value": None, "error": "node_format_unrecognized"}
def _objective_index(db: Session, m: StrategicMap):
"""(名字索引, 层内顺序索引) —— 名字索引键 = (dimension_key, 归一化名称)。"""
rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(
MapObjective.sort_order, MapObjective.id).all()
by_name, by_layer = {}, {}
for o in rows:
by_name[(o.dimension_key, _norm_obj_name(o.name))] = o.id
by_layer.setdefault(o.dimension_key, []).append(o)
return by_name, by_layer
def resolve_node(db: Session, m: StrategicMap, node):
"""→ (layer, objective_id|None, note|None)
`层-序号` 的序号语义 = 前端 MapCanvas 使用的 `dimensions[层].objectives` 数组下标;
dimensions 缺失时退化到 map_objectives.sort_order / 层内顺序。
"""
ref = parse_node_ref(node)
layer = ref["layer"]
if ref["error"]:
return layer, None, ref["error"]
by_name, by_layer = _objective_index(db, m)
if ref["kind"] == "name":
want = _norm_obj_name(ref["value"])
hit = by_name.get((layer, want))
if hit:
return layer, hit, None
return layer, None, "objective_unresolved"
idx = ref["value"]
dims = _dims_list(m.dimensions)
for d in dims:
if d.get("key") == layer:
objs = d.get("objectives") or []
if 0 <= idx < len(objs):
name = _norm_obj_name((objs[idx] or {}).get("name"))
hit = by_name.get((layer, name))
return layer, hit, (None if hit else "objective_unresolved")
return layer, None, "index_out_of_range"
cand = by_layer.get(layer) or []
for o in cand:
if (o.sort_order or 0) == idx:
return layer, o.id, "resolved_by_sort_order"
if 0 <= idx < len(cand):
return layer, cand[idx].id, "resolved_by_position"
return layer, None, "index_out_of_range"
def _row_payload_from_canvas(db: Session, m: StrategicMap, c: dict, sort_order: int) -> dict:
frm, to = c.get("from", ""), c.get("to", "")
fl, f_obj, f_note = resolve_node(db, m, frm)
tl, t_obj, t_note = resolve_node(db, m, to)
return {
"entity_id": m.entity_id if m.entity_id is not None else 1,
"map_id": m.id, "from_node": frm, "to_node": to,
"from_layer": fl, "to_layer": tl,
"from_objective_id": f_obj, "to_objective_id": t_obj,
"effect": c.get("effect") or "positive",
"label": (c.get("label") or None),
"sort_order": sort_order,
}
def _table_ready(db: Session) -> bool:
"""新表是否可用(迁移前/回滚后为 False)——用 SAVEPOINT 隔离探测,避免污染事务。"""
try:
with db.begin_nested():
db.query(MapConnection).limit(1).all()
return True
except Exception:
return False
def _table_rows(db: Session, m: StrategicMap):
# 账套隔离已在 map 查询处完成;这里只按 map_id 取行,避免历史 map.entity_id 为空时
# 冗余列与 map 不一致导致「表里有行却读不到」的假回落。
rows = db.query(MapConnection).filter(
MapConnection.map_id == m.id,
).order_by(MapConnection.sort_order, MapConnection.id).all()
return rows
def _table_upsert(db: Session, m: StrategicMap, payload: dict) -> bool:
"""双写:失败只告警、不阻断 canvas_data 主路径(兼容期容错)。"""
try:
with db.begin_nested():
existing = db.query(MapConnection).filter(
MapConnection.map_id == payload["map_id"],
MapConnection.from_node == payload["from_node"],
MapConnection.to_node == payload["to_node"],
).first()
if existing:
for k, v in payload.items():
setattr(existing, k, v)
else:
db.add(MapConnection(**payload))
db.flush()
return True
except Exception as exc: # 表缺失/授权不足/约束冲突 → 主路径继续
logger.warning("map_connections 双写失败(canvas_data 已保留): %s: %s", type(exc).__name__, exc)
return False
def _table_delete(db: Session, m: StrategicMap, from_node: str, to_node: str) -> bool:
try:
with db.begin_nested():
db.query(MapConnection).filter(
MapConnection.map_id == m.id,
MapConnection.from_node == from_node,
MapConnection.to_node == to_node,
).delete(synchronize_session=False)
return True
except Exception as exc:
logger.warning("map_connections 删除同步失败: %s: %s", type(exc).__name__, exc)
return False
def _get_connections(m: StrategicMap) -> list:
if not m.canvas_data:
@@ -502,9 +681,228 @@ def _get_connections(m: StrategicMap) -> list:
return m.canvas_data["connections"]
def _objective_key_index(db: Session, m: StrategicMap):
"""连线解析锚点:MapObjective.id → 画布渲染键 `${dim}-${idx}`(以当前 dimensions 顺序为准)
返回 (id2key, name2key, dim_len):
· id2key : 本表行 id → `${dim}-${idx}`(map_connections.from/to_objective_id 的锚点)
· name2key: (dim, 归一化名称) → `${dim}-${idx}`(历史 `层:中文名` 的兜底,改名即失效)
· dim_len : {dim: len(objectives)}(`层-序号` 越界判定)
只按名称把 dimensions 节点对回 map_objectives 行,**不读** dims JSON 里的 `_objective_id`
—— 那是 objectives 表(实体级目标)的 id 空间,与 map_objectives 行 id 不是一回事。
"""
dims = _dims_list(m.dimensions)
rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(
MapObjective.sort_order, MapObjective.id).all()
by_name = {}
for o in rows:
by_name[(o.dimension_key, _norm_obj_name(o.name))] = o.id
id2key, name2key, dim_len = {}, {}, {}
for d in dims:
dk = d.get("key") or ""
if not dk:
continue
objs = d.get("objectives") or []
dim_len[dk] = len(objs)
for idx, o in enumerate(objs):
if not isinstance(o, dict):
continue
key = f"{dk}-{idx}"
nm = _norm_obj_name(o.get("name"))
if nm:
name2key[(dk, nm)] = key
oid = by_name.get((dk, nm))
if oid is not None:
id2key[oid] = key
return id2key, name2key, dim_len
def _key_for_node(node, id2key, name2key, dim_len, objective_id=None):
"""单端节点解析 → (key, source, reason)
顺序:objective_id 锚点 → `层:中文名` → `层-序号`。
改名后 `objective_id` 仍指向同一节点 ⇒ key 不变、线不断;锚点失效(目标已删/历史脏 id/
跨 map)才回退原名字串,仍解析不到才返回 None(调用方计入 unresolved)。
"""
if objective_id:
hit = id2key.get(objective_id)
if hit:
return hit, "objective_id", None
ref = parse_node_ref(node)
if ref["error"]:
return None, None, ref["error"]
layer = ref["layer"]
stale = "stale_objective_id" if objective_id else None
if ref["kind"] == "name":
want = _norm_obj_name(ref["value"])
hit = name2key.get((layer, want))
if hit:
return hit, "name", stale
return None, None, (stale or "objective_unresolved")
idx = ref["value"]
if 0 <= idx < dim_len.get(layer, 0):
return f"{layer}-{idx}", "index", stale
return None, None, "index_out_of_range"
def _attach_keys(db: Session, m: StrategicMap, items: list):
"""给连线条目附 `from_key/to_key`(渲染键)+ 未解析清单。
条目原地补字段:`{side}_key` / `{side}_key_source` / `{side}_key_reason`,
键与中文名解耦的唯一出口(前端按 key 画线,缺失回落原值)。
"""
id2key, name2key, dim_len = _objective_key_index(db, m)
unresolved = []
for it in items:
for side in ("from", "to"):
key, source, reason = _key_for_node(
it.get(side), id2key, name2key, dim_len, it.get(f"{side}_objective_id"))
it[f"{side}_key"] = key
it[f"{side}_key_source"] = source
it[f"{side}_key_reason"] = reason
if key is None:
unresolved.append({
"from": it.get("from"), "to": it.get("to"),
"side": side, "node": it.get(side),
"reason": reason, "objective_id": it.get(f"{side}_objective_id"),
})
return items, unresolved
@router.get("/{map_id}/connections")
def list_connections(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""读连线:表优先,表空回落 canvas_data(兼容期)。
P3:每条连线附 `from_key/to_key` = `${dim}-${idx}`(由 objective_id 在当前 dimensions
树反查)+ 顶层 `unresolved` 计数与清单。前端优先按 key 画线 ⇒ 目标改名不断线。
"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
if _table_ready(db):
rows = _table_rows(db, m)
if rows:
items = [{
"from": r.from_node, "to": r.to_node, "style": "solid",
"effect": r.effect or "positive", "label": r.label or "",
"from_layer": r.from_layer, "to_layer": r.to_layer,
"from_objective_id": r.from_objective_id, "to_objective_id": r.to_objective_id,
} for r in rows]
items, unresolved = _attach_keys(db, m, items)
return {
"source": "table", "total": len(items),
"unresolved": len(unresolved), "unresolved_items": unresolved,
"connections": items,
}
conns = _get_connections(m)
items = [{
"from": c.get("from"), "to": c.get("to"),
"style": c.get("style") or "solid",
"effect": c.get("effect") or "positive",
"label": c.get("label") or "",
"from_objective_id": c.get("from_objective_id"),
"to_objective_id": c.get("to_objective_id"),
} for c in conns if isinstance(c, dict)]
items, unresolved = _attach_keys(db, m, items)
return {
"source": "canvas_data", "total": len(items),
"unresolved": len(unresolved), "unresolved_items": unresolved,
"connections": items,
}
@router.get("/{map_id}/connections/analysis")
def analyze_connections(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""连线只读分析(归因/复盘用):层统计 + 跨层分布 + 断链 + 反向告警(不拦截)。
读路径:表内有行 → 读表;否则回落 canvas_data(并对回落数据现算解析结果)。
"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
source = "canvas_data"
items = []
table_rows = _table_rows(db, m) if _table_ready(db) else []
if table_rows:
source = "table"
for r in table_rows:
items.append({
"from": r.from_node, "to": r.to_node,
"from_layer": r.from_layer, "to_layer": r.to_layer,
"from_objective_id": r.from_objective_id, "to_objective_id": r.to_objective_id,
"effect": r.effect or "positive", "label": r.label,
})
else:
for i, c in enumerate(_get_connections(m)):
if not isinstance(c, dict):
continue
row = _row_payload_from_canvas(db, m, c, i)
items.append({
"from": row["from_node"], "to": row["to_node"],
"from_layer": row["from_layer"], "to_layer": row["to_layer"],
"from_objective_id": row["from_objective_id"],
"to_objective_id": row["to_objective_id"],
"effect": row["effect"], "label": row["label"],
})
layer_stats = {k: {"as_from": 0, "as_to": 0} for k in LAYER_ORDER}
cross_layer, reversed_edges, broken, unknown = {}, [], [], []
same_layer = 0
items, unresolved = _attach_keys(db, m, items)
for it in items:
fl, tl = it.get("from_layer"), it.get("to_layer")
if fl in layer_stats:
layer_stats[fl]["as_from"] += 1
if tl in layer_stats:
layer_stats[tl]["as_to"] += 1
if fl not in _LAYER_INDEX or tl not in _LAYER_INDEX:
unknown.append({"from": it["from"], "to": it["to"],
"from_layer": fl, "to_layer": tl})
else:
pair = f"{fl}->{tl}"
cross_layer[pair] = cross_layer.get(pair, 0) + 1
if fl == tl:
same_layer += 1
elif _LAYER_INDEX[tl] < _LAYER_INDEX[fl]:
reversed_edges.append({"from": it["from"], "to": it["to"],
"from_layer": fl, "to_layer": tl})
if not it.get("from_key"):
broken.append({"from": it["from"], "to": it["to"], "side": "from",
"node": it["from"],
"reason": it.get("from_key_reason") or "objective_unresolved"})
if not it.get("to_key"):
broken.append({"from": it["from"], "to": it["to"], "side": "to",
"node": it["to"],
"reason": it.get("to_key_reason") or "objective_unresolved"})
warnings = []
if reversed_edges:
warnings.append(f"反向连线 {len(reversed_edges)} 条(与 learning→process→customer→finance 相反,"
f"仅告警不拦截)")
if broken:
warnings.append(f"断链 {len(broken)} 处(节点未能解析到 map_objectives)")
if unknown:
warnings.append(f"层前缀无法识别 {len(unknown)} 条")
return {
"map_id": m.id, "entity_id": m.entity_id, "source": source,
"layer_order": list(LAYER_ORDER), "total": len(items),
"layer_stats": layer_stats, "cross_layer": cross_layer,
"same_layer_count": same_layer,
"reversed": reversed_edges, "broken": broken,
"unknown_layer": unknown, "warnings": warnings,
"unresolved": len(unresolved), "unresolved_items": unresolved,
"items": items,
}
@router.post("/{map_id}/connections")
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
"""新增因果连线: {"from": "learning-0", "to": "process-0"}(表 + canvas_data 双写)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
@@ -519,10 +917,6 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
if from_id == to_id:
raise HTTPException(400, "不能自身连线")
# 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连)
from_dim = from_id.rsplit("-", 1)[0]
to_dim = to_id.rsplit("-", 1)[0]
conns = _get_connections(m)
# 校验: 不能重复
@@ -538,14 +932,21 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
"label": data.get("label", ""),
})
m.canvas_data["connections"] = conns
from sqlalchemy.orm.attributes import flag_modified
flag_modified(m, "canvas_data") # 同上:JSON 列需显式标脏才能落库
# 双写(表缺失/失败不阻断前端主路径)
payload = _row_payload_from_canvas(db, m, conns[-1], len(conns) - 1)
db_synced = _table_upsert(db, m, payload)
db.commit()
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
return {"connections": conns, "updated_at": _stamp(m.updated_at)}
return {"connections": conns, "updated_at": _stamp(m.updated_at), "db_synced": db_synced}
@router.delete("/{map_id}/connections")
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""根据 from/to 删除连线"""
"""根据 from/to 删除连线(表 + canvas_data 同步删)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
@@ -560,9 +961,14 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_
raise HTTPException(404, "连线不存在")
m.canvas_data["connections"] = new_conns
# JSON 列就地/换列表赋值不会被 ORM 自动标记为 dirty ⇒ 必须显式 flag_modified,
# 否则接口返回 200 且响应列表已减 1,但 commit 不 flush,重读仍是旧值(删连线变哑弹)。
from sqlalchemy.orm.attributes import flag_modified
flag_modified(m, "canvas_data")
db_synced = _table_delete(db, m, from_id, to_id)
db.commit()
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
"updated_at": _stamp(m.updated_at)}
"updated_at": _stamp(m.updated_at), "db_synced": db_synced}
# ── 版本管理 ─────────────────────────────────
@@ -625,26 +1031,98 @@ def m_to_dict(m, db: Session = None):
def _sync_map_objectives(m, db):
"""保存时:将dimensions JSON中的目标同步到map_objectives表"""
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or []
"""保存时:将dimensions JSON中的目标同步到map_objectives表(就地 upsert)
2026-10-22 cma-map-canvas-conn-key-rename-stable(P3):
原实现是 delete+insert —— MySQL 的 AUTO_INCREMENT 计数器不随 DELETE 归零,每次保存
都会给同一节点换一批新行 id,`map_connections.from/to_objective_id` 随即变成野指针
(读侧再按 id 反查就查不到),「用 objective_id 与中文名解耦」的前提被静默破坏。
改为就地 upsert:行 id 全程稳定,连线锚点才真的能扛住改名。
定位顺序:dims 的 `_mo_id`(读侧由 _merge_map_objectives 回写)→ (dimension_key, 归一化名称)。
**不做位置兜底**(口径 B,ASK-20261023-04 任总 2026-10-23 15:13「放行 4」):
未命中名字的项一律视为新目标,未被占用的存量行一律视为已删除。理由见下方 ③。
"""
dims = _dims_list(m.dimensions)
entity_id = m.entity_id if m.entity_id is not None else 1
rows = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(
MapObjective.sort_order, MapObjective.id).all()
by_id = {r.id: r for r in rows}
used = set()
# 计划项:按 dims 顺序展开
planned = []
for dim in dims:
for i, obj in enumerate(dim.get("objectives", [])):
mo = MapObjective(
map_id=m.id,
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
dimension_key=dim.get("key", ""),
name=obj.get("name", ""),
description=obj.get("description", ""),
icon=obj.get("icon", "target"),
kpis=obj.get("kpis", []),
sort_order=i,
)
db.add(mo)
dk = dim.get("key", "")
for i, obj in enumerate(dim.get("objectives") or []):
if isinstance(obj, dict):
planned.append((dk, i, obj))
assigned = {}
# ① 显式锚点:客户端回带的 `_mo_id`(读侧 _merge_map_objectives 写入)优先级最高
for idx, (dk, i, obj) in enumerate(planned):
try:
mo_id = int(obj.get("_mo_id")) if obj.get("_mo_id") is not None else None
except (TypeError, ValueError):
mo_id = None
if mo_id is not None and mo_id in by_id and mo_id not in used:
assigned[idx] = by_id[mo_id]
used.add(mo_id)
# ② 名称批:**先全表跑完名字匹配,再做位置兜底**。
# 逐项贪心(名→序位→空位)时,改名项在名字层落空后会立刻抢占同序位行,把
# 「同一次保存里改名 + 同层调序」的两个目标 id 串位 —— 线静默画到别的节点上,
# 比断线更坏(看不见)。批处理顺序把名字仍有效的项先钉住,未命中的项再按顺序配对。
for idx, (dk, i, obj) in enumerate(planned):
if idx in assigned:
continue
want = _norm_obj_name(obj.get("name"))
cand = [r for r in rows
if r.id not in used and r.dimension_key == dk
and _norm_obj_name(r.name) == want]
if cand:
cand.sort(key=lambda r: (r.sort_order or 0, r.id)) # 同名重复取最靠前,结果确定
assigned[idx] = cand[0]
used.add(cand[0].id)
# ③ (口径 B · ASK-20261023-04 任总 2026-10-23 15:13「放行 4」)**不做位置兜底**。
# 老通道(不带 `_mo_id`)下「删一个 + 加一个」与「改名」数据上不可区分:按位置配对
# 在其中一种情况下必然静默错绑(新项继承被删项的锚点,线正常画着却是错的)。
# 错绑比断线更坏 —— 断线在读侧 `unresolved` 里可见、可计数。故:
# · 名字未命中的项 → 一律当新目标插入(新行 id);
# · 未被占用的存量行 → 一律当已删除删除;
# · 连线锚点由此指向被删行 ⇒ 读侧记 `stale_objective_id` 并计入 unresolved。
# UI 通道(客户端回带 `_mo_id`)改名仍走 ① 精确保住锚点,本口径不影响它。
# 逐项贪心(名→序位→空位)的串位反证见 tests/test_conn_key_rename_stability.py
# 的 test_negative_greedy_matching_would_crossbind。
created = 0
keep = []
for idx, (dk, i, obj) in enumerate(planned):
target = assigned.get(idx)
if target is None:
target = MapObjective(map_id=m.id, entity_id=entity_id, dimension_key=dk or "finance")
db.add(target)
created += 1
target.entity_id = entity_id
target.dimension_key = dk
target.name = (obj.get("name") or "")[:200]
target.description = obj.get("description") or ""
target.icon = obj.get("icon") or "target"
target.kpis = obj.get("kpis") or []
target.sort_order = i
keep.append(target)
db.flush() # 新增行拿到 id 后才有 id 可比
deleted = 0
for r in rows:
if r.id not in used:
db.delete(r)
deleted += 1
db.commit()
return {"planned": len(planned), "created": created, "deleted": deleted,
"kept": len(used)}
def _merge_map_objectives(m, db):
@@ -678,6 +1156,9 @@ def _merge_map_objectives(m, db):
"icon": o.icon or existing.get("icon", "target"),
"kpis": o.kpis or existing.get("kpis", []),
**{k: existing[k] for k in existing if k not in ("name", "description", "icon", "kpis")},
# P3:回写本表行 id(连线锚点的落点)。只在读响应里出现,不落 dimensions JSON;
# 客户端原样带回保存时,_sync_map_objectives 据此就地更新、保住行 id。
"_mo_id": o.id,
})
dim["objectives"] = merged
m.dimensions = dims
@@ -1005,6 +1486,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
total_ok = 0
total_warn = 0
total_err = 0
total_hold = 0 # C1 数据待核计数(此前漏初始化 → 命中 hold_reason 即 UnboundLocalError → review 500)
total_obj_count = 0
focus_items = []
@@ -1026,12 +1508,15 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
if level and kpi_def.kpi_level != level:
continue
lv = latest_values.get(kpi_def.id, {})
actual = lv.get("actual_value")
# C1 数据前置:held KPI 的实测值一律不外泄(96.3% 零出口)
held = code in DATA_HOLD_KPIS
actual = None if held else lv.get("actual_value")
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
target = kpi_def.target_monthly or kpi_def.target_value
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
level = "gray"
ratio_val = None
if actual is not None and target:
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
lower_better = any(k in name for k in
@@ -1042,6 +1527,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
ratio = actual / target
if ratio < 0:
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
ratio_val = ratio
if ratio >= 0.9:
level = "green"
elif ratio >= 0.6:
@@ -1051,6 +1537,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
else:
level = "gray"
# C1 数据前置:held KPI 不着色、不透出达成率(level=pending 由前端灰虚线呈现)
if held:
level = "pending"
ratio_val = None
if level == "red":
worst_level = "red"
elif level == "yellow" and worst_level != "red":
@@ -1064,14 +1555,29 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
"actual_value": actual,
"unit": kpi_def.unit,
"level": level,
# 达成率(按指标方向归一,None=无目标/无实测,调用方须显式显示"无数据")
"ratio": round(ratio_val, 4) if ratio_val is not None else None,
})
kpi_ratios = [k["ratio"] for k in obj_kpis if k.get("ratio") is not None]
obj_ratio = round(sum(kpi_ratios) / len(kpi_ratios), 4) if kpi_ratios else None
# C1 数据前置:目标含 held KPI ⇒ 整节点「数据待核」,不计入任何达成率汇总
hold_reason = next((DATA_HOLD_KPIS[c] for c in obj.get("kpis", []) if c in DATA_HOLD_KPIS), None)
obj_item = {
"name": obj.get("name", ""),
"icon": obj.get("icon", ""),
"kpis": obj_kpis,
"level": worst_level,
"level": "pending" if hold_reason else worst_level,
"has_data": len(obj_kpis) > 0,
# 目标达成率 = 有实测值 KPI 的方向归一达成率均值;None ⇒ 前端显示"无数据"
"ratio": None if hold_reason else obj_ratio,
"kpi_total": len(obj_kpis),
"kpi_with_data": 0 if hold_reason else len(kpi_ratios),
"has_actual": False if hold_reason else bool(kpi_ratios),
"data_hold": bool(hold_reason),
"hold_reason": hold_reason,
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
"action_plan_summary": {
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
@@ -1082,6 +1588,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
}
objectives.append(obj_item)
# C1 数据前置:数据待核目标单列,不进达标/关注/风险任何一档
if hold_reason:
total_hold += 1
continue
if worst_level == "green":
total_ok += 1
elif worst_level == "yellow":
@@ -1115,6 +1626,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
"green": total_ok,
"yellow": total_warn,
"red": total_err,
"hold": total_hold,
"health_score": round(total_ok / total_obj_count * 100, 1) if total_obj_count > 0 else 0,
},
"focus_items": focus_items,
+1
View File
@@ -0,0 +1 @@
"""口径判据数据包(战略地图数据完备度等,只读常量)。"""
@@ -0,0 +1,73 @@
"""战略地图数据完备度 · 口径定义(唯一真源引用,勿改判据/分母)
口径 ID : CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0
定义件 : /srv/hermes/mailbox/project/pending-tasks/_reports/
caliber-strategy-map-completeness-20261018.md
裁定 : ASK-20261018-15 / 任总 2026-10-18T23:07:11「放行14和15」
本模块只放**判据数据**(23-KPI 挂接关系 + 来源指纹),不存任何汇总数:
覆盖率一律由 API 运行时对库内 `map_objectives` / `kpi_definitions` 现算。
口径变更 = 重新立 ASK 并改本文件 + 定义件,禁止就地改分母。
"""
CALIBER_ID = "CMA-CALIBER-MAP-COMPLETENESS-20261018"
CALIBER_VERSION = "1.0"
# 主口径:仅已发布地图 65(三年战略规划 2027-2029)
PRIMARY_MAP_ID = 65
PRIMARY_CALIBER = "map65_23kpi_cover"
PRIMARY_LABEL = "地图65·已发布·判据=被23KPI集挂接"
FOOTNOTE_CALIBER = "map_all_kpi_attached"
FOOTNOTE_LABEL = "全量4图·含归档图·判据=有KPI挂接"
KPI_CALIBER = "active_kpi_rows"
KPI_LABEL = "KPI 行(active·按行)"
# 来源指纹(定义件 §5,任何人可复现)
# 注:键名为上游件原名(其行数标记属上游历史口径),与本单交付分母无关,不得据此推算分母。
SOURCE_FILES = {
"map-objectives-dump.csv":
"3896963ee7aff0492073f9b61491a10b10f3b131484e1f1006655d2c2f9bda78",
"a2-linkage-23kpi.csv":
"4494313269938663a197fabecaee644a4f4c66c84a02b725527c07e917f63971",
"active-kpi-118.csv":
"e37dcd699a06a18d5de9aa5b543b7a48b6d7d12c3e4c440f21186ea870cda1c1",
}
# 被 23-KPI 集挂接的战略目标(objective_id -> (map_id, name))
# 来源 a2-linkage-23kpi.csv「挂接类型非空」逐行,26 条(全量 4 图)。
LINKED_OBJECTIVES = {
1020: (53, "营收增长"),
1022: (53, "客户拓展"),
1024: (53, "运营效率"),
1025: (53, "数字化能力"),
1021: (53, "成本与现金流"),
1023: (53, "客户亲密"),
20888: (57, "营收目标"),
20889: (57, "建立渠道分级体系"),
21013: (61, "营收目标"),
21014: (61, "保障现金流安全"),
21016: (61, "产品领先"),
22556: (65, "营业收入"),
22562: (65, "高贡献线收入占比"),
22566: (65, "组合决策机制(负贡献四线退出转投)"),
22571: (65, "贡献率思维全员培训"),
22557: (65, "营业利润"),
22563: (65, "重点客户回款率"),
22567: (65, "月度经营闭环"),
22572: (65, "渠道经理「选品+算账」能力认证"),
22558: (65, "边际贡献率 CM"),
22568: (65, "库存去化"),
22573: (65, "CMA 月度经营驾驶舱"),
22559: (65, "现金周转期 CCC"),
22565: (65, "客户集中度"),
22574: (65, "贡献率/CCC/净现金入考核与激励"),
22570: (65, "数据地基(清脏+改瓶+智享通直连)"),
}
# 分层标签(UI 用,与前端 LAYER_CONFIG 一致)
LAYER_LABELS = {
"finance": "财务",
"customer": "客户",
"process": "内部流程",
"learning": "学习成长",
}
+109
View File
@@ -0,0 +1,109 @@
"""战略地图 · 收入 / 毛利 / 管理指标口径(单一事实源,勿在别处写死)
口径来源(引用勿改):
/srv/hermes/mailbox/finance/pending-tasks/_reports/
project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md
改造单 project-20261018-cma-strategy-map-ux-revamp-fullstack 增量 v4
终稿结论(2026-10-19):
- 陕西博海、陕西酣客 **均总额法(主要责任人)**;
- 增值税 = 销售货物 13%(账内 11 张凭证实测,6% 代理服务零证据);
- 上游披露的「累计口径」金额(非年度数)= 冲减采购成本/存货成本(唯一落位,**无「补贴收入/其他收益」行**),
本模块不产生该数、也不允许任何调用点拼出该串(千分位与裸整数两种写法均在禁列);
- F2 毛利率新式 = (收入净额 − 折扣后净进价成本) / 收入净额 × 100;
旧式(含 `k` 的 5001/571103 组合)作废,**不得在本文件或调用点复现**;
- 历史期缺同票折扣发票(四件套 0/4)⇒ 金额只作现值展示并带「测算/待票」标识。
本模块只放**口径与科目映射**,不存金额;金额一律由 API 对库内 voucher_details 现算
(filter: summary NOT LIKE '%结转%',否则损益双向抵销归零)。
"""
# ── 收入口径状态(v4 起为已闭合)──────────────────────────────────────────────
STATUS = "closed"
CALIBER = "total" # total / net / mixed
CALIBER_LABEL = "总额法(会计)· 增值税13%"
DECIDED_BY = "A1 终稿(project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md)"
REASON = "两家均主要责任人 ⇒ 总额法;增值税销售货物 13% 实测,无税会差异"
# ── 按实体的科目映射(各账套科目体系不同:酣客 5001/5401,博海 6001/6401)──────
SUBJECT_MAP = {
1: {
"revenue": "5001", # 主营业务收入
"cost": "5401", # 主营业务成本
"purchase_discount": "530111", # 商品折扣(采购)
"purchase_gift": "530103", # 采购获赠(捐赠收益)
"lines": [
{"key": "liquor", "label": "酒类"},
{"key": "other", "label": "其他"},
],
},
2: {
"revenue": "6001", # 主营业务收入
"cost": "6401", # 主营业务成本
"purchase_discount": "6603", # 财务费用-现金折扣(贷方=采购折扣)
"purchase_gift": None,
"lines": [
{"key": "self_it", "label": "自营(IT)"},
{"key": "agency_liquor", "label": "代销(酒类)"},
],
},
}
# 酒类判定(财务 A1 件同一口径):摘要含以下任一关键词 ⇒ 酒类业务线
WINE_KEYWORDS = ("酣客", "酒窖", "酣亲", "秦莉", "牛彦平", "横山")
# 采购来源(博海酒类采购:陕西酣客 / 酣客酒业)
LIQUOR_SUPPLIERS = ("酣客", "酒业")
def wine_sql(column: str = "summary") -> str:
"""SQL 片段:酒类业务线判定(与财务回执同口径,勿改)。"""
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in WINE_KEYWORDS) + ")"
def supplier_sql(column: str = "summary") -> str:
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in LIQUOR_SUPPLIERS) + ")"
# ── 口径切换期标识位(v4 §4:不硬编码日期;生效日以任总拍板 ASK 为准)──────────
CALIBER_SWITCH = {
"has_switch": True,
"label": "口径变更",
"effective_date": None, # 未拍板前恒为 None —— 禁止写死日期
"comparability": "口径变更·不可比",
"note": "同比/达成率/预算执行率在切换期内不可比;生效日以任总拍板(ASK)为准",
}
# ── 缺件披露(v4 §5:界面不得呈现为「已齐」)─────────────────────────────────
MISSING_DOCS = [
"经销/代理协议",
"厂家价格政策",
"逐笔折扣发票+合同审批单",
"上游退货条款",
"发票台账(invoice_check 空)",
]
def business_lines(entity_id: int) -> list:
cfg = SUBJECT_MAP.get(entity_id)
return list(cfg["lines"]) if cfg else []
def snapshot(entity_id: int) -> dict:
"""对外口径快照(无金额;金额见 /api/cma/maps/summary)。"""
return {
"entity_id": entity_id,
"status": STATUS,
"caliber": CALIBER,
"caliber_label": CALIBER_LABEL,
"decided_by": DECIDED_BY,
"reason": REASON,
"allow_single_total": False, # 分业务线分列;不合并成单一「营业收入」
"businesses": [
{"key": b["key"], "label": b["label"], "unit": "万元"}
for b in business_lines(entity_id)
],
"switch": CALIBER_SWITCH,
"missing_docs": MISSING_DOCS,
"note": "分业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
+7 -1
View File
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv
from app.database import init_db, get_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_dimensions
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions, ar_aging, charter
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth, require_auth_401
@@ -40,6 +40,10 @@ app.include_router(auth.router)
app.include_router(kpis.router)
app.include_router(kpi_governance.router)
app.include_router(templates.router)
# ⚠️ 顺序要求:完备度/摘要条路由必须先于 maps/objectives 等「/{map_id}/...」通配路由注册,
# 否则 /maps/completeness/objectives 会被 /{map_id}/objectives 抢先匹配(map_id="completeness" → 422)。
app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
app.include_router(map_summary.router) # 战略地图摘要条(收入分线/新式毛利率/管理指标 · 口径 CMA-A1 终稿)
app.include_router(maps.router)
app.include_router(dashboard.router)
app.include_router(data.router)
@@ -101,7 +105,9 @@ app.include_router(tax_compliance.router)
app.include_router(verify.router)
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
app.include_router(ar_aging.router) # 应收账龄看板(T2 2026-10-20:逐户·六档·三口径禁合计)
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
app.include_router(charter.router) # 战略基座:战略章程(使命/愿景/价值观,P0 2026-10-21)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
+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.ext_input_ledger import TaxFilingLog, HrMonthlyLedger, LicenceLedger
from app.models.report_opening import ReportOpeningBalance
from app.models.strategy_charter import StrategyCharter
from app.models.treasury import (
TreasuryAccount, TreasuryBalance, TreasuryControlRule,
TreasuryPayment, TreasuryControlHit, TreasuryLimit,
@@ -166,6 +167,8 @@ class KPIAlert(Base):
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
alert_message = Column(String(500), nullable=False)
alert_type = Column(String(30), default="actual", comment="actual/forecast — 实际值超限/预测值超限")
dedupe_key = Column(String(160), nullable=True,
comment="生成侧幂等键 period|scope|ident;仅 alert_type=overdue 填充,历史行为 NULL")
suggestion = Column(Text, nullable=True, comment="情景建议")
action_plan_linked_id = Column(Integer, nullable=True, comment="关联的改善计划ID")
status = Column(String(20), default="pending", comment="pending/processing/resolved")
@@ -363,6 +366,37 @@ class MapObjective(Base):
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class MapConnection(Base):
"""战略地图四层因果连线(2026-10-21 P1:canvas_data.connections → 独立表)
迁移来源 = `strategic_maps.canvas_data->connections`,双写期表与 JSON 并存
(JSON 保留供前端渲染;表供归因/校验/分析)。
节点标识双兼容(生产实况两种格式并存):
- `dim:中文目标名`(生产存量实际格式,如 `learning:贡献率思维全员培训`)
- `dim-序号`(当前前端 MapCanvas 生成的格式,如 `learning-0`)
原始串原样保存在 from_node/to_node,解析结果落 from_layer/to_layer/from_objective_id。
"""
__tablename__ = "map_connections"
__table_args__ = (
UniqueConstraint("map_id", "from_node", "to_node", name="uk_map_conn_edge"),
)
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, index=True, comment="企业ID(冗余,便于按账套直查)")
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, index=True, comment="关联战略地图")
from_node = Column(String(255), nullable=False, comment="源节点原始标识(原样保留)")
to_node = Column(String(255), nullable=False, comment="目标节点原始标识(原样保留)")
from_layer = Column(String(20), nullable=True, comment="解析层: learning/process/customer/finance")
to_layer = Column(String(20), nullable=True, comment="解析层: learning/process/customer/finance")
from_objective_id = Column(Integer, ForeignKey("map_objectives.id", ondelete="SET NULL"), nullable=True, comment="源目标ID(可解析时回填)")
to_objective_id = Column(Integer, ForeignKey("map_objectives.id", ondelete="SET NULL"), nullable=True, comment="目标目标ID(可解析时回填)")
effect = Column(String(20), default="positive", comment="positive/negative(存量缺省 positive)")
label = Column(String(255), nullable=True, comment="连线标签")
sort_order = Column(Integer, default=0, comment="画布内原始顺序(保持渲染一致)")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class KPICausality(Base):
"""KPI因果链 — 记录KPI间的因果关系"""
__tablename__ = "kpi_causality"
+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
actual="$(pnpm -v 2>/dev/null | tail -1 | tr -d '[:space:]' || true)"
if [ "$actual" != "$expect" ]; then
echo "❌ pnpm 版本断言失败:package.json pin=$expect,实读 pnpm -v=${actual:-<空>}"
echo " 多为 corepack lastKnownGood 漂移(/root/.cache/node/corepack/lastKnownGood.json)"
echo " 修法:HOME=/root corepack prepare pnpm@$expect --activate | 中止(生产未被触碰)"
exit 1
echo "⚠️ pnpm 版本断言不匹配:package.json pin=$expect,实读 pnpm -v=${actual:-<空>}"
echo " 尝试自愈:corepack prepare pnpm@$expect --activate(integrity 依 packageManager 的 sha512 校验)"
corepack prepare "pnpm@$expect" --activate >/dev/null 2>&1 || true
actual="$(pnpm -v 2>/dev/null | tail -1 | tr -d '[:space:]' || true)"
if [ "$actual" != "$expect" ]; then
echo "❌ pnpm 版本断言失败(自愈未成功):pin=$expect,实读=${actual:-<空>}"
echo " 多为 corepack lastKnownGood 漂移或离线/缓存缺失;中止(生产未被触碰)"
exit 1
fi
echo " ✓ 自愈成功:pnpm $actual == package.json pin $expect"
fi
echo " ✓ pnpm 版本断言通过:pnpm $actual == package.json pin $expect"
}
+5
View File
@@ -81,8 +81,13 @@ declare module 'vue' {
KPIListView: typeof import('./src/components/KPIListView.vue')['default']
MapCanvasDialogs: typeof import('./src/components/map-canvas/MapCanvasDialogs.vue')['default']
MapCanvasToolbar: typeof import('./src/components/map-canvas/MapCanvasToolbar.vue')['default']
MapCompleteness: typeof import('./src/components/strategy-map/MapCompleteness.vue')['default']
MapDimensionFilter: typeof import('./src/components/map-canvas/MapDimensionFilter.vue')['default']
MapEditBar: typeof import('./src/components/strategy-map/MapEditBar.vue')['default']
MapNode: typeof import('./src/components/strategy-map/MapNode.vue')['default']
MapNodeDetail: typeof import('./src/components/strategy-map/MapNodeDetail.vue')['default']
MapSummaryBar: typeof import('./src/components/strategy-map/MapSummaryBar.vue')['default']
MapTargetTree: typeof import('./src/components/strategy-map/MapTargetTree.vue')['default']
MyDialog: typeof import('./src/components/MyDialog.vue')['default']
NodeEditDialog: typeof import('./src/components/strategy-map/NodeEditDialog.vue')['default']
OkrDecomposition: typeof import('./src/components/okr/OkrDecomposition.vue')['default']
+31
View File
@@ -75,6 +75,9 @@ export const mapApi = {
versionRollback: (mapId: number, vid: number) => api.post(`/maps/${mapId}/versions/${vid}/rollback`),
versionUpdateComment: (mapId: number, vid: number, comment: string) => api.put(`/maps/${mapId}/versions/${vid}`, { comment }),
versionSnapshot: (mapId: number, comment?: string) => api.post(`/maps/${mapId}/versions/snapshot`, comment ? { comment } : {}),
// 数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0 · 2026-10-18)
completenessSummary: () => api.get('/maps/completeness/summary'),
completenessObjectives: (params?: any) => api.get('/maps/completeness/objectives', { params }),
}
// 复盘记录持久化(大PDCA Act闭环 2026-08-27)
@@ -543,4 +546,32 @@ export const treasuryApi = {
listHits: (params?: any) => api.get('/treasury/control-hits', { params }),
}
// ── 应收账龄看板(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
@@ -0,0 +1,210 @@
<template>
<section class="mc-root" aria-label="战略地图数据完备度">
<div v-if="loading" class="mc-loading">加载中…</div>
<template v-else>
<!-- 主数:仅地图 65(已发布)→ 全页唯一主数位 -->
<div class="mc-main">
<div class="mc-main-head">
<span class="mc-main-title">数据完备度 · 目标覆盖</span>
<span class="mc-tag" data-caliber="map65_23kpi_cover">{{ primary.label }}</span>
</div>
<div v-if="primary.available" class="mc-main-body">
<div class="mc-main-value mono">
{{ primary.covered }}<span class="mc-slash">/</span>{{ primary.total }}
</div>
<div class="mc-main-pct mono">{{ primary.pct }}<span class="mc-unit">%</span></div>
</div>
<div v-else class="mc-main-body">
<div class="mc-main-value mono muted">—</div>
<div class="mc-main-pct muted">无数据</div>
</div>
<div class="mc-note">
分母 = {{ primary.available ? primary.map_title : '地图 65' }} 的
{{ primary.available ? primary.total : '—' }}个战略目标;
分子 = 被 23-KPI 集挂接的目标(挂接类型非空)
</div>
</div>
<!-- 附注:全量 4 图(含归档图),判据不同,字号/位置低于主数,且同句带标签 -->
<div class="mc-foot" data-caliber="map_all_kpi_attached">
<span class="mc-foot-label">附注</span>
<span class="mc-foot-text">
{{ footnote.label }}:<b class="mono">{{ footnote.display }}</b>
<template v-if="footnote.available"> = <b class="mono">{{ footnote.pct }}%</b></template>
<template v-else> · 无数据</template>
</span>
</div>
<!-- KPI 级:与目标级分列,不相除、不换算 -->
<div class="mc-kpi" data-caliber="active_kpi_rows">
<div class="mc-kpi-head">
<span class="mc-kpi-title">KPI 行(active · 按行)</span>
<span class="mc-kpi-total mono">{{ kpi.total }}</span>
</div>
<div class="mc-kpi-entities">
<span v-for="e in kpi.by_entity" :key="e.entity_id" class="mc-kpi-chip">
{{ entityLabel(e.entity_id) }} <b class="mono">{{ e.count }}</b>
</span>
<span class="mc-kpi-note mono">去重编码 {{ kpi.distinct_codes }}(另册,不与行数混用)</span>
</div>
<div class="mc-note">目标级与 KPI 级是两个层级的分母,本页分列展示,不做相除或换算</div>
</div>
<!-- 清单:目标级逐行(entity × 地图 × 层 × 目标) -->
<div class="mc-list-head">
<span>数据完备度清单 · 目标级</span>
<span class="mc-list-count mono">{{ rows.length }} 行</span>
</div>
<div v-if="rows.length === 0" class="mc-empty">
该实体暂无可统计的战略目标 · 去创建
</div>
<div v-else class="mc-table-wrap">
<table class="mc-table">
<thead>
<tr>
<th>地图</th>
<th>层级</th>
<th>战略目标</th>
<th>23-KPI 挂接</th>
<th>关联 KPI</th>
<th>完备状态</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.objective_id">
<td class="mono">{{ r.map_id }}</td>
<td>{{ r.layer_label }}</td>
<td :title="r.name">{{ r.name }}</td>
<td>
<span class="mc-pill" :class="r.covered_23kpi ? 'ok' : 'off'">
{{ r.covered_23kpi ? '已挂接' : '未挂接' }}
</span>
</td>
<td class="mc-kpis">{{ r.kpis.length ? r.kpis.join(' / ') : '无' }}</td>
<td>
<span class="mc-pill" :class="stateClass(r)">{{ stateText(r) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { mapApi } from '../../api/index'
const loading = ref(true)
const error = ref<string | null>(null)
const summary = ref<any>(null)
const rows = ref<any[]>([])
const primary = computed<any>(() => summary.value?.objective_level?.primary ?? { available: false, label: '地图65·已发布·判据=被23KPI集挂接' })
const footnote = computed<any>(() => summary.value?.objective_level?.footnote ?? { available: false, label: '全量4图·含归档图·判据=有KPI挂接', display: '—' })
const kpi = computed<any>(() => summary.value?.kpi_level ?? { total: null, by_entity: [], distinct_codes: null })
const ENTITY_LABELS: Record<number, string> = { 1: '酣客', 2: '博海' }
function entityLabel(id: number) {
return ENTITY_LABELS[id] ? `${ENTITY_LABELS[id]}(${id})` : `实体${id}`
}
function stateText(r: any) {
if (r.covered_23kpi) return '23-KPI 覆盖'
if (r.has_kpi) return '非23KPI 覆盖'
return '缺口(无 KPI)'
}
function stateClass(r: any) {
if (r.covered_23kpi) return 'ok'
if (r.has_kpi) return 'warn'
return 'bad'
}
async function load() {
loading.value = true
error.value = null
try {
const [s, o]: any = await Promise.all([
mapApi.completenessSummary(),
mapApi.completenessObjectives(),
])
summary.value = s
rows.value = Array.isArray(o?.rows) ? o.rows : []
} catch (e: any) {
error.value = e?.message || '数据完备度加载失败'
summary.value = null
rows.value = []
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.mc-root { display: flex; flex-direction: column; gap: 10px; }
.mc-loading, .mc-empty {
padding: 24px; text-align: center; color: var(--bh-text-muted, #94A3B8); font-size: 13px;
border: 1px dashed var(--bh-border, #E5E7EB); border-radius: 8px;
}
.mc-main {
border: 1px solid var(--bh-border, #E5E7EB); border-radius: 8px; padding: 12px 14px;
background: var(--bh-bg-card, #fff);
}
.mc-main-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.mc-main-title { font-size: 13px; color: var(--bh-text-secondary, #64748B); }
.mc-tag {
font-size: 11px; padding: 1px 6px; border-radius: 4px;
color: #635BFF; background: rgba(99, 91, 255, .08); border: 1px solid rgba(99, 91, 255, .25);
}
.mc-main-body { display: flex; align-items: baseline; gap: 10px; margin-top: 6px; }
.mc-main-value { font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--bh-text-primary, #0F172A); }
.mc-slash { color: var(--bh-text-muted, #94A3B8); font-weight: 500; margin: 0 2px; }
.mc-main-pct { font-size: 18px; font-weight: 600; color: #0E9F6E; }
.mc-unit { font-size: 11px; margin-left: 1px; }
.mc-note { margin-top: 6px; font-size: 11px; color: var(--bh-text-muted, #94A3B8); line-height: 1.5; }
.muted { color: var(--bh-text-muted, #94A3B8); }
.mc-foot {
display: flex; align-items: baseline; gap: 8px; padding: 8px 12px;
border-left: 3px solid var(--bh-border, #E5E7EB); background: #FAFAFB; border-radius: 4px;
font-size: 12px; color: var(--bh-text-secondary, #64748B);
}
.mc-foot-label { font-size: 11px; color: var(--bh-text-muted, #94A3B8); }
.mc-kpi {
border: 1px solid var(--bh-border, #E5E7EB); border-radius: 8px; padding: 10px 12px;
}
.mc-kpi-head { display: flex; align-items: baseline; justify-content: space-between; }
.mc-kpi-title { font-size: 13px; color: var(--bh-text-secondary, #64748B); }
.mc-kpi-total { font-size: 20px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
.mc-kpi-entities { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; align-items: center; }
.mc-kpi-chip {
font-size: 12px; padding: 2px 8px; border-radius: 4px; background: #F3F4F6; color: var(--bh-text-primary, #0F172A);
}
.mc-kpi-note { font-size: 11px; color: var(--bh-text-muted, #94A3B8); }
.mc-list-head {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 13px; color: var(--bh-text-secondary, #64748B); margin-top: 2px;
}
.mc-list-count { font-size: 12px; color: var(--bh-text-muted, #94A3B8); }
.mc-table-wrap { max-height: 340px; overflow: auto; border: 1px solid var(--bh-border, #E5E7EB); border-radius: 8px; }
.mc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.mc-table th {
position: sticky; top: 0; background: #F8FAFC; text-align: left; font-weight: 600;
color: var(--bh-text-secondary, #64748B); padding: 7px 10px; border-bottom: 1px solid var(--bh-border, #E5E7EB);
white-space: nowrap;
}
.mc-table td { padding: 6px 10px; border-bottom: 1px solid #F1F5F9; color: var(--bh-text-primary, #0F172A); }
.mc-table tbody tr:last-child td { border-bottom: none; }
.mc-table td:nth-child(3) { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mc-kpis { color: var(--bh-text-secondary, #64748B); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mc-pill { font-size: 11px; padding: 1px 6px; border-radius: 10px; white-space: nowrap; }
.mc-pill.ok { color: #0E9F6E; background: rgba(14, 159, 110, .08); }
.mc-pill.warn { color: #B45309; background: rgba(245, 158, 11, .12); }
.mc-pill.bad { color: #B91C1C; background: rgba(239, 68, 68, .10); }
.mc-pill.off { color: var(--bh-text-muted, #94A3B8); background: #F3F4F6; }
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
</style>
@@ -0,0 +1,196 @@
<template>
<div class="map-editbar">
<div class="eb-left">
<el-input
:model-value="search"
class="eb-search"
size="small"
clearable
placeholder="搜索目标 / 负责人 / KPI"
@update:model-value="(v: string) => $emit('update:search', v)"
>
<template #prefix>
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
</svg>
</template>
</el-input>
<div class="eb-chips" role="group" aria-label="状态筛选">
<button
v-for="opt in filterOptions"
:key="opt.value"
type="button"
class="eb-chip"
:class="{ on: filter === opt.value }"
@click="$emit('update:filter', opt.value)"
>{{ opt.label }}</button>
</div>
<div class="eb-chips eb-layers" role="group" aria-label="层筛选">
<button
v-for="cfg in layerConfigs"
:key="cfg.key"
type="button"
class="eb-chip layer-chip"
:class="{ off: !visibleLayers[cfg.key] }"
:title="visibleLayers[cfg.key] ? '隐藏该层' : '显示该层'"
@click="$emit('toggle-layer', cfg.key)"
>
<span class="layer-dot" :style="{ background: cfg.color }"></span>
{{ cfg.short }}
</button>
</div>
</div>
<div class="eb-right">
<el-dropdown trigger="click" @command="onAdd">
<el-button size="small" type="primary" plain>
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
加目标
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="cfg in layerConfigs" :key="cfg.key" :command="cfg.key">
<span class="dd-dot" :style="{ background: cfg.color }"></span>{{ cfg.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" @click="$emit('connect')">
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1" />
<path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />
</svg>
连因果
</el-button>
<el-button size="small" :disabled="!canUndo" @click="$emit('undo')" title="撤销上一步画布编辑">
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M9 14L4 9l5-5" /><path d="M4 9h10a6 6 0 0 1 0 12h-3" />
</svg>
撤销
</el-button>
<el-button size="small" @click="$emit('completeness')" title="数据完备度与清单(口径:地图65·23KPI判据)">
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M4 19V5" /><path d="M4 19h16" /><rect x="7" y="11" width="3" height="6" rx=".5" />
<rect x="12" y="7" width="3" height="10" rx=".5" /><rect x="17" y="13" width="3" height="4" rx=".5" />
</svg>
数据完备度
</el-button>
<el-button size="small" @click="$emit('fit')" title="缩放到适合窗口">
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
</svg>
回全景
</el-button>
<el-button size="small" text @click="$emit('toggle-tree')" :title="treeOpen ? '收起左侧目标树(画布全宽)' : '展开左侧目标树'">
{{ treeOpen ? '收起目标树' : '展开目标树' }}
</el-button>
<!-- C 方案:聚焦开关(全景 = 默认四层等宽) -->
<div class="eb-chips eb-focus" role="group" aria-label="聚焦层">
<button
type="button"
class="eb-chip"
:class="{ on: !focusLayer }"
title="四层全景(默认)"
@click="$emit('focus-layer', null)"
>全景</button>
<button
v-for="cfg in layerConfigs"
:key="'focus-' + cfg.key"
type="button"
class="eb-chip"
:class="{ on: focusLayer === cfg.key }"
:title="focusLayer === cfg.key ? '再次点击退出聚焦' : '聚焦' + cfg.label"
@click="$emit('focus-layer', focusLayer === cfg.key ? null : cfg.key)"
>
<span class="layer-dot" :style="{ background: cfg.color }"></span>
{{ cfg.short }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
search: string
filter: 'all' | 'risk' | 'nodata'
visibleLayers: Record<string, boolean>
layerConfigs: { key: string; label: string; short: string; color: string }[]
canUndo: boolean
treeOpen: boolean
/** C 方案聚焦层:null = 四层全景 */
focusLayer?: string | null
}>()
const emit = defineEmits<{
(e: 'update:search', v: string): void
(e: 'update:filter', v: 'all' | 'risk' | 'nodata'): void
(e: 'toggle-layer', key: string): void
(e: 'add-objective', key: string): void
(e: 'connect'): void
(e: 'undo'): void
(e: 'fit'): void
(e: 'toggle-tree'): void
(e: 'completeness'): void
(e: 'focus-layer', key: string | null): void
}>()
const filterOptions: { value: 'all' | 'risk' | 'nodata'; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'risk', label: '只看风险' },
{ value: 'nodata', label: '只看无数据' },
]
function onAdd(key: string) {
emit('add-objective', key)
}
</script>
<style scoped>
.map-editbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
flex-shrink: 0;
padding: 8px 10px;
margin-bottom: 10px;
background: var(--bh-bg-card, #fff);
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px;
}
.eb-left, .eb-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.eb-search { width: 220px; }
.eb-ico { width: 14px; height: 14px; vertical-align: -2px; }
.eb-chips { display: inline-flex; align-items: center; gap: 4px; padding: 2px; background: #F6F7F9; border: 1px solid #E5E7EB; border-radius: 6px; }
.eb-chip {
border: 0;
background: transparent;
color: #475569;
font-size: 12px;
line-height: 20px;
padding: 2px 10px;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: background .15s, color .15s;
}
.eb-chip:hover { color: #635BFF; }
.eb-chip.on { background: #635BFF; color: #fff; }
.eb-chip.off { color: #94A3B8; text-decoration: line-through; }
.layer-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.dd-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
</style>
@@ -1,6 +1,7 @@
<template>
<div
class="map-node"
:data-node-key="nodeKey"
:class="{
'linking-source': isLinkingSource,
'linking-target': isLinkingTarget,
@@ -118,7 +119,7 @@
></div>
</div>
<span class="node-progress-text" :style="{ color: progressColor(progressData.level) }">
{{ progressData.ratio > 0 ? progressData.ratio + '%' : '—' }}
{{ progressData.hasData && progressData.ratio != null ? progressData.ratio + '%' : '无数据' }}
</span>
</div>
<!-- KPI实际值快照:当前值 / 目标值 / 达成率 -->
@@ -242,6 +243,8 @@ function getKpiName(code: string): string {
}
function getKpiLevel(item: any): string {
// 后端 /maps/{id}/review 已按方向(含越低越好)判级:优先使用,避免前端重复口径
if (item.level) return item.level
if (item.actual != null && item.target) {
const ratio = item.actual / item.target
if (ratio >= 0.9) return 'green'
@@ -251,9 +254,10 @@ function getKpiLevel(item: any): string {
return 'gray'
}
/** KPI达成率文本(绿≥90% / 黄60-90% / 红<60%) */
/** KPI达成率文本(后端 ratio 已按指标方向计算;无数据显式标注) */
function kpiRateText(item: any): string {
if (item.actual == null || !item.target) return '—'
if (item.ratio != null) return Math.round(item.ratio) + '%'
if (item.actual == null || !item.target) return '无数据'
const ratio = item.actual / item.target
return Math.round(ratio * 100) + '%'
}
@@ -304,7 +308,7 @@ function getKrProgressText(kr: any): string {
<style scoped>
.map-node {
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
background: var(--bh-bg-card); border: 1px solid #e0e0e0; border-radius: 8px;
padding: 10px; margin-bottom: 8px; cursor: pointer;
transition: all 0.2s; position: relative;
}
@@ -348,7 +352,7 @@ function getKrProgressText(kr: any): string {
.node-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; }
.node-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; }
.node-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
.node-desc { font-size: 12px; color: #888; margin-bottom: 4px; }
.node-desc { font-size: 12px; color: var(--bh-text-tertiary); margin-bottom: 4px; }
.node-kpis { display: flex; flex-wrap: wrap; gap: 2px; }
.node-kpi-snapshots { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; }
@@ -359,8 +363,8 @@ function getKrProgressText(kr: any): string {
.snap-green { background: #f0f9eb; }
.snap-yellow { background: #fdf6ec; }
.snap-red { background: #fef0f0; }
.snap-gray { background: #f5f5f5; }
.snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 92px; color: #666; }
.snap-gray { background: var(--bh-bg-main); }
.snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 92px; color: var(--bh-text-secondary); }
.snap-value {
font-weight: 600; color: #333; white-space: nowrap;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
@@ -370,7 +374,7 @@ function getKrProgressText(kr: any): string {
.snap-now-yellow { color: #e6a23c; }
.snap-now-red { color: #f56c6c; }
.snap-now-gray { color: #333; }
.snap-target { font-weight: 400; color: #999; font-size: 10px; }
.snap-target { font-weight: 400; color: var(--bh-text-tertiary); font-size: 10px; }
.snap-rate {
font-size: 10px; font-weight: 700; flex-shrink: 0; min-width: 34px; text-align: right;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
@@ -379,9 +383,9 @@ function getKrProgressText(kr: any): string {
.rate-green { color: #67c23a; }
.rate-yellow { color: #e6a23c; }
.rate-red { color: #f56c6c; }
.rate-gray { color: #bbb; }
.rate-gray { color: var(--bh-text-tertiary); }
.kpi-snapshot-more { font-size: 10px; color: #409eff; text-align: center; cursor: pointer; padding: 1px; }
.node-click-hint { font-size: 10px; color: #bbb; text-align: center; margin-top: 2px; }
.node-click-hint { font-size: 10px; color: var(--bh-text-tertiary); text-align: center; margin-top: 2px; }
.node-plan-summary {
display: flex; align-items: center; gap: 6px;
@@ -403,7 +407,7 @@ function getKrProgressText(kr: any): string {
justify-content: flex-end;
}
.node-progress-label {
font-size: 10px; color: #909399; flex-shrink: 0; line-height: 1;
font-size: 10px; color: var(--bh-text-secondary); flex-shrink: 0; line-height: 1;
}
.node-progress-track {
flex: 1; max-width: 90px; height: 6px; background: #e4e7ed;
@@ -476,7 +480,7 @@ function getKrProgressText(kr: any): string {
margin-bottom: 4px;
border-radius: 6px;
border: 1px solid #e8f0fe;
background: #f8faff;
background: var(--bh-bg-main);
cursor: pointer;
transition: all 0.15s;
}
@@ -513,7 +517,7 @@ function getKrProgressText(kr: any): string {
}
.kr-sub-target {
font-size: 10px;
color: #909399;
color: var(--bh-text-secondary);
flex-shrink: 0;
}
/* KR 删除入口:hover 才显形,避免与 KR 文本拥挤;键盘聚焦时同样可见(可访问性) */
@@ -528,7 +532,7 @@ function getKrProgressText(kr: any): string {
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #c0c4cc;
color: var(--bh-placeholder);
cursor: pointer;
opacity: 0;
transition: opacity .15s, color .15s, background .15s, border-color .15s;
@@ -571,14 +575,14 @@ function getKrProgressText(kr: any): string {
.kr-sub-progress-text {
font-size: 10px;
font-weight: 600;
color: #606266;
color: var(--bh-text-regular);
min-width: 28px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.kr-sub-weight {
font-size: 10px;
color: #909399;
color: var(--bh-text-secondary);
background: #f0f5ff;
padding: 1px 6px;
border-radius: 8px;
@@ -590,7 +594,7 @@ function getKrProgressText(kr: any): string {
gap: 8px;
margin-top: 6px;
padding: 8px;
color: #909399;
color: var(--bh-text-secondary);
font-size: 12px;
border-top: 1px dashed #e4e7ed;
}
@@ -0,0 +1,338 @@
<template>
<aside class="node-detail" aria-label="目标详情">
<header class="nd-head">
<div class="nd-title-wrap">
<span class="nd-layer" :style="{ color: layerColor, borderColor: layerColor }">{{ layerLabel }}</span>
<h4 class="nd-title">{{ obj?.name || '未命名目标' }}</h4>
<div class="nd-sub">
<span class="nd-dot" :class="'dot-' + (progress?.level || 'gray')"></span>
<span>{{ levelText(progress?.level) }}</span>
<span class="dot-sep">·</span>
<span>负责人 {{ obj?.owner || '未设置' }}</span>
</div>
</div>
<button type="button" class="nd-close" aria-label="关闭详情" @click="$emit('close')">
<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>
</header>
<div class="nd-body">
<section class="nd-rate-card">
<div class="nd-rate-label">达成率</div>
<div class="nd-rate-value mono" :class="rateClass">
<template v-if="hasRate">{{ Math.round(progress!.ratio) }}<span class="unit">%</span></template>
<template v-else>无数据</template>
</div>
<div class="nd-rate-note">
<template v-if="hasRate">
由 {{ progress?.kpiWithData }} / {{ progress?.kpiTotal ?? progress?.kpiList?.length ?? 0 }} 个有实测值的 KPI 均值计算
</template>
<template v-else>该目标暂无同时具备目标值与实测值的 KPI</template>
</div>
</section>
<section v-if="obj?.description" class="nd-block">
<div class="nd-block-title">目标说明</div>
<p class="nd-desc">{{ obj.description }}</p>
</section>
<section class="nd-block">
<div class="nd-block-title">
关联 KPI
<span class="nd-count">{{ kpiList.length }}</span>
<span v-if="trendLoading" class="nd-loading">趋势加载中…</span>
</div>
<div v-if="kpiList.length === 0" class="nd-empty">
<p>该目标未关联 KPI,达成率无法计算。</p>
<div class="nd-empty-actions">
<el-button size="small" type="primary" plain @click="$emit('edit')">编辑目标并关联 KPI</el-button>
<el-button size="small" text @click="$emit('go-kpi-library')">去 KPI 库</el-button>
</div>
</div>
<ul v-else class="kpi-list">
<li v-for="k in kpiList" :key="k.code" class="kpi-item">
<div class="kpi-top">
<span class="kpi-dot" :class="'dot-' + (k.level || 'gray')"></span>
<button type="button" class="kpi-name" :title="'查看 KPI:' + (k.name || k.code)" @click="$emit('open-kpi', k.code)">
{{ k.name || k.code }}
</button>
<span class="kpi-rate mono" :class="'rate-' + (k.ratio != null ? levelOf(k.ratio) : 'gray')">
{{ k.ratio != null ? Math.round(k.ratio) + '%' : '无数据' }}
</span>
</div>
<div class="kpi-meta">
<span>目标 {{ fmtNum(k.target, k.unit) }}</span>
<span class="dot-sep">/</span>
<span>实际 {{ fmtNum(k.actual, k.unit) }}</span>
<span class="kpi-trend" :class="'trend-' + (trendOf(k.code)?.dir || 'none')">
<template v-if="trendOf(k.code)?.dir === 'up'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg>
</template>
<template v-else-if="trendOf(k.code)?.dir === 'down'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
</template>
<template v-else-if="trendOf(k.code)?.dir === 'flat'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 12h14" /></svg>
</template>
<span v-if="trendOf(k.code)?.dir">
较上期 {{ trendOf(k.code)?.deltaPct != null ? (trendOf(k.code)!.deltaPct! > 0 ? '+' : '') + trendOf(k.code)!.deltaPct!.toFixed(1) + '%' : '' }}
</span>
<span v-else-if="!trendLoading">暂无趋势数据</span>
</span>
</div>
</li>
</ul>
</section>
<section class="nd-block">
<div class="nd-block-title">因果上下游</div>
<div class="causal-row">
<span class="causal-label">被驱动</span>
<div class="causal-items">
<template v-if="upstream.length">
<button
v-for="u in upstream" :key="'u-' + u.key" type="button"
class="causal-chip" @click="$emit('select-key', u.key)"
>{{ u.name }}</button>
</template>
<span v-else class="causal-none">无上游连线</span>
</div>
</div>
<div class="causal-row">
<span class="causal-label">驱动</span>
<div class="causal-items">
<template v-if="downstream.length">
<button
v-for="d in downstream" :key="'d-' + d.key" type="button"
class="causal-chip" @click="$emit('select-key', d.key)"
>{{ d.name }}</button>
</template>
<span v-else class="causal-none">无下游连线</span>
</div>
</div>
</section>
<section class="nd-block">
<div class="nd-block-title">行动方案</div>
<div v-if="planSummary && planSummary.total > 0" class="plan-row">
<span class="mono plan-total">{{ planSummary.total }}</span>
<span>项行动方案</span>
<span v-if="planSummary.pending > 0" class="plan-pending">待办 {{ planSummary.pending }}</span>
<el-button size="small" text @click="$emit('open-plan')">查看</el-button>
</div>
<div v-else class="nd-empty-inline">
<span>暂无关联行动方案</span>
<el-button size="small" text @click="$emit('open-plan')">去创建</el-button>
</div>
</section>
</div>
<footer class="nd-foot">
<el-button size="small" @click="$emit('edit')">编辑目标</el-button>
<el-button size="small" text @click="$emit('open-kpi-detail')" :disabled="kpiList.length === 0">KPI 明细</el-button>
</footer>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface TrendInfo { dir: 'up' | 'down' | 'flat' | null; deltaPct: number | null; period?: string | null }
const props = defineProps<{
obj: any
dimKey: string
index: number
layerLabel: string
layerColor: string
progress: any
connections: any[]
nodeLabelMap: Record<string, string>
trendMap: Record<string, TrendInfo>
trendLoading?: boolean
}>()
defineEmits<{
(e: 'close'): void
(e: 'edit'): void
(e: 'open-kpi-detail'): void
(e: 'open-kpi', code: string): void
(e: 'open-plan'): void
(e: 'select-key', key: string): void
(e: 'go-kpi-library'): void
}>()
const nodeKey = computed(() => `${props.dimKey}-${props.index}`)
const kpiList = computed<any[]>(() => props.progress?.kpiList || [])
const planSummary = computed<any>(() => props.progress?.planSummary || null)
const hasRate = computed(() => props.progress?.hasData === true && props.progress?.ratio != null)
const rateClass = computed(() => {
if (!hasRate.value) return 'rate-gray'
const r = props.progress.ratio
return r >= 90 ? 'rate-green' : r >= 60 ? 'rate-yellow' : 'rate-red'
})
const upstream = computed(() => linkNames('up'))
const downstream = computed(() => linkNames('down'))
function linkNames(dir: 'up' | 'down') {
const list = (props.connections || []).filter((c: any) =>
dir === 'up' ? c.to === nodeKey.value && c.from !== nodeKey.value : c.from === nodeKey.value && c.to !== nodeKey.value
)
const seen = new Set<string>()
const out: { key: string; name: string }[] = []
for (const c of list) {
const k = dir === 'up' ? c.from : c.to
if (!k || seen.has(k)) continue
seen.add(k)
out.push({ key: k, name: props.nodeLabelMap[k] || k })
}
return out
}
function levelOf(ratio: number): string {
return ratio >= 90 ? 'green' : ratio >= 60 ? 'yellow' : 'red'
}
function levelText(level?: string): string {
if (level === 'green') return '达标'
if (level === 'yellow') return '关注'
if (level === 'red') return '风险'
return '无数据'
}
function trendOf(code: string): TrendInfo | undefined {
return props.trendMap?.[code]
}
function fmtNum(v: any, unit?: string | null): string {
if (v == null || v === '') return '无数据'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
const text = Math.abs(n) >= 10000
? (n / 10000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }) + ' 万'
: n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
return unit ? `${text} ${unit}` : text
}
</script>
<style scoped>
.node-detail {
width: 320px;
flex: 0 0 320px;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bh-bg-card, #fff);
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
}
.nd-head {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 14px 10px;
border-bottom: 1px solid #E5E7EB;
flex-shrink: 0;
}
.nd-title-wrap { flex: 1; min-width: 0; }
.nd-layer {
display: inline-block;
font-size: 10px;
line-height: 16px;
padding: 0 6px;
border: 1px solid currentColor;
border-radius: 4px;
margin-bottom: 4px;
}
.nd-title { margin: 0; font-size: 14px; font-weight: 700; color: #0F172A; line-height: 1.35; }
.nd-sub { margin-top: 3px; font-size: 11px; color: #94A3B8; display: flex; align-items: center; gap: 4px; }
.nd-dot { width: 7px; height: 7px; border-radius: 50%; }
.dot-green { background: #00C896; }
.dot-yellow { background: #F59E0B; }
.dot-red { background: #EF4444; }
.dot-gray { background: #CBD5E1; }
.dot-sep { color: #CBD5E1; }
.nd-close {
border: 0; background: transparent; color: #94A3B8; cursor: pointer;
width: 22px; height: 22px; padding: 2px; border-radius: 4px;
}
.nd-close:hover { background: #F6F7F9; color: #475569; }
.nd-close svg { width: 14px; height: 14px; }
.nd-body { flex: 1; min-height: 0; overflow-y: auto; padding: 10px 14px 12px; }
.nd-rate-card {
background: #F8FAFC;
border: 1px solid #EEF1F5;
border-radius: 8px;
padding: 10px 12px;
}
.nd-rate-label { font-size: 11px; color: #64748B; }
.nd-rate-value {
font-size: 26px;
font-weight: 700;
line-height: 1.2;
margin-top: 2px;
}
.nd-rate-value .unit { font-size: 12px; font-weight: 500; margin-left: 2px; }
.rate-green { color: #00C896; }
.rate-yellow { color: #F59E0B; }
.rate-red { color: #EF4444; }
.rate-gray { color: #94A3B8; font-size: 16px; }
.nd-rate-note { margin-top: 4px; font-size: 11px; color: #94A3B8; line-height: 1.45; }
.nd-block { margin-top: 12px; }
.nd-block-title {
display: flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 700; color: #475569; margin-bottom: 6px;
}
.nd-count {
font-size: 10px; font-weight: 500; color: #64748B;
background: #F1F5F9; border-radius: 8px; padding: 0 6px; line-height: 16px;
}
.nd-loading { margin-left: auto; font-size: 10px; font-weight: 400; color: #94A3B8; }
.nd-desc { margin: 0; font-size: 12px; color: #64748B; line-height: 1.55; }
.nd-empty { padding: 8px 10px; background: #FFF8EE; border: 1px solid #FDE7C3; border-radius: 6px; }
.nd-empty p { margin: 0 0 6px; font-size: 12px; color: #B45309; }
.nd-empty-actions { display: flex; align-items: center; gap: 4px; }
.kpi-list { list-style: none; margin: 0; padding: 0; }
.kpi-item { padding: 6px 0; border-bottom: 1px dashed #EEF1F5; }
.kpi-item:last-child { border-bottom: 0; }
.kpi-top { display: flex; align-items: center; gap: 5px; }
.kpi-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.kpi-name {
flex: 1; min-width: 0; border: 0; background: transparent; padding: 0;
font-size: 12px; color: #0F172A; text-align: left; cursor: pointer;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.kpi-name:hover { color: #635BFF; text-decoration: underline; }
.kpi-rate { font-size: 12px; font-weight: 700; }
.rate-green { color: #00C896; } .rate-yellow { color: #F59E0B; } .rate-red { color: #EF4444; } .rate-gray { color: #94A3B8; font-weight: 500; }
.kpi-meta {
display: flex; align-items: center; gap: 5px;
margin: 2px 0 0 11px; font-size: 11px; color: #94A3B8;
}
.kpi-trend { display: inline-flex; align-items: center; gap: 2px; margin-left: auto; color: #94A3B8; }
.kpi-trend svg { width: 11px; height: 11px; }
.trend-up { color: #0EA5E9; }
.trend-down { color: #F59E0B; }
.causal-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; }
.causal-label { flex: 0 0 44px; font-size: 11px; color: #94A3B8; line-height: 22px; }
.causal-items { display: flex; flex-wrap: wrap; gap: 4px; }
.causal-chip {
border: 1px solid #E5E7EB; background: #fff; color: #475569;
font-size: 11px; line-height: 20px; padding: 0 8px; border-radius: 4px; cursor: pointer;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.causal-chip:hover { border-color: #635BFF; color: #635BFF; }
.causal-none { font-size: 11px; color: #CBD5E1; line-height: 22px; }
.plan-row { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #475569; }
.plan-total { font-size: 16px; font-weight: 700; color: #0F172A; }
.plan-pending { font-size: 11px; color: #F59E0B; background: #FFF8EE; border-radius: 4px; padding: 0 6px; }
.nd-empty-inline { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #94A3B8; }
.nd-foot {
flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px;
padding: 8px 12px; border-top: 1px solid #E5E7EB; background: #FCFCFD;
}
.mono { font-family: 'IBM Plex Mono', Consolas, monospace; font-variant-numeric: tabular-nums; }
</style>
@@ -0,0 +1,354 @@
<template>
<section class="map-summary" aria-label="战略地图摘要">
<!-- C 方案(2026-10-23):汇总条压成单行(≤44px),5 项指标同排;明细收进「明细」展开位 -->
<div class="ms-row">
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
<div class="ms-metric ms-metric--rev">
<span class="ms-label">营业收入</span>
<span class="ms-value mono" :class="{ muted: revenueTotal == null }">
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="ms-unit">万元</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
<span v-if="revenue && revenue.caliber_label" class="ms-badge calm">{{ revenue.caliber_label }}</span>
</div>
<span class="ms-sep" aria-hidden="true"></span>
<!-- ② 毛利率 -->
<div class="ms-metric">
<span class="ms-label">毛利率</span>
<span class="ms-value mono" :class="{ muted: gmValue == null }">
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="ms-unit">%</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
<span v-if="grossMargin && grossMargin.data_tag" class="ms-badge warn">{{ grossMargin.data_tag }}</span>
</div>
<span class="ms-sep" aria-hidden="true"></span>
<!-- ③ 战略目标达成率 -->
<div class="ms-metric">
<span class="ms-label">战略目标达成率</span>
<span class="ms-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
<template v-if="achievement != null">{{ Math.round(achievement) }}<i class="ms-unit">%</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
</div>
<span class="ms-sep" aria-hidden="true"></span>
<!-- ④ 风险目标数 -->
<div class="ms-metric">
<span class="ms-label">风险目标</span>
<span class="ms-value mono" :class="riskCount == null ? 'muted' : (riskCount > 0 ? 'lv-red' : 'lv-green')">
<template v-if="riskCount != null">{{ riskCount }}</template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
</div>
<span class="ms-sep" aria-hidden="true"></span>
<!-- ⑤ 目标·连线(当前画布规模) -->
<div class="ms-metric ms-metric--scale">
<span class="ms-label">目标·连线</span>
<span class="ms-value mono">
<template v-if="objectivesTotal != null">{{ objectivesTotal }}</template>
<template v-else>—</template>
<i class="ms-unit">个目标</i>
<span class="ms-div">/</span>
<template v-if="connectionsTotal != null">{{ connectionsTotal }}</template>
<template v-else>—</template>
<i class="ms-unit">条连线</i>
</span>
</div>
<button
type="button"
class="ms-detail-btn"
:aria-expanded="detailOpen ? 'true' : 'false'"
@click="detailOpen = !detailOpen"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path :d="detailOpen ? 'M6 15l6-6 6 6' : 'M6 9l6 6 6-6'" />
</svg>
{{ detailOpen ? '收起' : '明细' }}
</button>
</div>
<!-- 明细(默认收起;展开后保留分线金额 / 口径 / 管理指标全部信息,不丢信息) -->
<div v-if="detailOpen" class="ms-detail">
<div class="ms-card">
<div class="ms-detail-head">
<span class="ms-label">营业收入 · 分业务线</span>
<span v-if="revenue && revenue.caliber_label" class="ms-badge calm">{{ revenue.caliber_label }}</span>
</div>
<div v-if="revenueLines.length" class="rev-lines">
<div v-for="ln in revenueLines" :key="ln.key" class="rev-line">
<span class="rev-line-label">{{ ln.label }}</span>
<span class="rev-line-value mono" :class="{ muted: ln.amount == null }">
<template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="ms-unit">万元</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
</div>
<div class="rev-line rev-line--total">
<span class="rev-line-label">{{ revenueTotalLabel }}</span>
<span class="rev-line-value mono">
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="ms-unit">万元</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</span>
</div>
</div>
<div v-else class="ms-value muted">—<i class="ms-unit">无数据</i></div>
<div class="ms-sub">{{ revenuePeriod }}</div>
</div>
<div class="ms-card">
<div class="ms-detail-head"><span class="ms-label">毛利率口径</span></div>
<div class="ms-value mono" :class="{ muted: gmValue == null }">
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="ms-unit">%</i></template>
<template v-else>—<i class="ms-unit">无数据</i></template>
</div>
<div class="ms-sub">{{ gmSub }}</div>
</div>
<div class="ms-card">
<div class="ms-detail-head"><span class="ms-label">达成率 / 风险分布</span></div>
<div class="ms-sub">
<template v-if="achievement != null">{{ achievementCount }} 个目标有实测值 · 其余无数据</template>
<template v-else>所有目标均无可用 KPI 实测值</template>
</div>
<div class="ms-sub">
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 风险 {{ summary.red }} · 共 {{ summary.total }} 个目标</template>
<template v-else>战略回顾数据未加载</template>
</div>
</div>
<div class="ms-card">
<div class="ms-detail-head"><span class="ms-label">口径变更 / 管理指标</span></div>
<div class="ms-foot">
<span v-if="switchInfo && switchInfo.has_switch" class="ms-badge warn" :title="switchInfo.note || ''">
{{ switchInfo.label }}<template v-if="switchInfo.effective_date"> · 自 {{ switchInfo.effective_date }}</template>
<em v-if="switchInfo.comparability"> · {{ switchInfo.comparability }}</em>
</span>
<span v-for="m in management" :key="m.key" class="mgmt" :title="m.caliber_note || ''">
<span class="mgmt-label">{{ m.label }}</span>
<span class="mgmt-value mono">{{ m.value == null ? '—' : m.value.toFixed(2) + '%' }}</span>
<span class="mgmt-tag">{{ m.tag }}</span>
<span v-if="m.abnormal" class="mgmt-abnormal">异常·须核查</span>
</span>
<span v-if="!(switchInfo && switchInfo.has_switch) && management.length === 0" class="ms-sub">无口径变更与管理指标</span>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
type RevLine = { key: string; label: string; amount: number | null; unit?: string; missing_reason?: string | null }
const props = defineProps<{
revenue: {
available?: boolean
caliber_label?: string
unit?: string
period?: string | null
lines?: RevLine[]
total?: number | null
total_label?: string
} | null
grossMargin: {
available?: boolean
value: number | null
caliber_label?: string
data_tag?: string
period?: string | null
note?: string
} | null
management?: Array<{
key: string
label: string
value: number | null
unit?: string
numerator?: number | null
denominator?: number | null
period?: string | null
tag?: string
abnormal?: boolean
caliber_note?: string
}>
achievement: number | null
achievementCount: number
summary: { total: number; green: number; yellow: number; red: number; health_score?: number } | null
switchInfo?: { has_switch?: boolean; label?: string; effective_date?: string | null; comparability?: string; note?: string } | null
loading?: boolean
/** ⑤ 目标·连线:当前地图目标数(null ⇒ 显式「—」) */
objectivesTotal?: number | null
/** ⑤ 目标·连线:当前地图连线数(null ⇒ 显式「—」) */
connectionsTotal?: number | null
}>()
const detailOpen = ref(false)
const revenueLines = computed<RevLine[]>(() => (props.revenue?.lines || []).filter(l => !!l && !!l.key))
/** 合计 = 各线相加的复算校验位,不与分线并列成第二套口径 */
const revenueTotal = computed<number | null>(() => {
const v = props.revenue?.total
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
})
const revenueTotalLabel = computed(() => props.revenue?.total_label || '合计(复算校验)')
const revenuePeriod = computed(() => {
const p = props.revenue?.period
if (!p) return '凭证期未取到 · 无数据'
return p + ' 实际值 · 各业务线全额售价分列(禁净额、禁合并)'
})
const gmValue = computed<number | null>(() => {
const v = props.grossMargin?.value
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
})
const gmSub = computed(() => {
if (gmValue.value == null) return '财务口径未取到 · 无数据'
const p = props.grossMargin?.period || ''
const cal = props.grossMargin?.caliber_label || ''
return [p ? p + ' 实际值' : '', cal].filter(Boolean).join(' · ')
})
const riskCount = computed<number | null>(() => (props.summary ? props.summary.red : null))
const achievementLevel = computed(() => {
const a = props.achievement
if (a == null) return 'gray'
if (a >= 90) return 'green'
if (a >= 60) return 'yellow'
return 'red'
})
/** 千分位展示;null ⇒ 「—」,不做任何兜底造数 */
function fmtNum(v: number | null | undefined): string {
if (v == null || Number.isNaN(Number(v))) return '—'
return Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 2, minimumFractionDigits: 0 })
}
</script>
<style scoped>
/* C 方案:单行汇总条(行高 40px + 上下 1px 边框 ≤44px) */
.map-summary {
flex-shrink: 0;
margin-bottom: 8px;
background: var(--bh-bg-card, #fff);
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px;
padding: 0 10px;
}
.ms-row {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
min-width: 0;
}
.ms-metric {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
white-space: nowrap;
}
.ms-metric--rev { flex: 0 1 auto; overflow: hidden; }
.ms-label {
font-size: 12px;
color: var(--bh-text-secondary, #64748B);
letter-spacing: .02em;
flex-shrink: 0;
}
.ms-value {
font-size: 17px;
font-weight: 700;
line-height: 1.1;
color: var(--bh-text-primary, #0F172A);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ms-value.muted { font-size: 13px; color: var(--bh-text-muted, #94A3B8); font-weight: 500; }
.ms-unit {
font-size: 10px;
font-weight: 500;
font-style: normal;
color: var(--bh-text-secondary, #64748B);
margin-left: 2px;
}
.ms-div { color: var(--bh-text-muted, #94A3B8); margin: 0 2px; }
.ms-sep { width: 1px; height: 18px; background: var(--bh-border, #E5E7EB); flex-shrink: 0; }
.ms-badge {
font-size: 10px;
line-height: 1.5;
padding: 0 5px;
border-radius: 4px;
border: 1px solid transparent;
white-space: nowrap;
flex-shrink: 0;
}
.ms-badge.calm { color: #0369A1; background: #F0F9FF; border-color: #BAE6FD; }
.ms-badge.warn { color: #B45309; background: #FFFBEB; border-color: #FDE68A; }
.ms-badge.warn em { font-style: normal; }
.ms-detail-btn {
margin-left: auto;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
height: 24px;
padding: 0 8px;
font-size: 11px;
color: var(--bh-text-secondary, #64748B);
background: transparent;
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 6px;
cursor: pointer;
}
.ms-detail-btn:hover { color: #0F172A; border-color: #CBD5E1; }
.ms-detail-btn svg { width: 12px; height: 12px; }
/* 明细展开区 */
.ms-detail {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
padding: 0 0 10px;
}
.ms-card {
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px;
padding: 8px 12px;
min-width: 0;
background: var(--bh-bg-subtle, #FCFCFD);
}
.ms-detail-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.rev-lines { display: flex; flex-direction: column; gap: 2px; }
.rev-line { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
.rev-line-label { font-size: 11px; color: var(--bh-text-secondary, #64748B); }
.rev-line-value { font-size: 12px; color: var(--bh-text-primary, #0F172A); }
.rev-line-value.muted { color: var(--bh-text-muted, #94A3B8); }
.rev-line--total { border-top: 1px dashed var(--bh-border, #E5E7EB); margin-top: 2px; padding-top: 2px; }
.ms-sub { margin-top: 4px; font-size: 11px; color: var(--bh-text-muted, #94A3B8); line-height: 1.5; }
.ms-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
.mgmt { display: inline-flex; align-items: baseline; gap: 4px; font-size: 11px; }
.mgmt-label { color: var(--bh-text-secondary, #64748B); }
.mgmt-value { color: var(--bh-text-primary, #0F172A); font-weight: 600; }
.mgmt-tag { font-size: 10px; padding: 0 4px; border-radius: 4px; background: #F1F5F9; color: #475569; }
.mgmt-abnormal { font-size: 10px; color: #B91C1C; }
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
.lv-green { color: #00C896; }
.lv-yellow { color: #F59E0B; }
.lv-red { color: #EF4444; }
/* 窄屏:单行改为可横向滚动,保留全部 5 项、不换行不截断 */
@media (max-width: 1280px) {
.ms-row { overflow-x: auto; }
}
</style>
@@ -0,0 +1,297 @@
<template>
<aside class="map-tree" aria-label="目标树">
<div class="tree-head">
<span class="tree-title">目标树</span>
<span class="tree-total">{{ visibleCount }} / {{ totalCount }} 个目标</span>
</div>
<div class="tree-scroll">
<section v-for="g in groups" :key="g.key" class="tree-group">
<button type="button" class="group-head" @click="$emit('toggle-group', g.key)">
<svg
class="chev" :class="{ folded: collapsed[g.key] }"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg>
<span class="group-dot" :style="{ background: g.color }"></span>
<span class="group-label">{{ g.label }}</span>
<span class="group-count">{{ g.objectives.length }}</span>
</button>
<div v-if="!collapsed[g.key]" class="group-body">
<div v-if="g.objectives.length === 0" class="group-empty">
<span>{{ search || filter !== 'all' ? '无匹配目标' : '该层暂无目标' }}</span>
<button type="button" class="link-btn" @click="$emit('add-objective', g.key)">去创建</button>
</div>
<div
v-for="o in g.objectives"
:key="o.key"
class="tree-node"
:class="{ active: activeKey === o.key, dimmed: !!search }"
draggable="true"
:title="'拖到画布中的另一个目标上可建立因果连线'"
@dragstart="onDragStart($event, o)"
@dragend="$emit('drag-end')"
@click="$emit('select', o)"
>
<div class="tn-row">
<button
v-if="o.krs && o.krs.length"
type="button"
class="kr-chev"
:class="{ open: expanded[o.key] }"
:aria-label="expanded[o.key] ? '收起关键结果' : '展开关键结果'"
@click.stop="toggleKr(o.key)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 6l6 6-6 6" />
</svg>
</button>
<span class="tn-level" :class="'dot-' + (o.level || 'gray')" :title="levelText(o.level)"></span>
<span class="tn-name" :title="o.name">{{ o.name }}</span>
</div>
<div class="tn-meta">
<span class="tn-rate mono" :class="rateClass(o)">{{ o.hasData && o.ratio != null ? Math.round(o.ratio) + '%' : '无数据' }}</span>
<span class="tn-sep">·</span>
<span>{{ o.kpiCount }} KPI</span>
<span class="tn-sep">·</span>
<span class="tn-owner" :title="o.owner">{{ o.owner || '未设负责人' }}</span>
</div>
<ul v-if="expanded[o.key] && o.krs && o.krs.length" class="kr-list" @click.stop>
<li v-for="(kr, ki) in o.krs" :key="ki" class="kr-item">
<span class="kr-idx mono">{{ ki + 1 }}</span>
<span class="kr-name" :title="kr.name">{{ kr.name || '未命名 KR' }}</span>
<span v-if="kr.target_value != null && kr.target_value !== ''" class="kr-target mono">目标 {{ kr.target_value }}</span>
</li>
</ul>
</div>
</div>
</section>
<div v-if="totalCount === 0" class="tree-empty">
<p>当前地图还没有任何目标</p>
<button type="button" class="link-btn" @click="$emit('add-objective', groups[0]?.key)">
去创建第一个目标
</button>
</div>
</div>
<div class="tree-foot">
<svg class="foot-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M12 3v6M12 15v6M3 12h6M15 12h6" /><circle cx="12" cy="12" r="3" />
</svg>
拖拽目标到画布可建立因果连线
</div>
</aside>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
interface TreeObjective {
key: string
index: number
name: string
owner?: string
level?: string
ratio: number | null
hasData: boolean
kpiCount: number
krs?: any[]
raw?: any
dimKey?: string
}
interface TreeGroup {
key: string
label: string
color: string
objectives: TreeObjective[]
}
const props = defineProps<{
groups: TreeGroup[]
search: string
filter: 'all' | 'risk' | 'nodata'
activeKey: string | null
collapsed: Record<string, boolean>
visibleCount: number
totalCount: number
}>()
const emit = defineEmits<{
(e: 'select', o: any): void
(e: 'toggle-group', key: string): void
(e: 'add-objective', key?: string): void
(e: 'drag-start', payload: { dimKey: string; index: number; obj: any; event: DragEvent }): void
(e: 'drag-end'): void
}>()
const expanded = reactive<Record<string, boolean>>({})
function toggleKr(key: string) {
expanded[key] = !expanded[key]
}
function rateClass(o: TreeObjective) {
if (!o.hasData || o.ratio == null) return 'rate-gray'
if (o.ratio >= 90) return 'rate-green'
if (o.ratio >= 60) return 'rate-yellow'
return 'rate-red'
}
function levelText(level?: string) {
if (level === 'green') return '达标'
if (level === 'yellow') return '关注'
if (level === 'red') return '风险'
return '无数据'
}
function onDragStart(e: DragEvent, o: TreeObjective) {
if (!o.dimKey) return
try {
e.dataTransfer?.setData('text/plain', o.key)
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'link'
} catch { /* 兼容受限浏览器 */ }
emit('drag-start', { dimKey: o.dimKey, index: o.index, obj: o.raw, event: e })
}
</script>
<style scoped>
.map-tree {
width: 272px;
flex: 0 0 272px;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bh-bg-card, #fff);
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
}
.tree-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 10px 12px 6px;
flex-shrink: 0;
}
.tree-title { font-size: 13px; font-weight: 700; color: #0F172A; }
.tree-total { font-size: 11px; color: #94A3B8; }
.tree-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 8px; }
.tree-group { margin-bottom: 6px; }
.group-head {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
padding: 5px 6px;
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: #475569;
text-align: left;
}
.group-head:hover { background: #F6F7F9; }
.group-dot { width: 8px; height: 8px; border-radius: 2px; }
.group-label { flex: 1; font-weight: 600; }
.group-count { font-size: 11px; color: #94A3B8; }
.chev { width: 12px; height: 12px; transition: transform .15s; color: #94A3B8; }
.chev.folded { transform: rotate(-90deg); }
.group-body { padding: 2px 0 4px; }
.group-empty {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
font-size: 11px;
color: #94A3B8;
}
.link-btn {
border: 0;
background: transparent;
color: #635BFF;
font-size: 11px;
cursor: pointer;
padding: 0;
}
.link-btn:hover { text-decoration: underline; }
.tree-node {
padding: 6px 8px 6px 10px;
border-radius: 6px;
border-left: 3px solid transparent;
cursor: pointer;
transition: background .12s;
}
.tree-node:hover { background: #F6F7F9; }
.tree-node.active { background: #F1F0FF; border-left-color: #635BFF; }
.tn-row { display: flex; align-items: center; gap: 5px; }
.tn-level { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot-green { background: #00C896; }
.dot-yellow { background: #F59E0B; }
.dot-red { background: #EF4444; }
.dot-gray { background: #CBD5E1; }
.tn-name {
font-size: 12.5px;
color: #0F172A;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tn-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
padding-left: 12px;
font-size: 11px;
color: #94A3B8;
white-space: nowrap;
overflow: hidden;
}
.tn-owner { overflow: hidden; text-overflow: ellipsis; }
.tn-rate { font-weight: 600; }
.rate-green { color: #00C896; }
.rate-yellow { color: #F59E0B; }
.rate-red { color: #EF4444; }
.rate-gray { color: #94A3B8; font-weight: 500; }
.kr-chev {
border: 0;
background: transparent;
padding: 0;
width: 12px;
height: 12px;
color: #94A3B8;
cursor: pointer;
flex-shrink: 0;
}
.kr-chev svg { width: 12px; height: 12px; transition: transform .15s; }
.kr-chev.open svg { transform: rotate(90deg); }
.kr-list { list-style: none; margin: 4px 0 0 19px; padding: 0; }
.kr-item {
display: flex;
align-items: baseline;
gap: 5px;
font-size: 11px;
color: #64748B;
line-height: 1.5;
}
.kr-idx { color: #94A3B8; }
.kr-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kr-target { color: #94A3B8; }
.tree-empty { padding: 20px 10px; text-align: center; font-size: 12px; color: #94A3B8; }
.tree-empty p { margin: 0 0 6px; }
.tree-foot {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
border-top: 1px solid #E5E7EB;
font-size: 11px;
color: #94A3B8;
}
.foot-ico { width: 12px; height: 12px; }
.mono { font-family: 'IBM Plex Mono', Consolas, monospace; font-variant-numeric: tabular-nums; }
</style>
@@ -1,8 +1,36 @@
<template>
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
<div class="layer-header" :style="{ background: color }">
<div
class="layer-header"
:class="{ 'is-focusable': true, 'is-focused': focused }"
:style="{ background: color }"
:title="focused ? '再次点击退出聚焦,恢复四层等宽' : '点击聚焦该层(其余三层压成单行摘要)'"
role="button"
tabindex="0"
:aria-pressed="focused ? 'true' : 'false'"
@click="$emit('focus')"
@keydown.enter.prevent="$emit('focus')"
@keydown.space.prevent="$emit('focus')"
>
<button
type="button"
class="layer-collapse-btn"
:aria-label="collapsed ? '展开该层' : '收起该层'"
:title="collapsed ? '展开该层' : '收起该层'"
@click.stop="$emit('toggle-collapse')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path :d="collapsed ? 'M9 6l6 6-6 6' : 'M6 9l6 6 6-6'" />
</svg>
</button>
<span class="layer-icon">{{ icon }}</span>
<span class="layer-name">{{ label }}</span>
<!-- 压缩态(C 方案聚焦模式):保「目标数 + 达成率色条」,整层信息不全隐 -->
<span v-if="compact" class="layer-mini" :aria-label="'该层达成率 ' + ratioText">
<span class="mini-track"><i class="mini-fill" :style="{ width: ratioBarWidth }"></i></span>
<span class="mini-ratio mono">{{ ratioText }}</span>
</span>
<span v-if="focused" class="layer-focus-flag">聚焦中</span>
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
<el-dropdown trigger="click" @command="onLayerAction">
<el-button class="layer-more-btn" size="small" text>
@@ -15,7 +43,7 @@
</template>
</el-dropdown>
</div>
<div class="layer-body" ref="bodyRef">
<div v-show="!collapsed" class="layer-body" ref="bodyRef">
<!-- vuedraggable: 每层独立group → 禁止跨层拖拽 -->
<draggable
v-if="objectives.length > 0"
@@ -23,6 +51,7 @@
:group="layerKey"
item-key="name"
:animation="150"
@start="onDragStart"
@end="onDragEnd"
class="layer-draggable"
>
@@ -30,7 +59,7 @@
<div class="layer-node-wrapper">
<MapNode
:key="`${layerKey}-${index}`"
:ref="el => setNodeRef(`${layerKey}-${index}`, el)"
:ref="el => setNodeRef(`${layerKey}-${index}`, el, element)"
:objective="element"
:node-key="`${layerKey}-${index}`"
:index="index"
@@ -44,7 +73,7 @@
:icon-map="iconMap"
:kpi-name-map="kpiNameMap"
:all-kpis="allKpis"
:chain-dimmed="chainNodeKeys.length > 0 && !chainNodeKeys.includes(`${layerKey}-${index}`)"
:chain-dimmed="isDimmed(`${layerKey}-${index}`)"
@click="onNodeClick(layerKey, index, element)"
@edit="$emit('edit-objective', { dimKey: layerKey, idx: index, obj: element })"
@delete="$emit('delete-objective', { dimKey: layerKey, idx: index })"
@@ -62,9 +91,9 @@
<!-- 空状态占位提示 -->
<div v-if="objectives.length === 0" class="layer-empty">
<el-empty description="暂无目标" :image-size="60">
<el-empty description="该层暂无目标" :image-size="60">
<el-button type="primary" size="small" @click="$emit('add-objective', layerKey)">
点击 + 添加目标
去创建目标
</el-button>
</el-empty>
</div>
@@ -78,7 +107,7 @@
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import { MoreFilled } from '@element-plus/icons-vue'
import draggable from 'vuedraggable'
import MapNode from './MapNode.vue'
@@ -98,6 +127,17 @@ const props = defineProps<{
allKpis?: any[]
/** 因果链查看模式下,不在链上的节点变淡 */
chainNodeKeys?: string[]
/** 搜索/筛选激活时:不在匹配集合内的节点变淡 */
dimActive?: boolean
dimmedKeys?: string[]
/** 层折叠(仅隐藏层体,保留层头) */
collapsed?: boolean
/** C 方案聚焦模式:其它层压成单行(层头 + 目标数 + 达成率色条) */
compact?: boolean
/** 该层达成率均值(无实测值传 null,界面显式「—」,不用示例数兜底) */
layerRatio?: number | null
/** 本层是否处于聚焦展开态 */
focused?: boolean
getNodeLevel: (key: string) => string
getNodeProgress: (key: string) => any
}>()
@@ -116,17 +156,65 @@ const emit = defineEmits<{
(e: 'update:label', val: string): void
(e: 'update:color', val: string): void
(e: 'update:icon', val: string): void
(e: 'toggle-collapse'): void
(e: 'reorder-start'): 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: 'add-kr', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'focus'): void
}>()
/** 压缩态达成率文案:无实测值显式「—」,禁止示例数字兜底 */
const ratioText = computed(() => {
const r = props.layerRatio
return r == null || Number.isNaN(Number(r)) ? '—' : `${Math.round(Number(r))}%`
})
/** 压缩态色条宽度(0–100%,无数据时 0 宽 + 灰色轨道) */
const ratioBarWidth = computed(() => {
const r = Number(props.layerRatio)
if (props.layerRatio == null || Number.isNaN(r)) return '0%'
return `${Math.max(0, Math.min(100, Math.round(r)))}%`
})
const bodyRef = ref<HTMLElement | null>(null)
// Node DOM refs for line calculation (reactive so parent can aggregate)
const nodeRefs = reactive<Record<string, any>>({})
function setNodeRef(key: string, el: any) {
if (el) nodeRefs[key] = el
/**
* 双键注册:`${layerKey}-${index}`(渲染键,历史契约)+ `${layerKey}:${目标名}`(数据键)。
* canvas_data.connections / map_connections 里的节点标识形如 `learning:贡献率思维全员培训`,
* 只注册渲染键会导致 ConnectionLines 全部取不到 DOM → 一条线都不画(P2 缺陷)。
* 同层同名目标:先到先得(与数据侧 `dim:name` 本身的歧义语义保持一致)。
* 卸载(el=null)时按 key 反查清理自己注册的数据键,避免残留指向已卸载 DOM(线画到 0,0)。
*/
const dataKeyOwner: Record<string, string> = {} // dataKey → 渲染键(谁注册的)
const ownerDataKey: Record<string, string> = {} // 渲染键 → dataKey
function setNodeRef(key: string, el: any, element?: any) {
// 组件 ref 给的是实例(不是 DOM),ConnectionLines 直接调 getBoundingClientRect
// ⇒ 统一归一化为 DOM 元素($el);取不到 DOM 视同卸载,走清理分支。
const dom: HTMLElement | null = el
? (el.nodeType === 1 ? el : (el.$el && el.$el.nodeType === 1 ? el.$el : null))
: null
if (!dom) {
const dk = ownerDataKey[key]
if (dk && dataKeyOwner[dk] === key) {
delete nodeRefs[dk]
delete dataKeyOwner[dk]
}
delete ownerDataKey[key]
delete nodeRefs[key]
return
}
nodeRefs[key] = dom
const nm = typeof element?.name === 'string' ? element.name : ''
if (nm) {
const dataKey = `${props.layerKey}:${nm}`
if (!nodeRefs[dataKey]) {
nodeRefs[dataKey] = dom
dataKeyOwner[dataKey] = key
ownerDataKey[key] = dataKey
}
}
}
// Expose nodeRefs to parent for connection line calculation
@@ -136,6 +224,17 @@ function onLayerAction(cmd: string) {
emit('layer-action', cmd)
}
/** 搜索/筛选/因果链三种变淡来源合并(任一命中即变淡) */
function isDimmed(key: string): boolean {
if (props.chainNodeKeys && props.chainNodeKeys.length > 0 && !props.chainNodeKeys.includes(key)) return true
if (props.dimActive && !(props.dimmedKeys || []).includes(key)) return true
return false
}
function onDragStart() {
emit('reorder-start')
}
/**
* 拖拽排序完成回调(由 vuedraggable/SortableJS 触发)
* 由于 :list 传递的是父级响应式数组的引用,SortableJS 已直接完成了数组重排,
@@ -164,7 +263,7 @@ export default {
border-radius: 12px;
display: flex;
flex-direction: column;
background: #fff;
background: var(--bh-bg-card);
min-height: 120px;
width: 100%;
box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
@@ -188,11 +287,76 @@ export default {
letter-spacing: 0.3px;
}
/* C 方案:层头可点击聚焦 */
.layer-header.is-focusable { cursor: pointer; }
.layer-header.is-focusable:focus-visible {
outline: 2px solid #fff;
outline-offset: -3px;
}
.layer-header.is-focused { box-shadow: inset 0 -2px 0 rgba(255, 255, 255, 0.55); }
.layer-focus-flag {
font-size: 10px;
font-weight: 500;
letter-spacing: .04em;
padding: 1px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.24);
flex-shrink: 0;
}
/* 压缩单行态:达成率色条 + 百分比(保信息不全隐) */
.layer-mini {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
min-width: 96px;
}
.mini-track {
width: 56px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.28);
overflow: hidden;
display: inline-block;
}
.mini-fill {
display: block;
height: 100%;
border-radius: 2px;
background: #fff;
transition: width .2s ease;
}
.mini-ratio {
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
opacity: .95;
}
.layer-icon {
font-size: 18px;
line-height: 1;
}
.layer-collapse-btn {
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
flex-shrink: 0;
}
.layer-collapse-btn:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
.layer-collapse-btn svg { width: 14px; height: 14px; }
.layer-name {
flex: 1;
}
@@ -136,12 +136,18 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessageBox } from 'element-plus'
import { getChainForConnection, getChainForNode } from '../../utils/chainGraph'
import { resolveEndpoints, relRectOf } from '../../utils/connKeys'
const props = defineProps({
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
layerRefs: { type: Object, required: true },
/** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */
nodeRefs: { type: Object, default: () => ({}) },
/**
* 服务端解析键(P3):与 connections 逐下标对齐的 [{from_key,to_key}]。
* key = `${层}-${序号}`,由 objective_id 反查而来 ⇒ 目标改名后线仍指向同一节点。
*/
connKeys: { type: Array, default: () => [] },
/** 层顺序列表 */
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1', effect, label}] */
@@ -328,26 +334,22 @@ function calcLayerConnections() {
// 节点坐标缓存(相对 svg):同一节点被多条连线引用时只 getBoundingClientRect 一次
const rectCache = {}
const origin = { left: svgRect.left, top: svgRect.top }
const getRect = (key) => {
if (rectCache[key]) return rectCache[key]
const el = props.nodeRefs[key]
if (!el) return null
const r = el.getBoundingClientRect()
rectCache[key] = {
left: r.left - svgRect.left,
top: r.top - svgRect.top,
right: r.right - svgRect.left,
bottom: r.bottom - svgRect.top,
width: r.width,
height: r.height,
}
if (!key) return null
if (key in rectCache) return rectCache[key]
// relRectOf:缺 ref / 已卸载 / 0×0(隐藏或未布局)一律 null —— 宁可不画,不画到 (0,0)
rectCache[key] = relRectOf(props.nodeRefs, key, origin) || null
return rectCache[key]
}
for (const conn of props.connections) {
const fr = getRect(conn.from)
const tr = getRect(conn.to)
if (!fr || !tr) continue
let skipped = 0
props.connections.forEach((conn, idx) => {
// P3:优先服务端解析键(objective_id 反查,改名不断线),缺失回落原标识串
const ep = resolveEndpoints(conn, idx, props.connKeys)
const fr = getRect(ep.from)
const tr = getRect(ep.to)
if (!fr || !tr) { skipped += 1; return }
// 源节点右侧中点 → 目标节点左侧中点
const x1 = fr.right
@@ -373,7 +375,11 @@ function calcLayerConnections() {
effect: conn.effect || 'positive',
label: conn.label || '',
})
}
})
// 悬停/拖拽期节点可能尚未注册(预取未回、改名后 DOM 重建)⇒ 有跳过就做有界重算,
// 而不是留一条静默消失的线(P2 暴露的「零绘制」同类问题)。
if (skipped > 0 && depth < 6) scheduleRetry(depth + 1)
layerConnections.value = result
}
+6 -4
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
ceo: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
finance: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
business: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/strategy-charter', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
}
export const ROLE_ACTIONS: Record<string, string[]> = {
@@ -26,6 +26,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ── GROUP 1: 规划与目标(Plan)──
{ path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], group: '🔵 P 规划与目标' },
{ path: '/strategy-charter', label: '战略章程', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/strategy-map', label: '战略地图看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
{ path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
@@ -39,6 +40,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ path: '/receivables', label: '应收款催收', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🟢 D 执行与控制' },
{ path: '/treasury-dashboard', label: '资金总览', icon: 'Money', roles: ['ceo','finance'], group: '🟢 D 执行与控制' },
{ path: '/treasury-payment', label: '资金内控', icon: 'Lock', roles: ['ceo','finance'], group: '🟢 D 执行与控制' },
{ path: '/ar-aging', label: '应收账龄', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' },
{ path: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/product-matrix', label: '波士顿产品矩阵', icon: 'PieChart', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
+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 } },
// /strategy-map: A区(只读活看板 —— 读库渲染,页内零写入口;2026-10-11 finance-20261011-strategy-map-frontend-fullstack)
{ path: 'strategy-map', name: 'StrategyMap', component: () => import('@/views/StrategyMap.vue'), meta: { title: '战略地图看板', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
// /strategy-charter: P 组·战略基座入口(使命/愿景/价值观;页内含 MyDialog 写入口 ⇒ B区)
{ path: 'strategy-charter', name: 'StrategyCharter', component: () => import('@/views/StrategyCharter.vue'), meta: { title: '战略章程', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
{ path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } },
{ path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
{ path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
@@ -60,6 +62,7 @@ const routes = [
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'], zone: 'B' } },
{ path: 'treasury-dashboard', name: 'TreasuryDashboard', component: () => import('@/views/TreasuryDashboard.vue'), meta: { title: '资金总览', roles: ['ceo','finance'], zone: 'B', editable: true } },
{ path: 'treasury-payment', name: 'TreasuryPayment', component: () => import('@/views/TreasuryPayment.vue'), meta: { title: '资金内控', roles: ['ceo','finance'], zone: 'B', editable: true } },
{ path: 'ar-aging', name: 'ArAging', component: () => import('@/views/ArAging.vue'), meta: { title: '应收账龄', roles: ['ceo', 'finance', 'business'], zone: 'A' } },
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
{ path: 'product-matrix', name: 'ProductMatrix', component: () => import('@/views/ProductMatrix.vue'), meta: { title: '波士顿产品矩阵', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
// /tax-compliance: B区(页内职责=税务记录/发票/社保人工录入维护;见盘点表)
+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>
File diff suppressed because it is too large Load Diff
+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>
<!-- ── 战略章程(P 战略基座:地图最顶层输入,只读;空态不显示,禁示例文案)── -->
<div v-if="charter" class="charter-strip">
<div class="cs-item cs-mission">
<span class="cs-k">使命</span>
<span class="cs-v">{{ charter.mission }}</span>
</div>
<div class="cs-item">
<span class="cs-k">愿景 · {{ charter.vision_year }}</span>
<span class="cs-v">{{ charter.vision }}</span>
</div>
<div class="cs-item cs-measurable">
<span class="cs-k">可验证状态</span>
<span class="cs-v">{{ charter.vision_measurable }}</span>
</div>
<router-link class="cs-link" to="/strategy-charter">查看章程</router-link>
</div>
<!-- ── 口径/统计条 ── -->
<div class="meta-bar">
<!-- 口径标识位(常驻,非 hover):与看板/KPI 列表共用同一组件与文案常量 -->
@@ -167,6 +184,7 @@ import {
Money, User, Setting, Reading, TrendCharts,
} from '@element-plus/icons-vue'
import { mapApi } from '../api/index'
import { charterApi } from '../api/index'
import CaliberBadge from '../components/CaliberBadge.vue'
import { caliberGapFor, currentCaliberEntityId, isCaliberKpiCode, type CaliberGapFacts } from '../constants/caliber'
@@ -175,6 +193,8 @@ const boardRef = ref<HTMLElement | null>(null)
const loading = ref(false)
const errorMsg = ref('')
const board = ref<any>(null)
// 战略章程(P 基座最顶层输入,只读;无行 → null,不显示任何文案)
const charter = ref<any>(null)
const period = ref<string>('')
const periods = ref<string[]>([])
const boardSize = ref({ w: 0, h: 0 })
@@ -317,6 +337,10 @@ function onResize() {
}
onMounted(async () => {
// 章程与地图并行取;章程失败不阻断地图(只读装饰项)
charterApi.get()
.then((res: any) => { if (res?.filled) charter.value = res.data })
.catch(() => { charter.value = null })
await load()
window.addEventListener('resize', onResize)
})
@@ -367,6 +391,16 @@ function doPrint() {
<style scoped>
.strategy-map-page { padding: 16px; background: #f5f7fa; min-height: 100%; }
/* 战略章程条(P 基座最顶层输入,只读) */
.charter-strip { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; padding: 10px 14px; margin-bottom: 10px; background: var(--bh-bg-card, #fff); border-left: 3px solid var(--bh-primary, #409eff); border-radius: var(--bh-radius-sm, 4px); box-shadow: var(--bh-shadow-sm, 0 1px 2px rgba(0,0,0,.06)); }
.cs-item { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.cs-k { flex: 0 0 auto; font-size: 12px; color: var(--bh-text-secondary, #909399); }
.cs-v { font-size: 13px; color: var(--bh-text-primary, #303133); max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-mission .cs-v { font-weight: 600; }
.cs-measurable .cs-v { color: var(--bh-text-regular, #606266); }
.cs-link { margin-left: auto; font-size: 12px; color: var(--bh-primary, #409eff); text-decoration: none; }
.cs-link:hover { text-decoration: underline; }
.page-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
.page-subtitle { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; color: #64748B; margin: 0; }
.title-icon { color: #64748B; font-size: 16px; }