Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b736e0727a | ||
|
|
86e24c6310 | ||
|
|
ecbe55c48a | ||
|
|
c030fb7d18 | ||
|
|
a8c9b38fff | ||
|
|
53cad7eaa9 | ||
|
|
a8141e8879 | ||
|
|
d85dd2a155 | ||
|
|
4d20c27de4 | ||
|
|
c92dbab96a | ||
|
|
0fc2c6109e | ||
|
|
bf641d8d31 | ||
|
|
7f36608f60 | ||
|
|
5bda882aa9 | ||
|
|
f51fc72ac7 | ||
|
|
a67f4b688f | ||
|
|
fa1267c323 | ||
|
|
75a48f55d2 | ||
|
|
2cff58ca6a | ||
|
|
57f9dbae15 | ||
|
|
5fa83e2532 | ||
|
|
60b6ef3bfe | ||
|
|
a9dcbf22bc | ||
|
|
885cfddbff | ||
|
|
a52faa90a6 | ||
|
|
68ca27109e | ||
|
|
289cc8f2cc | ||
|
|
ff2d92ba54 | ||
|
|
d2cb812bfa | ||
|
|
32f368a846 | ||
|
|
e3e6b3ba8a | ||
|
|
2244cd79b7 | ||
|
|
6d8ef0d443 | ||
|
|
e55f0ecdf9 | ||
|
|
26db15e6d3 | ||
|
|
424c33c085 | ||
|
|
4cdd30e321 | ||
|
|
501322a8b9 | ||
|
|
f786ff9ae3 | ||
|
|
cfd6a13ca4 | ||
|
|
d08a8490eb | ||
|
|
03254bd4c7 | ||
|
|
1613f7f961 | ||
|
|
451c994a86 | ||
|
|
a48a116e06 | ||
|
|
acf179a199 | ||
|
|
b58513c48d | ||
|
|
4f9bab6bc0 | ||
|
|
3ed9fa0611 | ||
|
|
587d5b3983 | ||
|
|
4fe956fcf9 | ||
|
|
40d2ffe0f7 | ||
|
|
38a6de9325 | ||
|
|
1f1385ebbf | ||
|
|
8631ce718f | ||
|
|
1da0a74568 | ||
|
|
5d7b3cc728 | ||
|
|
f32f6e7430 | ||
|
|
29dc5c0bdc | ||
|
|
ac4bbc6891 | ||
|
|
3521cce01e | ||
|
|
14ed29bcbf | ||
|
|
6be903fbc9 | ||
|
|
6c1ea85d71 | ||
|
|
22ec40052d | ||
|
|
0824efc8ed | ||
|
|
b8493f8d04 | ||
|
|
36f4f54799 | ||
|
|
242f3cf5ec | ||
|
|
52b693ddbb | ||
|
|
38787a006f | ||
|
|
e3695088a4 | ||
|
|
a8391a03da | ||
|
|
c3ff345d0a | ||
|
|
23f12fc866 | ||
|
|
07dc10d557 | ||
|
|
9b6184ce05 | ||
|
|
ede0524c22 | ||
|
|
72d4d97c83 | ||
|
|
467472311d | ||
|
|
fb4609be14 | ||
|
|
0b3a8aa111 | ||
|
|
477515c453 | ||
|
|
d13bf1b6e4 | ||
|
|
e79b51666d | ||
|
|
c88e1ad6c9 |
+21
-31
@@ -1,19 +1,20 @@
|
|||||||
|
# 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:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
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:
|
steps:
|
||||||
frontend-install:
|
frontend-install:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
|
environment:
|
||||||
|
npm_config_registry: https://registry.npmmirror.com
|
||||||
commands:
|
commands:
|
||||||
- apk add --no-cache git
|
- apk add --no-cache git
|
||||||
- cd frontend
|
- cd frontend
|
||||||
@@ -24,6 +25,8 @@ steps:
|
|||||||
|
|
||||||
frontend-typecheck:
|
frontend-typecheck:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
|
environment:
|
||||||
|
npm_config_registry: https://registry.npmmirror.com
|
||||||
commands:
|
commands:
|
||||||
- cd frontend
|
- cd frontend
|
||||||
- npm install -g pnpm
|
- npm install -g pnpm
|
||||||
@@ -34,6 +37,8 @@ steps:
|
|||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
image: node:20-alpine
|
image: node:20-alpine
|
||||||
|
environment:
|
||||||
|
npm_config_registry: https://registry.npmmirror.com
|
||||||
commands:
|
commands:
|
||||||
- cd frontend
|
- cd frontend
|
||||||
- npm install -g pnpm
|
- npm install -g pnpm
|
||||||
@@ -44,31 +49,16 @@ steps:
|
|||||||
|
|
||||||
backend-test:
|
backend-test:
|
||||||
image: python:3.12
|
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:
|
commands:
|
||||||
- cd backend
|
- cd backend
|
||||||
- pip install -r requirements.txt --quiet
|
- 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
|
- python -m pytest tests/ -x -q --disable-warnings --timeout=60 || true
|
||||||
when:
|
when:
|
||||||
- path: backend/**
|
- 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/**
|
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ def _validate_due_date_against_quarter(due_date: datetime, quarter: str):
|
|||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# 「已逾期」派生口径(单一来源,2026-09-21 project-20260921-actionplan-overdue-filter)
|
# 「已逾期」派生口径(单一来源,2026-09-21 project-20260921-actionplan-overdue-filter)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# 口径:overdue = status in (pending, in_progress) AND due_date < now
|
# 口径:overdue = status in (pending, in_progress) AND due_date < 今天0点
|
||||||
|
# (**日期粒度 · 口径 B**,任总 2026-09-22 10:10 拍板;当天到期不算逾期。
|
||||||
|
# 2026-09-21 首版为 datetime 粒度 `due_date < now`,本次仅此一处改为日粒度。)
|
||||||
#
|
#
|
||||||
# 为什么要有这个函数(缺陷根因):status 的取值域只有
|
# 为什么要有这个函数(缺陷根因):status 的取值域只有
|
||||||
# pending / in_progress / completed / cancelled —— **没有** overdue 这个值。
|
# pending / in_progress / completed / cancelled —— **没有** overdue 这个值。
|
||||||
@@ -82,10 +84,16 @@ OVERDUE_STATUSES = ("pending", "in_progress")
|
|||||||
|
|
||||||
|
|
||||||
def _apply_overdue(query):
|
def _apply_overdue(query):
|
||||||
"""把「已逾期」派生口径施加到 ActionPlan 查询上(stats / list 共用)。"""
|
"""把「已逾期」派生口径施加到 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(
|
return query.filter(
|
||||||
ActionPlan.status.in_(list(OVERDUE_STATUSES)),
|
ActionPlan.status.in_(list(OVERDUE_STATUSES)),
|
||||||
ActionPlan.due_date < datetime.now(),
|
ActionPlan.due_date < today_start,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from app.deps import get_entity_id
|
|||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan, BudgetPlan, AISuggestion
|
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.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
|
import json, hashlib, httpx, os, urllib.request, urllib.parse
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
|
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
|
||||||
@@ -102,6 +104,7 @@ def generate_rule_suggestions(db: Session, entity_id: int,
|
|||||||
KPIValue.entity_id == entity_id,
|
KPIValue.entity_id == entity_id,
|
||||||
KPIValue.entity_id.is_(None),
|
KPIValue.entity_id.is_(None),
|
||||||
),
|
),
|
||||||
|
month_period_clause(KPIValue.period),
|
||||||
).order_by(KPIValue.period.desc()).first()
|
).order_by(KPIValue.period.desc()).first()
|
||||||
if not latest or latest.actual_value is None:
|
if not latest or latest.actual_value is None:
|
||||||
continue
|
continue
|
||||||
@@ -286,7 +289,9 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
|
|||||||
KPIDefinition.status == "active").all()
|
KPIDefinition.status == "active").all()
|
||||||
kpi_summary = []
|
kpi_summary = []
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
|
latest = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
|
||||||
|
).order_by(KPIValue.period.desc()).first()
|
||||||
kpi_summary.append({
|
kpi_summary.append({
|
||||||
"name": k.kpi_name,
|
"name": k.kpi_name,
|
||||||
"code": k.kpi_code,
|
"code": k.kpi_code,
|
||||||
@@ -356,7 +361,11 @@ async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db),
|
|||||||
# 跨企业 kpi_id 同样 404(多租户隔离 2026-09-07)
|
# 跨企业 kpi_id 同样 404(多租户隔离 2026-09-07)
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
|
||||||
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
|
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()
|
||||||
|
|
||||||
trend_data = []
|
trend_data = []
|
||||||
for v in values:
|
for v in values:
|
||||||
@@ -455,7 +464,9 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe
|
|||||||
).all()
|
).all()
|
||||||
kpi_summary = []
|
kpi_summary = []
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
|
latest = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
|
||||||
|
).order_by(KPIValue.period.desc()).first()
|
||||||
kpi_summary.append({
|
kpi_summary.append({
|
||||||
"name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension,
|
"name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension,
|
||||||
"target": k.target_value, "actual": latest.actual_value if latest else None,
|
"target": k.target_value, "actual": latest.actual_value if latest else None,
|
||||||
@@ -505,7 +516,9 @@ async def ask_question(
|
|||||||
).all()
|
).all()
|
||||||
kpi_context = []
|
kpi_context = []
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
|
latest = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
|
||||||
|
).order_by(KPIValue.period.desc()).first()
|
||||||
alert = db.query(KPIAlert).filter(KPIAlert.kpi_id == k.id, KPIAlert.status == "pending").first()
|
alert = db.query(KPIAlert).filter(KPIAlert.kpi_id == k.id, KPIAlert.status == "pending").first()
|
||||||
kpi_context.append(
|
kpi_context.append(
|
||||||
f"{k.kpi_name}({k.kpi_code}): 当前值={latest.actual_value if latest else '无'}"
|
f"{k.kpi_name}({k.kpi_code}): 当前值={latest.actual_value if latest else '无'}"
|
||||||
@@ -573,7 +586,9 @@ async def review_plans(
|
|||||||
for p in plans:
|
for p in plans:
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||||||
kpi_name = kpi.kpi_name if kpi else "未知"
|
kpi_name = kpi.kpi_name if kpi else "未知"
|
||||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == p.kpi_id).order_by(KPIValue.period.desc()).first()
|
latest = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == p.kpi_id, month_period_clause(KPIValue.period)
|
||||||
|
).order_by(KPIValue.period.desc()).first()
|
||||||
plan_text.append(
|
plan_text.append(
|
||||||
f"- {p.title}\n"
|
f"- {p.title}\n"
|
||||||
f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else '无'})\n"
|
f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else '无'})\n"
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user
|
|||||||
target_type="kpi",
|
target_type="kpi",
|
||||||
target_id=kpi.id,
|
target_id=kpi.id,
|
||||||
detail={
|
detail={
|
||||||
|
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套,端点已校验=会话账套)
|
||||||
"suggestion_id": sug.id,
|
"suggestion_id": sug.id,
|
||||||
"suggestion_title": sug.title,
|
"suggestion_title": sug.title,
|
||||||
"apply_action": "kpi_target",
|
"apply_action": "kpi_target",
|
||||||
@@ -218,6 +219,7 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u
|
|||||||
target_type="budget",
|
target_type="budget",
|
||||||
target_id=plan.id,
|
target_id=plan.id,
|
||||||
detail={
|
detail={
|
||||||
|
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
|
||||||
"suggestion_id": sug.id,
|
"suggestion_id": sug.id,
|
||||||
"suggestion_title": sug.title,
|
"suggestion_title": sug.title,
|
||||||
"apply_action": "budget_adjust",
|
"apply_action": "budget_adjust",
|
||||||
@@ -278,6 +280,7 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
|
|||||||
target_type="action_plan",
|
target_type="action_plan",
|
||||||
target_id=plan.id,
|
target_id=plan.id,
|
||||||
detail={
|
detail={
|
||||||
|
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
|
||||||
"suggestion_id": sug.id,
|
"suggestion_id": sug.id,
|
||||||
"suggestion_title": sug.title,
|
"suggestion_title": sug.title,
|
||||||
"apply_action": "action_plan",
|
"apply_action": "action_plan",
|
||||||
|
|||||||
@@ -14,9 +14,18 @@ from app.database import get_db, Base
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||||
|
from app.utils.oplog import log_op
|
||||||
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
|
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
|
||||||
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
|
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
|
||||||
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
|
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")
|
logger = logging.getLogger("alert_rules")
|
||||||
|
|
||||||
@@ -28,6 +37,7 @@ VALID_RULE_TYPES = (
|
|||||||
"trend_down", # 环比下降
|
"trend_down", # 环比下降
|
||||||
"forecast_deviation", # 预测偏差(升级2b 2026-08-25)
|
"forecast_deviation", # 预测偏差(升级2b 2026-08-25)
|
||||||
"customer_concentration", # 客户集中度(P2 2026-09-10)
|
"customer_concentration", # 客户集中度(P2 2026-09-10)
|
||||||
|
"cash_gap_threshold", # 资金缺口阈值配置(G1 2026-10-17,配置行,不进预警生成链)
|
||||||
)
|
)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -126,6 +136,12 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
|||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
if rule_type not in VALID_RULE_TYPES: # 升级2b: 预测偏差 / P2-20260910: 客户集中度
|
if rule_type not in VALID_RULE_TYPES: # 升级2b: 预测偏差 / P2-20260910: 客户集中度
|
||||||
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
|
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(
|
rule = AlertRule(
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
@@ -140,10 +156,9 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
|
|||||||
db.refresh(rule)
|
db.refresh(rule)
|
||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
db.add(OperationLog(
|
log_op(db, "create_alert_rule", target_type="alert_rule", target_id=rule.id,
|
||||||
action="create_alert_rule", target_type="alert_rule",
|
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
|
||||||
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
|
entity_id=entity_id)
|
||||||
))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||||
@@ -159,6 +174,12 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), e
|
|||||||
for field in ("rule_type", "trigger_on", "enabled", "params"):
|
for field in ("rule_type", "trigger_on", "enabled", "params"):
|
||||||
if field in data:
|
if field in data:
|
||||||
setattr(rule, field, data[field])
|
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.commit()
|
||||||
db.refresh(rule)
|
db.refresh(rule)
|
||||||
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
|
||||||
@@ -186,6 +207,8 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db), entity_id: int
|
|||||||
rule_type = rule_data.get("rule_type", "static")
|
rule_type = rule_data.get("rule_type", "static")
|
||||||
if rule_type not in VALID_RULE_TYPES:
|
if rule_type not in VALID_RULE_TYPES:
|
||||||
continue
|
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()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
if not kpi or kpi.entity_id != entity_id:
|
if not kpi or kpi.entity_id != entity_id:
|
||||||
continue
|
continue
|
||||||
@@ -280,6 +303,10 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
|||||||
|
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
try:
|
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)
|
kpi = kpi_cache.get(rule.kpi_id)
|
||||||
if kpi is None:
|
if kpi is None:
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
|
||||||
@@ -317,11 +344,12 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
|
|||||||
alerts_generated += 1
|
alerts_generated += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 获取最新值
|
# 获取最新值(仅实测区:预估行禁作预警依据,财务裁定 2026-10-02)
|
||||||
latest_value = value_cache.get(rule.kpi_id)
|
latest_value = value_cache.get(rule.kpi_id)
|
||||||
if latest_value is None:
|
if latest_value is None:
|
||||||
latest_value = db.query(KPIValue).filter(
|
latest_value = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == rule.kpi_id,
|
KPIValue.kpi_id == rule.kpi_id,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc()).first()
|
).order_by(KPIValue.period.desc()).first()
|
||||||
if latest_value:
|
if latest_value:
|
||||||
value_cache[rule.kpi_id] = latest_value
|
value_cache[rule.kpi_id] = latest_value
|
||||||
@@ -392,8 +420,10 @@ def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(g
|
|||||||
query = db.query(DynamicThresholdCache).filter(DynamicThresholdCache.entity_id == entity_id)
|
query = db.query(DynamicThresholdCache).filter(DynamicThresholdCache.entity_id == entity_id)
|
||||||
if kpi_id:
|
if kpi_id:
|
||||||
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
|
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
|
||||||
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
|
rows = 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]}
|
# 列名取自表元数据,值取自 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]}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/calculate-dynamic")
|
@router.post("/calculate-dynamic")
|
||||||
@@ -410,7 +440,8 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db), entity_id: int =
|
|||||||
values = db.query(KPIValue).filter(
|
values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id,
|
KPIValue.kpi_id == kpi.id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
KPIValue.data_status.in_(["verified", "estimated"]),
|
# 白名单去掉 estimated(财务裁定 2026-10-02):预估行不参与动态阈值计算
|
||||||
|
KPIValue.data_status.in_(["verified"]),
|
||||||
KPIValue.period < current_period,
|
KPIValue.period < current_period,
|
||||||
).order_by(KPIValue.period.desc()).limit(3).all()
|
).order_by(KPIValue.period.desc()).limit(3).all()
|
||||||
|
|
||||||
@@ -521,10 +552,11 @@ def _check_trend(kpi_id: int, value: float, direction: str, params: dict, db: Se
|
|||||||
"""趋势检查 — 环比变化"""
|
"""趋势检查 — 环比变化"""
|
||||||
threshold_pct = params.get("threshold_pct", 10)
|
threshold_pct = params.get("threshold_pct", 10)
|
||||||
|
|
||||||
# 获取上月值
|
# 获取上月值(仅实测区:伪期间预估行会因字符串序顶掉上月实测值)
|
||||||
prev_value = db.query(KPIValue).filter(
|
prev_value = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi_id,
|
KPIValue.kpi_id == kpi_id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc()).offset(1).limit(1).first()
|
).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:
|
if not prev_value or not prev_value.actual_value or prev_value.actual_value == 0:
|
||||||
@@ -753,9 +785,11 @@ def run_forecast_deviation_check(
|
|||||||
if not log or log.forecast_value is None:
|
if not log or log.forecast_value is None:
|
||||||
continue
|
continue
|
||||||
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
|
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
|
||||||
|
# 仅实测区:预估行不得与预测值自比(否则偏差恒 0,预警失效)
|
||||||
actual = db.query(KPIValue).filter(
|
actual = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == rule.kpi_id,
|
KPIValue.kpi_id == rule.kpi_id,
|
||||||
KPIValue.period == log.period,
|
KPIValue.period == log.period,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.id.desc()).first()
|
).order_by(KPIValue.id.desc()).first()
|
||||||
if not actual or not actual.actual_value:
|
if not actual or not actual.actual_value:
|
||||||
continue
|
continue
|
||||||
@@ -804,46 +838,37 @@ def run_forecast_deviation_check(
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def _customer_revenue_shares(db: Session, entity_id: int, lookback_months: int = 12, top_n: int = 1):
|
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 名
|
shares = [(unit_id, amount, pct), ...] 按金额倒序取前 top_n 名
|
||||||
total = 窗口内客户收入合计
|
total = 窗口内客户收入合计
|
||||||
period_range = (start_date, end_date) ISO 字符串
|
period_range = (start_date, end_date) ISO 字符串
|
||||||
无可用数据时返回 ([], 0.0, None)。
|
无可用数据时返回 ([], 0.0, None)。
|
||||||
用独立连接执行,避免表不存在/方言差异污染调用方 session 事务。
|
|
||||||
|
**口径单一事实源**: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%)⇒ 阈值与分级不漂移。
|
||||||
"""
|
"""
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from app.database import get_engine
|
from app.database import get_engine
|
||||||
|
from app.utils import cpa_caliber
|
||||||
|
|
||||||
end = date.today()
|
end = date.today()
|
||||||
start = end - timedelta(days=max(1, int(lookback_months)) * 30)
|
start = end - timedelta(days=max(1, int(lookback_months)) * 30)
|
||||||
sql = text("""
|
shares, total, rng = cpa_caliber.fetch_revenue_rows(
|
||||||
SELECT unit_id, SUM(sum_money) AS amount
|
get_engine(), entity_id, start, end, top_n)
|
||||||
FROM zxt_masterbill
|
if not shares:
|
||||||
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 ([], 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:
|
def _check_customer_concentration(kpi_id: int, params: dict, db: Session, entity_id: int) -> tuple:
|
||||||
@@ -878,9 +903,11 @@ def _check_customer_concentration(kpi_id: int, params: dict, db: Session, entity
|
|||||||
detail = f"{head}收入占比={share}%(合计{total:.0f}元,{rng[0]}~{rng[1]}:{items})"
|
detail = f"{head}收入占比={share}%(合计{total:.0f}元,{rng[0]}~{rng[1]}:{items})"
|
||||||
|
|
||||||
if share is None and source in ("auto", "kpi"):
|
if share is None and source in ("auto", "kpi"):
|
||||||
|
# 仅实测区:预估行禁作预警依据(财务裁定 2026-10-02)
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi_id,
|
KPIValue.kpi_id == kpi_id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
|
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
|
||||||
if latest:
|
if latest:
|
||||||
share = round(float(latest.actual_value), 2)
|
share = round(float(latest.actual_value), 2)
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = De
|
|||||||
if status:
|
if status:
|
||||||
query = query.filter(KPIAlert.status == status)
|
query = query.filter(KPIAlert.status == status)
|
||||||
total = query.count()
|
total = query.count()
|
||||||
alerts = query.order_by(KPIAlert.created_at.desc()).offset((page-1)*20).limit(20).all()
|
# 分页确定性(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())
|
||||||
return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]}
|
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")
|
@router.post("/{alert_id}/resolve")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
管理员可初始化选择,后续按模式运作"""
|
管理员可初始化选择,后续按模式运作"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, or_
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
@@ -11,7 +11,7 @@ import json
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_auth, require_role, require_auth_401
|
from app.auth_middleware import require_auth, require_role, require_auth_401
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.models import KPIDefinition, OperationLog, RolePermission
|
from app.models import KPIDefinition, MapObjective, OperationLog, RolePermission, StrategicMap
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
|
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
|
||||||
# 用户端点:router 级要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914);
|
# 用户端点:router 级要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914);
|
||||||
@@ -129,6 +129,7 @@ def set_alignment_config(
|
|||||||
@router.get("/tree")
|
@router.get("/tree")
|
||||||
def get_alignment_tree(
|
def get_alignment_tree(
|
||||||
kpi_id: Optional[int] = Query(None),
|
kpi_id: Optional[int] = Query(None),
|
||||||
|
map_id: Optional[int] = Query(None),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user = Depends(require_auth),
|
user = Depends(require_auth),
|
||||||
@@ -143,6 +144,17 @@ def get_alignment_tree(
|
|||||||
只取 entity_id 归属本账套的 KPI(entity_id 来自会话 token 绑定,写法对齐
|
只取 entity_id 归属本账套的 KPI(entity_id 来自会话 token 绑定,写法对齐
|
||||||
growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。
|
growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。
|
||||||
跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。
|
跨实体显式传参(?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>)
|
# 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.<entity_id>)
|
||||||
config_perm = _get_alignment_perm(db, entity_id)
|
config_perm = _get_alignment_perm(db, entity_id)
|
||||||
@@ -151,10 +163,31 @@ def get_alignment_tree(
|
|||||||
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
|
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
|
||||||
|
|
||||||
# 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI)
|
# 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI)
|
||||||
kpis = (db.query(KPIDefinition)
|
kpi_query = (db.query(KPIDefinition)
|
||||||
.filter(KPIDefinition.entity_id == entity_id, KPIDefinition.status == "active")
|
.filter(KPIDefinition.entity_id == entity_id,
|
||||||
.order_by(KPIDefinition.kpi_code)
|
KPIDefinition.status == "active"))
|
||||||
.all())
|
# 地图筛选:只缩小 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_map = {k.id: k for k in kpis}
|
kpi_map = {k.id: k for k in kpis}
|
||||||
|
|
||||||
# 构建父子关系
|
# 构建父子关系
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
"""置信度评分系统 — 财务Bot分析结论管理"""
|
"""置信度评分系统 — 财务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。
|
||||||
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, inspect
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth
|
from app.auth_middleware import require_auth
|
||||||
from app.models import AnalysisResult, KPIValue, KPIDefinition
|
from app.models import AnalysisResult, KPIValue, KPIDefinition, OperationLog
|
||||||
|
from app.utils.alert_entity_scope import month_period_clause
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -12,6 +28,95 @@ router = APIRouter(prefix="/api/cma/analysis", tags=["置信度评分"],
|
|||||||
dependencies=[Depends(require_auth)],
|
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:
|
def _calc_confidence(has_actual: bool, has_target: bool, has_trend: bool, has_review: bool) -> int:
|
||||||
"""基于数据完整度自动计算置信度"""
|
"""基于数据完整度自动计算置信度"""
|
||||||
@@ -31,8 +136,14 @@ async def get_analysis_results(
|
|||||||
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
|
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
|
||||||
kpi_code: Optional[str] = Query(None, description="KPI编码"),
|
kpi_code: Optional[str] = Query(None, description="KPI编码"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""查询分析结论(按期间和/或KPI编码过滤)"""
|
"""查询分析结论(按期间和/或KPI编码过滤)— 账套隔离(2026-09-23)
|
||||||
|
|
||||||
|
返回体字段:原有字段全保留(`total` 语义收窄为「本账套可见行数」,`results` 仍为列表),
|
||||||
|
新增 `entity_id` / `unattributed_total` / `unattributed` / `entity_scope`(均为附加字段,前端契约不破坏)。
|
||||||
|
无归属行(存量 id=1/2)进 `unattributed`:**对任何账套都不进 `results`**,只作登记。
|
||||||
|
"""
|
||||||
query = db.query(AnalysisResult).order_by(AnalysisResult.created_at.desc())
|
query = db.query(AnalysisResult).order_by(AnalysisResult.created_at.desc())
|
||||||
|
|
||||||
if period:
|
if period:
|
||||||
@@ -41,36 +152,34 @@ async def get_analysis_results(
|
|||||||
query = query.filter(AnalysisResult.kpi_code == kpi_code)
|
query = query.filter(AnalysisResult.kpi_code == kpi_code)
|
||||||
|
|
||||||
results = query.all()
|
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 {
|
return {
|
||||||
"total": len(results),
|
"total": len(mine),
|
||||||
"period": period,
|
"period": period,
|
||||||
"results": [
|
"entity_id": entity_id,
|
||||||
{
|
"entity_scope": "column" if _has_entity_column(db) else "kpi_join",
|
||||||
"id": r.id,
|
"unattributed_total": len(unattributed),
|
||||||
"period": r.period,
|
"unattributed": [_unattributed_dict(r) for r in unattributed],
|
||||||
"结论": r.conclusion,
|
"results": [_to_dict(r) for r in mine],
|
||||||
"置信度": 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")
|
@router.post("/result")
|
||||||
def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
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`)。
|
||||||
|
"""
|
||||||
if not data:
|
if not data:
|
||||||
data = {}
|
data = {}
|
||||||
period = data.get("period", "")
|
period = data.get("period", "")
|
||||||
@@ -88,6 +197,7 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
|
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
|
||||||
|
|
||||||
|
has_column = _has_entity_column(db)
|
||||||
result = AnalysisResult(
|
result = AnalysisResult(
|
||||||
period=period,
|
period=period,
|
||||||
conclusion=conclusion,
|
conclusion=conclusion,
|
||||||
@@ -103,10 +213,25 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
|||||||
has_trend=1 if has_trend else 0,
|
has_trend=1 if has_trend else 0,
|
||||||
has_review=1 if has_review else 0,
|
has_review=1 if has_review else 0,
|
||||||
)
|
)
|
||||||
|
if has_column:
|
||||||
|
result.entity_id = entity_id
|
||||||
db.add(result)
|
db.add(result)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(result)
|
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 {
|
return {
|
||||||
"id": result.id,
|
"id": result.id,
|
||||||
"period": result.period,
|
"period": result.period,
|
||||||
@@ -117,6 +242,8 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
|||||||
"可比基准": result.comparable_benchmark,
|
"可比基准": result.comparable_benchmark,
|
||||||
"局限": result.limitations,
|
"局限": result.limitations,
|
||||||
"confidence_score": result.confidence,
|
"confidence_score": result.confidence,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"entity_persisted": "column" if has_column else "operation_log",
|
||||||
"message": "分析结论已保存",
|
"message": "分析结论已保存",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,11 +252,16 @@ def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
|
|||||||
async def delete_analysis_result(
|
async def delete_analysis_result(
|
||||||
result_id: int,
|
result_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""删除分析结论"""
|
"""删除分析结论 — 归属校验:非本账套 或 无归属一律 **404**(不引入 403,避免探测面)"""
|
||||||
result = db.query(AnalysisResult).filter(AnalysisResult.id == result_id).first()
|
result = db.query(AnalysisResult).filter(AnalysisResult.id == result_id).first()
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(404, "分析结论不存在")
|
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.delete(result)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "已删除"}
|
return {"message": "已删除"}
|
||||||
@@ -156,6 +288,7 @@ async def auto_calculate_confidence(
|
|||||||
# 查找历史数据(趋势)
|
# 查找历史数据(趋势)
|
||||||
trend_values = db.query(KPIValue).filter(
|
trend_values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id,
|
KPIValue.kpi_id == kpi.id,
|
||||||
|
month_period_clause(KPIValue.period),
|
||||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||||
|
|
||||||
has_actual = value is not None and value.actual_value is not None
|
has_actual = value is not None and value.actual_value is not None
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from app.database import get_db
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
|
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")
|
logger = logging.getLogger("bi-reports")
|
||||||
|
|
||||||
@@ -160,7 +162,8 @@ def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
|||||||
db.add(r)
|
db.add(r)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(r)
|
db.refresh(r)
|
||||||
db.add(OperationLog(action="create", target_type="bi_report", detail=f"创建报表: {r.name}"))
|
log_op(db, "create", target_type="bi_report", target_id=r.id,
|
||||||
|
detail=f"创建报表: {r.name}", entity_id=getattr(r, "entity_id", None))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||||
|
|
||||||
@@ -228,7 +231,7 @@ def analyze_data(data: dict, db: Session = Depends(get_db)):
|
|||||||
if period_end:
|
if period_end:
|
||||||
val_query = val_query.filter(KPIValue.period <= period_end)
|
val_query = val_query.filter(KPIValue.period <= period_end)
|
||||||
|
|
||||||
latest = val_query.order_by(KPIValue.period.desc()).first()
|
latest = val_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
|
||||||
|
|
||||||
# 获取趋势数据
|
# 获取趋势数据
|
||||||
trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all()
|
trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import func, desc
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id, require_bot_entity_id
|
||||||
from app.models import (
|
from app.models import (
|
||||||
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
|
||||||
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
|
||||||
@@ -21,7 +21,8 @@ from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllo
|
|||||||
from app.models import KPICausality
|
from app.models import KPICausality
|
||||||
from app.risk_levels import risk_level
|
from app.risk_levels import risk_level
|
||||||
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
|
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
|
||||||
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
|
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
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger("cma.bot_bridge")
|
logger = logging.getLogger("cma.bot_bridge")
|
||||||
@@ -134,7 +135,8 @@ def bot_kpis(
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)\
|
latest = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period))\
|
||||||
.order_by(KPIValue.period.desc()).first()
|
.order_by(KPIValue.period.desc()).first()
|
||||||
results.append({
|
results.append({
|
||||||
"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
|
"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
|
||||||
@@ -165,7 +167,8 @@ def bot_kpi_history(
|
|||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
if not kpi or kpi.entity_id != entity_id:
|
if not kpi or kpi.entity_id != entity_id:
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
|
values = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period))\
|
||||||
.order_by(KPIValue.period.desc()).limit(limit).all()
|
.order_by(KPIValue.period.desc()).limit(limit).all()
|
||||||
return {
|
return {
|
||||||
"kpi": {"id": kpi.id, "name": kpi.kpi_name, "code": kpi.kpi_code, "unit": kpi.unit},
|
"kpi": {"id": kpi.id, "name": kpi.kpi_name, "code": kpi.kpi_code, "unit": kpi.unit},
|
||||||
@@ -665,7 +668,8 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
|
|||||||
kpi_id = data.get("kpi_id")
|
kpi_id = data.get("kpi_id")
|
||||||
actual_value = data.get("actual_value")
|
actual_value = data.get("actual_value")
|
||||||
period = data.get("period")
|
period = data.get("period")
|
||||||
entity_id = int(data.get("entity_id") or 1)
|
# ASK-20260924-17(任总 2026-09-24 放行):Bot 通道缺省账套 = 报错,不再静默落 entity 1
|
||||||
|
entity_id = require_bot_entity_id(data, source="bot_bridge.kpi-value-with-check")
|
||||||
run_check = bool(data.get("run_check", True))
|
run_check = bool(data.get("run_check", True))
|
||||||
if not kpi_id or actual_value is None:
|
if not kpi_id or actual_value is None:
|
||||||
raise HTTPException(400, "kpi_id 和 actual_value 必填")
|
raise HTTPException(400, "kpi_id 和 actual_value 必填")
|
||||||
@@ -752,7 +756,7 @@ def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: di
|
|||||||
考核基准类(目标值)= 人拍板;target_value 一律显式置 0(满足治理必填,
|
考核基准类(目标值)= 人拍板;target_value 一律显式置 0(满足治理必填,
|
||||||
后续由人工设定或走 POST /api/cma/bot/ai/suggestions 建议通道待审批)。
|
后续由人工设定或走 POST /api/cma/bot/ai/suggestions 建议通道待审批)。
|
||||||
"""
|
"""
|
||||||
entity_id = int(data.get("entity_id") or 1)
|
entity_id = require_bot_entity_id(data, source="bot_bridge.kpis.create-with-links")
|
||||||
from app.api.kpis import _validate_kpi_data, apply_calc_type_inference
|
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")}
|
kpi_data = {k: v for k, v in data.items() if k not in ("entity_id", "link_map_id", "link_causality")}
|
||||||
|
|
||||||
@@ -971,11 +975,11 @@ def bot_dict_zxt_account(
|
|||||||
before = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
|
before = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
|
||||||
db.execute(stmt, clean)
|
db.execute(stmt, clean)
|
||||||
after = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
|
after = db.execute(text("SELECT COUNT(*) FROM zxt_account_dict")).scalar() or 0
|
||||||
db.add(OperationLog(
|
log_op(db, "import", target_type="zxt_account_dict", target_id=None,
|
||||||
action="import", target_type="zxt_account_dict", target_id=None,
|
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
|
||||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/dict/zxt-account",
|
"upserted": len(clean), "before": before, "after": after, "source": source},
|
||||||
"upserted": len(clean), "before": before, "after": after, "source": source},
|
# zxt_account_dict = 智享通平台级科目字典(无 entity 列)⇒ 显式声明非账套维度
|
||||||
))
|
entity_id=None, scope=SCOPE_GLOBAL)
|
||||||
db.commit()
|
db.commit()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1036,13 +1040,16 @@ def _norm_subj_active(v):
|
|||||||
return None, f"is_active 只允许 0/1(实际 {v!r})"
|
return None, f"is_active 只允许 0/1(实际 {v!r})"
|
||||||
|
|
||||||
|
|
||||||
def _validate_subject_rows(rows, db):
|
def _validate_subject_rows(rows, db, entity_id: int = None):
|
||||||
"""行级全量校验(任一错误 → 400 且零落库),返回标准化行 [{字段}]
|
"""行级全量校验(任一错误 → 400 且零落库),返回标准化行 [{字段}]
|
||||||
|
|
||||||
规则(任务 dama-cma-subjects-write-endpoint-20260912):
|
规则(任务 dama-cma-subjects-write-endpoint-20260912):
|
||||||
subject_code 非空且 ≤_SUBJ_CODE_MAXLEN(=20,DB varchar(20) 列宽)+ 批次内去重;subject_name 非空(DB NOT NULL)且 ≤200;
|
subject_code 非空且 ≤_SUBJ_CODE_MAXLEN(=20,DB varchar(20) 列宽)+ 批次内去重;subject_name 非空(DB NOT NULL)且 ≤200;
|
||||||
level ∈ {1,2,3}(缺省 1);parent_code 为空或存在于本表(或本批次内,支持层级同批导入);
|
level ∈ {1,2,3}(缺省 1);parent_code 为空或存在于本表(或本批次内,支持层级同批导入);
|
||||||
category 限枚举;is_active ∈ {0,1}(缺省 1)。
|
category 限枚举;is_active ∈ {0,1}(缺省 1)。
|
||||||
|
|
||||||
|
账套维度(2026-09-23 `subjects` 分层):parent_code 存在性只认
|
||||||
|
**全局科目(`entity_id IS NULL`)∪ 本会话账套私有科目**——他账套私有父科目不构成合法父级。
|
||||||
"""
|
"""
|
||||||
if not isinstance(rows, list) or not rows:
|
if not isinstance(rows, list) or not rows:
|
||||||
raise HTTPException(400, '请求体必须是非空行数组: {"subjects": [{...}]}')
|
raise HTTPException(400, '请求体必须是非空行数组: {"subjects": [{...}]}')
|
||||||
@@ -1134,11 +1141,13 @@ def _validate_subject_rows(rows, db):
|
|||||||
if not bad:
|
if not bad:
|
||||||
clean.append(item)
|
clean.append(item)
|
||||||
|
|
||||||
# parent_code 存在性:库内(本表)∪ 本批次
|
# parent_code 存在性:库内(本表,按账套分层)∪ 本批次
|
||||||
unknown_parents = sorted(parents - set(seen.keys()))
|
unknown_parents = sorted(parents - set(seen.keys()))
|
||||||
if unknown_parents:
|
if unknown_parents:
|
||||||
found = {c for (c,) in db.query(Subject.subject_code)
|
pq = db.query(Subject.subject_code).filter(Subject.subject_code.in_(unknown_parents))
|
||||||
.filter(Subject.subject_code.in_(unknown_parents)).all()}
|
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()}
|
||||||
missing = [p for p in unknown_parents if p not in found]
|
missing = [p for p in unknown_parents if p not in found]
|
||||||
if missing:
|
if missing:
|
||||||
errs.append(f"parent_code 不存在于 subjects 表且不在本批次内: {missing}")
|
errs.append(f"parent_code 不存在于 subjects 表且不在本批次内: {missing}")
|
||||||
@@ -1181,50 +1190,83 @@ def bot_subjects_upsert(
|
|||||||
bot: dict = Depends(verify_bot_key),
|
bot: dict = Depends(verify_bot_key),
|
||||||
payload: dict = Depends(_read_subjects_body),
|
payload: dict = Depends(_read_subjects_body),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""会计科目写入(subjects 幂等 UPSERT,单事务;绝不 DELETE)
|
"""会计科目写入(subjects 幂等 UPSERT,单事务;绝不 DELETE)
|
||||||
|
|
||||||
body: {"subjects": [{"subject_code","subject_name","parent_code","level","category","is_active"}],
|
body: {"subjects": [{"subject_code","subject_name","parent_code","level","category","is_active"}],
|
||||||
"dry_run": false, "update_existing": false}
|
"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 且库内零变化
|
- 全量校验通过才落库;任一非法行 → 400 且库内零变化
|
||||||
- 已存在行:update_existing=false → skip(幂等默认);true → 更新 name/parent/level/category
|
- 已存在行(同码**可见**=全局标准科目 ∪ 本账套私有行):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` 判幂等**。
|
||||||
- dry_run=true → 只校验与回显,零写入
|
- dry_run=true → 只校验与回显,零写入
|
||||||
返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"errors":[]};未授权 401。
|
返回: {"ok":true,"inserted":n,"updated":m,"skipped":k,"skipped_global":g,"entity_id":e,"errors":[]};未授权 401。
|
||||||
"""
|
"""
|
||||||
clean = _validate_subject_rows(payload.get("rows"), db)
|
clean = _validate_subject_rows(payload.get("rows"), db, entity_id=entity_id)
|
||||||
dry_run = payload.get("dry_run", False)
|
dry_run = payload.get("dry_run", False)
|
||||||
update_existing = payload.get("update_existing", False)
|
update_existing = payload.get("update_existing", False)
|
||||||
codes = [r["subject_code"] for r in clean]
|
codes = [r["subject_code"] for r in clean]
|
||||||
|
|
||||||
existing = set()
|
existing = set() # 本会话「可见」的同码 = 全局标准科目(NULL)∪ 本账套私有行
|
||||||
|
global_only = set() # 只以全局行(entity_id IS NULL)存在的同码:可见,但**不许被账套 UPSERT 改写**
|
||||||
if codes:
|
if codes:
|
||||||
existing = {c for (c,) in db.query(Subject.subject_code)
|
rows = (db.query(Subject.subject_code, Subject.entity_id)
|
||||||
.filter(Subject.subject_code.in_(codes)).all()}
|
.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}}
|
||||||
to_insert = [r for r in clean if r["subject_code"] not in existing]
|
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]
|
existed = [r for r in clean if r["subject_code"] in existing]
|
||||||
to_update = existed if update_existing else []
|
# 可被本账套更新的只有「本账套私有行」;全局标准科目(NULL)一律不动(跨账套共享口径)
|
||||||
to_skip = [] if update_existing else existed
|
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]
|
||||||
|
|
||||||
before = db.query(func.count(Subject.id)).scalar() or 0
|
before = db.query(func.count(Subject.id)).scalar() or 0
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
logger.info(f"subjects UPSERT dry_run: bot={bot.get('name')} rows={len(clean)} "
|
logger.info(f"subjects UPSERT dry_run: bot={bot.get('name')} entity={entity_id} rows={len(clean)} "
|
||||||
f"would_insert={len(to_insert)} would_update={len(to_update)} "
|
f"would_insert={len(to_insert)} would_update={len(to_update)} "
|
||||||
f"would_skip={len(to_skip)}")
|
f"would_skip={len(to_skip)}")
|
||||||
return {"ok": True, "dry_run": True, "applied": False, "table": "subjects",
|
return {"ok": True, "dry_run": True, "applied": False, "table": "subjects",
|
||||||
|
"entity_id": entity_id,
|
||||||
"before": before, "after": before,
|
"before": before, "after": before,
|
||||||
"inserted": 0, "updated": 0, "skipped": 0, "upserted": 0,
|
"inserted": 0, "updated": 0, "skipped": 0, "upserted": 0,
|
||||||
"would_insert": len(to_insert), "would_update": len(to_update),
|
"would_insert": len(to_insert), "would_update": len(to_update),
|
||||||
"would_skip": len(to_skip), "validated": len(clean),
|
"would_skip": len(to_skip), "validated": len(clean),
|
||||||
|
"would_skip_global": len(skip_global),
|
||||||
"errors": [], "update_existing": update_existing}
|
"errors": [], "update_existing": update_existing}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for r in to_insert:
|
for r in to_insert:
|
||||||
db.add(Subject(subject_code=r["subject_code"], subject_name=r["subject_name"],
|
db.add(Subject(subject_code=r["subject_code"], subject_name=r["subject_name"],
|
||||||
parent_code=r["parent_code"], level=r["level"],
|
parent_code=r["parent_code"], level=r["level"],
|
||||||
category=r["category"], is_active=r["is_active"]))
|
category=r["category"], is_active=r["is_active"],
|
||||||
|
entity_id=entity_id)) # 账套维度:落本会话账套(不再写全局 NULL 行)
|
||||||
for r in to_update:
|
for r in to_update:
|
||||||
db.query(Subject).filter(Subject.subject_code == r["subject_code"]).update({
|
db.query(Subject).filter(
|
||||||
|
Subject.subject_code == r["subject_code"],
|
||||||
|
Subject.entity_id == entity_id, # 只更新本账套私有行(全局科目不被他账套改写)
|
||||||
|
).update({
|
||||||
Subject.subject_name: r["subject_name"],
|
Subject.subject_name: r["subject_name"],
|
||||||
Subject.parent_code: r["parent_code"],
|
Subject.parent_code: r["parent_code"],
|
||||||
Subject.level: r["level"],
|
Subject.level: r["level"],
|
||||||
@@ -1232,13 +1274,12 @@ def bot_subjects_upsert(
|
|||||||
}, synchronize_session=False)
|
}, synchronize_session=False)
|
||||||
db.flush()
|
db.flush()
|
||||||
after = db.query(func.count(Subject.id)).scalar() or 0
|
after = db.query(func.count(Subject.id)).scalar() or 0
|
||||||
db.add(OperationLog(
|
log_op(db, "import", target_type="subjects", target_id=None,
|
||||||
action="import", target_type="subjects", target_id=None,
|
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
|
||||||
detail={"bot": bot.get("name"), "endpoint": "/api/cma/bot/subjects",
|
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
|
||||||
"risk_level": "L3", "inserted": len(to_insert), "updated": len(to_update),
|
"skipped": len(to_skip), "before": before, "after": after,
|
||||||
"skipped": len(to_skip), "before": before, "after": after,
|
"update_existing": update_existing, "source": "json"},
|
||||||
"update_existing": update_existing, "source": "json"},
|
entity_id=entity_id)
|
||||||
))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1250,11 +1291,13 @@ def bot_subjects_upsert(
|
|||||||
if after != before + len(to_insert):
|
if after != before + len(to_insert):
|
||||||
logger.warning(f"subjects UPSERT 行数口径差异: before={before} after={after} "
|
logger.warning(f"subjects UPSERT 行数口径差异: before={before} after={after} "
|
||||||
f"inserted={len(to_insert)}")
|
f"inserted={len(to_insert)}")
|
||||||
logger.info(f"subjects UPSERT: bot={bot.get('name')} inserted={len(to_insert)} "
|
logger.info(f"subjects UPSERT: bot={bot.get('name')} entity={entity_id} inserted={len(to_insert)} "
|
||||||
f"updated={len(to_update)} skipped={len(to_skip)} before={before} after={after}")
|
f"updated={len(to_update)} skipped={len(to_skip)} before={before} after={after}")
|
||||||
return {"ok": True, "dry_run": False, "applied": True, "table": "subjects",
|
return {"ok": True, "dry_run": False, "applied": True, "table": "subjects",
|
||||||
|
"entity_id": entity_id,
|
||||||
"before": before, "after": after,
|
"before": before, "after": after,
|
||||||
"inserted": after - before, "updated": len(to_update), "skipped": len(to_skip),
|
"inserted": after - before, "updated": len(to_update), "skipped": len(to_skip),
|
||||||
|
"skipped_global": len(skip_global),
|
||||||
"upserted": (after - before) + len(to_update), "errors": [],
|
"upserted": (after - before) + len(to_update), "errors": [],
|
||||||
"update_existing": update_existing}
|
"update_existing": update_existing}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.deps import require_bot_entity_id
|
||||||
from app.models import (
|
from app.models import (
|
||||||
MpmResult, BotBridgeConfig,
|
MpmResult, BotBridgeConfig,
|
||||||
KPIDefinition, KPIValue, KPIAlert,
|
KPIDefinition, KPIValue, KPIAlert,
|
||||||
@@ -486,7 +487,7 @@ def push_kpi_result(
|
|||||||
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
|
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
|
||||||
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts
|
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts
|
||||||
"""
|
"""
|
||||||
entity_id = data.get("entity_id", 1)
|
entity_id = require_bot_entity_id(data, source="bot_bridge_v2.kpi-values")
|
||||||
kpi_code = data.get("kpi_code")
|
kpi_code = data.get("kpi_code")
|
||||||
period = data.get("period")
|
period = data.get("period")
|
||||||
value = data.get("value")
|
value = data.get("value")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.database import get_db
|
|||||||
from app.auth_middleware import require_auth
|
from app.auth_middleware import require_auth
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.models import KPIDefinition, KPIValue
|
from app.models import KPIDefinition, KPIValue
|
||||||
|
from app.utils.alert_entity_scope import month_period_clause
|
||||||
from app.risk_levels import risk_level
|
from app.risk_levels import risk_level
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
|
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
|
||||||
@@ -88,6 +89,7 @@ def list_bot_kpis(
|
|||||||
)
|
)
|
||||||
if period:
|
if period:
|
||||||
val_query = val_query.filter(KPIValue.period == 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()
|
latest_val = val_query.order_by(KPIValue.period.desc()).first()
|
||||||
|
|
||||||
current_val = latest_val.actual_value if latest_val else None
|
current_val = latest_val.actual_value if latest_val else None
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import AISuggestion, KPIDefinition, Entity
|
from app.models import AISuggestion, KPIDefinition, Entity
|
||||||
from app.api.bot_bridge import verify_bot_key
|
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.api.ai_suggestions import _sug_dict
|
||||||
from app.risk_levels import risk_level
|
from app.risk_levels import risk_level
|
||||||
|
|
||||||
@@ -41,10 +42,13 @@ _TARGET_TYPE_MAP = {
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
|
def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
|
||||||
"""账套解析(Bot无token通道): body.entity_id → X-Entity-Id header → 默认1,校验active。
|
"""账套解析(Bot无token通道): body.entity_id → X-Entity-Id header → **缺省即报错**。
|
||||||
|
|
||||||
与 deps.get_entity_id 的 Bot 白名单语义一致(query/header/body 任一来源),
|
与 deps.get_entity_id 的 Bot 白名单语义一致(query/header/body 任一来源),
|
||||||
本端点不接收登录token,故不在此处理 Bearer。
|
本端点不接收登录token,故不在此处理 Bearer。
|
||||||
|
|
||||||
|
ASK-20260924-17(任总 2026-09-24 放行):**取消「默认 1(酣客)」兜底**——
|
||||||
|
缺省账套改用 `deps.require_bot_entity_id` 收口(缺 → 400,非数字 → 422)。
|
||||||
"""
|
"""
|
||||||
raw = None
|
raw = None
|
||||||
if data.get("entity_id") not in (None, ""):
|
if data.get("entity_id") not in (None, ""):
|
||||||
@@ -53,10 +57,7 @@ def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
|
|||||||
xh = request.headers.get("X-Entity-Id")
|
xh = request.headers.get("X-Entity-Id")
|
||||||
if xh and str(xh).isdigit():
|
if xh and str(xh).isdigit():
|
||||||
raw = int(xh)
|
raw = int(xh)
|
||||||
try:
|
eid = require_bot_entity_id({"entity_id": raw}, source="bot_suggestions")
|
||||||
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()
|
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||||
if not ent or ent.status != "active":
|
if not ent or ent.status != "active":
|
||||||
raise HTTPException(403, f"企业 entity_id={eid} 不存在或未激活")
|
raise HTTPException(403, f"企业 entity_id={eid} 不存在或未激活")
|
||||||
|
|||||||
+408
-32
@@ -1,6 +1,8 @@
|
|||||||
"""预算管理 API — 管理会计OS
|
"""预算管理 API — 管理会计OS
|
||||||
预算值的CRUD、自动分解、版本管理
|
预算值的CRUD、自动分解、版本管理
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
@@ -11,6 +13,15 @@ from app.database import get_db
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import BudgetPlan, KPIDefinition, OperationLog, KPIValue
|
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=["预算管理"],
|
router = APIRouter(prefix="/api/cma/budget", tags=["预算管理"],
|
||||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||||
@@ -95,7 +106,19 @@ def create_budget_plan(
|
|||||||
if not kpi:
|
if not kpi:
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
|
|
||||||
year, month = period.split("-")
|
# 期间形态校验(年度行同族残余 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(年度)")
|
||||||
version = data.get("version", "v1.0")
|
version = data.get("version", "v1.0")
|
||||||
map_id = data.get("map_id") # 预算归属地图(2026-08-27: 按地图隔离)
|
map_id = data.get("map_id") # 预算归属地图(2026-08-27: 按地图隔离)
|
||||||
|
|
||||||
@@ -137,7 +160,8 @@ def create_budget_plan(
|
|||||||
action="create",
|
action="create",
|
||||||
target_type="budget",
|
target_type="budget",
|
||||||
target_id=plan.id,
|
target_id=plan.id,
|
||||||
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value}, ensure_ascii=False),
|
detail=__import__("json").dumps({"kpi_id": kpi_id, "period": period, "value": budget_value,
|
||||||
|
"entity_id": entity_id}, ensure_ascii=False), # ④ 审计带账套
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -256,6 +280,7 @@ def auto_decompose_budget(
|
|||||||
last_year = year - 1
|
last_year = year - 1
|
||||||
values = db.query(KPIValue).filter(
|
values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kid,
|
KPIValue.kpi_id == kid,
|
||||||
|
actual_only_clause(), # 实测区口径:预估行禁作预算派生基数(kpi_value_scope)
|
||||||
KPIValue.period.like(f"{last_year}-%"),
|
KPIValue.period.like(f"{last_year}-%"),
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
).order_by(KPIValue.period.asc()).all()
|
).order_by(KPIValue.period.asc()).all()
|
||||||
@@ -329,6 +354,7 @@ def auto_decompose_budget(
|
|||||||
last_year = year - 1
|
last_year = year - 1
|
||||||
values = db.query(KPIValue).filter(
|
values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi_id,
|
KPIValue.kpi_id == kpi_id,
|
||||||
|
actual_only_clause(), # 实测区口径:预估行禁作预算派生基数(kpi_value_scope)
|
||||||
KPIValue.period.like(f"{last_year}-%"),
|
KPIValue.period.like(f"{last_year}-%"),
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
).order_by(KPIValue.period.asc()).all()
|
).order_by(KPIValue.period.asc()).all()
|
||||||
@@ -586,10 +612,14 @@ def budget_roll_forward(
|
|||||||
.filter(BudgetPlan.status == "active", KPIDefinition.entity_id == entity_id)
|
.filter(BudgetPlan.status == "active", KPIDefinition.entity_id == entity_id)
|
||||||
.all())
|
.all())
|
||||||
|
|
||||||
# 按KPI分组
|
# 按KPI分组(年度行排除 2026-10-13:budget_month=0 哨兵不参与月度滚动,仅年度行的 KPI 不删不产新行)
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
kpi_plans = defaultdict(list)
|
kpi_plans = defaultdict(list)
|
||||||
|
annual_rows_ignored = 0
|
||||||
for p in plans:
|
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)
|
kpi_plans[p.kpi_id].append(p)
|
||||||
|
|
||||||
rolled_kpis = []
|
rolled_kpis = []
|
||||||
@@ -649,6 +679,7 @@ def budget_roll_forward(
|
|||||||
"rolled_kpis": rolled_kpis,
|
"rolled_kpis": rolled_kpis,
|
||||||
"current_month": f"{current_year}-{current_month:02d}",
|
"current_month": f"{current_year}-{current_month:02d}",
|
||||||
"rolling_months": val.get("rolling_months", 12),
|
"rolling_months": val.get("rolling_months", 12),
|
||||||
|
"annual_rows_ignored": annual_rows_ignored,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -707,18 +738,15 @@ def get_budget_comparison(
|
|||||||
)
|
)
|
||||||
if kpi_id:
|
if kpi_id:
|
||||||
query = query.filter(BudgetPlan.kpi_id == kpi_id)
|
query = query.filter(BudgetPlan.kpi_id == kpi_id)
|
||||||
|
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
BudgetPlan.period.in_(periods),
|
BudgetPlan.period.in_(periods),
|
||||||
BudgetPlan.status == "active",
|
effective_plan_clause(entity_id), # 含账套隔离
|
||||||
BudgetPlan.entity_id == entity_id, # 账套隔离 2026-09-21
|
|
||||||
)
|
)
|
||||||
budget_plans = query.all()
|
budget_plans = query.all()
|
||||||
|
|
||||||
# 按KPI+期间索引
|
# 按KPI+期间索引(同键多版本 → 取生效行 active > approved > updated_at desc)
|
||||||
bp_map = {}
|
bp_map = effective_value_map(budget_plans)
|
||||||
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)
|
kpi_ids = set(bp.kpi_id for bp in budget_plans)
|
||||||
@@ -808,12 +836,13 @@ def get_kpi_comparison(
|
|||||||
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
|
periods = [f"{y}-{m:02d}" for m in range(1, 13)]
|
||||||
|
|
||||||
# 预算值
|
# 预算值
|
||||||
|
# 生效版口径(2026-10-03 状态机归一;保持原「未按账套过滤」行为不变)
|
||||||
budgets = db.query(BudgetPlan).filter(
|
budgets = db.query(BudgetPlan).filter(
|
||||||
BudgetPlan.kpi_id == kpi_id,
|
BudgetPlan.kpi_id == kpi_id,
|
||||||
BudgetPlan.period.in_(periods),
|
BudgetPlan.period.in_(periods),
|
||||||
BudgetPlan.status == "active",
|
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
|
||||||
).all()
|
).all()
|
||||||
budget_map = {bp.period: bp.budget_value for bp in budgets}
|
budget_map = {p: v for (_kid, p), v in effective_value_map(budgets).items()}
|
||||||
|
|
||||||
# 实际值
|
# 实际值
|
||||||
actuals = db.query(KPIValue).filter(
|
actuals = db.query(KPIValue).filter(
|
||||||
@@ -875,11 +904,16 @@ def check_budget_deviation(
|
|||||||
period = data.get("period") or datetime.now().strftime("%Y-%m")
|
period = data.get("period") or datetime.now().strftime("%Y-%m")
|
||||||
|
|
||||||
# 查询该期间有预算的KPI(多租户隔离 entity_id)
|
# 查询该期间有预算的KPI(多租户隔离 entity_id)
|
||||||
budget_plans = db.query(BudgetPlan).filter(
|
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
|
||||||
BudgetPlan.entity_id == entity_id,
|
_effective_rows = db.query(BudgetPlan).filter(
|
||||||
BudgetPlan.period == period,
|
BudgetPlan.period == period,
|
||||||
BudgetPlan.status == "active",
|
effective_plan_clause(entity_id),
|
||||||
).all()
|
).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:
|
if not budget_plans:
|
||||||
return {
|
return {
|
||||||
@@ -1079,17 +1113,24 @@ def get_deviation_alert_attribution(
|
|||||||
@router.get("/alert-direction")
|
@router.get("/alert-direction")
|
||||||
def get_alert_direction(
|
def get_alert_direction(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""越高越好型KPI方向配置 (P2-⑤ 2026-08-28: system_configs 可维护)"""
|
"""越高越好型KPI方向配置(**按会话账套**;2026-09-23 拆账套,零 DDL)
|
||||||
from app.utils.deviation_engine import get_higher_better_codes, CONFIG_KEY_HIGHER_BETTER
|
|
||||||
|
与 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
|
||||||
from app.models import SystemConfig
|
from app.models import SystemConfig
|
||||||
|
|
||||||
|
key = higher_better_key(entity_id)
|
||||||
cfg = db.query(SystemConfig).filter(
|
cfg = db.query(SystemConfig).filter(
|
||||||
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
SystemConfig.config_key == key
|
||||||
).first()
|
).first()
|
||||||
codes = get_higher_better_codes(db)
|
codes = get_higher_better_codes(db, entity_id)
|
||||||
return {
|
return {
|
||||||
"config_key": CONFIG_KEY_HIGHER_BETTER,
|
"config_key": key,
|
||||||
|
"entity_id": entity_id,
|
||||||
"codes": codes,
|
"codes": codes,
|
||||||
"is_configured": bool(cfg and cfg.config_value),
|
"is_configured": bool(cfg and cfg.config_value),
|
||||||
}
|
}
|
||||||
@@ -1100,10 +1141,15 @@ def update_alert_direction(
|
|||||||
data: dict,
|
data: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}"""
|
"""维护越高越好型KPI方向配置 (P2-⑤) body: {codes: ["SALES_TOTAL", ...]}
|
||||||
|
|
||||||
|
账套隔离(2026-09-23):只写本账套键 `kpi_alert_higher_better.<entity_id>`;
|
||||||
|
body 里的 `entity_id` 一律不信(会话账套为准,同 B1 口径)。
|
||||||
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
from app.utils.deviation_engine import CONFIG_KEY_HIGHER_BETTER
|
from app.utils.deviation_engine import higher_better_key
|
||||||
from app.models import SystemConfig
|
from app.models import SystemConfig
|
||||||
|
|
||||||
codes = data.get("codes")
|
codes = data.get("codes")
|
||||||
@@ -1111,19 +1157,20 @@ def update_alert_direction(
|
|||||||
raise HTTPException(400, "codes 必须是非空数组")
|
raise HTTPException(400, "codes 必须是非空数组")
|
||||||
codes = [str(c) for c in codes]
|
codes = [str(c) for c in codes]
|
||||||
|
|
||||||
|
key = higher_better_key(entity_id)
|
||||||
cfg = db.query(SystemConfig).filter(
|
cfg = db.query(SystemConfig).filter(
|
||||||
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
SystemConfig.config_key == key
|
||||||
).first()
|
).first()
|
||||||
if cfg:
|
if cfg:
|
||||||
cfg.config_value = _json.dumps(codes, ensure_ascii=False)
|
cfg.config_value = _json.dumps(codes, ensure_ascii=False)
|
||||||
else:
|
else:
|
||||||
db.add(SystemConfig(
|
db.add(SystemConfig(
|
||||||
config_key=CONFIG_KEY_HIGHER_BETTER,
|
config_key=key,
|
||||||
config_value=_json.dumps(codes, ensure_ascii=False),
|
config_value=_json.dumps(codes, ensure_ascii=False),
|
||||||
description="越高越好型KPI编码列表(实际低于预算才告警)",
|
description=f"越高越好型KPI编码列表(实际低于预算才告警)— 账套 {int(entity_id)}",
|
||||||
))
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"success": True, "config_key": CONFIG_KEY_HIGHER_BETTER, "codes": codes}
|
return {"success": True, "config_key": key, "entity_id": entity_id, "codes": codes}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/deviation-alerts/{alert_id}")
|
@router.put("/deviation-alerts/{alert_id}")
|
||||||
@@ -1366,6 +1413,8 @@ def apply_budget_method(
|
|||||||
base_actual = db.query(KPIValue).filter(
|
base_actual = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == rule.base_kpi_id,
|
KPIValue.kpi_id == rule.base_kpi_id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
|
# 实测区口径:预估行禁作预算基数(kpi_value_scope.actual_only_clause)
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.calculated_at.desc()).first()
|
).order_by(KPIValue.calculated_at.desc()).first()
|
||||||
if base_actual:
|
if base_actual:
|
||||||
base_val = base_actual.actual_value
|
base_val = base_actual.actual_value
|
||||||
@@ -1482,7 +1531,7 @@ def list_budget_versions(
|
|||||||
# 状态机对齐(2026-09-07 UI修复):DB 默认态是 active(当前在用/草稿态),
|
# 状态机对齐(2026-09-07 UI修复):DB 默认态是 active(当前在用/草稿态),
|
||||||
# 前端审批按钮语义用 draft=可提交审批/submitted=待审批/approved=已批准只读/rejected=已驳回
|
# 前端审批按钮语义用 draft=可提交审批/submitted=待审批/approved=已批准只读/rejected=已驳回
|
||||||
# → approval_status 把 active 映射为 draft,同时保留 status 原字段兼容其他引用(下拉"当前"标记等)
|
# → approval_status 把 active 映射为 draft,同时保留 status 原字段兼容其他引用(下拉"当前"标记等)
|
||||||
approval_status = "draft" if raw_status == "active" else raw_status
|
approval_status = approval_status_of(raw_status) # 单一实现(预算状态机口径)
|
||||||
updated_str = v["updated_at"].strftime("%Y-%m-%d %H:%M") if v["updated_at"] else ""
|
updated_str = v["updated_at"].strftime("%Y-%m-%d %H:%M") if v["updated_at"] else ""
|
||||||
result.append({
|
result.append({
|
||||||
"version": v["version"],
|
"version": v["version"],
|
||||||
@@ -1492,6 +1541,8 @@ def list_budget_versions(
|
|||||||
"plan_count": v["kpi_count"], # 前端列 prop 别名(BudgetManagement.vue versions tab)
|
"plan_count": v["kpi_count"], # 前端列 prop 别名(BudgetManagement.vue versions tab)
|
||||||
"total_budget": round(v["total_budget"], 2),
|
"total_budget": round(v["total_budget"], 2),
|
||||||
"period_count": len(v["periods"]),
|
"period_count": len(v["periods"]),
|
||||||
|
"is_effective": raw_status in EFFECTIVE_STATUSES, # 是否执行基准
|
||||||
|
"can_release": raw_status == "approved", # 是否可下达
|
||||||
"year": year,
|
"year": year,
|
||||||
"updated_at": updated_str,
|
"updated_at": updated_str,
|
||||||
"last_updated": updated_str, # 前端列 prop 别名
|
"last_updated": updated_str, # 前端列 prop 别名
|
||||||
@@ -1514,10 +1565,13 @@ def submit_budget_version(
|
|||||||
).all()
|
).all()
|
||||||
if not rows:
|
if not rows:
|
||||||
raise HTTPException(404, f"版本 {version} 不存在")
|
raise HTTPException(404, f"版本 {version} 不存在")
|
||||||
for r in rows:
|
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:
|
||||||
r.status = "submitted"
|
r.status = "submitted"
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"success": True, "version": version, "status": "submitted", "count": len(rows)}
|
return {"success": True, "version": version, "status": "submitted", "count": len(submittable)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/versions/approve")
|
@router.post("/versions/approve")
|
||||||
@@ -1536,10 +1590,13 @@ def approve_budget_version(
|
|||||||
if not rows:
|
if not rows:
|
||||||
raise HTTPException(404, f"版本 {version} 不存在")
|
raise HTTPException(404, f"版本 {version} 不存在")
|
||||||
new_status = "approved" if action == "approved" else "rejected"
|
new_status = "approved" if action == "approved" else "rejected"
|
||||||
for r in rows:
|
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:
|
||||||
r.status = new_status
|
r.status = new_status
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"success": True, "version": version, "status": new_status, "count": len(rows)}
|
return {"success": True, "version": version, "status": new_status, "count": len(decidable)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/versions/diff")
|
@router.post("/versions/diff")
|
||||||
@@ -1612,6 +1669,17 @@ def diff_budget_versions(
|
|||||||
# 预算↔现金流联动(断点修复#1, 2026-08-27)
|
# 预算↔现金流联动(断点修复#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")
|
@router.post("/sync-cash-plans")
|
||||||
def sync_cash_plans(
|
def sync_cash_plans(
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
@@ -1664,7 +1732,13 @@ 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 {}
|
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
|
created, updated, unclassified_count = 0, 0, 0
|
||||||
|
skipped_annual = 0
|
||||||
for b in budgets:
|
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)
|
kpi = kpis.get(b.kpi_id)
|
||||||
if not kpi:
|
if not kpi:
|
||||||
continue
|
continue
|
||||||
@@ -1717,8 +1791,310 @@ def sync_cash_plans(
|
|||||||
))
|
))
|
||||||
created += 1
|
created += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
message = f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条"
|
||||||
|
if skipped_annual:
|
||||||
|
message += f", 跳过年度行{skipped_annual}条"
|
||||||
return {
|
return {
|
||||||
"message": f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条",
|
"message": message,
|
||||||
"created": created, "updated": updated,
|
"created": created, "updated": updated,
|
||||||
"unclassified_count": unclassified_count,
|
"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,需提交审批 → 批准 → 下达后方可作为执行基准"}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from app.database import get_db
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
|
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 json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -91,7 +93,9 @@ def get_kpi_budget_candidates(
|
|||||||
latest_values = {}
|
latest_values = {}
|
||||||
for kpi in kpis:
|
for kpi in kpis:
|
||||||
v = db.query(KPIValue).filter(
|
v = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id
|
KPIValue.kpi_id == kpi.id,
|
||||||
|
# 实测区口径:预估行禁作预算生成依据(kpi_value_scope.actual_only_clause)
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.calculated_at.desc()).first()
|
).order_by(KPIValue.calculated_at.desc()).first()
|
||||||
if v:
|
if v:
|
||||||
latest_values[kpi.id] = v.actual_value
|
latest_values[kpi.id] = v.actual_value
|
||||||
@@ -225,6 +229,7 @@ def generate_budget_from_kpis(
|
|||||||
period = f"{year}-{month:02d}"
|
period = f"{year}-{month:02d}"
|
||||||
results = []
|
results = []
|
||||||
total_amount = 0
|
total_amount = 0
|
||||||
|
_kpi_entities = set() # 2026-09-23 ④:审计带账套(取被处理 KPI 自身的账套,不猜会话账套)
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
kpi_id = item.get("kpi_id")
|
kpi_id = item.get("kpi_id")
|
||||||
@@ -238,6 +243,7 @@ def generate_budget_from_kpis(
|
|||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||||
if not kpi:
|
if not kpi:
|
||||||
continue
|
continue
|
||||||
|
_kpi_entities.add(getattr(kpi, "entity_id", None))
|
||||||
|
|
||||||
# 检查是否已有记录
|
# 检查是否已有记录
|
||||||
existing = db.query(BudgetPlan).filter(
|
existing = db.query(BudgetPlan).filter(
|
||||||
@@ -283,20 +289,20 @@ def generate_budget_from_kpis(
|
|||||||
"plan_id": plan_id,
|
"plan_id": plan_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 操作日志
|
# 操作日志(2026-09-23 ④:detail 带账套;跨账套批量 ⇒ 留空 + 哨兵,不猜账套)
|
||||||
log = OperationLog(
|
_entities = sorted(x for x in _kpi_entities if x is not None)
|
||||||
user_id=getattr(current_user, "id", None),
|
log_op(db, "kpi_generate_budget", target_type="budget",
|
||||||
action="kpi_generate_budget",
|
user_id=getattr(current_user, "id", None),
|
||||||
target_type="budget",
|
detail={
|
||||||
detail=json.dumps({
|
"year": year,
|
||||||
"year": year,
|
"month": month,
|
||||||
"month": month,
|
"version": version,
|
||||||
"version": version,
|
"item_count": len(results),
|
||||||
"item_count": len(results),
|
"total_amount": total_amount,
|
||||||
"total_amount": total_amount,
|
"kpi_entity_ids": _entities,
|
||||||
}, ensure_ascii=False),
|
},
|
||||||
)
|
entity_id=(_entities[0] if len(_entities) == 1 else None),
|
||||||
db.add(log)
|
scope=(SCOPE_BATCH if len(_entities) > 1 else None))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+37
-7
@@ -12,6 +12,7 @@ from sqlalchemy import or_
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import get_entity_id, resolve_entity_for_request
|
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.auth_middleware import require_role
|
||||||
from app.models import CashPlan
|
from app.models import CashPlan
|
||||||
from app.utils.cash_forecast_engine import (
|
from app.utils.cash_forecast_engine import (
|
||||||
@@ -30,6 +31,20 @@ 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:
|
def _aging_bucket(days_late: int) -> str:
|
||||||
"""账龄区间(按逾期天数):未到期 / 0-30天 / 30-60天 / 60-90天 / 90天以上"""
|
"""账龄区间(按逾期天数):未到期 / 0-30天 / 30-60天 / 60-90天 / 90天以上"""
|
||||||
if days_late <= 0:
|
if days_late <= 0:
|
||||||
@@ -67,6 +82,7 @@ def _plan_dict(p: CashPlan) -> dict:
|
|||||||
"plan_type_label": "收款" if p.plan_type == "receive" else "付款",
|
"plan_type_label": "收款" if p.plan_type == "receive" else "付款",
|
||||||
"amount": round(p.amount or 0, 2),
|
"amount": round(p.amount or 0, 2),
|
||||||
"paid_amount": paid,
|
"paid_amount": paid,
|
||||||
|
"settled_amount": round(p.amount or 0, 2) if p.status == "completed" else paid,
|
||||||
"receivable_balance": max(balance, 0),
|
"receivable_balance": max(balance, 0),
|
||||||
"plan_date": p.plan_date.strftime("%Y-%m-%d") if p.plan_date else "",
|
"plan_date": p.plan_date.strftime("%Y-%m-%d") if p.plan_date else "",
|
||||||
"counterparty": p.counterparty or "",
|
"counterparty": p.counterparty or "",
|
||||||
@@ -563,7 +579,11 @@ def api_receivables(
|
|||||||
keyword: str = Query(None, description="客户/说明关键字"),
|
keyword: str = Query(None, description="客户/说明关键字"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选"""
|
"""应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选
|
||||||
|
|
||||||
|
口径(ASK-20261001-33):**只认客户级行**——月度汇总行(`counterparty` 空/空白)
|
||||||
|
不计入卡片 / 账龄 / 明细 / `owners`。
|
||||||
|
"""
|
||||||
query = db.query(CashPlan).filter(
|
query = db.query(CashPlan).filter(
|
||||||
CashPlan.entity_id == entity_id,
|
CashPlan.entity_id == entity_id,
|
||||||
CashPlan.plan_type == "receive",
|
CashPlan.plan_type == "receive",
|
||||||
@@ -582,7 +602,10 @@ def api_receivables(
|
|||||||
overdue_amount = 0.0
|
overdue_amount = 0.0
|
||||||
aging_amount = {}
|
aging_amount = {}
|
||||||
completed_amount = 0.0
|
completed_amount = 0.0
|
||||||
|
completed_count = 0
|
||||||
for p in plans:
|
for p in plans:
|
||||||
|
if not _is_customer_level_row(p):
|
||||||
|
continue # 月度汇总行(客户列为空/空白)不计入催收页
|
||||||
d = _plan_dict(p)
|
d = _plan_dict(p)
|
||||||
if status:
|
if status:
|
||||||
if status == "overdue":
|
if status == "overdue":
|
||||||
@@ -604,13 +627,18 @@ def api_receivables(
|
|||||||
aging_amount[d["aging_bucket"]] = aging_amount.get(d["aging_bucket"], 0) + d["receivable_balance"]
|
aging_amount[d["aging_bucket"]] = aging_amount.get(d["aging_bucket"], 0) + d["receivable_balance"]
|
||||||
elif d["status"] == "completed":
|
elif d["status"] == "completed":
|
||||||
completed_amount += d["amount"]
|
completed_amount += d["amount"]
|
||||||
|
completed_count += 1
|
||||||
|
|
||||||
owners = [r[0] for r in db.query(CashPlan.owner).filter(
|
# owners 与明细同一判据:月度汇总行(客户列为空/空白)不得贡献筛选项
|
||||||
CashPlan.entity_id == entity_id,
|
owners = sorted({
|
||||||
CashPlan.plan_type == "receive",
|
row.owner for row in db.query(CashPlan.owner, CashPlan.counterparty).filter(
|
||||||
CashPlan.owner.isnot(None),
|
CashPlan.entity_id == entity_id,
|
||||||
CashPlan.owner != "",
|
CashPlan.plan_type == "receive",
|
||||||
).distinct().order_by(CashPlan.owner.asc()).all()]
|
CashPlan.owner.isnot(None),
|
||||||
|
CashPlan.owner != "",
|
||||||
|
).distinct().all()
|
||||||
|
if _is_customer_level_row(row)
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"entity_id": entity_id,
|
"entity_id": entity_id,
|
||||||
@@ -624,6 +652,7 @@ def api_receivables(
|
|||||||
"overdue_count": overdue_count,
|
"overdue_count": overdue_count,
|
||||||
"overdue_amount": round(overdue_amount, 2),
|
"overdue_amount": round(overdue_amount, 2),
|
||||||
"completed_amount": round(completed_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])},
|
"aging": {k: round(v, 2) for k, v in sorted(aging_amount.items(), key=lambda x: x[0])},
|
||||||
},
|
},
|
||||||
"data": items,
|
"data": items,
|
||||||
@@ -974,6 +1003,7 @@ def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> t
|
|||||||
dv = db.query(KPIValue).filter(
|
dv = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == debt_kpi.id,
|
KPIValue.kpi_id == debt_kpi.id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
|
month_period_clause(KPIValue.period),
|
||||||
).order_by(KPIValue.period.desc()).first()
|
).order_by(KPIValue.period.desc()).first()
|
||||||
if dv and dv.actual_value is not None:
|
if dv and dv.actual_value is not None:
|
||||||
short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元
|
short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元
|
||||||
|
|||||||
@@ -196,40 +196,53 @@ def classify_unclassified(
|
|||||||
item.status = "classified"
|
item.status = "classified"
|
||||||
item.resolved_at = datetime.now()
|
item.resolved_at = datetime.now()
|
||||||
|
|
||||||
# ③ 联动生成 CashPlan(有期间和预算值时)
|
# ③ 联动生成 CashPlan(有期间和预算值时;年度行跳过,不落月度计划)
|
||||||
|
from app.api.budget import _is_annual_budget_row # 函数内局部导入,规避 import 顺序/循环风险
|
||||||
plan_created = False
|
plan_created = False
|
||||||
|
skipped_annual = False
|
||||||
if item.period and item.budget_value is not None:
|
if item.period and item.budget_value is not None:
|
||||||
try:
|
# 先按形态解析月份(不裸索引 split,避免 'YYYY' / 'YYYY-00' 抛异常被静默吞掉)
|
||||||
year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1])
|
parts = item.period.split("-") if isinstance(item.period, str) else []
|
||||||
plan_date = datetime(year, month, 1)
|
month = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||||
except Exception:
|
if _is_annual_budget_row(item.period, month):
|
||||||
plan_date = None
|
# 年度行(YYYY / YYYY-00 / budget_month=0)不得落成月度现金流计划
|
||||||
if plan_date:
|
skipped_annual = True
|
||||||
existing_plan = db.query(CashPlan).filter(
|
else:
|
||||||
CashPlan.entity_id == entity_id,
|
try:
|
||||||
CashPlan.related_kpi_id == item.kpi_id,
|
year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1])
|
||||||
CashPlan.plan_type == plan_type,
|
plan_date = datetime(year, month, 1)
|
||||||
CashPlan.plan_date == plan_date,
|
except Exception:
|
||||||
).first()
|
plan_date = None
|
||||||
if not existing_plan:
|
if plan_date:
|
||||||
db.add(CashPlan(
|
existing_plan = db.query(CashPlan).filter(
|
||||||
entity_id=entity_id,
|
CashPlan.entity_id == entity_id,
|
||||||
plan_type=plan_type,
|
CashPlan.related_kpi_id == item.kpi_id,
|
||||||
related_kpi_id=item.kpi_id,
|
CashPlan.plan_type == plan_type,
|
||||||
amount=item.budget_value,
|
CashPlan.plan_date == plan_date,
|
||||||
plan_date=plan_date,
|
).first()
|
||||||
description=f"待分类队列归类: {item.kpi_name or ''}",
|
if not existing_plan:
|
||||||
status="pending",
|
db.add(CashPlan(
|
||||||
source="budget_sync",
|
entity_id=entity_id,
|
||||||
))
|
plan_type=plan_type,
|
||||||
plan_created = True
|
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()
|
db.commit()
|
||||||
|
message = f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else "")
|
||||||
|
if skipped_annual:
|
||||||
|
message += "(年度行,跳过月度现金流计划)"
|
||||||
return {
|
return {
|
||||||
"message": f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else ""),
|
"message": message,
|
||||||
"plan_type": plan_type,
|
"plan_type": plan_type,
|
||||||
"rule_created": not existing_rule,
|
"rule_created": not existing_rule,
|
||||||
"plan_created": plan_created,
|
"plan_created": plan_created,
|
||||||
|
"skipped_annual": skipped_annual,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,9 @@
|
|||||||
—— period 是 voucher_date 派生属性,**不进幂等键**(2026-09-11 加固:日期口径
|
—— period 是 voucher_date 派生属性,**不进幂等键**(2026-09-11 加固:日期口径
|
||||||
在 summary 派生/主表 VoucherDate 之间切换时,含 period 会判重失效导致重复入库)。
|
在 summary 派生/主表 VoucherDate 之间切换时,含 period 会判重失效导致重复入库)。
|
||||||
命中既有行即跳过;仅当命中行 period 与新行不一致时计入 period_conflict 留痕(不覆盖)。
|
命中既有行即跳过;仅当命中行 period 与新行不一致时计入 period_conflict 留痕(不覆盖)。
|
||||||
7. entity_id:默认 1(陕西酣客,source=zxt-sxzxb20210228),Bot 通道可传 X-Entity-Id 或 body entity_id。
|
7. entity_id:**必传**(ASK-20260924-17,任总 2026-09-24 放行)——body `entity_id` 或
|
||||||
|
header `X-Entity-Id`(1=陕西酣客、2=陕西博海);**缺省 → 400**,
|
||||||
|
不再兜底默认账套 1(原口径:默认 1 陕西酣客,source=zxt-sxzxb20210228)。
|
||||||
8. 入库后不触发现金流/ledger 联动(凭证完整性未证实前不作为权威来源,见财务口径警示);
|
8. 入库后不触发现金流/ledger 联动(凭证完整性未证实前不作为权威来源,见财务口径警示);
|
||||||
也不影响 Excel multipart 导入(原端点不动)。
|
也不影响 Excel multipart 导入(原端点不动)。
|
||||||
"""
|
"""
|
||||||
@@ -45,7 +47,7 @@ from sqlalchemy import text as sa_text
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import resolve_entity_for_request
|
from app.deps import resolve_entity_for_request, require_bot_entity_id
|
||||||
from app.models import Entity, ImportLog, VoucherDetail
|
from app.models import Entity, ImportLog, VoucherDetail
|
||||||
from app.api.bot_bridge import verify_bot_key
|
from app.api.bot_bridge import verify_bot_key
|
||||||
|
|
||||||
@@ -225,12 +227,18 @@ def api_import_vouchers_json(
|
|||||||
nr["CwAccountID"] = v
|
nr["CwAccountID"] = v
|
||||||
norm_rows.append(nr)
|
norm_rows.append(nr)
|
||||||
|
|
||||||
# ── entity 解析 + active 校验 ──
|
# ── entity 解析 + active 校验(ASK-20260924-17:Bot 通道必须显式传账套)──
|
||||||
entity_id = resolve_entity_for_request(request, req_entity if req_entity not in (None, "") else 1)
|
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",
|
||||||
|
),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
entity_id = int(entity_id)
|
entity_id = int(entity_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
entity_id = 1
|
raise HTTPException(422, f"entity_id 无效: {entity_id!r}")
|
||||||
if not _entity_active(db, entity_id):
|
if not _entity_active(db, entity_id):
|
||||||
raise HTTPException(403, f"企业ID={entity_id} 不存在或未激活")
|
raise HTTPException(403, f"企业ID={entity_id} 不存在或未激活")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from app.database import get_db
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, KPIValue, KPIAlert, User
|
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
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger("cma.customer")
|
logger = logging.getLogger("cma.customer")
|
||||||
@@ -16,6 +18,21 @@ router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"],
|
|||||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
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):
|
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
|
||||||
"""解析时间区间"""
|
"""解析时间区间"""
|
||||||
@@ -72,7 +89,7 @@ def list_customer_kpis(
|
|||||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
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
|
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
|
||||||
elif period == "year":
|
elif period == "year":
|
||||||
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
|
values = base_query.filter(actual_only_clause(), 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_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
|
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:
|
elif period == "custom" and start_date and end_date:
|
||||||
@@ -86,7 +103,7 @@ def list_customer_kpis(
|
|||||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
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
|
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
|
||||||
else:
|
else:
|
||||||
latest = base_query.order_by(KPIValue.period.desc()).first()
|
latest = base_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
|
||||||
|
|
||||||
# 最新预警
|
# 最新预警
|
||||||
alert = db.query(KPIAlert).filter(
|
alert = db.query(KPIAlert).filter(
|
||||||
@@ -127,6 +144,7 @@ def list_customer_kpis(
|
|||||||
# 最近6期趋势数据
|
# 最近6期趋势数据
|
||||||
period_q = db.query(KPIValue).filter(
|
period_q = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
|
month_period_clause(KPIValue.period),
|
||||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||||
period_values = [
|
period_values = [
|
||||||
{"period": v.period, "value": v.actual_value}
|
{"period": v.period, "value": v.actual_value}
|
||||||
@@ -157,6 +175,78 @@ def list_customer_kpis(
|
|||||||
return {"data": result, "period": period, "total": len(result)}
|
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}")
|
@router.get("/trend/{kpi_id}")
|
||||||
def get_kpi_trend(
|
def get_kpi_trend(
|
||||||
kpi_id: int,
|
kpi_id: int,
|
||||||
@@ -171,6 +261,7 @@ def get_kpi_trend(
|
|||||||
# 获取最近N期数据
|
# 获取最近N期数据
|
||||||
values = db.query(KPIValue).filter(
|
values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi_id,
|
KPIValue.kpi_id == kpi_id,
|
||||||
|
month_period_clause(KPIValue.period),
|
||||||
).order_by(KPIValue.period.desc()).limit(months).all()
|
).order_by(KPIValue.period.desc()).limit(months).all()
|
||||||
|
|
||||||
trend_data = [
|
trend_data = [
|
||||||
|
|||||||
+421
-82
@@ -11,6 +11,13 @@ 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.cache import get as cache_get, set as cache_set
|
||||||
from app.utils.voucher_guard import apply_voucher_guard
|
from app.utils.voucher_guard import apply_voucher_guard
|
||||||
from app.utils.threshold_parser import eval_threshold
|
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 收口)。
|
# 指标方向(反向/正向)唯一权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口)。
|
||||||
# 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。
|
# 本文件原自带一份字面量副本,与 kpis.py 漂移后会与「能力评分」判定相反,故改为导入。
|
||||||
from app.api.kpis import REVERSE_INDICATORS
|
from app.api.kpis import REVERSE_INDICATORS
|
||||||
@@ -26,35 +33,98 @@ router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"],
|
|||||||
# ── 本量利·保本点 卡片只读数据(cma-bep-kpi Phase2 2026-09-08)──
|
# ── 本量利·保本点 卡片只读数据(cma-bep-kpi Phase2 2026-09-08)──
|
||||||
# 口径与 backend/scripts/kpi_bep_deriver.py 完全一致:
|
# 口径与 backend/scripts/kpi_bep_deriver.py 完全一致:
|
||||||
# 月销 = voucher_details subject 5001(主营业务收入) 贷方 YTD 月均(万元)
|
# 月销 = voucher_details subject 5001(主营业务收入) 贷方 YTD 月均(万元)
|
||||||
# 月管理费 = 5602% 借方 YTD 月均(账面口径含折旧)
|
# 月管理费 = 5602% 借−贷 净额 YTD 月均(carry_forward=0 行域;账面口径含折旧)
|
||||||
# 折旧 = 560213 借方 YTD 月均
|
# 折旧 = 560213 借−贷 净额 YTD 月均(同上;口径4 见 _decisions/cma-expense-profit-net-caliber-20261002.md)
|
||||||
# 渠补率 = 厂家政策 84.61%(财务Bot权威口径)
|
# 变动成本率 = **实测累计**(⑤ 口径统一:酣客=Σ(5401+5403+571103−530103)÷Σ5001贷;
|
||||||
# 保本额 = 月固定成本 ÷ (1-渠补率)
|
# 博海=Σ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,
|
||||||
|
# 不出保本值、不回落政策常量(不跨期回落,避免混期口径)。
|
||||||
# 账面口径(含折旧) / 现金口径(剔除折旧) 双口径只读返回,无写操作。
|
# 账面口径(含折旧) / 现金口径(剔除折旧) 双口径只读返回,无写操作。
|
||||||
# 现金口径数值优先读派生KPI(F_BEP_REVENUE_CASH/F_BEP_COVER_CASH)最新落库值,无派生值时回退现算。
|
|
||||||
BEP_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP",
|
BEP_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP",
|
||||||
"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH")
|
"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH",
|
||||||
|
"F_BEP_VC_RATE", "F_BEP_REBATE")
|
||||||
BEP_BOOK_CODES = ("F_BEP_REVENUE", "F_BEP_COVER", "F_BEP_GAP") # 账面3条必须齐全才 available
|
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_SALES_SUBJECT = "5001"
|
||||||
BEP_ADMIN_PREFIX = "5602"
|
BEP_ADMIN_PREFIX = "5602"
|
||||||
BEP_DEP_SUBJECT = "560213"
|
BEP_DEP_SUBJECT = "560213"
|
||||||
BEP_REBATE_POLICY_PCT = 84.61
|
# ⚠️ 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"}
|
||||||
|
|
||||||
|
|
||||||
def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
|
def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
|
||||||
field: str, subject_filter=None) -> float:
|
field: str, subject_filter=None, *, exclude_closing: bool = False,
|
||||||
|
subject_filters=None, terms=None) -> float:
|
||||||
"""聚合 voucher_details 金额(元),subject_filter: ('eq', code) / ('like', prefix)
|
"""聚合 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 均值。
|
作废/撤单标记过滤(2026-09-14 收口): 已作废凭证不参与 BEP 均值。
|
||||||
"""
|
"""
|
||||||
q = db.query(func.sum(getattr(VoucherDetail, field))).filter(
|
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(
|
||||||
VoucherDetail.entity_id == entity_id,
|
VoucherDetail.entity_id == entity_id,
|
||||||
VoucherDetail.carry_forward == 0,
|
VoucherDetail.carry_forward == 0,
|
||||||
VoucherDetail.period >= period_from,
|
VoucherDetail.period >= period_from,
|
||||||
VoucherDetail.period <= period_to,
|
VoucherDetail.period <= period_to,
|
||||||
)
|
)
|
||||||
if subject_filter:
|
if exclude_closing:
|
||||||
kind, code = subject_filter
|
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 kind == "eq":
|
if kind == "eq":
|
||||||
q = q.filter(VoucherDetail.subject_code == code)
|
q = q.filter(VoucherDetail.subject_code == code)
|
||||||
elif kind == "like":
|
elif kind == "like":
|
||||||
@@ -64,6 +134,33 @@ def _bep_sum_field(db, entity_id: int, period_from: str, period_to: str,
|
|||||||
return float(val or 0.0)
|
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:
|
def _bep_level(value, kpi) -> str:
|
||||||
"""按 KPI 静态阈值文本判定级别(绿→黄→红,命中即止),无阈值返回 none。
|
"""按 KPI 静态阈值文本判定级别(绿→黄→红,命中即止),无阈值返回 none。
|
||||||
与 alert_generator / kpis._static_threshold_level 语义一致。"""
|
与 alert_generator / kpis._static_threshold_level 语义一致。"""
|
||||||
@@ -92,7 +189,24 @@ def get_bep_card(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""本量利·保本点 卡片数据(只读,A区)——账面/现金双口径"""
|
"""本量利·保本点 卡片数据(只读,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}
|
||||||
|
|
||||||
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(
|
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(
|
||||||
KPIDefinition.entity_id == entity_id,
|
KPIDefinition.entity_id == entity_id,
|
||||||
KPIDefinition.status == "active",
|
KPIDefinition.status == "active",
|
||||||
@@ -103,59 +217,92 @@ def get_bep_card(
|
|||||||
return {"available": False, "message": "BEP账面口径派生KPI未完整注册",
|
return {"available": False, "message": "BEP账面口径派生KPI未完整注册",
|
||||||
"found": list(kpis.keys()), "missing": missing_book}
|
"found": list(kpis.keys()), "missing": missing_book}
|
||||||
|
|
||||||
# 最新派生期间与值(同派生器:source_type='derived');现金2条缺失不阻塞整卡
|
# 派生落库值(同派生器:source_type='derived')——按 code × period 建表,period desc 排序
|
||||||
latest = {}
|
# ⚠️ 不过滤 actual_value IS NULL:fail-closed 落库的 pending 期(值 NULL)必须参与「最新期」判定,
|
||||||
for code in BEP_CODES:
|
# 否则卡面会悄悄跨期回落到上一期,掩盖「本期不出值」的事实。
|
||||||
if code not in kpis:
|
rows = db.query(KPIValue, KPIDefinition.kpi_code).join(
|
||||||
latest[code] = None
|
KPIDefinition, KPIDefinition.id == KPIValue.kpi_id,
|
||||||
continue
|
).filter(
|
||||||
v = db.query(KPIValue).filter(
|
KPIDefinition.entity_id == entity_id,
|
||||||
KPIValue.kpi_id == kpis[code].id,
|
KPIDefinition.kpi_code.in_(BEP_CODES),
|
||||||
KPIValue.source_type == "derived",
|
KPIValue.source_type == "derived",
|
||||||
KPIValue.actual_value.isnot(None),
|
month_period_clause(KPIValue.period),
|
||||||
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
|
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).all()
|
||||||
latest[code] = {"period": v.period, "value": v.actual_value} if v else None
|
|
||||||
|
|
||||||
periods = [latest[c]["period"] for c in BEP_BOOK_CODES if latest[c]]
|
by_code = {} # {code: {period: value|None}}
|
||||||
if not periods:
|
remark_map = {} # {(code, period): remark} —— 用于区分「不适用」与「缺数」
|
||||||
return {"available": False, "message": "尚无派生值,请先运行 kpi_bep_deriver.py"}
|
for v, code in rows:
|
||||||
period = max(periods)
|
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]
|
||||||
year = period[:4]
|
year = period[:4]
|
||||||
months = int(period[5:7])
|
months = int(period[5:7])
|
||||||
period_from = f"{year}-01"
|
period_from = f"{year}-01"
|
||||||
period_to = period
|
|
||||||
|
|
||||||
# 输入口径(万元 / %)——与派生器一致
|
# fail-closed:必备派生值(含变动成本率)本期必须齐全,缺一条即不出保本值
|
||||||
sales_avg = _bep_sum_field(db, entity_id, period_from, period_to,
|
missing_at_period = [c for c in BEP_REQUIRED_CODES if by_code.get(c, {}).get(period) is None]
|
||||||
"credit_amount", ("eq", BEP_SALES_SUBJECT)) / 10000.0 / months
|
if missing_at_period:
|
||||||
admin_avg = _bep_sum_field(db, entity_id, period_from, period_to,
|
# ① 不适用(派生器 fail-closed 落库:remark 已注明「保本点不适用」)
|
||||||
"debit_amount", ("like", BEP_ADMIN_PREFIX)) / 10000.0 / months
|
# —— 与「缺数」区分:卡面显示「不适用」而非「数据不齐」,且不得显示 0。
|
||||||
dep_avg = _bep_sum_field(db, entity_id, period_from, period_to,
|
na_remarks = [remark_map.get((c, period), "") for c in missing_at_period]
|
||||||
"debit_amount", ("eq", BEP_DEP_SUBJECT)) / 10000.0 / months
|
na_hit = [r for r in na_remarks if "不适用" in r]
|
||||||
rebate_pct = BEP_REBATE_POLICY_PCT
|
if na_hit and BEP_VC_CODE not in missing_at_period:
|
||||||
cm_pct = 100.0 - rebate_pct
|
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}
|
||||||
|
|
||||||
# 账面口径:管理费含折旧(与派生落库值一致)
|
# 派生落库值(同期)—— 账面 / 现金两条路径同一来源,卡面不做实时复算
|
||||||
book_bep = round(admin_avg / (cm_pct / 100.0), 1)
|
d = {c: by_code.get(c, {}).get(period) for c in BEP_CODES}
|
||||||
book_cover = round(sales_avg / book_bep * 100.0, 1) if book_bep else 0.0
|
book_bep = float(d["F_BEP_REVENUE"])
|
||||||
book_gap = round((book_bep - sales_avg) / book_bep * 100.0, 1) if book_bep else 0.0
|
book_cover = float(d["F_BEP_COVER"])
|
||||||
# 现金口径:管理费-折旧;优先读派生KPI最新落库值(单一事实源),缺失时回退现算
|
book_gap = float(d["F_BEP_GAP"])
|
||||||
cash_admin = admin_avg - dep_avg
|
cash_bep = float(d["F_BEP_REVENUE_CASH"])
|
||||||
cash_derived_bep = latest["F_BEP_REVENUE_CASH"]["value"] if latest.get("F_BEP_REVENUE_CASH") else None
|
cash_cover = float(d["F_BEP_COVER_CASH"])
|
||||||
cash_derived_cover = latest["F_BEP_COVER_CASH"]["value"] if latest.get("F_BEP_COVER_CASH") else None
|
cash_gap = round(100.0 - cash_cover, 1) # 缺口 = 100 − 覆盖率(与派生器同口径)
|
||||||
if cash_derived_bep is not None and cash_derived_cover is not None:
|
# ⑤ 口径统一(任总 2026-10-03 18:00「统一」):保本公式用**变动成本率**(F_BEP_VC_RATE),
|
||||||
cash_bep = cash_derived_bep
|
# 边际贡献率 = 1 − 变动成本率;F_BEP_REBATE(实测渠补率)仅酣客(e1)有,**不再充当变动成本率**。
|
||||||
cash_cover = cash_derived_cover
|
vc_pct = float(d[BEP_VC_CODE])
|
||||||
else:
|
cm_pct = round(100.0 - vc_pct, 2)
|
||||||
cash_bep = round(cash_admin / (cm_pct / 100.0), 1) if cash_admin > 0 else 0.0
|
rebate_pct = (float(d[BEP_REBATE_CODE]) if d.get(BEP_REBATE_CODE) is not None else None)
|
||||||
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
|
|
||||||
|
|
||||||
# 回显最新落库派生值(账面3条+现金2条,存在时输出)
|
# 输入口径(万元,展示用;数值口径一律以派生落库值为准)——按实体口径现算
|
||||||
derived = {}
|
inp = _bep_inputs(db, entity_id, cal, period_from, period, months)
|
||||||
for code in BEP_CODES:
|
sales_avg = inp["sales_avg"]
|
||||||
lv = latest[code]
|
admin_avg = inp["admin_avg"]
|
||||||
derived[code] = {"period": lv["period"], "value": lv["value"]} if lv else None
|
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}
|
||||||
|
|
||||||
cover_kpi = kpis["F_BEP_COVER"]
|
cover_kpi = kpis["F_BEP_COVER"]
|
||||||
book_level = _bep_level(book_cover, cover_kpi)
|
book_level = _bep_level(book_cover, cover_kpi)
|
||||||
@@ -174,8 +321,13 @@ def get_bep_card(
|
|||||||
"sales_avg": round(sales_avg, 2),
|
"sales_avg": round(sales_avg, 2),
|
||||||
"admin_avg": round(admin_avg, 2),
|
"admin_avg": round(admin_avg, 2),
|
||||||
"dep_avg": round(dep_avg, 2),
|
"dep_avg": round(dep_avg, 2),
|
||||||
|
"vc_pct": vc_pct,
|
||||||
"rebate_pct": rebate_pct,
|
"rebate_pct": rebate_pct,
|
||||||
"cm_pct": round(cm_pct, 2),
|
"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,
|
||||||
},
|
},
|
||||||
"book": {
|
"book": {
|
||||||
"bep_revenue": book_bep,
|
"bep_revenue": book_bep,
|
||||||
@@ -193,7 +345,9 @@ def get_bep_card(
|
|||||||
"gap_level": cash_gap_level,
|
"gap_level": cash_gap_level,
|
||||||
},
|
},
|
||||||
"derived": derived,
|
"derived": derived,
|
||||||
"formula": "保本销售额 = 月固定成本 ÷ (1 − 渠补率84.61%)",
|
"data_source": "derived", # 账面/现金同源:均读派生落库值
|
||||||
|
"formula": (f"保本销售额 = 月固定成本 ÷ 边际贡献率{cm_pct:.2f}%"
|
||||||
|
f"(边际贡献率 = 1 − 变动成本率{vc_pct:.2f}%)"),
|
||||||
"readonly": True,
|
"readonly": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,9 +449,29 @@ def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month")
|
|||||||
cache_set("dashboard", cache_key, result, ttl_seconds=30)
|
cache_set("dashboard", cache_key, result, ttl_seconds=30)
|
||||||
return result
|
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")
|
@router.get("/kpis")
|
||||||
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
||||||
start_date: str = Query(None), end_date: str = Query(None),
|
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)):
|
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
start, end = parse_period(period, start_date, end_date)
|
start, end = parse_period(period, start_date, end_date)
|
||||||
period_str = start.strftime("%Y-%m")
|
period_str = start.strftime("%Y-%m")
|
||||||
@@ -307,21 +481,51 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
|||||||
KPIDefinition.entity_id == entity_id
|
KPIDefinition.entity_id == entity_id
|
||||||
).all()
|
).all()
|
||||||
result = []
|
result = []
|
||||||
|
forecast_section = [] # 预估区(单列,不计入上面的 actual_value)
|
||||||
|
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
|
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":
|
if period == "month":
|
||||||
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
|
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
|
||||||
elif period == "quarter":
|
elif period == "quarter":
|
||||||
months = period_prefix("quarter")
|
months = period_prefix("quarter")
|
||||||
values = base_query.filter(KPIValue.period.in_(months)).all()
|
latest = _aggregate_actual(actual_query.filter(KPIValue.period.in_(months)).all(), range_label)
|
||||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
if forecast_query is not None:
|
||||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
|
forecast_value = _sum_rows(forecast_query.filter(KPIValue.period.in_(months)).all())
|
||||||
elif period == "year":
|
elif period == "year":
|
||||||
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
|
latest = _aggregate_actual(
|
||||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
actual_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all(), range_label)
|
||||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
|
if forecast_query is not None:
|
||||||
|
forecast_value = _sum_rows(
|
||||||
|
forecast_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all())
|
||||||
elif period == "custom" and start_date and end_date:
|
elif period == "custom" and start_date and end_date:
|
||||||
periods = []
|
periods = []
|
||||||
d = start
|
d = start
|
||||||
@@ -329,36 +533,62 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
|
|||||||
periods.append(d.strftime("%Y-%m"))
|
periods.append(d.strftime("%Y-%m"))
|
||||||
d += timedelta(days=32)
|
d += timedelta(days=32)
|
||||||
d = d.replace(day=1)
|
d = d.replace(day=1)
|
||||||
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
|
latest = _aggregate_actual(actual_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
|
range_label)
|
||||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
|
if forecast_query is not None:
|
||||||
|
forecast_value = _sum_rows(
|
||||||
|
forecast_query.filter(KPIValue.period.in_(set(periods))).all())
|
||||||
else:
|
else:
|
||||||
latest = base_query.order_by(KPIValue.period.desc()).first()
|
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
|
||||||
|
|
||||||
alert = db.query(KPIAlert).filter(
|
alert = db.query(KPIAlert).filter(
|
||||||
KPIAlert.kpi_id == k.id,
|
KPIAlert.kpi_id == k.id,
|
||||||
KPIAlert.status == "pending",
|
KPIAlert.status == "pending",
|
||||||
).order_by(KPIAlert.id.desc()).first()
|
).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({
|
result.append({
|
||||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||||
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
|
"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,
|
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||||
"actual_value": latest.actual_value if latest else None,
|
"actual_value": latest.actual_value if latest else None,
|
||||||
"period": latest.period 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_level": alert.alert_level if alert else "none",
|
||||||
"alert_message": alert.alert_message if alert else None,
|
"alert_message": alert.alert_message if alert else None,
|
||||||
"frequency": k.frequency,
|
"frequency": k.frequency,
|
||||||
"responsible_dept": k.responsible_dept,
|
"responsible_dept": k.responsible_dept,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}}
|
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
|
||||||
|
|
||||||
|
|
||||||
@router.get("/my-kpis")
|
@router.get("/my-kpis")
|
||||||
def get_my_kpis(
|
def get_my_kpis(
|
||||||
current_user: User = Depends(require_auth),
|
current_user: User = Depends(require_auth),
|
||||||
period: str = Query("month"),
|
period: str = Query("month"),
|
||||||
|
include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
@@ -376,6 +606,7 @@ def get_my_kpis(
|
|||||||
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||||
).all()
|
).all()
|
||||||
result = []
|
result = []
|
||||||
|
forecast_section = [] # 预估区(单列,不进 actual_value / trend)
|
||||||
|
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
# business角色筛选
|
# business角色筛选
|
||||||
@@ -384,11 +615,36 @@ def get_my_kpis(
|
|||||||
if responsible and responsible != username and responsible != name:
|
if responsible and responsible != username and responsible != name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 实测区恒过滤(预估行禁作实测值/禁进趋势序列)
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
KPIValue.period == period_str,
|
KPIValue.period == period_str,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.id.desc()).first()
|
).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(
|
alert = db.query(KPIAlert).filter(
|
||||||
KPIAlert.kpi_id == k.id,
|
KPIAlert.kpi_id == k.id,
|
||||||
KPIAlert.status == "pending",
|
KPIAlert.status == "pending",
|
||||||
@@ -396,6 +652,8 @@ def get_my_kpis(
|
|||||||
|
|
||||||
trend_values = db.query(KPIValue).filter(
|
trend_values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
|
month_period_clause(KPIValue.period),
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
||||||
|
|
||||||
@@ -406,6 +664,9 @@ def get_my_kpis(
|
|||||||
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||||
"actual_value": latest.actual_value if latest else None,
|
"actual_value": latest.actual_value if latest else None,
|
||||||
"period": latest.period if latest else period_str,
|
"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_level": alert.alert_level if alert else "none",
|
||||||
"alert_message": alert.alert_message if alert else None,
|
"alert_message": alert.alert_message if alert else None,
|
||||||
"alert_id": alert.id if alert else None,
|
"alert_id": alert.id if alert else None,
|
||||||
@@ -418,13 +679,17 @@ def get_my_kpis(
|
|||||||
"threshold_red": k.threshold_red,
|
"threshold_red": k.threshold_red,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {"data": result, "user_role": role, "user_name": name, "period": period_str}
|
resp = {"data": result, "user_role": role, "user_name": name, "period": period_str}
|
||||||
|
if include_forecast:
|
||||||
|
resp["forecast"] = forecast_section
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.get("/finance-analysis")
|
@router.get("/finance-analysis")
|
||||||
def get_finance_analysis(
|
def get_finance_analysis(
|
||||||
current_user: User = Depends(require_auth),
|
current_user: User = Depends(require_auth),
|
||||||
period: str = Query("month"),
|
period: str = Query("month"),
|
||||||
|
include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
@@ -438,14 +703,33 @@ def get_finance_analysis(
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
kpi_data = []
|
kpi_data = []
|
||||||
|
forecast_section = [] # 预估区(单列,不进 trend / summary)
|
||||||
for k in finance_kpis:
|
for k in finance_kpis:
|
||||||
|
# 实测区恒过滤(预估行禁作实测值/禁进趋势序列)
|
||||||
latest = db.query(KPIValue).filter(
|
latest = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
KPIValue.period == period_str,
|
KPIValue.period == period_str,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.id.desc()).first()
|
).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(
|
trend_values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
|
month_period_clause(KPIValue.period),
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||||
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
|
||||||
|
|
||||||
@@ -459,6 +743,8 @@ def get_finance_analysis(
|
|||||||
"unit": k.unit, "target_value": k.target_value,
|
"unit": k.unit, "target_value": k.target_value,
|
||||||
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
|
||||||
"actual_value": latest.actual_value if latest else None,
|
"actual_value": latest.actual_value if latest else None,
|
||||||
|
"forecast_value": forecast_value,
|
||||||
|
"is_forecast": False,
|
||||||
"threshold_green": k.threshold_green,
|
"threshold_green": k.threshold_green,
|
||||||
"threshold_yellow": k.threshold_yellow,
|
"threshold_yellow": k.threshold_yellow,
|
||||||
"threshold_red": k.threshold_red,
|
"threshold_red": k.threshold_red,
|
||||||
@@ -472,7 +758,7 @@ def get_finance_analysis(
|
|||||||
cost_control = next((k for k in kpi_data if k["kpi_code"] == "COST_CONTROL_RATE"), None)
|
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)
|
receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None)
|
||||||
|
|
||||||
return {
|
resp = {
|
||||||
"period": period_str,
|
"period": period_str,
|
||||||
"kpis": kpi_data,
|
"kpis": kpi_data,
|
||||||
"summary": {
|
"summary": {
|
||||||
@@ -482,11 +768,21 @@ def get_finance_analysis(
|
|||||||
"receivable_turnover": receivable["actual_value"] if receivable else None,
|
"receivable_turnover": receivable["actual_value"] if receivable else None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if include_forecast:
|
||||||
|
resp["forecast"] = forecast_section
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.get("/predict")
|
@router.get("/predict")
|
||||||
def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def predict_kpis(include_forecast: bool = Query(
|
||||||
"""基于历史趋势预测下月KPI值(简单线性回归)"""
|
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`),不改变回归取数。
|
||||||
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
period_str = datetime.now().strftime("%Y-%m")
|
period_str = datetime.now().strftime("%Y-%m")
|
||||||
@@ -502,12 +798,25 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
|
|||||||
KPIDefinition.entity_id == entity_id
|
KPIDefinition.entity_id == entity_id
|
||||||
).all()
|
).all()
|
||||||
predictions = []
|
predictions = []
|
||||||
|
forecast_rows = [] # 预估区(单列,不参与回归)
|
||||||
|
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
values = db.query(KPIValue).filter(
|
values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.asc()).all()
|
).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个数据点才能做预测
|
# 需要至少3个数据点才能做预测
|
||||||
if len(values) < 3:
|
if len(values) < 3:
|
||||||
continue
|
continue
|
||||||
@@ -558,13 +867,16 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
|
|||||||
"data_points": len(points),
|
"data_points": len(points),
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
resp = {
|
||||||
"current_period": period_str,
|
"current_period": period_str,
|
||||||
"next_period": next_period,
|
"next_period": next_period,
|
||||||
"predictions": predictions,
|
"predictions": predictions,
|
||||||
"kpi_count": len(kpis),
|
"kpi_count": len(kpis),
|
||||||
"predictable_count": len(predictions),
|
"predictable_count": len(predictions),
|
||||||
}
|
}
|
||||||
|
if include_forecast:
|
||||||
|
resp["forecast_rows"] = forecast_rows
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
# ── 个人工作台 ──────────────────────────────
|
# ── 个人工作台 ──────────────────────────────
|
||||||
@@ -578,7 +890,8 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
|
|||||||
# H1/H2/FY 等汇总期行,则回退到原按 calculated_at 的取法(不改变无月期 KPI 的行为)。
|
# H1/H2/FY 等汇总期行,则回退到原按 calculated_at 的取法(不改变无月期 KPI 的行为)。
|
||||||
# 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把
|
# 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把
|
||||||
# 季度汇总行/半年度预估值(forecast)当成"最新"。
|
# 季度汇总行/半年度预估值(forecast)当成"最新"。
|
||||||
_MONTH_PERIOD_REGEX = r"^[0-9]{4}-(0[1-9]|1[0-2])$"
|
# 期间口径(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)
|
# 主值优先(2026-09-18 同期间重复行判据收口,项目单 project-20260918-kpi-row-priority)
|
||||||
# 同一期间可能并存两行:①主值(source_type='ledger' / data_status='verified',结账/复核口径)
|
# 同一期间可能并存两行:①主值(source_type='ledger' / data_status='verified',结账/复核口径)
|
||||||
@@ -597,10 +910,15 @@ _MAIN_VALUE_PRIORITY = case(
|
|||||||
|
|
||||||
|
|
||||||
def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
|
def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
|
||||||
"""该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。"""
|
"""该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。
|
||||||
|
|
||||||
|
口径(财务裁定 2026-10-02):**只取实测区**——预估行(data_status='estimated' 或
|
||||||
|
source_type='forecast')不得作为卡片「最新值」(工作台卡片=实测口径)。
|
||||||
|
"""
|
||||||
monthly = (
|
monthly = (
|
||||||
db.query(KPIValue)
|
db.query(KPIValue)
|
||||||
.filter(KPIValue.kpi_id == kpi_id, KPIValue.period.op("REGEXP")(_MONTH_PERIOD_REGEX))
|
.filter(KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period),
|
||||||
|
actual_only_clause())
|
||||||
.order_by(
|
.order_by(
|
||||||
KPIValue.period.desc(),
|
KPIValue.period.desc(),
|
||||||
_MAIN_VALUE_PRIORITY,
|
_MAIN_VALUE_PRIORITY,
|
||||||
@@ -613,7 +931,7 @@ def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
|
|||||||
return monthly
|
return monthly
|
||||||
return (
|
return (
|
||||||
db.query(KPIValue)
|
db.query(KPIValue)
|
||||||
.filter(KPIValue.kpi_id == kpi_id)
|
.filter(KPIValue.kpi_id == kpi_id, actual_only_clause())
|
||||||
.order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc())
|
.order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -622,6 +940,7 @@ def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
|
|||||||
@router.get("/my-dashboard")
|
@router.get("/my-dashboard")
|
||||||
def my_dashboard(
|
def my_dashboard(
|
||||||
current_user: User = Depends(require_auth),
|
current_user: User = Depends(require_auth),
|
||||||
|
include_forecast: bool = Query(False, description="是否单列返回预估区;默认 false=只取实测区"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
entity_id: int = Depends(get_entity_id),
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
@@ -665,9 +984,23 @@ def my_dashboard(
|
|||||||
all_kpis = assigned_kpis + preset_kpis
|
all_kpis = assigned_kpis + preset_kpis
|
||||||
|
|
||||||
kpi_list = []
|
kpi_list = []
|
||||||
|
forecast_section = [] # 预估区(单列,不作卡片实测值)
|
||||||
for k in all_kpis:
|
for k in all_kpis:
|
||||||
latest_v = latest_kpi_value(db, k.id)
|
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",
|
||||||
|
})
|
||||||
actual = latest_v.actual_value if latest_v else None
|
actual = latest_v.actual_value if latest_v else None
|
||||||
target = kpi_target_by_frequency(k)
|
target = kpi_target_by_frequency(k)
|
||||||
level = "gray"
|
level = "gray"
|
||||||
@@ -697,6 +1030,9 @@ def my_dashboard(
|
|||||||
"unit": k.unit,
|
"unit": k.unit,
|
||||||
"level": level,
|
"level": level,
|
||||||
"period": latest_v.period if latest_v else None,
|
"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/管理员看全部)
|
# 2. 我的改善行动(assignee匹配;CEO/管理员看全部)
|
||||||
@@ -776,8 +1112,11 @@ def my_dashboard(
|
|||||||
"related_type": "kpi",
|
"related_type": "kpi",
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
resp = {
|
||||||
"kpis": kpi_list,
|
"kpis": kpi_list,
|
||||||
"action_plans": plan_list,
|
"action_plans": plan_list,
|
||||||
"reminders": reminders,
|
"reminders": reminders,
|
||||||
}
|
}
|
||||||
|
if include_forecast:
|
||||||
|
resp["forecast"] = forecast_section
|
||||||
|
return resp
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Optional
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
|
from app.utils.oplog import log_op
|
||||||
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
|
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
|
||||||
|
|
||||||
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
|
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
|
||||||
@@ -365,7 +366,8 @@ def create_source(data: dict, db: Session = Depends(get_db), entity_id: int = De
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(source)
|
db.refresh(source)
|
||||||
# 操作日志
|
# 操作日志
|
||||||
db.add(OperationLog(action="create_source", target_type="source", detail=source.name))
|
log_op(db, "create_source", target_type="source", target_id=source.id,
|
||||||
|
detail=source.name, entity_id=entity_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
||||||
|
|
||||||
@@ -382,7 +384,8 @@ def update_source(source_id: int, data: dict, db: Session = Depends(get_db), ent
|
|||||||
setattr(source, key, data[key])
|
setattr(source, key, data[key])
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(source)
|
db.refresh(source)
|
||||||
db.add(OperationLog(action="update_source", target_type="source", detail=source.name))
|
log_op(db, "update_source", target_type="source", target_id=source.id,
|
||||||
|
detail=source.name, entity_id=entity_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
||||||
|
|
||||||
@@ -394,7 +397,8 @@ def delete_source(source_id: int, db: Session = Depends(get_db), entity_id: int
|
|||||||
).first()
|
).first()
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(404, "数据源不存在")
|
raise HTTPException(404, "数据源不存在")
|
||||||
db.add(OperationLog(action="delete_source", target_type="source", detail=source.name))
|
log_op(db, "delete_source", target_type="source", target_id=source.id,
|
||||||
|
detail=source.name, entity_id=entity_id)
|
||||||
db.delete(source)
|
db.delete(source)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "删除成功"}
|
return {"message": "删除成功"}
|
||||||
|
|||||||
@@ -107,7 +107,11 @@ def _query_items(
|
|||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
|
|
||||||
if not data_type or data_type == "subject":
|
if not data_type or data_type == "subject":
|
||||||
q = db.query(Subject).filter(Subject.is_active == 1)
|
# 账套分层(2026-09-23):全局标准准则科目 ∪ 本账套私有明细(与 KPI 侧同一账套口径)
|
||||||
|
q = db.query(Subject).filter(
|
||||||
|
Subject.is_active == 1,
|
||||||
|
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||||
|
)
|
||||||
if data_level:
|
if data_level:
|
||||||
q = q.filter(Subject.data_level == data_level)
|
q = q.filter(Subject.data_level == data_level)
|
||||||
if important == 1:
|
if important == 1:
|
||||||
@@ -181,9 +185,13 @@ def get_stats(
|
|||||||
current_user = Depends(require_auth),
|
current_user = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""数据分类分级统计概览(评估前准备看板)"""
|
"""数据分类分级统计概览(评估前准备看板)"""
|
||||||
subject_total = db.query(Subject).filter(Subject.is_active == 1).count()
|
subject_total = db.query(Subject).filter(
|
||||||
|
Subject.is_active == 1,
|
||||||
|
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||||
|
).count()
|
||||||
subject_marked = db.query(Subject).filter(
|
subject_marked = db.query(Subject).filter(
|
||||||
Subject.is_active == 1, Subject.important_flag == 1
|
Subject.is_active == 1, Subject.important_flag == 1,
|
||||||
|
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||||
).count()
|
).count()
|
||||||
kpi_total = db.query(KPIDefinition).filter(
|
kpi_total = db.query(KPIDefinition).filter(
|
||||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||||||
|
|||||||
+998
-147
File diff suppressed because it is too large
Load Diff
@@ -203,6 +203,7 @@ def push_deviation_to_map(
|
|||||||
target_type="map",
|
target_type="map",
|
||||||
target_id=map_id,
|
target_id=map_id,
|
||||||
detail=json.dumps({
|
detail=json.dumps({
|
||||||
|
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
"deviation_id": deviation_id,
|
"deviation_id": deviation_id,
|
||||||
"kpi_code": kpi_code,
|
"kpi_code": kpi_code,
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
"""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
|
||||||
@@ -10,6 +10,7 @@ from app.database import get_db
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import SystemConfig, OperationLog
|
from app.models import SystemConfig, OperationLog
|
||||||
|
from app.utils.oplog import log_op
|
||||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -126,6 +127,7 @@ def set_driver_mode(
|
|||||||
data: dict,
|
data: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
|
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
|
||||||
mode = data.get("mode", "subject")
|
mode = data.get("mode", "subject")
|
||||||
@@ -145,13 +147,11 @@ def set_driver_mode(
|
|||||||
db.add(cfg)
|
db.add(cfg)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
log = OperationLog(
|
# 2026-09-23 ④:审计带账套(会话账套)+ 显式标注该键当前仍是**全局键**
|
||||||
action="update",
|
#(budget_driver_mode 拆账套 = 另单排期项,见 dama-entity-isolation-sweep-20260923 §二之三)
|
||||||
target_type="budget",
|
log_op(db, "update", target_type="budget", target_id=0,
|
||||||
target_id=0,
|
detail={"mode": mode, "action": "切换编制模式", "entity_scope": "global_key"},
|
||||||
detail=json.dumps({"mode": mode, "action": "切换编制模式"}, ensure_ascii=False),
|
entity_id=entity_id)
|
||||||
)
|
|
||||||
db.add(log)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
|
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
|
||||||
@@ -335,6 +335,7 @@ def driver_calculate(
|
|||||||
target_type="budget",
|
target_type="budget",
|
||||||
target_id=budget_record.id,
|
target_id=budget_record.id,
|
||||||
detail=json.dumps({
|
detail=json.dumps({
|
||||||
|
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||||
"name": name,
|
"name": name,
|
||||||
"formula": formula_text,
|
"formula": formula_text,
|
||||||
"factors": complete_factors,
|
"factors": complete_factors,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.auth_middleware import require_role
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.models import KnowledgeEvent, KnowledgeSummary
|
from app.models import KnowledgeEvent, KnowledgeSummary
|
||||||
from app.services.knowledge_service import (
|
from app.services.knowledge_service import (
|
||||||
|
DeepSeekKeyMissing,
|
||||||
generate_summary_sync,
|
generate_summary_sync,
|
||||||
generate_daily_sync,
|
generate_daily_sync,
|
||||||
generate_weekly_sync,
|
generate_weekly_sync,
|
||||||
@@ -150,6 +151,9 @@ def trigger_daily_summary(
|
|||||||
try:
|
try:
|
||||||
result = generate_daily_sync(db, entity_id=entity_id)
|
result = generate_daily_sync(db, entity_id=entity_id)
|
||||||
return {"message": "每日摘要已生成", "summary": result}
|
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:
|
except Exception as e:
|
||||||
logger.exception("每日摘要生成失败")
|
logger.exception("每日摘要生成失败")
|
||||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||||
@@ -164,6 +168,9 @@ def trigger_weekly_summary(
|
|||||||
try:
|
try:
|
||||||
result = generate_weekly_sync(db, entity_id=entity_id)
|
result = generate_weekly_sync(db, entity_id=entity_id)
|
||||||
return {"message": "周度摘要已生成", "summary": result}
|
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:
|
except Exception as e:
|
||||||
logger.exception("周度摘要生成失败")
|
logger.exception("周度摘要生成失败")
|
||||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||||
@@ -178,6 +185,9 @@ def trigger_monthly_summary(
|
|||||||
try:
|
try:
|
||||||
result = generate_monthly_sync(db, entity_id=entity_id)
|
result = generate_monthly_sync(db, entity_id=entity_id)
|
||||||
return {"message": "月度摘要已生成", "summary": result}
|
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:
|
except Exception as e:
|
||||||
logger.exception("月度摘要生成失败")
|
logger.exception("月度摘要生成失败")
|
||||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.database import get_db
|
|||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.models.knowledge_article import KnowledgeArticle
|
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=["知识库嵌入"],
|
router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"],
|
||||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||||
@@ -51,7 +52,21 @@ def list_articles(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if related_page:
|
if related_page:
|
||||||
q = q.filter(KnowledgeArticle.related_page.contains(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))
|
||||||
if category:
|
if category:
|
||||||
q = q.filter(KnowledgeArticle.category == category)
|
q = q.filter(KnowledgeArticle.category == category)
|
||||||
articles = q.order_by(KnowledgeArticle.sort_order.asc(), KnowledgeArticle.id.asc()).all()
|
articles = q.order_by(KnowledgeArticle.sort_order.asc(), KnowledgeArticle.id.asc()).all()
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from app.database import get_db
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
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 (
|
from app.services.causality_verification import (
|
||||||
ALL_STATUSES,
|
ALL_STATUSES,
|
||||||
ALL_SOURCE_TYPES,
|
ALL_SOURCE_TYPES,
|
||||||
@@ -146,7 +148,7 @@ def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int
|
|||||||
)
|
)
|
||||||
if period:
|
if period:
|
||||||
query_values = query_values.filter(KPIValue.period == period)
|
query_values = query_values.filter(KPIValue.period == period)
|
||||||
latest = query_values.order_by(KPIValue.period.desc()).first()
|
latest = query_values.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
|
||||||
if latest:
|
if latest:
|
||||||
current_value = latest.actual_value
|
current_value = latest.actual_value
|
||||||
|
|
||||||
@@ -187,6 +189,7 @@ def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int
|
|||||||
tgt_val = db.query(KPIValue).filter(
|
tgt_val = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == target_id,
|
KPIValue.kpi_id == target_id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
|
month_period_clause(KPIValue.period),
|
||||||
).order_by(KPIValue.period.desc()).first()
|
).order_by(KPIValue.period.desc()).first()
|
||||||
|
|
||||||
predicted_value = None
|
predicted_value = None
|
||||||
@@ -356,8 +359,9 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
|
|||||||
db.add(c)
|
db.add(c)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(c)
|
db.refresh(c)
|
||||||
db.add(OperationLog(action="create", target_type="kpi_causality",
|
log_op(db, "create", target_type="kpi_causality", target_id=c.id,
|
||||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})"))
|
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})",
|
||||||
|
entity_id=c.entity_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _to_dict(c)
|
return _to_dict(c)
|
||||||
|
|
||||||
@@ -407,8 +411,9 @@ def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db
|
|||||||
c.verify_status = target_status
|
c.verify_status = target_status
|
||||||
c.verified_at = datetime.now()
|
c.verified_at = datetime.now()
|
||||||
c.verified_by = str(verified_by)[:50]
|
c.verified_by = str(verified_by)[:50]
|
||||||
db.add(OperationLog(action="verify", target_type="kpi_causality",
|
log_op(db, "verify", target_type="kpi_causality", target_id=causality_id,
|
||||||
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})"))
|
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})",
|
||||||
|
entity_id=c.entity_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(c)
|
db.refresh(c)
|
||||||
d = _to_dict(c)
|
d = _to_dict(c)
|
||||||
|
|||||||
+18
-8
@@ -10,6 +10,9 @@ from app.database import get_db
|
|||||||
from app.deps import get_entity_id
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
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.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.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||||
from app.utils.threshold_parser import (
|
from app.utils.threshold_parser import (
|
||||||
calc_level as _tp_calc_level,
|
calc_level as _tp_calc_level,
|
||||||
@@ -199,6 +202,7 @@ def get_kpi_score(
|
|||||||
)
|
)
|
||||||
if period:
|
if period:
|
||||||
val_query = val_query.filter(KPIValue.period == 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()
|
latest_val = val_query.order_by(KPIValue.period.desc()).first()
|
||||||
|
|
||||||
current_val = latest_val.actual_value if latest_val else None
|
current_val = latest_val.actual_value if latest_val else None
|
||||||
@@ -290,6 +294,7 @@ def get_kpi_glossary(
|
|||||||
latest_val = db.query(KPIValue).filter(
|
latest_val = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == k.id,
|
KPIValue.kpi_id == k.id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
|
month_period_clause(KPIValue.period),
|
||||||
).order_by(KPIValue.period.desc()).first()
|
).order_by(KPIValue.period.desc()).first()
|
||||||
|
|
||||||
current_value = latest_val.actual_value if latest_val else None
|
current_value = latest_val.actual_value if latest_val else None
|
||||||
@@ -567,7 +572,9 @@ def list_kpi_values(
|
|||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
if kpi.entity_id != entity_id:
|
if kpi.entity_id != entity_id:
|
||||||
raise HTTPException(404, "KPI不存在")
|
raise HTTPException(404, "KPI不存在")
|
||||||
vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).all()
|
vals = db.query(KPIValue).filter(
|
||||||
|
KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period)
|
||||||
|
).order_by(KPIValue.period.desc()).all()
|
||||||
return {
|
return {
|
||||||
"data": [{
|
"data": [{
|
||||||
"id": v.id,
|
"id": v.id,
|
||||||
@@ -598,6 +605,8 @@ def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends
|
|||||||
vals = db.query(KPIValue).filter(
|
vals = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi_id,
|
KPIValue.kpi_id == kpi_id,
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
|
# 实测区口径:预估行禁入趋势序列(kpi_value_scope.actual_only_clause)
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.period.asc()).all()
|
).order_by(KPIValue.period.asc()).all()
|
||||||
is_reverse = kpi.kpi_code in REVERSE_INDICATORS
|
is_reverse = kpi.kpi_code in REVERSE_INDICATORS
|
||||||
target = kpi.target_value
|
target = kpi.target_value
|
||||||
@@ -794,7 +803,7 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, enti
|
|||||||
db.add(kpi)
|
db.add(kpi)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(kpi)
|
db.refresh(kpi)
|
||||||
_log(db, 1, "create", "kpi", kpi.id, data)
|
_log(db, 1, "create", "kpi", kpi.id, data, entity_id=entity_id)
|
||||||
return kpi_to_dict(kpi)
|
return kpi_to_dict(kpi)
|
||||||
|
|
||||||
|
|
||||||
@@ -834,7 +843,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:
|
if hasattr(kpi, k) and v is not None:
|
||||||
setattr(kpi, k, v)
|
setattr(kpi, k, v)
|
||||||
db.commit()
|
db.commit()
|
||||||
_log(db, 1, "update", "kpi", kpi_id, data)
|
_log(db, 1, "update", "kpi", kpi_id, data, entity_id=entity_id)
|
||||||
return kpi_to_dict(kpi)
|
return kpi_to_dict(kpi)
|
||||||
|
|
||||||
|
|
||||||
@@ -907,14 +916,15 @@ def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), us
|
|||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
kpi.map_id = map_id
|
kpi.map_id = map_id
|
||||||
db.commit()
|
db.commit()
|
||||||
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id})
|
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id},
|
||||||
|
entity_id=entity_id)
|
||||||
return kpi_to_dict(kpi)
|
return kpi_to_dict(kpi)
|
||||||
|
|
||||||
|
|
||||||
def _log(db, user_id, action, target_type, target_id, detail):
|
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None):
|
||||||
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)
|
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
|
||||||
db.add(log)
|
return log_op(db, action, target_type=target_type, target_id=target_id,
|
||||||
db.commit()
|
detail=detail, user_id=user_id, entity_id=entity_id, commit=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{kpi_id}/objectives")
|
@router.get("/{kpi_id}/objectives")
|
||||||
|
|||||||
+26
-1
@@ -1,10 +1,17 @@
|
|||||||
"""AI获客 - 客户咨询接口"""
|
"""AI获客 - 客户咨询接口"""
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import json, os, datetime, urllib.request
|
import json, os, datetime, subprocess, urllib.request
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/lead", tags=["AI获客"])
|
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):
|
class LeadRequest(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
phone: str
|
phone: str
|
||||||
@@ -31,6 +38,22 @@ def notify_wecom(name, phone, requirement, source):
|
|||||||
except:
|
except:
|
||||||
pass # 通知失败不影响主流程
|
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("")
|
@router.post("")
|
||||||
def create_lead(data: LeadRequest):
|
def create_lead(data: LeadRequest):
|
||||||
"""接收客户咨询并保存"""
|
"""接收客户咨询并保存"""
|
||||||
@@ -45,5 +68,7 @@ def create_lead(data: LeadRequest):
|
|||||||
|
|
||||||
# 异步推送企微通知
|
# 异步推送企微通知
|
||||||
notify_wecom(data.name, data.phone, data.requirement, data.source)
|
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分钟内联系您"}
|
return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""战略地图 · 数据完备度 API(口径唯一真源:CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
|
||||||
|
|
||||||
|
主口径(对外唯一主数):仅地图 65(已发布)15/19 = 78.9%,判据 = 被 23-KPI 集挂接。
|
||||||
|
附注口径(只作附注):全量 4 图 30/31 = 96.8%,判据 = 有KPI挂接(含归档图)。
|
||||||
|
KPI 级:active 行 117(e1=73 / e2=44),按行计,与目标级分列、不相除。
|
||||||
|
|
||||||
|
所有汇总数一律运行时对库内数据现算;判据数据(23-KPI 挂接关系)见
|
||||||
|
app/data/caliber_map_completeness.py。两种禁用组合(仅地图65且按 kpis 非空、全量31且按命中 23-KPI 集)在本模块无生成路径。
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth_middleware import require_auth
|
||||||
|
from app.data import caliber_map_completeness as cal
|
||||||
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
|
from app.models import KPIDefinition, MapObjective, StrategicMap
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/cma/maps/completeness",
|
||||||
|
tags=["战略地图数据完备度"],
|
||||||
|
dependencies=[Depends(require_auth)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pct(n: int, d: int) -> Optional[float]:
|
||||||
|
"""占比只保留 1 位小数;分母为 0 返回 None(展示为「无数据」)。"""
|
||||||
|
if not d:
|
||||||
|
return None
|
||||||
|
return round(n * 100.0 / d, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_scopes(db: Session, entity_id: int):
|
||||||
|
"""返回(主口径地图, 同属主的地图列表)。主口径地图仅认 map_id=65 且属于该实体。"""
|
||||||
|
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).all()
|
||||||
|
primary = next((m for m in maps if m.id == cal.PRIMARY_MAP_ID), None)
|
||||||
|
return primary, maps
|
||||||
|
|
||||||
|
|
||||||
|
def _objective_rows(db: Session, maps):
|
||||||
|
"""拉取给定地图的战略目标,并逐行标记 23-KPI 挂接 / 是否有 KPI。"""
|
||||||
|
map_ids = [m.id for m in maps]
|
||||||
|
if not map_ids:
|
||||||
|
return [], {}
|
||||||
|
title_by_map = {m.id: m.title for m in maps}
|
||||||
|
status_by_map = {m.id: (m.status or "") for m in maps}
|
||||||
|
objs = (
|
||||||
|
db.query(MapObjective)
|
||||||
|
.filter(MapObjective.map_id.in_(map_ids))
|
||||||
|
.order_by(MapObjective.map_id, MapObjective.sort_order, MapObjective.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for o in objs:
|
||||||
|
kpis = o.kpis if isinstance(o.kpis, list) else []
|
||||||
|
rows.append({
|
||||||
|
"objective_id": o.id,
|
||||||
|
"map_id": o.map_id,
|
||||||
|
"map_title": title_by_map.get(o.map_id),
|
||||||
|
"map_status": status_by_map.get(o.map_id),
|
||||||
|
"layer": o.dimension_key,
|
||||||
|
"layer_label": cal.LAYER_LABELS.get(o.dimension_key, o.dimension_key),
|
||||||
|
"name": o.name,
|
||||||
|
"kpis": kpis,
|
||||||
|
"has_kpi": len(kpis) > 0,
|
||||||
|
"covered_23kpi": o.id in cal.LINKED_OBJECTIVES,
|
||||||
|
})
|
||||||
|
return rows, title_by_map
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def completeness_summary(
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""数据完备度总览:目标级(主数 + 附注)与 KPI 级(117 行)两层分列。"""
|
||||||
|
primary_map, maps = _entity_scopes(db, entity_id)
|
||||||
|
rows, _ = _objective_rows(db, maps)
|
||||||
|
|
||||||
|
# ---- 目标级 · 主口径:仅地图 65,判据 = 被 23-KPI 集挂接 ----
|
||||||
|
if primary_map is not None:
|
||||||
|
main_rows = [r for r in rows if r["map_id"] == cal.PRIMARY_MAP_ID]
|
||||||
|
main_total = len(main_rows)
|
||||||
|
main_covered = sum(1 for r in main_rows if r["covered_23kpi"])
|
||||||
|
primary = {
|
||||||
|
"available": True,
|
||||||
|
"caliber": cal.PRIMARY_CALIBER,
|
||||||
|
"label": cal.PRIMARY_LABEL,
|
||||||
|
"map_id": primary_map.id,
|
||||||
|
"map_title": primary_map.title,
|
||||||
|
"map_status": primary_map.status,
|
||||||
|
"covered": main_covered,
|
||||||
|
"total": main_total,
|
||||||
|
"pct": _pct(main_covered, main_total),
|
||||||
|
"display": f"{main_covered}/{main_total}",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
primary = {
|
||||||
|
"available": False,
|
||||||
|
"caliber": cal.PRIMARY_CALIBER,
|
||||||
|
"label": cal.PRIMARY_LABEL,
|
||||||
|
"map_id": cal.PRIMARY_MAP_ID,
|
||||||
|
"covered": None,
|
||||||
|
"total": None,
|
||||||
|
"pct": None,
|
||||||
|
"display": "—",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 目标级 · 附注口径:全量 4 图,判据 = 有 KPI 挂接 ----
|
||||||
|
# 判据复现(definition 件 §5 机读件 20261018-c1-spec/map-objectives-dump.csv 逐行):
|
||||||
|
# 23KPI 覆盖 26 + 非23KPI 但 map_objectives.kpis 非空 4 + 缺口 1 = 30/31。
|
||||||
|
# 即「被 23-KPI 集挂接」与「kpis 非空」取并集;与主口径判据(仅 23-KPI 挂接)不是同一判据。
|
||||||
|
# 附注与主数同属地图 65 的账套(全量 4 图);无地图 65 的账套不适用,展示「—」而非 0。
|
||||||
|
footnote_total = len(rows)
|
||||||
|
footnote_covered = sum(1 for r in rows if r["covered_23kpi"] or r["has_kpi"])
|
||||||
|
footnote_available = primary_map is not None and bool(rows)
|
||||||
|
footnote = {
|
||||||
|
"available": footnote_available,
|
||||||
|
"caliber": cal.FOOTNOTE_CALIBER,
|
||||||
|
"label": cal.FOOTNOTE_LABEL,
|
||||||
|
"covered": footnote_covered if footnote_available else None,
|
||||||
|
"total": footnote_total if footnote_available else None,
|
||||||
|
"pct": _pct(footnote_covered, footnote_total) if footnote_available else None,
|
||||||
|
"display": f"{footnote_covered}/{footnote_total}" if footnote_available else "—",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- KPI 级:active 按行(e1=73 / e2=44,合计 117;去重编码 102 另册)----
|
||||||
|
kpi_rows = (
|
||||||
|
db.query(KPIDefinition)
|
||||||
|
.filter(KPIDefinition.status == "active")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_entity: dict = {}
|
||||||
|
codes = set()
|
||||||
|
for k in kpi_rows:
|
||||||
|
by_entity[k.entity_id] = by_entity.get(k.entity_id, 0) + 1
|
||||||
|
codes.add(k.kpi_code)
|
||||||
|
kpi_level = {
|
||||||
|
"caliber": cal.KPI_CALIBER,
|
||||||
|
"label": cal.KPI_LABEL,
|
||||||
|
"total": len(kpi_rows),
|
||||||
|
"by_entity": [
|
||||||
|
{"entity_id": eid, "count": cnt} for eid, cnt in sorted(by_entity.items())
|
||||||
|
],
|
||||||
|
"distinct_codes": len(codes),
|
||||||
|
"note": "按 kpi_code 去重为独立口径,不与行数相除或换算",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"caliber_id": cal.CALIBER_ID,
|
||||||
|
"caliber_version": cal.CALIBER_VERSION,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"applicable": primary_map is not None,
|
||||||
|
"scope_note": "主口径仅覆盖地图 65(已发布);附注口径含归档图,判据不同,不得互换算",
|
||||||
|
"objective_level": {"primary": primary, "footnote": footnote},
|
||||||
|
"kpi_level": kpi_level,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/objectives")
|
||||||
|
def completeness_objectives(
|
||||||
|
map_id: Optional[int] = None,
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""数据完备度清单(目标级逐行):供空态清单与补齐排期使用。"""
|
||||||
|
_, maps = _entity_scopes(db, entity_id)
|
||||||
|
if map_id is not None:
|
||||||
|
maps = [m for m in maps if m.id == map_id]
|
||||||
|
rows, _ = _objective_rows(db, maps)
|
||||||
|
return {
|
||||||
|
"caliber_id": cal.CALIBER_ID,
|
||||||
|
"caliber_version": cal.CALIBER_VERSION,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"caliber": cal.FOOTNOTE_CALIBER,
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""战略地图 · KPI 摘要接口(真数据 · 口径 CMA-A1 终稿,2026-10-19)
|
||||||
|
|
||||||
|
口径唯一真源:app/data/revenue_caliber.py(含科目映射与禁写清单)
|
||||||
|
本模块**不存任何金额常量**:全部对 voucher_details 现场聚合。
|
||||||
|
|
||||||
|
取数铁律:
|
||||||
|
1. 必经 `summary NOT LIKE '%结转%'` —— 否则损益双向抵销,收入恒为 0(历史踩坑);
|
||||||
|
2. 实体隔离:只取 token 绑定的 entity_id,不跨账套;
|
||||||
|
3. 无数据 ⇒ null + missing_reason,**不得兜底造数**;
|
||||||
|
4. 分业务线分列;禁净额;禁合并成单一「营业收入」(合计仅作可复算校验位)。
|
||||||
|
|
||||||
|
历史期缺同票折扣发票(四件套 0/4)⇒ 金额带 `data_status="estimated"` +
|
||||||
|
`data_tag="测算/待票"`,不得静默按新式重算冒充实账。
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth_middleware import require_auth
|
||||||
|
from app.data import revenue_caliber as rc
|
||||||
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/cma/maps/summary",
|
||||||
|
tags=["战略地图摘要"],
|
||||||
|
dependencies=[Depends(require_auth)],
|
||||||
|
)
|
||||||
|
|
||||||
|
MONTH_RE = "^[0-9]{4}-[0-9]{2}$"
|
||||||
|
NOT_CARRY = "summary NOT LIKE '%结转%'"
|
||||||
|
|
||||||
|
|
||||||
|
def _wan(v) -> Optional[float]:
|
||||||
|
"""元 → 万元(2 位),None 透传。"""
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
return round(float(v) / 10000.0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _period_window(db: Session, entity_id: int):
|
||||||
|
"""取该实体最新有凭证的会计月,窗口 = 当年 01 ~ 最新月(不写死日期)。"""
|
||||||
|
latest = db.execute(text(
|
||||||
|
f"SELECT MAX(period) FROM voucher_details "
|
||||||
|
f"WHERE entity_id = :e AND period REGEXP :re AND {NOT_CARRY}"
|
||||||
|
), {"e": entity_id, "re": MONTH_RE}).scalar()
|
||||||
|
if not latest:
|
||||||
|
return None, None, None
|
||||||
|
year = str(latest)[:4]
|
||||||
|
return f"{year}-01", str(latest), year
|
||||||
|
|
||||||
|
|
||||||
|
def _sum(db: Session, sql: str, params: dict):
|
||||||
|
return db.execute(text(sql), params).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def map_summary(
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""摘要条数据:分业务线营业收入 / 毛利率(新式)/ 管理指标 / 口径切换标识。"""
|
||||||
|
cfg = rc.SUBJECT_MAP.get(entity_id)
|
||||||
|
p0, p1, year = _period_window(db, entity_id)
|
||||||
|
period_label = f"{p0}~{p1}" if p0 else None
|
||||||
|
|
||||||
|
if cfg is None or p0 is None:
|
||||||
|
return {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"period": {"from": p0, "to": p1, "label": period_label},
|
||||||
|
"revenue": {
|
||||||
|
"available": False,
|
||||||
|
"caliber": rc.CALIBER,
|
||||||
|
"caliber_label": rc.CALIBER_LABEL,
|
||||||
|
"unit": "万元",
|
||||||
|
"lines": [],
|
||||||
|
"missing_reason": "该账套无可用凭证期" if cfg else "未配置科目映射",
|
||||||
|
},
|
||||||
|
"gross_margin": {"available": False, "value": None, "missing_reason": "无数据"},
|
||||||
|
"management": [],
|
||||||
|
"switch": rc.CALIBER_SWITCH,
|
||||||
|
"missing_docs": rc.MISSING_DOCS,
|
||||||
|
}
|
||||||
|
|
||||||
|
base = (f"entity_id = :e AND period >= :p0 AND period <= :p1 AND {NOT_CARRY}")
|
||||||
|
params = {"e": entity_id, "p0": p0, "p1": p1}
|
||||||
|
wine = rc.wine_sql("summary")
|
||||||
|
rev_prefix = cfg["revenue"] + "%"
|
||||||
|
|
||||||
|
# ── 1. 收入:主营科目按业务线分列(全额售价,禁净额)──────────────────
|
||||||
|
revenue = {
|
||||||
|
"available": False,
|
||||||
|
"caliber": rc.CALIBER,
|
||||||
|
"caliber_label": rc.CALIBER_LABEL,
|
||||||
|
"unit": "万元",
|
||||||
|
"period": period_label,
|
||||||
|
"lines": [],
|
||||||
|
}
|
||||||
|
if cfg["lines"]:
|
||||||
|
# 表 1:酒类线(keywords 命中)
|
||||||
|
wine_net = _sum(db, f"""
|
||||||
|
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||||
|
WHERE {base} AND subject_code LIKE :pfx AND {wine}
|
||||||
|
""", {**params, "pfx": rev_prefix})
|
||||||
|
# 表 2:非酒类线(同一科目、不命中 keywords)
|
||||||
|
other_net = _sum(db, f"""
|
||||||
|
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||||
|
WHERE {base} AND subject_code LIKE :pfx AND NOT {wine}
|
||||||
|
""", {**params, "pfx": rev_prefix})
|
||||||
|
|
||||||
|
# e1 => [酒类, 其他];e2 => [自营(IT)=非酒类, 代销(酒类)=酒类]
|
||||||
|
values = {}
|
||||||
|
if entity_id == 1:
|
||||||
|
values = {"liquor": wine_net, "other": other_net}
|
||||||
|
elif entity_id == 2:
|
||||||
|
values = {"self_it": other_net, "agency_liquor": wine_net}
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for ln in cfg["lines"]:
|
||||||
|
raw = values.get(ln["key"])
|
||||||
|
lines.append({
|
||||||
|
"key": ln["key"],
|
||||||
|
"label": ln["label"],
|
||||||
|
"amount": _wan(raw),
|
||||||
|
"unit": "万元",
|
||||||
|
"data_status": "verified" if raw is not None else "missing",
|
||||||
|
"missing_reason": None if raw is not None else "该业务线本期无入账凭证",
|
||||||
|
})
|
||||||
|
total = _wan((wine_net or 0) + (other_net or 0)) if (wine_net is not None or other_net is not None) else None
|
||||||
|
revenue = {
|
||||||
|
**revenue,
|
||||||
|
"available": any(l["amount"] is not None for l in lines),
|
||||||
|
"lines": lines,
|
||||||
|
"total": total,
|
||||||
|
"total_label": "合计(各线相加,仅作复算校验)",
|
||||||
|
"merge_forbidden": True,
|
||||||
|
"note": "各业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 2. 毛利率:新式 (收入净额 − 折扣后净进价成本) / 收入净额 ────────────
|
||||||
|
gross_margin = {"available": False, "value": None, "missing_reason": "无数据"}
|
||||||
|
rev_total = _sum(db, f"""
|
||||||
|
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||||
|
WHERE {base} AND subject_code LIKE :pfx
|
||||||
|
""", {**params, "pfx": rev_prefix})
|
||||||
|
cost_total = _sum(db, f"""
|
||||||
|
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
|
||||||
|
WHERE {base} AND subject_code LIKE :pfx
|
||||||
|
""", {**params, "pfx": cfg["cost"] + "%"})
|
||||||
|
disc_total = _sum(db, f"""
|
||||||
|
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||||
|
WHERE {base} AND subject_code LIKE :pfx
|
||||||
|
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
|
||||||
|
|
||||||
|
if rev_total and float(rev_total) != 0 and cost_total is not None:
|
||||||
|
net_cost = float(cost_total) - float(disc_total or 0)
|
||||||
|
gm = (float(rev_total) - net_cost) / float(rev_total) * 100.0
|
||||||
|
gross_margin = {
|
||||||
|
"available": True,
|
||||||
|
"value": round(gm, 2),
|
||||||
|
"unit": "%",
|
||||||
|
"caliber": "net_purchase",
|
||||||
|
"caliber_label": "新式·折扣后净进价",
|
||||||
|
"formula": "(收入净额 − 折扣后净进价成本) / 收入净额 × 100",
|
||||||
|
"period": period_label,
|
||||||
|
# 四件套 0/4 ⇒ 历史期缺同票折扣发票,只可显示现值并带标识
|
||||||
|
"data_status": "estimated",
|
||||||
|
"data_tag": "测算/待票",
|
||||||
|
"note": "旧式(含 k 的 5001/571103 组合)已作废;历史缺票期不得冒充实账",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3. 管理指标(非会计科目,不进损益、不进摘要条主位)───────────────
|
||||||
|
management = []
|
||||||
|
if entity_id == 2:
|
||||||
|
# 与 A1 回执可复现口径一致:分母取**折让前贷方发生额**(639,944.00),
|
||||||
|
# 而不是 6603 净额(556,279.15,已抵扣借方)——两者差 28.24% 与 24.55% 之别。
|
||||||
|
disc = _sum(db, f"""
|
||||||
|
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||||
|
WHERE {base} AND subject_code LIKE :pfx AND {wine}
|
||||||
|
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
|
||||||
|
base_amt = _sum(db, f"""
|
||||||
|
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||||
|
WHERE {base} AND subject_code LIKE '2202%' AND summary LIKE '%采购收货%'
|
||||||
|
AND {rc.supplier_sql('summary')}
|
||||||
|
""", params)
|
||||||
|
if disc is not None and base_amt and float(base_amt) != 0:
|
||||||
|
management.append({
|
||||||
|
"key": "vendor_discount_dependency",
|
||||||
|
"label": "厂商折扣依赖度",
|
||||||
|
"value": round(abs(float(disc)) / float(base_amt) * 100.0, 2),
|
||||||
|
"unit": "%",
|
||||||
|
"numerator": round(abs(float(disc)), 2),
|
||||||
|
"numerator_label": "酒类采购折扣额",
|
||||||
|
"denominator": round(float(base_amt), 2),
|
||||||
|
"denominator_label": "酒类采购额(含税)",
|
||||||
|
"period": period_label,
|
||||||
|
"tag": "管理指标·非会计科目",
|
||||||
|
"caliber_note": "折扣额 ÷ 酒类采购额(含税);改造单 v4 §3 文字写「÷销售额」,"
|
||||||
|
"但 28.24% 的复现分母是采购额,按可复现口径披露,分母已单列可复核",
|
||||||
|
})
|
||||||
|
elif entity_id == 1 and cfg.get("purchase_gift") and rev_total:
|
||||||
|
gift = _sum(db, f"""
|
||||||
|
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
|
||||||
|
WHERE {base} AND subject_code LIKE :pfx
|
||||||
|
""", {**params, "pfx": cfg["purchase_gift"] + "%"})
|
||||||
|
if gift is not None and float(rev_total) != 0:
|
||||||
|
val = round(abs(float(gift)) / float(rev_total) * 100.0, 2)
|
||||||
|
management.append({
|
||||||
|
"key": "vendor_gift_dependency",
|
||||||
|
"label": "厂商赠酒依赖度",
|
||||||
|
"value": val,
|
||||||
|
"unit": "%",
|
||||||
|
"numerator": round(abs(float(gift)), 2),
|
||||||
|
"numerator_label": "采购获赠额",
|
||||||
|
"denominator": round(float(rev_total), 2),
|
||||||
|
"denominator_label": "营业收入(同期)",
|
||||||
|
"period": period_label,
|
||||||
|
"tag": "管理指标·非会计科目",
|
||||||
|
"abnormal": val > 100,
|
||||||
|
"caliber_note": "获赠额 ÷ 同期营业收入;>100% 属异常,须数据侧核查(不作损益行)",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"period": {"from": p0, "to": p1, "label": period_label},
|
||||||
|
"revenue": revenue,
|
||||||
|
"gross_margin": gross_margin,
|
||||||
|
"management": management,
|
||||||
|
"switch": rc.CALIBER_SWITCH,
|
||||||
|
"missing_docs": rc.MISSING_DOCS,
|
||||||
|
"caliber_source": {
|
||||||
|
"status": rc.STATUS,
|
||||||
|
"decided_by": rc.DECIDED_BY,
|
||||||
|
"reason": rc.REASON,
|
||||||
|
},
|
||||||
|
}
|
||||||
+693
-5
@@ -1,6 +1,8 @@
|
|||||||
"""战略地图 API"""
|
"""战略地图 API"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -8,11 +10,22 @@ from app.deps import get_entity_id
|
|||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
|
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
|
||||||
from app.utils.kr_name import sanitize_kr_name
|
from app.utils.kr_name import sanitize_kr_name
|
||||||
|
from app.utils.kpi_value_scope import actual_only_clause
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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=["战略地图"],
|
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
||||||
dependencies=[Depends(require_role("ceo", "finance"))],
|
dependencies=[Depends(require_role("ceo", "finance"))],
|
||||||
)
|
)
|
||||||
@@ -71,6 +84,17 @@ def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity
|
|||||||
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
|
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]}
|
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("")
|
@router.post("")
|
||||||
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
|
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
|
||||||
@@ -110,6 +134,297 @@ def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_i
|
|||||||
return m_to_dict(m, db)
|
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}")
|
@router.put("/{map_id}")
|
||||||
def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_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()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||||
@@ -119,6 +434,25 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id
|
|||||||
if m.entity_id != entity_id:
|
if m.entity_id != entity_id:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
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
|
old_status = m.status
|
||||||
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
|
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
|
||||||
# status 允许但仅限合法值域(draft/published), 防止任意发布
|
# status 允许但仅限合法值域(draft/published), 防止任意发布
|
||||||
@@ -225,8 +559,11 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
|
|||||||
"label": data.get("label", ""),
|
"label": data.get("label", ""),
|
||||||
})
|
})
|
||||||
m.canvas_data["connections"] = conns
|
m.canvas_data["connections"] = conns
|
||||||
|
# D2(2026-10-20):裸 JSON 列不做就地变更跟踪;显式标脏,与模型层 MutableDict 双保险
|
||||||
|
flag_modified(m, "canvas_data")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"connections": conns}
|
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
|
||||||
|
return {"connections": conns, "updated_at": _stamp(m.updated_at)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{map_id}/connections")
|
@router.delete("/{map_id}/connections")
|
||||||
@@ -246,8 +583,11 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_
|
|||||||
raise HTTPException(404, "连线不存在")
|
raise HTTPException(404, "连线不存在")
|
||||||
|
|
||||||
m.canvas_data["connections"] = new_conns
|
m.canvas_data["connections"] = new_conns
|
||||||
|
# D2(2026-10-20):换新列表赋值同样不被裸 JSON 列跟踪 ⇒ 显式标脏(根因修复点)
|
||||||
|
flag_modified(m, "canvas_data")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}}
|
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
|
||||||
|
"updated_at": _stamp(m.updated_at)}
|
||||||
|
|
||||||
|
|
||||||
# ── 版本管理 ─────────────────────────────────
|
# ── 版本管理 ─────────────────────────────────
|
||||||
@@ -655,7 +995,9 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
# 取每个KPI的最新一条
|
# 取每个KPI的最新一条
|
||||||
for kid in kpi_ids:
|
for kid in kpi_ids:
|
||||||
v = db.query(KPIValue).filter(
|
v = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kid
|
KPIValue.kpi_id == kid,
|
||||||
|
# 实测区口径:预估行禁作地图节点实测值(kpi_value_scope.actual_only_clause)
|
||||||
|
actual_only_clause(),
|
||||||
).order_by(KPIValue.calculated_at.desc()).first()
|
).order_by(KPIValue.calculated_at.desc()).first()
|
||||||
if v:
|
if v:
|
||||||
latest_values[kid] = {
|
latest_values[kid] = {
|
||||||
@@ -709,12 +1051,15 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
if level and kpi_def.kpi_level != level:
|
if level and kpi_def.kpi_level != level:
|
||||||
continue
|
continue
|
||||||
lv = latest_values.get(kpi_def.id, {})
|
lv = latest_values.get(kpi_def.id, {})
|
||||||
actual = lv.get("actual_value")
|
# C1 数据前置:held KPI 的实测值一律不外泄(96.3% 零出口)
|
||||||
|
held = code in DATA_HOLD_KPIS
|
||||||
|
actual = None if held else lv.get("actual_value")
|
||||||
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
|
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
|
||||||
target = kpi_def.target_monthly or kpi_def.target_value
|
target = kpi_def.target_monthly or kpi_def.target_value
|
||||||
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
|
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
|
||||||
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
|
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
|
||||||
level = "gray"
|
level = "gray"
|
||||||
|
ratio_val = None
|
||||||
if actual is not None and target:
|
if actual is not None and target:
|
||||||
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
|
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
|
||||||
lower_better = any(k in name for k in
|
lower_better = any(k in name for k in
|
||||||
@@ -725,6 +1070,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
ratio = actual / target
|
ratio = actual / target
|
||||||
if ratio < 0:
|
if ratio < 0:
|
||||||
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
|
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
|
||||||
|
ratio_val = ratio
|
||||||
if ratio >= 0.9:
|
if ratio >= 0.9:
|
||||||
level = "green"
|
level = "green"
|
||||||
elif ratio >= 0.6:
|
elif ratio >= 0.6:
|
||||||
@@ -734,6 +1080,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
else:
|
else:
|
||||||
level = "gray"
|
level = "gray"
|
||||||
|
|
||||||
|
# C1 数据前置:held KPI 不着色、不透出达成率(level=pending 由前端灰虚线呈现)
|
||||||
|
if held:
|
||||||
|
level = "pending"
|
||||||
|
ratio_val = None
|
||||||
|
|
||||||
if level == "red":
|
if level == "red":
|
||||||
worst_level = "red"
|
worst_level = "red"
|
||||||
elif level == "yellow" and worst_level != "red":
|
elif level == "yellow" and worst_level != "red":
|
||||||
@@ -747,14 +1098,29 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
"actual_value": actual,
|
"actual_value": actual,
|
||||||
"unit": kpi_def.unit,
|
"unit": kpi_def.unit,
|
||||||
"level": level,
|
"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 = {
|
obj_item = {
|
||||||
"name": obj.get("name", ""),
|
"name": obj.get("name", ""),
|
||||||
"icon": obj.get("icon", ""),
|
"icon": obj.get("icon", ""),
|
||||||
"kpis": obj_kpis,
|
"kpis": obj_kpis,
|
||||||
"level": worst_level,
|
"level": "pending" if hold_reason else worst_level,
|
||||||
"has_data": len(obj_kpis) > 0,
|
"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统计
|
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
|
||||||
"action_plan_summary": {
|
"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]]),
|
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
|
||||||
@@ -765,6 +1131,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
|||||||
}
|
}
|
||||||
objectives.append(obj_item)
|
objectives.append(obj_item)
|
||||||
|
|
||||||
|
# C1 数据前置:数据待核目标单列,不进达标/关注/风险任何一档
|
||||||
|
if hold_reason:
|
||||||
|
total_hold += 1
|
||||||
|
continue
|
||||||
|
|
||||||
if worst_level == "green":
|
if worst_level == "green":
|
||||||
total_ok += 1
|
total_ok += 1
|
||||||
elif worst_level == "yellow":
|
elif worst_level == "yellow":
|
||||||
@@ -911,3 +1282,320 @@ def get_review_record(
|
|||||||
raise HTTPException(404, "复盘记录不存在")
|
raise HTTPException(404, "复盘记录不存在")
|
||||||
return review_to_dict(r)
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,8 +124,13 @@ def list_logs(page: int = 1,
|
|||||||
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
|
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
|
||||||
.filter(KPIAlert.entity_id == entity_id))
|
.filter(KPIAlert.entity_id == entity_id))
|
||||||
total = scoped.count()
|
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(
|
logs = scoped.order_by(
|
||||||
NotificationLog.created_at.desc()
|
NotificationLog.created_at.desc(), NotificationLog.id.desc()
|
||||||
).offset((page - 1) * 20).limit(20).all()
|
).offset((page - 1) * 20).limit(20).all()
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
|
|||||||
@@ -45,12 +45,22 @@ def trace_ontology(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 第3层: 每个 KPI 依赖的科目 (kpi_subject_map)
|
# 第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 = {
|
subject_by_code = {
|
||||||
s.subject_code: s.subject_name
|
s.subject_code: s.subject_name
|
||||||
for s in db.query(Subject).filter(Subject.is_active == 1).all()
|
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()
|
||||||
}
|
}
|
||||||
kpi_layer = []
|
kpi_layer = []
|
||||||
for link, kpi in links:
|
for link, kpi in links:
|
||||||
|
# kpi_id 已由上方 Objective.entity_id 过滤链继承账套维度(见护栏注释),故此处不再按科目口径过滤
|
||||||
maps = (
|
maps = (
|
||||||
db.query(KPISubjectMap)
|
db.query(KPISubjectMap)
|
||||||
.filter(KPISubjectMap.kpi_id == kpi.id)
|
.filter(KPISubjectMap.kpi_id == kpi.id)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.utils.cash_forecast_engine import (
|
|||||||
)
|
)
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope
|
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.auth_middleware import require_auth_401
|
from app.auth_middleware import require_auth_401
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -113,7 +114,12 @@ def api_scenario_analysis(data: dict):
|
|||||||
|
|
||||||
@router.post("/cvp-detailed")
|
@router.post("/cvp-detailed")
|
||||||
def api_cvp_detailed(data: dict):
|
def api_cvp_detailed(data: dict):
|
||||||
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)"""
|
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)
|
||||||
|
|
||||||
|
⚠️ 情景模拟器(scenario simulation):入参为**假设值**推演(前端滑杆),
|
||||||
|
非 CMA 实测口径。真实实测口径见派生 KPI `F_BEP_*`(kpi_bep_deriver.py,单一事实源);
|
||||||
|
响应显式带 source='scenario_simulation' + is_mock=True,前端据此标注「情景模拟·非实测」。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
fixed_cost = float(data.get("fixed_cost", 617))
|
fixed_cost = float(data.get("fixed_cost", 617))
|
||||||
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
|
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
|
||||||
@@ -150,6 +156,9 @@ def api_cvp_detailed(data: dict):
|
|||||||
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
|
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"source": "scenario_simulation",
|
||||||
|
"is_mock": True,
|
||||||
|
"basis": "user_input_scenario",
|
||||||
"fixed_cost": fixed_cost,
|
"fixed_cost": fixed_cost,
|
||||||
"variable_cost_rate": round(variable_cost_rate * 100, 2),
|
"variable_cost_rate": round(variable_cost_rate * 100, 2),
|
||||||
"unit_price": unit_price,
|
"unit_price": unit_price,
|
||||||
@@ -772,7 +781,7 @@ def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None):
|
|||||||
)
|
)
|
||||||
if period:
|
if period:
|
||||||
q = q.filter(KPIValue.period == period)
|
q = q.filter(KPIValue.period == period)
|
||||||
rows = q.order_by(KPIValue.period.desc()).all()
|
rows = q.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).all()
|
||||||
|
|
||||||
entity = {"entity": ent.name}
|
entity = {"entity": ent.name}
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|||||||
+726
-385
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ from sqlalchemy import func
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.models import Subject
|
from app.models import Subject
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -20,9 +21,21 @@ def list_subjects(
|
|||||||
category: Optional[str] = Query(None, description="新30号准则分类过滤"),
|
category: Optional[str] = Query(None, description="新30号准则分类过滤"),
|
||||||
keyword: Optional[str] = Query(None, description="科目名称/编码搜索"),
|
keyword: Optional[str] = Query(None, description="科目名称/编码搜索"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""科目列表 — 支持新30号准则分类筛选"""
|
"""科目列表 — 支持新30号准则分类筛选
|
||||||
query = db.query(Subject).filter(Subject.is_active == 1)
|
|
||||||
|
账套分层(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), # 分层读取口径
|
||||||
|
)
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
query = query.filter(Subject.new_standard_category == category)
|
query = query.filter(Subject.new_standard_category == category)
|
||||||
@@ -48,6 +61,7 @@ def list_subjects(
|
|||||||
"new_standard_category": s.new_standard_category,
|
"new_standard_category": s.new_standard_category,
|
||||||
"is_active": s.is_active,
|
"is_active": s.is_active,
|
||||||
"remark": s.remark,
|
"remark": s.remark,
|
||||||
|
"entity_id": s.entity_id,
|
||||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||||
}
|
}
|
||||||
for s in subjects
|
for s in subjects
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.models import KPITemplate, KPIDefinition, OperationLog
|
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
|
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
|
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
|
||||||
@@ -74,7 +76,9 @@ def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES)
|
|||||||
db.add(t)
|
db.add(t)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(t)
|
db.refresh(t)
|
||||||
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name})
|
# 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)
|
||||||
return template_to_dict(t)
|
return template_to_dict(t)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,8 +111,16 @@ def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{template_id}/instantiate")
|
@router.post("/{template_id}/instantiate")
|
||||||
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db),
|
||||||
"""从模板实例化创建KPI,复制模板快照到kpi_definitions"""
|
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(反之则放过同账套重复)。
|
||||||
|
"""
|
||||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(404, "模板不存在")
|
raise HTTPException(404, "模板不存在")
|
||||||
@@ -116,10 +128,13 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
|
|||||||
kpi_code = data.get("kpi_code", t.kpi_code)
|
kpi_code = data.get("kpi_code", t.kpi_code)
|
||||||
kpi_name = data.get("kpi_name", t.kpi_name)
|
kpi_name = data.get("kpi_name", t.kpi_name)
|
||||||
|
|
||||||
# 检查编码唯一性
|
# 检查编码唯一性(按账套收紧:仅本账套内判重)
|
||||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
existing = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.kpi_code == kpi_code,
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(400, f"KPI编码 {kpi_code} 已存在,请修改")
|
raise HTTPException(400, f"KPI编码 {kpi_code} 在本账套已存在,请修改")
|
||||||
|
|
||||||
# 数据治理:入库必检(规则1) + 编码规范(规则3)
|
# 数据治理:入库必检(规则1) + 编码规范(规则3)
|
||||||
merged = {
|
merged = {
|
||||||
@@ -136,6 +151,7 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
|
|||||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": kpi_issues_message(gov_issues)})
|
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": kpi_issues_message(gov_issues)})
|
||||||
|
|
||||||
kpi = KPIDefinition(
|
kpi = KPIDefinition(
|
||||||
|
entity_id=entity_id, # 账套维度:显式挂会话账套(不得依赖模型 default=1)
|
||||||
template_id=t.id,
|
template_id=t.id,
|
||||||
is_system=0, # 从模板实例化的KPI不是系统预置
|
is_system=0, # 从模板实例化的KPI不是系统预置
|
||||||
kpi_code=kpi_code,
|
kpi_code=kpi_code,
|
||||||
@@ -161,13 +177,12 @@ def instantiate_template(template_id: int, data: dict, db: Session = Depends(get
|
|||||||
t.usage_count = (t.usage_count or 0) + 1
|
t.usage_count = (t.usage_count or 0) + 1
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code})
|
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code},
|
||||||
|
entity_id=entity_id)
|
||||||
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
|
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
|
||||||
|
|
||||||
|
|
||||||
def _log(db, user_id, action, target_type, target_id, detail):
|
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None, scope=None):
|
||||||
import json
|
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
|
||||||
log = OperationLog(user_id=user_id, action=action, target_type=target_type,
|
return log_op(db, action, target_type=target_type, target_id=target_id,
|
||||||
target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
|
detail=detail, user_id=user_id, entity_id=entity_id, scope=scope, commit=True)
|
||||||
db.add(log)
|
|
||||||
db.commit()
|
|
||||||
|
|||||||
+51
-13
@@ -24,6 +24,15 @@
|
|||||||
无 `entity_id` 列,无需 DDL)。两字面**刻意不合并**:一个改「用户自己的会话账套」、
|
无 `entity_id` 列,无需 DDL)。两字面**刻意不合并**:一个改「用户自己的会话账套」、
|
||||||
一个改「平台全局状态记录」,合并后审计上不可分辨。口径登记见
|
一个改「平台全局状态记录」,合并后审计上不可分辨。口径登记见
|
||||||
`docs/audit-and-notification-caliber-20260922.md`。
|
`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 止血操作无审计行)**不补写**:审计不得事后伪造。
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -32,6 +41,10 @@ from app.database import get_db
|
|||||||
from app.auth_middleware import require_auth_401, require_role
|
from app.auth_middleware import require_auth_401, require_role
|
||||||
from app.models import SystemConfig, Entity, OperationLog
|
from app.models import SystemConfig, Entity, OperationLog
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("cma.tenant")
|
||||||
|
|
||||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||||
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
|
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
|
||||||
# system_config.current_tenant(状态改写面)。收口为 require_auth_401。
|
# system_config.current_tenant(状态改写面)。收口为 require_auth_401。
|
||||||
@@ -82,11 +95,14 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
|
|||||||
raise HTTPException(404, "企业不存在")
|
raise HTTPException(404, "企业不存在")
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from_entity_id = None
|
||||||
previous = None
|
previous = None
|
||||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||||
if cfg and cfg.config_value:
|
if cfg and cfg.config_value:
|
||||||
try:
|
try:
|
||||||
previous = json.loads(cfg.config_value)
|
previous = json.loads(cfg.config_value)
|
||||||
|
if isinstance(previous, dict):
|
||||||
|
from_entity_id = previous.get("entity_id")
|
||||||
except Exception:
|
except Exception:
|
||||||
previous = None
|
previous = None
|
||||||
|
|
||||||
@@ -112,19 +128,41 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
|
|||||||
# 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人);
|
# 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人);
|
||||||
# target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤
|
# target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤
|
||||||
# (operation_logs 无 entity_id 列,无须 DDL)。
|
# (operation_logs 无 entity_id 列,无须 DDL)。
|
||||||
db.add(OperationLog(
|
# 2026-09-23 收口:detail 显式 from→to / source / operator;审计失败不静默(见 §7)。
|
||||||
user_id=user.id,
|
audit_detail = {
|
||||||
action="switch_tenant_state",
|
"from_entity_id": from_entity_id,
|
||||||
target_type="entity",
|
"to_entity_id": data.entity_id,
|
||||||
target_id=data.entity_id,
|
"entity_id": data.entity_id, # 兼容既有消费方/用例(= to)
|
||||||
detail={
|
"entity_name": ent.name,
|
||||||
"entity_id": data.entity_id,
|
"source": data.source,
|
||||||
"entity_name": ent.name,
|
"operator": {
|
||||||
"source": data.source,
|
"user_id": user.id,
|
||||||
"previous": previous,
|
"username": getattr(user, "username", None),
|
||||||
"note": "current_tenant 状态记录变更(非数据维度)",
|
"name": getattr(user, "name", None),
|
||||||
|
"role": getattr(user, "role", None),
|
||||||
},
|
},
|
||||||
))
|
"previous": previous,
|
||||||
db.commit()
|
"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="审计写入失败,状态变更已回滚")
|
||||||
|
|
||||||
return {"success": True, "current": state}
|
return {"success": True, "current": state}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.database import get_db
|
|||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import KPIDefinition, KPIValue, OperationLog
|
from app.models import KPIDefinition, KPIValue, OperationLog
|
||||||
from app.utils.threshold_parser import THRESHOLD_HELP, is_valid_threshold
|
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
|
from typing import Any, Optional
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -129,7 +130,11 @@ def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)):
|
|||||||
if not kpi:
|
if not kpi:
|
||||||
return {"error": "KPI不存在"}
|
return {"error": "KPI不存在"}
|
||||||
|
|
||||||
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
|
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()
|
||||||
|
|
||||||
if not values:
|
if not values:
|
||||||
# 无历史数据,按行业标准推荐
|
# 无历史数据,按行业标准推荐
|
||||||
|
|||||||
@@ -244,9 +244,25 @@ def has_action(user: User, action: str, db: Session = None) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
# ─── KPI可见性(按维度/分类过滤) ───
|
# ─── 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):
|
def _load_kpi_visibility(db: Session = None):
|
||||||
"""从RolePermission表加载kpi_visibility配置(独立缓存)"""
|
"""从RolePermission表加载kpi_visibility配置(独立缓存)
|
||||||
|
|
||||||
|
**非账套维度**(角色级天然全局,口径见上方注释与本档登记):
|
||||||
|
只读 `role_permissions.key = 'kpi_visibility'`,**有意不做账套后缀**——
|
||||||
|
这是集团共享口径,不是漏隔离。
|
||||||
|
"""
|
||||||
import time
|
import time
|
||||||
if not hasattr(_load_kpi_visibility, "_cache"):
|
if not hasattr(_load_kpi_visibility, "_cache"):
|
||||||
_load_kpi_visibility._cache = {"data": None, "ts": 0}
|
_load_kpi_visibility._cache = {"data": None, "ts": 0}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""口径判据数据包(战略地图数据完备度等,只读常量)。"""
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""战略地图数据完备度 · 口径定义(唯一真源引用,勿改判据/分母)
|
||||||
|
|
||||||
|
口径 ID : CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0
|
||||||
|
定义件 : /srv/hermes/mailbox/project/pending-tasks/_reports/
|
||||||
|
caliber-strategy-map-completeness-20261018.md
|
||||||
|
裁定 : ASK-20261018-15 / 任总 2026-10-18T23:07:11「放行14和15」
|
||||||
|
|
||||||
|
本模块只放**判据数据**(23-KPI 挂接关系 + 来源指纹),不存任何汇总数:
|
||||||
|
覆盖率一律由 API 运行时对库内 `map_objectives` / `kpi_definitions` 现算。
|
||||||
|
口径变更 = 重新立 ASK 并改本文件 + 定义件,禁止就地改分母。
|
||||||
|
"""
|
||||||
|
|
||||||
|
CALIBER_ID = "CMA-CALIBER-MAP-COMPLETENESS-20261018"
|
||||||
|
CALIBER_VERSION = "1.0"
|
||||||
|
|
||||||
|
# 主口径:仅已发布地图 65(三年战略规划 2027-2029)
|
||||||
|
PRIMARY_MAP_ID = 65
|
||||||
|
PRIMARY_CALIBER = "map65_23kpi_cover"
|
||||||
|
PRIMARY_LABEL = "地图65·已发布·判据=被23KPI集挂接"
|
||||||
|
FOOTNOTE_CALIBER = "map_all_kpi_attached"
|
||||||
|
FOOTNOTE_LABEL = "全量4图·含归档图·判据=有KPI挂接"
|
||||||
|
KPI_CALIBER = "active_kpi_rows"
|
||||||
|
KPI_LABEL = "KPI 行(active·按行)"
|
||||||
|
|
||||||
|
# 来源指纹(定义件 §5,任何人可复现)
|
||||||
|
# 注:键名为上游件原名(其行数标记属上游历史口径),与本单交付分母无关,不得据此推算分母。
|
||||||
|
SOURCE_FILES = {
|
||||||
|
"map-objectives-dump.csv":
|
||||||
|
"3896963ee7aff0492073f9b61491a10b10f3b131484e1f1006655d2c2f9bda78",
|
||||||
|
"a2-linkage-23kpi.csv":
|
||||||
|
"4494313269938663a197fabecaee644a4f4c66c84a02b725527c07e917f63971",
|
||||||
|
"active-kpi-118.csv":
|
||||||
|
"e37dcd699a06a18d5de9aa5b543b7a48b6d7d12c3e4c440f21186ea870cda1c1",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 被 23-KPI 集挂接的战略目标(objective_id -> (map_id, name))
|
||||||
|
# 来源 a2-linkage-23kpi.csv「挂接类型非空」逐行,26 条(全量 4 图)。
|
||||||
|
LINKED_OBJECTIVES = {
|
||||||
|
1020: (53, "营收增长"),
|
||||||
|
1022: (53, "客户拓展"),
|
||||||
|
1024: (53, "运营效率"),
|
||||||
|
1025: (53, "数字化能力"),
|
||||||
|
1021: (53, "成本与现金流"),
|
||||||
|
1023: (53, "客户亲密"),
|
||||||
|
20888: (57, "营收目标"),
|
||||||
|
20889: (57, "建立渠道分级体系"),
|
||||||
|
21013: (61, "营收目标"),
|
||||||
|
21014: (61, "保障现金流安全"),
|
||||||
|
21016: (61, "产品领先"),
|
||||||
|
22556: (65, "营业收入"),
|
||||||
|
22562: (65, "高贡献线收入占比"),
|
||||||
|
22566: (65, "组合决策机制(负贡献四线退出转投)"),
|
||||||
|
22571: (65, "贡献率思维全员培训"),
|
||||||
|
22557: (65, "营业利润"),
|
||||||
|
22563: (65, "重点客户回款率"),
|
||||||
|
22567: (65, "月度经营闭环"),
|
||||||
|
22572: (65, "渠道经理「选品+算账」能力认证"),
|
||||||
|
22558: (65, "边际贡献率 CM"),
|
||||||
|
22568: (65, "库存去化"),
|
||||||
|
22573: (65, "CMA 月度经营驾驶舱"),
|
||||||
|
22559: (65, "现金周转期 CCC"),
|
||||||
|
22565: (65, "客户集中度"),
|
||||||
|
22574: (65, "贡献率/CCC/净现金入考核与激励"),
|
||||||
|
22570: (65, "数据地基(清脏+改瓶+智享通直连)"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 分层标签(UI 用,与前端 LAYER_CONFIG 一致)
|
||||||
|
LAYER_LABELS = {
|
||||||
|
"finance": "财务",
|
||||||
|
"customer": "客户",
|
||||||
|
"process": "内部流程",
|
||||||
|
"learning": "学习成长",
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""战略地图 · 收入 / 毛利 / 管理指标口径(单一事实源,勿在别处写死)
|
||||||
|
|
||||||
|
口径来源(引用勿改):
|
||||||
|
/srv/hermes/mailbox/finance/pending-tasks/_reports/
|
||||||
|
project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md
|
||||||
|
改造单 project-20261018-cma-strategy-map-ux-revamp-fullstack 增量 v4
|
||||||
|
|
||||||
|
终稿结论(2026-10-19):
|
||||||
|
- 陕西博海、陕西酣客 **均总额法(主要责任人)**;
|
||||||
|
- 增值税 = 销售货物 13%(账内 11 张凭证实测,6% 代理服务零证据);
|
||||||
|
- 上游披露的「累计口径」金额(非年度数)= 冲减采购成本/存货成本(唯一落位,**无「补贴收入/其他收益」行**),
|
||||||
|
本模块不产生该数、也不允许任何调用点拼出该串(千分位与裸整数两种写法均在禁列);
|
||||||
|
- F2 毛利率新式 = (收入净额 − 折扣后净进价成本) / 收入净额 × 100;
|
||||||
|
旧式(含 `k` 的 5001/571103 组合)作废,**不得在本文件或调用点复现**;
|
||||||
|
- 历史期缺同票折扣发票(四件套 0/4)⇒ 金额只作现值展示并带「测算/待票」标识。
|
||||||
|
|
||||||
|
本模块只放**口径与科目映射**,不存金额;金额一律由 API 对库内 voucher_details 现算
|
||||||
|
(filter: summary NOT LIKE '%结转%',否则损益双向抵销归零)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── 收入口径状态(v4 起为已闭合)──────────────────────────────────────────────
|
||||||
|
STATUS = "closed"
|
||||||
|
CALIBER = "total" # total / net / mixed
|
||||||
|
CALIBER_LABEL = "总额法(会计)· 增值税13%"
|
||||||
|
DECIDED_BY = "A1 终稿(project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md)"
|
||||||
|
REASON = "两家均主要责任人 ⇒ 总额法;增值税销售货物 13% 实测,无税会差异"
|
||||||
|
|
||||||
|
# ── 按实体的科目映射(各账套科目体系不同:酣客 5001/5401,博海 6001/6401)──────
|
||||||
|
SUBJECT_MAP = {
|
||||||
|
1: {
|
||||||
|
"revenue": "5001", # 主营业务收入
|
||||||
|
"cost": "5401", # 主营业务成本
|
||||||
|
"purchase_discount": "530111", # 商品折扣(采购)
|
||||||
|
"purchase_gift": "530103", # 采购获赠(捐赠收益)
|
||||||
|
"lines": [
|
||||||
|
{"key": "liquor", "label": "酒类"},
|
||||||
|
{"key": "other", "label": "其他"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"revenue": "6001", # 主营业务收入
|
||||||
|
"cost": "6401", # 主营业务成本
|
||||||
|
"purchase_discount": "6603", # 财务费用-现金折扣(贷方=采购折扣)
|
||||||
|
"purchase_gift": None,
|
||||||
|
"lines": [
|
||||||
|
{"key": "self_it", "label": "自营(IT)"},
|
||||||
|
{"key": "agency_liquor", "label": "代销(酒类)"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 酒类判定(财务 A1 件同一口径):摘要含以下任一关键词 ⇒ 酒类业务线
|
||||||
|
WINE_KEYWORDS = ("酣客", "酒窖", "酣亲", "秦莉", "牛彦平", "横山")
|
||||||
|
|
||||||
|
# 采购来源(博海酒类采购:陕西酣客 / 酣客酒业)
|
||||||
|
LIQUOR_SUPPLIERS = ("酣客", "酒业")
|
||||||
|
|
||||||
|
|
||||||
|
def wine_sql(column: str = "summary") -> str:
|
||||||
|
"""SQL 片段:酒类业务线判定(与财务回执同口径,勿改)。"""
|
||||||
|
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in WINE_KEYWORDS) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def supplier_sql(column: str = "summary") -> str:
|
||||||
|
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in LIQUOR_SUPPLIERS) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 口径切换期标识位(v4 §4:不硬编码日期;生效日以任总拍板 ASK 为准)──────────
|
||||||
|
CALIBER_SWITCH = {
|
||||||
|
"has_switch": True,
|
||||||
|
"label": "口径变更",
|
||||||
|
"effective_date": None, # 未拍板前恒为 None —— 禁止写死日期
|
||||||
|
"comparability": "口径变更·不可比",
|
||||||
|
"note": "同比/达成率/预算执行率在切换期内不可比;生效日以任总拍板(ASK)为准",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 缺件披露(v4 §5:界面不得呈现为「已齐」)─────────────────────────────────
|
||||||
|
MISSING_DOCS = [
|
||||||
|
"经销/代理协议",
|
||||||
|
"厂家价格政策",
|
||||||
|
"逐笔折扣发票+合同审批单",
|
||||||
|
"上游退货条款",
|
||||||
|
"发票台账(invoice_check 空)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def business_lines(entity_id: int) -> list:
|
||||||
|
cfg = SUBJECT_MAP.get(entity_id)
|
||||||
|
return list(cfg["lines"]) if cfg else []
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(entity_id: int) -> dict:
|
||||||
|
"""对外口径快照(无金额;金额见 /api/cma/maps/summary)。"""
|
||||||
|
return {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"status": STATUS,
|
||||||
|
"caliber": CALIBER,
|
||||||
|
"caliber_label": CALIBER_LABEL,
|
||||||
|
"decided_by": DECIDED_BY,
|
||||||
|
"reason": REASON,
|
||||||
|
"allow_single_total": False, # 分业务线分列;不合并成单一「营业收入」
|
||||||
|
"businesses": [
|
||||||
|
{"key": b["key"], "label": b["label"], "unit": "万元"}
|
||||||
|
for b in business_lines(entity_id)
|
||||||
|
],
|
||||||
|
"switch": CALIBER_SWITCH,
|
||||||
|
"missing_docs": MISSING_DOCS,
|
||||||
|
"note": "分业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
|
||||||
|
}
|
||||||
+55
-2
@@ -1,4 +1,6 @@
|
|||||||
"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单"""
|
"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单"""
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import Request, Header, Query, Depends, HTTPException
|
from fastapi import Request, Header, Query, Depends, HTTPException
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -7,6 +9,8 @@ from app.database import get_db
|
|||||||
from app.auth_middleware import get_token_entity_id
|
from app.auth_middleware import get_token_entity_id
|
||||||
from app.models import Entity
|
from app.models import Entity
|
||||||
|
|
||||||
|
logger = logging.getLogger("cma.deps")
|
||||||
|
|
||||||
|
|
||||||
def get_entity_id(
|
def get_entity_id(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -20,7 +24,8 @@ def get_entity_id(
|
|||||||
1. Authorization Bearer token → 优先返回 token.entity_id(唯一可信来源)
|
1. Authorization Bearer token → 优先返回 token.entity_id(唯一可信来源)
|
||||||
2. query参数 / X-Entity-Id header → 仅Bot服务通道使用(校验entity状态active)
|
2. query参数 / X-Entity-Id header → 仅Bot服务通道使用(校验entity状态active)
|
||||||
3. body中的 entity_id(POST场景)→ Bot服务通道兼容
|
3. body中的 entity_id(POST场景)→ Bot服务通道兼容
|
||||||
4. 兜底默认 1(酣客)
|
4. **Bot 通道(带 X-BOT-KEY)无任何显式账套 → 400 显式报错**;
|
||||||
|
无 X-BOT-KEY 的存量公开接口 → 兜底默认 1(酣客)
|
||||||
|
|
||||||
安全说明:登录用户必须通过token绑定账套,query/header传入的entity_id
|
安全说明:登录用户必须通过token绑定账套,query/header传入的entity_id
|
||||||
在token存在时被忽略,防止越权传参(旧漏洞:query优先且无授权校验)。
|
在token存在时被忽略,防止越权传参(旧漏洞:query优先且无授权校验)。
|
||||||
@@ -65,7 +70,23 @@ def get_entity_id(
|
|||||||
# Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity)
|
# Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity)
|
||||||
raise HTTPException(403, f"企业 entity_id={candidate} 不存在或未激活")
|
raise HTTPException(403, f"企业 entity_id={candidate} 不存在或未激活")
|
||||||
|
|
||||||
return 1 # 默认酣客(无token、无参数时兜底,兼容存量公开接口)
|
# 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 / 无参数的存量公开接口:保持原兜底(酣客)
|
||||||
|
|
||||||
|
|
||||||
def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
|
def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
|
||||||
@@ -81,6 +102,38 @@ def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
|
|||||||
return fallback
|
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 补单)
|
# 跨租户读取防护(cma-cross-entity-endpoints-auth-20260914 补单)
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
# -*- 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"
|
||||||
+8
-1
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from app.database import init_db, get_db
|
from app.database import init_db, get_db
|
||||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury
|
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.utils.cache import clear_all as clear_cache, delete as delete_cache
|
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||||
from scripts.erp_sync import run_sync as run_erp_sync
|
from scripts.erp_sync import run_sync as run_erp_sync
|
||||||
from app.auth_middleware import require_auth, require_auth_401
|
from app.auth_middleware import require_auth, require_auth_401
|
||||||
@@ -41,6 +41,11 @@ app.include_router(kpis.router)
|
|||||||
app.include_router(kpi_governance.router)
|
app.include_router(kpi_governance.router)
|
||||||
app.include_router(templates.router)
|
app.include_router(templates.router)
|
||||||
app.include_router(maps.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(dashboard.router)
|
||||||
app.include_router(data.router)
|
app.include_router(data.router)
|
||||||
app.include_router(alerts.router)
|
app.include_router(alerts.router)
|
||||||
@@ -67,6 +72,7 @@ app.include_router(cashflow_structure.compat_router)
|
|||||||
app.include_router(cost.router)
|
app.include_router(cost.router)
|
||||||
app.include_router(predict.router)
|
app.include_router(predict.router)
|
||||||
app.include_router(growth_quality.router)
|
app.include_router(growth_quality.router)
|
||||||
|
app.include_router(map_dimensions.router)
|
||||||
app.include_router(products.router)
|
app.include_router(products.router)
|
||||||
app.include_router(reports.router)
|
app.include_router(reports.router)
|
||||||
app.include_router(security.router)
|
app.include_router(security.router)
|
||||||
@@ -100,6 +106,7 @@ app.include_router(tax_compliance.router)
|
|||||||
app.include_router(verify.router)
|
app.include_router(verify.router)
|
||||||
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
|
app.include_router(ext_input_ledgers.router) # 外部输入台账(M2 准入3 派生,2026-09-15)
|
||||||
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
|
app.include_router(treasury.router) # 司库(资金)阶段一:账户池/头寸/内控闸门(2026-09-15)
|
||||||
|
app.include_router(doc_center.router) # 文档中心:md 单一来源(运行期读盘,2026-09-24)
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""管理会计OS 数据模型"""
|
"""管理会计OS 数据模型"""
|
||||||
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint, Numeric, Date
|
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 app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
from app.models.budget_plan import BudgetPlan
|
from app.models.budget_plan import BudgetPlan
|
||||||
@@ -59,19 +60,29 @@ class StrategicMap(Base):
|
|||||||
version = Column(String(20), default="v1.0", comment="版本号")
|
version = Column(String(20), default="v1.0", comment="版本号")
|
||||||
status = Column(String(20), default="draft", comment="draft/published")
|
status = Column(String(20), default="draft", comment="draft/published")
|
||||||
dimensions = Column(JSON, nullable=True, comment="四维度和目标列表")
|
dimensions = Column(JSON, nullable=True, comment="四维度和目标列表")
|
||||||
canvas_data = Column(JSON, nullable=True, comment="画布连线数据")
|
canvas_data = Column(MutableDict.as_mutable(JSON), nullable=True, comment="画布连线数据")
|
||||||
created_by = Column(Integer, nullable=True)
|
created_by = Column(Integer, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
class KPIDefinition(Base):
|
class KPIDefinition(Base):
|
||||||
"""KPI字典"""
|
"""KPI字典
|
||||||
|
|
||||||
|
账套维度:`kpi_code` 唯一性为 **`(entity_id, kpi_code)` 复合**(生产库 `uk_entity_kpi`,
|
||||||
|
2026-09-23 账套维度收口批与模型对齐——原模型写 `unique=True`(全表唯一)与现网 DDL 不一致,
|
||||||
|
会让 e1/e2 无法各建同码 KPI,也让 SQLite 测试库建不出真实形态)。
|
||||||
|
"""
|
||||||
__tablename__ = "kpi_definitions"
|
__tablename__ = "kpi_definitions"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
entity_id = Column(Integer, default=1, comment="企业ID")
|
entity_id = Column(Integer, default=1, comment="企业ID")
|
||||||
map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
|
map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
|
||||||
kpi_code = Column(String(50), unique=True, nullable=False, comment="KPI编码")
|
# 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_name = Column(String(200), nullable=False, comment="KPI名称")
|
kpi_name = Column(String(200), nullable=False, comment="KPI名称")
|
||||||
dimension = Column(String(50), comment="所属维度: finance/customer/process/learning")
|
dimension = Column(String(50), comment="所属维度: finance/customer/process/learning")
|
||||||
objective = Column(String(200), comment="关联战略目标")
|
objective = Column(String(200), comment="关联战略目标")
|
||||||
@@ -83,7 +94,13 @@ class KPIDefinition(Base):
|
|||||||
data_owner = Column(String(100), default="待指定", comment="数据责任人")
|
data_owner = Column(String(100), default="待指定", comment="数据责任人")
|
||||||
frequency = Column(String(20), default="monthly", comment="daily/weekly/monthly/quarterly/yearly")
|
frequency = Column(String(20), default="monthly", comment="daily/weekly/monthly/quarterly/yearly")
|
||||||
unit = Column(String(50), default="%", comment="单位")
|
unit = Column(String(50), default="%", comment="单位")
|
||||||
target_value = Column(Float, nullable=True, 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_monthly = Column(Float, nullable=True, comment="月度目标值")
|
target_monthly = Column(Float, nullable=True, comment="月度目标值")
|
||||||
target_quarterly = Column(Float, nullable=True, comment="季度目标值")
|
target_quarterly = Column(Float, nullable=True, comment="季度目标值")
|
||||||
target_yearly = Column(Float, nullable=True, comment="年度目标值")
|
target_yearly = Column(Float, nullable=True, comment="年度目标值")
|
||||||
@@ -104,6 +121,9 @@ class KPIDefinition(Base):
|
|||||||
created_by = Column(Integer, nullable=True)
|
created_by = Column(Integer, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("entity_id", "kpi_code", name="uk_entity_kpi"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class KPIValue(Base):
|
class KPIValue(Base):
|
||||||
@@ -168,6 +188,36 @@ class OperationLog(Base):
|
|||||||
created_at = Column(DateTime, server_default=func.now())
|
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):
|
class NotificationChannel(Base):
|
||||||
"""通知渠道配置"""
|
"""通知渠道配置"""
|
||||||
__tablename__ = "notification_channels"
|
__tablename__ = "notification_channels"
|
||||||
@@ -448,6 +498,10 @@ class KPITemplate(Base):
|
|||||||
formula = Column(Text)
|
formula = Column(Text)
|
||||||
formula_desc = Column(String(500))
|
formula_desc = Column(String(500))
|
||||||
unit = Column(String(50))
|
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)
|
target_value = Column(Float)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
is_system = Column(Integer, default=0)
|
is_system = Column(Integer, default=0)
|
||||||
@@ -558,9 +612,16 @@ class ReportHistory(Base):
|
|||||||
|
|
||||||
|
|
||||||
class AnalysisResult(Base):
|
class AnalysisResult(Base):
|
||||||
"""财务Bot分析结论 — 带置信度评分"""
|
"""财务Bot分析结论 — 带置信度评分
|
||||||
|
|
||||||
|
账套维度(2026-09-23 账套化,任务 0-P1-project-20260923-entity-dimension-convergence §三④):
|
||||||
|
`entity_id IS NULL` = 历史未归属行(存量 2 行)——**不参与任何账套过滤**,
|
||||||
|
读取端点单独登记(`unattributed`),删除端点一律 404(不猜账套、不引入 403 探测面)。
|
||||||
|
"""
|
||||||
__tablename__ = "analysis_results"
|
__tablename__ = "analysis_results"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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")
|
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
|
||||||
conclusion = Column(String(1000), nullable=False, comment="分析结论")
|
conclusion = Column(String(1000), nullable=False, comment="分析结论")
|
||||||
confidence = Column(Integer, nullable=False, comment="置信度 0-100")
|
confidence = Column(Integer, nullable=False, comment="置信度 0-100")
|
||||||
@@ -608,10 +669,20 @@ class ScenarioSuggestion(Base):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
class Subject(Base):
|
class Subject(Base):
|
||||||
"""会计科目 — 新30号准则分类"""
|
"""会计科目 — 新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 视为互不相同 ⇒ 全局行的同码重复只能靠写入口判重兜住,
|
||||||
|
新增「全局科目」写入路径时必须显式查重(不可依赖索引)。
|
||||||
|
"""
|
||||||
__tablename__ = "subjects"
|
__tablename__ = "subjects"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
subject_code = Column(String(20), nullable=False, unique=True, comment="科目编码")
|
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_name = Column(String(200), nullable=False, comment="科目名称")
|
subject_name = Column(String(200), nullable=False, comment="科目名称")
|
||||||
parent_code = Column(String(20), nullable=True, comment="上级科目编码")
|
parent_code = Column(String(20), nullable=True, comment="上级科目编码")
|
||||||
level = Column(Integer, default=1, comment="科目级别 1-4")
|
level = Column(Integer, default=1, comment="科目级别 1-4")
|
||||||
@@ -624,6 +695,9 @@ class Subject(Base):
|
|||||||
remark = Column(String(500), nullable=True, comment="备注")
|
remark = Column(String(500), nullable=True, comment="备注")
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("entity_id", "subject_code", name="uk_entity_subject"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VoucherDetail(Base):
|
class VoucherDetail(Base):
|
||||||
@@ -801,6 +875,36 @@ class ObjectiveKPI(Base):
|
|||||||
__table_args__ = (UniqueConstraint("objective_id", "kpi_id", name="uk_obj_kpi"),)
|
__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):
|
class KR(Base):
|
||||||
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
|
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
|
||||||
__tablename__ = "krs"
|
__tablename__ = "krs"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import func, and_
|
from sqlalchemy import func, and_
|
||||||
|
|
||||||
from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue, UserEntity
|
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")
|
logger = logging.getLogger("cma.knowledge")
|
||||||
|
|
||||||
@@ -38,9 +39,23 @@ 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:
|
async def _call_deepseek(prompt: str, timeout: int = 30) -> str:
|
||||||
"""调用DeepSeek API生成摘要"""
|
"""调用DeepSeek API生成摘要"""
|
||||||
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e2...c2e8")
|
api_key = _require_deepseek_key() # 缺 env 直接抛,不拿假 Key 发请求
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
@@ -165,7 +180,7 @@ async def _kpi_snapshot(db: Session, entity_id: int = 1) -> list[dict]:
|
|||||||
for k in kpis:
|
for k in kpis:
|
||||||
latest = (
|
latest = (
|
||||||
db.query(KPIValue)
|
db.query(KPIValue)
|
||||||
.filter(KPIValue.kpi_id == k.id)
|
.filter(KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period))
|
||||||
.order_by(KPIValue.period.desc())
|
.order_by(KPIValue.period.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -652,7 +652,8 @@ def unmapped_subjects(db, entity_id: int, period: str, basis: str, items: Dict[s
|
|||||||
else:
|
else:
|
||||||
q = q.filter(VoucherDetail.period == period)
|
q = q.filter(VoucherDetail.period == period)
|
||||||
rows = apply_voucher_guard(db, q, VoucherDetail).group_by(VoucherDetail.subject_code).all()
|
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).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()}
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ def _subject_patterns(db: Session, kpi_id: int) -> list:
|
|||||||
优先用已推导的取数映射 kpi_value_sources.filter_rule.subject_codes —— 那是把
|
优先用已推导的取数映射 kpi_value_sources.filter_rule.subject_codes —— 那是把
|
||||||
kpi_subject_map(新准则 6xxx) 机械桥接到 voucher_details 实际科目(5xxx/1xxx/2xxx) 的结果;
|
kpi_subject_map(新准则 6xxx) 机械桥接到 voucher_details 实际科目(5xxx/1xxx/2xxx) 的结果;
|
||||||
无映射时退回 kpi_subject_map 原始科目(新老科目体系不一致时明细多为 0)。
|
无映射时退回 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
|
from app.models import KPIValueSource, KPISubjectMap
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,65 @@ def period_in_window(period, now: Optional[datetime] = None) -> bool:
|
|||||||
return str(period) >= window_lower_period(now)
|
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 的一致延伸):可归期原则 + 生成侧守卫 ──
|
# ── R5(裁定 2026-09-19,R3 的一致延伸):可归期原则 + 生成侧守卫 ──
|
||||||
|
|
||||||
def is_attributable_period(period) -> bool:
|
def is_attributable_period(period) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# -*- 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()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# -*- 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
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""比率类 KPI 单位口径归一(取数层) —— 财务口径裁定 裁定2a
|
"""比率类 KPI 单位口径归一(取数层) —— 口径裁定 20260917(2a) + 20260924(v2.1 取代两项)
|
||||||
|
|
||||||
依据:`wecom-finance/reports/CMA_口径裁定_20260917.md` 裁定2a
|
依据(唯一有效出处):`wecom-finance/reports/CMA_口径裁定_20260924_比率类单位口径_取代2a两指标.md`
|
||||||
· 比率类指标**统一为「百分比(%)」口径**:取数 ×100,与现有阈值(流动比率红 <150、速动 <80)同量纲;
|
—— 口径标识 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24`(任总 2026-09-24 16:38 放行 ASK-20260924-11)
|
||||||
· 逐个比率类 KPI**核对定义与单位**,出具对照表;
|
· **取代范围仅两项**:`F_CURRENT_RATIO` / `F_QUICK_RATIO` → 统一「倍」(无量纲),**判定层不再 ×100**
|
||||||
· **单位未核对前的「疑似错配」预警不推送**(降级待核,防假红灯 586/587)。
|
(同量纲直判)、`kpi_definitions` 的 unit/阈值/target_value 同步改倍口径;历史 `kpi_values` **不重算**;
|
||||||
|
· **其余比率类指标仍按 20260917 裁定2a(百分比 %)执行**:毛利率、净利率、资产负债率、渠补率、返利率、
|
||||||
|
收入增长率等;本表内 `F_GROSS_MARGIN` 那行**保持 ×100 不动**;
|
||||||
|
· **不得类推扩用**:再发现第三个「比率类单位/量纲矛盾」指标须**另行裁定**,不得以 v2.1 件为据类推改倍数;
|
||||||
|
· 被取代件 `wecom-finance/reports/CMA_口径裁定_20260917.md`:其 2a 对其余比率类仍有效、2b 仍有效,
|
||||||
|
**原件保留、一字节不改**。
|
||||||
|
· 2a 的过渡纪律(单位未核对前的「疑似错配」预警不推送)继续生效。
|
||||||
|
|
||||||
问题实证(2026-09-17 只读侦查,entity 1 酣客):
|
问题实证(2026-09-17 只读侦查 / 2026-09-24 v2.1 复核,entity 1 酣客):
|
||||||
`kpi_definitions` 声明单位 = %,但**取数**以比率(倍)存储:流动比率 1.29 / 速动比率 0.54 / 毛利率 0.38,
|
`kpi_definitions` 原声明单位 = %,但**取数**以比率(倍)存储:流动比率 1.29 / 速动比率 0.54 / 毛利率 0.38,
|
||||||
而阈值按百分数书写(<150 / <80 / <15)→ 跨 2 个数量级比较,判定不可信(09-09 的 586/587 即此形态)。
|
而阈值按百分数书写(>=200 / >=100 / <15)→ 跨 2 个数量级比较,判定不可信(09-09 的 586/587 即此形态)。
|
||||||
**注意**:按裁定2a 归一后 流动比率 129% < 150 → **红是合法的**(「流动性偏紧」),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` 会**恒绿**,比原「恒红」更坏)。
|
||||||
其余百分数类(返利率 48.44、收入增长率 -82.2、资产负债率 39.9、费用率 30.0、渠补率 75.0 …)
|
其余百分数类(返利率 48.44、收入增长率 -82.2、资产负债率 39.9、费用率 30.0、渠补率 75.0 …)
|
||||||
**取数已是百分数**,不得再 ×100(反过来会成为量级越界)。
|
**取数已是百分数**,不得再 ×100(反过来会成为量级越界)。
|
||||||
|
|
||||||
@@ -18,8 +25,9 @@
|
|||||||
· 归一因子表 `RATIO_CALIBER_TABLE`(kpi_code → 口径/因子/证据/是否已核对);
|
· 归一因子表 `RATIO_CALIBER_TABLE`(kpi_code → 口径/因子/证据/是否已核对);
|
||||||
· 判定侧只调 `resolve_caliber(actual, kpi)`,**不自行做字符串/量级判断**;
|
· 判定侧只调 `resolve_caliber(actual, kpi)`,**不自行做字符串/量级判断**;
|
||||||
· 表外且命中「疑似错配形态」的 KPI → status='dimension_suspect' → 调用方**不落预警、不推送**;
|
· 表外且命中「疑似错配形态」的 KPI → status='dimension_suspect' → 调用方**不落预警、不推送**;
|
||||||
· `norm_factor` 只影响**判定/推送口径**,不改 `kpi_values` 存储值、不改 `kpi_definitions` 阈值
|
· `norm_factor` 只影响**判定/推送口径**:2a 项不改 `kpi_values` 存储值、不改 `kpi_definitions` 阈值
|
||||||
(裁定2a 明确:统一取数而非改阈值)。
|
(统一取数);v2.1 取代的两项反之——**DB 的 unit/阈值已同步改倍口径**,故此处必须同量纲直判(factor=1):
|
||||||
|
**两侧只改一侧 ⇒ 恒红(未改本表)或恒绿(未改 DB)**。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -31,21 +39,22 @@ from app.utils.threshold_parser import parse_clauses
|
|||||||
FACTOR_PERCENT = 100
|
FACTOR_PERCENT = 100
|
||||||
FACTOR_IDENTITY = 1
|
FACTOR_IDENTITY = 1
|
||||||
|
|
||||||
#: 比率类 KPI 单位对照表(裁定2a「逐个核对定义与单位」的落地产物)
|
#: 比率类 KPI 单位对照表(裁定2a「逐个核对定义与单位」的落地产物;2026-09-24 v2.1 取代其中两项)
|
||||||
#: norm_factor=100 → 取数为比率(倍),判定前 ×100 归一到百分数口径
|
#: norm_factor=1 → 取数已是百分数/同量纲,直接判定(含 v2.1 两项「倍」口径:取数与阈值同量纲)
|
||||||
#: norm_factor=1 → 取数已是百分数/同量纲,直接判定
|
#: norm_factor=100 → 取数为比率(倍),判定前 ×100 归一到百分数口径(裁定2a;**本表仅毛利率仍用**)
|
||||||
RATIO_CALIBER_TABLE = {
|
RATIO_CALIBER_TABLE = {
|
||||||
# ---- 需归一(取数=倍,阈值=百分数写法)----
|
# ---- 倍口径(v2.1 @2026-09-24 取代 2a 之两项;取数与 DB 阈值同量纲 ⇒ 不再归一)----
|
||||||
"F_CURRENT_RATIO": {
|
"F_CURRENT_RATIO": {
|
||||||
"name": "流动比率", "declared_unit": "%", "caliber": "百分比(%)",
|
"name": "流动比率", "declared_unit": "倍", "caliber": "倍(无量纲)",
|
||||||
"norm_factor": FACTOR_PERCENT, "verified": True,
|
"norm_factor": FACTOR_IDENTITY, "verified": True,
|
||||||
"evidence": "2026-08 实测 1.29(比率量级)→ ×100 = 129%;阈值 <150 为百分数写法(裁定2a 明示示例:129<150 → 红,与「流动性偏紧」一致)",
|
"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% 判红已作废)",
|
||||||
},
|
},
|
||||||
"F_QUICK_RATIO": {
|
"F_QUICK_RATIO": {
|
||||||
"name": "速动比率", "declared_unit": "%", "caliber": "百分比(%)",
|
"name": "速动比率", "declared_unit": "倍", "caliber": "倍(无量纲)",
|
||||||
"norm_factor": FACTOR_PERCENT, "verified": True,
|
"norm_factor": FACTOR_IDENTITY, "verified": True,
|
||||||
"evidence": "2026-08 实测 0.54 → ×100 = 54%;阈值 <80 为百分数写法(经验值 1:1=100%)",
|
"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 倍",
|
||||||
},
|
},
|
||||||
|
# ---- 需归一(取数=倍,阈值=百分数写法;裁定2a 对其余比率类仍有效,不得顺手改)----
|
||||||
"F_GROSS_MARGIN": {
|
"F_GROSS_MARGIN": {
|
||||||
"name": "毛利率", "declared_unit": "%", "caliber": "百分比(%)",
|
"name": "毛利率", "declared_unit": "%", "caliber": "百分比(%)",
|
||||||
"norm_factor": FACTOR_PERCENT, "verified": True,
|
"norm_factor": FACTOR_PERCENT, "verified": True,
|
||||||
@@ -77,10 +86,17 @@ RATIO_CALIBER_TABLE = {
|
|||||||
"norm_factor": FACTOR_IDENTITY, "verified": True,
|
"norm_factor": FACTOR_IDENTITY, "verified": True,
|
||||||
"evidence": "2026-08 实测 -0.95 已是百分数(小体量亏损月,-0.95% 量级合理)",
|
"evidence": "2026-08 实测 -0.95 已是百分数(小体量亏损月,-0.95% 量级合理)",
|
||||||
},
|
},
|
||||||
"F_ROI": {
|
"F_ROA": {
|
||||||
"name": "总资产报酬率(ROA)", "declared_unit": "%", "caliber": "百分比(%)",
|
"name": "总资产报酬率(ROA)", "declared_unit": "%", "caliber": "百分比(%)",
|
||||||
"norm_factor": FACTOR_IDENTITY, "verified": True,
|
"norm_factor": FACTOR_IDENTITY, "verified": True,
|
||||||
"evidence": "2026-08 实测 -0.57 已是百分数",
|
"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 裁定)",
|
||||||
},
|
},
|
||||||
"F_DEBT_RATIO": {
|
"F_DEBT_RATIO": {
|
||||||
"name": "资产负债率", "declared_unit": "%", "caliber": "百分比(%)",
|
"name": "资产负债率", "declared_unit": "%", "caliber": "百分比(%)",
|
||||||
@@ -235,7 +251,7 @@ class _K:
|
|||||||
|
|
||||||
|
|
||||||
def _self_test() -> int:
|
def _self_test() -> int:
|
||||||
"""零库自证(裁定2a 证据):归一、同量纲直判、疑似错配降级、真实小值不误伤"""
|
"""零库自证(裁定2a + 2026-09-24 v2.1):倍口径直判、2a 归一、疑似错配降级、真实小值不误伤"""
|
||||||
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
|
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
|
||||||
|
|
||||||
def level(actual, kpi):
|
def level(actual, kpi):
|
||||||
@@ -251,10 +267,10 @@ def _self_test() -> int:
|
|||||||
|
|
||||||
cases = (
|
cases = (
|
||||||
# (说明, 实测, KPI, 期望)
|
# (说明, 实测, KPI, 期望)
|
||||||
("流动比率 1.29 → 归一 129% vs 红<150", 1.29,
|
("倍口径(v2.1) 流动比率 1.29 → 直判 1.29 ∈ [1.0,2.0) 黄", 1.29,
|
||||||
_K("F_CURRENT_RATIO", "%", ">=200", ">=150", "<150"), "red"),
|
_K("F_CURRENT_RATIO", "倍", ">=2.0", ">=1.0", "<1.0"), "yellow"),
|
||||||
("速动比率 0.54 → 归一 54% vs 红<80", 0.54,
|
("倍口径(v2.1) 速动比率 0.54 → 直判 0.54 ∈ [0.5,1.0) 黄", 0.54,
|
||||||
_K("F_QUICK_RATIO", "%", ">=100", ">=80", "<80"), "red"),
|
_K("F_QUICK_RATIO", "倍", ">=1.0", ">=0.5", "<0.5"), "yellow"),
|
||||||
("毛利率 0.38 → 归一 38% vs 绿>=20", 0.38,
|
("毛利率 0.38 → 归一 38% vs 绿>=20", 0.38,
|
||||||
_K("F_GROSS_MARGIN", "%", ">=20", ">=18", "<15"), "green"),
|
_K("F_GROSS_MARGIN", "%", ">=20", ">=18", "<15"), "green"),
|
||||||
("返利率 48.44 已百分数 → 红>12(不 ×100)", 48.44,
|
("返利率 48.44 已百分数 → 红>12(不 ×100)", 48.44,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from app.utils.kpi_value_scope import actual_only_clause
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
|
|
||||||
@@ -12,9 +13,17 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger("cma.cash_forecast")
|
logger = logging.getLogger("cma.cash_forecast")
|
||||||
|
|
||||||
# 默认现金阈值(万元)
|
# 默认现金阈值(万元)—— 单一事实源迁至 app/utils/cash_thresholds.py(G1,2026-10-17)
|
||||||
DEFAULT_CASH_WARNING = 20.0 # 黄灯 — 低于20万
|
# 此处保留同名常量以兼容既有 import(app/api/cash.py 等);数值未变:黄 20 / 红 10。
|
||||||
DEFAULT_CASH_CRITICAL = 10.0 # 红灯 — 低于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万
|
||||||
|
|
||||||
# 历史KPI编码映射(含候选编码,兼容F_*标准编码)
|
# 历史KPI编码映射(含候选编码,兼容F_*标准编码)
|
||||||
KPI_CODES = {
|
KPI_CODES = {
|
||||||
@@ -46,13 +55,17 @@ KPI_CODE_SUFFIXES = {
|
|||||||
# ── 余额快照批次(F4,2026-09-13)────────────────────────────
|
# ── 余额快照批次(F4,2026-09-13)────────────────────────────
|
||||||
# 这些 source 是「期末余额导入」批次:plan_date 是**导入基准日**(如 2026-06-30 期末),
|
# 这些 source 是「期末余额导入」批次:plan_date 是**导入基准日**(如 2026-06-30 期末),
|
||||||
# 不是真实到期日 → 逐户生成"到期未收款"会永远 overdue,属伪逾期(DAMA 审计 fin-alert-flood-e2)。
|
# 不是真实到期日 → 逐户生成"到期未收款"会永远 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 = {
|
BALANCE_SNAPSHOT_SOURCES = {
|
||||||
"bohai_ar_20260630",
|
"bohai_ar_20260630",
|
||||||
"bohai_ap_20260630",
|
"bohai_ap_20260630",
|
||||||
"bohai_prepay_20260630",
|
"bohai_prepay_20260630",
|
||||||
"receivables_migrate",
|
"receivables_migrate",
|
||||||
|
"hanke_ar",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# 处理开关:per_customer(逐户,旧行为) / skip(不生成逐户,默认) / aggregate(按批次聚合1条)
|
# 处理开关:per_customer(逐户,旧行为) / skip(不生成逐户,默认) / aggregate(按批次聚合1条)
|
||||||
_SNAPSHOT_MODE_DEFAULT = "skip"
|
_SNAPSHOT_MODE_DEFAULT = "skip"
|
||||||
_SNAPSHOT_MODES = ("per_customer", "skip", "aggregate")
|
_SNAPSHOT_MODES = ("per_customer", "skip", "aggregate")
|
||||||
@@ -202,6 +215,7 @@ def get_entity_kpi_history(entity_id: int, kpi_code: str, db: Session, limit_mon
|
|||||||
return []
|
return []
|
||||||
values = db.query(KPIValue).filter(
|
values = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id,
|
KPIValue.kpi_id == kpi.id,
|
||||||
|
actual_only_clause(), # 实测区口径:预估行禁进历史/趋势序列(kpi_value_scope)
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
).order_by(KPIValue.period.desc()).limit(limit_months).all()
|
).order_by(KPIValue.period.desc()).limit(limit_months).all()
|
||||||
return values
|
return values
|
||||||
@@ -256,6 +270,7 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
|
|||||||
if kpi:
|
if kpi:
|
||||||
latest_cash = db.query(KPIValue).filter(
|
latest_cash = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id,
|
KPIValue.kpi_id == kpi.id,
|
||||||
|
actual_only_clause(), # 实测区口径:预估行禁作现金余额依据(kpi_value_scope)
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
).order_by(KPIValue.period.desc()).first()
|
).order_by(KPIValue.period.desc()).first()
|
||||||
if latest_cash and latest_cash.actual_value is not None:
|
if latest_cash and latest_cash.actual_value is not None:
|
||||||
@@ -303,6 +318,8 @@ def forecast_cash_flow(
|
|||||||
db: Session,
|
db: Session,
|
||||||
days: int = 30,
|
days: int = 30,
|
||||||
current_cash: Optional[float] = None,
|
current_cash: Optional[float] = None,
|
||||||
|
warning_line: Optional[float] = None,
|
||||||
|
critical_line: Optional[float] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
预测未来30天现金流
|
预测未来30天现金流
|
||||||
@@ -311,9 +328,17 @@ def forecast_cash_flow(
|
|||||||
1. 获取历史经营现金流、应收、应付趋势
|
1. 获取历史经营现金流、应收、应付趋势
|
||||||
2. 推算每日现金流入/流出
|
2. 推算每日现金流入/流出
|
||||||
3. 生成每日预测值+置信区间
|
3. 生成每日预测值+置信区间
|
||||||
|
|
||||||
|
阈值(G1,2026-10-17):warning_line / critical_line 传 None 时取本账套配置
|
||||||
|
(alert_rules: cash_gap_threshold),未配置则默认 20/10 万;显式传入优先(便于复算对拍)。
|
||||||
"""
|
"""
|
||||||
from app.models import KPIDefinition, KPIValue
|
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:
|
if current_cash is None:
|
||||||
base_cash = get_current_cash_balance(db, entity_id)
|
base_cash = get_current_cash_balance(db, entity_id)
|
||||||
@@ -372,9 +397,9 @@ def forecast_cash_flow(
|
|||||||
upper = cash + std * 0.5
|
upper = cash + std * 0.5
|
||||||
|
|
||||||
# 预警状态
|
# 预警状态
|
||||||
if cash < DEFAULT_CASH_CRITICAL:
|
if cash < critical_line:
|
||||||
status = "red"
|
status = "red"
|
||||||
elif cash < DEFAULT_CASH_WARNING:
|
elif cash < warning_line:
|
||||||
status = "yellow"
|
status = "yellow"
|
||||||
else:
|
else:
|
||||||
status = "green"
|
status = "green"
|
||||||
@@ -393,20 +418,20 @@ def forecast_cash_flow(
|
|||||||
min_date = next(f["date"] for f in forecast if f["predicted_cash"] == min_cash)
|
min_date = next(f["date"] for f in forecast if f["predicted_cash"] == min_cash)
|
||||||
|
|
||||||
suggestions = []
|
suggestions = []
|
||||||
if min_cash < DEFAULT_CASH_CRITICAL:
|
if min_cash < critical_line:
|
||||||
suggestions.append({
|
suggestions.append({
|
||||||
"type": "critical",
|
"type": "critical",
|
||||||
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于警戒线{DEFAULT_CASH_CRITICAL}万",
|
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于警戒线{critical_line}万",
|
||||||
"actions": [
|
"actions": [
|
||||||
"立即催收大额应收账款",
|
"立即催收大额应收账款",
|
||||||
"暂停非必要支出",
|
"暂停非必要支出",
|
||||||
"准备短期融资安排",
|
"准备短期融资安排",
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
elif min_cash < DEFAULT_CASH_WARNING:
|
elif min_cash < warning_line:
|
||||||
suggestions.append({
|
suggestions.append({
|
||||||
"type": "warning",
|
"type": "warning",
|
||||||
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于关注线{DEFAULT_CASH_WARNING}万",
|
"message": f"预计{min_date}现金余额降至{min_cash:.1f}万,低于关注线{warning_line}万",
|
||||||
"actions": [
|
"actions": [
|
||||||
"加快应收账款回款",
|
"加快应收账款回款",
|
||||||
"控制采购付款节奏",
|
"控制采购付款节奏",
|
||||||
@@ -467,6 +492,7 @@ def calculate_accuracy(entity_id: int, db: Session) -> list:
|
|||||||
|
|
||||||
actuals = db.query(KPIValue).filter(
|
actuals = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == cash_kpi.id,
|
KPIValue.kpi_id == cash_kpi.id,
|
||||||
|
actual_only_clause(), # 实测区口径:预估行禁进实际值序列(预测输入)(kpi_value_scope)
|
||||||
KPIValue.actual_value.isnot(None),
|
KPIValue.actual_value.isnot(None),
|
||||||
).order_by(KPIValue.period.desc()).limit(12).all()
|
).order_by(KPIValue.period.desc()).limit(12).all()
|
||||||
|
|
||||||
@@ -602,8 +628,8 @@ def forecast_cash_flow_with_plans(
|
|||||||
db: Session,
|
db: Session,
|
||||||
days: int = 30,
|
days: int = 30,
|
||||||
current_cash: Optional[float] = None,
|
current_cash: Optional[float] = None,
|
||||||
warning_line: float = DEFAULT_CASH_WARNING,
|
warning_line: Optional[float] = None,
|
||||||
critical_line: float = DEFAULT_CASH_CRITICAL,
|
critical_line: Optional[float] = None,
|
||||||
include_plans: bool = True,
|
include_plans: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -612,11 +638,21 @@ def forecast_cash_flow_with_plans(
|
|||||||
2. 叠加 cash_plans 的应收(收) / 应付(付)
|
2. 叠加 cash_plans 的应收(收) / 应付(付)
|
||||||
3. 识别资金缺口日期(余额 < 警戒线)
|
3. 识别资金缺口日期(余额 < 警戒线)
|
||||||
4. 生成缺口前3天预警点
|
4. 生成缺口前3天预警点
|
||||||
|
|
||||||
|
阈值来源(G1,2026-10-17):None → 本账套配置(alert_rules: cash_gap_threshold)
|
||||||
|
→ 未配置时默认 20/10 万;显式传入值优先(复算对拍用)。响应内 `threshold_source`
|
||||||
|
明写本次取值来源,便于页面标注与回执取证。
|
||||||
"""
|
"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from app.models import CashPlan
|
from app.models import CashPlan
|
||||||
|
|
||||||
base = forecast_cash_flow(entity_id, db, days, current_cash)
|
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_cash = base["base_cash"]
|
base_cash = base["base_cash"]
|
||||||
|
|
||||||
# 预算校准:本月经营现金流预算优先作为基线(万元/月)
|
# 预算校准:本月经营现金流预算优先作为基线(万元/月)
|
||||||
@@ -801,6 +837,7 @@ def forecast_cash_flow_with_plans(
|
|||||||
"days": days,
|
"days": days,
|
||||||
"warning_line": warning_line,
|
"warning_line": warning_line,
|
||||||
"critical_line": critical_line,
|
"critical_line": critical_line,
|
||||||
|
"threshold_source": describe_cash_threshold_source(db, entity_id),
|
||||||
"budget_monthly_ocf": budget_ocf,
|
"budget_monthly_ocf": budget_ocf,
|
||||||
"forecast": forecast,
|
"forecast": forecast,
|
||||||
"gap_dates": gap_dates,
|
"gap_dates": gap_dates,
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""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大)
|
||||||
|
}
|
||||||
@@ -13,11 +13,13 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.database import get_session_local
|
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
|
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
|
||||||
# 裁定 R1/R2/R3(2026-09-19 财务口径):demo 账套排除 / 期间窗口 / 规整月期间
|
# 裁定 R1/R2/R3(2026-09-19 财务口径):demo 账套排除 / 期间窗口 / 规整月期间
|
||||||
from app.utils.alert_entity_scope import (
|
from app.utils.alert_entity_scope import (
|
||||||
excluded_entity_ids, kpi_excluded, period_in_window, window_lower_period,
|
excluded_entity_ids, kpi_excluded, period_in_window, window_lower_period,
|
||||||
guard_alert_period, # R5-补1(2026-09-19):期间可归期 + 关联 kpi_values
|
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")
|
logger = logging.getLogger("cma.deviation")
|
||||||
@@ -32,10 +34,32 @@ DEFAULT_HIGHER_BETTER = [
|
|||||||
CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better"
|
CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better"
|
||||||
|
|
||||||
|
|
||||||
def get_higher_better_codes(db, entity_id: int = None) -> list:
|
def higher_better_key(entity_id: int) -> str:
|
||||||
"""读取越高越好型KPI编码列表(system_configs 可维护,无配置回落默认)"""
|
"""账套维度的方向配置键名:`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` 只删新键,回退老码即可继续读旧键)。
|
||||||
|
"""
|
||||||
cfg = db.query(SystemConfig).filter(
|
cfg = db.query(SystemConfig).filter(
|
||||||
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
|
SystemConfig.config_key == higher_better_key(entity_id)
|
||||||
).first()
|
).first()
|
||||||
if cfg and cfg.config_value:
|
if cfg and cfg.config_value:
|
||||||
try:
|
try:
|
||||||
@@ -43,7 +67,7 @@ def get_higher_better_codes(db, entity_id: int = None) -> list:
|
|||||||
if isinstance(codes, list):
|
if isinstance(codes, list):
|
||||||
return [str(c) for c in codes]
|
return [str(c) for c in codes]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("system_configs[%s] 解析失败, 回落默认", CONFIG_KEY_HIGHER_BETTER)
|
logger.warning("system_configs[%s] 解析失败, 回落默认", higher_better_key(entity_id))
|
||||||
return list(DEFAULT_HIGHER_BETTER)
|
return list(DEFAULT_HIGHER_BETTER)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,14 +94,15 @@ def calc_deviation(actual: float, budget: float) -> dict:
|
|||||||
|
|
||||||
def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Optional[float]:
|
def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Optional[float]:
|
||||||
"""获取指定KPI在指定期间的预算值"""
|
"""获取指定KPI在指定期间的预算值"""
|
||||||
|
# 生效版口径(2026-10-03 状态机归一):approved(已批准) ∪ active(已下达)
|
||||||
query = db.query(BudgetPlan).filter(
|
query = db.query(BudgetPlan).filter(
|
||||||
BudgetPlan.kpi_id == kpi_id,
|
BudgetPlan.kpi_id == kpi_id,
|
||||||
BudgetPlan.period == period,
|
BudgetPlan.period == period,
|
||||||
BudgetPlan.status == "active",
|
BudgetPlan.status.in_(EFFECTIVE_STATUSES),
|
||||||
)
|
)
|
||||||
if version:
|
if version:
|
||||||
query = query.filter(BudgetPlan.version == version)
|
query = query.filter(BudgetPlan.version == version)
|
||||||
plan = query.order_by(BudgetPlan.updated_at.desc()).first()
|
plan = pick_effective(query.all()) # active > approved > updated_at desc
|
||||||
return plan.budget_value if plan else None
|
return plan.budget_value if plan else None
|
||||||
|
|
||||||
|
|
||||||
@@ -301,7 +326,8 @@ def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: fl
|
|||||||
actual = deviation.get("actual_value")
|
actual = deviation.get("actual_value")
|
||||||
budget = deviation.get("budget_value")
|
budget = deviation.get("budget_value")
|
||||||
|
|
||||||
# 方向性:越高越好型(配置化,system_configs.kpi_alert_higher_better)
|
# 方向性:越高越好型(配置化,本账套键 system_configs.kpi_alert_higher_better.<entity_id>;
|
||||||
|
# 2026-09-23 拆账套:entity_id 由调用方必传,不再读全局唯一键)
|
||||||
higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id)
|
higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id)
|
||||||
|
|
||||||
if higher_better:
|
if higher_better:
|
||||||
@@ -358,14 +384,18 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
|||||||
文案期间不可解析为 `YYYY-MM` 或该 (kpi, period) 无 `kpi_values` 行 → 跳过不入库;
|
文案期间不可解析为 `YYYY-MM` 或该 (kpi, period) 无 `kpi_values` 行 → 跳过不入库;
|
||||||
趋势类以「计算基准期(趋势末端当期)」为关联期间;差异类去重键
|
趋势类以「计算基准期(趋势末端当期)」为关联期间;差异类去重键
|
||||||
= `(entity_id, kpi_id, kpi_value_id, alert_type)`。
|
= `(entity_id, kpi_id, kpi_value_id, alert_type)`。
|
||||||
"""
|
|
||||||
if period is None:
|
|
||||||
period = datetime.now().strftime("%Y-%m")
|
|
||||||
if not period_in_window(period):
|
|
||||||
logger.warning(" 差异预警跳过(R2/R3): 期间 %s 非规整月或早于窗口下限 %s",
|
|
||||||
period, window_lower_period())
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
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)
|
excluded = excluded_entity_ids(db_session)
|
||||||
kpis = db_session.query(KPIDefinition).filter(
|
kpis = db_session.query(KPIDefinition).filter(
|
||||||
KPIDefinition.status == "active"
|
KPIDefinition.status == "active"
|
||||||
@@ -375,17 +405,35 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
|||||||
skipped_demo = 0
|
skipped_demo = 0
|
||||||
skipped_period_linkage = 0
|
skipped_period_linkage = 0
|
||||||
skipped_linkage_kpis = []
|
skipped_linkage_kpis = []
|
||||||
|
skipped_no_period = 0
|
||||||
|
skipped_out_of_window = 0
|
||||||
|
periods_used = {}
|
||||||
for kpi in kpis:
|
for kpi in kpis:
|
||||||
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
|
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
|
||||||
if kpi_excluded(db_session, kpi, excluded):
|
if kpi_excluded(db_session, kpi, excluded):
|
||||||
skipped_demo += 1
|
skipped_demo += 1
|
||||||
continue
|
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 预算(统一逻辑)
|
# 1. 差异预警:实际 vs 预算(统一逻辑)
|
||||||
result = build_deviation_alert(db_session, kpi, period)
|
result = build_deviation_alert(db_session, kpi, p)
|
||||||
if result["triggered"]:
|
if result["triggered"]:
|
||||||
deviation = result["deviation"]
|
deviation = result["deviation"]
|
||||||
alert_msg = (
|
alert_msg = (
|
||||||
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
|
f"{kpi.kpi_name}[{p}] 差异预警: 实际{deviation['actual_value']} "
|
||||||
f"vs 预算{deviation['budget_value']},"
|
f"vs 预算{deviation['budget_value']},"
|
||||||
f"差异率{deviation['deviation_rate']}%"
|
f"差异率{deviation['deviation_rate']}%"
|
||||||
)
|
)
|
||||||
@@ -393,10 +441,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
|||||||
|
|
||||||
# R5-补1(裁定 2026-09-19):期间可归期 + 必须落 kpi_value_id 关联
|
# R5-补1(裁定 2026-09-19):期间可归期 + 必须落 kpi_value_id 关联
|
||||||
#(文案里的期间不构成可归期;无 kpi_values 行 → 跳过不入库)
|
#(文案里的期间不构成可归期;无 kpi_values 行 → 跳过不入库)
|
||||||
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, period)
|
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, p)
|
||||||
if linked_kv is None:
|
if linked_kv is None:
|
||||||
skipped_period_linkage += 1
|
skipped_period_linkage += 1
|
||||||
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, period))
|
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, p))
|
||||||
else:
|
else:
|
||||||
# 去重键(R5-补1 变更):(entity_id, kpi_id, kpi_value_id, alert_type)
|
# 去重键(R5-补1 变更):(entity_id, kpi_id, kpi_value_id, alert_type)
|
||||||
#(原按 `alert_message contains period` 判断,改关联后按可归期目标去重)
|
#(原按 `alert_message contains period` 判断,改关联后按可归期目标去重)
|
||||||
@@ -423,13 +471,13 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
|||||||
new_count += 1
|
new_count += 1
|
||||||
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
|
||||||
|
|
||||||
# 2. 趋势异常检测(每期检查连续3期)
|
# 2. 趋势异常检测(每期检查连续3期;末端期 = 本 KPI 的回落期间 p)
|
||||||
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
|
trend = check_trend_anomaly(db_session, kpi.id, p, consecutive=3)
|
||||||
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
|
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
|
||||||
trend_alert_msg = trend["message"]
|
trend_alert_msg = trend["message"]
|
||||||
# R5-补1:趋势类以「计算基准期(趋势末端当期)」为关联期间;该期无行 → 跳过
|
# R5-补1:趋势类以「计算基准期(趋势末端当期)」为关联期间;该期无行 → 跳过
|
||||||
trend_periods = trend.get("periods") or []
|
trend_periods = trend.get("periods") or []
|
||||||
base_period = trend_periods[-1] if trend_periods else period
|
base_period = trend_periods[-1] if trend_periods else p
|
||||||
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, base_period)
|
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, base_period)
|
||||||
if linked_kv is None:
|
if linked_kv is None:
|
||||||
skipped_period_linkage += 1
|
skipped_period_linkage += 1
|
||||||
@@ -456,6 +504,9 @@ def run_deviation_check(db_session, period: str = None) -> int:
|
|||||||
|
|
||||||
if skipped_demo:
|
if skipped_demo:
|
||||||
logger.info(" 差异预警跳过 demo 账套 KPI %s 项(R1,裁定 2026-09-19)", 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:
|
if skipped_period_linkage:
|
||||||
logger.info(" 差异/趋势预警跳过(R5-补1 期间不可归期或无 kpi_values 行)KPI %s 项: %s",
|
logger.info(" 差异/趋势预警跳过(R5-补1 期间不可归期或无 kpi_values 行)KPI %s 项: %s",
|
||||||
skipped_period_linkage, skipped_linkage_kpis)
|
skipped_period_linkage, skipped_linkage_kpis)
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
# -*- 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
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# -*- 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
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
# -*- 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
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# -*- 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
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
# -*- 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)}}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# -*- 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) + "/:%"
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# -*- 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"]}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# -*- 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()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- 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()
|
||||||
@@ -16,8 +16,12 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
from app.database import get_session_local
|
from app.database import get_session_local
|
||||||
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog
|
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog
|
||||||
|
from app.utils.oplog import log_op
|
||||||
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
|
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
|
||||||
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
|
# 口径双轨(唯一事实源 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);
|
||||||
# 未核对/疑似单位错配的项降级待核(不落预警、不推送)
|
# 未核对/疑似单位错配的项降级待核(不落预警、不推送)
|
||||||
from app.utils.caliber_units import resolve_caliber
|
from app.utils.caliber_units import resolve_caliber
|
||||||
# 裁定 R1/R2/R3(2026-09-19 财务口径):账套范围(demo 排除)/ 期间窗口 / 规整月期间
|
# 裁定 R1/R2/R3(2026-09-19 财务口径):账套范围(demo 排除)/ 期间窗口 / 规整月期间
|
||||||
@@ -40,17 +44,15 @@ def resolve_latest_period(db_session, kpi_id=None):
|
|||||||
也不被别人(他账套/演示账套)更新的新期间带偏——实证:全局 MAX(period)=2026-09
|
也不被别人(他账套/演示账套)更新的新期间带偏——实证:全局 MAX(period)=2026-09
|
||||||
但那是演示账套 e6 的 8 条数据,酣客(e1) 的最新可用期间是 2026-08。
|
但那是演示账套 e6 的 8 条数据,酣客(e1) 的最新可用期间是 2026-08。
|
||||||
|
|
||||||
实现说明:伪期间过滤在 **Python 侧**做(`SELECT DISTINCT period` 只有十几行),
|
2026-09-25(ASK-20260924-15):实现**收口到唯一事实源**
|
||||||
不依赖 MySQL 的 `REGEXP` —— SQLite 测试库不注册 REGEXP,用 SQL 侧正则会让
|
`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 实测)。
|
||||||
"""
|
"""
|
||||||
q = db_session.query(KPIValue.period).filter(KPIValue.actual_value.isnot(None))
|
from app.utils.alert_entity_scope import resolve_latest_available_period
|
||||||
if kpi_id is not None:
|
return resolve_latest_available_period(db_session, kpi_id)
|
||||||
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:
|
def _parse_threshold(expr: str) -> tuple:
|
||||||
@@ -141,11 +143,13 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
|
|||||||
stats: Optional[dict] = None, now: Optional[datetime] = None) -> int:
|
stats: Optional[dict] = None, now: Optional[datetime] = None) -> int:
|
||||||
"""检查所有KPI的实际值是否触发预警,返回(将)新生成的预警数
|
"""检查所有KPI的实际值是否触发预警,返回(将)新生成的预警数
|
||||||
|
|
||||||
2026-09-17 财务口径裁定 2a/2b:
|
2026-09-17 财务口径裁定 2a/2b(2a 的两项已由 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24` 取代为倍口径):
|
||||||
· **期间(2b)**:period=None → 每个 KPI 回落「最近可用期间」(自己的最新 YYYY-MM,
|
· **期间(2b)**:period=None → 每个 KPI 回落「最近可用期间」(自己的最新 YYYY-MM,
|
||||||
排除 H1/H2 伪期间),不再硬编码当月(当月无值 → 永久 0 新预警);
|
排除 H1/H2 伪期间),不再硬编码当月(当月无值 → 永久 0 新预警);
|
||||||
· **单位(2a)**:判定前经 resolve_caliber() 归一到「百分比(%)」口径(取数×100,
|
· **单位口径**:判定前经 resolve_caliber()(唯一事实源 `RATIO_CALIBER_TABLE`)——
|
||||||
与阈值 150/80 同量纲);**未核对/疑似单位错配**的项降级待核 → 不落预警、不推送;
|
`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);
|
||||||
|
**未核对/疑似单位错配**的项降级待核 → 不落预警、不推送;
|
||||||
· dry_run=True → 只计算不写库(抽查复核与放行前证据用)。
|
· dry_run=True → 只计算不写库(抽查复核与放行前证据用)。
|
||||||
|
|
||||||
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope` 单一事实源):
|
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope` 单一事实源):
|
||||||
@@ -231,7 +235,7 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
|
|||||||
st["no_value"] += 1
|
st["no_value"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 单位口径(裁定2a):取数归一 + 疑似错配降级门
|
# 单位口径(v2.1 倍口径直判 / 2a 归一):resolve_caliber 单一入口 + 疑似错配降级门
|
||||||
cal = resolve_caliber(latest.actual_value, kpi)
|
cal = resolve_caliber(latest.actual_value, kpi)
|
||||||
if cal["status"] == "dimension_suspect":
|
if cal["status"] == "dimension_suspect":
|
||||||
st["dimension_suspect"] += 1
|
st["dimension_suspect"] += 1
|
||||||
@@ -409,7 +413,7 @@ def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool =
|
|||||||
now: Optional[datetime] = None) -> dict:
|
now: Optional[datetime] = None) -> dict:
|
||||||
"""生成预警并推送,返回统计
|
"""生成预警并推送,返回统计
|
||||||
|
|
||||||
2026-09-17 财务口径裁定 2a/2b:
|
2026-09-17 财务口径裁定 2a/2b(2a 的两项已由 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24` 取代为倍口径):
|
||||||
· **period=None** → 判定期间 = 各 KPI「最近可用期间」(裁定2b,不再硬编码当月);
|
· **period=None** → 判定期间 = 各 KPI「最近可用期间」(裁定2b,不再硬编码当月);
|
||||||
显式给出 period → 固定该期间(抽查复核用);
|
显式给出 period → 固定该期间(抽查复核用);
|
||||||
· **dry_run=True** → 零写入、零推送:返回将生成的预警清单与口径证据(放行前抽查复核)。
|
· **dry_run=True** → 零写入、零推送:返回将生成的预警清单与口径证据(放行前抽查复核)。
|
||||||
@@ -489,7 +493,7 @@ def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool =
|
|||||||
"push_skipped_no_channel": push_skipped_no_channel,
|
"push_skipped_no_channel": push_skipped_no_channel,
|
||||||
"cleaned_green": cleaned, "rule_engine": rule_stats,
|
"cleaned_green": cleaned, "rule_engine": rule_stats,
|
||||||
"coverage": coverage,
|
"coverage": coverage,
|
||||||
# 裁定2a 口径证据
|
# 口径证据(v2.1 倍口径直判 / 2a 归一;唯一事实源 caliber_units.RATIO_CALIBER_TABLE)
|
||||||
"normalized": st.get("normalized", 0), "normalized_kpis": st.get("normalized_kpis", []),
|
"normalized": st.get("normalized", 0), "normalized_kpis": st.get("normalized_kpis", []),
|
||||||
"dimension_suspect": st.get("dimension_suspect", 0), "suspect_kpis": st.get("suspect_kpis", []),
|
"dimension_suspect": st.get("dimension_suspect", 0), "suspect_kpis": st.get("suspect_kpis", []),
|
||||||
# 期间/取值覆盖明细
|
# 期间/取值覆盖明细
|
||||||
@@ -549,7 +553,7 @@ def coverage_note(result: dict) -> str:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
ap = argparse.ArgumentParser(description="预警生成与推送(2026-09-17 口径裁定 2a/2b)")
|
ap = argparse.ArgumentParser(description="预警生成与推送(口径:2a + 2026-09-24 v2.1 取代两项)")
|
||||||
ap.add_argument("--dry-run", action="store_true", help="只计算不写库不推送(抽查复核/放行前证据)")
|
ap.add_argument("--dry-run", action="store_true", help="只计算不写库不推送(抽查复核/放行前证据)")
|
||||||
ap.add_argument("--period", default=None, help="固定判定期间 YYYY-MM(缺省=各 KPI 最近可用期间,裁定2b)")
|
ap.add_argument("--period", default=None, help="固定判定期间 YYYY-MM(缺省=各 KPI 最近可用期间,裁定2b)")
|
||||||
ap.add_argument("--json", action="store_true", help="JSON 输出完整证据(含将生成的预警清单)")
|
ap.add_argument("--json", action="store_true", help="JSON 输出完整证据(含将生成的预警清单)")
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
#!/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())
|
||||||
@@ -33,7 +33,14 @@ from typing import Optional
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
CMA_API = os.getenv("CMA_API", "http://127.0.0.1:8010/api/cma/bot-bridge")
|
CMA_API = os.getenv("CMA_API", "http://127.0.0.1:8010/api/cma/bot-bridge")
|
||||||
BRIDGE_TOKEN = os.getenv("CMA_BRIDGE_TOKEN", "cma-bot-bridge-2026")
|
|
||||||
|
# 凭据只应从环境变量取;兼容期兜底历史共享 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
|
||||||
|
|
||||||
|
|
||||||
# MPM字段 → KPI编码映射(与 bot_bridge_v2.MPM_TO_KPI_MAP 保持一致)
|
# MPM字段 → KPI编码映射(与 bot_bridge_v2.MPM_TO_KPI_MAP 保持一致)
|
||||||
MPM_TO_KPI_MAP = {
|
MPM_TO_KPI_MAP = {
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
#!/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())
|
||||||
@@ -177,6 +177,63 @@ def write_journal(payload: dict) -> str:
|
|||||||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||||
return path
|
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():
|
def list_journals():
|
||||||
d = journal_dir()
|
d = journal_dir()
|
||||||
@@ -282,6 +339,11 @@ def cmd_apply(args, engine, conn_desc) -> int:
|
|||||||
structural=structural,
|
structural=structural,
|
||||||
)
|
)
|
||||||
path = write_journal(payload)
|
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 恢复
|
except Exception as exc: # DDL 无事务:失败时记录已建对象,供 --rollback 恢复
|
||||||
try:
|
try:
|
||||||
after_missing, _ = schema_diff(engine)
|
after_missing, _ = schema_diff(engine)
|
||||||
@@ -296,6 +358,10 @@ def cmd_apply(args, engine, conn_desc) -> int:
|
|||||||
error=f"{type(exc).__name__}: {exc}",
|
error=f"{type(exc).__name__}: {exc}",
|
||||||
)
|
)
|
||||||
path = write_journal(payload)
|
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"[FAIL] 迁移失败:{payload['error']}")
|
||||||
print(f" 已建对象 {created}(journal: {path})")
|
print(f" 已建对象 {created}(journal: {path})")
|
||||||
print(f" 回滚:python3 scripts/db_migrate.py --rollback {os.path.basename(path)}")
|
print(f" 回滚:python3 scripts/db_migrate.py --rollback {os.path.basename(path)}")
|
||||||
|
|||||||
+13
-10
@@ -23,6 +23,7 @@ load_dotenv()
|
|||||||
|
|
||||||
from app.database import get_engine, get_session_local
|
from app.database import get_engine, get_session_local
|
||||||
from app.models import KPIDefinition, KPIValue, OperationLog, DataSourceConfig
|
from app.models import KPIDefinition, KPIValue, OperationLog, DataSourceConfig
|
||||||
|
from app.utils.oplog import log_op, SCOPE_BATCH
|
||||||
|
|
||||||
logger = logging.getLogger("erp_sync")
|
logger = logging.getLogger("erp_sync")
|
||||||
|
|
||||||
@@ -421,16 +422,18 @@ def run_sync(dry_run: bool = False, kpi_codes: list = None, use_api: bool = True
|
|||||||
fail += 1
|
fail += 1
|
||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
log = OperationLog(
|
# 2026-09-23 ④:审计带账套。本脚本一次遍历**全库 active ERP KPI(可能跨账套)**:
|
||||||
action="erp_sync",
|
# 全部同账套 ⇒ 写该账套;跨账套 ⇒ 留空 + OPLOG_BATCH_NO_ENTITY 哨兵(不猜账套)
|
||||||
target_type="kpi",
|
_entity_ids = sorted({getattr(k, "entity_id", None) for k in kpis} - {None})
|
||||||
detail=json.dumps({
|
log_op(db, "erp_sync", target_type="kpi",
|
||||||
"total": len(kpis), "success": success,
|
detail={
|
||||||
"failed": fail, "api_mode": use_api,
|
"total": len(kpis), "success": success,
|
||||||
"period": datetime.now().strftime("%Y-%m"),
|
"failed": fail, "api_mode": use_api,
|
||||||
}, ensure_ascii=False),
|
"period": datetime.now().strftime("%Y-%m"),
|
||||||
)
|
"kpi_entity_ids": _entity_ids,
|
||||||
db.add(log)
|
},
|
||||||
|
entity_id=(_entity_ids[0] if len(_entity_ids) == 1 else None),
|
||||||
|
scope=(SCOPE_BATCH if len(_entity_ids) != 1 else None))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
logger.info(f"同步完成: {success}成功 / {fail}失败 / {len(kpis)}总计")
|
logger.info(f"同步完成: {success}成功 / {fail}失败 / {len(kpis)}总计")
|
||||||
|
|||||||
@@ -7,7 +7,29 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
# 直接调用BOT API
|
# 直接调用BOT API
|
||||||
CMA_BOT_API = "http://127.0.0.1:8010/api/cma/bot"
|
CMA_BOT_API = "http://127.0.0.1:8010/api/cma/bot"
|
||||||
API_KEY = "cma-bot-finance-2026"
|
|
||||||
|
# 凭据来源优先级: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()
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
#!/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())
|
||||||
@@ -10,6 +10,16 @@
|
|||||||
列:分类,编码,项目名称,方向,金额 分类∈{经营活动,投资活动,筹资活动}
|
列:分类,编码,项目名称,方向,金额 分类∈{经营活动,投资活动,筹资活动}
|
||||||
兼容 2026.08 的另存名 cashflow_stat_<YYYY.MM>.csv
|
兼容 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 位)
|
- 经营/投资/筹资 净额 = Σ(流入) − Σ(流出),单位 元 → 万元(÷10000,保留 2 位)
|
||||||
- **NA(无发生额)标注(本版本新增,裁定 finance-20260916-cma-caliber-ruling §2.1)**:
|
- **NA(无发生额)标注(本版本新增,裁定 finance-20260916-cma-caliber-ruling §2.1)**:
|
||||||
@@ -18,13 +28,16 @@
|
|||||||
供判定层用 `parse_na_activities(remark)` 取回 → **仅作标注与机读入口**;
|
供判定层用 `parse_na_activities(remark)` 取回 → **仅作标注与机读入口**;
|
||||||
口径裁定(2026-09-17)已定:该维按 **0 如实参与判定**(八类判定的**二维映射**,
|
口径裁定(2026-09-17)已定:该维按 **0 如实参与判定**(八类判定的**二维映射**,
|
||||||
经营(±)×筹资(±) 出 2 个候选类型),**不再**因 NA/0 整体判「未判定」。
|
经营(±)×筹资(±) 出 2 个候选类型),**不再**因 NA/0 整体判「未判定」。
|
||||||
- source='official'(官方报表口径);期初/期末现金 留空(主表采集缺陷,见报告 §项1-②)
|
- source='official'(官方报表口径)
|
||||||
|
- **期初/期末现金**(本版本新增):取科目余额表货币资金(1001/1002/1012)→ 万元 2 位,
|
||||||
|
源文件/科目缺失时保持 NULL(不写 0 造数);仅 entity_id=1(智享通通道=酣客)生效
|
||||||
- 同 (entity_id, period) 重复执行 = 覆盖更新(幂等)
|
- 同 (entity_id, period) 重复执行 = 覆盖更新(幂等)
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python3 scripts/import_cashflow_statements.py # dry-run(默认,只算不写)
|
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 --period 2026-07 # 单期 dry-run
|
||||||
python3 scripts/import_cashflow_statements.py --self-test # NA 标注逻辑自测(零库连接)
|
python3 scripts/import_cashflow_statements.py --self-test # 标注+期初期末自测(零库连接;
|
||||||
|
# 边界用例只在系统临时目录造样,不碰生产源)
|
||||||
python3 scripts/import_cashflow_statements.py --apply # 落库(需放行)
|
python3 scripts/import_cashflow_statements.py --apply # 落库(需放行)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -33,6 +46,7 @@ import glob
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
@@ -43,6 +57,13 @@ from app.api.cashflow_structure import classify_enterprise
|
|||||||
ZKT_BASE = "/root/WB_WS/_data/zxt-hanke/cashflow"
|
ZKT_BASE = "/root/WB_WS/_data/zxt-hanke/cashflow"
|
||||||
CLASSES = ("经营活动", "投资活动", "筹资活动")
|
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 固定短语模板 —— 判定层与量纲守卫共用,勿改字面量(裁定 §2.1 指定)
|
||||||
NA_PHRASE_TMPL = "{cls}本期无发生额(无项目行)"
|
NA_PHRASE_TMPL = "{cls}本期无发生额(无项目行)"
|
||||||
|
|
||||||
@@ -93,6 +114,52 @@ def _nets(rows):
|
|||||||
return {c: round(v["in"] - v["out"], 2) for c, v in agg.items()}, agg
|
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):
|
def collect(period_filter=None):
|
||||||
"""扫描盘上各期 → [payload dict](万元,未写库)"""
|
"""扫描盘上各期 → [payload dict](万元,未写库)"""
|
||||||
payloads = []
|
payloads = []
|
||||||
@@ -122,6 +189,7 @@ def collect(period_filter=None):
|
|||||||
os.path.basename(src), len(rows))
|
os.path.basename(src), len(rows))
|
||||||
remark = (head + ";NA(无发生额,按0参与判定): " + "、".join(na_phrases)) if na_phrases \
|
remark = (head + ";NA(无发生额,按0参与判定): " + "、".join(na_phrases)) if na_phrases \
|
||||||
else (head + ";三类活动均有项目行(无 NA)")
|
else (head + ";三类活动均有项目行(无 NA)")
|
||||||
|
cash_begin, cash_end, cash_meta = _read_cash_balances(period)
|
||||||
payloads.append({
|
payloads.append({
|
||||||
"period": period,
|
"period": period,
|
||||||
"status": "READY",
|
"status": "READY",
|
||||||
@@ -134,6 +202,9 @@ def collect(period_filter=None):
|
|||||||
"na_phrases": na_phrases,
|
"na_phrases": na_phrases,
|
||||||
"na_flags": {c: (c in missing) for c in CLASSES},
|
"na_flags": {c: (c in missing) for c in CLASSES},
|
||||||
"remark": remark,
|
"remark": remark,
|
||||||
|
"begin_cash": cash_begin,
|
||||||
|
"end_cash": cash_end,
|
||||||
|
"cash_meta": cash_meta,
|
||||||
})
|
})
|
||||||
return payloads
|
return payloads
|
||||||
|
|
||||||
@@ -163,12 +234,58 @@ def _self_test():
|
|||||||
assert ready, "无可入库期(数据源缺失)"
|
assert ready, "无可入库期(数据源缺失)"
|
||||||
all_inv_na = all("投资活动" in p["na_classes"] for p in ready)
|
all_inv_na = all("投资活动" in p["na_classes"] for p in ready)
|
||||||
passed += 1
|
passed += 1
|
||||||
print("=== NA 标注 self-test: %d/5 PASS | 期数=%d | 投资活动全NA=%s ==="
|
|
||||||
% (passed, len(ready), all_inv_na))
|
|
||||||
for p in ready:
|
|
||||||
print(" %-8s NA=%s" % (p["period"], "、".join(p["na_classes"]) or "无"))
|
|
||||||
print(" remark: %s" % p["remark"])
|
|
||||||
assert all_inv_na, "酣客 8 期投资活动应全为 NA(裁定 §2.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))
|
||||||
|
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(" remark: %s" % p["remark"])
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -218,6 +335,14 @@ def main():
|
|||||||
verdict, os.path.basename(p["src"])))
|
verdict, os.path.basename(p["src"])))
|
||||||
print(" NA(无发生额)=%s;投资栏标注=%s" % (
|
print(" NA(无发生额)=%s;投资栏标注=%s" % (
|
||||||
"、".join(p["na_classes"]) or "无", cls.get("investing_note") or "-"))
|
"、".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:
|
if not args.apply:
|
||||||
continue
|
continue
|
||||||
rec = db.query(CashFlowStructureValue).filter(
|
rec = db.query(CashFlowStructureValue).filter(
|
||||||
@@ -233,9 +358,16 @@ def main():
|
|||||||
rec.financing_net = n["筹资活动"]
|
rec.financing_net = n["筹资活动"]
|
||||||
rec.source = "official"
|
rec.source = "official"
|
||||||
rec.remark = p["remark"]
|
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()
|
db.flush()
|
||||||
written += 1
|
written += 1
|
||||||
print(" → %s (id=%s)" % ("入库" if created else "更新", rec.id))
|
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)))
|
||||||
if args.apply:
|
if args.apply:
|
||||||
db.commit()
|
db.commit()
|
||||||
print("=== 已提交 %d 期 ===" % written)
|
print("=== 已提交 %d 期 ===" % written)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/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)
|
||||||
@@ -1,28 +1,60 @@
|
|||||||
"""保本点(盈亏平衡)派生KPI执行器 — 管理会计OS (cma-bep-kpi 2026-09-08)
|
"""保本点(盈亏平衡)派生KPI执行器 — 管理会计OS (cma-bep-kpi 2026-09-08)
|
||||||
|
|
||||||
按财务Bot权威口径(陕西酣客_保本点分析_2026年1-7月.md)自动计算并落库五个派生KPI:
|
按财务Bot权威口径(陕西酣客_保本点分析_2026年1-7月.md)自动计算并落库六个派生KPI:
|
||||||
F_BEP_REVENUE 保本销售额(万元) = 月均管理费 ÷ 边际贡献率 (账面口径,含折旧)
|
F_BEP_REVENUE 保本销售额(万元) = 月均固定成本 ÷ 边际贡献率 (账面口径,含折旧)
|
||||||
F_BEP_COVER 保本覆盖率(%) = 月均销售 ÷ 保本销售额 × 100 (正向: >=100绿 >=85黄 <85红)
|
F_BEP_COVER 保本覆盖率(%) = 月均销售 ÷ 保本销售额 × 100 (正向: >=100绿 >=85黄 <85红)
|
||||||
F_BEP_GAP 保本缺口(%) = (保本销售额-月均销售) ÷ 保本销售额 × 100 (反向: <=0绿 <=15黄 >15红)
|
F_BEP_GAP 保本缺口(%) = (保本销售额-月均销售) ÷ 保本销售额 × 100 (反向: <=0绿 <=15黄 >15红)
|
||||||
F_BEP_REVENUE_CASH 现金保本销售额(万元) = (月均管理费-月均折旧) ÷ 边际贡献率 (现金口径,剔除折旧560213,中性无阈值)
|
F_BEP_REVENUE_CASH 现金保本销售额(万元) = (月均固定成本-月均折旧) ÷ 边际贡献率 (现金口径,中性无阈值)
|
||||||
F_BEP_COVER_CASH 现金保本覆盖率(%) = 月均销售 ÷ 现金保本销售额 × 100 (正向: >=100绿 >=85黄 <85红)
|
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报告一致):
|
口径(与财务Bot报告一致):
|
||||||
- 销售收入: voucher_details.subject_code='5001'(主营业务收入) 贷方合计,当年1月至目标月 YTD 月均
|
- 销售收入: entity1 = subject_code='5001'(主营业务收入) 贷方合计;entity2 = '6001' 贷方合计。当年1月至目标月 YTD 月均
|
||||||
- 管理费: subject_code LIKE '5602%'(管理费用,含560213折旧) 借方合计,YTD 月均 → 账面口径
|
- 固定成本: entity1 = subject_code LIKE '5601%'/'5602%'/'5603%'(销售/管理/财务费用,含560213折旧) 借−贷净额合计(carry_forward=0),YTD 月均〔⑤扩容〕
|
||||||
- 折旧: subject_code = '560213'(管理费用-折旧叶子) 借方合计,YTD 月均 → 现金口径剔除项(eq精确匹配,防5602130误含)
|
entity2 = 6601/6602/6603 借−贷净额合计(同上,但另需剔除结转腿)
|
||||||
- 渠补率: 厂家政策定死 84.61% (571103渠补支出/5001销售, 1-7月累计凭证验证≈84.0%~84.6%)
|
- 折旧: entity1 = '560213' 借−贷净额;entity2 = '1602' 贷方发生额(现金口径剔除项)
|
||||||
- 边际贡献率 = 1 - 渠补率 = 15.39%
|
- 变动成本率: **完整实测口径**(⑤口径统一 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)**同一处理路径风格**,仅文案与触发条件不同。
|
||||||
- 目标月: --period 显式指定;默认自动选最近「已结账」完整月(当月跳过)。
|
- 目标月: --period 显式指定;默认自动选最近「已结账」完整月(当月跳过)。
|
||||||
结账信号优先:DB 损益结转凭证(carry_forward=1 & subject_code='3103') → check.md 文件;
|
结账信号优先:DB 损益结转凭证(carry_forward=1 & 本年利润科目 3103(酣客)/4103(博海)) → check.md 文件;
|
||||||
两者皆无时才退回旧「销售额阈值」兜底(销售低 ≠ 未结账,仅保底用,日志标注依据)
|
两者皆无时才退回旧「销售额阈值」兜底(销售低 ≠ 未结账,仅保底用,日志标注依据)
|
||||||
- 单位: voucher 金额为元,聚合后 /10000 → 万元
|
- 单位: voucher 金额为元,聚合后 /10000 → 万元
|
||||||
- 幂等: 同 kpi_id+period+source_type='derived' 存在则更新,可安全重跑
|
- 幂等: 同 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'。
|
||||||
- 只写 source_type='derived' 行;不触碰 auto_collect/manual/excel/ledger 行;不改 collector 逻辑
|
- 只写 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 # 自动选目标月
|
||||||
venv/bin/python3 scripts/kpi_bep_deriver.py --period 2026-07 # 指定期间(实跑验证用)
|
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 --entity 1 --rebate-rate 84.61
|
||||||
venv/bin/python3 scripts/kpi_bep_deriver.py --dry-run # 只计算不落库
|
venv/bin/python3 scripts/kpi_bep_deriver.py --dry-run # 只计算不落库
|
||||||
"""
|
"""
|
||||||
@@ -35,7 +67,7 @@ from datetime import datetime, date
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, or_
|
||||||
from app.database import get_session_local
|
from app.database import get_session_local
|
||||||
from app.models import KPIValue, KPIDefinition, VoucherDetail, KPIAlert
|
from app.models import KPIValue, KPIDefinition, VoucherDetail, KPIAlert
|
||||||
# 裁定 R1(2026-09-19 财务口径):demo 账套不产生业务预警(派生值仍落库,仅跳过预警同步)
|
# 裁定 R1(2026-09-19 财务口径):demo 账套不产生业务预警(派生值仍落库,仅跳过预警同步)
|
||||||
@@ -45,6 +77,8 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me
|
|||||||
logger = logging.getLogger("cma.bep_deriver")
|
logger = logging.getLogger("cma.bep_deriver")
|
||||||
|
|
||||||
# 厂家政策渠补率(定死不可谈判,财务Bot报告权威值 2026-09-08)
|
# 厂家政策渠补率(定死不可谈判,财务Bot报告权威值 2026-09-08)
|
||||||
|
# ⚠️ 2026-10-02 起**仅作对照展示**(返回值 rebate_policy_pct / remark),不参与任何计算。
|
||||||
|
# 参与计算的渠补率 = 逐期实测累计(见 compute_rebate_rate / 文件头「渠补率」条)。
|
||||||
POLICY_REBATE_RATE_PCT = 84.61
|
POLICY_REBATE_RATE_PCT = 84.61
|
||||||
|
|
||||||
# 派生KPI 编码 → (kpi_code, 名称)
|
# 派生KPI 编码 → (kpi_code, 名称)
|
||||||
@@ -54,20 +88,90 @@ TARGET_KPIS = [
|
|||||||
("F_BEP_GAP", "保本缺口"),
|
("F_BEP_GAP", "保本缺口"),
|
||||||
("F_BEP_REVENUE_CASH", "现金口径保本销售额"),
|
("F_BEP_REVENUE_CASH", "现金口径保本销售额"),
|
||||||
("F_BEP_COVER_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 文案用现金口径)
|
# 现金口径 KPI 编码(计算路径独立于账面,remark 文案用现金口径)
|
||||||
CASH_CODES = {"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH"}
|
CASH_CODES = {"F_BEP_REVENUE_CASH", "F_BEP_COVER_CASH"}
|
||||||
|
|
||||||
SALES_CODE = "5001" # 主营业务收入
|
SALES_CODE = "5001" # 主营业务收入(酣客)
|
||||||
ADMIN_PREFIX = "5602" # 管理费用(含折旧叶子560213)
|
ADMIN_PREFIX = "5602" # 管理费用(含折旧叶子560213)
|
||||||
DEP_SUBJECT = "560213" # 管理费用-折旧(现金口径剔除项,eq 精确匹配防 5602130 误含)
|
DEP_SUBJECT = "560213" # 管理费用-折旧(现金口径剔除项,eq 精确匹配防 5602130 误含)
|
||||||
REBATE_CODE = "571103" # 渠补/捐赠支出(校验用,不直接进公式)
|
REBATE_CODE = "571103" # 渠补/捐赠支出(校验用,不直接进公式)
|
||||||
CARRY_SUBJECT = "3103" # 本年利润(carry_forward=1 且该科目 = 该期损益结转凭证)
|
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 一致)——结账信号次选来源
|
# 智享通落盘目录(与 app/api/data.py 的 ZXT_LANDING_DIR 一致)——结账信号次选来源
|
||||||
ZXT_LANDING_DIR = "/root/WB_WS/_data/zxt-hanke"
|
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):
|
def _month_range(period: str):
|
||||||
"""返回该期间当年1月1日到月末的日期边界(用于 voucher_date 过滤兜底)"""
|
"""返回该期间当年1月1日到月末的日期边界(用于 voucher_date 过滤兜底)"""
|
||||||
@@ -85,44 +189,181 @@ def _month_range(period: str):
|
|||||||
|
|
||||||
|
|
||||||
def _sum_field(db, entity_id: int, period_from: str, period_to: str,
|
def _sum_field(db, entity_id: int, period_from: str, period_to: str,
|
||||||
field: str, subject_filter=None) -> float:
|
field: str, subject_filter=None, *, exclude_closing: bool = False,
|
||||||
"""聚合 voucher_details 金额(元)。subject_filter: ('eq', code) / ('like', prefix)"""
|
subject_filters=None) -> float:
|
||||||
q = db.query(func.sum(getattr(VoucherDetail, field))).filter(
|
"""聚合 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(
|
||||||
VoucherDetail.entity_id == entity_id,
|
VoucherDetail.entity_id == entity_id,
|
||||||
VoucherDetail.carry_forward == 0,
|
VoucherDetail.carry_forward == 0,
|
||||||
VoucherDetail.period >= period_from,
|
VoucherDetail.period >= period_from,
|
||||||
VoucherDetail.period <= period_to,
|
VoucherDetail.period <= period_to,
|
||||||
)
|
)
|
||||||
|
if explicit := subject_filters:
|
||||||
|
q = q.filter(or_(*[_subject_clause(k, c) for k, c in explicit]))
|
||||||
if subject_filter:
|
if subject_filter:
|
||||||
kind, code = subject_filter
|
kind, code = subject_filter
|
||||||
if kind == "eq":
|
q = q.filter(_subject_clause(kind, code))
|
||||||
q = q.filter(VoucherDetail.subject_code == code)
|
if exclude_closing:
|
||||||
elif kind == "like":
|
q = q.filter(or_(VoucherDetail.summary.is_(None),
|
||||||
q = q.filter(VoucherDetail.subject_code.like(code + "%"))
|
~VoucherDetail.summary.like(CLOSING_SUMMARY_LIKE)))
|
||||||
val = q.scalar()
|
val = q.scalar()
|
||||||
return float(val or 0.0)
|
return float(val or 0.0)
|
||||||
|
|
||||||
|
|
||||||
def _month_sales(db, entity_id: int, period: str) -> float:
|
def _subject_clause(kind: str, code: str):
|
||||||
"""单月销售收入(万元)——用于目标月完整度判断"""
|
"""科目过滤谓词:('eq', code) 精确 / ('like', prefix) 前缀"""
|
||||||
return _sum_field(db, entity_id, period, period, "credit_amount",
|
if kind == "eq":
|
||||||
("eq", SALES_CODE)) / 10000.0
|
return VoucherDetail.subject_code == code
|
||||||
|
if kind == "like":
|
||||||
|
return VoucherDetail.subject_code.like(code + "%")
|
||||||
|
raise ValueError(f"未知科目过滤类型: {kind}")
|
||||||
|
|
||||||
|
|
||||||
def _is_period_closed(db, entity_id: int, period: str) -> tuple:
|
# 带号科目项的字段映射(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:
|
||||||
"""判断该期间是否已结账(损益结转已完成)。返回 (bool, 依据说明)。
|
"""判断该期间是否已结账(损益结转已完成)。返回 (bool, 依据说明)。
|
||||||
|
|
||||||
主信号(DB): voucher_details 存在 carry_forward=1 且 subject_code='3103'(本年利润) 的行
|
主信号(DB): voucher_details 存在 carry_forward=1 且 subject_code=该账套本年利润科目
|
||||||
= 该期损益结转凭证已生成
|
(酣客 3103 / 博海 4103)= 该期损益结转凭证已生成
|
||||||
次信号(文件): ZXT_LANDING_DIR/{YYYY.MM}/check.md 同时含「本期: PASS」与「损益结转: PASS」
|
次信号(文件): {landing_dir}/{YYYY.MM}/check.md 同时含「本期: PASS」与「损益结转: PASS」
|
||||||
|
(仅酣客有智享通落盘目录;博海 landing_dir=None ⇒ 跳过该信号)
|
||||||
均不成立 → (False, "")。任何读取异常均吞掉,不影响主流程。
|
均不成立 → (False, "")。任何读取异常均吞掉,不影响主流程。
|
||||||
"""
|
"""
|
||||||
|
cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1]
|
||||||
# 主信号:DB 损益结转凭证
|
# 主信号:DB 损益结转凭证
|
||||||
try:
|
try:
|
||||||
hit = db.query(VoucherDetail.id).filter(
|
hit = db.query(VoucherDetail.id).filter(
|
||||||
VoucherDetail.entity_id == entity_id,
|
VoucherDetail.entity_id == entity_id,
|
||||||
VoucherDetail.carry_forward == 1,
|
VoucherDetail.carry_forward == 1,
|
||||||
VoucherDetail.subject_code == CARRY_SUBJECT,
|
VoucherDetail.subject_code == cal["closing_subject"],
|
||||||
VoucherDetail.period == period,
|
VoucherDetail.period == period,
|
||||||
).first()
|
).first()
|
||||||
if hit:
|
if hit:
|
||||||
@@ -130,21 +371,23 @@ def _is_period_closed(db, entity_id: int, period: str) -> tuple:
|
|||||||
except Exception as e: # 查询异常不阻断,降级到文件信号
|
except Exception as e: # 查询异常不阻断,降级到文件信号
|
||||||
logger.warning("结账信号(DB)查询失败 period=%s: %s", period, e)
|
logger.warning("结账信号(DB)查询失败 period=%s: %s", period, e)
|
||||||
|
|
||||||
# 次信号:智享通落盘 check.md
|
# 次信号:智享通落盘 check.md(仅酣客)
|
||||||
|
landing = cal.get("landing_dir")
|
||||||
try:
|
try:
|
||||||
path = os.path.join(ZXT_LANDING_DIR, period.replace("-", "."), "check.md")
|
if landing:
|
||||||
if os.path.exists(path):
|
path = os.path.join(landing, period.replace("-", "."), "check.md")
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
if os.path.exists(path):
|
||||||
txt = f.read()
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
if "本期: PASS" in txt and "损益结转: PASS" in txt:
|
txt = f.read()
|
||||||
return True, "check.md"
|
if "本期: PASS" in txt and "损益结转: PASS" in txt:
|
||||||
|
return True, "check.md"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # 文件缺失/读取失败一律忽略
|
pass # 文件缺失/读取失败一律忽略
|
||||||
|
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
def _pick_target_period(db, entity_id: int, now: date) -> str:
|
def _pick_target_period(db, entity_id: int, now: date, caliber: dict = None) -> str:
|
||||||
"""自动选择目标月:跳过当月。
|
"""自动选择目标月:跳过当月。
|
||||||
|
|
||||||
判定顺序(2026-09-12 修订):
|
判定顺序(2026-09-12 修订):
|
||||||
@@ -152,6 +395,7 @@ def _pick_target_period(db, entity_id: int, now: date) -> str:
|
|||||||
结账成立时不再用销售额做任何筛选(销售低 ≠ 未结账,历史缺陷根因)
|
结账成立时不再用销售额做任何筛选(销售低 ≠ 未结账,历史缺陷根因)
|
||||||
2. 所有候选月均无结账信号 → 退回旧「销售额 < 近6月均×30%」兜底(日志标注『依据销售额兜底』)
|
2. 所有候选月均无结账信号 → 退回旧「销售额 < 近6月均×30%」兜底(日志标注『依据销售额兜底』)
|
||||||
"""
|
"""
|
||||||
|
cal = caliber or ENTITY_CALIBERS.get(entity_id) or ENTITY_CALIBERS[1]
|
||||||
# 收集已有数据月份(5001 贷方)
|
# 收集已有数据月份(5001 贷方)
|
||||||
rows = db.query(VoucherDetail.period).filter(
|
rows = db.query(VoucherDetail.period).filter(
|
||||||
VoucherDetail.entity_id == entity_id,
|
VoucherDetail.entity_id == entity_id,
|
||||||
@@ -172,7 +416,7 @@ def _pick_target_period(db, entity_id: int, now: date) -> str:
|
|||||||
|
|
||||||
# 1) 结账信号优先(DB损益结转 → check.md),命中即采用
|
# 1) 结账信号优先(DB损益结转 → check.md),命中即采用
|
||||||
for p in candidates:
|
for p in candidates:
|
||||||
closed, reason = _is_period_closed(db, entity_id, p)
|
closed, reason = _is_period_closed(db, entity_id, p, cal)
|
||||||
if closed:
|
if closed:
|
||||||
logger.info("目标月 %s 判定已结账(依据:%s),采用", p, reason)
|
logger.info("目标月 %s 判定已结账(依据:%s),采用", p, reason)
|
||||||
return p
|
return p
|
||||||
@@ -186,14 +430,14 @@ def _pick_target_period(db, entity_id: int, now: date) -> str:
|
|||||||
for p in periods:
|
for p in periods:
|
||||||
if p >= cur:
|
if p >= cur:
|
||||||
continue
|
continue
|
||||||
s = _month_sales(db, entity_id, p)
|
s = _month_sales(db, entity_id, p, cal)
|
||||||
ref_months.append((p, s))
|
ref_months.append((p, s))
|
||||||
if len(ref_months) >= 6:
|
if len(ref_months) >= 6:
|
||||||
break
|
break
|
||||||
ref_avg = (sum(s for _, s in ref_months) / len(ref_months)) if ref_months else None
|
ref_avg = (sum(s for _, s in ref_months) / len(ref_months)) if ref_months else None
|
||||||
|
|
||||||
for p in candidates:
|
for p in candidates:
|
||||||
s = _month_sales(db, entity_id, p)
|
s = _month_sales(db, entity_id, p, cal)
|
||||||
if s <= 0:
|
if s <= 0:
|
||||||
continue # 该月无销售,往前找
|
continue # 该月无销售,往前找
|
||||||
if ref_avg and s < ref_avg * 0.3:
|
if ref_avg and s < ref_avg * 0.3:
|
||||||
@@ -229,45 +473,112 @@ def _eval_static_threshold(value: float, kpi) -> tuple:
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float,
|
def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float = None,
|
||||||
write_kpi: bool = True) -> dict:
|
write_kpi: bool = True) -> dict:
|
||||||
"""计算并落库五个派生KPI(账面3条+现金2条)。返回结果摘要。"""
|
"""计算并落库六个派生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)")
|
||||||
|
|
||||||
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(
|
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(
|
||||||
KPIDefinition.entity_id == entity_id,
|
KPIDefinition.entity_id == entity_id,
|
||||||
KPIDefinition.kpi_code.in_([c for c, _ in TARGET_KPIS]),
|
KPIDefinition.kpi_code.in_([c for c, _ in TARGET_KPIS]),
|
||||||
KPIDefinition.status == "active",
|
KPIDefinition.status == "active",
|
||||||
).all()}
|
).all()}
|
||||||
missing = [c for c, _ in TARGET_KPIS if c not in kpis]
|
missing = [c for c in CORE_KPI_CODES if c not in kpis]
|
||||||
if missing:
|
if missing:
|
||||||
raise RuntimeError(f"entity={entity_id} 缺少派生KPI定义: {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]
|
year = period[:4]
|
||||||
period_from = f"{year}-01"
|
period_from = f"{year}-01"
|
||||||
period_to = period
|
period_to = period
|
||||||
months = int(period[5:7]) # YTD 月数(1月=1 ... 7月=7)
|
months = int(period[5:7]) # YTD 月数(1月=1 ... 7月=7)
|
||||||
|
|
||||||
# 1. 取数(元 → 万元)
|
# 1. 取数(元 → 万元)——按实体口径
|
||||||
sales_sum_w = _sum_field(db, entity_id, period_from, period_to, "credit_amount", ("eq", SALES_CODE)) / 10000.0
|
# 固定成本经 bep_caliber.fixed_cost_spec() 唯一解析口:e1 前缀净额 / e2 带号科目项仅借方(§4-1)
|
||||||
admin_sum_w = _sum_field(db, entity_id, period_from, period_to, "debit_amount", ("like", ADMIN_PREFIX)) / 10000.0
|
fix_mode, fix_spec = fixed_cost_spec(cal)
|
||||||
rebate_sum_w = _sum_field(db, entity_id, period_from, period_to, "debit_amount", ("eq", REBATE_CODE)) / 10000.0
|
sales_sum_w = _sum_field(db, entity_id, period_from, period_to,
|
||||||
dep_sum_w = _sum_field(db, entity_id, period_from, period_to, "debit_amount", ("eq", DEP_SUBJECT)) / 10000.0
|
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
|
||||||
|
|
||||||
sales_avg = sales_sum_w / months
|
sales_avg = sales_sum_w / months
|
||||||
admin_avg = admin_sum_w / months
|
admin_avg = admin_sum_w / months
|
||||||
dep_avg = dep_sum_w / months
|
dep_avg = dep_sum_w / months
|
||||||
cash_admin_avg = admin_avg - dep_avg # 现金口径固定成本(剔除折旧纸面费用)
|
cash_admin_avg = admin_avg - dep_avg # 现金口径固定成本(剔除折旧纸面费用)
|
||||||
|
|
||||||
# 2. 渠补率:厂家政策值优先,日志附凭证累计校验
|
# 2. 变动成本率(⑤ 后=销货成本+税金及附加+渠补−厂补 完整口径):默认逐期实测累计
|
||||||
calc_rebate = (rebate_sum_w / sales_sum_w * 100.0) if sales_sum_w > 0 else 0.0
|
rebate_info = compute_margin_rate(db, entity_id, period_from, period_to, cal)
|
||||||
if rebate_rate_pct is None:
|
calc_pct = rebate_info["rebate_pct"]
|
||||||
rebate_rate_pct = POLICY_REBATE_RATE_PCT
|
# ⑤:渠补率(e1 专用;e2 无 ⇒ available=False)。F_BEP_REBATE 专用,独立于上面变动成本率。
|
||||||
cm_pct = 100.0 - rebate_rate_pct # 边际贡献率(%)
|
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)
|
||||||
|
|
||||||
# 3. 派生计算(万元 / %)——账面3条路径不变;现金口径在账面基础上剔除折旧
|
# 3. 派生计算(万元 / %)——账面3条路径不变;现金口径在账面基础上剔除折旧
|
||||||
bep = admin_avg / (cm_pct / 100.0) if cm_pct > 0 else 0.0
|
bep = admin_avg / (cm_pct / 100.0)
|
||||||
cover = (sales_avg / bep * 100.0) if bep > 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
|
gap = ((bep - sales_avg) / bep * 100.0) if bep > 0 else 0.0
|
||||||
cash_bep = cash_admin_avg / (cm_pct / 100.0) if cm_pct > 0 else 0.0
|
cash_bep = cash_admin_avg / (cm_pct / 100.0)
|
||||||
cash_cover = (sales_avg / cash_bep * 100.0) if cash_bep > 0 else 0.0
|
cash_cover = (sales_avg / cash_bep * 100.0) if cash_bep > 0 else 0.0
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
@@ -276,19 +587,51 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float,
|
|||||||
"F_BEP_GAP": round(gap, 1),
|
"F_BEP_GAP": round(gap, 1),
|
||||||
"F_BEP_REVENUE_CASH": round(cash_bep, 1),
|
"F_BEP_REVENUE_CASH": round(cash_bep, 1),
|
||||||
"F_BEP_COVER_CASH": round(cash_cover, 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',幂等)
|
# 4. upsert kpi_values (source_type='derived',幂等)
|
||||||
batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
batch = f"bep-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||||
written = {}
|
written = {}
|
||||||
value_rows = {}
|
value_rows = {}
|
||||||
|
changed = []
|
||||||
for code, _ in TARGET_KPIS:
|
for code, _ in TARGET_KPIS:
|
||||||
kpi = kpis[code]
|
kpi = kpis.get(code)
|
||||||
if code in CASH_CODES:
|
if kpi is None: # 定义缺失(仅可能为 F_BEP_REBATE,见上方告警)
|
||||||
remark = (f"现金口径: 管理费月均{admin_avg:.2f}万-折旧月均{dep_avg:.2f}万"
|
written[code] = "no-definition"
|
||||||
f"=固定现金{cash_admin_avg:.2f}万 ÷ 边际贡献率{cm_pct:.2f}% → 保本{cash_bep:.2f}万")
|
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}%)")
|
||||||
else:
|
else:
|
||||||
remark = f"保本派生器: 销售月均{sales_avg:.1f}万/管理费月均{admin_avg:.1f}万/渠补率{rebate_rate_pct:.2f}%"
|
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}%")
|
||||||
existing = db.query(KPIValue).filter(
|
existing = db.query(KPIValue).filter(
|
||||||
KPIValue.kpi_id == kpi.id,
|
KPIValue.kpi_id == kpi.id,
|
||||||
KPIValue.period == period,
|
KPIValue.period == period,
|
||||||
@@ -297,10 +640,24 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float,
|
|||||||
if write_kpi:
|
if write_kpi:
|
||||||
is_new = existing is None
|
is_new = existing is None
|
||||||
if existing:
|
if existing:
|
||||||
existing.actual_value = values[code]
|
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.source_batch = batch
|
existing.source_batch = batch
|
||||||
existing.remark = remark
|
if value_changed:
|
||||||
existing.data_status = "pending"
|
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,复核流入口不变。
|
||||||
else:
|
else:
|
||||||
existing = KPIValue(
|
existing = KPIValue(
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
@@ -359,11 +716,14 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float,
|
|||||||
status="pending",
|
status="pending",
|
||||||
))
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# 值变留痕(ASK-20260924-10):落复核提示件(报告类 → _reports/,不进队列根)
|
||||||
|
_write_value_changed_note(entity_id, period, batch, changed)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"entity_id": entity_id,
|
"entity_id": entity_id,
|
||||||
"period": period,
|
"period": period,
|
||||||
"months": months,
|
"months": months,
|
||||||
|
"data_status": "ok",
|
||||||
"sales_sum_w": round(sales_sum_w, 2),
|
"sales_sum_w": round(sales_sum_w, 2),
|
||||||
"sales_avg": round(sales_avg, 2),
|
"sales_avg": round(sales_avg, 2),
|
||||||
"admin_sum_w": round(admin_sum_w, 2),
|
"admin_sum_w": round(admin_sum_w, 2),
|
||||||
@@ -371,42 +731,244 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float,
|
|||||||
"dep_sum_w": round(dep_sum_w, 2),
|
"dep_sum_w": round(dep_sum_w, 2),
|
||||||
"dep_avg": round(dep_avg, 2),
|
"dep_avg": round(dep_avg, 2),
|
||||||
"cash_admin_avg": round(cash_admin_avg, 2),
|
"cash_admin_avg": round(cash_admin_avg, 2),
|
||||||
"rebate_sum_w": round(rebate_sum_w, 2),
|
"rebate_sum_w": rebate_info["rebate_sum_w"],
|
||||||
"rebate_policy_pct": rebate_rate_pct,
|
"rebate_sales_base_w": rebate_info["sales_sum_w"],
|
||||||
"rebate_calc_pct": round(calc_rebate, 2),
|
"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,
|
||||||
"cm_pct": round(cm_pct, 2),
|
"cm_pct": round(cm_pct, 2),
|
||||||
"values": values,
|
"values": values,
|
||||||
"written": written,
|
"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():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description="保本点派生KPI执行器")
|
ap = argparse.ArgumentParser(description="保本点派生KPI执行器")
|
||||||
ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认自动选最近完整月)")
|
ap.add_argument("--period", default=None, help="目标期间 YYYY-MM(默认自动选最近完整月)")
|
||||||
ap.add_argument("--entity", type=int, default=1, help="entity_id(默认1=陕西酣客)")
|
ap.add_argument("--entity", type=int, default=1, help="entity_id(1=陕西酣客 2=博海)")
|
||||||
ap.add_argument("--rebate-rate", type=float, default=None,
|
ap.add_argument("--rebate-rate", type=float, default=None,
|
||||||
help="渠补率%覆盖(默认厂家政策84.61)")
|
help="变动成本率%%人工覆盖(诊断用;**默认按 YTD 从 voucher_details 实测现算**,不再取政策常量)")
|
||||||
ap.add_argument("--dry-run", action="store_true", help="只计算不落库")
|
ap.add_argument("--dry-run", action="store_true", help="只计算不落库")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
db = get_session_local()()
|
db = get_session_local()()
|
||||||
try:
|
try:
|
||||||
period = args.period or _pick_target_period(db, args.entity, date.today())
|
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)
|
||||||
r = run_derive(db, args.entity, period, args.rebate_rate,
|
r = run_derive(db, args.entity, period, args.rebate_rate,
|
||||||
write_kpi=not args.dry_run)
|
write_kpi=not args.dry_run)
|
||||||
print(f"保本点派生计算完成 entity={r['entity_id']} period={r['period']} "
|
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"(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" 销售收入累计 {r['sales_sum_w']:.2f}万 月均 {r['sales_avg']:.2f}万")
|
print(f" 销售收入累计 {r['sales_sum_w']:.2f}万 月均 {r['sales_avg']:.2f}万")
|
||||||
print(f" 管理费累计 {r['admin_sum_w']:.2f}万 月均 {r['admin_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}万 (560213) | "
|
print(f" 折旧累计 {r['dep_sum_w']:.2f}万 月均 {r['dep_avg']:.2f}万 ({cal['dep_filter'][1]}) | "
|
||||||
f"现金固定成本月均 {r['cash_admin_avg']:.2f}万 (管理费-折旧)")
|
f"现金固定成本月均 {r['cash_admin_avg']:.2f}万 (固定成本-折旧)")
|
||||||
print(f" 渠补支出累计 {r['rebate_sum_w']:.2f}万 | 渠补率: 政策{r['rebate_policy_pct']:.2f}% "
|
print(f" 变动成本累计 {r['rebate_sum_w']:.2f}万 | {cal['margin_label']}(YTD) {r['rebate_actual_pct']:.2f}% "
|
||||||
f"(凭证累计校验 {r['rebate_calc_pct']:.2f}%) | 边际贡献率 {r['cm_pct']:.2f}%")
|
f"({cal['margin_desc']}) | 边际贡献率 {r['cm_pct']:.2f}%")
|
||||||
print(f" F_BEP_REVENUE 保本销售额 = {r['values']['F_BEP_REVENUE']} 万元")
|
print(f" F_BEP_REVENUE 保本销售额 = {r['values']['F_BEP_REVENUE']} 万元")
|
||||||
print(f" F_BEP_COVER 保本覆盖率 = {r['values']['F_BEP_COVER']} %")
|
print(f" F_BEP_COVER 保本覆盖率 = {r['values']['F_BEP_COVER']} %")
|
||||||
print(f" F_BEP_GAP 保本缺口 = {r['values']['F_BEP_GAP']} %")
|
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_REVENUE_CASH 现金保本销售额 = {r['values']['F_BEP_REVENUE_CASH']} 万元")
|
||||||
print(f" F_BEP_COVER_CASH 现金保本覆盖率 = {r['values']['F_BEP_COVER_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']}")
|
print(f" 落库: {r['written']}")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
#!/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)
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""比率类 KPI 单位对照表(只读)—— 财务口径裁定 裁定2a 的产出器
|
"""比率类 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 核对「定义 / 声明单位 / 阈值量纲 / 实测值量纲」,出对照表。
|
用途:逐个比率类 KPI 核对「定义 / 声明单位 / 阈值量纲 / 实测值量纲」,出对照表。
|
||||||
|
两类口径在表里分栏可见:`factor=1` + `unit=倍` = 倍口径同量纲直判;`factor=100` = 裁定2a 百分比归一。
|
||||||
判定侧的事实源是 `app/utils/caliber_units.RATIO_CALIBER_TABLE`(本脚本只核对与呈现,
|
判定侧的事实源是 `app/utils/caliber_units.RATIO_CALIBER_TABLE`(本脚本只核对与呈现,
|
||||||
不写库、不改阈值)。
|
不写库、不改阈值)。
|
||||||
|
|
||||||
@@ -80,7 +86,7 @@ def main():
|
|||||||
print(json.dumps(out, ensure_ascii=False, indent=1, default=str))
|
print(json.dumps(out, ensure_ascii=False, indent=1, default=str))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print("=== 比率类 KPI 单位对照表(只读;裁定2a)===")
|
print("=== 比率类 KPI 单位对照表(只读;口径 = 2a + v2.1 取代两项)===")
|
||||||
print("%-4s %-8s %-22s %-6s %-10s %-12s %-8s %-6s %s" % (
|
print("%-4s %-8s %-22s %-6s %-10s %-12s %-8s %-6s %s" % (
|
||||||
"eid", "code", "name", "unit", "latest", "actual", "status", "factor", "thresholds"))
|
"eid", "code", "name", "unit", "latest", "actual", "status", "factor", "thresholds"))
|
||||||
for x in out:
|
for x in out:
|
||||||
|
|||||||
@@ -200,6 +200,11 @@ def main():
|
|||||||
code_name, names, codes = load_voucher_index(db)
|
code_name, names, codes = load_voucher_index(db)
|
||||||
subject_dict = load_subject_dict(db)
|
subject_dict = load_subject_dict(db)
|
||||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == ENTITY_ID).all()}
|
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()
|
rows = db.query(KPISubjectMap).order_by(KPISubjectMap.kpi_id, KPISubjectMap.id).all()
|
||||||
grouped = defaultdict(list)
|
grouped = defaultdict(list)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#!/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())
|
||||||
@@ -2,15 +2,20 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""阈值单位口径修正 + 量纲守卫装配(迁移脚本·准备态)
|
"""阈值单位口径修正 + 量纲守卫装配(迁移脚本·准备态)
|
||||||
|
|
||||||
⚠️ **已被取代,不得执行(2026-09-17)**:
|
⚠️ **已被取代,不得执行(2026-09-17 首次判定;2026-09-24 复判并补新锚)**:
|
||||||
本脚本基于 2026-09-16 的口径草案(**把阈值改成「倍」写法** >=2.0/>=1.0/<1.0)。
|
本脚本基于 2026-09-16 的口径草案(**把阈值改成「倍」写法** >=2.0/>=1.0/<1.0)。
|
||||||
2026-09-17 财务**正式裁定**(`wecom-finance/reports/CMA_口径裁定_20260917.md` 裁定2a)
|
**状态更新(2026-09-24)**:
|
||||||
采用**相反方向**:比率类统一「百分比(%)」口径、**阈值保持不变**(150/80),
|
· 现行有效裁定 = `wecom-finance/reports/CMA_口径裁定_20260924_比率类单位口径_取代2a两指标.md`
|
||||||
在**取数层** ×100 归一(实现:`app/utils/caliber_units.py` + `scripts/alert_generator.py`)。
|
(口径标识 `CMA-CALIBER-v2.1/kpi-unit@2026-09-24`;任总 2026-09-24 16:38 放行 ASK-20260924-11)。
|
||||||
⇒ 执行本脚本会把口径反向切回「倍」,与现行裁定冲突;假红灯 586/587 在新口径下
|
该件**只取代** `CMA_口径裁定_20260917.md` §2a 中的 **`F_CURRENT_RATIO` / `F_QUICK_RATIO`**
|
||||||
**是合法红灯**(流动比率 1.29=129% < 150,与「流动性偏紧」一致),**不得作废**。
|
两项(改「倍」、无量纲直判);**其余比率类仍按 20260917 裁定2a(百分比 %)执行**。
|
||||||
保留本文件仅为审计链(历史草案)——如未来另有阈值单位新裁定,须先改 `PLAN` 与
|
· 本脚本 `PLAN` 里这两项的「倍」方向**与 v2.1 一致**,但**已由 ASK-20260924-11 单以受控路径落地**
|
||||||
`--resolve-false-reds` 的语义再执行。
|
(DB 写经受控脚本 + 判定层 `app/utils/caliber_units.py` 同量纲直判 + 代码测试发布),
|
||||||
|
**不得再执行本脚本**;本脚本 `PLAN` 另含 **`F_DEBT_RATIO`(不在 v2.1 取代范围内)**,
|
||||||
|
执行会越界改动他项 ⇒ 仅保留作审计链(历史草案)。
|
||||||
|
· `--resolve-false-reds` 的旧语义(586/587 按「口径错误」**作废**)**已作废**:v2.1 下该两条按
|
||||||
|
**口径变更改判**(1.29 倍 → 黄、0.54 倍 → 黄),留痕写明「口径变更所致」,**不删历史行**。
|
||||||
|
· 如未来另有阈值单位新裁定,须先改 `PLAN` 与 `--resolve-false-reds` 的语义再执行。
|
||||||
|
|
||||||
原依据:财务口径裁定 finance-20260916-cma-caliber-ruling.md v1.0 §四(裁定③)+ §3.3/§3.4
|
原依据:财务口径裁定 finance-20260916-cma-caliber-ruling.md v1.0 §四(裁定③)+ §3.3/§3.4
|
||||||
· 4.1 单位规范:偿债能力比率=**倍**(无量纲);资产负债率等=%;返利率=%
|
· 4.1 单位规范:偿债能力比率=**倍**(无量纲);资产负债率等=%;返利率=%
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""`subjects` 账套分层 DDL + 存量回填 — 2026-09-23
|
||||||
|
|
||||||
|
任务:`0-P1-project-20260923-entity-dimension-convergence-fullstack` §三③
|
||||||
|
(裁定件 `project-entity-dimension-ruling-20260923` §3.1「方案 A:分层」)
|
||||||
|
|
||||||
|
背景
|
||||||
|
----
|
||||||
|
`subjects` 207 行混装且 `subject_code` **全局唯一**(`uk_subject_code`):
|
||||||
|
· 43 行标准准则科目(1001 库存现金 …,`remark IS NULL`)= 天然全局
|
||||||
|
· 164 行 `remark='zxt-sync'` 酣客(智享通)企业明细 = 账套私有
|
||||||
|
双 token 实测两账套拿到**同一份 207 行** ⇒ 博海页面出现酣客的
|
||||||
|
`100106 酒窖备用金` / `100107 现金备用卡(唐)` / `100108 友赞商户`(结构性串配置)。
|
||||||
|
|
||||||
|
分层口径(`knowledge_articles` 同款已验模式)
|
||||||
|
------------------------------------------
|
||||||
|
`entity_id IS NULL` = 全局标准准则科目(两账套共享)
|
||||||
|
`entity_id = N` = 账套 N 私有明细
|
||||||
|
唯一键 `(subject_code)` → `(entity_id, subject_code)`
|
||||||
|
⚠️ MySQL 把 NULL 视为互不相同的索引值 ⇒ 「全局行同码重复」索引拦不住,
|
||||||
|
由写入口(`POST /api/cma/bot/subjects` UPSERT 判重)兜住,不可依赖索引。
|
||||||
|
|
||||||
|
动作(四段可独立跑,互不隐含)
|
||||||
|
----------------------------
|
||||||
|
--check 只读预检(默认):列/索引现状 + 行数分布 + 唯一键重复检查,零写入
|
||||||
|
--apply DDL:ADD COLUMN entity_id INT NULL + INDEX + UNIQUE(entity_id,subject_code)
|
||||||
|
− DROP INDEX uk_subject_code(幂等:已存在的步骤自动跳过)
|
||||||
|
--backfill 存量回填(**须 --evidence <json>**,与 DDL 分开放行;口径乙 = 2026-09-23 财务裁定):
|
||||||
|
`remark='zxt-sync'` 的 164 行**剔除排除白名单**后 → 156 行置 `--target-entity`
|
||||||
|
(默认 1);`remark IS NULL` 的 43 行 + 排除白名单 8 行保持/复位 `NULL`(合计 51)
|
||||||
|
⇒ 写后 `GROUP BY entity_id` = {1:156, NULL:51}
|
||||||
|
取证件 `exclude_subject_codes`:逐码命中须 = `expect_hits_per_code` 且总和须 =
|
||||||
|
`total_expect`,否则 rc=3(**不做部分回填**);**锚点双轨**:`by_remark_anchor`
|
||||||
|
(zxt-sync 164 / (null) 43 / total 207)与 `counts`(e1 156 / global_null 51)
|
||||||
|
并存校验,任一不符即 fail-closed rc=3
|
||||||
|
--check --evidence <json> 只读预检附口径乙校验(白名单逐码命中 + 锚点双轨读数),零写入
|
||||||
|
--down 回滚:重建 `uk_subject_code`(先断言无同码重复,fail-closed)→ 删新唯一键 → 删列
|
||||||
|
--show-create 打印 SHOW CREATE TABLE subjects 原文(验收证据)
|
||||||
|
|
||||||
|
生产保护(生产资产派单纪律)
|
||||||
|
--------------------------
|
||||||
|
目标库名 == 'cma' 时 `--apply/--backfill/--down` 一律拒绝;须 `--allow-prod` **且**
|
||||||
|
环境变量 `CMA_DDL_APPROVED=1`(= 已拿到 ops 审批单)才放行。`--check/--show-create` 只读不受限。
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
venv/bin/python3 scripts/migrate_subjects_entity_id_20260923.py --check
|
||||||
|
CMA_DDL_APPROVED=1 venv/bin/python3 scripts/migrate_subjects_entity_id_20260923.py \\
|
||||||
|
--apply --allow-prod
|
||||||
|
CMA_DDL_APPROVED=1 venv/bin/python3 scripts/migrate_subjects_entity_id_20260923.py \\
|
||||||
|
--backfill --allow-prod --evidence /path/to/evidence.json --target-entity 1
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, inspect, text # noqa: E402
|
||||||
|
|
||||||
|
TABLE = "subjects"
|
||||||
|
COLUMN = "entity_id"
|
||||||
|
NEW_UNIQUE = "uk_entity_subject"
|
||||||
|
OLD_UNIQUE = "uk_subject_code"
|
||||||
|
NEW_INDEX = "ix_subjects_entity_id"
|
||||||
|
PROD_DB_NAMES = ("cma",)
|
||||||
|
DDL_ADD_COLUMN = (
|
||||||
|
f"ALTER TABLE {TABLE} ADD COLUMN {COLUMN} INT NULL "
|
||||||
|
"COMMENT '企业ID(NULL=全局标准准则科目 / N=账套私有明细)' AFTER id"
|
||||||
|
)
|
||||||
|
DDL_ADD_UNIQUE = f"ALTER TABLE {TABLE} ADD UNIQUE INDEX {NEW_UNIQUE} ({COLUMN}, subject_code)"
|
||||||
|
DDL_DROP_OLD_UNIQUE = f"ALTER TABLE {TABLE} DROP INDEX {OLD_UNIQUE}"
|
||||||
|
DDL_ADD_OLD_UNIQUE = f"ALTER TABLE {TABLE} ADD UNIQUE INDEX {OLD_UNIQUE} (subject_code)"
|
||||||
|
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, unique: bool = False) -> str:
|
||||||
|
cols_s = ", ".join(cols)
|
||||||
|
if is_sqlite(engine):
|
||||||
|
return f"CREATE {'UNIQUE ' if unique else ''}INDEX {name} ON {TABLE} ({cols_s})"
|
||||||
|
return f"ALTER TABLE {TABLE} ADD {'UNIQUE ' if unique else ''}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 ""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 口径乙(2026-09-23 财务裁定,wecom-finance)
|
||||||
|
# · e1(酣客私有)= remark='zxt-sync' 行 **剔除排除白名单** 后的行数
|
||||||
|
# · global_null = remark IS NULL 行 + 排除白名单行
|
||||||
|
# · by_remark_anchor 与 counts 两轨并存,须同时与取证件一致(fail-closed)
|
||||||
|
# ============================================================
|
||||||
|
EXCLUDE_KEY = "exclude_subject_codes"
|
||||||
|
ANCHOR_KEY = "by_remark_anchor"
|
||||||
|
NULL_REMARK = "(null)"
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceError(Exception):
|
||||||
|
"""取证件/锚点校验失败 ⇒ fail-closed(rc=3:零写入、不做部分回填)"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_evidence(path) -> dict:
|
||||||
|
"""读取证件 JSON 并做结构校验(缺件/缺字段 ⇒ EvidenceError)"""
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
raise EvidenceError(f"取证件不存在:{path!r}(--backfill 必须带 --evidence <json>)")
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
ev = json.load(fh)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise EvidenceError(f"取证件解析失败:{exc}") from exc
|
||||||
|
missing = [k for k in ("dama_evidence", "finance_confirmation", "counts") if not ev.get(k)]
|
||||||
|
if missing:
|
||||||
|
raise EvidenceError(f"取证件缺字段 {missing}")
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
|
def exclude_spec(ev: dict) -> dict:
|
||||||
|
"""口径乙排除白名单规格(字段名以取证件 JSON 为准)"""
|
||||||
|
spec = ev.get(EXCLUDE_KEY)
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
raise EvidenceError(f"取证件缺 {EXCLUDE_KEY} 段(无法确定白名单,拒绝执行)")
|
||||||
|
codes = [str(c).strip() for c in (spec.get("codes") or [])]
|
||||||
|
per = spec.get("expect_hits_per_code")
|
||||||
|
total = spec.get("total_expect")
|
||||||
|
if not codes:
|
||||||
|
raise EvidenceError(f"{EXCLUDE_KEY}.codes 为空,拒绝执行")
|
||||||
|
if not spec.get("fail_closed"):
|
||||||
|
raise EvidenceError(f"{EXCLUDE_KEY}.fail_closed 缺失(非 fail-closed 形态,拒绝执行)")
|
||||||
|
if len(set(codes)) != len(codes):
|
||||||
|
raise EvidenceError(f"{EXCLUDE_KEY}.codes 存在重复:{codes}")
|
||||||
|
if per != 1:
|
||||||
|
raise EvidenceError(f"expect_hits_per_code={per!r} != 1,拒绝执行")
|
||||||
|
if total is None or int(total) != len(codes):
|
||||||
|
raise EvidenceError(f"total_expect={total!r} != 白名单码数 {len(codes)},拒绝执行")
|
||||||
|
return {"codes": codes, "expect_hits_per_code": 1, "total_expect": int(total)}
|
||||||
|
|
||||||
|
|
||||||
|
def probe_exclude_hits(engine, codes) -> dict:
|
||||||
|
"""逐码命中数(只读):subject_code 精确匹配行数"""
|
||||||
|
hits = {}
|
||||||
|
with engine.connect() as conn:
|
||||||
|
for code in codes:
|
||||||
|
hits[code] = int(conn.execute(
|
||||||
|
text(f"SELECT COUNT(*) FROM {TABLE} WHERE subject_code = :c"), {"c": code}).scalar())
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def caliber_b_readings(engine, codes) -> dict:
|
||||||
|
"""现网口径乙读数(只读):by_remark 实读 + e1/global_null 推算 + 白名单逐码命中"""
|
||||||
|
st = state(engine)
|
||||||
|
by_remark = dict(st["counts"].get("by_remark") or {})
|
||||||
|
live_remark = {"zxt-sync": int(by_remark.get("zxt-sync", 0)),
|
||||||
|
NULL_REMARK: int(by_remark.get(NULL_REMARK, 0)),
|
||||||
|
"total": int(st["counts"].get("total") or 0)}
|
||||||
|
hits = probe_exclude_hits(engine, codes)
|
||||||
|
hit_total = sum(hits.values())
|
||||||
|
live_counts = {"e1": live_remark["zxt-sync"] - hit_total,
|
||||||
|
"global_null": live_remark[NULL_REMARK] + hit_total}
|
||||||
|
return {"state": st, "by_remark": live_remark, "counts": live_counts,
|
||||||
|
"exclude_hits": hits, "exclude_total": hit_total}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_caliber_b(engine, ev, spec) -> dict:
|
||||||
|
"""双轨锚点校验:白名单逐码命中 + by_remark + counts,三者全部须与取证件一致"""
|
||||||
|
rd = caliber_b_readings(engine, spec["codes"])
|
||||||
|
hits = rd["exclude_hits"]
|
||||||
|
bad = {c: n for c, n in hits.items() if n != spec["expect_hits_per_code"]}
|
||||||
|
if bad:
|
||||||
|
raise EvidenceError(f"排除白名单逐码命中 != {spec['expect_hits_per_code']}:{bad}(拒绝执行)")
|
||||||
|
if rd["exclude_total"] != spec["total_expect"]:
|
||||||
|
raise EvidenceError(f"排除白名单命中总数 {rd['exclude_total']} != total_expect "
|
||||||
|
f"{spec['total_expect']}(拒绝执行,不做部分回填)")
|
||||||
|
exp_anchor = ev.get(ANCHOR_KEY)
|
||||||
|
if not isinstance(exp_anchor, dict):
|
||||||
|
raise EvidenceError(f"取证件缺 {ANCHOR_KEY}(锚点双轨的 remark 轨缺失,拒绝执行)")
|
||||||
|
counts = ev.get("counts") or {}
|
||||||
|
pairs = [("by_remark_anchor.zxt-sync", exp_anchor.get("zxt-sync"), rd["by_remark"]["zxt-sync"]),
|
||||||
|
(f"by_remark_anchor.{NULL_REMARK}", exp_anchor.get(NULL_REMARK), rd["by_remark"][NULL_REMARK]),
|
||||||
|
("by_remark_anchor.total", exp_anchor.get("total"), rd["by_remark"]["total"]),
|
||||||
|
("counts.e1", counts.get("e1"), rd["counts"]["e1"]),
|
||||||
|
("counts.global_null", counts.get("global_null"), rd["counts"]["global_null"])]
|
||||||
|
mismatch = [(k, e, l) for k, e, l in pairs if e is None or int(e) != int(l)]
|
||||||
|
if mismatch:
|
||||||
|
raise EvidenceError("锚点双轨与现网不符:" + ";".join(
|
||||||
|
f"{k}: evidence={e} vs live={l}" for k, e, l in mismatch))
|
||||||
|
if not rd["state"]["has_column"]:
|
||||||
|
raise EvidenceError("entity_id 列未落地,先跑 --apply(本单不得重跑 DDL)")
|
||||||
|
rd["spec"] = spec
|
||||||
|
rd["evidence_by_remark"] = {k: exp_anchor.get(k) for k in ("zxt-sync", NULL_REMARK, "total")}
|
||||||
|
rd["evidence_counts"] = {k: counts.get(k) for k in ("e1", "global_null")}
|
||||||
|
return rd
|
||||||
|
|
||||||
|
|
||||||
|
def print_caliber_b(rd: dict, evidence_path) -> None:
|
||||||
|
spec = rd.get("spec") or {}
|
||||||
|
print(f"== 口径乙校验(evidence: {evidence_path})==")
|
||||||
|
print(f" 排除白名单逐码命中:{rd['exclude_hits']}(合计 {rd['exclude_total']};"
|
||||||
|
f"expect_hits_per_code={spec.get('expect_hits_per_code')} / "
|
||||||
|
f"total_expect={spec.get('total_expect')})")
|
||||||
|
print(f" 锚点轨 A by_remark(现网):{rd['by_remark']} | 取证件:{rd['evidence_by_remark']}")
|
||||||
|
print(f" 锚点轨 B counts(现网):{rd['counts']} | 取证件:{rd['evidence_counts']}")
|
||||||
|
|
||||||
|
|
||||||
|
def do_precheck_evidence(engine, evidence_path) -> int:
|
||||||
|
"""--check 附 --evidence 的只读口径乙校验(零写入):通过 rc=0,任一不符 rc=3"""
|
||||||
|
try:
|
||||||
|
ev = load_evidence(evidence_path)
|
||||||
|
spec = exclude_spec(ev)
|
||||||
|
rd = verify_caliber_b(engine, ev, spec)
|
||||||
|
except EvidenceError as exc:
|
||||||
|
print(f"!!! {exc}")
|
||||||
|
print("!!! fail-closed:只读预检未通过(零写入)")
|
||||||
|
return 3
|
||||||
|
print_caliber_b(rd, evidence_path)
|
||||||
|
print(" [OK] 白名单命中 = total_expect 且锚点双轨一致 ⇒ --backfill 前置判据满足")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def state(engine) -> dict:
|
||||||
|
insp = inspect(engine)
|
||||||
|
tables = set(insp.get_table_names())
|
||||||
|
out = {"table_exists": TABLE in tables, "has_column": False,
|
||||||
|
"indexes": [], "unique_keys": [], "counts": {}}
|
||||||
|
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)})
|
||||||
|
out["unique_keys"] = sorted({uc["name"] for uc in insp.get_unique_constraints(TABLE)})
|
||||||
|
with engine.connect() as conn:
|
||||||
|
out["counts"]["total"] = conn.execute(text(f"SELECT COUNT(*) FROM {TABLE}")).scalar()
|
||||||
|
if out["has_column"]:
|
||||||
|
rows = conn.execute(text(
|
||||||
|
f"SELECT IFNULL({COLUMN},'NULL'), COUNT(*) FROM {TABLE} GROUP BY 1 ORDER BY 1")).fetchall()
|
||||||
|
out["counts"]["by_entity"] = [[str(r[0]), int(r[1])] for r in rows]
|
||||||
|
rows = conn.execute(text(
|
||||||
|
f"SELECT IFNULL(remark,'(null)'), COUNT(*) FROM {TABLE} GROUP BY 1 ORDER BY 2 DESC")).fetchall()
|
||||||
|
out["counts"]["by_remark"] = [[str(r[0]), int(r[1])] for r in rows]
|
||||||
|
dup_code = conn.execute(text(
|
||||||
|
f"SELECT COUNT(*) FROM (SELECT subject_code FROM {TABLE} GROUP BY subject_code "
|
||||||
|
f"HAVING COUNT(*) > 1) t")).scalar()
|
||||||
|
out["duplicate_subject_code_groups"] = int(dup_code)
|
||||||
|
if out["has_column"]:
|
||||||
|
dup_pair = conn.execute(text(
|
||||||
|
f"SELECT COUNT(*) FROM (SELECT {COLUMN}, subject_code FROM {TABLE} "
|
||||||
|
f"GROUP BY {COLUMN}, subject_code HAVING COUNT(*) > 1) t")).scalar()
|
||||||
|
out["duplicate_entity_code_groups"] = int(dup_pair)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def print_check(st: dict) -> None:
|
||||||
|
print("== 只读预检(inspector / SELECT,零写入)==")
|
||||||
|
print(f" table_exists={st['table_exists']} has_column={st['has_column']}")
|
||||||
|
print(f" indexes={st['indexes']}")
|
||||||
|
print(f" unique_keys={st['unique_keys']}")
|
||||||
|
print(f" counts={st['counts']}")
|
||||||
|
print(f" duplicate_subject_code_groups={st.get('duplicate_subject_code_groups')}")
|
||||||
|
if "duplicate_entity_code_groups" in st:
|
||||||
|
print(f" duplicate_entity_code_groups={st['duplicate_entity_code_groups']}")
|
||||||
|
|
||||||
|
|
||||||
|
def do_apply(engine) -> int:
|
||||||
|
st = state(engine)
|
||||||
|
if not st["table_exists"]:
|
||||||
|
print("!!! 目标库没有 subjects 表,拒绝 --apply")
|
||||||
|
return 3
|
||||||
|
if st.get("duplicate_subject_code_groups"):
|
||||||
|
print("!!! subjects.subject_code 已有重复组,先处置数据再加/换唯一键")
|
||||||
|
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)
|
||||||
|
present = set(cur["indexes"]) | set(cur["unique_keys"])
|
||||||
|
add_idx = stmt_add_index(engine, NEW_INDEX, [COLUMN])
|
||||||
|
if NEW_INDEX not in present:
|
||||||
|
conn.execute(text(add_idx))
|
||||||
|
done.append(add_idx)
|
||||||
|
add_uk = stmt_add_index(engine, NEW_UNIQUE, [COLUMN, "subject_code"], unique=True)
|
||||||
|
if NEW_UNIQUE not in present:
|
||||||
|
conn.execute(text(add_uk))
|
||||||
|
done.append(add_uk)
|
||||||
|
if OLD_UNIQUE in present:
|
||||||
|
drop_old = stmt_drop_index(engine, OLD_UNIQUE)
|
||||||
|
conn.execute(text(drop_old))
|
||||||
|
done.append(drop_old)
|
||||||
|
print("== APPLY 完成,执行语句:")
|
||||||
|
for s in done:
|
||||||
|
print(" " + s)
|
||||||
|
if not done:
|
||||||
|
print(" (无语句执行:已是目标形态,幂等)")
|
||||||
|
print_check(state(engine))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def do_backfill(engine, evidence_path: str, target: int) -> int:
|
||||||
|
"""口径乙回填:zxt-sync 剔除排除白名单 → target;其余(含白名单码)保持/复位 NULL"""
|
||||||
|
try:
|
||||||
|
ev = load_evidence(evidence_path)
|
||||||
|
spec = exclude_spec(ev)
|
||||||
|
rd = verify_caliber_b(engine, ev, spec)
|
||||||
|
except EvidenceError as exc:
|
||||||
|
print(f"!!! {exc}")
|
||||||
|
print("!!! fail-closed:拒绝执行 --backfill(事务未开启,零写入、不做部分回填)")
|
||||||
|
return 3
|
||||||
|
print_caliber_b(rd, evidence_path)
|
||||||
|
codes = spec["codes"]
|
||||||
|
ph = {f"c{i}": c for i, c in enumerate(codes)}
|
||||||
|
in_clause = ", ".join(f":c{i}" for i in range(len(codes)))
|
||||||
|
with engine.begin() as conn:
|
||||||
|
n1 = conn.execute(text(
|
||||||
|
f"UPDATE {TABLE} SET {COLUMN} = :e WHERE remark = 'zxt-sync' "
|
||||||
|
f"AND subject_code NOT IN ({in_clause})"), dict(ph, e=target)).rowcount
|
||||||
|
n0 = conn.execute(text(
|
||||||
|
f"UPDATE {TABLE} SET {COLUMN} = NULL WHERE {COLUMN} IS NOT NULL "
|
||||||
|
f"AND (remark IS NULL OR subject_code IN ({in_clause}))"), ph).rowcount
|
||||||
|
print(f"== BACKFILL 完成(口径乙):remark='zxt-sync' 且非排除白名单 → entity_id={target} "
|
||||||
|
f"命中 {n1} 行;标准准则科目 + 排除白名单码保持/复位 NULL {n0} 行")
|
||||||
|
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
|
||||||
|
if st.get("duplicate_subject_code_groups"):
|
||||||
|
print(f"!!! 已存在同码多行({st['duplicate_subject_code_groups']} 组)⇒ 无法重建全局唯一键 "
|
||||||
|
f"uk_subject_code,拒绝 --down(fail-closed:先人工处置同码行)")
|
||||||
|
return 3
|
||||||
|
if st["counts"]["by_entity"] and len(st["counts"]["by_entity"]) > 1 and not yes:
|
||||||
|
print(f"!!! 列内已有分账套数据 {st['counts']['by_entity']}:回滚会丢失账套归属,须显式 --yes")
|
||||||
|
return 3
|
||||||
|
with engine.begin() as conn:
|
||||||
|
present = set(st["indexes"]) | set(st["unique_keys"])
|
||||||
|
if NEW_UNIQUE in present:
|
||||||
|
conn.execute(text(stmt_drop_index(engine, NEW_UNIQUE)))
|
||||||
|
cur = state(engine)
|
||||||
|
present = set(cur["indexes"]) | set(cur["unique_keys"])
|
||||||
|
if NEW_INDEX in present:
|
||||||
|
conn.execute(text(stmt_drop_index(engine, NEW_INDEX)))
|
||||||
|
if OLD_UNIQUE not in present:
|
||||||
|
conn.execute(text(stmt_add_index(engine, OLD_UNIQUE, ["subject_code"], unique=True)))
|
||||||
|
conn.execute(text(DDL_DROP_COLUMN))
|
||||||
|
print("== DOWN 完成:重建 uk_subject_code + 删除 entity_id 列")
|
||||||
|
print_check(state(engine))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="subjects 账套分层 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("--backfill", action="store_true", help="存量回填(须 --evidence)")
|
||||||
|
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("--evidence", default=None, help="--backfill 的取证件 JSON")
|
||||||
|
ap.add_argument("--target-entity", type=int, default=1, help="--backfill 目标账套(默认 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.backfill 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.backfill:
|
||||||
|
return do_backfill(engine, args.evidence, args.target_entity)
|
||||||
|
if args.down:
|
||||||
|
return do_down(engine, args.yes)
|
||||||
|
|
||||||
|
st = state(engine)
|
||||||
|
print_check(st)
|
||||||
|
if args.evidence:
|
||||||
|
rc = do_precheck_evidence(engine, args.evidence)
|
||||||
|
if rc != 0:
|
||||||
|
return rc
|
||||||
|
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())
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""复盘落地执行器 —— 生成「月度运营复盘 / 季度战略回顾」并经既有 API 落库(2026-10-12)
|
||||||
|
|
||||||
|
任务: yanxue-20261012-review-records-write(任总 2026-10-12 P0-1)
|
||||||
|
链路: app/utils/review_builder.build_review(只读算)
|
||||||
|
→ POST /api/cma/maps/{map_id}/review-records(**唯一写入口**,已部署端点,不改后端)
|
||||||
|
→ GET /api/cma/maps/{map_id}/review-records(读回校验)
|
||||||
|
|
||||||
|
用法(默认只预览,不写):
|
||||||
|
cd /srv/hermes/shared/cma-management/backend
|
||||||
|
./venv/bin/python3 scripts/review_run.py --entity 2 --map 49 --type monthly --print
|
||||||
|
./venv/bin/python3 scripts/review_run.py --entity 2 --map 49 --type quarterly --write \
|
||||||
|
--evidence /path/evidence.json
|
||||||
|
|
||||||
|
写动作纪律:
|
||||||
|
· **幂等(2026-10-13 P1-1)**:--write 前先按同一读路径列出该 map 的复盘;同日同 review_type 已存在 ⇒ NOOP 零写(`--force` 才强制再写)。
|
||||||
|
· 幂等闸读不到(列表接口非 200)⇒ **fail-closed 不写**(rc=6),宁可缺一条也不产重复行。
|
||||||
|
· 本脚本不做任何直接 SQL 写;落库一律经 HTTP API(走鉴权/多租户/审计同一路径)。
|
||||||
|
· 凭据进程内从 600 权限文件读取(--admin-env 或 $CMA_ADMIN_ENV_FILE),不入命令行/日志。
|
||||||
|
· 生产写前先过 approval-gate(调用方负责),本脚本只负责「按放行结果执行一次」。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.database import get_db # noqa: E402
|
||||||
|
from app.utils.review_builder import ( # noqa: E402
|
||||||
|
REVIEW_TYPES, build_review,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path: str) -> dict:
|
||||||
|
d = {}
|
||||||
|
with open(path) as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
d[k.strip()] = v.strip()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _http(method: str, url: str, token=None, body=None, timeout=30):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", "Bearer " + token)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return r.status, json.loads(r.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode()[:800]
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return -1, repr(e)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--entity", type=int, required=True)
|
||||||
|
ap.add_argument("--map", type=int, required=True, dest="map_id")
|
||||||
|
ap.add_argument("--type", required=True, choices=list(REVIEW_TYPES))
|
||||||
|
ap.add_argument("--date", default=None, help="复盘日期 YYYY-MM-DD(缺省今天)")
|
||||||
|
ap.add_argument("--period", default=None, help="现值期间上限 YYYY-MM(缺省当月)")
|
||||||
|
ap.add_argument("--api-base", default="http://127.0.0.1:8010")
|
||||||
|
ap.add_argument("--admin-env", default=os.environ.get("CMA_ADMIN_ENV_FILE", ""),
|
||||||
|
help="登录凭据文件(600);仅 --write 需要")
|
||||||
|
ap.add_argument("--print", dest="do_print", action="store_true", help="打印复盘全文(预览)")
|
||||||
|
ap.add_argument("--json-out", default=None, help="把生成载荷落 JSON(留痕)")
|
||||||
|
ap.add_argument("--write", action="store_true", help="经 API 落库(默认不写)")
|
||||||
|
ap.add_argument("--force", action="store_true",
|
||||||
|
help="跳过同日同 type 幂等闸(默认关;常态化触发勿用)")
|
||||||
|
ap.add_argument("--evidence", default=None, help="写模式证据 JSON 落盘路径")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
rdate = datetime.strptime(args.date, "%Y-%m-%d").date() if args.date else date.today()
|
||||||
|
|
||||||
|
db = next(get_db())
|
||||||
|
try:
|
||||||
|
payload = build_review(db, entity_id=args.entity, map_id=args.map_id,
|
||||||
|
review_type=args.type, period_ref=args.period,
|
||||||
|
review_date=rdate)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
db.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
post_body = {
|
||||||
|
"review_type": args.type,
|
||||||
|
"review_date": rdate.isoformat(),
|
||||||
|
"summary": payload["summary"],
|
||||||
|
"adjustments": payload["adjustments"],
|
||||||
|
"next_priorities": payload["next_priorities"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.do_print:
|
||||||
|
print(payload["summary"])
|
||||||
|
print("\n--- next_priorities ---")
|
||||||
|
for i, p in enumerate(payload["next_priorities"], 1):
|
||||||
|
print(f"{i}. {p}")
|
||||||
|
print(f"\n--- meta --- {json.dumps(payload['meta'], ensure_ascii=False)}")
|
||||||
|
print(f"--- adjustments 条目数 {len(payload['adjustments'])} "
|
||||||
|
f"(kpi {sum(1 for a in payload['adjustments'] if a['type'] == 'kpi')} / "
|
||||||
|
f"kr {sum(1 for a in payload['adjustments'] if a['type'] == 'kr')} / "
|
||||||
|
f"objective {sum(1 for a in payload['adjustments'] if a['type'] == 'objective')})")
|
||||||
|
|
||||||
|
if args.json_out:
|
||||||
|
with open(args.json_out, "w") as fh:
|
||||||
|
json.dump({"post_body": post_body, "snapshot": payload["snapshot"]},
|
||||||
|
fh, ensure_ascii=False, indent=1, default=str)
|
||||||
|
print(f"[json-out] {args.json_out}")
|
||||||
|
|
||||||
|
if not args.write:
|
||||||
|
print("[preview] 未写库(需 --write)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.admin_env or not os.path.exists(args.admin_env):
|
||||||
|
print(f"❌ --write 需要凭据文件(--admin-env 或 $CMA_ADMIN_ENV_FILE):{args.admin_env}")
|
||||||
|
return 2
|
||||||
|
cred = _load_env_file(args.admin_env)
|
||||||
|
st, res = _http("POST", f"{args.api_base}/api/cma/auth/login", body={
|
||||||
|
"username": cred["CMA_ADMIN_USER"],
|
||||||
|
"password": cred["CMA_ADMIN_PASS"],
|
||||||
|
"entity_id": int(cred["CMA_ADMIN_ENTITY_ID"]),
|
||||||
|
})
|
||||||
|
if st != 200:
|
||||||
|
print(f"❌ 登录失败 {st}: {res}")
|
||||||
|
return 3
|
||||||
|
token = res["token"]
|
||||||
|
|
||||||
|
# ---- 幂等闸(P1-1):同日同 type 已存在 ⇒ NOOP(零写)----
|
||||||
|
if not args.force:
|
||||||
|
st_l0, listed0 = _http("GET",
|
||||||
|
f"{args.api_base}/api/cma/maps/{args.map_id}/review-records",
|
||||||
|
token=token)
|
||||||
|
if st_l0 != 200 or not isinstance(listed0, dict):
|
||||||
|
print(f"❌ 幂等闸无法读取历史复盘(GET list={st_l0})⇒ fail-closed 不写,rc=6")
|
||||||
|
return 6
|
||||||
|
existing_id = None
|
||||||
|
for r0 in listed0.get("data", []):
|
||||||
|
if (r0.get("review_type") == args.type
|
||||||
|
and str(r0.get("review_date") or "")[:10] == rdate.isoformat()):
|
||||||
|
existing_id = r0.get("id")
|
||||||
|
break
|
||||||
|
if existing_id is not None:
|
||||||
|
print(f"[idempotent] {args.type} / {rdate.isoformat()} 已存在 id={existing_id} ⇒ NOOP(零写)")
|
||||||
|
if args.evidence:
|
||||||
|
with open(args.evidence, "w") as fh:
|
||||||
|
json.dump({"task": "yanxue-20261012-review-records-write",
|
||||||
|
"at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"entity_id": args.entity, "map_id": args.map_id,
|
||||||
|
"review_type": args.type, "review_date": rdate.isoformat(),
|
||||||
|
"action": "NOOP", "record_id": existing_id,
|
||||||
|
"idempotent_guard": True, "list_status": st_l0,
|
||||||
|
"reason": "same review_type+review_date already present"},
|
||||||
|
fh, ensure_ascii=False, indent=1, default=str)
|
||||||
|
print(f"[evidence] {args.evidence}")
|
||||||
|
return 0
|
||||||
|
st, created = _http("POST",
|
||||||
|
f"{args.api_base}/api/cma/maps/{args.map_id}/review-records",
|
||||||
|
token=token, body=post_body)
|
||||||
|
print(f"[write] POST review-records -> {st}")
|
||||||
|
if st not in (200, 201):
|
||||||
|
print(f"❌ 落库失败: {created}")
|
||||||
|
return 4
|
||||||
|
record_id = created.get("id") if isinstance(created, dict) else None
|
||||||
|
|
||||||
|
# 读回校验(同 API,与复盘页同一读路径)
|
||||||
|
st_l, listed = _http("GET", f"{args.api_base}/api/cma/maps/{args.map_id}/review-records",
|
||||||
|
token=token)
|
||||||
|
st_d, detail = _http("GET",
|
||||||
|
f"{args.api_base}/api/cma/maps/{args.map_id}/review-records/{record_id}",
|
||||||
|
token=token)
|
||||||
|
ok = (st_l == 200 and st_d == 200
|
||||||
|
and isinstance(listed, dict) and any(r.get("id") == record_id
|
||||||
|
for r in listed.get("data", [])))
|
||||||
|
print(f"[read-back] list={st_l} detail={st_d} 命中记录 {record_id}: {ok}")
|
||||||
|
|
||||||
|
if args.evidence:
|
||||||
|
with open(args.evidence, "w") as fh:
|
||||||
|
json.dump({
|
||||||
|
"task": "yanxue-20261012-review-records-write",
|
||||||
|
"at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"entity_id": args.entity, "map_id": args.map_id, "review_type": args.type,
|
||||||
|
"review_date": rdate.isoformat(),
|
||||||
|
"record_id": record_id,
|
||||||
|
"post_status": st, "list_status": st_l, "detail_status": st_d,
|
||||||
|
"read_back_ok": ok,
|
||||||
|
"meta": payload["meta"],
|
||||||
|
"created": created,
|
||||||
|
"read_back": detail if isinstance(detail, dict) else detail,
|
||||||
|
}, fh, ensure_ascii=False, indent=1, default=str)
|
||||||
|
print(f"[evidence] {args.evidence}")
|
||||||
|
|
||||||
|
return 0 if ok else 5
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""`quality_rule_config` 出厂默认值落库(全账套模板行 entity_id=0,is_default=1)
|
||||||
|
|
||||||
|
任务:ASK-20260928-05(CMA 财务七规则可配置化 · 最小实现),清单 §8「初始化」条目。
|
||||||
|
|
||||||
|
**本脚本是可选的**:读路径三层解析的最后一层就是**代码内置默认**
|
||||||
|
(`app/dq_rule_config.py` 的 SPEC/GLOBAL_SPEC,与清单 §7 建议默认值逐项一致),
|
||||||
|
因此**不落库也能上线且行为相同**(零行为差异)。落库的价值只有两个:
|
||||||
|
|
||||||
|
1. 让「全账套模板」显式可见(UI 的「来源」列由 `内置默认` 变成 `全账套模板`);
|
||||||
|
2. 给「恢复出厂默认 / 当前值 vs 默认值 diff」留一行可审计的锚(`is_default=1`)。
|
||||||
|
|
||||||
|
幂等契约
|
||||||
|
--------
|
||||||
|
首跑 --apply:created = 39(缺失项),skipped = 0
|
||||||
|
二跑 --apply:created = 0,skipped = 39,零副作用(**不覆盖**已存在的行)
|
||||||
|
已存在且值与默认**不同** → 只报告 diff(`differs`),**不覆盖**(`--force` 才覆盖)
|
||||||
|
|
||||||
|
边界
|
||||||
|
----
|
||||||
|
* 生产写:需要在**审批门**放行后执行(`approval-gate`);不改表结构(DDL 由
|
||||||
|
`backend/scripts/db_migrate.py --apply` 的部署链特权步负责)。
|
||||||
|
* 连接:与 db_migrate 同口径 —— `--db-url` > `CMA_MIGRATE_DB_USER/PASS`(TCP)> 本机 root socket;
|
||||||
|
显式拒绝应用运行账号(`cma_api`)。
|
||||||
|
* 回滚:`--rollback latest` 只删本次 --apply 记录在 journal 里的行 id(零 `DELETE` 全表)。
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
python3 scripts/seed_dq_rule_config.py --check # 只读预检(默认动作)
|
||||||
|
python3 scripts/seed_dq_rule_config.py --apply # 落默认行 + 写 journal
|
||||||
|
python3 scripts/seed_dq_rule_config.py --apply --force # 已存在且不同的行也覆盖(慎用)
|
||||||
|
python3 scripts/seed_dq_rule_config.py --rollback latest
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text # noqa: E402
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DEFAULT_JOURNAL_DIR = os.path.join(HERE, "backups", "dq-rule-config-seed-journal")
|
||||||
|
DEFAULT_SOCKET = "/var/run/mysqld/mysqld.sock"
|
||||||
|
DEFAULT_DB = "cma"
|
||||||
|
FORBIDDEN_RUN_ACCOUNTS = {"cma_api"}
|
||||||
|
TABLE = "quality_rule_config"
|
||||||
|
|
||||||
|
|
||||||
|
def _db_name(args) -> str:
|
||||||
|
return (args.db_name or os.getenv("CMA_MIGRATE_DB_NAME")
|
||||||
|
or os.getenv("CMA_DB_NAME") or DEFAULT_DB)
|
||||||
|
|
||||||
|
|
||||||
|
def build_engine(args):
|
||||||
|
db_name = _db_name(args)
|
||||||
|
if args.db_url:
|
||||||
|
url = args.db_url if "/" in args.db_url.rsplit("@", 1)[-1] else args.db_url.rstrip("/") + "/" + db_name
|
||||||
|
return create_engine(url, pool_pre_ping=True), f"db-url({url})"
|
||||||
|
user = os.getenv("CMA_MIGRATE_DB_USER", "").strip()
|
||||||
|
if user:
|
||||||
|
if user in FORBIDDEN_RUN_ACCOUNTS:
|
||||||
|
raise SystemExit(f"[FAIL] {user} 是应用运行账号,不得用于本脚本;请用专用账号或 root socket。")
|
||||||
|
pwd = os.getenv("CMA_MIGRATE_DB_PASS", "")
|
||||||
|
host = os.getenv("CMA_MIGRATE_DB_HOST", "127.0.0.1")
|
||||||
|
port = os.getenv("CMA_MIGRATE_DB_PORT", "3306")
|
||||||
|
url = f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{db_name}?charset=utf8mb4"
|
||||||
|
return create_engine(url, pool_pre_ping=True), f"tcp({user}@{host}:{port}/{db_name})"
|
||||||
|
sock = args.socket or os.getenv("CMA_MIGRATE_DB_SOCKET") or DEFAULT_SOCKET
|
||||||
|
if not os.path.exists(sock):
|
||||||
|
raise SystemExit(f"[FAIL] 未设 CMA_MIGRATE_DB_USER 且 socket 不存在:{sock}")
|
||||||
|
url = f"mysql+pymysql://root@localhost/{db_name}?unix_socket={sock}&charset=utf8mb4"
|
||||||
|
return create_engine(url, pool_pre_ping=True), f"socket(root@{sock}/{db_name})"
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults():
|
||||||
|
"""(rule_key, param_key, value, value_type, open_level, desc) 全量默认 —— 单一来源 = app/dq_rule_config。"""
|
||||||
|
from app import dq_rule_config as dqcfg
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for rule in dqcfg.RULE_KEYS:
|
||||||
|
for param, spec in dqcfg.SPEC[rule].items():
|
||||||
|
out.append((rule, param, spec.get("default"), spec.get("type"),
|
||||||
|
spec.get("open_level"), spec.get("desc")))
|
||||||
|
for param, spec in dqcfg.GLOBAL_SPEC.items():
|
||||||
|
out.append((dqcfg.GLOBAL_RULE_KEY, param, spec.get("default"), spec.get("type"),
|
||||||
|
spec.get("open_level"), spec.get("desc")))
|
||||||
|
# 规则级 detail_limit 的出厂值 = 全局 detail_limit(内建默认口径)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _journal_dir() -> str:
|
||||||
|
d = os.getenv("CMA_DQ_SEED_JOURNAL_DIR") or DEFAULT_JOURNAL_DIR
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_check(args, engine, conn_desc) -> int:
|
||||||
|
rows = _defaults()
|
||||||
|
missing, same, differs = [], [], []
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if not conn.dialect.has_table(conn, TABLE):
|
||||||
|
print(f"[check] 连接 {conn_desc} → 表 {TABLE} **不存在**:"
|
||||||
|
f"请先跑 `backend/scripts/db_migrate.py --apply`(D 部署链特权迁移步)。")
|
||||||
|
return 0
|
||||||
|
for rule, param, value, vtype, level, desc in rows:
|
||||||
|
got = conn.execute(text(
|
||||||
|
f"SELECT param_value FROM {TABLE} WHERE entity_id=0 AND rule_key=:r AND param_key=:p"),
|
||||||
|
{"r": rule, "p": param}).scalar()
|
||||||
|
if got is None:
|
||||||
|
missing.append((rule, param, value))
|
||||||
|
elif json.loads(got) == value:
|
||||||
|
same.append((rule, param))
|
||||||
|
else:
|
||||||
|
differs.append((rule, param, json.loads(got), value))
|
||||||
|
print(f"[check] 连接 {conn_desc}")
|
||||||
|
print(f"[check] 待插入 {len(missing)} / 已一致 {len(same)} / 值不同 {len(differs)}(共 {len(rows)} 项)")
|
||||||
|
for rule, param, value in missing[:10]:
|
||||||
|
print(f" + {rule}.{param} = {value}")
|
||||||
|
for rule, param, have, want in differs:
|
||||||
|
print(f" ! {rule}.{param} 库内={have} 默认={want}(不加 --force 不覆盖)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_apply(args, engine, conn_desc) -> int:
|
||||||
|
rows = _defaults()
|
||||||
|
created, skipped, differs, failed = [], [], [], []
|
||||||
|
with engine.begin() as conn:
|
||||||
|
if not conn.dialect.has_table(conn, TABLE):
|
||||||
|
print(f"[FAIL] 表 {TABLE} 不存在 —— 先跑 db_migrate.py --apply")
|
||||||
|
return 1
|
||||||
|
for rule, param, value, vtype, level, desc in rows:
|
||||||
|
payload = json.dumps(value, ensure_ascii=False)
|
||||||
|
cur = conn.execute(text(
|
||||||
|
f"SELECT id, param_value FROM {TABLE} WHERE entity_id=0 AND rule_key=:r AND param_key=:p"),
|
||||||
|
{"r": rule, "p": param}).fetchone()
|
||||||
|
if cur is None:
|
||||||
|
res = conn.execute(text(
|
||||||
|
f"INSERT INTO {TABLE} (entity_id, rule_key, param_key, param_value, value_type,"
|
||||||
|
f" open_level, is_default, description, updated_by)"
|
||||||
|
f" VALUES (0, :r, :p, :v, :t, :l, 1, :d, 'seed:ASK-20260928-05')"),
|
||||||
|
{"r": rule, "p": param, "v": payload, "t": vtype, "l": level, "d": (desc or "")[:500]})
|
||||||
|
created.append({"id": int(res.lastrowid), "rule_key": rule, "param_key": param,
|
||||||
|
"value": value})
|
||||||
|
elif json.loads(cur[1]) == value:
|
||||||
|
skipped.append((rule, param))
|
||||||
|
elif args.force:
|
||||||
|
conn.execute(text(f"UPDATE {TABLE} SET param_value=:v, is_default=1,"
|
||||||
|
f" updated_by='seed:ASK-20260928-05' WHERE id=:i"),
|
||||||
|
{"v": payload, "i": cur[0]})
|
||||||
|
failed.append({"id": int(cur[0]), "rule_key": rule, "param_key": param,
|
||||||
|
"before": json.loads(cur[1]), "after": value, "forced": True})
|
||||||
|
else:
|
||||||
|
differs.append((rule, param, json.loads(cur[1]), value))
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"action": "seed_dq_rule_config", "status": "applied",
|
||||||
|
"at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"connection": conn_desc, "db_name": _db_name(args),
|
||||||
|
"created": created, "created_count": len(created),
|
||||||
|
"skipped_count": len(skipped), "differs": differs, "forced": failed,
|
||||||
|
"actor": f"{os.getenv('USER', 'unknown')}@{socket.gethostname()}",
|
||||||
|
}
|
||||||
|
# 文件名带微秒:同一秒内连跑两次不会互相覆盖(离线回归实证)
|
||||||
|
path = os.path.join(_journal_dir(), f"{datetime.now().strftime('%Y%m%dT%H%M%S-%f')}-applied.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||||
|
print(f"[apply] 连接 {conn_desc}")
|
||||||
|
print(f"[apply] 新增 {len(created)} / 跳过(已一致) {len(skipped)} / 值不同未覆盖 {len(differs)}"
|
||||||
|
f" / 强制覆盖 {len(failed)}")
|
||||||
|
for rule, param, have, want in differs:
|
||||||
|
print(f" ! {rule}.{param} 库内={have} ≠ 默认={want}(未覆盖)")
|
||||||
|
print(f"[apply] journal: {path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_rollback(args, engine, conn_desc) -> int:
|
||||||
|
d = _journal_dir()
|
||||||
|
ref = args.rollback
|
||||||
|
if ref in ("latest", ""):
|
||||||
|
cands = sorted(f for f in os.listdir(d) if f.endswith("-applied.json"))
|
||||||
|
if not cands:
|
||||||
|
raise SystemExit("[FAIL] 没有可回滚的 journal")
|
||||||
|
path = os.path.join(d, cands[-1])
|
||||||
|
else:
|
||||||
|
path = ref if os.path.exists(ref) else os.path.join(d, os.path.basename(ref))
|
||||||
|
payload = json.load(open(path, encoding="utf-8"))
|
||||||
|
ids = [c["id"] for c in payload.get("created", [])]
|
||||||
|
print(f"[rollback] journal={os.path.basename(path)} 将删除 {len(ids)} 行(仅本次新增的 id)")
|
||||||
|
if not ids:
|
||||||
|
print("[rollback] 无新增行,零动作")
|
||||||
|
return 0
|
||||||
|
with engine.begin() as conn:
|
||||||
|
res = conn.execute(text(f"DELETE FROM {TABLE} WHERE id IN ({','.join(str(int(i)) for i in ids)})"))
|
||||||
|
print(f"[rollback] deleted={res.rowcount}(连接 {conn_desc})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="quality_rule_config 出厂默认值落库(ASK-20260928-05)")
|
||||||
|
g = ap.add_mutually_exclusive_group()
|
||||||
|
g.add_argument("--check", action="store_true", help="只读预检(默认动作)")
|
||||||
|
g.add_argument("--apply", action="store_true", help="落默认行 + journal")
|
||||||
|
g.add_argument("--rollback", metavar="REF", nargs="?", const="latest",
|
||||||
|
help="回滚最近一次 --apply 新增的行(REF=latest 或 journal 文件名)")
|
||||||
|
ap.add_argument("--force", action="store_true", help="已存在且值不同时也覆盖(慎用)")
|
||||||
|
ap.add_argument("--json", action="store_true")
|
||||||
|
ap.add_argument("--socket", default=None)
|
||||||
|
ap.add_argument("--db-url", default=None)
|
||||||
|
ap.add_argument("--db-name", default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
engine, desc = build_engine(args)
|
||||||
|
if args.apply:
|
||||||
|
return cmd_apply(args, engine, desc)
|
||||||
|
if args.rollback is not None:
|
||||||
|
return cmd_rollback(args, engine, desc)
|
||||||
|
return cmd_check(args, engine, desc)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- CMA BEP 变动成本率派生KPI定义 — F_BEP_VC_RATE(2026-10-03 ⑤ 口径统一)
|
||||||
|
-- 依据: 任总 2026-10-03 18:00 DM「统一」+ 任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack §追加⑤
|
||||||
|
-- 口径: 变动成本率 = 变动成本 ÷ 收入基数(YTD 累计,2位小数)
|
||||||
|
-- · 酣客(e1): 变动成本 = Σ5401借(销货成本)+Σ5403借(税金及附加)+Σ571103借(渠补)−Σ530103贷(厂补)
|
||||||
|
-- · 博海(e2): 变动成本 = Σ6401借(主营业务成本)
|
||||||
|
-- 保本额 = 固定成本月均 ÷ (1 − 变动成本率)
|
||||||
|
-- 说明: F_BEP_REBATE(实测渠补率/博海主营成本率) 保留,⑤ 后**不再充当变动成本率**
|
||||||
|
-- 幂等: NOT EXISTS 守卫,可安全重跑
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||||
|
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||||
|
kpi_level, status, bot_source, epic, created_by)
|
||||||
|
SELECT 1, 'F_BEP_VC_RATE', '变动成本率', 'finance', 'profitability',
|
||||||
|
'round((sum(5401借)+sum(5403借)+sum(571103借)-sum(530103贷))/sum(5001贷)*100,2)',
|
||||||
|
'变动成本率(YTD累计,⑤完整口径)=(Σ5401借+Σ5403借+Σ571103借−Σ530103贷)÷Σ5001贷; 供保本点卡面单一事实源; 缺数期不出值(fail-closed)',
|
||||||
|
'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 5401/5403/571103/530103/5001 累计)', '财务部', 'monthly', '%', 0.00,
|
||||||
|
'ratio', NULL, NULL, NULL,
|
||||||
|
'strategic', 'active', 'finance-bot', 'Epic2', 1
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_BEP_VC_RATE'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||||
|
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||||
|
kpi_level, status, bot_source, epic, created_by)
|
||||||
|
SELECT 2, 'F_BEP_VC_RATE', '变动成本率(主营业务成本率)', 'finance', 'profitability',
|
||||||
|
'round(sum(6401借)/sum(6001贷)*100,2)',
|
||||||
|
'变动成本率(实测,YTD累计)=Σ6401借方÷Σ6001贷方(剔结转账腿); 博海无渠补科目; 供保本点卡面单一事实源, 无阈值',
|
||||||
|
'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6401/6001 累计)', '财务部', 'monthly', '%', 0,
|
||||||
|
'ratio', NULL, NULL, NULL,
|
||||||
|
'strategic', 'active', 'finance-bot', 'Epic2', 1
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM kpi_definitions WHERE entity_id = 2 AND kpi_code = 'F_BEP_VC_RATE'
|
||||||
|
);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- CMA BEP(保本点) 派生KPI定义 — 博海 entity_id=2 — cma-bep-kpi-20261003
|
||||||
|
-- 任务: finance-20261003-cvp-fix-and-bohai-coverage-fullstack.md(放行出处 任总 2026-10-03 11:07/11:13 CST DM)
|
||||||
|
-- 口径(博海新口径,勿套酣客渠补公式):
|
||||||
|
-- 变动成本率 = Σ6401借方 ÷ Σ6001贷方(主营业务成本率;博海无渠补科目571103)
|
||||||
|
-- 固定成本 = (6601销售费用 + 6602管理费用 + 6603财务费用) 借−贷净额月均
|
||||||
|
-- 折旧剔除 = 1602 累计折旧「当期贷方发生额」(现金口径;博海科目表只到4位一级,无费用类折旧叶子)
|
||||||
|
-- 剔除期间损益结转腿(summary LIKE '%结转%'):博海该账套 carry_forward 恒 0,不剔除则已结账月份净额恒 0
|
||||||
|
-- 单位/阈值与 entity 1 保持一致;仅新增,幂等(NOT EXISTS 守卫),不触碰 entity 1 定义
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||||
|
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||||
|
kpi_level, status, bot_source, epic, created_by)
|
||||||
|
SELECT 2, 'F_BEP_REVENUE', '保本销售额', 'finance', 'profitability',
|
||||||
|
'round(月固定成本(万元) / (1 - 变动成本率), 1)',
|
||||||
|
'账面口径=月固定成本((6601+6602+6603)借−贷净额月均, 剔除结转账腿)÷边际贡献率(1-变动成本率); 中性参考值, 越高需更多销售覆盖',
|
||||||
|
'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6601/6602/6603 月均)', '财务部', 'monthly', '万元', 0,
|
||||||
|
'ratio', NULL, NULL, NULL,
|
||||||
|
'strategic', 'active', 'finance-bot', 'Epic2', 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_REVENUE');
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||||
|
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||||
|
kpi_level, status, bot_source, epic, created_by)
|
||||||
|
SELECT 2, 'F_BEP_COVER', '保本覆盖率', 'finance', 'profitability',
|
||||||
|
'round(实际月销/保本销售额*100,1)',
|
||||||
|
'实际月销(6001贷方月均,剔除结转账腿)÷保本销售额; 正向指标: >=100绿 >=85黄 <85红',
|
||||||
|
'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6001月均)', '财务部', 'monthly', '%', 100,
|
||||||
|
'ratio', '>=100', '>=85', '<85',
|
||||||
|
'strategic', 'active', 'finance-bot', 'Epic2', 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_COVER');
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||||
|
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||||
|
kpi_level, status, bot_source, epic, created_by)
|
||||||
|
SELECT 2, 'F_BEP_GAP', '保本缺口', 'finance', 'profitability',
|
||||||
|
'round((保本销售额-实际月销)/保本销售额*100,1)',
|
||||||
|
'反向指标(缺口越小越好): <=0绿(已保本) <=15黄 >15红',
|
||||||
|
'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6001月均)', '财务部', 'monthly', '%', 0,
|
||||||
|
'ratio', '<=0', '<=15', '>15',
|
||||||
|
'strategic', 'active', 'finance-bot', 'Epic2', 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_GAP');
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||||
|
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||||
|
kpi_level, status, bot_source, epic, created_by)
|
||||||
|
SELECT 2, 'F_BEP_REVENUE_CASH', '现金口径保本销售额', 'finance', 'profitability',
|
||||||
|
'round((月固定成本月均-月折旧月均)/(1-变动成本率),1)',
|
||||||
|
'现金口径=月固定成本(6601+6602+6603 净额月均, 剔除折旧1602当期贷方发生额)÷边际贡献率; 中性参考值, 无阈值',
|
||||||
|
'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6601/6602/6603/1602月均)', '财务部', 'monthly', '万元', 0,
|
||||||
|
'ratio', NULL, NULL, NULL,
|
||||||
|
'strategic', 'active', 'finance-bot', 'Epic2', 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_REVENUE_CASH');
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||||
|
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||||
|
kpi_level, status, bot_source, epic, created_by)
|
||||||
|
SELECT 2, 'F_BEP_COVER_CASH', '现金口径保本覆盖率', 'finance', 'profitability',
|
||||||
|
'round(实际月销/现金口径保本额*100,1)',
|
||||||
|
'实际月销(6001贷方月均)÷现金口径保本额; 正向指标: >=100绿 >=85黄 <85红',
|
||||||
|
'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6001/6601/6602/6603/1602月均)', '财务部', 'monthly', '%', 100,
|
||||||
|
'ratio', '>=100', '>=85', '<85',
|
||||||
|
'strategic', 'active', 'finance-bot', 'Epic2', 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_COVER_CASH');
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||||
|
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||||
|
kpi_level, status, bot_source, epic, created_by)
|
||||||
|
SELECT 2, 'F_BEP_REBATE', '变动成本率(主营业务成本率)', 'finance', 'profitability',
|
||||||
|
'round(sum(6401借方)/sum(6001贷方)*100,2)',
|
||||||
|
'变动成本率(实测,YTD累计)=Σ6401借方÷Σ6001贷方(剔结转账腿); 博海无渠补科目571103, 故不套酣客渠补公式; 供保本点卡面单一事实源, 无阈值',
|
||||||
|
'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 6401/6001 累计)', '财务部', 'monthly', '%', 0,
|
||||||
|
'ratio', NULL, NULL, NULL,
|
||||||
|
'strategic', 'active', 'finance-bot', 'Epic2', 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM kpi_definitions WHERE entity_id=2 AND kpi_code='F_BEP_REBATE');
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- CMA BEP 实测渠补率派生KPI定义 — F_BEP_REBATE(2026-10-02)
|
||||||
|
-- 口径: 渠补率(实测) = Σ(571103 借方) ÷ Σ(5001 贷方) × 100,期间 = 年初至当期(YTD),2位小数
|
||||||
|
-- 依据: 财务Bot 2026-10-02 裁定(任总 11:43 CST 放行「改,这样数据更加真实」)
|
||||||
|
-- 政策常量 84.61% 自本日起仅作对照展示,不再参与计算
|
||||||
|
-- 幂等: NOT EXISTS 守卫,可安全重跑
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_value,
|
||||||
|
target_calc_type, threshold_green, threshold_yellow, threshold_red,
|
||||||
|
kpi_level, status, bot_source, epic, created_by)
|
||||||
|
SELECT 1, 'F_BEP_REBATE', '实测渠补率', 'finance', 'profitability',
|
||||||
|
'round(sum(571103借方)/sum(5001贷方)*100,2)',
|
||||||
|
'渠补率(实测,YTD累计)=Σ571103借方÷Σ5001贷方; 供保本点卡面单一事实源; 政策常量84.61%仅对照; 缺数期不出值(fail-closed)',
|
||||||
|
'derived', 'BEP派生器 kpi_bep_deriver.py(voucher_details 571103/5001 累计)', '财务部', 'monthly', '%', 0.00,
|
||||||
|
'ratio', NULL, NULL, NULL,
|
||||||
|
'strategic', 'active', 'finance-bot', 'Epic2', 1
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM kpi_definitions WHERE entity_id = 1 AND kpi_code = 'F_BEP_REBATE'
|
||||||
|
);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
-- CMA 客户获利能力(CPA / Customer Profitability)KPI 定义 — 2026-10-04
|
||||||
|
-- 任务: finance-20261003-cpa-customer-profitability-mvp-fullstack(P0-a;派单方 财务Bot,放行 任总 2026-10-03 18:17 CST DM「立」)
|
||||||
|
-- 主体范围: 陕西酣客 entity_id=1(zxt_masterbill 唯一有数主体)
|
||||||
|
-- 口径裁定与失败原因: 见 backend/app/utils/cpa_caliber.py 与
|
||||||
|
-- backend/scripts/kpi_customer_profitability_deriver.py 头部文档
|
||||||
|
--
|
||||||
|
-- 动作:
|
||||||
|
-- ① 新增 C_CUST_REVENUE 客户收入(万元) status=active ← 派生器当即可供值
|
||||||
|
-- ② 新增 C_CUST_COUNT 客户数(个) status=active ← 派生器当即可供值
|
||||||
|
-- ③ 新增 5 条 P1 定义(fail-closed,**不供值**)status=inactive ← 口径已登记,待数据/裁定
|
||||||
|
-- C_CUST_COGS_ALLOC / C_CUST_CHAN_SUB / C_CUST_CONTRIB / C_CUST_CONTRIB_RATE / C_CUST_AVG_CONTRIB
|
||||||
|
-- (inactive 而非 active:避免「有定义无值」在看板上伪装成「无数据」,P0-b 三态按「未定义·待建」显示)
|
||||||
|
-- * C_CUST_CONCENTRATION 复用既有 kpi_id=23(formula 前5大客户收入/总收入×100%),本 SQL 不新增/不改名;
|
||||||
|
-- 其 2026-01~09 九期现网值(source_batch finance-20261002-kpi6, verified)**原样保留**,派生器只补缺口期次。
|
||||||
|
--
|
||||||
|
-- 幂等: NOT EXISTS 守卫,可安全重跑(重跑=零副作用)
|
||||||
|
-- 注意: (entity_id, kpi_code) 唯一键 uk_entity_kpi
|
||||||
|
|
||||||
|
-- ① 客户收入(万元)
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||||
|
kpi_level, status, epic, bot_source, data_source_config)
|
||||||
|
SELECT 1, 'C_CUST_REVENUE', '客户收入', 'customer', 'customer_profitability',
|
||||||
|
'Σ 客户销售额(万元)',
|
||||||
|
'期内客户收入合计 = Σ zxt_masterbill.sum_money(entity_id=1, unit_id>0, sum_money>0, bill_date 期内)÷10000;'
|
||||||
|
'口径单一事实源 app/utils/cpa_caliber.py(与客户集中度预警同源)',
|
||||||
|
'erp', '智享通销售单据 zxt_masterbill(unit_id=往来单位/客户)', '财务部', 'monthly', '万元', 'amount',
|
||||||
|
'strategic', 'active', 'Epic2', 'fullstack-bot',
|
||||||
|
'{"source_ids": [], "deriver": "scripts/kpi_customer_profitability_deriver.py", "basis": "zxt_masterbill.sum_money(含税原值)"}'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||||
|
WHERE entity_id = 1 AND kpi_code = 'C_CUST_REVENUE') AS t);
|
||||||
|
|
||||||
|
-- ② 客户数(个)
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||||
|
kpi_level, status, epic, bot_source, data_source_config)
|
||||||
|
SELECT 1, 'C_CUST_COUNT', '客户数', 'customer', 'customer_profitability',
|
||||||
|
'期内活跃客户数(个)',
|
||||||
|
'期内 zxt_masterbill 活跃往来单位数(unit_id 去重;含极小金额客户,与客户收入聚合同过滤)',
|
||||||
|
'erp', '智享通销售单据 zxt_masterbill', '财务部', 'monthly', '个', 'number',
|
||||||
|
'strategic', 'active', 'Epic2', 'fullstack-bot',
|
||||||
|
'{"source_ids": [], "deriver": "scripts/kpi_customer_profitability_deriver.py"}'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||||
|
WHERE entity_id = 1 AND kpi_code = 'C_CUST_COUNT') AS t);
|
||||||
|
|
||||||
|
-- ③ P1 fail-closed 定义(口径已登记,供值待裁定/待打标;status=inactive)
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||||
|
kpi_level, status, epic, bot_source, data_source_config)
|
||||||
|
SELECT 1, 'C_CUST_COGS_ALLOC', '客户销货成本(分摊)', 'customer', 'customer_profitability',
|
||||||
|
'客户收入 × 公司整体销货成本率(5401÷5001)',
|
||||||
|
'【P1 fail-closed 未供值】基数口径未决:客户收入基座 zxt_masterbill 含税单据流(2026-01~09=4,883.52万)'
|
||||||
|
'与凭证账 5401/5001(收入量级~900万)相差约5.4×,直接相乘无经济意义;且须与「统一后的 CVP 变动成本口径」'
|
||||||
|
'(finance-20261003-cvp-fix-and-bohai-coverage 第⑤项)一致 ⇒ 待财务裁定基数后落值。禁写 0/分摊值。',
|
||||||
|
'derived', '待建(scripts/kpi_customer_profitability_deriver.py 已留位)', '财务部', 'monthly', '万元', 'amount',
|
||||||
|
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||||
|
'{"fail_closed": true, "reason_code": "cogs_base_undecided", "pending_on": "finance-20261003-cvp-fix-and-bohai-coverage#5"}'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||||
|
WHERE entity_id = 1 AND kpi_code = 'C_CUST_COGS_ALLOC') AS t);
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||||
|
kpi_level, status, epic, bot_source, data_source_config)
|
||||||
|
SELECT 1, 'C_CUST_CHAN_SUB', '客户渠道补贴', 'customer', 'customer_profitability',
|
||||||
|
'Σ 客户专属渠道补贴',
|
||||||
|
'【P1 fail-closed 未供值】571103 渠道补贴目前仅有总额、不可按客户/渠道拆分;须先落「渠道补贴台账」'
|
||||||
|
'(人员/渠道/客户 × 期间 × 金额)再取值。禁按收入占比硬摊。',
|
||||||
|
'derived', '待建(渠道补贴打标台账)', '财务部', 'monthly', '万元', 'amount',
|
||||||
|
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||||
|
'{"fail_closed": true, "reason_code": "channel_subsidy_untagged"}'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||||
|
WHERE entity_id = 1 AND kpi_code = 'C_CUST_CHAN_SUB') AS t);
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||||
|
kpi_level, status, epic, bot_source, data_source_config)
|
||||||
|
SELECT 1, 'C_CUST_CONTRIB', '客户贡献毛利', 'customer', 'customer_profitability',
|
||||||
|
'客户收入 − 客户销货成本(分摊) − 客户专属渠道补贴',
|
||||||
|
'【P1 fail-closed 未供值】依赖 C_CUST_COGS_ALLOC 与 C_CUST_CHAN_SUB,二者皆未落值 ⇒ 不计算、不写 0。',
|
||||||
|
'derived', '待建', '财务部', 'monthly', '万元', 'amount',
|
||||||
|
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||||
|
'{"fail_closed": true, "reason_code": "depends_on_cogs_alloc_and_chan_sub"}'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||||
|
WHERE entity_id = 1 AND kpi_code = 'C_CUST_CONTRIB') AS t);
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||||
|
kpi_level, status, epic, bot_source, data_source_config)
|
||||||
|
SELECT 1, 'C_CUST_CONTRIB_RATE', '客户贡献毛利率', 'customer', 'customer_profitability',
|
||||||
|
'客户贡献毛利 ÷ 客户收入 × 100%',
|
||||||
|
'【P1 fail-closed 未供值】依赖 C_CUST_CONTRIB ⇒ 不计算。',
|
||||||
|
'derived', '待建', '财务部', 'monthly', '%', 'ratio',
|
||||||
|
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||||
|
'{"fail_closed": true, "reason_code": "depends_on_contrib"}'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||||
|
WHERE entity_id = 1 AND kpi_code = 'C_CUST_CONTRIB_RATE') AS t);
|
||||||
|
|
||||||
|
INSERT INTO kpi_definitions
|
||||||
|
(entity_id, kpi_code, kpi_name, dimension, category, formula, formula_desc,
|
||||||
|
data_source_type, data_source, data_owner, frequency, unit, target_calc_type,
|
||||||
|
kpi_level, status, epic, bot_source, data_source_config)
|
||||||
|
SELECT 1, 'C_CUST_AVG_CONTRIB', '客户平均贡献值', 'customer', 'customer_profitability',
|
||||||
|
'期内客户贡献毛利合计 ÷ 期内活跃客户数',
|
||||||
|
'【P1 fail-closed 未供值】依赖 C_CUST_CONTRIB ⇒ 不计算。页面「客户平均贡献值」卡即此码;'
|
||||||
|
'P0 阶段按「未定义·待建」显示(定义已登记但 inactive)。',
|
||||||
|
'derived', '待建', '财务部', 'monthly', '万元', 'amount',
|
||||||
|
'strategic', 'inactive', 'Epic2', 'fullstack-bot',
|
||||||
|
'{"fail_closed": true, "reason_code": "depends_on_contrib"}'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM kpi_definitions
|
||||||
|
WHERE entity_id = 1 AND kpi_code = 'C_CUST_AVG_CONTRIB') AS t);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user