Author SHA1 Message Date
Hermes CI Fix 7aebe96bb3 feat(deploy): T2 frontend-layer assertions after [3] (insert-only)
3 independent hard gates: (1) per-file md5 reconciliation dist/assets vs webroot/assets, (2) entry chunk from webroot/index.html must exist in dist with same md5, (3) every chunk referenced by the main bundle must return HTTP 200 from SITE_URL. Env overrides FRONTEND_DIST_DIR / CMA_WEBROOT / SITE_URL for offline stub rehearsal (no build). No push to main.
2026-09-18 02:13:19 +08:00
262 changed files with 2903 additions and 39824 deletions
+31 -21
View File
@@ -1,20 +1,19 @@
# CMA CI 门禁(单一部署通道)
#
# 本 workflow 只做「门禁」:前端 typecheck/build + 后端测试。
# 部署不走 CI —— 由宿主 Gitea post-receive hook(/root/cma-management/deploy.sh)承担,
# 避免 CI 与宿主 hook 双通道同时 git pull / systemctl restart 互相打架。
#
# 依赖安装走国内源:容器里直连 pypi.org 装 backend 依赖 >240s 未完成(2026-09-16 实测),
# 华为云镜像同依赖 14s。
when:
- branch: main
event: push
variables:
- &ssh_setup |
apk add --no-cache openssh-client rsync
mkdir -p ~/.ssh
printf '%s\n' "$SSH_DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H git.sxbh.ltd >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
steps:
frontend-install:
image: node:20-alpine
environment:
npm_config_registry: https://registry.npmmirror.com
commands:
- apk add --no-cache git
- cd frontend
@@ -25,8 +24,6 @@ steps:
frontend-typecheck:
image: node:20-alpine
environment:
npm_config_registry: https://registry.npmmirror.com
commands:
- cd frontend
- npm install -g pnpm
@@ -37,8 +34,6 @@ steps:
frontend-build:
image: node:20-alpine
environment:
npm_config_registry: https://registry.npmmirror.com
commands:
- cd frontend
- npm install -g pnpm
@@ -49,16 +44,31 @@ steps:
backend-test:
image: python:3.12
environment:
PIP_INDEX_URL: https://mirrors.huaweicloud.com/repository/pypi/simple/
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PIP_TIMEOUT: "30"
PIP_RETRIES: "5"
commands:
- cd backend
- pip install -r requirements.txt --quiet
# pytest/pytest-timeout 不在 requirements.txt 里,不装则下面一行永远 No module named pytest
- pip install pytest pytest-timeout --quiet
- python -m pytest tests/ -x -q --disable-warnings --timeout=60 || true
when:
- path: backend/**
frontend-deploy:
image: alpine:latest
secrets:
- SSH_DEPLOY_KEY
commands:
- *ssh_setup
- rsync -avz --delete frontend/dist/ root@git.sxbh.ltd:/var/www/cma/
- ssh root@git.sxbh.ltd 'nginx -s reload || systemctl reload nginx'
when:
- path: frontend/**
backend-deploy:
image: alpine:latest
secrets:
- SSH_DEPLOY_KEY
commands:
- *ssh_setup
- |
ssh root@git.sxbh.ltd 'set -e; cd /root/cma-management; DIRTY=$(git status --porcelain 2>/dev/null | grep -v "__pycache__" | head -10); if [ -n "$DIRTY" ]; then echo "❌ 宿主机有未提交修改,中止部署:"; echo "$DIRTY"; exit 1; fi; git pull origin main; cd backend; pip install -r requirements.txt --quiet --no-cache-dir; systemctl restart cma-backend; SMOKE_READY_MAX="${SMOKE_READY_MAX:-30}"; READY_TRY=0; READY_OK=0; READY_T0=$(date +%s); READY_BODY=/tmp/cma-ci-ready-body.json; while [ $(( $(date +%s) - READY_T0 )) -lt "$SMOKE_READY_MAX" ]; do CODE=$(curl -s -o "$READY_BODY" -w "%{http_code}" --max-time 2 http://127.0.0.1:8010/health || echo 000); if [ "$CODE" = "200" ] && grep -q "\"status\":\"ok\"" "$READY_BODY" 2>/dev/null; then READY_OK=1; break; fi; sleep 0.5; READY_TRY=$(( READY_TRY + 1 )); done; if [ "$READY_OK" != "1" ]; then echo "🚨 后端就绪超时 ${SMOKE_READY_MAX}s(未达 HTTP200 + status=ok)"; cat "$READY_BODY" 2>/dev/null; exit 1; fi; echo "✅ 后端就绪(轮询 ${READY_TRY} 次,上限 ${SMOKE_READY_MAX}s)"; cat "$READY_BODY"'
when:
- path: backend/**
+32 -269
View File
@@ -1,8 +1,7 @@
"""改善行动计划 API — 管理会计OS"""
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy import or_
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone
from typing import Optional
import re
import logging
@@ -15,9 +14,6 @@ from app.api.verify import build_auto_verify_rule
logger = logging.getLogger("cma.action_plans")
# 区分「未传该字段」与「显式传 null」的哨兵(update_plan 的 objective_id 口径,2026-09-18)
_UNSET = object()
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
@@ -63,40 +59,6 @@ def _validate_due_date_against_quarter(due_date: datetime, quarter: str):
f"KR截止日期({due.date()})超出本季度范围({q_end.date()}),最大截止为{q_end.date()}")
# ──────────────────────────────────────────────
# 「已逾期」派生口径(单一来源,2026-09-21 project-20260921-actionplan-overdue-filter)
# ──────────────────────────────────────────────
# 口径:overdue = status in (pending, in_progress) AND due_date < 今天0点
# (**日期粒度 · 口径 B**,任总 2026-09-22 10:10 拍板;当天到期不算逾期。
# 2026-09-21 首版为 datetime 粒度 `due_date < now`,本次仅此一处改为日粒度。)
#
# 为什么要有这个函数(缺陷根因):status 的取值域只有
# pending / in_progress / completed / cancelled —— **没有** overdue 这个值。
# 前端把统计卡片的 key('overdue')直接当 status 传给列表端点
# (frontend/src/views/ActionPlanLibrary.vue:414 卡片 key、:26 filterStatus、:422 params.status),
# 而 list_plans 原实现按 `ActionPlan.status == 'overdue'` 直查 → 恒 0 条,
# 于是出现「卡片 7 条 ≠ 列表 0 条」。
# 统计端点 plan_stats 早就用的是派生口径 —— 两处口径分家正是本次不一致的来源。
# 故把派生口径抽成本函数,plan_stats 与 list_plans **共用同一实现**(同源),
# 今后口径只此一处,不再出现「卡片 ≠ 列表」。
OVERDUE_KEY = "overdue"
OVERDUE_STATUSES = ("pending", "in_progress")
def _apply_overdue(query):
"""把「已逾期」派生口径施加到 ActionPlan 查询上(stats / list 共用)。
口径 B(日期粒度,任总 2026-09-22 拍板):due_date < **今天 0 点** —— 当天到期不算逾期。
与 cash.py::_plan_dict(应收催收 overdue)及前端行内角标
ActionPlanLibrary.vue::isOverdue 同粒度(二者本就是日期粒度,本单未动)。
"""
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
return query.filter(
ActionPlan.status.in_(list(OVERDUE_STATUSES)),
ActionPlan.due_date < today_start,
)
def plan_to_dict(p: ActionPlan) -> dict:
return {
"id": p.id,
@@ -135,12 +97,7 @@ def list_plans(
"""获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)"""
query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc())
if status == OVERDUE_KEY:
# 「已逾期」是**派生条件**而非 status 值(口径与 /stats 同源,2026-09-21)
# 前端统计卡片直接把 key='overdue' 当 status 传来,故此处按派生口径过滤,
# 与 plan_stats 共用 _apply_overdue,保证「卡片条数 = 列表条数」。
query = _apply_overdue(query)
elif status:
if status:
query = query.filter(ActionPlan.status == status)
if kpi_id:
query = query.filter(ActionPlan.kpi_id == kpi_id)
@@ -167,10 +124,7 @@ def list_plans(
item["kpi_dimension"] = kpi.dimension if kpi else None
# 附带KR信息(行动挂KR 2026-08-27)
if p.kr_id is not None:
# 账套隔离(2026-09-18 batch3 读侧同族残余):kr_id 可能指向对端账套 KR(存量脏行,
# 写入侧 create_plan/update_plan 已校验归属但旧行仍存在)→ 富化查询必须带账套,
# 否则列表跨账套回显对端 KR 标题
kr = db.query(KR).filter(KR.id == p.kr_id, KR.entity_id == entity_id).first()
kr = db.query(KR).filter(KR.id == p.kr_id).first()
item["kr_title"] = kr.title if kr else None
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
else:
@@ -210,25 +164,14 @@ def create_plan(
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
# 账套隔离: 关联Objective必须属于当前企业(2026-09-18 batch3 派生单)
# 旧实现只按 Objective.id 查询(仅用于季度校验),不校验 entity_id,
# 随后 ActionPlan(objective_id=...) 直接落库 → 本账套计划可挂到对端账套目标
# 校验截止日期与关联Objective的季度匹配
objective_id = data.get("objective_id")
if objective_id:
obj = db.query(Objective).filter(
Objective.id == objective_id, Objective.entity_id == entity_id).first()
if not obj:
raise HTTPException(404, "关联目标不存在")
# 校验截止日期与关联Objective的季度匹配
if due_date and obj.quarter:
if objective_id and due_date:
obj = db.query(Objective).filter(Objective.id == objective_id).first()
if obj and obj.quarter:
_validate_due_date_against_quarter(due_date, obj.quarter)
plan = ActionPlan(
# 账套隔离(2026-09-18 batch3 派生单):必须显式写会话账套 ——
# 旧实现不写 entity_id,落模型默认值 1(e2 会话创建的计划库内 entity_id=1,
# 实测 OBS1)。list/stats/update/delete 靠 join KPIDefinition 判账套,
# 但任何按 ActionPlan.entity_id 过滤的读路径(如 OKR 分解视图)会失效。
entity_id=entity_id,
alert_id=data.get("alert_id"),
kpi_id=data["kpi_id"],
objective_id=objective_id,
@@ -298,11 +241,9 @@ def update_plan(
plan_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
"""更新行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
@@ -314,35 +255,21 @@ def update_plan(
plan.assignee = data["assignee"]
if "priority" in data:
plan.priority = data["priority"]
# 目标/KR 归属口径(2026-09-18 batch3 派生单):
# ① 显式传 objective_id(含 null=显式置空)→ 以入参为准(优先于 kr_id 推导值)
# ② 未传 objective_id、且改挂合法 KR、且计划当前无目标 → 继承该 KR 的目标
# ③ 未传 objective_id 且计划已有目标 → 保持不动
# 两者都必须是当前账套的数据,否则 404;校验全部放在改动 plan 之前,
# 保证 404 请求对计划零改动(旧实现无归属校验,且末尾无条件覆盖推导值)
kr_ent = _UNSET
if "kr_id" in data and data["kr_id"] is not None:
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan 既有写法;2026-09-18 batch3)
kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
objective_change = _UNSET
if "objective_id" in data:
objective_change = data["objective_id"] # 口径①
elif kr_ent is not _UNSET and plan.objective_id is None:
objective_change = kr_ent.objective_id # 口径②
if objective_change is not _UNSET and objective_change is not None:
obj_ent = db.query(Objective).filter(
Objective.id == objective_change, Objective.entity_id == entity_id).first()
if not obj_ent:
raise HTTPException(404, "关联目标不存在")
# 校验全部通过后落值(口径③:objective_change 仍为 _UNSET 时目标保持不动)
if "kr_id" in data:
plan.kr_id = kr_ent.id if kr_ent is not _UNSET else None
if objective_change is not _UNSET:
plan.objective_id = objective_change
# 行动挂KR: 支持置空(null)或改挂
if data["kr_id"] is None:
plan.kr_id = None
else:
kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
plan.kr_id = kr_ent.id
if data.get("objective_id") is not None:
plan.objective_id = data["objective_id"]
elif plan.objective_id is None:
plan.objective_id = kr_ent.objective_id
if "objective_id" in data:
plan.objective_id = data["objective_id"]
if "due_date" in data:
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
if "status" in data:
@@ -358,10 +285,9 @@ def update_plan(
@router.delete("/{plan_id}")
def delete_plan(plan_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""删除行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
def delete_plan(plan_id: int, db: Session = Depends(get_db)):
"""删除行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
db.delete(plan)
@@ -370,19 +296,9 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db), entity_id: int = De
@router.get("/stats")
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id)):
"""行动计划统计(账套隔离: join KPI 按企业过滤, 2026-09-18 batch3)
口径说明(为何改):本端点原为全局统计(仅 business 角色按 assignee 过滤)→ 非 e1 账套
用户看到的是含其它账套计划的合计,与同文件列表端点 list_plans(join KPIDefinition 按账套)
的可见集不一致。现按同文件既有先例(update_plan/delete_plan 的 join(KPIDefinition))过滤,
保证「统计数 = 列表可见行数」。数据侧存在 action_plans 行 entity_id 与 KPI 归属不一致的
存量行(batch2 报告 §六.2),统一以 KPI 归属为准与列表口径一致。
"""
query = (db.query(ActionPlan)
.join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id)
.filter(KPIDefinition.entity_id == entity_id))
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth)):
"""行动计划统计"""
query = db.query(ActionPlan)
if current_user.role == "business":
query = query.filter(
(ActionPlan.assignee == current_user.username) |
@@ -392,7 +308,8 @@ def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(requi
pending = query.filter(ActionPlan.status == "pending").count()
in_progress = query.filter(ActionPlan.status == "in_progress").count()
completed = query.filter(ActionPlan.status == "completed").count()
overdue = _apply_overdue(query).count()
from datetime import datetime
overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.due_date < datetime.now()).count()
return {
"total": total,
"pending": pending,
@@ -529,157 +446,3 @@ def get_coso_checklist(
if not data:
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
return data
@router.post("/coso-generate-actions")
def generate_coso_actions(
request: Request,
data: Optional[dict] = Body(default=None),
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""COSO内控自检 → 生成改善行动(把自检「未通过」条目一键转为行动计划)
口径说明:
- 账套(多租户铁律):写入 entity_id 只取 token 绑定账套(get_entity_id),
body 里若含 entity_id 字段一律忽略;
- 数据源:与 get_coso_checklist 同口径(resolve_session_slug 按会话账套取同一份
自检)——屏幕上看到哪份自检,就用哪份生成,不做跨账套内容搬运;
- 关联 KPI:action_plans.kpi_id 是 NOT NULL 且列表按 KPI 归属做账套 join,
故必须挂本账套 KPI;
- 幂等:判重键 (entity_id, title),已存在的计入 skipped 不再 INSERT;
- auto_verify_rule 显式不生成:COSO 条目没有可复算的 KPI 判据。
"""
data = data or {}
# ① 取自检数据(与只读端点同口径解析 slug)
slug = resolve_session_slug(request, "hanke")
coso = COSO_CHECKLIST_DATA.get(slug)
if not coso:
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
# ② 待生成条目:passed 为真的一律不生成
failed_items = []
for el in coso["elements"]:
for item in el.get("items") or []:
if not item.get("passed"):
failed_items.append((el, item))
# ③ 无未通过条目 → 返回空结果结构(不算错误)
if not failed_items:
return {
"entity_id": entity_id,
"entity_slug": slug,
"kpi_id": None,
"kpi_name": None,
"failed_total": 0,
"created": 0,
"skipped": 0,
"created_ids": [],
"source": "coso",
}
# ④ 关联 KPI(必须,kpi_id NOT NULL 且列表按 KPI 归属做账套 join)
if data.get("kpi_id"):
# 显式指定:必须属于当前账套(跨账套 KPI 一律 404,不得跨账套写)
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == data["kpi_id"],
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "关联KPI不存在")
else:
# 自动选取(判据显式、可解释):
# a. 名称/编码含关键词(按序:内控/合规/COSO/control/compliance),id 最小者优先
# b. 都没有 → 本账套 id 最小的 KPI(确定性兜底)
# c. 本账套一个 KPI 都没有 → 400(无法满足 kpi_id NOT NULL)
kpi = None
for kw in ("内控", "合规", "COSO", "control", "compliance"):
kpi = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id)
.filter(or_(
KPIDefinition.kpi_name.like(f"%{kw}%"),
KPIDefinition.kpi_code.like(f"%{kw}%"),
))
.order_by(KPIDefinition.id)
.first())
if kpi:
break
if not kpi:
kpi = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id)
.order_by(KPIDefinition.id)
.first())
if not kpi:
raise HTTPException(400, "当前账套没有可关联的KPI,无法生成改善行动")
# ⑤ 截止日期:body 显式传则校验格式且不得早于今天;否则默认 +30 天
due_date = None
if data.get("due_date"):
try:
due_date = datetime.strptime(str(data["due_date"]), "%Y-%m-%d")
except (ValueError, TypeError):
raise HTTPException(400, f"due_date 格式非法: {data['due_date']}(应为 YYYY-MM-DD)")
if due_date.date() < datetime.now().date():
raise HTTPException(400, "due_date 不能早于今天")
else:
due_date = datetime.now() + timedelta(days=30)
# ⑥ 幂等判重键 (entity_id, title):带账套过滤 —— 对端账套同标题行不抑制本账套创建
existing_titles = {
p.title for p in db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).all()
}
created_plans = []
skipped_count = 0
for el, item in failed_items:
title = f"[COSO] {el['name']}·{item['text']}"[:200] # title 列宽 String(200)
if title in existing_titles:
skipped_count += 1
continue
existing_titles.add(title) # 本批次内自去重
score = el.get("score") or 0
if score < 40:
priority = "high"
elif score < 60:
priority = "medium"
else:
priority = "low"
plan = ActionPlan(
entity_id=entity_id,
kpi_id=kpi.id,
title=title,
# 来源标注便于回溯(自检哪条条目生成了这条行动)
description=(
f"来源: COSO内控自检 / {el['name']}({el['id']})/ 条目 {item['id']}: {item['text']}\n"
f"自检结论: {item.get('detail') or '未通过'}"
),
status="pending",
progress=0,
priority=priority,
due_date=due_date,
created_by=current_user.name or current_user.username,
# 不传 auto_verify_rule:该列是 MySQL json 列,显式传 None 会被落成 JSON null
# (JSON_TYPE()='NULL'、IS NOT NULL 为真)而不是 SQL NULL,会让 bot_iron_law 的
# auto_verify_rule.isnot(None) 把「没有规则」的行算成「有规则」。不赋值则 INSERT
# 省略该列 → 保持 SQL NULL。COSO 条目没有可复算判据,引擎对空规则是跳过(安全)。
)
db.add(plan)
created_plans.append(plan)
# ⑦ 单次 add 循环 + 一次 commit:任一异常向上抛,不留半批
db.commit()
created_ids = [p.id for p in created_plans]
return {
"entity_id": entity_id,
"entity_slug": slug,
"kpi_id": kpi.id,
"kpi_name": kpi.kpi_name,
"failed_total": len(failed_items), # 自检未通过条目总数
"created": len(created_ids), # 本次新建条数
"skipped": skipped_count, # 已存在(幂等命中)条数
"created_ids": created_ids, # 新建行 id 列表
"source": "coso",
}
+5 -20
View File
@@ -8,8 +8,6 @@ from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan, BudgetPlan, AISuggestion
from app.utils.cache import get as cache_get, set as cache_set
from app.utils.alert_entity_scope import month_period_clause
from app.utils.kpi_value_scope import actual_only_clause
import json, hashlib, httpx, os, urllib.request, urllib.parse
from datetime import datetime, date
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
@@ -104,7 +102,6 @@ def generate_rule_suggestions(db: Session, entity_id: int,
KPIValue.entity_id == entity_id,
KPIValue.entity_id.is_(None),
),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first()
if not latest or latest.actual_value is None:
continue
@@ -289,9 +286,7 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
KPIDefinition.status == "active").all()
kpi_summary = []
for k in kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
kpi_summary.append({
"name": k.kpi_name,
"code": k.kpi_code,
@@ -361,11 +356,7 @@ async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db),
# 跨企业 kpi_id 同样 404(多租户隔离 2026-09-07)
raise HTTPException(404, "KPI不存在")
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
# 实测区口径:预估行禁入 AI 趋势序列/LLM 上下文(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.period.asc()).all()
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
trend_data = []
for v in values:
@@ -464,9 +455,7 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe
).all()
kpi_summary = []
for k in kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
kpi_summary.append({
"name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension,
"target": k.target_value, "actual": latest.actual_value if latest else None,
@@ -516,9 +505,7 @@ async def ask_question(
).all()
kpi_context = []
for k in kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
alert = db.query(KPIAlert).filter(KPIAlert.kpi_id == k.id, KPIAlert.status == "pending").first()
kpi_context.append(
f"{k.kpi_name}({k.kpi_code}): 当前值={latest.actual_value if latest else '无'}"
@@ -586,9 +573,7 @@ async def review_plans(
for p in plans:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
kpi_name = kpi.kpi_name if kpi else "未知"
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == p.kpi_id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
latest = db.query(KPIValue).filter(KPIValue.kpi_id == p.kpi_id).order_by(KPIValue.period.desc()).first()
plan_text.append(
f"- {p.title}\n"
f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else '无'})\n"
+9 -41
View File
@@ -98,13 +98,9 @@ def list_suggestions(
@router.get("/{suggestion_id}")
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db)):
"""建议详情"""
s = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).first()
s = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not s:
raise HTTPException(404, "建议不存在")
return {"data": _sug_dict(s)}
@@ -118,10 +114,7 @@ def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
).first()
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
before = kpi.target_value
@@ -141,7 +134,6 @@ def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user
target_type="kpi",
target_id=kpi.id,
detail={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套,端点已校验=会话账套)
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "kpi_target",
@@ -161,10 +153,7 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
).first()
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
@@ -219,7 +208,6 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u
target_type="budget",
target_id=plan.id,
detail={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "budget_adjust",
@@ -238,10 +226,7 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单 2030b: 目标KPI须归属建议账套
).first()
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
@@ -253,7 +238,6 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
due_date = None
plan = ActionPlan(
entity_id=sug.entity_id, # 2026-09-18 派生单: 写建议归属账套(=会话账套,端点已校验)
kpi_id=kpi_id,
title=title,
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
@@ -280,7 +264,6 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
target_type="action_plan",
target_id=plan.id,
detail={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "action_plan",
@@ -307,21 +290,14 @@ def preview_suggestion(suggestion_id: int, db: Session = Depends(get_db),
- budget_adjust:{kpi_name, period, current_budget, new_budget}
- action_plan: {kpi_name, plan_title, assignee, priority, due_date}
"""
sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 预览须归属会话账套(原依赖未用于过滤)
).first()
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
sd = sug.suggestion_data or {}
kpi = None
kpi_id = sd.get("kpi_id") or sug.target_id
if kpi_id:
# 2026-09-18 派生单 2030b: 目标KPI须归属建议账套;对端KPI降级为 KPI#<id>(不回显名/目标值)
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == sug.entity_id,
).first()
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if sug.suggestion_type == "kpi_target":
return {"data": {
@@ -364,7 +340,6 @@ def apply_suggestion(
suggestion_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
@@ -374,10 +349,7 @@ def apply_suggestion(
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
"""
sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).first()
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
if sug.status == "applied":
@@ -413,14 +385,10 @@ def apply_suggestion(
def dismiss_suggestion(
suggestion_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""忽略建议"""
sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).first()
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
sug.status = "dismissed"
+42 -116
View File
@@ -14,18 +14,6 @@ from app.database import get_db, Base
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
from app.utils.oplog import log_op
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
# 「实测区 / 预估区」分区口径(财务裁定 2026-10-02):**预估行禁作预警依据**——
# 本文件的取数读路径一律加 actual_only_clause(),动态阈值白名单去掉 estimated。
from app.utils.kpi_value_scope import actual_only_clause
# G1(2026-10-17):资金缺口阈值的单一事实源(默认 20/10 万 + 参数校验)
from app.utils.cash_thresholds import (
CASH_GAP_RULE_TYPE,
validate_params as validate_cash_gap_params,
)
logger = logging.getLogger("alert_rules")
@@ -37,7 +25,6 @@ VALID_RULE_TYPES = (
"trend_down", # 环比下降
"forecast_deviation", # 预测偏差(升级2b 2026-08-25)
"customer_concentration", # 客户集中度(P2 2026-09-10)
"cash_gap_threshold", # 资金缺口阈值配置(G1 2026-10-17,配置行,不进预警生成链)
)
# ============================================================
@@ -136,12 +123,6 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
raise HTTPException(404, "KPI不存在")
if rule_type not in VALID_RULE_TYPES: # 升级2b: 预测偏差 / P2-20260910: 客户集中度
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
# G1(2026-10-17):资金缺口阈值为引擎级参数,入库前校验数值(非法配置一律拒收,
# 防「配置写坏 → 预警静默回落默认值」的假生效)。
if rule_type == CASH_GAP_RULE_TYPE:
err = validate_cash_gap_params(data.get("params"))
if err:
raise HTTPException(400, err)
rule = AlertRule(
entity_id=entity_id,
@@ -156,9 +137,10 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
db.refresh(rule)
# 日志
log_op(db, "create_alert_rule", target_type="alert_rule", target_id=rule.id,
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
entity_id=entity_id)
db.add(OperationLog(
action="create_alert_rule", target_type="alert_rule",
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
))
db.commit()
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
@@ -174,12 +156,6 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), e
for field in ("rule_type", "trigger_on", "enabled", "params"):
if field in data:
setattr(rule, field, data[field])
# G1(2026-10-17):改完仍是资金阈值配置行 → 参数必须合法(拒收非法值)
if rule.rule_type == CASH_GAP_RULE_TYPE:
err = validate_cash_gap_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}}
@@ -207,8 +183,6 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db), entity_id: int
rule_type = rule_data.get("rule_type", "static")
if rule_type not in VALID_RULE_TYPES:
continue
if rule_type == CASH_GAP_RULE_TYPE and validate_cash_gap_params(rule_data.get("params")):
continue
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi or kpi.entity_id != entity_id:
continue
@@ -283,30 +257,16 @@ def generate_default_rules(db: Session = Depends(get_db), entity_id: int = Depen
@router.post("/check-all")
def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)
R1(裁定 2026-09-19):demo 演示账套不参与预警生成 → 直接返回 skipped。
"""
if is_excluded_entity(db, entity_id):
logger.info("预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成", "generated": 0,
"skipped": True, "reason": "R1_demo_entity_excluded"}
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)"""
rules = db.query(AlertRule).filter(
AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all()
kpi_cache = {}
value_cache = {}
alerts_generated = 0
# R5(裁定 2026-09-19):期间非规整月(含伪期间 2026-H1/H2)→ 该行不入库,跳过并记数
skipped_non_month_period = 0
skipped_non_month_period_kpis = []
for rule in rules:
try:
# G1(2026-10-17):cash_gap_threshold 是资金引擎的参数配置行,不是 KPI 预警规则,
# 不进 KPIValue 比价链(否则未来若绑定到有值的 KPI 会误生成预警)。
if rule.rule_type == CASH_GAP_RULE_TYPE:
continue
kpi = kpi_cache.get(rule.kpi_id)
if kpi is None:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
@@ -344,12 +304,11 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
alerts_generated += 1
continue
# 获取最新值(仅实测区:预估行禁作预警依据,财务裁定 2026-10-02)
# 获取最新值
latest_value = value_cache.get(rule.kpi_id)
if latest_value is None:
latest_value = db.query(KPIValue).filter(
KPIValue.kpi_id == rule.kpi_id,
actual_only_clause(),
).order_by(KPIValue.period.desc()).first()
if latest_value:
value_cache[rule.kpi_id] = latest_value
@@ -373,14 +332,6 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db)
if alert_level and alert_level != "green":
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库。
# 注:`latest_value` 按 period 字符串倒序取,`2026-H1` 会排在 `2026-09` 之前 →
# 伪期间确实可能被取为「最新值」,此处是伪期间入库唯一的拦截点。
if not guard_alert_period(period, source="alert_rules.run_all_alert_checks",
detail="kpi=%s rule_id=%s" % (kpi.kpi_code, rule.id)):
skipped_non_month_period += 1
skipped_non_month_period_kpis.append("%s[%s]" % (kpi.kpi_code, period))
continue
# 检查是否已有相同预警
existing_alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == rule.kpi_id,
@@ -406,12 +357,7 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
continue
db.commit()
if skipped_non_month_period:
logger.info("R5 期间守卫:本次跳过 %s 条不可归期预警(%s)",
skipped_non_month_period, skipped_non_month_period_kpis)
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated,
"skipped_non_month_period": skipped_non_month_period,
"skipped_non_month_period_kpis": skipped_non_month_period_kpis}
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated}
@router.get("/dynamic-thresholds")
@@ -420,10 +366,8 @@ def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(g
query = db.query(DynamicThresholdCache).filter(DynamicThresholdCache.entity_id == entity_id)
if kpi_id:
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
rows = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
# 列名取自表元数据,值取自 ORM 行(原实现把 Column 对象当行取值,AttributeError: 'Comparator' object has no attribute 'id')
columns = [col.name for col in DynamicThresholdCache.__table__.columns]
return {"data": [{name: getattr(row, name) for name in columns} for row in rows]}
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
return {"data": [{c.name: getattr(c, c.name) for c in DynamicThresholdCache.__table__.columns} for c in cache]}
@router.post("/calculate-dynamic")
@@ -440,8 +384,7 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db), entity_id: int =
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
# 白名单去掉 estimated(财务裁定 2026-10-02):预估行不参与动态阈值计算
KPIValue.data_status.in_(["verified"]),
KPIValue.data_status.in_(["verified", "estimated"]),
KPIValue.period < current_period,
).order_by(KPIValue.period.desc()).limit(3).all()
@@ -552,11 +495,10 @@ def _check_trend(kpi_id: int, value: float, direction: str, params: dict, db: Se
"""趋势检查 — 环比变化"""
threshold_pct = params.get("threshold_pct", 10)
# 获取上月值(仅实测区:伪期间预估行会因字符串序顶掉上月实测值)
# 获取上月值
prev_value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
actual_only_clause(),
).order_by(KPIValue.period.desc()).offset(1).limit(1).first()
if not prev_value or not prev_value.actual_value or prev_value.actual_value == 0:
@@ -700,14 +642,7 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
@router.post("/check-forecast")
def run_forecast_alert_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
"""
if is_excluded_entity(db, entity_id):
logger.info("预测值预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)"""
generated = _check_forecast_alerts(db, entity_id=entity_id)
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
@@ -755,14 +690,7 @@ def run_forecast_deviation_check(
):
"""预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation
对每条偏差规则: 取最新预测log(kpi_forecast_log) vs 该期实际值(kpi_values),
偏差 > threshold_pct → 生成/更新 pending 预警(去重)
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
"""
if is_excluded_entity(db, entity_id):
logger.info("预测偏差检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
偏差 > threshold_pct → 生成/更新 pending 预警(去重)"""
from app.models import KpiForecastLog
rules = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id,
@@ -785,11 +713,9 @@ def run_forecast_deviation_check(
if not log or log.forecast_value is None:
continue
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
# 仅实测区:预估行不得与预测值自比(否则偏差恒 0,预警失效)
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == rule.kpi_id,
KPIValue.period == log.period,
actual_only_clause(),
).order_by(KPIValue.id.desc()).first()
if not actual or not actual.actual_value:
continue
@@ -838,37 +764,46 @@ def run_forecast_deviation_check(
# ============================================================
def _customer_revenue_shares(db: Session, entity_id: int, lookback_months: int = 12, top_n: int = 1):
"""聚合客户(往来单位)收入占比 — 数据源 zxt_masterbill(智享通**销售类**单据, unit_id=客户)
"""聚合客户(往来单位)收入占比 — 数据源 zxt_masterbill(智享通销售单据, unit_id=客户/往来单位)
返回 (shares, total, period_range):
返回 (shares, total, period_range):
shares = [(unit_id, amount, pct), ...] 按金额倒序取前 top_n 名
total = 窗口内客户收入合计
period_range = (start_date, end_date) ISO 字符串
无可用数据时返回 ([], 0.0, None)。
**口径单一事实源**:SQL 在 `app/utils/cpa_caliber.py`(CPA 派生器共用同一实现,
防「同一客户收入两份结果」)。用独立连接执行,避免表不存在/方言差异污染调用方 session 事务。
2026-10-04 P0 口径纠错(任务 finance-20261004T0905-cpa-revenue-caliber-p0):
① 增列**单据类型白名单** `bill_type IN (1,4,14)`(销售单/退货/换货)——非销售单据
(进货/验收/其它入库/预付款/收款/费用)的 `unit_id` 是**供应商**,混入会把
「客户集中度」度量成「往来单位集中度」(旧口径 Top1/Top2 恰是最大供应商);
② 取舍口径统一为 `(sum_money + sum_tax_money) > 0`(原 `money_only` 与之在本库
当前数据上**逐行同集、差集 0 行**,故行为**零变化**;原「17.2% 低估」表述
经财务复验证否,见 `cpa_caliber` 模块 docstring)。
预警阈值 50% 在新口径下仍不触发(Top1 22.52% < 50%)⇒ 阈值与分级不漂移。
用独立连接执行,避免表不存在/方言差异污染调用方 session 事务。
"""
from datetime import date, timedelta
from app.database import get_engine
from app.utils import cpa_caliber
end = date.today()
start = end - timedelta(days=max(1, int(lookback_months)) * 30)
shares, total, rng = cpa_caliber.fetch_revenue_rows(
get_engine(), entity_id, start, end, top_n)
if not shares:
sql = text("""
SELECT unit_id, SUM(sum_money) AS amount
FROM zxt_masterbill
WHERE entity_id = :eid
AND unit_id IS NOT NULL AND unit_id > 0
AND sum_money IS NOT NULL AND sum_money > 0
AND bill_date IS NOT NULL
AND bill_date >= :start AND bill_date <= :end
GROUP BY unit_id
ORDER BY amount DESC
""")
try:
with get_engine().connect() as conn:
rows = conn.execute(sql, {"eid": entity_id, "start": start, "end": end}).fetchall()
except Exception as e:
logger.error(f"客户收入聚合失败 entity_id={entity_id}: {e}")
return ([], 0.0, None)
return (shares, total, rng)
if not rows:
return ([], 0.0, None)
total = float(sum(float(r[1] or 0) for r in rows))
if total <= 0:
return ([], 0.0, None)
n = max(1, int(top_n))
shares = [(int(r[0]), float(r[1] or 0), round(float(r[1] or 0) / total * 100, 2)) for r in rows[:n]]
return (shares, total, (start.isoformat(), end.isoformat()))
def _check_customer_concentration(kpi_id: int, params: dict, db: Session, entity_id: int) -> tuple:
@@ -903,11 +838,9 @@ def _check_customer_concentration(kpi_id: int, params: dict, db: Session, entity
detail = f"{head}收入占比={share}%(合计{total:.0f}元,{rng[0]}~{rng[1]}:{items})"
if share is None and source in ("auto", "kpi"):
# 仅实测区:预估行禁作预警依据(财务裁定 2026-10-02)
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
actual_only_clause(),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
if latest:
share = round(float(latest.actual_value), 2)
@@ -928,14 +861,7 @@ def run_customer_concentration_check(
entity_id: int = Depends(get_entity_id),
):
"""客户集中度检查(P2 2026-09-10)— 遍历本企业 customer_concentration 规则,
计算客户收入占比,超阈值生成/更新 pending 预警(去重)。
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
"""
if is_excluded_entity(db, entity_id):
logger.info("客户集中度检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
计算客户收入占比,超阈值生成/更新 pending 预警(去重)。"""
rules = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id,
AlertRule.rule_type == "customer_concentration",
+17 -44
View File
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog, ActionPlan
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
import logging
logger = logging.getLogger("cma.alerts")
@@ -16,28 +16,18 @@ router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
@router.get("")
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离(2026-09-19 dama-20260919 C1):按**预警行自身** entity_id 过滤
# 修复前按 KPI 归属企业过滤(2026-08-23 P1b)→ KPI 属他账套而预警行属本账套的行反向泄漏
# (实测 entity 2 口径 225 → 223,泄漏 id=589/590)
query = db.query(KPIAlert).filter(KPIAlert.entity_id == entity_id)
# 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b)
query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == entity_id)
if status:
query = query.filter(KPIAlert.status == status)
total = query.count()
# 分页确定性(2026-09-25 alerts-list-pagination-skip):原为 created_at.desc() **单键** +
# offset/limit — kpi_alerts 每批 07:00 生成、**大量行同秒 created_at**,MySQL 并列组内顺序
# 不确定 ⇒ 偏移分页重复并漏行(entity 1 只读实测:total=281 / 逐页取满 281 行 / 唯一 id 仅 268 /
# 重复 13;id=587 在无过滤列表不可达,而同行 ?status=pending 首屏正常可见)。
# 补**确定性次键** id.desc()(同秒内后写入者在前)→ 全序分页,逐页遍历可覆盖全部 total 行。
alerts = (query.order_by(KPIAlert.created_at.desc(), KPIAlert.id.desc())
.offset((page-1)*20).limit(20).all())
alerts = query.order_by(KPIAlert.created_at.desc()).offset((page-1)*20).limit(20).all()
return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]}
@router.post("/{alert_id}/resolve")
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
alert.status = "resolved"
@@ -55,12 +45,9 @@ def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), enti
@router.post("/{alert_id}/process")
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""标记预警为处理中(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""标记预警为处理中"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.status == "resolved":
@@ -76,12 +63,9 @@ def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db), enti
@router.post("/{alert_id}/escalate")
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""升级预警级别(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""升级预警级别"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
assignee = data.get("assignee")
@@ -98,18 +82,11 @@ def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db), ent
@router.get("/check-timeout")
def check_alert_timeout(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色
账套隔离(2026-09-19 写路径跨账套修复):按**预警行自身** KPIAlert.entity_id 过滤;
修复前 join kpi_definitions 按 KPI 归属企业过滤 → KPI 属他账套而预警行属本账套的行
会被本账套调用**批量改写**(升级 red + 置 processing),比只读泄漏更严重
(2026-08 之前为全账套批处理;2026-09-18 加的 join 口径见 dama-20260919 派生单)。
"""
def check_alert_timeout(db: Session = Depends(get_db)):
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色"""
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24)
timeout_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
KPIAlert.created_at < cutoff,
KPIAlert.alert_level != "red",
@@ -125,18 +102,14 @@ def check_alert_timeout(db: Session = Depends(get_db), entity_id: int = Depends(
@router.post("/{alert_id}/create-action-plan")
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""从预警创建改善行动计划(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""从预警创建改善行动计划"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.action_plan_linked_id:
raise HTTPException(400, "已关联改善计划")
plan = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
alert_id=alert.id,
kpi_id=alert.kpi_id,
title=f"改善: {alert.alert_message}",
+16 -97
View File
@@ -3,15 +3,14 @@
管理员可初始化选择,后续按模式运作"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from sqlalchemy import func
from typing import Optional, List
from datetime import datetime
import json
from app.database import get_db
from app.auth_middleware import require_auth, require_role, require_auth_401
from app.deps import get_entity_id
from app.models import KPIDefinition, MapObjective, OperationLog, RolePermission, StrategicMap
from app.models import KPIDefinition, OperationLog, RolePermission
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
# 用户端点:router 级要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914);
@@ -20,30 +19,7 @@ router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
dependencies=[Depends(require_auth_401)],
)
# ── B3(a) 对齐模式拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
# 背景:`role_permissions.key` 为 unique 单行键 → `alignment_config` 全库一行,
# 任一账套的 ceo/it 改对齐模式 → **全账套生效**(跨账套配置污染)。
# 治本(方案 A,零 DDL):键名带账套后缀 → `alignment_config.<entity_id>`。
# · 读:只读本账套键;未配置 → configured=false(/tree 提示先初始化),
# **不回退旧全局键**(回退即恢复跨账套生效)。
# · 写:只写本账套键(entity 来自会话 token)。
# · 旧值搬运:脚本 scripts/migrate_cash_balance_key_entity_20260922.py --check/--apply
# (同一 500 口径:原全局值落到「当前生产账套」名下;--down 删新键并写回旧键)。
ALIGNMENT_KEY_PREFIX = "alignment_config"
def alignment_key(entity_id: int) -> str:
"""账套维度的对齐模式键名:alignment_config.<entity_id>"""
return f"{ALIGNMENT_KEY_PREFIX}.{int(entity_id)}"
def _get_alignment_perm(db: Session, entity_id: int):
"""读本账套的对齐模式配置行(不存在 → None)"""
return db.query(RolePermission).filter(
RolePermission.key == alignment_key(entity_id)
).first()
# 三种对齐模式定义
ALIGNMENT_MODES = [
{
"key": "vertical_decomposition",
@@ -73,25 +49,18 @@ def list_modes():
@router.get("/config")
def get_alignment_config(entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db)):
"""获取**当前账套**的对齐模式配置(B3(a) 拆账套,2026-09-22)
修复前:读全局唯一键 `alignment_config` → 任一账套改模式全平台生效。
修复后:只读 `alignment_config.<entity_id>`;未配置 → configured=false。
"""
perm = _get_alignment_perm(db, entity_id)
def get_alignment_config(db: Session = Depends(get_db)):
"""获取当前系统对齐模式配置(公开接口,无需认证)"""
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
if not perm:
return {
"mode": None,
"configured": False,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES,
}
return {
"mode": perm.value,
"configured": True,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES,
}
@@ -99,95 +68,45 @@ def get_alignment_config(entity_id: int = Depends(get_entity_id),
@router.post("/config")
def set_alignment_config(
data: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
user = Depends(require_role("ceo", "it")),
):
"""初始化/修改**当前账套**的对齐模式(CEO/IT权限)
B3(a)(2026-09-22):只写 `alignment_config.<entity_id>`,不覆盖他账套;
entity 来自会话 token,显式传他账套 entity_id → get_entity_id 403。
"""
"""初始化/修改系统对齐模式(CEO/IT权限)"""
mode_key = data.get("mode")
if mode_key not in [m["key"] for m in ALIGNMENT_MODES]:
raise HTTPException(400, f"无效的对齐模式: {mode_key}")
perm = _get_alignment_perm(db, entity_id)
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
if perm:
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat(),
"entity_id": entity_id}
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat()}
else:
perm = RolePermission(key=alignment_key(entity_id),
value={"mode": mode_key, "set_at": datetime.now().isoformat(),
"entity_id": entity_id})
perm = RolePermission(key="alignment_config", value={"mode": mode_key, "set_at": datetime.now().isoformat()})
db.add(perm)
db.commit()
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key, "entity_id": entity_id}
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key}
@router.get("/tree")
def get_alignment_tree(
kpi_id: Optional[int] = Query(None),
map_id: Optional[int] = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
user = Depends(require_auth),
):
"""获取KPI对齐关系树(按当前账套隔离)
"""获取KPI对齐关系树
根据当前系统配置的对齐模式,返回KPI的父子层级关系。
如果指定kpi_id,返回该KPI及其下级树;
如果不指定,返回整个对齐树。
账套隔离(2026-09-18 project-20260918-alignment-entity-leak):
只取 entity_id 归属本账套的 KPI(entity_id 来自会话 token 绑定,写法对齐
growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。
跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。
地图筛选(2026-10-01 project-20261001-align-tree-map-filter-fullstack):
`map_id` 参与**KPI 选择集**过滤,且只作用于 KPI 选择集——三种模式的构建函数不变。
过滤取「本账套该地图已关联的 KPI」,关联源按**权威程度**合并两条通道:
① `map_objectives.kpis`(**战略地图编辑器**写入的 KPI 编码数组,真源
`maps.py::_sync_map_objectives`;现网唯一有内容的通道);
② `KPIDefinition.map_id`(KPI 详情页「关联地图」写入;现网 329 行全为 NULL)。
两者取并集后与「本账套 `status='active'`」求交。跨账套/不存在的地图 id:先核该地图
归属本账套,不属本账套即返回**空树**(不泄漏他账套 KPI)。
不传 `map_id`:逐字段维持改前行为(本账套全部 active KPI)。
"""
# 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.<entity_id>)
config_perm = _get_alignment_perm(db, entity_id)
# 获取当前模式
config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
mode = config_perm.value.get("mode") if config_perm else None
if not mode:
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
# 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI)
kpi_query = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active"))
# 地图筛选:只缩小 KPI 选择集,不改动任何构建逻辑
empty_by_map = False
if map_id is not None:
owned = (db.query(StrategicMap.id)
.filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id)
.first())
if not owned:
empty_by_map = True # 跨账套 / 不存在 ⇒ 空树(不泄漏他账套 KPI)
else:
codes = set()
for (arr,) in (db.query(MapObjective.kpis)
.filter(MapObjective.map_id == map_id).all()):
if isinstance(arr, list):
codes.update(str(c) for c in arr if c)
if codes:
kpi_query = kpi_query.filter(or_(
KPIDefinition.map_id == map_id,
KPIDefinition.kpi_code.in_(sorted(codes)),
))
else:
kpi_query = kpi_query.filter(KPIDefinition.map_id == map_id)
kpis = [] if empty_by_map else kpi_query.order_by(KPIDefinition.kpi_code).all()
# 获取所有KPI
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").order_by(KPIDefinition.kpi_code).all()
kpi_map = {k.id: k for k in kpis}
# 构建父子关系
+28 -161
View File
@@ -1,26 +1,10 @@
"""置信度评分系统 — 财务Bot分析结论管理
账套维度(2026-09-23 账套维度收口批,任务 0-P1-project-20260923-entity-dimension-convergence
§二③ 读侧止血 + §三④ 账套化):
处置前:三个端点均无 `get_entity_id` ⇒ e1 / e2 双 token 拿到**逐字节相同**的返回体
(实测 2 行存量:`id=1 TEST001` / `id=2 kpi_code=NULL`)。
处置后:读/写/删均按会话账套判定归属;`entity_id` 列未落地时读侧退化为
「`kpi_code` JOIN `kpi_definitions.entity_id`」止血口径(DDL 与代码可分两步上线)。
归属三级(**任何一级都不猜账套**):
① `analysis_results.entity_id`(列已落地且非 NULL)
② 该行 `kpi_code` 在 `kpi_definitions` 中**唯一归属单账套**时所指向的 `entity_id`
③ 判不出归属(`kpi_code` 为空 / 库内无该码 / 该码跨账套二义 / 列为 NULL)→ 进
`unattributed` 登记池,**不返回给任何账套**;删除端点对其一律 404。
"""
"""置信度评分系统 — 财务Bot分析结论管理"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, inspect
from sqlalchemy import func
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth
from app.models import AnalysisResult, KPIValue, KPIDefinition, OperationLog
from app.utils.alert_entity_scope import month_period_clause
from app.models import AnalysisResult, KPIValue, KPIDefinition
from typing import Optional, List
from datetime import datetime
@@ -28,95 +12,6 @@ router = APIRouter(prefix="/api/cma/analysis", tags=["置信度评分"],
dependencies=[Depends(require_auth)],
)
# `analysis_results.entity_id` 列存在性缓存(键 = engine 对象 id;DDL 前后同一进程内可能变化,
# 故只缓存判定结果、不做进程级常量:迁移后重启服务即刷新)
_ENTITY_COL_CACHE: dict = {}
def _has_entity_column(db) -> bool:
"""`analysis_results.entity_id` 是否已落地(DDL 迁移 2026-09-23 未执行时为 False)。
读侧止血必须先于 DDL 合入 ⇒ 不能无条件引用该列;列不在时按 `kpi_code` JOIN 判归属。
"""
bind = db.get_bind()
key = id(bind)
cached = _ENTITY_COL_CACHE.get(key)
if cached is None:
try:
cols = {c["name"] for c in inspect(bind).get_columns(AnalysisResult.__tablename__)}
cached = "entity_id" in cols
except Exception: # noqa: BLE001 —— 结构探不通时按"列不存在"的保守口径走 join
cached = False
_ENTITY_COL_CACHE[key] = cached
return cached
def _kpi_owners(db, codes: List[Optional[str]]) -> dict:
"""`kpi_code` → 拥有该码的账套集合(同码可跨账套存在,故为集合)"""
codes = [c for c in codes if c]
if not codes:
return {}
owners: dict = {}
rows = (
db.query(KPIDefinition.kpi_code, KPIDefinition.entity_id)
.filter(KPIDefinition.kpi_code.in_(codes))
.all()
)
for code, eid in rows:
if eid is not None:
owners.setdefault(code, set()).add(eid)
return owners
def _row_owners(db, r, owners_map: dict) -> set:
"""单行归属账套集合;空集 = 判不出归属(不猜账套)
列口径:`entity_id` 非 NULL 即为唯一归属。
join 口径(列未落地 / 列为 NULL 的历史行):只有该 `kpi_code` 在 `kpi_definitions`
中**唯一归属单账套**时才认定——同码跨账套(库内 22 个码属两账套)属归属二义,
按「不猜账套」进登记池,宁可不可见也不误判给他账套。
"""
if _has_entity_column(db):
eid = getattr(r, "entity_id", None)
if eid is not None:
return {eid}
if r.kpi_code:
owners = owners_map.get(r.kpi_code, set())
return owners if len(owners) == 1 else set()
return set()
def _to_dict(r) -> dict:
return {
"id": r.id,
"period": r.period,
"结论": r.conclusion,
"置信度": f"{r.confidence}%",
"数据来源": r.data_source,
"计算逻辑": r.calculation_logic,
"可比基准": r.comparable_benchmark,
"局限": r.limitations,
"kpi_code": r.kpi_code,
"kpi_name": r.kpi_name,
"has_actual": bool(r.has_actual),
"has_target": bool(r.has_target),
"has_trend": bool(r.has_trend),
"has_review": bool(r.has_review),
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
}
def _unattributed_dict(r) -> dict:
"""无归属登记项:只回标识与元信息,不回结论正文(登记而非共享)"""
return {
"id": r.id,
"period": r.period,
"kpi_code": r.kpi_code,
"kpi_name": r.kpi_name,
"confidence": r.confidence,
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
}
def _calc_confidence(has_actual: bool, has_target: bool, has_trend: bool, has_review: bool) -> int:
"""基于数据完整度自动计算置信度"""
@@ -136,14 +31,8 @@ async def get_analysis_results(
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
kpi_code: Optional[str] = Query(None, description="KPI编码"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查询分析结论(按期间和/或KPI编码过滤)— 账套隔离(2026-09-23)
返回体字段:原有字段全保留(`total` 语义收窄为「本账套可见行数」,`results` 仍为列表),
新增 `entity_id` / `unattributed_total` / `unattributed` / `entity_scope`(均为附加字段,前端契约不破坏)。
无归属行(存量 id=1/2)进 `unattributed`:**对任何账套都不进 `results`**,只作登记。
"""
"""查询分析结论(按期间和/或KPI编码过滤)"""
query = db.query(AnalysisResult).order_by(AnalysisResult.created_at.desc())
if period:
@@ -152,34 +41,36 @@ async def get_analysis_results(
query = query.filter(AnalysisResult.kpi_code == kpi_code)
results = query.all()
owners_map = _kpi_owners(db, [r.kpi_code for r in results])
mine, unattributed = [], []
for r in results:
owners = _row_owners(db, r, owners_map)
if not owners:
unattributed.append(r)
elif entity_id in owners:
mine.append(r)
return {
"total": len(mine),
"total": len(results),
"period": period,
"entity_id": entity_id,
"entity_scope": "column" if _has_entity_column(db) else "kpi_join",
"unattributed_total": len(unattributed),
"unattributed": [_unattributed_dict(r) for r in unattributed],
"results": [_to_dict(r) for r in mine],
"results": [
{
"id": r.id,
"period": r.period,
"结论": r.conclusion,
"置信度": f"{r.confidence}%",
"数据来源": r.data_source,
"计算逻辑": r.calculation_logic,
"可比基准": r.comparable_benchmark,
"局限": r.limitations,
"kpi_code": r.kpi_code,
"kpi_name": r.kpi_name,
"has_actual": bool(r.has_actual),
"has_target": bool(r.has_target),
"has_trend": bool(r.has_trend),
"has_review": bool(r.has_review),
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
}
for r in results
],
}
@router.post("/result")
def create_analysis_result(data: dict = None, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""提交分析结果(自动计算置信度)— 账套维度:本次写入挂**会话账套**
`entity_id` 列未落地时(DDL 迁移前)先把会话账套写进 `OperationLog.detail.entity_id`,
列落地后直接写列(响应始终回 `entity_id`)。
"""
def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
"""提交分析结果(自动计算置信度)"""
if not data:
data = {}
period = data.get("period", "")
@@ -197,7 +88,6 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db),
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
has_column = _has_entity_column(db)
result = AnalysisResult(
period=period,
conclusion=conclusion,
@@ -213,25 +103,10 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db),
has_trend=1 if has_trend else 0,
has_review=1 if has_review else 0,
)
if has_column:
result.entity_id = entity_id
db.add(result)
db.commit()
db.refresh(result)
if not has_column:
# DDL 未落地前的过渡留痕:会话账套只存在于日志,不猜列
import json
db.add(OperationLog(
user_id=None, action="create", target_type="analysis_result",
target_id=result.id,
detail=json.dumps({"entity_id": entity_id, "kpi_code": kpi_code,
"period": period,
"note": "analysis_results.entity_id 列未落地,账套暂存于日志"},
ensure_ascii=False),
))
db.commit()
return {
"id": result.id,
"period": result.period,
@@ -242,8 +117,6 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db),
"可比基准": result.comparable_benchmark,
"局限": result.limitations,
"confidence_score": result.confidence,
"entity_id": entity_id,
"entity_persisted": "column" if has_column else "operation_log",
"message": "分析结论已保存",
}
@@ -252,16 +125,11 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db),
async def delete_analysis_result(
result_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""删除分析结论 — 归属校验:非本账套 或 无归属一律 **404**(不引入 403,避免探测面)"""
"""删除分析结论"""
result = db.query(AnalysisResult).filter(AnalysisResult.id == result_id).first()
if not result:
raise HTTPException(404, "分析结论不存在")
owners = _row_owners(db, result, _kpi_owners(db, [result.kpi_code]))
if entity_id not in owners:
# 他账套 id 与无归属行走同一文案(不暴露"该行存在但不属于你")
raise HTTPException(404, "分析结论不存在")
db.delete(result)
db.commit()
return {"message": "已删除"}
@@ -288,7 +156,6 @@ async def auto_calculate_confidence(
# 查找历史数据(趋势)
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(6).all()
has_actual = value is not None and value.actual_value is not None
+2 -5
View File
@@ -13,8 +13,6 @@ 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 KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
from app.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
logger = logging.getLogger("bi-reports")
@@ -162,8 +160,7 @@ def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
db.add(r)
db.commit()
db.refresh(r)
log_op(db, "create", target_type="bi_report", target_id=r.id,
detail=f"创建报表: {r.name}", entity_id=getattr(r, "entity_id", None))
db.add(OperationLog(action="create", target_type="bi_report", detail=f"创建报表: {r.name}"))
db.commit()
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
@@ -231,7 +228,7 @@ def analyze_data(data: dict, db: Session = Depends(get_db)):
if period_end:
val_query = val_query.filter(KPIValue.period <= period_end)
latest = val_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
latest = val_query.order_by(KPIValue.period.desc()).first()
# 获取趋势数据
trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all()
+36 -97
View File
@@ -9,7 +9,7 @@ from sqlalchemy import func, desc
from datetime import datetime
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id, require_bot_entity_id
from app.deps import get_entity_id
from app.models import (
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
@@ -20,9 +20,6 @@ from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models import KPICausality
from app.risk_levels import risk_level
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period, month_period_clause
from app.utils.oplog import log_op, SCOPE_GLOBAL
import json
logger = logging.getLogger("cma.bot_bridge")
@@ -135,8 +132,7 @@ def bot_kpis(
results = []
for k in kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period))\
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)\
.order_by(KPIValue.period.desc()).first()
results.append({
"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
@@ -167,8 +163,7 @@ def bot_kpi_history(
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi or kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period))\
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
.order_by(KPIValue.period.desc()).limit(limit).all()
return {
"kpi": {"id": kpi.id, "name": kpi.kpi_name, "code": kpi.kpi_code, "unit": kpi.unit},
@@ -668,8 +663,7 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
kpi_id = data.get("kpi_id")
actual_value = data.get("actual_value")
period = data.get("period")
# ASK-20260924-17(任总 2026-09-24 放行):Bot 通道缺省账套 = 报错,不再静默落 entity 1
entity_id = require_bot_entity_id(data, source="bot_bridge.kpi-value-with-check")
entity_id = int(data.get("entity_id") or 1)
run_check = bool(data.get("run_check", True))
if not kpi_id or actual_value is None:
raise HTTPException(400, "kpi_id 和 actual_value 必填")
@@ -679,10 +673,6 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
raise HTTPException(404, "KPI不存在")
period = period or datetime.now().strftime("%Y-%m")
# R1(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge.kpi-value-with-check",
detail="kpi_id=%s" % kpi_id)
# ① 写值(upsert)
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, KPIValue.period == period).first()
@@ -699,9 +689,7 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
# ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量)
alerts = []
# R5(裁定 2026-09-19):期间非规整月(含伪期间)→ 不入库,跳过并记数
skipped_non_month_period = 0
if run_check and alerts_allowed:
if run_check:
from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend
rules = db.query(AlertRule).filter(
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id,
@@ -721,11 +709,6 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
else:
continue
if level and level != "green":
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 → 该 kpi_alerts 行不入库
if not guard_alert_period(period, source="bot_bridge.kpi-value-with-check",
detail="kpi_id=%s rule_id=%s" % (kpi_id, rule.id)):
skipped_non_month_period += 1
continue
dup = db.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
@@ -737,13 +720,8 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
except Exception as e:
logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
db.commit()
resp = {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
"period": period, "alerts": alerts, "status": "ok",
"skipped_non_month_period": skipped_non_month_period}
if not alerts_allowed:
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警(值照写、预警跳过)
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
return resp
return {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
"period": period, "alerts": alerts, "status": "ok"}
@router.post("/kpis/create-with-links")
@@ -756,7 +734,7 @@ def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: di
考核基准类(目标值)= 人拍板;target_value 一律显式置 0(满足治理必填,
后续由人工设定或走 POST /api/cma/bot/ai/suggestions 建议通道待审批)。
"""
entity_id = require_bot_entity_id(data, source="bot_bridge.kpis.create-with-links")
entity_id = int(data.get("entity_id") or 1)
from app.api.kpis import _validate_kpi_data, apply_calc_type_inference
kpi_data = {k: v for k, v in data.items() if k not in ("entity_id", "link_map_id", "link_causality")}
@@ -975,11 +953,11 @@ def bot_dict_zxt_account(
before = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
db.execute(stmt, clean)
after = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
log_op(db, "import", target_type="zxt_account_dict", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
"upserted": len(clean), "before": before, "after": after, "source": source},
# zxt_account_dict = 智享通平台级科目字典(无 entity 列)⇒ 显式声明非账套维度
entity_id=None, scope=SCOPE_GLOBAL)
db.add(OperationLog(
action="import", target_type="zxt_account_dict", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
"upserted": len(clean), "before": before, "after": after, "source": source},
))
db.commit()
except HTTPException:
raise
@@ -1040,16 +1018,13 @@ def _norm_subj_active(v):
return None, f"is_active 只允许 0/1(实际 {v!r})"
def _validate_subject_rows(rows, db, entity_id: int = None):
def _validate_subject_rows(rows, db):
"""行级全量校验(任一错误 → 400 且零落库),返回标准化行 [{字段}]
规则(任务 dama-cma-subjects-write-endpoint-20260912):
subject_code 非空且 ≤_SUBJ_CODE_MAXLEN(=20,DB varchar(20) 列宽)+ 批次内去重;subject_name 非空(DB NOT NULL)且 ≤200;
level ∈ {1,2,3}(缺省 1);parent_code 为空或存在于本表(或本批次内,支持层级同批导入);
category 限枚举;is_active ∈ {0,1}(缺省 1)。
账套维度(2026-09-23 `subjects` 分层):parent_code 存在性只认
**全局科目(`entity_id IS NULL`)∪ 本会话账套私有科目**——他账套私有父科目不构成合法父级。
"""
if not isinstance(rows, list) or not rows:
raise HTTPException(400, '请求体必须是非空行数组: {"subjects": [{...}]}')
@@ -1141,13 +1116,11 @@ def _validate_subject_rows(rows, db, entity_id: int = None):
if not bad:
clean.append(item)
# parent_code 存在性:库内(本表,按账套分层)∪ 本批次
# parent_code 存在性:库内(本表)∪ 本批次
unknown_parents = sorted(parents - set(seen.keys()))
if unknown_parents:
pq = db.query(Subject.subject_code).filter(Subject.subject_code.in_(unknown_parents))
if entity_id is not None:
pq = pq.filter((Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id))
found = {c for (c,) in pq.all()}
found = {c for (c,) in db.query(Subject.subject_code)
.filter(Subject.subject_code.in_(unknown_parents)).all()}
missing = [p for p in unknown_parents if p not in found]
if missing:
errs.append(f"parent_code 不存在于 subjects 表且不在本批次内: {missing}")
@@ -1190,83 +1163,50 @@ def bot_subjects_upsert(
bot: dict = Depends(verify_bot_key),
payload: dict = Depends(_read_subjects_body),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""会计科目写入(subjects 幂等 UPSERT,单事务;绝不 DELETE)
body: {"subjects": [{"subject_code","subject_name","parent_code","level","category","is_active"}],
"dry_run": false, "update_existing": false}
账套(`entity_id`)来源:会话账套(`get_entity_id`:token → query/header `X-Entity-Id`/`entity_id` →
默认 1);**不接受 body 内的 entity_id**(Bot 通道按 header/query 显式传,防越权落错账套)。
- 唯一键口径(2026-09-23 `subjects` 分层):`(entity_id, subject_code)`——同账套内同码才视为「已存在」;
不同账套可各有同码科目(如两账套都有 `1001 库存现金`)
- 全量校验通过才落库;任一非法行 → 400 且库内零变化
- 已存在行(同码**可见**=全局标准科目 ∪ 本账套私有行):update_existing=false → skip(幂等默认);
true → 更新 name/parent/level/category —— **但只更新本账套私有行**;
同码若只以**全局标准科目**(`entity_id IS NULL`)存在,则**永不被账套 UPSERT 改写**(计入 `skipped_global`),
同时也不再插入本账套同码副本(避免标准科目重复)。
- **幂等语义(2026-09-24 R6 收口,判据勿误读)**:`update_existing=true` ⇒ **批内既存行全量重写**
(逐行 UPDATE,不做值级 diff)⇒ 返回的 `updated=N` 是「**批内既存行数**」,**不是**「值发生变化的行数」,
且**含本轮 `inserted` 的新行**:同一 payload 连跑两次,首跑 `inserted=k, updated=m`,
复跑 `inserted=0, updated=k+m`(实测例:首跑 `inserted=5/updated=2` → 复跑 `updated=7`,
见 `operation_logs` detail 原文 id=347/348)。此形态**属既定行为、非异常写入**:库内不产生重复行
(唯一键 `(entity_id, subject_code)` + 只 UPDATE 不 DELETE/INSERT)。幂等判据取 `inserted=0`
且 `before == after`(叠加 `COUNT(*)`/`MAX(id)` 不变),**不得用 `updated` 判幂等**。
- 已存在行:update_existing=false → skip(幂等默认);true → 更新 name/parent/level/category
- dry_run=true → 只校验与回显,零写入
返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"skipped_global":g,"entity_id":e,"errors":[]};未授权 401。
返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"errors":[]};未授权 401。
"""
clean = _validate_subject_rows(payload.get("rows"), db, entity_id=entity_id)
clean = _validate_subject_rows(payload.get("rows"), db)
dry_run = payload.get("dry_run", False)
update_existing = payload.get("update_existing", False)
codes = [r["subject_code"] for r in clean]
existing = set() # 本会话「可见」的同码 = 全局标准科目(NULL)∪ 本账套私有行
global_only = set() # 只以全局行(entity_id IS NULL)存在的同码:可见,但**不许被账套 UPSERT 改写**
existing = set()
if codes:
rows = (db.query(Subject.subject_code, Subject.entity_id)
.filter(Subject.subject_code.in_(codes),
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id))
.all())
by_code = {}
for c, eid in rows:
by_code.setdefault(c, set()).add(eid)
existing = set(by_code)
global_only = {c for c, eids in by_code.items() if eids == {None}}
existing = {c for (c,) in db.query(Subject.subject_code)
.filter(Subject.subject_code.in_(codes)).all()}
to_insert = [r for r in clean if r["subject_code"] not in existing]
existed = [r for r in clean if r["subject_code"] in existing]
# 可被本账套更新的只有「本账套私有行」;全局标准科目(NULL)一律不动(跨账套共享口径)
updatable = [r for r in existed if r["subject_code"] not in global_only]
to_update = updatable if update_existing else []
updated_codes = {r["subject_code"] for r in to_update}
to_skip = [r for r in existed if r["subject_code"] not in updated_codes]
skip_global = [r for r in to_skip if r["subject_code"] in global_only]
to_update = existed if update_existing else []
to_skip = [] if update_existing else existed
before = db.query(func.count(Subject.id)).scalar() or 0
if dry_run:
logger.info(f"subjects UPSERT dry_run: bot={bot.get('name')} entity={entity_id} rows={len(clean)} "
logger.info(f"subjects UPSERT dry_run: bot={bot.get('name')} rows={len(clean)} "
f"would_insert={len(to_insert)} would_update={len(to_update)} "
f"would_skip={len(to_skip)}")
return {"ok": True, "dry_run": True, "applied": False, "table": "subjects",
"entity_id": entity_id,
"before": before, "after": before,
"inserted": 0, "updated": 0, "skipped": 0, "upserted": 0,
"would_insert": len(to_insert), "would_update": len(to_update),
"would_skip": len(to_skip), "validated": len(clean),
"would_skip_global": len(skip_global),
"errors": [], "update_existing": update_existing}
try:
for r in to_insert:
db.add(Subject(subject_code=r["subject_code"], subject_name=r["subject_name"],
parent_code=r["parent_code"], level=r["level"],
category=r["category"], is_active=r["is_active"],
entity_id=entity_id)) # 账套维度:落本会话账套(不再写全局 NULL 行)
category=r["category"], is_active=r["is_active"]))
for r in to_update:
db.query(Subject).filter(
Subject.subject_code == r["subject_code"],
Subject.entity_id == entity_id, # 只更新本账套私有行(全局科目不被他账套改写)
).update({
db.query(Subject).filter(Subject.subject_code == r["subject_code"]).update({
Subject.subject_name: r["subject_name"],
Subject.parent_code: r["parent_code"],
Subject.level: r["level"],
@@ -1274,12 +1214,13 @@ def bot_subjects_upsert(
}, synchronize_session=False)
db.flush()
after = db.query(func.count(Subject.id)).scalar() or 0
log_op(db, "import", target_type="subjects", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
"skipped": len(to_skip), "before": before, "after": after,
"update_existing": update_existing, "source": "json"},
entity_id=entity_id)
db.add(OperationLog(
action="import", target_type="subjects", target_id=None,
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
"skipped": len(to_skip), "before": before, "after": after,
"update_existing": update_existing, "source": "json"},
))
db.commit()
except HTTPException:
raise
@@ -1291,13 +1232,11 @@ def bot_subjects_upsert(
if after != before + len(to_insert):
logger.warning(f"subjects UPSERT 行数口径差异: before={before} after={after} "
f"inserted={len(to_insert)}")
logger.info(f"subjects UPSERT: bot={bot.get('name')} entity={entity_id} inserted={len(to_insert)} "
logger.info(f"subjects UPSERT: bot={bot.get('name')} inserted={len(to_insert)} "
f"updated={len(to_update)} skipped={len(to_skip)} before={before} after={after}")
return {"ok": True, "dry_run": False, "applied": True, "table": "subjects",
"entity_id": entity_id,
"before": before, "after": after,
"inserted": after - before, "updated": len(to_update), "skipped": len(to_skip),
"skipped_global": len(skip_global),
"upserted": (after - before) + len(to_update), "errors": [],
"update_existing": update_existing}
+5 -63
View File
@@ -18,15 +18,12 @@ from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.deps import require_bot_entity_id
from app.models import (
MpmResult, BotBridgeConfig,
KPIDefinition, KPIValue, KPIAlert,
ActionPlan, Entity, OperationLog,
ActionPlan, Entity,
)
from app.risk_levels import risk_level
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
logger = logging.getLogger("cma.bot_bridge_v2")
@@ -310,12 +307,6 @@ def receive_mpm_result(
if not entity:
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
# R1/R5(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警;
# 期间非规整月(含 2026-H1/H2 伪期间)的 kpi_alerts 行一律不入库(跳过 + 记日志)。
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge_v2.receive_mpm_result")
period_attributable = guard_alert_period(period, source="bot_bridge_v2.receive_mpm_result",
detail="entity_id=%s" % entity_id)
# ── 1. 写入MPM结果 ──
record = MpmResult(
entity_id=entity_id,
@@ -365,7 +356,6 @@ def receive_mpm_result(
# ── 3. 触发预警 ──
alerts = []
alerts_skipped_no_kpi = [] # B3(b): KPI 定义缺失而跳过的预警(留痕用)
for field, config in ALERT_THRESHOLDS.items():
field_value = results.get(field)
if field_value is None:
@@ -377,20 +367,7 @@ def receive_mpm_result(
if (operator == ">" and field_value > threshold) or \
(operator == ">=" and field_value >= threshold):
# R1/R5 生成侧守卫(裁定 2026-09-19):demo 账套 / 不可归期期间 → 该行不入库
if not alerts_allowed:
logger.info("R1 账套守卫:跳过不入库(demo 演示账套)entity_id=%s", entity_id)
continue
if not period_attributable:
logger.info("R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r", period)
continue
# 查找关联KPI
# B3(b)(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922):
# 修复前 `kpi_id=kpi_id or 1` —— KPI 定义缺失时把预警错挂到「全库第 1 个 KPI」
# (该 KPI 可能属他账套:预警 entity_id 正确、KPI 维度却跨了账套)。
# 现改为**显式校验**:拿不到本账套的 KPI 定义 → 不建预警(跳过)+ 落 OperationLog 留痕。
# 为什么不是 `kpi_id=None`:`kpi_alerts.kpi_id` 为 NOT NULL(models/__init__.py:145),
# batch5 §B3(b) 方案 A(保留可见性)在本 schema 下不可行;方案 B(跳过 + 审计)落地。
kpi_id = None
if kpi_code:
kpi_def = db.query(KPIDefinition).filter(
@@ -400,38 +377,11 @@ def receive_mpm_result(
if kpi_def:
kpi_id = kpi_def.id
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
if kpi_id is None:
# 宁缺勿错:不入库,但必须留痕(防「预警静默丢失」)
alerts_skipped_no_kpi.append(f"{kpi_name}(kpi_code={kpi_code!r})")
db.add(OperationLog(
user_id=None,
action="skip_alert",
target_type="alert",
target_id=None,
detail={
"reason": "kpi_definition_not_found_for_entity",
"kpi_code": kpi_code,
"kpi_name": kpi_name,
"entity_id": entity_id,
"period": period,
"field": field,
"field_value": field_value,
"threshold": threshold,
"source": "bot_bridge_v2.mpm-result",
},
))
logger.warning(
"B3(b) 跳过建预警(KPI定义缺失,不再回落 kpi_id=1): kpi_code=%r entity_id=%s period=%s",
kpi_code, entity_id, period,
)
continue
# 创建预警
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
alert = KPIAlert(
entity_id=entity_id, # 2026-09-13 F1: 显式写归属
kpi_id=kpi_id, # B3(b): 显式校验后的本账套 KPI(禁止 or 1 兜底)
kpi_id=kpi_id or 1, # fallback to first KPI if not found
alert_level="red",
alert_message=alert_msg,
alert_type="actual",
@@ -444,24 +394,16 @@ def receive_mpm_result(
db.commit()
db.refresh(record)
resp = {
return {
"success": True,
"kpi_updated": len(kpi_updates),
"alerts_triggered": len(alerts),
# B3(b): 因本账套无对应 KPI 定义而跳过的预警(已落 OperationLog,非静默丢失)
"alerts_skipped_missing_kpi": len(alerts_skipped_no_kpi),
"mpm_record_id": record.id,
"details": {
"kpi_codes": kpi_updates,
"alerts": alerts,
"alerts_skipped_missing_kpi": alerts_skipped_no_kpi,
},
"skipped_non_month_period": (not period_attributable),
}
if not alerts_allowed:
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
return resp
@router.post("/kpi-result")
@@ -487,7 +429,7 @@ def push_kpi_result(
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts
"""
entity_id = require_bot_entity_id(data, source="bot_bridge_v2.kpi-values")
entity_id = data.get("entity_id", 1)
kpi_code = data.get("kpi_code")
period = data.get("period")
value = data.get("value")
+1 -5
View File
@@ -8,7 +8,6 @@ from app.database import get_db
from app.auth_middleware import require_auth
from app.deps import get_entity_id
from app.models import KPIDefinition, KPIValue
from app.utils.alert_entity_scope import month_period_clause
from app.risk_levels import risk_level
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
@@ -63,13 +62,11 @@ def list_bot_kpis(
source: str = Query("finance-bot", description="Bot标识"),
period: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取某Bot的所有KPI(含评分)(账套隔离 2026-09-21:仅本账套,对齐 update_bot_kpi_value)"""
"""获取某Bot的所有KPI(含评分)"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.bot_source == source,
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).order_by(KPIDefinition.kpi_code).all()
if not kpis:
@@ -89,7 +86,6 @@ def list_bot_kpis(
)
if period:
val_query = val_query.filter(KPIValue.period == period)
val_query = val_query.filter(month_period_clause(KPIValue.period))
latest_val = val_query.order_by(KPIValue.period.desc()).first()
current_val = latest_val.actual_value if latest_val else None
+5 -6
View File
@@ -24,7 +24,6 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models import AISuggestion, KPIDefinition, Entity
from app.api.bot_bridge import verify_bot_key
from app.deps import require_bot_entity_id
from app.api.ai_suggestions import _sug_dict
from app.risk_levels import risk_level
@@ -42,13 +41,10 @@ _TARGET_TYPE_MAP = {
def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
"""账套解析(Bot无token通道): body.entity_id → X-Entity-Id header → **缺省即报错**。
"""账套解析(Bot无token通道): body.entity_id → X-Entity-Id header → 默认1,校验active。
与 deps.get_entity_id 的 Bot 白名单语义一致(query/header/body 任一来源),
本端点不接收登录token,故不在此处理 Bearer。
ASK-20260924-17(任总 2026-09-24 放行):**取消「默认 1(酣客)」兜底**——
缺省账套改用 `deps.require_bot_entity_id` 收口(缺 → 400,非数字 → 422)。
"""
raw = None
if data.get("entity_id") not in (None, ""):
@@ -57,7 +53,10 @@ def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
xh = request.headers.get("X-Entity-Id")
if xh and str(xh).isdigit():
raw = int(xh)
eid = require_bot_entity_id({"entity_id": raw}, source="bot_suggestions")
try:
eid = int(raw) if raw is not None else 1
except (TypeError, ValueError):
raise HTTPException(422, f"entity_id 无效: {raw!r}")
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent or ent.status != "active":
raise HTTPException(403, f"企业 entity_id={eid} 不存在或未激活")
+45 -461
View File
@@ -1,8 +1,6 @@
"""预算管理 API — 管理会计OS
预算值的CRUD、自动分解、版本管理
"""
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
@@ -13,15 +11,6 @@ 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 BudgetPlan, KPIDefinition, OperationLog, KPIValue
from app.utils.kpi_value_scope import actual_only_clause
from app.utils.oplog import log_op
from app.utils.budget_status import (
EFFECTIVE_STATUSES,
effective_plan_clause,
effective_value_map,
pick_effective,
approval_status_of,
)
router = APIRouter(prefix="/api/cma/budget", tags=["预算管理"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
@@ -88,9 +77,8 @@ def create_budget_plan(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""创建或更新单条预算计划(账套隔离 2026-09-21:KPI 归属校验 + 写会话账套)"""
"""创建或更新单条预算计划"""
kpi_id = data.get("kpi_id")
period = data.get("period")
budget_value = data.get("budget_value")
@@ -98,27 +86,12 @@ def create_budget_plan(
if not all([kpi_id, period, budget_value is not None]):
raise HTTPException(400, "缺少必要参数: kpi_id, period, budget_value")
# 检查KPI是否存在(账套隔离 2026-09-21:仅本账套 KPI,防把预算挂到对端 KPI)
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
# 检查KPI是否存在
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 期间形态校验(年度行同族残余 2026-10-13:YYYY-00 显式接受为年度行,YYYY 提示补全形态)
if not isinstance(period, str):
raise HTTPException(400, "预算期间格式不正确:请使用 YYYY-MM(月 01–12)或 YYYY-00(年度)")
if re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", period):
# 月度形态
year, month = period.split("-")[0], int(period.split("-")[1])
elif re.fullmatch(r"\d{4}-00", period):
# 年度形态(系统内部既有年度行,如 apply-method / 年度分解写入):显式接受
year, month = period.split("-")[0], 0
elif re.fullmatch(r"\d{4}", period):
raise HTTPException(400, f"预算期间缺少月份:年度预算请使用 {period}-00 形态(例:2027-00),月度预算使用 YYYY-MM")
else:
raise HTTPException(400, "预算期间格式不正确:请使用 YYYY-MM(月 01–12)或 YYYY-00(年度)")
year, month = period.split("-")
version = data.get("version", "v1.0")
map_id = data.get("map_id") # 预算归属地图(2026-08-27: 按地图隔离)
@@ -139,7 +112,6 @@ def create_budget_plan(
return {"message": "预算已更新", "id": existing.id}
else:
plan = BudgetPlan(
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1
kpi_id=kpi_id,
map_id=map_id,
period=period,
@@ -160,8 +132,7 @@ def create_budget_plan(
action="create",
target_type="budget",
target_id=plan.id,
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value,
"entity_id": entity_id}, ensure_ascii=False), # ④ 审计带账套
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value}, ensure_ascii=False),
)
db.add(log)
db.commit()
@@ -174,12 +145,9 @@ def update_budget_plan(
plan_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(BudgetPlan).filter(
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
).first()
"""更新预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
@@ -200,12 +168,9 @@ def update_budget_plan(
def delete_budget_plan(
plan_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""删除预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(BudgetPlan).filter(
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
).first()
"""删除预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
db.delete(plan)
@@ -280,7 +245,6 @@ def auto_decompose_budget(
last_year = year - 1
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kid,
actual_only_clause(), # 实测区口径:预估行禁作预算派生基数(kpi_value_scope)
KPIValue.period.like(f"{last_year}-%"),
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.asc()).all()
@@ -342,9 +306,7 @@ def auto_decompose_budget(
if annual_budget is None:
raise HTTPException(400, "缺少必要参数: annual_budget(单KPI分解需传年度预算值)")
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id,
).first()
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
@@ -354,7 +316,6 @@ def auto_decompose_budget(
last_year = year - 1
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
actual_only_clause(), # 实测区口径:预估行禁作预算派生基数(kpi_value_scope)
KPIValue.period.like(f"{last_year}-%"),
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.asc()).all()
@@ -372,7 +333,6 @@ def auto_decompose_budget(
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
@@ -382,7 +342,6 @@ def auto_decompose_budget(
existing.budget_value = monthly_value
else:
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id, period=period,
budget_value=monthly_value, budget_year=year,
budget_month=m, version=version, status="active",
@@ -398,7 +357,6 @@ def auto_decompose_budget(
period = f"{year}-{m:02d}"
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
@@ -407,7 +365,6 @@ def auto_decompose_budget(
existing.budget_value = monthly
else:
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id, period=period,
budget_value=monthly, budget_year=year,
budget_month=m, version=version, status="active",
@@ -437,7 +394,6 @@ def get_deviation_report(
dimension: Optional[str] = Query(None),
alert_level: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取差异分析报告(汇总多个KPI的实际vs预算差异)"""
if period is None:
@@ -448,10 +404,7 @@ def get_deviation_report(
else:
period = datetime.now().strftime("%Y-%m")
query = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
)
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
if kpi_id:
query = query.filter(KPIDefinition.id == kpi_id)
if dimension:
@@ -581,14 +534,12 @@ def set_budget_config(
def budget_roll_forward(
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""
滚动预算自动延展:
- 删除最早一个月的预测数据
- 新增未来一个月的预测数据(取最近三个月均值)
- 返回延展结果
账套隔离:只处理会话账套(join kpi_definitions 按企业过滤),2026-09-18
"""
from app.models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
@@ -606,20 +557,13 @@ def budget_roll_forward(
now = datetime.now()
current_year, current_month = now.year, now.month
# 获取本账套 active 的预算记录(账套隔离: join KPI 按企业过滤, 2026-09-18 — 原为全账套)
plans = (db.query(BudgetPlan)
.join(KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id)
.filter(BudgetPlan.status == "active", KPIDefinition.entity_id == entity_id)
.all())
# 获取所有active的预算记录
plans = db.query(BudgetPlan).filter(BudgetPlan.status == "active").all()
# 按KPI分组(年度行排除 2026-10-13:budget_month=0 哨兵不参与月度滚动,仅年度行的 KPI 不删不产新行)
# 按KPI分组
from collections import defaultdict
kpi_plans = defaultdict(list)
annual_rows_ignored = 0
for p in plans:
if _is_annual_budget_row(p.period, p.budget_month):
annual_rows_ignored += 1
continue
kpi_plans[p.kpi_id].append(p)
rolled_kpis = []
@@ -654,7 +598,6 @@ def budget_roll_forward(
if not existing:
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id,
period=new_period,
budget_value=avg_value,
@@ -679,7 +622,6 @@ def budget_roll_forward(
"rolled_kpis": rolled_kpis,
"current_month": f"{current_year}-{current_month:02d}",
"rolling_months": val.get("rolling_months", 12),
"annual_rows_ignored": annual_rows_ignored,
}
@@ -692,7 +634,6 @@ def get_budget_comparison(
kpi_id: Optional[int] = Query(None),
year: Optional[int] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""
获取实际值vs预测值对比数据
@@ -738,15 +679,14 @@ def get_budget_comparison(
)
if kpi_id:
query = query.filter(BudgetPlan.kpi_id == kpi_id)
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
query = query.filter(
BudgetPlan.period.in_(periods),
effective_plan_clause(entity_id), # 含账套隔离
)
query = query.filter(BudgetPlan.period.in_(periods), BudgetPlan.status == "active")
budget_plans = query.all()
# 按KPI+期间索引(同键多版本 → 取生效行 active > approved > updated_at desc)
bp_map = effective_value_map(budget_plans)
# 按KPI+期间索引
bp_map = {}
for bp in budget_plans:
key = (bp.kpi_id, bp.period)
bp_map[key] = bp.budget_value
# 查询实际值
kpi_ids = set(bp.kpi_id for bp in budget_plans)
@@ -815,7 +755,6 @@ def get_kpi_comparison(
kpi_id: int,
year: Optional[int] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""
获取单个KPI的实际vs预测对比数据(用于图表展示)
@@ -826,23 +765,19 @@ def get_kpi_comparison(
now = datetime.now()
y = year or now.year
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
).first()
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
# 预算值
# 生效版口径(2026-10-03 状态机归一;保持原「未按账套过滤」行为不变)
budgets = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period.in_(periods),
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
BudgetPlan.status == "active",
).all()
budget_map = {p: v for (_kid, p), v in effective_value_map(budgets).items()}
budget_map = {bp.period: bp.budget_value for bp in budgets}
# 实际值
actuals = db.query(KPIValue).filter(
@@ -904,16 +839,11 @@ def check_budget_deviation(
period = data.get("period") or datetime.now().strftime("%Y-%m")
# 查询该期间有预算的KPI(多租户隔离 entity_id)
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
_effective_rows = db.query(BudgetPlan).filter(
budget_plans = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period,
effective_plan_clause(entity_id),
BudgetPlan.status == "active",
).all()
# 同 KPI 多版本并存 → 取生效行(active > approved > updated_at desc),防重复告警
_by_kpi = {}
for _bp in _effective_rows:
_by_kpi.setdefault(_bp.kpi_id, []).append(_bp)
budget_plans = [r for r in (pick_effective(v) for v in _by_kpi.values()) if r is not None]
if not budget_plans:
return {
@@ -1113,24 +1043,17 @@ def get_deviation_alert_attribution(
@router.get("/alert-direction")
def get_alert_direction(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""越高越好型KPI方向配置(**按会话账套**;2026-09-23 拆账套,零 DDL)
与 B1 `cash.current_balance` 同款:只读本账套键 `kpi_alert_higher_better.<entity_id>`,
本账套未配置 → 走 deviation_engine 内置默认表(兜底,不跨账套借用)。
"""
from app.utils.deviation_engine import get_higher_better_codes, higher_better_key
"""越高越好型KPI方向配置 (P2-⑤ 2026-08-28: system_configs 可维护)"""
from app.utils.deviation_engine import get_higher_better_codes, CONFIG_KEY_HIGHER_BETTER
from app.models import SystemConfig
key = higher_better_key(entity_id)
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == key
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
codes = get_higher_better_codes(db, entity_id)
codes = get_higher_better_codes(db)
return {
"config_key": key,
"entity_id": entity_id,
"config_key": CONFIG_KEY_HIGHER_BETTER,
"codes": codes,
"is_configured": bool(cfg and cfg.config_value),
}
@@ -1141,15 +1064,10 @@ def update_alert_direction(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}
账套隔离(2026-09-23):只写本账套键 `kpi_alert_higher_better.<entity_id>`;
body 里的 `entity_id` 一律不信(会话账套为准,同 B1 口径)。
"""
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}"""
import json as _json
from app.utils.deviation_engine import higher_better_key
from app.utils.deviation_engine import CONFIG_KEY_HIGHER_BETTER
from app.models import SystemConfig
codes = data.get("codes")
@@ -1157,20 +1075,19 @@ def update_alert_direction(
raise HTTPException(400, "codes 必须是非空数组")
codes = [str(c) for c in codes]
key = higher_better_key(entity_id)
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == key
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
if cfg:
cfg.config_value = _json.dumps(codes, ensure_ascii=False)
else:
db.add(SystemConfig(
config_key=key,
config_key=CONFIG_KEY_HIGHER_BETTER,
config_value=_json.dumps(codes, ensure_ascii=False),
description=f"越高越好型KPI编码列表(实际低于预算才告警)— 账套 {int(entity_id)}",
description="越高越好型KPI编码列表(实际低于预算才告警)",
))
db.commit()
return {"success": True, "config_key": key, "entity_id": entity_id, "codes": codes}
return {"success": True, "config_key": CONFIG_KEY_HIGHER_BETTER, "codes": codes}
@router.put("/deviation-alerts/{alert_id}")
@@ -1178,14 +1095,10 @@ def update_deviation_alert(
alert_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新偏差预警(如标记已解决)(账套隔离 2026-09-21:跨账套 id → 404)"""
"""更新偏差预警(如标记已解决)"""
from app.models import BudgetDeviationAlert
alert = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.id == alert_id,
BudgetDeviationAlert.entity_id == entity_id,
).first()
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警记录不存在")
if "status" in data:
@@ -1413,8 +1326,6 @@ def apply_budget_method(
base_actual = db.query(KPIValue).filter(
KPIValue.kpi_id == rule.base_kpi_id,
KPIValue.actual_value.isnot(None),
# 实测区口径:预估行禁作预算基数(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.calculated_at.desc()).first()
if base_actual:
base_val = base_actual.actual_value
@@ -1531,7 +1442,7 @@ def list_budget_versions(
# 状态机对齐(2026-09-07 UI修复):DB 默认态是 active(当前在用/草稿态),
# 前端审批按钮语义用 draft=可提交审批/submitted=待审批/approved=已批准只读/rejected=已驳回
# → approval_status 把 active 映射为 draft,同时保留 status 原字段兼容其他引用(下拉"当前"标记等)
approval_status = approval_status_of(raw_status) # 单一实现(预算状态机口径)
approval_status = "draft" if raw_status == "active" else raw_status
updated_str = v["updated_at"].strftime("%Y-%m-%d %H:%M") if v["updated_at"] else ""
result.append({
"version": v["version"],
@@ -1541,8 +1452,6 @@ def list_budget_versions(
"plan_count": v["kpi_count"], # 前端列 prop 别名(BudgetManagement.vue versions tab)
"total_budget": round(v["total_budget"], 2),
"period_count": len(v["periods"]),
"is_effective": raw_status in EFFECTIVE_STATUSES, # 是否执行基准
"can_release": raw_status == "approved", # 是否可下达
"year": year,
"updated_at": updated_str,
"last_updated": updated_str, # 前端列 prop 别名
@@ -1565,13 +1474,10 @@ def submit_budget_version(
).all()
if not rows:
raise HTTPException(404, f"版本 {version} 不存在")
submittable = [r for r in rows if (r.status or "") in ("draft", "active")]
if not submittable:
raise HTTPException(400, f"版本 {version} 无可提交行(需 draft/active,当前: {sorted({r.status for r in rows})})")
for r in submittable:
for r in rows:
r.status = "submitted"
db.commit()
return {"success": True, "version": version, "status": "submitted", "count": len(submittable)}
return {"success": True, "version": version, "status": "submitted", "count": len(rows)}
@router.post("/versions/approve")
@@ -1590,13 +1496,10 @@ def approve_budget_version(
if not rows:
raise HTTPException(404, f"版本 {version} 不存在")
new_status = "approved" if action == "approved" else "rejected"
decidable = [r for r in rows if (r.status or "") == "submitted"]
if not decidable:
raise HTTPException(400, f"版本 {version} 无可审批行(需 submitted,当前: {sorted({r.status for r in rows})})")
for r in decidable:
for r in rows:
r.status = new_status
db.commit()
return {"success": True, "version": version, "status": new_status, "count": len(decidable)}
return {"success": True, "version": version, "status": new_status, "count": len(rows)}
@router.post("/versions/diff")
@@ -1669,17 +1572,6 @@ def diff_budget_versions(
# 预算↔现金流联动(断点修复#1, 2026-08-27)
# ════════════════════════════════════════════════════════════
def _is_annual_budget_row(period: Optional[str], budget_month: Optional[int]) -> bool:
"""年度预算行判定(形态驱动):budget_month == 0(年度哨兵),
或 period 命中年度形态 YYYY / YYYY-00。"""
if budget_month == 0:
return True
if period is None:
return False
p = str(period).strip()
return re.fullmatch(r"\d{4}(?:-00)?", p) is not None
@router.post("/sync-cash-plans")
def sync_cash_plans(
entity_id: int = Depends(get_entity_id),
@@ -1732,13 +1624,7 @@ def sync_cash_plans(
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
created, updated, unclassified_count = 0, 0, 0
skipped_annual = 0
for b in budgets:
# 年度汇总行(budget_month=0 哨兵 / period=YYYY 或 YYYY-00)不联动现金流:
# CashPlan 无年度维度, 跳过以免全年额回落成 YYYY-01 污染按月分桶
if _is_annual_budget_row(b.period, b.budget_month):
skipped_annual += 1
continue
kpi = kpis.get(b.kpi_id)
if not kpi:
continue
@@ -1791,310 +1677,8 @@ def sync_cash_plans(
))
created += 1
db.commit()
message = f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条"
if skipped_annual:
message += f", 跳过年度行{skipped_annual}条"
return {
"message": message,
"message": f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条",
"created": created, "updated": updated,
"unclassified_count": unclassified_count,
"skipped_annual": skipped_annual,
}
# ============ 预算闭环补齐(2026-10-03 施工单 CMA-20261003 阶段 3:最小可用骨架) ============
# 设计件 docs/budget-module-governance-design.md §4:控制 / 调整 / 下达 / 考核
# 零 DDL:全部基于既有 budget_plans / kpi_values / operation_logs。
DEFAULT_CONTROL_THRESHOLD_PCT = 20.0
def _budget_control_threshold(db) -> float:
"""控制阈值(%):system_configs.budget_control_threshold,缺省 20%。"""
from app.models import SystemConfig
import json as _json
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == "budget_control_threshold"
).first()
if cfg:
try:
return float(_json.loads(cfg.config_value))
except (ValueError, TypeError):
return DEFAULT_CONTROL_THRESHOLD_PCT
return DEFAULT_CONTROL_THRESHOLD_PCT
def _effective_rows_for_periods(db, entity_id: int, periods):
"""期间集合内的生效预算行(同键取生效行,防同 KPI 多版本重复计算)。"""
rows = db.query(BudgetPlan).filter(
BudgetPlan.period.in_(periods),
effective_plan_clause(entity_id),
).all()
grouped = {}
for r in rows:
grouped.setdefault((r.kpi_id, r.period), []).append(r)
out = []
for key, rows_of_key in grouped.items():
row = pick_effective(rows_of_key)
if row is not None:
out.append((key[0], key[1], row))
return out
def _direction_of(kpi, higher_better_codes) -> str:
return "higher" if (kpi.kpi_code in higher_better_codes or kpi.kpi_name in higher_better_codes) else "lower"
@router.get("/control/status")
def get_budget_control_status(
year: Optional[int] = Query(None),
period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""预算控制状态(缺失环「控制」最小骨架,只读):生效预算 vs 实际 → ok/warn/over。"""
from app.utils.deviation_engine import get_higher_better_codes
y = year or datetime.now().year
periods = [period] if period else [f"{y}-{m:02d}" for m in range(1, 13)]
threshold = _budget_control_threshold(db)
eff = _effective_rows_for_periods(db, entity_id, periods)
kpi_ids = {kid for kid, _p, _r in eff}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
actuals = {}
if kpi_ids:
for v in db.query(KPIValue).filter(
KPIValue.kpi_id.in_(kpi_ids),
KPIValue.period.in_(periods),
KPIValue.actual_value.isnot(None),
actual_only_clause(),
).all():
actuals[(v.kpi_id, v.period)] = v.actual_value
higher_better = set(get_higher_better_codes(db, entity_id))
items = []
summary = {"total": 0, "ok": 0, "warn": 0, "over": 0, "no_actual": 0}
for kid, per, plan in sorted(eff, key=lambda x: (x[0], x[1])):
kpi = kpis.get(kid)
if kpi is None:
continue
budget = plan.budget_value or 0
actual = actuals.get((kid, per))
direction = _direction_of(kpi, higher_better)
level, rate, deviation_rate = "no_actual", None, None
if actual is not None and budget:
rate = round(actual / budget * 100, 2)
deviation_rate = round((actual - budget) / abs(budget) * 100, 2)
if direction == "higher":
level = "over" if deviation_rate <= -threshold else ("warn" if deviation_rate <= -threshold * 0.8 else "ok")
else:
level = "over" if deviation_rate >= threshold else ("warn" if deviation_rate >= threshold * 0.8 else "ok")
summary["total"] += 1
summary[level] = summary.get(level, 0) + 1
items.append({
"kpi_id": kid,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit or "",
"period": per,
"basis_version": plan.version,
"basis_status": plan.status,
"direction": direction,
"budget_value": budget,
"actual_value": actual,
"execution_rate": rate,
"deviation_rate": deviation_rate,
"control_level": level,
})
return {"year": y, "period": period, "threshold_pct": threshold, "summary": summary, "items": items}
@router.get("/assessment")
def get_budget_assessment(
year: Optional[int] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""预算考核(缺失环「考核」最小骨架,只读):生效预算 vs 实际 → 达成率 + 等级 A/B/C/D。"""
from app.utils.deviation_engine import get_higher_better_codes
y = year or datetime.now().year
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
eff = _effective_rows_for_periods(db, entity_id, periods)
kpi_ids = {kid for kid, _p, _r in eff}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
actuals = {}
if kpi_ids:
for v in db.query(KPIValue).filter(
KPIValue.kpi_id.in_(kpi_ids),
KPIValue.period.in_(periods),
KPIValue.actual_value.isnot(None),
actual_only_clause(),
).all():
actuals[(v.kpi_id, v.period)] = v.actual_value
higher_better = set(get_higher_better_codes(db, entity_id))
agg = {}
for kid, per, plan in eff:
row = agg.setdefault(kid, {"budget_total": 0.0, "actual_total": 0.0, "periods": set(), "basis_version": plan.version,
"basis_status": plan.status, "_ts": plan.updated_at})
row["budget_total"] += plan.budget_value or 0
row["periods"].add(per)
if plan.updated_at and (row["_ts"] is None or plan.updated_at > row["_ts"]):
row["_ts"] = plan.updated_at
row["basis_version"] = plan.version
row["basis_status"] = plan.status
if (kid, per) in actuals:
row["actual_total"] += actuals[(kid, per)] or 0
items = []
summary = {"kpi_count": 0, "a": 0, "b": 0, "c": 0, "d": 0, "na": 0,
"total_budget": 0.0, "total_actual": 0.0}
for kid, row in sorted(agg.items()):
kpi = kpis.get(kid)
if kpi is None:
continue
direction = _direction_of(kpi, higher_better)
bt, at = round(row["budget_total"], 2), round(row["actual_total"], 2)
achieve, grade = None, "NA"
if bt and row["periods"]:
achieve = round((at / bt * 100) if direction == "higher" else (bt / at * 100 if at else 0.0), 2)
grade = "A" if achieve >= 100 else ("B" if achieve >= 90 else ("C" if achieve >= 80 else "D"))
summary["kpi_count"] += 1
summary[grade.lower()] = summary.get(grade.lower(), 0) + 1
summary["total_budget"] += bt
summary["total_actual"] += at
items.append({
"kpi_id": kid,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit or "",
"direction": direction,
"basis_version": row["basis_version"],
"basis_status": row["basis_status"],
"period_count": len(row["periods"]),
"budget_total": bt,
"actual_total": at,
"achieve_rate": achieve,
"grade": grade,
})
summary["total_budget"] = round(summary["total_budget"], 2)
summary["total_actual"] = round(summary["total_actual"], 2)
summary["achieve_rate"] = round(summary["total_actual"] / summary["total_budget"] * 100, 2) if summary["total_budget"] else None
return {"year": y, "summary": summary, "items": items}
@router.post("/versions/release")
def release_budget_version(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""预算下达(缺失环「下达」):已批准版本 → 已下达(active)。fail-closed + 幂等。"""
version = (data.get("version") or "").strip()
if not version:
raise HTTPException(400, "需要 version")
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.version == version,
).all()
if not rows:
raise HTTPException(404, f"版本 {version} 不存在")
approved = [r for r in rows if (r.status or "") == "approved"]
if not approved:
# 幂等判据 = 「本版本确有下达历史」(operation_logs 记录),而不是 active 现状:
# 迁移前遗留的 active 行 = 在用/草稿态,不能当成「已下达」(设计件 §1.2/§1.3)。
released_before = any(
isinstance(l.detail, dict)
and l.detail.get("op") == "release"
and l.detail.get("version") == version
for l in db.query(OperationLog)
.filter(OperationLog.target_type == "budget_version")
.order_by(OperationLog.id.desc())
.limit(500)
.all()
)
if released_before and any((r.status or "") == "active" for r in rows):
return {"success": True, "version": version, "status": "active", "count": 0, "already": True}
raise HTTPException(400, f"版本 {version} 不可下达(需 approved,当前: {sorted({r.status for r in rows})})")
for r in approved:
r.status = "active"
log_op(db, "update", target_type="budget_version", target_id=None,
detail={"op": "release", "version": version, "count": len(approved),
"from_status": "approved", "to_status": "active"},
entity_id=entity_id)
db.commit()
return {"success": True, "version": version, "status": "active", "count": len(approved), "already": False}
@router.post("/adjustments")
def create_budget_adjustment(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""预算调整(缺失环「调整」最小骨架):基于生效基线版本派生新版本(draft),原版本零改动。"""
base_version = (data.get("base_version") or "").strip()
new_version = (data.get("new_version") or "").strip()
reason = (data.get("reason") or "").strip()
items = data.get("items") or []
if not base_version or not new_version:
raise HTTPException(400, "需要 base_version 与 new_version")
if new_version == base_version:
raise HTTPException(400, "new_version 不能与 base_version 相同")
if not reason:
raise HTTPException(400, "调整单必须填写 reason(可追溯)")
if db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id, BudgetPlan.version == new_version
).first():
raise HTTPException(400, f"版本 {new_version} 已存在(调整单只允许派生新版本,不覆盖)")
base_rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.version == base_version,
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
).all()
if not base_rows:
raise HTTPException(404, f"基线版本 {base_version} 不存在或非生效版")
overrides = {}
for it in items:
kid, per = it.get("kpi_id"), it.get("period")
if kid is None or not per or it.get("budget_value") is None:
raise HTTPException(400, "items 每项需 kpi_id / period / budget_value")
overrides[(int(kid), per)] = float(it["budget_value"])
operator = current_user.name if hasattr(current_user, "name") else ""
applied, unmatched = 0, len(overrides)
for r in base_rows:
key = (r.kpi_id, r.period)
if key in overrides:
val = overrides.pop(key)
applied += 1
unmatched -= 1
else:
val = r.budget_value
db.add(BudgetPlan(
entity_id=entity_id,
map_id=r.map_id,
kpi_id=r.kpi_id,
period=r.period,
budget_value=val,
budget_year=r.budget_year,
budget_month=r.budget_month,
version=new_version,
status="draft",
source_type=r.source_type or "manual",
calc_logic=r.calc_logic,
remark=f"调整单:{reason} 基线:{base_version}"[:500],
created_by=operator,
))
log_op(db, "create", target_type="budget_adjustment", target_id=None,
detail={"base_version": base_version, "new_version": new_version, "reason": reason,
"copied": len(base_rows), "applied_overrides": applied, "unmatched_overrides": unmatched},
entity_id=entity_id)
db.commit()
return {"success": True, "new_version": new_version, "base_version": base_version, "reason": reason,
"created": len(base_rows), "applied_overrides": applied, "unmatched_overrides": unmatched,
"status": "draft", "note": "调整版本已生成为 draft,需提交审批 → 批准 → 下达后方可作为执行基准"}
+17 -28
View File
@@ -5,11 +5,8 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
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 KPIDefinition, BudgetPlan, KPIValue, OperationLog
from app.utils.oplog import log_op, SCOPE_BATCH
from app.utils.kpi_value_scope import actual_only_clause
import json
import logging
from datetime import datetime
@@ -78,24 +75,18 @@ def _calc_budget(kpi: KPIDefinition) -> dict:
def get_kpi_budget_candidates(
year: int = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取本账套可用于生成预算的KPI列表,按类型分类(账套隔离 2026-09-21)"""
"""获取可用于生成预算的KPI列表,按类型分类"""
if not year:
year = datetime.now().year
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
# 获取每个KPI的最新实际值
latest_values = {}
for kpi in kpis:
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
# 实测区口径:预估行禁作预算生成依据(kpi_value_scope.actual_only_clause)
actual_only_clause(),
KPIValue.kpi_id == kpi.id
).order_by(KPIValue.calculated_at.desc()).first()
if v:
latest_values[kpi.id] = v.actual_value
@@ -229,7 +220,6 @@ def generate_budget_from_kpis(
period = f"{year}-{month:02d}"
results = []
total_amount = 0
_kpi_entities = set() # 2026-09-23 ④:审计带账套(取被处理 KPI 自身的账套,不猜会话账套)
for item in items:
kpi_id = item.get("kpi_id")
@@ -243,7 +233,6 @@ def generate_budget_from_kpis(
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
continue
_kpi_entities.add(getattr(kpi, "entity_id", None))
# 检查是否已有记录
existing = db.query(BudgetPlan).filter(
@@ -289,20 +278,20 @@ def generate_budget_from_kpis(
"plan_id": plan_id,
})
# 操作日志(2026-09-23 ④:detail 带账套;跨账套批量 ⇒ 留空 + 哨兵,不猜账套)
_entities = sorted(x for x in _kpi_entities if x is not None)
log_op(db, "kpi_generate_budget", target_type="budget",
user_id=getattr(current_user, "id", None),
detail={
"year": year,
"month": month,
"version": version,
"item_count": len(results),
"total_amount": total_amount,
"kpi_entity_ids": _entities,
},
entity_id=(_entities[0] if len(_entities) == 1 else None),
scope=(SCOPE_BATCH if len(_entities) > 1 else None))
# 操作日志
log = OperationLog(
user_id=getattr(current_user, "id", None),
action="kpi_generate_budget",
target_type="budget",
detail=json.dumps({
"year": year,
"month": month,
"version": version,
"item_count": len(results),
"total_amount": total_amount,
}, ensure_ascii=False),
)
db.add(log)
db.commit()
return {
+25 -81
View File
@@ -12,7 +12,6 @@ from sqlalchemy import or_
import pandas as pd
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from app.utils.alert_entity_scope import month_period_clause
from app.auth_middleware import require_role
from app.models import CashPlan
from app.utils.cash_forecast_engine import (
@@ -31,20 +30,6 @@ router = APIRouter(
)
def _is_customer_level_row(plan) -> bool:
"""行形态判据(催收页唯一口径,ASK-20261001-33):**客户级行** = 客户列非空且非空白。
月度汇总行(`counterparty` 为 NULL / 空白,如 `source='zxt-sync'` 的 67 行实收历史)
**不是客户级应收**,不得计入催收页的卡片 / 账龄 / 明细 / `owners`——否则求和会得到
¥1.86 亿的假「应收总额」(67 行汇总 18,596.71 万 与 18 行客户级 71.68 万混加)。
判据取**行形态**而非 `source` 白名单:`source` 是数据来源标记(`zxt-sync` = 智享通通道
注入标记,来源正确、不得丢弃),不能用来区分行语义。
`str.strip()` 覆盖 SQL `TRIM()` 不处理的制表符 / 全角空格等空白变体。
"""
return bool((plan.counterparty or "").strip())
def _aging_bucket(days_late: int) -> str:
"""账龄区间(按逾期天数):未到期 / 0-30天 / 30-60天 / 60-90天 / 90天以上"""
if days_late <= 0:
@@ -82,7 +67,6 @@ def _plan_dict(p: CashPlan) -> dict:
"plan_type_label": "收款" if p.plan_type == "receive" else "付款",
"amount": round(p.amount or 0, 2),
"paid_amount": paid,
"settled_amount": round(p.amount or 0, 2) if p.status == "completed" else paid,
"receivable_balance": max(balance, 0),
"plan_date": p.plan_date.strftime("%Y-%m-%d") if p.plan_date else "",
"counterparty": p.counterparty or "",
@@ -132,21 +116,15 @@ def api_get_balance(
@router.post("/balance")
def api_set_balance(
data: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""设置当前现金余额(万元),作为预测基线
账套隔离(B1,2026-09-22):基线落 `cash.current_balance.<entity_id>`,
entity 来自会话 token(显式传他账套 entity_id → get_entity_id 403)。
修复前本端点无 entity 依赖 → 任一账套设基线会覆盖全平台唯一键。
"""
"""设置当前现金余额(万元),作为预测基线"""
from app.utils.cash_forecast_engine import set_current_cash_balance
value = float(data.get("current_cash", 0))
if value < 0:
raise HTTPException(400, "现金余额不能为负")
set_current_cash_balance(db, value, entity_id)
return {"message": "当前现金余额已更新", "entity_id": entity_id, "current_cash": value}
set_current_cash_balance(db, value)
return {"message": "当前现金余额已更新", "current_cash": value}
# ══════════════════════════════════════════════════════════
@@ -220,12 +198,9 @@ def api_create_plan(request: Request, data: dict, db: Session = Depends(get_db))
@router.put("/plans/{plan_id}")
def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""更新收付款计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
"""更新收付款计划"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
if "plan_type" in data:
@@ -273,12 +248,9 @@ def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db),
@router.delete("/plans/{plan_id}")
def api_delete_plan(plan_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""删除收付款计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
def api_delete_plan(plan_id: int, db: Session = Depends(get_db)):
"""删除收付款计划"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
db.delete(plan)
@@ -287,12 +259,9 @@ def api_delete_plan(plan_id: int, db: Session = Depends(get_db),
@router.post("/plans/{plan_id}/complete")
def api_complete_plan(plan_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""标记计划为已完成(收款到账/付款完成)(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
def api_complete_plan(plan_id: int, db: Session = Depends(get_db)):
"""标记计划为已完成(收款到账/付款完成)"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
plan.status = "completed"
@@ -579,11 +548,7 @@ def api_receivables(
keyword: str = Query(None, description="客户/说明关键字"),
db: Session = Depends(get_db),
):
"""应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选
口径(ASK-20261001-33):**只认客户级行**——月度汇总行(`counterparty` 空/空白)
不计入卡片 / 账龄 / 明细 / `owners`。
"""
"""应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选"""
query = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
@@ -602,10 +567,7 @@ def api_receivables(
overdue_amount = 0.0
aging_amount = {}
completed_amount = 0.0
completed_count = 0
for p in plans:
if not _is_customer_level_row(p):
continue # 月度汇总行(客户列为空/空白)不计入催收页
d = _plan_dict(p)
if status:
if status == "overdue":
@@ -627,18 +589,13 @@ def api_receivables(
aging_amount[d["aging_bucket"]] = aging_amount.get(d["aging_bucket"], 0) + d["receivable_balance"]
elif d["status"] == "completed":
completed_amount += d["amount"]
completed_count += 1
# owners 与明细同一判据:月度汇总行(客户列为空/空白)不得贡献筛选项
owners = sorted({
row.owner for row in db.query(CashPlan.owner, CashPlan.counterparty).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.owner.isnot(None),
CashPlan.owner != "",
).distinct().all()
if _is_customer_level_row(row)
})
owners = [r[0] for r in db.query(CashPlan.owner).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.owner.isnot(None),
CashPlan.owner != "",
).distinct().order_by(CashPlan.owner.asc()).all()]
return {
"entity_id": entity_id,
@@ -652,7 +609,6 @@ def api_receivables(
"overdue_count": overdue_count,
"overdue_amount": round(overdue_amount, 2),
"completed_amount": round(completed_amount, 2),
"completed_count": completed_count,
"aging": {k: round(v, 2) for k, v in sorted(aging_amount.items(), key=lambda x: x[0])},
},
"data": items,
@@ -660,13 +616,9 @@ def api_receivables(
@router.post("/receivables/{plan_id}/payment")
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS
(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db)):
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "应收计划不存在")
if plan.plan_type != "receive":
@@ -820,11 +772,7 @@ def _is_carry_forward(summary: str, subject_name: str) -> bool:
def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float:
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)
账套隔离(B1,2026-09-22):本函数已按 entity_id 计算余额,写回时**带上该账套**
(修复前丢账套 → 任一家导入网银流水就覆盖全平台唯一键的基线)。
"""
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)"""
from app.models import VoucherDetail
from app.utils.cash_forecast_engine import set_current_cash_balance
from app.utils.voucher_guard import apply_voucher_guard
@@ -839,11 +787,8 @@ def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> f
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
), 2)
cash_wan = round(balance_yuan / 10000, 4)
set_current_cash_balance(db, cash_wan, entity_id)
logger.info(
f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 "
f"→ 现金余额联动(账套 {entity_id})"
)
set_current_cash_balance(db, cash_wan)
logger.info(f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 → 现金余额联动")
return cash_wan
@@ -1003,7 +948,6 @@ def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> t
dv = db.query(KPIValue).filter(
KPIValue.kpi_id == debt_kpi.id,
KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first()
if dv and dv.actual_value is not None:
short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元
+26 -39
View File
@@ -196,53 +196,40 @@ def classify_unclassified(
item.status = "classified"
item.resolved_at = datetime.now()
# ③ 联动生成 CashPlan(有期间和预算值时;年度行跳过,不落月度计划)
from app.api.budget import _is_annual_budget_row # 函数内局部导入,规避 import 顺序/循环风险
# ③ 联动生成 CashPlan(有期间和预算值时)
plan_created = False
skipped_annual = False
if item.period and item.budget_value is not None:
# 先按形态解析月份(不裸索引 split,避免 'YYYY' / 'YYYY-00' 抛异常被静默吞掉)
parts = item.period.split("-") if isinstance(item.period, str) else []
month = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
if _is_annual_budget_row(item.period, month):
# 年度行(YYYY / YYYY-00 / budget_month=0)不得落成月度现金流计划
skipped_annual = True
else:
try:
year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1])
plan_date = datetime(year, month, 1)
except Exception:
plan_date = None
if plan_date:
existing_plan = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.related_kpi_id == item.kpi_id,
CashPlan.plan_type == plan_type,
CashPlan.plan_date == plan_date,
).first()
if not existing_plan:
db.add(CashPlan(
entity_id=entity_id,
plan_type=plan_type,
related_kpi_id=item.kpi_id,
amount=item.budget_value,
plan_date=plan_date,
description=f"待分类队列归类: {item.kpi_name or ''}",
status="pending",
source="budget_sync",
))
plan_created = True
try:
year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1])
plan_date = datetime(year, month, 1)
except Exception:
plan_date = None
if plan_date:
existing_plan = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.related_kpi_id == item.kpi_id,
CashPlan.plan_type == plan_type,
CashPlan.plan_date == plan_date,
).first()
if not existing_plan:
db.add(CashPlan(
entity_id=entity_id,
plan_type=plan_type,
related_kpi_id=item.kpi_id,
amount=item.budget_value,
plan_date=plan_date,
description=f"待分类队列归类: {item.kpi_name or ''}",
status="pending",
source="budget_sync",
))
plan_created = True
db.commit()
message = f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else "")
if skipped_annual:
message += "(年度行,跳过月度现金流计划)"
return {
"message": message,
"message": f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else ""),
"plan_type": plan_type,
"rule_created": not existing_rule,
"plan_created": plan_created,
"skipped_annual": skipped_annual,
}
+5 -13
View File
@@ -29,9 +29,7 @@
—— period 是 voucher_date 派生属性,**不进幂等键**(2026-09-11 加固:日期口径
在 summary 派生/主表 VoucherDate 之间切换时,含 period 会判重失效导致重复入库)。
命中既有行即跳过;仅当命中行 period 与新行不一致时计入 period_conflict 留痕(不覆盖)。
7. entity_id:**必传**(ASK-20260924-17,任总 2026-09-24 放行)——body `entity_id` 或
header `X-Entity-Id`(1=陕西酣客、2=陕西博海);**缺省 → 400**,
不再兜底默认账套 1(原口径:默认 1 陕西酣客,source=zxt-sxzxb20210228)。
7. entity_id:默认 1(陕西酣客,source=zxt-sxzxb20210228),Bot 通道可传 X-Entity-Id 或 body entity_id。
8. 入库后不触发现金流/ledger 联动(凭证完整性未证实前不作为权威来源,见财务口径警示);
也不影响 Excel multipart 导入(原端点不动)。
"""
@@ -47,7 +45,7 @@ from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import resolve_entity_for_request, require_bot_entity_id
from app.deps import resolve_entity_for_request
from app.models import Entity, ImportLog, VoucherDetail
from app.api.bot_bridge import verify_bot_key
@@ -227,18 +225,12 @@ def api_import_vouchers_json(
nr["CwAccountID"] = v
norm_rows.append(nr)
# ── entity 解析 + active 校验(ASK-20260924-17:Bot 通道必须显式传账套)──
entity_id = resolve_entity_for_request(
request,
require_bot_entity_id(
{"entity_id": req_entity if req_entity not in (None, "") else request.headers.get("X-Entity-Id")},
source="cash_vouchers_json.import/vouchers-json",
),
)
# ── entity 解析 + active 校验 ──
entity_id = resolve_entity_for_request(request, req_entity if req_entity not in (None, "") else 1)
try:
entity_id = int(entity_id)
except Exception:
raise HTTPException(422, f"entity_id 无效: {entity_id!r}")
entity_id = 1
if not _entity_active(db, entity_id):
raise HTTPException(403, f"企业ID={entity_id} 不存在或未激活")
+12 -33
View File
@@ -176,34 +176,21 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db),
# ============================================================
# ABC 作业成本
# 账套隔离(2026-09-18,0-P1-project-20260918-entity-isolation-batch2):
# GET /abc/activities 原为全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心;
# 现按会话账套过滤(AbcActivity.entity_id)。
# OpenCode 多租户审查 2026-09-07 的「单实体演示数据暂不强制隔离」豁免就此撤销。
# 账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):batch2 同族残留收口
# - POST /abc/activities 原签名无账套依赖、不写归属 → 落模型默认 entity_id=1;现按会话账套写入
# - POST /abc/allocate 原无账套依赖、activity_id 不校验归属、分配记录落默认 1;
# 现校验 activity_id 归属本账套(跨账套 → 404,文案对齐同文件先例)
# 并把 entity_id 透传 cost_engine.allocate_cost 显式写入 AbcAllocation
# - GET /abc/allocations 原查询只按 period/product_code → 现加 entity_id 过滤(读型同族)
# 注:ABC 作业中心/分配为单实体演示数据(ab_activities/ab_allocations 全为 entity 1,
# 历史演示用途),OpenCode 多租户审查 2026-09-07 判定暂不强制按账套隔离;若启用多企业 ABC 需补 entity 过滤。
# ============================================================
@router.get("/abc/activities")
def list_abc_activities(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""查询ABC作业中心列表(账套隔离:只返回本账套)"""
items = (db.query(AbcActivity)
.filter(AbcActivity.entity_id == entity_id)
.order_by(AbcActivity.activity_code)
.all())
def list_abc_activities(db: Session = Depends(get_db)):
"""查询ABC作业中心列表"""
items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all()
return {"data": items}
@router.post("/abc/activities")
def create_abc_activity(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""创建ABC作业中心(账套隔离:按会话账套写入归属)"""
def create_abc_activity(data: dict, db: Session = Depends(get_db)):
"""创建ABC作业中心"""
act = AbcActivity(
entity_id=entity_id,
activity_code=data["activity_code"],
activity_name=data["activity_name"],
activity_desc=data.get("activity_desc"),
@@ -219,21 +206,14 @@ def create_abc_activity(data: dict, db: Session = Depends(get_db),
@router.post("/abc/allocate")
def do_allocate(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""执行ABC成本分配(账套隔离:activity_id 须属本账套,跨账套 → 404)"""
act = (db.query(AbcActivity)
.filter(AbcActivity.id == data["activity_id"], AbcActivity.entity_id == entity_id)
.first())
if not act:
raise HTTPException(404, "作业中心不存在")
def do_allocate(data: dict, db: Session = Depends(get_db)):
"""执行ABC成本分配"""
result = allocate_cost(
activity_id=data["activity_id"],
period=data.get("period", datetime.now().strftime("%Y-%m")),
product_code=data["product_code"],
product_name=data.get("product_name", ""),
driver_consumed=data["driver_consumed"],
entity_id=entity_id,
)
return result
@@ -241,10 +221,9 @@ def do_allocate(data: dict, db: Session = Depends(get_db),
@router.get("/abc/allocations")
def list_allocations(period: Optional[str] = Query(None),
product_code: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查询ABC分配记录(账套隔离:只返回本账套)"""
query = db.query(AbcAllocation).filter(AbcAllocation.entity_id == entity_id)
db: Session = Depends(get_db)):
"""查询ABC分配记录"""
query = db.query(AbcAllocation)
if period:
query = query.filter(AbcAllocation.period == period)
if product_code:
+2 -96
View File
@@ -5,11 +5,8 @@ from sqlalchemy import func, desc
from typing import Optional
from datetime import 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 KPIDefinition, KPIValue, KPIAlert, User
from app.utils.alert_entity_scope import month_period_clause
from app.utils.kpi_value_scope import actual_only_clause
import logging
logger = logging.getLogger("cma.customer")
@@ -18,21 +15,6 @@ router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ── 客户维度「标准指标族」九卡(与前端 CustomerDashboard.vue 的 CUSTOMER_TEMPLATES 逐项对应)
# (卡片名, 默认单位, 预设目标值, 实例层 kpi_code 或 None)
# 第四列为 None = 该卡在 KPI 库**无可用定义**(页面须显示「未定义·待建」,不得显示 0/无数据)。
STANDARD_INDICATORS = [
("客户满意度", "分", 90, "C_SATISFACTION"),
("客户留存率", "%", 85, None),
("新客户增长率", "%", 20, None),
("客户渠补率", "%", 95, None),
("客户投诉率", "%", 5, "C_COMPLAINT_RATE"),
("客户平均贡献值", "元", 10000, None),
("客户转化率", "%", 30, None),
("客户活跃度", "%", 70, None),
("NPS净推荐值", "分", 60, None),
]
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
"""解析时间区间"""
@@ -63,7 +45,6 @@ def list_customer_kpis(
end_date: str = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""获取客户维度KPI列表(含最新值、预警、趋势)"""
start, end = parse_period(period, start_date, end_date)
@@ -73,7 +54,6 @@ def list_customer_kpis(
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
).order_by(KPIDefinition.kpi_code).all()
result = []
@@ -89,7 +69,7 @@ def list_customer_kpis(
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
elif period == "year":
values = base_query.filter(actual_only_clause(), KPIValue.period.like(f"{period_str[:4]}%")).all()
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
elif period == "custom" and start_date and end_date:
@@ -103,7 +83,7 @@ def list_customer_kpis(
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
else:
latest = base_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
latest = base_query.order_by(KPIValue.period.desc()).first()
# 最新预警
alert = db.query(KPIAlert).filter(
@@ -144,7 +124,6 @@ def list_customer_kpis(
# 最近6期趋势数据
period_q = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(6).all()
period_values = [
{"period": v.period, "value": v.actual_value}
@@ -175,78 +154,6 @@ def list_customer_kpis(
return {"data": result, "period": period, "total": len(result)}
@router.get("/standard-indicators")
def standard_indicators(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""客户维度「标准指标族」九卡三态(真值 / 无数据 / 未定义·待建)——只读。
CPA P0-b(2026-10-04):页面 9 张预设卡此前对「KPI 库无定义」的项渲染成「无数据」,
与「定义在库但无值」不可区分。本端点逐卡给出三态,页面据此渲染标签,**禁止空显示为 0**。
映射为**显式白名单**(不做名称模糊匹配),仅当实例层 KPI 与预设卡语义完全一致才连:
客户满意度 → C_SATISFACTION(实例层同名,active,有值)
客户投诉率 → C_COMPLAINT_RATE(实例层同名,inactive ⇒ 无值)
其余 7 张保持未定义(依据任务 finance-20261003 现状表 §一「7 个在 KPI 库无可用定义」):
客户留存率 ≠ 客户保留率(C_RETENTION_RATE)、客户渠补率 ≠ 渠补率(C_REBATE_RATE)、
新客户增长率 ≠ 新客户数(C_NEW_CLIENTS)、客户平均贡献值/客户转化率/客户活跃度/NPS净推荐值 库内皆无。
"""
codes = [c for _, _, _, c in STANDARD_INDICATORS if c]
defs = {}
if codes:
# 用 raw SQL 读定义层:**不用 KPIDefinition ORM**——该模型的 `direction` 列由他单
# (CMA-KPI-20261003 KPI字典分层治理)在途新增,生产库尚未落列,ORM 查询会 1054;
# raw SQL 只取本端点需要的列,与在途 DDL 解耦(与 zxt_masterbill 同一形态)。
from sqlalchemy import bindparam, text as _text
stmt = _text(
"SELECT id, kpi_code, unit, target_value, status FROM kpi_definitions "
"WHERE entity_id = :eid AND dimension = 'customer' AND kpi_code IN :codes"
).bindparams(bindparam("codes", expanding=True))
rows = db.execute(stmt, {"eid": entity_id, "codes": codes}).fetchall()
defs = {r[1]: {"id": r[0], "unit": r[2], "target_value": r[3], "status": r[4]} for r in rows}
items = []
summary = {"value": 0, "no_data": 0, "undefined": 0, "total": len(STANDARD_INDICATORS)}
state_labels = {"value": "真值", "no_data": "无数据", "undefined": "未定义·待建"}
for name, unit, target, code in STANDARD_INDICATORS:
kpi = defs.get(code) if code else None
if kpi is None:
state = "undefined"
items.append({
"name": name, "unit": unit, "target_value": target, "state": state,
"state_label": state_labels[state], "kpi_code": None, "kpi_id": None,
"kpi_status": None, "actual_value": None, "period": None,
"why": "KPI 库无该定义",
})
else:
val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi["id"],
KPIValue.entity_id == entity_id,
KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
state = "value" if val else "no_data"
items.append({
"name": name, "unit": kpi["unit"] or unit,
"target_value": kpi["target_value"] if kpi["target_value"] is not None else target,
"state": state, "state_label": state_labels[state],
"kpi_code": code, "kpi_id": kpi["id"], "kpi_status": kpi["status"],
"actual_value": val.actual_value if val else None,
"period": val.period if val else None,
"why": None if val else "定义在库(status=%s)但无任何月期间值" % kpi["status"],
})
summary[state] += 1
return {
"entity_id": entity_id,
"items": items,
"summary": summary,
"note": ("state: value=定义在库且有值 / no_data=定义在库但无值 / "
"undefined=KPI 库无定义(未定义·待建);禁止把 undefined/no_data 显示为 0"),
}
@router.get("/trend/{kpi_id}")
def get_kpi_trend(
kpi_id: int,
@@ -261,7 +168,6 @@ def get_kpi_trend(
# 获取最近N期数据
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(months).all()
trend_data = [
+84 -474
View File
@@ -1,7 +1,7 @@
"""驾驶舱 API v2 — 支持时间区间"""
from fastapi import APIRouter, Depends, Query, Request, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import case, func, or_
from sqlalchemy import func, or_
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
@@ -11,13 +11,6 @@ from app.models import KPIDefinition, KPIValue, KPIAlert, User, VoucherDetail
from app.utils.cache import get as cache_get, set as cache_set
from app.utils.voucher_guard import apply_voucher_guard
from app.utils.threshold_parser import eval_threshold
from app.utils.alert_entity_scope import month_period_clause
# BEP 实体口径单一事实源(2026-10-03 扩面:酣客 entity1 / 博海 entity2 同表驱动派生器与卡面)
from app.utils.bep_caliber import CLOSING_SUMMARY_LIKE, caliber_or_none, fixed_cost_spec
# 「实测区 / 预估区」分区口径单一事实源(财务裁定 2026-10-02,任总「把 H2 预测行与实际分离」)
# —— 预估行(data_status='estimated' 或 source_type='forecast')禁进实测汇总 / 趋势序列 /
# 预警依据;仅 include_forecast=true 时以独立字段(forecast_value / is_forecast)单列返回。
from app.utils.kpi_value_scope import actual_only_clause, forecast_only_clause, is_forecast_row
# 指标方向(反向/正向)唯一权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口)。
# 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。
from app.api.kpis import REVERSE_INDICATORS
@@ -33,98 +26,35 @@ router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"],
# ── 本量利·保本点 卡片只读数据(cma-bep-kpi Phase2 2026-09-08)──
# 口径与 backend/scripts/kpi_bep_deriver.py 完全一致:
# 月销 = voucher_details subject 5001(主营业务收入) 贷方 YTD 月均(万元)
# 月管理费 = 5602% 借−贷 净额 YTD 月均(carry_forward=0 行域;账面口径含折旧)
# 折旧 = 560213 借−贷 净额 YTD 月均(同上;口径4 见 _decisions/cma-expense-profit-net-caliber-20261002.md)
# 变动成本率 = **实测累计**(⑤ 口径统一:酣客=Σ(5401+5403+571103−530103)÷Σ5001贷;
# 博海=Σ6401借÷Σ6001贷)——取自派生落库值 F_BEP_VC_RATE(单一事实源)
# 实测渠补率 = Σ571103借 ÷ Σ5001贷(酣客专用,F_BEP_REBATE,仅作 chip 展示,**不再充当变动成本率**)
# 保本额 = 月固定成本 ÷ 边际贡献率(边际贡献率 = 1 − 变动成本率)
# 2026-10-02 口径修订(任总 11:43 CST 放行「改,这样数据更加真实」):
# ① 渠补率不再用政策常量 84.61 参与计算,改用派生落库的实测累计值;
# 政策常量仅作对照返回(inputs.rebate_policy_pct),**不参与计算**;
# ⚠️ 2026-10-04(O1 收敛):该对照值的**唯一来源** = app/utils/bep_caliber.py 的
# ENTITY_CALIBERS[entity]["policy_pct"](酣客 e1 = 84.61;博海 e2 = None,无渠补科目)。
# 端点各处一律直接取口径值,**不得**再回落本模块内的硬编码常量——否则 e2 会被下游
# API 消费者误读为「博海有 84.61% 政策渠补率」(口径单一源原则)。
# ② 账面/现金两条路径**统一读派生落库值**——此前 book_bep 实时复算 voucher、
# cash_bep 读派生值,9 月数据入库而派生器未跑时同卡两口径不同步;
# ③ fail-closed:六条派生值(含渠补率)**同期齐全**才出卡;缺数 ⇒ available=False,
# 不出保本值、不回落政策常量(不跨期回落,避免混期口径)。
# 月管理费 = 5602% 借方 YTD 月均(账面口径含折旧)
# 折旧 = 560213 借方 YTD 月均
# 渠补率 = 厂家政策 84.61%(财务Bot权威口径)
# 保本额 = 月固定成本 ÷ (1-渠补率)
# 账面口径(含折旧) / 现金口径(剔除折旧) 双口径只读返回,无写操作。
# 现金口径数值优先读派生KPI(F_BEP_REVENUE_CASH/F_BEP_COVER_CASH)最新落库值,无派生值时回退现算。
BEP_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP",
"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH",
"F_BEP_VC_RATE", "F_BEP_REBATE")
"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH")
BEP_BOOK_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP") # 账面3条必须齐全才 available
BEP_CASH_CODES = ("F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH")
# 保本公式所用「变动成本率」编码(⑤ 口径统一 2026-10-03:边际贡献率 = 1 − 变动成本率)
BEP_VC_CODE = "F_BEP_VC_RATE"
# 实测渠补率编码(酣客 e1 专用、**可选**;⑤ 后**不再充当变动成本率**,仅作卡面 chip)
BEP_REBATE_CODE = "F_BEP_REBATE"
# fail-closed 必备六条:VC_RATE 必填;REBATE 因博海(e2)无渠补科目而不作必备
BEP_REQUIRED_CODES = tuple(c for c in BEP_CODES if c != BEP_REBATE_CODE)
# 展示用输入口径常量(**仅酣客(entity1)**;多实体一律走 app/utils/bep_caliber.py 的 ENTITY_CALIBERS,
# 2026-10-03 扩面:博海 entity2 为 6001/6401/6601+6602+6603/1602)
BEP_SALES_SUBJECT = "5001"
BEP_ADMIN_PREFIX = "5602"
BEP_DEP_SUBJECT = "560213"
# ⚠️ 2026-10-04(O1 收敛):本模块**不再**定义政策渠补率常量(原 BEP_REBATE_POLICY_PCT)。
# 政策常量的唯一来源 = app/utils/bep_caliber.py::ENTITY_CALIBERS[entity]["policy_pct"]
# (酣客 e1 = 84.61;博海 e2 = None,因无渠补科目)。端点各处直接取口径值,不做回落。
# bep_caliber 的带号科目项(terms)以 `debit`/`credit` 命名,本模块聚合用的是模型列名
# `debit_amount`/`credit_amount` ⇒ 必须做字段名映射。
# 2026-10-16 修复(P0):289cc8f2 引入的 terms 分支把 'debit' 原样传入 ⇒
# `getattr(VoucherDetail, 'debit')` AttributeError ⇒ e2 博海 `GET /api/cma/dashboard/bep-card` 500。
_BEP_FIELD_ALIAS = {"debit": "debit_amount", "credit": "credit_amount", "net": "net"}
BEP_REBATE_POLICY_PCT = 84.61
def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
field: str, subject_filter=None, *, exclude_closing: bool = False,
subject_filters=None, terms=None) -> float:
field: str, subject_filter=None) -> float:
"""聚合 voucher_details 金额(元),subject_filter: ('eq', code) / ('like', prefix)
field="net" = 借−贷 净额(口径4:费用/损益一律净额)。
⚠️ 净额只在 carry_forward==0 行域成立(含结转腿时损益类净额恒 0)
—— 见 /srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md。
exclude_closing(2026-10-03):该账套结转腿未标 carry_forward 时(博海:全账套 cf 恒 0),
须按 summary NOT LIKE '%结转%' 显式剔除,否则已结账期间净额恒 0。
subject_filters:多条 OR(固定成本前缀口径;酣客 5601/5602/5603 借−贷净额)。
terms(2026-10-15,ASK-20261014-04 §4-1):**带号科目项** [(sign, code, field), ...]
—— `bep_caliber.fixed_cost_spec()` 解析出的博海形态(6601/6602/6603 **仅借方**);
每项独立聚合后按符号相加,行域(账套/cf=0/剔结转腿/剔作废)与上面完全一致。
作废/撤单标记过滤(2026-09-14 收口): 已作废凭证不参与 BEP 均值。
"""
if terms:
total = 0.0
for sign, code, tfield in terms:
# terms 记法字段名 → 模型列名(缺此映射 ⇒ getattr(VoucherDetail,'debit') AttributeError)
col_field = _BEP_FIELD_ALIAS.get(str(tfield), str(tfield))
total += (1.0 if sign == "+" else -1.0) * _bep_sum_field(
db, entity_id, period_from, period_to, col_field,
("like", str(code)[:-1]) if str(code).endswith("%") else ("eq", code),
exclude_closing=exclude_closing)
return total
expr = (VoucherDetail.debit_amount - VoucherDetail.credit_amount
if field == "net" else getattr(VoucherDetail, field))
q = db.query(func.sum(expr)).filter(
q = db.query(func.sum(getattr(VoucherDetail, field))).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 0,
VoucherDetail.period >= period_from,
VoucherDetail.period <= period_to,
)
if exclude_closing:
q = q.filter(or_(VoucherDetail.summary.is_(None),
~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE)))
filt = subject_filter
if subject_filters:
clauses = []
for kind, code in subject_filters:
clauses.append(VoucherDetail.subject_code == code if kind == "eq"
else VoucherDetail.subject_code.like(code + "%"))
q = q.filter(or_(*clauses))
elif filt:
kind, code = filt
if subject_filter:
kind, code = subject_filter
if kind == "eq":
q = q.filter(VoucherDetail.subject_code == code)
elif kind == "like":
@@ -134,33 +64,6 @@ def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
return float(val or 0.0)
def _bep_inputs(db, entity_id: int, cal: dict, period_from: str, period: str, months: int) -> dict:
"""卡面展示用输入(万元/百分比)——按实体口径现算,与派生器同源同式。
固定成本(admin):经 `bep_caliber.fixed_cost_spec()` **唯一解析口**取科目构成 ——
e1 酣客 = 5601%/5602%/5603% 借−贷净额;e2 博海 = 6601/6602/6603 **仅借方**(§4-1 批后口径)。
"""
fix_mode, fix_spec = fixed_cost_spec(cal)
sales = _bep_sum_field(db, entity_id, period_from, period, cal["revenue_field"],
cal["revenue_filter"], exclude_closing=cal["exclude_closing"]) / 10000.0
if fix_mode == "terms":
admin = _bep_sum_field(db, entity_id, period_from, period, "net",
terms=fix_spec, exclude_closing=cal["exclude_closing"]) / 10000.0
else:
admin = _bep_sum_field(db, entity_id, period_from, period, "net",
subject_filters=fix_spec,
exclude_closing=cal["exclude_closing"]) / 10000.0
dep = _bep_sum_field(db, entity_id, period_from, period, cal["dep_field"],
cal["dep_filter"], exclude_closing=cal["exclude_closing"]) / 10000.0
return {
"sales_avg": round(sales / months, 2),
"admin_avg": round(admin / months, 2),
"dep_avg": round(dep / months, 2),
"cash_admin_avg": round((admin - dep) / months, 2),
}
def _bep_level(value, kpi) -> str:
"""按 KPI 静态阈值文本判定级别(绿→黄→红,命中即止),无阈值返回 none。
与 alert_generator / kpis._static_threshold_level 语义一致。"""
@@ -189,24 +92,7 @@ def get_bep_card(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""本量利·保本点 卡片数据(只读,A区)——账面/现金双口径
2026-10-02 口径修订(任总 11:43 CST 放行):
· 渠补率 = 派生落库的**实测累计值**(F_BEP_REBATE,单一事实源);政策常量仅对照返回;
· 账面 / 现金两条路径**统一读派生落库值**(不再实时复算 voucher);
· fail-closed:六条派生值同期不齐(尤其缺渠补率)⇒ available=False,不出保本值、不回落常量。
2026-10-03 口径统一(⑤,任总 18:00 CST):**边际贡献率 = 1 − 变动成本率**(F_BEP_VC_RATE,
完整口径:销货成本+税金及附加+渠补−厂补 / 博海 6401÷6001),不再用渠补率充当变动成本率;
实测渠补率 F_BEP_REBATE 降为 e1 专用展示 chip(e2 无渠补科目 ⇒ None,不再纳入必备集)。
"""
cal = caliber_or_none(entity_id)
if cal is None:
return {"available": False, "entity_id": entity_id,
"message": f"entity={entity_id} 未注册 BEP 口径(见 app/utils/bep_caliber.py)",
# 未注册口径 ⇒ 无政策常量来源(口径单一源):不得回落硬编码值
"rebate_policy_pct": None, "readonly": True}
"""本量利·保本点 卡片数据(只读,A区)——账面/现金双口径"""
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
@@ -217,92 +103,59 @@ def get_bep_card(
return {"available": False, "message": "BEP账面口径派生KPI未完整注册",
"found": list(kpis.keys()), "missing": missing_book}
# 派生落库值(同派生器:source_type='derived')——按 code × period 建表,period desc 排序
# ⚠️ 不过滤 actual_value IS NULL:fail-closed 落库的 pending 期(值 NULL)必须参与「最新期」判定,
# 否则卡面会悄悄跨期回落到上一期,掩盖「本期不出值」的事实。
rows = db.query(KPIValue, KPIDefinition.kpi_code).join(
KPIDefinition, KPIDefinition.id == KPIValue.kpi_id,
).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(BEP_CODES),
KPIValue.source_type == "derived",
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).all()
# 最新派生期间与值(同派生器:source_type='derived');现金2条缺失不阻塞整卡
latest = {}
for code in BEP_CODES:
if code not in kpis:
latest[code] = None
continue
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpis[code].id,
KPIValue.source_type == "derived",
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
latest[code] = {"period": v.period, "value": v.actual_value} if v else None
by_code = {} # {code: {period: value|None}}
remark_map = {} # {(code, period): remark} —— 用于区分「不适用」与「缺数」
for v, code in rows:
by_code.setdefault(code, {}).setdefault(v.period, v.actual_value)
remark_map.setdefault((code, v.period), v.remark or "")
# 卡面期 = 账面三条派生行的**最新期间**(与派生器目标期一致)
book_periods = sorted({p for c in BEP_BOOK_CODES for p in by_code.get(c, {})}, reverse=True)
if not book_periods:
return {"available": False, "entity_id": entity_id,
"message": "尚无派生值,请先运行 kpi_bep_deriver.py",
"rebate_policy_pct": cal.get("policy_pct"), "readonly": True}
period = book_periods[0]
periods = [latest[c]["period"] for c in BEP_BOOK_CODES if latest[c]]
if not periods:
return {"available": False, "message": "尚无派生值,请先运行 kpi_bep_deriver.py"}
period = max(periods)
year = period[:4]
months = int(period[5:7])
period_from = f"{year}-01"
period_to = period
# fail-closed:必备派生值(含变动成本率)本期必须齐全,缺一条即不出保本值
missing_at_period = [c for c in BEP_REQUIRED_CODES if by_code.get(c, {}).get(period) is None]
if missing_at_period:
# ① 不适用(派生器 fail-closed 落库:remark 已注明「保本点不适用」)
# —— 与「缺数」区分:卡面显示「不适用」而非「数据不齐」,且不得显示 0。
na_remarks = [remark_map.get((c, period), "") for c in missing_at_period]
na_hit = [r for r in na_remarks if "不适用" in r]
if na_hit and BEP_VC_CODE not in missing_at_period:
reason = na_hit[0]
label = ("边际贡献为负 · 保本点不适用" if "边际贡献非正" in reason
else "固定成本退化 · 保本点不适用")
return {"available": False, "not_applicable": True, "entity_id": entity_id,
"period": period, "months": months,
"label": label, "reason": reason,
"vc_pct": by_code.get(BEP_VC_CODE, {}).get(period),
"rebate_pct": by_code.get(BEP_REBATE_CODE, {}).get(period),
"rebate_policy_pct": cal.get("policy_pct"),
"entity_name": cal["name"],
"missing_codes": missing_at_period,
"derived_periods": book_periods[:6],
"data_source": "derived", "readonly": True}
# ② 缺数(真不齐)
if BEP_VC_CODE in missing_at_period:
msg = (f"{cal['margin_label']}不可得:本期未落 F_BEP_VC_RATE 派生值 ⇒ 不出保本值"
"(fail-closed,不回落政策常量、不跨期回落)")
else:
msg = ("本期派生值不齐 %s ⇒ 不出保本值(fail-closed,不跨期回落)"
% ",".join(missing_at_period))
return {"available": False, "entity_id": entity_id, "period": period,
"message": msg, "missing_codes": missing_at_period,
"rebate_policy_pct": cal.get("policy_pct"),
"derived_periods": book_periods[:6], "readonly": True}
# 输入口径(万元 / %)——与派生器一致
sales_avg = _bep_sum_field(db, entity_id, period_from, period_to,
"credit_amount", ("eq", BEP_SALES_SUBJECT)) / 10000.0 / months
admin_avg = _bep_sum_field(db, entity_id, period_from, period_to,
"debit_amount", ("like", BEP_ADMIN_PREFIX)) / 10000.0 / months
dep_avg = _bep_sum_field(db, entity_id, period_from, period_to,
"debit_amount", ("eq", BEP_DEP_SUBJECT)) / 10000.0 / months
rebate_pct = BEP_REBATE_POLICY_PCT
cm_pct = 100.0 - rebate_pct
# 派生落库值(同期)—— 账面 / 现金两条路径同一来源,卡面不做实时复算
d = {c: by_code.get(c, {}).get(period) for c in BEP_CODES}
book_bep = float(d["F_BEP_REVENUE"])
book_cover = float(d["F_BEP_COVER"])
book_gap = float(d["F_BEP_GAP"])
cash_bep = float(d["F_BEP_REVENUE_CASH"])
cash_cover = float(d["F_BEP_COVER_CASH"])
cash_gap = round(100.0 - cash_cover, 1) # 缺口 = 100 − 覆盖率(与派生器同口径)
# ⑤ 口径统一(任总 2026-10-03 18:00「统一」):保本公式用**变动成本率**(F_BEP_VC_RATE),
# 边际贡献率 = 1 − 变动成本率;F_BEP_REBATE(实测渠补率)仅酣客(e1)有,**不再充当变动成本率**。
vc_pct = float(d[BEP_VC_CODE])
cm_pct = round(100.0 - vc_pct, 2)
rebate_pct = (float(d[BEP_REBATE_CODE]) if d.get(BEP_REBATE_CODE) is not None else None)
# 账面口径:管理费含折旧(与派生落库值一致)
book_bep = round(admin_avg / (cm_pct / 100.0), 1)
book_cover = round(sales_avg / book_bep * 100.0, 1) if book_bep else 0.0
book_gap = round((book_bep - sales_avg) / book_bep * 100.0, 1) if book_bep else 0.0
# 现金口径:管理费-折旧;优先读派生KPI最新落库值(单一事实源),缺失时回退现算
cash_admin = admin_avg - dep_avg
cash_derived_bep = latest["F_BEP_REVENUE_CASH"]["value"] if latest.get("F_BEP_REVENUE_CASH") else None
cash_derived_cover = latest["F_BEP_COVER_CASH"]["value"] if latest.get("F_BEP_COVER_CASH") else None
if cash_derived_bep is not None and cash_derived_cover is not None:
cash_bep = cash_derived_bep
cash_cover = cash_derived_cover
else:
cash_bep = round(cash_admin / (cm_pct / 100.0), 1) if cash_admin > 0 else 0.0
cash_cover = round(sales_avg / cash_bep * 100.0, 1) if cash_bep else 0.0
cash_gap = round((cash_bep - sales_avg) / cash_bep * 100.0, 1) if cash_bep else 0.0
# 输入口径(万元,展示用;数值口径一律以派生落库值为准)——按实体口径现算
inp = _bep_inputs(db, entity_id, cal, period_from, period, months)
sales_avg = inp["sales_avg"]
admin_avg = inp["admin_avg"]
dep_avg = inp["dep_avg"]
cash_admin = inp["cash_admin_avg"]
policy_pct = cal.get("policy_pct") # 博海无渠补政策常量 ⇒ None(前端不显示对照)
# 回显六条派生落库值(同期)
derived = {c: {"period": period, "value": d[c]} for c in BEP_CODES}
# 回显最新落库派生值(账面3条+现金2条,存在时输出)
derived = {}
for code in BEP_CODES:
lv = latest[code]
derived[code] = {"period": lv["period"], "value": lv["value"]} if lv else None
cover_kpi = kpis["F_BEP_COVER"]
book_level = _bep_level(book_cover, cover_kpi)
@@ -321,13 +174,8 @@ def get_bep_card(
"sales_avg": round(sales_avg, 2),
"admin_avg": round(admin_avg, 2),
"dep_avg": round(dep_avg, 2),
"vc_pct": vc_pct,
"rebate_pct": rebate_pct,
"rebate_policy_pct": policy_pct, # 口径单一源:bep_caliber.ENTITY_CALIBERS[entity]["policy_pct"]
"rebate_source": ("derived:F_BEP_VC_RATE(⑤完整口径:销货成本+税金及附加+渠补−厂补)"
if entity_id == 1
else "derived:F_BEP_VC_RATE(Σ6401借÷Σ6001贷)"),
"cm_pct": cm_pct,
"cm_pct": round(cm_pct, 2),
},
"book": {
"bep_revenue": book_bep,
@@ -345,9 +193,7 @@ def get_bep_card(
"gap_level": cash_gap_level,
},
"derived": derived,
"data_source": "derived", # 账面/现金同源:均读派生落库值
"formula": (f"保本销售额 = 月固定成本 ÷ 边际贡献率{cm_pct:.2f}%"
f"(边际贡献率 = 1 − 变动成本率{vc_pct:.2f}%)"),
"formula": "保本销售额 = 月固定成本 ÷ (1 − 渠补率84.61%)",
"readonly": True,
}
@@ -449,29 +295,9 @@ def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month")
cache_set("dashboard", cache_key, result, ttl_seconds=30)
return result
def _sum_rows(values):
"""区间汇总辅助:保留原实现的「和为 0 视为无数据」历史语义(回归等值前提)"""
if not values:
return None
total = sum(v.actual_value for v in values if v.actual_value)
return total if total else None
def _aggregate_actual(values, label):
"""实测区区间汇总 → 与原匿名对象同形(actual_value / period)"""
total = _sum_rows(values)
if total is None:
return None
return type('obj', (object,), {"actual_value": total, "period": label})()
@router.get("/kpis")
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
start_date: str = Query(None), end_date: str = Query(None),
include_forecast: bool = Query(
False,
description="是否单列返回预估区(data_status=estimated 或 source_type=forecast);"
"默认 false=只取实测区,预估行禁与实测相加"),
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
start, end = parse_period(period, start_date, end_date)
period_str = start.strftime("%Y-%m")
@@ -481,51 +307,21 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
KPIDefinition.entity_id == entity_id
).all()
result = []
forecast_section = [] # 预估区(单列,不计入上面的 actual_value)
for k in kpis:
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
# 实测区恒过滤:预估行(estimated/forecast)不进实测取值与汇总
actual_query = base_query.filter(actual_only_clause())
forecast_query = base_query.filter(forecast_only_clause()) if include_forecast else None
actual_value = None
forecast_value = None
is_forecast = False
# 期间展示标签(各模式下与实测取值同口径)
if period == "month":
range_label = period_str
elif period == "quarter":
_months = period_prefix("quarter")
range_label = f"{_months[0]}~{_months[-1]}"
elif period == "year":
range_label = period_str[:4]
elif period == "custom" and start_date and end_date:
range_label = f"{start_date}~{end_date}"
else:
range_label = None
if period == "month":
latest = actual_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
if forecast_query is not None:
fc_row = forecast_query.filter(
KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
if fc_row is not None:
forecast_value = fc_row.actual_value
if latest is None: # 该期只有预估行:值单列展示并打标,不并入实测
is_forecast = True
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
elif period == "quarter":
months = period_prefix("quarter")
latest = _aggregate_actual(actual_query.filter(KPIValue.period.in_(months)).all(), range_label)
if forecast_query is not None:
forecast_value = _sum_rows(forecast_query.filter(KPIValue.period.in_(months)).all())
values = base_query.filter(KPIValue.period.in_(months)).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
elif period == "year":
latest = _aggregate_actual(
actual_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all(), range_label)
if forecast_query is not None:
forecast_value = _sum_rows(
forecast_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all())
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
elif period == "custom" and start_date and end_date:
periods = []
d = start
@@ -533,64 +329,37 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
periods.append(d.strftime("%Y-%m"))
d += timedelta(days=32)
d = d.replace(day=1)
latest = _aggregate_actual(actual_query.filter(KPIValue.period.in_(set(periods))).all(),
range_label)
if forecast_query is not None:
forecast_value = _sum_rows(
forecast_query.filter(KPIValue.period.in_(set(periods))).all())
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
else:
latest = actual_query.filter(
month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
if forecast_query is not None:
fc_row = forecast_query.filter(
month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
if fc_row is not None:
forecast_value = fc_row.actual_value
if latest is None:
is_forecast = True
range_label = fc_row.period
latest = base_query.order_by(KPIValue.period.desc()).first()
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
if forecast_value is not None:
forecast_section.append({
"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"value": forecast_value, "unit": k.unit,
"period": (latest.period if latest is not None else range_label),
"data_status": "estimated", "is_forecast": True, "period_type": "forecast",
})
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else None,
"forecast_value": forecast_value,
"is_forecast": is_forecast,
"period_type": "forecast" if is_forecast else "actual",
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
})
resp = {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}}
if include_forecast:
resp["forecast"] = forecast_section
return resp
return {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}}
@router.get("/my-kpis")
def get_my_kpis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取当前用户负责的KPI
- business角色:只看自己负责的KPI
@@ -601,12 +370,8 @@ def get_my_kpis(
name = current_user.name
period_str = datetime.now().strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
).all()
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
result = []
forecast_section = [] # 预估区(单列,不进 actual_value / trend)
for k in kpis:
# business角色筛选
@@ -615,36 +380,11 @@ def get_my_kpis(
if responsible and responsible != username and responsible != name:
continue
# 实测区恒过滤(预估行禁作实测值/禁进趋势序列)
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
actual_only_clause(),
).order_by(KPIValue.id.desc()).first()
forecast_value = None
is_forecast = False
if include_forecast:
fc_row = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
forecast_only_clause(),
).order_by(KPIValue.id.desc()).first()
# 无月期预估行时回退到最近一条预估行(半年度 H2 等伪期间),仅作单列展示
if fc_row is None:
fc_row = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
forecast_only_clause(),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
if fc_row is not None:
forecast_value = fc_row.actual_value
is_forecast = latest is None
forecast_section.append({
"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"value": forecast_value, "unit": k.unit, "period": fc_row.period,
"data_status": "estimated", "is_forecast": True, "period_type": "forecast",
})
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
@@ -652,8 +392,6 @@ def get_my_kpis(
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
month_period_clause(KPIValue.period),
actual_only_clause(),
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
@@ -664,9 +402,6 @@ def get_my_kpis(
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else period_str,
"forecast_value": forecast_value,
"is_forecast": is_forecast,
"period_type": "forecast" if is_forecast else "actual",
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"alert_id": alert.id if alert else None,
@@ -679,17 +414,13 @@ def get_my_kpis(
"threshold_red": k.threshold_red,
})
resp = {"data": result, "user_role": role, "user_name": name, "period": period_str}
if include_forecast:
resp["forecast"] = forecast_section
return resp
return {"data": result, "user_role": role, "user_name": name, "period": period_str}
@router.get("/finance-analysis")
def get_finance_analysis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
@@ -703,33 +434,14 @@ def get_finance_analysis(
).all()
kpi_data = []
forecast_section = [] # 预估区(单列,不进 trend / summary)
for k in finance_kpis:
# 实测区恒过滤(预估行禁作实测值/禁进趋势序列)
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
actual_only_clause(),
).order_by(KPIValue.id.desc()).first()
forecast_value = None
if include_forecast:
fc_row = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
forecast_only_clause(),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
if fc_row is not None:
forecast_value = fc_row.actual_value
forecast_section.append({
"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"value": forecast_value, "unit": k.unit, "period": fc_row.period,
"data_status": "estimated", "is_forecast": True, "period_type": "forecast",
})
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
month_period_clause(KPIValue.period),
actual_only_clause(),
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
@@ -743,8 +455,6 @@ def get_finance_analysis(
"unit": k.unit, "target_value": k.target_value,
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
"actual_value": latest.actual_value if latest else None,
"forecast_value": forecast_value,
"is_forecast": False,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
@@ -758,7 +468,7 @@ def get_finance_analysis(
cost_control = next((k for k in kpi_data if k["kpi_code"] == "COST_CONTROL_RATE"), None)
receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None)
resp = {
return {
"period": period_str,
"kpis": kpi_data,
"summary": {
@@ -768,21 +478,11 @@ def get_finance_analysis(
"receivable_turnover": receivable["actual_value"] if receivable else None,
}
}
if include_forecast:
resp["forecast"] = forecast_section
return resp
@router.get("/predict")
def predict_kpis(include_forecast: bool = Query(
False, description="是否单列返回预估区;默认 false=只取实测区(预估行禁进预测序列)"),
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""基于历史趋势预测下月KPI值(简单线性回归)
口径(财务裁定 2026-10-02):预估行(estimated/forecast)**不进回归序列**——
预估禁与实测相加、禁作预测/预警依据。`include_forecast=true` 仅额外单列返回
预估区(`forecast_rows`),不改变回归取数。
"""
def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""基于历史趋势预测下月KPI值(简单线性回归)"""
from datetime import datetime, timedelta
period_str = datetime.now().strftime("%Y-%m")
@@ -798,25 +498,12 @@ def predict_kpis(include_forecast: bool = Query(
KPIDefinition.entity_id == entity_id
).all()
predictions = []
forecast_rows = [] # 预估区(单列,不参与回归)
for k in kpis:
values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
actual_only_clause(),
).order_by(KPIValue.period.asc()).all()
if include_forecast:
for fc_row in db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
forecast_only_clause(),
).order_by(KPIValue.period.asc()).all():
forecast_rows.append({
"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"period": fc_row.period, "value": fc_row.actual_value,
"unit": k.unit, "data_status": "estimated", "is_forecast": True, "period_type": "forecast",
})
# 需要至少3个数据点才能做预测
if len(values) < 3:
continue
@@ -867,80 +554,21 @@ def predict_kpis(include_forecast: bool = Query(
"data_points": len(points),
})
resp = {
return {
"current_period": period_str,
"next_period": next_period,
"predictions": predictions,
"kpi_count": len(kpis),
"predictable_count": len(predictions),
}
if include_forecast:
resp["forecast_rows"] = forecast_rows
return resp
# ── 个人工作台 ──────────────────────────────
# KPIValue 取数口径(2026-09-18 工作台「我的KPI」期间缺口修正)
# 原实现(my_dashboard 内联)按 calculated_at 倒序取「最近写入」的一条:批量补录/复采会把
# **旧期间**的行写得更晚 → 卡片显示过期期间。实测 entity1:F_REVENUE/F_NET_PROFIT 的
# 2026-07 行写入时刻(2026-09-14 17:01:22, id=2128/2130)晚于 2026-08 行(17:00:30, id=2125/2127)
# → 取到 7 月;C_REBATE_RATE 1–4 月补录行 calculated_at 并列(2026-08-24 17:17:38) → 取到 4 月。
# 新口径:优先取「月期间 YYYY-MM」中最新的一期(月期是采集/录入的常规粒度);若该 KPI 只有
# H1/H2/FY 等汇总期行,则回退到原按 calculated_at 的取法(不改变无月期 KPI 的行为)。
# 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把
# 季度汇总行/半年度预估值(forecast)当成"最新"。
# 期间口径(R3 正则 / SQL 等价式)已于 2026-09-26 收口到 app/utils/alert_entity_scope.py
# (is_month_period / month_period_clause),此处不再保留第二份副本。
# 主值优先(2026-09-18 同期间重复行判据收口,项目单 project-20260918-kpi-row-priority)
# 同一期间可能并存两行:①主值(source_type='ledger' / data_status='verified',结账/复核口径)
# ②自动归集重复行(source_type='auto_collect',备注自述「与主值为同一事实重复行」)。
# 原判据仅看写入时刻:重复行由复采批量写入、calculated_at 更晚 ⇒ 会被选中(实测 entity1
# 2026-08 的 F_REVENUE/F_NET_PROFIT:2136/2127 auto_collect 写于 09-14,晚于主值 1822/2102)。
# 今日两行数值一致(Δ=0)故用户不可见;一旦重复行与主值漂移,卡片会静默显示重复行的值。
# 故在**同一期间内**加「主值优先」:verified(已复核)> ledger(账面主值)> 其余。
# 注意:跨期间仍以 period 倒序为第一判据(不改变 d33b5e1 的期间口径——当期只有未复核行时,
# 不会回退到上一期已复核行)。
_MAIN_VALUE_PRIORITY = case(
(KPIValue.data_status == "verified", 0),
(KPIValue.source_type == "ledger", 1),
else_=2,
)
def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
"""该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。
口径(财务裁定 2026-10-02):**只取实测区**——预估行(data_status='estimated' 或
source_type='forecast')不得作为卡片「最新值」(工作台卡片=实测口径)。
"""
monthly = (
db.query(KPIValue)
.filter(KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period),
actual_only_clause())
.order_by(
KPIValue.period.desc(),
_MAIN_VALUE_PRIORITY,
KPIValue.calculated_at.desc(),
KPIValue.id.desc(),
)
.first()
)
if monthly is not None:
return monthly
return (
db.query(KPIValue)
.filter(KPIValue.kpi_id == kpi_id, actual_only_clause())
.order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc())
.first()
)
@router.get("/my-dashboard")
def my_dashboard(
current_user: User = Depends(require_auth),
include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
@@ -984,23 +612,11 @@ def my_dashboard(
all_kpis = assigned_kpis + preset_kpis
kpi_list = []
forecast_section = [] # 预估区(单列,不作卡片实测值)
for k in all_kpis:
latest_v = latest_kpi_value(db, k.id)
forecast_value = None
if include_forecast:
fc_row = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
forecast_only_clause(),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
if fc_row is not None:
forecast_value = fc_row.actual_value
forecast_section.append({
"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"value": forecast_value, "unit": k.unit, "period": fc_row.period,
"data_status": "estimated", "is_forecast": True, "period_type": "forecast",
})
latest_v = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id
).order_by(KPIValue.calculated_at.desc()).first()
actual = latest_v.actual_value if latest_v else None
target = kpi_target_by_frequency(k)
level = "gray"
@@ -1030,9 +646,6 @@ def my_dashboard(
"unit": k.unit,
"level": level,
"period": latest_v.period if latest_v else None,
"forecast_value": forecast_value,
"is_forecast": is_forecast_row(latest_v),
"period_type": "actual",
})
# 2. 我的改善行动(assignee匹配;CEO/管理员看全部)
@@ -1112,11 +725,8 @@ def my_dashboard(
"related_type": "kpi",
})
resp = {
return {
"kpis": kpi_list,
"action_plans": plan_list,
"reminders": reminders,
}
if include_forecast:
resp["forecast"] = forecast_section
return resp
+3 -7
View File
@@ -9,7 +9,6 @@ from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.utils.oplog import log_op
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
@@ -366,8 +365,7 @@ def create_source(data: dict, db: Session = Depends(get_db), entity_id: int = De
db.commit()
db.refresh(source)
# 操作日志
log_op(db, "create_source", target_type="source", target_id=source.id,
detail=source.name, entity_id=entity_id)
db.add(OperationLog(action="create_source", target_type="source", detail=source.name))
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@@ -384,8 +382,7 @@ def update_source(source_id: int, data: dict, db: Session = Depends(get_db), ent
setattr(source, key, data[key])
db.commit()
db.refresh(source)
log_op(db, "update_source", target_type="source", target_id=source.id,
detail=source.name, entity_id=entity_id)
db.add(OperationLog(action="update_source", target_type="source", detail=source.name))
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@@ -397,8 +394,7 @@ def delete_source(source_id: int, db: Session = Depends(get_db), entity_id: int
).first()
if not source:
raise HTTPException(404, "数据源不存在")
log_op(db, "delete_source", target_type="source", target_id=source.id,
detail=source.name, entity_id=entity_id)
db.add(OperationLog(action="delete_source", target_type="source", detail=source.name))
db.delete(source)
db.commit()
return {"message": "删除成功"}
+3 -11
View File
@@ -107,11 +107,7 @@ def _query_items(
items: list[dict] = []
if not data_type or data_type == "subject":
# 账套分层(2026-09-23):全局标准准则科目 ∪ 本账套私有明细(与 KPI 侧同一账套口径)
q = db.query(Subject).filter(
Subject.is_active == 1,
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
)
q = db.query(Subject).filter(Subject.is_active == 1)
if data_level:
q = q.filter(Subject.data_level == data_level)
if important == 1:
@@ -185,13 +181,9 @@ def get_stats(
current_user = Depends(require_auth),
):
"""数据分类分级统计概览(评估前准备看板)"""
subject_total = db.query(Subject).filter(
Subject.is_active == 1,
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
).count()
subject_total = db.query(Subject).filter(Subject.is_active == 1).count()
subject_marked = db.query(Subject).filter(
Subject.is_active == 1, Subject.important_flag == 1,
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
Subject.is_active == 1, Subject.important_flag == 1
).count()
kpi_total = db.query(KPIDefinition).filter(
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
File diff suppressed because it is too large Load Diff
+22 -59
View File
@@ -6,12 +6,10 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.deps import get_entity_id
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
# 裁定 R5-补1(2026-09-19 财务口径):差异反打路径同源接入 R1/R5 守卫
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
import json
import logging
from datetime import datetime
logger = logging.getLogger("cma.deviation_push")
@@ -24,7 +22,6 @@ router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
def push_deviation_to_map(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""从差异分析回写实际值到战略地图节点
@@ -50,25 +47,9 @@ def push_deviation_to_map(
if new_value is None:
raise HTTPException(400, "缺少 newValue")
# R5-补1(财务裁定 2026-09-19):「请求体无 period 一律不落库」
# 旧码在缺 period 时用当月冒充期间再落库(预警 + kpi_values 双写)——
# 该兜底正好绕过 R5「可归期」判据,故此处直接整请求零业务写入返回。
if not period:
logger.info(
"R5-补1 期间守卫:deviation_push 请求无 period,整请求零业务写入"
" entity_id=%s map_id=%s node_id=%s", entity_id, map_id, node_id)
return {
"success": False,
"message": "缺少 period:按 R5-补1(期间不可归期)未落库任何数据",
"alert_created": False,
"alert_skipped_reason": "no_period",
"zero_write": True,
"review_topic_created": False,
}
# 1. 查找战略地图(2026-09-18 派生单: 地图须归属会话账套,NULL 归属按 e1 兼容)
# 1. 查找战略地图
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m or (m.entity_id or 1) != entity_id:
if not m:
raise HTTPException(404, "战略地图不存在")
# 2. 解析 node_id 格式: "finance-0"
@@ -108,12 +89,13 @@ def push_deviation_to_map(
kpi_code = kpi_codes[0]
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi or (kpi.entity_id or 1) != entity_id:
if not kpi:
raise HTTPException(404, f"KPI {kpi_code} 不存在")
# 3. 更新实际值到 KPIValue 表
#(R5-补1:period 已在上方强制校验,原「缺 period 用当月兜底」分支已删除——
# 该兜底会伪造期间,正是 R5「不可归期」的成因之一)
if not period:
period = datetime.now().strftime("%Y-%m")
existing_value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
@@ -122,22 +104,20 @@ def push_deviation_to_map(
if existing_value:
existing_value.actual_value = new_value
existing_value.source_type = "manual"
kv_row = existing_value
else:
kv_row = KPIValue(
kv = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=new_value,
source_type="manual",
)
db.add(kv_row)
db.add(kv)
db.flush()
# 4. 检查是否触发预警
alert_created = False
alert_id = None
alert_skipped_reason = None
if kpi.target_value and kpi.target_value > 0:
ratio = new_value / kpi.target_value
if ratio < 0.7:
@@ -150,41 +130,28 @@ def push_deviation_to_map(
alert_level = None
if alert_level:
# R1/R5 双守卫(裁定 R5-补1):demo 账套 / 非规整月期间 → 该 kpi_alerts 行不入库,
# 返回体登记跳过原因(KPI 值照写,与 bot_bridge 同口径:裁定只约束预警行)
if not entity_alert_guard(db, entity_id, source="deviation_push.push_deviation_to_map",
detail="kpi=%s" % kpi_code):
alert_skipped_reason = "R1_demo_entity_excluded"
elif not guard_alert_period(period, source="deviation_push.push_deviation_to_map",
detail="kpi=%s" % kpi_code):
alert_skipped_reason = "R5_non_attributable_period"
if alert_skipped_reason is None:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
kpi_value_id=kv_row.id, # R5-补1: 期间关联(同 kpi 同期间行 → 可派生)
alert_level=alert_level,
alert_message=alert_msg,
status="pending",
)
db.add(alert)
db.flush()
alert_created = True
alert_id = alert.id
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
alert_level=alert_level,
alert_message=alert_msg,
status="pending",
)
db.add(alert)
db.flush()
alert_created = True
alert_id = alert.id
# 5. 生成战略回顾会议题
review_topic_created = False
if create_review_topic:
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
existing_topic = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id, # 2026-09-18 派生单: 去重按账套隔离
ActionPlan.title == topic_title,
ActionPlan.status.in_(["pending", "in_progress"]),
).first()
if not existing_topic:
topic = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
kpi_id=kpi.id,
title=topic_title,
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
@@ -203,14 +170,12 @@ def push_deviation_to_map(
target_type="map",
target_id=map_id,
detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"node_id": node_id,
"deviation_id": deviation_id,
"kpi_code": kpi_code,
"new_value": new_value,
"period": period,
"alert_created": alert_created,
"alert_skipped_reason": alert_skipped_reason,
"review_topic_created": review_topic_created,
}, ensure_ascii=False),
)
@@ -225,17 +190,15 @@ def push_deviation_to_map(
"new_value": new_value,
"alert_created": alert_created,
"alert_id": alert_id,
"alert_skipped_reason": alert_skipped_reason,
"review_topic_created": review_topic_created,
}
@router.get("/map-nodes/{map_id}")
def get_map_nodes(map_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
def get_map_nodes(map_id: int, db: Session = Depends(get_db)):
"""获取战略地图的全部节点(供反打选择使用)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m or (m.entity_id or 1) != entity_id: # 2026-09-18 派生单: NULL 归属按 e1 兼容(同 push 护栏)
if not m:
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
-555
View File
@@ -1,555 +0,0 @@
"""CMA 文档中心 API —— md 单一来源(任务 yanxue-20260924-cma-docs-center-poc)
口径(任总 2026-09-24 00:54 拍板):**指标进 UI、结论出 md、机器走 API/MCP。**
设计要点
--------
1. 内容单一来源 = 运行期目录(默认 `/srv/hermes/shared/cma-content`,可用 `CMA_DOCS_DIR` 覆盖),
一篇一个 `docs/<slug>.md`,front-matter 带 title/category/category_name/icon/summary/order。
2. **故意不放在仓库内**:CMA 部署链是「部署树 = origin/main 已提交树」(deploy.sh `[0]/[1]`
`git checkout -f` + `git clean -fdx`):仓内内容目录会被每次部署重置,且内容改动会让
部署树出现「未提交修改」而被 `[1.1]` 判失败、**阻断整个部署**。放仓外 ⇒ 改内容不需要
git/部署/重启,dev 树与部署树天然共读同一份。
3. **本模块每次请求实时读盘,无缓存、不做 build-time 打包** ⇒ 新增/修改一个 md 后,
页面刷新即变,前端无需改码、无需 rebuild。
4. 只**新增**端点,不改任何既有接口语义;内容为系统级共享知识(与 `knowledge_articles`
`entity_id IS NULL` 的全局共享同族),不做账套分区,故不接 `entity_id`。
5. 写口 = 文件系统(Bot/Agent 直接写 md),**不提供写 API**,避免出现"第二来源"。
"""
from __future__ import annotations
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from urllib.parse import quote
from app.auth_middleware import require_auth, require_role
from app.database import get_db
from app.deps import get_session_entity
from app.models import Entity, User, UserEntity
logger = logging.getLogger("cma.doc_center_api")
DEFAULT_DOCS_DIR = "/srv/hermes/shared/cma-content"
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
router = APIRouter(
prefix="/api/cma/docs",
tags=["文档中心"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ────────────────────────── 内容目录(运行期读盘)──────────────────────────
def content_root() -> Path:
"""内容根目录(每次调用实时解析,便于测试/迁移换目录)"""
return Path(os.environ.get("CMA_DOCS_DIR", DEFAULT_DOCS_DIR))
def docs_dir() -> Path:
return content_root() / "docs"
def _parse_scalar(raw: str):
raw = raw.strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
body = raw[1:-1]
return body.replace('\\"', '"').replace("\\\\", "\\")
if re.fullmatch(r"-?\d+", raw or ""):
return int(raw)
return raw
def parse_front_matter(text: str) -> Optional[dict]:
"""极简 YAML front-matter 解析(只支持 `key: value` 标量,避免引入新依赖)"""
if not text.startswith("---"):
return None
end = text.find("\n---", 3)
if end < 0:
return None
meta = {}
for line in text[3:end].splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
key, sep, val = line.partition(":")
if not sep:
continue
meta[key.strip()] = _parse_scalar(val)
body = text[end + 4:]
if body.startswith("\n"):
body = body[1:]
return {"meta": meta, "body": body}
def _category_registry() -> list:
"""分类注册表(categories.json):决定分类顺序与展示名/图标"""
path = content_root() / "categories.json"
cats = []
if path.is_file():
import json
try:
data = json.loads(path.read_text(encoding="utf-8"))
cats = list(data.get("categories") or [])
except Exception as exc: # 注册表损坏不阻断读内容,只在响应里暴露
logger.warning("categories.json 解析失败: %s", exc)
return []
return cats
def _read_doc(path: Path):
try:
text = path.read_text(encoding="utf-8")
except Exception as exc:
return None, f"读取失败: {exc}"
parsed = parse_front_matter(text)
if parsed is None:
return None, "缺少 front-matter(--- 包裹的 title/category/... )"
meta = parsed["meta"]
for field in ("title", "category", "summary"):
if not str(meta.get(field, "")).strip():
return None, f"front-matter 缺必填字段: {field}"
stat = path.stat()
return {
"slug": path.stem,
"title": str(meta["title"]),
"category": str(meta["category"]),
"summary": str(meta["summary"]),
"order": int(meta.get("order") or 0),
"icon": str(meta.get("icon") or ""),
"category_name": str(meta.get("category_name") or ""),
"bytes": stat.st_size,
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"content": parsed["body"],
}, None
def load_docs() -> tuple:
"""实时读盘:返回 (docs, skipped, available)。docs 已按 (category order, doc order, slug) 排序"""
d = docs_dir()
if not d.is_dir():
return [], [], False
cats = _category_registry()
cat_order = {c.get("key"): int(c.get("order") or 0) for c in cats}
docs, skipped = [], []
for path in sorted(d.glob("*.md")):
if not SLUG_RE.match(path.stem):
skipped.append({"file": path.name, "reason": "文件名不符合 slug 规则 ^[a-z0-9][a-z0-9-]{0,63}$"})
continue
doc, err = _read_doc(path)
if err:
skipped.append({"file": path.name, "reason": err})
continue
docs.append(doc)
docs.sort(key=lambda x: (cat_order.get(x["category"], 10_000), x["category"], x["order"], x["slug"]))
return docs, skipped, True
def _categories_view(docs: list) -> list:
"""分类视图:按注册表 `order` 升序(未登记分类补在末尾,不丢内容)"""
cats = _category_registry()
seen, out, seq = set(), [], 0
for c in cats:
key = c.get("key")
if not key or key in seen:
continue
seen.add(key)
seq += 1
out.append({
"key": key,
"name": c.get("name") or key,
"icon": c.get("icon") or "",
"order": int(c.get("order") or 0),
"_seq": seq,
"count": len([d for d in docs if d["category"] == key]),
})
for d in docs:
if d["category"] in seen:
continue
seen.add(d["category"])
seq += 1
out.append({
"key": d["category"],
"name": d["category_name"] or d["category"],
"icon": d["icon"] or "",
"order": 10_000,
"_seq": seq,
"count": len([x for x in docs if x["category"] == d["category"]]),
})
out.sort(key=lambda c: (c["order"], c["_seq"]))
for c in out:
c.pop("_seq")
return out
def _meta_only(doc: dict) -> dict:
return {k: v for k, v in doc.items() if k != "content"}
# ────────────────────────── 端点(只新增)──────────────────────────
@router.get("")
def list_docs(
request: Request,
with_content: bool = Query(False, description="是否带上 md 正文(默认只返回元数据)"),
category: Optional[str] = Query(None, description="按分类 key 过滤"),
type: str = Query("knowledge", description="内容类型:knowledge(缺省,知识库)/ report(报告解读)"),
domain: Optional[str] = Query(None, description="type=report:按域过滤"),
entity: Optional[str] = Query(None, description="type=report:按账套归属过滤"),
kind: Optional[str] = Query(None, description="type=report:按报告类型过滤"),
date_from: Optional[str] = Query(None, description="type=report:起始日(含)YYYY-MM-DD"),
date_to: Optional[str] = Query(None, description="type=report:结束日(含)YYYY-MM-DD"),
diagnose: bool = Query(False, description="type=report:附带被排除件明细(自诊断)"),
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""文档列表(实时读盘)。
- `data`:按「分类顺序 → 篇内 order → slug」排序的文档元数据(`with_content=true` 时含正文)
- `categories`:分类视图(name/icon/count),前端按它分组渲染
- `source`:内容目录绝对路径(机器侧取内容用同一来源,满足「机器走 API」)
- `skipped`:被跳过的 md 及原因(缺 front-matter/文件名非法),便于 Bot 自诊断
"""
if str(type or "").strip().lower() == "report":
return _list_reports(
request=request, db=db, user=current_user,
domain=domain, entity=entity, kind=kind,
date_from=date_from, date_to=date_to, diagnose=bool(diagnose),
)
docs, skipped, available = load_docs()
all_docs = docs
if category:
docs = [d for d in docs if d["category"] == category]
return {
"count": len(docs),
"available": available,
"source": str(docs_dir()),
"categories": _categories_view(all_docs), # 分类视图按全量内容统计(不受 category 过滤影响)
"data": docs if with_content else [_meta_only(d) for d in docs],
"skipped": skipped,
"format": "markdown",
}
@router.get("/{slug}")
def get_doc(slug: str):
"""单篇文档(含 md 正文,实时读盘)"""
if not SLUG_RE.match(slug or ""):
raise HTTPException(400, "无效的文档标识(只允许 [a-z0-9-],1-64 位,字母数字开头)")
path = docs_dir() / f"{slug}.md"
if not path.is_file():
raise HTTPException(404, "文档不存在")
doc, err = _read_doc(path)
if err:
raise HTTPException(422, f"文档格式不合法: {err}")
return doc
# ══════════════════════ 报告解读(type=report)══════════════════════════
# 口径(规范 /srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md,本单按其实现):
# ① 进门标记 = front-matter + `conclusion` 非空(§二「front-matter 本身就是进门标记」
# + §三「conclusion 是唯一硬字段」):缺任一 ⇒ **不进列表、不报错**。
# 回执/审计/施工/工作件天然不带 front-matter ⇒ 天然不进;**不搬家、不新建内容目录**。
# ② 规范 §四 另列 title/date/domain/sources 为必填:缺失**不拒收**,标 `incomplete` 并按
# 兜底值展示(若按字面拒收,页面只能是空壳,与 §一「无体之用」的立意相悖)。
# ③ 只**新增** type=report 分支与新端点 /report/{rid};type 缺省走知识库分支,语义逐字不变。
# ④ 权限:按 entity + CMA 既有 roles 分区 —— ceo/it 可跨账套读结论,其余角色只看本账套 + 集团/通用。
# ⑤ 读口 = 文件系统(实时读盘,改一篇 md 刷新即生效),无写 API(与知识库同口径,避免第二来源)。
WISDOM_REPORTS_DIR = "/srv/hermes/wisdom/_reports"
PROFILES_ROOT = "/root/.hermes/profiles"
REPORT_SPEC_PATH = "/srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md"
REPORT_HEAD_BYTES = 8192
REPORT_DIRS_ENV = "CMA_REPORTS_DIRS"
DOMAIN_LABELS = {
"finance": "财务", "customer": "客户", "process": "流程", "learning": "学习成长",
"tech": "技术", "ops": "运维", "market": "市场", "gov": "政务",
}
KIND_LABELS = {"report": "报告", "analysis": "分析", "brief": "摘要", "warning": "预警"}
GLOBAL_ENTITIES = ("集团", "通用")
CROSS_ENTITY_ROLES = ("ceo", "it")
ENTITY_NAME_FALLBACK = {1: "酣客", 2: "博海"}
_SAFE_STEM_RE = re.compile(r"^[^/\\\x00]{1,160}$")
def _as_list(val) -> list:
"""front-matter 值 → 列表(支持行内数组 [a, b] / 逗号分隔 / 单值)"""
if val is None:
return []
if isinstance(val, list):
return [str(v).strip() for v in val if str(v).strip()]
s = str(val).strip()
if not s:
return []
if s.startswith("[") and s.endswith("]"):
s = s[1:-1]
return [p.strip().strip("\"'") for p in s.split(",") if p.strip()]
def _kpi_link(raw: str) -> dict:
"""kpi_refs 项 → 一跳链(数字 / kpi:<id> → 指标详情页;其余 → 指标字典页关键词)"""
v = str(raw).strip()
m = re.fullmatch(r"(?:kpi[:/]?)?(\d{1,9})", v, flags=re.IGNORECASE)
if m:
return {"raw": v, "url": f"/kpis/{m.group(1)}", "resolved": "detail"}
return {"raw": v, "url": "/kpis?keyword=" + quote(v), "resolved": "list"}
def reports_dirs() -> list:
"""[(source_key, Path)] —— 既有报告落点的发现结果。
env `CMA_REPORTS_DIRS` 覆盖优先(`:`/`;` 分隔,条目可写 `key=path`,供测试与隔离实测);
缺省 = 自动发现:`/srv/hermes/wisdom/_reports` + 各 profile 的 `pending-tasks/_reports`
(按 resolve 后路径去重,避免 pending-tasks 为符号链接时重复收录)。
"""
raw = (os.environ.get(REPORT_DIRS_ENV) or "").strip()
out, seen = [], set()
if raw:
for i, item in enumerate(x for x in re.split(r"[:;]", raw) if x.strip()):
item = item.strip()
key, sep, path = item.partition("=")
if not sep:
key, path = f"src{i}", item
out.append(((key or f"src{i}").strip(), Path(path)))
return out
cands = []
wisdom = Path(WISDOM_REPORTS_DIR)
if wisdom.is_dir():
cands.append(("wisdom", wisdom))
for d in sorted(Path(PROFILES_ROOT).glob("*/pending-tasks/_reports")):
if not d.is_dir():
continue
prof = d.parent.parent.name
cands.append((prof[6:] if prof.startswith("wecom-") else prof, d))
for key, path in cands:
try:
rp = path.resolve()
except OSError:
rp = path
if rp in seen:
continue
seen.add(rp)
out.append((key, path))
return out
def _parse_report_text(text: str, path: Path, source_key: str):
"""报告件 → (record, None) 或 (None, 排除码)。
排除码:`no_frontmatter`(无 --- 进门标记)/ `missing_conclusion`(缺唯一硬字段)/
`unreadable:<err>`。**排除即静默**(不进列表、不报错),这正是负对照的判据。
"""
parsed = parse_front_matter(text) if text.startswith("---") else None
if parsed is None:
return None, "no_frontmatter"
meta = parsed["meta"]
if not str(meta.get("conclusion") or "").strip():
return None, "missing_conclusion"
try:
stat = path.stat()
except OSError as exc:
return None, f"unreadable:{exc}"
refs = _as_list(meta.get("kpi_refs"))
rec = {
"id": f"{source_key}:{path.stem}",
"title": str(meta.get("title") or "").strip() or path.stem,
"date": (str(meta.get("date") or "").strip()
or datetime.fromtimestamp(stat.st_mtime).date().isoformat()),
"domain": str(meta.get("domain") or "").strip() or "未分类",
"entity": str(meta.get("entity") or "").strip(),
"kind": str(meta.get("kind") or "").strip() or "report",
"conclusion": str(meta["conclusion"]).strip(),
"sources": _as_list(meta.get("sources")),
"impact": str(meta.get("impact") or "").strip(),
"decisions": _as_list(meta.get("decisions")),
"kpi_refs": refs,
"kpi_links": [_kpi_link(r) for r in refs],
"file": path.name,
"source": source_key,
"path": str(path),
"bytes": stat.st_size,
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"incomplete": [f for f in ("title", "date", "domain", "sources")
if not str(meta.get(f) or "").strip()],
"content": parsed["body"],
}
return rec, None
def _read_report(path: Path, source_key: str, full: bool = False):
try:
if full:
text = path.read_text(encoding="utf-8", errors="replace")
else:
with open(path, "rb") as fh:
text = fh.read(REPORT_HEAD_BYTES).decode("utf-8", errors="replace")
if text.startswith("---") and text.find("\n---", 3) < 0:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return None, f"unreadable:{exc}"
return _parse_report_text(text, path, source_key)
def _entity_scope(db: Session, user: User, request: Request) -> dict:
"""当前会话的可见范围:账套 id / 账套名称集合 / 是否跨账套角色"""
entity_id = get_session_entity(request)
if entity_id is None:
rel = db.query(UserEntity).filter(UserEntity.user_id == user.id).first()
entity_id = rel.entity_id if rel else None
names = set()
if entity_id is not None:
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if ent:
names |= {str(ent.name or "").strip(), str(ent.short_name or "").strip()}
fb = ENTITY_NAME_FALLBACK.get(int(entity_id))
if fb:
names.add(fb)
names.discard("")
return {
"role": user.role,
"entity_id": entity_id,
"entity_names": sorted(names),
"cross_entity": user.role in CROSS_ENTITY_ROLES,
}
def _report_visible(rec: dict, scope: dict) -> bool:
"""按 entity 分区:跨账套角色(ceo/it)→ 全可见;其余 → 本账套名 + 集团/通用。
未标 entity 的件按「通用」处理(entity 属选填字段,不设门槛);
显式 entity 数字与会话账套一致也算命中(兼容 `entity: 2` 写法)。
"""
if scope["cross_entity"]:
return True
e = str(rec.get("entity") or "").strip()
if not e or e in GLOBAL_ENTITIES:
return True
if e in set(scope["entity_names"]):
return True
if e.isdigit() and scope["entity_id"] is not None and int(e) == int(scope["entity_id"]):
return True
return False
def _facet(items: list, field: str, labels: dict) -> list:
cnt = {}
for r in items:
k = str(r.get(field) or "").strip() or "(未标)"
cnt[k] = cnt.get(k, 0) + 1
return [{"key": k, "name": labels.get(k, k), "count": v}
for k, v in sorted(cnt.items(), key=lambda kv: (-kv[1], kv[0]))]
def _list_reports(request: Request, db: Session, user: User,
domain=None, entity=None, kind=None,
date_from=None, date_to=None, diagnose: bool = False) -> dict:
"""报告解读列表:扫描既有报告落点 → 只收带 front-matter + conclusion 的件 → 按 entity 分区 → 筛选"""
scope = _entity_scope(db, user, request)
included, sources = [], []
excluded = {"no_frontmatter": 0, "missing_conclusion": 0, "other": 0}
detail = []
for key, base in reports_dirs():
exists = base.is_dir()
found = 0
if exists:
for path in sorted(base.glob("*.md")):
found += 1
rec, reason = _read_report(path, key)
if reason:
if reason == "no_frontmatter":
excluded["no_frontmatter"] += 1
elif reason == "missing_conclusion":
excluded["missing_conclusion"] += 1
else:
excluded["other"] += 1
if diagnose and len(detail) < 200:
detail.append({"file": path.name, "source": key, "reason": reason})
continue
if not _report_visible(rec, scope):
continue
included.append(rec)
sources.append({"key": key, "path": str(base),
"exists": exists, "md_count": found})
visible_total = len(included)
def _keep(r: dict) -> bool:
if domain and r["domain"] != domain:
return False
if entity and r["entity"] != entity:
return False
if kind and r["kind"] != kind:
return False
if date_from and (r["date"] or "") < date_from:
return False
if date_to and (r["date"] or "") > date_to:
return False
return True
rows = [r for r in included if _keep(r)]
rows.sort(key=lambda r: (r["date"] or "", r["title"]), reverse=True)
dates = sorted({r["date"] for r in included if r["date"]})
data = []
for r in rows:
r = dict(r)
r.pop("content", None)
data.append(r)
payload = {
"type": "report",
"count": len(data),
"available": any(s["exists"] for s in sources),
"scope": scope,
"sources": sources,
"excluded": excluded,
"facets": {
"domain": _facet(included, "domain", DOMAIN_LABELS),
"entity": _facet(included, "entity", {}),
"kind": _facet(included, "kind", KIND_LABELS),
"date": {"min": dates[0] if dates else "", "max": dates[-1] if dates else ""},
},
"totals": {"visible": visible_total, "filtered_out": visible_total - len(data)},
"data": data,
"format": "markdown",
"spec": REPORT_SPEC_PATH,
}
if diagnose:
payload["excluded_detail"] = detail
return payload
@router.get("/report/{rid}")
def get_report(rid: str, request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth)):
"""单篇报告(含 md 正文,实时读盘)。`rid` = `<source_key>:<文件名主干>`"""
key, sep, stem = (rid or "").partition(":")
if not sep or not key:
raise HTTPException(400, "无效的报告标识(应为 <source>:<文件名>)")
if not _SAFE_STEM_RE.match(stem) or stem.startswith(".") or ".." in stem:
raise HTTPException(400, "无效的报告标识(文件名非法)")
base = {k: p for k, p in reports_dirs()}.get(key)
if base is None:
raise HTTPException(404, "报告不存在")
path = base / f"{stem}.md"
if path.parent != base or not path.is_file():
raise HTTPException(404, "报告不存在")
rec, reason = _read_report(path, key, full=True)
if reason:
raise HTTPException(422, f"报告格式不合法: {reason}")
scope = _entity_scope(db, current_user, request)
if not _report_visible(rec, scope):
raise HTTPException(
403,
f"无权访问企业 {rec.get('entity') or '(未标注)'} 的报告(当前账套: {scope.get('entity_names') or '未绑定'})",
)
return rec
+9 -16
View File
@@ -7,10 +7,8 @@ from typing import Optional, List
from datetime import datetime
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 SystemConfig, OperationLog
from app.utils.oplog import log_op
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
router = APIRouter(
@@ -127,7 +125,6 @@ def set_driver_mode(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
mode = data.get("mode", "subject")
@@ -147,11 +144,13 @@ def set_driver_mode(
db.add(cfg)
db.commit()
# 2026-09-23 ④:审计带账套(会话账套)+ 显式标注该键当前仍是**全局键**
#(budget_driver_mode 拆账套 = 另单排期项,见 dama-entity-isolation-sweep-20260923 §二之三)
log_op(db, "update", target_type="budget", target_id=0,
detail={"mode": mode, "action": "切换编制模式", "entity_scope": "global_key"},
entity_id=entity_id)
log = OperationLog(
action="update",
target_type="budget",
target_id=0,
detail=json.dumps({"mode": mode, "action": "切换编制模式"}, ensure_ascii=False),
)
db.add(log)
db.commit()
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
@@ -255,7 +254,6 @@ def driver_calculate(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""
驱动因子计算预算。
@@ -315,7 +313,6 @@ def driver_calculate(
# 记录计算结果
budget_record = DriverFactorBudget(
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
name=name,
industry=industry,
template_id=template_id,
@@ -335,7 +332,6 @@ def driver_calculate(
target_type="budget",
target_id=budget_record.id,
detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"name": name,
"formula": formula_text,
"factors": complete_factors,
@@ -461,12 +457,9 @@ def driver_sensitivity(
def list_driver_history(
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查看本账套最近的驱动因子预算计算记录(账套隔离 2026-09-21)"""
records = db.query(DriverFactorBudget).filter(
DriverFactorBudget.entity_id == entity_id,
).order_by(
"""查看最近的驱动因子预算计算记录"""
records = db.query(DriverFactorBudget).order_by(
DriverFactorBudget.created_at.desc()
).limit(limit).all()
+17 -45
View File
@@ -290,9 +290,7 @@ def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list
})
else:
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
# 账套隔离 2026-09-21:累计口径限本账套(他账套在途不得污染本账套额度判定)
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
ExpenseReimbursement.entity_id == reimb.entity_id,
ExpenseReimbursement.expense_type == reimb.expense_type,
ExpenseReimbursement.id != reimb.id,
ExpenseReimbursement.status.in_(["pending", "approved"]),
@@ -345,7 +343,6 @@ def submit_reimbursement(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""提交报销单 → 自动校验规则(超限自动打回)"""
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
@@ -369,7 +366,6 @@ def submit_reimbursement(
expense_date = None
reimb = ExpenseReimbursement(
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
applicant=applicant,
department=data.get("department", ""),
@@ -406,9 +402,8 @@ def list_reimbursements(
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)(账套隔离 2026-09-21:列表按会话账套)"""
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.entity_id == entity_id)
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
q = db.query(ExpenseReimbursement)
if status:
q = q.filter(ExpenseReimbursement.status == status)
if expense_type:
@@ -430,29 +425,20 @@ def list_reimbursements(
def get_reimbursement(
reimb_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""报销单详情(账套隔离 2026-09-21:跨账套 → 404)"""
r = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == reimb_id,
ExpenseReimbursement.entity_id == entity_id,
).first()
"""报销单详情"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
return _reimb_to_dict(r)
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str,
entity_id: int = None):
"""执行审批动作: approve/reject/return(账套隔离 2026-09-21:跨账套 → 404)"""
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id)
if entity_id is not None:
q = q.filter(ExpenseReimbursement.entity_id == entity_id)
r = q.first()
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str):
"""执行审批动作: approve/reject/return"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("pending", "returned"):
raise HTTPException(400, f"当前状态({r.status})不可审批")
@@ -476,11 +462,10 @@ def approve_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""审批通过(账套隔离 2026-09-21:跨账套 → 404)"""
"""审批通过"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver, entity_id)
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver)
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
@@ -490,14 +475,13 @@ def reject_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""审批拒绝(账套隔离 2026-09-21:跨账套 → 404)"""
"""审批拒绝"""
comment = (data or {}).get("comment", "")
if not comment:
raise HTTPException(400, "拒绝时必须填写审批意见")
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "reject", comment, approver, entity_id)
r = _do_approve(db, reimb_id, "reject", comment, approver)
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
@@ -507,11 +491,10 @@ def return_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""人工打回(账套隔离 2026-09-21:跨账套 → 404)"""
"""人工打回"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver, entity_id)
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver)
return {"message": "已打回", "data": _reimb_to_dict(r)}
@@ -521,13 +504,9 @@ def resubmit_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
_=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""被驳回/打回后重新提交 → 重新自动校验(账套隔离 2026-09-21:跨账套 → 404)"""
r = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == reimb_id,
ExpenseReimbursement.entity_id == entity_id,
).first()
"""被驳回/打回后重新提交 → 重新自动校验"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("returned", "rejected"):
@@ -558,7 +537,6 @@ def resubmit_reimbursement(
def expense_stats(
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
@@ -566,9 +544,7 @@ def expense_stats(
period = period or now.strftime("%Y-%m")
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).filter(
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
).group_by(
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).group_by(
ExpenseReimbursement.status
).all():
if st in counts:
@@ -584,7 +560,6 @@ def expense_stats(
)
.filter(
ExpenseReimbursement.status.in_(["pending", "approved"]),
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
)
.group_by(ExpenseReimbursement.expense_type)
@@ -608,7 +583,6 @@ def expense_stats(
.filter(
ExpenseReimbursement.status == "returned",
ExpenseReimbursement.check_result == "fail",
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
)
.order_by(ExpenseReimbursement.id.desc())
.limit(50)
@@ -630,9 +604,7 @@ def expense_stats(
for r in over_limit_rows
]
recent_rows = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
recent_rows = db.query(ExpenseReimbursement).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
recent = [_reimb_to_dict(r) for r in recent_rows]
# 本月预算使用率(招待费15万/月 管理费91万/月)
-10
View File
@@ -18,7 +18,6 @@ from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import KnowledgeEvent, KnowledgeSummary
from app.services.knowledge_service import (
DeepSeekKeyMissing,
generate_summary_sync,
generate_daily_sync,
generate_weekly_sync,
@@ -151,9 +150,6 @@ def trigger_daily_summary(
try:
result = generate_daily_sync(db, entity_id=entity_id)
return {"message": "每日摘要已生成", "summary": result}
except DeepSeekKeyMissing as e:
logger.error("每日摘要生成失败(缺少 DeepSeek Key): %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
logger.exception("每日摘要生成失败")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
@@ -168,9 +164,6 @@ def trigger_weekly_summary(
try:
result = generate_weekly_sync(db, entity_id=entity_id)
return {"message": "周度摘要已生成", "summary": result}
except DeepSeekKeyMissing as e:
logger.error("周度摘要生成失败(缺少 DeepSeek Key): %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
logger.exception("周度摘要生成失败")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
@@ -185,9 +178,6 @@ def trigger_monthly_summary(
try:
result = generate_monthly_sync(db, entity_id=entity_id)
return {"message": "月度摘要已生成", "summary": result}
except DeepSeekKeyMissing as e:
logger.error("月度摘要生成失败(缺少 DeepSeek Key): %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
logger.exception("月度摘要生成失败")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
+1 -16
View File
@@ -10,7 +10,6 @@ from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models.knowledge_article import KnowledgeArticle
from app.utils.route_key import candidate_route_keys, route_template_child_pattern
router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
@@ -52,21 +51,7 @@ def list_articles(
)
)
if related_page:
# KBFF-v11c(2026-10-12):匹配口径由「子串包含 LIKE '%<v>%'」收敛为
# 「归一化候选键精确等值 ∪ 模板子路由」——消除短键/前缀超集命中
# (prop=/maps 曾连带命中 /maps/canvas + /maps-review;prop=/kpi 命中 /kpis)。
# 口径实现唯一处 = app/utils/route_key.py(调用方不得二次截断/拼接)。
keys = candidate_route_keys(related_page)
if not keys:
# 不可归一(纯 query/空白等)⇒ 明确无命中,不退化为“不过滤=全量”
return {"data": []}
conds = [KnowledgeArticle.related_page.in_(keys)]
for k in keys:
# 模板子路由:存储值形如 <键>/:<占位符>(占位符命名不限),仍属该页
conds.append(
KnowledgeArticle.related_page.like(route_template_child_pattern(k), escape="\\")
)
q = q.filter(or_(*conds))
q = q.filter(KnowledgeArticle.related_page.contains(related_page))
if category:
q = q.filter(KnowledgeArticle.category == category)
articles = q.order_by(KnowledgeArticle.sort_order.asc(), KnowledgeArticle.id.asc()).all()
+5 -10
View File
@@ -12,8 +12,6 @@ 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 KPIDefinition, KPICausality, KPIValue, OperationLog
from app.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
from app.services.causality_verification import (
ALL_STATUSES,
ALL_SOURCE_TYPES,
@@ -148,7 +146,7 @@ def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int
)
if period:
query_values = query_values.filter(KPIValue.period == period)
latest = query_values.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
latest = query_values.order_by(KPIValue.period.desc()).first()
if latest:
current_value = latest.actual_value
@@ -189,7 +187,6 @@ def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int
tgt_val = db.query(KPIValue).filter(
KPIValue.kpi_id == target_id,
KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first()
predicted_value = None
@@ -359,9 +356,8 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
db.add(c)
db.commit()
db.refresh(c)
log_op(db, "create", target_type="kpi_causality", target_id=c.id,
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})",
entity_id=c.entity_id)
db.add(OperationLog(action="create", target_type="kpi_causality",
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})"))
db.commit()
return _to_dict(c)
@@ -411,9 +407,8 @@ def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db
c.verify_status = target_status
c.verified_at = datetime.now()
c.verified_by = str(verified_by)[:50]
log_op(db, "verify", target_type="kpi_causality", target_id=causality_id,
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})",
entity_id=c.entity_id)
db.add(OperationLog(action="verify", target_type="kpi_causality",
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})"))
db.commit()
db.refresh(c)
d = _to_dict(c)
+2 -7
View File
@@ -15,7 +15,6 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
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 KPIDefinition
@@ -167,13 +166,9 @@ def validate_kpi(
def governance_audit(
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""校验本账套活跃KPI,输出按4条规则分组的不合规清单(账套隔离 2026-09-21)。"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
"""全量校验所有活跃KPI,输出按4条规则分组的不合规清单。"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
non_compliant = []
by_rule: dict = {1: [], 2: [], 3: [], 4: []}
+18 -50
View File
@@ -10,9 +10,6 @@ from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
from app.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
from app.utils.kpi_value_scope import actual_only_clause
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
from app.utils.threshold_parser import (
calc_level as _tp_calc_level,
@@ -202,7 +199,6 @@ def get_kpi_score(
)
if period:
val_query = val_query.filter(KPIValue.period == period)
val_query = val_query.filter(month_period_clause(KPIValue.period))
latest_val = val_query.order_by(KPIValue.period.desc()).first()
current_val = latest_val.actual_value if latest_val else None
@@ -294,7 +290,6 @@ def get_kpi_glossary(
latest_val = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first()
current_value = latest_val.actual_value if latest_val else None
@@ -442,13 +437,9 @@ def get_kpi_causality_chain(
kpi_id: int,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""KPI因果链追踪 — 返回单个KPI的上下游因果链(账套隔离 2026-09-21:跨账套 id → 404)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
"""KPI因果链追踪 — 返回单个KPI的上下游因果链"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
@@ -572,9 +563,7 @@ def list_kpi_values(
raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
vals = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).all()
vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).all()
return {
"data": [{
"id": v.id,
@@ -605,8 +594,6 @@ def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends
vals = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
# 实测区口径:预估行禁入趋势序列(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.period.asc()).all()
is_reverse = kpi.kpi_code in REVERSE_INDICATORS
target = kpi.target_value
@@ -803,7 +790,7 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
db.add(kpi)
db.commit()
db.refresh(kpi)
_log(db, 1, "create", "kpi", kpi.id, data, entity_id=entity_id)
_log(db, 1, "create", "kpi", kpi.id, data)
return kpi_to_dict(kpi)
@@ -843,7 +830,7 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
if hasattr(kpi, k) and v is not None:
setattr(kpi, k, v)
db.commit()
_log(db, 1, "update", "kpi", kpi_id, data, entity_id=entity_id)
_log(db, 1, "update", "kpi", kpi_id, data)
return kpi_to_dict(kpi)
@@ -897,47 +884,32 @@ def kpi_to_dict(k):
@router.put("/{kpi_id}/associate-map")
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""关联KPI到战略地图(账套隔离 2026-09-21:KPI 与地图均须属本账套,跨账套 → 404)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""关联KPI到战略地图"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
map_id = data.get("map_id")
if map_id is not None:
m = db.query(StrategicMap).filter(
StrategicMap.id == map_id,
StrategicMap.entity_id == entity_id,
).first()
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
kpi.map_id = map_id
db.commit()
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id},
entity_id=entity_id)
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id})
return kpi_to_dict(kpi)
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None):
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
return log_op(db, action, target_type=target_type, target_id=target_id,
detail=detail, user_id=user_id, entity_id=entity_id, commit=True)
def _log(db, user_id, action, target_type, target_id, detail):
log = OperationLog(user_id=user_id, action=action, target_type=target_type, target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
db.add(log)
db.commit()
@router.get("/{kpi_id}/objectives")
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查看KPI所属的目标和战略地图(账套隔离 2026-09-21:跨账套 id → 404)
鉴权口径:本 router 级 `dependencies=[Depends(require_role(...))]` 已覆盖本端点
(无 token → 401),此处不再重复挂 require_auth,只补缺失的账套收口。
"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id,
).first()
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
"""查看KPI所属的目标和战略地图"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
@@ -950,19 +922,15 @@ def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db),
}
if kpi.map_id:
m = db.query(StrategicMap).filter(
StrategicMap.id == kpi.map_id, StrategicMap.entity_id == entity_id,
).first()
m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first()
if m:
result["map"] = {"id": m.id, "title": m.title, "status": m.status}
if kpi.objective:
objs = db.query(MapObjective).filter(
MapObjective.map_id == kpi.map_id,
MapObjective.entity_id == entity_id,
MapObjective.name == kpi.objective,
).all()
result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs]
return result
+1 -26
View File
@@ -1,17 +1,10 @@
"""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"
class LeadRequest(BaseModel):
name: str
phone: str
@@ -38,22 +31,6 @@ def notify_wecom(name, phone, requirement, source):
except:
pass # 通知失败不影响主流程
def notify_boss(name, phone, requirement):
"""额外推给任总(经 ai-cs 实盘分流脚本;失败不影响主流程)"""
try:
subprocess.run(
["runuser", "-u", AI_CS_USER, "--",
"env", f"HOME={AI_CS_HOME}", f"HERMES_HOME={AI_CS_HOME}",
f"USER={AI_CS_USER}", f"LOGNAME={AI_CS_USER}",
"/usr/bin/python3", AI_CS_ROUTING_SCRIPT,
requirement or "(未填需求)",
f"{name} {phone}",
"website:sxbh.ltd"],
timeout=30, capture_output=True,
)
except Exception:
pass
@router.post("")
def create_lead(data: LeadRequest):
"""接收客户咨询并保存"""
@@ -68,7 +45,5 @@ def create_lead(data: LeadRequest):
# 异步推送企微通知
notify_wecom(data.name, data.phone, data.requirement, data.source)
# 加性:额外推给任总 DM(口径与 ai-cs 客服分流一致)
notify_boss(data.name, data.phone, data.requirement)
return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"}
-179
View File
@@ -1,179 +0,0 @@
"""战略地图 · 数据完备度 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,
}
-600
View File
@@ -1,600 +0,0 @@
# -*- coding: utf-8 -*-
"""战略地图 · 维度切片(时间 / 门店 / 产品)—— A3
目的:让战略地图的 KPI 能按维度切片重算,而不是永远只显示总额/最新值。
口径(与既有读路径保持一致,不另造口径):
- 实测区:复用 `app.utils.kpi_value_scope.actual_only_clause()`,预估行不得进入切片。
- 指标方向:优先 `kpi_definitions.direction`;缺失时退回
`app.utils.strategy_board.INVERSE_KPI_IDS` 显式白名单(不用 NULL 推断)。
- 状态类:`unit == '状态'` ⇒ 1 = 达标 / 0 = 未达标,不做除法。
- 达成率阈值沿用地图现有语义:≥90% success / ≥60% warning / <60% danger。
三维可用性(以库内事实为准,不假装有数据):
- 时间:`kpi_values.period`(YYYY / YYYY-Hn / YYYY-MM)真实存在,可切。
- 产品:`product_sales`(仅 entity 1,2026-01…2026-08,按 `product_name`)真实存在;
只对「产品级可取数」的 KPI(PRODUCT_SCOPED_CODES)生效,其余 KPI 显式不可用。
- 门店:库内**无**门店级事实表(无 store/dept 字段,`voucher_details` 无辅助核算),
仅能从 `subjects.subject_name` 反推门店名称 ⇒ 提供候选值但标记不可用;
切片结果一律 `available=false`,**绝不回退总额**。
空态契约:所选维度下没有某 KPI 的实测值时,返回 `actual_value=null` +
`available=false` + `reason`;绝不把总额/其它期间的值填进来。
"""
from __future__ import annotations
import json
import re
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.auth_middleware import require_role
from app.database import get_db
from app.deps import get_entity_id
from app.models import KPIDefinition, KPIValue, MapObjective, ProductSales, StrategicMap
from app.utils.kpi_value_scope import actual_only_clause
from app.utils.strategy_board import INVERSE_KPI_IDS, STATUS_UNIT
router = APIRouter(
prefix="/api/cma/maps",
tags=["战略地图维度切片"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
#: 目前只有营业收入的月度总额来自 product_sales(已对拍:2026-01…07 与 KPI 值逐月相等)
PRODUCT_SCOPED_CODES = frozenset({"F_REVENUE"})
#: 流量型单位:季度/年度切片按「月份求和」
FLOW_UNITS = frozenset({"万元", "元", "个", "次", "轮", "件", "人"})
#: 达成率 → 色阶(与地图现有 legend 一致)
SUCCESS_RATE = 0.9
WARNING_RATE = 0.6
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
_QUARTER_RE = re.compile(r"^\d{4}-Q[1-4]$")
_YEAR_RE = re.compile(r"^\d{4}$")
_STORE_SUFFIX_RE = re.compile(r"(吴忠地标店|吴忠隆泰店|新区大店|地标店|一店|二店)")
_STORE_DISPLAY_ORDER = ["一店", "二店", "吴忠地标店", "新区大店", "吴忠隆泰店"]
#: 存量/余额型指标:区间切片取「最新一期」,禁止按月求和(否则余额被累加)
BALANCE_HINTS = ("余额", "库存", "去化", "货币资金", "借款", "应收", "应付", "资产", "负债", "占用", "头寸")
#: 产品源与 KPI 实测值的允许偏差(超过即判源未对齐,不参与切片)
PRODUCT_SOURCE_TOLERANCE = 0.01
# ── 小工具 ────────────────────────────────────────────────
def _obj_kpi_codes(raw: Any) -> list[str]:
"""map_objectives.kpis 兼容四种存储形态:list[str] / json 字符串 / list[dict] / NULL。"""
if raw is None:
return []
data = raw
if isinstance(raw, str):
try:
data = json.loads(raw)
except (TypeError, ValueError):
return []
if not isinstance(data, list):
return []
out: list[str] = []
for item in data:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict):
code = item.get("kpi_code") or item.get("code")
if code:
out.append(str(code))
return out
def _map_or_404(db: Session, map_id: int, entity_id: int) -> StrategicMap:
m = db.query(StrategicMap).filter(
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id,
).first()
if not m:
raise HTTPException(404, "战略地图不存在")
return m
def _map_kpis(db: Session, map_id: int, entity_id: int):
"""返回 (map_objectives, {kpi_code: KPIDefinition}),KPI 只取本实体(账套隔离)。"""
objs = db.query(MapObjective).filter(
MapObjective.map_id == map_id, MapObjective.entity_id == entity_id,
).order_by(MapObjective.sort_order, MapObjective.id).all()
codes: set[str] = set()
for o in objs:
codes.update(_obj_kpi_codes(o.kpis))
defs: dict[str, KPIDefinition] = {}
if codes:
for k in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id, KPIDefinition.kpi_code.in_(codes),
).all():
defs[k.kpi_code] = k
return objs, defs
def _parse_period(period: Optional[str]) -> tuple[Optional[str], Optional[str]]:
"""返回 (粒度, 归一值):year / quarter / month;非法或空返回 (None, None)。"""
if not period:
return None, None
p = period.strip()
if _MONTH_RE.match(p):
return "month", p
if _QUARTER_RE.match(p):
return "quarter", p
if _YEAR_RE.match(p):
return "year", p
return None, None
def _months_of(period_type: str, period: str) -> list[str]:
if period_type == "month":
return [period]
year = period[:4]
if period_type == "quarter":
q = int(period[-1])
start = (q - 1) * 3 + 1
return [f"{year}-{m:02d}" for m in range(start, start + 3)]
return [f"{year}-{m:02d}" for m in range(1, 13)]
def _unit_kind(unit: Optional[str], kpi: Optional[KPIDefinition] = None) -> str:
u = (unit or "").strip()
if u == STATUS_UNIT:
return "status"
name = (getattr(kpi, "kpi_name", "") or "") + " " + (getattr(kpi, "kpi_code", "") or "")
if any(h in name for h in BALANCE_HINTS):
return "balance"
if u in FLOW_UNITS:
return "flow"
return "ratio"
def _is_lower_better(kpi: KPIDefinition) -> bool:
direction = (getattr(kpi, "direction", None) or "").strip().lower()
if direction in ("lower_better", "lower", "desc", "down"):
return True
if direction in ("higher_better", "higher", "asc", "up"):
return False
return kpi.id in INVERSE_KPI_IDS
def _level_from_ratio(ratio: Optional[float]) -> str:
if ratio is None:
return "gray"
if ratio >= SUCCESS_RATE:
return "green"
if ratio >= WARNING_RATE:
return "yellow"
return "red"
def _base_target(kpi: KPIDefinition) -> Optional[float]:
for attr in ("target_monthly", "target_value"):
v = getattr(kpi, attr, None)
if v is not None:
try:
return float(v)
except (TypeError, ValueError):
continue
return None
def _scaled_target(kpi: KPIDefinition, period_type: str, year_period: Optional[str], db: Session, entity_id: int) -> tuple[Optional[float], str]:
"""目标值:年切片优先取 kpi_targets 的年度行;否则按单位缩放月度目标。
返回 (target, target_basis)。
"""
if year_period:
row = db.execute(text(
"SELECT target_value FROM kpi_targets WHERE entity_id=:e AND kpi_id=:k "
"AND period=:p AND target_type IN ('yearly','annual') ORDER BY target_type='yearly' DESC LIMIT 1"
), {"e": entity_id, "k": kpi.id, "p": year_period}).fetchone()
if row and row[0] is not None:
return float(row[0]), f"kpi_targets.{year_period}.yearly"
base = _base_target(kpi)
if base is None:
return None, "no_target"
kind = _unit_kind(kpi.unit, kpi)
if kind == "status":
return None, "status_no_target"
if kind == "balance":
return base, "target_balance"
if period_type == "month":
return base, "target_monthly"
if period_type == "quarter":
return (base * 3 if kind == "flow" else base), "target_monthly×3"
return (base * 12 if kind == "flow" else base), "target_monthly×12"
def _aggregate(rows: list[KPIValue], unit: Optional[str], kpi: Optional[KPIDefinition] = None) -> tuple[Optional[float], str, list[str], bool]:
"""把区间内的实测行聚合成一个切片值。
返回 (value, aggregation, months_used, partial)。
- 状态类 / 存量余额类:取最新一期(不求和)
- 流量型:求和
- 比率/天数:均值
"""
if not rows:
return None, "none", [], True
ordered = sorted(rows, key=lambda v: (str(v.period), v.id))
months = [str(v.period) for v in ordered]
kind = _unit_kind(unit, kpi)
if kind in ("status", "balance"):
last = ordered[-1]
return (float(last.actual_value) if last.actual_value is not None else None), "latest", [str(last.period)], False
vals = [float(v.actual_value) for v in ordered if v.actual_value is not None]
if not vals:
return None, "none", months, True
if kind == "flow":
return round(sum(vals), 4), "sum_of_months", months, False
return round(sum(vals) / len(vals), 4), "mean_of_months", months, False
# ── 维度候选值 ────────────────────────────────────────────
def _time_options(db: Session, kpi_ids: list[int], entity_id: int) -> dict:
if not kpi_ids:
return {"available": False, "reason": "该地图未绑定 KPI", "years": [], "quarters": [], "months": []}
rows = db.query(KPIValue.period).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id.in_(kpi_ids),
actual_only_clause(),
).distinct().all()
periods = [str(r[0]) for r in rows if r[0]]
months = sorted(p for p in periods if _MONTH_RE.match(p))
quarters = sorted(p for p in periods if _QUARTER_RE.match(p))
years = sorted({p[:4] for p in periods})
if months:
for y in sorted({m[:4] for m in months}):
for q in range(1, 5):
qq = f"{y}-Q{q}"
if qq not in quarters and any(m in _months_of("quarter", qq) for m in months):
quarters.append(qq)
quarters.sort()
return {
"available": bool(periods),
"reason": None if periods else "该地图 KPI 无实测值(kpi_values 为空)",
"years": years,
"quarters": quarters,
"months": months,
"granularity": ["year", "quarter", "month"],
}
def _store_options(db: Session, entity_id: int) -> dict:
rows = db.execute(text(
"SELECT DISTINCT subject_code, subject_name FROM subjects WHERE entity_id=:e AND subject_name LIKE '%店%'"
), {"e": entity_id}).fetchall()
found: set[str] = set()
for _code, name in rows:
for hit in _STORE_SUFFIX_RE.findall(str(name or "")):
found.add("吴忠地标店" if hit == "地标店" else hit)
values = [{"code": f"BR-{i+1:02d}", "name": n} for i, n in enumerate(
[n for n in _STORE_DISPLAY_ORDER if n in found] or sorted(found)
)]
return {
"available": False,
"reason": "该维度暂无数据:库内无门店级事实表(无 store/dept 字段,凭证无辅助核算),"
"门店名称仅能从费用类科目名称反推,无法按门店切 KPI;不回退总额",
"values": values,
"source": "subjects.subject_name 反推(仅费用类科目)",
"coverage": "expense_subjects_only",
}
def _product_options(db: Session, entity_id: int) -> dict:
rows = db.query(ProductSales.product_name).filter(
ProductSales.entity_id == entity_id,
).distinct().all()
names = sorted({str(r[0]) for r in rows if r[0]})
if not names:
return {"available": False, "reason": "该实体无产品级销售数据(product_sales 为空)", "values": [], "kpi_scope": sorted(PRODUCT_SCOPED_CODES)}
span = db.query(ProductSales.period_month).filter(
ProductSales.entity_id == entity_id,
).distinct().all()
months = sorted({str(r[0]) for r in span if r[0]})
return {
"available": True,
"reason": None,
"values": [{"code": n, "name": n} for n in names],
"kpi_scope": sorted(PRODUCT_SCOPED_CODES),
"period_coverage": {"min": months[0] if months else None, "max": months[-1] if months else None, "months": months},
"source": "product_sales.product_name(按 period_month 聚合,元→万元)",
}
# ── 切片计算 ──────────────────────────────────────────────
def _slice_kpi_month(db: Session, kpi: KPIDefinition, entity_id: int, period: str) -> tuple[Optional[float], Optional[str]]:
rows = db.query(KPIValue).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
actual_only_clause(),
).order_by(KPIValue.id.desc()).all()
if not rows:
return None, f"{period} 无实测值"
# 同期多行:优先 verified/calculated(strategy_board 的同口径排序)
rank = {"verified": 3, "calculated": 2, "pending": 1}
best = sorted(rows, key=lambda v: rank.get((v.data_status or "").lower(), 0), reverse=True)[0]
return (float(best.actual_value) if best.actual_value is not None else None), None
def _slice_kpi_period(db: Session, kpi: KPIDefinition, entity_id: int, ptype: str, period: str) -> tuple[Optional[float], str, list[str], Optional[str]]:
"""时间切片:月=精确值;季/年=区间聚合(年优先取 YYYY 行)。"""
if ptype == "month":
val, err = _slice_kpi_month(db, kpi, entity_id, period)
return val, "exact", [period], err
if ptype == "year":
val, err = _slice_kpi_month(db, kpi, entity_id, period)
if val is not None:
return val, "exact", [period], None
months = _months_of(ptype, period)
rows = db.query(KPIValue).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id == kpi.id,
KPIValue.period.in_(months),
actual_only_clause(),
).all()
val, agg, used, _ = _aggregate(rows, kpi.unit, kpi)
expected = 3 if ptype == "quarter" else 12
if val is None:
return None, "none", [], f"{period} 无实测值(区间内无月度数据)"
partial = len(used) < expected and agg != "latest"
note = f"{period} 仅 {len(used)}/{expected} 个月有实测值" if partial else None
return val, agg, used, note
def _slice_kpi_product(db: Session, kpi: KPIDefinition, entity_id: int, product: str, ptype: str, period: str) -> tuple[Optional[float], str, list[str], Optional[str]]:
"""产品切片:仅对 PRODUCT_SCOPED_CODES 生效,并按「逐月源一致性」闸门。
`product_sales` 月合计与 KPI 同月实测值必须吻合(±1%)才纳入;不吻合的月份
视为源未对齐(例如 2026-08 产品表 8.08 万 vs KPI 14.45 万),
不做缩放、不并入,避免用产品表冒充 KPI 总额。
"""
if kpi.kpi_code not in PRODUCT_SCOPED_CODES:
return None, "unsupported", [], "该 KPI 无产品级取数(维度未落库),不回退总额"
months = _months_of(ptype, period)
# 逐月源一致性:product_sales 全量合计(元) vs KPI 实测值(万元)
totals = db.query(ProductSales.period_month, ProductSales.sales_amount).filter(
ProductSales.entity_id == entity_id, ProductSales.period_month.in_(months),
).all()
by_month: dict[str, float] = {}
for pm, amt in totals:
by_month[str(pm)] = by_month.get(str(pm), 0.0) + float(amt or 0)
kpi_month: dict[str, float] = {}
for m in months:
v, _err = _slice_kpi_month(db, kpi, entity_id, m)
if v is not None:
kpi_month[m] = float(v) * 10000.0 # 万元 → 元
covered = []
misaligned = []
for m in months:
if m not in by_month or m not in kpi_month:
continue
base = kpi_month[m]
if base == 0:
continue
if abs(by_month[m] - base) / abs(base) <= PRODUCT_SOURCE_TOLERANCE:
covered.append(m)
else:
misaligned.append(m)
if not covered:
detail = ""
if misaligned:
detail = "(源未对齐月份:" + "、".join(misaligned) + ")"
return None, "uncovered", [], f"产品表与 KPI 实测值不一致{detail},该期间不做产品切片、不回退总额"
rows = db.query(ProductSales).filter(
ProductSales.entity_id == entity_id,
ProductSales.product_name == product,
ProductSales.period_month.in_(covered),
).all()
if not rows:
return None, "none", [], f"产品「{product}」在 {'、'.join(covered)} 无销售数据"
used = sorted({r.period_month for r in rows})
total = sum(float(r.sales_amount or 0) for r in rows) / 10000.0 # 元 → 万元
note = None
if len(covered) < len(months):
note = f"仅 {'、'.join(covered)} 通过源一致性校验" + (f";未对齐:{'、'.join(misaligned)}" if misaligned else "")
return round(total, 4), "sum_of_product_sales", used, note
def build_slice(db: Session, entity_id: int, map_id: int, period: Optional[str], store: Optional[str], product: Optional[str]) -> dict:
_map_or_404(db, map_id, entity_id)
objs, defs = _map_kpis(db, map_id, entity_id)
ptype, pnorm = _parse_period(period)
if period and not ptype:
raise HTTPException(400, "period 格式应为 YYYY / YYYY-Qn / YYYY-MM")
if not pnorm:
# 未选时间:取该地图 KPI 的最新实测期(沿用现有「最新值」语义,并显式标注)
latest = None
ids = [k.id for k in defs.values()]
if ids:
rows = db.query(KPIValue.period).filter(
KPIValue.entity_id == entity_id, KPIValue.kpi_id.in_(ids), actual_only_clause(),
).all()
cand = [str(r[0]) for r in rows if r[0]]
months = sorted(p for p in cand if _MONTH_RE.match(p))
pnorm = months[-1] if months else (sorted(cand)[-1] if cand else None)
if pnorm:
ptype = "month" if _MONTH_RE.match(pnorm) else ("quarter" if _QUARTER_RE.match(pnorm) else "year")
if latest is None and not pnorm:
latest = None
ptype = ptype or "month"
pnorm = pnorm or ""
dims_meta = {
"time": {"available": bool(pnorm), "period": pnorm, "type": ptype,
"reason": None if pnorm else "该地图 KPI 无实测值"},
"store": _store_options(db, entity_id) if store else {"available": True, "selected": None},
"product": _product_options(db, entity_id) if product else {"available": True, "selected": None},
}
if store:
dims_meta["store"]["selected"] = store
if product:
dims_meta["product"]["selected"] = product
objectives: list[dict] = []
any_available = False
empty_reasons: list[str] = []
for obj in objs:
codes = [c for c in _obj_kpi_codes(obj.kpis) if c in defs]
kpi_rows: list[dict] = []
worst = "green"
for code in codes:
kpi = defs[code]
target = None
basis = "no_period"
actual = None
agg = "none"
used: list[str] = []
reason: Optional[str] = None
if store:
reason = "该维度暂无数据:无门店级 KPI 事实表,不回退总额"
elif not pnorm:
reason = "无可用实测期间"
elif product:
actual, agg, used, reason = _slice_kpi_product(db, kpi, entity_id, product, ptype, pnorm)
target, basis = _scaled_target(kpi, ptype, pnorm[:4] if ptype == "year" else None, db, entity_id)
# 产品切片只覆盖通过源一致性校验的月份 ⇒ 流量型目标按覆盖月数缩放
if actual is not None and _unit_kind(kpi.unit, kpi) == "flow" and used:
base = _base_target(kpi)
if base is not None:
target = round(base * len(used), 4)
basis = f"target_monthly×{len(used)}(覆盖月数)"
else:
actual, agg, used, note = _slice_kpi_period(db, kpi, entity_id, ptype, pnorm)
target, basis = _scaled_target(kpi, ptype, pnorm[:4] if ptype == "year" else None, db, entity_id)
reason = note
available = actual is not None
act = float(actual) if actual is not None else None
tgt = float(target) if target is not None else None
ratio = None
level = "gray"
if act is not None and _unit_kind(kpi.unit) == "status":
ratio = 1.0 if act >= 1 else 0.0
level = "green" if ratio == 1.0 else "red"
elif act is not None and tgt:
if _is_lower_better(kpi):
ratio = (tgt / act) if act > 0 else None
else:
ratio = act / tgt if tgt != 0 else None
level = _level_from_ratio(ratio)
if reason and available and agg != "exact":
# 部分月份可用:值可用但必须带口径说明
pass
if level == "red":
worst = "red"
elif level == "yellow" and worst != "red":
worst = "yellow"
elif level == "gray" and worst == "green" and not available:
worst = "gray"
if not available:
empty_reasons.append(reason or f"{code} 无数据")
any_available = any_available or available
kpi_rows.append({
"kpi_id": kpi.id,
"kpi_code": code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit,
"actual_value": actual,
"target_value": target,
"target_basis": basis,
"achievement_pct": (round(ratio * 100, 1) if ratio is not None else None),
"level": level,
"available": available,
"reason": reason,
"aggregation": agg,
"periods_used": used,
})
objectives.append({
"objective_id": obj.id,
"dimension_key": obj.dimension_key,
"name": obj.name,
"kpis": kpi_rows,
"level": worst if kpi_rows else "gray",
"has_data": any(k["available"] for k in kpi_rows),
})
empty = not any_available
empty_reason = None
if empty:
if store:
empty_reason = "该维度暂无数据:库内无门店级 KPI 事实表(G4 未落地),不回退总额"
elif product:
specific = next((r for r in empty_reasons if "产品" in r), None)
empty_reason = specific or "该维度暂无数据:所选产品未覆盖该地图 KPI,不回退总额"
elif not pnorm:
empty_reason = "该地图 KPI 无实测值"
else:
empty_reason = empty_reasons[0] if empty_reasons else "所选切片无实测值"
return {
"map_id": map_id,
"entity_id": entity_id,
"slice": {"period": pnorm, "period_type": ptype, "store": store, "product": product},
"empty": empty,
"empty_reason": empty_reason,
"dims": dims_meta,
"objectives": objectives,
"caliber": {
"actual_scope": "实测区(预估行排除)",
"achievement_thresholds": {"success": SUCCESS_RATE, "warning": WARNING_RATE},
"aggregation": {"flow": "sum_of_months", "ratio": "mean_of_months", "status": "latest"},
"no_fallback": "维度无数据时返回 null,不用总额/其它期间冒充",
},
}
# ── 路由 ──────────────────────────────────────────────────
@router.get("/{map_id}/dimension-options")
def get_dimension_options(
map_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""战略地图三维候选值(时间真实可切;门店/产品按库内事实显式标注可用性)。"""
_map_or_404(db, map_id, entity_id)
_objs, defs = _map_kpis(db, map_id, entity_id)
ids = [k.id for k in defs.values()]
return {
"map_id": map_id,
"entity_id": entity_id,
"time": _time_options(db, ids, entity_id),
"store": _store_options(db, entity_id),
"product": _product_options(db, entity_id),
"kpi_count": len(defs),
}
@router.get("/{map_id}/slice")
def get_map_slice(
map_id: int,
period: Optional[str] = Query(None, description="YYYY / YYYY-Qn / YYYY-MM;空=最新实测期"),
store: Optional[str] = Query(None, description="门店名称;库内无门店级事实时显式空态"),
product: Optional[str] = Query(None, description="产品名称(product_sales)"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""按 时间/门店/产品 切片重算地图 KPI(只读;无数据 ⇒ null + 原因,绝不回退总额)。"""
return build_slice(db, entity_id, map_id, period, store, product)
-238
View File
@@ -1,238 +0,0 @@
"""战略地图 · 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,
},
}
+5 -693
View File
@@ -1,8 +1,6 @@
"""战略地图 API"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from typing import Optional
from datetime import date, datetime, timedelta
from app.database import get_db
@@ -10,22 +8,11 @@ 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.utils.kr_name import sanitize_kr_name
from app.utils.kpi_value_scope import actual_only_clause
import copy
import json
import logging
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"))],
)
@@ -84,17 +71,6 @@ 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导致加载报错)
@@ -134,297 +110,6 @@ def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_i
return m_to_dict(m, db)
# ── 乐观并发保护(缺陷修复 cma-strategy-map-autosave-stale-overwrite, 2026-10-12)──
# 背景(生产实测,非推测):在线画布页把「浏览器本地快照」每 30s 整表 PUT 一次
# (dimensions + canvas_data)。若期间服务端已被他方写入(批处理/脚本/他方 API/他人编辑),
# 过期快照会静默回置服务端数据,并驱动 _sync_map_objectives 依快照重建节点行
# —— 实测把 19 行 map_objectives 删掉且未插回。
# 规则:凡请求体要覆写 dimensions/canvas_data 的写,必须携带客户端基准
# base_updated_at(或 updated_at;取库内既有列,不新增表/列);与库内现值不符 → 409。
# 校验在**任何写入之前**完成 ⇒ 校验不过时一行都不删/不改。
CONCURRENCY_GUARDED_FIELDS = ("dimensions", "canvas_data")
CONCURRENCY_BASE_KEYS = ("base_updated_at", "updated_at")
def _stamp(dt) -> Optional[str]:
"""库内时间戳 → 秒级 ISO 字符串(响应体/409 头统一口径)"""
if isinstance(dt, datetime):
return dt.replace(microsecond=0).isoformat()
return str(dt) if dt is not None else None
def _norm_stamp(raw) -> Optional[datetime]:
"""客户端基准 → 秒级 datetime(带时区先转服务器本地时区);不可解析返回 None"""
if raw is None:
return None
if isinstance(raw, datetime):
dt = raw
else:
s = str(raw).strip()
if not s:
return None
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(s)
except ValueError:
return None
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt.replace(microsecond=0)
def _require_base_stamp(m: StrategicMap, data: dict) -> datetime:
"""并发基准唯一取值口:缺失 / 不可解析 → 409 fail-closed(调用方据此保证「一行都不改」)"""
base_raw = None
for k in CONCURRENCY_BASE_KEYS:
if data.get(k) is not None:
base_raw = data[k]
break
current = _stamp(m.updated_at)
if base_raw is None:
code = "concurrency_base_required"
detail = "缺少并发基准(base_updated_at):覆写战略地图内容须携带载入时的 updated_at"
else:
base = _norm_stamp(base_raw)
if base is not None:
return base
code = "concurrency_base_invalid"
detail = "并发基准(base_updated_at)格式无法解析"
raise HTTPException(409, detail, headers={
"X-CMA-Concurrency-Code": code,
"X-CMA-Map-Updated-At": current or "",
})
def assert_map_not_stale(m: StrategicMap, data: dict, fields=CONCURRENCY_GUARDED_FIELDS):
"""覆写 <fields> 前的前置校验(A 兜底 —— 本单不得移除)
- 不涉及护栏字段(或值为 None ⇒ 不会被赋值)→ 直接放行(如 MapList 只改 title/version);
- 缺基准 / 基准不可解析 / 基准与服务端现值不符 → 409(fail-closed:宁拒不覆写);
- 409 响应体带 detail 文案,响应头带 X-CMA-Concurrency-Code 与 X-CMA-Map-Updated-At。
- fields 参数供 P2 的按 objective 粒度通道复用(该通道只对 canvas_data 沿用本粒度)。
"""
overwriting = [k for k in fields if k in data and data.get(k) is not None]
if not overwriting:
return
base = _require_base_stamp(m, data)
if base != _norm_stamp(m.updated_at):
raise HTTPException(409, "地图已被修改,刷新后重试", headers={
"X-CMA-Concurrency-Code": "stale_map",
"X-CMA-Map-Updated-At": _stamp(m.updated_at) or "",
})
# ── 按 objective 粒度合并(P2 cma-strategy-map-objective-merge, 2026-10-12)──────
# 残留面(A 落地后):dimensions 仍是整表提交与覆写 ⇒ 两个会话编辑同一地图的**不同** objective 时,
# 后到者带过期基准被 409 拒绝:安全,但用户被迫整页刷新重做,且 409 语义被稀释
# (真冲突与「互不相干的并发」同码)。
#
# 本层把写通道从「整表覆写」收敛为「按 objective 粒度合并」:服务端保留未被本地改动的节点
# (含他方新增/删除的节点),仅对本地脏项做增/改/删;校验仍在任何写之前完成。
#
# 请求契约(全部沿用既有列,不新增表/列):
# dimensions 本地快照(结构/顺序;未标脏的节点内容服务端一律不采纳)
# dirty_objectives [{"dim_key": "finance", "objective_id": 12|null, "name": "目标A",
# "base": {...}(客户端载入时该目标的服务端原文)|null(本地新增),
# "value": {...}(新值)|null(本地删除)}]
# canvas_data 与库内现值一致 ⇒ 视为未改动(不覆写);有实质差异 ⇒ 退回 A 的 map 级校验
# base_updated_at 仍必需(缺失/不可解析 → 409,与 A 同)
#
# 冲突判定:脏项 base 与服务端现值不一致 ⇒ **同一 objective** 被并发改 ⇒ 409
# X-CMA-Concurrency-Code: stale_objective(内容冲突)/ stale_objective_missing(已被他人删除)
DIRTY_OBJECTIVES_KEY = "dirty_objectives"
def _is_granular_payload(data: dict) -> bool:
"""按 objective 粒度通道的开关:请求携带 dirty_objectives 列表(旧调用方不带 ⇒ 旧整表语义)"""
return isinstance(data.get(DIRTY_OBJECTIVES_KEY), list)
def _dims_list(raw) -> list:
"""dimensions 容错读:str / None / 非 list → [];只保留 dict 元素"""
if isinstance(raw, str):
try:
raw = json.loads(raw)
except ValueError:
raw = []
if not isinstance(raw, list):
return []
return [d for d in raw if isinstance(d, dict)]
def _obj_tag(o) -> Optional[int]:
"""objective 的服务端行 id(_objective_id / objective_id),无则 None"""
if not isinstance(o, dict):
return None
raw = o.get("_objective_id")
if raw is None:
raw = o.get("objective_id")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def _obj_key(dim_key: str, o) -> tuple:
"""objective 稳定标识:优先行 id,退化到 (维度, 名称)"""
tag = _obj_tag(o)
if tag is not None:
return (dim_key, "id", tag)
return (dim_key, "name", str((o or {}).get("name") or "").strip())
def _obj_name_key(dim_key: str, o) -> tuple:
return (dim_key, "name", str((o or {}).get("name") or "").strip())
def _canon_obj(o) -> str:
"""objective 内容归一化(并发冲突比对):丢 _ 前缀簿记键 / None / 空串,稳定序列化"""
def clean(v):
if isinstance(v, dict):
return {k: clean(x) for k, x in v.items()
if not str(k).startswith("_") and x is not None and x != ""}
if isinstance(v, list):
return [clean(x) for x in v]
return v
return json.dumps(clean(o or {}), ensure_ascii=False, sort_keys=True)
def _canon_json(v) -> str:
return json.dumps(v, ensure_ascii=False, sort_keys=True, default=str)
def _canon_canvas(v) -> str:
"""canvas_data 归一化:None / {} / {'connections': []} 等价 ⇒ 视为「无改动」"""
if not isinstance(v, dict):
return _canon_json({"connections": []})
conns = v.get("connections")
return _canon_json({"connections": conns if isinstance(conns, list) else []})
def _find_objective(dim: dict, probe, dim_key: str) -> Optional[int]:
"""在 dim.objectives 中定位(先按行 id,退化按名称);返回下标,未命中 None"""
objs = dim.get("objectives") or []
key = _obj_key(dim_key, probe)
name_key = _obj_name_key(dim_key, probe)
for i, o in enumerate(objs):
if _obj_key(dim_key, o) == key:
return i
for i, o in enumerate(objs):
if _obj_key(dim_key, o) == name_key:
return i
return None
def _raise_stale_objective(m: StrategicMap, name: str, why: str):
raise HTTPException(409, f"目标「{name}」{why},刷新后重试", headers={
"X-CMA-Concurrency-Code": "stale_objective",
"X-CMA-Map-Updated-At": _stamp(m.updated_at) or "",
})
def _merge_dimensions_by_objective(m: StrategicMap, data: dict):
"""按 objective 粒度合并 dimensions;与库内现值等价时返回 None(调用方不写该字段)
顺序:
① 冲突校验(全部脏项,任何写之前):脏项 base(客户端载入时原文)≠ 服务端现值 ⇒ 409;
② 合并:仅对脏项增/改/删,服务端其余节点(含他方新增)原样保留;
③ 顺序:本地脏过的维度按提交快照顺序重排(服务端独有节点稳定排末尾);
④ 维度层元数据不在本粒度内(无编辑入口)⇒ 服务端保留现值,新维度取提交元数据。
"""
dims_in = _dims_list(data.get("dimensions"))
dirty = [e for e in (data.get(DIRTY_OBJECTIVES_KEY) or []) if isinstance(e, dict)]
current = copy.deepcopy(_dims_list(m.dimensions))
submitted_by_key = {d.get("key") or "": d for d in dims_in}
server_by_key = {d.get("key") or "": d for d in current}
# ── ① 冲突校验(fail-closed:任一脏项冲突即整体 409,此处尚未动任何数据)──
for e in dirty:
base = e.get("base")
if base is None:
continue # 本地新增:无基准可比
dk = e.get("dim_key") or ""
srv_dim = server_by_key.get(dk)
srv_obj = None
if srv_dim is not None:
pos = _find_objective(srv_dim, e, dk)
if pos is not None:
srv_obj = (srv_dim.get("objectives") or [])[pos]
name = str(e.get("name") or (base or {}).get("name") or "")
if srv_obj is None:
_raise_stale_objective(m, name, "已被他人删除")
if _canon_obj(srv_obj) != _canon_obj(base):
_raise_stale_objective(m, name, "已被他人修改")
# ── ② 合并 ──
merged = copy.deepcopy(current)
merged_by_key = {d.get("key") or "": d for d in merged}
touched = []
for e in dirty:
dk = e.get("dim_key") or ""
dim = merged_by_key.get(dk)
if dim is None:
sub = submitted_by_key.get(dk) or {"key": dk}
dim = {"key": dk, "name": sub.get("name") or sub.get("label") or dk,
"icon": sub.get("icon"), "color": sub.get("color"), "objectives": []}
merged.append(dim)
merged_by_key[dk] = dim
if dk not in touched:
touched.append(dk)
value = e.get("value")
if value is None: # 本地删除:定位不到 ⇒ 已被他人删除(幂等)
pos = _find_objective(dim, e, dk)
if pos is not None:
dim.setdefault("objectives", []).pop(pos)
continue
if e.get("base") is None: # 本地新增:追加(绝不覆盖服务端既有同名节点)
dim.setdefault("objectives", []).append(copy.deepcopy(value))
continue
pos = _find_objective(dim, e, dk)
if pos is None:
dim.setdefault("objectives", []).append(copy.deepcopy(value))
else:
dim["objectives"][pos] = copy.deepcopy(value)
# ── ③ 顺序:本地脏过的维度按提交快照重排 ──
for dk in touched:
dim = merged_by_key[dk]
sub_objs = (submitted_by_key.get(dk) or {}).get("objectives") or []
ranks = []
for o in sub_objs:
ranks.append(_obj_key(dk, o))
ranks.append(_obj_name_key(dk, o))
def _rank(o, ranks=ranks, dk=dk):
for cand in (_obj_key(dk, o), _obj_name_key(dk, o)):
if cand in ranks:
return ranks.index(cand)
return len(ranks)
dim["objectives"] = sorted(dim.get("objectives") or [], key=_rank)
if _canon_json(merged) == _canon_json(current):
return None
return merged
def _guard_canvas_data_granular(m: StrategicMap, data: dict):
"""granular 通道下 canvas_data 的护栏:
与库内现值一致(含 None / {} / {'connections': []} 等价)⇒ 视为未改动,剔除该字段不覆写;
有实质差异 ⇒ 退回 A 的 map 级基准校验(过期 ⇒ 409 stale_map)。
"""
if data.get("canvas_data") is None:
data.pop("canvas_data", None)
return
if _canon_canvas(data["canvas_data"]) == _canon_canvas(m.canvas_data):
data.pop("canvas_data", None)
return
assert_map_not_stale(m, data, fields=("canvas_data",))
@router.put("/{map_id}")
def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
@@ -434,25 +119,6 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id
if m.entity_id != entity_id:
raise HTTPException(404, "战略地图不存在")
# 并发保护(A 兜底 2026-10-12 + 按 objective 粒度合并 P2 2026-10-12):
# - 旧调用方(不带 dirty_objectives)⇒ 整表语义不变:覆写 dimensions/canvas_data 前校验基准;
# - 带 dirty_objectives ⇒ 按 objective 粒度合并:基准仍必需(fail-closed),但只有「同一 objective」
# 被并发改才 409(stale_objective);他方在其它 objective 上的改动被保留而非拒绝。
# 校验全部在任何 setattr/删行/同步之前完成 ⇒ 校验不过时一行都不改。
if _is_granular_payload(data):
_require_base_stamp(m, data)
_guard_canvas_data_granular(m, data)
merged_dims = _merge_dimensions_by_objective(m, data)
if merged_dims is None:
data.pop("dimensions", None) # 合并结果与库内现值等价 ⇒ 不写该字段
else:
data["dimensions"] = merged_dims
else:
assert_map_not_stale(m, data)
# 基准字段/脏项明细不是业务字段,剔除后不参与赋值
data = {k: v for k, v in data.items()
if k not in CONCURRENCY_BASE_KEYS and k != DIRTY_OBJECTIVES_KEY}
old_status = m.status
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
# status 允许但仅限合法值域(draft/published), 防止任意发布
@@ -559,11 +225,8 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
"label": data.get("label", ""),
})
m.canvas_data["connections"] = conns
# D2(2026-10-20):裸 JSON 列不做就地变更跟踪;显式标脏,与模型层 MutableDict 双保险
flag_modified(m, "canvas_data")
db.commit()
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
return {"connections": conns, "updated_at": _stamp(m.updated_at)}
return {"connections": conns}
@router.delete("/{map_id}/connections")
@@ -583,11 +246,8 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_
raise HTTPException(404, "连线不存在")
m.canvas_data["connections"] = new_conns
# D2(2026-10-20):换新列表赋值同样不被裸 JSON 列跟踪 ⇒ 显式标脏(根因修复点)
flag_modified(m, "canvas_data")
db.commit()
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
"updated_at": _stamp(m.updated_at)}
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}}
# ── 版本管理 ─────────────────────────────────
@@ -995,9 +655,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
# 取每个KPI的最新一条
for kid in kpi_ids:
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kid,
# 实测区口径:预估行禁作地图节点实测值(kpi_value_scope.actual_only_clause)
actual_only_clause(),
KPIValue.kpi_id == kid
).order_by(KPIValue.calculated_at.desc()).first()
if v:
latest_values[kid] = {
@@ -1051,15 +709,12 @@ 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, {})
# C1 数据前置:held KPI 的实测值一律不外泄(96.3% 零出口)
held = code in DATA_HOLD_KPIS
actual = None if held else lv.get("actual_value")
actual = 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
@@ -1070,7 +725,6 @@ 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:
@@ -1080,11 +734,6 @@ 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":
@@ -1098,29 +747,14 @@ 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": "pending" if hold_reason else worst_level,
"level": 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]]),
@@ -1131,11 +765,6 @@ 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":
@@ -1282,320 +911,3 @@ def get_review_record(
raise HTTPException(404, "复盘记录不存在")
return review_to_dict(r)
# ── 战略地图「活看板」(只读)────────────────────────────────────────
# 任务:finance-20261011-strategy-map-frontend-fullstack(任总 2026-10-11 19:07「收口」)
# 口径单一源:app/utils/strategy_board.py(色阶/逆指标白名单/状态类/零点保护)
# 只读:本端点不写任何表;entity_id 严格取 token → 博海 e2 天然隔离。
# kpi_targets.target_type 取值:兼容期读两形(不阻塞渲染)
# - 现状(2026-10-11 只读实测):全库仅 `annual: 57`(e1 战略框架同步写入,值域外)
# - P0 修正单 finance-20261011-kpi-targets-targettype-fix-dama 会把它们改成 `yearly`
# - 两形都读 ⇒ 修正前后同一份代码都取得到数;同 (kpi_id, period) 重复时偏好 `yearly`
_PERIOD_TYPES = ("yearly", "annual")
def _sb_resolve_bindings(db: Session, entity_id: int, objs: list) -> tuple[dict, dict]:
"""节点 ↔ KPI 绑定:优先 `map_objectives.kpis`(收口单已回填),缺失时按名称回查。
返回 (bindings, sources):``bindings[obj.id] = (kpi_id, kpi_code)``;
``sources[obj.id]`` ∈ {kpis, kpis_code, name, note, none} —— 供复核方判断
是否已用上 19 条回填绑定(回填前 name/note 兜底)。
"""
from app.models import KPIDefinition
bindings: dict = {}
sources: dict = {}
declared_codes: dict = {}
for obj in objs:
raw = obj.kpis
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
raw = None
if isinstance(raw, dict):
raw = [raw]
if not isinstance(raw, list):
continue
for item in raw:
if isinstance(item, dict):
kid, code = item.get("kpi_id"), item.get("kpi_code") or item.get("code")
if kid:
bindings[obj.id] = (int(kid), code)
sources[obj.id] = "kpis"
break
if code:
declared_codes[obj.id] = code
break
elif isinstance(item, str):
declared_codes[obj.id] = item
break
def _def_by_code(code: str, cache: dict):
if code in cache:
return cache[code]
row = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == code,
).first()
cache[code] = row
return row
code_cache: dict = {}
for oid, code in declared_codes.items():
row = _def_by_code(code, code_cache)
if row:
bindings[oid] = (row.id, row.kpi_code)
sources[oid] = "kpis_code"
# 兜底:按节点名回查 KPI 名 / kpi_targets.note("名称 · 期间")
for obj in objs:
if obj.id in bindings:
continue
row = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_name == obj.name,
).first()
if row:
bindings[obj.id] = (row.id, row.kpi_code)
sources[obj.id] = "name"
continue
hit = db.execute(text(
"SELECT kpi_id FROM kpi_targets "
"WHERE entity_id = :e AND target_type IN ('yearly', 'annual') AND note LIKE :pat "
"ORDER BY period LIMIT 1"
), {"e": entity_id, "pat": f"{obj.name} · %"}).first()
if hit:
bindings[obj.id] = (int(hit[0]), None)
sources[obj.id] = "note"
else:
sources[obj.id] = "none"
return bindings, sources
@router.get("/{map_id}/strategy-board")
def get_strategy_board(
map_id: int,
period: Optional[str] = Query(None, description="期间 2027/2028/2029,默认取最小可用期间"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""战略地图活看板(只读):19 节点分层 + 达成率色阶 + 因果箭头。
- 目标值:`kpi_targets`(`target_type` 兼容 `yearly`/`annual` 两形,按 period)
- 实际值:`kpi_values` **实测区**最新期(`kpi_value_scope.actual_only_clause`)
- 色阶:r = 实际/目标;≥1.0 success / ≥0.8 warning / <0.8 danger / 无实际值 info
- 逆指标(显式白名单 504/511/230/23)取倒数;状态类(unit='状态')按 1/0 判色
"""
from app.models import KPIValue, KPICausality, KPIDefinition
from app.utils.strategy_board import (
INVERSE_KPI_IDS, LAYER_DISPLAY_ORDER, LAYER_META, LEVEL_COLORS, STATUS_UNIT,
compute_level, layer_meta, period_rank, pick_latest,
)
warnings: list = []
m = db.query(StrategicMap).filter(
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id,
).first()
if not m:
raise HTTPException(404, "战略地图不存在")
objs = db.query(MapObjective).filter(
MapObjective.map_id == map_id, MapObjective.entity_id == entity_id,
).order_by(MapObjective.sort_order, MapObjective.id).all()
if not objs:
warnings.append("该地图下无 map_objectives 节点(map_objectives 为空)")
bindings, sources = _sb_resolve_bindings(db, entity_id, objs)
kpi_ids = sorted({v[0] for v in bindings.values() if v and v[0]})
kpi_defs = {}
if kpi_ids:
for k in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id, KPIDefinition.id.in_(kpi_ids),
).all():
kpi_defs[k.id] = k
# ── 目标值(kpi_targets 无 ORM 模型,用只读 text() 取)──
targets: dict = {}
periods: list = []
if kpi_ids:
id_list = ",".join(str(int(i)) for i in kpi_ids) # 已 int 化,无注入面
ttype = ",".join("'%s'" % t for t in _PERIOD_TYPES)
rows = db.execute(text(
"SELECT kpi_id, period, target_type, target_value FROM kpi_targets "
f"WHERE entity_id = :e AND target_type IN ({ttype}) AND kpi_id IN ({id_list})"
), {"e": entity_id}).fetchall()
# 同 (kpi_id, period) 两形并存(兼容期)⇒ 取 `_PERIOD_TYPES` 中序更靠前者(yearly 优先)
_type_rank = {t: i for i, t in enumerate(_PERIOD_TYPES)}
best: dict = {}
for r in rows:
kid, per, ttype_row, tv = int(r[0]), str(r[1]), str(r[2]), r[3]
rank = _type_rank.get(ttype_row, len(_PERIOD_TYPES))
if (kid, per) in best and best[(kid, per)][0] <= rank:
continue
best[(kid, per)] = (rank, float(tv) if tv is not None else None)
for (kid, per) in best:
targets[(kid, per)] = best[(kid, per)][1]
if per not in periods:
periods.append(per)
periods.sort(key=period_rank)
# `direction` 列在 ORM 模型里未映射(库内有、模型无);判色**不依赖**它
# (逆指标用显式白名单,见 strategy_board.INVERSE_KPI_IDS),此处仅为回执留证
directions: dict = {}
if kpi_ids:
id_list_d = ",".join(str(int(i)) for i in kpi_ids)
for r in db.execute(text(
f"SELECT id, direction FROM kpi_definitions WHERE id IN ({id_list_d})"
)).fetchall():
directions[int(r[0])] = r[1]
# ── 实际值:实测区最新期(每 KPI 一条)──
currents: dict = {}
if kpi_ids:
vrows = db.query(KPIValue).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id.in_(kpi_ids),
actual_only_clause(),
).all()
by_kpi: dict = {}
for v in vrows:
by_kpi.setdefault(v.kpi_id, []).append(v)
for kid, rows_k in by_kpi.items():
currents[kid] = pick_latest(rows_k)
# ── 因果箭头(先按本单 source_type,为空则退回该实体全部)──
edges_raw = db.query(KPICausality).filter(
KPICausality.entity_id == entity_id,
KPICausality.source_type == "strategy-2027",
).all()
if not edges_raw:
edges_raw = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
if edges_raw:
warnings.append("未命中 source_type='strategy-2027' 的因果链,已退回该实体全部因果链")
chosen_period = period if period else (periods[0] if periods else None)
obj_by_kpi = {v[0]: k for k, v in bindings.items() if v and v[0]}
nodes: list = []
for obj in objs:
kid, code = bindings.get(obj.id, (None, None))
kdef = kpi_defs.get(kid) if kid else None
unit = (kdef.unit if kdef else None) or None
target = targets.get((kid, chosen_period)) if kid else None
vrow = currents.get(kid) if kid else None
cur = float(vrow.actual_value) if (vrow and vrow.actual_value is not None) else None
calc = compute_level(kpi_id=kid, unit=unit, current=cur, target=target)
lmeta = layer_meta(obj.dimension_key)
owner = None
if kdef:
owner = getattr(kdef, "responsible_user", None) or getattr(kdef, "data_owner", None)
dept = getattr(kdef, "responsible_dept", None)
if dept and owner:
owner = f"{dept}·{owner}"
elif dept:
owner = dept
nodes.append({
"id": obj.id,
"name": obj.name,
"dimension_key": obj.dimension_key,
"layer_name": lmeta["name"],
"layer_color": lmeta["color"],
"icon": obj.icon,
"sort_order": obj.sort_order,
"description": obj.description,
"kpi_id": kid,
"kpi_code": code or (kdef.kpi_code if kdef else None),
"kpi_name": kdef.kpi_name if kdef else None,
"unit": unit,
"direction": directions.get(kid),
"owner": owner,
"binding_source": sources.get(obj.id, "none"),
"current": cur,
"current_period": (vrow.period if vrow else None),
"current_data_status": (vrow.data_status if vrow else None),
"current_source_type": (vrow.source_type if vrow else None),
"target": target,
"rate": calc["rate"],
"level": calc["level"],
"color": calc["color"],
"mode": calc["mode"],
"reason": calc["reason"],
})
if obj.id not in bindings or not kid:
warnings.append(f"节点「{obj.name}」未绑定 KPI(map_objectives.kpis 为空且回查未命中)")
elif code is None and kdef:
code = kdef.kpi_code
layer_buckets: dict = {k: [] for k in LAYER_DISPLAY_ORDER}
for n in nodes:
layer_buckets.setdefault(n["dimension_key"] if n["dimension_key"] in LAYER_META else "unknown", []).append(n)
layers: list = []
for key in LAYER_DISPLAY_ORDER:
lmeta = LAYER_META[key]
layers.append({"key": key, "name": lmeta["name"], "color": lmeta["color"],
"nodes": layer_buckets.get(key, [])})
for key, bucket in layer_buckets.items():
if key not in LAYER_META and bucket:
layers.append({"key": key, "name": key, "color": LEVEL_COLORS["info"], "nodes": bucket})
warnings.append(f"存在规范外 dimension_key='{key}'({len(bucket)} 个节点)")
edges: list = []
dropped = 0
for e in edges_raw:
sid, tid = obj_by_kpi.get(e.source_kpi_id), obj_by_kpi.get(e.target_kpi_id)
if sid is None or tid is None:
dropped += 1
continue
edges.append({
"id": e.id,
"source_node_id": sid, "target_node_id": tid,
"source_kpi_id": e.source_kpi_id, "target_kpi_id": e.target_kpi_id,
"strength": float(e.strength) if e.strength is not None else None,
"lag_months": e.lag_months,
"direction": e.direction,
"formula": e.formula,
})
if dropped:
warnings.append(f"{dropped} 条因果链端点不在本图 19 节点内,已剔除")
asof = None
asof_rank = -1
for v in currents.values():
if period_rank(v.period) > asof_rank:
asof, asof_rank = v.period, period_rank(v.period)
with_actual = sum(1 for n in nodes if n["current"] is not None)
return {
"map": {
"id": m.id, "entity_id": m.entity_id, "title": m.title,
"version": m.version, "status": m.status,
"updated_at": m.updated_at.isoformat() if getattr(m, "updated_at", None) else None,
},
"period": chosen_period,
"periods": periods,
"data_asof": asof,
"layers": layers,
"edges": edges,
"stats": {
"nodes": len(nodes),
"with_actual": with_actual,
"missing_actual": len(nodes) - with_actual,
"edges": len(edges),
"by_level": {lv: sum(1 for n in nodes if n["level"] == lv) for lv in LEVEL_COLORS},
},
"caliber": {
"source": "app/utils/strategy_board.py",
"level_rule": "r=actual/target;>=1.0 success;>=0.8 warning;<0.8 danger;no actual info",
"inverse_whitelist": sorted(INVERSE_KPI_IDS),
"status_unit": STATUS_UNIT,
"actual_scope": "kpi_values 实测区(排除 estimated/forecast)最新期",
"zero_guard": "target 0/缺失 ⇒ info,禁 NaN/Inf",
},
"warnings": warnings,
}
+6 -45
View File
@@ -2,21 +2,10 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role, require_auth_401
from datetime import datetime
# ── B2(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)────────────────
# 本模块两张表在 `models/__init__.py` 均**无 entity_id 列**,按「分表处置」口径落地:
# · `notification_logs`(Log 类)→ **经父键 join 归属**,零迁移:
# alert_id → kpi_alerts.id → kpi_alerts.entity_id(GET /logs 已收口)。
# · `notification_channels`(**配置类,无父键可桥接**)→ 任总 2026-09-22 已批
# 「方案 1(零 DDL)」:**保持全局 = 集团共享配置**(一套 wecom/mail/sms 渠道
# 供全账套复用;预警推送是集团行为,渠道与账套无关)。
# ⚠️ 因此 `/channels*` 四个端点**有意不按账套过滤**——这是口径,不是漏隔离。
# 若后续需要「账套私有渠道」,须单独立单加 entity_id 列(DDL + 回填 + 唯一键改造)。
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
@@ -63,10 +52,8 @@ def update_channel(ch_id: int, data: dict, db: Session = Depends(get_db)):
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
# 字段白名单(2026-09-21 mass-assignment 收口):仅业务字段,其余键忽略
_updatable = ("name", "config", "enabled")
for k, v in data.items():
if k in _updatable:
if hasattr(ch, k) and k not in ("id", "created_at"):
setattr(ch, k, v)
db.commit()
db.refresh(ch)
@@ -108,40 +95,14 @@ def test_channel(ch_id: int, db: Session = Depends(get_db)):
@router.get("/logs")
def list_logs(page: int = 1,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db)):
"""通知历史(**按会话账套收口**,B2 零迁移)
2026-09-22 dama-workbench-entity-isolation-fullstack-20260922(§四 B2):
`notification_logs` 无 entity_id 列 → 经父键 join 归属:
`notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id`。
口径与 batch5 对 `KpiDataQualityLog` 的处置一致(宁少勿错):
`alert_id IS NULL` 的行无可归属父键 → **不返回**(不暴露给任一账套)。
"""
from app.models import KPIAlert
scoped = (db.query(NotificationLog)
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
.filter(KPIAlert.entity_id == entity_id))
total = scoped.count()
# 排序补**确定性次键** `id DESC`(任务 0-P3-project-20260925-cma-same-class-pagination-determinism-fullstack):
# 通知日志由预警推送批量写入,同一秒多行时 MySQL filesort 在并列组内顺序不受控 ⇒ 单键
# `created_at DESC` + offset/limit 会让偏移分页重复并漏行。两键后偏移分页成为全序分页。
# 同族先例:app/api/alerts.py(e369508)、app/api/cash.py:165、app/api/data_quality.py:230。
# 本行**不改**任何账套收口口径(join 归属 / `alert_id IS NULL` 不返回 / policy+note 文案)。
logs = scoped.order_by(
NotificationLog.created_at.desc(), NotificationLog.id.desc()
def list_logs(page: int = 1, db: Session = Depends(get_db)):
"""通知历史"""
total = db.query(NotificationLog).count()
logs = db.query(NotificationLog).order_by(
NotificationLog.created_at.desc()
).offset((page - 1) * 20).limit(20).all()
return {
"total": total,
"entity_id": entity_id,
# 口径字段(2026-09-22 DAMA 回查 §5 收口):上线后本页在无预警推送的账套会显示 0 条,
# 属口径而非故障——现有 8 行历史记录全是 `alert_id IS NULL` 的 OKR 复盘提醒(无账套归属),
# 按「宁少勿错」不返回。前端据此文案提示,避免被当成页面坏了。
"policy": "alert-linked-only",
"note": ("仅显示与预警推送关联的记录(按父键 kpi_alerts 归属会话账套)。"
"无父键的历史通知(如 OKR 复盘提醒,写入方 backend/scripts/okr_review_reminder.py)"
"不属于任何账套,不在本列表返回。口径登记:docs/audit-and-notification-caliber-20260922.md"),
"data": [{
"id": l.id,
"alert_id": l.alert_id,
-5
View File
@@ -547,11 +547,6 @@ def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
week_end = week_start + timedelta(days=7)
action_plans = db.query(ActionPlan).filter(
ActionPlan.objective_id == okr_id,
# 账套隔离(2026-09-18 batch3 派生单):objective_id 已由
# _get_objective_or_404 校验归属,但计划侧此前无账套条件 →
# 对端账套计划(含脏行)会出现在本账套分解视图(实测 OBS2)。
# 前置条件:create_plan 必须已写会话账套 entity_id(顺序铁律)。
ActionPlan.entity_id == entity_id,
ActionPlan.due_date.between(week_start, week_end)
).all()
+1 -17
View File
@@ -1,24 +1,10 @@
"""
OKR模板库 API — 按维度筛选 + 用户自定义模板
⚠️ 集团级共享口径(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,§四 B2)
------------------------------------------------------------------------------------
`okr_templates` 表**无 entity_id 列**,且属**模板/配置类**(无父键可 join 桥接)。
任总 2026-09-22「都批」已按 **方案 1(零 DDL)** 拍板:
**保持全局 = 集团级共享配置** —— 模板库是集团知识资产,一套模板(维度/行业标签/
目标+KR 骨架)供全账套复用;账套差异体现在**使用模板生成的具体目标/KR 行**
(`objectives` / `map_objectives` 均带 entity_id,已隔离),而不是模板本身。
因此本模块的列表/详情/新增/改/删**有意不按账套过滤**——这是口径,不是漏隔离。
若后续确需「账套私有模板」,须单独立单加 `entity_id` 列(DDL + 回填 + 唯一键
`name+dimension` 改造),本单不预设。
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role
from app.models import OKRTemplate, StrategicMap
from app.utils.kr_name import sanitize_kr_name
@@ -267,8 +253,7 @@ def increment_use_count(template_id: int, db: Session = Depends(get_db)):
@router.post("/{template_id}/apply")
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)):
"""应用 OKR 模板 — 创建战略地图并填入 O+KR"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
@@ -308,7 +293,6 @@ def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_d
})
m = StrategicMap(
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1(对齐 maps.py)
title=map_title,
version=data.get("version", "v1.0"),
status="draft",
+1 -11
View File
@@ -45,22 +45,12 @@ def trace_ontology(
)
# 第3层: 每个 KPI 依赖的科目 (kpi_subject_map)
# ⚠️ 护栏(2026-09-23 账套维度收口批 ②,零结构改动、零读取逻辑改动):
# 该表是唯一一张「语义上必然账套相关、却无账套列」的表 —— **账套维度经 `kpi_id` 继承**
# (`kpi_definitions.entity_id`,本函数先按 `Objective.entity_id` 过滤目标、再按 kpi_id 取映射)。
# **禁止新增按 `subject_code` 反查的读口,禁止按 `subject_code` 批量写的入口**:
# 科目码在标准准则体系里两账套同义(同一 code 可属不同账套),按科目反查/批量写立刻跨账套。
subject_by_code = {
s.subject_code: s.subject_name
for s in db.query(Subject).filter(
Subject.is_active == 1,
# 账套分层(2026-09-23 subjects 加 entity_id 列):全局标准科目 ∪ 本账套私有明细
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
).all()
for s in db.query(Subject).filter(Subject.is_active == 1).all()
}
kpi_layer = []
for link, kpi in links:
# kpi_id 已由上方 Objective.entity_id 过滤链继承账套维度(见护栏注释),故此处不再按科目口径过滤
maps = (
db.query(KPISubjectMap)
.filter(KPISubjectMap.kpi_id == kpi.id)
+69 -165
View File
@@ -10,8 +10,7 @@ from app.utils.cash_forecast_engine import (
calculate_accuracy, generate_scenario_suggestion,
)
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope
from app.utils.alert_entity_scope import month_period_clause
from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_auth_401
from sqlalchemy.orm import Session
@@ -114,12 +113,7 @@ def api_scenario_analysis(data: dict):
@router.post("/cvp-detailed")
def api_cvp_detailed(data: dict):
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)
⚠️ 情景模拟器(scenario simulation):入参为**假设值**推演(前端滑杆),
非 CMA 实测口径。真实实测口径见派生 KPI `F_BEP_*`(kpi_bep_deriver.py,单一事实源);
响应显式带 source='scenario_simulation' + is_mock=True,前端据此标注「情景模拟·非实测」。
"""
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)"""
try:
fixed_cost = float(data.get("fixed_cost", 617))
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
@@ -156,9 +150,6 @@ def api_cvp_detailed(data: dict):
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
return {
"source": "scenario_simulation",
"is_mock": True,
"basis": "user_input_scenario",
"fixed_cost": fixed_cost,
"variable_cost_rate": round(variable_cost_rate * 100, 2),
"unit_price": unit_price,
@@ -733,182 +724,95 @@ def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
return indicators
# ── 增长质量诊断取数(账套隔离 2026-09-21)────────────────────────
# 输入 ← 库内 KPI 实际值(kpi_values),编码口径沿用 kpi_definitions 既有编码
# (同 app/api/growth_quality.py 的 KPI_CODES),不新增计算口径。
_GQ_KPI_CODE_MAP = {
"C_REBATE_RATE": "rebateRate", # 渠补率(%)
"F_GROSS_MARGIN": "trueGrossMargin", # 毛利率(%)
"F_REVENUE_GROWTH": "revenueGrowthRate", # 收入增长率(%)
}
_GQ_DIM_SCORERS = {
"revenueStructure": _score_revenue_structure,
"profitStructure": _score_profit_structure,
"cashAssets": _score_cash_assets,
"growthDriver": _score_growth_driver,
"orgEfficiency": _score_org_efficiency,
}
# 各维度所需输入键。缺任一输入 → 该维度不评分(避免用缺省 0 编造低分)
_GQ_DIM_INPUTS = {
"revenueStructure": ("rebateRate",),
"profitStructure": ("trueGrossMargin",),
"cashAssets": ("cashRatio",),
"growthDriver": ("expenseGrowthRate", "revenueGrowthRate"),
"orgEfficiency": ("mgmtRatio",),
}
def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None):
"""按会话账套从库内 KPI 实际值取增长质量输入。
返回 (entity_dict|None, collected):
- entity_dict 只含库内真实取到的指标(缺的键不填,绝不用请求体/硬编码兜底)
- collected = 本账套是否存在增长质量相关 KPI 实际值
"""
from app.models import Entity, KPIDefinition, KPIValue
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if not ent:
return None, False
q = (
db.query(KPIDefinition.kpi_code, KPIValue.actual_value, KPIValue.period)
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
.filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(list(_GQ_KPI_CODE_MAP.keys())),
KPIValue.actual_value.isnot(None))
)
if period:
q = q.filter(KPIValue.period == period)
rows = q.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).all()
entity = {"entity": ent.name}
seen = set()
for code, value, _period in rows:
if code in seen:
continue
seen.add(code)
entity[_GQ_KPI_CODE_MAP[code]] = float(value)
return entity, bool(seen)
def _growth_quality_score(entity: dict):
"""计算「有输入的维度」评分 + 明细 + 缺失维度清单(缺输入的维度不评分)。"""
scores, detail, missing = {}, {}, []
for dim, inputs in _GQ_DIM_INPUTS.items():
if any(k not in entity for k in inputs):
missing.append(dim)
detail[dim] = {"score": None, "data_missing": True,
"indicators": [], "suggestions": []}
continue
score = _GQ_DIM_SCORERS[dim](entity)
scores[dim] = score
detail[dim] = {
"score": score,
"indicators": _get_dim_detail_indicators(dim, entity),
"suggestions": _generate_improvement_suggestions(dim, score, entity),
}
return scores, detail, missing
def _gq_level_of(overall: float):
if overall is None:
return None, None
if overall >= 4:
return "🟢 越增长越轻", "excellent"
if overall >= 3:
return "🟡 增长质量中等", "medium"
if overall >= 2:
return "🟠 需关注", "warning"
return "🔴 越增长越重", "danger"
@router.post("/growth-quality")
def api_growth_quality(request: Request, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""增长质量诊断 — 五维度评分+综合评分+诊断结论(账套隔离 2026-09-21)
取数:按会话账套从库内 KPI 实际值取,移除请求体 `data.get("entity", data)` 回退
(原回退可用客户端可控的「经营数据」伪造诊断结论);库内无数据时如实返回
collected=false + 空维度,不编造评分。请求体显式 entity_id ≠ 会话账套 → 403。
"""
def api_growth_quality(request: Request, data: dict):
"""增长质量诊断 — 五维度评分+综合评分+诊断结论"""
try:
data = data or {}
# 显式 body entity_id 与会话账套不一致 → 403(文案同 app/deps.py)
assert_entity_scope(request, data.get("entity_id"), db)
period = data.get("period")
entity, collected = _growth_quality_entity_from_db(db, entity_id, period)
if entity is None:
raise HTTPException(404, f"实体 {entity_id} 不存在")
entity_name = entity.get("entity", "该企业")
period_label = period or "当前"
entity_id = resolve_entity_for_request(request, data.get("entity_id"))
ENTITY_DATA = {
1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447},
2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4},
}
entity = ENTITY_DATA.get(entity_id, data.get("entity", data))
entity_name = entity.get("entity", entity.get("name", "该企业"))
period = entity.get("period", data.get("period", "当前"))
if not collected:
return {
"entity_id": entity_id,
"entity_name": entity_name,
"period": period_label,
"collected": False,
"data_source": "kpi_values",
"message": "该账套暂无增长质量相关 KPI 实际值(无数据不评分)",
"overall": None,
"level": None,
"level_type": None,
"dimensions": {},
"detail": {},
"dimensions_missing": list(_GQ_DIM_INPUTS.keys()),
"missing_metrics": sorted(set(_GQ_KPI_CODE_MAP.values())),
"diagnosis": None,
"compare": None,
# 五维度评分
dim_scores = {
"revenueStructure": _score_revenue_structure(entity),
"profitStructure": _score_profit_structure(entity),
"cashAssets": _score_cash_assets(entity),
"growthDriver": _score_growth_driver(entity),
"orgEfficiency": _score_org_efficiency(entity),
}
overall = round(sum(dim_scores.values()) / 5, 1)
# 综合等级
if overall >= 4:
level = "🟢 越增长越轻"
level_type = "excellent"
elif overall >= 3:
level = "🟡 增长质量中等"
level_type = "medium"
elif overall >= 2:
level = "🟠 需关注"
level_type = "warning"
else:
level = "🔴 越增长越重"
level_type = "danger"
# 诊断结论
dimensions_payload = {}
detail_payload = {}
for dim, score in dim_scores.items():
dimensions_payload[dim] = {"score": score, "weight": 20}
detail_payload[dim] = {
"score": score,
"indicators": _get_dim_detail_indicators(dim, entity),
"suggestions": _generate_improvement_suggestions(dim, score, entity),
}
dim_scores, detail_payload, dims_missing = _growth_quality_score(entity)
dimensions_payload = {dim: {"score": s, "weight": 20} for dim, s in dim_scores.items()}
overall = round(sum(dim_scores.values()) / len(dim_scores), 1) if dim_scores else None
level, level_type = _gq_level_of(overall)
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name) if overall is not None else None
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
# 对比:只接受账套内 compare_entity_id 并从库内取数(原请求体 compare 数据不再采信)
# 对比数据(如果请求中包含多个实体)
compare = data.get("compare", None)
compare_result = None
compare_entity_id = data.get("compare_entity_id")
if compare_entity_id:
assert_entity_scope(request, int(compare_entity_id), db)
c_entity, c_collected = _growth_quality_entity_from_db(db, int(compare_entity_id), period)
if c_entity is not None and c_collected:
cdims, _c_detail, _c_missing = _growth_quality_score(c_entity)
if cdims:
c_overall = round(sum(cdims.values()) / len(cdims), 1)
compare_result = {
"entity_id": int(compare_entity_id),
"entity_name": c_entity.get("entity", "对比企业"),
"overall": c_overall,
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
"level": _gq_level_of(c_overall)[0],
}
if compare:
compare_entity = compare
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业"))
cdims = {
"revenueStructure": _score_revenue_structure(compare_entity),
"profitStructure": _score_profit_structure(compare_entity),
"cashAssets": _score_cash_assets(compare_entity),
"growthDriver": _score_growth_driver(compare_entity),
"orgEfficiency": _score_org_efficiency(compare_entity),
}
compare_overall = round(sum(cdims.values()) / 5, 1)
compare_result = {
"entity_name": compare_name,
"overall": compare_overall,
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
"level": ("🟢 越增长越轻" if compare_overall >= 4 else
"🟡 增长质量中等" if compare_overall >= 3 else
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
}
return {
"entity_id": entity_id,
"entity_name": entity_name,
"period": period_label,
"collected": True,
"data_source": "kpi_values",
"period": period,
"overall": overall,
"level": level,
"level_type": level_type,
"dimensions": dimensions_payload,
"detail": detail_payload,
"dimensions_missing": dims_missing,
"diagnosis": diagnosis,
"compare": compare_result,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
from app.utils.kpi_forecast_engine import ( # noqa: E402
MODELS, forecast_kpi, forecast_finance_kpis,
+383 -724
View File
File diff suppressed because it is too large Load Diff
+2 -16
View File
@@ -5,7 +5,6 @@ from sqlalchemy import func
from typing import Optional, List
from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import Subject
import logging
@@ -21,21 +20,9 @@ def list_subjects(
category: Optional[str] = Query(None, description="新30号准则分类过滤"),
keyword: Optional[str] = Query(None, description="科目名称/编码搜索"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""科目列表 — 支持新30号准则分类筛选
账套分层(2026-09-23 `subjects` 加 `entity_id` 列):返回**全局标准准则科目**
(`entity_id IS NULL`)∪ **本账套私有明细**(`entity_id = 会话账套`)。
行为变更(预期):博海会话不再看到酣客智享通明细(`100106 酒窖备用金` / `100108 友赞商户` 等)。
2026-09-26(yanxue-20260924-cma-fetch-caliber-fixes):响应**增补 `entity_id`**
(`NULL` = 全局标准准则科目;`= 会话账套` = 本账套私有明细)——原响应不含该字段,
共享层(51 行)与酣客私有(156 行)在界面上不可区分。既有 10 个字段语义与名称不变。
"""
query = db.query(Subject).filter(
Subject.is_active == 1,
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id), # 分层读取口径
)
"""科目列表 — 支持新30号准则分类筛选"""
query = db.query(Subject).filter(Subject.is_active == 1)
if category:
query = query.filter(Subject.new_standard_category == category)
@@ -61,7 +48,6 @@ def list_subjects(
"new_standard_category": s.new_standard_category,
"is_active": s.is_active,
"remark": s.remark,
"entity_id": s.entity_id,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
}
for s in subjects
+2 -6
View File
@@ -831,7 +831,7 @@ def seed_demo_data(
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
if exists:
continue
db.add(ExpenseReimbursement(entity_id=eid, **d))
db.add(ExpenseReimbursement(**d))
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
invoice_demo = [
@@ -854,11 +854,7 @@ def seed_demo_data(
)
if exists:
continue
inv_data = dict(d)
# 演示日期按既有写入口径解析为 datetime(字符串日期在 SQLite 下无法绑定 DateTime 列;
# MySQL 表现不变——同文件 :402 正常写入路径亦经 _parse_date)
inv_data["invoice_date"] = _parse_date(inv_data.get("invoice_date"))
inv = InvoiceCheck(entity_id=eid, **inv_data)
inv = InvoiceCheck(entity_id=eid, **d)
_check_invoice(db, inv)
db.add(inv)
created["invoice"] += 1
+13 -28
View File
@@ -8,9 +8,7 @@ from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import KPITemplate, KPIDefinition, OperationLog
from app.utils.oplog import log_op, SCOPE_GLOBAL
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
@@ -76,9 +74,7 @@ def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES)
db.add(t)
db.commit()
db.refresh(t)
# kpi_templates = 系统预置/用户自定义**模板库**(非账套维度,裁定见 docs/entity-dimension-data-dictionary.md)
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name},
scope=SCOPE_GLOBAL)
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name})
return template_to_dict(t)
@@ -111,16 +107,8 @@ def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_
@router.post("/{template_id}/instantiate")
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db),
user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
"""从模板实例化创建KPI,复制模板快照到kpi_definitions
账套维度(2026-09-23 账套维度收口批 ①):新 KPI 显式挂**会话账套**,不做静默兜底。
历史缺陷:本构造点未传 `entity_id` ⇒ 模型 `default=1` 把任意账套实例化出的 KPI
静默挂到酣客(e1),与「建预算未写账套」同源。
判重口径:`kpi_code` 唯一性按账套判定(库内 `uk_entity_kpi(UNIQUE entity_id,kpi_code)`);
全表判重会让博海无法建与酣客同码的 KPI(反之则放过同账套重复)。
"""
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""从模板实例化创建KPI,复制模板快照到kpi_definitions"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
@@ -128,13 +116,10 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
kpi_code = data.get("kpi_code", t.kpi_code)
kpi_name = data.get("kpi_name", t.kpi_name)
# 检查编码唯一性(按账套收紧:仅本账套内判重)
existing = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
# 检查编码唯一性
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if existing:
raise HTTPException(400, f"KPI编码 {kpi_code} 在本账套已存在,请修改")
raise HTTPException(400, f"KPI编码 {kpi_code} 已存在,请修改")
# 数据治理:入库必检(规则1) + 编码规范(规则3)
merged = {
@@ -151,7 +136,6 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": kpi_issues_message(gov_issues)})
kpi = KPIDefinition(
entity_id=entity_id, # 账套维度:显式挂会话账套(不得依赖模型 default=1)
template_id=t.id,
is_system=0, # 从模板实例化的KPI不是系统预置
kpi_code=kpi_code,
@@ -177,12 +161,13 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
t.usage_count = (t.usage_count or 0) + 1
db.commit()
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code},
entity_id=entity_id)
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code})
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None, scope=None):
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
return log_op(db, action, target_type=target_type, target_id=target_id,
detail=detail, user_id=user_id, entity_id=entity_id, scope=scope, commit=True)
def _log(db, user_id, action, target_type, target_id, detail):
import json
log = OperationLog(user_id=user_id, action=action, target_type=target_type,
target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
db.add(log)
db.commit()
+10 -110
View File
@@ -1,49 +1,10 @@
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务
⚠️ 语义收敛(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,任总已批「都批」)
--------------------------------------------------------------------------------------
`system_configs.current_tenant` 是**全局唯一键**(该表无 entity_id 列,`config_key` unique)
⇒ 一次写入全平台生效。本次事故(2026-09-04 前端企业切换器把它写成 entity_id=2)暴露的
结构性缺陷正是「全局单值键被当成个人当前视图 / 数据维度」。
本文件的收敛口径:
1. **它不是数据维度**。工作台/看板/报表取数一律用**登录会话的 entity**
(`app/deps.py::get_entity_id` ← JWT token 绑定),前端本地视图用
`localStorage.cma_entity_id`(登录窗口写入)。禁止任何取数端点读本键。
2. 它的用途**只剩**:项目 Bot 分发 A2A 任务时的状态记录(本模块原始设计目的)。
3. **仅 admin 会话可读**(GET /current):系统无 `admin` 角色(ROLES: ceo/finance/
business/it),故映射为持有 `admin` 动作权限的 `it` + 业务负责人 `ceo`。
4. 写(POST /switch)**不额外收紧角色**:项目 Bot 分发任务时需要写该状态,
收紧会切断其原始用途。
5. 每次写都落 `operation_logs`(action=`switch_tenant_state`)留痕 —— 修复前本端点
无审计(ops 止血单假定「走端点即有审计」不成立,已在本单补齐)。
6. **审计行可归属(2026-09-22 DAMA 独立回查 §4b 后补)**:`user_id` = 当前会话用户
(原写 None ⇒ 事后无法追操作人);`target_type="entity"` + `target_id=<entity_id>`
与 `POST /auth/switch-entity`(action=`switch_entity`,`auth.py`)**共用同一目标维度**
⇒「按账套查换账套记录」= `WHERE target_type='entity' AND target_id=?`(`operation_logs`
无 `entity_id` 列,无需 DDL)。两字面**刻意不合并**:一个改「用户自己的会话账套」、
一个改「平台全局状态记录」,合并后审计上不可分辨。口径登记见
`docs/audit-and-notification-caliber-20260922.md`。
7. **审计缺口的收口(2026-09-23 dama-entity-isolation-sweep-20260923 §二)**:
- `detail` 显式给出 `from_entity_id → to_entity_id`(原先只有 `previous` 整包,
要人肉解析才知道「从哪来」)+ `source` + `operator`(id/username/role)。
- **审计失败不静默**:状态键与审计行走**同一事务**,审计写入异常 → `rollback()`
(状态变更一并回滚,绝不留「改了但没审计」的窗口)+ `logger.error(AUDIT_WRITE_FAILED…)`
+ HTTP 500。日志前缀 `AUDIT_WRITE_FAILED` 供运维 grep/告警链订阅。
- `GET /current` **无写路径**(纯只读,且已限 it/ceo),故不落审计——不给读操作
写审计表,避免审计面被读流量稀释。
- 历史缺口(2026-09-22 止血操作无审计行)**不补写**:审计不得事后伪造。
"""
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth_401, require_role
from app.models import SystemConfig, Entity, OperationLog
import logging
logger = logging.getLogger("cma.tenant")
from app.auth_middleware import require_auth_401
from app.models import SystemConfig, Entity
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
@@ -64,15 +25,8 @@ def _tenant_name(entity_id: int) -> str:
@router.get("/current")
def get_current_tenant(
db: Session = Depends(get_db),
user=Depends(require_role("it", "ceo")),
):
"""查询当前租户状态记录(**仅 admin 会话**:it/ceo)
2026-09-22 收敛:本键**不是**数据维度——前端/接口取数一律按会话 entity,
不得读本键决定「取哪个实体的数据」(本次工作台跨实体污染的直接触发点)。
"""
def get_current_tenant(db: Session = Depends(get_db)):
"""查询当前租户"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg and cfg.config_value:
import json
@@ -84,28 +38,13 @@ def get_current_tenant(
@router.post("/switch")
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
user=Depends(require_auth_401)):
"""切换当前租户状态记录(项目Bot 分发 A2A 任务用;CMA前端企业切换器已下线)
2026-09-22:写操作**不动**(保留 Bot 通道可用性),补 `operation_logs` 审计留痕。
"""
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
"""切换当前租户(CMA前端企业切换器调用)"""
ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
if not ent:
raise HTTPException(404, "企业不存在")
import json
from_entity_id = None
previous = None
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg and cfg.config_value:
try:
previous = json.loads(cfg.config_value)
if isinstance(previous, dict):
from_entity_id = previous.get("entity_id")
except Exception:
previous = None
state = {
"tenant_id": _tenant_name(data.entity_id),
"entity_id": data.entity_id,
@@ -115,54 +54,15 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
"switched_at": __import__("datetime").datetime.now().isoformat(),
}
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg:
cfg.config_value = json.dumps(state, ensure_ascii=False)
else:
db.add(SystemConfig(
config_key=TENANT_KEY,
config_value=json.dumps(state, ensure_ascii=False),
description="当前租户状态记录(仅供项目Bot分发A2A任务;非数据维度,2026-09-22 语义收敛)",
description="当前租户状态(CMA企业切换联动)",
))
# 审计留痕(2026-09-22 补齐:本端点原无 operation_logs 记录)
# 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人);
# target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤
# (operation_logs 无 entity_id 列,无须 DDL)。
# 2026-09-23 收口:detail 显式 from→to / source / operator;审计失败不静默(见 §7)。
audit_detail = {
"from_entity_id": from_entity_id,
"to_entity_id": data.entity_id,
"entity_id": data.entity_id, # 兼容既有消费方/用例(= to)
"entity_name": ent.name,
"source": data.source,
"operator": {
"user_id": user.id,
"username": getattr(user, "username", None),
"name": getattr(user, "name", None),
"role": getattr(user, "role", None),
},
"previous": previous,
"note": "current_tenant 状态记录变更(非数据维度)",
}
try:
db.add(OperationLog(
user_id=user.id,
action="switch_tenant_state",
target_type="entity",
target_id=data.entity_id,
detail=audit_detail,
))
# 状态键与审计行同一事务提交:审计写不进 ⇒ 状态变更一并回滚
# (绝不留「改了但没审计」的窗口;失败必须可见,不得静默)
db.commit()
except Exception:
db.rollback()
logger.error(
"AUDIT_WRITE_FAILED tenant/switch from=%s to=%s user_id=%s source=%s "
"→ 审计写入失败,状态变更已回滚(无审计不得留变更)",
from_entity_id, data.entity_id, getattr(user, "id", None), data.source,
exc_info=True,
)
raise HTTPException(status_code=500, detail="审计写入失败,状态变更已回滚")
db.commit()
return {"success": True, "current": state}
+1 -6
View File
@@ -11,7 +11,6 @@ from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, OperationLog
from app.utils.threshold_parser import THRESHOLD_HELP, is_valid_threshold
from app.utils.kpi_value_scope import actual_only_clause
from typing import Any, Optional
import json
@@ -130,11 +129,7 @@ def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)):
if not kpi:
return {"error": "KPI不存在"}
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
# 实测区口径:预估行禁作阈值推荐依据(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.period.asc()).all()
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
if not values:
# 无历史数据,按行业标准推荐
+1 -17
View File
@@ -244,25 +244,9 @@ def has_action(user: User, action: str, db: Session = None) -> bool:
# ─── KPI可见性(按维度/分类过滤) ───
#
# ⚠️ 口径裁定(2026-09-23 dama-entity-isolation-sweep-20260923 §一②):
# `role_permissions.key = 'kpi_visibility'` **属「非账套维度」的全局配置,不拆账套**
# —— 理由:① 值本身是 `{role: [维度规则]}` 的**角色维度**字典,无账套语义;
# ② 角色枚举(ceo/finance/business/it)是平台级字典,`role_permissions` 表
# 亦无 `entity_id` 列(`key` unique),「按角色看哪些维度」是集团统一口径;
# ③ 与 `route_permissions` / `action_permissions` 同族(同为角色级权限面),
# 若只拆本键会与同表另两键口径分裂。
# 判别口径:**同表同族的键按同一维度处理**;本键不消费任何账套状态、也不被账套写侧改写。
# 登记:`docs/global-config-keys-caliber-20260923.md`(键维度字典)。
def _load_kpi_visibility(db: Session = None):
"""从RolePermission表加载kpi_visibility配置(独立缓存)
**非账套维度**(角色级天然全局,口径见上方注释与本档登记):
只读 `role_permissions.key = 'kpi_visibility'`,**有意不做账套后缀**——
这是集团共享口径,不是漏隔离。
"""
"""从RolePermission表加载kpi_visibility配置(独立缓存)"""
import time
if not hasattr(_load_kpi_visibility, "_cache"):
_load_kpi_visibility._cache = {"data": None, "ts": 0}
-1
View File
@@ -1 +0,0 @@
"""口径判据数据包(战略地图数据完备度等,只读常量)。"""
@@ -1,73 +0,0 @@
"""战略地图数据完备度 · 口径定义(唯一真源引用,勿改判据/分母)
口径 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
@@ -1,109 +0,0 @@
"""战略地图 · 收入 / 毛利 / 管理指标口径(单一事实源,勿在别处写死)
口径来源(引用勿改):
/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": "分业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
+2 -55
View File
@@ -1,6 +1,4 @@
"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单"""
import logging
from fastapi import Request, Header, Query, Depends, HTTPException
from typing import Optional
from sqlalchemy.orm import Session
@@ -9,8 +7,6 @@ from app.database import get_db
from app.auth_middleware import get_token_entity_id
from app.models import Entity
logger = logging.getLogger("cma.deps")
def get_entity_id(
request: Request,
@@ -24,8 +20,7 @@ def get_entity_id(
1. Authorization Bearer token → 优先返回 token.entity_id(唯一可信来源)
2. query参数 / X-Entity-Id header → 仅Bot服务通道使用(校验entity状态active)
3. body中的 entity_id(POST场景)→ Bot服务通道兼容
4. **Bot 通道(带 X-BOT-KEY)无任何显式账套 → 400 显式报错**;
无 X-BOT-KEY 的存量公开接口 → 兜底默认 1(酣客)
4. 兜底默认 1(酣客)
安全说明:登录用户必须通过token绑定账套,query/header传入的entity_id
在token存在时被忽略,防止越权传参(旧漏洞:query优先且无授权校验)。
@@ -70,23 +65,7 @@ def get_entity_id(
# Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity)
raise HTTPException(403, f"企业 entity_id={candidate} 不存在或未激活")
# 4. 兜底(ASK-20260924-17,任总 2026-09-24 16:40「放行 17」):
# **Bot 通道缺省账套 = 报错,不再静默兜底 entity 1(酣客)**。
# 背景:Bot 取数接口不带账套参数时兜底 entity 1 ⇒ 跨 Bot/脚本漏传参数即用酣客数字
# 回答博海的问题(实测:不带参数 GET /api/cma/bot/overview → {"entity_id":1, "kpis_total":44})。
# 「问错账套」比看不到更危险,故 Bot 通道(带 X-BOT-KEY)要求**显式传账套**。
# 边界:无 X-BOT-KEY 的存量公开/历史接口保持原兜底行为(不在 ASK-17 范围)。
if str(request.headers.get("X-BOT-KEY") or "").strip():
raise HTTPException(
status_code=400,
detail=(
"缺少账套参数 entity_id:Bot 通道必须显式传账套"
"(query `?entity_id=<n>` 或 header `X-Entity-Id: <n>`;"
"1=陕西酣客、2=陕西博海),不再兜底默认账套(ASK-20260924-17)。"
),
)
return 1 # 无 token / 无参数的存量公开接口:保持原兜底(酣客)
return 1 # 默认酣客(无token、无参数时兜底,兼容存量公开接口)
def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
@@ -102,38 +81,6 @@ def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
return fallback
def require_bot_entity_id(data: Optional[dict], *, source: str = "") -> int:
"""Bot 通道写端点:**账套参数必填**(ASK-20260924-17,任总 2026-09-24 16:40「放行 17」)
`deps.get_entity_id` 只能覆盖「用 Depends 取账套」的 GET 端点;Bot 写端点多从
body 里读 `entity_id`,原写法 `int(data.get("entity_id") or 1)` 会**静默兜底账套 1
(酣客)**——跨 Bot/脚本漏传参数即「用酣客数字回答博海的问题」。
本函数把「缺省即报错」收口到一处(`_require_*` 三处 + 建议通道 + 凭证导入共用):
· 显式传 `entity_id`(int 或数字字符串,含 0/负数由调用方各自的 active 校验接手)→ 返回 int;
· 缺失 / 空串 → **HTTP 400**,文案说明必须显式传账套及可选值;
· 非数字 → HTTP 422(与既有 `entity_id 无效` 文案同族,不静默回落)。
`source` 仅用于日志定位(哪条写入通道漏传)。
"""
raw = (data or {}).get("entity_id")
if raw is None or (isinstance(raw, str) and not raw.strip()):
logger.warning(
"Bot 通道缺少账套参数 entity_id → 400(source=%s)", source or "-")
raise HTTPException(
status_code=400,
detail=(
"缺少账套参数 entity_id:Bot 通道必须显式传账套"
"(body.entity_id / header `X-Entity-Id`;1=陕西酣客、2=陕西博海),"
"不再兜底默认账套(ASK-20260924-17)。"
),
)
try:
return int(raw)
except (TypeError, ValueError):
raise HTTPException(422, f"entity_id 无效: {raw!r}")
# ============================================================
# 跨租户读取防护(cma-cross-entity-endpoints-auth-20260914 补单)
#
-695
View File
@@ -1,695 +0,0 @@
# -*- coding: utf-8 -*-
"""DAMA 数据质量治理规则可配置项(财务七规则)—— 单一事实源 + 三层解析
任务:ASK-20260928-05(任总 2026-09-28 13:38「放行 4 和 5」,本项目 Bot 转呈)
来源清单:wecom-dama《财务七规则可配置化 — 可配置项清单 + 建议默认值 + 权限/留痕建议》
(`wecom-dama/pending-tasks/_reports/project-20260923-quality-rules-config-inventory-dama.md`)
本模块承担三件事(唯一来源,勿在别处复制常量)
----------------------------------------------
1. **开放面**:37 个规则级参数(`SPEC`)+ 2 个全局项(`GLOBAL_SPEC`)。清单 §3 逐条落成代码;
测试断言项数 = 37 / 2,防后续会话漏项或私自扩面。
2. **锁死面**:清单 §4 的 10 类(判定表达式本体 / 级别归属 / 评分函数 / 实体作用域 /
规则集与数量 / 匹配方式自由化 / kpi_completeness 口径源 / `entity_id IS NULL` 处置 /
Bot 通道写配置 / 越界权重)——不在 SPEC 里的键一律 400(写端点),
`LOCKED_PARAM_KEYS` 仅用于把「锁死」讲清楚(可读文案),不是白名单。
3. **三层解析**:账套行(`entity_id`)→ 模板行(`entity_id=0`)→ 代码内置默认;
**禁止跨账套回退**(历史事故:`cash.current_balance` 全局键被全账套共用)。
口径(任总 2026-09-28 放行原文:「口径用清单默认值」)
----------------------------------------------------
* 默认值 = 清单 §7「建议默认值」总表,即「现行值,上线零行为差异」,
**例外一**:`virtual_pollution.suspect_keywords` 去掉 `sync`
(`zxt-sync` 是酣客生产同步来源,现行 134 行 100% 假阳性)——ASK-05 的行为变更;
* 演示/测试账套(3/4/5/6)**不开配置**(`CONFIG_ENTITIES = (1, 2)`,清单 §6.4 + ASK-05 口径②);
读路径仍按三层解析返回生效值(模板/内置默认),只是写端点拒绝。
* **例外二(2026-09-29 尾项,ASK-05 ③-2 拍板「排除」)**:`scope.excluded_entities`
出厂默认 `[3,4,5,6]`,且**已接线** —— 全账套(经营)口径 `entity_id=0` 的评分与明细
不再计入这些账套的行(判据见 `app/api/data_quality.py::_excluded_clause`)。
单账套查询仍可查看(DAMA §6.4「参与检查展示」),只计 `excluded_from_business_caliber=True`。
名单为**参数化**:改本配置值即可调整(零代码回退)。
配置值一律以 JSON 字符串落库(`param_value`),`value_type` 记录类型以便前端/审计呈现。
"""
from __future__ import annotations
import hashlib
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import inspect
from sqlalchemy.orm import Session
logger = logging.getLogger("cma.dq.rule_config")
#: 表名(DDL 走部署链特权迁移步 `backend/scripts/db_migrate.py --apply`,属生产写审批范围)
TABLE_NAME = "quality_rule_config"
#: 全账套模板行的 entity_id(0 = 模板,不参与具体账套取数)
TEMPLATE_ENTITY_ID = 0
#: 开放配置写入的真实账套(口径②:演示/测试账套不开配置)
CONFIG_ENTITIES: Tuple[int, ...] = (1, 2)
#: 内置默认明细展示条数(清单 §3.9 全局项 `detail_limit` 的出厂值)
DEFAULT_DETAIL_LIMIT = 10
#: 写配置必须带变更理由,最短长度(清单 §5.1「反刷分约束」第 1 条)
MIN_REASON_LEN = 10
RULE_KEYS: Tuple[str, ...] = (
"unit_check", "dup_alert", "orphan_check", "virtual_pollution",
"entity_check", "kpi_completeness", "reconciliation",
)
# 全局项(清单 §3.9):platform 级,仅 it 可写
GLOBAL_RULE_KEY = "global"
# 规则级参数统一三件套(每条规则都有;`detail_limit` 为 platform 级)
_CAPS = {
"deduct_cap": dict(type="int", default=15, open_level="entity",
bounds=(0, 50), overflow="clamp",
desc="本规则扣分上限(0 = 只报不计分)"),
"deduct_factor": dict(type="int", default=3, open_level="entity",
bounds=(0, 10), overflow="clamp",
desc="本规则每命中一条的扣分系数"),
}
_WARN_CAPS = {
"deduct_cap": dict(type="int", default=10, open_level="entity",
bounds=(0, 50), overflow="clamp",
desc="本规则扣分上限(0 = 只报不计分)"),
"deduct_factor": dict(type="int", default=1, open_level="entity",
bounds=(0, 10), overflow="clamp",
desc="本规则每命中一条的扣分系数"),
}
_DETAIL_LIMIT = dict(type="int", default=DEFAULT_DETAIL_LIMIT, open_level="platform",
bounds=(1, 100), overflow="clamp",
desc="本规则明细展示条数(平台级,仅 it 可写)")
_ENABLED = dict(type="bool", default=True, open_level="entity",
desc="启用开关(关闭后该规则不参与评分,明细不再展示)")
#: 7 条规则的开放参数(37 项;清单 §3.1–§3.7 逐条落成)
SPEC: Dict[str, Dict[str, dict]] = {
"unit_check": {
"enabled": dict(_ENABLED),
"amount_threshold": dict(type="int", default=10000, open_level="entity",
bounds=(100, 1000000), overflow="reject",
desc="金额阈值(万元):cash_plans.amount > 阈值 即命中(越界拒绝)"),
**_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"dup_alert": {
"enabled": dict(_ENABLED),
"min_count": dict(type="int", default=2, open_level="entity",
bounds=(2, 20), overflow="reject",
desc="同一 plan_id 的 pending 预警条数下限(≥ 该值即重复;越界拒绝)"),
**_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"orphan_check": {
"enabled": dict(_ENABLED),
**_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"virtual_pollution": {
"enabled": dict(_ENABLED),
"suspect_keywords": dict(
type="json", default=["test", "虚拟", "demo"], open_level="entity",
max_items=20, item_max_len=32, forbidden_chars="%_",
desc="嫌疑关键词表(大小写不敏感包含式匹配;不含 sync —— 本单唯一行为变更)"),
"source_whitelist": dict(
type="json",
default=["zxt-sync", "bohai_ar", "bohai_ar_20260630", "bohai_prepay_20260630",
"bohai_ap_20260630", "receivables_migrate"],
open_level="entity", max_items=20, item_max_len=32, forbidden_chars="%_",
desc="来源白名单(豁免优先于关键词:命中即不算污染)"),
"manual_source_value": dict(type="str", default="manual", open_level="entity",
max_len=50,
desc="「待人工确认」的来源值(非污染,按 warning 附加扣分)"),
**_CAPS,
"manual_cap": dict(type="int", default=10, open_level="entity",
bounds=(0, 30), overflow="clamp",
desc="待人工确认(manual)附加上限"),
"manual_factor": dict(type="int", default=1, open_level="entity",
bounds=(0, 5), overflow="clamp",
desc="待人工确认(manual)附加系数"),
"detail_limit": dict(_DETAIL_LIMIT),
},
"entity_check": {
"enabled": dict(_ENABLED),
**_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"kpi_completeness": {
"enabled": dict(_ENABLED),
**_WARN_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
"reconciliation": {
"enabled": dict(_ENABLED),
"diff_threshold_pct": dict(type="float", default=20, open_level="entity",
bounds=(1, 200), overflow="reject",
desc="预算月度合计 vs 年度目标差异阈值(%);越界拒绝"),
"compare_mode": dict(type="enum", default="abs", open_level="entity",
enum=("abs", "upper_only", "lower_only"),
desc="比较方向:abs=双侧 / upper_only=仅超目标 / lower_only=仅低于目标"),
**_WARN_CAPS,
"detail_limit": dict(_DETAIL_LIMIT),
},
}
#: 全局项 key(经营口径排除名单,F6 / ASK-05 ③-2)
EXCLUDED_ENTITIES_KEY = "scope.excluded_entities"
#: `resolve()["scope"]` / GET 响应里排除名单的**字段名**(短名)。
#: 两者不同名是历史形态:配置项 key 带 `scope.` 前缀(落库/写端点用),
#: 响应 `scope` 段是「已按前缀归组」后的字典 ⇒ 取值为短名。
EXCLUDED_ENTITIES_FIELD = "excluded_entities"
#: 全局项(2 项;清单 §3.9)
GLOBAL_SPEC: Dict[str, dict] = {
"detail_limit": dict(_DETAIL_LIMIT),
EXCLUDED_ENTITIES_KEY: dict(
type="json", default=[3, 4, 5, 6], open_level="platform", max_items=20, item_max_len=6,
desc="排除出「经营口径」评分与播报的账套(3=测试企业 / 4=VerifyCorp / 5=VerifyTest / "
"6=演示企业)。已接线:全账套口径(entity_id=0)的评分与明细不再计入这些账套的行;"
"单账套查询仍可查看(展示),但标记「不计入经营口径」。写权限仅 it(平台级);"
"改本配置值即可调整名单(零代码回退)"),
}
# ── 锁死面(清单 §4;仅用于给出可读文案,非白名单)─────────────────────────
LOCKED_ITEMS: Tuple[dict, ...] = (
dict(key="expression", item="判定表达式本体(字段/算子/SQL 片段/正则)",
reason="表达式可配 = 治理规则可被一次配置改成永久绿灯"),
dict(key="level", item="级别归属(error/warning)",
reason="级别直接决定扣分上限,降级是最廉价的绕分路径;确有差异请用已开放的扣分权重表达"),
dict(key="score_fn", item="评分函数(max(0,100−Σ)、7 条固定、各规则独立封顶后累加、无总封顶)",
reason="评分是跨规则/跨期可比的口径基线"),
dict(key="entity_scope", item="实体作用域语义(规则 1 用 cash_plans.entity_id;2/3/6/7 用 kpi_definitions.entity_id;5 用 kpi_values.entity_id)",
reason="作用域 = 安全边界,开放会引入跨账套借用通路"),
dict(key="rule_set", item="规则 key 集合与规则数量(固定 7 条)",
reason="规则集是治理骨架,新增规则必须走代码评审"),
dict(key="match_mode", item="匹配方式自由化(关键词升级为正则/SQL 片段)",
reason="注入面 + 不可验证;匹配方式固定为大小写不敏感包含式"),
dict(key="completeness_caliber", item="kpi_completeness 口径源 kpi_definitions.status='active'(含豁免名单 exempt_kpi_codes)",
reason="KPI 存废开关已是 status,再加豁免名单 = 双开关口径分裂"),
dict(key="null_entity_policy", item="规则5 kpi_values.entity_id IS NULL 行的处置",
reason="NULL 语义未裁定前保持 ignore"),
dict(key="bot_write", item="Bot 通道(X-BOT-KEY)写配置",
reason="Bot 通道无 users 行 → 无操作人可留痕 = 审计断链"),
dict(key="weight_range", item="负系数 / 超大上限 / 非整数权重",
reason="负系数会变成加分(可刷分);一律钳制到开放区间"),
)
#: 键名 → 锁死文案(写端点遇到这些 param_key 时给「锁死」提示而非笼统的未知参数)
LOCKED_PARAM_KEYS = {it["key"]: it for it in LOCKED_ITEMS}
# 常见别名(历史/前端习惯叫法)→ 锁死项 key
_LOCKED_ALIASES = {
"expr": "expression", "predicate": "expression", "sql": "expression",
"rule_level": "level", "severity": "level",
"score": "score_fn", "score_func": "score_fn", "total_deduct_cap": "score_fn",
"exempt_kpi_codes": "completeness_caliber", "status_source": "completeness_caliber",
"entity_id_source": "entity_scope", "scope_field": "entity_scope",
"rules": "rule_set", "rule_keys": "rule_set", "rule_count": "rule_set",
"match_regex": "match_mode", "regex": "match_mode", "pattern": "match_mode",
"null_policy": "null_entity_policy",
}
OPEN_LEVEL_ENTITY = "entity"
OPEN_LEVEL_PLATFORM = "platform"
# ══════════════════════════════════════════════════════════════════
# 参数查找 / 校验
# ══════════════════════════════════════════════════════════════════
def is_global_param(rule_key: str) -> bool:
return rule_key == GLOBAL_RULE_KEY
def spec_of(rule_key: str) -> Dict[str, dict]:
"""返回某 rule_key 的参数规格表(全局项用 rule_key='global')。"""
if is_global_param(rule_key):
return GLOBAL_SPEC
return SPEC.get(rule_key) or {}
def param_spec(rule_key: str, param_key: str) -> Optional[dict]:
return spec_of(rule_key).get(param_key)
def all_param_pairs() -> List[Tuple[str, str]]:
"""(rule_key, param_key) 全量清单——37 规则级 + 2 全局项。"""
pairs = [(r, p) for r in RULE_KEYS for p in SPEC[r]]
pairs += [(GLOBAL_RULE_KEY, p) for p in GLOBAL_SPEC]
return pairs
def count_rule_params() -> int:
"""规则级开放参数项数(断言用,清单 §10.2 = 37)。"""
return sum(len(SPEC[r]) for r in RULE_KEYS)
def open_level_of(rule_key: str, param_key: str) -> str:
spec = param_spec(rule_key, param_key) or {}
return spec.get("open_level") or OPEN_LEVEL_ENTITY
def value_type_of(rule_key: str, param_key: str) -> str:
spec = param_spec(rule_key, param_key) or {}
return spec.get("type") or "json"
def dumps_value(value: Any) -> str:
"""统一序列化(落库格式;bool/int/float/str/数组一律 JSON)。"""
return json.dumps(value, ensure_ascii=False)
def loads_value(raw: Any, value_type: str = "") -> Any:
"""反序列化;非 JSON 的历史/手写行容错(返回 None 表示不可用 → 上层回落)。"""
if raw is None:
return None
if isinstance(raw, (int, float, bool, list, dict)):
return raw
try:
return json.loads(str(raw))
except (ValueError, TypeError):
text = str(raw)
if value_type == "int":
try:
return int(float(text))
except (ValueError, TypeError):
return None
if value_type == "float":
try:
return float(text)
except (ValueError, TypeError):
return None
if value_type == "bool":
if text.strip().lower() in ("1", "true", "yes"):
return True
if text.strip().lower() in ("0", "false", "no"):
return False
return None
if value_type == "str":
return text
return None
def _coerce_type(value: Any, spec: dict) -> Tuple[bool, Any]:
"""类型强转(拒绝型:类型不符 → 不可用)。bool 与 int 严格区分(Python 里 bool 是 int 子类)。"""
t = spec.get("type") or "json"
if t == "bool":
if isinstance(value, bool):
return True, value
if isinstance(value, str) and value.strip().lower() in ("true", "false"):
return True, value.strip().lower() == "true"
if isinstance(value, int) and value in (0, 1):
return True, bool(value)
return False, None
if t == "int":
if isinstance(value, bool):
return False, None
if isinstance(value, int):
return True, value
if isinstance(value, float) and float(value).is_integer():
return True, int(value)
if isinstance(value, str):
s = value.strip()
try:
return True, int(s)
except ValueError:
return False, None
return False, None
if t == "float":
if isinstance(value, bool):
return False, None
if isinstance(value, (int, float)):
return True, float(value)
if isinstance(value, str):
try:
return True, float(value.strip())
except ValueError:
return False, None
return False, None
if t == "str":
if not isinstance(value, str):
return False, None
return True, value
if t == "enum":
if not isinstance(value, str):
return False, None
return True, value.strip()
if t == "json":
if not isinstance(value, list):
return False, None
return True, value
return False, None
def validate_param(rule_key: str, param_key: str, value: Any) -> dict:
"""参数级校验(清单 §3 的「取值边界 / 越界处理」逐条落地)。
返回 ``{"ok": bool, "value": <规范化值>, "error": str|None, "warning": str|None}``;
越界处理两类:``reject``(400,附区间文案)/ ``clamp``(钳制到端点 + 响应告警)。
"""
spec = param_spec(rule_key, param_key)
if spec is None:
locked = LOCKED_PARAM_KEYS.get(
param_key, LOCKED_PARAM_KEYS.get(_LOCKED_ALIASES.get(param_key, ""))
)
if locked:
return dict(ok=False, value=None,
error=f"参数 {param_key} 属锁死项「{locked['item']}」,不允许配置:{locked['reason']}",
warning=None)
return dict(ok=False, value=None,
error=f"未知参数 rule={rule_key} param={param_key};"
f"可选: {', '.join(spec_of(rule_key).keys()) or '(该规则无开放参数)'}",
warning=None)
passed, value = _coerce_type(value, spec)
if not passed:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 类型非法:要求 {spec.get('type')}", warning=None)
caution = None
if spec.get("type") == "enum":
if value not in tuple(spec.get("enum") or ()):
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 取值非法:仅支持 {', '.join(spec['enum'])}",
warning=None)
return dict(ok=True, value=value, error=None, warning=None)
if spec.get("type") == "json":
max_items = int(spec.get("max_items") or 0)
item_max_len = int(spec.get("item_max_len") or 0)
forbidden = spec.get("forbidden_chars") or ""
if max_items and len(value) > max_items:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 元素过多:上限 {max_items} 项(当前 {len(value)})",
warning=None)
cleaned: List[str] = []
for it in value:
if not isinstance(it, str):
if spec.get("item_max_len") == 6 and isinstance(it, int) and not isinstance(it, bool):
it = str(it) # scope.excluded_entities:账套号允许传 int
else:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 仅允许字符串数组元素", warning=None)
s = it.strip()
if not s:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 不允许空串元素", warning=None)
if item_max_len and len(s) > item_max_len:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 元素过长:单元素上限 {item_max_len} 字符({s})",
warning=None)
if any(ch in s for ch in forbidden):
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 元素含非法字符 {forbidden}(不支持通配符/自由模式)",
warning=None)
cleaned.append(s)
# 去重保序(重复项无意义且会让 LIKE 子句膨胀)
seen, uniq = set(), []
for s in cleaned:
if s not in seen:
seen.add(s)
uniq.append(s)
if len(uniq) != len(cleaned):
caution = f"{rule_key}.{param_key} 已去重:{len(cleaned)} → {len(uniq)} 项"
return dict(ok=True, value=uniq, error=None, warning=caution)
if spec.get("type") == "str":
max_len = int(spec.get("max_len") or 0)
s = value
if not s.strip():
return dict(ok=False, value=None, error=f"{rule_key}.{param_key} 不允许空串", warning=None)
if max_len and len(s) > max_len:
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 过长:上限 {max_len} 字符", warning=None)
return dict(ok=True, value=s, error=None, warning=None)
bounds = spec.get("bounds")
if bounds:
lo, hi = bounds
if value < lo or value > hi:
if spec.get("overflow") == "clamp":
clamped = min(max(value, lo), hi)
logger.warning("DQ_RULE_CONFIG_CLAMPED %s.%s %s → %s(区间 [%s, %s])",
rule_key, param_key, value, clamped, lo, hi)
return dict(ok=True, value=clamped, error=None,
warning=f"{rule_key}.{param_key} 越界已钳制到区间端点:{value} → {clamped}"
f"(允许区间 [{lo}, {hi}])")
return dict(ok=False, value=None,
error=f"{rule_key}.{param_key} 越界:允许区间 [{lo}, {hi}],收到 {value}",
warning=None)
return dict(ok=True, value=value, error=None, warning=None)
# ══════════════════════════════════════════════════════════════════
# 经营口径排除名单(F6 / ASK-05 ③-2「排除演示账套」)—— 单一事实源
# ══════════════════════════════════════════════════════════════════
def normalize_entity_ids(raw: Any) -> List[int]:
"""把配置里的账套号规范成 int 列表(去重保序;不可解析的元素忽略 = 视为不排除)。"""
out: List[int] = []
for v in raw or []:
try:
n = int(v)
except (TypeError, ValueError):
continue
if n < 0 or n in out:
continue
out.append(n)
return out
def excluded_entities_from_resolution(resolution: Optional[dict]) -> List[int]:
"""从 `resolve()` 结果里取经营口径排除名单(唯一取值口)。
⚠ 兼容两种字段形态:`scope` 段由 `_assemble()` 按前缀归组 ⇒ 短名
(`EXCLUDED_ENTITIES_FIELD`);直接取配置项全名的情况也接受。
缺失字段名会让本函数静默返回空集(= 排除名单形同未接线),故两种都查。
"""
scope = (resolution or {}).get("scope") or {}
raw = scope.get(EXCLUDED_ENTITIES_FIELD)
if raw is None:
raw = scope.get(EXCLUDED_ENTITIES_KEY)
return normalize_entity_ids(raw)
def excluded_entity_ids(db: Session, entity_id: int = TEMPLATE_ENTITY_ID) -> List[int]:
"""按账套三层解析后取排除名单(供引擎/播报侧单一来源引用)。
读路径绝不 500:解析异常 → 返回内置默认(`resolve()` 内部已做容错,此处再兜一层)。
"""
try:
return excluded_entities_from_resolution(resolve(db, entity_id))
except Exception as exc: # noqa: BLE001
logger.warning("DQ_SCOPE_EXCLUDED_RESOLVE_FAILED entity=%s: %s → 用内置默认",
entity_id, exc)
return normalize_entity_ids(GLOBAL_SPEC[EXCLUDED_ENTITIES_KEY].get("default"))
def is_excluded_from_business_caliber(entity_id: Any, excluded: Any) -> bool:
"""单个账套是否被排除出经营口径(entity_id 为空/0 → False,不排除默认账套)。"""
try:
eid = int(entity_id or 0)
except (TypeError, ValueError):
return False
return bool(eid) and eid in set(normalize_entity_ids(excluded))
# ══════════════════════════════════════════════════════════════════
# 读路径:三层解析
# ══════════════════════════════════════════════════════════════════
def table_ready(db: Session) -> bool:
"""配置表是否就绪(DDL 未落地时**不得**让读路径 500:回落内置默认)。"""
try:
return bool(inspect(db.get_bind()).has_table(TABLE_NAME))
except Exception as exc: # noqa: BLE001
logger.warning("DQ_RULE_CONFIG_TABLE_PROBE_FAILED %s: %s", TABLE_NAME, exc)
return False
def _load_rows(db: Session, entity_id: int) -> Dict[Tuple[str, str], dict]:
"""读模板行 + 本账套行(一次查询);返回 {(rule_key, param_key): {entity_id, value, raw, row}}。"""
from app.models import QualityRuleConfig
eids = [TEMPLATE_ENTITY_ID]
if entity_id != TEMPLATE_ENTITY_ID:
eids.append(entity_id)
rows = db.query(QualityRuleConfig).filter(QualityRuleConfig.entity_id.in_(eids)).all()
out: Dict[Tuple[str, str], dict] = {}
for row in rows:
vt = value_type_of(row.rule_key, row.param_key)
val = loads_value(row.param_value, vt)
if val is None:
logger.warning("DQ_RULE_CONFIG_ROW_UNPARSABLE id=%s %s.%s value=%r → 忽略该行(回落下一层)",
row.id, row.rule_key, row.param_key, row.param_value)
continue
out[(row.rule_key, row.param_key)] = {
"entity_id": row.entity_id, "value": val, "row": row,
}
return out
def resolve(db: Session, entity_id: int) -> dict:
"""三层解析当前生效配置(账套行 → 模板行 → 内置默认;**不跨账套回退**)。
返回结构(前端/审计/引擎共用)::
{"entity_id": 1, "config_version": "…", "table_ready": True,
"rules": {rule_key: {param_key: {"value":…, "source": "entity|template|builtin",
"open_level":…, "desc":…, "updated_by":…, "updated_at":…}}},
"global": {...同 rules 的形状…},
"values": {rule_key: {param_key: <裸值>}}, # 引擎侧取值口
"scope": {"excluded_entities": [...]}}
"""
eid = int(entity_id or 0)
ready = table_ready(db)
rows = _load_rows(db, eid) if ready else {}
return _assemble(eid, ready, rows)
def builtin_resolution(entity_id: int = 0) -> dict:
"""纯内置默认的解析结果(配置表缺失/读异常时的回落形态,字段与 `resolve()` 逐字一致)。"""
return _assemble(int(entity_id or 0), False, {})
def _assemble(eid: int, ready: bool, rows: Dict[Tuple[str, str], dict]) -> dict:
"""把「行 + 内置默认」组装成对外结构(resolve / builtin_resolution 共用)。"""
def _one(rule_key: str, param_key: str, spec: dict) -> dict:
val, source, row = spec.get("default"), "builtin", None
tmpl = rows.get((rule_key, param_key))
if tmpl and tmpl["entity_id"] == TEMPLATE_ENTITY_ID:
val, source, row = tmpl["value"], "template", tmpl["row"]
elif eid != TEMPLATE_ENTITY_ID:
own = rows.get((rule_key, param_key))
if own and own["entity_id"] == eid:
val, source, row = own["value"], "entity", own["row"]
entry = {
"value": val, "source": source, "open_level": spec.get("open_level"),
"desc": spec.get("desc"), "updated_by": None, "updated_at": None,
# 类型/取值区间对前端呈现(**仅提示**;越界/类型一律由写端点判定,
# 前端不得据本字段自造「校验通过」——清单 §5.1 反刷分约束)
"value_type": spec.get("type") or "json",
"bounds": list(spec["bounds"]) if spec.get("bounds") else None,
"enum": list(spec["enum"]) if spec.get("enum") else None,
"overflow": spec.get("overflow"),
}
if row is not None:
entry["updated_by"] = getattr(row, "updated_by", None)
ua = getattr(row, "updated_at", None)
entry["updated_at"] = ua.isoformat(timespec="seconds") if ua else None
entry["row_id"] = row.id
return entry
rules: Dict[str, Dict[str, dict]] = {}
values: Dict[str, Dict[str, Any]] = {}
for rule_key in RULE_KEYS:
rules[rule_key] = {p: _one(rule_key, p, s) for p, s in SPEC[rule_key].items()}
values[rule_key] = {p: e["value"] for p, e in rules[rule_key].items()}
glob: Dict[str, dict] = {p: _one(GLOBAL_RULE_KEY, p, s) for p, s in GLOBAL_SPEC.items()}
values[GLOBAL_RULE_KEY] = {p: e["value"] for p, e in glob.items()}
# 规则级 detail_limit 未配置时,回落全局 detail_limit(清单 §3.9)
global_limit = values[GLOBAL_RULE_KEY].get("detail_limit") or DEFAULT_DETAIL_LIMIT
for rule_key in RULE_KEYS:
entry = rules[rule_key]["detail_limit"]
if entry["source"] == "builtin":
entry["value"] = global_limit
entry["source"] = "builtin(global)"
values[rule_key]["detail_limit"] = entry["value"]
version = hashlib.sha1(
json.dumps(values, sort_keys=True, ensure_ascii=False).encode("utf-8")
).hexdigest()[:12]
return {
"entity_id": eid,
"config_entity_id": eid if eid in CONFIG_ENTITIES else TEMPLATE_ENTITY_ID,
"entity_config_enabled": eid in CONFIG_ENTITIES,
"table_ready": ready,
"config_version": version,
"rules": rules,
"global": glob,
"values": values,
"scope": {"excluded_entities": values[GLOBAL_RULE_KEY].get("scope.excluded_entities") or []},
}
def engine_values(db: Session, entity_id: int) -> dict:
"""引擎取值口(data_quality 规则引擎唯一的配置读取入口)。"""
return resolve(db, entity_id)["values"]
# ══════════════════════════════════════════════════════════════════
# 写路径:单参数 upsert(逐项「一次只改一档」)
# ══════════════════════════════════════════════════════════════════
def upsert_param(db: Session, entity_id: int, rule_key: str, param_key: str, value: Any,
*, username: Optional[str] = None) -> dict:
"""写一行配置(调用方负责权限/校验/审计/事务边界);返回 {id, before, after, source_before}。"""
from app.models import QualityRuleConfig
before = resolve(db, entity_id)
if is_global_param(rule_key):
# 全局项:`resolve()["global"]` 直接以 param_key 为键(无 rule_key 一层)
before_entry = (before.get("global") or {}).get(param_key, {}) or {}
else:
before_entry = ((before.get("rules") or {}).get(rule_key, {}) or {}).get(param_key, {}) or {}
value_type = value_type_of(rule_key, param_key)
payload = dumps_value(value)
row = (db.query(QualityRuleConfig)
.filter(QualityRuleConfig.entity_id == int(entity_id),
QualityRuleConfig.rule_key == rule_key,
QualityRuleConfig.param_key == param_key)
.first())
if row is None:
row = QualityRuleConfig(
entity_id=int(entity_id), rule_key=rule_key, param_key=param_key,
param_value=payload, value_type=value_type,
open_level=open_level_of(rule_key, param_key), is_default=False,
description=(param_spec(rule_key, param_key) or {}).get("desc"),
updated_by=username,
)
db.add(row)
else:
row.param_value = payload
row.value_type = value_type
row.is_default = False
row.updated_by = username
db.flush() # 拿到 id,且撞唯一键时当场暴露
return {
"id": row.id,
"before": before_entry.get("value"),
"before_source": before_entry.get("source"),
"after": value,
}
def history(db: Session, entity_id: int, limit: int = 20) -> List[dict]:
"""本账套的配置变更历史(读 `operation_logs`;该表无 entity_id 列 → 按 detail JSON 过滤)。"""
from app.models import OperationLog
rows = (db.query(OperationLog)
.filter(OperationLog.action == ACTION_UPDATE)
.order_by(OperationLog.id.desc())
.limit(max(limit * 5, 50))
.all())
out: List[dict] = []
for r in rows:
detail = r.detail if isinstance(r.detail, dict) else {}
if detail.get("entity_id") != int(entity_id):
continue
out.append({
"id": r.id, "at": r.created_at.isoformat(timespec="seconds") if r.created_at else None,
"user_id": r.user_id,
"rule_key": detail.get("rule_key"), "param_key": detail.get("param_key"),
"before": detail.get("before"), "after": detail.get("after"),
"reason": detail.get("reason"), "target_id": r.target_id,
})
if len(out) >= limit:
break
return out
#: 审计动作名(清单 §5.2 逐字)
ACTION_UPDATE = "update_dq_rule_config"
TARGET_TYPE = "dq_rule_config"
+1 -8
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_completeness, map_summary, map_dimensions
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury
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
@@ -41,11 +41,6 @@ app.include_router(kpis.router)
app.include_router(kpi_governance.router)
app.include_router(templates.router)
app.include_router(maps.router)
# 顺序铁律(2026-10-20 D1 修复):同前缀族的路由必须把**字面路径段**路由器 include 在
# 任何 `/{map_id}/<literal>` 参数化路由(objectives.router 的 GET /{map_id}/objectives)之前,
# 否则 /api/cma/maps/completeness/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(dashboard.router)
app.include_router(data.router)
app.include_router(alerts.router)
@@ -72,7 +67,6 @@ app.include_router(cashflow_structure.compat_router)
app.include_router(cost.router)
app.include_router(predict.router)
app.include_router(growth_quality.router)
app.include_router(map_dimensions.router)
app.include_router(products.router)
app.include_router(reports.router)
app.include_router(security.router)
@@ -106,7 +100,6 @@ 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(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
+8 -112
View File
@@ -1,6 +1,5 @@
"""管理会计OS 数据模型"""
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, text, UniqueConstraint, Index, Numeric, Date
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint, Numeric, Date
from app.database import Base
from app.models.budget_plan import BudgetPlan
@@ -60,29 +59,19 @@ class StrategicMap(Base):
version = Column(String(20), default="v1.0", comment="版本号")
status = Column(String(20), default="draft", comment="draft/published")
dimensions = Column(JSON, nullable=True, comment="四维度和目标列表")
canvas_data = Column(MutableDict.as_mutable(JSON), nullable=True, comment="画布连线数据")
canvas_data = Column(JSON, nullable=True, comment="画布连线数据")
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class KPIDefinition(Base):
"""KPI字典
账套维度:`kpi_code` 唯一性为 **`(entity_id, kpi_code)` 复合**(生产库 `uk_entity_kpi`,
2026-09-23 账套维度收口批与模型对齐——原模型写 `unique=True`(全表唯一)与现网 DDL 不一致,
会让 e1/e2 无法各建同码 KPI,也让 SQLite 测试库建不出真实形态)。
"""
"""KPI字典"""
__tablename__ = "kpi_definitions"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
# 2026-09-23 账套维度收口批:生产库 kpi_definitions 有这两列、模型缺失 ⇒
# `instantiate_template` 的 `KPIDefinition(template_id=…, is_system=…)` 必抛
# TypeError(HTTP 500),「从模板实例化」实际不可用。按现网 DDL 补齐(模型↔库对齐)。
template_id = Column(Integer, nullable=True, comment="来源模板ID (kpi_templates.id)")
is_system = Column(Integer, default=0, comment="是否系统预置模板实例化(1=是)")
kpi_code = Column(String(50), nullable=False, comment="KPI编码(唯一性=(entity_id, kpi_code))")
kpi_code = Column(String(50), unique=True, nullable=False, comment="KPI编码")
kpi_name = Column(String(200), nullable=False, comment="KPI名称")
dimension = Column(String(50), comment="所属维度: finance/customer/process/learning")
objective = Column(String(200), comment="关联战略目标")
@@ -94,13 +83,7 @@ class KPIDefinition(Base):
data_owner = Column(String(100), default="待指定", comment="数据责任人")
frequency = Column(String(20), default="monthly", comment="daily/weekly/monthly/quarterly/yearly")
unit = Column(String(50), default="%", comment="单位")
# 2026-10-03 KPI字典分层治理(任务 CMA-KPI-20261003)阶段1:direction 结构列。
# 枚举 higher_better(越大越好)/lower_better(越小越好),NULL=未定。
# 既有行由 migrate_kpi_direction_20261003.py 按 threshold_green 比较符**结构推定**回填
# (非口径裁定);口径/阈值语义仍属 finance/DAMA 在途批次。
direction = Column(String(16), nullable=True,
comment="指标方向: higher_better越大越好/lower_better越小越好/NULL未定")
target_value = Column(Float, nullable=True, comment="目标值(NULL=未设目标;0=真零目标;兼容旧字段)")
target_value = Column(Float, nullable=True, comment="目标值(兼容旧字段)")
target_monthly = Column(Float, nullable=True, comment="月度目标值")
target_quarterly = Column(Float, nullable=True, comment="季度目标值")
target_yearly = Column(Float, nullable=True, comment="年度目标值")
@@ -121,9 +104,6 @@ class KPIDefinition(Base):
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("entity_id", "kpi_code", name="uk_entity_kpi"),
)
class KPIValue(Base):
@@ -188,36 +168,6 @@ class OperationLog(Base):
created_at = Column(DateTime, server_default=func.now())
class QualityRuleConfig(Base):
"""DAMA 数据质量治理规则可配置项(财务七规则)— ASK-20260928-05
逐 param 一行;`entity_id=0` = **全账套模板行**(不参与具体账套取数)。
读取三层:账套行 → 模板行(0) → 代码内置默认(禁止跨账套回退);
规格/默认值/边界/校验的**单一来源** = `app/dq_rule_config.py`(本模型只存行)。
DDL 走部署链特权迁移步 `backend/scripts/db_migrate.py --apply`(生产写审批范围)。
"""
__tablename__ = "quality_rule_config"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, default=0, index=True,
comment="账套;0=全账套默认模板")
rule_key = Column(String(50), nullable=False, comment="七规则 key 或 global(全局项)")
param_key = Column(String(50), nullable=False, comment="参数名(见 dq_rule_config.SPEC)")
param_value = Column(String(500), nullable=False, comment="值(JSON 序列化字符串)")
value_type = Column(String(20), nullable=False, default="int",
comment="int/float/bool/str/json/enum")
open_level = Column(String(10), nullable=False, default="entity",
comment="entity=账套级(ceo/it) / platform=平台级(仅it)")
is_default = Column(Boolean, nullable=False, default=False,
comment="1=出厂默认行(用于「恢复默认」与审计对照)")
description = Column(String(500), nullable=True, comment="参数说明(面向使用者)")
updated_by = Column(String(50), nullable=True, comment="最后修改人 users.username")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
UniqueConstraint("entity_id", "rule_key", "param_key", name="uq_rule_param"),
)
class NotificationChannel(Base):
"""通知渠道配置"""
__tablename__ = "notification_channels"
@@ -498,10 +448,6 @@ class KPITemplate(Base):
formula = Column(Text)
formula_desc = Column(String(500))
unit = Column(String(50))
# 2026-10-03 KPI字典分层治理(任务 CMA-KPI-20261003)阶段1:字典层同加 direction;
# 回填规则 = 继承同 kpi_code 实例的已定方向(见 docs/kpi-dictionary-layering-design.md §2.2)。
direction = Column(String(16), nullable=True,
comment="指标方向: higher_better越大越好/lower_better越小越好/NULL未定")
target_value = Column(Float)
description = Column(Text)
is_system = Column(Integer, default=0)
@@ -612,16 +558,9 @@ class ReportHistory(Base):
class AnalysisResult(Base):
"""财务Bot分析结论 — 带置信度评分
账套维度(2026-09-23 账套化,任务 0-P1-project-20260923-entity-dimension-convergence §三④):
`entity_id IS NULL` = 历史未归属行(存量 2 行)——**不参与任何账套过滤**,
读取端点单独登记(`unattributed`),删除端点一律 404(不猜账套、不引入 403 探测面)。
"""
"""财务Bot分析结论 — 带置信度评分"""
__tablename__ = "analysis_results"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=True, index=True,
comment="企业ID(NULL=历史未归属行,不参与账套过滤)")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
conclusion = Column(String(1000), nullable=False, comment="分析结论")
confidence = Column(Integer, nullable=False, comment="置信度 0-100")
@@ -669,20 +608,10 @@ class ScenarioSuggestion(Base):
# ============================================================
class Subject(Base):
"""会计科目 — 新30号准则分类
账套维度(2026-09-23 `subjects` 分层,任务 0-P1-project-20260923-entity-dimension-convergence):
`entity_id IS NULL` = 全局标准准则科目(两账套共享)
`entity_id = N` = 账套 N 私有明细(如酣客智享通 `remark='zxt-sync'` 明细)
唯一性 = `(entity_id, subject_code)`:不同账套允许同码;同一账套内同码仍拒绝(应用层 UPSERT 判重)。
⚠️ MySQL 唯一索引把 NULL 视为互不相同 ⇒ 全局行的同码重复只能靠写入口判重兜住,
新增「全局科目」写入路径时必须显式查重(不可依赖索引)。
"""
"""会计科目 — 新30号准则分类"""
__tablename__ = "subjects"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=True, index=True,
comment="企业ID(NULL=全局标准准则科目 / N=账套私有明细)")
subject_code = Column(String(20), nullable=False, comment="科目编码(唯一性=(entity_id, subject_code))")
subject_code = Column(String(20), nullable=False, unique=True, comment="科目编码")
subject_name = Column(String(200), nullable=False, comment="科目名称")
parent_code = Column(String(20), nullable=True, comment="上级科目编码")
level = Column(Integer, default=1, comment="科目级别 1-4")
@@ -695,9 +624,6 @@ class Subject(Base):
remark = Column(String(500), 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", "subject_code", name="uk_entity_subject"),
)
class VoucherDetail(Base):
@@ -875,36 +801,6 @@ class ObjectiveKPI(Base):
__table_args__ = (UniqueConstraint("objective_id", "kpi_id", name="uk_obj_kpi"),)
class KPITarget(Base):
"""KPI 期间目标(考核型·方案 A)—— ③ 读点收敛 CMA-KPI-20261003
唯一真源(考核型):`(entity_id, kpi_id, period, target_type)` 唯一;
承诺型目标只写 `krs`(裁定 §二 边界声明:同一 KPI 不得两处均有 target)。
**列定义与 ② DDL 出件包 `up.sql` 逐字一致**(11 列 + uk_entity_kpi_period_type + 2 索引);
`entity_id` 无 DDL DEFAULT(写入口必须显式传值,防静默默认把实体隔离降级为约定);
`updated_at` 无 `ON UPDATE CURRENT_TIMESTAMP`(由 ORM/写入口维护,与现网 kpi_definitions 同风格)。
"""
__tablename__ = "kpi_targets"
id = Column(Integer, primary_key=True, autoincrement=True)
entity_id = Column(Integer, nullable=False, comment="账套(企业ID);写入口必须显式传值(实体隔离)")
kpi_id = Column(Integer, nullable=False, comment="指标实例 → kpi_definitions.id")
period = Column(String(20), nullable=False, comment="期间: 2026-08(月)/2026Q3(季)/2026(年)")
target_type = Column(String(16), nullable=False, comment="目标粒度: monthly/quarterly/yearly")
target_value = Column(Numeric(15, 2), nullable=True, comment="NULL=该期未设;0=真零")
source = Column(String(20), nullable=False, server_default=text("'manual'"),
comment="manual/okr/budget/import")
note = Column(String(255), nullable=True, comment="备注")
created_by = Column(String(64), nullable=True, comment="写入者(用户/Bot标识)")
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now())
__table_args__ = (
UniqueConstraint("entity_id", "kpi_id", "period", "target_type", name="uk_entity_kpi_period_type"),
Index("ix_kpi_targets_entity_period", "entity_id", "period"),
Index("ix_kpi_targets_kpi", "kpi_id"),
)
class KR(Base):
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
__tablename__ = "krs"
+2 -17
View File
@@ -20,7 +20,6 @@ from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue, UserEntity
from app.utils.alert_entity_scope import month_period_clause
logger = logging.getLogger("cma.knowledge")
@@ -39,23 +38,9 @@ KPI变化:列出核心指标变化(名称、方向、幅度)
注意:如果事件列表为空或没有有价值的信息,直接输出"本期无重要变化"。"""
class DeepSeekKeyMissing(RuntimeError):
"""DEEPSEEK_API_KEY 未配置 —— 禁止用硬编码默认 Key 兜底(2026-10-18 代码卫生)。"""
def _require_deepseek_key() -> str:
"""强制从环境变量读取 DeepSeek Key;缺失即抛明确错误,绝不静默使用假 Key。"""
api_key = (os.getenv("DEEPSEEK_API_KEY") or "").strip()
if not api_key:
raise DeepSeekKeyMissing(
"DEEPSEEK_API_KEY 未配置(knowledge_service 禁止硬编码默认 key)"
)
return api_key
async def _call_deepseek(prompt: str, timeout: int = 30) -> str:
"""调用DeepSeek API生成摘要"""
api_key = _require_deepseek_key() # 缺 env 直接抛,不拿假 Key 发请求
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e2...c2e8")
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
@@ -180,7 +165,7 @@ async def _kpi_snapshot(db: Session, entity_id: int = 1) -> list[dict]:
for k in kpis:
latest = (
db.query(KPIValue)
.filter(KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period))
.filter(KPIValue.kpi_id == k.id)
.order_by(KPIValue.period.desc())
.first()
)
+1 -2
View File
@@ -652,8 +652,7 @@ def unmapped_subjects(db, entity_id: int, period: str, basis: str, items: Dict[s
else:
q = q.filter(VoucherDetail.period == period)
rows = apply_voucher_guard(db, q, VoucherDetail).group_by(VoucherDetail.subject_code).all()
cats = {c: cat for c, cat in db.query(Subject.subject_code, Subject.category)
.filter((Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id)).all()}
cats = {c: cat for c, cat in db.query(Subject.subject_code, Subject.category).all()}
except Exception: # noqa: BLE001
return []
-7
View File
@@ -85,13 +85,6 @@ def _subject_patterns(db: Session, kpi_id: int) -> list:
优先用已推导的取数映射 kpi_value_sources.filter_rule.subject_codes —— 那是把
kpi_subject_map(新准则 6xxx) 机械桥接到 voucher_details 实际科目(5xxx/1xxx/2xxx) 的结果;
无映射时退回 kpi_subject_map 原始科目(新老科目体系不一致时明细多为 0)。
⚠️ kpi_subject_map 护栏(2026-09-23 账套维度收口批 ②,零结构/逻辑改动):
该表是唯一一张「语义上必然账套相关、却无账套列」的表 —— **账套维度经 `kpi_id` 继承**
(调用方拿到的 kpi_id 来自已按 `entity_id` 过滤的 KPI,本函数两处查询**全部按 `kpi_id`**,
从不按 `subject_code` 反查)。**禁止新增按 `subject_code` 反查的读口,**
**禁止按 `subject_code` 批量写的入口**:同一 `subject_code` 可同时属多个账套(标准准则科目全局共享),
按科目反查或批量写会立刻跨账套串数。
"""
from app.models import KPIValueSource, KPISubjectMap
-194
View File
@@ -1,194 +0,0 @@
# -*- coding: utf-8 -*-
"""预警「参与范围」口径 —— 单一事实源(财务裁定 R1/R2/R3,2026-09-19)
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`(任总 2026-09-18「按建议执行」)
- **R1 账套范围**:`entities.status = 'demo'` 的账套(演示账套)**不参与预警生成/遍历**
—— demo 仅用于演示展示,不产生业务预警。
- **R2 期间窗口**:预警遍历期间 = **当月往前 6 个自然月(含当月)**;更早期间**不再新生成**。
与裁定 2b(各 KPI 最近可用期间)叠加:`max(可用期间下限, 当月-6月)`。
- **R3 规整月期间**:仅 `YYYY-MM` 参与;`H1/H2/FY`、纯年份等伪期间**一律不参与**。
约定:所有预警生成入口(alert_generator / deviation_engine / kpi_bep_deriver /
alert_rules 生成端点 / cash_forecast_engine)**只调本模块的入口函数**,
禁止在调用点各写一份 `status == 'demo'` / 期间正则 / 窗口算术(避免多处漂移)。
"""
import logging
import re
from datetime import datetime
from typing import Optional
from app.models import Entity, KPIDefinition
logger = logging.getLogger("cma.alert_scope")
#: R1:不参与预警的账套状态
ALERT_EXCLUDED_ENTITY_STATUSES = ("demo",)
#: R2:期间窗口长度(含当月,6 = 当月 + 往前 5 个月)
ALERT_WINDOW_MONTHS = 6
#: R3:规整月期间(严格校验 01–12,`2026-13` 之类非法月同样不参与)
MONTH_PERIOD_RE = r"^[0-9]{4}-(?:0[1-9]|1[0-2])$"
_MONTH_PERIOD_RE = re.compile(MONTH_PERIOD_RE)
def excluded_entity_ids(db_session) -> set:
"""R1:返回不参与预警遍历的账套 id 集合(当前 status='demo' → entity 3/4/5/6)。
fail-open:读不到 entities 表(测试库/权限异常)时返回空集并记 WARNING——
口径缺失不应把预警主流程整条打断(生成链路另有 fail-safe)。
"""
try:
rows = db_session.query(Entity.id).filter(
Entity.status.in_(ALERT_EXCLUDED_ENTITY_STATUSES)
).all()
return {int(r[0]) for r in rows}
except Exception as e: # pragma: no cover - 防御分支
logger.warning("读取 entities.status=%s 失败,本次按「不排除任何账套」处理: %s",
list(ALERT_EXCLUDED_ENTITY_STATUSES), e)
return set()
def is_excluded_entity(db_session, entity_id: Optional[int]) -> bool:
"""R1:单个账套是否被排除(None → 按默认账套 1 处理,与 KPIAlert.entity_id 缺省一致)"""
return int(entity_id or 1) in excluded_entity_ids(db_session)
def kpi_excluded(db_session, kpi: KPIDefinition, excluded: Optional[set] = None) -> bool:
"""R1:KPI 归属账套是否被排除(可传入预取的 excluded 集合,避免循环内重复查询)"""
eids = excluded if excluded is not None else excluded_entity_ids(db_session)
return int(getattr(kpi, "entity_id", None) or 1) in eids
def window_lower_period(now: Optional[datetime] = None) -> str:
"""R2:期间窗口下限 = 当月往前 6 个自然月(含当月)→ 当月-5 月。
2026-09 → `2026-04`(窗口 = 2026-04 ~ 2026-09)。
"""
now = now or datetime.now()
months = now.month - (ALERT_WINDOW_MONTHS - 1)
year = now.year
while months <= 0:
months += 12
year -= 1
return "%04d-%02d" % (year, months)
def is_month_period(period) -> bool:
"""R3:是否规整月期间(YYYY-MM)"""
return bool(_MONTH_PERIOD_RE.fullmatch(str(period or "")))
def period_in_window(period, now: Optional[datetime] = None) -> bool:
"""R3 + R2:期间是否可参与预警生成(规整月 + 落在窗口内)"""
if not is_month_period(period):
return False
return str(period) >= window_lower_period(now)
def month_period_clause(column):
"""R3 的 SQL 等价式:`column` 必须落在规整月 `YYYY-MM`(供**取数层**查询使用)。
为什么取数层必须是 SQL 侧(而不是取完再在 Python 里挑):
取数层的动作是「`ORDER BY period DESC` 取最新一行」(`.first()` / `.limit(N)`)。
伪期间 `2026-H2` 的**字符串序大于任何同年月份**('H' > '0'),所以只要它留在排序里,
「最新实际期」就被它顶掉——取到的是**预测值/半年值**,而且 Python 侧再过滤也救不回来
(最新那一行已经不是月期间,第二行才是),`.first()` 的结果直接变成错值或空。
故本口径必须在 **SQL 侧**参与排序前的筛选。
跨库可移植:只用 `LENGTH` / `SUBSTR` / `BETWEEN` / `IN`(MySQL、SQLite、PostgreSQL 同名可用),
**不用 `REGEXP`**——SQLite 测试库未注册 REGEXP,SQL 侧正则会让单测与生产走两套语义。
与 Python 侧 `is_month_period` 同源等价:长度 7 + 第 5 位 `-` + 月份两位数字且 01–12
(`2026-13` 之类非法月同样排除)。禁止在调用点另写一份长度/正则判断。
"""
from sqlalchemy import and_, func, or_ # 局部导入:本模块顶层不依赖 sqlalchemy 表达式 API
tens = func.substr(column, 6, 1)
ones = func.substr(column, 7, 1)
return and_(
func.length(column) == 7,
func.substr(column, 5, 1) == "-",
or_(
and_(tens == "0", ones.between("1", "9")),
and_(tens == "1", ones.in_(["0", "1", "2"])),
),
)
# ── 裁定 2b(2026-09-17):「各 KPI 自己的最近可用期间」,单一实现 ──
#
# 2026-09-25(ASK-20260924-15):原实现只在 `scripts/alert_generator.resolve_latest_period`,
# 而 `app/utils/deviation_engine.run_deviation_check` 仍硬编码 `datetime.now()`(当月)⇒
# 期间口径在**两个生成入口之间漂移**,差异/趋势预警对「当月无 kpi_values」的账套永久 0 新增。
# 现收口到本模块(预警口径唯一事实源):两处入口共同调用,禁止再各写一份。
def resolve_latest_available_period(db_session, kpi_id=None) -> Optional[str]:
"""裁定 2b:返回(该 KPI 的)「最近可用期间」——`kpi_values` 内最新的规整月 `YYYY-MM`。
给定 kpi_id → 返回**该 KPI 自己**的最近可用期间:不硬编码当月,也不被别人
(他账套/演示账套)更新的新期间带偏——实证:全局 MAX(period)=2026-09 但那是
演示账套 e6 的 8 条数据,酣客(e1) 的最新可用期间是 2026-08。
实现说明:伪期间过滤在 **Python 侧**做(`SELECT DISTINCT period` 只有十几行),
不依赖 MySQL 的 `REGEXP`——SQLite 测试库不注册 REGEXP,用 SQL 侧正则会让单测
与生产走两套语义。判据用 R3 单一正则(`is_month_period`,严格 01–12)。
"""
from app.models import KPIValue # 局部导入:避免 models ↔ utils 的循环导入风险
q = db_session.query(KPIValue.period).filter(KPIValue.actual_value.isnot(None))
if kpi_id is not None:
q = q.filter(KPIValue.kpi_id == kpi_id)
for (p,) in q.distinct().order_by(KPIValue.period.desc()).all():
if is_month_period(p):
return p
return None
# ── R5(裁定 2026-09-19,R3 的一致延伸):可归期原则 + 生成侧守卫 ──
def is_attributable_period(period) -> bool:
"""R5:期间是否「可归期」= 规整月 `YYYY-MM`。
`period IS NULL` 与 `2026-H1/H2/FY` 同属「不可归期」→ 不参与预警
(不可复核、不可归责、不可驱动行动)。
"""
return is_month_period(period)
def guard_alert_period(period, *, source: str = "", detail: str = "") -> bool:
"""R5 生成侧守卫:期间非规整月(含 None)→ 该 `kpi_alerts` 行**不入库**(跳过 + 记日志)。
返回 True = 放行(可入库);False = 跳过(**调用点必须不落库**)。
裁定原文:「任何生成路径产出的 kpi_alerts 行,period 非 YYYY-MM 一律不入库(跳过并记日志),防复发。」
"""
if is_attributable_period(period):
return True
logger.info(
"R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r source=%s detail=%s",
period, source, detail)
return False
def entity_alert_guard(db_session, entity_id, *, source: str = "", detail: str = "") -> bool:
"""R1 通用生成侧守卫:demo 账套在任何写通道上都不得产生业务预警。
「R1 与入口无关」——包含按显式 `entity_id` 写入的 Bot 通道(bot_bridge / bot_bridge_v2 等)。
返回 True = 放行;False = 跳过(原因 = R1 demo 账套排除,沿用 `excluded_entity_ids`)。
"""
if is_excluded_entity(db_session, entity_id):
logger.info(
"R1 账套守卫:跳过不入库(demo 演示账套不产生业务预警)entity_id=%s source=%s detail=%s",
entity_id, source, detail)
return False
return True
def scope_summary(db_session, now: Optional[datetime] = None) -> dict:
"""口径证据(写日志/报告用):被排除账套 + 窗口下限"""
return {
"excluded_entities": sorted(excluded_entity_ids(db_session)),
"window_lower_period": window_lower_period(now),
"window_months": ALERT_WINDOW_MONTHS,
}
-142
View File
@@ -1,142 +0,0 @@
# -*- coding: utf-8 -*-
"""BEP(本量利·保本点)实体口径注册表 —— **单一事实源**。
被两侧共用,避免口径漂移:
· 派生落库写入面:`backend/scripts/kpi_bep_deriver.py`
· 卡面读取面 :`backend/app/api/dashboard.py::/bep-card`
口径(2026-10-03 财务裁定 + 任总 18:00「统一」,任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack):
| 项 | entity 1 陕西酣客 | entity 2 博海 |
|---|---|---|
| 变动成本(分子) | **(Σ5401借 + Σ5403借 + Σ571103借 − Σ530103贷)**〔⑤口径统一〕 | **Σ6401借 + Σ6403借**〔ASK-20261014-04 §4-2 批后口径〕 |
| 收入基数(分母) | Σ5001 贷方(主营业务收入) | Σ6001 贷方(主营业务收入,**不含** 6051)〔§4-4 维持现行〕 |
| 变动成本率 | 上式 ÷ Σ5001贷(**完整口径**,非渠补率) | 上式 ÷ Σ6001贷(主营业务成本率 + 税金及附加) |
| 实测渠补率(F_BEP_REBATE) | Σ571103借 ÷ Σ5001贷(**保留**为渠补单一事实源,**不再**充当变动成本率) | 无(None) |
| 固定成本 | `5601%` + `5602%` + `5603%` 借−贷净额〔⑤扩容〕 | `6601` + `6602` + `6603` **仅借方**〔ASK-20261014-04 §4-1 批后口径〕 |
| 财务收益(单列备查,**不进**固定成本) | 无(`560303` 利息收入 2026 零发生) | `6603` **贷方**(现金折扣收益等 64.40 万)〔§4-1〕 |
| 折旧剔除(现金口径) | `560213`(管理费用-折旧叶子) | `1602` 累计折旧**当期贷方发生额**(见下) |
| 本期利润结转科目 | `3103` 本年利润 | `4103` 本年利润 |
| 结账信号次选(落盘) | `/root/WB_WS/_data/zxt-hanke/{YYYY.MM}/check.md` | 无(只用 DB 信号) |
⑤ 口径统一依据(任总 2026-10-03 18:00 DM「统一」):完整口径唯一可勾稽至营业利润
`808.80 − 672.65 − 224.16 = −88.01 ≈ −88.00` 既定营业利润。
2026-08 锚点(YTD 月均):变动成本率 **83.17%**、边际贡献率 **16.83%**、固定月均 **28.02 万**、
保本 **166.45 万/月**、覆盖率 **60.74%**、缺口 **39.26%**、现金保本 **≈121.8 万/月**。
博海折旧口径说明:博海科目表只到 4 位一级科目(无费用类折旧叶子,**不存在** 酣客式 `560213`),
唯一含「折旧」的是资产类 `1602 累计折旧`;其**当期贷方发生额** = 当期计提折旧额(对方科目
计入 `6602 管理费用`),故取它作现金口径剔除项。实测 2026-01..07 每月 1,468.83 元,
2026-08 起无计提行 ⇒ 2026-08 剔除额为 0(已在回报件列明)。
⚠️ 博海账套的「期间损益结转腿」(`summary='结转期间损溢'`,凭证如 `记-705/记-706`)
**未**标 `carry_forward=1`(全账套 17343 行 carry_forward 恒 0)⇒ 若不显式剔除,
损益类科目「借−贷」净额在已结账月份恒为 0,会把指标静默清零
(口径见 `/srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md`)。
故博海口径 `exclude_closing=True`。
⑥ ASK-20261014-04 批后口径(任总 2026-10-14 11:20「放行4」= 放行 4-1 / 4-2 / 4-5;4-3 / 4-4 维持现行):
· §4-1(放行)博海固定成本**弃「借−贷净额」**:`6601` / `6602` / `6603` **仅借方**进固定成本
(2026-01..09 = 86.95 + 39.09 + 7.95 = **133.98 万**;净额口径为 69.58 万);
`6603` **贷方**(现金折扣收益等 64.40 万)**作收益项单列**,`finance_income_terms`,**不再冲减固定成本**
—— 净额口径的病灶是 2026-03 单月固定成本被折扣收益打成 **−9.02 万**(负值),批后为正值。
· §4-2(放行)博海变动成本追加 `6403 营业税金及附加`(2026-01..09 = 0.37 万)⇒ 530.17 万。
· §4-3 / §4-4(维持现行)`571112 商品折扣` 不冲减酣客收入;边际贡献收入基数维持 e1=`5001` /
e2=`6001`(**不含** e2 `6051`)。
· 落点唯一:本表 `ENTITY_CALIBERS`(含本头注);报表层 / 卡面 / 派生器**一律**经
`fixed_cost_spec()` 解析,不得另写科目集。酣客(e1)本次**未改**,仍走 `fixed_filters` 净额。
"""
CLOSING_SUMMARY_LIKE = "%结转%"
# 酣客结账信号次选来源(智享通落盘 check.md;与 app/api/data.py 的 ZXT_LANDING_DIR 同源)
ZXT_LANDING_DIR_HANKE = "/root/WB_WS/_data/zxt-hanke"
# 变动成本「带号科目项」记法: terms = [(sign, subject_code, field), ...]
# sign='+' ⇒ 取该科目 field 的发生额相加;sign='-' ⇒ 取该科目 field 的发生额相减
# field ∈ {'debit' → debit_amount, 'credit' → credit_amount, 'net' → 借−贷}
ENTITY_CALIBERS = {
1: dict(
name="陕西酣客",
margin_label="变动成本率",
margin_desc="(Σ5401借+Σ5403借+Σ571103借−Σ530103贷) ÷ Σ5001贷",
# ⑤ 口径统一(2026-10-03 任总「统一」):变动成本 = 销货成本 + 税金及附加 + 渠补 − 厂补
varcost_terms=[("+", "5401", "debit"), ("+", "5403", "debit"),
("+", "571103", "debit"), ("-", "530103", "credit")],
# F_BEP_REBATE 实测渠补率专用(单一事实源保留;不再充当变动成本率)
rebate_terms=[("+", "571103", "debit")],
revenue_field="credit_amount", revenue_filter=("eq", "5001"),
fixed_filters=[("like", "5601"), ("like", "5602"), ("like", "5603")],
# §4-1 未改酣客:仍走「借−贷净额」前缀口径 ⇒ fixed_terms 显式 None(不隐式继承)
fixed_terms=None,
finance_income_terms=None, # 酣客 560303 利息收入 2026 零发生 ⇒ 无单列财务收益项
fixed_label="固定成本(5601+5602+5603 借−贷净额)",
dep_field="net", dep_filter=("eq", "560213"),
closing_subject="3103",
landing_dir=ZXT_LANDING_DIR_HANKE,
exclude_closing=False,
policy_pct=84.61, # 政策常量:仅对照展示,不参与计算(仅酣客有)
),
2: dict(
name="博海",
margin_label="变动成本率(主营成本率+税金及附加)",
margin_desc="(Σ6401借+Σ6403借) ÷ Σ6001贷",
# §4-2(放行):追加 6403 营业税金及附加(与 e1 含 5403 对齐)
varcost_terms=[("+", "6401", "debit"), ("+", "6403", "debit")],
rebate_terms=None, # 博海无渠补科目
revenue_field="credit_amount", revenue_filter=("eq", "6001"),
# §4-1(放行):弃「借−贷净额」⇒ 带号科目项 + field=debit(仅借方);
# 6603 贷方(现金折扣收益)改走 finance_income_terms 单列,不再冲减固定成本
fixed_terms=[("+", "6601", "debit"), ("+", "6602", "debit"), ("+", "6603", "debit")],
fixed_filters=None, # 显式置空:禁止两套口径并存(fixed_cost_spec 以 terms 优先)
finance_income_terms=[("+", "6603", "credit")],
fixed_label="固定成本(6601+6602+6603 借方)",
dep_field="credit_amount", dep_filter=("eq", "1602"),
closing_subject="4103",
landing_dir=None,
exclude_closing=True,
policy_pct=None,
),
}
def caliber_or_none(entity_id: int):
"""取实体口径;未注册返回 None(调用方自行决定放行/拒答)。"""
return ENTITY_CALIBERS.get(entity_id)
def caliber_for(entity_id: int) -> dict:
"""取实体口径;未注册抛 KeyError(写入面用,fail-closed)。"""
cal = ENTITY_CALIBERS.get(entity_id)
if cal is None:
raise KeyError(f"entity={entity_id} 未注册 BEP 口径(见 app/utils/bep_caliber.py)")
return cal
# ── 固定成本 / 财务收益:**唯一解析口** ──────────────────────────────────────
# 报表层(app/utils/profit_lines.py)、卡面(app/api/dashboard.py)、派生器
# (scripts/kpi_bep_deriver.py)三处一律经本函数取科目构成,禁各自写一套(防「卡面新口径 /
# KPI 旧口径」)。§4-1 后:e2 走 terms(`6601/6602/6603` 仅借方),e1 走 filters(借−贷净额)。
def fixed_cost_spec(cal: dict):
"""固定成本取数规格 → (mode, spec)。
mode='terms' ⇒ spec = [(sign, code, field), ...](带号科目项;§4-1 后博海形态)
mode='filters' ⇒ spec = [(kind, code), ...](前缀/精确 OR + 借−贷净额;酣客形态)
"""
terms = cal.get("fixed_terms")
if terms:
return "terms", [tuple(t) for t in terms]
return "filters", [tuple(f) for f in (cal.get("fixed_filters") or [])]
def finance_income_terms(cal: dict) -> list:
"""财务收益(单列备查,**不进**固定成本)带号科目项;无则空列表(§4-1 只涉博海)。"""
return [tuple(t) for t in (cal.get("finance_income_terms") or [])]
def caliber_digest() -> str:
"""口径指纹(证据用):ENTITY_CALIBERS 的 md5 —— 口径一改指纹即变,便于「改前/改后」取证。"""
import hashlib
import json
blob = json.dumps(ENTITY_CALIBERS, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.md5(blob.encode("utf-8")).hexdigest()
-100
View File
@@ -1,100 +0,0 @@
# -*- coding: utf-8 -*-
"""预算状态机口径 —— 单一事实源(2026-10-03,施工单 CMA-20261003 阶段 1)
设计件:`docs/budget-module-governance-design.md` §1
口径(逐字固化):
| status | 语义 | 入执行面 |
|---|---|---|
| `draft` | 草稿(编制中,可编辑) | 否 |
| `submitted` | 待审批 | 否 |
| `approved` | **已批准(= 执行基准 / 生效版)** | 是 |
| `active` | 已下达(下达动作后)/ 历史遗留「在用」 | 是 |
| `rejected` | 已驳回 | 否 |
| `archived` | 已归档 | 否 |
- **执行/对比/差异的唯一基准 = `approved` ∪ `active`**:审批通过即获得执行资格,
修的是「执行查询只读 `active` ⇒ 已批准预算从执行视图消失」这一处口径错配。
- 同键 `(kpi_id, period)` 多行并存时选行优先级:`active`(已下达)> `approved`,
同级取 `updated_at` 最新 —— 保证取行确定性(改前 `bp_map[key] = value` 后写覆盖先写,
结果依赖 DB 返回顺序)。
- 既有 216 行 `active` 本单**不改标**(口径彻底归一属待 finance/任总确认项,见设计件 §1.3);
`active` 在 UI 侧仍映射为 `draft`(历史兼容,`approval_status_of`)。
**规则**:任何「预算执行基准」查询必须走 `effective_plan_clause()` /
`pick_effective()`,**禁止再手写 `BudgetPlan.status == "active"`**。
非执行面用法(新建去重、自动分解、滚动延展、方法应用、现金流联动)
不在本口径范围内,随「彻底归一」单一起收敛(设计件 §1.5)。
"""
from sqlalchemy import and_
from app.models import BudgetPlan
#: 执行基准状态(生效版)
EFFECTIVE_STATUSES = ("approved", "active")
#: UI 展示为「草稿」的状态(`active` = 历史遗留「在用」态)
DRAFT_LIKE_STATUSES = ("draft", "active")
#: 已下达态(选行优先级最高)
_STATUS_ACTIVE = "active"
def effective_plan_clause(entity_id: int):
"""执行基准条件(含账套隔离)—— 单一取数口。
用法:`db.query(BudgetPlan).filter(BudgetPlan.period == p, effective_plan_clause(eid))`
"""
if entity_id is None:
raise ValueError("effective_plan_clause 必须传 entity_id(账套隔离,禁止漏传落默认账套)")
return and_(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
)
def effective_status_priority(status: str) -> int:
"""选行优先级:`active`(已下达) 最高,其余生效态次之。"""
return 0 if status == _STATUS_ACTIVE else 1
def _sort_key(row):
ts = getattr(row, "updated_at", None)
try:
stamp = ts.timestamp() if ts is not None else 0.0
except (AttributeError, ValueError, OSError):
stamp = 0.0
return (effective_status_priority(getattr(row, "status", "") or ""), -stamp)
def pick_effective(rows):
"""同一 `(kpi_id, period)` 的多行 → 取生效行;空输入返回 None。"""
if not rows:
return None
return sorted(rows, key=_sort_key)[0]
def effective_value_map(rows):
"""行列表 → `{(kpi_id, period): budget_value}`(按生效优先级去重)。"""
grouped = {}
for r in rows:
grouped.setdefault((r.kpi_id, r.period), []).append(r)
out = {}
for key, rows_of_key in grouped.items():
row = pick_effective(rows_of_key)
if row is not None:
out[key] = row.budget_value
return out
def is_effective_status(status: str) -> bool:
return (status or "") in EFFECTIVE_STATUSES
def approval_status_of(raw: str) -> str:
"""DB status → 前端 `approval_status`(历史兼容:`active` 曾表示在用/草稿态)。"""
if not raw:
return "draft"
if raw in DRAFT_LIKE_STATUSES:
return "draft"
return raw
+29 -45
View File
@@ -1,23 +1,16 @@
# -*- coding: utf-8 -*-
"""比率类 KPI 单位口径归一(取数层) —— 口径裁定 20260917(2a) + 20260924(v2.1 取代两项)
"""比率类 KPI 单位口径归一(取数层) —— 财务口径裁定 裁定2a
依据(唯一有效出处):`wecom-finance/reports/CMA_口径裁定_20260924_比率类单位口径_取代2a两指标.md`
—— 口径标识 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24`(任总 2026-09-24 16:38 放行 ASK-20260924-11)
· **取代范围仅两项**:`F_CURRENT_RATIO` / `F_QUICK_RATIO` → 统一「倍」(无量纲),**判定层不再 ×100**
(同量纲直判)、`kpi_definitions` 的 unit/阈值/target_value 同步改倍口径;历史 `kpi_values` **不重算**;
· **其余比率类指标仍按 20260917 裁定2a(百分比 %)执行**:毛利率、净利率、资产负债率、渠补率、返利率、
收入增长率等;本表内 `F_GROSS_MARGIN` 那行**保持 ×100 不动**;
· **不得类推扩用**:再发现第三个「比率类单位/量纲矛盾」指标须**另行裁定**,不得以 v2.1 件为据类推改倍数;
· 被取代件 `wecom-finance/reports/CMA_口径裁定_20260917.md`:其 2a 对其余比率类仍有效、2b 仍有效,
**原件保留、一字节不改**。
· 2a 的过渡纪律(单位未核对前的「疑似错配」预警不推送)继续生效。
依据:`wecom-finance/reports/CMA_口径裁定_20260917.md` 裁定2a
· 比率类指标**统一为「百分比(%)」口径**:取数 ×100,与现有阈值(流动比率红 <150、速动 <80)同量纲;
· 逐个比率类 KPI**核对定义与单位**,出具对照表;
· **单位未核对前的「疑似错配」预警不推送**(降级待核,防假红灯 586/587)。
问题实证(2026-09-17 只读侦查 / 2026-09-24 v2.1 复核,entity 1 酣客):
`kpi_definitions` 原声明单位 = %,但**取数**以比率(倍)存储:流动比率 1.29 / 速动比率 0.54 / 毛利率 0.38,
而阈值按百分数书写(>=200 / >=100 / <15)→ 跨 2 个数量级比较,判定不可信(09-09 的 586/587 即此形态)。
**v2.1 处置**:流动/速动两项按**倍**口径落地(unit='倍';绿>=2.0 / 黄>=1.0 / 红<1.0 与
绿>=1.0 / 黄>=0.5 / 红<0.5)⇒ 2026-08 的 1.29 → **黄**、0.54 → **黄**;
本模块对这两项**不再 ×100**(旧写法 `1.29×100=129` 去比 `>=2.0` 会**恒绿**,比原「恒红」更坏)。
问题实证(2026-09-17 只读侦查,entity 1 酣客):
`kpi_definitions` 声明单位 = %,但**取数**以比率(倍)存储:流动比率 1.29 / 速动比率 0.54 / 毛利率 0.38,
而阈值按百分数书写(<150 / <80 / <15)→ 跨 2 个数量级比较,判定不可信(09-09 的 586/587 即此形态)。
**注意**:按裁定2a 归一后 流动比率 129% < 150 → **红是合法的**(「流动性偏紧」),586/587 不是假红灯、
不作废;真正的错配处置是「未核对项不推送」。
其余百分数类(返利率 48.44、收入增长率 -82.2、资产负债率 39.9、费用率 30.0、渠补率 75.0 …)
**取数已是百分数**,不得再 ×100(反过来会成为量级越界)。
@@ -25,9 +18,8 @@
· 归一因子表 `RATIO_CALIBER_TABLE`(kpi_code → 口径/因子/证据/是否已核对);
· 判定侧只调 `resolve_caliber(actual, kpi)`,**不自行做字符串/量级判断**;
· 表外且命中「疑似错配形态」的 KPI → status='dimension_suspect' → 调用方**不落预警、不推送**;
· `norm_factor` 只影响**判定/推送口径**:2a 项不改 `kpi_values` 存储值、不改 `kpi_definitions` 阈值
(统一取数);v2.1 取代的两项反之——**DB 的 unit/阈值已同步改倍口径**,故此处必须同量纲直判(factor=1):
**两侧只改一侧 ⇒ 恒红(未改本表)或恒绿(未改 DB)**。
· `norm_factor` 只影响**判定/推送口径**,不改 `kpi_values` 存储值、不改 `kpi_definitions` 阈值
(裁定2a 明确:统一取数而非改阈值)。
"""
from __future__ import annotations
@@ -39,22 +31,21 @@ from app.utils.threshold_parser import parse_clauses
FACTOR_PERCENT = 100
FACTOR_IDENTITY = 1
#: 比率类 KPI 单位对照表(裁定2a「逐个核对定义与单位」的落地产物;2026-09-24 v2.1 取代其中两项)
#: norm_factor=1 → 取数已是百分数/同量纲,直接判定(含 v2.1 两项「倍」口径:取数与阈值同量纲)
#: norm_factor=100 → 取数为比率(倍),判定前 ×100 归一到百分数口径(裁定2a;**本表仅毛利率仍用**)
#: 比率类 KPI 单位对照表(裁定2a「逐个核对定义与单位」的落地产物)
#: norm_factor=100 → 取数为比率(倍),判定前 ×100 归一到百分数口径
#: norm_factor=1 → 取数已是百分数/同量纲,直接判定
RATIO_CALIBER_TABLE = {
# ---- 倍口径(v2.1 @2026-09-24 取代 2a 之两项;取数与 DB 阈值同量纲 ⇒ 不再归一)----
# ---- 需归一(取数=倍,阈值=百分数写法)----
"F_CURRENT_RATIO": {
"name": "流动比率", "declared_unit": "倍", "caliber": "倍(无量纲)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "口径 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24`(取代 2a 之 F_CURRENT_RATIO):unit=倍 / 绿>=2.0 / 黄>=1.0 / 红<1.0;2026-08 实测 1.29(倍)→ 同量纲直判 = 黄;不再 ×100(旧 129% 判红已作废)",
"name": "流动比率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_PERCENT, "verified": True,
"evidence": "2026-08 实测 1.29(比率量级)→ ×100 = 129%;阈值 <150 为百分数写法(裁定2a 明示示例:129<150 → 红,与「流动性偏紧」一致)",
},
"F_QUICK_RATIO": {
"name": "速动比率", "declared_unit": "倍", "caliber": "倍(无量纲)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "口径 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24`(取代 2a 之 F_QUICK_RATIO):unit=倍 / 绿>=1.0 / 黄>=0.5 / 红<0.5;2026-08 实测 0.54(倍)→ 同量纲直判 = 黄;经验值 1:1 = 1.0 倍",
"name": "速动比率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_PERCENT, "verified": True,
"evidence": "2026-08 实测 0.54 → ×100 = 54%;阈值 <80 为百分数写法(经验值 1:1=100%)",
},
# ---- 需归一(取数=倍,阈值=百分数写法;裁定2a 对其余比率类仍有效,不得顺手改)----
"F_GROSS_MARGIN": {
"name": "毛利率", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_PERCENT, "verified": True,
@@ -86,17 +77,10 @@ RATIO_CALIBER_TABLE = {
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 -0.95 已是百分数(小体量亏损月,-0.95% 量级合理)",
},
"F_ROA": {
"F_ROI": {
"name": "总资产报酬率(ROA)", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 实测 -0.57 已是百分数(kpi_code 2026-10-03 由 F_ROI 正名为 F_ROA,"
"kpi_id/历史值不变;真 ROI 另立 F_ROI)",
},
"F_ROI": {
"name": "投入资本回报率(ROI)", "declared_unit": "%", "caliber": "百分比(%)",
"norm_factor": FACTOR_IDENTITY, "verified": True,
"evidence": "2026-08 派生实测 -6.74(YTD 营业利润÷投入资本,已是百分数;口径见 "
"wecom-finance 2026-10-02 裁定)",
"evidence": "2026-08 实测 -0.57 已是百分数",
},
"F_DEBT_RATIO": {
"name": "资产负债率", "declared_unit": "%", "caliber": "百分比(%)",
@@ -251,7 +235,7 @@ class _K:
def _self_test() -> int:
"""零库自证(裁定2a + 2026-09-24 v2.1):倍口径直判、2a 归一、疑似错配降级、真实小值不误伤"""
"""零库自证(裁定2a 证据):归一、同量纲直判、疑似错配降级、真实小值不误伤"""
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
def level(actual, kpi):
@@ -267,10 +251,10 @@ def _self_test() -> int:
cases = (
# (说明, 实测, KPI, 期望)
("倍口径(v2.1) 流动比率 1.29 → 直判 1.29 ∈ [1.0,2.0) 黄", 1.29,
_K("F_CURRENT_RATIO", "倍", ">=2.0", ">=1.0", "<1.0"), "yellow"),
("倍口径(v2.1) 速动比率 0.54 → 直判 0.54 ∈ [0.5,1.0) 黄", 0.54,
_K("F_QUICK_RATIO", "倍", ">=1.0", ">=0.5", "<0.5"), "yellow"),
("流动比率 1.29 → 归一 129% vs 红<150", 1.29,
_K("F_CURRENT_RATIO", "%", ">=200", ">=150", "<150"), "red"),
("速动比率 0.54 → 归一 54% vs 红<80", 0.54,
_K("F_QUICK_RATIO", "%", ">=100", ">=80", "<80"), "red"),
("毛利率 0.38 → 归一 38% vs 绿>=20", 0.38,
_K("F_GROSS_MARGIN", "%", ">=20", ">=18", "<15"), "green"),
("返利率 48.44 已百分数 → 红>12(不 ×100)", 48.44,
+21 -102
View File
@@ -4,7 +4,6 @@ import os
from datetime import datetime, timedelta
from typing import Optional, TYPE_CHECKING
from sqlalchemy.orm import Session
from app.utils.kpi_value_scope import actual_only_clause
import math
import random
@@ -13,17 +12,9 @@ if TYPE_CHECKING:
logger = logging.getLogger("cma.cash_forecast")
# 默认现金阈值(万元)—— 单一事实源迁至 app/utils/cash_thresholds.py(G1,2026-10-17)
# 此处保留同名常量以兼容既有 import(app/api/cash.py 等);数值未变:黄 20 / 红 10。
from app.utils.cash_thresholds import ( # noqa: E402
DEFAULT_CRITICAL as _CFG_DEFAULT_CRITICAL,
DEFAULT_WARNING as _CFG_DEFAULT_WARNING,
get_cash_gap_thresholds,
describe_source as describe_cash_threshold_source,
)
DEFAULT_CASH_WARNING = _CFG_DEFAULT_WARNING # 黄灯 — 低于20万
DEFAULT_CASH_CRITICAL = _CFG_DEFAULT_CRITICAL # 红灯 — 低于10万
# 默认现金阈值(万元)
DEFAULT_CASH_WARNING = 20.0 # 黄灯 — 低于20万
DEFAULT_CASH_CRITICAL = 10.0 # 红灯 — 低于10万
# 历史KPI编码映射(含候选编码,兼容F_*标准编码)
KPI_CODES = {
@@ -55,17 +46,13 @@ KPI_CODE_SUFFIXES = {
# ── 余额快照批次(F4,2026-09-13)────────────────────────────
# 这些 source 是「期末余额导入」批次:plan_date 是**导入基准日**(如 2026-06-30 期末),
# 不是真实到期日 → 逐户生成"到期未收款"会永远 overdue,属伪逾期(DAMA 审计 fin-alert-flood-e2)。
# hanke_ar(酣客 2026-07 期末客户级应收,ASK-20261001-33)语义与 bohai_ar_20260630 完全相同,
# 2026-10-01 按设计件 §6.2「快照批次登记」加入名单:默认 skip ⇒ 不逐户生成伪逾期告警。
BALANCE_SNAPSHOT_SOURCES = {
"bohai_ar_20260630",
"bohai_ap_20260630",
"bohai_prepay_20260630",
"receivables_migrate",
"hanke_ar",
}
# 处理开关:per_customer(逐户,旧行为) / skip(不生成逐户,默认) / aggregate(按批次聚合1条)
_SNAPSHOT_MODE_DEFAULT = "skip"
_SNAPSHOT_MODES = ("per_customer", "skip", "aggregate")
@@ -215,7 +202,6 @@ def get_entity_kpi_history(entity_id: int, kpi_code: str, db: Session, limit_mon
return []
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
actual_only_clause(), # 实测区口径:预估行禁进历史/趋势序列(kpi_value_scope)
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(limit_months).all()
return values
@@ -235,48 +221,19 @@ def calc_trend(values: list) -> float:
return slope / max(abs(avg_y), 1.0) * 100 # 趋势百分比
# ── B1 现金基线拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
# 背景:`SystemConfig.config_key` 为 unique 单行键 → `cash.current_balance` 全库仅一行,
# 所有账套共用;任一账套导入网银流水(`cash.py::_sync_cash_balance_from_ledger` 按 entity
# 算完余额后丢账套写回)即覆盖其他账套的预测基线。实测事故值 -9.3 被全账套共用。
#
# 治本(方案 A,零 DDL):键名带账套后缀 → `cash.current_balance.<entity_id>`。
# · 读:只读本账套键;本账套未设值 → None(由 KPI 实际值兜底,**不跨账套借用**)。
# 不做「回退旧全局键」——那正是本次污染通路(其余账套会读到别家的基线)。
# · 写:只写本账套键(调用方必须传会话 entity)。
# · 旧值搬运:一次性落到「当前生产账套」名下(脚本
# `scripts/migrate_cash_balance_key_entity_20260922.py`,--check/--apply/--down)。
# 回退路径:--down 删新键(旧全局键原样保留,回退老码即可继续读)。
CASH_BALANCE_KEY_PREFIX = "cash.current_balance"
def cash_balance_key(entity_id: int) -> str:
"""账套维度的现金基线键名:cash.current_balance.<entity_id>"""
return f"{CASH_BALANCE_KEY_PREFIX}.{int(entity_id)}"
def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
"""获取当前现金余额(万元)— 优先级:KPI实际值 > 本账套SystemConfig键 > None
账套隔离(B1,2026-09-22):只读 `cash.current_balance.<entity_id>`。
本账套未设基线 → 返回 None(由 KPI 实际值兜底 / 预测引擎默认值),
**不回退旧全局键 `cash.current_balance`**——回退即恢复跨账套借用。
"""
"""获取当前现金余额(万元)— 优先级:KPI实际值 > SystemConfig > None"""
from app.models import SystemConfig, KPIValue
# C3(2026-09-19 dama-20260919):补传实体前缀后缀候选(与 F2 的应收/应付路径一致)
# 修复前漏传 → 博海/演示企业一条候选都不命中 → 回退 SystemConfig.cash.current_balance(全库唯一键 -9.3)
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"],
KPI_CODE_SUFFIXES["cash_balance"])
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"])
if kpi:
latest_cash = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
actual_only_clause(), # 实测区口径:预估行禁作现金余额依据(kpi_value_scope)
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
if latest_cash and latest_cash.actual_value is not None:
return float(latest_cash.actual_value)
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == cash_balance_key(entity_id)
SystemConfig.config_key == "cash.current_balance"
).first()
if cfg and cfg.config_value:
try:
@@ -286,27 +243,19 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
return None
def set_current_cash_balance(db: Session, value: float, entity_id: int) -> float:
"""设置当前现金余额(写入本账套键 `cash.current_balance.<entity_id>`,供预测引擎使用)
entity_id 为**必传**(2026-09-22 B1):签名中不设默认值,强制调用方带上会话账套,
防「漏传 → 落默认账套」重演本次污染。全部调用点见 cash.py(POST /balance、
网银流水导入联动)。
"""
def set_current_cash_balance(db: Session, value: float) -> float:
"""设置当前现金余额(写入SystemConfig,供预测引擎使用)"""
from app.models import SystemConfig
if entity_id is None:
raise ValueError("set_current_cash_balance 必须传 entity_id(账套隔离,2026-09-22 B1)")
key = cash_balance_key(entity_id)
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == key
SystemConfig.config_key == "cash.current_balance"
).first()
if cfg:
cfg.config_value = str(value)
else:
cfg = SystemConfig(
config_key=key,
config_key="cash.current_balance",
config_value=str(value),
description=f"当前现金余额(万元),资金预测基线 — 账套 {int(entity_id)}",
description="当前现金余额(万元),资金预测基线",
)
db.add(cfg)
db.commit()
@@ -318,27 +267,17 @@ def forecast_cash_flow(
db: Session,
days: int = 30,
current_cash: Optional[float] = None,
warning_line: Optional[float] = None,
critical_line: Optional[float] = None,
) -> dict:
"""
预测未来30天现金流
算法:
1. 获取历史经营现金流、应收、应付趋势
2. 推算每日现金流入/流出
3. 生成每日预测值+置信区间
阈值(G1,2026-10-17):warning_line / critical_line 传 None 时取本账套配置
(alert_rules: cash_gap_threshold),未配置则默认 20/10 万;显式传入优先(便于复算对拍)。
"""
from app.models import KPIDefinition, KPIValue
if warning_line is None or critical_line is None:
cfg_warning, cfg_critical = get_cash_gap_thresholds(db, entity_id)
warning_line = cfg_warning if warning_line is None else warning_line
critical_line = cfg_critical if critical_line is None else critical_line
# 获取当前现金余额
if current_cash is None:
base_cash = get_current_cash_balance(db, entity_id)
@@ -397,9 +336,9 @@ def forecast_cash_flow(
upper = cash + std * 0.5
# 预警状态
if cash < critical_line:
if cash < DEFAULT_CASH_CRITICAL:
status = "red"
elif cash < warning_line:
elif cash < DEFAULT_CASH_WARNING:
status = "yellow"
else:
status = "green"
@@ -418,20 +357,20 @@ def forecast_cash_flow(
min_date = next(f["date"] for f in forecast if f["predicted_cash"] == min_cash)
suggestions = []
if min_cash < critical_line:
if min_cash < DEFAULT_CASH_CRITICAL:
suggestions.append({
"type": "critical",
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于警戒线{critical_line}万",
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于警戒线{DEFAULT_CASH_CRITICAL}万",
"actions": [
"立即催收大额应收账款",
"暂停非必要支出",
"准备短期融资安排",
]
})
elif min_cash < warning_line:
elif min_cash < DEFAULT_CASH_WARNING:
suggestions.append({
"type": "warning",
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于关注线{warning_line}万",
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于关注线{DEFAULT_CASH_WARNING}万",
"actions": [
"加快应收账款回款",
"控制采购付款节奏",
@@ -492,7 +431,6 @@ def calculate_accuracy(entity_id: int, db: Session) -> list:
actuals = db.query(KPIValue).filter(
KPIValue.kpi_id == cash_kpi.id,
actual_only_clause(), # 实测区口径:预估行禁进实际值序列(预测输入)(kpi_value_scope)
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(12).all()
@@ -628,8 +566,8 @@ def forecast_cash_flow_with_plans(
db: Session,
days: int = 30,
current_cash: Optional[float] = None,
warning_line: Optional[float] = None,
critical_line: Optional[float] = None,
warning_line: float = DEFAULT_CASH_WARNING,
critical_line: float = DEFAULT_CASH_CRITICAL,
include_plans: bool = True,
) -> dict:
"""
@@ -638,21 +576,11 @@ def forecast_cash_flow_with_plans(
2. 叠加 cash_plans 的应收(收) / 应付(付)
3. 识别资金缺口日期(余额 < 警戒线)
4. 生成缺口前3天预警点
阈值来源(G1,2026-10-17):None → 本账套配置(alert_rules: cash_gap_threshold)
→ 未配置时默认 20/10 万;显式传入值优先(复算对拍用)。响应内 `threshold_source`
明写本次取值来源,便于页面标注与回执取证。
"""
from collections import defaultdict
from app.models import CashPlan
if warning_line is None or critical_line is None:
cfg_warning, cfg_critical = get_cash_gap_thresholds(db, entity_id)
warning_line = cfg_warning if warning_line is None else warning_line
critical_line = cfg_critical if critical_line is None else critical_line
base = forecast_cash_flow(entity_id, db, days, current_cash,
warning_line=warning_line, critical_line=critical_line)
base = forecast_cash_flow(entity_id, db, days, current_cash)
base_cash = base["base_cash"]
# 预算校准:本月经营现金流预算优先作为基线(万元/月)
@@ -837,7 +765,6 @@ def forecast_cash_flow_with_plans(
"days": days,
"warning_line": warning_line,
"critical_line": critical_line,
"threshold_source": describe_cash_threshold_source(db, entity_id),
"budget_monthly_ocf": budget_ocf,
"forecast": forecast,
"gap_dates": gap_dates,
@@ -876,17 +803,9 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
(此前博海走无排序兜底 → 命中 uk_entity_kpi 索引必中 BH_ADM_EXPENSE(437 管理费用))
F3 去重命中时刷新文本/等级/逾期天数/归属(此前直接 continue → 金额与天数永久冻结在首版)
F4 余额快照批次行按开关处理(默认 skip,不再逐户生成伪"到期未收款")
R1(裁定 2026-09-19):demo 演示账套不产生业务预警 → 直接返回 skipped。
"""
import json as _json
from app.models import KPIAlert, CashPlan, KPIDefinition
from app.utils.alert_entity_scope import is_excluded_entity
if is_excluded_entity(db, entity_id):
logger.info("资金预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"entity_id": entity_id, "skipped": True, "reason": "R1_demo_entity_excluded",
"new_alerts": [], "refreshed": []}
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
new_alerts = []
-133
View File
@@ -1,133 +0,0 @@
# -*- coding: utf-8 -*-
"""资金缺口阈值 —— per-entity 配置(复用 alert_rules 表,不新建表)
任务:fullstack-20261017-warning-capability-build-p0 / G1(P0-A)
方案依据:`_reports/yanxue-20261017-warning-capability-build-p0.md` §G1 + 决策点 D4(建议 B)
口径(默认值不动):
· 黄灯(warning) = 20.0 万元 —— 与改前 `cash_forecast_engine.DEFAULT_CASH_WARNING` 一致
· 红灯(critical)= 10.0 万元 —— 与改前 `cash_forecast_engine.DEFAULT_CASH_CRITICAL` 一致
· 配置存在时按配置;未配置 / 配置非法 / 读取异常 → **一律回落默认值**(fail-safe,不让预警失效)
存储形态(零 DDL,复用现有 `alert_rules`):
entity_id = 账套
rule_type = "cash_gap_threshold"
kpi_id = 该账套现金余额 KPI(CASH_001 / BH_CASH_BALANCE 等),满足表 NOT NULL 约束
enabled = 1
params = {"warning": 20.0, "critical": 10.0} # 单位:万元
为什么单独一个 rule_type 而不复用 static:
static 规则走 `_check_static` 与 KPIValue 比价,参数语义是 `>=90` 之类表达式;
资金阈值是**引擎级参数**(决定缺口日判定与红黄分级),不是单条 KPI 的阈值。
独立 rule_type 让 `run_all_alert_checks` 的 if/elif 链自然跳过它(alert_level 保持 None),
不会生成预警行,也不影响现有 static/trend 规则。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger("cma.cash_thresholds")
#: alert_rules.rule_type 取值(配置行,不进预警生成链)
CASH_GAP_RULE_TYPE = "cash_gap_threshold"
#: 默认阈值(万元)—— 与改前硬编码常量逐字一致
DEFAULT_WARNING = 20.0
DEFAULT_CRITICAL = 10.0
PARAM_KEYS = ("warning", "critical")
def _as_float(value: Any) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
return None
def validate_params(params: Any) -> Optional[str]:
"""校验阈值参数;合法返回 None,非法返回中文错误信息(供 API 层直接回 400)。"""
if not isinstance(params, dict):
return "params 必须是对象,形如 {\"warning\": 20.0, \"critical\": 10.0}(单位:万元)"
warning = _as_float(params.get("warning"))
critical = _as_float(params.get("critical"))
if warning is None or critical is None:
return "params 必须同时包含数值型 warning 与 critical(单位:万元)"
if warning <= 0 or critical <= 0:
return "warning / critical 必须大于 0(单位:万元)"
if critical > warning:
return "critical(红灯线)不得高于 warning(黄灯线)"
return None
def parse_params(params: Any) -> Optional[Tuple[float, float]]:
"""解析参数 → (warning, critical);非法返回 None(调用方回落默认值)。"""
if validate_params(params) is not None:
return None
return float(params["warning"]), float(params["critical"])
def get_cash_gap_thresholds(db, entity_id: int) -> Tuple[float, float]:
"""读取账套资金缺口阈值;无配置/非法/异常 → 默认 (20.0, 10.0)。
· 只认 enabled=1、最新 id 的一行(同一账套多行时以最新为准,不累加)
· 任何异常都不上抛:预警不能因为配置表读失败而消失(fail-safe)
"""
try:
from app.api.alert_rules import AlertRule # 延迟导入,避免 engine ↔ api 循环
rule = (
db.query(AlertRule)
.filter(
AlertRule.entity_id == int(entity_id),
AlertRule.rule_type == CASH_GAP_RULE_TYPE,
AlertRule.enabled == 1,
)
.order_by(AlertRule.id.desc())
.first()
)
if rule is None:
return DEFAULT_WARNING, DEFAULT_CRITICAL
params = rule.params
if isinstance(params, str):
import json
try:
params = json.loads(params)
except ValueError:
logger.warning("资金阈值配置 params 非合法 JSON(rule_id=%s)→ 回落默认值", rule.id)
return DEFAULT_WARNING, DEFAULT_CRITICAL
parsed = parse_params(params)
if parsed is None:
logger.warning("资金阈值配置参数非法(rule_id=%s params=%r)→ 回落默认值", rule.id, params)
return DEFAULT_WARNING, DEFAULT_CRITICAL
return parsed
except Exception as exc: # noqa: BLE001 — 配置读取失败不得使预警整体失效
logger.warning("读取资金阈值配置失败(entity_id=%s):%s → 回落默认值", entity_id, exc)
return DEFAULT_WARNING, DEFAULT_CRITICAL
def describe_source(db, entity_id: int) -> Dict[str, Any]:
"""取证用:说明本次阈值来自「配置」还是「默认」,便于回执与页面标注。"""
try:
from app.api.alert_rules import AlertRule
rule = (
db.query(AlertRule)
.filter(
AlertRule.entity_id == int(entity_id),
AlertRule.rule_type == CASH_GAP_RULE_TYPE,
AlertRule.enabled == 1,
)
.order_by(AlertRule.id.desc())
.first()
)
if rule is not None and parse_params(rule.params) is not None:
return {"source": "alert_rules", "rule_id": rule.id,
"params": rule.params, "entity_id": int(entity_id)}
except Exception as exc: # noqa: BLE001
logger.warning("读取资金阈值来源失败:%s", exc)
return {"source": "default", "rule_id": None,
"params": {"warning": DEFAULT_WARNING, "critical": DEFAULT_CRITICAL},
"entity_id": int(entity_id) if entity_id is not None else None}
+4 -17
View File
@@ -158,21 +158,11 @@ def calc_driver_rate(activity_id: int) -> dict:
def allocate_cost(activity_id: int, period: str, product_code: str,
product_name: str, driver_consumed: float,
entity_id: Optional[int] = None) -> dict:
"""按动因分配成本到产品
账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):
- entity_id 非 None 时,作业中心查询按本账套过滤(跨账套 activity_id 查不到 → 返回错误字典,
端点侧已先行 404);
- 新建 AbcAllocation 显式写入 entity_id(不传则维持原语义:不写归属,落模型默认)。
"""
product_name: str, driver_consumed: float) -> dict:
"""按动因分配成本到产品"""
db = get_session_local()()
try:
q_act = db.query(AbcActivity).filter(AbcActivity.id == activity_id)
if entity_id is not None:
q_act = q_act.filter(AbcActivity.entity_id == entity_id)
act = q_act.first()
act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first()
if not act or act.driver_rate == 0:
# 自动计算分配率
if act and act.driver_volume > 0:
@@ -181,7 +171,7 @@ def allocate_cost(activity_id: int, period: str, product_code: str,
if not act or act.driver_rate == 0:
return {"error": "分配率未设置"}
allocated = round(driver_consumed * act.driver_rate, 2)
alloc_kwargs = dict(
alloc = AbcAllocation(
period=period,
activity_id=activity_id,
product_code=product_code,
@@ -189,9 +179,6 @@ def allocate_cost(activity_id: int, period: str, product_code: str,
driver_consumed=driver_consumed,
allocated_cost=allocated,
)
if entity_id is not None:
alloc_kwargs["entity_id"] = entity_id
alloc = AbcAllocation(**alloc_kwargs)
db.add(alloc)
db.commit()
return {
-252
View File
@@ -1,252 +0,0 @@
"""CPA(客户获利性)取数口径 —— 单一事实源(2026-10-04,任务 finance-20261003-cpa-customer-profitability-mvp-fullstack。
2026-10-04 P0 口径纠错(任务 finance-20261004T0905-cpa-revenue-caliber-p0,依据财务独立复验件
`finance-20261004-cpa-mvp-independent-verify` §5.3):增列**单据类型白名单**(只取销售类),
避免把采购/资金/库存/费用单据的**供应商**当成客户。
为什么有本模块
--------------
客户级收入聚合此前只有一处实现(`app/api/alert_rules.py::_customer_revenue_shares`,
供客户集中度预警使用)。CPA 派生器必须复用**同一套**聚合,否则同一指标出现两份结果
(任务 handoff 明写「勿另写第二套客户收入聚合,抽公共函数」)。
本模块即那唯一的公共函数;`alert_rules._customer_revenue_shares` 与本派生器都调它。
基座
----
`zxt_masterbill`(智享通单据流,仅 entity1 有数据:15,755 行 / 284 个往来单位 /
2021-03 ~ 2026-09-30)。**无** unit 名称列 ⇒ 客户标识只用 `unit_id`。
口径(勿擅改)
--------------
* **单据类型白名单 = 销售类 `bill_type IN (1, 4, 14)`**(1=XSD 销售单 / 4=XSTHD 销售退货 /
14=XSHHD 销售换货;等价判据 `bill_sn LIKE 'XSD%'/'XSTH%'/'XSHH%'` 实测逐行同集)。
非销售单据一律排除:0=JHD 进货 / 128=YSKD 验收 / 17=QTRK 其它入库 / 129=YFKD 预付款 /
101=SKD 收款 / 103=YBFYD 费用 / 104=QTSRD 其它收入 / 18=QTCK 其它出库。
**依据**:这些单据的 `unit_id` 是**供应商**(`abst` 实读「向【酣客酒业(广州)】进货」「预付款单」),
混入会把「客户集中度」变成「往来单位(含供应商)集中度」——旧口径 Top1/Top2 恰是本公司最大供应商,
也是「4,883.52 万 ÷ 账套 5001 808.80 万 = 6.04× 基座差」的真因。
⚠️ 该纠错**改变对外数字**(户数 73→65、Top1 25.3835%→22.5150%、Top2 归属由供应商改真实客户);
属**事实性纠错**(财务定死 + 任总知会),非选型。
* `C_CUST_REVENUE` / 快照明细 `revenue` = Σ`zxt_masterbill.sum_money`(按 `unit_id`、期内、**不含税原值**)。
过滤:`entity_id=? AND unit_id>0 AND bill_type IN (1,4,14) AND (sum_money+sum_tax_money)>0
AND bill_date IS NOT NULL`。
* **取舍口径(符号过滤)**:`(sum_money + sum_tax_money) > 0`(`tax_incl`)与 `sum_money > 0`
(`money_only`)在本库当前数据上**完全等价**(2026-01~09 双向差集 **0 行**;全表亦 0 行)。
原报告「407 行红冲(含税为正、金额为负)⇒ 预警侧低估客户收入 17.2%」经财务复验**证否并作废**:
407 行 = 1,651(无过滤全行) − 1,244(符号过滤),由 **302 行零额 + 105 行负额**组成(负额两式同样排除,
Σ`sum_money`=−841.22 万 / Σ含税=−1,717.97 万);所谓「1,651 行 / 78 户 / 4,042.31 万」实为
**无符号过滤(全行)**的结果(原报告**标签错置**);「17.23%」= 4,883.52÷4,042.31−1,
是「加符号过滤 vs **不滤**」之差(方向为**高估**),**不是**两路径之差。
⇒ 全体系**统一书面口径** `(sum_money + sum_tax_money) > 0`(预警与 CPA 同源同式,差集 0 ⇒ 行为零变化)。
* 对拍锚点(2026-01~09,销售类白名单口径):**65 户 / 1,207.74 万元 / Top1=22.5150%(unit152) /
Top2 单户=13.9734%(unit155) / Top4 累计=46.9407% / Top10 累计=69.7511%**(见 `ANCHORS_2026_01_09`)。
与账套 5001 逐月同向同量级(01: 229.02 vs 180.87;07: 81.55 vs 81.08;08: 15.83 vs 14.45)⇒ 可勾稽。
* `C_CUST_CONCENTRATION`(**沿用** `kpi_definitions.formula`)= 前5大客户收入 ÷ 期内客户收入合计 ×100。
(任务书 §2.1 写的 Top1 与现网定义「前5大」不一致 ⇒ 见报告「口径冲突登记」;
两者本模块都算:`top_n=1` / `top_n=5`。)
* `C_CUST_COGS_ALLOC` / `C_CUST_CHAN_SUB` / `C_CUST_CONTRIB` / `C_CUST_CONTRIB_RATE`:
**fail-closed,本模块不产出可写值**(理由见 `FAIL_CLOSED_REASONS`)——任何调用方
不得据此写 0 / 分摊值(任务 §2.2 与验收「禁止事项自证」)。
无写路径:本模块只做读聚合与纯计算,不 commit。
"""
from __future__ import annotations
from datetime import date
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
# ---------------------------------------------------------------------------
# 口径常量(单一事实源)
# ---------------------------------------------------------------------------
# 销售类单据白名单(P0 纠错主体):1=销售单 XSD / 4=销售退货 XSTHD / 14=销售换货 XSHHD
SALES_BILL_TYPES: Tuple[int, ...] = (1, 4, 14)
BILL_SCOPE_FILTER = {
"sales": f"bill_type IN ({', '.join(str(t) for t in SALES_BILL_TYPES)})",
# 'all' 仅用于对拍/回归(复现旧口径与财务复验的全单据数字),**不得**用于对外指标口径。
"all": "1 = 1",
}
DEFAULT_BILL_SCOPE = "sales"
REVENUE_FILTER = (
"entity_id = :eid AND unit_id IS NOT NULL AND unit_id > 0 "
"AND {bill_type} AND {sign} AND bill_date IS NOT NULL"
)
# 金额与取舍口径(**2026-10-04 财务复验更正**)
# 度量 = sum_money(不含税原值;与任务 §2.1 字面一致)
# 取舍 = 两种「净额符号」过滤器,决定**哪些行计入**:
# 'tax_incl' = (sum_money + sum_tax_money) > 0 ← **全体系统一书面口径**(预警 + CPA 同源)
# 'money_only'= sum_money > 0 ← 保留作回归位;与本库当前数据**逐行同集**(差集 0 行)
# 2026-01~09 销售类白名单下(两式同值):624 行 / 65 户 / 1,207.74 万元 /
# Top1 unit152 22.5150% / Top2 unit155 13.9734% / Top4 累计 46.9407% / Top10 累计 69.7511%。
MEASURE_SQL = "sum_money"
SIGN_FILTER = {"tax_incl": "(sum_money + sum_tax_money) > 0", "money_only": "sum_money > 0"}
DEFAULT_SIGN_BASIS = "tax_incl"
# 2026-01~09 对拍锚点(销售类白名单口径;派生器 `--anchor` 与回归共用,禁各写一份)
ANCHORS_2026_01_09 = {
"units": 65, "total_wan": 1207.74,
"top1": 22.5150, "top1_unit": 152,
"top2": 13.9734, "top2_unit": 155,
"top4_cum": 46.9407, "top10_cum": 69.7511,
}
ANCHOR_TOL = 0.05
REVENUE_SQL_WINDOW = """
SELECT unit_id, SUM({money}) AS amount
FROM zxt_masterbill
WHERE {filter}
AND bill_date >= :start AND bill_date <= :end
GROUP BY unit_id
ORDER BY amount DESC
"""
# period 桶:SUBSTR/SUBSTRING(date,1,7) 在 MySQL 与 SQLite 上均成立(隐式转字符串)
REVENUE_SQL_MONTHLY = """
SELECT SUBSTR(bill_date, 1, 7) AS period, unit_id, SUM({money}) AS amount
FROM zxt_masterbill
WHERE {filter}
GROUP BY SUBSTR(bill_date, 1, 7), unit_id
ORDER BY period, amount DESC
"""
def _filter_sql(sign_basis: str, bill_scope: str) -> str:
if sign_basis not in SIGN_FILTER:
raise ValueError(f"unknown sign_basis: {sign_basis!r} (expect {sorted(SIGN_FILTER)})")
if bill_scope not in BILL_SCOPE_FILTER:
raise ValueError(f"unknown bill_scope: {bill_scope!r} (expect {sorted(BILL_SCOPE_FILTER)})")
return REVENUE_FILTER.format(
bill_type=BILL_SCOPE_FILTER[bill_scope], sign=SIGN_FILTER[sign_basis])
def _sql_window(sign_basis: str = DEFAULT_SIGN_BASIS, bill_scope: str = DEFAULT_BILL_SCOPE) -> str:
return REVENUE_SQL_WINDOW.format(money=MEASURE_SQL, filter=_filter_sql(sign_basis, bill_scope))
def _sql_monthly(sign_basis: str = DEFAULT_SIGN_BASIS, bill_scope: str = DEFAULT_BILL_SCOPE) -> str:
return REVENUE_SQL_MONTHLY.format(money=MEASURE_SQL, filter=_filter_sql(sign_basis, bill_scope))
CONCENTRATION_TOP_N_DEFAULT = 5 # 现网 kpi_definitions.formula = 前5大客户
FAIL_CLOSED_REASONS = {
"C_CUST_COGS_ALLOC": (
"销货成本分摊 = 客户收入 × 公司整体销货成本率(5401÷5001)——**乘法式已废止**"
"(财务定死 2026-10-04:跨基座相乘无经济意义)。"
"**基数口径待任总一字定**:客户收入基座为 zxt_masterbill **销售类**单据流(2026-01~09 = 1,207.74 万),"
"而 5401/5001 来自凭证账(同期 5001 = 808.80 万、5401 = 811.76 万,成本率 100.37%);"
"乘法式会凭空放大 6 倍(旧全单据口径 4,883.52 ÷ 808.80 = 6.04× 基座差)。"
"财务推荐「成本池 × 客户份额」分置式:`Σ5401(期间,carry_forward=0) × [客户 i 收入 ÷ 期内客户收入合计]`"
"(份额分母须与本模块口径同一事实源);选项 2 = zxt 实际单据成本;选项 3 = 维持 NULL。"
"⇒ 裁定前该列**恒 NULL**,禁 0、禁硬摊。"
),
"C_CUST_CHAN_SUB": (
"客户专属渠道补贴须先按客户/渠道打标(571103 目前为总额、不可拆分,见任务 §2.2);"
"打标台账未到 ⇒ fail-closed,禁按收入占比硬摊。"
),
"C_CUST_CONTRIB": "依赖 C_CUST_CHAN_SUB(未打标)⇒ 不可算。",
"C_CUST_CONTRIB_RATE": "依赖 C_CUST_CONTRIB(不可算)⇒ 不可算。",
}
# 快照表允许写值的列(其余列写 NULL + fail_closed_reason 留痕)
WRITABLE_SNAPSHOT_COLUMNS = ("revenue",) # cogs_alloc/chan_sub/contrib/contrib_rate 属 P1
# ---------------------------------------------------------------------------
# 读聚合
# ---------------------------------------------------------------------------
def fetch_revenue_rows(engine, entity_id: int, start: date, end: date, top_n: int = 1,
sign_basis: str = DEFAULT_SIGN_BASIS, bill_scope: str = DEFAULT_BILL_SCOPE):
"""窗口内按客户聚合收入(元),按金额倒序。
sign_basis: 'tax_incl'=按含税净额取符号(**统一书面口径**)/ 'money_only'=按 sum_money 取符号
(回归位;与本库当前数据逐行同集)
bill_scope: 'sales'=销售类单据白名单(默认,P0 纠错)/ 'all'=全单据(仅对拍用,不得对外)
返回 (shares, total, period_range):
shares = [(unit_id, amount, pct), ...] 取前 top_n
total = 窗口内客户收入合计(元)
period_range = (start.isoformat(), end.isoformat())
无可用数据返回 ([], 0.0, None)。异常返回 ([], 0.0, None)(与既有预警路径一致,不抛出)。
"""
from sqlalchemy import text
try:
with engine.connect() as conn:
rows = conn.execute(
text(_sql_window(sign_basis, bill_scope)),
{"eid": entity_id, "start": start, "end": end},
).fetchall()
except Exception:
return ([], 0.0, None)
if not rows:
return ([], 0.0, None)
total = float(sum(float(r[1] or 0) for r in rows))
if total <= 0:
return ([], 0.0, None)
n = max(1, int(top_n))
shares = [
(int(r[0]), float(r[1] or 0), round(float(r[1] or 0) / total * 100, 2))
for r in rows[:n]
]
return (shares, total, (start.isoformat(), end.isoformat()))
def fetch_monthly_revenue(engine, entity_id: int, sign_basis: str = DEFAULT_SIGN_BASIS,
bill_scope: str = DEFAULT_BILL_SCOPE) -> Dict[str, List[Tuple[int, float]]]:
"""按自然月聚合的客户收入(元)。
返回 {period: [(unit_id, amount), ...] 按金额倒序};period 形如 'YYYY-MM'。
统一只返回**有可用数据**的期次(空期不出现)。
"""
from sqlalchemy import text
out: Dict[str, List[Tuple[int, float]]] = {}
with engine.connect() as conn:
rows = conn.execute(text(_sql_monthly(sign_basis, bill_scope)),
{"eid": entity_id}).fetchall()
for period, unit_id, amount in rows:
out.setdefault(str(period), []).append((int(unit_id), float(amount or 0)))
for p in out:
out[p].sort(key=lambda x: x[1], reverse=True)
return out
# ---------------------------------------------------------------------------
# 纯计算
# ---------------------------------------------------------------------------
def concentration_pct(pairs: Sequence[Tuple[int, float]], top_n: int = CONCENTRATION_TOP_N_DEFAULT) -> Optional[float]:
"""前 top_n 客户合计收入占期内客户收入合计的百分比(%)。无数据返回 None。"""
if not pairs:
return None
total = sum(a for _, a in pairs)
if total <= 0:
return None
n = max(1, int(top_n))
top = sum(a for _, a in pairs[:n])
return round(top / total * 100, 2)
def to_wan(yuan: float) -> float:
"""元 → 万元(4 位小数;1 位 = 1 元)。"""
return round(float(yuan) / 10000.0, 4)
def active_customer_count(pairs: Sequence[Tuple[int, float]]) -> int:
"""期内活跃客户数(按 unit_id 去重,含极小金额客户——与对拍锚点 65 口径一致)。"""
return len({u for u, _ in pairs})
def monthly_metrics(pairs: Sequence[Tuple[int, float]]) -> Dict[str, Optional[float]]:
"""单期汇总口径(写入 kpi_values 的那几个数)。"""
return {
"C_CUST_REVENUE": to_wan(sum(a for _, a in pairs)) if pairs else None,
"C_CUST_COUNT": float(active_customer_count(pairs)) if pairs else None,
"C_CUST_CONCENTRATION": concentration_pct(pairs, 1), # 任务 §2.1 口径(Top1)
"C_CUST_CONCENTRATION_TOP5": concentration_pct(pairs, 5), # 现网定义口径(前5大)
}
+55 -190
View File
@@ -13,14 +13,7 @@ from datetime import datetime
from typing import Optional
from app.database import get_session_local
from app.utils.budget_status import EFFECTIVE_STATUSES, pick_effective
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
# 裁定 R1/R2/R3(2026-09-19 财务口径):demo 账套排除 / 期间窗口 / 规整月期间
from app.utils.alert_entity_scope import (
excluded_entity_ids, kpi_excluded, period_in_window, window_lower_period,
guard_alert_period, # R5-补1(2026-09-19):期间可归期 + 关联 kpi_values
resolve_latest_available_period, # 裁定2b(2026-09-25 ASK-15):期间回落单一实现
)
logger = logging.getLogger("cma.deviation")
@@ -34,32 +27,10 @@ DEFAULT_HIGHER_BETTER = [
CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better"
def higher_better_key(entity_id: int) -> str:
"""账套维度的方向配置键名:`kpi_alert_higher_better.<entity_id>`(零 DDL)
entity_id 必传:`None` → ValueError(防「漏传 → 落默认账套」重演污染)。
"""
if entity_id is None:
raise ValueError(
"higher_better_key 必须传 entity_id(账套隔离,2026-09-23 "
"dama-entity-isolation-sweep-20260923)"
)
return f"{CONFIG_KEY_HIGHER_BETTER}.{int(entity_id)}"
def get_higher_better_codes(db, entity_id: int) -> list:
"""读取**本账套**越高越好型KPI编码列表(entity_id 必传,漏传 TypeError)
账套隔离(2026-09-23 dama-entity-isolation-sweep-20260923 §一①,零 DDL):
· 只读 `kpi_alert_higher_better.<entity_id>`,**不做「回退旧全局键」**——
回退分支本身就是跨账套借用通路(与 `cash.current_balance` 同源缺陷)。
· 本账套未配置 → `DEFAULT_HIGHER_BETTER`(业务兜底:方向判定的内置默认表;
与拆键前「全局键无行」时的行为逐字一致,故对现存账套零行为差异)。
· 旧全局键 `kpi_alert_higher_better` 原样保留作回退杠杆
(搬运脚本 `--down` 只删新键,回退老码即可继续读旧键)。
"""
def get_higher_better_codes(db, entity_id: int = None) -> list:
"""读取越高越好型KPI编码列表(system_configs 可维护,无配置回落默认)"""
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == higher_better_key(entity_id)
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
if cfg and cfg.config_value:
try:
@@ -67,7 +38,7 @@ def get_higher_better_codes(db, entity_id: int) -> list:
if isinstance(codes, list):
return [str(c) for c in codes]
except Exception:
logger.warning("system_configs[%s] 解析失败, 回落默认", higher_better_key(entity_id))
logger.warning("system_configs[%s] 解析失败, 回落默认", CONFIG_KEY_HIGHER_BETTER)
return list(DEFAULT_HIGHER_BETTER)
@@ -94,58 +65,24 @@ def calc_deviation(actual: float, budget: float) -> dict:
def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Optional[float]:
"""获取指定KPI在指定期间的预算值"""
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
query = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
BudgetPlan.status == "active",
)
if version:
query = query.filter(BudgetPlan.version == version)
plan = pick_effective(query.all()) # active > approved > updated_at desc
plan = query.order_by(BudgetPlan.updated_at.desc()).first()
return plan.budget_value if plan else None
def get_kpi_value_row(db, kpi_id: int, period: str):
"""取 (kpi_id, period) 的 KPIValue 行(取行口径唯一来源)。
与 get_actual_for_kpi 同源:预警行关联的 kpi_values 行 = 数值来源那一行。
"""
return db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == period,
).order_by(KPIValue.calculated_at.desc()).first()
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
"""获取指定KPI在指定期间的实际值"""
row = get_kpi_value_row(db, kpi_id, period)
return row.actual_value if row else None
def resolve_alert_kpi_value(db, kpi, period: str):
"""R5-补1(财务裁定 2026-09-19):解析差异/趋势预警行必须关联的 `kpi_values` 行。
裁定原文:「此类预警行必须在落库时写入 `kpi_value_id`,指向 **同 `entity_id` +
同 `kpi_id` + 文案所示规整月期间** 的 `kpi_values` 行。文案期间不可解析为 `YYYY-MM`,
或该 (kpi_id, period) 无 `kpi_values` 行 → 按 R5 `guard_alert_period` 跳过不入库
(不得以 `kpi_value_id=NULL` 落行——『文案里有期间』不构成可归期)。」
- 账套约束由 `kpi_id` 传递:KPIDefinition 归属单一账套,故 kpi_id 已唯一确定 entity_id;
取行口径与 `get_kpi_value_row`(= `get_actual_for_kpi` 的来源行)完全一致,
保证「预警关联的那一行」就是该预警数值的来源行,不会关联到别的账套/别的期间。
- 返回 `(kpi_value_row | None, skip_reason | None)`;跳过原因供调用点计数与日志。
"""
if not guard_alert_period(period, source="deviation_engine.resolve_alert_kpi_value",
detail="kpi=%s" % getattr(kpi, "kpi_code", "")):
return None, "R5_non_attributable_period"
row = get_kpi_value_row(db, kpi.id, period)
if row is None:
logger.info(
"R5-补1 期间关联:跳过不入库(该 (kpi, period) 无 kpi_values 行,期间不可派生)"
" kpi=%s period=%s", getattr(kpi, "kpi_code", ""), period)
return None, "no_kpi_value_row"
return row, None
val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == period,
).order_by(KPIValue.calculated_at.desc()).first()
return val.actual_value if val else None
def calc_period_deviation(db, kpi_id: int, period: str) -> dict:
@@ -326,8 +263,7 @@ def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: fl
actual = deviation.get("actual_value")
budget = deviation.get("budget_value")
# 方向性:越高越好型(配置化,本账套键 system_configs.kpi_alert_higher_better.<entity_id>;
# 2026-09-23 拆账套:entity_id 由调用方必传,不再读全局唯一键)
# 方向性:越高越好型(配置化,system_configs.kpi_alert_higher_better)
higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id)
if higher_better:
@@ -375,141 +311,70 @@ def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: fl
def run_deviation_check(db_session, period: str = None) -> int:
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope`):
· R1 —— `status='demo'` 的账套不参与遍历(demo 仅演示,不产生业务预警);
· R2/R3 —— 期间须为规整月 `YYYY-MM` 且落在窗口(当月往前 6 个自然月,含当月)内。
· R5-补1 —— 差异/趋势分支**必须落 `kpi_value_id` 关联**(同 kpi 同期间的 `kpi_values` 行);
文案期间不可解析为 `YYYY-MM` 或该 (kpi, period) 无 `kpi_values` 行 → 跳过不入库;
趋势类以「计算基准期(趋势末端当期)」为关联期间;差异类去重键
= `(entity_id, kpi_id, kpi_value_id, alert_type)`。
2026-09-25(ASK-20260924-15,任总 2026-09-24 16:40「放行 15」)——**期间口径修复**:
· **period=None → 每个 KPI 回落「自己的最近可用期间」**(裁定2b,
`alert_entity_scope.resolve_latest_available_period`),与 `alert_generator.run_alert_check`
同源;**不再硬编码 `datetime.now()`(当月)**。
缺陷实证:当月(2026-09)尚无 `kpi_values` ⇒ 差异分支取不到实际值、趋势末端期落在
空月 ⇒ 本入口**永久 0 新增**(这正是「当期为当月 → 0 新增」的缺陷形态;同一缺陷
2026-09-17 只在 alert_generator 侧修过,本入口漏改)。
· 显式传 period → 固定该期间(原语义不变:抽查复核/测试用)。
· R2/R3 窗口判定**逐 KPI**做(回落期间各不相同,不能再用一次全局判定)。
"""
fixed_period = period # None = 逐 KPI 回落(裁定2b);有值 = 固定期间(原语义)
excluded = excluded_entity_ids(db_session)
kpis = db_session.query(KPIDefinition).filter(
KPIDefinition.status == "active"
).all()
new_count = 0
skipped_demo = 0
skipped_period_linkage = 0
skipped_linkage_kpis = []
skipped_no_period = 0
skipped_out_of_window = 0
periods_used = {}
for kpi in kpis:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if kpi_excluded(db_session, kpi, excluded):
skipped_demo += 1
continue
# 期间口径(裁定2b,2026-09-25):显式 → 固定;缺省 → 该 KPI 自己的最近可用期间
p = fixed_period or resolve_latest_available_period(db_session, kpi.id)
if not p:
skipped_no_period += 1
logger.warning(" 差异/趋势预警跳过(无可用期间): %s", kpi.kpi_name)
continue
# R2/R3(逐 KPI):期间须为规整月且落在窗口(当月往前 6 个自然月,含当月)
if not period_in_window(p):
skipped_out_of_window += 1
logger.info(" 差异/趋势预警跳过(R2/R3,窗口下限 %s): %s[%s]",
window_lower_period(), kpi.kpi_name, p)
continue
periods_used[p] = periods_used.get(p, 0) + 1
# 1. 差异预警:实际 vs 预算(统一逻辑)
result = build_deviation_alert(db_session, kpi, p)
result = build_deviation_alert(db_session, kpi, period)
if result["triggered"]:
deviation = result["deviation"]
alert_msg = (
f"{kpi.kpi_name}[{p}] 差异预警: 实际{deviation['actual_value']} "
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
f"vs 预算{deviation['budget_value']},"
f"差异率{deviation['deviation_rate']}%"
)
alert_type = result["alert_type"] or "actual"
# R5-补1(裁定 2026-09-19):期间可归期 + 必须落 kpi_value_id 关联
#(文案里的期间不构成可归期;无 kpi_values 行 → 跳过不入库)
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, p)
if linked_kv is None:
skipped_period_linkage += 1
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, p))
else:
# 去重键(R5-补1 变更):(entity_id, kpi_id, kpi_value_id, alert_type)
#(原按 `alert_message contains period` 判断,改关联后按可归期目标去重)
existing = db_session.query(KPIAlert).filter(
KPIAlert.entity_id == (kpi.entity_id or 1),
KPIAlert.kpi_id == kpi.id,
KPIAlert.kpi_value_id == linked_kv.id,
KPIAlert.alert_type == alert_type,
KPIAlert.status.in_(["pending", "processing"]),
).first()
# 检查是否已有同KPI同期间的差异预警
existing = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[差异预警]"),
KPIAlert.alert_message.contains(period),
KPIAlert.status.in_(["pending", "processing"]),
).first()
if not existing:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
kpi_id=kpi.id,
kpi_value_id=linked_kv.id, # R5-补1: 期间经该行可派生(可归期)
alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}",
alert_type=alert_type,
suggestion=result["suggestion"],
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
if not existing:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
kpi_id=kpi.id,
alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}",
alert_type=result["alert_type"] or "actual",
suggestion=result["suggestion"],
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
# 2. 趋势异常检测(每期检查连续3期;末端期 = 本 KPI 的回落期间 p)
trend = check_trend_anomaly(db_session, kpi.id, p, consecutive=3)
# 2. 趋势异常检测(每期检查连续3期)
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
trend_alert_msg = trend["message"]
# R5-补1:趋势类以「计算基准期(趋势末端当期)」为关联期间;该期无行 → 跳过
trend_periods = trend.get("periods") or []
base_period = trend_periods[-1] if trend_periods else p
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, base_period)
if linked_kv is None:
skipped_period_linkage += 1
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, base_period))
else:
existing_trend = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[趋势预警]"),
KPIAlert.status.in_(["pending", "processing"]),
).first()
existing_trend = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[趋势预警]"),
KPIAlert.status.in_(["pending", "processing"]),
).first()
if not existing_trend:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
kpi_value_id=linked_kv.id, # R5-补1: 趋势末端当期关联
alert_level=trend["level"],
alert_message=f"[趋势预警] {trend_alert_msg}",
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
if not existing_trend:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
alert_level=trend["level"],
alert_message=f"[趋势预警] {trend_alert_msg}",
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
if skipped_demo:
logger.info(" 差异预警跳过 demo 账套 KPI %s 项(R1,裁定 2026-09-19)", skipped_demo)
logger.info(" 差异/趋势预警期间口径: %s;判定期间分布=%s;跳过(无可用期间)%s 项 / (R2-R3 窗口外)%s 项 / (R5-补1 无 kpi_values 行)%s 项",
("固定 %s" % fixed_period) if fixed_period else "各 KPI 最近可用期间(裁定2b)",
periods_used, skipped_no_period, skipped_out_of_window, skipped_period_linkage)
if skipped_period_linkage:
logger.info(" 差异/趋势预警跳过(R5-补1 期间不可归期或无 kpi_values 行)KPI %s 项: %s",
skipped_period_linkage, skipped_linkage_kpis)
db_session.commit()
return new_count
-439
View File
@@ -1,439 +0,0 @@
# -*- coding: utf-8 -*-
"""杜邦分析「区间取值」口径单一事实源 —— 流量累计 × 存量平均 × 可选年化 + 恒等式守护
依据(唯一有效出处):
· 任务件 `finance-20261003-dupont-period-basis-design-fullstack.md`
· 放行出处:任总 2026-10-03 09:49 CST DM「立」(承 09:31「杜邦分析如何进行区间取值设计」)
· 财务口径裁定见任务件 §二(本模块逐条落地,不另立口径)
核心原则(§二 开篇):
**区间一旦选定,流量(分子)与存量平均(分母)必须同区间;且三因子必须同区间——杜邦恒等式才成立。**
取值规则(§二.1,硬性):
· 损益类(流量) → 区间**累计发生额**(期间 [start, end] 内净额)
· 资产负债类(存量)→ 区间**平均余额** = (期初 + 期末) / 2
· 跨期比较 → **年化** = 流量 × 12 / 区间月数(比率型因子见 `annualize_factors` 说明)
fail-closed(§二.6):
期初余额缺失 ⇒ **显式**回落期末,并在返回值标注 `basis_fallback=true`;**禁静默**。
单一事实源(§二.5):
总资产 / 净资产 / 权益乘数 不再由前端拼 `raw_data`,而是由
`scripts/kpi_dupont_deriver.py` 统一派生为 `F_ASSET_TOTAL` / `F_EQUITY_TOTAL` /
`F_EQUITY_MULTIPLIER`(`source_type='derived'`);本模块是其**唯一读取入口**(派生器与
只读端点共用),口径参数落 `kpi_definitions.data_source_config.params`。
⚠️ 取数源(§二.5 注):**不用**旧脚本 `register_dupont_kpis.py` 的 ERP 直连近似口径
(那套只有「现金银行 + 固定资产」,缺应收/存货);一律走 CMA 库内
`subject_balances`(存量)+ `voucher_details`(流量)。
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import text
#: 恒等式容差(百分点)——§二.3 建议 0.05
IDENTITY_TOLERANCE = 0.05
#: 支持的区间维度(前端选择器 §二.2)
PERIOD_PRESETS = ("ytd", "ttm", "month", "quarter", "half", "year", "custom")
# ────────────────────────── 账套口径(单一事实源) ──────────────────────────
#: 各账套会计科目体系差异(实测 2026-10-03):
#: · entity 1(酣客):旧准则科目 —— 收入 5001,权益 3xxx,资产 1xxx,负债 2xxx;
#: 净利润有期末结转腿(`carry_forward=1` 的 3xxx 行),与已上线 verified 的月度
#: F_NET_PROFIT/F_ROE/F_ROA 同口径(实测 2026-08:F_NET_PROFIT=-10.46、F_ROE=-0.95、
#: F_ROA=-0.57 与本模块输出逐项一致)。
#: · entity 2(博海):新 30 号准则科目 —— 收入 6001/6051/6052,权益 4xxx,费用 6xxx;
#: `voucher_details` 无任何 `carry_forward=1` 行 ⇒ 净利润走损益类净额口径。
CALIBER_DEFAULTS: Dict[str, Any] = {
"asset_prefix": "1",
"liability_prefix": "2",
"1": { # entity_id=1 酣客
"equity_prefixes": ["3"],
"revenue_codes": ["5001"],
"pnl_prefixes": ["5", "6"],
"net_profit_mode": "carry_forward", # Σ(贷−借) over carry_forward=1 且 权益前缀
},
"2": { # entity_id=2 博海
"equity_prefixes": ["4"],
"revenue_codes": ["6001", "6051", "6052"],
"pnl_prefixes": ["6"],
"net_profit_mode": "pnl_net", # Σ(贷−借) over carry_forward=0 且 损益前缀
},
#: 未登记账套的保守兜底(新准则形态)
"_fallback": {
"equity_prefixes": ["4"],
"revenue_codes": ["6001", "6051", "6052"],
"pnl_prefixes": ["6"],
"net_profit_mode": "pnl_net",
},
}
#: 参数读取入口(可被 `kpi_definitions.data_source_config.params` 覆盖;禁散落字面量)
PARAM_KPI_CODE = "F_ASSET_TOTAL"
def caliber_for(entity_id: int) -> Dict[str, Any]:
"""账套科目体系口径(内置默认;可供 DB 覆盖的键:equity_prefixes/revenue_codes/net_profit_mode)"""
ent = CALIBER_DEFAULTS.get(str(entity_id)) or CALIBER_DEFAULTS["_fallback"]
return {
"asset_prefix": CALIBER_DEFAULTS["asset_prefix"],
"liability_prefix": CALIBER_DEFAULTS["liability_prefix"],
"equity_prefixes": list(ent["equity_prefixes"]),
"revenue_codes": list(ent["revenue_codes"]),
"pnl_prefixes": list(ent["pnl_prefixes"]),
"net_profit_mode": ent["net_profit_mode"],
}
def load_dupont_params(db, entity_id: int) -> Dict[str, Any]:
"""读取账套口径参数:`kpi_definitions.data_source_config.params` → 内置默认兜底。
兼容形态:`{"params": {...}}` / 裸 `{...}` / JSON 字符串 / None。读取失败绝不抛错
(口径回落默认,端点不因参数缺件 500)。
"""
caliber = caliber_for(entity_id)
try:
row = db.execute(text(
"SELECT data_source_config FROM kpi_definitions "
"WHERE entity_id = :eid AND kpi_code = :code LIMIT 1"
), {"eid": entity_id, "code": PARAM_KPI_CODE}).first()
except Exception: # pragma: no cover - 表缺失等极端情形
row = None
raw = row[0] if row else None
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8", "replace")
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
raw = None
if isinstance(raw, dict):
params = raw.get("params") if isinstance(raw.get("params"), dict) else raw
for key in ("equity_prefixes", "revenue_codes", "pnl_prefixes", "net_profit_mode",
"asset_prefix", "liability_prefix"):
if key in params:
caliber[key] = params[key]
caliber["_params_source"] = "kpi_definitions.data_source_config.params"
else:
caliber["_params_source"] = "builtin-default"
return caliber
# ────────────────────────── 区间窗口 ──────────────────────────
def prev_month(period: str) -> str:
y, m = int(period[:4]), int(period[5:7])
if m == 1:
return f"{y - 1}-12"
return f"{y}-{m - 1:02d}"
def month_index(period: str) -> int:
return int(period[5:7])
def months_between(start: str, end: str) -> int:
"""闭区间 [start, end] 的月数(含首尾)。"""
if not start or not end:
return 0
return (int(end[:4]) - int(start[:4])) * 12 + (int(end[5:7]) - int(start[5:7])) + 1
def _fmt_cn(start: str, end: str) -> str:
if start == end:
return f"{start[:4]}年{int(start[5:7])}月"
return f"{start[:4]}年{int(start[5:7])}-{int(end[5:7])}月"
#: 区间后缀(R3:仅在「返回窗口 == 名义窗口」时保留 —— 统一经 `apply_period_suffix` 施加)
PERIOD_SUFFIX: Dict[str, str] = {
"ytd": "(YTD 年初至今)", "ttm": "(TTM 滚动 12 月)", "month": "(单月)",
"quarter": "(季度)", "half": "(半年)", "year": "(全年)", "custom": "(自定义区间)",
}
def apply_period_suffix(label: str, period_key: Optional[str], clipped: bool = False) -> str:
"""R3 后缀纪律:只在**未发生裁剪**(返回窗口 == 名义窗口)时保留「(季度)/(半年)…」。
发生任何裁剪 ⇒ 去后缀(`label` 已是实际窗口的 `_fmt_cn`)。`period_key` 只表**请求形态**,
**不等于**返回窗口类型(返回窗口类型由 `months` + `period` 标签表达)。
"""
if clipped:
return label
return f"{label}{PERIOD_SUFFIX.get(period_key or '', '')}"
def resolve_window(period: Optional[str], start: Optional[str], end: Optional[str],
available: List[str], preset: Optional[str] = None,
today: Optional[str] = None, anchor: Optional[str] = None) -> Dict[str, Any]:
"""区间维度 → [period_start, period_end](闭区间,YYYY-MM)。
§二.2 区间维度:月 / 季 / 半年 / 年 / YTD(默认)/ TTM / 自定义起止。
可用月域 `available`(升序 YYYY-MM 列表)用于:① 缺省锚定;② 区间裁剪。
`anchor` = 默认区间的锚月(端点传入「存量最新月」`latest_stock_month`,缺省取 available 末月)。
裁剪纪律(财务侧 2026-10-05 口径裁定 R1–R4,件
`_reports/project-20261005-dupont-oob-ruling-finance.md`):
· **R1 无交集 ⇒ 显式拒绝**:`e < avail_start` 或 `s > latest` ⇒ 返回 `error`
(调用侧转 HTTP 400),**不返回任何窗口/数值字段**(不得以 null/空串占位);
· **R2 有交集 ⇒ 双向裁剪**:窗口 = 交集 `[max(s, avail_start), min(e, latest)]`(只裁不扩),
`months` = 实际月数 ⇒ 恒 ≥ 1(消灭 `months = 0/负数` 与窗口倒挂);
· **R3 后缀纪律**:是否允许调用侧挂「(季度)/(半年)…」后缀 = `clipped` 字段
(未裁剪才保留;裁剪后 `period` = 实际窗口标签);
· **R4 裁剪留痕**:见 `clip_note`(上界沿用既有文案;下界为对称文案;两端同时裁剪则并存)。
⚠️ 更正(2026-10-05,同裁定 M1):下界越界**不是**无条件 400 —— 「有交集但下界越界」
= 裁到 `avail_start` + 留痕 + 去后缀(R2/R3/R4);**只有「整区间与可用月域无交集」才 400**。
旧 docstring 声称「下界越界则显式 400,见 `validate`」,而本模块从未有 `validate` 函数。
"""
avail = sorted(available or [])
latest = avail[-1] if avail else None
avail_start = avail[0] if avail else None
today = today or datetime.now().strftime("%Y-%m")
anchor = min(anchor or latest or today, today)
if latest:
anchor = min(anchor, latest)
p = (period or "").strip().lower()
if preset:
p = preset.strip().lower()
note = None
if not p or p in ("ytd", "default"):
if not anchor:
return {"error": "无可用数据月域,无法解析默认 YTD 区间"}
s, e = f"{anchor[:4]}-01", anchor
key = "ytd"
elif p in ("ttm",):
if not anchor:
return {"error": "无可用数据月域,无法解析 TTM 区间"}
# M2(同裁定 §4):TTM 名义区间 = [anchor−11, anchor](**12 个月**)。
# 历史实现取 anchor 的「去年同月」(如 anchor=2026-08 ⇒ 2025-08)= 13 个月,与后缀
# 「(TTM 滚动 12 月)」自相矛盾,且 `months` 进年化分母 ⇒ 年化被低估 12/13 ≈ 7.7%。
y, m = int(anchor[:4]), int(anchor[5:7])
idx = y * 12 + (m - 1) - 11
s = f"{idx // 12}-{idx % 12 + 1:02d}"
e = anchor
key = "ttm"
elif p in ("custom",) or start or end:
if not (start and end):
return {"error": "自定义区间须同时给出 start 与 end(YYYY-MM)"}
s, e = start, end
key = "custom"
elif len(p) == 7 and p[5] == "q" and p[6].isdigit(): # 2026-q3
y, q = int(p[:4]), int(p[6])
s, e = f"{y}-{(q - 1) * 3 + 1:02d}", f"{y}-{q * 3:02d}"
key = "quarter"
elif len(p) == 7 and p[5] == "h" and p[6].isdigit(): # 2026-h1
y, h = int(p[:4]), int(p[6])
s, e = f"{y}-{(h - 1) * 6 + 1:02d}", f"{y}-{h * 6:02d}"
key = "half"
elif len(p) == 7 and p[5:].isdigit(): # 2026-08 → 单月
s = e = p
key = "month"
elif len(p) == 4 and p.isdigit(): # 2026 → 全年
s, e = f"{p}-01", f"{p}-12"
key = "year"
else:
return {"error": f"无法识别的区间参数 period={period!r}(支持 ytd/ttm/YYYY-MM/YYYY-Qn/YYYY-Hn/YYYY/custom)"}
# ── R1/R2:与可用月域求交集(无交集 ⇒ 显式拒绝;有交集 ⇒ 双向裁剪) ──────────
if not avail:
return {"error": "无可用数据月域,无法计算区间指标"}
latest = avail[-1]
avail_start = avail[0]
if e < avail_start or s > latest:
return {"error": f"请求区间 {s}~{e} 与可用月域 {avail_start}~{latest} 无交集,无法计算区间指标"}
notes: List[str] = []
lo, hi = s, e
if s < avail_start:
lo = avail_start
notes.append(f"下界 {s} 早于可用月域起点({avail_start}),已裁至可用域")
if e > latest:
hi = latest
notes.append(f"上界 {e} 超出可用月域(最新 {latest}),已裁至可用域")
if lo > hi: # 名义区间自身颠倒(如 custom start > end)——不得产出倒挂窗口
return {"error": f"请求区间 {s}~{e} 与可用月域 {avail_start}~{latest} 无交集,无法计算区间指标"}
clipped = (lo, hi) != (s, e)
months = months_between(lo, hi) # R2 ⇒ 恒 ≥ 1
return {
"period_key": key, "period_start": lo, "period_end": hi, "months": months,
"label": _fmt_cn(lo, hi),
"nominal_start": s, "nominal_end": e, "nominal_months": months_between(s, e),
"clipped": clipped,
"clip_note": ";".join(notes) if notes else None,
"available_start": avail_start, "available_end": latest,
}
# ────────────────────────── 取数(流量 / 存量) ──────────────────────────
def _flow_expr(field: str) -> str:
"""流量表达式。命名显式化(避免与存量 net 的借−贷/贷−借混淆):
· `credit` → 贷方发生额
· `debit` → 借方发生额
· `credit_debit` → 贷−借(收入类、结转腿自然方向)
· `debit_credit` → 借−贷(成本费用类自然方向)
"""
expr = {"credit": "credit_amount", "debit": "debit_amount",
"credit_debit": "(credit_amount - debit_amount)",
"debit_credit": "(debit_amount - credit_amount)"}.get(field)
if expr is None:
raise ValueError(f"未知流量字段 {field!r}(credit/debit/credit_debit/debit_credit)")
return expr
def sum_flows(db, entity_id: int, start: str, end: str, spec: Dict[str, Any]) -> float:
"""流量累计(元)。spec: {codes:[...]|prefixes:[...], field: 见 _flow_expr, carry_forward: 0|1}"""
parts, args = [], {"eid": entity_id, "s": start, "e": end}
where = ["entity_id = :eid", "period >= :s", "period <= :e"]
cf = spec.get("carry_forward")
if cf is not None:
where.append("carry_forward = :cf")
args["cf"] = int(cf)
for i, code in enumerate(spec.get("codes") or []):
parts.append(f"subject_code = :c{i}")
args[f"c{i}"] = code
for i, pfx in enumerate(spec.get("prefixes") or []):
parts.append(f"subject_code LIKE :p{i}")
args[f"p{i}"] = f"{pfx}%"
if parts:
where.append("(" + " OR ".join(parts) + ")")
sql = (f"SELECT COALESCE(SUM({_flow_expr(spec.get('field', 'credit_debit'))}), 0) "
f"FROM voucher_details WHERE " + " AND ".join(where))
return float(db.execute(text(sql), args).scalar() or 0.0)
def _bal_expr(field: str) -> str:
return {"cb": "(cb_dr - cb_cr)", "cb_rev": "(cb_cr - cb_dr)",
"ob": "(ob_dr - ob_cr)", "ob_rev": "(ob_cr - ob_dr)"}[field]
def stock_balance(db, entity_id: int, period: str, prefixes: List[str],
side: str = "net") -> Dict[str, Optional[float]]:
"""存量余额(元):期初 `ob_*` / 期末 `cb_*`,一级科目(`level=1`,防跨层双计)。
side: 'net' = 借−贷(资产用)/ 'net_rev' = 贷−借(权益、负债用)。
返回 {"begin": 元|None, "end": 元|None, "row_found": bool}
ob 字段: net→ob / net_rev→ob_rev;cb 同理。
"""
ob_f = "ob" if side == "net" else "ob_rev"
cb_f = "cb" if side == "net" else "cb_rev"
pfx_sql, args = [], {"eid": entity_id, "period": period}
for i, pfx in enumerate(prefixes):
pfx_sql.append(f"subject_code LIKE :p{i}")
args[f"p{i}"] = f"{pfx}%"
sql = (f"SELECT COALESCE(SUM({_bal_expr(ob_f)}), 0), COALESCE(SUM({_bal_expr(cb_f)}), 0), COUNT(*) "
f"FROM subject_balances WHERE entity_id = :eid AND period = :period AND `level` = 1 "
f"AND (" + " OR ".join(pfx_sql) + ")")
row = db.execute(text(sql), args).first()
if not row or not row[2]:
return {"begin": None, "end": None, "row_found": False}
return {"begin": float(row[0] or 0.0), "end": float(row[1] or 0.0), "row_found": True}
def available_months(db, entity_id: int) -> List[str]:
"""可用月域 = `voucher_details` 与 `subject_balances` 期间并集(升序)。
⚠️ 两次单表查询后 Python 合并,**不用 SQL `UNION`** —— 两表 `period` 列 collation
不同,MySQL 直接 UNION 会报 `1271 Illegal mix of collations`(2026-10-03 实测)。
"""
months: set = set()
for tbl in ("voucher_details", "subject_balances"):
rows = db.execute(text(
f"SELECT DISTINCT period FROM {tbl} WHERE entity_id = :eid"
), {"eid": entity_id}).fetchall()
months.update(str(r[0]) for r in rows if r[0])
return sorted(months)
def latest_stock_month(db, entity_id: int) -> Optional[str]:
"""最后一个**存量可取**的期间(subject_balances 有该期一级科目行)。
默认区间锚点必须取它而非流量最新月:两账套刻度不同(实测 entity2 流量到 2026-09、
存量只到 2026-08),若按流量最新月锚定,默认视图会落到「期末存量缺失」的区间上
(fail-closed ⇒ 全空),属可用性缺陷而非诚实的 fail-closed。
"""
row = db.execute(text(
"SELECT MAX(period) FROM subject_balances WHERE entity_id = :eid AND `level` = 1"
), {"eid": entity_id}).scalar()
return str(row) if row else None
# ────────────────────────── 纯计算(无 DB,便于单测) ──────────────────────────
def annualize_factors(factors: Dict[str, Optional[float]], months: int,
annualize: bool) -> Dict[str, Any]:
"""年化(§二.1):**流量** × 12 / 区间月数。
比率型因子的年化是非平凡的,本函数显式给出每因子的实际缩放倍数,避免「看着像没生效」:
· 净利率 = 净利润/营业收入 —— 分子分母同为流量,×k 相消 ⇒ scale=1.0(**值不变**)
· 总资产周转率 = 营业收入/平均总资产 —— 仅分子年化 ⇒ scale=k
· 权益乘数 = 平均总资产/平均净资产 —— 分子分母同为存量 ⇒ scale=1.0(**值不变**)
· ROE = 净利润/平均净资产 —— 仅分子年化 ⇒ scale=k
这也正是恒等式在年化下继续成立的原因:
k·净利润/平均净资产 ≡ 净利率 × (k·周转率) × 权益乘数。
"""
k = (12.0 / months) if (annualize and months) else 1.0
scales = {"net_profit_margin": 1.0, "asset_turnover": k, "equity_multiplier": 1.0,
"roe": k}
out: Dict[str, Any] = {"scale": k, "factors": {}, "per_factor_scale": scales}
for name, val in (factors or {}).items():
s = scales.get(name, 1.0)
out["factors"][name] = None if val is None else val * s
return out
def compute_factors(net_profit_w: Optional[float], revenue_w: Optional[float],
total_assets_w: Optional[float], equity_w: Optional[float]) -> Dict[str, Optional[float]]:
"""三因子 + ROE(万元口径;ROE/净利率为百分点)。任一分母为 0/缺失 ⇒ 该因子 None(fail-closed)。"""
out = {"net_profit_margin": None, "asset_turnover": None,
"equity_multiplier": None, "roe": None}
if revenue_w:
out["net_profit_margin"] = net_profit_w / revenue_w * 100.0 if net_profit_w is not None else None
if total_assets_w:
out["asset_turnover"] = revenue_w / total_assets_w if revenue_w is not None else None
if equity_w:
out["equity_multiplier"] = total_assets_w / equity_w
if net_profit_w is not None:
out["roe"] = net_profit_w / equity_w * 100.0
return {k: (None if v is None else v) for k, v in out.items()}
def check_identity(roe: Optional[float], net_profit_margin: Optional[float],
asset_turnover: Optional[float], equity_multiplier: Optional[float],
tolerance: float = IDENTITY_TOLERANCE) -> Dict[str, Any]:
"""恒等式守护(§二.3):| ROE − 净利润率 × 总资产周转率 × 权益乘数 | ≤ 容差(百分点)。
不成立 ⇒ `status='identity_broken'`,调用方**不得返回数值**(只返回三因子各自的区间元数据)。
"""
if None in (roe, net_profit_margin, asset_turnover, equity_multiplier):
return {"checked": False, "status": "unavailable", "tolerance": tolerance,
"reason": "三因子或 ROE 不可得(分母缺失),恒等式免检"}
product = net_profit_margin / 100.0 * asset_turnover * equity_multiplier * 100.0
diff = abs(roe - product)
return {"checked": True, "status": "pass" if diff <= tolerance else "identity_broken",
"roe": round(roe, 6), "product": round(product, 6), "diff": round(diff, 6),
"tolerance": tolerance}
def build_window_meta(label: str, start: str, end: str, months: int,
basis: str, annualized: bool, fallback: bool,
annualize_scale: float = 1.0,
fallback_reason: Optional[str] = None) -> Dict[str, Any]:
"""§二.4 口径元数据(随值返回):period_start / period_end / basis / annualized 四项。"""
meta = {"period": label, "period_start": start, "period_end": end, "months": months,
"basis": basis, "annualized": bool(annualized), "basis_fallback": bool(fallback),
"annualize_scale": annualize_scale}
if fallback_reason:
meta["basis_fallback_reason"] = fallback_reason
return meta
-67
View File
@@ -1,67 +0,0 @@
# -*- coding: utf-8 -*-
"""KPI 取值分区口径 —— 「实测区 / 预估区」单一事实源(财务裁定 2026-10-02)
依据:`wecom-finance/pending-tasks/finance-20261002-h2-forecast-actual-separation-fullstack.md`
(任总 2026-10-02 11:35 CST DM 直令「把 H2 预测行与实际分离」)
财务口径裁定(逐字):
| 区 | 判据 | 展示 | 禁止 |
|---|---|---|---|
| 实测区 | `data_status` ∈ {verified, pending, calculated} 且 `source_type` ≠ forecast | 正常展示 | 不得含 estimated |
| 预估区 | `data_status = estimated` 或 `source_type = forecast` | 单列区 / 带「预估」标签 | 禁与实测相加、禁进趋势序列、禁作预警依据 |
实现口径说明(**边界决策,供复核**):
- 本模块按**预估区的补集**实现实测区(`data_status='estimated'` 或 `source_type='forecast'`
一律排除),而不是按 `{verified, pending, calculated}` 正向白名单实现。
理由两条:①裁定的「禁止」列只点名 `estimated`;②本单验收要求
「回归:实测区各端点返回值与改动前逐期等值(**仅少掉 2 行预估**)」,
正向白名单会连带排除 `invalid`(6) / `error`(6) 行,超出该回归口径。
若产品口径要求把 invalid/error 也逐出取数层,属**另一次口径变更**,须财务重新裁定。
- `NULL` 值按实测处理(`coalesce(col, '')`):历史行 `data_status` 可能为空,
空值不等于「预估」(fail-open 到既有行为,不静默吞行)。
约定:所有对外 `/api/cma/**` 的 KPIValue 读路径**默认只取实测区**;
仅当调用方显式传 `include_forecast=true` 时,预估行才**以独立字段**
(`forecast_value` / `is_forecast` / `forecast_rows`)返回,**不与实测相加**。
禁止在调用点各写一份 `data_status == 'estimated'` 字面量(避免多处漂移)。
"""
from sqlalchemy import and_, func, or_
from app.models import KPIValue
#: 预估区 —— 数据状态判据
FORECAST_DATA_STATUS = "estimated"
#: 预估区 —— 来源类型判据
FORECAST_SOURCE_TYPES = ("forecast",)
def _non_null_neq(column, value):
"""`coalesce(column, '') != value` —— NULL 视为「非该值」(不静默吞历史空值行)"""
return func.coalesce(column, "") != value
def actual_only_clause():
"""实测区 SQL 判据 = 预估区补集(对外读路径默认过滤条件)"""
return and_(
_non_null_neq(KPIValue.data_status, FORECAST_DATA_STATUS),
*[_non_null_neq(KPIValue.source_type, t) for t in FORECAST_SOURCE_TYPES],
)
def forecast_only_clause():
"""预估区 SQL 判据(`include_forecast=true` 时单列取数用)"""
return or_(
KPIValue.data_status == FORECAST_DATA_STATUS,
KPIValue.source_type.in_(FORECAST_SOURCE_TYPES),
)
def is_forecast_row(row) -> bool:
"""Python 侧同判据(单一事实源,避免调用点重写字面量)"""
if row is None:
return False
data_status = getattr(row, "data_status", None) or ""
source_type = getattr(row, "source_type", None) or ""
return (data_status == FORECAST_DATA_STATUS
or source_type in FORECAST_SOURCE_TYPES)
-107
View File
@@ -1,107 +0,0 @@
# -*- coding: utf-8 -*-
"""操作审计写入统一入口(2026-09-23,`dama-entity-isolation-sweep-20260923` §二之二④)。
**约定(只动写侧;不加列、不改读侧口径、不回填历史)**:
1. 每条 `operation_logs` **尽量带 `detail.entity_id`** —— 语义是「**本动作所属账套**」:
会话账套(`Depends(get_entity_id)`)/ Bot 入参账套 / 被处理对象自身的账套。
2. 确实没有账套上下文的写入口 → `entity_id=None`,并打一条 `OPLOG_NO_ENTITY` 警告:
把覆盖缺口留成**可 grep 的哨兵**(下一轮审计收口有据可查),
**绝不猜账套** —— 猜错比留空更有害(错账套会让 `knowledge_service` 归集时又串一次)。
3. 新的审计写入口一律走 `log_op()`,不要新写 `OperationLog(...)` 构造(第 28 处必漏)。
**读侧(本轮不动)**:`app/services/knowledge_service.py::extract_events` 仍按
`user_id → user_entities` 兜底过滤 —— `user_id IS NULL` 的 Bot/脚本日志因此进不了记忆抽取
(**覆盖缺口,非泄漏**)。`detail.entity_id` 到位后下一轮才切读侧。
"""
from __future__ import annotations
import json
import logging
from app.models import OperationLog
logger = logging.getLogger("cma.oplog")
#: 哨兵前缀(供 grep / 告警链订阅):无账套上下文的写入口
NO_ENTITY_TAG = "OPLOG_NO_ENTITY"
#: 哨兵前缀:跨账套批量写(一次动作覆盖多账套,无法归单一账套)——覆盖缺口,待下一轮收口
BATCH_NO_ENTITY_TAG = "OPLOG_BATCH_NO_ENTITY"
#: `scope` 取值:显式声明「该写入口不是账套维度」(平台字典表 / 角色级配置),不再报缺口
SCOPE_GLOBAL = "global"
#: `scope` 取值:一次动作跨多账套批量(无法归单账套)——照实报警,不猜账套
SCOPE_BATCH = "batch"
def _as_detail_dict(detail) -> dict:
"""把三种历史 detail 写法归一到 dict(**不改变已有存储形态语义**)。
- ``dict`` → 直接用(复制一份,不就地改调用方的对象)
- JSON **对象**字符串(历史写法 ``json.dumps({...})``)→ 解析为 dict,与 entity_id 合并进同一对象
- 其他字符串 → ``{"note": <原文>}``(原先以标量字符串存的行,升级为对象并附账套)
"""
if detail is None:
return {}
if isinstance(detail, dict):
return dict(detail)
if isinstance(detail, str):
s = detail.strip()
if s.startswith("{"):
try:
v = json.loads(s)
except (ValueError, TypeError):
v = None
if isinstance(v, dict):
return v
return {"note": detail}
return {"note": str(detail)}
def log_op(db, action, *, target_type=None, target_id=None, detail=None,
user_id=None, entity_id=None, scope=None, commit=False):
"""写一条 `operation_logs`,统一带账套(`detail.entity_id`)。
:param db: SQLAlchemy Session
:param action: 动作名(DB 非空列)
:param target_type: 目标类型(DB 非空列)
:param target_id: 目标 id(可空)
:param detail: dict(推荐)/ JSON 对象字符串 / 普通 str(存为 ``{"note": <原文>}``)/ None
:param user_id: 操作人;Bot/脚本无用户时 None(读侧本轮仍按 user_id 兜底)
:param entity_id: **本动作所属账套**;无上下文时 None(触发 OPLOG_NO_ENTITY 哨兵)
:param scope: ``SCOPE_GLOBAL`` = 该写入口按设计非账套维度(写 ``detail.entity_scope``,不报警);
``SCOPE_BATCH`` = 跨账套批量(写 ``entity_scope=batch`` **并**报警,不猜账套);
None = 未知(报警,留可 grep 的覆盖缺口)
:param commit: 是否立即提交(默认 False,交由调用方事务边界)
"""
d = _as_detail_dict(detail)
# 调用方若已把账套写在 detail 里(历史写法),沿用之,避免同一个键两套来源
if entity_id is None and d.get("entity_id") is not None:
entity_id = d["entity_id"]
if entity_id is not None:
d["entity_id"] = int(entity_id)
elif scope == SCOPE_GLOBAL:
d["entity_scope"] = SCOPE_GLOBAL
elif scope == SCOPE_BATCH:
d["entity_scope"] = SCOPE_BATCH
logger.warning(
"%s action=%s target_type=%s target_id=%s — 跨账套批量写,detail.entity_id 留空"
"(禁止猜账套)", BATCH_NO_ENTITY_TAG, action, target_type, target_id)
else:
logger.warning(
"%s action=%s target_type=%s target_id=%s — 该写入口无账套上下文,"
"detail.entity_id 留空(禁止猜账套)", NO_ENTITY_TAG, action, target_type, target_id)
row = OperationLog(
user_id=user_id,
action=action,
target_type=target_type,
target_id=target_id,
detail=d or None,
)
db.add(row)
if commit:
db.commit()
return row
-418
View File
@@ -1,418 +0,0 @@
# -*- coding: utf-8 -*-
"""管理利润表(贡献式)取数层 —— 真实科目发生额,按账套(entity)隔离。
**口径唯一源**:`app/utils/bep_caliber.py::ENTITY_CALIBERS`
· 收入 :`caliber['revenue_field']` + `caliber['revenue_filter']`(e1=5001 贷方 / e2=6001 贷方)
· 变动成本 :`caliber['varcost_terms']`(带号科目项;e1 = 5401借 + 5403借 + 571103借 − 530103贷)
· 固定成本 :`caliber['fixed_terms']`(带号科目项,**优先**)否则 `caliber['fixed_filters']`
(前缀元组 → 派生为 `('+', '<前缀>%', 'net')` 借−贷净额);两者一律经
`bep_caliber.fixed_cost_spec()` **唯一解析口**解析(报表/卡面/派生器同源)。
§4-1 批后:博海走 terms(`6601/6602/6603` **仅借方**),酣客仍走 filters(净额)。
· 财务收益 :`caliber['finance_income_terms']`(§4-1:博海 `6603` **贷方**)—— **单列备查**,
**不进**固定成本、不进末行、不参与勾稽(纯展示项)。
· 期间行域 :`carry_forward == 0` + `caliber['exclude_closing']`(结转腿未标账套须显式剔 `summary LIKE '%结转%'`)
· 作废行域 :`apply_voucher_guard`(`voucher_void_marks`,全库唯一实现)
铁律(2026-10-14 管理利润表重建单)
1. 本模块**不定义任何科目集、不含任何比例常量**;科目构成一律从 ENTITY_CALIBERS 派生。
口径要改 → 只改 `bep_caliber.py` 一处(本层与报表层零改动自动跟随),**禁第二套口径**。
2. **禁** KPI 比率 × 收入倒推(旧实现 `F_PROFIT_RATE` / `F_NET_PROFIT_RATE` / `F_COST_RATIO` 路线已废);
**禁**硬编码比例(旧实现:变动成本 = 营收 × 写死的五成比例)。
3. 舍入一律「**汇总后舍入**」:先按元聚合相减,末步 ÷10000 取 2 位;勿逐分量舍入后相加。
4. 查不到的数据**如实标缺**(`None` + `missing` 列表),不倒推、不回落政策常量。
5. 末行标签必须与算法一致:本层算的是「收入 − 变动成本 − 固定成本」⇒ 标签 `营业利润`
(常量 `BOTTOM_LABEL`)。**不称净利润**(未扣所得税/营业外收支/投资收益);
**不称息税前利润**(已扣 6603 财务费用,EBIT 应在其之上)。
"""
from __future__ import annotations
import hashlib
import json
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, or_
from app.models import ImportLog, VoucherDetail
from app.utils.bep_caliber import (CLOSING_SUMMARY_LIKE, caliber_or_none,
finance_income_terms as cal_finance_income_terms,
fixed_cost_spec)
from app.utils.voucher_guard import apply_voucher_guard
# ── 展示口径 ────────────────────────────────────────────────────────────────
UNIT = "万元" # 库内为元,展示 ÷10,000(与 bep_caliber 头注一致)
YUAN_PER_WAN = 10000.0
ROUND_DP = 2
# 勾稽容差(万元):三分量各 2 位舍入,「汇总后舍入」口径下最大漂移 < 0.015 ⇒ 0.02
RECON_TOL_WAN = 0.02
# 末行标签(与算法一致:收入 − 变动成本 − 固定成本)
BOTTOM_LABEL = "营业利润"
BOTTOM_LABEL_NOTE = ("= 营业收入 − 变动成本 − 固定成本;不含营业外收支、投资收益、所得税 "
"⇒ 不标「净利润」;已扣 6603 财务费用 ⇒ 不标「息税前利润」")
RECON_FORMULA = "营业收入 − 变动成本 − 固定成本 = 末行"
FIELD_MAP = {
"debit": lambda: VoucherDetail.debit_amount,
"credit": lambda: VoucherDetail.credit_amount,
"net": lambda: VoucherDetail.debit_amount - VoucherDetail.credit_amount, # 借−贷
# 列名别名(与 dashboard._bep_sum_field / kpi_bep_deriver._sum_field 的调用风格互通,防两套拼写)
"debit_amount": lambda: VoucherDetail.debit_amount,
"credit_amount": lambda: VoucherDetail.credit_amount,
}
_FIELD_FROM_COLUMN = {"debit_amount": "debit", "credit_amount": "credit", "net": "net"}
# 口径指纹涉及的键(变更即变;供报表/回执标注取证)
_DIGEST_KEYS = ("name", "varcost_terms", "fixed_terms", "fixed_filters", "revenue_filter",
"revenue_field", "exclude_closing", "closing_subject")
# ── 通用聚合原语(机制层;与 dashboard._bep_sum_field / kpi_bep_deriver._sum_field 同式同参)──
def subject_clause(kind: str, code: str):
"""科目过滤谓词:('eq', code) 精确 / ('like', prefix) 前缀。"""
if kind == "eq":
return VoucherDetail.subject_code == code
if kind == "like":
return VoucherDetail.subject_code.like(str(code) + "%")
raise ValueError(f"未知科目过滤类型: {kind}")
def sum_by_subject(db, entity_id: int, period_from: str, period_to: str, field: str,
subject_filter: Optional[Tuple[str, str]] = None, *,
subject_filters: Optional[List[Tuple[str, str]]] = None,
exclude_closing: bool = False) -> Dict[str, float]:
"""按科目分组的金额聚合(**元**)→ {subject_code: 金额}。
· field ∈ {debit, credit, net(=借−贷)};`net` 只在 `carry_forward==0` 行域成立
· subject_filter = ('eq', code) / ('like', prefix);subject_filters = [(kind, code), ...] 多科目 OR
· 作废/撤单行经 apply_voucher_guard 剔除(标记表缺失时原样放行并告警一次)
· 返回按科目明细 ⇒ 报表每行可逐行对到真实科目(取数留痕)
"""
expr = FIELD_MAP[field]()
q = db.query(VoucherDetail.subject_code, func.sum(expr)).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 0,
VoucherDetail.period >= period_from,
VoucherDetail.period <= period_to,
)
if subject_filters:
q = q.filter(or_(*[subject_clause(k, c) for k, c in subject_filters]))
elif subject_filter:
q = q.filter(subject_clause(*subject_filter))
if exclude_closing:
q = q.filter(or_(VoucherDetail.summary.is_(None),
~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE)))
q = q.group_by(VoucherDetail.subject_code).order_by(VoucherDetail.subject_code)
q = apply_voucher_guard(db, q, VoucherDetail)
return {str(code): float(amount or 0.0) for code, amount in q.all()}
def sum_field(db, entity_id: int, period_from: str, period_to: str, field: str,
subject_filter: Optional[Tuple[str, str]] = None, *,
subject_filters: Optional[List[Tuple[str, str]]] = None,
exclude_closing: bool = False) -> float:
"""单科目/多科目金额合计(元)—— `sum_by_subject` 的标量包装(供口径对拍)。"""
return float(sum(sum_by_subject(db, entity_id, period_from, period_to, field,
subject_filter, subject_filters=subject_filters,
exclude_closing=exclude_closing).values()))
def sum_terms(db, entity_id: int, period_from: str, period_to: str, terms, *,
exclude_closing: bool = False) -> dict:
"""「带号科目项」合计(元)—— terms = [(sign, code, field), ...]。
· sign='+' 加该科目项发生额;'-' 减之
· code 以 '%' 结尾 ⇒ 前缀匹配(like),否则精确匹配(eq)
· 返回 {'total_yuan': float, 'parts': [{'sign','code','field','match','total_yuan','by_subject'}]}
`parts` 即逐行取数留痕(可逐项对到真实科目与金额)
"""
total = 0.0
parts: List[dict] = []
for sign, code, field in terms:
if sign not in ("+", "-"):
raise ValueError(f"未知符号: {sign}(只支持 +/-)")
raw = str(code)
kind = "like" if raw.endswith("%") else "eq"
code_arg = raw[:-1] if kind == "like" else raw
by = sum_by_subject(db, entity_id, period_from, period_to, field,
(kind, code_arg), exclude_closing=exclude_closing)
sub = float(sum(by.values()))
total += sub if sign == "+" else -sub
parts.append({
"sign": sign, "code": raw, "field": field, "match": kind,
"total_yuan": round(sub, ROUND_DP),
"by_subject": {k: round(v, 2) for k, v in by.items()},
})
return {"total_yuan": total, "parts": parts}
def has_voucher_rows(db, entity_id: int, period_from: str, period_to: str,
exclude_closing: bool = False) -> bool:
"""该账套/期间行域内是否有明细行(无 ⇒ 报表如实标缺,而不是当作 0)。"""
q = db.query(func.count(VoucherDetail.id)).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 0,
VoucherDetail.period >= period_from,
VoucherDetail.period <= period_to,
)
if exclude_closing:
q = q.filter(or_(VoucherDetail.summary.is_(None),
~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE)))
q = apply_voucher_guard(db, q, VoucherDetail)
return bool(q.scalar() or 0)
# ── 口径派生(全部来自 ENTITY_CALIBERS,本层零科目集) ───────────────────────
def revenue_terms(cal: dict) -> list:
"""收入项(带号科目项):由 caliber['revenue_field'/'revenue_filter'] 派生。"""
kind, code = cal["revenue_filter"]
field = _FIELD_FROM_COLUMN.get(cal.get("revenue_field") or "credit_amount", "credit")
return [("+", (str(code) + "%") if kind == "like" else str(code), field)]
def fixed_cost_terms(cal: dict) -> Tuple[list, str]:
"""固定成本科目项:**经 `bep_caliber.fixed_cost_spec()` 唯一解析口**。
优先 `fixed_terms`(带号项,§4-1 后博海形态:仅借方),否则由 `fixed_filters` 派生
(前缀 + 借−贷净额,酣客形态)。
返回 (terms, mode);mode 供报表标注当前口径形态(`fixed_terms` / `fixed_filters(net)`)。
"""
mode, spec = fixed_cost_spec(cal)
if mode == "terms":
return [tuple(t) for t in spec], "fixed_terms"
derived = [("+", str(code) + "%", "net") for _kind, code in spec]
return derived, "fixed_filters(net)"
def caliber_digest(cal: dict) -> str:
"""口径指纹(md5 12 位)—— 口径表变更即变,供报表/回执标注取证。"""
payload = json.dumps({k: cal.get(k) for k in _DIGEST_KEYS},
ensure_ascii=False, sort_keys=True, default=str)
return hashlib.md5(payload.encode("utf-8")).hexdigest()[:12]
def prev_period_of(period: str) -> str:
"""上期(月)—— '2026-01' → '2025-12'。"""
year, month = str(period).split("-")[:2]
y, m = int(year), int(month)
m -= 1
if m <= 0:
m += 12
y -= 1
return f"{y}-{m:02d}"
# ── 取数主入口 ──────────────────────────────────────────────────────────────
def compute_lines(db, entity_id: int, period_from: str, period_to: str) -> dict:
"""按 entity 口径从**真实科目发生额**算管理利润表各行(元 + 万元)。
返回:
available 口径已注册且有明细行(否则 False + missing 说明,禁倒推)
caliber 口径快照(含 digest / 各科目项 ⇒ 报表可逐行对到科目)
raw_yuan 元级原值(revenue/variable_cost/contribution/fixed_cost/result)
values 万元(2 位,**汇总后舍入**:先元级相减,末步落位)
rows 各行的带号科目项明细(parts/by_subject,取数留痕)
"""
out = {
"entity_id": entity_id, "period_from": period_from, "period_to": period_to,
"available": False, "missing": [],
"raw_yuan": {}, "values": {k: None for k in
("revenue", "variable_cost", "contribution", "fixed_cost", "result")},
"rows": {}, "caliber": None,
}
cal = caliber_or_none(entity_id)
if cal is None:
out["missing"].append(
f"entity={entity_id} 未在 ENTITY_CALIBERS 注册口径(禁编造/禁跨账套套用:"
f"固定成本与收入基数按账套而异,见 app/utils/bep_caliber.py)")
return out
exclude_closing = bool(cal.get("exclude_closing"))
fix_terms, fix_mode = fixed_cost_terms(cal)
rev_terms = revenue_terms(cal)
fin_terms = cal_finance_income_terms(cal)
out["caliber"] = {
"entity_id": entity_id,
"name": cal.get("name"),
"margin_label": cal.get("margin_label"),
"margin_desc": cal.get("margin_desc"),
"revenue_terms": [list(t) for t in rev_terms],
"varcost_terms": [list(t) for t in (cal.get("varcost_terms") or [])],
"fixed_cost_terms": [list(t) for t in fix_terms],
"fixed_mode": fix_mode,
"finance_income_terms": [list(t) for t in fin_terms],
"exclude_closing": exclude_closing,
"closing_subject": cal.get("closing_subject"),
"digest": caliber_digest(cal),
"unit": UNIT,
}
if not has_voucher_rows(db, entity_id, period_from, period_to, exclude_closing):
out["missing"].append(
f"entity={entity_id} 期间 {period_from}..{period_to} 无凭证明细行(如实标缺,不倒推)")
return out
rev = sum_terms(db, entity_id, period_from, period_to, rev_terms, exclude_closing=exclude_closing)
var = sum_terms(db, entity_id, period_from, period_to, cal.get("varcost_terms") or [],
exclude_closing=exclude_closing)
fix = sum_terms(db, entity_id, period_from, period_to, fix_terms, exclude_closing=exclude_closing)
# 财务收益(§4-1:博海 6603 贷方)—— 单列备查,**不进**固定成本/末行,不参与勾稽
fin = (sum_terms(db, entity_id, period_from, period_to, fin_terms,
exclude_closing=exclude_closing) if fin_terms
else {"total_yuan": 0.0, "parts": []})
rev_y = rev["total_yuan"]
var_y = var["total_yuan"]
fix_y = fix["total_yuan"]
con_y = rev_y - var_y # 边际贡献
res_y = con_y - fix_y # 末行(营业利润)
out["rows"] = {"revenue": rev, "variable_cost": var, "fixed_cost": fix,
"finance_income": fin}
out["raw_yuan"] = {"revenue": round(rev_y, 2), "variable_cost": round(var_y, 2),
"contribution": round(con_y, 2), "fixed_cost": round(fix_y, 2),
"result": round(res_y, 2)}
# 汇总后舍入:元级相减后一次性落万元,不逐分量舍入相加
out["values"] = {"revenue": round(rev_y / YUAN_PER_WAN, ROUND_DP),
"variable_cost": round(var_y / YUAN_PER_WAN, ROUND_DP),
"contribution": round(con_y / YUAN_PER_WAN, ROUND_DP),
"fixed_cost": round(fix_y / YUAN_PER_WAN, ROUND_DP),
"result": round(res_y / YUAN_PER_WAN, ROUND_DP)}
out["finance_income"] = {
"value": round(fin["total_yuan"] / YUAN_PER_WAN, ROUND_DP),
"raw_yuan": round(fin["total_yuan"], ROUND_DP),
"terms": fin["parts"], "unit": UNIT,
"name": "财务收益(单列备查)",
"basis": ("ENTITY_CALIBERS[entity]['finance_income_terms'](§4-1:6603 贷方 = 现金折扣收益等)"
";单列备查,不进固定成本、不参与勾稽"),
}
out["available"] = True
return out
def import_batches(db, entity_id: int, period: str, limit: int = 5) -> list:
"""数据批次 / 导入时刻(**纯展示备注**,4-5)—— 不参与任何计算。
取自 `import_logs`(该账套 + 该期间),倒序最多 `limit` 条:批次号 / 行数 / 导入时刻。
无记录 ⇒ 空列表(页面不显示备注,不编造批次)。
"""
try:
rows = (db.query(ImportLog)
.filter(ImportLog.entity_id == entity_id, ImportLog.period == period)
.order_by(ImportLog.id.desc()).limit(limit).all())
except Exception: # noqa: BLE001 —— 展示项:取数失败不得影响报表主体
return []
return [{"batch": r.batch,
"rows": int(r.success_rows if r.success_rows is not None else (r.total_rows or 0)),
"total_rows": int(r.total_rows or 0),
"failed_rows": int(r.failed_rows or 0),
"import_type": r.import_type,
"imported_at": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else None,
"filename": r.filename} for r in rows]
def reconcile(revenue: Optional[float], variable_cost: Optional[float],
fixed_cost: Optional[float], result: Optional[float],
tol: float = RECON_TOL_WAN) -> dict:
"""勾稽自检(纯函数):**收入 − 变动成本 − 固定成本 = 末行**。
不平 ⇒ `balanced=False` + `diff`(报表层必须显式标出,**不许静默不平**);
组件缺数 ⇒ `balanced=False` + `components_present=False` + `missing`(同样不静默)。
"""
names = (("revenue", revenue), ("variable_cost", variable_cost),
("fixed_cost", fixed_cost), ("result", result))
missing = [n for n, v in names if v is None]
if missing:
return {"balanced": False, "diff": None, "lhs": None, "rhs": result,
"tolerance": tol, "components_present": False, "missing": missing,
"formula": RECON_FORMULA,
"note": "组件缺数 ⇒ 无法勾稽(显式标缺,不静默平)"}
# 上行已排除 None(missing 为空)⇒ 此处断言收敛类型,供静态检查/防回归
assert revenue is not None and variable_cost is not None \
and fixed_cost is not None and result is not None
lhs = round(revenue - variable_cost - fixed_cost, ROUND_DP)
diff = round(lhs - result, ROUND_DP)
balanced = abs(diff) <= tol
return {"balanced": balanced, "diff": diff, "lhs": lhs, "rhs": result,
"tolerance": tol, "components_present": True, "missing": [],
"formula": RECON_FORMULA,
"note": ("收入 − 变动成本 − 固定成本 = 末行 ✓" if balanced else
f"⚠️ 勾稽不平:收入−变动−固定={lhs},末行={result},差 {diff}(万元)")}
def _calc_chg(cur: Optional[float], prev: Optional[float]) -> Optional[float]:
if cur is not None and prev is not None and prev != 0:
return round((cur - prev) / prev * 100, 2)
return None
def build_items(cur: dict, prev: dict, bottom_label: str = BOTTOM_LABEL) -> list:
"""按管理利润表结构组 5 行(收入/变动成本/边际贡献/固定成本/末行),含环比与占比。
每行附 `terms`(带号科目项)+ `raw_yuan`(元级)⇒ 页面/回执可**逐行对到真实科目**。
口径未注册或无明细行 ⇒ 各行 value 为 None(如实标缺)。
"""
cv = cur.get("values") or {}
pv = prev.get("values") or {}
cre = cur.get("rows") or {}
pre = prev.get("rows") or {}
def terms_of(key: str, src: dict):
row = (cre if src is cur else pre).get(key)
return row.get("parts") if row else None
def raw(key: str) -> Optional[float]:
return (cur.get("raw_yuan") or {}).get(key)
def raw_prev(key: str) -> Optional[float]:
return (prev.get("raw_yuan") or {}).get(key)
def ratio_of(key: str, values: dict) -> Optional[float]:
rev = values.get("revenue")
v = values.get(key)
return round(v / rev * 100, 2) if (v is not None and rev) else None
items = [
{"name": "营业收入", "line_key": "revenue",
"value": cv.get("revenue"), "prev_value": pv.get("revenue"),
"change_rate": _calc_chg(cv.get("revenue"), pv.get("revenue")),
"ratio": 100.0 if cv.get("revenue") is not None else None,
"raw_yuan": raw("revenue"), "prev_raw_yuan": raw_prev("revenue"),
"terms": terms_of("revenue", cur), "prev_terms": terms_of("revenue", prev),
"unit": UNIT, "basis": "按 entity 口径的收入科目贷方发生额(e1=5001 / e2=6001)"},
{"name": "减:变动成本", "line_key": "variable_cost",
"value": cv.get("variable_cost"), "prev_value": pv.get("variable_cost"),
"change_rate": _calc_chg(cv.get("variable_cost"), pv.get("variable_cost")),
"ratio": ratio_of("variable_cost", cv),
"raw_yuan": raw("variable_cost"), "prev_raw_yuan": raw_prev("variable_cost"),
"terms": terms_of("variable_cost", cur), "prev_terms": terms_of("variable_cost", prev),
"unit": UNIT, "basis": "ENTITY_CALIBERS[entity]['varcost_terms'](带号科目项)"},
{"name": "= 边际贡献", "line_key": "contribution", "is_subtotal": True,
"value": cv.get("contribution"), "prev_value": pv.get("contribution"),
"change_rate": _calc_chg(cv.get("contribution"), pv.get("contribution")),
"ratio": ratio_of("contribution", cv),
"raw_yuan": raw("contribution"), "prev_raw_yuan": raw_prev("contribution"),
"derived_from": ["revenue", "variable_cost"], "unit": UNIT,
"basis": "= 营业收入 − 变动成本(元级相减后落万元)"},
{"name": "减:固定成本", "line_key": "fixed_cost",
"value": cv.get("fixed_cost"), "prev_value": pv.get("fixed_cost"),
"change_rate": _calc_chg(cv.get("fixed_cost"), pv.get("fixed_cost")),
"ratio": ratio_of("fixed_cost", cv),
"raw_yuan": raw("fixed_cost"), "prev_raw_yuan": raw_prev("fixed_cost"),
"terms": terms_of("fixed_cost", cur), "prev_terms": terms_of("fixed_cost", prev),
"unit": UNIT,
"basis": ("ENTITY_CALIBERS[entity] 经 bep_caliber.fixed_cost_spec() 解析:"
"fixed_terms=带号科目项(§4-1 博海 6601/6602/6603 仅借方)/ "
"fixed_filters=前缀借−贷净额(酣客 5601/5602/5603)")},
{"name": f"= {bottom_label}", "line_key": "operating_profit", "is_total": True,
"value": cv.get("result"), "prev_value": pv.get("result"),
"change_rate": _calc_chg(cv.get("result"), pv.get("result")),
"ratio": ratio_of("result", cv),
"raw_yuan": raw("result"), "prev_raw_yuan": raw_prev("result"),
"derived_from": ["revenue", "variable_cost", "fixed_cost"], "unit": UNIT,
"label": bottom_label, "label_basis": BOTTOM_LABEL_NOTE,
"basis": RECON_FORMULA},
]
return items
-94
View File
@@ -1,94 +0,0 @@
# -*- coding: utf-8 -*-
"""盈利能力三指标(ROI / EVA / RI)参数单一事实源 —— 口径裁定 2026-10-02
依据(唯一有效出处):财务Bot 2026-10-02 裁定(任总 11:59 CST DM「立;但①要补真ROI」)
任务件 `finance-20261002-profitability-indicators-roi-eva-ri-fullstack.md`
口径(万元):
· 真 ROI(IMA/CMA 口径) = 营业利润 ÷ 投入资本
投入资本 = 有息负债(2001 短期借款 + 2501 长期借款)+ 所有者权益(3xxx 一级科目净额)
· EVA = NOPAT − 投入资本 × WACC ,NOPAT = 经营口径营业利润 × (1 − 所得税率)
· RI = 营业利润 − 投入资本 × 最低报酬率
· 经营口径营业利润 = (5001 − 5401 − 5403 − 5601 − 5602 − 5603) + 厂补(530103) − 渠补(571103)
厂补/渠补**计入经营**(参数开关可回旧口径);571112 商品折扣(销售) 默认**不计入**
参数化纪律(任务单 §二-1「勿硬编码进公式文本」):
所有参数存放于 `kpi_definitions.data_source_config` 的 `params` 子对象,**单一事实源**:
· `F_ROI.data_source_config.params` —— 利润口径开关(三条指标共用)
· `F_EVA.data_source_config.params` —— tax_rate / wacc
· `F_RI.data_source_config.params` —— min_return_rate
本模块是**唯一读取入口**(派生器与只读端点共用),读不到即回落 DEFAULTS。
改参数 = 改 DB 的 JSON 后重跑派生器(无需改代码),见 `scripts/kpi_profitability_deriver.py`。
"""
from __future__ import annotations
import json
from typing import Any, Dict
from app.models import KPIDefinition
#: 三条指标的 kpi_code(与种子 SQL `scripts/sql/profitability_kpi_defs_20261003.sql` 一致)
ROI_KPI = "F_ROI"
EVA_KPI = "F_EVA"
RI_KPI = "F_RI"
PROFITABILITY_KPIS = (ROI_KPI, EVA_KPI, RI_KPI)
#: 出厂默认参数(DB 参数缺失/未解析时的兜底;与裁定 §一 一致)
DEFAULTS: Dict[str, Any] = {
# —— 利润口径开关(三条共用,落 F_ROI.params)——
"include_factory_subsidy": True, # 厂补 530103 计入经营
"include_channel_subsidy": True, # 渠补 571103 计入经营
"include_sales_discount": False, # 571112 商品折扣(销售) 默认不计入(未决开关,任总点头后置 true)
# —— 资本成本参数 ——
"tax_rate": 0.25, # 所得税率(落 F_EVA.params)
"wacc": 0.08, # 加权平均资本成本(落 F_EVA.params)
"min_return_rate": 0.08, # RI 最低报酬率(落 F_RI.params,默认同 WACC)
# —— 口径开关 ——
"annualize": False, # 是否年化(默认 False = YTD 累计口径,公式/备注已声明)
"capital_basis": "ending", # 投入资本口径:ending=期末 / average=平均((期初+期末)/2)
}
def parse_params(raw: Any) -> Dict[str, Any]:
"""把 `data_source_config`(dict / JSON 字符串 / None)解析成 params 子对象。
兼容形态:`{"params": {...}}`(本单约定) / 裸 `{...}`(历史行无 params 包装) / None。
"""
if raw is None:
return {}
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8", "replace")
if isinstance(raw, str):
raw = raw.strip()
if not raw:
return {}
try:
raw = json.loads(raw)
except (ValueError, TypeError):
return {}
if not isinstance(raw, dict):
return {}
params = raw.get("params", raw)
if not isinstance(params, dict):
return {}
return {k: v for k, v in params.items() if v is not None}
def load_profitability_params(db, entity_id: int) -> Dict[str, Any]:
"""读取并合并三条指标行的参数(F_ROI ← F_EVA ← F_RI 逐层覆盖 DEFAULTS)。
读不到定义/参数 ⇒ 对应层不覆盖(DEFAULTS 兜底)。返回的 dict 恒含 DEFAULTS 的全部键。
"""
out = dict(DEFAULTS)
rows = db.query(KPIDefinition.kpi_code, KPIDefinition.data_source_config).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(list(PROFITABILITY_KPIS)),
).all()
by_code = {}
for code, cfg in rows:
by_code[code] = parse_params(cfg)
for code in PROFITABILITY_KPIS:
for k, v in (by_code.get(code) or {}).items():
if k in out:
out[k] = v
return out
-443
View File
@@ -1,443 +0,0 @@
# -*- coding: utf-8 -*-
"""复盘内容生成器 —— 从 live 数据构建「O/KR/KPI 现值 + 偏差 + 关联行动」(2026-10-12)
任务: yanxue-20261012-review-records-write(任总 2026-10-12 P0-1 立单)
背景: `review_records` 表 2026-08-27 建、78 天零行 —— 写入链路(API/前端)已通,
缺的是**产出真实复盘内容的生成器**与**触发口径**。本模块补前者。
设计纪律(为什么这么写):
1. **只读**:本模块只 SELECT,不产生任何写动作;落库由调用方(scripts/review_run.py)
经已部署的 `POST /api/cma/maps/{id}/review-records` 完成 —— 写入路径保持单一。
2. **不另立口径**:
· KPI 现值取数沿用实测区口径 `app.utils.kpi_value_scope.actual_only_clause()`
(预估行不作实测值),与 `maps.py /review`、看板同源;
· 期间过滤沿用 `period <= 当前月`(与 verify 引擎一致,不取未来期间);
· 目标值读 `target_monthly or target_value`(与 maps.py /review 一致)。
3. **方向/色阶按契约**(references/kpi-direction-and-attainment-contract.md):
· `direction IS NULL` **不默认正向** —— 标 `未配方向`,不做达成率判定;
· `unit='状态'` 走 1=达成 / 0、NULL=未达成,不走达成率;
· 目标 0 / 缺失 / 现值缺失 ⇒ 一律 `info`(灰),禁除零;
· **偏差 = 现值 − 目标**(有符号),不依赖方向 ⇒ 全量可比,是本复盘的主判据。
4. 关联行动按**库内既有挂靠**取(ActionPlan.kpi_id / objective_id / kr_id),
不发明新挂靠;挂靠缺失如实计入 `linkage_gaps`(这正是 PDCA 报告指出的「89% 行动未挂 O」)。
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.models import ActionPlan, KPIDefinition, KPIValue, KR, Objective, StrategicMap
from app.utils.kpi_value_scope import actual_only_clause
# 色阶阈值(与 maps.py /review 同口径:绿≥90% / 黄60-90% / 红<60%)
GREEN_RATIO = 0.9
YELLOW_RATIO = 0.6
REVIEW_TYPES = ("monthly", "quarterly")
REVIEW_TYPE_LABEL = {"monthly": "月度运营复盘", "quarterly": "季度战略回顾"}
def _f(v) -> Optional[float]:
"""JSON 列可能回读为 Decimal / str;转不了返回 None(不抛)。"""
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _period_limit(ref: Optional[str] = None) -> str:
"""现值期间上限:'YYYY-MM'(当月及以前;不取未来期间)。"""
if ref:
return str(ref)[:7]
return datetime.now().strftime("%Y-%m")
def _level_from_ratio(ratio: Optional[float]) -> str:
if ratio is None:
return "info"
if ratio >= GREEN_RATIO:
return "green"
if ratio >= YELLOW_RATIO:
return "yellow"
return "red"
def _kpi_reading(kpi: KPIDefinition, actual: Optional[float], target: Optional[float],
period: Optional[str]) -> Dict[str, Any]:
"""单个 KPI 的现值/偏差/判定。方向未配不判定(契约 §2)。"""
direction = (getattr(kpi, "direction", None) or "").strip() or None
out: Dict[str, Any] = {
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit,
"direction": direction or "unset",
"period": period,
"actual_value": actual,
"target_value": target,
"deviation": None,
"deviation_pct": None,
"attainment": None,
"level": "info",
"verdict": "无法判定",
}
if actual is not None and target is not None:
out["deviation"] = round(actual - target, 4)
if target != 0:
out["deviation_pct"] = round((actual - target) / abs(target) * 100, 2)
# 状态类:不走达成率(契约 §2)
if (kpi.unit or "").strip() == "状态":
if actual == 1:
out.update(level="green", verdict="达成")
else:
out.update(level="red", verdict="未达成")
return out
# 零点/空值保护(契约 §3)
if actual is None or target is None or target == 0:
out["verdict"] = "数据不足" if actual is None else "目标缺失/为0"
return out
# 方向未配 ⇒ 不判达成率,只给偏差(契约 §2:禁止默认正向)
if direction is None:
out["verdict"] = "方向未配,仅列现值/偏差"
return out
ratio = target / actual if direction == "lower_better" else actual / target
if ratio is None or ratio != ratio or ratio in (float("inf"), float("-inf")):
out["verdict"] = "数据异常"
return out
out["attainment"] = round(ratio, 4)
out["level"] = _level_from_ratio(ratio)
out["verdict"] = {"green": "达标", "yellow": "接近达标", "red": "未达标"}[out["level"]]
return out
def _latest_kpi_values(db: Session, kpi_ids: List[int], entity_id: int,
limit: str) -> Dict[int, Dict[str, Any]]:
"""每个 KPI 取 period <= 当前月 的最新一条**实测区**值(预估行不作实测)。"""
out: Dict[int, Dict[str, Any]] = {}
for kid in kpi_ids:
v = (
db.query(KPIValue)
.filter(
KPIValue.kpi_id == kid,
KPIValue.entity_id == entity_id,
KPIValue.actual_value.isnot(None),
KPIValue.period <= limit,
actual_only_clause(),
)
.order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc())
.first()
)
if v:
out[kid] = {"actual_value": _f(v.actual_value), "period": v.period,
"source_type": v.source_type, "data_status": v.data_status}
return out
def build_review(db: Session, entity_id: int, map_id: int, review_type: str = "quarterly",
period_ref: Optional[str] = None, review_date: Optional[date] = None,
top_n: int = 8) -> Dict[str, Any]:
"""构建一条复盘记录载荷(只读)。
返回 {summary, adjustments, next_priorities, snapshot, meta}:
· summary 人类可读复盘结论(落 review_records.summary,复盘页可直接读回)
· adjustments 结构化「O/KR/KPI 现值 + 偏差 + 关联行动」清单
(落 review_records.adjustments;条目形如
{type, target, action, current_value, target_value, deviation, level})
· next_priorities 下期优先级(落 review_records.next_priorities)
· snapshot 本次复盘的只读数据快照(供 --print/留痕;DB 侧由 adjustments 承载)
"""
if review_type not in REVIEW_TYPES:
raise ValueError(f"review_type 必须是 {REVIEW_TYPES} 之一,收到 {review_type!r}")
review_date = review_date or datetime.now().date()
limit = _period_limit(period_ref)
m = db.query(StrategicMap).filter(StrategicMap.id == map_id,
StrategicMap.entity_id == entity_id).first()
if not m:
raise ValueError(f"战略地图不存在: map_id={map_id} entity_id={entity_id}")
dims = m.dimensions
if isinstance(dims, str):
import json as _json
dims = _json.loads(dims)
dims = dims or []
# ── 1. 战略地图维度节点(O 的战略面)───────────────────────────────
map_nodes: List[Dict[str, Any]] = []
map_kpi_codes: set[str] = set()
for dim in dims:
for obj in dim.get("objectives") or []:
map_nodes.append({"dimension": dim.get("key"), "dimension_name": dim.get("name"),
"name": obj.get("name"), "kpis": obj.get("kpis") or []})
map_kpi_codes.update(obj.get("kpis") or [])
# ── 2. 目标级 O / KR ─────────────────────────────────────────────
objectives = (
db.query(Objective).filter(Objective.entity_id == entity_id).order_by(Objective.id).all()
)
obj_ids = [o.id for o in objectives]
krs = (
db.query(KR).filter(KR.entity_id == entity_id, KR.objective_id.in_(obj_ids or [0]))
.order_by(KR.id).all()
) if obj_ids else []
obj_by_id = {o.id: o for o in objectives}
kr_kpi_ids = [k.metric_kpi_id for k in krs if k.metric_kpi_id]
# ── 3. 关联行动(按库内既有挂靠:kpi_id / objective_id / kr_id)─────
plans = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).all()
kpi_ids_from_plans = sorted({p.kpi_id for p in plans if p.kpi_id})
kpi_defs = (
db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id,
(KPIDefinition.id.in_(set(kr_kpi_ids) | set(kpi_ids_from_plans)) if
(kr_kpi_ids or kpi_ids_from_plans) else False))
.all()
) if (kr_kpi_ids or kpi_ids_from_plans) else []
if map_kpi_codes:
kpi_defs = kpi_defs + db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(map_kpi_codes),
).all()
seen_kpi_ids: set[int] = set()
kpi_defs = [k for k in kpi_defs if not (k.id in seen_kpi_ids or seen_kpi_ids.add(k.id))]
kpi_ids = [k.id for k in kpi_defs]
kpi_by_id = {k.id: k for k in kpi_defs}
latest = _latest_kpi_values(db, kpi_ids, entity_id, limit)
kpi_rows: List[Dict[str, Any]] = []
for k in sorted(kpi_defs, key=lambda x: (x.kpi_code or "")):
lv = latest.get(k.id) or {}
target = _f(k.target_monthly) if k.target_monthly is not None else _f(k.target_value)
row = _kpi_reading(k, lv.get("actual_value"), target, lv.get("period"))
row["kpi_status"] = k.status
row["kpi_level"] = k.kpi_level
kpi_rows.append(row)
# 关联行动:kpi_id / objective_id / kr_id 任一命中即关联(不发明挂靠)
in_scope_kpi_ids = {r["kpi_id"] for r in kpi_rows}
kr_ids = {k.id for k in krs}
action_rows: List[Dict[str, Any]] = []
for p in plans:
hit = []
if p.kpi_id and p.kpi_id in in_scope_kpi_ids:
hit.append("kpi")
if p.objective_id and p.objective_id in set(obj_ids):
hit.append("objective")
if p.kr_id and p.kr_id in kr_ids:
hit.append("kr")
if not hit:
continue
k = kpi_by_id.get(p.kpi_id)
action_rows.append({
"plan_id": p.id,
"title": p.title,
"status": p.status,
"progress": p.progress or 0,
"assignee": p.assignee,
"due_date": p.due_date.date().isoformat() if p.due_date else None,
"verify_status": p.verify_status,
"linked_by": hit,
"kpi_code": k.kpi_code if k else None,
"kpi_name": k.kpi_name if k else None,
"objective_id": p.objective_id,
"kr_id": p.kr_id,
})
# ── 4. 偏差清单(主判据:现值 − 目标;方向未配者只列偏差不判达标)────
# 可比偏差:目标已设且非 0 且现值可用。目标为 0/未设者单位间不可比,
# 单列 `unset_target`(这本身是「补齐 KPI 目标」的待办,不混进排名)。
_rank = {"red": 0, "yellow": 1, "green": 2, "info": 3}
comparable = [r for r in kpi_rows
if r["deviation"] is not None and (r["target_value"] or 0) != 0]
unset_target = [r for r in kpi_rows if (r["target_value"] or 0) == 0]
# 排序:非达标(红/黄/未配方向不可判)在前,各自按 |偏差%| 降序 —— 复盘的"要点先看"。
_bad = sorted([r for r in comparable if r["level"] != "green"],
key=lambda r: (-abs(r["deviation_pct"] or 0), r["kpi_code"] or ""))
_ok = sorted([r for r in comparable if r["level"] == "green"],
key=lambda r: (-abs(r["deviation_pct"] or 0), r["kpi_code"] or ""))
deviations = _bad + _ok
linkage_gaps = {
"objectives_without_map": sum(1 for o in objectives if not o.strategic_map_id),
"objectives_without_quarter": sum(1 for o in objectives if not (o.quarter or "").strip()),
"krs_without_kpi": sum(1 for k in krs if not k.metric_kpi_id),
"krs_without_due_date": sum(1 for k in krs if not k.due_date),
"krs_progress_zero": sum(1 for k in krs if not (k.progress or 0)),
"plans_total": len(plans),
"plans_linked_objective": sum(1 for p in plans if p.objective_id),
"plans_linked_kr": sum(1 for p in plans if p.kr_id),
"plans_linked_kpi": sum(1 for p in plans if p.kpi_id),
"plans_with_rule": sum(1 for p in plans if p.auto_verify_rule),
"plans_without_due_date": sum(1 for p in plans if not p.due_date),
}
# ── 5. 结构化「调整项 / 现值+偏差+行动」清单(落 adjustments)────────
adjustments: List[Dict[str, Any]] = []
for r in kpi_rows:
linked = [a["title"] for a in action_rows if a["kpi_code"] == r["kpi_code"]]
adjustments.append({
"type": "kpi",
"target": r["kpi_code"],
"target_name": r["kpi_name"],
"action": (";".join(linked) if linked else "未挂行动计划,需补挂责任人与规则"),
"current_value": r["actual_value"],
"target_value": r["target_value"],
"deviation": r["deviation"],
"deviation_pct": r["deviation_pct"],
"period": r["period"],
"unit": r["unit"],
"level": r["level"],
"verdict": r["verdict"],
"linked_plan_ids": [a["plan_id"] for a in action_rows if a["kpi_code"] == r["kpi_code"]],
})
for k in krs:
o = obj_by_id.get(k.objective_id)
adjustments.append({
"type": "kr",
"target": k.title,
"target_name": f"{o.title}/{k.title}" if o else k.title,
"action": (f"目标值 {_f(k.target_value)},现值 {_f(k.current_value)}"
if k.current_value is not None else "尚无实测现值,需接 KPI 或人工填报"),
"current_value": _f(k.current_value),
"target_value": _f(k.target_value),
"deviation": (round(_f(k.current_value) - _f(k.target_value), 4)
if k.current_value is not None and k.target_value is not None else None),
"progress": k.progress or 0,
"status": k.status,
"due_date": k.due_date.date().isoformat() if k.due_date else None,
"level": "info",
"verdict": ("进行中" if (k.progress or 0) > 0 else "无进度"),
})
for o in objectives:
adjustments.append({
"type": "objective",
"target": o.title,
"target_name": o.title,
"action": (f"季度 {o.quarter}" if (o.quarter or "").strip() else "未填季度,需补 SMARTER 的 T"),
"dimension": o.dimension,
"current_value": o.progress,
"target_value": 100,
"progress": o.progress or 0,
"status": o.status,
"owner": o.owner,
"level": "info",
"verdict": "未开工" if not (o.progress or 0) else "进行中",
})
# ── 6. 下期优先级(确定性规则,不用 LLM)────────────────────────────
priorities: List[str] = []
for r in _bad[:3]:
priorities.append(
f"{r['kpi_code']} 偏差 {r['deviation']:+.2f}{r['unit'] or ''}"
f"(现值 {r['actual_value']} / 目标 {r['target_value']},{r['verdict']})"
)
if unset_target:
priorities.append(f"补齐 KPI 目标:{len(unset_target)} 项目标为 0/未设,无目标即无法判达标"
f"(如 {', '.join(r['kpi_code'] for r in unset_target[:4])})")
if linkage_gaps["krs_progress_zero"]:
priorities.append(f"KR 进度回填:{linkage_gaps['krs_progress_zero']}/{len(krs)} 条进度为 0,接 KPI 自动算进度或人工填报")
if linkage_gaps["plans_linked_kr"] < linkage_gaps["plans_total"]:
priorities.append(f"行动挂靠补齐:{linkage_gaps['plans_total'] - linkage_gaps['plans_linked_kr']} 条行动未挂 KR(现挂 KR {linkage_gaps['plans_linked_kr']} 条)")
if linkage_gaps["krs_without_due_date"]:
priorities.append(f"KR 截止日期补齐:{linkage_gaps['krs_without_due_date']}/{len(krs)} 条无 due_date")
if linkage_gaps["objectives_without_quarter"]:
priorities.append(f"目标季度补齐:{linkage_gaps['objectives_without_quarter']}/{len(objectives)} 条未填 quarter")
# ── 7. 人读结论(落 review_records.summary,复盘页时间线直接渲染)────
label = REVIEW_TYPE_LABEL[review_type]
lines: List[str] = []
lines.append(f"【{label}】{m.title}")
lines.append(f"复盘日期 {review_date.isoformat()}|口径:KPI 现值取实测区最新值(period ≤ {limit}),"
f"偏差 = 现值 − 目标(有符号)")
lines.append("")
lines.append(f"一、目标级(小 PDCA)现状:O {len(objectives)} 条、KR {len(krs)} 条;"
f"O 进度非零 {len(objectives) - sum(1 for o in objectives if not (o.progress or 0))} 条、"
f"KR 有进度 {len(krs) - linkage_gaps['krs_progress_zero']} 条、"
f"KR 已挂 KPI {len(krs) - linkage_gaps['krs_without_kpi']} 条。")
dims_summary = ", ".join(f"{n['dimension_name']}({sum(1 for x in map_nodes if x['dimension'] == n['dimension'])})"
for n in [{"dimension": d.get("key"), "dimension_name": d.get("name")} for d in dims])
lines.append(f"  战略地图节点 {len(map_nodes)} 个:{dims_summary}")
lines.append("")
lines.append(f"二、KPI 现值与偏差({len(kpi_rows)} 项纳入复盘;未达标/不可判在前,各按 |偏差%| 降序,"
f"列前 {min(top_n, len(deviations))}):")
if deviations:
for r in deviations[:top_n]:
pct = (f"({r['deviation_pct']:+.1f}%)" if r["deviation_pct"] is not None else "(目标缺失,无百分比)")
lines.append(f" · {r['kpi_code']} {r['kpi_name']}:现值 {r['actual_value']}{r['unit'] or ''}"
f"({r['period']})/ 目标 {r['target_value']}{r['unit'] or ''}"
f" 偏差 {r['deviation']:+.2f}{r['unit'] or ''}"
f"{pct} 判定:{r['verdict']}")
else:
lines.append(" · 本期无可比偏差(无「目标已设且现值可用」的 KPI)")
if unset_target:
lines.append(f" (另 {len(unset_target)} 项 KPI 目标为 0 或未设 ⇒ 无可比偏差,需先补目标:"
+ ", ".join(r["kpi_code"] for r in unset_target[:6])
+ ("…" if len(unset_target) > 6 else "") + ")")
no_dir = [r for r in kpi_rows if r["direction"] == "unset"]
if no_dir:
lines.append(f" (其中 {len(no_dir)} 项 KPI 未配方向,按契约只列现值/偏差、不作达标判定:"
+ ", ".join(r["kpi_code"] for r in no_dir[:6])
+ ("…" if len(no_dir) > 6 else "") + ")")
lines.append("")
lines.append(f"三、关联行动:命中 {len(action_rows)} 条(挂 KPI {sum(1 for a in action_rows if 'kpi' in a['linked_by'])}、"
f"挂 O {sum(1 for a in action_rows if 'objective' in a['linked_by'])}、"
f"挂 KR {sum(1 for a in action_rows if 'kr' in a['linked_by'])});"
f"状态 completed {sum(1 for a in action_rows if a['status'] == 'completed')} / "
f"in_progress {sum(1 for a in action_rows if a['status'] == 'in_progress')} / "
f"pending {sum(1 for a in action_rows if a['status'] == 'pending')}")
lines.append(f"  全量行动 {linkage_gaps['plans_total']} 条中:挂 O {linkage_gaps['plans_linked_objective']} 条、"
f"挂 KR {linkage_gaps['plans_linked_kr']} 条、已配验证规则 {linkage_gaps['plans_with_rule']} 条、"
f"无截止日期 {linkage_gaps['plans_without_due_date']} 条")
lines.append("")
lines.append("四、为什么没达成 / 关键偏差:")
if deviations:
top = deviations[0]
lines.append(f" · 最大偏差项 {top['kpi_code']}:{top['verdict']},偏差 {top['deviation']:+.2f}{top['unit'] or ''}"
f"(现值 {top['actual_value']} vs 目标 {top['target_value']})")
lines.append(f" · 结构性问题:O 未挂战略地图 {linkage_gaps['objectives_without_map']} 条、"
f"未填季度 {linkage_gaps['objectives_without_quarter']} 条;KR 无 KPI 挂靠 {linkage_gaps['krs_without_kpi']} 条、"
f"无截止日期 {linkage_gaps['krs_without_due_date']} 条;行动未挂 KR {linkage_gaps['plans_total'] - linkage_gaps['plans_linked_kr']} 条")
lines.append("")
lines.append("五、下期优先级:")
for i, p in enumerate(priorities, 1):
lines.append(f" {i}. {p}")
summary = "\n".join(lines)
snapshot = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"entity_id": entity_id,
"map_id": map_id,
"map_title": m.title,
"review_type": review_type,
"review_date": review_date.isoformat(),
"period_limit": limit,
"map_nodes": map_nodes,
"kpis": kpi_rows,
"kpis_unset_target": [r["kpi_code"] for r in unset_target],
"objectives": [{"id": o.id, "title": o.title, "dimension": o.dimension,
"quarter": o.quarter, "status": o.status, "progress": o.progress,
"owner": o.owner} for o in objectives],
"krs": [{"id": k.id, "objective_id": k.objective_id, "title": k.title,
"metric_kpi_id": k.metric_kpi_id, "target_value": _f(k.target_value),
"current_value": _f(k.current_value), "progress": k.progress,
"status": k.status} for k in krs],
"actions": action_rows,
"linkage_gaps": linkage_gaps,
}
return {"summary": summary, "adjustments": adjustments,
"next_priorities": priorities, "snapshot": snapshot,
"meta": {"entity_id": entity_id, "map_id": map_id, "review_type": review_type,
"review_date": review_date.isoformat(), "period_limit": limit,
"kpi_count": len(kpi_rows), "action_count": len(action_rows)}}
-128
View File
@@ -1,128 +0,0 @@
# -*- coding: utf-8 -*-
"""路由键归一与候选键派生(KBFF-v11c,2026-10-12)
背景(隐患):
知识文章列表接口原用 ``KnowledgeArticle.related_page.contains(related_page)``
⇒ SQL ``LIKE '%<prop>%'`` **子串包含**。prop 取短键/前缀即超集命中:
prop=``/maps`` 会同时命中 ``/maps/canvas`` 与 ``/maps-review``;prop=``/kpi`` 命中 ``/kpis``。
口径(本模块 = related_page 匹配口径的**唯一实现处**):
匹配 = 「归一化候选键集合的**精确等值**」∪「模板子路由(``<键>/:<占位符>``)」。
调用方只传原始路由串,**不得**自行截断/拼接/大小写变换 —— 否则会出现第二套口径。
归一化规则(``normalize_route``):
① 去首尾空白;丢弃 ``?query`` 与 ``#fragment``;
② 反斜杠 → ``/``;压缩连续 ``/``;补前导 ``/``;
③ 去末尾 ``/``(根 ``/`` 保留);
④ 统一小写(CMA 前端路由均小写定义,容忍手工录入大小写差异)。
候选键(``candidate_route_keys``)—— 带参路由兼容:
入参可能是**具体实例路径**(如 ``/maps/canvas/42``),而表内存的是**页面身份**
(``/maps/canvas``)或**模板形式**(``/maps/canvas/:id``,见模型注释)。
故候选键 = 归一化完整路径 ∪ 父路径(末段为变量时)∪ 各自模板形式,
匹配走 ``related_page IN (候选键)`` —— 全程等值,无任意子串包含。
变量段判据:纯数字 / 长十六进制(≥8) / UUID。
占位符命名不统一时(他方可能写 ``:articleId``),由调用方叠加
``route_template_child_pattern(key)`` 给出的前缀模式兜住,见该函数。
"""
from __future__ import annotations
import re
from typing import List, Optional, Tuple
#: 变量段占位符(与 app/models/knowledge_article.py 注释 ``如 /maps/canvas/:id`` 同约定)
PARAM_PLACEHOLDER = ":id"
_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
_HEX_RE = re.compile(r"^[0-9a-f]{8,}$")
_NUM_RE = re.compile(r"^\d+$")
_MULTI_SLASH_RE = re.compile(r"/{2,}")
def normalize_route(path: Optional[str]) -> str:
"""把任意路由串归一为比较键。不可归一时返回空串(调用方须按“无命中”处理)。"""
if path is None:
return ""
s = str(path).strip()
if not s:
return ""
# 丢弃 query / fragment(保留原有 path 语义)
s = s.split("?", 1)[0].split("#", 1)[0]
s = s.replace("\\", "/").strip()
if not s:
return ""
if not s.startswith("/"):
s = "/" + s
s = _MULTI_SLASH_RE.sub("/", s)
s = s.lower()
if len(s) > 1:
s = s.rstrip("/")
return s or "/"
def is_variable_segment(seg: str) -> bool:
"""段是否形如变量实例(数字 / 长十六进制 / UUID)。"""
if not seg:
return False
if _UUID_RE.match(seg):
return True
if _NUM_RE.match(seg):
return True
return bool(_HEX_RE.match(seg))
def is_dynamic_segment(seg: str) -> bool:
"""段是否「动态」:变量实例(``42``)或占位符本身(``:id`` / ``:articleId``)。
用于从动态末段回推页面身份(父路径),使「传具体实例」与「传模板串」两种调用姿势
都能命中表内的页面身份键。
"""
return seg.startswith(":") or is_variable_segment(seg)
def _split(path: str) -> List[str]:
return [p for p in path.split("/") if p]
def template_route(path: str) -> str:
"""把变量段替换为占位符,得到模板形式(``/maps/canvas/42`` → ``/maps/canvas/:id``)。"""
segs = _split(path)
if not segs:
return path
return "/" + "/".join(PARAM_PLACEHOLDER if is_variable_segment(s) else s for s in segs)
def candidate_route_keys(path: Optional[str]) -> Tuple[str, ...]:
"""归一化候选键集合(已排序去重)。空元组 = 不可归一 ⇒ 无命中。"""
key = normalize_route(path)
if not key:
return ()
keys = {key}
segs = _split(key)
if segs:
keys.add(template_route(key))
# 末段是动态段(变量实例 / 占位符)⇒ 补父路径(及父路径的模板形式)
if is_dynamic_segment(segs[-1]) and len(segs) > 1:
parent = "/" + "/".join(segs[:-1])
keys.add(parent)
keys.add(template_route(parent))
return tuple(sorted(keys))
def escape_like(value: str) -> str:
"""转义 LIKE 元字符(配合 ``escape='\\\\'`` 使用)。"""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def route_template_child_pattern(key: str) -> str:
"""模板子路由前缀模式:``<键>/:<任意占位符>``(含更深层级)。
``/maps/canvas`` → ``/maps/canvas/:%`` —— 只命中“把该页声明为模板”的存储值
(``/maps/canvas/:id`` / ``/maps/canvas/:articleId``),
不会命中兄弟页(``/maps-review`` 之类),也不命中更深的静态页
(``/maps/canvas/detail`` 不含前导 ``:``)。
"""
return escape_like(key) + "/:%"
-228
View File
@@ -1,228 +0,0 @@
# -*- coding: utf-8 -*-
"""战略地图「活看板」色阶口径 —— 单一事实源(任务 finance-20261011-strategy-map-frontend-fullstack)
口径依据(逐字,来源见任务件 §三/§四 与 D1 裁定):
| 层 | dimension_key | 层色 |
|---|---|---|
| 财务(滞后) | finance | #F56C6C |
| 客户(过渡) | customer | #409EFF |
| 流程(领先) | process | #67C23A |
| 学习(根本驱动) | learning | #E6A23C |
色阶(§四):
r = 实际值 / 目标值
r >= 1.0 -> success #16A34A
r >= 0.8 -> warning #EAB308
r < 0.8 -> danger #DC2626
无实际值 -> info #94A3B8
特例(**必须**按此三类处理,不要按 direction 字段过滤):
- §4.1 逆指标 ⇒ 取倒数再算:`INVERSE_KPI_IDS` **显式 code/ID 白名单**。
不用 `direction='lower_better'` 过滤 —— 库内 `#230 EXT_139` / `#23 C_CUST_CONCENTRATION`
的 `direction` 为 **NULL**(dama 知会件 `dama-20261011-strategy-map-direction-contract-fyi`),
照 direction 过滤会把这两条逆指标错算成正向。
- §4.2 状态类 ⇒ 不走达成率:判据是 **`unit == '状态'`**(不是 `direction IS NULL`,
逆指标 #230/#23 的 direction 同样是 NULL)。约定 **1 ⇒ success;0 / NULL ⇒ danger**,
并且**绝不**做 `current / target`(避免除零)。
- §4.3 零点保护:目标值 0 / 缺失 ⇒ info(灰),禁止 NaN / Infinity / 除零崩溃。
本模块为纯函数(无 DB / 无框架依赖),供 `api/maps.py` 与单测共用。
"""
from __future__ import annotations
from typing import Any, Iterable, Optional
# ── 层色规范(§三,固定,不可自定义)────────────────────────────
LAYER_META: dict[str, dict[str, str]] = {
"finance": {"key": "finance", "name": "财务(滞后)", "color": "#F56C6C"},
"customer": {"key": "customer", "name": "客户(过渡)", "color": "#409EFF"},
"process": {"key": "process", "name": "流程(领先)", "color": "#67C23A"},
"learning": {"key": "learning", "name": "学习(根本驱动)", "color": "#E6A23C"},
}
#: 展示顺序:自下而上 学习 → 流程 → 客户 → 财务(§五.1)
LAYER_DISPLAY_ORDER: tuple[str, ...] = ("learning", "process", "customer", "finance")
#: 色阶四级色(§四)
LEVEL_COLORS: dict[str, str] = {
"success": "#16A34A",
"warning": "#EAB308",
"danger": "#DC2626",
"info": "#94A3B8",
}
#: 逆指标白名单(§4.1 + D1 裁定 §二:CCC(504) / 库存(511) / 短期借款 EXT_139(230) / 客户集中度(23))
#: 显式写死,不从 direction 推导(理由见模块 docstring)
INVERSE_KPI_IDS: frozenset[int] = frozenset({504, 511, 230, 23})
#: 状态类判据(§4.2:按 unit 判,不按 direction 判)
STATUS_UNIT = "状态"
#: 达成率阈值(§四)
WARNING_RATE = 0.8
SUCCESS_RATE = 1.0
#: 取值状态优先级(同期多行时取更可信的一条)
STATUS_RANK: dict[str, int] = {"verified": 3, "calculated": 2, "pending": 1}
_SUB_PERIOD_MONTHS = {"Q1": 3, "Q2": 6, "Q3": 9, "Q4": 12, "H1": 6, "H2": 12}
class StrategyBoardError(ValueError):
"""口径层显式错误(不静默兜底)"""
def period_rank(period: Optional[str]) -> int:
"""期间可比排序键:统一折成「年内月序」再比,避免字典序把 `2026-H1` 排在 `2026-08` 之后。
- ``YYYY-MM`` → year*12 + MM
- ``YYYY-Qn`` → year*12 + 3n
- ``YYYY-Hn`` → year*12 + 6n
- ``YYYY`` → year*12 + 12
无法解析 → 0(排在所有可解析期间之前,不参与「最新期」竞争)。
"""
if not period:
return 0
raw = str(period).strip()
parts = raw.replace("/", "-").split("-")
try:
year = int(parts[0])
except (ValueError, IndexError):
return 0
if len(parts) < 2:
return year * 12 + 12
sub = parts[1].strip().upper()
if sub.isdigit():
month = int(sub)
if 1 <= month <= 12:
return year * 12 + month
return 0
return year * 12 + _SUB_PERIOD_MONTHS.get(sub, 0)
def status_rank(data_status: Optional[str]) -> int:
"""取值状态优先级(同一期间多行时优先 verified)"""
return STATUS_RANK.get((data_status or "").strip().lower(), 0)
def pick_latest(rows: Iterable[Any], *, period_attr: str = "period",
status_attr: str = "data_status", id_attr: str = "id") -> Optional[Any]:
"""从(已按口径过滤的)实测区行里挑「最新期 + 同期间内最可信」的一条。
排序键 = (期间序, 取值状态序, id)。全部无法解析期间时退回 id,绝不返回随机行。
"""
best = None
best_key = None
for row in rows:
key = (
period_rank(getattr(row, period_attr, None)),
status_rank(getattr(row, status_attr, None)),
getattr(row, id_attr, 0) or 0,
)
if best_key is None or key > best_key:
best, best_key = row, key
return best
def is_status_kpi(unit: Optional[str]) -> bool:
"""状态类判据:unit == '状态'(§4.2)"""
return (unit or "").strip() == STATUS_UNIT
def is_inverse_kpi(kpi_id: Optional[int]) -> bool:
"""逆指标判据:显式白名单(§4.1)"""
return kpi_id is not None and int(kpi_id) in INVERSE_KPI_IDS
def compute_level(
*,
kpi_id: Any = None,
unit: Any = None,
current: Any = None,
target: Any = None,
) -> dict[str, Any]:
"""算单节点色阶。返回 ``{level, color, rate, mode, reason}``。
- ``mode``:``status`` / ``inverse`` / ``normal``
- ``rate``:达成率(状态类与灰灯为 ``None``)
- ``reason``:灰灯/非达成率的原因(供前端 tooltip 与复核用)
任何入参形态(None / 0 / 负数 / 非数值字符串)都不得抛异常、不得返回 NaN/Inf。
"""
mode = "status" if is_status_kpi(unit) else ("inverse" if is_inverse_kpi(kpi_id) else "normal")
out: dict[str, Any] = {"level": "info", "color": LEVEL_COLORS["info"], "rate": None,
"mode": mode, "reason": None}
# ── §4.2 状态类:不走达成率 ──
if mode == "status":
if current is None:
# 件 §4.2 逐字:「0 / NULL = 未达成 ⇒ 红」
out.update(level="danger", color=LEVEL_COLORS["danger"], reason="状态未填报(NULL ⇒ 未达成)")
return out
try:
val = float(current)
except (TypeError, ValueError):
out.update(level="danger", color=LEVEL_COLORS["danger"], reason="状态值非法 ⇒ 未达成")
return out
if val == 1:
out.update(level="success", color=LEVEL_COLORS["success"], reason="状态=1(已达成)")
else:
out.update(level="danger", color=LEVEL_COLORS["danger"], reason=f"状态={val:g}(未达成)")
return out
cur = _to_finite(current)
tgt = _to_finite(target)
# ── §4.3 零点保护 / 无实际值 ──
if cur is None:
out["reason"] = "无实际值"
return out
if tgt is None or tgt == 0:
out["reason"] = "目标值缺失或为 0"
return out
if mode == "inverse":
if cur == 0:
# 逆指标现值为 0 ⇒ 倒数无定义,按零点保护置灰(禁除零)
out["reason"] = "逆指标现值为 0(倒数无定义)"
return out
rate = tgt / cur
else:
rate = cur / tgt
if rate != rate or rate in (float("inf"), float("-inf")): # NaN / Inf 双保险
out["reason"] = "达成率不可计算"
return out
out["rate"] = round(rate, 4)
if rate >= SUCCESS_RATE:
out.update(level="success", color=LEVEL_COLORS["success"])
elif rate >= WARNING_RATE:
out.update(level="warning", color=LEVEL_COLORS["warning"])
else:
out.update(level="danger", color=LEVEL_COLORS["danger"])
return out
def _to_finite(value: Any) -> Optional[float]:
"""容错取数:None / 空串 / 非数值 → None;NaN / Inf → None(禁入算)"""
if value is None or value == "":
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or num in (float("inf"), float("-inf")):
return None
return num
def layer_meta(dimension_key: Optional[str]) -> dict[str, str]:
"""层元信息;未知 dimension_key 归入 info 灰的「未分层」占位(不抛错、不丢节点)"""
key = (dimension_key or "").strip()
if key in LAYER_META:
return LAYER_META[key]
return {"key": key or "unknown", "name": key or "未分层", "color": LEVEL_COLORS["info"]}
-25
View File
@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
"""A3 产品切片正例:2026-07(源一致性通过)逐产品核对。"""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for line in open('/root/cma-management/backend/.env'):
line = line.strip()
if '=' in line and not line.startswith('#') and line.split('=')[0].startswith('CMA_DB'):
k, v = line.split('=', 1); os.environ.setdefault(k, v)
from app.database import get_session_local
from app.api.map_dimensions import build_slice
db = get_session_local()()
for prod in ("酣客酱酒", "禧酱", "标准版"):
r = build_slice(db, 1, 65, "2026-07", None, prod)
rev = [k for o in r["objectives"] for k in o["kpis"] if k["kpi_code"] == "F_REVENUE"][0]
print(prod, "| empty:", r["empty"], "|", r["empty_reason"], "| rev:", rev["actual_value"], "/", rev["target_value"], rev["achievement_pct"], rev["aggregation"], rev["periods_used"], "| note:", rev["reason"])
r = build_slice(db, 1, 65, "2026-Q3", None, "酣客酱酒")
rev = [k for o in r["objectives"] for k in o["kpis"] if k["kpi_code"] == "F_REVENUE"][0]
print("Q3 酣客酱酒 | empty:", r["empty"], "| rev:", rev["actual_value"], "/", rev["target_value"], rev["achievement_pct"], rev["aggregation"], rev["periods_used"], "| note:", rev["reason"])
r = build_slice(db, 1, 65, None, None, None)
print("默认(最新期):", r["slice"], [ (k["kpi_code"],k["actual_value"]) for o in r["objectives"] for k in o["kpis"] if k["available"]][:6])
print("store 空态:", build_slice(db, 1, 65, "2026-07", "一店", None)["empty_reason"])
db.close()
-46
View File
@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
"""A3 自测:直接调 build_slice(不启服务),校验真实库切片与空态。"""
import os, sys, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
for line in open('/root/cma-management/backend/.env'):
line = line.strip()
if '=' in line and not line.startswith('#') and line.split('=')[0].startswith(('CMA_DB', 'ERP_DB')):
k, v = line.split('=', 1); os.environ.setdefault(k, v)
from app.database import get_session_local
from app.api.map_dimensions import build_slice, _time_options, _store_options, _product_options
db = get_session_local()()
def brief(r):
print("slice:", r["slice"], "| empty:", r["empty"], "|", r["empty_reason"])
for o in r["objectives"]:
ks = [f'{k["kpi_code"]}={k["actual_value"]}/{k["target_value"]}@{k["achievement_pct"]}%({k["level"]},{k["aggregation"]}){"" if k["available"] else "[空:"+str(k["reason"])[:36]+"]"}' for k in o["kpis"]]
if ks:
print(" ", o["dimension_key"], o["name"], "|", "; ".join(ks))
print("=== 1) 2026-08 月切片 (map 65)")
brief(build_slice(db, 1, 65, "2026-08", None, None))
print("\n=== 2) 2026-Q3 季切片")
brief(build_slice(db, 1, 65, "2026-Q3", None, None))
print("\n=== 3) 2026 年切片")
brief(build_slice(db, 1, 65, "2026", None, None))
print("\n=== 4) 产品切片 酣客酱酒 @2026-08")
brief(build_slice(db, 1, 65, "2026-08", None, "酣客酱酒"))
print("\n=== 5) 产品切片 不存在产品 @2026-08")
brief(build_slice(db, 1, 65, "2026-08", None, "不存在的产品X"))
print("\n=== 6) 门店切片 一店 @2026-08")
brief(build_slice(db, 1, 65, "2026-08", "一店", None))
print("\n=== 7) entity 隔离: map65 + entity2")
try:
build_slice(db, 2, 65, "2026-08", None, None)
print("FAIL 未拦截")
except Exception as e:
print("OK 拦截:", type(e).__name__, getattr(e, "status_code", ""), getattr(e, "detail", e))
print("\n=== 8) options map65")
o = build_slice(db, 1, 65, None, None, None)
print("time dims:", json.dumps(o["dims"]["time"], ensure_ascii=False)[:300])
print("store:", json.dumps(o["dims"]["store"], ensure_ascii=False)[:300])
print("product:", json.dumps(o["dims"]["product"], ensure_ascii=False)[:300])
db.close()
+47 -214
View File
@@ -15,20 +15,11 @@ from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog
from app.utils.oplog import log_op
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
# 口径双轨(唯一事实源 app/utils/caliber_units.RATIO_CALIBER_TABLE):
# · `CMA-CALIBER-v2.1/kpi-unit@2026-09-24`(wecom-finance/reports/CMA_口径裁定_20260924_比率类单位口径_取代2a两指标.md):
# `F_CURRENT_RATIO` / `F_QUICK_RATIO` 改「倍」(无量纲、同量纲直判,**不再 ×100**);
# · 裁定2a(2026-09-17 财务口径,wecom-finance/reports/CMA_口径裁定_20260917.md):其余比率类仍「百分比(%)」(取数×100);
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
# 未核对/疑似单位错配的项降级待核(不落预警、不推送)
from app.utils.caliber_units import resolve_caliber
# 裁定 R1/R2/R3(2026-09-19 财务口径):账套范围(demo 排除)/ 期间窗口 / 规整月期间
from app.utils.alert_entity_scope import (
ALERT_WINDOW_MONTHS, excluded_entity_ids, is_month_period, kpi_excluded,
period_in_window, window_lower_period,
)
logger = logging.getLogger("cma.alert_gen")
@@ -44,15 +35,17 @@ def resolve_latest_period(db_session, kpi_id=None):
也不被别人(他账套/演示账套)更新的新期间带偏——实证:全局 MAX(period)=2026-09
但那是演示账套 e6 的 8 条数据,酣客(e1) 的最新可用期间是 2026-08。
2026-09-25(ASK-20260924-15):实现**收口到唯一事实源**
`app.utils.alert_entity_scope.resolve_latest_available_period`——原实现与
`deviation_engine` 的期间口径各自一份,导致差异/趋势入口仍硬编码当月。
语义差异(有意):判据由本文件旧正则 `^[0-9]{4}-[0-9]{2}$`(会接受 `2026-13`
这类非法月)改为 R3 单一正则 `is_month_period`(严格 01–12);现网数据无
非法月期间,故行为逐项一致(本单 A/B 实测)。
实现说明:伪期间过滤在 **Python 侧**做(`SELECT DISTINCT period` 只有十几行),
不依赖 MySQL 的 `REGEXP` —— SQLite 测试库不注册 REGEXP,用 SQL 侧正则会让
单测与生产走两套语义。
"""
from app.utils.alert_entity_scope import resolve_latest_available_period
return resolve_latest_available_period(db_session, kpi_id)
q = db_session.query(KPIValue.period).filter(KPIValue.actual_value.isnot(None))
if kpi_id is not None:
q = q.filter(KPIValue.kpi_id == kpi_id)
for (p,) in q.distinct().order_by(KPIValue.period.desc()).all():
if _PERIOD_RE.fullmatch(str(p or "")):
return p
return None
def _parse_threshold(expr: str) -> tuple:
@@ -92,94 +85,22 @@ def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
return True, msg
def action_plan_entity_guard(db_session, *, kpi, alert, stats: Optional[dict] = None) -> bool:
"""写前同源校验(2026-09-22 派生单 B4)—— 建行动计划的账套归属门。
口径照抄同族守卫姿势(`app/api/bot_bridge_v2.py` 的 R1 账套守卫 / B3(b) `skip_alert` 留痕):
· 来源账套 = KPI 定义归属(`kpi.entity_id`);
· 目标账套 = **同一次事务里刚建的预警归属**(`alert.entity_id`,即本次遍历账套);
· 两者都存在且相等 → 返回 True(调用方建计划并**显式写** `entity_id=kpi.entity_id`);
· 任一为空 / 不相等 → 返回 False:**不建计划**、落 `OperationLog(action="skip_plan")` 留痕、
计入统计输出;**不 raise、不中断流水线**(预警与后续 KPI 照常处理)。
为什么必须设门而不是「照传 None」:`action_plans.entity_id` 为 NOT NULL DEFAULT 1
(模型 `default=1` 与生产 DDL 双默认)——传空值会被**静默兜底到账套 1**,
正是「酣客(e1) 工作台渲染博海(e2)/演示(e6) KPI 名」的成因(同事务内预警归属正确、
计划归属落 1,只有 KPI 不属于 e1 时暴露)。
"""
kpi_entity_id = getattr(kpi, "entity_id", None)
target_entity_id = getattr(alert, "entity_id", None)
if (kpi_entity_id is not None and target_entity_id is not None
and int(kpi_entity_id) == int(target_entity_id)):
return True
st = stats if stats is not None else {}
st["skipped_plan_entity_mismatch"] = st.get("skipped_plan_entity_mismatch", 0) + 1
st.setdefault("skipped_plan_kpis", []).append(
"%s[kpi_e=%s→plan_e=%s]" % (getattr(kpi, "kpi_code", None), kpi_entity_id, target_entity_id))
db_session.add(OperationLog(
user_id=None,
action="skip_plan",
target_type="action_plan",
target_id=getattr(alert, "id", None),
detail={
"reason": "plan_entity_attribution_mismatch",
"kpi_id": getattr(kpi, "id", None),
"kpi_code": getattr(kpi, "kpi_code", None),
"kpi_entity_id": kpi_entity_id,
"entity_id": target_entity_id,
"alert_id": getattr(alert, "id", None),
"source": "scripts.alert_generator.run_alert_check",
},
))
logger.warning(
"B4 账套守卫:跳过建计划(KPI 归属=%s / 目标账套=%s 不一致或为空;"
"不再静默落默认账套 1)kpi_code=%s alert_id=%s",
kpi_entity_id, target_entity_id, getattr(kpi, "kpi_code", None), getattr(alert, "id", None))
return False
def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = False,
stats: Optional[dict] = None, now: Optional[datetime] = None) -> int:
stats: Optional[dict] = None) -> int:
"""检查所有KPI的实际值是否触发预警,返回(将)新生成的预警数
2026-09-17 财务口径裁定 2a/2b(2a 的两项已由 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24` 取代为倍口径):
2026-09-17 财务口径裁定 2a/2b:
· **期间(2b)**:period=None → 每个 KPI 回落「最近可用期间」(自己的最新 YYYY-MM,
排除 H1/H2 伪期间),不再硬编码当月(当月无值 → 永久 0 新预警);
· **单位口径**:判定前经 resolve_caliber()(唯一事实源 `RATIO_CALIBER_TABLE`)——
`F_CURRENT_RATIO`/`F_QUICK_RATIO` 按 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24` **倍口径同量纲直判**
(factor=1,与 DB 新阈值 2.0/1.0 与 1.0/0.5 同量纲);其余比率类仍按裁定2a 归一到「百分比(%)」(取数×100);
**未核对/疑似单位错配**的项降级待核 → 不落预警、不推送;
· **单位(2a)**:判定前经 resolve_caliber() 归一到「百分比(%)」口径(取数×100,
与阈值 150/80 同量纲);**未核对/疑似单位错配**的项降级待核 → 不落预警、不推送;
· dry_run=True → 只计算不写库(抽查复核与放行前证据用)。
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope` 单一事实源):
· **R1 账套**:`status='demo'` 的账套(演示账套)不参与遍历 → 跳过并计
`skipped_demo_kpis`;
· **R2 期间窗口**:仅生成「当月往前 6 个自然月(含当月)」内的预警;更早期间跳过
(`out_of_window`),与 2b 叠加 = max(各 KPI 最近可用期间, 当月-6月);
· **R3 伪期间**:仅规整月期间 YYYY-MM 参与,H1/H2/FY 跳过(`skipped_pseudo_period`)。
· now 参数仅用于口径复算/测试(缺省=系统当前时间)。
2026-09-22(本单·第 4 处写入点):red/yellow 自动建计划前经 `action_plan_entity_guard`
写前同源校验,并**显式写** `ActionPlan.entity_id = kpi.entity_id` —— 修复前不传归属、
由模型 default=1 / 生产 DDL DEFAULT 1 静默兜底,导致非 e1 账套的 KPI 触发时落跨账套计划。
"""
st = stats if stats is not None else {}
window_lower = window_lower_period(now)
excluded_eids = excluded_entity_ids(db_session)
for k, v in (("period_source", "fixed" if period else "per_kpi_latest"), ("period_fixed", period),
("periods_used", {}), ("normalized", 0), ("normalized_kpis", []),
("dimension_suspect", 0), ("suspect_kpis", []), ("no_period", 0),
("no_value", 0), ("planned", []), ("skipped_existing", 0),
# R1/R2/R3 口径证据
("excluded_demo_entities", sorted(excluded_eids)),
("window_lower_period", window_lower),
("window_months", ALERT_WINDOW_MONTHS),
("skipped_demo_kpis", 0), ("demo_kpis", []),
("skipped_pseudo_period", 0), ("pseudo_period_kpis", []),
("out_of_window", 0), ("out_of_window_kpis", []),
# 2026-09-22 计划归属守卫(B4)证据
("skipped_plan_entity_mismatch", 0), ("skipped_plan_kpis", [])):
("no_value", 0), ("planned", []), ("skipped_existing", 0)):
st.setdefault(k, v)
kpis = db_session.query(KPIDefinition).filter(
@@ -188,12 +109,6 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
new_count = 0
for kpi in kpis:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if kpi_excluded(db_session, kpi, excluded_eids):
st["skipped_demo_kpis"] += 1
st["demo_kpis"].append("%s/e%s" % (kpi.kpi_code, kpi.entity_id or 1))
continue
# 跳过无阈值的KPI
thr = {
"red": kpi.threshold_red,
@@ -210,21 +125,6 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
logger.warning(f" 跳过(无可用期间): {kpi.kpi_name} 无任何 YYYY-MM 实测值")
continue
# R3(裁定 2026-09-19):仅规整月期间 YYYY-MM 参与,伪期间一律不参与
if not is_month_period(p):
st["skipped_pseudo_period"] += 1
st["pseudo_period_kpis"].append("%s[%s]" % (kpi.kpi_code, p))
logger.warning(" 跳过(伪期间 R3): %s 期间=%s 非 YYYY-MM" % (kpi.kpi_name, p))
continue
# R2(裁定 2026-09-19):期间窗口 = 当月往前 6 个自然月(含当月),更早不新生成
if str(p) < window_lower:
st["out_of_window"] += 1
st["out_of_window_kpis"].append("%s[%s]" % (kpi.kpi_code, p))
logger.info(" 跳过(期间超出窗口 R2,窗口下限 %s): %s[%s]" % (
window_lower, kpi.kpi_name, p))
continue
# 获取该KPI该期间的最新值
latest = db_session.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
@@ -235,7 +135,7 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
st["no_value"] += 1
continue
# 单位口径(v2.1 倍口径直判 / 2a 归一):resolve_caliber 单一入口 + 疑似错配降级门
# 单位口径(裁定2a):取数归一 + 疑似错配降级门
cal = resolve_caliber(latest.actual_value, kpi)
if cal["status"] == "dimension_suspect":
st["dimension_suspect"] += 1
@@ -314,25 +214,19 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
plan_title = f"渠补谈判: {msg} —— 下游渠道返利谈判"
elif kpi.kpi_code == "F_COST_RATIO":
plan_title = f"费用压缩: {msg} —— 管理费削减计划"
# 2026-09-22(本单主修·第 4 处写入点):写前同源校验 + 显式写归属。
# 修复前构造 ActionPlan 不传 entity_id → 模型 default=1 / DDL DEFAULT 1
# 静默兜底,非 e1 账套的 KPI 触发即产生「预警归属正确、计划归属落 1」的
# 跨账套计划(酣客工作台渲染博海/demo KPI 名的唯一活写入点)。
if action_plan_entity_guard(db_session, kpi=kpi, alert=alert, stats=st):
plan = ActionPlan(
entity_id=kpi.entity_id, # 归属唯一事实源:来源 KPI 的账套
alert_id=alert.id,
kpi_id=kpi.id,
title=plan_title,
assignee="财务部",
priority="high" if level == "red" else "medium",
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
created_by="alert_generator",
)
db_session.add(plan)
db_session.flush()
alert.action_plan_linked_id = plan.id
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
plan = ActionPlan(
alert_id=alert.id,
kpi_id=kpi.id,
title=plan_title,
assignee="财务部",
priority="high" if level == "red" else "medium",
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
created_by="alert_generator",
)
db_session.add(plan)
db_session.flush()
alert.action_plan_linked_id = plan.id
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
break # 只取最高级别
elif level == "red" and not triggered:
# 红没触发(判定顺序中 red 最先)→ 若该期间该KPI已有 pending/processing 红灯,
@@ -367,8 +261,7 @@ def run_rule_engine_checks(db_session) -> dict:
AlertRule, run_forecast_deviation_check, run_customer_concentration_check,
)
stats = {"forecast_deviation": 0, "customer_concentration": 0,
"skipped_demo_entities": 0, "excluded_entities": sorted(excluded_entity_ids(db_session))}
stats = {"forecast_deviation": 0, "customer_concentration": 0}
try:
rows = db_session.query(AlertRule.entity_id).filter(
AlertRule.enabled == 1,
@@ -379,11 +272,6 @@ def run_rule_engine_checks(db_session) -> dict:
return stats
for (eid,) in rows:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if int(eid or 1) in set(stats["excluded_entities"]):
stats["skipped_demo_entities"] += 1
logger.info(f" 规则引擎跳过(demo 账套 R1): entity={eid}")
continue
for key, fn in (("forecast_deviation", run_forecast_deviation_check),
("customer_concentration", run_customer_concentration_check)):
try:
@@ -397,42 +285,24 @@ def run_rule_engine_checks(db_session) -> dict:
return stats
def _enabled_channel_count(db_session) -> int:
"""R4(裁定 2026-09-19):推送出口计数 —— 生成/推送解耦,空通道只记录不报错。"""
from app.models import NotificationChannel
try:
return int(db_session.query(NotificationChannel).filter(
NotificationChannel.enabled == True # noqa: E712 - 与其他调用点一致
).count())
except Exception as e: # pragma: no cover - 防御分支
logger.warning(f"读取通知渠道失败(按无渠道处理): {e}")
return 0
def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool = False,
now: Optional[datetime] = None) -> dict:
def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool = False) -> dict:
"""生成预警并推送,返回统计
2026-09-17 财务口径裁定 2a/2b(2a 的两项已由 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24` 取代为倍口径):
2026-09-17 财务口径裁定 2a/2b:
· **period=None** → 判定期间 = 各 KPI「最近可用期间」(裁定2b,不再硬编码当月);
显式给出 period → 固定该期间(抽查复核用);
· **dry_run=True** → 零写入、零推送:返回将生成的预警清单与口径证据(放行前抽查复核)。
2026-09-19 裁定 R1–R4:
· R1/R2/R3 在 run_alert_check 内落地(demo 账套排除 / 期间窗口 / 规整月期间);
· **R4 生成与推送解耦**:`notification_channels` 为空时仅入库、不报错、不阻断;
"""
cur_month = (now or datetime.now()).strftime("%Y-%m")
cur_month = datetime.now().strftime("%Y-%m")
st = {}
logger.info("开始预警检查(当月=%s;判定期间=%s;窗口下限=%s;排除账套=%s)..." % (
cur_month, ("固定 %s" % period) if period else "各 KPI 最近可用期间(裁定2b)",
window_lower_period(now), sorted(excluded_entity_ids(db_session))))
logger.info("开始预警检查(当月=%s;判定期间=%s)..." % (
cur_month, ("固定 %s" % period) if period else "各 KPI 最近可用期间(裁定2b)"))
# 0. Bug1修复: 先清理历史green预警(dry-run 不写)
cleaned = 0 if dry_run else cleanup_green_alerts(db_session)
# 1. 生成预警(期间口径 + 单位口径均在此落地)
new_count = run_alert_check(db_session, period=period, dry_run=dry_run, stats=st, now=now)
new_count = run_alert_check(db_session, period=period, dry_run=dry_run, stats=st)
logger.info(f"预警检查完成: {'将新增' if dry_run else '新增'} {new_count} 条")
# 1a. 覆盖度自检(2026-09-16 项3;2026-09-17 裁定2b 过渡期保留并扩展「期间回落」明细):
@@ -468,47 +338,27 @@ def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool =
else run_rule_engine_checks(db_session)
# 2. 推送(dry-run 零推送)
# R4(裁定 2026-09-19):生成与推送解耦 —— notification_channels 为空时
# **仅入库、不报错、不阻断**(推送出口缺失只是"未触达",不等于"未生成")。
channels_enabled = _enabled_channel_count(db_session)
push_skipped_no_channel = channels_enabled == 0
pushed = 0
if new_count > 0 and not dry_run:
if push_skipped_no_channel:
logger.info("推送出口缺失(notification_channels 启用数=0)→ 仅入库、不推送、不阻断(裁定 R4)")
else:
try:
from app.utils.notifier import push_pending_alerts
pushed = push_pending_alerts(db_session)
logger.info(f"推送完成: {pushed} 条")
except Exception as e:
logger.error(f"推送失败(不影响已入库预警,生成/推送解耦 R4): {e}")
try:
from app.utils.notifier import push_pending_alerts
pushed = push_pending_alerts(db_session)
logger.info(f"推送完成: {pushed} 条")
except Exception as e:
logger.error(f"推送失败: {e}")
return {"period": cur_month, "dry_run": dry_run,
"period_source": st.get("period_source"), "period_fixed": st.get("period_fixed"),
"periods_used": st.get("periods_used", {}),
"new_alerts": new_count, "pushed": pushed,
# R4:推送出口(生成/推送解耦证据)
"channels_enabled": channels_enabled,
"push_skipped_no_channel": push_skipped_no_channel,
"cleaned_green": cleaned, "rule_engine": rule_stats,
"coverage": coverage,
# 口径证据(v2.1 倍口径直判 / 2a 归一;唯一事实源 caliber_units.RATIO_CALIBER_TABLE)
# 裁定2a 口径证据
"normalized": st.get("normalized", 0), "normalized_kpis": st.get("normalized_kpis", []),
"dimension_suspect": st.get("dimension_suspect", 0), "suspect_kpis": st.get("suspect_kpis", []),
# 期间/取值覆盖明细
"no_period": st.get("no_period", 0), "no_value": st.get("no_value", 0),
"skipped_existing": st.get("skipped_existing", 0), "planned": st.get("planned", []),
# 裁定 R1/R2/R3 口径证据
"excluded_demo_entities": st.get("excluded_demo_entities", []),
"skipped_demo_kpis": st.get("skipped_demo_kpis", 0),
"window_lower_period": st.get("window_lower_period"), "window_months": st.get("window_months"),
"out_of_window": st.get("out_of_window", 0), "out_of_window_kpis": st.get("out_of_window_kpis", []),
"skipped_pseudo_period": st.get("skipped_pseudo_period", 0),
"pseudo_period_kpis": st.get("pseudo_period_kpis", []),
# 2026-09-22 计划归属守卫(B4):归属不一致/为空 → 跳过建计划的留痕计数
"skipped_plan_entity_mismatch": st.get("skipped_plan_entity_mismatch", 0),
"skipped_plan_kpis": st.get("skipped_plan_kpis", [])}
"skipped_existing": st.get("skipped_existing", 0), "planned": st.get("planned", [])}
def coverage_note(result: dict) -> str:
@@ -530,30 +380,13 @@ def coverage_note(result: dict) -> str:
% (result["dimension_suspect"], (result.get("suspect_kpis") or [])[:3]))
if result.get("normalized"):
parts.append(" | 口径归一 ×100 %s 项" % result["normalized"])
# 裁定 R1/R2/R3/R4(2026-09-19)证据
if result.get("skipped_demo_kpis"):
parts.append(" | R1 排除 demo 账套 %s(跳过 KPI %s 项)"
% (result.get("excluded_demo_entities"), result["skipped_demo_kpis"]))
if result.get("out_of_window"):
parts.append(" | R2 窗口(下限 %s)外不新生成 %s 项"
% (result.get("window_lower_period"), result["out_of_window"]))
if result.get("skipped_pseudo_period"):
parts.append(" | R3 伪期间跳过 %s 项" % result["skipped_pseudo_period"])
if result.get("push_skipped_no_channel"):
parts.append(" | R4 推送出口缺失(启用渠道 %s) → 仅入库、不阻断"
% result.get("channels_enabled"))
# 2026-09-22 计划归属守卫(B4):跨账套计划不再静默落默认账套 1
if result.get("skipped_plan_entity_mismatch"):
parts.append(" | B4 计划归属守卫:跳过建计划 %s 项 %s(KPI 归属与目标账套不一致/为空)"
% (result["skipped_plan_entity_mismatch"],
(result.get("skipped_plan_kpis") or [])[:3]))
return "".join(parts)
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="预警生成与推送(口径:2a + 2026-09-24 v2.1 取代两项)")
ap = argparse.ArgumentParser(description="预警生成与推送(2026-09-17 口径裁定 2a/2b)")
ap.add_argument("--dry-run", action="store_true", help="只计算不写库不推送(抽查复核/放行前证据)")
ap.add_argument("--period", default=None, help="固定判定期间 YYYY-MM(缺省=各 KPI 最近可用期间,裁定2b)")
ap.add_argument("--json", action="store_true", help="JSON 输出完整证据(含将生成的预警清单)")
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""杜邦 KPI 定义种子应用器 —— 执行 scripts/sql/dupont_kpi_defs_20261003.sql
任务:finance-20261003-dupont-period-basis-design-fullstack.md
动作:① 两账套注册 F_ASSET_TOTAL/F_EQUITY_TOTAL/F_EQUITY_MULTIPLIER(derived)
② F_ASSET_TURNOVER data_source_type: manual → derived
幂等:SQL 自带条件 INSERT(NOT EXISTS)+ 条件 UPDATE,可安全重跑。
用法(**写库**,须先过 approval-gate 放行):
venv/bin/python3 scripts/apply_dupont_kpi_defs.py --check # 只读预检(不执行任何语句)
venv/bin/python3 scripts/apply_dupont_kpi_defs.py --apply # 执行(需放行)
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
from app.database import get_engine # noqa: E402
SQL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"sql", "dupont_kpi_defs_20261003.sql")
TARGETS = ("F_ASSET_TOTAL", "F_EQUITY_TOTAL", "F_EQUITY_MULTIPLIER")
TARGET_ENTITIES = (1, 2)
def statements(sql_text: str):
"""按 ASCII ';' 切分(本文件的字符串字面量内不含 ';',已人工核对)"""
out = []
for chunk in sql_text.split(";"):
stmt = chunk.strip()
if stmt and not all(ln.strip().startswith("--") or not ln.strip() for ln in stmt.splitlines()):
out.append(stmt)
return out
def snapshot(conn) -> dict:
rows = conn.execute(text("""SELECT entity_id, kpi_code, data_source_type, status
FROM kpi_definitions
WHERE entity_id IN (1,2) AND kpi_code IN
('F_ASSET_TOTAL','F_EQUITY_TOTAL','F_EQUITY_MULTIPLIER',
'F_ASSET_TURNOVER')
ORDER BY entity_id, kpi_code""")).fetchall()
return {f"{r[0]}/{r[1]}": (r[2], r[3]) for r in rows}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--check", action="store_true", help="只读预检(不执行写语句)")
ap.add_argument("--apply", action="store_true", help="执行种子 SQL(写库)")
args = ap.parse_args()
if not (args.check or args.apply):
print("须指定 --check 或 --apply", file=sys.stderr)
return 2
eng = get_engine()
with open(SQL_FILE, encoding="utf-8") as f:
stmts = statements(f.read())
print(f"SQL 文件: {SQL_FILE}")
print(f"语句数: {len(stmts)}")
for i, s in enumerate(stmts, 1):
head = " ".join(s.split())[:110]
print(f" [{i}] {head}...")
with eng.connect() as conn:
before = snapshot(conn)
print(f"\n写前像({len(before)} 行): {before}")
if args.check:
print("\n[--check] 只读预检:未执行任何语句(写路径未触发)")
return 0
for i, s in enumerate(stmts, 1):
r = conn.execute(text(s))
print(f" [{i}] rowcount={r.rowcount}")
conn.commit()
after = snapshot(conn)
print(f"\n写后像({len(after)} 行): {after}")
added = {k: v for k, v in after.items() if k not in before}
changed = {k: (before[k], v) for k, v in after.items() if k in before and before[k] != v}
print(f"新增: {added}")
print(f"变更: {changed}")
missing = [f"{e}/{c}" for e in TARGET_ENTITIES for c in TARGETS if f"{e}/{c}" not in after]
print(f"缺件: {missing or '无'}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,319 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""G0 数据就绪核验(只读)— 经营预警能力落地 P0-A
任务键: fullstack-20261017-warning-capability-build-p0(上游 yanxue-20261017-warning-capability-build-p0)
用途: 对「资金 / 库存 / 销售 / 客户 / 员工」五类数据在 CMA 库内的就绪程度取证:
行数(分 entity)、期间上下界、枚举完整度、缺口清单。
纪律:
· 只读:仅 SELECT / information_schema;凭据用 cma_reader(仅 GRANT SELECT ON cma.*)
· 不改库、不注数、不建表;缺表/缺期间如实列为 gap,不用 0 或示例数填充
· 期间上下界取数据自身字段(product_sales.period_month / cash_plans.plan_date /
*_inventory.source_batch),无期间字段的数据集明确标 period_field=null
用法:
CMA_READER_ENV=/path/to/.secrets/cma-reader.env \
python3 backend/scripts/audit_warning_data_readiness.py [--entity 1] [--out FILE]
退出码: 0 = 核验已执行(无论是否存在数据缺口);2 = 连接/权限失败
"""
import argparse
import json
import os
import sys
from datetime import datetime
import pymysql
DEFAULT_READER_ENV = "/root/.hermes/profiles/wecom-fullstack/.secrets/cma-reader.env"
TARGET_ENTITY_DEFAULT = 1 # 酣客
# 需要取证的数据集(表名 → 期间字段/断言口径)
TARGETS = [
# 资金(G1 依赖)
{"table": "cash_plans", "period_field": "plan_date", "kind": "fact",
"enum_fields": ["plan_type", "status", "source"], "amount_fields": ["amount"]},
{"table": "alert_rules", "period_field": None, "kind": "config", "expect_period": False,
"enum_fields": ["rule_type", "trigger_on", "enabled"], "amount_fields": []},
{"table": "kpi_alerts", "period_field": None, "kind": "fact", "expect_period": False,
"enum_fields": ["alert_level", "status", "alert_type"], "amount_fields": []},
# 库存(G3 依赖)
{"table": "product_inventory", "period_field": "source_batch", "kind": "agg",
"enum_fields": ["product_category", "source_batch"], "amount_fields": ["end_amount", "init_amount"]},
{"table": "product_inventory_detail", "period_field": "source_batch", "kind": "fact",
"enum_fields": ["unit", "source_batch"], "amount_fields": ["end_amount", "init_amount"]},
# 销售(G3 依赖)
{"table": "product_sales", "period_field": "period_month", "kind": "fact",
"enum_fields": ["unit", "period_month"], "amount_fields": ["sales_amount", "cost_amount"]},
# 客户(G4 依赖)
{"table": "customer_profitability_snapshot", "period_field": "period", "kind": "fact",
"enum_fields": ["period", "calib_status"], "amount_fields": ["revenue", "contrib"]},
# 智享通维度/事实表(方案 §G0:应在接收侧路由,此处核验是否已入 CMA 库)
{"table": "productinout_recent", "period_field": "None", "kind": "absent-probe",
"enum_fields": [], "amount_fields": []},
{"table": "productinout_history", "period_field": "None", "kind": "absent-probe",
"enum_fields": [], "amount_fields": []},
{"table": "units_dict", "period_field": None, "kind": "absent-probe",
"enum_fields": [], "amount_fields": []},
{"table": "employee_dict", "period_field": None, "kind": "absent-probe",
"enum_fields": [], "amount_fields": []},
{"table": "product_dict", "period_field": None, "kind": "absent-probe",
"enum_fields": [], "amount_fields": []},
]
def load_env(path):
env = {}
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line and "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
missing = [k for k in ("CMA_READER_DB_HOST", "CMA_READER_DB_PORT",
"CMA_READER_DB_USER", "CMA_READER_DB_PASS",
"CMA_READER_DB_NAME") if k not in env]
if missing:
raise SystemExit(f"凭据文件缺字段: {missing}")
return env
def table_exists(cur, name):
cur.execute("SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema=DATABASE() AND table_name=%s", (name,))
return cur.fetchone()[0] > 0
def columns(cur, name):
cur.execute("SELECT column_name, column_type, is_nullable FROM information_schema.columns "
"WHERE table_schema=DATABASE() AND table_name=%s ORDER BY ordinal_position", (name,))
return [{"name": c, "type": t, "nullable": (n == "YES")} for c, t, n in cur.fetchall()]
def scalar(cur, sql, args=None):
cur.execute(sql, args or ())
row = cur.fetchone()
return None if row is None else row[0]
def inspect_table(cur, spec, entity_id):
name = spec["table"]
out = {"table": name, "kind": spec["kind"], "exists": table_exists(cur, name),
"expect_period": spec.get("expect_period", True)}
if not out["exists"]:
out["gap"] = "表不存在于 CMA 库(entity 无关)"
return out
cols = columns(cur, name)
col_names = {c["name"] for c in cols}
out["columns"] = cols
out["row_count_total"] = int(scalar(cur, "SELECT COUNT(*) FROM `%s`" % name) or 0)
if "entity_id" in col_names:
cur.execute("SELECT entity_id, COUNT(*) FROM `%s` GROUP BY entity_id ORDER BY entity_id" % name)
out["rows_by_entity"] = {str(e if e is not None else "NULL"): int(c) for e, c in cur.fetchall()}
out["row_count_entity"] = out["rows_by_entity"].get(str(entity_id), 0)
else:
out["rows_by_entity"] = None
out["row_count_entity"] = out["row_count_total"]
out["gap_no_entity_column"] = True
pf = spec["period_field"]
if pf and pf in col_names and pf != "None":
where = " WHERE entity_id=%s" % int(entity_id) if "entity_id" in col_names else ""
cur.execute("SELECT MIN(`%s`), MAX(`%s`), COUNT(DISTINCT `%s`) FROM `%s`%s"
% (pf, pf, pf, name, where))
lo, hi, distinct = cur.fetchone()
out["period_field"] = pf
out["period_bounds"] = {"min": _jsonable(lo), "max": _jsonable(hi), "distinct": int(distinct or 0)}
cur.execute("SELECT `%s`, COUNT(*) FROM `%s`%s GROUP BY `%s` ORDER BY `%s`"
% (pf, name, where, pf, pf))
out["period_rows"] = [[_jsonable(k), int(v)] for k, v in cur.fetchall()]
else:
out["period_field"] = None
out["period_bounds"] = None
out["gap_no_period_field"] = True
# 枚举完整度:值分布 + 空值率
enum_detail = {}
where = " WHERE entity_id=%s" % int(entity_id) if "entity_id" in col_names else ""
denom = out["row_count_entity"] or 0
for ef in spec["enum_fields"]:
if ef not in col_names:
enum_detail[ef] = {"missing_column": True}
continue
cur.execute("SELECT `%s`, COUNT(*) FROM `%s`%s GROUP BY `%s` ORDER BY COUNT(*) DESC LIMIT 25"
% (ef, name, where, ef))
dist = [{"value": _jsonable(v), "rows": int(c)} for v, c in cur.fetchall()]
nulls = int(scalar(cur, "SELECT COUNT(*) FROM `%s`%s %s `%s` IS NULL"
% (name, where, "AND" if where else "WHERE", ef)) or 0)
blanks = 0
if any(c["type"].startswith(("varchar", "char", "text")) for c in cols if c["name"] == ef):
blanks = int(scalar(cur, "SELECT COUNT(*) FROM `%s`%s %s `%s`=''"
% (name, where, "AND" if where else "WHERE", ef)) or 0)
enum_detail[ef] = {"distinct": len(dist), "distribution": dist,
"null_rows": nulls, "blank_rows": blanks,
"null_rate": round(nulls / denom, 4) if denom else None}
out["enum_completeness"] = enum_detail
# 金额字段空值与合计(仅 entity 范围)
amt = {}
for af in spec["amount_fields"]:
if af in col_names:
cur.execute("SELECT COUNT(*), COALESCE(SUM(`%s`),0) FROM `%s`%s %s `%s` IS NULL"
% (af, name, where, "AND" if where else "WHERE", af))
nulls, _ = cur.fetchone()
total = scalar(cur, "SELECT COALESCE(SUM(`%s`),0) FROM `%s`%s" % (af, name, where))
amt[af] = {"null_rows": int(nulls or 0), "sum": _jsonable(total)}
out["amount_fields"] = amt
return out
def data_quality_checks(cur, entity_id: int) -> list:
"""数据质量取证(只读):不只看「有没有表/几行」,还要看这批数据能不能支撑看板口径。
每条 finding 都带 SQL 结果,供回执逐条引用;发现问题不等于改库,只如实登记。
"""
findings = []
has = table_exists(cur, "product_inventory")
if has:
cur.execute(
"SELECT COUNT(*), COALESCE(SUM(init_qty),0), COALESCE(SUM(init_amount),0), "
"SUM(CASE WHEN end_qty < 0 THEN 1 ELSE 0 END), COUNT(DISTINCT source_batch) "
"FROM product_inventory WHERE entity_id=%s", (entity_id,))
rows, init_qty, init_amt, neg_rows, batches = cur.fetchone()
if float(init_qty or 0) == 0 and float(init_amt or 0) == 0:
findings.append({
"table": "product_inventory", "type": "init_all_zero",
"detail": "entity_%s 全部行 init_qty/init_amount = 0 ⇒ 期初口径不可用,"
"看板期初列必须显示『数据未就绪』" % entity_id,
"evidence": {"rows": int(rows), "sum_init_qty": float(init_qty or 0),
"sum_init_amount": float(init_amt or 0)}})
if int(neg_rows or 0) > 0:
findings.append({
"table": "product_inventory", "type": "negative_end_qty",
"detail": "entity_%s 有负期末数量行 ⇒ 该批为「当期进销」而非「期初+期末」,"
"期末/周转需标注口径来源" % entity_id,
"evidence": {"rows_with_negative_end_qty": int(neg_rows)}})
if int(batches or 0) == 1:
findings.append({
"table": "product_inventory", "type": "single_batch_only",
"detail": "仅 1 个 source_batch ⇒ 库存周转只能算单期快照,不能做多期环比/呆滞趋势",
"evidence": {"distinct_source_batch": int(batches)}})
for kw in ("employ", "staff", "salesman"):
cur.execute("SELECT table_name FROM information_schema.tables "
"WHERE table_schema=DATABASE() AND table_name LIKE %s", ("%" + kw + "%",))
hits = [r[0] for r in cur.fetchall()]
if hits:
findings.append({"table": hits[0], "type": "employee_dim_suspected",
"detail": "库内存在疑似员工维度表:%s(需逐表核口径)" % ",".join(hits)})
cur.execute("SELECT table_name FROM information_schema.tables "
"WHERE table_schema=DATABASE() AND (table_name LIKE '%%employ%%' "
"OR table_name LIKE '%%staff%%' OR table_name LIKE '%%salesman%%')")
if not cur.fetchall():
findings.append({
"table": "(none)", "type": "no_employee_dimension_table",
"detail": "CMA 库内无任何员工/业务员维度表 ⇒ 员工绩效明细(G4)当前无数据源,"
"不只是口径未定(D2),需先解决 employee_dict 落库"})
return findings
def _jsonable(v):
if v is None:
return None
if isinstance(v, (int, float, str)):
return v
if hasattr(v, "isoformat"):
return v.isoformat()
from decimal import Decimal
if isinstance(v, Decimal):
return float(v)
return str(v)
def build_gaps(reports, entity_id):
gaps, flags = [], []
for r in reports:
if not r.get("exists"):
gaps.append({"table": r["table"], "type": "missing_table",
"detail": "CMA 库无此表(zxt 接收侧仅路由到通道,未落地 CMA)"})
continue
if r.get("row_count_entity", 0) == 0:
gaps.append({"table": r["table"], "type": "no_rows_for_entity",
"detail": "entity_%s 无行" % entity_id})
for f, v in (r.get("enum_completeness") or {}).items():
if v.get("missing_column"):
gaps.append({"table": r["table"], "type": "missing_column", "detail": f})
elif v.get("null_rate") == 1.0:
flags.append({"table": r["table"], "field": f, "type": "enum_all_null",
"detail": "该字段全为空 → 不能作为看板分组/口径依据"})
if r.get("gap_no_period_field") and r.get("expect_period", True):
gaps.append({"table": r["table"], "type": "no_period_field",
"detail": "无期间字段 → 无法声明覆盖区间(页面必须标『数据未就绪』)"})
return gaps, flags
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--entity", type=int, default=TARGET_ENTITY_DEFAULT)
ap.add_argument("--out", default="")
args = ap.parse_args()
reader_env = os.environ.get("CMA_READER_ENV", DEFAULT_READER_ENV)
env = load_env(reader_env)
try:
conn = pymysql.connect(host=env["CMA_READER_DB_HOST"], port=int(env["CMA_READER_DB_PORT"]),
user=env["CMA_READER_DB_USER"], password=env["CMA_READER_DB_PASS"],
database=env["CMA_READER_DB_NAME"], charset="utf8mb4")
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": "connect_failed: %s" % str(exc)[:200]},
ensure_ascii=False), file=sys.stderr)
return 2
reports, db_meta = [], {}
try:
cur = conn.cursor()
cur.execute("SELECT CURRENT_USER(), DATABASE(), VERSION(), NOW()")
u, db, ver, now = cur.fetchone() or (None, None, None, None)
db_meta = {"current_user": u, "database": db, "version": ver,
"server_now": _jsonable(now), "readonly_account": True,
"reader_env_file": os.path.basename(reader_env)}
for spec in TARGETS:
reports.append(inspect_table(cur, spec, args.entity))
gaps, flags = build_gaps(reports, args.entity)
data_quality = data_quality_checks(cur, args.entity)
for f in data_quality:
gaps.append({"table": f["table"], "type": f["type"], "detail": f["detail"],
"evidence": f.get("evidence")})
cur.close()
finally:
conn.close()
payload = {
"task": "fullstack-20261017-warning-capability-build-p0",
"stage": "G0 data readiness (read-only)",
"generated_at": datetime.now().isoformat(timespec="seconds"),
"entity_id": args.entity,
"db": db_meta,
"datasets": reports,
"gaps": gaps,
"gap_count": len(gaps),
"data_quality": data_quality,
"flags": flags,
"flag_count": len(flags),
}
text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
if args.out:
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w", encoding="utf-8") as fh:
fh.write(text + "\n")
print("evidence written: %s (%d bytes)" % (args.out, len(text)))
print("gap_count=%d" % len(gaps))
for g in gaps:
print("GAP %-32s %-22s %s" % (g["table"], g["type"], g["detail"]))
else:
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -8
View File
@@ -33,14 +33,7 @@ from typing import Optional
import httpx
CMA_API = os.getenv("CMA_API", "http://127.0.0.1:8010/api/cma/bot-bridge")
# 凭据只应从环境变量取;兼容期兜底历史共享 Token(P3 待轮换,轮换后删除兜底)
_LEGACY_BRIDGE_TOKEN = "cma-bot-bridge-2026"
BRIDGE_TOKEN = (os.getenv("CMA_BRIDGE_TOKEN") or "").strip()
if not BRIDGE_TOKEN:
print("[WARN] CMA_BRIDGE_TOKEN 未配置,回退历史共享 Token(待轮换)", file=sys.stderr)
BRIDGE_TOKEN = _LEGACY_BRIDGE_TOKEN
BRIDGE_TOKEN = os.getenv("CMA_BRIDGE_TOKEN", "cma-bot-bridge-2026")
# MPM字段 → KPI编码映射(与 bot_bridge_v2.MPM_TO_KPI_MAP 保持一致)
MPM_TO_KPI_MAP = {
-408
View File
@@ -1,408 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""资金缺口「讲人话」— G2 验收件(P0-A)
任务:fullstack-20261017-warning-capability-build-p0(上游 yanxue-20261017-warning-capability-build-p0)
方案:`_reports/yanxue-20261017-warning-capability-build-p0.md` §G1/G2 + §四「数字零错误(可复算)」
纪律(本脚本存在的唯一理由):
1. **数字由规则层算死**:所有数字一律来自 `forecast_cash_flow_with_plans` 的返回值,
脚本把这份 payload 作为「白名单」喂给 LLM,并**事后断言**生成文本里的数字集合
⊆ 规则层数字集合(数值相等,非字符串相等;`20` 与 `20.0` 视为同一数字)。
2. **LLM 只写文字**:prompt 明确禁止四舍五入、估算、派生、新增数字、自造日期。
3. **断言失败即不带文案上线**:首次违例 → 收紧 prompt 重试一次;仍违例 → 退出码 1、
`assertion.pass=false`、`narrative` 置空(调用方不得把文案推给用户)。
用法:
# 规则层取数(只读,走 cma_reader)→ LLM 出文案 → 数字守恒断言
python3 backend/scripts/cash_gap_narrative.py --entity 1 --days 30
# 只跑断言自测(不连库、不连 LLM,离线可判)
python3 backend/scripts/cash_gap_narrative.py --selftest
# 用现成 payload 出文案(离线复算/回放)
python3 backend/scripts/cash_gap_narrative.py --payload-json p.json
退出码:0 = 断言通过且文案完整;1 = 数字违例 / 段落缺失 / 取数或调用失败
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import sys
from datetime import datetime
from decimal import Decimal, InvalidOperation
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
for _p in (BACKEND_DIR, SCRIPTS_DIR):
if _p not in sys.path:
sys.path.insert(0, _p)
READER_ENV_DEFAULT = "/root/.hermes/profiles/wecom-fullstack/.secrets/cma-reader.env"
#: DeepSeek Key 来源候选(按序取第一个「非空」;脚本只记来源路径,不打印 Key)
#: 生产 CMA backend/.env 中的 Key 已被供应商判为 invalid(2026-10-17 实测 401),
#: 故 Bot 侧优先用 Hermes 通道 Key;生产 Key 轮换另走派单,本脚本不改生产配置。
KEY_ENV_CANDIDATES = (
"/root/.hermes/profiles/wecom-fullstack/.env",
"/root/.hermes/.env",
"/root/cma-management/backend/.env",
)
#: 文本里允许出现的非数字词表(避免把「TOP3」里的 3 当数字)——prompt 亦禁止
DATE_PATTERNS = (
re.compile(r"\d{4}-\d{2}-\d{2}"),
re.compile(r"\d{4}年\d{1,2}月\d{1,2}日"),
re.compile(r"\d{4}-\d{2}(?!\d)"),
re.compile(r"\d{4}年\d{1,2}月(?!\d)"),
)
NUMBER_PATTERN = re.compile(r"-?\d+(?:\.\d+)?")
REQUIRED_SECTIONS = ("原因", "建议")
# ────────────────────────── 数字集合工具 ──────────────────────────
def _dec(value) -> Decimal | None:
"""把任意数值转 Decimal(量化到 2 位,与规则层 round(2) 口径一致);非法返回 None。"""
try:
d = Decimal(str(value))
except (InvalidOperation, ValueError):
return None
if not d.is_finite():
return None
return d.quantize(Decimal("0.01"))
def collect_rule_numbers(payload) -> tuple[set[Decimal], set[str]]:
"""递归收集 payload 中所有数值与日期(白名单)。
· 数值同时收 x 与 -x:文本用「下降 37.81%」表达负趋势属同一数字,不算改数
· 布尔值不算数字;字符串里的日期单独收集,不计入数字白名单
"""
values: set[Decimal] = set()
dates: set[str] = set()
def walk(node):
if isinstance(node, bool) or node is None:
return
if isinstance(node, (int, float, Decimal)):
d = _dec(node)
if d is not None:
values.add(d)
values.add(-d)
return
if isinstance(node, str):
for pat in DATE_PATTERNS:
dates.update(pat.findall(node))
return
if isinstance(node, dict):
for v in node.values():
walk(v)
return
if isinstance(node, (list, tuple)):
for v in node:
walk(v)
walk(payload)
return values, dates
def extract_text_numbers(text: str) -> tuple[list[tuple[str, Decimal]], set[str], str]:
"""抽取文本中的日期与数字;返回 (数字token列表, 日期集合, 去日期后的文本)。"""
dates: set[str] = set()
stripped = text
for pat in DATE_PATTERNS:
for m in pat.findall(stripped):
dates.add(m)
stripped = pat.sub(" ", stripped)
numbers: list[tuple[str, Decimal]] = []
for raw in NUMBER_PATTERN.findall(stripped):
d = _dec(raw)
if d is not None:
numbers.append((raw, d))
return numbers, dates, stripped
def assert_number_conservation(text: str, payload) -> dict:
"""核心断言:文本数字 ⊆ 规则层数字;日期 ⊆ 规则层日期。返回结构化结果。"""
allowed_values, allowed_dates = collect_rule_numbers(payload)
numbers, dates, _ = extract_text_numbers(text)
value_violations = [
{"token": tok, "value": str(val)}
for tok, val in numbers
if val not in allowed_values
]
date_violations = sorted(d for d in dates if d not in allowed_dates)
return {
"pass": not value_violations and not date_violations,
"checked_numbers": len(numbers),
"allowed_numbers": len(allowed_values),
"value_violations": value_violations,
"date_violations": date_violations,
"allowed_values_sample": sorted(str(v) for v in allowed_values)[:40],
"allowed_dates": sorted(allowed_dates),
}
# ────────────────────────── 规则层取数 ──────────────────────────
def _dotenv(path: str) -> dict:
env = {}
for line in open(path, encoding="utf-8"):
line = line.strip()
if line and "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def resolve_deepseek_key() -> tuple[str, str]:
"""按候选顺序取 DeepSeek Key;返回 (key, 来源路径)。只用于调用,不落盘不打印。"""
current = os.environ.get("DEEPSEEK_API_KEY", "").strip()
for path in KEY_ENV_CANDIDATES:
if not os.path.exists(path):
continue
try:
key = _dotenv(path).get("DEEPSEEK_API_KEY", "").strip()
except OSError:
continue
if key:
return key, path
return current, "environment" if current else ""
def load_rule_payload(entity_id: int, days: int) -> dict:
"""只读取数:cma_reader 账号(SELECT only)→ 引擎同一函数,与端点同源。"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.utils import cash_forecast_engine as engine
rd = _dotenv(os.environ.get("CMA_READER_ENV", READER_ENV_DEFAULT))
url = ("mysql+pymysql://%s:%s@%s:%s/%s?charset=utf8mb4"
% (rd["CMA_READER_DB_USER"], rd["CMA_READER_DB_PASS"],
rd["CMA_READER_DB_HOST"], rd["CMA_READER_DB_PORT"], rd["CMA_READER_DB_NAME"]))
eng = create_engine(url, pool_pre_ping=True)
db = sessionmaker(bind=eng)()
try:
payload = engine.forecast_cash_flow_with_plans(entity_id, db, days=days)
finally:
db.close()
return payload
# ────────────────────────── prompt ──────────────────────────
SYSTEM_PROMPT = (
"你是管理会计,只负责把已经算好的资金缺口结论写成老板能读懂的「原因」和「建议」。"
"硬性规则:"
"① 只能引用我给你的数字,逐字照抄,不得四舍五入、不得换算单位、不得估算、不得派生新数字;"
"② 禁止出现我未提供的任何新数字(包括数量、比例、天数、排名,如「TOP3」里的 3 不能用);"
"③ 日期只能照抄我给的日期;"
"④ 输出两段,段首分别为「原因:」与「建议:」,每段不超过 120 字;"
"⑤ 不要复述表格,不要下结论性预测以外的推断。"
)
def build_prompt(payload: dict) -> str:
"""把规则层 payload 压成「可引用数字白名单」,避免把整包原始明细灌给模型。"""
def r(v):
return None if v is None else round(float(v), 2)
first_gap = (payload.get("gap_dates") or [None])[0] or {}
first_level = {"red": "红灯", "yellow": "黄灯"}.get(str(first_gap.get("level") or ""), "无")
compact = {
"账套entity_id": payload.get("entity_id"),
"预测窗口天数": payload.get("days"),
"当前现金余额(万元)": r(payload.get("base_cash")),
"黄灯警戒线(万元)": r(payload.get("warning_line")),
"红灯警戒线(万元)": r(payload.get("critical_line")),
"阈值来源": (payload.get("threshold_source") or {}).get("source"),
"最低余额(万元)": r(payload.get("min_cash")),
"最低余额日期": payload.get("min_cash_date"),
"缺口天数": len(payload.get("gap_dates") or []),
"首个缺口": first_gap or None,
"首个缺口级别": first_level,
"月度经营现金流预算(万元/月)": r((payload.get("trends") or {}).get("budget_monthly_ocf")),
"经营现金流趋势(%)": r((payload.get("trends") or {}).get("operating_cash_flow_trend_pct")),
"应收趋势(%)": r((payload.get("trends") or {}).get("receivables_trend_pct")),
"应付趋势(%)": r((payload.get("trends") or {}).get("payables_trend_pct")),
"逾期应收笔数": (payload.get("summary") or {}).get("overdue_receive_count"),
"逾期应收金额(万元)": r((payload.get("summary") or {}).get("overdue_receive_amount")),
}
return (
"以下是规则层已经算好的资金缺口数据(唯一可引用数字来源):\n"
+ json.dumps(compact, ensure_ascii=False, indent=2)
+ "\n请只基于这些数字写「原因」与「建议」两段。"
)
def strict_retry_prompt(payload: dict, bad_text: str, violations: list) -> str:
return (
build_prompt(payload)
+ "\n\n上一次输出违反了数字纪律,出现了未授权的数字:"
+ json.dumps(violations, ensure_ascii=False)
+ "\n上一次输出为:\n" + bad_text
+ "\n请重写,删除上述未授权数字,其余规则不变。"
)
# ────────────────────────── 主流程 ──────────────────────────
def split_sections(text: str) -> dict:
"""把模型输出切成 原因 / 建议 两段(段首关键字)。"""
out = {"reason": "", "advice": ""}
reason = re.search(r"原因[::]\s*(.*?)(?=建议[::]|$)", text, re.S)
advice = re.search(r"建议[::]\s*(.*)$", text, re.S)
if reason:
out["reason"] = reason.group(1).strip()
if advice:
out["advice"] = advice.group(1).strip()
return out
async def generate(entity_id: int, days: int, payload: dict | None = None) -> dict:
key, key_source = resolve_deepseek_key()
if key:
# 必须在 import ai_brief 之前落位:ai_brief 模块级 load_dotenv 不覆盖已存在的环境变量,
# 否则会读到生产 .env 里已被判 invalid 的旧 Key(2026-10-17 实测 401)。
os.environ["DEEPSEEK_API_KEY"] = key
from ai_brief import call_deepseek # 复用既有 DeepSeek 通道(scripts/ai_brief.py:24)
payload = payload if payload is not None else load_rule_payload(entity_id, days)
prompt = build_prompt(payload)
text = await call_deepseek(prompt, SYSTEM_PROMPT)
assertion = assert_number_conservation(text or "", payload)
attempts = 1
if not assertion["pass"] and text:
text2 = await call_deepseek(
strict_retry_prompt(payload, text, assertion["value_violations"] + assertion["date_violations"]),
SYSTEM_PROMPT,
)
assertion2 = assert_number_conservation(text2 or "", payload)
attempts = 2
if assertion2["pass"]:
text, assertion = text2, assertion2
else:
assertion = assertion2 # 以最后一次为准(仍为 fail)
sections = split_sections(text or "")
missing = [s for s in REQUIRED_SECTIONS if not sections[{"原因": "reason", "建议": "advice"}[s]]]
ok = assertion["pass"] and not missing
return {
"task": "fullstack-20261017-warning-capability-build-p0",
"stage": "G2 cash gap narrative (number-conservation asserted)",
"generated_at": datetime.now().isoformat(timespec="seconds"),
"entity_id": entity_id,
"days": days,
"model": "deepseek-chat",
"llm_key_source": key_source,
"llm_empty_response": not bool((text or "").strip()),
"attempts": attempts,
"assertion": assertion,
"missing_sections": missing,
"pass": ok,
"narrative": ({"reason": sections["reason"], "advice": sections["advice"], "raw": text}
if ok else None),
"rule_layer": {
"base_cash": payload.get("base_cash"),
"warning_line": payload.get("warning_line"),
"critical_line": payload.get("critical_line"),
"min_cash": payload.get("min_cash"),
"min_cash_date": payload.get("min_cash_date"),
"threshold_source": payload.get("threshold_source"),
"gap_days": len(payload.get("gap_dates") or []),
},
}
# ────────────────────────── 断言自测(离线) ──────────────────────────
SELFTEST_PAYLOAD = {
"entity_id": 1, "days": 30, "base_cash": -9.3, "warning_line": 20.0, "critical_line": 10.0,
"min_cash": -8.74, "min_cash_date": "2026-10-18", "threshold_source": {"source": "default"},
"gap_dates": [{"date": "2026-10-18", "gap_amount": 28.74, "level": "red"}],
"trends": {"budget_monthly_ocf": 16.67, "receivables_trend_pct": -37.81},
"summary": {"overdue_receive_count": 18, "overdue_receive_amount": 71.68},
}
def selftest() -> int:
cases = [
("good_basic",
"原因:现金余额-9.3万元,2026-10-18 降至最低-8.74万元,低于警戒线20.0万元,缺口28.74万元;"
"逾期应收18笔共71.68万元。建议:优先催收逾期应收,暂停非必要支出。",
True),
("good_sign_abs",
"原因:应收趋势下降37.81%,月度经营现金流预算16.67万元/月,缺口1天。"
"建议:跟进2026-10-18 起的资金缺口。",
True),
("bad_rounding",
"原因:现金余额约-9万元,最低-8.7万元。建议:催收应收。",
False),
("bad_derived",
"原因:现金余额-9.3万元,缺口28.74万元,约为逾期应收的40%。建议:催收。",
False),
("bad_new_count",
"原因:现金余额-9.3万元。建议:优先催收TOP3客户。",
False),
("bad_fake_date",
"原因:现金余额-9.3万元,预计2026-11-01 断流。建议:催收。",
False),
]
failures = 0
print("== G2 数字守恒断言自测 ==")
for name, text, expect in cases:
res = assert_number_conservation(text, SELFTEST_PAYLOAD)
verdict = "PASS" if res["pass"] == expect else "FAIL"
if verdict == "FAIL":
failures += 1
print(" %-5s %-16s expect_pass=%-5s got_pass=%-5s value_violations=%s date_violations=%s"
% (verdict, name, expect, res["pass"],
[v["token"] for v in res["value_violations"]], res["date_violations"]))
total = len(cases)
print("RESULT: %s %d/%d" % ("PASS" if failures == 0 else "FAIL", total - failures, total))
return 0 if failures == 0 else 1
def main() -> int:
ap = argparse.ArgumentParser(description="资金缺口讲人话(数字守恒断言)")
ap.add_argument("--entity", type=int, default=1)
ap.add_argument("--days", type=int, default=30)
ap.add_argument("--payload-json", help="已有规则层 payload(跳过取数)")
ap.add_argument("--out", help="输出 JSON 路径")
ap.add_argument("--selftest", action="store_true", help="只跑断言自测(离线)")
args = ap.parse_args()
if args.selftest:
return selftest()
payload = None
if args.payload_json:
payload = json.load(open(args.payload_json, encoding="utf-8"))
result = asyncio.run(generate(args.entity, args.days, payload))
out = args.out or os.path.join(
"/root/.hermes/profiles/wecom-fullstack/pending-tasks/_evidence",
"fullstack-20261017-warning-capability-build-p0",
"g2-narrative-%s-e%d.json" % (datetime.now().strftime("%Y%m%d-%H%M%S"), args.entity))
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump(result, fh, ensure_ascii=False, indent=2)
print(json.dumps({k: result[k] for k in
("pass", "attempts", "assertion", "missing_sections", "rule_layer")},
ensure_ascii=False, indent=2))
if result["narrative"]:
print("原因:" + result["narrative"]["reason"])
print("建议:" + result["narrative"]["advice"])
print("OUT:", out)
return 0 if result["pass"] else 1
if __name__ == "__main__":
raise SystemExit(main())
-66
View File
@@ -177,63 +177,6 @@ def write_journal(payload: dict) -> str:
json.dump(payload, fh, ensure_ascii=False, indent=2)
return path
def write_anchor_pointer(payload: dict, journal_path: str) -> str:
"""原子写 journal/last-anchor.json —— 授权面收敛步(grant_surface_sync.py)消费的锚(R1)
键:journal(本次 journal 文件名,供 R1-a 同源自证)/ status / created_tables / created_new /
anchor_csv / finished_at / git_head / db_name / actor / carried_over / carried_from /
written_at / converged_at
R1-b:已存在指针且其 status=applied 而未收敛(无 converged_at)时,本次 created_tables =
旧集合 ∪ 本次 created,并记 carried_over / carried_from;无旧指针或旧指针已收敛时 = 本次
加性写:不改 journal、不改任何既有键语义;失败由调用方按告警处理(收敛步会据此 rc=2 中止)
"""
d = journal_dir()
path = os.path.join(d, "last-anchor.json")
prev = None
try:
with open(path, encoding="utf-8") as fh:
prev = json.load(fh)
except Exception:
prev = None
created = [t for t in (payload.get("created_tables") or [])]
carried_over, carried_from = False, None
if (isinstance(prev, dict) and prev.get("status") == "applied"
and not prev.get("converged_at") and (prev.get("created_tables") or [])):
old = list(prev.get("created_tables") or [])
created = sorted(set(old) | set(created))
carried_from = os.path.basename(str(prev.get("journal") or "")) or None
carried_over = True
doc = {
"pointer": "last-anchor.json",
"journal": os.path.basename(journal_path),
"status": payload.get("status"),
"created_tables": created,
"created_new": payload.get("created_new"),
"anchor_csv": ",".join(created),
"carried_over": carried_over,
"carried_from": carried_from,
"finished_at": payload.get("finished_at"),
"git_head": payload.get("git_head"),
"db_name": payload.get("db_name"),
"actor": payload.get("actor"),
"written_at": datetime.now().isoformat(timespec="seconds"),
"converged_at": None,
}
tmp = "%s.tmp.%d" % (path, os.getpid())
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(doc, fh, ensure_ascii=False, indent=2)
fh.write("\n")
try:
ref = path if os.path.exists(path) else journal_path
if os.path.exists(ref):
st = os.stat(ref)
os.chmod(tmp, st.st_mode & 0o777)
os.chown(tmp, st.st_uid, st.st_gid)
except Exception:
pass
os.replace(tmp, path)
return path
def list_journals():
d = journal_dir()
@@ -339,11 +282,6 @@ def cmd_apply(args, engine, conn_desc) -> int:
structural=structural,
)
path = write_journal(payload)
try:
write_anchor_pointer(payload, path)
except Exception as exc: # 加性:指针写失败不阻断迁移,由收敛步 fail-closed 兜住
print(f"[apply] [warn] 锚指针写入失败(收敛步会据此 rc=2 中止部署):"
f"{type(exc).__name__}: {exc}")
except Exception as exc: # DDL 无事务:失败时记录已建对象,供 --rollback 恢复
try:
after_missing, _ = schema_diff(engine)
@@ -358,10 +296,6 @@ def cmd_apply(args, engine, conn_desc) -> int:
error=f"{type(exc).__name__}: {exc}",
)
path = write_journal(payload)
try:
write_anchor_pointer(payload, path)
except Exception as exc: # 加性:失败路径同样留锚(status=failed),收敛步只消费 applied
print(f"[apply] [warn] 锚指针写入失败(失败路径):{type(exc).__name__}: {exc}")
print(f"[FAIL] 迁移失败:{payload['error']}")
print(f" 已建对象 {created}(journal: {path})")
print(f" 回滚:python3 scripts/db_migrate.py --rollback {os.path.basename(path)}")
+10 -13
View File
@@ -23,7 +23,6 @@ load_dotenv()
from app.database import get_engine, get_session_local
from app.models import KPIDefinition, KPIValue, OperationLog, DataSourceConfig
from app.utils.oplog import log_op, SCOPE_BATCH
logger = logging.getLogger("erp_sync")
@@ -422,18 +421,16 @@ def run_sync(dry_run: bool = False, kpi_codes: list = None, use_api: bool = True
fail += 1
if not dry_run:
# 2026-09-23 ④:审计带账套。本脚本一次遍历**全库 active ERP KPI(可能跨账套)**:
# 全部同账套 ⇒ 写该账套;跨账套 ⇒ 留空 + OPLOG_BATCH_NO_ENTITY 哨兵(不猜账套)
_entity_ids = sorted({getattr(k, "entity_id", None) for k in kpis} - {None})
log_op(db, "erp_sync", target_type="kpi",
detail={
"total": len(kpis), "success": success,
"failed": fail, "api_mode": use_api,
"period": datetime.now().strftime("%Y-%m"),
"kpi_entity_ids": _entity_ids,
},
entity_id=(_entity_ids[0] if len(_entity_ids) == 1 else None),
scope=(SCOPE_BATCH if len(_entity_ids) != 1 else None))
log = OperationLog(
action="erp_sync",
target_type="kpi",
detail=json.dumps({
"total": len(kpis), "success": success,
"failed": fail, "api_mode": use_api,
"period": datetime.now().strftime("%Y-%m"),
}, ensure_ascii=False),
)
db.add(log)
db.commit()
logger.info(f"同步完成: {success}成功 / {fail}失败 / {len(kpis)}总计")
+1 -23
View File
@@ -7,29 +7,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 直接调用BOT API
CMA_BOT_API = "http://127.0.0.1:8010/api/cma/bot"
# 凭据来源优先级:CMA_BOT_KEY_FINANCE(单键)→ CMA_BOT_API_KEYS(JSON 注册表,取 role=finance)
# 兼容期兜底:历史共享键(P3 待轮换,轮换后删除本兜底)
_LEGACY_SHARED_KEY = "cma-bot-finance-2026"
def _resolve_api_key() -> str:
key = (os.getenv("CMA_BOT_KEY_FINANCE") or "").strip()
if key:
return key
raw = (os.getenv("CMA_BOT_API_KEYS") or "").strip()
if raw:
try:
for k, v in json.loads(raw).items():
if isinstance(v, dict) and v.get("role") == "finance":
return k
except Exception:
pass
print("[WARN] 未配置 CMA_BOT_KEY_FINANCE / CMA_BOT_API_KEYS,回退历史共享键(待轮换)", file=sys.stderr)
return _LEGACY_SHARED_KEY
API_KEY = _resolve_api_key()
API_KEY = "cma-bot-finance-2026"
import httpx
from datetime import datetime
-465
View File
@@ -1,465 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""grant_surface_sync.py —— 新表授权面收敛(部署链步)
设计口径(2026-09-28,项目Bot;派单件 yanxue-20260928-newtable-grant-surface-automation)
------------------------------------------------------------------------------
问题:新表的表级授权不在任何自动步骤内。`db_migrate.py --apply` 只建表(create_all),
`treasury_migrate.py --apply` 的授权收敛步的 `NEW_TABLES` 硬编码 6 张 treasury 表
⇒ 新建业务表「表在、写权缺」→ 端点 500 / 1142 → 人工派单 + 铸审批 ID + 手工 GRANT。
实测同形已 3 例:subject_balances(09-21) / product_inventory(09-22) / quality_rule_config(09-28)。
本件(方案 A 的机制实现)三条不变量:
1. 只增不减:只发 GRANT,**从不** REVOKE;白名单外已存在的权限只报「漂移」,由基线巡检+人工处置。
2. fail-closed:① 本次部署新建的表若不在白名单 write_tables(= 无 table_ops 裁定)→ **零写入**中止;
② 白名单不可解析 / 缺 table_ops 裁定 / table_ops 含表级 SELECT → 中止;
③ 缺「本次新建表」锚 → apply 模式中止(不猜、不静默跳过覆盖检查)。
3. 集合键只出自白名单事实源(write_tables / table_ops),不从现网反推、不从自声明文本取。
用法
----
python3 grant_surface_sync.py --check [--new-tables a,b] # 只读计划(零写入)
python3 grant_surface_sync.py --apply --new-tables a,b # 收敛(GRANT 白名单集合)
python3 grant_surface_sync.py --apply --anchor-file <json> # 收敛(锚 = db_migrate 指针件)
python3 grant_surface_sync.py --audit # 只读双向差集巡检(方案 B 核心)
锚(Δ1 / Δ3 / R1)
------------------
* 「缺锚」只认三种:`--new-tables` 未给 / `--anchor-file` 缺件或坏件 / 锚件 `status != applied`;
**`created_tables=[]` = 合法零集**(幂等发布不中止,仍按白名单收敛)。
* R1-a 同源自证:锚件须携带本次 journal 引用(`journal`)与 `finished_at`;收敛步校验该 journal
在册、`status=applied`、`finished_at` 与锚件一致、且是目录内**最新**的 `-applied` 件;
任一不符 ⇒ rc=2、零写入(挡住「指针写失败但仍读到上一轮旧锚」的静默路径)。
* R1-b 未收敛锚不回退(累积):`db_migrate.write_anchor_pointer()` 写新指针时,若旧指针
`status=applied` 且无 `converged_at`,则 `created_tables` = 旧集合 ∪ 本次 created。
* R1-c 收敛回写:本步 `--apply` rc=0 时把 `converged_at`/语句数/`statements_md5` 原子回写锚件(幂等)。
连接(特权通道,与 db_migrate.py / treasury_migrate.py 同口径;Δ2)
env CMA_MIGRATE_DB_USER/PASS/HOST/PORT 优先(专用迁移账号,TCP)
或 --host/--port/--user/--password;CLI 显式连接旗标优先于 env(沙箱夹具用)
否则本机 unix socket + root(/var/run/mysqld/mysqld.sock)
[WARN] 应用运行账号 `cma_api` **不得**作迁移身份(无 GRANT 权限)—— 显式拒绝。
退出码
0 成功 / 1 回读不符(apply) / 2 用法或缺锚或锚不可用 / 3 新表缺裁定(fail-closed, 零写入)
4 白名单或连接不可用 / 5 越权漂移且 --drift-policy fail
"""
import argparse
import hashlib
import json
import os
import sys
from datetime import datetime
import pymysql
DEFAULT_WHITELIST = os.getenv(
"CMA_WRITE_WHITELIST", "/root/WB_WS/_reports/_cma_write_table_whitelist.json")
DEFAULT_SOCKET = os.getenv("CMA_MIGRATE_DB_SOCKET", "/var/run/mysqld/mysqld.sock")
DEFAULT_DB = os.getenv("CMA_MIGRATE_DB_NAME", "cma")
APP_ACCOUNT = os.getenv("CMA_APP_ACCOUNT", "cma_api")
HOSTS = ("localhost", "127.0.0.1")
# 授权面域:本步只可能涉及这 4 种表级 DML 权限(读面由库级 SELECT ON <db>.* 覆盖)
PRIV_DOMAIN = ("INSERT", "UPDATE", "DELETE")
# Δ2:应用运行账号禁止作迁移身份(与 db_migrate.py:FORBIDDEN_RUN_ACCOUNTS 同口径)
FORBIDDEN_RUN_ACCOUNTS = {"cma_api"}
ANCHOR_STATUS_APPLIED = "applied"
def md5f(path):
h = hashlib.md5()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def parse_privs(raw):
"""table_ops 值 -> 权限集合。值形如 'INSERT,UPDATE (...中文说明(含嵌套括号))'。"""
head = str(raw)
for sep in ("(", "("):
i = head.find(sep)
if i >= 0:
head = head[:i]
return {p.strip().upper() for p in head.split(",") if p.strip()}
def load_whitelist(path):
"""读白名单 -> (write_tables:set, ops_fn, meta)。fail-closed:不可解析即退出。"""
if not os.path.exists(path):
raise SystemExit("[FAIL] 白名单不存在:%s" % path)
try:
with open(path, encoding="utf-8") as fh:
doc = json.load(fh)
except Exception as exc:
raise SystemExit("[FAIL] 白名单不可解析 %s: %s" % (path, exc))
wt = doc.get("write_tables") or []
ops = doc.get("table_ops") or {}
if not wt:
raise SystemExit("[FAIL] 白名单 write_tables 为空(fail-closed,不猜)")
default_raw = ops.get("_default", "INSERT,UPDATE,DELETE")
return set(wt), ops, {"path": path, "md5": md5f(path), "default_ops": default_raw,
"write_tables_count": len(wt)}
def wanted_privs(table, ops, default_raw):
raw = ops.get(table, default_raw)
privs = parse_privs(raw)
bad = sorted(p for p in privs if p not in PRIV_DOMAIN)
if bad:
raise SystemExit(
"[FAIL] 白名单 %s 授权集含域外/冗余项 %s —— 读面由库级 SELECT 覆盖,"
"表级 SELECT 正是要收敛的冗余;缺裁定请回报调度方另单" % (table, bad))
return tuple(p for p in PRIV_DOMAIN if p in privs)
def resolve_identity(args):
"""Δ2 连接身份解析 -> (kind, kwargs, desc)。与 db_migrate.py::build_engine 同口径:
env CMA_MIGRATE_DB_USER(专用迁移账号,TCP)优先;未设则本机 unix socket + root。
CLI 显式连接旗标(--host / 非默认 --socket)优先于 env —— 保留沙箱夹具既有用法。
`cma_api` 等应用运行账号一律拒绝(无 GRANT 权限 = 本机制不成立)。"""
def _reject(user):
if user in FORBIDDEN_RUN_ACCOUNTS:
raise SystemExit(
"[FAIL] 连接身份 %s 是**应用运行账号**(无 GRANT 权限)——\n"
" 授权面收敛必须走特权迁移身份(CMA_MIGRATE_DB_USER 或本机 root socket)。"
"已拒绝执行。" % user)
cli_explicit = bool(args.host) or bool(args.socket and args.socket != DEFAULT_SOCKET)
if cli_explicit:
user = (args.user or "root").strip()
_reject(user)
if args.host:
desc = "tcp(%s@%s:%s/%s)" % (user, args.host, args.port, args.db)
return "tcp", {"host": args.host, "port": args.port, "user": user,
"password": args.password, "database": args.db}, desc
desc = "socket(%s@%s/%s)" % (user, args.socket, args.db)
return "socket", {"unix_socket": args.socket, "user": user,
"password": args.password, "database": args.db}, desc
env_user = os.getenv("CMA_MIGRATE_DB_USER", "").strip()
if env_user:
_reject(env_user)
host = os.getenv("CMA_MIGRATE_DB_HOST", "127.0.0.1")
port = int(os.getenv("CMA_MIGRATE_DB_PORT", "3306"))
desc = "tcp(%s@%s:%s/%s)" % (env_user, host, port, args.db)
return "tcp", {"host": host, "port": port, "user": env_user,
"password": os.getenv("CMA_MIGRATE_DB_PASS", ""),
"database": args.db}, desc
sock = args.socket or os.getenv("CMA_MIGRATE_DB_SOCKET") or DEFAULT_SOCKET
user = (args.user or "root").strip()
_reject(user)
desc = "socket(%s@%s/%s)" % (user, sock, args.db)
return "socket", {"unix_socket": sock, "user": user,
"password": args.password, "database": args.db}, desc
def connect(args):
kind, kwargs, desc = resolve_identity(args)
return pymysql.connect(autocommit=True, charset="utf8mb4", **kwargs), desc
def load_anchor_pointer(path):
"""Δ3 + R1-a:读锚指针件(`db_migrate.py --apply` 原子写)-> (created_tables, meta)。
任一情形不符 ⇒ SystemExit(调用方转 rc=2、零写入):
① 缺件 / 非 JSON / 结构非法;② status != applied;
③ R1-a 同源自证不符(缺 journal 引用 / journal 不在册 / 该 journal status != applied /
finished_at 不一致 / 该 journal 不是目录内最新 -applied 件)。"""
if not path or not os.path.exists(path):
raise SystemExit("[FAIL] 锚件不存在:%s(apply 缺锚 ⇒ fail-closed,零写入)" % path)
try:
with open(path, encoding="utf-8") as fh:
doc = json.load(fh)
except Exception as exc:
raise SystemExit("[FAIL] 锚件不可解析 %s: %s ⇒ 零写入" % (path, exc))
if not isinstance(doc, dict):
raise SystemExit("[FAIL] 锚件结构非法(非 JSON 对象):%s ⇒ 零写入" % path)
status = str(doc.get("status") or "")
if status != ANCHOR_STATUS_APPLIED:
raise SystemExit("[FAIL] 锚件 status=%s != applied(本次 apply 未成功)⇒ 零写入"
% (status or "<none>"))
jname = str(doc.get("journal") or "")
if not jname:
raise SystemExit("[FAIL] R1-a:锚件缺 journal 引用(无法自证出自本次 apply)⇒ 零写入")
jdir = os.path.dirname(os.path.abspath(path))
jpath = os.path.join(jdir, os.path.basename(jname))
if not os.path.exists(jpath):
raise SystemExit("[FAIL] R1-a:锚件引用的 journal 不在册 %s ⇒ 零写入" % jname)
try:
with open(jpath, encoding="utf-8") as fh:
jdoc = json.load(fh)
except Exception as exc:
raise SystemExit("[FAIL] R1-a:journal 不可解析 %s: %s ⇒ 零写入" % (jname, exc))
if str(jdoc.get("status") or "") != ANCHOR_STATUS_APPLIED:
raise SystemExit("[FAIL] R1-a:journal %s status=%s != applied ⇒ 零写入"
% (jname, jdoc.get("status")))
if doc.get("finished_at") != jdoc.get("finished_at"):
raise SystemExit("[FAIL] R1-a:锚件 finished_at=%s != journal %s 的 %s ⇒ 零写入"
% (doc.get("finished_at"), jname, jdoc.get("finished_at")))
applied = sorted(f for f in os.listdir(jdir) if f.endswith("-applied.json"))
if not applied or applied[-1] != os.path.basename(jname):
raise SystemExit("[FAIL] R1-a:锚件引用的 journal %s 不是目录内最新 apply 记录"
"(最新=%s)⇒ 锚非出自本次 apply,零写入"
% (os.path.basename(jname), applied[-1] if applied else "<无>"))
created = list(doc.get("created_tables") or [])
meta = {"path": path, "journal": os.path.basename(jname), "status": status,
"finished_at": doc.get("finished_at"), "created_tables": created,
"carried_over": bool(doc.get("carried_over")), "carried_from": doc.get("carried_from"),
"converged_at": doc.get("converged_at")}
return created, meta
def atomic_write_json(path, doc):
"""原子替换写 JSON(同目录 tmp + os.replace;模式/属组跟随原件的现状)。"""
tmp = "%s.tmp.%d" % (path, os.getpid())
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(doc, fh, ensure_ascii=False, indent=2)
fh.write("\n")
try:
if os.path.exists(path):
st = os.stat(path)
os.chmod(tmp, st.st_mode & 0o777)
os.chown(tmp, st.st_uid, st.st_gid)
else:
os.chmod(tmp, 0o644)
except Exception:
pass
os.replace(tmp, path)
def write_back_converged(anchor_path, out):
"""R1-c:收敛步 apply rc=0 时把 converged_at(+ 语句数 / statements_md5)原子回写锚件(幂等)。
rc!=0 不调用。回写不改动 created_tables / status / journal —— 只加收敛态四键。"""
try:
with open(anchor_path, encoding="utf-8") as fh:
doc = json.load(fh)
except Exception as exc:
return {"ok": False, "error": "%s: %s" % (type(exc).__name__, exc)}
stmts = list(out.get("executed") or [])
doc["converged_at"] = datetime.now().isoformat(timespec="seconds")
doc["converged_statements"] = len(stmts)
doc["converged_statements_md5"] = hashlib.md5("\n".join(stmts).encode("utf-8")).hexdigest()
doc["converged_by"] = "grant_surface_sync.py"
try:
atomic_write_json(anchor_path, doc)
except Exception as exc:
return {"ok": False, "error": "%s: %s" % (type(exc).__name__, exc)}
return {"ok": True, "path": anchor_path, "converged_at": doc["converged_at"],
"statements": doc["converged_statements"],
"statements_md5": doc["converged_statements_md5"]}
def db_tables(cur, db):
cur.execute("""SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA=%s AND TABLE_TYPE='BASE TABLE'""", (db,))
return {r[0] for r in cur.fetchall()}
def table_privs(cur, db, tables):
"""{(host, table): {priv}} —— 只枚举 cma_api 的双 host(带引号字面量,LIKE 恒 0 行)。"""
grantees = ["'%s'@'%s'" % (APP_ACCOUNT, h) for h in HOSTS]
out = {}
cur.execute("""SELECT GRANTEE, TABLE_NAME, PRIVILEGE_TYPE
FROM information_schema.TABLE_PRIVILEGES
WHERE TABLE_SCHEMA=%s AND GRANTEE IN (%s, %s)""",
(db, grantees[0], grantees[1]))
for grantee, table, priv in cur.fetchall():
_u, _s, host = str(grantee).rpartition("@")
out.setdefault((host.strip().strip("'"), str(table)), set()).add(str(priv).upper())
return out
def build_plan(dbn, tables, wt, ops, default_raw, cur_privs):
"""纯函数(只依赖白名单 + 现网只读快照)-> (statements, matrix, drift)。
statements : [GRANT ...] —— 逐 host/表白名单集合减去现持集合(只增不减,无 REVOKE)
matrix : 逐 (host,表) 的 wanted/have/missing/extras
drift : 白名单外已持权限(只报,不 REVOKE)
"""
stmts, matrix, drift = [], [], []
for host in HOSTS:
for t in sorted(wt):
if t not in tables:
continue
want = wanted_privs(t, ops, default_raw)
have = cur_privs.get((host, t), set())
missing = tuple(p for p in want if p not in have)
extras = tuple(sorted(have - set(want)))
matrix.append({"host": host, "table": t, "wanted": list(want),
"have": sorted(have), "missing": list(missing), "extras": list(extras)})
if extras:
drift.append("DRIFT %s@%s: 白名单外已持 %s(本步不 REVOKE,交基线巡检/ops 处置)"
% (t, host, list(extras)))
if missing:
stmts.append("GRANT %s ON `%s`.`%s` TO '%s'@'%s'"
% (", ".join(missing), dbn, t, APP_ACCOUNT, host))
return stmts, matrix, drift
def main():
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="执行收敛(只 GRANT)")
g.add_argument("--audit", action="store_true", help="只读双向差集巡检(方案 B)")
ap.add_argument("--whitelist", default=DEFAULT_WHITELIST)
ap.add_argument("--db", default=DEFAULT_DB)
ap.add_argument("--new-tables", default=None,
help="本次部署新建表(逗号分隔);= create_all 的 created_tables")
ap.add_argument("--anchor-file", default=None,
help="锚指针件(db_migrate --apply 写的 journal/last-anchor.json);"
"与 --new-tables 二选一")
ap.add_argument("--drift-policy", choices=("warn", "fail"), default="warn")
ap.add_argument("--socket", default=DEFAULT_SOCKET)
ap.add_argument("--host", default=None)
ap.add_argument("--port", type=int, default=3306)
ap.add_argument("--user", default="root")
ap.add_argument("--password", default="")
args = ap.parse_args()
out = {"script": "grant_surface_sync.py", "mode": "apply" if args.apply else
("audit" if args.audit else "check"), "db": args.db, "hosts": list(HOSTS),
"app_account": APP_ACCOUNT, "drift_policy": args.drift_policy}
try:
wt, ops, wl_meta = load_whitelist(args.whitelist)
except SystemExit as exc:
print(json.dumps(out, ensure_ascii=False, indent=2))
print(str(exc), file=sys.stderr)
return 4
out["whitelist"] = wl_meta
try:
conn, conn_desc = connect(args)
except SystemExit as exc: # Δ2:非法身份(应用运行账号)⇒ 用法级 fail-closed
print(json.dumps(out, ensure_ascii=False, indent=2))
print(str(exc), file=sys.stderr)
return 2
except Exception as exc:
print(json.dumps(out, ensure_ascii=False, indent=2))
print("[FAIL] 连接失败: %s" % exc, file=sys.stderr)
return 4
out["connection"] = conn_desc
try:
cur = conn.cursor()
tables = db_tables(cur, args.db)
cur_privs = table_privs(cur, args.db, tables)
# ── 巡检模式(方案 B 核心):只读双向差集,零写入 ──
if args.audit:
in_db_not_wl = sorted(t for t in tables if t not in wt)
in_wl_not_db = sorted(t for t in wt if t not in tables)
wl_no_grant = sorted("%s@%s" % (t, h) for t in sorted(wt) if t in tables
for h in HOSTS if not cur_privs.get((h, t)))
out.update({"audit": {"db_tables": len(tables), "whitelist_tables": len(wt),
"db_tables_not_in_whitelist": in_db_not_wl,
"whitelist_tables_absent_in_db": in_wl_not_db,
"whitelist_tables_without_grant": wl_no_grant},
"note": "方向一(库内表⊄白名单)需代码证据判读:只读表合法无裁定,"
"故只报不判;方向二(白名单⊄实授权)即待授权清单,判 GAP"})
# 判据只取「授权面缺口」:白名单表缺授权 / 白名单表缺表。
# 库内多出的表只报不判(只读表本就无须裁定,判它等于自造假缺陷)。
grant_gap = bool(in_wl_not_db or wl_no_grant)
out["verdict"] = ("GAP" if grant_gap else
("NO_GRANT_GAP_DB_TABLES_NOT_WHITELISTED" if in_db_not_wl
else "NO_GAP"))
print(json.dumps(out, ensure_ascii=False, indent=2))
return 1 if grant_gap else 0
# ── 锚解析(Δ1/Δ3):缺锚只认「未给/缺件坏件/status!=applied」;空集 = 合法零集 ──
anchor_meta, new_tables = None, []
if args.new_tables is not None: # 显式给了(含空串 = 合法零集)
new_tables = [t.strip() for t in args.new_tables.split(",") if t.strip()]
anchor_source = "--new-tables"
elif args.anchor_file:
try:
new_tables, anchor_meta = load_anchor_pointer(args.anchor_file) # R1-a
except SystemExit as exc:
out["anchor_source"] = "--anchor-file"
out["verdict"] = "FAIL_CLOSED_ANCHOR_UNUSABLE"
print(json.dumps(out, ensure_ascii=False, indent=2))
print(str(exc), file=sys.stderr)
return 2
anchor_source = "--anchor-file:%s" % anchor_meta["journal"]
else:
anchor_source = "none"
out["anchor_source"] = anchor_source # Δ4
coverage = {"anchor": (args.new_tables if args.new_tables is not None else
(anchor_meta or {}).get("created_tables")),
"anchor_source": anchor_source, "new_tables": new_tables,
"anchor_meta": anchor_meta, "missing_caliber": []}
if args.apply and anchor_source == "none":
out["coverage"] = coverage
print(json.dumps(out, ensure_ascii=False, indent=2))
print("[FAIL] apply 缺「本次部署新建表」锚(--new-tables / --anchor-file)"
"—— fail-closed,零写入", file=sys.stderr)
return 2
coverage["missing_caliber"] = sorted(t for t in new_tables if t not in wt)
out["coverage"] = coverage
stmts, matrix, drift = build_plan(args.db, tables, wt, ops,
wl_meta["default_ops"], cur_privs)
out["plan"] = {"statements": stmts, "statement_count": len(stmts),
"matrix": matrix, "drift": drift,
"tables_in_whitelist_present_in_db": sorted(t for t in wt if t in tables),
"whitelist_tables_absent_in_db": sorted(t for t in wt if t not in tables)}
if coverage["missing_caliber"]:
out["verdict"] = "FAIL_CLOSED_NEW_TABLE_WITHOUT_CALIBER"
print(json.dumps(out, ensure_ascii=False, indent=2))
print("[FAIL] 本次部署新建表缺白名单裁定 %s —— 零写入中止(部署链应据此非零退出)"
% coverage["missing_caliber"], file=sys.stderr)
return 3
if args.drift_policy == "fail" and drift:
out["verdict"] = "DRIFT"
print(json.dumps(out, ensure_ascii=False, indent=2))
print("[FAIL] 检测到白名单外已持权限(drift-policy=fail):%s" % drift, file=sys.stderr)
return 5
for d in drift:
print("[warn] " + d, file=sys.stderr)
if not args.apply:
out["verdict"] = "PLAN" if stmts else "CONVERGED_ALREADY"
out["statements_md5"] = hashlib.md5("\n".join(stmts).encode("utf-8")).hexdigest()
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
# ── 执行(只 GRANT;不做任何 REVOKE/DML/DDL)──
executed, skipped = [], []
for stmt in stmts:
cur.execute(stmt)
executed.append(stmt)
out["executed"] = executed
out["skipped_idempotent"] = skipped
after = table_privs(cur, args.db, tables)
readback, bad = [], []
for host in HOSTS:
for t in sorted(t for t in wt if t in tables):
want = set(wanted_privs(t, ops, wl_meta["default_ops"]))
have = after.get((host, t), set())
readback.append({"host": host, "table": t, "wanted": sorted(want),
"have": sorted(have), "ok": want <= have})
if not want <= have:
bad.append("%s@%s have=%s want=%s" % (t, host, sorted(have), sorted(want)))
out["readback"] = readback
out["converged"] = not bad
out["converged_mismatch"] = bad
out["verdict"] = "APPLIED" if not bad else "READBACK_MISMATCH"
# 幂等自证:二跑语句数应为 0(在同一连接、同一现网态上重算计划)
stmts2, _m2, _d2 = build_plan(args.db, tables, wt, ops,
wl_meta["default_ops"], table_privs(cur, args.db, tables))
out["idempotent_second_run_statement_count"] = len(stmts2)
# R1-c:仅收敛成功(rc=0)时回写锚件;rc!=0 与 --check 都不写
if not bad and anchor_meta is not None:
out["anchor_writeback"] = write_back_converged(anchor_meta["path"], out)
print(json.dumps(out, ensure_ascii=False, indent=2))
if bad:
print("[FAIL] 授权回读不符白名单:%s" % bad, file=sys.stderr)
return 1
return 0
finally:
conn.close()
if __name__ == "__main__":
sys.exit(main())
+7 -139
View File
@@ -10,16 +10,6 @@
列:分类,编码,项目名称,方向,金额 分类∈{经营活动,投资活动,筹资活动}
兼容 2026.08 的另存名 cashflow_stat_<YYYY.MM>.csv
期初/期末现金数据源(本版本新增,任务 project-20260923-cashflow-beginend-cash-backfill):
/root/WB_WS/_data/zxt-hanke/<YYYY.MM>/kmyeb.csv(科目余额表,只读)
取 科目编码 ∈ {1001 库存现金, 1002 银行存款, 1012 其他货币资金}
期初 = Σ(期初借 − 期初贷);期末 = Σ(期末借 − 期末贷);元 → 万元(÷10000,2 位)
依据:科目余额表货币资金逐期变动 == 库内三大活动净额合计(8/8 期吻合 ±0.01,
2026-09-23 交叉验证);上游「现金流量表主表」(ReportDesign 1405)金额实测全 0 不可用
⇒ 主表口径(历史 §项1-② 缺陷)不改,改走科目余额表货币资金。
该源属**智享通通道 = 酣客账套**(KMYEB_ENTITY_ID),故仅对 entity_id=1 生效;
其它账套保持 NULL(不得把酣客货币资金写到别家账套)。
口径:
- 经营/投资/筹资 净额 = Σ(流入) − Σ(流出),单位 元 → 万元(÷10000,保留 2 位)
- **NA(无发生额)标注(本版本新增,裁定 finance-20260916-cma-caliber-ruling §2.1)**:
@@ -28,16 +18,13 @@
供判定层用 `parse_na_activities(remark)` 取回 → **仅作标注与机读入口**;
口径裁定(2026-09-17)已定:该维按 **0 如实参与判定**(八类判定的**二维映射**,
经营(±)×筹资(±) 出 2 个候选类型),**不再**因 NA/0 整体判「未判定」。
- source='official'(官方报表口径)
- **期初/期末现金**(本版本新增):取科目余额表货币资金(1001/1002/1012)→ 万元 2 位,
源文件/科目缺失时保持 NULL(不写 0 造数);仅 entity_id=1(智享通通道=酣客)生效
- source='official'(官方报表口径);期初/期末现金 留空(主表采集缺陷,见报告 §项1-②)
- 同 (entity_id, period) 重复执行 = 覆盖更新(幂等)
用法:
python3 scripts/import_cashflow_statements.py # dry-run(默认,只算不写)
python3 scripts/import_cashflow_statements.py --period 2026-07 # 单期 dry-run
python3 scripts/import_cashflow_statements.py --self-test # 标注+期初期末自测(零库连接;
# 边界用例只在系统临时目录造样,不碰生产源)
python3 scripts/import_cashflow_statements.py --self-test # NA 标注逻辑自测(零库连接)
python3 scripts/import_cashflow_statements.py --apply # 落库(需放行)
"""
import argparse
@@ -46,7 +33,6 @@ import glob
import os
import re
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -57,13 +43,6 @@ from app.api.cashflow_structure import classify_enterprise
ZKT_BASE = "/root/WB_WS/_data/zxt-hanke/cashflow"
CLASSES = ("经营活动", "投资活动", "筹资活动")
#: 科目余额表(kmyeb.csv)根目录 —— 与 cashflow/ 同级,期初/期末现金来源
KMYEB_BASE = "/root/WB_WS/_data/zxt-hanke"
#: 货币资金科目:库存现金 / 银行存款 / 其他货币资金
CASH_CODES = ("1001", "1002", "1012")
#: kmyeb 属智享通通道 = 酣客账套;其它账套不得套用(防跨账套写脏)
KMYEB_ENTITY_ID = 1
#: NA 固定短语模板 —— 判定层与量纲守卫共用,勿改字面量(裁定 §2.1 指定)
NA_PHRASE_TMPL = "{cls}本期无发生额(无项目行)"
@@ -114,52 +93,6 @@ def _nets(rows):
return {c: round(v["in"] - v["out"], 2) for c, v in agg.items()}, agg
def _read_cash_balances(period, base=KMYEB_BASE):
"""读科目余额表货币资金 → (begin_wan, end_wan, meta)。
期初 = Σ(期初借 − 期初贷)、期末 = Σ(期末借 − 期末贷),科目 ∈ CASH_CODES,元 → 万元 2 位。
借方−贷方为**有符号**口径:贷方大于借方即为负(红字/透支/方向反),**不得取绝对值或清零**。
源文件缺失 / 无任一货币资金科目行 ⇒ 返回 (None, None, meta),由调用方保持 NULL(不写 0 造数)。
"""
day_dir = os.path.join(base, period.replace("-", "."))
src = os.path.join(day_dir, "kmyeb.csv")
if not os.path.exists(src):
return None, None, {"status": "SKIP_no_kmyeb", "src": src, "codes": []}
beg = 0.0
end = 0.0
hits = []
with open(src, encoding="utf-8-sig") as fh:
for r in csv.DictReader(fh):
code = (r.get("科目编码") or "").strip()
if code not in CASH_CODES:
continue
try:
b = float((r.get("期初借") or "0").strip() or 0) - float((r.get("期初贷") or "0").strip() or 0)
e = float((r.get("期末借") or "0").strip() or 0) - float((r.get("期末贷") or "0").strip() or 0)
except ValueError:
continue
hits.append(code)
beg += b
end += e
if not hits:
return None, None, {"status": "SKIP_no_cash_subject", "src": src, "codes": []}
return (round(beg / 10000.0, 2), round(end / 10000.0, 2),
{"status": "READY", "src": src, "codes": hits})
def _write_kmyeb(root, period, rows):
"""自测专用:在临时目录造 kmyeb.csv 样件(表头与生产源同构)。"""
d = os.path.join(root, period.replace("-", "."))
os.makedirs(d, exist_ok=True)
p = os.path.join(d, "kmyeb.csv")
with open(p, "w", encoding="utf-8-sig", newline="") as fh:
w = csv.writer(fh)
w.writerow(["科目编码", "科目名称", "期初借", "期初贷", "本期借", "本期贷",
"本年借", "本年贷", "当前借", "当前贷", "期末借", "期末贷"])
w.writerows(rows)
return p
def collect(period_filter=None):
"""扫描盘上各期 → [payload dict](万元,未写库)"""
payloads = []
@@ -189,7 +122,6 @@ def collect(period_filter=None):
os.path.basename(src), len(rows))
remark = (head + ";NA(无发生额,按0参与判定): " + "、".join(na_phrases)) if na_phrases \
else (head + ";三类活动均有项目行(无 NA)")
cash_begin, cash_end, cash_meta = _read_cash_balances(period)
payloads.append({
"period": period,
"status": "READY",
@@ -202,9 +134,6 @@ def collect(period_filter=None):
"na_phrases": na_phrases,
"na_flags": {c: (c in missing) for c in CLASSES},
"remark": remark,
"begin_cash": cash_begin,
"end_cash": cash_end,
"cash_meta": cash_meta,
})
return payloads
@@ -234,58 +163,12 @@ def _self_test():
assert ready, "无可入库期(数据源缺失)"
all_inv_na = all("投资活动" in p["na_classes"] for p in ready)
passed += 1
assert all_inv_na, "酣客 8 期投资活动应全为 NA(裁定 §2.1 实证)"
# 6~8) 期初/期末现金取数边界(临时目录造样,零生产源写)
with tempfile.TemporaryDirectory() as tmp:
# 6) kmyeb 缺失 → 保持 NULL(不得写 0)
b6, e6, m6 = _read_cash_balances("2026-01", base=tmp)
assert (b6, e6) == (None, None) and m6["status"] == "SKIP_no_kmyeb", (b6, e6, m6)
passed += 1
# 7) 文件在但无货币资金科目(非现金科目不得混入)
_write_kmyeb(tmp, "2026-01", [
["1122", "应收账款", "999", "0", "0", "0", "0", "0", "0", "0", "999", "0"]])
b7, e7, m7 = _read_cash_balances("2026-01", base=tmp)
assert (b7, e7) == (None, None) and m7["status"] == "SKIP_no_cash_subject", (b7, e7, m7)
passed += 1
# 8) 借贷为负(贷>借):负号必须保留,不得 abs/清零;非现金科目应被忽略
_write_kmyeb(tmp, "2026-02", [
["1001", "库存现金", "0", "5000", "0", "0", "0", "0", "0", "0", "0", "12000"],
["1002", "银行存款", "20000", "0", "0", "0", "0", "0", "0", "0", "30000", "0"],
["1122", "应收账款", "999", "0", "0", "0", "0", "0", "0", "0", "999", "0"]])
b8, e8, m8 = _read_cash_balances("2026-02", base=tmp)
assert (b8, e8) == (1.5, 1.8), (b8, e8)
assert m8["status"] == "READY" and sorted(m8["codes"]) == ["1001", "1002"], m8
_write_kmyeb(tmp, "2026-03", [
["1001", "库存现金", "0", "12000", "0", "0", "0", "0", "0", "0", "0", "12000"]])
b8b, e8b, _m = _read_cash_balances("2026-03", base=tmp)
assert (b8b, e8b) == (-1.2, -1.2), (b8b, e8b)
passed += 1
# 9) 真实源 8 期:期初/期末齐全 + 与三大净额自洽(期末−期初 == Σ净额 ±0.02)+ 跨期连续(±0.01)
with_cash = [p for p in ready
if p["begin_cash"] is not None and p["end_cash"] is not None]
assert len(with_cash) == len(ready), "部分期未取到货币资金:%d/%d" % (len(with_cash), len(ready))
print("=== NA 标注 self-test: %d/5 PASS | 期数=%d | 投资活动全NA=%s ==="
% (passed, len(ready), all_inv_na))
for p in ready:
s = round(sum(p["nets_wan"][c] for c in CLASSES), 2)
d = round(p["end_cash"] - p["begin_cash"], 2)
assert abs(d - s) <= 0.02, ("期末−期初≠净额合计", p["period"], d, s)
prev_end = None
for p in ready:
if prev_end is not None:
assert abs(p["begin_cash"] - prev_end) <= 0.01, (
"跨期不连续", p["period"], p["begin_cash"], prev_end)
prev_end = p["end_cash"]
passed += 1
print("=== self-test: %d/9 PASS | 期数=%d | 投资活动全NA=%s | 期初/期末齐全=%d/%d ==="
% (passed, len(ready), all_inv_na, len(with_cash), len(ready)))
for p in ready:
print(" %-8s NA=%s | 期初=%.2f 期末=%.2f 万(Δ=%.2f / 净额合计=%.2f)"
% (p["period"], "、".join(p["na_classes"]) or "无", p["begin_cash"], p["end_cash"],
round(p["end_cash"] - p["begin_cash"], 2),
round(sum(p["nets_wan"][c] for c in CLASSES), 2)))
print(" %-8s NA=%s" % (p["period"], "、".join(p["na_classes"]) or "无"))
print(" remark: %s" % p["remark"])
assert all_inv_na, "酣客 8 期投资活动应全为 NA(裁定 §2.1 实证)"
return 0
@@ -335,14 +218,6 @@ def main():
verdict, os.path.basename(p["src"])))
print(" NA(无发生额)=%s;投资栏标注=%s" % (
"、".join(p["na_classes"]) or "无", cls.get("investing_note") or "-"))
cash_applicable = (args.entity_id == KMYEB_ENTITY_ID)
has_cash = p["begin_cash"] is not None and p["end_cash"] is not None
print(" 期初/期末现金 = %s / %s 万(源 %s)%s" % (
"NULL" if p["begin_cash"] is None else "%.2f" % p["begin_cash"],
"NULL" if p["end_cash"] is None else "%.2f" % p["end_cash"],
os.path.basename(p["cash_meta"]["src"]),
"" if cash_applicable else
";账套 %d 非智享通通道 → 不写期初/期末" % args.entity_id))
if not args.apply:
continue
rec = db.query(CashFlowStructureValue).filter(
@@ -358,16 +233,9 @@ def main():
rec.financing_net = n["筹资活动"]
rec.source = "official"
rec.remark = p["remark"]
if cash_applicable and has_cash:
# 期初/期末现金:科目余额表货币资金(已 ÷10000 留 2 位)
rec.begin_cash = p["begin_cash"]
rec.end_cash = p["end_cash"]
db.flush()
written += 1
print(" → %s (id=%s) 期初/期末=%s" % (
"入库" if created else "更新", rec.id,
"NULL(源缺失,保持原值)" if not (cash_applicable and has_cash)
else "%.2f / %.2f 万" % (rec.begin_cash, rec.end_cash)))
print(" → %s (id=%s)" % ("入库" if created else "更新", rec.id))
if args.apply:
db.commit()
print("=== 已提交 %d 期 ===" % written)
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""只读侦查(生产读,本单 dama-entity-isolation-sweep-20260923 §一/§二 前置)
只 SELECT,无任何写。用于:
1. system_configs / role_permissions 全量键实况(含 kpi_alert_higher_better / budget_mode /
budget_driver_mode / current_tenant / cash.current_balance.* / alignment_config.*)
2. operation_logs 实况(action 分布、switch_entity / switch_tenant_state 行数、最近行)
3. entities 实况(账套清单)
4. kpi_definitions 列面(是否有自身方向字段)+ 逐账套 KPI 分布
用法: python3 <this> [--json out.json]
"""
import json
import os
import sys
from pathlib import Path
BACKEND = Path("/srv/hermes/shared/cma-management/backend")
os.chdir(BACKEND)
sys.path.insert(0, str(BACKEND))
from sqlalchemy import text # noqa: E402
from app.database import get_engine # noqa: E402
out = {}
eng = get_engine()
with eng.connect() as c:
out["db"] = c.execute(text("SELECT DATABASE()")).scalar()
out["system_configs"] = [
{"id": r[0], "config_key": r[1], "config_value": r[2], "description": r[3],
"updated_at": str(r[4])}
for r in c.execute(text(
"SELECT id, config_key, config_value, description, updated_at "
"FROM system_configs ORDER BY config_key"))
]
out["role_permissions"] = [
{"id": r[0], "key": r[1], "value": r[2], "updated_at": str(r[3])}
for r in c.execute(text("SELECT id, `key`, `value`, updated_at FROM role_permissions ORDER BY `key`"))
]
out["entities"] = [
{"id": r[0], "name": r[1], "short_name": r[2], "status": r[3]}
for r in c.execute(text("SELECT id, name, short_name, status FROM entities ORDER BY id"))
]
out["op_logs_actions"] = [
{"action": r[0], "n": r[1], "last": str(r[2])}
for r in c.execute(text(
"SELECT action, COUNT(*) n, MAX(created_at) last FROM operation_logs "
"GROUP BY action ORDER BY n DESC"))
]
out["op_logs_switch_rows"] = [
{"id": r[0], "user_id": r[1], "action": r[2], "target_type": r[3], "target_id": r[4],
"detail": (r[5] or "")[:300], "created_at": str(r[6])}
for r in c.execute(text(
"SELECT id, user_id, action, target_type, target_id, detail, created_at "
"FROM operation_logs WHERE action LIKE '%switch%' OR action LIKE '%tenant%' "
"ORDER BY id DESC LIMIT 10"))
]
out["op_logs_recent"] = [
{"id": r[0], "user_id": r[1], "action": r[2], "target_type": r[3], "target_id": r[4],
"created_at": str(r[5])}
for r in c.execute(text(
"SELECT id, user_id, action, target_type, target_id, created_at "
"FROM operation_logs ORDER BY id DESC LIMIT 12"))
]
out["system_configs_cols"] = [
{"col": r[0], "type": r[1], "null": r[2], "key": r[3], "default": str(r[4])}
for r in c.execute(text(
"SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT "
"FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() "
"AND TABLE_NAME IN ('system_configs','role_permissions','operation_logs') "
"ORDER BY TABLE_NAME, ORDINAL_POSITION"))
]
out["kpi_def_cols"] = [
r[0] for r in c.execute(text(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kpi_definitions' "
"ORDER BY ORDINAL_POSITION"))
]
out["kpi_by_entity"] = [
{"entity_id": r[0], "n": r[1]} for r in c.execute(text(
"SELECT entity_id, COUNT(*) n FROM kpi_definitions GROUP BY entity_id ORDER BY entity_id"))
]
out["budgetplan_by_entity"] = [
{"entity_id": r[0], "n": r[1]} for r in c.execute(text(
"SELECT kd.entity_id, COUNT(*) n FROM budget_plans bp "
"JOIN kpi_definitions kd ON kd.id=bp.kpi_id GROUP BY kd.entity_id ORDER BY kd.entity_id"))
]
print(json.dumps(out, ensure_ascii=False, indent=1, default=str))
if "--json" in sys.argv:
p = sys.argv[sys.argv.index("--json") + 1]
Path(p).write_text(json.dumps(out, ensure_ascii=False, indent=1, default=str))
print("WROTE", p)
+76 -644
View File
@@ -1,60 +1,28 @@
"""保本点(盈亏平衡)派生KPI执行器 — 管理会计OS (cma-bep-kpi 2026-09-08)
按财务Bot权威口径(陕西酣客_保本点分析_2026年1-7月.md)自动计算并落库六个派生KPI:
F_BEP_REVENUE 保本销售额(万元) = 月均固定成本 ÷ 边际贡献率 (账面口径,含折旧)
按财务Bot权威口径(陕西酣客_保本点分析_2026年1-7月.md)自动计算并落库五个派生KPI:
F_BEP_REVENUE 保本销售额(万元) = 月均管理费 ÷ 边际贡献率 (账面口径,含折旧)
F_BEP_COVER 保本覆盖率(%) = 月均销售 ÷ 保本销售额 × 100 (正向: >=100绿 >=85黄 <85红)
F_BEP_GAP 保本缺口(%) = (保本销售额-月均销售) ÷ 保本销售额 × 100 (反向: <=0绿 <=15黄 >15红)
F_BEP_REVENUE_CASH 现金保本销售额(万元) = (月均固定成本-月均折旧) ÷ 边际贡献率 (现金口径,中性无阈值)
F_BEP_REVENUE_CASH 现金保本销售额(万元) = (月均管理费-月均折旧) ÷ 边际贡献率 (现金口径,剔除折旧560213,中性无阈值)
F_BEP_COVER_CASH 现金保本覆盖率(%) = 月均销售 ÷ 现金保本销售额 × 100 (正向: >=100绿 >=85黄 <85红)
F_BEP_VC_RATE 变动成本率(%) = 见下方「实体口径注册表」 (YTD累计; 保本公式分母,无阈值)
F_BEP_REBATE 实测渠补率(%) = e1 专用展示 chip(Σ571103÷Σ5001,YTD; e2 无渠补科目 ⇒ NULL)
实体口径注册表(2026-10-03 新增,任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack):
· entity 1 陕西酣客:变动成本率 = Σ(5401销货成本 + 5403税金及附加 + 571103渠补 − 530103厂补) ÷ Σ5001贷方
〔⑤口径统一 2026-10-03,任总 18:00「统一」;勾稽:808.80 − 672.65 − 224.16 = −88.01 ≈ −88.00 营业利润〕;
固定成本 = 5601% + 5602% + 5603% 净额;折旧剔除 = 560213(管理费用-折旧叶子)。
实测渠补率 F_BEP_REBATE 保留(仍是渠补单一事实源),但**不再充当变动成本率**。
· entity 2 博海:**无渠补科目(571103)**,变动成本 = Σ6401借(主营业务成本),
变动成本率 = Σ6401借 ÷ Σ6001贷;固定成本 = (6601 销售费用 + 6602 管理费用 + 6603 财务费用) 净额;
折旧剔除 = 1602 累计折旧「当期贷方发生额」(博海科目表只到 4 位一级,无费用类折旧叶子;
2026-08 起博海 1602 无计提行,剔除额为 0 —— 见回报件)。
博海明细账的期间损益结转腿(summary='结转期间损溢',账套内 **未** 标 carry_forward=1)
必须显式剔除,否则净额恒 0 会把已结账期间清零(口径见
/srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md)。
口径(与财务Bot报告一致):
- 销售收入: entity1 = subject_code='5001'(主营业务收入) 贷方合计;entity2 = '6001' 贷方合计。当年1月至目标月 YTD 月均
- 固定成本: entity1 = subject_code LIKE '5601%'/'5602%'/'5603%'(销售/管理/财务费用,含560213折旧) 借−贷净额合计(carry_forward=0),YTD 月均〔⑤扩容〕
entity2 = 6601/6602/6603 借−贷净额合计(同上,但另需剔除结转腿)
- 折旧: entity1 = '560213' 借−贷净额;entity2 = '1602' 贷方发生额(现金口径剔除项)
- 变动成本率: **完整实测口径**(⑤口径统一 2026-10-03,任总 18:00「统一」;勾稽件 _reports/陕西酣客_边际贡献式损益表_20261003.md)
酣客 = Σ(5401销货成本 + 5403税金及附加 + 571103渠补 − 530103厂补) ÷ Σ(5001 贷方) × 100,YTD,保留 2 位小数。
`POLICY_REBATE_RATE_PCT = 84.61` 政策常量**仅作对照展示**(写进返回值 rebate_policy_pct / remark),
**不参与任何计算**;`--rebate-rate` 显式传值时视为人工诊断覆盖(正常调用不再使用)。
- 边际贡献率 = 100 − 变动成本率
- fail-closed 边界(2026-10-02 裁定):某期 571103 或 5001 缺数/为 0 ⇒ **该期不出保本值**
(六条派生行写 actual_value=NULL + data_status='pending' + remark 注明「变动成本率不可得」),
**不得静默回落 84.61 常量**;累计口径天然平滑单月波动(8 月单月 571103/5001=134% 不影响累计值)。
- fail-closed 边界 ②(2026-10-03 裁定,本单):**变动成本率 ≥ 100% ⇔ 边际贡献率 ≤ 0 ⇒ 保本点不适用**。
该期 五条保本派生值(REVENUE/COVER/GAP/REVENUE_CASH/COVER_CASH)写 actual_value=NULL +
data_status='pending' + remark 含「边际贡献非正,保本点不适用」,**禁止回落 0.0**
(0.0 会被前端显示成「月保本 0 万、覆盖率 0%」,语义完全错误);变动成本率本身照常落库。
与既有 fail-closed(缺数/为 0)**同一处理路径风格**,仅文案与触发条件不同。
- 销售收入: voucher_details.subject_code='5001'(主营业务收入) 贷方合计,当年1月至目标月 YTD 月均
- 管理费: subject_code LIKE '5602%'(管理费用,含560213折旧) 借方合计,YTD 月均 → 账面口径
- 折旧: subject_code = '560213'(管理费用-折旧叶子) 借方合计,YTD 月均 → 现金口径剔除项(eq精确匹配,防5602130误含)
- 渠补率: 厂家政策定死 84.61% (571103渠补支出/5001销售, 1-7月累计凭证验证≈84.0%~84.6%)
- 边际贡献率 = 1 - 渠补率 = 15.39%
- 目标月: --period 显式指定;默认自动选最近「已结账」完整月(当月跳过)。
结账信号优先:DB 损益结转凭证(carry_forward=1 & 本年利润科目 3103(酣客)/4103(博海)) → check.md 文件;
结账信号优先:DB 损益结转凭证(carry_forward=1 & subject_code='3103') → check.md 文件;
两者皆无时才退回旧「销售额阈值」兜底(销售低 ≠ 未结账,仅保底用,日志标注依据)
- 单位: voucher 金额为元,聚合后 /10000 → 万元
- 幂等: 同 kpi_id+period+source_type='derived' 存在则更新,可安全重跑;
**不降级**(ASK-20260924-10 / 2026-09-24):值未变或值变都不回写 data_status,
保留既有状态(含人工/任总放行的 verified),避免「人工转正 → 次日派生静默打回 pending」。
值变时 remark 留痕并落复核提示件(见 _reports/fullstack-<YYYYMMDD>-bep-value-changed-review.md)。
**例外**:fail-closed 两条路径(不可得 / 边际贡献非正)按裁定显式写 data_status='pending'。
- 幂等: 同 kpi_id+period+source_type='derived' 存在则更新,可安全重跑
- 只写 source_type='derived' 行;不触碰 auto_collect/manual/excel/ledger 行;不改 collector 逻辑
用法:
venv/bin/python3 scripts/kpi_bep_deriver.py # 自动选目标月
venv/bin/python3 scripts/kpi_bep_deriver.py --period 2026-07 # 指定期间(实跑验证用)
venv/bin/python3 scripts/kpi_bep_deriver.py --entity 2 --period 2026-08 # 博海(新口径)
venv/bin/python3 scripts/kpi_bep_deriver.py --entity 1 --rebate-rate 84.61
venv/bin/python3 scripts/kpi_bep_deriver.py --dry-run # 只计算不落库
"""
@@ -67,18 +35,14 @@ from datetime import datetime, date
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import re
from sqlalchemy import func, or_
from sqlalchemy import func
from app.database import get_session_local
from app.models import KPIValue, KPIDefinition, VoucherDetail, KPIAlert
# 裁定 R1(2026-09-19 财务口径):demo 账套不产生业务预警(派生值仍落库,仅跳过预警同步)
from app.utils.alert_entity_scope import is_excluded_entity
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("cma.bep_deriver")
# 厂家政策渠补率(定死不可谈判,财务Bot报告权威值 2026-09-08)
# ⚠️ 2026-10-02 起**仅作对照展示**(返回值 rebate_policy_pct / remark),不参与任何计算。
# 参与计算的渠补率 = 逐期实测累计(见 compute_rebate_rate / 文件头「渠补率」条)。
POLICY_REBATE_RATE_PCT = 84.61
# 派生KPI 编码 → (kpi_code, 名称)
@@ -88,90 +52,20 @@ TARGET_KPIS = [
("F_BEP_GAP", "保本缺口"),
("F_BEP_REVENUE_CASH", "现金口径保本销售额"),
("F_BEP_COVER_CASH", "现金口径保本覆盖率"),
("F_BEP_VC_RATE", "变动成本率"),
("F_BEP_REBATE", "实测渠补率"),
]
# 变动成本率 KPI(⑤ 口径统一 2026-10-03:保本公式分子口径 = 销货成本+税金及附加+渠补−厂补)
VC_RATE_KPI = "F_BEP_VC_RATE"
# 实测渠补率 KPI 编码(卡面单一事实源;无阈值,不参与预警级别判定;⑤ 后**不再充当变动成本率**)
REBATE_KPI = "F_BEP_REBATE"
# 保本口径 KPI(缺 F_BEP_REBATE 定义时不阻断派生主流程,见 run_derive 告警分支)
CORE_KPI_CODES = [c for c, _ in TARGET_KPIS if c != REBATE_KPI]
# 现金口径 KPI 编码(计算路径独立于账面,remark 文案用现金口径)
CASH_CODES = {"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH"}
SALES_CODE = "5001" # 主营业务收入(酣客)
SALES_CODE = "5001" # 主营业务收入
ADMIN_PREFIX = "5602" # 管理费用(含折旧叶子560213)
DEP_SUBJECT = "560213" # 管理费用-折旧(现金口径剔除项,eq 精确匹配防 5602130 误含)
REBATE_CODE = "571103" # 渠补/捐赠支出(校验用,不直接进公式)
CARRY_SUBJECT = "3103" # 本年利润(carry_forward=1 且该科目 = 该期损益结转凭证)
# ── 实体口径注册表(2026-10-03)——单一事实源见 app/utils/bep_caliber.py ─────────
# 变动成本率(rate)= numerator ÷ denominator(YTD 累计):
# · 酣客:Σ571103借方(渠补) ÷ Σ5001贷方(收入) —— 2026-10-02 财务裁定;结转账腿已由 carry_forward=1 标识
# · 博海:Σ6401借方(主营业务成本) ÷ Σ6001贷方(收入) —— 2026-10-03 财务裁定「新口径,勿套酣客渠补公式」
# exclude_closing:该账套的「期间损益结转腿」是否需要用 summary 显式剔除
# (博海账套的结转行 carry_forward 仍是 0,不剔除则已结账期间净额恒 0 ⇒ 指标被清零)
# 同表被 backend/app/api/dashboard.py::/bep-card 共用(口径不漂移)。
from app.utils.bep_caliber import ( # noqa: E402 (紧随 sys.path 之后导入)
CLOSING_SUMMARY_LIKE, ENTITY_CALIBERS, caliber_for, fixed_cost_spec,
)
# 智享通落盘目录(与 app/api/data.py 的 ZXT_LANDING_DIR 一致)——结账信号次选来源
ZXT_LANDING_DIR = "/root/WB_WS/_data/zxt-hanke"
# ── ASK-20260924-10(2026-09-24 任总放行):派生器不得静默降级已转正行 ──────────
# 缺陷(原实现):upsert 分支无条件 `existing.data_status = "pending"`(与值是否变化无关)
# ⇒ 人工/任总放行的值转正为 verified 后,次日 06:35 派生器一跑就被打回 pending,
# 看板 / 预警 / Bot 全链路再次读不到,交付在 24 小时内静默消失。
# 修正口径(本单指定,属待财务复核的裁定点):
# · 值未变 → 不写 data_status(保留既有,含 verified);source_batch/remark/actual_value 照旧更新
# · 值变 → 同样不写 data_status(不降级,避免看板数字忽现忽隐)
# + remark 追加「值已更新(batch=…, 旧X → 新Y) 待财务复核」+ 落复核提示件
# · 新期间(原本无行)→ 维持 pending(9-14 复核流入口不变)
# 提示件为**报告类**,落 `_reports/` 而非对方队列根(避免误唤醒其他 Bot);
# 落点可用环境变量 BEP_REVIEW_NOTE_DIR 覆盖(沙箱/测试用)。
DEFAULT_REVIEW_NOTE_DIR = "/root/.hermes/profiles/wecom-finance/pending-tasks/_reports"
def _review_note_dir() -> str:
return os.environ.get("BEP_REVIEW_NOTE_DIR") or DEFAULT_REVIEW_NOTE_DIR
def _write_value_changed_note(entity_id: int, period: str, batch: str, changed: list):
"""值变时落「待财务复核」提示件。写失败只告警,绝不阻断派生主流程。"""
if not changed:
return None
try:
d = _review_note_dir()
os.makedirs(d, exist_ok=True)
path = os.path.join(d, "fullstack-%s-bep-value-changed-review.md" % datetime.now().strftime("%Y%m%d"))
lines = [
"# 保本派生值变动 · 待财务复核(派生器自动生成;报告类产物,非队列任务)",
"",
"- 生成器: `backend/scripts/kpi_bep_deriver.py`(值变路径,ASK-20260924-10 口径)",
"- entity_id: %s | period: %s | batch: %s" % (entity_id, period, batch),
"- 生成时刻: %s" % datetime.now().strftime("%F %T"),
"- 口径: 值变**不降级** data_status(保留既有,含 verified),仅 remark 留痕 + 请财务复核",
"",
"| kpi_code | 旧值 | 新值 | data_status(写后) |",
"|---|---|---|---|",
]
for c in changed:
lines.append("| %s | %s | %s | %s |" % (c["code"], c["old"], c["new"], c["status"]))
lines += ["", "> 本件落在 `_reports/`(报告类),不进队列根,避免误唤醒其他 Bot。", ""]
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
logger.warning("值变留痕:复核提示件已落 %s(%d 个 KPI)", path, len(changed))
return path
except Exception as e: # 提示件失败不影响派生落库
logger.error("值变复核提示件写入失败(不阻断派生): %s", e)
return None
def _month_range(period: str):
"""返回该期间当年1月1日到月末的日期边界(用于 voucher_date 过滤兜底)"""
@@ -189,181 +83,44 @@ def _month_range(period: str):
def _sum_field(db, entity_id: int, period_from: str, period_to: str,
field: str, subject_filter=None, *, exclude_closing: bool = False,
subject_filters=None) -> float:
"""聚合 voucher_details 金额(元)。subject_filter: ('eq', code) / ('like', prefix)
field="net" = 借−贷 净额(口径4:费用/损益一律净额)。
⚠️ 净额只在 carry_forward==0 行域成立(含结转腿时损益类净额恒 0)
—— 见 /srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md。
exclude_closing=True 时再显式剔除「期间损益结转腿」(summary 含「结转」)——
用于结转行未被标 carry_forward=1 的账套(博海),否则净额恒 0 会把已结账期间清零。
subject_filters: [('like','6601'), ('like','6602'), ...] 多科目 OR(与 subject_filter 二选一)
"""
expr = (VoucherDetail.debit_amount - VoucherDetail.credit_amount
if field == "net" else getattr(VoucherDetail, field))
q = db.query(func.sum(expr)).filter(
field: str, subject_filter=None) -> float:
"""聚合 voucher_details 金额(元)。subject_filter: ('eq', code) / ('like', prefix)"""
q = db.query(func.sum(getattr(VoucherDetail, field))).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 0,
VoucherDetail.period >= period_from,
VoucherDetail.period <= period_to,
)
if explicit := subject_filters:
q = q.filter(or_(*[_subject_clause(k, c) for k, c in explicit]))
if subject_filter:
kind, code = subject_filter
q = q.filter(_subject_clause(kind, code))
if exclude_closing:
q = q.filter(or_(VoucherDetail.summary.is_(None),
~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE)))
if kind == "eq":
q = q.filter(VoucherDetail.subject_code == code)
elif kind == "like":
q = q.filter(VoucherDetail.subject_code.like(code + "%"))
val = q.scalar()
return float(val or 0.0)
def _subject_clause(kind: str, code: str):
"""科目过滤谓词:('eq', code) 精确 / ('like', prefix) 前缀"""
if kind == "eq":
return VoucherDetail.subject_code == code
if kind == "like":
return VoucherDetail.subject_code.like(code + "%")
raise ValueError(f"未知科目过滤类型: {kind}")
def _month_sales(db, entity_id: int, period: str) -> float:
"""单月销售收入(万元)——用于目标月完整度判断"""
return _sum_field(db, entity_id, period, period, "credit_amount",
("eq", SALES_CODE)) / 10000.0
# 带号科目项的字段映射(terms 记法见 bep_caliber.ENTITY_CALIBERS)
_TERM_FIELD = {
"debit": lambda: VoucherDetail.debit_amount,
"credit": lambda: VoucherDetail.credit_amount,
"net": lambda: VoucherDetail.debit_amount - VoucherDetail.credit_amount,
}
def _sum_terms(db, entity_id: int, period_from: str, period_to: str,
terms, *, exclude_closing: bool = False) -> float:
"""按「带号科目项」聚合金额(元)。terms = [(sign, code, field), ...]
· sign='+' ⇒ 加该科目 field 发生额;sign='-' ⇒ 减之
· field ∈ {debit, credit, net}
· code 以 '%' 结尾时按前缀(like)匹配,否则精确匹配
⑤ 口径统一(2026-10-03):酣客变动成本 = (Σ5401借 + Σ5403借 + Σ571103借 − Σ530103贷)。
"""
total = 0.0
for sign, code, field in terms:
expr = _TERM_FIELD[field]()
q = db.query(func.sum(expr)).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 0,
VoucherDetail.period >= period_from,
VoucherDetail.period <= period_to,
)
q = (q.filter(VoucherDetail.subject_code.like(code)) if code.endswith("%")
else q.filter(VoucherDetail.subject_code == code))
if exclude_closing:
q = q.filter(or_(VoucherDetail.summary.is_(None),
~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE)))
v = float(q.scalar() or 0.0)
total += v if sign == "+" else -v
return total
def compute_margin_rate(db, entity_id: int, period_from: str, period_to: str,
caliber: dict = None) -> dict:
"""变动成本率(YTD 累计,2 位小数)—— 按实体口径现算。
· 酣客(entity1):Σ571103借方 ÷ Σ5001贷方(实测渠补率)
· 博海(entity2):Σ6401借方 ÷ Σ6001贷方(主营业务成本率)
返回兼容既有键名:{'available', 'rebate_pct', 'rebate_sum_w', 'sales_sum_w', 'reason'}
(rebate_sum_w = 分子/变动成本,sales_sum_w = 分母/收入基数;供卡面/报告沿用)。
**fail-closed**:分子或分母缺数/为 0 ⇒ available=False, rebate_pct=None
(调用方不得回落政策常量,须按「不可得」处理)。
"""
cal = caliber or ENTITY_CALIBERS[entity_id]
num_raw = _sum_terms(db, entity_id, period_from, period_to,
cal["varcost_terms"], exclude_closing=cal["exclude_closing"])
den_raw = _sum_field(db, entity_id, period_from, period_to,
cal["revenue_field"], cal["revenue_filter"],
exclude_closing=cal["exclude_closing"])
num_w, den_w = num_raw / 10000.0, den_raw / 10000.0
info = {
"rebate_sum_w": round(num_w, 2),
"sales_sum_w": round(den_w, 2),
"rebate_pct": None,
"available": False,
"reason": "",
"label": cal["margin_label"],
"desc": cal["margin_desc"],
}
if den_w <= 0:
info["reason"] = f"{cal['margin_label']}不可得:{cal['revenue_filter'][1]} 累计缺数或为 0"
return info
if num_w <= 0:
info["reason"] = f"{cal['margin_label']}不可得:变动成本累计缺数或为 0"
return info
info["available"] = True
info["rebate_pct"] = round(num_w / den_w * 100.0, 2)
return info
def compute_rebate_rate(db, entity_id: int, period_from: str, period_to: str) -> dict:
"""实测累计**渠补率**(F_BEP_REBATE,2026-10-02 口径)—— 酣客(entity1) 专用。
渠补率(实测) = Σ(571103 借方) ÷ Σ(5001 贷方) × 100,期间 = 年初至当期(YTD),保留 2 位小数。
· 571103 = 渠补/捐赠支出(损益类费用科目)⇒ 本期发生额落**借方**;
· 5001 = 主营业务收入 ⇒ 本期发生额落**贷方**;
· 行域 = carry_forward=0(排除期间损益结转腿)。
⚠️ ⑤ 口径统一(2026-10-03)后:本函数只算**渠补口径**,是 F_BEP_REBATE 的单一事实源;
保本公式所用的**变动成本率**(销货成本+税金及附加+渠补−厂补)见 compute_margin_rate。
博海(rebate_terms=None)无渠补科目 ⇒ 返回 available=False(F_BEP_REBATE=NULL)。
"""
cal = ENTITY_CALIBERS.get(entity_id)
if not cal or not cal.get("rebate_terms"):
return {"available": False, "rebate_pct": None, "rebate_sum_w": 0.0,
"sales_sum_w": 0.0, "label": "实测渠补率",
"reason": f"{cal['name'] if cal else entity_id} 无渠补科目"}
num_raw = _sum_terms(db, entity_id, period_from, period_to,
cal["rebate_terms"], exclude_closing=cal["exclude_closing"])
den_raw = _sum_field(db, entity_id, period_from, period_to,
cal["revenue_field"], cal["revenue_filter"],
exclude_closing=cal["exclude_closing"])
num_w, den_w = num_raw / 10000.0, den_raw / 10000.0
info = {"rebate_sum_w": round(num_w, 2), "sales_sum_w": round(den_w, 2),
"rebate_pct": None, "available": False, "reason": "", "label": "实测渠补率"}
if den_w <= 0:
info["reason"] = "渠补率不可得:Σ5001贷累计缺数或为 0"
return info
if num_w <= 0:
info["reason"] = "渠补率不可得:Σ571103借累计缺数或为 0"
return info
info["available"] = True
info["rebate_pct"] = round(num_w / den_w * 100.0, 2)
return info
def _month_sales(db, entity_id: int, period: str, caliber: dict = None) -> float:
"""单月销售收入(万元)——用于目标月完整度判断(按实体口径的收入科目)"""
cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1]
return _sum_field(db, entity_id, period, period, cal["revenue_field"],
cal["revenue_filter"], exclude_closing=cal["exclude_closing"]) / 10000.0
def _is_period_closed(db, entity_id: int, period: str, caliber: dict = None) -> tuple:
def _is_period_closed(db, entity_id: int, period: str) -> tuple:
"""判断该期间是否已结账(损益结转已完成)。返回 (bool, 依据说明)。
主信号(DB): voucher_details 存在 carry_forward=1 且 subject_code=该账套本年利润科目
(酣客 3103 / 博海 4103)= 该期损益结转凭证已生成
次信号(文件): {landing_dir}/{YYYY.MM}/check.md 同时含「本期: PASS」与「损益结转: PASS」
(仅酣客有智享通落盘目录;博海 landing_dir=None ⇒ 跳过该信号)
主信号(DB): voucher_details 存在 carry_forward=1 且 subject_code='3103'(本年利润) 的行
= 该期损益结转凭证已生成
次信号(文件): ZXT_LANDING_DIR/{YYYY.MM}/check.md 同时含「本期: PASS」与「损益结转: PASS」
均不成立 → (False, "")。任何读取异常均吞掉,不影响主流程。
"""
cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1]
# 主信号:DB 损益结转凭证
try:
hit = db.query(VoucherDetail.id).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 1,
VoucherDetail.subject_code == cal["closing_subject"],
VoucherDetail.subject_code == CARRY_SUBJECT,
VoucherDetail.period == period,
).first()
if hit:
@@ -371,23 +128,21 @@ def _is_period_closed(db, entity_id: int, period: str, caliber: dict = None) ->
except Exception as e: # 查询异常不阻断,降级到文件信号
logger.warning("结账信号(DB)查询失败 period=%s: %s", period, e)
# 次信号:智享通落盘 check.md(仅酣客)
landing = cal.get("landing_dir")
# 次信号:智享通落盘 check.md
try:
if landing:
path = os.path.join(landing, period.replace("-", "."), "check.md")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
txt = f.read()
if "本期: PASS" in txt and "损益结转: PASS" in txt:
return True, "check.md"
path = os.path.join(ZXT_LANDING_DIR, period.replace("-", "."), "check.md")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
txt = f.read()
if "本期: PASS" in txt and "损益结转: PASS" in txt:
return True, "check.md"
except Exception:
pass # 文件缺失/读取失败一律忽略
return False, ""
def _pick_target_period(db, entity_id: int, now: date, caliber: dict = None) -> str:
def _pick_target_period(db, entity_id: int, now: date) -> str:
"""自动选择目标月:跳过当月。
判定顺序(2026-09-12 修订):
@@ -395,7 +150,6 @@ def _pick_target_period(db, entity_id: int, now: date, caliber: dict = None) ->
结账成立时不再用销售额做任何筛选(销售低 ≠ 未结账,历史缺陷根因)
2. 所有候选月均无结账信号 → 退回旧「销售额 < 近6月均×30%」兜底(日志标注『依据销售额兜底』)
"""
cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1]
# 收集已有数据月份(5001 贷方)
rows = db.query(VoucherDetail.period).filter(
VoucherDetail.entity_id == entity_id,
@@ -416,7 +170,7 @@ def _pick_target_period(db, entity_id: int, now: date, caliber: dict = None) ->
# 1) 结账信号优先(DB损益结转 → check.md),命中即采用
for p in candidates:
closed, reason = _is_period_closed(db, entity_id, p, cal)
closed, reason = _is_period_closed(db, entity_id, p)
if closed:
logger.info("目标月 %s 判定已结账(依据:%s),采用", p, reason)
return p
@@ -430,14 +184,14 @@ def _pick_target_period(db, entity_id: int, now: date, caliber: dict = None) ->
for p in periods:
if p >= cur:
continue
s = _month_sales(db, entity_id, p, cal)
s = _month_sales(db, entity_id, p)
ref_months.append((p, s))
if len(ref_months) >= 6:
break
ref_avg = (sum(s for _, s in ref_months) / len(ref_months)) if ref_months else None
for p in candidates:
s = _month_sales(db, entity_id, p, cal)
s = _month_sales(db, entity_id, p)
if s <= 0:
continue # 该月无销售,往前找
if ref_avg and s < ref_avg * 0.3:
@@ -473,112 +227,45 @@ def _eval_static_threshold(value: float, kpi) -> tuple:
return None, None
def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None,
def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float,
write_kpi: bool = True) -> dict:
"""计算并落库六个派生KPI(账面3条+现金2条+变动成本率1条)。返回结果摘要。
实体口径见 ENTITY_CALIBERS(entity1 酣客=渠补率;entity2 博海=主营业务成本率)。
变动成本率(2026-10-02 裁定):默认按 YTD 从 voucher_details **现算**,
政策常量 84.61% 仅作对照返回(rebate_policy_pct,仅 entity1 有对照物)。
显式传 rebate_rate_pct(`--rebate-rate`)时视为人工诊断覆盖(仅 entity1 语境)。
fail-closed ①:变动成本率不可得且无人工覆盖 ⇒ 该期不出保本值(六行 pending/NULL)。
fail-closed ②(2026-10-03):变动成本率 ≥100%(边际贡献率 ≤0)⇒ 五条保本值 NULL +
remark「边际贡献非正,保本点不适用」,**禁写 0.0**。
"""
cal = ENTITY_CALIBERS.get(entity_id)
if cal is None:
raise RuntimeError(f"entity={entity_id} 未注册 BEP 口径(见 ENTITY_CALIBERS)")
"""计算并落库五个派生KPI(账面3条+现金2条)。返回结果摘要。"""
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_([c for c, _ in TARGET_KPIS]),
KPIDefinition.status == "active",
).all()}
missing = [c for c in CORE_KPI_CODES if c not in kpis]
missing = [c for c, _ in TARGET_KPIS if c not in kpis]
if missing:
raise RuntimeError(f"entity={entity_id} 缺少派生KPI定义: {missing}")
if REBATE_KPI not in kpis:
# 变动成本率定义缺失不阻断保本主流程(部署顺序容错),但必须显式告警:
# 卡面此时取不到 rebate_pct(单一事实源缺失),需尽快补 bep_kpi_defs*/rebate 种子 SQL。
logger.error("entity=%s 缺少 %s 定义(见 scripts/sql/bep_kpi_defs*.sql);"
"本期跳过变动成本率落库,卡面将判「不可得」", entity_id, REBATE_KPI)
year = period[:4]
period_from = f"{year}-01"
period_to = period
months = int(period[5:7]) # YTD 月数(1月=1 ... 7月=7)
# 1. 取数(元 → 万元)——按实体口径
# 固定成本经 bep_caliber.fixed_cost_spec() 唯一解析口:e1 前缀净额 / e2 带号科目项仅借方(§4-1)
fix_mode, fix_spec = fixed_cost_spec(cal)
sales_sum_w = _sum_field(db, entity_id, period_from, period_to,
cal["revenue_field"], cal["revenue_filter"],
exclude_closing=cal["exclude_closing"]) / 10000.0
if fix_mode == "terms":
admin_sum_w = _sum_terms(db, entity_id, period_from, period_to, fix_spec,
exclude_closing=cal["exclude_closing"]) / 10000.0
else:
admin_sum_w = _sum_field(db, entity_id, period_from, period_to, "net",
subject_filters=fix_spec,
exclude_closing=cal["exclude_closing"]) / 10000.0
dep_sum_w = _sum_field(db, entity_id, period_from, period_to,
cal["dep_field"], cal["dep_filter"],
exclude_closing=cal["exclude_closing"]) / 10000.0
# 1. 取数(元 → 万元)
sales_sum_w = _sum_field(db, entity_id, period_from, period_to, "credit_amount", ("eq", SALES_CODE)) / 10000.0
admin_sum_w = _sum_field(db, entity_id, period_from, period_to, "debit_amount", ("like", ADMIN_PREFIX)) / 10000.0
rebate_sum_w = _sum_field(db, entity_id, period_from, period_to, "debit_amount", ("eq", REBATE_CODE)) / 10000.0
dep_sum_w = _sum_field(db, entity_id, period_from, period_to, "debit_amount", ("eq", DEP_SUBJECT)) / 10000.0
sales_avg = sales_sum_w / months
admin_avg = admin_sum_w / months
dep_avg = dep_sum_w / months
cash_admin_avg = admin_avg - dep_avg # 现金口径固定成本(剔除折旧纸面费用)
# 2. 变动成本率(⑤ 后=销货成本+税金及附加+渠补−厂补 完整口径):默认逐期实测累计
rebate_info = compute_margin_rate(db, entity_id, period_from, period_to, cal)
calc_pct = rebate_info["rebate_pct"]
# ⑤:渠补率(e1 专用;e2 无 ⇒ available=False)。F_BEP_REBATE 专用,独立于上面变动成本率。
rebate_kpi_info = compute_rebate_rate(db, entity_id, period_from, period_to)
if rebate_rate_pct is not None:
rebate_info = dict(rebate_info, available=True, rebate_pct=round(float(rebate_rate_pct), 2),
reason="人工覆盖(--rebate-rate)")
logger.warning("变动成本率使用人工覆盖值 %.2f%%(YTD 实算值 %s)", rebate_rate_pct,
("%.2f%%" % calc_pct) if calc_pct is not None else "不可得")
elif not rebate_info["available"]:
# fail-closed:不出保本值,绝不静默回落政策常量 84.61
logger.error("period=%s %s ⇒ 本期不出保本值(fail-closed,不回落政策常量)",
period, rebate_info["reason"])
return _fail_closed_rebate_unavailable(db, entity_id, period, months, rebate_info, cal,
kpis, write_kpi)
rebate_pct = rebate_info["rebate_pct"]
cm_pct = 100.0 - rebate_pct # 边际贡献率(%) = 100 − 变动成本率
# 2b. fail-closed ②(2026-10-03):边际贡献率 ≤ 0 ⇒ 保本点不适用(禁写 0.0)
if cm_pct <= 0:
logger.error("period=%s %s=%.2f%% ≥100%% ⇒ 边际贡献非正,保本点不适用(fail-closed,禁写 0.0)",
period, cal["margin_label"], rebate_pct)
return _fail_closed_cm_non_positive(db, entity_id, period, months, rebate_info, cal,
kpis, write_kpi, sales_avg=sales_avg,
admin_avg=admin_avg, dep_avg=dep_avg,
rebate_kpi_info=rebate_kpi_info)
# 2c. fail-closed ③(2026-10-03,同族补充):固定成本退化 ⇒ 保本点不适用
# 边际贡献率 > 0 但分子(固定成本)退化时,公式给出 保本额≈0 / 覆盖率趋于无穷,
# 与 0.0 兜底同属「语义崩溃」类(实测:博海 2026-01 账面净固定成本月均 0.01万≈0、
# 现金固定成本月均 −0.14万 ⇒ 公式值 保本 0.1万 / 覆盖率 175981% / 现金保本 −2.3万)。
# 判据(同族、无新增经验常量):月均账面固定成本 ≤ 0 **或** 月均现金固定成本 ≤ 0。
if admin_avg <= 0 or cash_admin_avg <= 0:
logger.error("period=%s %s月均=%.2f万、现金固定成本月均=%.2f万(含折旧月均%.2f万)"
"⇒ 固定成本退化,保本点不适用(fail-closed)",
period, cal["fixed_label"], admin_avg, cash_admin_avg, dep_avg)
return _fail_closed_cm_non_positive(db, entity_id, period, months, rebate_info, cal,
kpis, write_kpi, sales_avg=sales_avg,
admin_avg=admin_avg, dep_avg=dep_avg, guard="fixed",
cash_admin_avg=cash_admin_avg,
rebate_kpi_info=rebate_kpi_info)
# 2. 渠补率:厂家政策值优先,日志附凭证累计校验
calc_rebate = (rebate_sum_w / sales_sum_w * 100.0) if sales_sum_w > 0 else 0.0
if rebate_rate_pct is None:
rebate_rate_pct = POLICY_REBATE_RATE_PCT
cm_pct = 100.0 - rebate_rate_pct # 边际贡献率(%)
# 3. 派生计算(万元 / %)——账面3条路径不变;现金口径在账面基础上剔除折旧
bep = admin_avg / (cm_pct / 100.0)
bep = admin_avg / (cm_pct / 100.0) if cm_pct > 0 else 0.0
cover = (sales_avg / bep * 100.0) if bep > 0 else 0.0
gap = ((bep - sales_avg) / bep * 100.0) if bep > 0 else 0.0
cash_bep = cash_admin_avg / (cm_pct / 100.0)
cash_bep = cash_admin_avg / (cm_pct / 100.0) if cm_pct > 0 else 0.0
cash_cover = (sales_avg / cash_bep * 100.0) if cash_bep > 0 else 0.0
values = {
@@ -587,51 +274,19 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None,
"F_BEP_GAP": round(gap, 1),
"F_BEP_REVENUE_CASH": round(cash_bep, 1),
"F_BEP_COVER_CASH": round(cash_cover, 1),
"F_BEP_VC_RATE": round(rebate_pct, 2), # 变动成本率(⑤ 保本分子口径)
"F_BEP_REBATE": (round(rebate_kpi_info["rebate_pct"], 2)
if rebate_kpi_info["available"] else None), # 实测渠补率(e1;e2=NULL)
}
# 4. upsert kpi_values (source_type='derived',幂等)
batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written = {}
value_rows = {}
changed = []
for code, _ in TARGET_KPIS:
kpi = kpis.get(code)
if kpi is None: # 定义缺失(仅可能为 F_BEP_REBATE,见上方告警)
written[code] = "no-definition"
continue
if code == VC_RATE_KPI:
remark = (f"变动成本率(YTD累计): {cal['margin_desc']} = "
f"{rebate_info['rebate_sum_w']:.2f}万 ÷ {rebate_info['sales_sum_w']:.2f}万 "
f"× 100 = {rebate_pct:.2f}%(⑤口径统一:销货成本+税金及附加+渠补−厂补;"
f"保本额 = 固定成本月均 ÷ (1−变动成本率))")
elif code == REBATE_KPI:
if rebate_kpi_info["available"]:
rr = rebate_kpi_info["rebate_pct"]
remark = (f"实测渠补率(YTD累计): Σ571103借方 {rebate_kpi_info['rebate_sum_w']:.2f}万 ÷ "
f"Σ5001贷方 {rebate_kpi_info['sales_sum_w']:.2f}万 × 100 = {rr:.2f}%"
f"(政策常量 {POLICY_REBATE_RATE_PCT:.2f}% 仅供对照;⑤ 后**不再充当变动成本率**,"
f"变动成本率见 F_BEP_VC_RATE)")
else:
remark = f"{cal['name']}无渠补科目(实测渠补率不适用);⑤ 后变动成本率见 F_BEP_VC_RATE"
elif code in CASH_CODES:
if entity_id == 1:
remark = (f"现金口径: 管理费月均{admin_avg:.2f}万-折旧月均{dep_avg:.2f}万"
f"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万"
f"(实测渠补率{rebate_pct:.2f}%)")
else:
remark = (f"现金口径: {cal['fixed_label']}月均{admin_avg:.2f}万-折旧月均{dep_avg:.2f}万"
f"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万"
f"({cal['margin_label']}{rebate_pct:.2f}%)")
kpi = kpis[code]
if code in CASH_CODES:
remark = (f"现金口径: 管理费月均{admin_avg:.2f}万-折旧月均{dep_avg:.2f}万"
f"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万")
else:
if entity_id == 1:
remark = (f"保本派生器: 销售月均{sales_avg:.1f}万/管理费月均{admin_avg:.1f}万/"
f"实测渠补率{rebate_pct:.2f}%(政策{POLICY_REBATE_RATE_PCT:.2f}%仅对照)")
else:
remark = (f"保本派生器: 销售月均{sales_avg:.1f}万/{cal['fixed_label']}月均{admin_avg:.1f}万/"
f"{cal['margin_label']}{rebate_pct:.2f}%")
remark = f"保本派生器: 销售月均{sales_avg:.1f}万/管理费月均{admin_avg:.1f}万/渠补率{rebate_rate_pct:.2f}%"
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
@@ -640,24 +295,10 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None,
if write_kpi:
is_new = existing is None
if existing:
old_value = float(existing.actual_value) if existing.actual_value is not None else None
nv = values[code]
new_value = float(nv) if nv is not None else None
value_changed = ((old_value is None) != (new_value is None)
or (old_value is not None and abs(old_value - new_value) > 1e-9))
existing.actual_value = nv
existing.actual_value = values[code]
existing.source_batch = batch
if value_changed:
existing.remark = (f"{remark}|值已更新(batch={batch}, "
f"旧{old_value} → 新{new_value}) 待财务复核")
changed.append({"code": code, "old": old_value, "new": new_value,
"status": existing.data_status})
else:
existing.remark = remark
# 2026-09-24 ASK-20260924-10:**不再**无条件 `existing.data_status = "pending"`。
# 值未变 / 值变 一律不写 data_status ⇒ 保留既有状态(含人工放行的 verified)。
# 原写法会让「人工转正」在次日 06:35 静默被打回 pending(看板数字忽现忽隐)。
# 新期间(下方 else 分支)仍写 pending,复核流入口不变。
existing.remark = remark
existing.data_status = "pending"
else:
existing = KPIValue(
entity_id=entity_id,
@@ -679,11 +320,7 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None,
# 5. 同步预警: 仅对本派生KPI评估 threshold(green=正常不落 pending,yellow/red 落 pending)。
# 幂等规则:同一 KPIValue 已有同级别 pending → 保持不动(值未变不重建);
# 其它/旧级别 pending → resolve;最新派生行永远只留一条当前级别。
# R1(裁定 2026-09-19):demo 账套不产生业务预警 → 整段预警同步跳过(派生值照落库)。
target_sync = [] if is_excluded_entity(db, entity_id) else TARGET_KPIS
if not target_sync:
logger.info("demo 账套(entity=%s)跳过预警同步(R1,裁定 2026-09-19)", entity_id)
for code, _ in target_sync:
for code, _ in TARGET_KPIS:
kpi = kpis[code]
kpi_value = value_rows.get(kpi.id)
if not kpi_value:
@@ -716,14 +353,11 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None,
status="pending",
))
db.commit()
# 值变留痕(ASK-20260924-10):落复核提示件(报告类 → _reports/,不进队列根)
_write_value_changed_note(entity_id, period, batch, changed)
return {
"entity_id": entity_id,
"period": period,
"months": months,
"data_status": "ok",
"sales_sum_w": round(sales_sum_w, 2),
"sales_avg": round(sales_avg, 2),
"admin_sum_w": round(admin_sum_w, 2),
@@ -731,244 +365,42 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None,
"dep_sum_w": round(dep_sum_w, 2),
"dep_avg": round(dep_avg, 2),
"cash_admin_avg": round(cash_admin_avg, 2),
"rebate_sum_w": rebate_info["rebate_sum_w"],
"rebate_sales_base_w": rebate_info["sales_sum_w"],
"rebate_actual_pct": rebate_pct,
"vc_rate_pct": rebate_pct,
"rebate_kpi_pct": (rebate_kpi_info["rebate_pct"] if rebate_kpi_info["available"] else None),
"rebate_policy_pct": POLICY_REBATE_RATE_PCT,
"rebate_source": rebate_info.get("reason") or "YTD实测累计(⑤完整口径)",
"rebate_calc_pct": round(calc_pct, 2) if calc_pct is not None else None,
"rebate_sum_w": round(rebate_sum_w, 2),
"rebate_policy_pct": rebate_rate_pct,
"rebate_calc_pct": round(calc_rebate, 2),
"cm_pct": round(cm_pct, 2),
"values": values,
"written": written,
"value_changed": changed,
}
def _fail_closed_rebate_unavailable(db, entity_id: int, period: str, months: int,
rebate_info: dict, cal: dict, kpis: dict,
write_kpi: bool) -> dict:
"""fail-closed 落库:变动成本率不可得 ⇒ 该期不出保本值。
六条派生行写 actual_value=NULL + data_status='pending' + remark 注明「不可得」,
**不回落**政策常量 84.61(2026-10-02 财务裁定 / 任总 11:43 CST 放行)。
不评估阈值、不产生/解除预警(无值 ⇒ 无级别可评)。
"""
reason = rebate_info["reason"] or f"{cal['margin_label']}不可得"
if entity_id == 1:
detail = (f"{reason};Σ571103借方 {rebate_info['rebate_sum_w']:.2f}万 / "
f"Σ5001贷方 {rebate_info['sales_sum_w']:.2f}万 ⇒ 本期不出保本值(fail-closed,"
f"不回落政策常量 {POLICY_REBATE_RATE_PCT:.2f}%)")
else:
detail = (f"{reason};{cal['margin_desc']} = {rebate_info['rebate_sum_w']:.2f}万 / "
f"{rebate_info['sales_sum_w']:.2f}万 ⇒ 本期不出保本值(fail-closed,禁回落、禁跨期)")
batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written = {}
if write_kpi:
for code, _ in TARGET_KPIS:
kpi = kpis.get(code)
if kpi is None:
written[code] = "no-definition"
continue
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
KPIValue.source_type == "derived",
).first()
if existing is None:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=kpi.id,
period=period,
actual_value=None,
source_type="derived",
source_batch=batch,
data_status="pending",
remark=detail,
))
written[code] = "insert"
else:
existing.actual_value = None
existing.source_batch = batch
existing.data_status = "pending"
existing.remark = detail
written[code] = "upsert"
db.commit()
else:
written = {c: "dry-run" for c, _ in TARGET_KPIS}
return {
"entity_id": entity_id,
"period": period,
"months": months,
"data_status": "pending",
"rebate_unavailable": True,
"reason": detail,
"sales_sum_w": rebate_info["sales_sum_w"],
"rebate_sum_w": rebate_info["rebate_sum_w"],
"rebate_actual_pct": None,
"rebate_policy_pct": POLICY_REBATE_RATE_PCT,
"rebate_source": reason,
"values": {},
"written": written,
"value_changed": [],
}
def _fail_closed_cm_non_positive(db, entity_id: int, period: str, months: int,
rebate_info: dict, cal: dict, kpis: dict, write_kpi: bool,
sales_avg: float = None, admin_avg: float = None,
dep_avg: float = None, guard: str = "cm",
cash_admin_avg: float = None,
rebate_kpi_info: dict = None) -> dict:
"""fail-closed ② 落库:保本点**不适用**(2026-10-03 财务裁定)。
五条保本派生值(REVENUE/COVER/GAP/REVENUE_CASH/COVER_CASH)写
actual_value=NULL + data_status='pending' + remark 含「保本点不适用」,
**禁止回落 0.0**(0.0 会被前端显示成「月保本 0 万、覆盖率 0%」,语义完全错误)。
变动成本率本身是可测量的 ⇒ 该 KPI 照常落库(值 + YTD 累计 remark)。
不评估阈值、不产生/解除预警(保本值无值 ⇒ 无级别可评)。
guard 触发条件(同族,均属「公式退化」):
· 'cm' 边际贡献率 ≤ 0(变动成本率 ≥ 100%)——本单裁定 ①
· 'fixed' 月均账面固定成本 ≤ 0 ——同族补充③(分子非正 ⇒ 保本额≈0、覆盖率趋于无穷)
"""
rebate_pct = rebate_info["rebate_pct"]
cm_pct = round(100.0 - float(rebate_pct), 2)
if guard == "fixed":
detail = (f"保本点不适用(固定成本退化:{cal['fixed_label']}月均 {admin_avg:.2f}万 ≤ 0 或 "
f"现金固定成本月均 {cash_admin_avg:.2f}万 ≤ 0 ⇒ 分子退化,保本额≈0、覆盖率无意义;"
f"{cal['margin_label']} {rebate_pct:.2f}%、边际贡献率 {cm_pct:.2f}%;"
f"fail-closed,禁写 0.0)")
else:
detail = (f"边际贡献非正,保本点不适用({cal['margin_label']} {rebate_pct:.2f}% ≥ 100%,"
f"边际贡献率 {cm_pct:.2f}% ⇒ 每增一元销售不产生正贡献,保本点无经济意义;"
f"fail-closed,禁写 0.0)")
vc_rate_remark = (f"变动成本率(YTD累计): {cal['margin_desc']} = "
f"{rebate_info['rebate_sum_w']:.2f}万 ÷ {rebate_info['sales_sum_w']:.2f}万 "
f"× 100 = {rebate_pct:.2f}%(⑤口径统一;保本点因边际贡献非正/固定成本退化不适用)")
if rebate_kpi_info and rebate_kpi_info.get("available"):
rebate_remark = (f"实测渠补率(YTD累计): Σ571103借方 {rebate_kpi_info['rebate_sum_w']:.2f}万 ÷ "
f"Σ5001贷方 {rebate_kpi_info['sales_sum_w']:.2f}万 × 100 = "
f"{rebate_kpi_info['rebate_pct']:.2f}%(政策常量 {POLICY_REBATE_RATE_PCT:.2f}% 仅供对照;"
f"⑤ 后不再充当变动成本率)")
else:
rebate_remark = f"{cal['name']}无渠补科目(实测渠补率不适用);变动成本率见 F_BEP_VC_RATE"
batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written = {}
changed = []
if write_kpi:
for code, _ in TARGET_KPIS:
kpi = kpis.get(code)
if kpi is None:
written[code] = "no-definition"
continue
if code == VC_RATE_KPI:
new_val = round(float(rebate_pct), 2) # 变动成本率可测 ⇒ 照常落库
rm = vc_rate_remark
elif code == REBATE_KPI:
new_val = (round(float(rebate_kpi_info["rebate_pct"]), 2)
if rebate_kpi_info and rebate_kpi_info.get("available") else None)
rm = rebate_remark
else:
new_val = None
rm = detail
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
KPIValue.source_type == "derived",
).first()
if existing is None:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=kpi.id,
period=period,
actual_value=new_val,
source_type="derived",
source_batch=batch,
data_status="pending",
remark=rm,
))
written[code] = "insert"
else:
old_value = float(existing.actual_value) if existing.actual_value is not None else None
value_changed = ((old_value is None) != (new_val is None)
or (old_value is not None and abs(old_value - float(new_val)) > 1e-9))
existing.actual_value = new_val
existing.source_batch = batch
existing.data_status = "pending" # 裁定:fail-closed 路径显式 pending
existing.remark = rm
if value_changed:
changed.append({"code": code, "old": old_value, "new": new_val,
"status": "pending"})
written[code] = "upsert"
db.commit()
_write_value_changed_note(entity_id, period, batch, changed)
else:
written = {c: "dry-run" for c, _ in TARGET_KPIS}
return {
"entity_id": entity_id,
"period": period,
"months": months,
"data_status": "pending",
"cm_non_positive": True,
"reason": detail,
"rebate_sum_w": rebate_info["rebate_sum_w"],
"sales_sum_w": rebate_info["sales_sum_w"],
"rebate_actual_pct": rebate_pct,
"rebate_policy_pct": POLICY_REBATE_RATE_PCT,
"rebate_source": rebate_info.get("reason") or cal["margin_desc"],
"cm_pct": cm_pct,
"sales_avg": round(sales_avg, 2) if sales_avg is not None else None,
"admin_avg": round(admin_avg, 2) if admin_avg is not None else None,
"dep_avg": round(dep_avg, 2) if dep_avg is not None else None,
"values": {},
"written": written,
"value_changed": changed,
}
def main():
ap = argparse.ArgumentParser(description="保本点派生KPI执行器")
ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认自动选最近完整月)")
ap.add_argument("--entity", type=int, default=1, help="entity_id(1=陕西酣客 2=博海)")
ap.add_argument("--entity", type=int, default=1, help="entity_id(默认1=陕西酣客)")
ap.add_argument("--rebate-rate", type=float, default=None,
help="变动成本率%%人工覆盖(诊断用;**默认按 YTD 从 voucher_details 实测现算**,不再取政策常量)")
help="渠补率%覆盖(默认厂家政策84.61)")
ap.add_argument("--dry-run", action="store_true", help="只计算不落库")
args = ap.parse_args()
db = get_session_local()()
try:
cal = ENTITY_CALIBERS.get(args.entity)
if cal is None:
print(f"❌ entity={args.entity} 未注册 BEP 口径(见 ENTITY_CALIBERS)")
return 2
period = args.period or _pick_target_period(db, args.entity, date.today(), cal)
period = args.period or _pick_target_period(db, args.entity, date.today())
r = run_derive(db, args.entity, period, args.rebate_rate,
write_kpi=not args.dry_run)
print(f"保本点派生计算完成 entity={r['entity_id']}({cal['name']}) period={r['period']} "
f"(YTD {r['months']}个月) mode={'dry-run' if args.dry_run else 'write'} "
f"data_status={r['data_status']}")
if r["data_status"] == "pending":
kind = "边际贡献非正 ⇒ 保本点不适用" if r.get("cm_non_positive") else "不可得"
print(f" ⚠ fail-closed({kind}):{r['reason']}")
if r.get("rebate_actual_pct") is not None:
print(f" {cal['margin_label']}(YTD) = {r['rebate_actual_pct']:.2f}% "
f"| 边际贡献率 {r.get('cm_pct')}% ⇒ 仅落变动成本率,五条保本值 NULL")
print(f" 落库: {r['written']}")
return
print(f"保本点派生计算完成 entity={r['entity_id']} period={r['period']} "
f"(YTD {r['months']}个月) mode={'dry-run' if args.dry_run else 'write'}")
print(f" 销售收入累计 {r['sales_sum_w']:.2f}万 月均 {r['sales_avg']:.2f}万")
print(f" 固定成本累计 {r['admin_sum_w']:.2f}万 月均 {r['admin_avg']:.2f}万 ({cal['fixed_label']},账面口径)")
print(f" 折旧累计 {r['dep_sum_w']:.2f}万 月均 {r['dep_avg']:.2f}万 ({cal['dep_filter'][1]}) | "
f"现金固定成本月均 {r['cash_admin_avg']:.2f}万 (固定成本-折旧)")
print(f" 变动成本累计 {r['rebate_sum_w']:.2f}万 | {cal['margin_label']}(YTD) {r['rebate_actual_pct']:.2f}% "
f"({cal['margin_desc']}) | 边际贡献率 {r['cm_pct']:.2f}%")
print(f" 管理费累计 {r['admin_sum_w']:.2f}万 月均 {r['admin_avg']:.2f}万 (含折旧,账面口径)")
print(f" 折旧累计 {r['dep_sum_w']:.2f}万 月均 {r['dep_avg']:.2f}万 (560213) | "
f"现金固定成本月均 {r['cash_admin_avg']:.2f}万 (管理费-折旧)")
print(f" 渠补支出累计 {r['rebate_sum_w']:.2f}万 | 渠补率: 政策{r['rebate_policy_pct']:.2f}% "
f"(凭证累计校验 {r['rebate_calc_pct']:.2f}%) | 边际贡献率 {r['cm_pct']:.2f}%")
print(f" F_BEP_REVENUE 保本销售额 = {r['values']['F_BEP_REVENUE']} 万元")
print(f" F_BEP_COVER 保本覆盖率 = {r['values']['F_BEP_COVER']} %")
print(f" F_BEP_GAP 保本缺口 = {r['values']['F_BEP_GAP']} %")
print(f" F_BEP_REVENUE_CASH 现金保本销售额 = {r['values']['F_BEP_REVENUE_CASH']} 万元")
print(f" F_BEP_COVER_CASH 现金保本覆盖率 = {r['values']['F_BEP_COVER_CASH']} %")
print(f" F_BEP_REBATE {cal['margin_label']} = {r['values']['F_BEP_REBATE']} %")
print(f" 落库: {r['written']}")
finally:
db.close()
@@ -1,433 +0,0 @@
#!/usr/bin/env python3
"""CPA 客户获利性派生器 —— 客户级收入快照 + 汇总口径 KPI 值(任务 finance-20261003-cpa-... P0-a)
产物
----
1) 明细表 `customer_profitability_snapshot`(每客户每期一行,幂等 upsert;见
`scripts/migrate_cpa_snapshot_20261004.py`)
2) `kpi_values` 汇总口径(**只存汇总,避免 284 客户 × 期数污染 KPI 宽表**,任务 §2.1):
* `C_CUST_REVENUE` 期内客户收入合计(万元)
* `C_CUST_COUNT` 期内活跃客户数(个)
* `C_CUST_CONCENTRATION` 现网定义 = 前5大客户收入 ÷ 期内合计 ×100。
`--concentration`(默认 `fill-gaps`)控制**已有期次**的处置:
* `fill-gaps` = 仅补库内尚无该期行的期次(2026-01~09 九期原样保留)
* `overwrite` = **重算并覆盖**指定期次(P0 口径纠错后重算九期用;
属生产写,须走放行)
口径(2026-10-04 P0 纠错后)
单据类型白名单 = **销售类 `bill_type IN (1,4,14)`**(XSD 销售单 / XSTHD 销售退货 /
XSHHD 销售换货);非销售单据(进货/验收/入库/预付款/收款/费用)的 unit_id 是**供应商**,
不得计入客户收入。取数口径单一事实源 = `app/utils/cpa_caliber.py`(与
`alert_rules._customer_revenue_shares` 同一实现)。
2026-01~09 对拍锚点:65 户 / 1,207.74 万 / Top1 22.5150%(u152) / Top2 13.9734%(u155) /
Top4 46.9407% / Top10 69.7511%(`cpa_caliber.ANCHORS_2026_01_09`)。
fail-closed(本器**不写**以下列/值;理由见 `app/utils/cpa_caliber.py::FAIL_CLOSED_REASONS`)
`cogs_alloc` / `chan_sub` / `contrib` / `contrib_rate` / `C_CUST_COGS_ALLOC` /
`C_CUST_CHAN_SUB` / `C_CUST_CONTRIB` / `C_CUST_CONTRIB_RATE` / 客户平均贡献值
⇒ 一律不落库(禁 0、禁分摊值),只在快照行 `fail_closed_reason` 留痕。
口径单一事实源
客户收入聚合 = `app/utils/cpa_caliber.py`(与 `alert_rules._customer_revenue_shares` 同一实现)。
用法
----
python3 scripts/kpi_customer_profitability_deriver.py --check # 只读预检 + 对拍锚点(默认)
python3 scripts/kpi_customer_profitability_deriver.py --apply # 单事务幂等写
python3 scripts/kpi_customer_profitability_deriver.py --verify # 只读写后回查
python3 scripts/kpi_customer_profitability_deriver.py --anchor # 只跑对拍锚点断言
选项
--entity N 账套(默认 1;本单主体范围 = 陕西酣客 e1)
--db-url URL 显式连接串(离线演练 `sqlite:///<scratch>.db`)
--since/--until 仅处理该区间(YYYY-MM)
--batch NAME source_batch 名(默认 cpa-20261004)
退出码
0 成功 | 1 fail-closed 前置不满足 / 校验失败 | 2 用法错误
"""
import argparse
import datetime as _dt
import json
import os
import sys
from urllib.parse import quote_plus
HERE = os.path.dirname(os.path.abspath(__file__))
BACKEND = os.path.dirname(HERE)
if BACKEND not in sys.path:
sys.path.insert(0, BACKEND)
DEFAULT_BATCH = "cpa-20261004"
SNAPSHOT_TABLE = "customer_profitability_snapshot"
FAIL_CLOSED_NOTE = (
"cogs_alloc/chan_sub/contrib/contrib_rate = P1 fail-closed(未裁定口径/未打标),禁静默 0 或分摊值"
)
# 对拍锚点**单一事实源** = `cpa_caliber.ANCHORS_2026_01_09`(销售类白名单口径,2026-10-04 P0 纠错后)
def _anchors():
cal = _load_caliber()
return cal.ANCHORS_2026_01_09, cal.ANCHOR_TOL
def _engine(args):
from sqlalchemy import create_engine
if args.db_url:
url = args.db_url
else:
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(BACKEND, ".env"), override=False)
except Exception:
pass
user = os.environ.get("CMA_DB_USER", "cma_api")
pwd = os.environ.get("CMA_DB_PASS", "")
host = os.environ.get("CMA_DB_HOST", "127.0.0.1")
port = os.environ.get("CMA_DB_PORT", "3306")
name = os.environ.get("CMA_DB_NAME", "cma")
url = f"mysql+pymysql://{user}:{quote_plus(pwd)}@{host}:{port}/{name}?charset=utf8mb4"
kw = {"connect_args": {"check_same_thread": False}} if url.startswith("sqlite") else {}
return create_engine(url, **kw), url
def _dialect(url):
return "sqlite" if url.startswith("sqlite") else "mysql"
def _table_exists(conn, dialect):
from sqlalchemy import text
if dialect == "sqlite":
return bool(conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name=:t"),
{"t": SNAPSHOT_TABLE}).fetchall())
return bool(conn.execute(text("SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:t"),
{"t": SNAPSHOT_TABLE}).fetchall())
def _grants_ok(conn, dialect, table):
"""写前置:应用账号须对 kpi_values 与目标表有 DML。"""
from sqlalchemy import text
if dialect == "sqlite":
return True, "sqlite(离线演练,无授权面)"
rows = conn.execute(text("SHOW GRANTS FOR CURRENT_USER()")).fetchall()
joined = "\n".join(r[0] for r in rows)
has_kv = ("kpi_values" in joined) or ("ON `cma`.*" in joined) or ("ON *.*" in joined)
has_new = (f"`{table}`" in joined) or ("ON `cma`.*" in joined) or ("ON *.*" in joined)
return (has_kv and has_new), joined
def _load_caliber():
from app.utils import cpa_caliber
return cpa_caliber
def _dump_prior_concentration(engine, entity_id, kpi_id, ts):
"""**覆盖前备份**现网 `C_CUST_CONCENTRATION` 全部行(回退点),落 `scripts/backups/`。
返回备份文件绝对路径(写进结果 JSON,供审批串/回退引用)。
"""
import json as _json
from sqlalchemy import text
cols = ("id", "period", "actual_value", "data_status", "source_type", "source_batch",
"remark", "entity_id")
with engine.connect() as conn:
rows = conn.execute(text(
"SELECT id, period, actual_value, data_status, source_type, source_batch, remark, entity_id "
"FROM kpi_values WHERE entity_id=:e AND kpi_id=:k ORDER BY period, id"),
{"e": entity_id, "k": kpi_id}).fetchall()
d = os.path.join(HERE, "backups")
os.makedirs(d, exist_ok=True)
path = os.path.join(d, "cpa-concentration-prior-%s.json" % ts.replace(":", "").replace(" ", "-"))
with open(path, "w", encoding="utf-8") as f:
_json.dump({"ts": ts, "entity_id": entity_id, "kpi_id": kpi_id,
"rows": [dict(zip(cols, r)) for r in rows]}, f, ensure_ascii=False,
indent=2, default=str)
return path
def compute(engine, dialect, entity_id):
"""只读计算:{period: {unit_id: amount}} + 汇总。"""
cal = _load_caliber()
monthly = cal.fetch_monthly_revenue(engine, entity_id)
summary = {}
for p, pairs in monthly.items():
m = cal.monthly_metrics(pairs)
m["_total_yuan"] = sum(a for _, a in pairs)
signature = _rank_signature(pairs) if len(pairs) > 10 else None
m["_signature"] = signature
summary[p] = m
return monthly, summary
def _rank_signature(pairs):
"""前10名 (unit_id, 分) 指纹(防静默漂移)。"""
return [(u, round(a, 2)) for u, a in pairs[:10]]
def anchor_check(summary):
"""2026-01~09 对拍锚点(与任务验收标准逐项比)。"""
pairs = []
for p in sorted(summary):
if "2026-01" <= p <= "2026-09":
pairs.append(p)
# 需要按窗口重算 topN(不能逐月相加),故单独取窗口
return pairs
def run(engine, dialect, entity_id, batch, apply=False, verify=False, since=None, until=None,
concentration="fill-gaps", only="all"):
cal = _load_caliber()
from sqlalchemy import text
result = {"entity_id": entity_id, "batch": batch, "apply": apply, "verify": verify,
"concentration_mode": concentration, "only": only}
if concentration not in ("fill-gaps", "overwrite"):
raise SystemExit("FAIL-CLOSED: --concentration 仅支持 fill-gaps / overwrite")
if only not in ("all", "concentration"):
raise SystemExit("FAIL-CLOSED: --only 仅支持 all / concentration")
if apply and only == "concentration" and concentration != "overwrite":
raise SystemExit("FAIL-CLOSED: --only concentration 必须配 --concentration overwrite(否则九期被跳过=空写)")
with engine.connect() as conn:
exists = _table_exists(conn, dialect)
if apply and only != "concentration" and not exists:
raise SystemExit("FAIL-CLOSED: 目标表不存在,请先跑 migrate_cpa_snapshot_20261004.py --apply")
rows_before = None
if exists:
rows_before = conn.execute(text(f"SELECT COUNT(*) FROM {SNAPSHOT_TABLE} WHERE entity_id=:e"),
{"e": entity_id}).scalar()
kpi_ids = {}
for code in ("C_CUST_REVENUE", "C_CUST_COUNT", "C_CUST_CONCENTRATION"):
r = conn.execute(text("SELECT id FROM kpi_definitions WHERE entity_id=:e AND kpi_code=:c"),
{"e": entity_id, "c": code}).fetchone()
kpi_ids[code] = r[0] if r else None
existing_conc_periods = set()
if kpi_ids["C_CUST_CONCENTRATION"]:
for (p,) in conn.execute(text("SELECT period FROM kpi_values WHERE entity_id=:e AND kpi_id=:k"),
{"e": entity_id, "k": kpi_ids["C_CUST_CONCENTRATION"]}).fetchall():
existing_conc_periods.add(p)
monthly = cal.fetch_monthly_revenue(engine, entity_id)
periods = sorted(p for p in monthly if (not since or p >= since) and (not until or p <= until))
result["periods"] = len(periods)
result["period_range"] = [periods[0], periods[-1]] if periods else None
result["kpi_ids"] = kpi_ids
result["existing_concentration_periods"] = sorted(existing_conc_periods)
if verify:
return _verify(engine, dialect, entity_id, monthly, periods, kpi_ids, existing_conc_periods, now_ts=None)
snap_rows = []
kpi_rows = []
conc_written_periods = []
for p in periods:
pairs = monthly[p]
total_yuan = sum(a for _, a in pairs)
if only != "concentration":
for idx, (uid, amount) in enumerate(pairs, start=1):
snap_rows.append({
"entity_id": entity_id, "period": p, "unit_id": uid,
"revenue": cal.to_wan(amount), "rank_no": idx,
"calib_status": "revenue_only", "fail_closed_reason": FAIL_CLOSED_NOTE,
})
kpi_rows.append(("C_CUST_REVENUE", p, cal.to_wan(total_yuan)))
kpi_rows.append(("C_CUST_COUNT", p, float(cal.active_customer_count(pairs))))
if concentration == "overwrite" or p not in existing_conc_periods:
kpi_rows.append(("C_CUST_CONCENTRATION", p, cal.concentration_pct(pairs, 5)))
if p in existing_conc_periods:
conc_written_periods.append(p)
result["only"] = only
result["snapshot_rows"] = len(snap_rows)
result["kpi_rows"] = len(kpi_rows)
result["concentration_overwritten_periods"] = sorted(conc_written_periods)
result["kpi_rows_skipped_no_definition"] = [c for c, i in kpi_ids.items() if i is None]
if not apply:
result["preview"] = {
"first_periods": {p: {k: v for k, v in cal.monthly_metrics(monthly[p]).items()} for p in periods[:2]},
"last_periods": {p: {k: v for k, v in cal.monthly_metrics(monthly[p]).items()} for p in periods[-2:]},
}
return result
# ---- 写路径(单事务) ----
with engine.connect() as conn:
ok, grants = _grants_ok(conn, dialect, SNAPSHOT_TABLE)
result["grants_ok"] = ok
if not ok:
raise SystemExit("FAIL-CLOSED: 应用账号缺 DML 授权(kpi_values / 目标表)。原始 SHOW GRANTS:\n" + grants)
need = (["C_CUST_CONCENTRATION"] if only == "concentration"
else ["C_CUST_REVENUE", "C_CUST_COUNT", "C_CUST_CONCENTRATION"])
missing = [c for c in need if kpi_ids.get(c) is None]
if missing:
raise SystemExit("FAIL-CLOSED: KPI 定义缺失 %s —— 请先执行 scripts/sql/cpa_kpi_defs_20261004.sql" % missing)
now_ts = _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if concentration == "overwrite" and kpi_ids.get("C_CUST_CONCENTRATION"):
result["prior_backup"] = _dump_prior_concentration(engine, entity_id,
kpi_ids["C_CUST_CONCENTRATION"], now_ts)
inserted_snap = updated_snap = 0
kpi_inserted = kpi_updated = 0
with engine.begin() as conn:
for r in snap_rows:
row = conn.execute(text(
f"SELECT id FROM {SNAPSHOT_TABLE} WHERE entity_id=:entity_id AND period=:period AND unit_id=:unit_id"),
r).fetchone()
if row:
conn.execute(text(
f"UPDATE {SNAPSHOT_TABLE} SET revenue=:revenue, rank_no=:rank_no, "
"calib_status=:calib_status, fail_closed_reason=:fail_closed_reason, updated_at=:ts "
"WHERE id=:id"), {**r, "ts": now_ts, "id": row[0]})
updated_snap += 1
else:
conn.execute(text(
f"INSERT INTO {SNAPSHOT_TABLE} (entity_id, period, unit_id, revenue, rank_no, calib_status, "
"fail_closed_reason, created_at, updated_at) VALUES (:entity_id, :period, :unit_id, :revenue, "
":rank_no, :calib_status, :fail_closed_reason, :ts, :ts)"), {**r, "ts": now_ts})
inserted_snap += 1
for code, period, value in kpi_rows:
kid = kpi_ids[code]
if value is None:
continue # fail-closed:不给值(不写 NULL 新行,避免污染宽表)
row = conn.execute(text(
"SELECT id, actual_value, data_status FROM kpi_values "
"WHERE entity_id=:e AND kpi_id=:k AND period=:p ORDER BY id DESC LIMIT 1"),
{"e": entity_id, "k": kid, "p": period}).fetchone()
conc_note = ("重算覆盖期次(P0 销售类白名单口径)"
if concentration == "overwrite" and period in existing_conc_periods
else "补值期次(库内原无该期行)")
remark = {"C_CUST_REVENUE": "口径=Σzxt_masterbill.sum_money(销售类 bill_type IN (1,4,14)、"
"期内、unit_id>0、符号过滤(sum_money+sum_tax_money)>0)",
"C_CUST_COUNT": "口径=期内 zxt_masterbill 销售类单据活跃客户数(unit_id 去重)",
"C_CUST_CONCENTRATION": f"口径=前5大客户收入/期内客户收入合计×100%({conc_note})"}[code]
if row:
# 幂等:值不变则零写入;不降级 data_status
if abs(float(row[1] or 0) - float(value)) < 1e-9 and row[2]:
continue
conn.execute(text("UPDATE kpi_values SET actual_value=:v, source_type='derived', source_batch=:b, "
"remark=:r WHERE id=:id"),
{"v": value, "b": batch, "r": remark, "id": row[0]})
kpi_updated += 1
else:
conn.execute(text(
"INSERT INTO kpi_values (entity_id, kpi_id, period, actual_value, source_type, source_batch, "
"data_status, calculated_at, remark) VALUES (:e, :k, :p, :v, 'derived', :b, 'pending', :ts, :r)"),
{"e": entity_id, "k": kid, "p": period, "v": value, "b": batch, "ts": now_ts, "r": remark})
kpi_inserted += 1
result.update(snapshot_inserted=inserted_snap, snapshot_updated=updated_snap,
kpi_inserted=kpi_inserted, kpi_updated=kpi_updated, ts=now_ts)
return result
def _verify(engine, dialect, entity_id, monthly, periods, kpi_ids, existing_conc_periods, now_ts=None):
"""只读写后回查:快照行数/值锚点 + kpi_values 三族。"""
from sqlalchemy import text
cal = _load_caliber()
out = {"verify": True, "entity_id": entity_id}
with engine.connect() as conn:
out["snapshot_rows"] = conn.execute(text(
f"SELECT COUNT(*) FROM {SNAPSHOT_TABLE} WHERE entity_id=:e"), {"e": entity_id}).scalar()
out["snapshot_periods"] = conn.execute(text(
f"SELECT COUNT(DISTINCT period) FROM {SNAPSHOT_TABLE} WHERE entity_id=:e"), {"e": entity_id}).scalar()
# 值锚点:2026-01~09 快照合计 == 4,883.52 万
r = conn.execute(text(
f"SELECT ROUND(SUM(revenue),2), COUNT(DISTINCT unit_id) FROM {SNAPSHOT_TABLE} "
"WHERE entity_id=:e AND period>='2026-01' AND period<='2026-09'"), {"e": entity_id}).fetchone()
out["anchor_2026_01_09_snapshot"] = {"total_wan": float(r[0] or 0), "units": int(r[1] or 0)}
out["fail_closed_columns_all_null"] = conn.execute(text(
f"SELECT COUNT(*) FROM {SNAPSHOT_TABLE} WHERE entity_id=:e AND "
"(cogs_alloc IS NOT NULL OR chan_sub IS NOT NULL OR contrib IS NOT NULL OR contrib_rate IS NOT NULL)"),
{"e": entity_id}).scalar()
# kpi_values 回查
kv = {}
for code in ("C_CUST_REVENUE", "C_CUST_COUNT", "C_CUST_CONCENTRATION"):
kid = kpi_ids.get(code)
if not kid:
kv[code] = None
continue
rows = conn.execute(text("SELECT period, actual_value, data_status FROM kpi_values "
"WHERE entity_id=:e AND kpi_id=:k ORDER BY period"),
{"e": entity_id, "k": kid}).fetchall()
kv[code] = {"n": len(rows), "sample": [[r[0], float(r[1]) if r[1] is not None else None] for r in rows[:3]],
"last": [[r[0], float(r[1]) if r[1] is not None else None] for r in rows[-2:]]}
out["kpi_values"] = kv
# 期次覆盖:快照期次 == 有数据期次
out["periods_with_data"] = len(periods)
out["concentration_untouched_note"] = (
"现网 2026-01~09 九期在写前已存在 ⇒ 本器不覆盖(见 existing_concentration_periods)")
out["existing_concentration_periods"] = sorted(existing_conc_periods)
return out
def anchor_assert(engine, entity_id):
"""2026-01~09 窗口对拍锚点(任务验收标准逐项)。
取数**只走** `cpa_caliber` 单一事实源(不另写第二份聚合 SQL):逐月客户对按月求和
= 窗口客户合计(每行只属一个 unit,故月度求和与窗口 GROUP BY 等价)。
"""
cal = _load_caliber()
anchors, tol = _anchors()
monthly = cal.fetch_monthly_revenue(engine, entity_id)
by_unit = {}
for p, pairs in monthly.items():
if "2026-01" <= p <= "2026-09":
for u, a in pairs:
by_unit[u] = by_unit.get(u, 0.0) + a
pairs = sorted(by_unit.items(), key=lambda x: x[1], reverse=True)
total = sum(a for _, a in pairs)
cum = lambda n: round(sum(a for _, a in pairs[:n]) / total * 100, 4) if total else None
got = {"units": len(pairs), "total_wan": round(total / 10000, 2),
"top1": round(pairs[0][1] / total * 100, 4) if pairs else None,
"top1_unit": pairs[0][0] if pairs else None,
"top2": round(pairs[1][1] / total * 100, 4) if len(pairs) > 1 else None,
"top2_unit": pairs[1][0] if len(pairs) > 1 else None,
"top4_cum": cum(4), "top10_cum": cum(10)}
checks = {}
for k, exp in anchors.items():
v = got.get(k)
if isinstance(exp, int) and not isinstance(exp, bool) and k.endswith("_unit"):
checks[k] = {"expected": exp, "got": v, "pass": (v == exp)}
continue
checks[k] = {"expected": exp, "got": v,
"pass": (v is not None and abs(float(v) - float(exp)) <= tol)}
return {"anchor": got, "checks": checks, "ALL_PASS": all(c["pass"] for c in checks.values())}
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("--verify", action="store_true")
g.add_argument("--anchor", action="store_true")
ap.add_argument("--entity", type=int, default=1)
ap.add_argument("--db-url")
ap.add_argument("--batch", default=DEFAULT_BATCH)
ap.add_argument("--concentration", choices=("fill-gaps", "overwrite"), default="fill-gaps",
help="已有期次处置:fill-gaps=只补缺期(默认)/ overwrite=重算并覆盖(P0 纠错后重算九期用)")
ap.add_argument("--only", choices=("all", "concentration"), default="all",
help="写面:all=快照+三族汇总(默认)/ concentration=**仅**写 C_CUST_CONCENTRATION 期次"
"(P0 九期重算最小写面;需配 --concentration overwrite)")
ap.add_argument("--since")
ap.add_argument("--until")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
engine, url = _engine(args)
dialect = _dialect(url)
if args.anchor:
res = anchor_assert(engine, args.entity)
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0 if res["ALL_PASS"] else 1
res = run(engine, dialect, args.entity, args.batch, apply=args.apply,
verify=args.verify, since=args.since, until=args.until,
concentration=args.concentration, only=args.only)
print(json.dumps(res, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
-183
View File
@@ -1,183 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""杜邦·存量与乘数派生器 —— F_ASSET_TOTAL / F_EQUITY_TOTAL / F_EQUITY_MULTIPLIER / F_ASSET_TURNOVER
任务:`finance-20261003-dupont-period-basis-design-fullstack.md`(P1,提出者 wecom-finance)
放行出处:任总 2026-10-03 09:49 CST DM「立」
口径(任务件 §二,单一事实源 `app/utils/dupont_basis.py`):
· 总资产(期末) = Σ(cb_dr−cb_cr) over subject_balances level=1 AND code LIKE '1%'
· 净资产(期末) = Σ(cb_cr−cb_dr) over subject_balances level=1 AND code LIKE 权益前缀
· 权益乘数 = **平均总资产 ÷ 平均净资产**((期初+期末)/2;期初缺失 ⇒ 显式回落期末)
· 总资产周转率 = 区间**累计**营业收入 ÷ **平均总资产**(单月口径;跨期比较用 /reports/dupont 年化)
取数:CMA 库内 `subject_balances`(存量)+ `voucher_details`(流量)。**不用**旧脚本
`register_dupont_kpis.py` 的 ERP 直连近似口径(缺应收/存货)。
幂等:同 (kpi_id, period, source_type='derived') 存在则原位更新;**不降级 data_status**
(kpi_values 无业务唯一键 ⇒ 必须 SELECT→UPDATE/INSERT,禁 ON DUPLICATE KEY)。
边界:只写 `source_type='derived'` 行;不改任何 kpi_definitions(定义由种子 SQL 负责);无 DDL。
用法:
venv/bin/python3 scripts/kpi_dupont_deriver.py --check # 只算不写(只读闸门形态)
venv/bin/python3 scripts/kpi_dupont_deriver.py --dry-run --entity 1 # 同 --check
venv/bin/python3 scripts/kpi_dupont_deriver.py --write --entity all # 落库(需放行)
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
from app.database import get_session_local # noqa: E402
from app.models import KPIDefinition, KPIValue # noqa: E402
from app.utils.dupont_basis import ( # noqa: E402
available_months, latest_stock_month, load_dupont_params, stock_balance, sum_flows,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("cma.dupont_deriver")
KPI_CODES = ("F_ASSET_TOTAL", "F_EQUITY_TOTAL", "F_EQUITY_MULTIPLIER", "F_ASSET_TURNOVER")
def derive_one(db, entity_id: int, period: str, caliber: dict) -> dict:
"""算一个账套一个期间的四个值(不写库)。"""
eq_prefixes = list(caliber["equity_prefixes"])
ta = stock_balance(db, entity_id, period, [caliber["asset_prefix"]], "net")
eq = stock_balance(db, entity_id, period, eq_prefixes, "net_rev")
if ta.get("end") is None or eq.get("end") is None:
return {"entity_id": entity_id, "period": period, "unavailable": True,
"reason": f"{period} 无 subject_balances 一级科目行(资产/权益)"}
fallback = ta.get("begin") is None or eq.get("begin") is None
ta_avg = ta["end"] if fallback else (ta["begin"] + ta["end"]) / 2.0
eq_avg = eq["end"] if fallback else (eq["begin"] + eq["end"]) / 2.0
revenue = sum_flows(db, entity_id, period, period,
{"codes": caliber["revenue_codes"], "field": "credit", "carry_forward": 0})
w = lambda v: None if v is None else v / 10000.0
ta_w, eq_w, ta_avg_w, eq_avg_w, rev_w = w(ta["end"]), w(eq["end"]), w(ta_avg), w(eq_avg), w(revenue)
values = {
"F_ASSET_TOTAL": round(ta_w, 2),
"F_EQUITY_TOTAL": round(eq_w, 2),
"F_EQUITY_MULTIPLIER": round(ta_avg_w / eq_avg_w, 4) if eq_avg_w else None,
"F_ASSET_TURNOVER": round(rev_w / ta_avg_w, 4) if ta_avg_w else None,
}
basis_txt = "期末回落(期初缺失)" if fallback else "平均(期初+期末)/2"
remarks = {
"F_ASSET_TOTAL": f"期末总资产 {ta_w:.2f}万(level=1,code LIKE '{caliber['asset_prefix']}%')",
"F_EQUITY_TOTAL": (f"期末净资产 {eq_w:.2f}万(level=1,权益前缀 {'/'.join(eq_prefixes)})"),
"F_EQUITY_MULTIPLIER": (f"权益乘数 = 平均总资产 {ta_avg_w:.2f}万 ÷ 平均净资产 {eq_avg_w:.2f}万 "
f"= {values['F_EQUITY_MULTIPLIER']}({basis_txt})"),
"F_ASSET_TURNOVER": (f"总资产周转率 = 区间营业收入 {rev_w:.2f}万 ÷ 平均总资产 {ta_avg_w:.2f}万 "
f"= {values['F_ASSET_TURNOVER']}({basis_txt};单月口径,跨期比较用 dupont 端点年化)"),
}
return {"entity_id": entity_id, "period": period, "unavailable": False,
"basis_fallback": fallback, "values": values, "remarks": remarks,
"raw": {"total_assets_end": ta_w, "equity_end": eq_w, "total_assets_avg": ta_avg_w,
"equity_avg": eq_avg_w, "revenue": rev_w}}
def write_one(db, res: dict, write: bool = True) -> dict:
"""幂等落库:同 (kpi_id, period, source_type='derived') 原位 UPDATE,否则 INSERT。"""
if res.get("unavailable"):
return {"written": "unavailable"}
entity_id, period = res["entity_id"], res["period"]
defs = {d.kpi_code: d for d in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(list(KPI_CODES)),
).all()}
batch = f"dupont-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written = {}
for code in KPI_CODES:
kpi = defs.get(code)
val = res["values"].get(code)
if kpi is None:
written[code] = "no-definition"
logger.warning("kpi_definitions 缺 %s(entity=%s)——请先应用 dupont_kpi_defs_20261003.sql",
code, entity_id)
continue
if val is None:
written[code] = "unavailable(分母为0)"
continue
if not write:
written[code] = f"dry-run({val})"
continue
row = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.period == period,
KPIValue.source_type == "derived",
).first()
if row:
old = float(row.actual_value) if row.actual_value is not None else None
row.actual_value = val
row.source_batch = batch
row.remark = res["remarks"][code] + (f"|值更新(旧{old} → 新{val})" if old != val else "")
written[code] = "upsert" # 不降级 data_status(ASK-20260924-10)
else:
db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=val,
source_type="derived", source_batch=batch,
data_status="pending", remark=res["remarks"][code]))
written[code] = "insert"
db.flush()
if write:
db.commit()
return {"written": written, "batch": batch}
def main() -> int:
ap = argparse.ArgumentParser(description="杜邦·存量与乘数派生器")
ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认取 subject_balances 最新期)")
ap.add_argument("--entity", default="all", help="entity_id 或 all(默认 all=1,2)")
ap.add_argument("--write", action="store_true", help="实际落库(缺省=只算不写)")
ap.add_argument("--dry-run", action="store_true", help="只算不写(同缺省)")
ap.add_argument("--check", action="store_true", help="只读检查(同 --dry-run,供闸门只读标志)")
args = ap.parse_args()
write = bool(args.write)
entities = [1, 2] if args.entity == "all" else [int(args.entity)]
db = get_session_local()()
rc = 0
try:
for eid in entities:
caliber = load_dupont_params(db, eid)
# 默认期间 = **存量最新月**(subject_balances 有 level=1 行)= 可算存量口径的最后一个月;
# 不能用流量最新月(实测 entity2 流量到 2026-09 而存量只到 2026-08 ⇒ 会算空)
period = args.period or latest_stock_month(db, eid)
if not period:
logger.error("entity=%s 无 subject_balances 一级科目行,无可用期间", eid)
rc = 2
continue
res = derive_one(db, eid, period, caliber)
if res.get("unavailable"):
print(f"entity={eid} period={period} 不可得:{res['reason']}")
rc = 2
continue
out = write_one(db, res, write=write)
r = res["raw"]
print(f"entity={eid} period={period} mode={'write' if write else 'dry-run'} "
f"batch={out.get('batch')} 口径来源={caliber.get('_params_source')}")
print(f" 期末总资产 {r['total_assets_end']:.2f}万 / 期末净资产 {r['equity_end']:.2f}万 / "
f"平均总资产 {r['total_assets_avg']:.2f}万 / 平均净资产 {r['equity_avg']:.2f}万 / "
f"营业收入 {r['revenue']:.2f}万"
+ (" ⚠️ basis_fallback=期初缺失,已回落期末" if res["basis_fallback"] else ""))
print(f" F_ASSET_TOTAL={res['values']['F_ASSET_TOTAL']}万 "
f"F_EQUITY_TOTAL={res['values']['F_EQUITY_TOTAL']}万 "
f"F_EQUITY_MULTIPLIER={res['values']['F_EQUITY_MULTIPLIER']}倍 "
f"F_ASSET_TURNOVER={res['values']['F_ASSET_TURNOVER']}次")
print(f" 落库: {out['written']}")
finally:
db.close()
return rc
if __name__ == "__main__":
sys.exit(main())
@@ -1,356 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""盈利能力三指标派生器 —— 真 ROI / EVA / RI(cma-profitability 2026-10-03)
任务:`finance-20261002-profitability-indicators-roi-eva-ri-fullstack.md`(P1,提出者 wecom-finance)
放行出处:任总 2026-10-02 11:59 CST DM「立;但①要补真ROI」
口径(财务裁定,万元):
营业利润(旧口径) = 5001 − 5401 − 5403 − (5601 + 5602 + 5603)
经营口径营业利润 = 旧口径 + 厂补(530103) − 渠补(571103) [参数开关可回旧口径]
(可选 +571112 商品折扣(销售),默认不计入)
投入资本 = 有息负债(2001 + 2501) + 所有者权益(3xxx 一级科目净额)
真 ROI = 经营口径营业利润 ÷ 投入资本 × 100
NOPAT = 经营口径营业利润 × (1 − 所得税率)
EVA = NOPAT − 投入资本 × WACC
RI = 经营口径营业利润 − 投入资本 × 最低报酬率
取数:
· 损益科目 → `voucher_details`(净额 = 借−贷,`carry_forward=0` 行域,同 kpi_bep_deriver)
· 投入资本 → `subject_balances`(期末余额 cb_cr−cb_dr;3xxx 仅取 `level=1` 一级科目,防跨层双计)
· 期间口径 = **YTD 累计(年初至目标月)**;是否年化由参数 `annualize` 控制(默认 False,
即 ROI/EVA/RI 均为 YTD 非年化口径,已写进 kpi_definitions.formula_desc 与 remark)
参数:`app/utils/profitability_params.py`(唯一读取入口;落 `kpi_definitions.data_source_config.params`)
幂等:同 kpi_id + entity + period + source_type='derived' 存在则原位更新;**不降级 data_status**
(ASK-20260924-10 口径:保留既有状态,含人工放行的 verified)
边界:只写 `source_type='derived'` 行;不改任何 kpi_definitions(定义由种子 SQL 负责);无 DDL。
用法:
venv/bin/python3 scripts/kpi_profitability_deriver.py --period 2026-08 # 落库
venv/bin/python3 scripts/kpi_profitability_deriver.py --period 2026-08 --dry-run # 只算不写
venv/bin/python3 scripts/kpi_profitability_deriver.py --entity 1 --period 2026-08
"""
import argparse
import logging
import os
import sys
from datetime import datetime
from typing import Any, Dict, Optional
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import func, text
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, VoucherDetail
from app.utils.profitability_params import (
EVA_KPI, PROFITABILITY_KPIS, RI_KPI, ROI_KPI, load_profitability_params,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("cma.profitability_deriver")
#: 损益科目口径(与任务单 §一 逐字一致)
SALES_CODE = "5001" # 主营业务收入(贷方)
COST_CODE = "5401" # 主营业务成本
TAX_SURCHARGE_CODE = "5403" # 营业税金及附加
EXPENSE_PREFIXES = ("5601", "5602", "5603") # 销售/管理/财务费用
FACTORY_SUBSIDY_CODE = "530103" # 厂补(贷方)
CHANNEL_SUBSIDY_CODE = "571103" # 渠补(借方)
SALES_DISCOUNT_CODE = "571112" # 商品折扣(销售),默认不计入
#: 有息负债(一级科目)
INTEREST_BEARING_CODES = ("2001", "2501")
#: 所有者权益前缀(一级科目 3xxx,恰 4 位;用 LIKE 保证 MySQL/SQLite 双引擎可跑)
EQUITY_PREFIX = "3"
EQUITY_LEVEL1_LIKE = "3___"
# ────────────────────────── 取数 ──────────────────────────
def ytd_window(period: str) -> tuple:
"""目标期间 → YTD 起止(年初 .. 当期)"""
year = period[:4]
return f"{year}-01", period
def sum_voucher(db, entity_id: int, period_from: str, period_to: str,
field: str, subject_filter=None) -> float:
"""聚合 voucher_details 金额**元**。field: 'net'(借−贷) / 'debit_amount' / 'credit_amount'。
净额口径只在 `carry_forward=0` 行域成立(含结转腿时损益类净额恒 0)——见
/srv/hermes/wisdom/_decisions/cma-expense-profit-net-caliber-20261002.md。
"""
expr = (VoucherDetail.debit_amount - VoucherDetail.credit_amount
if field == "net" else getattr(VoucherDetail, field))
q = db.query(func.sum(expr)).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.carry_forward == 0,
VoucherDetail.period >= period_from,
VoucherDetail.period <= period_to,
)
if subject_filter:
kind, code = subject_filter
if kind == "eq":
q = q.filter(VoucherDetail.subject_code == code)
elif kind == "like":
q = q.filter(VoucherDetail.subject_code.like(code + "%"))
return float(q.scalar() or 0.0)
def operating_profit(db, entity_id: int, period: str, params: Dict[str, Any]) -> Dict[str, float]:
"""YTD 营业利润(万元):旧口径 / 经营口径 + 各项明细,全部留痕供复核。"""
p_from, p_to = ytd_window(period)
sales = sum_voucher(db, entity_id, p_from, p_to, "credit_amount", ("eq", SALES_CODE))
cost = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", COST_CODE))
tax_surcharge = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", TAX_SURCHARGE_CODE))
expenses = sum(sum_voucher(db, entity_id, p_from, p_to, "net", ("like", p))
for p in EXPENSE_PREFIXES)
op_legacy = (sales - cost - tax_surcharge - expenses) / 10000.0
factory_subsidy = -sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", FACTORY_SUBSIDY_CODE)) / 10000.0
channel_subsidy = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", CHANNEL_SUBSIDY_CODE)) / 10000.0
sales_discount = sum_voucher(db, entity_id, p_from, p_to, "net", ("eq", SALES_DISCOUNT_CODE)) / 10000.0
op = op_legacy
if params["include_factory_subsidy"]:
op += factory_subsidy
if params["include_channel_subsidy"]:
op -= channel_subsidy
if params["include_sales_discount"]:
op -= sales_discount
return {
"sales_w": round(sales / 10000.0, 4),
"cost_w": round(cost / 10000.0, 4),
"tax_surcharge_w": round(tax_surcharge / 10000.0, 4),
"expenses_w": round(expenses / 10000.0, 4),
"op_profit_legacy_w": round(op_legacy, 4),
"factory_subsidy_w": round(factory_subsidy, 4),
"channel_subsidy_w": round(channel_subsidy, 4),
"sales_discount_w": round(sales_discount, 4),
"op_profit_w": round(op, 4),
}
def _equity_sql():
return text("""
SELECT COALESCE(SUM(cb_cr - cb_dr), 0)
FROM subject_balances
WHERE entity_id = :eid AND period = :period AND `level` = 1
AND subject_code LIKE :eq_like
""")
def _debt_sql():
return text("""
SELECT COALESCE(SUM(cb_cr - cb_dr), 0)
FROM subject_balances
WHERE entity_id = :eid AND period = :period AND `level` = 1
AND subject_code IN ('2001', '2501')
""")
def invest_capital(db, entity_id: int, period: str, basis: str = "ending") -> Dict[str, Any]:
"""投入资本(万元)= 有息负债 + 所有者权益(`subject_balances` 期末/平均)。
3xxx **只取 `level=1`(一级科目)**:`row_kind` 有 subtotal_level1/detail 两层,
含明细层会把 `3104 利润分配` 与其子科目 `310415` 双计(实测 +598.55 万虚增)。
"""
def _query(p: str) -> Dict[str, float]:
debt = float(db.execute(_debt_sql(), {"eid": entity_id, "period": p}).scalar() or 0.0)
equity = float(db.execute(_equity_sql(), {"eid": entity_id, "period": p,
"eq_like": EQUITY_LEVEL1_LIKE}).scalar() or 0.0)
return {"debt": debt / 10000.0, "equity": equity / 10000.0}
cur = _query(period)
out = {
"interest_bearing_debt_w": round(cur["debt"], 4),
"equity_w": round(cur["equity"], 4),
"basis": basis,
"ending_w": round(cur["debt"] + cur["equity"], 4),
}
total = out["ending_w"]
if basis == "average":
y, m = int(period[:4]), int(period[5:7])
if m == 1:
prev_period = f"{y - 1}-12"
else:
prev_period = f"{y}-{m - 1:02d}"
prev = _query(prev_period)
prev_ok = (prev["debt"] + prev["equity"]) != 0.0
out["prev_period"] = prev_period
out["prev_total_w"] = round(prev["debt"] + prev["equity"], 4)
if prev_ok:
total = (out["ending_w"] + out["prev_total_w"]) / 2.0
else:
out["average_fallback"] = "上一期 subject_balances 缺数 → 回落期末口径"
out["basis"] = "ending(fallback)"
out["invest_capital_w"] = round(total, 4)
return out
# ────────────────────────── 计算 ──────────────────────────
def compute_indicators(op_profit_w: float, invest_capital_w: float,
months: int, params: Dict[str, Any]) -> Dict[str, Optional[float]]:
"""纯计算(无 DB,便于单测):返回三条指标值(None = 不可得,fail-closed)。"""
if not invest_capital_w:
return {ROI_KPI: None, EVA_KPI: None, RI_KPI: None, "nopat_w": None, "scale": None}
scale = (12.0 / months) if (params.get("annualize") and months) else 1.0
op = op_profit_w * scale
nopat = op * (1.0 - float(params["tax_rate"]))
return {
ROI_KPI: round(op / invest_capital_w * 100.0, 2),
"nopat_w": round(nopat, 4),
EVA_KPI: round(nopat - invest_capital_w * float(params["wacc"]), 2),
RI_KPI: round(op - invest_capital_w * float(params["min_return_rate"]), 2),
"scale": scale,
}
# ────────────────────────── 落库 ──────────────────────────
def run_derive(db, entity_id: int, period: str, write_kpi: bool = True) -> Dict[str, Any]:
params = load_profitability_params(db, entity_id)
months = int(period[5:7])
profit = operating_profit(db, entity_id, period, params)
capital = invest_capital(db, entity_id, period, params.get("capital_basis", "ending"))
values = compute_indicators(profit["op_profit_w"], capital["invest_capital_w"], months, params)
defs = {d.kpi_code: d for d in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(list(PROFITABILITY_KPIS)),
).all()}
switch_txt = (f"厂补{'计入' if params['include_factory_subsidy'] else '不计入'}/"
f"渠补{'计入' if params['include_channel_subsidy'] else '不计入'}/"
f"商品折扣{'计入' if params['include_sales_discount'] else '不计入'}")
basis_txt = ("YTD累计(非年化)" if not params.get("annualize")
else f"YTD年化(×12/{months})")
common = (f"营业利润({switch_txt}; {basis_txt}) {profit['op_profit_w']:.2f}万 / "
f"投入资本({capital['basis']}) {capital['invest_capital_w']:.2f}万")
remarks = {
ROI_KPI: (f"{common} × 100 = {values[ROI_KPI]}%"
if values[ROI_KPI] is not None else f"{common}|投入资本为 0,ROI 不可得"),
EVA_KPI: (f"NOPAT {values['nopat_w']:.2f}万(税率{params['tax_rate']:.0%}) − "
f"投入资本 {capital['invest_capital_w']:.2f}万 × WACC {params['wacc']:.2%} "
f"= {values[EVA_KPI]}万" if values[EVA_KPI] is not None else f"{common}|投入资本为 0"),
RI_KPI: (f"营业利润 {profit['op_profit_w']:.2f}万 − 投入资本 {capital['invest_capital_w']:.2f}万 × "
f"最低报酬率 {params['min_return_rate']:.2%} = {values[RI_KPI]}万"
if values[RI_KPI] is not None else f"{common}|投入资本为 0"),
}
batch = f"profit-{datetime.now().strftime('%Y%m%d%H%M%S')}"
written, changed = {}, []
for code in PROFITABILITY_KPIS:
kpi = defs.get(code)
if kpi is None:
written[code] = "no-definition"
logger.warning("kpi_definitions 缺 %s(entity=%s)——请先应用种子 SQL", code, entity_id)
continue
if values[code] is None:
written[code] = "unavailable(invest_capital=0)"
continue
if not write_kpi:
written[code] = "dry-run"
continue
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
KPIValue.source_type == "derived",
).first()
if existing:
old = float(existing.actual_value) if existing.actual_value is not None else None
new = float(values[code])
existing.actual_value = new
existing.source_batch = batch
if old is None or abs(old - new) > 1e-9:
existing.remark = f"{remarks[code]}|值已更新(batch={batch}, 旧{old} → 新{new}) 待财务复核"
changed.append({"code": code, "old": old, "new": new,
"data_status": existing.data_status})
else:
existing.remark = remarks[code]
# 不降级 data_status(ASK-20260924-10)
written[code] = "upsert"
else:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=kpi.id,
period=period,
actual_value=values[code],
source_type="derived",
source_batch=batch,
data_status="pending",
remark=remarks[code],
))
written[code] = "insert"
db.flush()
if write_kpi:
db.commit()
return {
"entity_id": entity_id,
"period": period,
"months": months,
"mode": "write" if write_kpi else "dry-run",
"batch": batch,
"params": params,
"profit": profit,
"capital": capital,
"nopat_w": values.get("nopat_w"),
"values": {c: values[c] for c in PROFITABILITY_KPIS},
"remarks": remarks,
"written": written,
"changed": changed,
}
def _pick_period(db, entity_id: int) -> Optional[str]:
"""默认目标期间 = subject_balances 中该账套最新的期间(投入资本的唯一可取期间)。"""
row = db.execute(text(
"SELECT MAX(period) FROM subject_balances WHERE entity_id = :eid"
), {"eid": entity_id}).scalar()
return str(row) if row else None
def main():
ap = argparse.ArgumentParser(description="盈利能力三指标派生器(真 ROI / EVA / RI)")
ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认取 subject_balances 最新期)")
ap.add_argument("--entity", type=int, default=1, help="entity_id(默认 1=陕西酣客)")
ap.add_argument("--dry-run", action="store_true", help="只计算不落库")
args = ap.parse_args()
db = get_session_local()()
try:
period = args.period or _pick_period(db, args.entity)
if not period:
logger.error("entity=%s 无 subject_balances 记录,无可用期间", args.entity)
return 2
r = run_derive(db, args.entity, period, write_kpi=not args.dry_run)
print(f"盈利能力派生完成 entity={r['entity_id']} period={r['period']} "
f"(YTD {r['months']}个月) mode={r['mode']} batch={r['batch']}")
p, c = r["profit"], r["capital"]
print(f" 销售收入 {p['sales_w']:.2f}万 − 成本 {p['cost_w']:.2f}万 − 税金及附加 {p['tax_surcharge_w']:.2f}万 "
f"− 三费 {p['expenses_w']:.2f}万 = 旧口径营业利润 {p['op_profit_legacy_w']:.2f}万")
print(f" 厂补 {p['factory_subsidy_w']:.2f}万 / 渠补 {p['channel_subsidy_w']:.2f}万 / "
f"商品折扣 {p['sales_discount_w']:.2f}万 → 经营口径营业利润 {p['op_profit_w']:.2f}万")
print(f" 投入资本({c['basis']}) = 有息负债 {c['interest_bearing_debt_w']:.2f}万 + "
f"所有者权益 {c['equity_w']:.2f}万 = {c['invest_capital_w']:.2f}万")
print(f" F_ROI = {r['values'][ROI_KPI]} %")
print(f" F_EVA = {r['values'][EVA_KPI]} 万元 (NOPAT {r['nopat_w']:.2f}万)")
print(f" F_RI = {r['values'][RI_KPI]} 万元")
print(f" 落库: {r['written']}")
if r["changed"]:
print(f" 值变: {r['changed']}")
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main() or 0)
+2 -8
View File
@@ -1,14 +1,8 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""比率类 KPI 单位对照表(只读)—— 口径 = 裁定2a + 2026-09-24 v2.1(取代两项)
依据:`wecom-finance/reports/CMA_口径裁定_20260924_比率类单位口径_取代2a两指标.md`
(`CMA-CALIBER-v2.1/kpi-unit@2026-09-24`)—— 取代 `wecom-finance/reports/CMA_口径裁定_20260917.md`
§2a 中的 `F_CURRENT_RATIO` / `F_QUICK_RATIO` **两项**(改「倍」、无量纲直判、不再 ×100);
其余比率类指标仍按 20260917 裁定2a(百分比 %)执行。
"""比率类 KPI 单位对照表(只读)—— 财务口径裁定 裁定2a 的产出器
用途:逐个比率类 KPI 核对「定义 / 声明单位 / 阈值量纲 / 实测值量纲」,出对照表。
两类口径在表里分栏可见:`factor=1` + `unit=倍` = 倍口径同量纲直判;`factor=100` = 裁定2a 百分比归一。
判定侧的事实源是 `app/utils/caliber_units.RATIO_CALIBER_TABLE`(本脚本只核对与呈现,
不写库、不改阈值)。
@@ -86,7 +80,7 @@ def main():
print(json.dumps(out, ensure_ascii=False, indent=1, default=str))
return 0
print("=== 比率类 KPI 单位对照表(只读;口径 = 2a + v2.1 取代两项)===")
print("=== 比率类 KPI 单位对照表(只读;裁定2a)===")
print("%-4s %-8s %-22s %-6s %-10s %-12s %-8s %-6s %s" % (
"eid", "code", "name", "unit", "latest", "actual", "status", "factor", "thresholds"))
for x in out:
@@ -200,11 +200,6 @@ def main():
code_name, names, codes = load_voucher_index(db)
subject_dict = load_subject_dict(db)
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == ENTITY_ID).all()}
# ⚠️ kpi_subject_map 护栏(2026-09-23 账套维度收口批 ②,零逻辑改动):
# 该表无账套列 —— **账套维度经 `kpi_id` 继承**。此处全表取映射后由 `kpis`(已按
# `entity_id == ENTITY_ID` 过滤)逐行滤掉非本账套 KPI ⇒ 现状安全。
# **禁止新增按 `subject_code` 反查的读口,禁止按 `subject_code` 批量写的入口**:
# 同一科目码可同时属多个账套(标准准则科目全局共享),按科目反查/批量写立刻跨账套。
rows = db.query(KPISubjectMap).order_by(KPISubjectMap.kpi_id, KPISubjectMap.id).all()
grouped = defaultdict(list)
for r in rows:
@@ -1,203 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""`analysis_results` 账套化 DDL — 2026-09-23
任务:`0-P1-project-20260923-entity-dimension-convergence-fullstack` §三④
(裁定件 `project-entity-dimension-ruling-20260923` §3.2「读侧先止血、写侧随 DDL」)
背景
----
`analysis_results` 只有 2 行(`id=1 period=2026-06 kpi_code=TEST001` /
`id=2 period=2026-07 kpi_code=NULL`,均 2026-07-29 创建),**无账套列**;
`GET/POST/DELETE /api/cma/analysis/result*` 三端点原无 `get_entity_id`
⇒ 双 token 返回体**逐字节相同**(跨账套读,页面 `AnalysisConfidence.vue` 已上线)。
本脚本只做 DDL(加列 + 索引),不做任何数据订正:
· 存量 2 行**保持 `entity_id = NULL`** —— 归属存疑,按裁定件交 DAMA 取证 / 财务确认后另办;
· 代码侧口径(`app/api/analysis_results.py`):`NULL` 行 = 无归属,进 `unattributed` 登记池,
不参与任何账套过滤;列落地后读侧自动从 `kpi_code JOIN` 止血口径切到列口径。
用法
----
venv/bin/python3 scripts/migrate_analysis_results_entity_id_20260923.py --check
CMA_DDL_APPROVED=1 venv/bin/python3 scripts/migrate_analysis_results_entity_id_20260923.py \\
--apply --allow-prod
CMA_DDL_APPROVED=1 venv/bin/python3 scripts/migrate_analysis_results_entity_id_20260923.py \\
--down --allow-prod --yes
venv/bin/python3 scripts/migrate_analysis_results_entity_id_20260923.py --show-create
生产保护(生产资产派单纪律):目标库名 == 'cma' 时 `--apply/--down` 一律拒绝;
须 `--allow-prod` **且** `CMA_DDL_APPROVED=1`(= 已拿到 ops 审批单)才放行;
`--check/--show-create` 为只读,不受限但会打标。
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, inspect, text # noqa: E402
TABLE = "analysis_results"
COLUMN = "entity_id"
NEW_INDEX = "ix_analysis_results_entity_id"
PROD_DB_NAMES = ("cma",)
DDL_ADD_COLUMN = (
f"ALTER TABLE {TABLE} ADD COLUMN {COLUMN} INT NULL "
"COMMENT '企业ID(NULL=历史未归属行,不参与账套过滤)' AFTER id"
)
DDL_DROP_COLUMN = f"ALTER TABLE {TABLE} DROP COLUMN {COLUMN}"
def is_sqlite(engine) -> bool:
"""沙箱演练(SQLite)与生产(MySQL)的索引/列 DDL 方言差异"""
return engine.dialect.name == "sqlite"
def stmt_add_column(engine) -> str:
"""生产形态 = MySQL(带 COMMENT / AFTER);沙箱 = SQLite 精简形态(语法不支持二者)"""
if is_sqlite(engine):
return f"ALTER TABLE {TABLE} ADD COLUMN {COLUMN} INT NULL"
return DDL_ADD_COLUMN
def stmt_add_index(engine, name: str, cols: list) -> str:
cols_s = ", ".join(cols)
if is_sqlite(engine):
return f"CREATE INDEX {name} ON {TABLE} ({cols_s})"
return f"ALTER TABLE {TABLE} ADD INDEX {name} ({cols_s})"
def stmt_drop_index(engine, name: str) -> str:
return f"DROP INDEX {name}" if is_sqlite(engine) else f"ALTER TABLE {TABLE} DROP INDEX {name}"
def db_name_of(engine) -> str:
try:
return engine.url.database or ""
except Exception:
return ""
def state(engine) -> dict:
insp = inspect(engine)
tables = set(insp.get_table_names())
out = {"table_exists": TABLE in tables, "has_column": False, "indexes": [], "rows": []}
if not out["table_exists"]:
return out
out["has_column"] = COLUMN in {c["name"] for c in insp.get_columns(TABLE)}
out["indexes"] = sorted({i["name"] for i in insp.get_indexes(TABLE)})
with engine.connect() as conn:
out["total"] = conn.execute(text(f"SELECT COUNT(*) FROM {TABLE}")).scalar()
if out["has_column"]:
rows = conn.execute(text(
f"SELECT id, period, kpi_code, IFNULL({COLUMN},'NULL') FROM {TABLE} ORDER BY id")).fetchall()
else:
rows = conn.execute(text(
f"SELECT id, period, kpi_code, 'NO_COLUMN' FROM {TABLE} ORDER BY id")).fetchall()
out["rows"] = [[r[0], r[1], r[2], str(r[3])] for r in rows]
return out
def print_check(st: dict) -> None:
print("== 只读预检(inspector / SELECT,零写入)==")
print(f" table_exists={st['table_exists']} has_column={st['has_column']} total={st.get('total')}")
print(f" indexes={st['indexes']}")
for r in st.get("rows", []):
print(f" id={r[0]} period={r[1]} kpi_code={r[2]} entity_id={r[3]}")
def do_apply(engine) -> int:
st = state(engine)
if not st["table_exists"]:
print("!!! 目标库没有 analysis_results 表,拒绝 --apply")
return 3
done = []
with engine.begin() as conn:
if not st["has_column"]:
add_col = stmt_add_column(engine)
conn.execute(text(add_col))
done.append(add_col)
cur = state(engine)
if NEW_INDEX not in cur["indexes"]:
add_idx = stmt_add_index(engine, NEW_INDEX, [COLUMN])
conn.execute(text(add_idx))
done.append(add_idx)
print("== APPLY 完成,执行语句:")
for s in done:
print(" " + s)
if not done:
print(" (无语句执行:已是目标形态,幂等)")
print_check(state(engine))
return 0
def do_down(engine, yes: bool) -> int:
st = state(engine)
if not st["has_column"]:
print("== DOWN 跳过:entity_id 列不存在(已是回滚态)")
return 0
assigned = [r for r in st.get("rows", []) if r[3] not in ("NULL", "NO_COLUMN")]
if assigned and not yes:
print(f"!!! 已有 {len(assigned)} 行带账套归属 {assigned}:回滚会丢失归属,须显式 --yes")
return 3
with engine.begin() as conn:
if NEW_INDEX in st["indexes"]:
conn.execute(text(stmt_drop_index(engine, NEW_INDEX)))
conn.execute(text(DDL_DROP_COLUMN))
print("== DOWN 完成:删除 entity_id 索引 + 列")
print_check(state(engine))
return 0
def main():
ap = argparse.ArgumentParser(description="analysis_results 账套化 DDL")
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(默认)")
g.add_argument("--apply", action="store_true", help="执行 DDL(幂等)")
g.add_argument("--down", action="store_true", help="回滚:删索引 + 删列")
ap.add_argument("--show-create", action="store_true", help="打印 SHOW CREATE TABLE")
ap.add_argument("--url", default=None, help="目标库 SQLAlchemy URL(默认 backend/.env)")
ap.add_argument("--allow-prod", action="store_true", help="生产库放行开关(须 CMA_DDL_APPROVED=1)")
ap.add_argument("--yes", action="store_true", help="--down 有账套归属行时的确认开关")
args = ap.parse_args()
if args.url:
engine = create_engine(args.url, echo=False)
else:
from app.database import get_engine
engine = get_engine()
dbname = db_name_of(engine)
print(f"目标库: {dbname or '(未知)'} | dialect={engine.dialect.name} | host={engine.url.host or 'socket'}")
is_prod = dbname in PROD_DB_NAMES
if (args.apply or args.down) and is_prod:
if not args.allow_prod or os.getenv("CMA_DDL_APPROVED") != "1":
print(f"!!! 拒绝执行:目标库是生产库 {dbname}。")
print("!!! 生产 DDL 须 ops 审批后另派执行(本单边界:不得直连生产写入)。")
print("!!! 放行方式:--allow-prod 且 export CMA_DDL_APPROVED=1(持有审批单)。")
return 3
if args.apply:
return do_apply(engine)
if args.down:
return do_down(engine, args.yes)
st = state(engine)
print_check(st)
if is_prod:
print(" [注意] 目标是生产库:以上仅为只读预检,未做任何写入。")
if args.show_create:
if engine.dialect.name != "mysql":
print("== SHOW CREATE TABLE 跳过(非 MySQL 目标)==")
else:
with engine.connect() as conn:
print(conn.execute(text(f"SHOW CREATE TABLE {TABLE}")).fetchone()[1])
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,329 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""全局键拆账套 — 一次性搬运(数据迁移,非 DDL)
任务:`dama-workbench-entity-isolation-fullstack-20260922` §三 B1/B3(a)
(DAMA 诊断 / 项目Bot batch5 §B 方案段 / 任总 2026-09-22「都批」)
背景
----
`system_configs.config_key` 与 `role_permissions.key` 均为 **unique 单行键** ⇒
下列两个「全局键」全库只有一行,被所有账套共用(本次「酣客工作台显示博海数据」
污染的同源结构性缺陷):
键 处置
------------------------------- ------------------------------------------
system_configs.cash.current_balance → cash.current_balance.<entity_id>
role_permissions.alignment_config → alignment_config.<entity_id>
代码侧(读写)已改为**只认带账套后缀的新键**(方案 A,零 DDL):
backend/app/utils/cash_forecast_engine.py (get_/set_current_cash_balance)
backend/app/api/cash.py (POST /balance、网银流水联动)
backend/app/api/alignment.py (GET/POST /config、GET /tree)
本脚本 = **旧值一次性搬运**(把原来唯一的那一行值落到「当前生产账套」名下)。
「当前生产账套」判定:默认 `--target-entity 1`(entity 1 = 陕西酣客,生产账套),
`--check` 会打印**逐账套凭证推导的货币资金期末余额**与旧键值的比对,供人工确认
(哪个账套的 1001/1002 期末余额 == 旧键值 ⇒ 该值本来就是它写的)。
口径(须与代码注释一致)
------------------------
· 新键值 = 旧键值(一次性搬运)**只落到 target-entity**;**其余账套留空**
⇒ 留空语义 = 该账套现金基线走 KPI 实际值兜底 / 预测引擎默认值,
**不跨账套借用**(正是本次污染的成因)。
· 旧键**原样保留不删**(回退杠杆:回退老代码即可继续读旧键)。
· alignment_config 同理:旧全局值落到 target-entity;其余账套 configured=false。
· 幂等:新键已存在 → 不覆盖(除非 `--force`);二跑 `--apply` 零副作用。
用法
----
# 只读预检(默认动作,零写入)
python3 scripts/migrate_cash_balance_key_entity_20260922.py --check
# 写库(**需审批 / 生产写**;--apply 才写)
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --target-entity 2
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --force # 覆盖已存在的新键
# 回退(删新键 + 把旧全局键写回原值)
python3 scripts/migrate_cash_balance_key_entity_20260922.py --down
选项
----
--target-entity N 旧值落哪个账套(默认 1;--check 会给出证据建议)
--force 新键已存在时允许覆盖(默认拒绝,保留既有值)
--json 机器可读输出
--allow-db NAME 生产库名保护豁免(默认只允许库名 `cma`)
退出码
------
0 成功 | 1 执行失败 | 2 用法/保护拒绝(库名不符、目标账套不存在)
⚠️ 本脚本写的是**生产业务库**:不得在开发级会话直接 `--apply`。
依据《生产资产派单纪律》(skills-shared/operations/prod-asset-dispatch-discipline):
碰生产资产一律走派单 + 审批;`--check` 同样是生产读,须留单痕。
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
CASH_KEY = "cash.current_balance" # 旧全局键(保留不删)
ALIGN_KEY = "alignment_config" # 旧全局键(保留不删)
PROD_DB_NAME = "cma" # 生产库名(保护口径)
CASH_DESC = "当前现金余额(万元),资金预测基线 — 账套 {eid}"
ALIGN_DESC = "对齐模式配置 — 账套 {eid}(B3(a) 拆账套 2026-09-22)"
def _engine():
from app.database import get_engine
return get_engine()
def _scalar(conn, sql, **params):
row = conn.execute(text(sql), params).fetchone()
return row[0] if row else None
def _detect_db_name(conn) -> str:
try:
return _scalar(conn, "SELECT DATABASE()") or ""
except Exception:
return ""
def _cash_ledger_by_entity(conn):
"""逐账套:货币资金(1001/1002, carry_forward=0) 期末净额(元 → 万元)
与 `cash.py::_sync_cash_balance_from_ledger` 同口径(该函数正是写旧键的调用方)
——用于回答「-9.3 这个值原本是哪家写的」。
"""
out = {}
try:
rows = conn.execute(text(
"SELECT entity_id, "
" ROUND(SUM(COALESCE(debit_amount,0) - COALESCE(credit_amount,0))/10000, 4) AS wan, "
" COUNT(*) AS n "
"FROM voucher_details "
"WHERE carry_forward = 0 AND subject_code IS NOT NULL "
" AND (subject_code LIKE '1001%' OR subject_code LIKE '1002%') "
"GROUP BY entity_id ORDER BY entity_id"
)).fetchall()
for r in rows:
out[int(r[0])] = {"cash_wan": float(r[1] or 0), "voucher_rows": int(r[2] or 0)}
except Exception as e: # 表缺失/权限 → 只报告,不阻断
out["_error"] = str(e)[:300]
return out
def _snapshot(conn):
"""只读快照:新旧键现状 + 逐账套凭证推导余额"""
snap = {
"db": _detect_db_name(conn),
"legacy": {
"cash.current_balance": None,
"alignment_config": None,
},
"new_keys": {"cash": {}, "alignment": {}},
"entities": [],
"cash_ledger_by_entity": _cash_ledger_by_entity(conn),
}
row = conn.execute(text(
"SELECT config_value, description FROM system_configs WHERE config_key = :k"
), {"k": CASH_KEY}).fetchone()
if row:
snap["legacy"]["cash.current_balance"] = {"config_value": row[0], "description": row[1]}
row = conn.execute(text(
"SELECT value FROM role_permissions WHERE `key` = :k"
), {"k": ALIGN_KEY}).fetchone()
if row:
snap["legacy"]["alignment_config"] = {"value": row[0]}
for r in conn.execute(text(
"SELECT id, name, short_name, status FROM entities ORDER BY id"
)).fetchall():
snap["entities"].append({"id": int(r[0]), "name": r[1], "short_name": r[2], "status": r[3]})
for r in conn.execute(text(
"SELECT config_key, config_value FROM system_configs "
"WHERE config_key LIKE :p ORDER BY config_key"
), {"p": CASH_KEY + ".%"}).fetchall():
snap["new_keys"]["cash"][r[0]] = r[1]
for r in conn.execute(text(
"SELECT `key`, value FROM role_permissions WHERE `key` LIKE :p ORDER BY `key`"
), {"p": ALIGN_KEY + ".%"}).fetchall():
snap["new_keys"]["alignment"][r[0]] = r[1]
# 建议目标账套(只读推断):旧值 == 某账套凭证推导余额
suggestion = None
legacy_cash = (snap["legacy"]["cash.current_balance"] or {}).get("config_value")
if legacy_cash is not None:
try:
want = float(legacy_cash)
for eid, info in snap["cash_ledger_by_entity"].items():
if isinstance(eid, int) and abs(float(info["cash_wan"]) - want) < 1e-6:
suggestion = eid
break
except Exception:
suggestion = None
snap["suggested_target_entity"] = suggestion
return snap
def cmd_check(args):
eng = _engine()
with eng.connect() as conn:
snap = _snapshot(conn)
if args.json:
print(json.dumps(snap, ensure_ascii=False, indent=2))
return 0
print(f"目标库: {snap['db']}")
print(f"旧全局键 cash.current_balance : {snap['legacy']['cash.current_balance']}")
print(f"旧全局键 alignment_config : {snap['legacy']['alignment_config']}")
print(f"已存在新键(cash) : {snap['new_keys']['cash']}")
print(f"已存在新键(alignment) : {snap['new_keys']['alignment']}")
print("逐账套凭证推导货币资金期末余额(万元):")
for eid, info in snap["cash_ledger_by_entity"].items():
print(f" entity {eid}: {info}")
print(f"建议 --target-entity : {snap['suggested_target_entity']}"
f"(旧值与他家凭证推导余额一致时给出;None = 无匹配,按默认 1 或人工裁定)")
print("【只读预检,零写入】")
return 0
def _apply_cash(conn, target: int, force: bool, dry: bool):
new_key = f"{CASH_KEY}.{target}"
row = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": CASH_KEY}).fetchone()
if not row:
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
legacy_value = row[0]
exist = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": new_key}).fetchone()
if exist and not force:
return {"key": new_key, "action": "noop",
"reason": "新键已存在且未 --force(保留既有值,幂等)",
"existing_value": exist[0]}
if dry:
return {"key": new_key, "action": "would-insert-or-update",
"value": legacy_value, "existing_value": exist[0] if exist else None}
if exist:
conn.execute(text(
"UPDATE system_configs SET config_value = :v, description = :d WHERE config_key = :k"
), {"v": legacy_value, "d": CASH_DESC.format(eid=target), "k": new_key})
action = "updated"
else:
conn.execute(text(
"INSERT INTO system_configs (config_key, config_value, description) "
"VALUES (:k, :v, :d)"
), {"k": new_key, "v": legacy_value, "d": CASH_DESC.format(eid=target)})
action = "inserted"
return {"key": new_key, "action": action, "value": legacy_value}
def _apply_alignment(conn, target: int, force: bool, dry: bool):
new_key = f"{ALIGN_KEY}.{target}"
row = conn.execute(text(
"SELECT value FROM role_permissions WHERE `key` = :k"
), {"k": ALIGN_KEY}).fetchone()
if not row:
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
exist = conn.execute(text(
"SELECT value FROM role_permissions WHERE `key` = :k"
), {"k": new_key}).fetchone()
if exist and not force:
return {"key": new_key, "action": "noop",
"reason": "新键已存在且未 --force(保留既有值,幂等)",
"existing_value": exist[0]}
if dry:
return {"key": new_key, "action": "would-insert", "value": row[0]}
conn.execute(text(
"INSERT INTO role_permissions (`key`, value) VALUES (:k, :v)"
), {"k": new_key, "v": row[0]})
return {"key": new_key, "action": "inserted", "value": row[0]}
def _guard_db(args, conn):
db = _detect_db_name(conn)
if args.allow_db:
return db
if db != PROD_DB_NAME:
print(f"[拒绝] 库名 {db!r} ≠ 生产库 {PROD_DB_NAME!r};"
f"如确为预期,请显式加 --allow-db {db}", file=sys.stderr)
sys.exit(2)
return db
def cmd_apply(args):
eng = _engine()
results = []
with eng.begin() as conn:
db = _guard_db(args, conn)
# 目标账套存在性校验(防写出孤儿键)
ent = conn.execute(text("SELECT id FROM entities WHERE id = :i"),
{"i": args.target_entity}).fetchone()
if not ent:
print(f"[拒绝] 目标账套 entity_id={args.target_entity} 不存在", file=sys.stderr)
sys.exit(2)
results.append(_apply_cash(conn, args.target_entity, args.force, dry=False))
results.append(_apply_alignment(conn, args.target_entity, args.force, dry=False))
out = {"mode": "apply", "db": db, "target_entity": args.target_entity, "results": results}
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else
"\n".join(f" {r}" for r in results))
return 0
def cmd_down(args):
"""回退:删除新键(旧全局键原样保留,老代码即可继续读)"""
eng = _engine()
removed = []
with eng.begin() as conn:
db = _guard_db(args, conn)
r1 = conn.execute(text(
"DELETE FROM system_configs WHERE config_key LIKE :p"), {"p": CASH_KEY + ".%"})
r2 = conn.execute(text(
"DELETE FROM role_permissions WHERE `key` LIKE :p"), {"p": ALIGN_KEY + ".%"})
removed = [f"system_configs(+{CASH_KEY}.*) deleted={r1.rowcount}",
f"role_permissions({ALIGN_KEY}.*) deleted={r2.rowcount}"]
print(json.dumps({"mode": "down", "db": db, "removed": removed},
ensure_ascii=False, indent=2) if args.json else "\n".join(removed))
print("旧全局键未改动 → 代码回退到老版本即可继续读旧键。")
return 0
def main():
p = argparse.ArgumentParser(description="全局键拆账套 · 一次性搬运(cash.current_balance / alignment_config)")
g = p.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(默认)")
g.add_argument("--apply", action="store_true", help="写库(需审批)")
g.add_argument("--down", action="store_true", help="回退:删除新键")
p.add_argument("--target-entity", type=int, default=1,
help="旧值落到哪个账套(默认 1 = 陕西酣客,生产账套)")
p.add_argument("--force", action="store_true", help="允许覆盖已存在的新键")
p.add_argument("--json", action="store_true", help="机器可读输出")
p.add_argument("--allow-db", default=None, help="生产库名保护豁免(默认仅允许 cma)")
args = p.parse_args()
if args.apply:
return cmd_apply(args)
if args.down:
return cmd_down(args)
return cmd_check(args)
if __name__ == "__main__":
sys.exit(main())
@@ -1,247 +0,0 @@
#!/usr/bin/env python3
"""CPA 客户获利性明细快照表 —— 独立特权 DDL 步(幂等 / 可回滚 / 带 journal)
任务:finance-20261003-cpa-customer-profitability-mvp-fullstack(P0-a)
为什么是独立脚本而不是 ORM 模型
------------------------------
运行库账号 `cma_api` 只有 SELECT + 表级 DML 白名单(**无 DDL**),建表须走特权通道
(本机 unix socket + root,或专用迁移账号)。同时 `app/models/__init__.py` 此刻有**他单在途
改动**(CMA-KPI-20261003 KPI 字典分层治理),本单不碰该共享文件 ⇒ 新表以独立迁移脚本落地,
运行时以 raw SQL 访问(与既有 `zxt_masterbill` 同一形态)。
用法
----
python3 scripts/migrate_cpa_snapshot_20261004.py --check # 只读预检(默认)
python3 scripts/migrate_cpa_snapshot_20261004.py --apply # 幂等建表 + 写 journal
python3 scripts/migrate_cpa_snapshot_20261004.py --status # 历史 journal
python3 scripts/migrate_cpa_snapshot_20261004.py --rollback latest
python3 scripts/migrate_cpa_snapshot_20261004.py --show-create
选项
----
--db-url URL 显式连接串(离线演练用 `sqlite:///<scratch>.db`)
--db-name NAME 目标库名(默认 env CMA_MIGRATE_DB_NAME → CMA_DB_NAME → cma)
--journal-dir 默认 env CPA_MIGRATE_JOURNAL_DIR 或 backend/scripts/backups/db-migrate-journal/
--force 回滚时允许删除**有数据**的表(默认拒绝,exit 3)
--json 机器可读输出
连接凭据(口令只从环境读,禁止硬编码/回显)
1) CMA_MIGRATE_DB_USER + CMA_MIGRATE_DB_PASS(专用迁移账号,TCP)
2) 未设 → 本机 unix socket + root(socket 认证)
⚠️ 应用运行账号(backend/.env 的 CMA_DB_USER = cma_api)不得被本脚本使用:无 DDL 权限。
幂等契约
首跑 --apply:created=1;二跑 --apply:created=0 且零副作用(不 ALTER / 不 DROP / 不动数据行)。
退出码
0 成功 | 1 失败(fail-closed)| 2 用法错误 | 3 回滚被拒(目标表有数据且未 --force)
"""
import argparse
import datetime as _dt
import json
import os
import subprocess
import sys
TABLE = "customer_profitability_snapshot"
MYSQL_DDL = f"""
CREATE TABLE IF NOT EXISTS `{TABLE}` (
`id` INT NOT NULL AUTO_INCREMENT,
`entity_id` INT NOT NULL COMMENT '企业ID(账套,多租户隔离)',
`period` VARCHAR(7) NOT NULL COMMENT '期间 YYYY-MM(自然月,来源 zxt_masterbill.bill_date)',
`unit_id` INT NOT NULL COMMENT '客户/往来单位ID(zxt_masterbill.unit_id;库内无名称源)',
`revenue` DECIMAL(18,4) NOT NULL DEFAULT 0.0000 COMMENT '客户收入(万元;Σsum_money,含税原值口径)',
`cogs_alloc` DECIMAL(18,4) NULL COMMENT '客户销货成本(分摊)(万元)——P1 fail-closed,未裁定口径前恒 NULL',
`chan_sub` DECIMAL(18,4) NULL COMMENT '客户渠道补贴(万元)——P1 待打标台账,恒 NULL',
`contrib` DECIMAL(18,4) NULL COMMENT '客户贡献毛利(万元)——P1,恒 NULL',
`contrib_rate` DECIMAL(10,4) NULL COMMENT '客户贡献毛利率(%)——P1,恒 NULL',
`rank_no` INT NULL COMMENT '期内收入排名(1=最高)',
`calib_status` VARCHAR(30) NOT NULL DEFAULT 'revenue_only' COMMENT '口径状态: revenue_only / full(P1)',
`fail_closed_reason` VARCHAR(500) NULL COMMENT '未落值列的原因(fail-closed 留痕,禁静默 0)',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_cpa_snap_entity_period_unit` (`entity_id`, `period`, `unit_id`),
KEY `ix_cpa_snap_entity_period_rank` (`entity_id`, `period`, `rank_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户获利性明细快照(CPA P0-a;每客户每期一行,幂等 upsert)'
"""
SQLITE_DDL = f"""
CREATE TABLE IF NOT EXISTS {TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER NOT NULL,
period TEXT NOT NULL,
unit_id INTEGER NOT NULL,
revenue NUMERIC NOT NULL DEFAULT 0,
cogs_alloc NUMERIC,
chan_sub NUMERIC,
contrib NUMERIC,
contrib_rate NUMERIC,
rank_no INTEGER,
calib_status TEXT NOT NULL DEFAULT 'revenue_only',
fail_closed_reason TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_cpa_snap_entity_period_unit UNIQUE (entity_id, period, unit_id)
)
"""
def _now():
return _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
def _journal_dir(arg):
if arg:
return arg
env = os.environ.get("CPA_MIGRATE_JOURNAL_DIR")
if env:
return env
here = os.path.dirname(os.path.abspath(__file__))
d = os.path.join(here, "backups", "db-migrate-journal")
os.makedirs(d, exist_ok=True)
return d
def _resolve_url(args):
if args.db_url:
return args.db_url
if os.environ.get("CMA_MIGRATE_DB_URL"):
return os.environ["CMA_MIGRATE_DB_URL"]
user = os.environ.get("CMA_MIGRATE_DB_USER")
pwd = os.environ.get("CMA_MIGRATE_DB_PASS", "")
if user:
host = os.environ.get("CMA_MIGRATE_DB_HOST", "127.0.0.1")
port = os.environ.get("CMA_MIGRATE_DB_PORT", "3306")
name = args.db_name or os.environ.get("CMA_MIGRATE_DB_NAME") or os.environ.get("CMA_DB_NAME") or "cma"
from urllib.parse import quote_plus
return f"mysql+pymysql://{user}:{quote_plus(pwd)}@{host}:{port}/{name}?charset=utf8mb4"
name = args.db_name or os.environ.get("CMA_DB_NAME") or "cma"
return f"mysql+pymysql://root@localhost/{name}?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4"
def _engine(args):
from sqlalchemy import create_engine
url = _resolve_url(args)
kw = {}
if url.startswith("sqlite"):
kw["connect_args"] = {"check_same_thread": False}
return create_engine(url, **kw), url
def _table_exists(conn, url):
from sqlalchemy import text
if url.startswith("sqlite"):
r = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name=:t"), {"t": TABLE}).fetchall()
else:
r = conn.execute(text("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:t"),
{"t": TABLE}).fetchall()
return bool(r)
def _rowcount(conn):
from sqlalchemy import text
try:
return int(conn.execute(text(f"SELECT COUNT(*) FROM {TABLE}")).scalar() or 0)
except Exception:
return None
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("--status", action="store_true")
g.add_argument("--rollback", nargs="?", const="latest")
g.add_argument("--show-create", action="store_true")
ap.add_argument("--db-url")
ap.add_argument("--db-name")
ap.add_argument("--journal-dir")
ap.add_argument("--force", action="store_true")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
engine, url = _engine(args)
redacted = url.split("//")[0] + "//***"
dialect = "sqlite" if url.startswith("sqlite") else "mysql"
out = {"dialect": dialect, "table": TABLE, "url": redacted}
if args.status:
d = _journal_dir(args.journal_dir)
files = sorted(f for f in os.listdir(d) if f.startswith("cpa-snapshot-"))
out["journals"] = files
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else "\n".join(files) or "(no journal)")
return 0
from sqlalchemy import text
with engine.connect() as conn:
exists = _table_exists(conn, url)
rows = _rowcount(conn) if exists else None
out.update(existing=exists, rowcount=rows)
if args.show_create:
if not exists:
out["show_create"] = None
else:
if dialect == "sqlite":
out["show_create"] = conn.execute(text(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=:t"), {"t": TABLE}).scalar()
else:
out["show_create"] = conn.execute(text(f"SHOW CREATE TABLE `{TABLE}`")).fetchone()[1]
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
if args.rollback is not None:
if not exists:
out["action"] = "rollback-noop"; print(json.dumps(out, ensure_ascii=False)); return 0
if rows and not args.force:
out["action"] = "rollback-refused"; out["why"] = f"表内有 {rows} 行,未加 --force"
print(json.dumps(out, ensure_ascii=False)); return 3
jdir = _journal_dir(args.journal_dir)
jf = os.path.join(jdir, f"cpa-snapshot-rollback-{_now()}.json")
with open(jf, "w") as fh:
json.dump({"table": TABLE, "rows_before": rows, "ts": _now()}, fh, ensure_ascii=False)
with engine.begin() as c2:
c2.execute(text(f"DROP TABLE {TABLE}" if dialect == "sqlite" else f"DROP TABLE `{TABLE}`"))
out["action"] = "rolled-back"; out["journal"] = jf
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
if not args.apply:
# --check:只读预检
out["action"] = "check"
out["would_create"] = not exists
if exists:
cols = conn.execute(text(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:t ORDER BY ORDINAL_POSITION"
), {"t": TABLE}).fetchall() if dialect == "mysql" else conn.execute(
text(f"PRAGMA table_info({TABLE})")).fetchall()
out["columns"] = [c[0] for c in cols] if dialect == "mysql" else [c[1] for c in cols]
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else
f"[check] table={TABLE} existing={exists} rowcount={rows} would_create={not exists}")
return 0
# --apply:幂等建表
ddl = SQLITE_DDL if dialect == "sqlite" else MYSQL_DDL
jdir = _journal_dir(args.journal_dir)
created_new = 0
if not exists:
with engine.begin() as c2:
c2.execute(text(ddl))
created_new = 1
with engine.connect() as conn3:
assert _table_exists(conn3, url), "建表后自检失败"
jf = os.path.join(jdir, f"cpa-snapshot-{_now()}{'' if created_new else '-noop'}.json")
with open(jf, "w") as fh:
json.dump({"table": TABLE, "created": created_new, "ddl": ddl.strip(), "ts": _now()}, fh, ensure_ascii=False, indent=2)
out.update(action="applied", created_new=created_new, journal=jf,
rowcount_after=_rowcount(conn3))
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,244 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""全局键拆账套 — `kpi_alert_higher_better` 一次性搬运(数据迁移,非 DDL)
任务:`dama-entity-isolation-sweep-20260923` §一①(DAMA 独立回查新增漏项,任总 2026-09-23 批)
背景
----
`system_configs.config_key` 为 **unique 单行键** ⇒ `kpi_alert_higher_better` 全库只有一行,
被所有账套共用。消费点(拆键前,读的都是那唯一一行):
backend/app/utils/deviation_engine.py get_higher_better_codes() L32/L37-38
build_deviation_alert() L305
backend/app/api/budget.py GET /alert-direction L1087
PUT /alert-direction L1114-1125
任一套账写一次,**方向判定(实际低于预算才告警 vs 实际超预算才告警)全平台翻转**。
治本(方案 A,零 DDL):键名带账套后缀 → `kpi_alert_higher_better.<entity_id>`
· 读:只读本账套键;本账套未配置 → `DEFAULT_HIGHER_BETTER`(内置默认表,兜底),
**不回退旧全局键**(回退即跨账套借用)。
· 写:`PUT /alert-direction` 只写会话账套键,body 里的 entity_id 不信。
本脚本 = 旧值一次性搬运。
⚠️ 生产实况(2026-09-23 只读核对):**库内无 `kpi_alert_higher_better` 行**
⇒ `--check` 报 `legacy=None`,`--apply` 走 `skip`(无可搬运值),零写入。
即:拆键后各账套行为与拆键前**逐字一致**(都走内置默认表),不存在「其余账套留空
导致行为退化」的问题。若将来出现旧全局行(例如先于本单部署的 PUT 写入过),
本脚本可直接把它落到 `--target-entity`。
用法
----
# 只读预检(默认动作,零写入)
python3 scripts/migrate_higher_better_key_entity_20260923.py --check
# 写库(**需审批 / 生产写**;--apply 才写)
python3 scripts/migrate_higher_better_key_entity_20260923.py --apply --target-entity 1
# 回退(删新键;旧全局键原样保留)
python3 scripts/migrate_higher_better_key_entity_20260923.py --down
退出码
------
0 成功 | 1 执行失败 | 2 用法/保护拒绝(库名不符、目标账套不存在)
⚠️ 本脚本写的是**生产业务库**:不得在开发级会话直接 `--apply`。
依据《生产资产派单纪律》:碰生产资产一律走派单 + 审批;`--check` 同样是生产读,须留单痕。
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
KEY = "kpi_alert_higher_better" # 旧全局键(保留不删)
PROD_DB_NAME = "cma" # 生产库名(保护口径)
DESC = "越高越好型KPI编码列表(实际低于预算才告警)— 账套 {eid}"
def _engine():
from app.database import get_engine
return get_engine()
def _scalar(conn, sql, **params):
row = conn.execute(text(sql), params).fetchone()
return row[0] if row else None
def _detect_db_name(conn) -> str:
try:
return _scalar(conn, "SELECT DATABASE()") or ""
except Exception:
return ""
def _entity_evidence(conn):
"""逐账套只读证据:KPI 定义数 / 预算行数(供「原值该落哪家」人工确认)"""
out = {}
try:
kpi_rows = conn.execute(text(
"SELECT entity_id, COUNT(*) FROM kpi_definitions GROUP BY entity_id ORDER BY entity_id"
)).fetchall()
bud_rows = conn.execute(text(
"SELECT kd.entity_id, COUNT(*) FROM budget_plans bp "
"JOIN kpi_definitions kd ON kd.id = bp.kpi_id GROUP BY kd.entity_id ORDER BY kd.entity_id"
)).fetchall()
kpi = {int(r[0]) for r in kpi_rows if r[0] is not None}
bud = {int(r[0]): int(r[1]) for r in bud_rows if r[0] is not None}
for eid in sorted(kpi | set(bud)):
out[eid] = {"budget_plans": bud.get(eid, 0)}
except Exception as e: # 表缺失/权限 → 只报告,不阻断
out["_error"] = str(e)[:300]
return out
def _snapshot(conn):
snap = {
"db": _detect_db_name(conn),
"legacy_key": KEY,
"legacy": None,
"new_keys": {},
"entities": [],
"entity_evidence": _entity_evidence(conn),
"suggested_target_entity": None,
}
row = conn.execute(text(
"SELECT config_value, description, updated_at FROM system_configs WHERE config_key = :k"
), {"k": KEY}).fetchone()
if row:
snap["legacy"] = {"config_value": row[0], "description": row[1], "updated_at": str(row[2])}
for r in conn.execute(text(
"SELECT id, name, short_name, status FROM entities ORDER BY id"
)).fetchall():
snap["entities"].append({"id": int(r[0]), "name": r[1], "short_name": r[2], "status": r[3]})
for r in conn.execute(text(
"SELECT config_key, config_value FROM system_configs WHERE config_key LIKE :p ORDER BY config_key"
), {"p": KEY + ".%"}).fetchall():
snap["new_keys"][r[0]] = r[1]
# 「原值落哪家」:旧全局值唯一时,若只有一家账套是 active 生产账套 → 建议它;
# 多 active 账套(e1 酣客 + e2 博海)⇒ 不给自动建议,需人工裁定(写进报告)。
active = [e["id"] for e in snap["entities"] if e["status"] == "active"]
if snap["legacy"] and len(active) == 1:
snap["suggested_target_entity"] = active[0]
return snap
def cmd_check(args):
eng = _engine()
with eng.connect() as conn:
snap = _snapshot(conn)
if args.json:
print(json.dumps(snap, ensure_ascii=False, indent=2))
return 0
print(f"目标库: {snap['db']}")
print(f"旧全局键 {KEY} : {snap['legacy']}")
print(f"已存在新键: {snap['new_keys']}")
print("逐账套只读证据(预算行数):")
for eid, info in snap["entity_evidence"].items():
print(f" entity {eid}: {info}")
print(f"建议 --target-entity : {snap['suggested_target_entity']}"
f"(None = 无旧值 或 多 active 账套需人工裁定)")
print("【只读预检,零写入】")
return 0
def _apply(conn, target: int, force: bool):
new_key = f"{KEY}.{target}"
row = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": KEY}).fetchone()
if not row:
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
exist = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": new_key}).fetchone()
if exist and not force:
return {"key": new_key, "action": "noop",
"reason": "新键已存在且未 --force(保留既有值,幂等)",
"existing_value": exist[0]}
if exist:
conn.execute(text(
"UPDATE system_configs SET config_value = :v, description = :d WHERE config_key = :k"
), {"v": row[0], "d": DESC.format(eid=target), "k": new_key})
return {"key": new_key, "action": "updated", "value": row[0]}
conn.execute(text(
"INSERT INTO system_configs (config_key, config_value, description) VALUES (:k, :v, :d)"
), {"k": new_key, "v": row[0], "d": DESC.format(eid=target)})
return {"key": new_key, "action": "inserted", "value": row[0]}
def _guard_db(args, conn):
db = _detect_db_name(conn)
if args.allow_db:
return db
if db != PROD_DB_NAME:
print(f"[拒绝] 库名 {db!r} ≠ 生产库 {PROD_DB_NAME!r};"
f"如确为预期,请显式加 --allow-db {db}", file=sys.stderr)
sys.exit(2)
return db
def cmd_apply(args):
eng = _engine()
with eng.begin() as conn:
db = _guard_db(args, conn)
ent = conn.execute(text("SELECT id FROM entities WHERE id = :i"),
{"i": args.target_entity}).fetchone()
if not ent:
print(f"[拒绝] 目标账套 entity_id={args.target_entity} 不存在", file=sys.stderr)
sys.exit(2)
results = [_apply(conn, args.target_entity, args.force)]
out = {"mode": "apply", "db": db, "target_entity": args.target_entity, "results": results}
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else
"\n".join(f" {r}" for r in results))
return 0
def cmd_down(args):
"""回退:删除新键(旧全局键原样保留,老代码即可继续读)"""
eng = _engine()
with eng.begin() as conn:
db = _guard_db(args, conn)
r = conn.execute(text("DELETE FROM system_configs WHERE config_key LIKE :p"),
{"p": KEY + ".%"})
removed = [f"system_configs({KEY}.*) deleted={r.rowcount}"]
print(json.dumps({"mode": "down", "db": db, "removed": removed},
ensure_ascii=False, indent=2) if args.json else "\n".join(removed))
print("旧全局键未改动 → 代码回退到老版本即可继续读旧键。")
return 0
def main():
p = argparse.ArgumentParser(
description="全局键拆账套 · 一次性搬运(kpi_alert_higher_better)")
g = p.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(默认)")
g.add_argument("--apply", action="store_true", help="写库(需审批)")
g.add_argument("--down", action="store_true", help="回退:删除新键")
p.add_argument("--target-entity", type=int, default=1,
help="旧值落到哪个账套(默认 1 = 陕西酣客,当前生产账套)")
p.add_argument("--force", action="store_true", help="允许覆盖已存在的新键")
p.add_argument("--json", action="store_true", help="机器可读输出")
p.add_argument("--allow-db", default=None, help="生产库名保护豁免(默认仅允许 cma)")
args = p.parse_args()
if args.apply:
return cmd_apply(args)
if args.down:
return cmd_down(args)
return cmd_check(args)
if __name__ == "__main__":
sys.exit(main())

Some files were not shown because too many files have changed in this diff Show More