Author SHA1 Message Date
Hermes CI Fix b736e0727a fix(strategy-map): 战略地图三处 P1 缺陷(D1 完备度路由 422 / D2 连线删除不落库 / D3 窄屏拖拽失效)
D1 GET /api/cma/maps/completeness/objectives 恒 422:真抢占者是先注册的
   objectives.router GET /{map_id}/objectives(map_id 被当 int 解析)。把
   map_completeness / map_summary 两个字面路径路由器前移到 objectives 之前,
   不动 prefix、不删参数化路由。新增同前缀族全量巡检用例。

D2 DELETE /{id}/connections 返回 200 不落库:canvas_data 为裸 Column(JSON),
   就地/换列表赋值不被 ORM 判脏。模型改 MutableDict.as_mutable(JSON)(不改 DDL),
   add_connection / delete_connection_by_key 均显式 flag_modified 双保险。

D3 窄视口 .kp-panel(fixed/z-1000) 遮画布,onTreeDragOver 用 elementFromPoint
   取节点恒 null ⇒ 拖拽建连线失效。改为遍历画布内 [data-node-key] 做 rect 包含
   判定、多点取中心最近者,不再回退 elementFromPoint。

测试:新增 backend/tests/test_map_route_shadowing_20261020.py(路由解析断言到
endpoint.__module__)与 test_map_connection_persistence_20261020.py(新 session
原生 SQL 复读 + 负测)。零生产写、零上线。
2026-10-21 02:04:28 +08:00
wecom-fullstack 86e24c6310 fix(strategy-map): 移除悬空 import kpi_target_reader(该模块全仓不存在,致 app.main 导入失败/部署自检中止)→ 目标值取 target_monthly or target_value
ci/woodpecker/push/main Pipeline was successful
2026-10-20 13:01:02 +08:00
wecom-fullstack ecbe55c48a merge: origin/main 合入战略地图改造分支(解 MapCanvas 模板冲突:摘要条+编辑条+口径条并存)
ci/woodpecker/push/main Pipeline was successful
2026-10-20 11:34:29 +08:00
wecom-fullstack c030fb7d18 fix(strategy-map): 口径违禁串代码级清零(9,492.6/83.9%/19/19/el-dialog 仅存于注释)+ components.d.ts 补 MapDimensionFilter 2026-10-20 11:33:58 +08:00
wecom-fullstack a8c9b38fff feat(caliber-label): 科目口径·缺票 常驻标识 + 测算/待票 数值标识(展示层 P2)
ci/woodpecker/push/main Pipeline was canceled
- 新增 constants/caliber.ts(文案单一真源)+ components/CaliberBadge.vue(常驻,非 hover)
- 经营看板/Dashboard:页头常驻「科目口径 · 缺票」(tooltip 含 四件套 0/4、invoice_check 0 行)+ 毛利率与 A 口径 KPI 卡「测算/待票」
- 战略地图看板/StrategyMap:meta-bar 常驻标识位 + 节点级测算标识(与 v4 §2/§4 共用同一组件)
- 战略地图画布/MapCanvas、KPI 列表 KPIListView 同步复用同一组件
- 只做展示层:不改库、不重算历史值、不静默按实账回填
2026-10-20 02:11:00 +08:00
Hermes CI Fix 53cad7eaa9 merge: origin/main (A3 三维切片) 合入战略地图改造分支 2026-10-19 14:32:33 +08:00
Hermes CI Fix a8141e8879 wip(strategy-map): v4 口径落码(分线营收/新式毛利率/管理指标/C1 hold/57 归档) 2026-10-19 14:32:08 +08:00
wecom-fullstackandHermes CI Fix d85dd2a155 feat(strategy-map): A3 三维切片(时间/门店/产品)——切片重算 + 显式空态 + entity 隔离
ci/woodpecker/push/main Pipeline was successful
- 新增 /api/cma/maps/{id}/dimension-options 与 /{id}/slice(只读)
- 时间:kpi_values.period 月/季/年;flow 求和、ratio 均值、balance/status 取最新
- 产品:product_sales + 逐月源一致性闸门(偏差>1% 不切片)
- 门店:库内无门店级事实 ⇒ available=false + reason,绝不回退总额
- 前端 MapDimensionFilter 挂到 MapCanvas,切换即 emit 切片,清除恢复 /review
2026-10-19 13:32:49 +08:00
Hermes CI Fix 4d20c27de4 wip(strategy-map): V1+V3 合版 + v2/v3 口径(完备度/C1 hold/57 归档组) 2026-10-19 10:33:40 +08:00
project-bot c92dbab96a feat(kb): KnowledgePanel 接线 6 页 + /maps/canvas 错配消除
ci/woodpecker/push/main Pipeline was successful
- /budget /kpis /deviations /predict /maps-review /alerts 六页按 related_page 挂载 KnowledgePanel
- /maps/canvas 页 prop 由 /maps 更正为 /maps/canvas(与表值一致,消除恒取空)
- 匹配口径走 backend/app/utils/route_key.py(origin/main 已有,KBFF-v11c 精确等值)
- 验证:本地产物 CDP 冒烟 18/18(六页 dom=api=1;/maps 命中 0 负对照)
2026-10-19 10:23:12 +08:00
wecom-fullstack 0fc2c6109e style(page-title): 补漏——战略地图看板页标题弱化 + KPIListView 标题 emoji 清零
ci/woodpecker/push/main Pipeline was successful
- StrategyMap.vue: 页内 h2 19px/600/深色(与路由标题「战略地图看板」基本同名)降为
  div.page-subtitle 14px/500/#64748B(保留地图名+版本+状态等增量信息,不再充当 H1)
- KPIListView.vue: 组件 h3 图标默认值 '📊' 置空(v-if 渲染),分类标题与明细卡头
  '📋' 改为 Lucide 内联 SVG(clipboard-list,18/16px,#64748B)
- 仅模板与样式:不动 MainLayout、不改接口/数据/路由 key、不碰 MapCanvas.vue
2026-10-19 04:34:35 +08:00
wecom-fullstack bf641d8d31 refactor(menu): 侧边栏按决策域重构(L1 九域 + L2 决策场景)+ PDCA 回退开关
ci/woodpecker/push/main Pipeline was successful
- 新增 frontend/src/config/decisionDomains.ts:9 个决策域(100 战略…900 系统)
  与 22 个 L2 决策场景,只写 path 关系;文案/图标/roles 仍取 permission.ts MENU_ITEMS
- MainLayout.vue:决策域菜单为默认(el-sub-menu 域 → el-menu-item-group 场景 → 页面),
  「我的工作台」常驻置顶,系统域默认折叠;未登记菜单项兜底进 900「未归类」(不丢项)
- 回退开关:localStorage.cma_menu_mode='pdca' 或 URL ?menu=pdca,右上角下拉可切换
- 路由 path、页面、API、DB、zone A/B 权限判定一律未改
- components.d.ts 由 unplugin-vue-components 自动补 ElMenuItemGroup 声明
2026-10-18 21:45:32 +08:00
Hermes CI Fix 7f36608f60 chore(frontend): 重新生成 components.d.ts(budget 面板组件 + ForecastTag 自动注册)
ci/woodpecker/push/main Pipeline failed
2026-10-18 20:37:51 +08:00
Hermes CI Fix 5bda882aa9 Merge remote-tracking branch 'origin/main' into integration/merge-wt-20261018 2026-10-18 20:32:32 +08:00
wecom-fullstack f51fc72ac7 style(page-title): B 全站规范——页面内同名标题清零 + 增量副标题弱化 + 标题 emoji 换 Lucide
ci/woodpecker/push/main Pipeline failed
- 39+ 页删除与布局标题同名/基本同名的页面内 h1-h4(同屏三标题→两处)
- 增量副标题弱化为 14px #64748B 小字:收付款计划/战略回顾/地图版本/会计科目打标
- 标题 emoji 清零,保留标题处改 Lucide 内联 SVG(bar-chart-3/video/scale/clipboard-list/alert-circle/map/history)
- KPIListView 增加 hideTitle(仅展示),客户/学习看板隐藏组件内重复页头
- 不动 MainLayout、不改接口/业务逻辑、不碰 MapCanvas.vue
2026-10-18 19:33:11 +08:00
Hermes CI Fix a67f4b688f feat(receivables): customer-level rows only + register hanke_ar as balance snapshot batch
ASK-20261001-33: api_receivables counts only rows with a non-blank counterparty (monthly aggregate rows stay in table but out of the collection page cards/aging/detail/owners); BALANCE_SNAPSHOT_SOURCES gains hanke_ar (default skip, no per-customer overdue alerts). New pytest file covers aggregate-row exclusion, e2 baseline digitwise, snapshot registration.
(cherry picked from commit 475f78bc8c5f67f2716c721172a88ff89ec58227)
2026-10-18 16:42:06 +08:00
Hermes CI Fix fa1267c323 feat(budget): effective-version caliber + process-order tabs + 4 missing rings
Task: CMA-20261003 (budget module governance). Local commit only; push is
gated on release approval (see report).

Backend
- app/utils/budget_status.py (new): single source for the effective-version
  caliber. EFFECTIVE_STATUSES = approved + active; effective_plan_clause()
  enforces entity isolation; pick_effective() priority active > approved >
  updated_at desc. Fixes: approved budgets disappeared from the execution
  surface because the three queries only read status='active'.
- budget.py: /comparison, /comparison/kpi/{id}, /deviation-check now read the
  effective surface; versions list gains is_effective / can_release fields;
  submit/approve are fail-closed (draft|active -> submitted -> approved).
- deviation_engine.get_budget_for_kpi: effective surface + deterministic pick.
- 4 missing rings, zero DDL, appended to the same router:
  POST /versions/release (approved -> active, idempotent by operation_logs),
  POST /adjustments (derive a new draft version, base rows untouched),
  GET /control/status (effective budget vs actual -> ok/warn/over),
  GET /assessment (achieve rate + A/B/C/D grade).
  Audit rows written through app.utils.oplog.log_op with entity_id.

Frontend
- BudgetManagement.vue: top-level tabs reordered to the process order
  (strategy, method, driver, input, decompose, versions, release, execution,
  deviation, adjust, assess, rolling). Pane name values unchanged, so old
  bookmarks keep working; routes unchanged.
- Cash classify rules / unclassified queue moved to /cash-plan
  (components/CashClassifyPanel.vue); budget actual-value collection moved to
  /data-manage (components/BudgetCollectPanel.vue). Mount points only: no
  change to cash_plans data, classify logic, collection logic or kpi_values.
- api/index.ts: 4 ring endpoints exposed.

Docs / tests
- docs/budget-module-governance-design.md: phase-0 design doc (status machine,
  tab order, domain boundaries, 4-ring design, cleanup write pack scope).
- tests/test_budget_status_machine.py (new): effective surface, version
  guards, release idempotency, adjustment, control and assessment.
- tests/test_budget.py: existing cases adapted to the fail-closed contract.

(cherry picked from commit 4267fc4d25ee45c5675a541af9777be96fe9f9c2)
2026-10-18 16:32:55 +08:00
Hermes CI Fix 75a48f55d2 feat(strategy-map): live board reading map_objectives + kpi_targets (read-only)
Task: finance-20261011-strategy-map-frontend-fullstack (P2, read-only integration)

- backend/app/utils/strategy_board.py: single source of truth for the level
  caliber (layer colors, inverse-kpi explicit whitelist, status class by
  unit, zero guard, period ranking, latest-actual picking). Pure functions.
- backend/app/api/maps.py: GET /api/cma/maps/{map_id}/strategy-board
  - nodes straight from map_objectives (sorted by sort_order, id)
  - node<->kpi binding with 4-step fallback, binding_source echoed back
  - targets from kpi_targets (target_type compatible: yearly + annual),
    deduped per (kpi_id, period) with yearly preferred
  - actuals from kpi_values actual-only scope, latest period
  - causality edges from kpi_causality, out-of-map endpoints dropped+counted
  - entity_id taken from the token only (no cross-tenant reads)
  - zero writes; nothing is persisted by this endpoint
- frontend/src/views/StrategyMap.vue: /strategy-map live board (4 swim lanes
  bottom-up, node cards, 20 causality arrows in SVG, period switch, A3 print).
  No emoji, null guards everywhere (no toFixed on null).
- api/index.ts + router/index.ts + permission.ts: route + menu registration
  (/strategy-map, zone A read-only, roles ceo/finance/business/it).
- backend/tests/test_strategy_board_20261011.py: 33 cases (spec section 4 /
  4.1 / 4.2 / 4.3 + section 7 fixtures + threshold boundaries + period rank).
- backend/tests/test_map_objectives_kpis_form_20261011.py: 14 cases for the
  four kpis column shapes (list, json string, object list, NULL/[]/weird).

Not included: push + backend restart (production write, separate gate).

(cherry picked from commit c675bde6b4beaf1d8a9d02a80f5434203a53f348)
2026-10-18 16:31:52 +08:00
Hermes CI Fix 2cff58ca6a feat(docs-center): report reading section (type=report) on /api/cma/docs
Increment #2 over PoC ede0524 (task yanxue-20260924-cma-report-source).

- backend derives reports from existing drop points (wisdom/_reports + every profile pending-tasks/_reports), read live per request, no cache, no rebuild

- gate = front-matter plus non-empty conclusion; missing either means silently out of the list (receipts/audits never enter); title/date/domain/sources missing are marked incomplete instead of rejected

- entity + role partitioning (ceo/it cross-entity; others own entity plus group/generic); kpi_refs resolve to a one-hop metric link

- new GET /api/cma/docs/report/{rid} detail endpoint; knowledge branch unchanged (type defaults to knowledge)

- frontend: CMAKnowledge adds a report reading pane (filters + detail dialog), reuses the existing markdown renderer, no new deps

(cherry picked from commit 7c6e0857e68133f5ba2a539fd3569aeb82547911)
2026-10-18 16:31:52 +08:00
Hermes CI Fix 57f9dbae15 fix(action-plans): 「已逾期」改日期粒度(口径 B,当天到期不算逾期)
- _apply_overdue():`ActionPlan.due_date < datetime.now()` → `... < 今天0点`
  (唯一实现,plan_stats 与 list_plans 共用,故一处改即两端点同改)
- 口径同步注释(口径 A→B 的沿革与分界式);行为改动仅此一处
- 测试夹具改到日粒度(整天偏移),新增「今日到期不算逾期」鉴别行与
  `test_overdue_caliber_is_date_granularity` 口径锁定用例(防回退 A)
- 依据:任总 2026-09-22 10:10 拍板口径 B;前端角标 ActionPlanLibrary.vue::isOverdue
  与 cash.py::_plan_dict 本就同粒度,本单未动
- 只改码:未 push / 未部署 / 未重启(发布须放行)

(cherry picked from commit 2da5b6da018013f860430913b5b0e0436f966685)
2026-10-18 16:31:52 +08:00
wecom-project 5fa83e2532 fix(knowledge): related_page 过滤精确化(子串包含 -> 归一候选键等值)
KBFF-v11c(派单 KBFF-v11c-relatedpage-exact-match-filter · A 侧 wecom-yanxue · 执行人 wecom-project)
本提交=代码面切片:未 push、未部署;生产部署另走发布单 + approval-gate(回滚 = git revert <本 sha>)。

- backend/app/api/knowledge_articles.py:related_page 过滤由 LIKE '%<v>%'(子串包含,短键/前缀超集命中)
  收敛为「归一候选键 IN ∪ 模板子路由 <键>/:<占位符>」;不可归一入参 -> 空集(不退化为全量)
- backend/app/utils/route_key.py(新增):匹配口径唯一实现处(归一化 / 候选键派生 / 模板模式)
- backend/tests/test_kbff_v11c_related_page_exact_match_20261012.py(新增):42 用例
  —— 生产 seed 7 值逐值回归(各中 1 行)、短键 /maps->0 行 /kpi->0 行、带参路由与尾斜杠/query/大小写兼容、
  改造前 contains 语义负对照、账套分区与 category 组合语义保持

(cherry picked from commit 2a5cb182c237ae724d4b506b68eb48810ba27b16)
2026-10-18 16:31:52 +08:00
fullstackandHermes CI Fix 60b6ef3bfe fix(alert-rules): dynamic-thresholds 迭代 Column 当行取值致 500
GET /api/cma/alert-rules/dynamic-thresholds 在 {c.name: getattr(c, c.name) for c in
DynamicThresholdCache.__table__.columns} 里把 Column 对象当 ORM 行取属性,触发
AttributeError: 'Comparator' object has no attribute 'id'。改为列名取自表元数据、
值取自 query 结果行;entity_id 过滤与 limit 50/kpi_id 分支不变。

(cherry picked from commit 4130726be411a74a3318d9dc6bbc518a6c60d0ff)
2026-10-18 16:31:52 +08:00
wecom-fullstackandHermes CI Fix a9dcbf22bc chore(security): 摘除 knowledge_service 硬编码默认 DeepSeek Key(缺 env 抛明确错误/503)
- knowledge_service: 移除 os.getenv 默认值占位,改 _require_deepseek_key() 必填,缺 env 抛 DeepSeekKeyMissing
- knowledge API: generate/{daily,weekly,monthly} 缺 Key 返回 503(可观测,不再静默走模板兜底)
- finance_bot / verify_zxt_vouchers_json / bot_bridge_push: 凭据改 env 优先(CMA_BOT_KEY_* / CMA_BOT_API_KEYS / CMA_BRIDGE_TOKEN),历史共享键仅作兼容期兜底并告警
- tests: 新增 5 例回归(缺 Key 服务层抛错、API 503;有 Key 正常 200)

(cherry picked from commit 859bd8276d7c5aad4bf10ee3399b6cccc188f064)
2026-10-18 16:31:52 +08:00
wecom-fullstack 885cfddbff feat(warning-p0a): 资金缺口阈值外置 + 复算对拍 + 讲人话(数字守恒) + 数据就绪核验
ci/woodpecker/push/main Pipeline failed
P0-A(任务 fullstack-20261017-warning-capability-build-p0):
- cash_thresholds.py:阈值迁 per-entity(复用 alert_rules.rule_type=cash_gap_threshold,
  默认 20/10 万不动;无配置/非法/异常一律回落默认,fail-safe 不改预警行为)
- cash_forecast_engine.py:阈值改从配置读取,响应新增 threshold_source 明写来源
- verify_cash_gap_replay.py:同输入双跑逐字段 diff=0 + 手算 3/3 + 端点逐字段对拍(--live)
- cash_gap_narrative.py:复用 ai_brief.call_deepseek,只写原因/建议;
  断言生成文本数字集合 ⊆ 规则层数字集合,违例即不带文案上线
- audit_warning_data_readiness.py:G0 只读核验(cma_reader SELECT only),
  行数/期间/枚举 + 数据质量缺口(期初全 0、负期末、单批次、无员工维度表)

未 push(push 即部署,须项目侧书面放行);未改生产库/未动 cron。
2026-10-17 23:33:51 +08:00
Hermes FullstackandHermes CI Fix a52faa90a6 fix(dashboard): bep-card terms 分支补字段别名映射(修 289cc8f 引入的 e2 500)
ci/woodpecker/push/main Pipeline was successful
289cc8f(§4-1 博海固定成本仅借方)在 dashboard._bep_sum_field 新增 terms 分支,
把 bep_caliber 的 terms 记法字段名(debit/credit)原样传给 getattr(VoucherDetail, field)
⇒ AttributeError: type object 'VoucherDetail' has no attribute 'debit'
⇒ GET /api/cma/dashboard/bep-card(entity=2 博海)稳定 500(entity=1 走 filters 分支不受影响)。

修复:terms 分支内做字段名映射 debit→debit_amount / credit→credit_amount(与
profit_lines.FIELD_MAP、kpi_bep_deriver._TERM_FIELD 同式),再递归聚合。

回归测试:tests/test_profit_summary_real_caliber_20261014.py 新增
test_dashboard_card_e2_terms_mode_field_alias(修复前 RED:复现同一 AttributeError;
修复后 GREEN:e2 固定成本 98,000 元 / 卡面 9.80 万)。
2026-10-16 18:28:50 +08:00
wecom-fullstack 68ca27109e feat(report): 管理利润表「变动成本」行加负值标注(脚注+tooltip,纯展示)
ci/woodpecker/push/main Pipeline was successful
依据 e1 变动成本负值口径核查(口径无误,设计使然):变动成本含贷方冲减项(厂补),
个别期间(2026-08 / 2025-07)可为负。本单仅在报表层加说明,防读者误读为取数/口径错误。

- 项目列「变动成本」行加 InfoFilled 悬停 tooltip(按 line_key === 'variable_cost' 精确命中)
- 表格下方加脚注一行
- 未改取数逻辑、未改口径、未改任何数值(diff:1 file, 11 insertions(+), 0 deletions(-))
2026-10-16 18:13:50 +08:00
Hermes CI Fix 289cc8f2cc fix(profit-caliber): 博海口径 §4-1/§4-2/§4-5 落地 —— 固定成本仅借方 + 6403 进变动 + 财务收益单列 + 批次备注
ci/woodpecker/push/main Pipeline was successful
ASK-20261014-04(任总 2026-10-14 11:20「放行4」)批后口径:
- §4-1 博海固定成本弃净额:6601/6602/6603 仅借方(133.98 万,2026-01..09);2026-03 单月由 -9.02 万转正;
  6603 贷方(现金折扣收益 64.40 万)改 finance_income_terms 单列备查,不进固定成本/末行/勾稽。
- §4-2 博海变动成本追加 6403 营业税金及附加(530.17 万)。
- §4-3/§4-4 维持现行(酣客收入/边际贡献基数不动)。
- §4-5 管理利润表页加「数据批次/导入时刻」纯展示备注(import_logs)。
- 口径唯一源仍为 bep_caliber.ENTITY_CALIBERS;报表/卡面/派生器一律经 fixed_cost_spec() 解析(读口写口一致)。
2026-10-16 12:32:52 +08:00
wecom-fullstackandHermes CI Fix ff2d92ba54 ci(woodpecker): 部署通道收口为单链 —— CI 只做门禁,部署恒走宿主 Gitea hook
ci/woodpecker/push/main Pipeline was successful
② 现状:push origin main 同时触发两条写生产链 ——
   (a) 宿主 Gitea post-receive hook → /usr/local/sbin/gitea-cma-deploy-hook.sh → deploy.sh(铺站 + 重启 + 冒烟)
   (b) Woodpecker pipeline 的 frontend-deploy(rsync --delete dist → /var/www/cma + nginx reload)
       与 backend-deploy(在开发区 git pull + systemctl restart cma-backend)
   两条链并发 ⇒ 两套在途部署判定、重复劳动与证据歧义(2026-10-13 事故即此形态)。
本提交复用 ci/wp-v3-fix-20260916 已写就的 .woodpecker/main.yml(wecom-project 2026-09-16 8ddcbe1):
   删除 variables(ssh_setup) 与 frontend-deploy/backend-deploy 两步,只留门禁
   (frontend-install/typecheck/build + backend-test),并补国内镜像与 pytest 安装。
零生产写:候选仅落本 worktree 分支,未 push。
2026-10-16 03:05:57 +08:00
wecom-ops d2cb812bfa chore(deploy): 触发部署链复跑(真修复 · 无内容变更)
ci/woodpecker/push/main Pipeline failed
2026-10-16 00:19:31 +08:00
wecom-fullstackandHermes CI Fix 32f368a846 fix(infra): pnpm 工具链去脆弱化 —— frontend/package.json 落 packageManager pin + deploy.sh pnpm 版本断言(fail-closed)
ci/woodpecker/push/main Pipeline failed
① frontend/package.json 补 "packageManager": "pnpm@10.33.2+sha512.a90faf6f…":
   corepack 在项目未声明该字段时按全局 lastKnownGood.json 解析版本,而该文件会被任何跑过
   corepack 的项目改写(2026-10-13 → 12.11.2 ⇒ MODULE_NOT_FOUND;2026-10-14 → 9.15.9 ⇒
   packages field missing)。pin 落库后 corepack 只认 pin,宿主机状态漂移不再影响部署树。
   选 10.33.2 的依据:pnpm-workspace.yaml 的 allowBuilds 仅 pnpm 10 识别;部署树 node_modules
   .modules.yaml packageManager=pnpm@10.33.2(store/v11/v10);lockfileVersion 9.0;2026-10-13
   22:53 该版本下部署成功。
② deploy.sh 新增 assert_pnpm_version()(fail-closed,两处调用:1.2a 首次 pnpm 调用前、[2] 构建前):
   期望版本只取自 package.json 的 packageManager(单一源),与 pnpm -v 实读逐字比对,缺失/不一致
   即中止;两次调用都先于 [3] 铺站与 [5] 重启 ⇒ 任何生产写之前失败关闭。

零生产写:候选仅落本 worktree 分支,未 push、未动 /srv/hermes/shared/cma-deploy 与 dev 树。
2026-10-15 08:35:20 +08:00
wecom-fullstack e3e6b3ba8a fix(reports): 全表真实取数——清零示例兜底(BS_DEMO/CF_DEMO/_get_demo_block_total/0.50) + 上期同比/勾稽/预测标注 + CF官方口径单位收口
ci/woodpecker/push/main Pipeline failed
2026-10-15 04:13:40 +08:00
hermes 2244cd79b7 fix(reports): profit-summary 真实科目取数重建(去 KPI 比率倒推/硬编码 0.50 + 勾稽自检 + entity 隔离)
ci/woodpecker/push/main Pipeline was canceled
2026-10-14 23:14:41 +08:00
wecom-fullstack 6d8ef0d443 fix(infra): pnpm-workspace.yaml 补 packages 字段(pnpm 9.15.9 强制)+ vue-demi 占位符改 true
ci/woodpecker/push/main Pipeline failed
根因:corepack 修到 pnpm 9.15.9 后,pnpm 见 pnpm-workspace.yaml 即进 workspace 模式,
强制要求 packages 字段;缺失 ⇒ 'packages field missing or empty' ⇒ 部署树 pnpm build 失败。
本地 pnpm build 验证通过(16.05s, exit 0)。
2026-10-14 11:35:05 +08:00
wecom-fullstack e55f0ecdf9 fix(ui): 侧栏「资金内控」图标缺失——MainLayout 补 Lock 到 import 与 iconMap
ci/woodpecker/push/main Pipeline failed
任总 2026-10-14 10:49 报「资金内控:导航栏缺少图标」。
根因:permission.ts:40 icon:"Lock",而 layouts/MainLayout.vue 的
@element-plus/icons-vue import 与 iconMap 均无 Lock ⇒ iconMap["Lock"]=undefined
⇒ <component :is="undefined"> 渲染为空。全量核:菜单 40 项 / 19 个不同 icon 值,
仅 Lock 真缺。本提交仅该 1 文件 2 行(import + iconMap 各补 Lock),
不改其它图标、菜单顺序、分组、角色可见性。
2026-10-14 10:54:11 +08:00
wecom-fullstackandHermes CI Fix 26db15e6d3 fix(ui): 系统级设置归位(KPI对齐/预算模式切换入口收归系统设置)+ 业务页右下浮窗提醒
ci/woodpecker/push/main Pipeline failed
- KPIAlignment.vue: 移除页头切换按钮与模式选择器,去 saveAlignConfig;新增右下浮窗
- BudgetManagement.vue: 预算模式 el-switch -> 只读标签,去 onBudgetModeChange;新增右下浮窗
- RolePermissions.vue: 保留目标对齐正本,新增「预算模式」Tab(系统级切换入口)
- 仅 frontend/ 三件;build 通过
2026-10-13 23:04:07 +08:00
wecom-fullstack 424c33c085 feat(kpi,review): KPI direction 列 + KPITarget 模型 + 复盘产出方入库(R1 收口)
ci/woodpecker/push/main Pipeline failed
R1 收口批次(A 侧裁定 p12-ruling-input-r1-batch.RULING.md 主路径)· 单个快进提交:
- backend/app/models/__init__.py(+42/−2):CMA-KPI-20261003 阶段1 `direction`(KPIDefinition/KPITemplate)
  + ② KPITarget(kpi_targets,11 列 + uk_entity_kpi_period_type + 2 索引)
- 复盘产出方三件(review_builder 含 getattr(direction) 硬化 / review_run / 测试件),原始提交件 26dc242 的重铸件

收口边界申报:本提交**不含** D-1 批次(VoucherDetail.row_uid / ImportLog uq_import_logs_entity_batch_type)——
只读补证显示 prod `cma` 无该列/该唯一键(证据 out/r1-t1-batches.json + out/r1-t1-d1-placement.json),
携行会导致 deploy 后 voucher_details 整实体 SELECT/INSERT 报 1054;D-1 另单(先 DDL 后 ORM)。
2026-10-13 20:33:42 +08:00
Hermes CI Fix 4cdd30e321 feat(lead): 网站线索额外推送任总DM(经 ai-cs 实盘分流脚本)
ci/woodpecker/push/main Pipeline failed
- 新增 notify_boss():POST /api/cma/lead 时,除既有全员群 notify_wecom 外,
  额外经 ai-cs 实盘分流脚本(routing-live.py)通知 wecom:200653946(加性,不改动既有逻辑)
- 跨 profile 调用必须显式传 HOME/HERMES_HOME=/var/lib/wecom-ai-cs/profile:
  cma-backend.service Environment 为空,缺省会解析到非 ai-cs profile(实测无 wecom 目标)
- 失败静默(capture_output + except pass),不影响主流程
任务: ai-cs-20261013-website-lead-to-boss-and-routing-hook-ops
2026-10-13 17:48:00 +08:00
Hermes CI Fix 501322a8b9 feat(maps): 战略地图按 objective 粒度合并(granular 通道),不同 objective 并发不再互相 409
ci/woodpecker/push/main Pipeline failed
2026-10-13 11:37:24 +08:00
wecom-fullstackandwecom-yanxue f786ff9ae3 test(docs-center): 金丝雀分类断言改「必备分类在场+相对顺序」(分类扩展不再留假红)
ci/woodpecker/push/main Pipeline failed
- test_prod_content_readable 原硬编码 [term,formula,practice,faq],与生产 categories.json 全等比较;
  2026-10-12 14:05 内容目录新增 manual(模块手册,order 5) 后期望值失鲜 ⇒ 基线/改后两棵树同红。
- 改为子集式断言(必备分类在场 + 过滤后相对顺序),不再硬编码全集;分类扩展不再红。
- 同族断言盘点:本文件其余分类断言走 tmp content_dir 自持 categories.json,不依赖生产内容目录,无需改。
2026-10-13 07:48:18 +08:00
Hermes CI Fix cfd6a13ca4 fix(budget): 年度行(budget_month=0)不回落 1 月 + 同族 R1/R2/R3
ci/woodpecker/push/main Pipeline failed
- R-A2 sync-cash-plans: 年度行形态判定(_is_annual_budget_row)跳过 + skipped_annual 披露
- R2 POST /plans 期间形态校验(YYYY-MM / YYYY-00 显式接受)
- R3 budget_roll_forward 排除年度行
- R1 cash_classify 归类路径年度行判据 + 披露
- 新增 2 个回归件(14+10 例)

仅含本族 hunk(禁整文件提交): 开发区其余 14 hunk 属他会话在途件,未纳入。
2026-10-13 03:41:39 +08:00
Hermes CI Fix d08a8490eb fix(maps): 覆写通道扩面清点——同契约调用点补基准(3 例静默降级 + 2 例新增失败)
ci/woodpecker/push/main Pipeline failed
缺陷 cma-strategy-map-autosave-stale-overwrite 的护栏(PUT /api/cma/maps/{id} 覆写
dimensions/canvas_data 须带 base_updated_at,否则 409 fail-closed)改变了同一写通道的
契约 ⇒ 全仓清点调用点,在调用点取基准(写前读 updated_at),**不放宽护栏**:

- tests/test_map_versions.py: 新增 _base_updated_at(db, map_id) 助手;3 处覆写 dimensions 的
  PUT 补基准(test_rollback_backs_up_current_and_restores_content 会直接失败;
  test_rollback_again_uses_next_free_backup_version 两处此前已静默降级——PUT 被拒但用例仍绿,
  即「改了内容再回滚」的前提实际未发生)。
- tests/test_kr_name_pollution_20260916.py: 2 处 KR 透传护栏用例补基准。

改前/改后全量对拍(同树 pythonpath):
  baseline 22 failed / fixed2 18 failed;diff 仅剩 tests/test_doc_center_20260924.py::
  TestProdContentCanary::test_prod_content_readable —— 基线树同样失败(/srv/hermes/shared/
  cma-content 于 14:05 被写入 manual 分类,共享内容目录变化),与本单无关。
  本单新增的 5 例在基线树全红、改后全绿。

任务: yanxue-20261012-cma-map-autosave-stale-overwrite-defect-fullstack
2026-10-12 17:06:07 +08:00
wecom-fullstack 03254bd4c7 fix(maps): 战略地图写通道乐观并发 + 画布自动保存仅脏时写
生产缺陷 cma-strategy-map-autosave-stale-overwrite(D4 批次 19 行 map_objectives 被过期快照覆写):
- 后端 update_map: 覆写 dimensions/canvas_data 须带 base_updated_at,与服务端 updated_at 不符 → 409;
  校验在任何写入之前完成(校验不过时一行都不删/不改)
- 连线写通道(POST/DELETE connections)响应补 updated_at,供前端采纳新基准、避免自伤 409
- 前端 MapCanvas: 自动保存仅在 draft + isDirty 时触发;请求体带 base_updated_at;收到 409 停自动保存不重试
- 修复 async onMounted 竞态:await 期间被卸载后其续段仍注册定时器(清理钩子先跑完、无人回收)
  → 泄漏写者永久在后台用过期快照覆写(生产 referer=/maps 仍每 30–60s 写)。加 disposed 守卫 + stopAutoSave
- 测试: backend/tests/test_map_concurrency_20261012.py(8 例,改前 5 败);前端行为台架 11/11(改前 3/11)

任务: yanxue-20261012-cma-map-autosave-stale-overwrite-defect-fullstack
2026-10-12 11:42:56 +08:00
Hermes CI Fix 1613f7f961 fix(cpa): sales bill-type whitelist for customer revenue caliber (P0)
ci/woodpecker/push/main Pipeline failed
Customer revenue/concentration now filters bill_type IN (1,4,14) (XSD/XSTHD/XSHHD).

Non-sales docs (JHD purchase, YFKD prepay, YSKD accept, QTRK, SKD, YBFYD) carry SUPPLIER unit_id and were polluting 'customer concentration' (old Top1/Top2 were our biggest suppliers; root cause of 6.04x base gap).

Unify sign filter to (sum_money+sum_tax_money)>0 (equivalent to money_only on current data, diff 0 rows) and correct the withdrawn 407-row/17.2% wording in cpa_caliber docstring.

Deriver: anchors from cpa_caliber single source; new --concentration overwrite and --only concentration (minimal 9-row write face) with pre-overwrite backup.

Tests: fixture gains bill_type/sum_tax_money; +5 whitelist cases (44 passed targeted).
2026-10-09 17:00:37 +08:00
Hermes CI Fix 451c994a86 feat(cpa): customer profitability MVP P0-a/b - revenue caliber single source + snapshot table + 9-card tri-state
Task: finance-20261003-cpa-customer-profitability-mvp-fullstack (P0-a / P0-b)

- backend/app/utils/cpa_caliber.py (new): single source of truth for
  customer-level revenue aggregation from zxt_masterbill. Reused by
  alert_rules._customer_revenue_shares (dedupe: no second implementation).
- backend/scripts/migrate_cpa_snapshot_20261004.py (new): idempotent DDL for
  customer_profitability_snapshot (UNIQUE entity_id+period+unit_id),
  --check / --apply / --rollback / --status / --show-create + journal.
- backend/scripts/kpi_customer_profitability_deriver.py (new): monthly
  snapshot + kpi_values upsert (single transaction, idempotent).
  fail-closed: cogs_alloc / chan_sub / contrib / contrib_rate stay NULL and
  are never written as 0 or as an apportioned value.
- backend/scripts/sql/cpa_kpi_defs_20261004.sql (new): C_CUST_REVENUE and
  C_CUST_COUNT active; 5 P1 codes registered inactive with fail-closed reason.
- backend/app/api/customer_dashboard.py: new read-only endpoint
  GET /api/cma/customer-dashboard/standard-indicators returning the 9-card
  tri-state (value / no_data / undefined). Reads kpi_definitions via raw SQL
  to stay decoupled from the in-flight KPI direction DDL.
- frontend/src/views/CustomerDashboard.vue: 9-card panel rendering the three
  states (never renders an empty value as 0).

Anchor check 2026-01..09 (offline sqlite rehearsal, ALL_PASS=true):
73 customers / 4883.52 wan / top1 25.3835 / top2 23.4905 / top4 63.3249 /
top10 81.7675 - all six match the task acceptance anchors.

No production write and no deploy in this commit; release is gated.
2026-10-09 17:00:37 +08:00
wecom-fullstack a48a116e06 fix(dupont): insight.detail 叙述段字面 None 收口(表现层 _fmt_val,仅文案)
ci/woodpecker/push/main Pipeline failed
- 恒等式叙述段 5 处裸 f-string 插值(三因子 value / ROE / identity.diff)改走 _fmt_val:
  None → 「-」(与同段四列 _fmt 同族),有值原样输出(不经 :.2f,保证有值路径逐字节不变)
- 结构化字段 roe / factors[*].value / identity.diff 仍为 null;check_identity 语义、dupont_basis.py 未改
- 基线 origin/main acf179a;本单零生产写(不推远端/不同步部署树/不重启/不连库)
2026-10-06 23:36:06 +08:00
Hermes CI Fix acf179a199 fix(subjects): migrate 脚本口径乙 fail-closed(排除白名单逐码 + 双轨锚点)
ci/woodpecker/push/main Pipeline failed
2026-10-06 17:03:44 +08:00
wecom-fullstack b58513c48d docs(ddl): runbook 首步强制「迁移前像 dump 哈希」(--apply 之前)
ci/woodpecker/push/main Pipeline was successful
CMA-DDL-RUNBOOK-20261005 / parent: CMA-KPI-20261003-D-T1
- docs/db-migration-runbook.md 新增 §3.0:任何 --apply 之前对每张受影响表落
  全表有序 (PK, 受影响列) dump,记录 sha256 + 行数 + 字节数,归档 backups/pre-ddl-<task>/
- 含可直接复制的一行命令(ORDER BY 主键保证字节级确定性)
- §4 发布 checklist 第 4 条(列级 ALTER)指向 §3.0

仅文档;零生产库/零部署/零重启。
2026-10-05 13:02:02 +08:00
wecom-fullstack 4f9bab6bc0 fix(dupont): reject out-of-domain window + bidirectional clip + suffix discipline (R1-R4/M2)
ci/woodpecker/push/main Pipeline failed
Per finance ruling 2026-10-05 (project-20261005-dupont-oob-window-fix):

- R1: no intersection with available month domain => explicit error (endpoint 400), no window fields

- R2: with intersection => intersection window, months>=1 (kills inversion and months<=0)

- R3: quarter/half/... suffix kept only when returned window == nominal window (clipped flag)

- R4: symmetric lower-bound clip note; M2: TTM nominal window = [anchor-11, anchor] (12 months)

- M1: docstring correction (lower-bound overflow is clipped, not 400)

No push / no deploy / no restart / no DB write.
2026-10-05 02:02:21 +08:00
Hermes CI Fix 3ed9fa0611 fix(cvp): O1 residue - e2 rebate_policy_pct 置 null(口径单一源 = bep_caliber)
ci/woodpecker/push/main Pipeline failed
问题:/bep-card 端点对 e2(博海)回落模块内硬编码常量 BEP_REBATE_POLICY_PCT=84.61,
而 bep_caliber.ENTITY_CALIBERS[2]["policy_pct"] 为 None(博海无渠补科目)⇒ 下游 API
消费者会把 payload 里的 rebate_policy_pct=84.61 误读为「博海有 84.61% 政策渠补率」。

改动(严格限定 dashboard.py 一个模块 + 1 条守护用例):
- 删除模块级常量 BEP_REBATE_POLICY_PCT;四处回落分支改为直接取口径值
  (未注册口径分支置 None;其余取 cal.get("policy_pct") / 已算出的 policy_pct)。
- 口径单一事实源维持 app/utils/bep_caliber.py,端点不做任何回落。
- 新增 tests/test_bep_rebate_actual_caliber.py::test_o1_policy_pct_single_source_no_hardcoded_fallback
  (改前必红 / 改后必绿,e1=84.61 作同端点负对照)。

改前/改后(端点函数直调,生产库只读,e1/e2 同批):
  改前 e2 inputs.rebate_policy_pct = 84.61 → 改后 null;e1 恒为 84.61(负对照不变)。
2026-10-04 22:37:20 +08:00
wecom-fullstackandHermes CI Fix 587d5b3983 fix(cvp): BEP ⑤ 口径统一落地(变动成本率=完整口径)
ci/woodpecker/push/main Pipeline failed
- 派生器/口径:F_BEP_VC_RATE 新增;卡面/公式行文案改「月固定成本 ÷ 边际贡献率」
- 保本额 = 月固定成本 ÷ (1 − 变动成本率);实测渠补率(F_BEP_REBATE)降为 e1 专用 chip
- 报告卡口径说明文字同步(固定成本=固定费用合计)

任务: finance-20261003-cvp-fix-and-bohai-coverage-fullstack ⑤
2026-10-04 19:53:12 +08:00
Hermes CI Fix 4fe956fcf9 fix(dupont): resolve YYYY-Qn / YYYY-Hn period windows (500 on quarter/half)
ci/woodpecker/push/main Pipeline failed
Root cause: resolve_window() matched quarter/half with p[4] == "q" / "h"
(always '-' for "2026-q2"/"2026-h1"), and the branches sat after the
single-month branch -> dead code. Real input fell into the month branch,
so months_between() raised ValueError on int('q2') -> HTTP 500 for all
18 quarter/half options in the period selector.

Fix: judge on p[5] in ("q","h") with a digit guard on p[6], evaluated
before the single-month branch; month branch tightened to p[5:].isdigit().
Existing error-branch wording unchanged.

Tests: backend/tests/test_dupont_period_options.py (18 defect options,
case equivalence, month/year/ytd/ttm/custom non-regression, malformed
input returns error dict instead of raising).
2026-10-04 18:34:05 +08:00
Hermes CI Fix 40d2ffe0f7 fix(dupont): None guard for raw formatting in /reports/dupont insight detail
ci/woodpecker/push/main Pipeline failed
Non-ending stock windows (e.g. ?period=2026-Q2 / 2026-05 / custom) return raw total_assets/equity as None when the window has no subject_balances level-1 rows; identity guard reports status 'unavailable' (not 'identity_broken') and execution continues into the insight detail f-string, where .2f on None raised TypeError -> HTTP 500. Add explicit fmt() ('-' for None) for the four raw columns in detail.

Task: fullstack-20261004-1544 (P1; proposer wecom-fullstack; ASK-20261004-07 follow-up). No DDL. Single file.
2026-10-04 18:03:34 +08:00
Hermes CI Fix 38a6de9325 feat(dupont): period-basis design (cumulative flow x average stock, optional annualize, identity guard)
ci/woodpecker/push/main Pipeline failed
Task: finance-20261003-dupont-period-basis-design-fullstack (P1; proposer wecom-finance)
Basis ruling: finance 2026-10-03 (Boss 09:49 CST DM approval of the design task)

- /reports/dupont accepts period/start/end/annualize (default YTD), returns
  period_start/period_end/basis/annualized metadata; identity guard
  |ROE - npm x turnover x multiplier| <= 0.05 pp, else identity_broken (no values).
- new app/utils/dupont_basis.py = single source of truth (window resolve, flow
  cumulative via voucher_details, stock average via subject_balances level=1,
  fail-closed basis_fallback, pure calc + identity check).
- scripts/kpi_dupont_deriver.py (idempotent derived writer, dry-run/check/write),
  scripts/apply_dupont_kpi_defs.py + sql/dupont_kpi_defs_20261003.sql (seed:
  F_ASSET_TOTAL/F_EQUITY_TOTAL/F_EQUITY_MULTIPLIER as derived; F_ASSET_TURNOVER
  manual -> derived). Seed already applied under approval 296cc1fd.
- scripts/verify_dupont_period_basis.py: read-only acceptance battery A1-A9
  (independent SQL recompute). BEFORE (base tree) 1/10 PASS -> AFTER 10/13 PASS;
  remaining 3 need the deriver value write (approval-gated).
- frontend DupontAnalysis.vue: period selector (YTD default) + annualize switch
  + caliber metadata bar + identity_broken alert.

No DDL. No ERP direct connection (old register_dupont_kpis.py caliber dropped).
2026-10-04 13:35:25 +08:00
Hermes CI Fix 1f1385ebbf feat(cvp): CM≤0 fail-closed 禁写0.0 + 博海BEP扩面(新口径) + 影子CVP标注is_mock + 前端NULL守卫/不适用
ci/woodpecker/push/main Pipeline failed
- kpi_bep_deriver.py: 实体口径注册表(app/utils/bep_caliber.py 单一事实源);博海(6001/6401/6601-03/1602);
  fail-closed② 边际贡献非正/固定成本退化 ⇒ 五条保本值 NULL+pending+remark'不适用'(禁回落0.0)
- dashboard.py /bep-card: 多实体口径 + not_applicable 态返回 label/reason
- predict.py /cvp-detailed: 显式 source=scenario_simulation + is_mock=true(影子CVP标注)
- 前端 BEPBreakEvenCard.vue: 全量数值判空 + '不适用'态;PredictDashboard.vue: 情景模拟·非实测标签

验收 accept-bep-cvp-20261003.py: BEFORE 2/11 → AFTER 11/11 PASS
任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack
2026-10-03 16:03:12 +08:00
Hermes CI Fix 8631ce718f feat(profitability): real ROI (op profit / invested capital), enable EVA, add RI
ci/woodpecker/push/main Pipeline failed
Kaliber: wecom-finance 2026-10-02 ruling (Boss 11:59 CST "li; but (1) add real ROI").

- kpi_definitions seed scripts/sql/profitability_kpi_defs_20261003.sql:
  F_ROI renamed to F_ROA (same kpi_id=20, all 8 historical values kept), new F_ROI
  (real IMA caliber = operating profit / invested capital), F_EVA activated with unit
  and thresholds aligned to 万元, new F_RI (residual income).
- parameters are NOT hardcoded in formula text: they live in
  kpi_definitions.data_source_config.params (tax_rate / wacc / min_return_rate /
  include_factory_subsidy / include_channel_subsidy / include_sales_discount /
  annualize / capital_basis); single reader = app/utils/profitability_params.py.
- deriver scripts/kpi_profitability_deriver.py: YTD operating profit from
  voucher_details (carry_forward=0 net), invested capital from subject_balances
  level-1 rows only, idempotent upsert into kpi_values (source_type=derived, no
  data_status downgrade, fail-closed when invested capital is 0).
- read-only endpoint GET /reports/value-management reads the derived rows only
  (single source of truth, no recomputation) and exposes the parameter block.
- DupontAnalysis.vue: value management section (ROI / ROA / ROE / EVA / RI).
- caliber_units.py: RATIO_CALIBER_TABLE key F_ROI renamed to F_ROA, new F_ROI row.
- tests: backend/tests/test_profitability_indicators.py (21 cases).
2026-10-03 02:10:47 +08:00
Hermes CI Fix 1da0a74568 fix(h2-forecast): 预估区残余读路径 P2 收口(3 类 6 处)
任总 2026-10-02 11:35 直令「对外 /api/cma/** 读 KPIValue 默认只取实测区」同族残余
(ac4bbc6 范围外的 3 类 6 处)。6 处一律引 app.utils.kpi_value_scope.actual_only_clause(),
零新写字面量口径:
- customer_dashboard.py:76    年视图汇总(预估禁与实测相加)
- budget.py:260               按去年实绩加权派生基数(批量 auto-decompose)
- budget.py:333               同上(单 KPI auto-decompose)
- cash_forecast_engine.py:204 get_entity_kpi_history 历史/趋势序列
- cash_forecast_engine.py:259 get_current_cash_balance 最新值
- cash_forecast_engine.py:471 calculate_accuracy 实际值序列(预测输入)

证据:同脚本双树 A/B 15/15(改前 5/15,真库 kpi-forecast history 去掉 2026-H2、
next_target 336.32→250.99);分类计数 33/41/9/3 → 39 covered/38 period_guarded/9 write/0 unguarded;
pytest 3 failed/1529 passed,失败集与基线逐条同 ID。不 push / 不部署 / 不重启 / 不写库。
Task: project-20261002-h2-forecast-residual-reads-p2-fullstack
2026-10-02 23:41:17 +08:00
Hermes CI Fix 5d7b3cc728 feat(h2-forecast): 实测区/预估区口径分离 —— 对外读路径默认只取实测区
ci/woodpecker/push/main Pipeline failed
- 新增 app/utils/kpi_value_scope.py(口径单一事实源:actual_only_clause / forecast_only_clause / is_forecast_row)
- dashboard.py: /kpis /my-kpis /finance-analysis /predict /my-dashboard / latest_kpi_value 默认剔预估,include_forecast=true 单列返回
- alert_rules.py: 动态阈值白名单去 estimated + 4 处读路径加实测区判据
- 前端 ForecastTag.vue 新增;Dashboard.vue / MyDashboard.vue 预估单列 + 标签(不与实测相加)

Task: finance-20261002-h2-forecast-separation-deploy-fullstack
2026-10-02 23:33:15 +08:00
wecom-fullstack f32f6e7430 feat(receivables): 催收页只认客户级行 — 月度汇总行不计入卡片/账龄/明细/owners
- 口径实现逐字节取自既有已批准件 475f78b(ASK-20261001-33, wt/hanke-ar-customer-level):
  _is_customer_level_row(plan) = counterparty 非空且非空白(.strip() 覆盖制表符/全角空格)
  用在两处且仅两处:明细循环 + owners 查询 ⇒ 四条消费面同一判据
- 数据零变更:cash_plans 一行不删不改(source='zxt-sync' 的 67 行月度汇总行=正确通道数据,保留)
- 不新写第二份实现;不改 frontend/src/api/index.ts 全局拦截器;无 DDL、无数据迁移
2026-10-02 21:15:42 +08:00
Hermes CI Fix 29dc5c0bdc fix(receivables): unwrap response envelope once + align card/column field names
- Receivables.vue: const d = r (was r.data || r) -> cards/table no longer blank

- cards: pending_balance(待收余额) / overdue / completed_amount / completed_count

- columns: counterparty(+description fallback) / owner(未分配) / settled_amount / plan_date / aging_bucket

- cash.py: summary.completed_count + _plan_dict.settled_amount (existing keys untouched)

- api/index.ts untouched (global interceptor)

(cherry picked from commit d60557622441b971970fb9dc86ef7491c3a9d72b)
2026-10-02 21:12:20 +08:00
Hermes CI Fix ac4bbc6891 fix(h2-forecast): 对外 KPIValue 残余读路径统一实测区口径(7 处)
任总 2026-10-02 11:35 直令「对外 /api/cma/** 读 KPIValue 默认只取实测区;
预估禁进趋势序列 / 禁作预警与预算依据」的同族残余读路径收口。

7 处逐处改(均引 app/utils/kpi_value_scope.actual_only_clause,不新写字面量口径):
- kpis.py:604            GET /kpis/{id} 详情历史值(预估禁进趋势序列)
- ai_analysis.py:363     GET /ai/kpi-analysis/{id} 趋势序列/LLM 上下文
- thresholds.py:132      GET /thresholds/suggest/{id} 阈值推荐依据
- budget_generate.py:94  GET /budget/kpi-budget-candidates 预算候选最新值
- budget.py:1380         POST /budget/apply-method 派生基数(预算依据)
- maps.py:657            GET /maps/{id}/review 地图节点最新值
- data_quality.py:363    GET /data-quality/stats 缺失判定(仅预估行不算有实际值)

证据:同脚本双树 A/B 11/11(改前 3/11 FAIL)+ pytest 3 failed/1529 passed 与基线逐条同 ID。
不 push / 不部署 / 不重启 / 不写库。
2026-10-02 21:01:25 +08:00
Hermes CI Fix 3521cce01e fix(bep-caliber): seed target_value NULL -> 0.00 (kpi_definitions NOT NULL, 1048)
ci/woodpecker/push/main Pipeline failed
首发串 [4/8] 以 pymysql 1048 失败: kpi_definitions.target_value 为 NOT NULL(decimal(15,2) default 0.00),种子显式写 NULL 覆盖默认值。

本提交只改 backend/scripts/sql/bep_kpi_defs_rebate_20261002.sql 一行。
2026-10-02 15:55:30 +08:00
Hermes CI Fix 14ed29bcbf fix(bep-caliber): rebate rate uses YTD actual voucher cumulative (571103 debit / 5001 credit), book+cash card read derived, fail-closed
ci/woodpecker/push/main Pipeline was canceled
2026-10-02 12:20:39 +08:00
Hermes CI Fix 6be903fbc9 fix(bep-caliber): expense and profit sums use net (debit minus credit) under carry_forward=0
ci/woodpecker/push/main Pipeline failed
ASK-20261002-06 fix04u: admin(5602%) and depreciation(560213) take net amount in both the dashboard card helper and the BEP deriver; sales (5001) keeps credit_amount. Verbatim per proposal section 2. No DB writes, no kpi_values backfill.
2026-10-02 11:22:38 +08:00
Hermes CI Fix 6c1ea85d71 fix(alignment): /tree map_id filter via map_objectives.kpis union (v2)
ci/woodpecker/push/main Pipeline failed
- add missing 'or_' import (NameError on map_id calls)

- union of map_objectives.kpis codes and KPIDefinition.map_id, intersect with entity active KPIs

- cross-entity / unknown map_id -> empty tree (no leak)

- no map_id -> unchanged behaviour
2026-10-01 21:12:02 +08:00
wecom-fullstack 22ec40052d fix(alignment): /tree accept and apply map_id filter for KPI selection
GET /api/cma/alignment/tree ignored the map_id query param (FastAPI silently dropped it) so the board always rendered the full active-KPI set of the account. Add map_id: Optional[int] = Query(None) and filter KPIDefinition.map_id when provided; no param keeps the previous behaviour. Entity scoping unchanged (entity_id from token) - cross-account map_id yields an empty tree, no other-account KPI is returned. Only the KPI selection set is filtered; vertical/horizontal/bsc_chain builders untouched.
2026-10-01 15:58:40 +08:00
wecom-fullstack 0824efc8ed feat(data-quality): 规则配置可编辑入口 + 经营口径排除接线(ASK-20260928-05 尾项)
ci/woodpecker/push/main Pipeline failed
依据:任总 2026-09-29 08:49「P1和P2批」放行 P1-cma-dq-rule-config-approval-20260923 ①③
     (派单件 0-P1-project-20260929-qrc-write-entry-and-demo-exclusion-fullstack)

三处尾项(本单):

1) 前端可编辑入口(补拍板件 ① 未被上轮覆盖的部分)
   - frontend/src/views/DataQuality.vue:规则配置 Tab 由「只读」改为
     ceo/it 可编辑、finance/business 只读(角色口径与 router/index.ts:45 及
     系统设置页一致);新增「操作/编辑」列(平台级项仅 it 可点、演示账套禁用);
     编辑走 MyDialog(业务弹窗铁律),必填变更理由(>=10 字),取值区间/枚举
     由后端返回字段呈现,越界/非法一律如实回显后端 detail,前端不自造校验通过。

2) scope.excluded_entities 接线 + 出厂默认 [3,4,5,6](拍板件 ③-2「排除」)
   - app/dq_rule_config.py:默认由 [] 改为 [3,4,5,6];新增单一事实源
     EXCLUDED_ENTITIES_KEY / EXCLUDED_ENTITIES_FIELD、normalize_entity_ids()、
     excluded_entities_from_resolution()、excluded_entity_ids()、
     is_excluded_from_business_caliber()。
   - app/api/data_quality.py:新增 _excluded_clause()(仅全账套口径 eid=0 生效,
     COALESCE(col,1) NOT IN (...) 参数化,NULL 归属不吞行)与 _business_excluded();
     七条规则的明细查询与 _detail_count 全部叠加排除子句;_plan_id_set /
     _pending_alert_plan_links 支持 excluded(播报口径同源)。
   - 响应新增 business_caliber{caliber,exclusion_applied,excluded_entities,included}
     与 scope_excluded_entities;单账套查询保持展示、仅标注不计入经营口径。
   - PUT /rule-config:平台级项改写全账套模板行(entity_id=0),审计 detail 与响应
     增 target_entity_id;GET 增 scope_note 与 write_contract{roles,platform_roles,
     min_reason_len,entity_config_entities,bot_channel_write,endpoint}。

3) 修复两处实现缺陷(本单测试新增用例的 RED 驱动)
   - excluded_entities_from_resolution() 取键与 resolve()["scope"] 的归组形态不一致
     (配了 scope. 全名去取已归组后的短名字典)⇒ 恒返回空集,接线静默失效;
     改为短名优先、全名兼容(EXCLUDED_ENTITIES_FIELD)。
   - upsert_param() 全局项 before 取值多一层 rule_key 查找 ⇒ before 恒 None;
     改为全局项直接以 param_key 为键。

4) 测试:tests/test_dq_rule_config_20260928.py 3 处既有断言按新口径更新 +
   新增 E 类 8 例(排除默认/单一事实源、全账套与单账套两种口径面、A/B 聚合差异
   只来自被排除账套、明细计数同源、平台级项改名单即时生效、平台级写落模板行)。

不改:判定表达式本体 / 级别归属 / 评分函数 / 规则集(DAMA §4 锁死 10 类)、
      unit_check.amount_threshold(保持 10000)、virtual_pollution 关键词表与来源白名单。

边界:本提交只含 4 文件(backend/app/dq_rule_config.py、
      backend/app/api/data_quality.py、backend/tests/test_dq_rule_config_20260928.py、
      frontend/src/views/DataQuality.vue);未裹挟他人在制件
      (audit_log.py / migrate_subjects_entity_id_20260923.py / package.json)。

证据:pending-tasks/_evidence/ask5-tail-20260929/
      (pytest 全量 1 failed/1516 passed + 失败项归因 A/B、vue-tsc rc=0、
       ab-exclusion-pre-write.json 只读 A/B 原始输出、gate dry-run 等)
2026-09-29 13:00:48 +08:00
wecom-fullstack b8493f8d04 feat(db): 新表授权面收敛机制落地(白名单驱动 · 增量锚定 · fail-closed · 只增不减)
依据:A 侧(wecom-yanxue)裁定 编号 2「准予但需调整」+ R1(裁定件
yanxue-20260928-newtable-grant-surface-impl-plan-ruling.md,md5 272d1a8b4db0db0e9f35f5e62695ae50)
三条开口 (a) 空锚=合法零集 / (b) --anchor-file 载体等价 / (c) drill WARN 不阻断(两条独立分支)

改动面(3 件,全部开发工作树):
- 新增 backend/scripts/grant_surface_sync.py:项目侧原型逐字复制 + Δ1/Δ2/Δ3/Δ4 + R1
  Δ1 锚语义(未给=缺锚 rc=2 / 空集=合法零集)、Δ2 连接身份与 db_migrate 同口径并拒绝 cma_api、
  Δ3 --anchor-file(读 db-migrate-journal 指针件)、Δ4 anchor_source 入 JSON
  R1-a 同源自证(journal 在册 + status=applied + finished_at 一致 + 目录内最新 -applied)
  R1-b 未收敛锚累积由 db_migrate.write_anchor_pointer 实现、R1-c 收敛回写 converged_at
- db_migrate.py 加性 +2 处:新增 write_anchor_pointer()(原子写 journal/last-anchor.json);
  cmd_apply 成功/失败两路径各调一次;零变更面未动(列级差异只告警 / create_all 幂等 /
  --check 不写指针 / --rollback 与 resolve_journal 语义 / JSON 键集)
- deploy.sh 加性 1 步 [4b-2/5] 授权面收敛(插在「✓ 结构迁移完成」与「# 5. 重启后端服务」之间):
  apply 跑 --apply --anchor-file ... --drift-policy warn,rc!=0 ⇒ 打印缺裁定清单 + exit 1
  (后端不重启、旧码继续服务);drill 跑 --check,读数原样打印、WARN 不阻断

证据(全部零生产面):
- py_compile x3 通过;bash -n deploy.sh 通过(PYTHONPYCACHEPREFIX 隔离,仓内零新增 pyc)
- 纯函数用例 11/11(零连库):语句均为 GRANT / 无 REVOKE / 语句表 ⊆ write_tables / 域外权限 SystemExit
- 沙箱 S1–S9 回归 9/9 一致(SUMMARY.txt 与项目侧基线逐字节相同 md5 1a697e002a2cba8ae9b5ad9cc71b022d)
- 锚/R1 新例 18/18:缺锚/缺件/status=failed/R1-a 四负例均 rc=2 零写入;空锚 rc=0 executed=[]
- R1 验收链:发布1 建表 X(∉白名单) ⇒ rc=3 零写入 + 片段 exit 1 ⇒ 不修白名单重跑 ⇒ 仍 rc=3 ⇒
  补白名单 ⇒ rc=0 + X 获授权 + converged_at 落定 ⇒ 三次重跑 executed=[](幂等)
- 既有 backend/tests:1509 passed / 0 failed,改前(HEAD 导出树)与改后失败集零差异

边界:本地 commit,零 push / 零部署 / 零重启 / 零连生产库 / 零 DDL-DML-GRANT-REVOKE;
上线(push + 部署 ⇒ 授权面生效)另单另铸 approval ID 且必附 R2 预演 diff(本单不为任何动作铸 ID)
2026-09-28 17:01:39 +08:00
wecom-fullstack 36f4f54799 feat(data-quality): 财务七规则可配置化最小实现(ASK-20260928-05)
ci/woodpecker/push/main Pipeline failed
任总 2026-09-28 13:38 放行「4 和 5」→ 落笔最小实现:
- 新表 quality_rule_config(39 项:37 规则级 + 2 全局)= 唯一配置源
- 三层解析:账套行 → 全账套模板(entity_id=0) → 代码内置默认(=清单 §7 建议值,零行为差异)
- 端点 /api/cma/data-quality/rule-config(GET/PUT)+ /history + /meta:写入仅 ceo/it,
  platform 级仅 it,演示账套(functional)写禁,变更落 operation_logs 同事务
- 数据质量页新增「规则配置」只读 Tab(懒加载)
- 唯一行为变更:污染判定去掉 sync —— 酣客 zxt-sync 系统迁入数据不再被误判(口径可配置)
- 出厂默认落库脚本 scripts/seed_dq_rule_config.py(--check/--apply/--rollback,幂等 39/0)
- 回归:新套件 35 例 + 存量 1508 passed(唯一 FAILED 为他人未完成
  migrate_subjects_entity_id_20260923.py 改动所致,HEAD 干净树同例通过)

生产写(DDL/seed)与 push 均走审批门:DDL=54434656 push=3bd00774
2026-09-28 13:56:16 +08:00
Hermes CI Fix 242f3cf5ec fix(fetch-caliber): 取数层排除半年/预测伪期间 + subjects 响应补 entity_id
R3 期间口径(仅 YYYY-MM)延伸到取数层:新增 alert_entity_scope.month_period_clause()(跨库可移植 SQL 等价式),应用到 15 个文件 26 处「按 period 倒序取最新值/最近N期」位点;dashboard 原 MySQL-only REGEXP 正则收口到同单一事实源。

GET /api/cma/subjects 响应增补 entity_id(NULL=全局标准准则科目 / N=账套私有明细),既有 10 字段语义与名称不变。

证据:pending-tasks/_reports/yanxue-20260924-cma-fetch-caliber-fixes-fullstack.md(改前/改后 A/B:F_NET_PROFIT 2026-H2/-55.0 → 2026-08/-10.46)。新增 7 例回归测试;全量 1 failed(预存在) 1473 passed,基线同项。
2026-09-26 02:42:04 +08:00
wecom-fullstack 52b693ddbb ASK-20260924-15/17: Bot 通道缺省账套→400 + 差异/趋势入口期间口径回落
- deps.get_entity_id: Bot 通道(带 X-BOT-KEY)无显式账套 → 400(旧为兜底 entity 1=酣客);
  无 key 的存量公开/用户端点行为不变(仍 401/兜底 1)
- 新增 deps.require_bot_entity_id():写端点缺省账套一处收口 → 400(406/422 同族)
  接入 bot_bridge(×2) / bot_bridge_v2 / bot_suggestions / cash_vouchers_json,并同步文档口径
- deviation_engine.run_deviation_check: period=None 不再硬编码当月,逐 KPI 回落
  「该 KPI 自己的最近可用期间」(裁定2b,单一实现收口到 alert_entity_scope);(当月无
  kpi_values ⇒ 差异/趋势入口永久 0 新增);R2/R3 窗口判定改为逐 KPI;显式 period 语义不变
- alert_generator.resolve_latest_period 委托同一实现(消除期间口径两份)
- 测试:新增 tests/test_ask15_17_engine_and_entity_required_20260925.py(12 例);
  4 个既有测试文件把「旧兜底账套 1」显式化(断言未改)
2026-09-25 22:42:25 +08:00
Hermes CI Fix 38787a006f fix(notifications): deterministic tiebreaker for paginated notification log list
Same-class gap as e369508 (alerts list): single-key ORDER BY created_at DESC plus offset/limit lets MySQL filesort return an unstable order inside same-second tie groups, so offset pagination duplicates and skips rows. Append secondary key id DESC, aligning with app/api/alerts.py, app/api/cash.py and app/api/data_quality.py.

Entity-scope caliber untouched: parent-key join attribution, alert_id IS NULL rows still excluded, policy/note wording unchanged. Local commit only - no push (release gated by ASK-20260925-04).
2026-09-25 16:34:52 +08:00
Hermes CI Fix e3695088a4 fix(alerts): deterministic tiebreaker for paginated alert list
list_alerts ordered by created_at DESC alone; kpi_alerts has many rows sharing the same second timestamp (07:00 batches), so MySQL tie order is unstable and offset pagination both duplicated and skipped rows (entity 1 read-only probe: total=281, 281 rows walked, only 268 unique ids, 13 duplicates; id 587 unreachable in the unfiltered list while visible in ?status=pending).

Add secondary key id DESC so pages form a total order; add regression suite test_alerts_pagination_stability_20260925.py (tie-heavy fixture: unique==total, zero duplicates, strict id DESC inside a tie group, page concat == single-shot ordered query, unfiltered vs status-filtered visibility, entity isolation negative control).
2026-09-25 14:06:38 +08:00
wecom-fullstack a8391a03da fix(caliber): 流动/速动比率改「倍」口径 + 判定层同量纲直判(ASK-20260924-11 任总放行)
ci/woodpecker/push/main Pipeline failed
- caliber_units: F_CURRENT_RATIO/F_QUICK_RATIO 由 FACTOR_PERCENT(×100) 退场为 FACTOR_IDENTITY;declared_unit=倍 / caliber=倍(无量纲)/ evidence 改引 v2.1 锚;F_GROSS_MARGIN 保持 ×100 不动

- 引用面同步 7 文件:alert_generator / kpi_unit_crosscheck / migrate_...20260916 横幅 / 三个测试套件;self-test 两条改倍口径期望 yellow

口径: CMA-CALIBER-v2.1/kpi-unit@2026-09-24 取代 2a 之两项;其余比率类仍按裁定2a 百分比口径
2026-09-25 10:39:18 +08:00
wecom-fullstack c3ff345d0a fix(bep): 派生器不再静默降级已转正行(ASK-20260924-10 任总放行)
ci/woodpecker/push/main Pipeline failed
- upsert 分支原无条件 existing.data_status='pending' ⇒ 人工转正次日 06:35 被打回 pending
- 值未变/值变一律不写 data_status(保留既有,含 verified)
- 值变:remark 追加「值已更新(batch=…, 旧X → 新Y) 待财务复核」+ 落复核提示件(_reports/,不进队列根)
- 新期间仍写 pending(9-14 复核流入口不变);算式/TARGET_KPIS/阈值逻辑不动
2026-09-25 07:07:01 +08:00
fullstack-bot 23f12fc866 fix(knowledge): render DB dynamic articles via markdown safe renderer
ci/woodpecker/push/main Pipeline failed
A: dynamic knowledge-articles content goes through marked instead of raw v-html (escape-first, no new deps).

B: plain-text fallback normalize (bracket headings, circled numbers to ordered list, blank lines, key-number bold, idempotent).

C: detail-content styles for h2/h3/pre/blockquote/paragraph spacing.

D: dialog width 700 to 860, content padding, in-dialog scroll.

Task: project-20260924-knowledge-dialog-render-fullstack
2026-09-24 23:32:50 +08:00
Hermes CI Fix 07dc10d557 fix(ui): 全仓 el-dialog 清剿 → MyDialog(26 处/17 文件)
ci/woodpecker/push/main Pipeline failed
- 项目铁律:一律 MyDialog(Element 原生弹窗在本项目长期不可见)
- 含用户报的 OKR 模板库「应用」弹窗(OKRTemplates.vue)
- 等价适配:width 字符串 px → 数字绑定;append-to-body/destroy-on-close
  /top/show-close/class 等 EP 专有属性移除(MyDialog 已 teleport+卸载语义)
- 新增 MyDialog 显式 import;零后端/零数据改动
- 全仓 <el-dialog> 0 命中;components.d.ts 随之移除 ElDialog 自动注册
2026-09-24 17:36:05 +08:00
wecom-fullstack 9b6184ce05 fix(data-quality): 陈旧预警阈值落 30 天(任总 2026-09-24 00:35 拍板,唯一取值来源) 2026-09-24 17:27:23 +08:00
Hermes CI Fix ede0524c22 feat(docs-center): CMAKnowledge reads md single source via new /api/cma/docs
ci/woodpecker/push/main Pipeline failed
New router app/api/doc_center.py: GET /api/cma/docs (list, ?with_content) and GET /api/cma/docs/{slug}; runtime disk read, role-gated (ceo/finance/business/it), no existing API semantics changed.

Content dir /srv/hermes/shared/cma-content (docs/*.md + categories.json). Placed outside the repo on purpose: the deploy tree equals the committed tree (deploy.sh checkout -f + clean -fdx), so in-repo content would be reset on every deploy and content edits would fail the deploy-tree dirty check.

CMAKnowledge.vue: 20 hardcoded items migrated to md, page now fetches at runtime and renders md with the existing marked dep (no new deps); table borders restored via :deep() since scoped styles never reach v-html content.

Tests: backend/tests/test_doc_center_20260924.py (20 cases). Full suite 1448 passed (baseline 1428 + 20).
2026-09-24 01:07:29 +08:00
Hermes CI Fix 72d4d97c83 fix(data-quality): KPI质量监控页 — 账套口径收口 + 环图颜色真值 + 天数单一来源 + 异常数下钻
ci/woodpecker/push/main Pipeline failed
1) 跨账套混算修复(最高优先):/stats 的 total_logs/open_logs/severity_counts/type_counts
   原先直查 kpi_data_quality_log 无任何账套过滤(全库数),而同页 /logs 已按账套收口,
   导致「卡片数字与下方列表对不上」。现四项与 /logs 共用 _scoped_quality_log_query
   (kpi_id join kpi_definitions,内连接,孤儿日志不计)。零 DDL:不给该表加 entity_id 列。
2) /logs 服务端分页(page/page_size,默认 50、上限 200)+ total 改真实 COUNT(*)
   (原为截断后行数 len(),且 limit=100 静默截断)。
3) 新增只读端点 GET /missing-data-kpis:「无实际值 KPI」清单(服务端分页 + 真实 total,
   口径与 /stats.data_missing 逐字一致),供「无实际值 KPI 占比」卡片下钻。
4) 环图黑块修复:ECharts 走 canvas,不解析 CSS 变量,原 itemStyle.color 传 CSS 变量会回退黑色。
   改合法 HEX 实值(与主题色同值 #F56C6C / #E6A23C)。全仓同类写法扫描:33 处 CSS 变量命中,
   仅此 2 处在 ECharts 上下文(已修);ReportCenter.vue:848-849(经 bscScoreColor() 流向 :style)、
   KPIDetail.vue:244/267(el-timeline-item :color 落 inline style)均为 CSS 上下文,非缺陷,未改。
5) 阈值文案单一来源:新增 STALE_THRESHOLD_DAYS = 180,接口 threshold_days 与之同源,
   后端注释同步修正;阈值本身保持 180 不动(改阈值属口径变更,须另行拍板)。
6) 前端 DataQuality.vue:卡片标题加维度限定词(KPI元数据完整度评分 / 无实际值 KPI 占比 /
   超 N 天未更新预警由接口渲染)、顶部四项加层级标注(记录数 vs 实体数)、三张异常卡 + 无实际值
   KPI 可点击,用 MyDialog 打开明细(0 值置灰不可点,服务端分页 50/页,导出 CSV 全量)、
   质量异常日志列表改服务端分页(显示 total,与卡片同口径)。未新增 el-dialog、未改 api/index.ts。

未动:KPI 检查算法 / 检查类型集合 / 异常判定阈值;未回退「财务七规则」标签页既有下钻
(govDetail* 与 rule-detail 端点)。
2026-09-24 00:40:11 +08:00
wecom-fullstack 467472311d feat(cma): audit write-side entity closure + entity-dimension data dictionary
task dama-entity-isolation-sweep-20260923 (sections 2-2 4/5, merged by projectBot 2026-09-23 18:2x)

4) operation_logs write side: every audit write carries the account (no DDL, no column added)

   - new app/utils/oplog.py::log_op() single wrapper: sets detail.entity_id; scope=global for

     by-design non-entity writes (platform dict / role-level); scope=batch + OPLOG_BATCH_NO_ENTITY

     sentinel for cross-account batches; no context -> OPLOG_NO_ENTITY sentinel (never guess)

   - 16 remaining construction points converted (data/alert_rules/bi_reports/kpi_causality/

     bot_bridge x2/budget_generate/driver_budget/kpis _log x3/templates _log x2/erp_sync);

     pre-4 state had 20 sites without entity_id, now 0 outside the wrapper

   - read side untouched (knowledge_service still user_id fallback); history not backfilled

5) docs/entity-dimension-data-dictionary.md: 8-table three-state registry

   (kpi_templates/driver_factor_templates/scenario_suggestions/bot_bridge_config = non-entity;

    kpi_subject_map = inherited via kpi_id; analysis_results/subjects = account dimension,

    legacy rows still NULL -> other task; operation_logs = non-entity column + detail.entity_id)

Evidence: RED 2 failed/7 passed (reverted call sites) -> GREEN full 1428 passed; 9 new tests

including two read-back rows; app md5 restore verified identical; no cross-account writes

LOCAL commit only - push is gated (ahead contains the P1 entity-dim batch commit fb4609b)
2026-09-23 23:08:26 +08:00
Hermes CI Fix fb4609be14 feat(entity-dim): 账套维度收口批 — subjects/analysis_results 加列 + 分析结论账套化 + 模板实例化构造点补账套
ci/woodpecker/push/main Pipeline failed
- ① kpi_templates instantiate 构造点显式传会话账套(原缺 entity_id ⇒ 静默落 e1),kpi_code 判重按账套收紧
- ② kpi_subject_map 3 个读取点加护栏注释(不加列、不改语义)
- ③ subjects 分层:读口 union(NULL=全局标准准则科目 / N=账套私有),UPSERT 唯一键改 (entity_id, subject_code)
- ④ analysis_results 账套化:读/写/删三端点按会话账套判定归属(列未落地时 kpi_code JOIN 止血),
     无归属行进 unattributed 登记、删除一律 404
- 模型层按现网 DDL 补齐:KPIDefinition(template_id/is_system + uk_entity_kpi)、AnalysisResult.entity_id、Subject.entity_id
- 新增 migrate_{subjects,analysis_results}_entity_id_20260923.py(--check/--apply/--down)、
  22 条对账用例 test_entity_dimension_convergence_20260923.py

DDL 已由 ops 受控通道落地(subjects 21:31:56 / analysis_results 21:31:59 CST,审批 1e9b4d9a/5ffb1640)
本提交为切片提交,未 push(发布须另取放行)
2026-09-23 21:39:24 +08:00
Hermes CI Fix 0b3a8aa111 feat(data-quality): anomaly-count drilldown to full detail + real COUNT(*) caliber
ci/woodpecker/push/main Pipeline failed
backend: GET /api/cma/data-quality/rule-detail (COUNT(*) total, structured items, server-side paging, scope=manual); governance seven-rule counts use real totals instead of len(LIMIT-ed rows). frontend: clickable anomaly count -> MyDialog detail (no new el-dialog) + full CSV export.
2026-09-23 18:49:57 +08:00
wecom-fullstack 477515c453 fix(cma): 隔离扫尾 — kpi_alert_higher_better 拆账套 + tenant/switch 审计收口
ci/woodpecker/push/main Pipeline was canceled
任务 dama-entity-isolation-sweep-20260923(DAMA 独立回查漏项 / 任总 2026-09-23「按照建议执行」)

① 补漏同源全局键 kpi_alert_higher_better(system_configs,零 DDL)
   - deviation_engine: 新增 higher_better_key(entity_id);get_higher_better_codes(db, entity_id)
     改为只读本账套键、不回退旧全局键;未配置 → DEFAULT_HIGHER_BETTER(兜底,与原行为逐字一致)
   - budget.py: GET/PUT /alert-direction 挂 get_entity_id,只读写会话账套键;body entity_id 不信
   - scripts/migrate_higher_better_key_entity_20260923.py: --check/--apply/--down + 库名保护 + 幂等
   - 生产实况:库内该键 0 行 ⇒ 无原值可搬运(--apply 走 skip 零写入),e1 行为不变

② kpi_visibility 口径裁定:角色级天然全局 = 非账套维度,不拆(关闭本项)
   - auth_middleware 注释 + docstring 登记「有意不做账套后缀」;口径字典
     docs/global-config-keys-caliber-20260923.md(含 budget_mode/budget_driver_mode 评估结论)

③ tenant/switch 审计收口
   - detail 显式 from_entity_id → to_entity_id + source + operator{id,username,name,role}
   - 审计失败不静默:同事务回滚 + AUDIT_WRITE_FAILED ERROR + HTTP 500(不留「改了但没审计」)
   - GET /current 无写路径不落审计;历史缺口不补写

证据:新增/改写用例 17 条;全量 1397 passed(去新增文件 1380 + 17);RED 态 8 failed
2026-09-23 18:29:52 +08:00
Hermes CI Fix d13bf1b6e4 feat(cashflow): 期初/期末现金补采 — 科目余额表货币资金(1001/1002/1012) 入库
ci/woodpecker/push/main Pipeline failed
- import_cashflow_statements.py 新增 _read_cash_balances(): 读 <YYYY.MM>/kmyeb.csv
  货币资金(1001/1002/1012) 期初借-贷 / 期末借-贷, ÷10000 留 2 位 → begin_cash/end_cash
- 仅 entity_id=1(智享通通道=酣客) 生效, 其它账套保持 NULL; 源缺失/科目缺失保持 NULL 不写 0
- 既有三大活动净额与 remark NA 固定短语逻辑未动; dry-run 默认/--period/幂等语义不变
- --self-test 扩至 9/9: 新增 kmyeb缺失/科目缺失/借贷为负 三类边界 + 真实源自洽与跨期连续
2026-09-23 16:20:43 +08:00
Hermes CI Fix e79b51666d fix(growth-quality): default period to newest period from API
ci/woodpecker/push/main Pipeline failed
The component shipped a hardcoded initial period (2026-06) that is present in the periods list, so the previous guard kept it and the page never defaulted to the newest period. Initialize empty so loadPeriods() applies periods[0] (backend returns DESC); /diagnosis treats an empty period as 'newest with data'.
2026-09-23 15:44:07 +08:00
Hermes CI Fix c88e1ad6c9 fix(growth-quality): period select reads periods array from object response
ci/woodpecker/push/main Pipeline failed
Backend GET /growth-quality/periods returns {entity_id,periods[],coverage{}}; axios interceptor already unwraps response.data so the old r.data||r||[] put the whole object into el-select and Vue rendered JSON.

Also default to periods[0]: backend returns periods in DESC order, so old code picked the OLDEST period. Only frontend/src/views/GrowthQuality.vue changed.
2026-09-23 15:29:44 +08:00
228 changed files with 30004 additions and 2429 deletions
+21 -31
View File
@@ -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/**
+11 -3
View File
@@ -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,
) )
+20 -5
View File
@@ -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"
+3
View File
@@ -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",
+64 -37
View File
@@ -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)
+7 -1
View File
@@ -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")
+39 -6
View File
@@ -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}
# 构建父子关系 # 构建父子关系
+161 -28
View File
@@ -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
+5 -2
View File
@@ -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()
+77 -34
View File
@@ -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}
+2 -1
View File
@@ -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")
+2
View File
@@ -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
+6 -5
View File
@@ -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
View File
@@ -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,需提交审批 → 批准 → 下达后方可作为执行基准"}
+21 -15
View File
@@ -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
View File
@@ -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单位元
+39 -26
View File
@@ -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,
} }
+13 -5
View File
@@ -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} 不存在或未激活")
+93 -2
View File
@@ -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
View File
@@ -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
+7 -3
View File
@@ -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": "删除成功"}
+11 -3
View File
@@ -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
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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,
+555
View File
@@ -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
+8 -7
View File
@@ -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,
+10
View File
@@ -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)}")
+16 -1
View File
@@ -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()
+10 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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分钟内联系您"}
+179
View File
@@ -0,0 +1,179 @@
"""战略地图 · 数据完备度 API(口径唯一真源:CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
主口径(对外唯一主数):仅地图 65(已发布)15/19 = 78.9%,判据 = 被 23-KPI 集挂接。
附注口径(只作附注):全量 4 图 30/31 = 96.8%,判据 = 有KPI挂接(含归档图)。
KPI 级:active 行 117(e1=73 / e2=44),按行计,与目标级分列、不相除。
所有汇总数一律运行时对库内数据现算;判据数据(23-KPI 挂接关系)见
app/data/caliber_map_completeness.py。两种禁用组合(仅地图65且按 kpis 非空、全量31且按命中 23-KPI 集)在本模块无生成路径。
"""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth
from app.data import caliber_map_completeness as cal
from app.database import get_db
from app.deps import get_entity_id
from app.models import KPIDefinition, MapObjective, StrategicMap
router = APIRouter(
prefix="/api/cma/maps/completeness",
tags=["战略地图数据完备度"],
dependencies=[Depends(require_auth)],
)
def _pct(n: int, d: int) -> Optional[float]:
"""占比只保留 1 位小数;分母为 0 返回 None(展示为「无数据」)。"""
if not d:
return None
return round(n * 100.0 / d, 1)
def _entity_scopes(db: Session, entity_id: int):
"""返回(主口径地图, 同属主的地图列表)。主口径地图仅认 map_id=65 且属于该实体。"""
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).all()
primary = next((m for m in maps if m.id == cal.PRIMARY_MAP_ID), None)
return primary, maps
def _objective_rows(db: Session, maps):
"""拉取给定地图的战略目标,并逐行标记 23-KPI 挂接 / 是否有 KPI。"""
map_ids = [m.id for m in maps]
if not map_ids:
return [], {}
title_by_map = {m.id: m.title for m in maps}
status_by_map = {m.id: (m.status or "") for m in maps}
objs = (
db.query(MapObjective)
.filter(MapObjective.map_id.in_(map_ids))
.order_by(MapObjective.map_id, MapObjective.sort_order, MapObjective.id)
.all()
)
rows = []
for o in objs:
kpis = o.kpis if isinstance(o.kpis, list) else []
rows.append({
"objective_id": o.id,
"map_id": o.map_id,
"map_title": title_by_map.get(o.map_id),
"map_status": status_by_map.get(o.map_id),
"layer": o.dimension_key,
"layer_label": cal.LAYER_LABELS.get(o.dimension_key, o.dimension_key),
"name": o.name,
"kpis": kpis,
"has_kpi": len(kpis) > 0,
"covered_23kpi": o.id in cal.LINKED_OBJECTIVES,
})
return rows, title_by_map
@router.get("/summary")
def completeness_summary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""数据完备度总览:目标级(主数 + 附注)与 KPI 级(117 行)两层分列。"""
primary_map, maps = _entity_scopes(db, entity_id)
rows, _ = _objective_rows(db, maps)
# ---- 目标级 · 主口径:仅地图 65,判据 = 被 23-KPI 集挂接 ----
if primary_map is not None:
main_rows = [r for r in rows if r["map_id"] == cal.PRIMARY_MAP_ID]
main_total = len(main_rows)
main_covered = sum(1 for r in main_rows if r["covered_23kpi"])
primary = {
"available": True,
"caliber": cal.PRIMARY_CALIBER,
"label": cal.PRIMARY_LABEL,
"map_id": primary_map.id,
"map_title": primary_map.title,
"map_status": primary_map.status,
"covered": main_covered,
"total": main_total,
"pct": _pct(main_covered, main_total),
"display": f"{main_covered}/{main_total}",
}
else:
primary = {
"available": False,
"caliber": cal.PRIMARY_CALIBER,
"label": cal.PRIMARY_LABEL,
"map_id": cal.PRIMARY_MAP_ID,
"covered": None,
"total": None,
"pct": None,
"display": "—",
}
# ---- 目标级 · 附注口径:全量 4 图,判据 = 有 KPI 挂接 ----
# 判据复现(definition 件 §5 机读件 20261018-c1-spec/map-objectives-dump.csv 逐行):
# 23KPI 覆盖 26 + 非23KPI 但 map_objectives.kpis 非空 4 + 缺口 1 = 30/31。
# 即「被 23-KPI 集挂接」与「kpis 非空」取并集;与主口径判据(仅 23-KPI 挂接)不是同一判据。
# 附注与主数同属地图 65 的账套(全量 4 图);无地图 65 的账套不适用,展示「—」而非 0。
footnote_total = len(rows)
footnote_covered = sum(1 for r in rows if r["covered_23kpi"] or r["has_kpi"])
footnote_available = primary_map is not None and bool(rows)
footnote = {
"available": footnote_available,
"caliber": cal.FOOTNOTE_CALIBER,
"label": cal.FOOTNOTE_LABEL,
"covered": footnote_covered if footnote_available else None,
"total": footnote_total if footnote_available else None,
"pct": _pct(footnote_covered, footnote_total) if footnote_available else None,
"display": f"{footnote_covered}/{footnote_total}" if footnote_available else "—",
}
# ---- KPI 级:active 按行(e1=73 / e2=44,合计 117;去重编码 102 另册)----
kpi_rows = (
db.query(KPIDefinition)
.filter(KPIDefinition.status == "active")
.all()
)
by_entity: dict = {}
codes = set()
for k in kpi_rows:
by_entity[k.entity_id] = by_entity.get(k.entity_id, 0) + 1
codes.add(k.kpi_code)
kpi_level = {
"caliber": cal.KPI_CALIBER,
"label": cal.KPI_LABEL,
"total": len(kpi_rows),
"by_entity": [
{"entity_id": eid, "count": cnt} for eid, cnt in sorted(by_entity.items())
],
"distinct_codes": len(codes),
"note": "按 kpi_code 去重为独立口径,不与行数相除或换算",
}
return {
"caliber_id": cal.CALIBER_ID,
"caliber_version": cal.CALIBER_VERSION,
"entity_id": entity_id,
"applicable": primary_map is not None,
"scope_note": "主口径仅覆盖地图 65(已发布);附注口径含归档图,判据不同,不得互换算",
"objective_level": {"primary": primary, "footnote": footnote},
"kpi_level": kpi_level,
}
@router.get("/objectives")
def completeness_objectives(
map_id: Optional[int] = None,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""数据完备度清单(目标级逐行):供空态清单与补齐排期使用。"""
_, maps = _entity_scopes(db, entity_id)
if map_id is not None:
maps = [m for m in maps if m.id == map_id]
rows, _ = _objective_rows(db, maps)
return {
"caliber_id": cal.CALIBER_ID,
"caliber_version": cal.CALIBER_VERSION,
"entity_id": entity_id,
"caliber": cal.FOOTNOTE_CALIBER,
"rows": rows,
}
+600
View File
@@ -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)
+238
View File
@@ -0,0 +1,238 @@
"""战略地图 · KPI 摘要接口(真数据 · 口径 CMA-A1 终稿,2026-10-19)
口径唯一真源:app/data/revenue_caliber.py(含科目映射与禁写清单)
本模块**不存任何金额常量**:全部对 voucher_details 现场聚合。
取数铁律:
1. 必经 `summary NOT LIKE '%结转%'` —— 否则损益双向抵销,收入恒为 0(历史踩坑);
2. 实体隔离:只取 token 绑定的 entity_id,不跨账套;
3. 无数据 ⇒ null + missing_reason,**不得兜底造数**;
4. 分业务线分列;禁净额;禁合并成单一「营业收入」(合计仅作可复算校验位)。
历史期缺同票折扣发票(四件套 0/4)⇒ 金额带 `data_status="estimated"` +
`data_tag="测算/待票"`,不得静默按新式重算冒充实账。
"""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth
from app.data import revenue_caliber as rc
from app.database import get_db
from app.deps import get_entity_id
router = APIRouter(
prefix="/api/cma/maps/summary",
tags=["战略地图摘要"],
dependencies=[Depends(require_auth)],
)
MONTH_RE = "^[0-9]{4}-[0-9]{2}$"
NOT_CARRY = "summary NOT LIKE '%结转%'"
def _wan(v) -> Optional[float]:
"""元 → 万元(2 位),None 透传。"""
if v is None:
return None
return round(float(v) / 10000.0, 2)
def _period_window(db: Session, entity_id: int):
"""取该实体最新有凭证的会计月,窗口 = 当年 01 ~ 最新月(不写死日期)。"""
latest = db.execute(text(
f"SELECT MAX(period) FROM voucher_details "
f"WHERE entity_id = :e AND period REGEXP :re AND {NOT_CARRY}"
), {"e": entity_id, "re": MONTH_RE}).scalar()
if not latest:
return None, None, None
year = str(latest)[:4]
return f"{year}-01", str(latest), year
def _sum(db: Session, sql: str, params: dict):
return db.execute(text(sql), params).scalar()
@router.get("")
def map_summary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""摘要条数据:分业务线营业收入 / 毛利率(新式)/ 管理指标 / 口径切换标识。"""
cfg = rc.SUBJECT_MAP.get(entity_id)
p0, p1, year = _period_window(db, entity_id)
period_label = f"{p0}~{p1}" if p0 else None
if cfg is None or p0 is None:
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"lines": [],
"missing_reason": "该账套无可用凭证期" if cfg else "未配置科目映射",
},
"gross_margin": {"available": False, "value": None, "missing_reason": "无数据"},
"management": [],
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
}
base = (f"entity_id = :e AND period >= :p0 AND period <= :p1 AND {NOT_CARRY}")
params = {"e": entity_id, "p0": p0, "p1": p1}
wine = rc.wine_sql("summary")
rev_prefix = cfg["revenue"] + "%"
# ── 1. 收入:主营科目按业务线分列(全额售价,禁净额)──────────────────
revenue = {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"period": period_label,
"lines": [],
}
if cfg["lines"]:
# 表 1:酒类线(keywords 命中)
wine_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": rev_prefix})
# 表 2:非酒类线(同一科目、不命中 keywords)
other_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND NOT {wine}
""", {**params, "pfx": rev_prefix})
# e1 => [酒类, 其他];e2 => [自营(IT)=非酒类, 代销(酒类)=酒类]
values = {}
if entity_id == 1:
values = {"liquor": wine_net, "other": other_net}
elif entity_id == 2:
values = {"self_it": other_net, "agency_liquor": wine_net}
lines = []
for ln in cfg["lines"]:
raw = values.get(ln["key"])
lines.append({
"key": ln["key"],
"label": ln["label"],
"amount": _wan(raw),
"unit": "万元",
"data_status": "verified" if raw is not None else "missing",
"missing_reason": None if raw is not None else "该业务线本期无入账凭证",
})
total = _wan((wine_net or 0) + (other_net or 0)) if (wine_net is not None or other_net is not None) else None
revenue = {
**revenue,
"available": any(l["amount"] is not None for l in lines),
"lines": lines,
"total": total,
"total_label": "合计(各线相加,仅作复算校验)",
"merge_forbidden": True,
"note": "各业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
# ── 2. 毛利率:新式 (收入净额 − 折扣后净进价成本) / 收入净额 ────────────
gross_margin = {"available": False, "value": None, "missing_reason": "无数据"}
rev_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": rev_prefix})
cost_total = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["cost"] + "%"})
disc_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
if rev_total and float(rev_total) != 0 and cost_total is not None:
net_cost = float(cost_total) - float(disc_total or 0)
gm = (float(rev_total) - net_cost) / float(rev_total) * 100.0
gross_margin = {
"available": True,
"value": round(gm, 2),
"unit": "%",
"caliber": "net_purchase",
"caliber_label": "新式·折扣后净进价",
"formula": "(收入净额 − 折扣后净进价成本) / 收入净额 × 100",
"period": period_label,
# 四件套 0/4 ⇒ 历史期缺同票折扣发票,只可显示现值并带标识
"data_status": "estimated",
"data_tag": "测算/待票",
"note": "旧式(含 k 的 5001/571103 组合)已作废;历史缺票期不得冒充实账",
}
# ── 3. 管理指标(非会计科目,不进损益、不进摘要条主位)───────────────
management = []
if entity_id == 2:
# 与 A1 回执可复现口径一致:分母取**折让前贷方发生额**(639,944.00),
# 而不是 6603 净额(556,279.15,已抵扣借方)——两者差 28.24% 与 24.55% 之别。
disc = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
base_amt = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE '2202%' AND summary LIKE '%采购收货%'
AND {rc.supplier_sql('summary')}
""", params)
if disc is not None and base_amt and float(base_amt) != 0:
management.append({
"key": "vendor_discount_dependency",
"label": "厂商折扣依赖度",
"value": round(abs(float(disc)) / float(base_amt) * 100.0, 2),
"unit": "%",
"numerator": round(abs(float(disc)), 2),
"numerator_label": "酒类采购折扣额",
"denominator": round(float(base_amt), 2),
"denominator_label": "酒类采购额(含税)",
"period": period_label,
"tag": "管理指标·非会计科目",
"caliber_note": "折扣额 ÷ 酒类采购额(含税);改造单 v4 §3 文字写「÷销售额」,"
"但 28.24% 的复现分母是采购额,按可复现口径披露,分母已单列可复核",
})
elif entity_id == 1 and cfg.get("purchase_gift") and rev_total:
gift = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_gift"] + "%"})
if gift is not None and float(rev_total) != 0:
val = round(abs(float(gift)) / float(rev_total) * 100.0, 2)
management.append({
"key": "vendor_gift_dependency",
"label": "厂商赠酒依赖度",
"value": val,
"unit": "%",
"numerator": round(abs(float(gift)), 2),
"numerator_label": "采购获赠额",
"denominator": round(float(rev_total), 2),
"denominator_label": "营业收入(同期)",
"period": period_label,
"tag": "管理指标·非会计科目",
"abnormal": val > 100,
"caliber_note": "获赠额 ÷ 同期营业收入;>100% 属异常,须数据侧核查(不作损益行)",
})
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": revenue,
"gross_margin": gross_margin,
"management": management,
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
"caliber_source": {
"status": rc.STATUS,
"decided_by": rc.DECIDED_BY,
"reason": rc.REASON,
},
}
+693 -5
View File
@@ -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,
}
+6 -1
View File
@@ -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,
+11 -1
View File
@@ -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 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -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
+28 -13
View File
@@ -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
View File
@@ -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}
+6 -1
View File
@@ -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:
# 无历史数据,按行业标准推荐 # 无历史数据,按行业标准推荐
+17 -1
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
"""口径判据数据包(战略地图数据完备度等,只读常量)。"""
@@ -0,0 +1,73 @@
"""战略地图数据完备度 · 口径定义(唯一真源引用,勿改判据/分母)
口径 ID : CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0
定义件 : /srv/hermes/mailbox/project/pending-tasks/_reports/
caliber-strategy-map-completeness-20261018.md
裁定 : ASK-20261018-15 / 任总 2026-10-18T23:07:11「放行14和15」
本模块只放**判据数据**(23-KPI 挂接关系 + 来源指纹),不存任何汇总数:
覆盖率一律由 API 运行时对库内 `map_objectives` / `kpi_definitions` 现算。
口径变更 = 重新立 ASK 并改本文件 + 定义件,禁止就地改分母。
"""
CALIBER_ID = "CMA-CALIBER-MAP-COMPLETENESS-20261018"
CALIBER_VERSION = "1.0"
# 主口径:仅已发布地图 65(三年战略规划 2027-2029)
PRIMARY_MAP_ID = 65
PRIMARY_CALIBER = "map65_23kpi_cover"
PRIMARY_LABEL = "地图65·已发布·判据=被23KPI集挂接"
FOOTNOTE_CALIBER = "map_all_kpi_attached"
FOOTNOTE_LABEL = "全量4图·含归档图·判据=有KPI挂接"
KPI_CALIBER = "active_kpi_rows"
KPI_LABEL = "KPI 行(active·按行)"
# 来源指纹(定义件 §5,任何人可复现)
# 注:键名为上游件原名(其行数标记属上游历史口径),与本单交付分母无关,不得据此推算分母。
SOURCE_FILES = {
"map-objectives-dump.csv":
"3896963ee7aff0492073f9b61491a10b10f3b131484e1f1006655d2c2f9bda78",
"a2-linkage-23kpi.csv":
"4494313269938663a197fabecaee644a4f4c66c84a02b725527c07e917f63971",
"active-kpi-118.csv":
"e37dcd699a06a18d5de9aa5b543b7a48b6d7d12c3e4c440f21186ea870cda1c1",
}
# 被 23-KPI 集挂接的战略目标(objective_id -> (map_id, name))
# 来源 a2-linkage-23kpi.csv「挂接类型非空」逐行,26 条(全量 4 图)。
LINKED_OBJECTIVES = {
1020: (53, "营收增长"),
1022: (53, "客户拓展"),
1024: (53, "运营效率"),
1025: (53, "数字化能力"),
1021: (53, "成本与现金流"),
1023: (53, "客户亲密"),
20888: (57, "营收目标"),
20889: (57, "建立渠道分级体系"),
21013: (61, "营收目标"),
21014: (61, "保障现金流安全"),
21016: (61, "产品领先"),
22556: (65, "营业收入"),
22562: (65, "高贡献线收入占比"),
22566: (65, "组合决策机制(负贡献四线退出转投)"),
22571: (65, "贡献率思维全员培训"),
22557: (65, "营业利润"),
22563: (65, "重点客户回款率"),
22567: (65, "月度经营闭环"),
22572: (65, "渠道经理「选品+算账」能力认证"),
22558: (65, "边际贡献率 CM"),
22568: (65, "库存去化"),
22573: (65, "CMA 月度经营驾驶舱"),
22559: (65, "现金周转期 CCC"),
22565: (65, "客户集中度"),
22574: (65, "贡献率/CCC/净现金入考核与激励"),
22570: (65, "数据地基(清脏+改瓶+智享通直连)"),
}
# 分层标签(UI 用,与前端 LAYER_CONFIG 一致)
LAYER_LABELS = {
"finance": "财务",
"customer": "客户",
"process": "内部流程",
"learning": "学习成长",
}
+109
View File
@@ -0,0 +1,109 @@
"""战略地图 · 收入 / 毛利 / 管理指标口径(单一事实源,勿在别处写死)
口径来源(引用勿改):
/srv/hermes/mailbox/finance/pending-tasks/_reports/
project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md
改造单 project-20261018-cma-strategy-map-ux-revamp-fullstack 增量 v4
终稿结论(2026-10-19):
- 陕西博海、陕西酣客 **均总额法(主要责任人)**;
- 增值税 = 销售货物 13%(账内 11 张凭证实测,6% 代理服务零证据);
- 上游披露的「累计口径」金额(非年度数)= 冲减采购成本/存货成本(唯一落位,**无「补贴收入/其他收益」行**),
本模块不产生该数、也不允许任何调用点拼出该串(千分位与裸整数两种写法均在禁列);
- F2 毛利率新式 = (收入净额 − 折扣后净进价成本) / 收入净额 × 100;
旧式(含 `k` 的 5001/571103 组合)作废,**不得在本文件或调用点复现**;
- 历史期缺同票折扣发票(四件套 0/4)⇒ 金额只作现值展示并带「测算/待票」标识。
本模块只放**口径与科目映射**,不存金额;金额一律由 API 对库内 voucher_details 现算
(filter: summary NOT LIKE '%结转%',否则损益双向抵销归零)。
"""
# ── 收入口径状态(v4 起为已闭合)──────────────────────────────────────────────
STATUS = "closed"
CALIBER = "total" # total / net / mixed
CALIBER_LABEL = "总额法(会计)· 增值税13%"
DECIDED_BY = "A1 终稿(project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md)"
REASON = "两家均主要责任人 ⇒ 总额法;增值税销售货物 13% 实测,无税会差异"
# ── 按实体的科目映射(各账套科目体系不同:酣客 5001/5401,博海 6001/6401)──────
SUBJECT_MAP = {
1: {
"revenue": "5001", # 主营业务收入
"cost": "5401", # 主营业务成本
"purchase_discount": "530111", # 商品折扣(采购)
"purchase_gift": "530103", # 采购获赠(捐赠收益)
"lines": [
{"key": "liquor", "label": "酒类"},
{"key": "other", "label": "其他"},
],
},
2: {
"revenue": "6001", # 主营业务收入
"cost": "6401", # 主营业务成本
"purchase_discount": "6603", # 财务费用-现金折扣(贷方=采购折扣)
"purchase_gift": None,
"lines": [
{"key": "self_it", "label": "自营(IT)"},
{"key": "agency_liquor", "label": "代销(酒类)"},
],
},
}
# 酒类判定(财务 A1 件同一口径):摘要含以下任一关键词 ⇒ 酒类业务线
WINE_KEYWORDS = ("酣客", "酒窖", "酣亲", "秦莉", "牛彦平", "横山")
# 采购来源(博海酒类采购:陕西酣客 / 酣客酒业)
LIQUOR_SUPPLIERS = ("酣客", "酒业")
def wine_sql(column: str = "summary") -> str:
"""SQL 片段:酒类业务线判定(与财务回执同口径,勿改)。"""
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in WINE_KEYWORDS) + ")"
def supplier_sql(column: str = "summary") -> str:
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in LIQUOR_SUPPLIERS) + ")"
# ── 口径切换期标识位(v4 §4:不硬编码日期;生效日以任总拍板 ASK 为准)──────────
CALIBER_SWITCH = {
"has_switch": True,
"label": "口径变更",
"effective_date": None, # 未拍板前恒为 None —— 禁止写死日期
"comparability": "口径变更·不可比",
"note": "同比/达成率/预算执行率在切换期内不可比;生效日以任总拍板(ASK)为准",
}
# ── 缺件披露(v4 §5:界面不得呈现为「已齐」)─────────────────────────────────
MISSING_DOCS = [
"经销/代理协议",
"厂家价格政策",
"逐笔折扣发票+合同审批单",
"上游退货条款",
"发票台账(invoice_check 空)",
]
def business_lines(entity_id: int) -> list:
cfg = SUBJECT_MAP.get(entity_id)
return list(cfg["lines"]) if cfg else []
def snapshot(entity_id: int) -> dict:
"""对外口径快照(无金额;金额见 /api/cma/maps/summary)。"""
return {
"entity_id": entity_id,
"status": STATUS,
"caliber": CALIBER,
"caliber_label": CALIBER_LABEL,
"decided_by": DECIDED_BY,
"reason": REASON,
"allow_single_total": False, # 分业务线分列;不合并成单一「营业收入」
"businesses": [
{"key": b["key"], "label": b["label"], "unit": "万元"}
for b in business_lines(entity_id)
],
"switch": CALIBER_SWITCH,
"missing_docs": MISSING_DOCS,
"note": "分业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
+55 -2
View File
@@ -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 补单)
# #
+695
View File
@@ -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
View File
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv from dotenv import load_dotenv
from app.database import init_db, get_db from app.database import init_db, get_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury 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):
+112 -8
View File
@@ -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"
+17 -2
View File
@@ -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()
) )
+2 -1
View File
@@ -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 []
+7
View File
@@ -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
+59
View File
@@ -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:
+142
View File
@@ -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()
+100
View File
@@ -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
+45 -29
View File
@@ -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,
+49 -12
View File
@@ -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,
+133
View File
@@ -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}
+252
View File
@@ -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大)
}
+72 -21
View File
@@ -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)
+439
View File
@@ -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
+67
View File
@@ -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)
+107
View File
@@ -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
+418
View File
@@ -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
+94
View File
@@ -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
+443
View File
@@ -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)}}
+128
View File
@@ -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) + "/:%"
+228
View File
@@ -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"]}
+25
View File
@@ -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()
+46
View File
@@ -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()
+22 -18
View File
@@ -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 输出完整证据(含将生成的预警清单)")
+92
View File
@@ -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())
+8 -1
View File
@@ -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 = {
+408
View File
@@ -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())
+66
View File
@@ -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
View File
@@ -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)}总计")
+23 -1
View File
@@ -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
+465
View File
@@ -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())
+140 -8
View File
@@ -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)
+637 -75
View File
@@ -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())
+183
View File
@@ -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)
+8 -2
View File
@@ -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())
+209
View File
@@ -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())
+226
View File
@@ -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