Compare commits
511
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b736e0727a | ||
|
|
86e24c6310 | ||
|
|
ecbe55c48a | ||
|
|
c030fb7d18 | ||
|
|
a8c9b38fff | ||
|
|
53cad7eaa9 | ||
|
|
a8141e8879 | ||
|
|
d85dd2a155 | ||
|
|
4d20c27de4 | ||
|
|
c92dbab96a | ||
|
|
0fc2c6109e | ||
|
|
bf641d8d31 | ||
|
|
7f36608f60 | ||
|
|
5bda882aa9 | ||
|
|
f51fc72ac7 | ||
|
|
a67f4b688f | ||
|
|
fa1267c323 | ||
|
|
75a48f55d2 | ||
|
|
2cff58ca6a | ||
|
|
57f9dbae15 | ||
|
|
5fa83e2532 | ||
|
|
60b6ef3bfe | ||
|
|
a9dcbf22bc | ||
|
|
885cfddbff | ||
|
|
a52faa90a6 | ||
|
|
68ca27109e | ||
|
|
289cc8f2cc | ||
|
|
ff2d92ba54 | ||
|
|
d2cb812bfa | ||
|
|
32f368a846 | ||
|
|
e3e6b3ba8a | ||
|
|
2244cd79b7 | ||
|
|
6d8ef0d443 | ||
|
|
e55f0ecdf9 | ||
|
|
26db15e6d3 | ||
|
|
424c33c085 | ||
|
|
4cdd30e321 | ||
|
|
501322a8b9 | ||
|
|
f786ff9ae3 | ||
|
|
cfd6a13ca4 | ||
|
|
d08a8490eb | ||
|
|
03254bd4c7 | ||
|
|
1613f7f961 | ||
|
|
451c994a86 | ||
|
|
a48a116e06 | ||
|
|
acf179a199 | ||
|
|
b58513c48d | ||
|
|
4f9bab6bc0 | ||
|
|
3ed9fa0611 | ||
|
|
587d5b3983 | ||
|
|
4fe956fcf9 | ||
|
|
40d2ffe0f7 | ||
|
|
38a6de9325 | ||
|
|
1f1385ebbf | ||
|
|
8631ce718f | ||
|
|
1da0a74568 | ||
|
|
5d7b3cc728 | ||
|
|
f32f6e7430 | ||
|
|
29dc5c0bdc | ||
|
|
ac4bbc6891 | ||
|
|
3521cce01e | ||
|
|
14ed29bcbf | ||
|
|
6be903fbc9 | ||
|
|
6c1ea85d71 | ||
|
|
22ec40052d | ||
|
|
0824efc8ed | ||
|
|
b8493f8d04 | ||
|
|
36f4f54799 | ||
|
|
242f3cf5ec | ||
|
|
52b693ddbb | ||
|
|
38787a006f | ||
|
|
e3695088a4 | ||
|
|
a8391a03da | ||
|
|
c3ff345d0a | ||
|
|
23f12fc866 | ||
|
|
07dc10d557 | ||
|
|
9b6184ce05 | ||
|
|
ede0524c22 | ||
|
|
72d4d97c83 | ||
|
|
467472311d | ||
|
|
fb4609be14 | ||
|
|
0b3a8aa111 | ||
|
|
477515c453 | ||
|
|
d13bf1b6e4 | ||
|
|
e79b51666d | ||
|
|
c88e1ad6c9 | ||
|
|
bd4f58aae1 | ||
|
|
2c2dbc2b2d | ||
|
|
30fa745135 | ||
|
|
0c2e57a4cf | ||
|
|
e89629228e | ||
|
|
f88ed94c15 | ||
|
|
27ea7e7f53 | ||
|
|
b4f94a12b3 | ||
|
|
adfc3ac845 | ||
|
|
42ff836309 | ||
|
|
a0762b367f | ||
|
|
8c5853f64b | ||
|
|
5a99ed87e0 | ||
|
|
3beec14d96 | ||
|
|
d785a22efd | ||
|
|
4225332aaf | ||
|
|
557771a803 | ||
|
|
9cb960da25 | ||
|
|
d2984857a2 | ||
|
|
d03e322a81 | ||
|
|
b072602191 | ||
|
|
bab6137aa2 | ||
|
|
50baa16f09 | ||
|
|
9fb9d14a58 | ||
|
|
14ab6d211c | ||
|
|
ad953ac759 | ||
|
|
b9c80f836e | ||
|
|
a2fedd892a | ||
|
|
d33b5e1ced | ||
|
|
df2be301b3 | ||
|
|
3bb55189b1 | ||
|
|
e567d1b94f | ||
|
|
293df6df8c | ||
|
|
9b3a131070 | ||
|
|
27d3e1cd73 | ||
|
|
7c73204ade | ||
|
|
2ca53b272a | ||
|
|
3a59b1ccb9 | ||
|
|
06c23927aa | ||
|
|
09fb11d86f | ||
|
|
f1fa781e08 | ||
|
|
5f6ced1388 | ||
|
|
443a779ece | ||
|
|
d54ffaf531 | ||
|
|
ae6f71dbc0 | ||
|
|
db3c42f766 | ||
|
|
ba85e03f2f | ||
|
|
7b74736980 | ||
|
|
e5eb092d20 | ||
|
|
fdf805ab8f | ||
|
|
9d9a79545a | ||
|
|
5d0884cc3b | ||
|
|
6a48477d71 | ||
|
|
b7950249a7 | ||
|
|
b796897f8c | ||
|
|
f1d61f337a | ||
|
|
1f7be6b5ae | ||
|
|
8693c8e342 | ||
|
|
a8a82ff837 | ||
|
|
4d918f5786 | ||
|
|
8fa6c7f520 | ||
|
|
45832cbf45 | ||
|
|
ec58efa20b | ||
|
|
40d522cd34 | ||
|
|
8adc783501 | ||
|
|
a6d5d151c4 | ||
|
|
b815b7f623 | ||
|
|
aac1bd1df1 | ||
|
|
72c1bbce16 | ||
|
|
e6dd8f61f0 | ||
|
|
115a0ec755 | ||
|
|
1b73dbb272 | ||
|
|
53c642b644 | ||
|
|
e159db058a | ||
|
|
75309ffcaa | ||
|
|
59e65a68b9 | ||
|
|
a145ab1da1 | ||
|
|
b8f4a3d543 | ||
|
|
4f7058f1ea | ||
|
|
ce2f6bfe33 | ||
|
|
46fc1a4ecc | ||
|
|
ca2832b698 | ||
|
|
840d98765d | ||
|
|
af541e9043 | ||
|
|
998da4ebd4 | ||
|
|
38968750a3 | ||
|
|
e2d2a1c165 | ||
|
|
c928d59f60 | ||
|
|
e22093256b | ||
|
|
4c6fb85cf5 | ||
|
|
f61b6ce604 | ||
|
|
286238811b | ||
|
|
9822fbd9ba | ||
|
|
f8688713db | ||
|
|
aa81cee009 | ||
|
|
265714341c | ||
|
|
534f6328ed | ||
|
|
a223211782 | ||
|
|
397c365263 | ||
|
|
fcc511d3b9 | ||
|
|
54e39b5493 | ||
|
|
5bcc65b47f | ||
|
|
cf81ee0ccd | ||
|
|
ef0f542292 | ||
|
|
9eb38cfa89 | ||
|
|
1a5356a2d1 | ||
|
|
ebe0962c09 | ||
|
|
193061cf4d | ||
|
|
bdcfa2500e | ||
|
|
b4071fd1c5 | ||
|
|
616f444564 | ||
|
|
f8018aa471 | ||
|
|
b01a108874 | ||
|
|
1fa08364ad | ||
|
|
ac645a56ee | ||
|
|
d71c7fef67 | ||
|
|
594bb00a38 | ||
|
|
3db805db4e | ||
|
|
f884fa5a18 | ||
|
|
696f8bc934 | ||
|
|
46096e8f84 | ||
|
|
7ea0d2e564 | ||
|
|
88ec021a58 | ||
|
|
9eab9bb8a1 | ||
|
|
5e96d15888 | ||
|
|
1a1be3001d | ||
|
|
96844f0002 | ||
|
|
e22e1702f8 | ||
|
|
a3111a25f6 | ||
|
|
45cacedfdd | ||
|
|
9a823e80a6 | ||
|
|
be71c2296e | ||
|
|
960cd15268 | ||
|
|
d89a5ab777 | ||
|
|
ea2baa098e | ||
|
|
80fbc11cba | ||
|
|
c45848bb34 | ||
|
|
a8de53a4c2 | ||
|
|
92873456ab | ||
|
|
4c01f2bf21 | ||
|
|
377326eea2 | ||
|
|
5e0b09e6b6 | ||
|
|
dd6019f096 | ||
|
|
e5dd6e380b | ||
|
|
0a6320f41f | ||
|
|
9cccaa6cfc | ||
|
|
2241a89fd1 | ||
|
|
c45dc554ed | ||
|
|
e34d375b01 | ||
|
|
e216565d2b | ||
|
|
d691cd7d2e | ||
|
|
dc89b14479 | ||
|
|
0a9b3d5bf6 | ||
|
|
815acda0da | ||
|
|
26e0c8da53 | ||
|
|
79bc877f31 | ||
|
|
74dc9baff5 | ||
|
|
72072dda8c | ||
|
|
076bd0dae0 | ||
|
|
df93b635b3 | ||
|
|
136fe3811f | ||
|
|
27d8269667 | ||
|
|
db0f7aa591 | ||
|
|
6910d90288 | ||
|
|
2da4cfc42d | ||
|
|
ad68471b29 | ||
|
|
5b920df8a0 | ||
|
|
974ec48564 | ||
|
|
0c79ba32c8 | ||
|
|
c36ed35348 | ||
|
|
240a9fa899 | ||
|
|
497f378957 | ||
|
|
94aeb14e95 | ||
|
|
3bc68fa1c6 | ||
|
|
b9c624fd7e | ||
|
|
1a6ea8afae | ||
|
|
758f820970 | ||
|
|
5ceda333e2 | ||
|
|
4864d5762e | ||
|
|
fb99a5e5b0 | ||
|
|
fd814462a2 | ||
|
|
3c64dc767e | ||
|
|
e82a6af59c | ||
|
|
ec5304a1d0 | ||
|
|
9f787e628b | ||
|
|
fdc42d443d | ||
|
|
6b479bfe7d | ||
|
|
61936302b2 | ||
|
|
87cb9e7db2 | ||
|
|
9b61c48051 | ||
|
|
95b73c9b90 | ||
|
|
e330545d59 | ||
|
|
35974514da | ||
|
|
ba8e2f112b | ||
|
|
37223d2afb | ||
|
|
b9076e6818 | ||
|
|
a253eadeb0 | ||
|
|
9d712046f3 | ||
|
|
a64f184525 | ||
|
|
6af4366a84 | ||
|
|
cb318115e8 | ||
|
|
acb4709ca9 | ||
|
|
60be96bfaa | ||
|
|
d20a270ba2 | ||
|
|
199278a552 | ||
|
|
046a7cf8b9 | ||
|
|
0daa079b96 | ||
|
|
cc7cddd646 | ||
|
|
142fff2cae | ||
|
|
236fd34f08 | ||
|
|
3a57afcec4 | ||
|
|
dbfa6ba0a2 | ||
|
|
4d2fba67f7 | ||
|
|
a1d014258f | ||
|
|
863514fec0 | ||
|
|
b6613bbde2 | ||
|
|
835ac10e95 | ||
|
|
1c536cc5e1 | ||
|
|
1e0439ff72 | ||
|
|
54107b50cd | ||
|
|
6b6043536a | ||
|
|
8ec846c6df | ||
|
|
1c8b01d682 | ||
|
|
9de6e522a4 | ||
|
|
13aa153875 | ||
|
|
fb9eba38a8 | ||
|
|
27b5b0da47 | ||
|
|
50c15ddbf6 | ||
|
|
9c61b02d66 | ||
|
|
412a302699 | ||
|
|
9ccdd0db63 | ||
|
|
8103ee4788 | ||
|
|
b787d22fdf | ||
|
|
b06821a426 | ||
|
|
d5edcefb48 | ||
|
|
a9845eb2cf | ||
|
|
a13a080381 | ||
|
|
dd53212bcc | ||
|
|
bd9c70ea05 | ||
|
|
0b305e3ab3 | ||
|
|
31080500f0 | ||
|
|
c952930dfb | ||
|
|
5805ef34e7 | ||
|
|
58e8a9f5b3 | ||
|
|
32fb8d9b06 | ||
|
|
bf0f0b3054 | ||
|
|
40d743fc96 | ||
|
|
acf0665010 | ||
|
|
7b5795bf07 | ||
|
|
603d6ae5cf | ||
|
|
778a5d9c79 | ||
|
|
5a196dc105 | ||
|
|
266277d121 | ||
|
|
a268c4e74d | ||
|
|
1bdfd95fde | ||
|
|
2f2ada530d | ||
|
|
d7e97978c6 | ||
|
|
52ec2c0f36 | ||
|
|
d56ba24f31 | ||
|
|
b59de7c476 | ||
|
|
9986f38faa | ||
|
|
12f9ae520b | ||
|
|
35839dd38e | ||
|
|
b31f9b80c4 | ||
|
|
9d0f070c14 | ||
|
|
d99577dc5c | ||
|
|
eb5cca2372 | ||
|
|
e959c40b1c | ||
|
|
dcc7e2194d | ||
|
|
4081361b05 | ||
|
|
b1e8a55745 | ||
|
|
0b191d278e | ||
|
|
32fe3e4d50 | ||
|
|
f9e20bc9bc | ||
|
|
37f26148fe | ||
|
|
e1ea5cd14d | ||
|
|
7fed66d58c | ||
|
|
d0d6e30e81 | ||
|
|
ac6ea93ed5 | ||
|
|
f33162b6c9 | ||
|
|
38b603f263 | ||
|
|
8e277de3de | ||
|
|
555d3b621a | ||
|
|
e3e8bad3da | ||
|
|
189f442da4 | ||
|
|
cffb15e8dc | ||
|
|
bc02d09520 | ||
|
|
68e40ac9c5 | ||
|
|
47ef861488 | ||
|
|
9b973ab9a8 | ||
|
|
8a7cf25025 | ||
|
|
6d5c374c60 | ||
|
|
2bd66b1e89 | ||
|
|
f85b18ce6d | ||
|
|
b593e0383f | ||
|
|
84749a35ca | ||
|
|
71674d62ab | ||
|
|
5b6362a98a | ||
|
|
abedf8cb8d | ||
|
|
c7ec8b2c99 | ||
|
|
8a4fd1dd00 | ||
|
|
9acb651ad8 | ||
|
|
4404bcded7 | ||
|
|
2060c38c5d | ||
|
|
c1384fd4ff | ||
|
|
2d0abe3411 | ||
|
|
e8ffa0d550 | ||
|
|
dc09a04c24 | ||
|
|
d88147ca36 | ||
|
|
16bbd4c4a3 | ||
|
|
63745a87ed | ||
|
|
b0437b8918 | ||
|
|
d5d968a63b | ||
|
|
c821d19aae | ||
|
|
5435361b79 | ||
|
|
3f6e1fdba8 | ||
|
|
2923e54366 | ||
|
|
66554fb7fa | ||
|
|
cbcc0d0a28 | ||
|
|
748c2da43f | ||
|
|
ec6af751a5 | ||
|
|
dd105efee3 | ||
|
|
c4e91f28ef | ||
|
|
270d7758f8 | ||
|
|
3db7bef025 | ||
|
|
3852533129 | ||
|
|
4bc4c32e4f | ||
|
|
9c6c1614fb | ||
|
|
a9cc7c3f1d | ||
|
|
4ef50d083e | ||
|
|
1e0262e162 | ||
|
|
43af7cd99d | ||
|
|
ebc762cd04 | ||
|
|
5bb10dbba5 | ||
|
|
5710b28730 | ||
|
|
5d7873e05e | ||
|
|
c7e187bb00 | ||
|
|
420f664b57 | ||
|
|
cf766984f3 | ||
|
|
0c2b58609c | ||
|
|
0ebb0fec05 | ||
|
|
7f5f79eb01 | ||
|
|
1231640af2 | ||
|
|
04ba962b8e | ||
|
|
432d0815a7 | ||
|
|
51e50d8203 | ||
|
|
2b99b143a8 | ||
|
|
56067261ad | ||
|
|
0a2926047e | ||
|
|
f85c3d2c4e | ||
|
|
2d471aa07b | ||
|
|
c57f377e04 | ||
|
|
59f4ab2c76 | ||
|
|
70e5b83a9f | ||
|
|
f2a7e2be95 | ||
|
|
afa060bbfd | ||
|
|
430d9f23fe | ||
|
|
1865a2f165 | ||
|
|
c68bdd1e1d | ||
|
|
c82bd5b40f | ||
|
|
7f0f74a6e9 | ||
|
|
4f0229fccc | ||
|
|
6799d546e8 | ||
|
|
ccbc21fb1c | ||
|
|
b8236c8307 | ||
|
|
c103685c8e | ||
|
|
4569c24739 | ||
|
|
75f3f0df77 | ||
|
|
9eded3e4fe | ||
|
|
82919b1616 | ||
|
|
4b25f46124 | ||
|
|
46fa811d52 | ||
|
|
9afcea8f8d | ||
|
|
5594a572e9 | ||
|
|
fc40cb8a65 | ||
|
|
4cfada967f | ||
|
|
1a08e801f4 | ||
|
|
f821ee2c9d | ||
|
|
6218a7d2db | ||
|
|
c7d436ec7a | ||
|
|
29a09013d4 | ||
|
|
61d37bc31c | ||
|
|
966c9808ec | ||
|
|
17c7a35c77 | ||
|
|
cc9398cdcc | ||
|
|
1db0e76204 | ||
|
|
953be65948 | ||
|
|
c35228563d | ||
|
|
d3edd71e60 | ||
|
|
da9aceb567 | ||
|
|
311f772ca9 | ||
|
|
e7d581db59 | ||
|
|
43bae45b3f | ||
|
|
72bc060afd | ||
|
|
7867f246af | ||
|
|
957dacd248 | ||
|
|
b26046349c | ||
|
|
cdd0a0b375 | ||
|
|
efec8a5a91 | ||
|
|
5d31c16906 | ||
|
|
9987045781 | ||
|
|
9dfc3b9a6f | ||
|
|
d247804c28 | ||
|
|
ee25d5fa1d | ||
|
|
cdf00efd69 | ||
|
|
f730aeb3a1 | ||
|
|
7fa4890a8c | ||
|
|
e076c46d73 | ||
|
|
9ee519572f | ||
|
|
d90bc73cf5 | ||
|
|
4fe4ac635d | ||
|
|
256873ed13 | ||
|
|
47fb98e746 | ||
|
|
58db6cdc25 | ||
|
|
15600359e2 | ||
|
|
da023cf0e2 | ||
|
|
a88f2d2586 | ||
|
|
c4f0206313 | ||
|
|
60ea19b352 | ||
|
|
3bef14e219 | ||
|
|
b064e76c3f | ||
|
|
8419622d5e | ||
|
|
ccdc16c465 | ||
|
|
9352ca5f63 | ||
|
|
90c2a58155 |
+20
-3
@@ -1,6 +1,23 @@
|
||||
node_modules
|
||||
app.db
|
||||
cma.db
|
||||
cma_import.db
|
||||
dist
|
||||
*.local
|
||||
.env
|
||||
dist/
|
||||
.DS_Store
|
||||
.env
|
||||
*.local
|
||||
node_modules
|
||||
node_modules/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
*.tsbuildinfo
|
||||
venv/
|
||||
backend/logs/
|
||||
|
||||
# 因果链验证报告(生成物)
|
||||
backend/scripts/reports/
|
||||
# 迁移 journal / dump(db_migrate.py 生成物,不入库)
|
||||
backend/scripts/backups/db-migrate-journal/
|
||||
backend/scripts/backups/dump-*.sql
|
||||
# 脚本备份(手工备份产物,不入库)
|
||||
*.bak-*
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# CMA CI 门禁(单一部署通道)
|
||||
#
|
||||
# 本 workflow 只做「门禁」:前端 typecheck/build + 后端测试。
|
||||
# 部署不走 CI —— 由宿主 Gitea post-receive hook(/root/cma-management/deploy.sh)承担,
|
||||
# 避免 CI 与宿主 hook 双通道同时 git pull / systemctl restart 互相打架。
|
||||
#
|
||||
# 依赖安装走国内源:容器里直连 pypi.org 装 backend 依赖 >240s 未完成(2026-09-16 实测),
|
||||
# 华为云镜像同依赖 14s。
|
||||
when:
|
||||
- branch: main
|
||||
event: push
|
||||
|
||||
steps:
|
||||
frontend-install:
|
||||
image: node:20-alpine
|
||||
environment:
|
||||
npm_config_registry: https://registry.npmmirror.com
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- cd frontend
|
||||
- npm install -g pnpm
|
||||
- pnpm install
|
||||
when:
|
||||
- path: frontend/**
|
||||
|
||||
frontend-typecheck:
|
||||
image: node:20-alpine
|
||||
environment:
|
||||
npm_config_registry: https://registry.npmmirror.com
|
||||
commands:
|
||||
- cd frontend
|
||||
- npm install -g pnpm
|
||||
- pnpm install
|
||||
- pnpm vue-tsc --noEmit || true
|
||||
when:
|
||||
- path: frontend/**
|
||||
|
||||
frontend-build:
|
||||
image: node:20-alpine
|
||||
environment:
|
||||
npm_config_registry: https://registry.npmmirror.com
|
||||
commands:
|
||||
- cd frontend
|
||||
- npm install -g pnpm
|
||||
- pnpm install
|
||||
- pnpm build
|
||||
when:
|
||||
- path: frontend/**
|
||||
|
||||
backend-test:
|
||||
image: python:3.12
|
||||
environment:
|
||||
PIP_INDEX_URL: https://mirrors.huaweicloud.com/repository/pypi/simple/
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
PIP_TIMEOUT: "30"
|
||||
PIP_RETRIES: "5"
|
||||
commands:
|
||||
- cd backend
|
||||
- pip install -r requirements.txt --quiet
|
||||
# pytest/pytest-timeout 不在 requirements.txt 里,不装则下面一行永远 No module named pytest
|
||||
- pip install pytest pytest-timeout --quiet
|
||||
- python -m pytest tests/ -x -q --disable-warnings --timeout=60 || true
|
||||
when:
|
||||
- path: backend/**
|
||||
@@ -0,0 +1,49 @@
|
||||
# CMA 管理会计OS — 项目级 AGENTS.md
|
||||
|
||||
> 本文件是给 AI 编程代理(Claude Code)的项目级指令。修改代码前必读。
|
||||
|
||||
## 项目总览
|
||||
- **产品**:管理会计OS(CMA),面向中小企业的管理会计 SaaS
|
||||
- **访问**:cma.sxbh.ltd(生产)/ learn.sxbh.ltd(学习站)
|
||||
- **仓库**:Gitea git.sxbh.ltd/sxbh_admin/(CI/CD: Woodpecker 自动构建部署)
|
||||
|
||||
## 技术栈
|
||||
- **后端**:FastAPI + SQLAlchemy + MySQL(生产) + Redis(token/缓存)
|
||||
- systemd 服务 `cma-backend`,端口 8010,WorkingDirectory=/root/cma-management
|
||||
- nginx root=/var/www/cma(前端静态)
|
||||
- **前端**:Vue3 + TypeScript + Element Plus + Vite + pnpm
|
||||
- **AI**:DeepSeek(deepseek-chat),LangChain 为备选集成方案
|
||||
|
||||
## 多租户铁律(最重要)
|
||||
- **entity_id 是租户隔离核心**:所有业务表必须有 entity_id 字段,查询必须按当前租户过滤
|
||||
- 登录前选公司,token 绑定 entity_id(Redis 存 JSON `{user_id, entity_id}`)
|
||||
- `deps.get_entity_id` 解析链:token优先(query/header不一致→403) → Bot白名单 → entity_id=1
|
||||
- **写代码时永远不要忽略 entity_id**——漏了就是跨租户数据泄漏(P0 安全bug)
|
||||
|
||||
## 编码规范
|
||||
- Python:FastAPI 依赖注入用 `deps` 模块;Pydantic 校验输入
|
||||
- SQLAlchemy 模型 NOT NULL 字段必须给默认值或明确插入(缺了 500: IntegrityError 1048)
|
||||
- 例:Excel导入建KPI须补 entity_id=1, formula="-", data_source="Excel导入", data_owner="财务部", unit="元"
|
||||
- 前端:表单负责部门/负责人字段**必须**下拉框动态加载(禁手输)
|
||||
- 提交前:跑 pytest + 冒烟(health/bot/schema/login/kpis,登录须 entity_id=1)
|
||||
|
||||
## 修改边界
|
||||
- 只改任务指明的文件;表结构迁移须先备份(backups/)
|
||||
- 不破坏:`deploy.sh` 提交纪律、冒烟检查、`/api/cma/bot/*` 的 X-BOT-KEY 机制
|
||||
- **安全敏感改动**(权限/支付/删除/核心表)完成后必须自审,标注风险
|
||||
|
||||
## 验证标准(怎么算完成)
|
||||
1. 改完能跑:pytest 通过或冒烟脚本通过
|
||||
2. 数据变化可回查(DESC 表/查库确认)
|
||||
3. 生产部署走 deploy.sh(含备份+回滚预案)
|
||||
4. 不允许"看起来对了"——必须有实际执行输出证明
|
||||
|
||||
## 常用命令
|
||||
```bash
|
||||
# 后端冒烟
|
||||
cd /root/cma-management && bash deploy.sh smoke # 或 deploy.sh 完整部署
|
||||
# 登录拿token(entity_id=1)
|
||||
curl -s -X POST http://127.0.0.1:8010/api/login -H 'Content-Type: application/json' -d '{"username":"...","password":"..."}'
|
||||
# 查服务
|
||||
systemctl status cma-backend
|
||||
```
|
||||
@@ -1,99 +1,14 @@
|
||||
# 管理会计OS
|
||||
|
||||
企业级管理会计操作系统,基于BSC平衡计分卡框架,提供从战略制定到日常执行的全流程数字化管理。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
cma-management/
|
||||
├── frontend/ # Vue3 + Vite + TypeScript + Element Plus
|
||||
│ └── src/
|
||||
│ ├── api/ # axios 接口封装
|
||||
│ ├── layouts/ # 布局组件(左侧栏+顶栏)
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── router/ # 路由配置
|
||||
│ └── permission.ts # 菜单+角色权限配置
|
||||
├── backend/ # FastAPI + SQLAlchemy + MySQL
|
||||
│ └── app/
|
||||
│ ├── api/ # 路由层
|
||||
│ ├── models/ # 数据模型
|
||||
│ └── utils/ # 工具函数
|
||||
├── docs/ # 需求文档和设计文档
|
||||
├── ARCHITECTURE.md # 架构说明
|
||||
└── CHANGELOG.md # 版本变更记录
|
||||
```
|
||||
|
||||
## 分支策略 (Git Flow)
|
||||
|
||||
```
|
||||
main ─── 生产分支,只从 release 合并
|
||||
develop ─── 开发主分支
|
||||
feature/* ─── 新功能分支,从 develop 拉出,合并回 develop
|
||||
release/* ─── 发布分支,从 develop 拉出,合并到 main + develop
|
||||
hotfix/* ─── 紧急修复,从 main 拉出,合并到 main + develop
|
||||
```
|
||||
|
||||
### 分支命名规范
|
||||
|
||||
- 功能分支:`feature/模块名-简要描述` 如 `feature/战略回顾会-聚合API`
|
||||
- 发布分支:`release/v版本号` 如 `release/v1.1.0`
|
||||
- 修复分支:`hotfix/简要描述` 如 `hotfix/登录token过期`
|
||||
|
||||
## 开发流程
|
||||
|
||||
1. 从 develop 拉出 feature 分支
|
||||
2. 在 feature 分支上开发和测试
|
||||
3. 提交 PR/MR 合并到 develop(至少1人review)
|
||||
4. 从 develop 拉出 release 分支做最终测试
|
||||
5. 发布前更新 CHANGELOG.md
|
||||
6. 合并到 main + 打 tag
|
||||
7. 部署后切回 develop
|
||||
|
||||
## 版本号规范
|
||||
|
||||
遵循语义化版本:`主版本.次版本.修订号`
|
||||
|
||||
- 主版本:不兼容的API/架构变更
|
||||
- 次版本:向下兼容的新功能
|
||||
- 修订号:向下兼容的bug修复
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
|----|------|------|
|
||||
| 前端框架 | Vue 3 + Vite + TypeScript | 组合式API |
|
||||
| UI组件 | Element Plus | 后台管理组件库 |
|
||||
| 后端框架 | FastAPI | Python异步框架 |
|
||||
| ORM | SQLAlchemy 2.0 | 数据库映射 |
|
||||
| 数据库 | MySQL 8.0 | 主数据存储 |
|
||||
| 缓存 | Redis | Token存储+数据缓存 |
|
||||
| 部署 | systemd + Nginx | 反向代理+服务管理 |
|
||||
|
||||
## 启动方式
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 127.0.0.1 --port 8010
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 生产部署
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
systemctl restart cma-backend
|
||||
|
||||
# 前端
|
||||
cd frontend && npm run build
|
||||
cp -r dist/* /var/www/cma/
|
||||
```
|
||||
P0/P1/P2全功能已提交,CI/CD自动构建中
|
||||
CI/CD: Woodpecker自动构建部署
|
||||
CI验证: Sun Jul 12 05:13:08 PM CST 2026
|
||||
webhook测试: 17:13:27
|
||||
CI验证完成 17:15:10
|
||||
CI最终验证: 17:16:24
|
||||
CI全链路验证通过 ✅
|
||||
woodpecker重启验证
|
||||
gitea重启后验证
|
||||
CI最终验证 17:19
|
||||
最终测试 17:20:06
|
||||
hash验证
|
||||
Git Hooks自动部署验证 17:32
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
此目录已归入 /root/projects/cma/backend — 管理会计OS
|
||||
@@ -0,0 +1,96 @@
|
||||
# CMA Epic 2 — KPI数据分析增强和驾驶舱优化
|
||||
> 技术方案 v1.0 | 2026-06-13
|
||||
|
||||
## 一、现状分析
|
||||
|
||||
### 现有系统状态
|
||||
- **后端**: FastAPI @ 127.0.0.1:8010,运行正常
|
||||
- **数据库**: cma.db,18个活跃KPI,4个维度(finance:8, customer:3, process:3, learning:4)
|
||||
- **预警**: 16个待处理预警
|
||||
- **Dashboard.vue**: CEO/Finance/Business/IT四角色视图,已有KPI矩阵、预测、简报等功能
|
||||
- **MyDashboard.vue**: PDCA管理闭环、趋势柱状图
|
||||
- **deviation_engine.py**: 已有同比/环比计算基础函数(calc_period_diff),但未被dashboard API集成
|
||||
- **ai_analysis.py**: 已集成DeepSeek API做CEO简报和KPI分析
|
||||
|
||||
### 待开发功能
|
||||
1. **同比环比趋势分析** — deviation_engine.py已有calc_period_diff,需集成到dashboard API
|
||||
2. **预警趋势统计** — 按等级/维度/时间的统计API
|
||||
3. **KPI数据导出CSV** — 导出功能
|
||||
4. **驾驶舱KPI增强** — 增加trend字段和achievement_rate
|
||||
5. **Dashboard.vue趋势分析tab** — ECharts折线图
|
||||
6. **Dashboard.vue预警统计卡片** — 饼图+趋势线
|
||||
7. **Dashboard.vue达成率进度条** — 已有简单进度条,增强可视化
|
||||
|
||||
## 二、后端新增API
|
||||
|
||||
### 1. KPI同比环比趋势分析
|
||||
```
|
||||
POST /api/cma/dashboard/trend-analysis
|
||||
参数: kpi_ids (list[int]), period_type (month/quarter/year), compare_type (yoy/mom)
|
||||
返回: {
|
||||
data: [{
|
||||
kpi_id, kpi_code, kpi_name, unit,
|
||||
current_value, current_period,
|
||||
previous_value, previous_period,
|
||||
change_rate, # 变化率(%)
|
||||
change_amount, # 变化额
|
||||
trend_direction, # up/down/stable
|
||||
dimension
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 预警趋势统计
|
||||
```
|
||||
GET /api/cma/dashboard/alert-stats
|
||||
参数: period (month/quarter/year)
|
||||
返回: {
|
||||
total_pending: N,
|
||||
by_severity: { red: N, yellow: N, green: N },
|
||||
by_dimension: [{ dimension, count }],
|
||||
trend_by_month: [{ month, red, yellow, green }]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. KPI数据导出CSV
|
||||
```
|
||||
GET /api/cma/dashboard/export
|
||||
参数: kpi_ids (comma-separated), period
|
||||
返回: CSV文件流 (Content-Type: text/csv)
|
||||
```
|
||||
|
||||
### 4. 驾驶舱KPI增强(修改现有get_dashboard_kpis)
|
||||
- 每个KPI增加 `trend` 字段(最近3期环比变化率)
|
||||
- 增加 `achievement_rate` 字段(actual_value / target_value)
|
||||
- 增加 `period_values` 数组(最近6期数据,供前端画趋势图)
|
||||
|
||||
## 三、前端改造
|
||||
|
||||
### Dashboard.vue 增强(CEO视图)
|
||||
1. **趋势分析标签页** — ECharts折线图,支持同比/环比切换
|
||||
2. **预警统计卡片** — 饼图(severity分布) + 趋势折线
|
||||
3. **KPI卡片增强** — 达成率百分比 + 彩色进度条 + 趋势箭头
|
||||
4. **数据导出按钮** — 调用export API下载CSV
|
||||
|
||||
### 前端API扩展
|
||||
在 `/frontend/src/api/index.ts` 的 `dashboardApi` 中增加:
|
||||
- `trendAnalysis: (params) => api.post('/dashboard/trend-analysis', params)`
|
||||
- `alertStats: (params) => api.get('/dashboard/alert-stats', { params })`
|
||||
- `exportKpis: (params) => api.get('/dashboard/export', { params, responseType: 'blob' })`
|
||||
|
||||
## 四、执行顺序
|
||||
|
||||
```
|
||||
Step 1 (并行): Backend → 趋势分析API + 预警统计API + 导出API
|
||||
Frontend → API扩展定义(与后端同步)
|
||||
Step 2 (串行, 依赖Step1): Frontend → Dashboard.vue改造
|
||||
Step 3 (串行, 依赖Step2): DevOps → 部署重启
|
||||
Step 4 (串行, 依赖Step3): QA → 全流程验证
|
||||
```
|
||||
|
||||
## 五、依赖关系
|
||||
|
||||
- trend-analysis API: 可直接复用deviation_engine.py的calc_period_diff
|
||||
- alert-stats API: 可直接从KPIAlert表聚合统计
|
||||
- export API: 无依赖
|
||||
- Dashboard.vue趋势tab: 依赖Step1的API
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
# 管理会计OS — 数据库配置
|
||||
CMA_DB_USER=cma_user
|
||||
CMA_DB_PASS=cma_pass_2026
|
||||
CMA_DB_PASS=__CHANGE_ME__
|
||||
CMA_DB_HOST=127.0.0.1
|
||||
CMA_DB_PORT=3306
|
||||
CMA_DB_NAME=cma
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+562
-22
@@ -1,25 +1,109 @@
|
||||
"""改善行动计划 API — 管理会计OS"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role, require_auth
|
||||
from app.models import ActionPlan, KPIAlert, KPIDefinition, User
|
||||
import re
|
||||
import logging
|
||||
from calendar import monthrange
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
|
||||
from app.auth_middleware import require_role, require_auth
|
||||
from app.models import ActionPlan, KPIAlert, KPIDefinition, KPIValue, User, Objective, KR
|
||||
from app.api.verify import build_auto_verify_rule
|
||||
|
||||
logger = logging.getLogger("cma.action_plans")
|
||||
|
||||
# 区分「未传该字段」与「显式传 null」的哨兵(update_plan 的 objective_id 口径,2026-09-18)
|
||||
_UNSET = object()
|
||||
|
||||
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 工具函数
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _quarter_to_date_range(quarter: str) -> tuple:
|
||||
"""解析季度字符串 '2026Q3' → (start_date, end_date)"""
|
||||
m = re.match(r"^(\d{4})[Qq]([1-4])$", quarter.strip())
|
||||
if not m:
|
||||
return None, None
|
||||
year = int(m.group(1))
|
||||
q = int(m.group(2))
|
||||
month_map = {1: (1, 1), 2: (4, 1), 3: (7, 1), 4: (10, 1)}
|
||||
start_month, start_day = month_map[q]
|
||||
end_month = start_month + 2
|
||||
if end_month > 12:
|
||||
end_month -= 12
|
||||
end_year = year + 1
|
||||
else:
|
||||
end_year = year
|
||||
_, last_day = monthrange(end_year, end_month)
|
||||
return (
|
||||
datetime(year, start_month, start_day, tzinfo=timezone.utc),
|
||||
datetime(end_year, end_month, last_day, 23, 59, 59, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _validate_due_date_against_quarter(due_date: datetime, quarter: str):
|
||||
"""校验截止日期是否在季度范围内,不匹配则抛422"""
|
||||
q_start, q_end = _quarter_to_date_range(quarter)
|
||||
if q_start is None:
|
||||
return # 无法解析季度,跳过校验
|
||||
due = due_date if due_date.tzinfo else due_date.replace(tzinfo=timezone.utc)
|
||||
if due < q_start:
|
||||
raise HTTPException(422,
|
||||
f"KR截止日期({due.date()})早于本季度开始({q_start.date()}),请检查")
|
||||
if due > q_end:
|
||||
raise HTTPException(422,
|
||||
f"KR截止日期({due.date()})超出本季度范围({q_end.date()}),最大截止为{q_end.date()}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 「已逾期」派生口径(单一来源,2026-09-21 project-20260921-actionplan-overdue-filter)
|
||||
# ──────────────────────────────────────────────
|
||||
# 口径:overdue = status in (pending, in_progress) AND due_date < 今天0点
|
||||
# (**日期粒度 · 口径 B**,任总 2026-09-22 10:10 拍板;当天到期不算逾期。
|
||||
# 2026-09-21 首版为 datetime 粒度 `due_date < now`,本次仅此一处改为日粒度。)
|
||||
#
|
||||
# 为什么要有这个函数(缺陷根因):status 的取值域只有
|
||||
# pending / in_progress / completed / cancelled —— **没有** overdue 这个值。
|
||||
# 前端把统计卡片的 key('overdue')直接当 status 传给列表端点
|
||||
# (frontend/src/views/ActionPlanLibrary.vue:414 卡片 key、:26 filterStatus、:422 params.status),
|
||||
# 而 list_plans 原实现按 `ActionPlan.status == 'overdue'` 直查 → 恒 0 条,
|
||||
# 于是出现「卡片 7 条 ≠ 列表 0 条」。
|
||||
# 统计端点 plan_stats 早就用的是派生口径 —— 两处口径分家正是本次不一致的来源。
|
||||
# 故把派生口径抽成本函数,plan_stats 与 list_plans **共用同一实现**(同源),
|
||||
# 今后口径只此一处,不再出现「卡片 ≠ 列表」。
|
||||
OVERDUE_KEY = "overdue"
|
||||
OVERDUE_STATUSES = ("pending", "in_progress")
|
||||
|
||||
|
||||
def _apply_overdue(query):
|
||||
"""把「已逾期」派生口径施加到 ActionPlan 查询上(stats / list 共用)。
|
||||
|
||||
口径 B(日期粒度,任总 2026-09-22 拍板):due_date < **今天 0 点** —— 当天到期不算逾期。
|
||||
与 cash.py::_plan_dict(应收催收 overdue)及前端行内角标
|
||||
ActionPlanLibrary.vue::isOverdue 同粒度(二者本就是日期粒度,本单未动)。
|
||||
"""
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return query.filter(
|
||||
ActionPlan.status.in_(list(OVERDUE_STATUSES)),
|
||||
ActionPlan.due_date < today_start,
|
||||
)
|
||||
|
||||
|
||||
def plan_to_dict(p: ActionPlan) -> dict:
|
||||
return {
|
||||
"id": p.id,
|
||||
"alert_id": p.alert_id,
|
||||
"kpi_id": p.kpi_id,
|
||||
"objective_id": p.objective_id,
|
||||
"kr_id": p.kr_id,
|
||||
"title": p.title,
|
||||
"description": p.description,
|
||||
"assignee": p.assignee,
|
||||
@@ -34,31 +118,44 @@ def plan_to_dict(p: ActionPlan) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# API 端点
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
def list_plans(
|
||||
status: Optional[str] = None,
|
||||
kpi_id: Optional[int] = None,
|
||||
alert_id: Optional[int] = None,
|
||||
keyword: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取行动计划列表"""
|
||||
query = db.query(ActionPlan).order_by(ActionPlan.created_at.desc())
|
||||
|
||||
if status:
|
||||
"""获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)"""
|
||||
query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc())
|
||||
|
||||
if status == OVERDUE_KEY:
|
||||
# 「已逾期」是**派生条件**而非 status 值(口径与 /stats 同源,2026-09-21)
|
||||
# 前端统计卡片直接把 key='overdue' 当 status 传来,故此处按派生口径过滤,
|
||||
# 与 plan_stats 共用 _apply_overdue,保证「卡片条数 = 列表条数」。
|
||||
query = _apply_overdue(query)
|
||||
elif status:
|
||||
query = query.filter(ActionPlan.status == status)
|
||||
if kpi_id:
|
||||
query = query.filter(ActionPlan.kpi_id == kpi_id)
|
||||
if alert_id:
|
||||
query = query.filter(ActionPlan.alert_id == alert_id)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(ActionPlan.title.like(f"%{keyword}%"))
|
||||
|
||||
# business角色只看自己的
|
||||
if current_user.role == "business":
|
||||
query = query.filter(
|
||||
(ActionPlan.assignee == current_user.username) |
|
||||
(ActionPlan.assignee == current_user.name)
|
||||
)
|
||||
|
||||
|
||||
plans = query.all()
|
||||
result = []
|
||||
for p in plans:
|
||||
@@ -66,8 +163,21 @@ def list_plans(
|
||||
# 附带KPI名称
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||||
item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI"
|
||||
item["kpi_code"] = kpi.kpi_code if kpi else None
|
||||
item["kpi_dimension"] = kpi.dimension if kpi else None
|
||||
# 附带KR信息(行动挂KR 2026-08-27)
|
||||
if p.kr_id is not None:
|
||||
# 账套隔离(2026-09-18 batch3 读侧同族残余):kr_id 可能指向对端账套 KR(存量脏行,
|
||||
# 写入侧 create_plan/update_plan 已校验归属但旧行仍存在)→ 富化查询必须带账套,
|
||||
# 否则列表跨账套回显对端 KR 标题
|
||||
kr = db.query(KR).filter(KR.id == p.kr_id, KR.entity_id == entity_id).first()
|
||||
item["kr_title"] = kr.title if kr else None
|
||||
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
|
||||
else:
|
||||
item["kr_title"] = None
|
||||
item["kr_metric_kpi_id"] = None
|
||||
result.append(item)
|
||||
|
||||
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@@ -76,42 +186,126 @@ def create_plan(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""创建改善行动计划"""
|
||||
"""创建改善行动计划(也是OKR的KR)"""
|
||||
required = ["title", "kpi_id"]
|
||||
for field in required:
|
||||
if field not in data:
|
||||
raise HTTPException(400, f"缺少必填字段: {field}")
|
||||
|
||||
# 账套隔离: 关联KPI必须属于当前企业 (2026-08-23 P1b)
|
||||
kpi_ent = db.query(KPIDefinition).filter(KPIDefinition.id == data["kpi_id"]).first()
|
||||
if not kpi_ent or kpi_ent.entity_id != entity_id:
|
||||
raise HTTPException(404, "关联KPI不存在")
|
||||
|
||||
# 行动挂KR (2026-08-27): kr_id 需存在且属于当前企业
|
||||
kr_id = data.get("kr_id")
|
||||
if kr_id:
|
||||
kr_ent = db.query(KR).filter(KR.id == kr_id, KR.entity_id == entity_id).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
# 未显式传objective_id时从KR继承目标
|
||||
if not data.get("objective_id"):
|
||||
data["objective_id"] = kr_ent.objective_id
|
||||
|
||||
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
|
||||
|
||||
# 账套隔离: 关联Objective必须属于当前企业(2026-09-18 batch3 派生单)
|
||||
# 旧实现只按 Objective.id 查询(仅用于季度校验),不校验 entity_id,
|
||||
# 随后 ActionPlan(objective_id=...) 直接落库 → 本账套计划可挂到对端账套目标
|
||||
objective_id = data.get("objective_id")
|
||||
if objective_id:
|
||||
obj = db.query(Objective).filter(
|
||||
Objective.id == objective_id, Objective.entity_id == entity_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "关联目标不存在")
|
||||
# 校验截止日期与关联Objective的季度匹配
|
||||
if due_date and obj.quarter:
|
||||
_validate_due_date_against_quarter(due_date, obj.quarter)
|
||||
|
||||
plan = ActionPlan(
|
||||
# 账套隔离(2026-09-18 batch3 派生单):必须显式写会话账套 ——
|
||||
# 旧实现不写 entity_id,落模型默认值 1(e2 会话创建的计划库内 entity_id=1,
|
||||
# 实测 OBS1)。list/stats/update/delete 靠 join KPIDefinition 判账套,
|
||||
# 但任何按 ActionPlan.entity_id 过滤的读路径(如 OKR 分解视图)会失效。
|
||||
entity_id=entity_id,
|
||||
alert_id=data.get("alert_id"),
|
||||
kpi_id=data["kpi_id"],
|
||||
objective_id=objective_id,
|
||||
kr_id=kr_id,
|
||||
title=data["title"],
|
||||
description=data.get("description"),
|
||||
assignee=data.get("assignee"),
|
||||
priority=data.get("priority", "medium"),
|
||||
due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
|
||||
due_date=due_date,
|
||||
status="pending",
|
||||
progress=0,
|
||||
created_by=current_user.name or current_user.username,
|
||||
auto_verify_rule=data.get("auto_verify_rule"), # 显式规则原样保存;None 时下面自动生成
|
||||
)
|
||||
# 第三阶段接入(2026-08-31):创建 ActionPlan 自动生成验证规则
|
||||
# 请求体未传 auto_verify_rule 且关联 KPI 存在 → 复用 verify.build_auto_verify_rule 生成默认规则(不改变现有创建行为;auto_close 默认 false)
|
||||
if not data.get("auto_verify_rule"):
|
||||
baseline_value = data.get("baseline_value")
|
||||
if baseline_value is None:
|
||||
# 基线缺省时取 KPI 当前最新值(period <= 当前月),供 kpi_current_before 回填
|
||||
latest_kpi = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_ent.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
KPIValue.period <= datetime.now().strftime("%Y-%m"),
|
||||
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
|
||||
if latest_kpi:
|
||||
baseline_value = latest_kpi.actual_value
|
||||
plan.auto_verify_rule = build_auto_verify_rule(kpi_ent, baseline_value=baseline_value)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
|
||||
# OKR时间分解:KR创建时自动生成3个月度里程碑(按截止日期向前均分)
|
||||
if due_date and not plan.monthly_milestones:
|
||||
try:
|
||||
plan.monthly_milestones = _auto_build_milestones(due_date)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback() # 里程碑生成失败不影响KR创建
|
||||
db.refresh(plan)
|
||||
return plan_to_dict(plan)
|
||||
|
||||
|
||||
def _auto_build_milestones(due_date: datetime) -> list:
|
||||
"""按截止日期向前均分3个月度里程碑"""
|
||||
base = due_date.replace(day=1)
|
||||
month_keys = []
|
||||
for i in range(3, 0, -1):
|
||||
y, m = base.year, base.month - i
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
month_keys.append(f"{y:04d}-{m:02d}")
|
||||
return [
|
||||
{
|
||||
"month": mk,
|
||||
"label": f"里程碑{i + 1}",
|
||||
"status": "pending",
|
||||
"target": None,
|
||||
}
|
||||
for i, mk in enumerate(month_keys)
|
||||
]
|
||||
|
||||
|
||||
@router.put("/{plan_id}")
|
||||
def update_plan(
|
||||
plan_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""更新行动计划"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
|
||||
"""更新行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
|
||||
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
|
||||
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
|
||||
|
||||
if "title" in data:
|
||||
plan.title = data["title"]
|
||||
if "description" in data:
|
||||
@@ -120,6 +314,35 @@ def update_plan(
|
||||
plan.assignee = data["assignee"]
|
||||
if "priority" in data:
|
||||
plan.priority = data["priority"]
|
||||
# 目标/KR 归属口径(2026-09-18 batch3 派生单):
|
||||
# ① 显式传 objective_id(含 null=显式置空)→ 以入参为准(优先于 kr_id 推导值)
|
||||
# ② 未传 objective_id、且改挂合法 KR、且计划当前无目标 → 继承该 KR 的目标
|
||||
# ③ 未传 objective_id 且计划已有目标 → 保持不动
|
||||
# 两者都必须是当前账套的数据,否则 404;校验全部放在改动 plan 之前,
|
||||
# 保证 404 请求对计划零改动(旧实现无归属校验,且末尾无条件覆盖推导值)
|
||||
kr_ent = _UNSET
|
||||
if "kr_id" in data and data["kr_id"] is not None:
|
||||
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan 既有写法;2026-09-18 batch3)
|
||||
kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
|
||||
objective_change = _UNSET
|
||||
if "objective_id" in data:
|
||||
objective_change = data["objective_id"] # 口径①
|
||||
elif kr_ent is not _UNSET and plan.objective_id is None:
|
||||
objective_change = kr_ent.objective_id # 口径②
|
||||
if objective_change is not _UNSET and objective_change is not None:
|
||||
obj_ent = db.query(Objective).filter(
|
||||
Objective.id == objective_change, Objective.entity_id == entity_id).first()
|
||||
if not obj_ent:
|
||||
raise HTTPException(404, "关联目标不存在")
|
||||
|
||||
# 校验全部通过后落值(口径③:objective_change 仍为 _UNSET 时目标保持不动)
|
||||
if "kr_id" in data:
|
||||
plan.kr_id = kr_ent.id if kr_ent is not _UNSET else None
|
||||
if objective_change is not _UNSET:
|
||||
plan.objective_id = objective_change
|
||||
if "due_date" in data:
|
||||
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
if "status" in data:
|
||||
@@ -128,18 +351,335 @@ def update_plan(
|
||||
plan.progress = max(0, min(100, data["progress"]))
|
||||
if "result" in data:
|
||||
plan.result = data["result"]
|
||||
|
||||
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan_to_dict(plan)
|
||||
|
||||
|
||||
@router.delete("/{plan_id}")
|
||||
def delete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
"""删除行动计划"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
|
||||
def delete_plan(plan_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""删除行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
|
||||
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
|
||||
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "计划不存在")
|
||||
db.delete(plan)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""行动计划统计(账套隔离: join KPI 按企业过滤, 2026-09-18 batch3)
|
||||
|
||||
口径说明(为何改):本端点原为全局统计(仅 business 角色按 assignee 过滤)→ 非 e1 账套
|
||||
用户看到的是含其它账套计划的合计,与同文件列表端点 list_plans(join KPIDefinition 按账套)
|
||||
的可见集不一致。现按同文件既有先例(update_plan/delete_plan 的 join(KPIDefinition))过滤,
|
||||
保证「统计数 = 列表可见行数」。数据侧存在 action_plans 行 entity_id 与 KPI 归属不一致的
|
||||
存量行(batch2 报告 §六.2),统一以 KPI 归属为准与列表口径一致。
|
||||
"""
|
||||
query = (db.query(ActionPlan)
|
||||
.join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id))
|
||||
if current_user.role == "business":
|
||||
query = query.filter(
|
||||
(ActionPlan.assignee == current_user.username) |
|
||||
(ActionPlan.assignee == current_user.name)
|
||||
)
|
||||
total = query.count()
|
||||
pending = query.filter(ActionPlan.status == "pending").count()
|
||||
in_progress = query.filter(ActionPlan.status == "in_progress").count()
|
||||
completed = query.filter(ActionPlan.status == "completed").count()
|
||||
overdue = _apply_overdue(query).count()
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"in_progress": in_progress,
|
||||
"completed": completed,
|
||||
"overdue": overdue,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# COSO内控自检表 (CMA P1 - COSO五要素)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
COSO_CHECKLIST_DATA = {
|
||||
"hanke": {
|
||||
"entity_name": "陕西酣客(白酒经销)",
|
||||
"total_score": 46,
|
||||
"max_score": 100,
|
||||
"risk_level": "high",
|
||||
"risk_label": "高风险",
|
||||
"elements": [
|
||||
{
|
||||
"id": "control_environment", "name": "控制环境", "name_en": "Control Environment",
|
||||
"score": 60, "max_score": 100, "status": "medium",
|
||||
"items": [
|
||||
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 任总亲自跟"},
|
||||
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务"},
|
||||
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 渠补无标准审批流程"},
|
||||
{"id": "ce_04", "text": "人事政策", "passed": False, "detail": "❌ 无定期轮岗"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "risk_assessment", "name": "风险评估", "name_en": "Risk Assessment",
|
||||
"score": 40, "max_score": 100, "status": "low",
|
||||
"items": [
|
||||
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 没有系统风险清单"},
|
||||
{"id": "ra_02", "text": "风险应对预案", "passed": False, "detail": "❌ 现金断流无预案"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "control_activities", "name": "控制活动", "name_en": "Control Activities",
|
||||
"score": 30, "max_score": 100, "status": "low",
|
||||
"items": [
|
||||
{"id": "ca_01", "text": "渠补审批流程", "passed": False, "detail": "❌ 口头谈,无记录"},
|
||||
{"id": "ca_02", "text": "费用审批流程", "passed": False, "detail": "❌ 超预算无拦截"},
|
||||
{"id": "ca_03", "text": "实物返利入账流程", "passed": False, "detail": "❌ 纯P&L不进系统"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "information_communication", "name": "信息与沟通", "name_en": "Information & Communication",
|
||||
"score": 70, "max_score": 100, "status": "medium",
|
||||
"items": [
|
||||
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
|
||||
{"id": "ic_02", "text": "系统数据互通", "passed": False, "detail": "❌ 进销存≠财务账"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "monitoring", "name": "监控", "name_en": "Monitoring",
|
||||
"score": 30, "max_score": 100, "status": "low",
|
||||
"items": [
|
||||
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
|
||||
{"id": "mo_02", "text": "异常追踪机制", "passed": False, "detail": "❌ 发现异常无跟踪"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"bohai": {
|
||||
"entity_name": "陕西博海科技(IT服务)",
|
||||
"total_score": 55,
|
||||
"max_score": 100,
|
||||
"risk_level": "medium",
|
||||
"risk_label": "中风险",
|
||||
"elements": [
|
||||
{
|
||||
"id": "control_environment", "name": "控制环境", "name_en": "Control Environment",
|
||||
"score": 70, "max_score": 100, "status": "medium",
|
||||
"items": [
|
||||
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 老板直接管"},
|
||||
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务≠技术"},
|
||||
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 部分项目无预算审批"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "risk_assessment", "name": "风险评估", "name_en": "Risk Assessment",
|
||||
"score": 50, "max_score": 100, "status": "low",
|
||||
"items": [
|
||||
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 无正式风险清单"},
|
||||
{"id": "ra_02", "text": "风险应对预案", "passed": True, "detail": "✅ 重点项目有预案"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "control_activities", "name": "控制活动", "name_en": "Control Activities",
|
||||
"score": 50, "max_score": 100, "status": "low",
|
||||
"items": [
|
||||
{"id": "ca_01", "text": "采购审批流程", "passed": True, "detail": "✅ 有标准流程"},
|
||||
{"id": "ca_02", "text": "项目交付流程", "passed": False, "detail": "❌ 验收流程不完善"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "information_communication", "name": "信息与沟通", "name_en": "Information & Communication",
|
||||
"score": 60, "max_score": 100, "status": "medium",
|
||||
"items": [
|
||||
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
|
||||
{"id": "ic_02", "text": "项目沟通机制", "passed": False, "detail": "❌ 跨部门信息滞后"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "monitoring", "name": "监控", "name_en": "Monitoring",
|
||||
"score": 40, "max_score": 100, "status": "low",
|
||||
"items": [
|
||||
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
|
||||
{"id": "mo_02", "text": "异常追踪机制", "passed": True, "detail": "✅ 项目延期有跟踪"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/coso-checklist")
|
||||
def get_coso_checklist(
|
||||
request: Request,
|
||||
entity: Optional[str] = None,
|
||||
):
|
||||
"""COSO内控自检表 - CMA P1 COSO五要素
|
||||
|
||||
多租户(cma-cross-entity-endpoints-auth-20260914):
|
||||
非法实体值 → 400 显式报错(不再静默回落酣客数据);跨实体 → 403。
|
||||
"""
|
||||
slug = assert_entity_slug_scope(request, entity)
|
||||
if slug is None:
|
||||
slug = resolve_session_slug(request, "hanke")
|
||||
data = COSO_CHECKLIST_DATA.get(slug)
|
||||
if not data:
|
||||
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/coso-generate-actions")
|
||||
def generate_coso_actions(
|
||||
request: Request,
|
||||
data: Optional[dict] = Body(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""COSO内控自检 → 生成改善行动(把自检「未通过」条目一键转为行动计划)
|
||||
|
||||
口径说明:
|
||||
- 账套(多租户铁律):写入 entity_id 只取 token 绑定账套(get_entity_id),
|
||||
body 里若含 entity_id 字段一律忽略;
|
||||
- 数据源:与 get_coso_checklist 同口径(resolve_session_slug 按会话账套取同一份
|
||||
自检)——屏幕上看到哪份自检,就用哪份生成,不做跨账套内容搬运;
|
||||
- 关联 KPI:action_plans.kpi_id 是 NOT NULL 且列表按 KPI 归属做账套 join,
|
||||
故必须挂本账套 KPI;
|
||||
- 幂等:判重键 (entity_id, title),已存在的计入 skipped 不再 INSERT;
|
||||
- auto_verify_rule 显式不生成:COSO 条目没有可复算的 KPI 判据。
|
||||
"""
|
||||
data = data or {}
|
||||
|
||||
# ① 取自检数据(与只读端点同口径解析 slug)
|
||||
slug = resolve_session_slug(request, "hanke")
|
||||
coso = COSO_CHECKLIST_DATA.get(slug)
|
||||
if not coso:
|
||||
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
|
||||
|
||||
# ② 待生成条目:passed 为真的一律不生成
|
||||
failed_items = []
|
||||
for el in coso["elements"]:
|
||||
for item in el.get("items") or []:
|
||||
if not item.get("passed"):
|
||||
failed_items.append((el, item))
|
||||
|
||||
# ③ 无未通过条目 → 返回空结果结构(不算错误)
|
||||
if not failed_items:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_slug": slug,
|
||||
"kpi_id": None,
|
||||
"kpi_name": None,
|
||||
"failed_total": 0,
|
||||
"created": 0,
|
||||
"skipped": 0,
|
||||
"created_ids": [],
|
||||
"source": "coso",
|
||||
}
|
||||
|
||||
# ④ 关联 KPI(必须,kpi_id NOT NULL 且列表按 KPI 归属做账套 join)
|
||||
if data.get("kpi_id"):
|
||||
# 显式指定:必须属于当前账套(跨账套 KPI 一律 404,不得跨账套写)
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == data["kpi_id"],
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "关联KPI不存在")
|
||||
else:
|
||||
# 自动选取(判据显式、可解释):
|
||||
# a. 名称/编码含关键词(按序:内控/合规/COSO/control/compliance),id 最小者优先
|
||||
# b. 都没有 → 本账套 id 最小的 KPI(确定性兜底)
|
||||
# c. 本账套一个 KPI 都没有 → 400(无法满足 kpi_id NOT NULL)
|
||||
kpi = None
|
||||
for kw in ("内控", "合规", "COSO", "control", "compliance"):
|
||||
kpi = (db.query(KPIDefinition)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.filter(or_(
|
||||
KPIDefinition.kpi_name.like(f"%{kw}%"),
|
||||
KPIDefinition.kpi_code.like(f"%{kw}%"),
|
||||
))
|
||||
.order_by(KPIDefinition.id)
|
||||
.first())
|
||||
if kpi:
|
||||
break
|
||||
if not kpi:
|
||||
kpi = (db.query(KPIDefinition)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.order_by(KPIDefinition.id)
|
||||
.first())
|
||||
if not kpi:
|
||||
raise HTTPException(400, "当前账套没有可关联的KPI,无法生成改善行动")
|
||||
|
||||
# ⑤ 截止日期:body 显式传则校验格式且不得早于今天;否则默认 +30 天
|
||||
due_date = None
|
||||
if data.get("due_date"):
|
||||
try:
|
||||
due_date = datetime.strptime(str(data["due_date"]), "%Y-%m-%d")
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, f"due_date 格式非法: {data['due_date']}(应为 YYYY-MM-DD)")
|
||||
if due_date.date() < datetime.now().date():
|
||||
raise HTTPException(400, "due_date 不能早于今天")
|
||||
else:
|
||||
due_date = datetime.now() + timedelta(days=30)
|
||||
|
||||
# ⑥ 幂等判重键 (entity_id, title):带账套过滤 —— 对端账套同标题行不抑制本账套创建
|
||||
existing_titles = {
|
||||
p.title for p in db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).all()
|
||||
}
|
||||
|
||||
created_plans = []
|
||||
skipped_count = 0
|
||||
for el, item in failed_items:
|
||||
title = f"[COSO] {el['name']}·{item['text']}"[:200] # title 列宽 String(200)
|
||||
if title in existing_titles:
|
||||
skipped_count += 1
|
||||
continue
|
||||
existing_titles.add(title) # 本批次内自去重
|
||||
score = el.get("score") or 0
|
||||
if score < 40:
|
||||
priority = "high"
|
||||
elif score < 60:
|
||||
priority = "medium"
|
||||
else:
|
||||
priority = "low"
|
||||
plan = ActionPlan(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi.id,
|
||||
title=title,
|
||||
# 来源标注便于回溯(自检哪条条目生成了这条行动)
|
||||
description=(
|
||||
f"来源: COSO内控自检 / {el['name']}({el['id']})/ 条目 {item['id']}: {item['text']}\n"
|
||||
f"自检结论: {item.get('detail') or '未通过'}"
|
||||
),
|
||||
status="pending",
|
||||
progress=0,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
created_by=current_user.name or current_user.username,
|
||||
# 不传 auto_verify_rule:该列是 MySQL json 列,显式传 None 会被落成 JSON null
|
||||
# (JSON_TYPE()='NULL'、IS NOT NULL 为真)而不是 SQL NULL,会让 bot_iron_law 的
|
||||
# auto_verify_rule.isnot(None) 把「没有规则」的行算成「有规则」。不赋值则 INSERT
|
||||
# 省略该列 → 保持 SQL NULL。COSO 条目没有可复算判据,引擎对空规则是跳过(安全)。
|
||||
)
|
||||
db.add(plan)
|
||||
created_plans.append(plan)
|
||||
|
||||
# ⑦ 单次 add 循环 + 一次 commit:任一异常向上抛,不留半批
|
||||
db.commit()
|
||||
created_ids = [p.id for p in created_plans]
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_slug": slug,
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"failed_total": len(failed_items), # 自检未通过条目总数
|
||||
"created": len(created_ids), # 本次新建条数
|
||||
"skipped": skipped_count, # 已存在(幂等命中)条数
|
||||
"created_ids": created_ids, # 新建行 id 列表
|
||||
"source": "coso",
|
||||
}
|
||||
|
||||
+334
-32
@@ -2,20 +2,256 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, text as sa_text
|
||||
from sqlalchemy import func, text as sa_text, or_
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan
|
||||
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
|
||||
import json, hashlib, httpx, os
|
||||
from datetime import datetime
|
||||
from app.utils.alert_entity_scope import month_period_clause
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
import json, hashlib, httpx, os, urllib.request, urllib.parse
|
||||
from datetime import datetime, date
|
||||
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# R1 决策建议生成(规则驱动,稳定可复现,落库 ai_suggestions)
|
||||
# ============================================================
|
||||
def _sug_dict(s: AISuggestion) -> dict:
|
||||
return {
|
||||
"id": s.id,
|
||||
"entity_id": s.entity_id,
|
||||
"source": s.source,
|
||||
"suggestion_type": s.suggestion_type,
|
||||
"target_type": s.target_type,
|
||||
"category": s.category or "decision",
|
||||
"pushed": s.pushed or 0,
|
||||
"target_id": s.target_id,
|
||||
"title": s.title,
|
||||
"content": s.content,
|
||||
"suggestion_data": s.suggestion_data or {},
|
||||
"status": s.status,
|
||||
"applied_by": s.applied_by,
|
||||
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
|
||||
"apply_detail": s.apply_detail or [],
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _existing_unapplied(db: Session, entity_id: int, suggestion_type: str,
|
||||
target_id: int, title: str) -> bool:
|
||||
"""幂等:同entity+类型+目标+标题的未应用建议存在则跳过"""
|
||||
return db.query(AISuggestion).filter(
|
||||
AISuggestion.entity_id == entity_id,
|
||||
AISuggestion.suggestion_type == suggestion_type,
|
||||
AISuggestion.target_id == target_id,
|
||||
AISuggestion.title == title,
|
||||
AISuggestion.status == "unapplied",
|
||||
).first() is not None
|
||||
|
||||
|
||||
def generate_rule_suggestions(db: Session, entity_id: int,
|
||||
source: str = "dashboard", user_id: int = None,
|
||||
kpi_id: int = None) -> list:
|
||||
"""从数据规则生成决策建议并落库(R1,路线图2026-08-30)
|
||||
|
||||
规则:
|
||||
1. KPI执行率<70% → 建议建行动方案(异常类)
|
||||
2. KPI执行率>110% → 建议上调KPI目标(机会类)
|
||||
3. 预算执行率>110% → 建议调预算(预算类)
|
||||
4. 有pending预警 → 建议建行动方案处理预警
|
||||
幂等:同 entity+type+target_id+title+status=unapplied 不重复建。
|
||||
"""
|
||||
now = datetime.now()
|
||||
period = now.strftime("%Y-%m")
|
||||
created = []
|
||||
|
||||
def _add(suggestion_type: str, target_type: str, tid: int,
|
||||
title: str, content: str, suggestion_data: dict):
|
||||
nonlocal created
|
||||
if _existing_unapplied(db, entity_id, suggestion_type, tid, title):
|
||||
return
|
||||
sug = AISuggestion(
|
||||
entity_id=entity_id,
|
||||
user_id=user_id,
|
||||
source=source,
|
||||
suggestion_type=suggestion_type,
|
||||
target_type=target_type,
|
||||
category="alert" if target_type == "alert" else "decision",
|
||||
target_id=tid,
|
||||
title=title,
|
||||
content=content,
|
||||
suggestion_data=suggestion_data,
|
||||
status="unapplied",
|
||||
)
|
||||
db.add(sug)
|
||||
created.append(sug)
|
||||
|
||||
# 查询KPI(可按kpi_id过滤)
|
||||
q = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active")
|
||||
if kpi_id:
|
||||
q = q.filter(KPIDefinition.id == kpi_id)
|
||||
kpis = q.all()
|
||||
|
||||
for k in kpis:
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
or_(
|
||||
KPIValue.entity_id == entity_id,
|
||||
KPIValue.entity_id.is_(None),
|
||||
),
|
||||
month_period_clause(KPIValue.period),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
if not latest or latest.actual_value is None:
|
||||
continue
|
||||
actual = latest.actual_value
|
||||
target = k.target_value
|
||||
ratio = (actual / target) if target else None
|
||||
|
||||
# 1. 异常:执行率<70% → 建行动方案
|
||||
if ratio is not None and ratio < 0.7:
|
||||
title = f"提升 {k.kpi_name}:达成率仅{ratio*100:.0f}%"
|
||||
content = (f"KPI[{k.kpi_name}] 最新期间{latest.period}实际值{actual:g},"
|
||||
f"目标{target:g},达成率{ratio*100:.1f}%,低于70%预警线。"
|
||||
f"建议制定专项改善行动方案。")
|
||||
_add("action_plan", "kpi", k.id, title, content, {
|
||||
"kpi_id": k.id, "priority": "high",
|
||||
"title": f"改善: {k.kpi_name}达成率提升",
|
||||
})
|
||||
# 2. 机会:执行率>110% → 上调KPI目标
|
||||
elif ratio is not None and ratio > 1.1:
|
||||
new_target = round(actual * 1.05, 2)
|
||||
title = f"上调 {k.kpi_name} 目标:达成率{ratio*100:.0f}%超预期"
|
||||
content = (f"KPI[{k.kpi_name}] 达成率{ratio*100:.1f}%超过110%,"
|
||||
f"建议将目标从{target:g}上调至{new_target:g},保持牵引力。")
|
||||
_add("kpi_target", "kpi", k.id, title, content, {
|
||||
"kpi_id": k.id, "target_value": new_target,
|
||||
})
|
||||
|
||||
# 3. 预算执行率>110% → 调预算
|
||||
budget_rows = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.status == "active",
|
||||
BudgetPlan.period == period,
|
||||
).all()
|
||||
for b in budget_rows:
|
||||
actual = db.query(func.max(KPIValue.actual_value)).filter(
|
||||
KPIValue.kpi_id == b.kpi_id,
|
||||
KPIValue.period == b.period,
|
||||
).scalar()
|
||||
if actual is None or b.budget_value is None or b.budget_value <= 0:
|
||||
continue
|
||||
exec_ratio = actual / b.budget_value
|
||||
if exec_ratio > 1.1:
|
||||
kpi_name = "KPI"
|
||||
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
|
||||
if k:
|
||||
kpi_name = k.kpi_name
|
||||
title = f"调整 {kpi_name} 预算:执行率{exec_ratio*100:.0f}%超预算"
|
||||
content = (f"预算[{kpi_name}] {period}预算值{b.budget_value:g},"
|
||||
f"实际{actual:g},执行率{exec_ratio*100:.1f}%超过110%。"
|
||||
f"建议同步调整预算/现金流/行动方案。")
|
||||
_add("budget_adjust", "budget", b.kpi_id, title, content, {
|
||||
"kpi_id": b.kpi_id, "period": period, "budget_value": round(actual, 2),
|
||||
})
|
||||
|
||||
# 4. pending预警 → 建行动方案(多租户隔离 2026-09-07: 只取本账套预警)
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
).all()
|
||||
for a in alerts:
|
||||
k = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == a.kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
|
||||
title = f"处理预警:{kpi_name} {a.alert_message[:30]}"
|
||||
content = f"存在待处理预警({a.alert_level}级):{a.alert_message}。建议建立行动方案跟进。"
|
||||
_add("action_plan", "alert", a.id, title, content, {
|
||||
"kpi_id": a.kpi_id, "priority": "high" if a.alert_level == "red" else "medium",
|
||||
"alert_id": a.id,
|
||||
"title": f"处理预警: {kpi_name}",
|
||||
})
|
||||
|
||||
if created:
|
||||
db.commit()
|
||||
for s in created:
|
||||
db.refresh(s)
|
||||
# R1触达修复(2026-08-31): 只对新建的决策类建议推送企微(预警类不推防噪音)
|
||||
# 防轰炸: 同 title 建议幂等不重建 + pushed 标记只推一次;存量不推(只推新建)
|
||||
for s in created:
|
||||
if s.category == "decision" and not s.pushed:
|
||||
ok = _push_decision_suggestion(s)
|
||||
if ok:
|
||||
s.pushed = 1
|
||||
db.commit()
|
||||
return created
|
||||
|
||||
|
||||
_TYPE_LABELS = {"kpi_target": "KPI目标", "budget_adjust": "预算调整", "action_plan": "行动方案"}
|
||||
|
||||
|
||||
def _push_decision_suggestion(s: AISuggestion) -> bool:
|
||||
"""决策类建议推送到企微(8800 relay 公司群中继)
|
||||
|
||||
仅 decision 类;预警类不进推送流。失败不影响主流程(try/except)。
|
||||
8800/send 只接受 form-encoded 参数(msg/source/msgtype/touser),
|
||||
勿用 JSON body(relay 会返回 msg is required,lead.py 的 JSON 写法是错的)。
|
||||
"""
|
||||
if getattr(s, "category", "decision") != "decision":
|
||||
return False
|
||||
type_label = _TYPE_LABELS.get(s.suggestion_type, s.suggestion_type)
|
||||
content = (
|
||||
f"## 📌 AI决策建议\n"
|
||||
f"**{s.title}**\n"
|
||||
f"{str(s.content or '')[:120]}\n"
|
||||
f"类型标签: {type_label}\n"
|
||||
f"---\n"
|
||||
f"⏰ {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
)
|
||||
try:
|
||||
data = urllib.parse.urlencode({
|
||||
"msg": content,
|
||||
"source": "管理会计OS",
|
||||
"msgtype": "markdown",
|
||||
}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8800/send",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return '"ok": true' in body or '"ok":true' in body
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _unapplied_suggestions(db: Session, entity_id: int, limit: int = 20) -> list:
|
||||
items = db.query(AISuggestion).filter(
|
||||
AISuggestion.entity_id == entity_id,
|
||||
AISuggestion.status == "unapplied",
|
||||
).order_by(AISuggestion.created_at.desc()).limit(limit).all()
|
||||
return [_sug_dict(s) for s in items]
|
||||
|
||||
|
||||
def _require_deepseek_key() -> str:
|
||||
"""强制从环境变量读取 DeepSeek Key,禁止硬编码默认值(安全修复 2026-08-31)"""
|
||||
api_key = os.getenv("DEEPSEEK_API_KEY")
|
||||
if not api_key:
|
||||
raise HTTPException(503, "DEEPSEEK_API_KEY 未配置(禁止硬编码默认key,安全修复 2026-08-31)")
|
||||
return api_key
|
||||
|
||||
|
||||
async def _call_deepseek(prompt: str) -> str:
|
||||
"""调用DeepSeek API"""
|
||||
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8")
|
||||
api_key = _require_deepseek_key()
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
@@ -34,18 +270,28 @@ async def _call_deepseek(prompt: str) -> str:
|
||||
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
@router.get("/dashboard-analysis")
|
||||
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db)):
|
||||
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""AI分析驾驶舱数据"""
|
||||
# 尝试缓存
|
||||
cache_key = f"dashboard_analysis:{role}"
|
||||
cache_key = f"dashboard_analysis:{role}:{entity_id}"
|
||||
cached = cache_get("ai", cache_key)
|
||||
if cached:
|
||||
# 缓存命中(LLM文本10分钟内不重复调用),但轻量规则建议仍执行(幂等)
|
||||
try:
|
||||
generate_rule_suggestions(db, entity_id, source="dashboard")
|
||||
except Exception:
|
||||
pass
|
||||
cached["suggestions"] = _unapplied_suggestions(db, entity_id)
|
||||
return cached
|
||||
# 获取当前KPI数据
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active").all()
|
||||
kpi_summary = []
|
||||
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({
|
||||
"name": k.kpi_name,
|
||||
"code": k.kpi_code,
|
||||
@@ -56,8 +302,11 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
|
||||
"unit": k.unit,
|
||||
})
|
||||
|
||||
# 获取预警
|
||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
|
||||
# 获取预警(多租户隔离 2026-09-07: 只统计本账套)
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
).count()
|
||||
|
||||
# 构建分析prompt
|
||||
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
|
||||
@@ -81,26 +330,42 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
|
||||
except Exception as e:
|
||||
analysis = f"AI分析暂时不可用: {str(e)}"
|
||||
|
||||
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts}
|
||||
# R1: 规则驱动生成可落地决策建议(幂等落库)
|
||||
try:
|
||||
generate_rule_suggestions(db, entity_id, source="dashboard")
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts,
|
||||
"suggestions": _unapplied_suggestions(db, entity_id)}
|
||||
# 缓存10分钟
|
||||
cache_set("ai", cache_key, result, ttl_seconds=600)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/kpi-analysis/{kpi_id}")
|
||||
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db)):
|
||||
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""AI分析单个KPI"""
|
||||
# 尝试缓存
|
||||
cache_key = f"kpi_analysis:{kpi_id}"
|
||||
cache_key = f"kpi_analysis:{kpi_id}:{entity_id}"
|
||||
cached = cache_get("ai", cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
# 跨企业 kpi_id 同样 404(多租户隔离 2026-09-07)
|
||||
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 = []
|
||||
for v in values:
|
||||
@@ -129,7 +394,14 @@ KPI名称:{kpi.kpi_name}
|
||||
except Exception as e:
|
||||
analysis = f"分析暂时不可用: {str(e)}"
|
||||
|
||||
result = {"kpi_name": kpi.kpi_name, "analysis": analysis}
|
||||
# R1: 生成该KPI的可落地建议
|
||||
try:
|
||||
generate_rule_suggestions(db, entity_id, source="kpi", kpi_id=kpi_id)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
result = {"kpi_name": kpi.kpi_name, "analysis": analysis,
|
||||
"suggestions": _unapplied_suggestions(db, entity_id)}
|
||||
cache_set("ai", cache_key, result, ttl_seconds=600)
|
||||
return result
|
||||
|
||||
@@ -141,7 +413,7 @@ async def _stream_analysis(prompt: str):
|
||||
"POST",
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY', 'sk-8e24e6eb87f2475e96ea0980002dc2e8')}",
|
||||
"Authorization": f"Bearer {_require_deepseek_key()}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
@@ -172,9 +444,11 @@ async def _stream_analysis(prompt: str):
|
||||
|
||||
|
||||
@router.get("/dashboard-analysis-stream")
|
||||
async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db)):
|
||||
async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""AI分析驾驶舱数据 — SSE流式输出"""
|
||||
cache_key = f"dashboard_analysis:{role}"
|
||||
# 多租户隔离 2026-09-07: cache key 补 entity_id(原跨企业串缓存)
|
||||
cache_key = f"dashboard_analysis:{role}:{entity_id}"
|
||||
cached = cache_get("ai", cache_key)
|
||||
if cached:
|
||||
# 缓存存在,直接以流的形式一次性返回
|
||||
@@ -184,16 +458,24 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe
|
||||
yield "data: {\"text\": \"[DONE]\"}\n\n"
|
||||
return StreamingResponse(cached_stream(), media_type="text/event-stream")
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
kpi_summary = []
|
||||
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({
|
||||
"name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension,
|
||||
"target": k.target_value, "actual": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else None, "unit": k.unit,
|
||||
})
|
||||
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
).count()
|
||||
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
|
||||
role_label = {"ceo": "CEO(总经理)", "finance": "财务部", "business": "业务部"}.get(role, "管理层")
|
||||
prompt = f"""我是一家公司的管理层,以下是当前管理会计系统的KPI数据和系统状态,请给出专业的分析和管理建议:
|
||||
@@ -218,6 +500,7 @@ async def ask_question(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""自然语言查询 — CEO问企业经营问题"""
|
||||
body = await request.json()
|
||||
@@ -226,24 +509,37 @@ async def ask_question(
|
||||
if not question:
|
||||
raise HTTPException(400, "请输入问题")
|
||||
|
||||
# 收集系统数据作为上下文
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
||||
# 收集系统数据作为上下文(多租户隔离 2026-09-07: 只取本账套)
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
kpi_context = []
|
||||
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()
|
||||
kpi_context.append(
|
||||
f"{k.kpi_name}({k.kpi_code}): 当前值={latest.actual_value if latest else '无'}"
|
||||
f"{' ⚠️' + alert.alert_level if alert else ''}"
|
||||
)
|
||||
|
||||
# 获取改善计划
|
||||
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(10).all()
|
||||
# 获取改善计划(多租户隔离 2026-09-07: 只取本账套)
|
||||
plans = db.query(ActionPlan).filter(
|
||||
ActionPlan.entity_id == entity_id,
|
||||
).order_by(ActionPlan.created_at.desc()).limit(10).all()
|
||||
plan_context = [f"- {p.title}({p.assignee}, {p.status}, {p.progress}%)" for p in plans]
|
||||
|
||||
# 获取预警
|
||||
red_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "red").count()
|
||||
yellow_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "yellow").count()
|
||||
red_alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending", KPIAlert.alert_level == "red",
|
||||
).count()
|
||||
yellow_alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending", KPIAlert.alert_level == "yellow",
|
||||
).count()
|
||||
|
||||
system_context = f"""你是管理会计OS的AI助手,基于以下企业数据回答管理层问题。
|
||||
|
||||
@@ -275,9 +571,13 @@ async def ask_question(
|
||||
async def review_plans(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""AI复盘改善行动计划执行效果"""
|
||||
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.asc()).all()
|
||||
# 多租户隔离 2026-09-07: ActionPlan 按 entity_id 过滤(表 2026-08-31 已加列回填)
|
||||
plans = db.query(ActionPlan).filter(
|
||||
ActionPlan.entity_id == entity_id,
|
||||
).order_by(ActionPlan.created_at.asc()).all()
|
||||
|
||||
if not plans:
|
||||
return {"analysis": "暂无改善行动计划,无法复盘"}
|
||||
@@ -286,7 +586,9 @@ async def review_plans(
|
||||
for p in plans:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
|
||||
kpi_name = kpi.kpi_name if kpi else "未知"
|
||||
latest = db.query(KPIValue).filter(KPIValue.kpi_id == p.kpi_id).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(
|
||||
f"- {p.title}\n"
|
||||
f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else '无'})\n"
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""AI决策建议 — 一键应用到KPI/预算/行动方案 (路线图R1 2026-08-30)
|
||||
|
||||
北极星④决策闭环:AI建议 → 点击应用 → 写库变更 → OperationLog留痕 → 前端可查已应用/未应用。
|
||||
应用动作复用现有 kpis/budget/action_plans 数据模型,不新建业务接口。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, resolve_entity_for_request
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import AISuggestion, KPIDefinition, KPIValue, OperationLog, BudgetPlan, ActionPlan
|
||||
|
||||
router = APIRouter(prefix="/api/cma/ai/suggestions", tags=["AI建议"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def _sug_dict(s: AISuggestion) -> dict:
|
||||
return {
|
||||
"id": s.id,
|
||||
"entity_id": s.entity_id,
|
||||
"user_id": s.user_id,
|
||||
"source": s.source,
|
||||
"suggestion_type": s.suggestion_type,
|
||||
"target_type": s.target_type,
|
||||
"category": s.category or "decision",
|
||||
"pushed": s.pushed or 0,
|
||||
"target_id": s.target_id,
|
||||
"title": s.title,
|
||||
"content": s.content,
|
||||
"suggestion_data": s.suggestion_data or {},
|
||||
"status": s.status,
|
||||
"applied_by": s.applied_by,
|
||||
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
|
||||
"apply_detail": s.apply_detail or [],
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_suggestion(
|
||||
request: Request,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""创建AI建议(前端AI分析/手动保存建议)"""
|
||||
suggestion_type = data.get("suggestion_type") or data.get("type")
|
||||
title = (data.get("title") or "").strip()
|
||||
if not suggestion_type:
|
||||
raise HTTPException(400, "缺少 suggestion_type (kpi_target/budget_adjust/action_plan)")
|
||||
if not title:
|
||||
raise HTTPException(400, "缺少 title")
|
||||
|
||||
entity_id = resolve_entity_for_request(request, data.get("entity_id") or 1)
|
||||
|
||||
sug = AISuggestion(
|
||||
entity_id=entity_id,
|
||||
user_id=getattr(current_user, "id", None),
|
||||
source=data.get("source", "manual"),
|
||||
suggestion_type=suggestion_type,
|
||||
target_type=data.get("target_type", "kpi"),
|
||||
category="alert" if data.get("target_type") == "alert" else data.get("category", "decision"),
|
||||
target_id=data.get("target_id"),
|
||||
title=title,
|
||||
content=data.get("content"),
|
||||
suggestion_data=data.get("suggestion_data") or {},
|
||||
status="unapplied",
|
||||
)
|
||||
db.add(sug)
|
||||
db.commit()
|
||||
db.refresh(sug)
|
||||
return {"success": True, "message": "建议已保存", "data": _sug_dict(sug)}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_suggestions(
|
||||
status: Optional[str] = Query(None, description="unapplied/applied/dismissed"),
|
||||
suggestion_type: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None, description="decision/alert 建议分类过滤"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""建议列表(前端建议中心/详情页查看已应用/未应用状态)"""
|
||||
query = db.query(AISuggestion).filter(AISuggestion.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(AISuggestion.status == status)
|
||||
if suggestion_type:
|
||||
query = query.filter(AISuggestion.suggestion_type == suggestion_type)
|
||||
if category:
|
||||
query = query.filter(AISuggestion.category == category)
|
||||
items = query.order_by(AISuggestion.created_at.desc()).limit(200).all()
|
||||
return {"data": [_sug_dict(s) for s in items], "total": len(items)}
|
||||
|
||||
|
||||
@router.get("/{suggestion_id}")
|
||||
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""建议详情"""
|
||||
s = db.query(AISuggestion).filter(
|
||||
AISuggestion.id == suggestion_id,
|
||||
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
|
||||
).first()
|
||||
if not s:
|
||||
raise HTTPException(404, "建议不存在")
|
||||
return {"data": _sug_dict(s)}
|
||||
|
||||
|
||||
def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
|
||||
"""改KPI目标"""
|
||||
target_value = params.get("target_value")
|
||||
if target_value is None:
|
||||
raise HTTPException(400, "应用kpi_target需要 target_value")
|
||||
kpi_id = params.get("kpi_id") or sug.target_id
|
||||
if not kpi_id:
|
||||
raise HTTPException(400, "缺少 kpi_id")
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_id} 不存在")
|
||||
before = kpi.target_value
|
||||
kpi.target_value = float(target_value)
|
||||
db.flush()
|
||||
detail_item = {
|
||||
"target_type": "kpi",
|
||||
"target_id": kpi.id,
|
||||
"target_name": kpi.kpi_name,
|
||||
"action": "update_target_value",
|
||||
"before": before,
|
||||
"after": float(target_value),
|
||||
}
|
||||
db.add(OperationLog(
|
||||
user_id=getattr(current_user, "id", None),
|
||||
action="ai_suggestion_apply",
|
||||
target_type="kpi",
|
||||
target_id=kpi.id,
|
||||
detail={
|
||||
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套,端点已校验=会话账套)
|
||||
"suggestion_id": sug.id,
|
||||
"suggestion_title": sug.title,
|
||||
"apply_action": "kpi_target",
|
||||
"before": before,
|
||||
"after": float(target_value),
|
||||
},
|
||||
))
|
||||
return detail_item
|
||||
|
||||
|
||||
def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
|
||||
"""调预算(BudgetPlan upsert,按 kpi_id+period)"""
|
||||
period = params.get("period")
|
||||
budget_value = params.get("budget_value")
|
||||
if not period or budget_value is None:
|
||||
raise HTTPException(400, "应用budget_adjust需要 period + budget_value")
|
||||
kpi_id = params.get("kpi_id") or sug.target_id
|
||||
if not kpi_id:
|
||||
raise HTTPException(400, "缺少 kpi_id")
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_id} 不存在")
|
||||
|
||||
# 解析期间 2026-09 → year=2026 month=9
|
||||
try:
|
||||
parts = period.split("-")
|
||||
year = int(parts[0])
|
||||
month = int(parts[1])
|
||||
except Exception:
|
||||
raise HTTPException(400, f"period格式错误: {period} (需要 YYYY-MM)")
|
||||
|
||||
plan = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == sug.entity_id,
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.budget_year == year,
|
||||
BudgetPlan.budget_month == month,
|
||||
BudgetPlan.status == "active",
|
||||
).first()
|
||||
|
||||
before = None
|
||||
if plan:
|
||||
before = plan.budget_value
|
||||
plan.budget_value = float(budget_value)
|
||||
else:
|
||||
plan = BudgetPlan(
|
||||
entity_id=sug.entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
budget_value=float(budget_value),
|
||||
budget_year=year,
|
||||
budget_month=month,
|
||||
version="v1.0",
|
||||
status="active",
|
||||
source_type="ai_suggestion",
|
||||
calc_logic=f"AI建议应用 #{sug.id}: {sug.title}",
|
||||
created_by=getattr(current_user, "name", "") or "",
|
||||
)
|
||||
db.add(plan)
|
||||
db.flush()
|
||||
detail_item = {
|
||||
"target_type": "budget",
|
||||
"target_id": plan.id,
|
||||
"target_name": f"{kpi.kpi_name}[{period}]",
|
||||
"action": "update_budget" if before is not None else "create_budget",
|
||||
"before": before,
|
||||
"after": float(budget_value),
|
||||
}
|
||||
db.add(OperationLog(
|
||||
user_id=getattr(current_user, "id", None),
|
||||
action="ai_suggestion_apply",
|
||||
target_type="budget",
|
||||
target_id=plan.id,
|
||||
detail={
|
||||
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
|
||||
"suggestion_id": sug.id,
|
||||
"suggestion_title": sug.title,
|
||||
"apply_action": "budget_adjust",
|
||||
"kpi_id": kpi_id,
|
||||
"period": period,
|
||||
"before": before,
|
||||
"after": float(budget_value),
|
||||
},
|
||||
))
|
||||
return detail_item
|
||||
|
||||
|
||||
def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
|
||||
"""建行动方案"""
|
||||
title = (params.get("title") or "").strip() or sug.title
|
||||
kpi_id = params.get("kpi_id") or sug.target_id
|
||||
if not kpi_id:
|
||||
raise HTTPException(400, "缺少 kpi_id")
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单 2030b: 目标KPI须归属建议账套
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_id} 不存在")
|
||||
|
||||
due_date = None
|
||||
if params.get("due_date"):
|
||||
try:
|
||||
due_date = datetime.strptime(str(params["due_date"])[:10], "%Y-%m-%d")
|
||||
except Exception:
|
||||
due_date = None
|
||||
|
||||
plan = ActionPlan(
|
||||
entity_id=sug.entity_id, # 2026-09-18 派生单: 写建议归属账套(=会话账套,端点已校验)
|
||||
kpi_id=kpi_id,
|
||||
title=title,
|
||||
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
|
||||
assignee=params.get("assignee") or "",
|
||||
priority=params.get("priority") or "medium",
|
||||
due_date=due_date,
|
||||
status="pending",
|
||||
progress=0,
|
||||
created_by=getattr(current_user, "name", "") or "ai_suggestion",
|
||||
)
|
||||
db.add(plan)
|
||||
db.flush()
|
||||
detail_item = {
|
||||
"target_type": "action_plan",
|
||||
"target_id": plan.id,
|
||||
"target_name": title,
|
||||
"action": "create_action_plan",
|
||||
"before": None,
|
||||
"after": plan.id,
|
||||
}
|
||||
db.add(OperationLog(
|
||||
user_id=getattr(current_user, "id", None),
|
||||
action="ai_suggestion_apply",
|
||||
target_type="action_plan",
|
||||
target_id=plan.id,
|
||||
detail={
|
||||
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
|
||||
"suggestion_id": sug.id,
|
||||
"suggestion_title": sug.title,
|
||||
"apply_action": "action_plan",
|
||||
"kpi_id": kpi_id,
|
||||
"plan_title": title,
|
||||
},
|
||||
))
|
||||
return detail_item
|
||||
|
||||
|
||||
_APPLYERS = {
|
||||
"kpi_target": _apply_kpi_target,
|
||||
"budget_adjust": _apply_budget_adjust,
|
||||
"action_plan": _apply_action_plan,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{suggestion_id}/preview")
|
||||
def preview_suggestion(suggestion_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""应用前预览:将变更什么(当前值 → 新值),建立信任 (R1触达修复 2026-08-31)
|
||||
|
||||
- kpi_target: {kpi_name, current_target, new_target}
|
||||
- budget_adjust:{kpi_name, period, current_budget, new_budget}
|
||||
- action_plan: {kpi_name, plan_title, assignee, priority, due_date}
|
||||
"""
|
||||
sug = db.query(AISuggestion).filter(
|
||||
AISuggestion.id == suggestion_id,
|
||||
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 预览须归属会话账套(原依赖未用于过滤)
|
||||
).first()
|
||||
if not sug:
|
||||
raise HTTPException(404, "建议不存在")
|
||||
sd = sug.suggestion_data or {}
|
||||
kpi = None
|
||||
kpi_id = sd.get("kpi_id") or sug.target_id
|
||||
if kpi_id:
|
||||
# 2026-09-18 派生单 2030b: 目标KPI须归属建议账套;对端KPI降级为 KPI#<id>(不回显名/目标值)
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == sug.entity_id,
|
||||
).first()
|
||||
|
||||
if sug.suggestion_type == "kpi_target":
|
||||
return {"data": {
|
||||
"type": "kpi_target",
|
||||
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
|
||||
"current_target": kpi.target_value if kpi else None,
|
||||
"new_target": sd.get("target_value"),
|
||||
}}
|
||||
if sug.suggestion_type == "budget_adjust":
|
||||
period = sd.get("period") or sug.target_type
|
||||
current_budget = None
|
||||
if kpi and period:
|
||||
bp = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == sug.entity_id,
|
||||
BudgetPlan.kpi_id == kpi.id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.status == "active",
|
||||
).order_by(BudgetPlan.id.desc()).first()
|
||||
current_budget = bp.budget_value if bp else None
|
||||
return {"data": {
|
||||
"type": "budget_adjust",
|
||||
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
|
||||
"period": period,
|
||||
"current_budget": current_budget,
|
||||
"new_budget": sd.get("budget_value"),
|
||||
}}
|
||||
# action_plan
|
||||
return {"data": {
|
||||
"type": "action_plan",
|
||||
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
|
||||
"plan_title": sd.get("title") or sug.title,
|
||||
"assignee": sd.get("assignee") or "",
|
||||
"priority": sd.get("priority") or "medium",
|
||||
"due_date": sd.get("due_date") or "",
|
||||
}}
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/apply")
|
||||
def apply_suggestion(
|
||||
suggestion_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
|
||||
|
||||
Body 示例:
|
||||
{"action": "kpi_target", "target_value": 2000000}
|
||||
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
|
||||
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
|
||||
"""
|
||||
sug = db.query(AISuggestion).filter(
|
||||
AISuggestion.id == suggestion_id,
|
||||
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
|
||||
).first()
|
||||
if not sug:
|
||||
raise HTTPException(404, "建议不存在")
|
||||
if sug.status == "applied":
|
||||
raise HTTPException(400, "该建议已应用,不能重复应用")
|
||||
if sug.status == "dismissed":
|
||||
raise HTTPException(400, "该建议已忽略,如需应用请重新创建")
|
||||
|
||||
action = data.get("action") or sug.suggestion_type
|
||||
applier = _APPLYERS.get(action)
|
||||
if not applier:
|
||||
raise HTTPException(400, f"不支持的应用动作: {action} (支持 kpi_target/budget_adjust/action_plan)")
|
||||
|
||||
# 应用参数 = 请求体参数 覆盖 建议默认参数
|
||||
params = dict(sug.suggestion_data or {})
|
||||
params.update({k: v for k, v in data.items() if k != "action" and v is not None})
|
||||
|
||||
detail_item = applier(db, sug, params, current_user)
|
||||
sug.status = "applied"
|
||||
sug.applied_by = getattr(current_user, "name", "") or ""
|
||||
sug.applied_user_id = getattr(current_user, "id", None)
|
||||
sug.applied_at = datetime.now()
|
||||
sug.apply_detail = [detail_item]
|
||||
db.commit()
|
||||
db.refresh(sug)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "建议已应用并留痕",
|
||||
"data": _sug_dict(sug),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}/dismiss")
|
||||
def dismiss_suggestion(
|
||||
suggestion_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""忽略建议"""
|
||||
sug = db.query(AISuggestion).filter(
|
||||
AISuggestion.id == suggestion_id,
|
||||
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
|
||||
).first()
|
||||
if not sug:
|
||||
raise HTTPException(404, "建议不存在")
|
||||
sug.status = "dismissed"
|
||||
db.commit()
|
||||
return {"success": True, "message": "建议已忽略"}
|
||||
+967
-62
File diff suppressed because it is too large
Load Diff
+357
-13
@@ -1,30 +1,374 @@
|
||||
"""预警 API"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, Request
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIAlert, OperationLog
|
||||
from app.models import KPIAlert, OperationLog, ActionPlan
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.alerts")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
@router.get("")
|
||||
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db)):
|
||||
query = db.query(KPIAlert)
|
||||
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 账套隔离(2026-09-19 dama-20260919 C1):按**预警行自身** entity_id 过滤
|
||||
# 修复前按 KPI 归属企业过滤(2026-08-23 P1b)→ KPI 属他账套而预警行属本账套的行反向泄漏
|
||||
# (实测 entity 2 口径 225 → 223,泄漏 id=589/590)
|
||||
query = db.query(KPIAlert).filter(KPIAlert.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(KPIAlert.status == status)
|
||||
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]}
|
||||
|
||||
@router.post("/{alert_id}/resolve")
|
||||
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
|
||||
if alert:
|
||||
alert.status = "resolved"
|
||||
alert.resolution = data.get("resolution", "")
|
||||
alert.assignee = data.get("assignee", alert.assignee)
|
||||
from datetime import datetime; alert.resolved_at = datetime.now()
|
||||
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
|
||||
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
alert.status = "resolved"
|
||||
alert.resolution = data.get("resolution", "")
|
||||
alert.assignee = data.get("assignee", alert.assignee)
|
||||
from datetime import datetime; alert.resolved_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return {
|
||||
"message": "已处理",
|
||||
"assignee": alert.assignee,
|
||||
"alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns},
|
||||
"suggest_create_action_plan": alert.alert_level == "red",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{alert_id}/process")
|
||||
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""标记预警为处理中(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
|
||||
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
|
||||
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
if alert.status == "resolved":
|
||||
raise HTTPException(400, "已处理的预警不能重复处理")
|
||||
assignee = data.get("assignee")
|
||||
if not assignee:
|
||||
raise HTTPException(400, "缺少处理人")
|
||||
alert.status = "processing"
|
||||
alert.assignee = assignee
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return {"message": "已标记为处理中", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
|
||||
|
||||
|
||||
@router.post("/{alert_id}/escalate")
|
||||
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""升级预警级别(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
|
||||
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
|
||||
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
assignee = data.get("assignee")
|
||||
if not assignee:
|
||||
raise HTTPException(400, "缺少处理人")
|
||||
if alert.status == "resolved":
|
||||
raise HTTPException(400, "已处理的预警不能升级")
|
||||
if alert.alert_level != "red":
|
||||
alert.alert_level = "red"
|
||||
alert.assignee = assignee
|
||||
db.commit()
|
||||
return {"message": "已处理", "assignee": alert.assignee}
|
||||
db.refresh(alert)
|
||||
return {"message": "已升级", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
|
||||
|
||||
|
||||
@router.get("/check-timeout")
|
||||
def check_alert_timeout(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色
|
||||
|
||||
账套隔离(2026-09-19 写路径跨账套修复):按**预警行自身** KPIAlert.entity_id 过滤;
|
||||
修复前 join kpi_definitions 按 KPI 归属企业过滤 → KPI 属他账套而预警行属本账套的行
|
||||
会被本账套调用**批量改写**(升级 red + 置 processing),比只读泄漏更严重
|
||||
(2026-08 之前为全账套批处理;2026-09-18 加的 join 口径见 dama-20260919 派生单)。
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = datetime.now() - timedelta(hours=24)
|
||||
timeout_alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.entity_id == entity_id,
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.created_at < cutoff,
|
||||
KPIAlert.alert_level != "red",
|
||||
).all()
|
||||
upgraded_count = 0
|
||||
for alert in timeout_alerts:
|
||||
alert.alert_level = "red"
|
||||
alert.status = "processing"
|
||||
upgraded_count += 1
|
||||
if upgraded_count:
|
||||
db.commit()
|
||||
return {"total_timeout": len(timeout_alerts), "upgraded_count": upgraded_count}
|
||||
|
||||
|
||||
@router.post("/{alert_id}/create-action-plan")
|
||||
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""从预警创建改善行动计划(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
|
||||
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
|
||||
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
|
||||
if not alert:
|
||||
raise HTTPException(404, "预警不存在")
|
||||
if alert.action_plan_linked_id:
|
||||
raise HTTPException(400, "已关联改善计划")
|
||||
plan = ActionPlan(
|
||||
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
|
||||
alert_id=alert.id,
|
||||
kpi_id=alert.kpi_id,
|
||||
title=f"改善: {alert.alert_message}",
|
||||
assignee=data.get("assignee", ""),
|
||||
priority="high" if alert.alert_level == "red" else "medium",
|
||||
created_by=data.get("created_by", ""),
|
||||
)
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
alert.action_plan_linked_id = plan.id
|
||||
db.commit()
|
||||
return {"message": "改善行动计划已创建", "plan_id": plan.id, "priority": plan.priority}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 功能4: 风险矩阵热力图 (CMA P2 - ERM框架、风险识别四象限)
|
||||
|
||||
RISK_MATRIX_DATA = {
|
||||
"hanke": {
|
||||
"entity_name": "陕西酣客(白酒经销)",
|
||||
"quadrants": [
|
||||
{
|
||||
"impact": "high",
|
||||
"probability": "high",
|
||||
"label": "高影响×高概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_001",
|
||||
"name": "流动性风险",
|
||||
"impact_label": "高",
|
||||
"probability_label": "高",
|
||||
"detail": "现金2.2万 vs 短债350万 → 断流风险",
|
||||
"impact_value": 90,
|
||||
"probability_value": 85,
|
||||
"type": "red",
|
||||
"measures": ["催收大额应收", "协商短期借款续贷"],
|
||||
"responsible": "任富海",
|
||||
"deadline": "7月底",
|
||||
},
|
||||
{
|
||||
"id": "risk_002",
|
||||
"name": "合规风险",
|
||||
"impact_label": "高",
|
||||
"probability_label": "高",
|
||||
"detail": "欠税426万 · 折旧违规",
|
||||
"impact_value": 95,
|
||||
"probability_value": 80,
|
||||
"type": "red",
|
||||
"measures": ["补缴欠税计划", "重新梳理折旧政策"],
|
||||
"responsible": "任富海",
|
||||
"deadline": "8月底",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "high",
|
||||
"probability": "medium",
|
||||
"label": "高影响×中概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_003",
|
||||
"name": "政策风险",
|
||||
"impact_label": "高",
|
||||
"probability_label": "中",
|
||||
"detail": "白酒消费税调整可能导致成本上升15-20%",
|
||||
"impact_value": 85,
|
||||
"probability_value": 50,
|
||||
"type": "orange",
|
||||
"measures": ["关注政策动向", "预留税务缓冲资金"],
|
||||
"responsible": "财务部",
|
||||
"deadline": "持续关注",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "medium",
|
||||
"probability": "high",
|
||||
"label": "中影响×高概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_004",
|
||||
"name": "运营风险",
|
||||
"impact_label": "中",
|
||||
"probability_label": "高",
|
||||
"detail": "Model C 成本模型未落地,成本核算偏差",
|
||||
"impact_value": 60,
|
||||
"probability_value": 80,
|
||||
"type": "yellow",
|
||||
"measures": ["推动Model C落地", "建立成本标准化流程"],
|
||||
"responsible": "财务部",
|
||||
"deadline": "8月中",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "medium",
|
||||
"probability": "medium",
|
||||
"label": "中影响×中概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_005",
|
||||
"name": "战略风险",
|
||||
"impact_label": "中",
|
||||
"probability_label": "中",
|
||||
"detail": "酒类零交易,新业务方向不确定",
|
||||
"impact_value": 55,
|
||||
"probability_value": 55,
|
||||
"type": "yellow",
|
||||
"measures": ["制定新业务评估框架", "定期战略复盘"],
|
||||
"responsible": "管理层",
|
||||
"deadline": "9月底",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "low",
|
||||
"probability": "low",
|
||||
"label": "低影响×低概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_006",
|
||||
"name": "市场风险",
|
||||
"impact_label": "低",
|
||||
"probability_label": "低",
|
||||
"detail": "行业需求波动,但酣客已基本退出市场",
|
||||
"impact_value": 25,
|
||||
"probability_value": 20,
|
||||
"type": "green",
|
||||
"measures": ["定期监控行业数据"],
|
||||
"responsible": "业务部",
|
||||
"deadline": "每季度",
|
||||
},
|
||||
{
|
||||
"id": "risk_007",
|
||||
"name": "人员风险",
|
||||
"impact_label": "低",
|
||||
"probability_label": "低",
|
||||
"detail": "核心团队稳定,短期内无流失风险",
|
||||
"impact_value": 20,
|
||||
"probability_value": 15,
|
||||
"type": "green",
|
||||
"measures": ["保持团队激励", "关键岗位备份"],
|
||||
"responsible": "人事部",
|
||||
"deadline": "持续",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"bohai": {
|
||||
"entity_name": "陕西博海科技(IT服务)",
|
||||
"quadrants": [
|
||||
{
|
||||
"impact": "high",
|
||||
"probability": "medium",
|
||||
"label": "高影响×中概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_b_001",
|
||||
"name": "现金流风险",
|
||||
"impact_label": "高",
|
||||
"probability_label": "中",
|
||||
"detail": "应收账款账期延长,现金流紧张",
|
||||
"impact_value": 85,
|
||||
"probability_value": 55,
|
||||
"type": "orange",
|
||||
"measures": ["加快应收催收", "建立信用管理制度"],
|
||||
"responsible": "任富海",
|
||||
"deadline": "7月底",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "medium",
|
||||
"probability": "high",
|
||||
"label": "中影响×高概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_b_002",
|
||||
"name": "项目交付风险",
|
||||
"impact_label": "中",
|
||||
"probability_label": "高",
|
||||
"detail": "多个项目并行,交付压力大",
|
||||
"impact_value": 65,
|
||||
"probability_value": 75,
|
||||
"type": "yellow",
|
||||
"measures": ["优化项目排期", "增加外包资源"],
|
||||
"responsible": "项目部",
|
||||
"deadline": "持续",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"impact": "low",
|
||||
"probability": "medium",
|
||||
"label": "低影响×中概率",
|
||||
"risks": [
|
||||
{
|
||||
"id": "risk_b_003",
|
||||
"name": "技术迭代风险",
|
||||
"impact_label": "低",
|
||||
"probability_label": "中",
|
||||
"detail": "新技术跟踪不及时,可能落后",
|
||||
"impact_value": 30,
|
||||
"probability_value": 45,
|
||||
"type": "green",
|
||||
"measures": ["定期技术培训", "技术栈评估"],
|
||||
"responsible": "技术部",
|
||||
"deadline": "每季度",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/risk-matrix")
|
||||
def get_risk_matrix(
|
||||
request: Request,
|
||||
entity: Optional[str] = Query(None, description="hanke/bohai(须与会话账套一致);缺省按会话账套"),
|
||||
):
|
||||
"""风险矩阵热力图数据 - CMA P2 ERM四象限
|
||||
|
||||
多租户(cma-cross-entity-endpoints-auth-20260914):
|
||||
非法实体值 → 400 显式报错(不再静默回落酣客数据);跨实体 → 403。
|
||||
"""
|
||||
slug = assert_entity_slug_scope(request, entity)
|
||||
if slug is None:
|
||||
slug = resolve_session_slug(request, "hanke")
|
||||
data = RISK_MATRIX_DATA.get(slug)
|
||||
if not data:
|
||||
raise HTTPException(404, f"实体 entity={slug} 无风险矩阵数据")
|
||||
return data
|
||||
|
||||
+102
-18
@@ -3,20 +3,47 @@
|
||||
管理员可初始化选择,后续按模式运作"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import func, or_
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, OperationLog, RolePermission
|
||||
from app.auth_middleware import require_auth, require_role, require_auth_401
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KPIDefinition, MapObjective, OperationLog, RolePermission, StrategicMap
|
||||
|
||||
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
|
||||
# 不设全局权限,每个接口单独控制
|
||||
# 用户端点:router 级要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914);
|
||||
# 原注释「不设全局权限,每个接口单独控制」保留语义——写接口仍各自叠加 require_role,
|
||||
# router 级只是「至少已认证」底线(补齐 /modes、GET /config 原无鉴权的缺口)。
|
||||
dependencies=[Depends(require_auth_401)],
|
||||
)
|
||||
|
||||
# 三种对齐模式定义
|
||||
# ── B3(a) 对齐模式拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
|
||||
# 背景:`role_permissions.key` 为 unique 单行键 → `alignment_config` 全库一行,
|
||||
# 任一账套的 ceo/it 改对齐模式 → **全账套生效**(跨账套配置污染)。
|
||||
# 治本(方案 A,零 DDL):键名带账套后缀 → `alignment_config.<entity_id>`。
|
||||
# · 读:只读本账套键;未配置 → configured=false(/tree 提示先初始化),
|
||||
# **不回退旧全局键**(回退即恢复跨账套生效)。
|
||||
# · 写:只写本账套键(entity 来自会话 token)。
|
||||
# · 旧值搬运:脚本 scripts/migrate_cash_balance_key_entity_20260922.py --check/--apply
|
||||
# (同一 500 口径:原全局值落到「当前生产账套」名下;--down 删新键并写回旧键)。
|
||||
ALIGNMENT_KEY_PREFIX = "alignment_config"
|
||||
|
||||
|
||||
def alignment_key(entity_id: int) -> str:
|
||||
"""账套维度的对齐模式键名:alignment_config.<entity_id>"""
|
||||
return f"{ALIGNMENT_KEY_PREFIX}.{int(entity_id)}"
|
||||
|
||||
|
||||
def _get_alignment_perm(db: Session, entity_id: int):
|
||||
"""读本账套的对齐模式配置行(不存在 → None)"""
|
||||
return db.query(RolePermission).filter(
|
||||
RolePermission.key == alignment_key(entity_id)
|
||||
).first()
|
||||
|
||||
|
||||
ALIGNMENT_MODES = [
|
||||
{
|
||||
"key": "vertical_decomposition",
|
||||
@@ -46,18 +73,25 @@ def list_modes():
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def get_alignment_config(db: Session = Depends(get_db)):
|
||||
"""获取当前系统对齐模式配置(公开接口,无需认证)"""
|
||||
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
|
||||
def get_alignment_config(entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db)):
|
||||
"""获取**当前账套**的对齐模式配置(B3(a) 拆账套,2026-09-22)
|
||||
|
||||
修复前:读全局唯一键 `alignment_config` → 任一账套改模式全平台生效。
|
||||
修复后:只读 `alignment_config.<entity_id>`;未配置 → configured=false。
|
||||
"""
|
||||
perm = _get_alignment_perm(db, entity_id)
|
||||
if not perm:
|
||||
return {
|
||||
"mode": None,
|
||||
"configured": False,
|
||||
"entity_id": entity_id,
|
||||
"modes": ALIGNMENT_MODES,
|
||||
}
|
||||
return {
|
||||
"mode": perm.value,
|
||||
"configured": True,
|
||||
"entity_id": entity_id,
|
||||
"modes": ALIGNMENT_MODES,
|
||||
}
|
||||
|
||||
@@ -65,45 +99,95 @@ def get_alignment_config(db: Session = Depends(get_db)):
|
||||
@router.post("/config")
|
||||
def set_alignment_config(
|
||||
data: dict,
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
user = Depends(require_role("ceo", "it")),
|
||||
):
|
||||
"""初始化/修改系统对齐模式(CEO/IT权限)"""
|
||||
"""初始化/修改**当前账套**的对齐模式(CEO/IT权限)
|
||||
|
||||
B3(a)(2026-09-22):只写 `alignment_config.<entity_id>`,不覆盖他账套;
|
||||
entity 来自会话 token,显式传他账套 entity_id → get_entity_id 403。
|
||||
"""
|
||||
mode_key = data.get("mode")
|
||||
if mode_key not in [m["key"] for m in ALIGNMENT_MODES]:
|
||||
raise HTTPException(400, f"无效的对齐模式: {mode_key}")
|
||||
|
||||
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
|
||||
perm = _get_alignment_perm(db, entity_id)
|
||||
if perm:
|
||||
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat()}
|
||||
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat(),
|
||||
"entity_id": entity_id}
|
||||
else:
|
||||
perm = RolePermission(key="alignment_config", value={"mode": mode_key, "set_at": datetime.now().isoformat()})
|
||||
perm = RolePermission(key=alignment_key(entity_id),
|
||||
value={"mode": mode_key, "set_at": datetime.now().isoformat(),
|
||||
"entity_id": entity_id})
|
||||
db.add(perm)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key}
|
||||
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key, "entity_id": entity_id}
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
def get_alignment_tree(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
map_id: Optional[int] = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
user = Depends(require_auth),
|
||||
):
|
||||
"""获取KPI对齐关系树
|
||||
"""获取KPI对齐关系树(按当前账套隔离)
|
||||
|
||||
根据当前系统配置的对齐模式,返回KPI的父子层级关系。
|
||||
如果指定kpi_id,返回该KPI及其下级树;
|
||||
如果不指定,返回整个对齐树。
|
||||
|
||||
账套隔离(2026-09-18 project-20260918-alignment-entity-leak):
|
||||
只取 entity_id 归属本账套的 KPI(entity_id 来自会话 token 绑定,写法对齐
|
||||
growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。
|
||||
跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。
|
||||
|
||||
地图筛选(2026-10-01 project-20261001-align-tree-map-filter-fullstack):
|
||||
`map_id` 参与**KPI 选择集**过滤,且只作用于 KPI 选择集——三种模式的构建函数不变。
|
||||
|
||||
过滤取「本账套该地图已关联的 KPI」,关联源按**权威程度**合并两条通道:
|
||||
① `map_objectives.kpis`(**战略地图编辑器**写入的 KPI 编码数组,真源
|
||||
`maps.py::_sync_map_objectives`;现网唯一有内容的通道);
|
||||
② `KPIDefinition.map_id`(KPI 详情页「关联地图」写入;现网 329 行全为 NULL)。
|
||||
两者取并集后与「本账套 `status='active'`」求交。跨账套/不存在的地图 id:先核该地图
|
||||
归属本账套,不属本账套即返回**空树**(不泄漏他账套 KPI)。
|
||||
不传 `map_id`:逐字段维持改前行为(本账套全部 active KPI)。
|
||||
"""
|
||||
# 获取当前模式
|
||||
config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
|
||||
# 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.<entity_id>)
|
||||
config_perm = _get_alignment_perm(db, entity_id)
|
||||
mode = config_perm.value.get("mode") if config_perm else None
|
||||
if not mode:
|
||||
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
|
||||
|
||||
# 获取所有KPI
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").order_by(KPIDefinition.kpi_code).all()
|
||||
# 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI)
|
||||
kpi_query = (db.query(KPIDefinition)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active"))
|
||||
# 地图筛选:只缩小 KPI 选择集,不改动任何构建逻辑
|
||||
empty_by_map = False
|
||||
if map_id is not None:
|
||||
owned = (db.query(StrategicMap.id)
|
||||
.filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id)
|
||||
.first())
|
||||
if not owned:
|
||||
empty_by_map = True # 跨账套 / 不存在 ⇒ 空树(不泄漏他账套 KPI)
|
||||
else:
|
||||
codes = set()
|
||||
for (arr,) in (db.query(MapObjective.kpis)
|
||||
.filter(MapObjective.map_id == map_id).all()):
|
||||
if isinstance(arr, list):
|
||||
codes.update(str(c) for c in arr if c)
|
||||
if codes:
|
||||
kpi_query = kpi_query.filter(or_(
|
||||
KPIDefinition.map_id == map_id,
|
||||
KPIDefinition.kpi_code.in_(sorted(codes)),
|
||||
))
|
||||
else:
|
||||
kpi_query = kpi_query.filter(KPIDefinition.map_id == map_id)
|
||||
kpis = [] if empty_by_map else kpi_query.order_by(KPIDefinition.kpi_code).all()
|
||||
kpi_map = {k.id: k for k in kpis}
|
||||
|
||||
# 构建父子关系
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""置信度评分系统 — 财务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 sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, inspect
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth
|
||||
from app.models import AnalysisResult, KPIValue, KPIDefinition, OperationLog
|
||||
from app.utils.alert_entity_scope import month_period_clause
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/cma/analysis", tags=["置信度评分"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
# `analysis_results.entity_id` 列存在性缓存(键 = engine 对象 id;DDL 前后同一进程内可能变化,
|
||||
# 故只缓存判定结果、不做进程级常量:迁移后重启服务即刷新)
|
||||
_ENTITY_COL_CACHE: dict = {}
|
||||
|
||||
|
||||
def _has_entity_column(db) -> bool:
|
||||
"""`analysis_results.entity_id` 是否已落地(DDL 迁移 2026-09-23 未执行时为 False)。
|
||||
|
||||
读侧止血必须先于 DDL 合入 ⇒ 不能无条件引用该列;列不在时按 `kpi_code` JOIN 判归属。
|
||||
"""
|
||||
bind = db.get_bind()
|
||||
key = id(bind)
|
||||
cached = _ENTITY_COL_CACHE.get(key)
|
||||
if cached is None:
|
||||
try:
|
||||
cols = {c["name"] for c in inspect(bind).get_columns(AnalysisResult.__tablename__)}
|
||||
cached = "entity_id" in cols
|
||||
except Exception: # noqa: BLE001 —— 结构探不通时按"列不存在"的保守口径走 join
|
||||
cached = False
|
||||
_ENTITY_COL_CACHE[key] = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _kpi_owners(db, codes: List[Optional[str]]) -> dict:
|
||||
"""`kpi_code` → 拥有该码的账套集合(同码可跨账套存在,故为集合)"""
|
||||
codes = [c for c in codes if c]
|
||||
if not codes:
|
||||
return {}
|
||||
owners: dict = {}
|
||||
rows = (
|
||||
db.query(KPIDefinition.kpi_code, KPIDefinition.entity_id)
|
||||
.filter(KPIDefinition.kpi_code.in_(codes))
|
||||
.all()
|
||||
)
|
||||
for code, eid in rows:
|
||||
if eid is not None:
|
||||
owners.setdefault(code, set()).add(eid)
|
||||
return owners
|
||||
|
||||
|
||||
def _row_owners(db, r, owners_map: dict) -> set:
|
||||
"""单行归属账套集合;空集 = 判不出归属(不猜账套)
|
||||
|
||||
列口径:`entity_id` 非 NULL 即为唯一归属。
|
||||
join 口径(列未落地 / 列为 NULL 的历史行):只有该 `kpi_code` 在 `kpi_definitions`
|
||||
中**唯一归属单账套**时才认定——同码跨账套(库内 22 个码属两账套)属归属二义,
|
||||
按「不猜账套」进登记池,宁可不可见也不误判给他账套。
|
||||
"""
|
||||
if _has_entity_column(db):
|
||||
eid = getattr(r, "entity_id", None)
|
||||
if eid is not None:
|
||||
return {eid}
|
||||
if r.kpi_code:
|
||||
owners = owners_map.get(r.kpi_code, set())
|
||||
return owners if len(owners) == 1 else set()
|
||||
return set()
|
||||
|
||||
|
||||
def _to_dict(r) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"period": r.period,
|
||||
"结论": r.conclusion,
|
||||
"置信度": f"{r.confidence}%",
|
||||
"数据来源": r.data_source,
|
||||
"计算逻辑": r.calculation_logic,
|
||||
"可比基准": r.comparable_benchmark,
|
||||
"局限": r.limitations,
|
||||
"kpi_code": r.kpi_code,
|
||||
"kpi_name": r.kpi_name,
|
||||
"has_actual": bool(r.has_actual),
|
||||
"has_target": bool(r.has_target),
|
||||
"has_trend": bool(r.has_trend),
|
||||
"has_review": bool(r.has_review),
|
||||
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _unattributed_dict(r) -> dict:
|
||||
"""无归属登记项:只回标识与元信息,不回结论正文(登记而非共享)"""
|
||||
return {
|
||||
"id": r.id,
|
||||
"period": r.period,
|
||||
"kpi_code": r.kpi_code,
|
||||
"kpi_name": r.kpi_name,
|
||||
"confidence": r.confidence,
|
||||
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _calc_confidence(has_actual: bool, has_target: bool, has_trend: bool, has_review: bool) -> int:
|
||||
"""基于数据完整度自动计算置信度"""
|
||||
if has_review:
|
||||
return 95
|
||||
if has_actual and has_target and has_trend:
|
||||
return 85
|
||||
if has_actual and has_target:
|
||||
return 70
|
||||
if has_actual:
|
||||
return 50
|
||||
return 30 # 无实际值,基于推测
|
||||
|
||||
|
||||
@router.get("/result")
|
||||
async def get_analysis_results(
|
||||
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
|
||||
kpi_code: Optional[str] = Query(None, description="KPI编码"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""查询分析结论(按期间和/或KPI编码过滤)— 账套隔离(2026-09-23)
|
||||
|
||||
返回体字段:原有字段全保留(`total` 语义收窄为「本账套可见行数」,`results` 仍为列表),
|
||||
新增 `entity_id` / `unattributed_total` / `unattributed` / `entity_scope`(均为附加字段,前端契约不破坏)。
|
||||
无归属行(存量 id=1/2)进 `unattributed`:**对任何账套都不进 `results`**,只作登记。
|
||||
"""
|
||||
query = db.query(AnalysisResult).order_by(AnalysisResult.created_at.desc())
|
||||
|
||||
if period:
|
||||
query = query.filter(AnalysisResult.period == period)
|
||||
if kpi_code:
|
||||
query = query.filter(AnalysisResult.kpi_code == kpi_code)
|
||||
|
||||
results = query.all()
|
||||
owners_map = _kpi_owners(db, [r.kpi_code for r in results])
|
||||
mine, unattributed = [], []
|
||||
for r in results:
|
||||
owners = _row_owners(db, r, owners_map)
|
||||
if not owners:
|
||||
unattributed.append(r)
|
||||
elif entity_id in owners:
|
||||
mine.append(r)
|
||||
|
||||
return {
|
||||
"total": len(mine),
|
||||
"period": period,
|
||||
"entity_id": entity_id,
|
||||
"entity_scope": "column" if _has_entity_column(db) else "kpi_join",
|
||||
"unattributed_total": len(unattributed),
|
||||
"unattributed": [_unattributed_dict(r) for r in unattributed],
|
||||
"results": [_to_dict(r) for r in mine],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/result")
|
||||
def create_analysis_result(data: dict = None, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""提交分析结果(自动计算置信度)— 账套维度:本次写入挂**会话账套**
|
||||
|
||||
`entity_id` 列未落地时(DDL 迁移前)先把会话账套写进 `OperationLog.detail.entity_id`,
|
||||
列落地后直接写列(响应始终回 `entity_id`)。
|
||||
"""
|
||||
if not data:
|
||||
data = {}
|
||||
period = data.get("period", "")
|
||||
conclusion = data.get("conclusion", "")
|
||||
data_source = data.get("data_source", "")
|
||||
calculation_logic = data.get("calculation", "")
|
||||
comparable_benchmark = data.get("comparable_benchmark")
|
||||
limitations = data.get("limitations")
|
||||
kpi_code = data.get("kpi_code")
|
||||
kpi_name = data.get("kpi_name")
|
||||
has_actual = data.get("has_actual", False)
|
||||
has_target = data.get("has_target", False)
|
||||
has_trend = data.get("has_trend", False)
|
||||
has_review = data.get("has_review", False)
|
||||
|
||||
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
|
||||
|
||||
has_column = _has_entity_column(db)
|
||||
result = AnalysisResult(
|
||||
period=period,
|
||||
conclusion=conclusion,
|
||||
confidence=confidence,
|
||||
data_source=data_source,
|
||||
calculation_logic=calculation_logic,
|
||||
comparable_benchmark=comparable_benchmark,
|
||||
limitations=limitations,
|
||||
kpi_code=kpi_code,
|
||||
kpi_name=kpi_name,
|
||||
has_actual=1 if has_actual else 0,
|
||||
has_target=1 if has_target else 0,
|
||||
has_trend=1 if has_trend else 0,
|
||||
has_review=1 if has_review else 0,
|
||||
)
|
||||
if has_column:
|
||||
result.entity_id = entity_id
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
|
||||
if not has_column:
|
||||
# DDL 未落地前的过渡留痕:会话账套只存在于日志,不猜列
|
||||
import json
|
||||
db.add(OperationLog(
|
||||
user_id=None, action="create", target_type="analysis_result",
|
||||
target_id=result.id,
|
||||
detail=json.dumps({"entity_id": entity_id, "kpi_code": kpi_code,
|
||||
"period": period,
|
||||
"note": "analysis_results.entity_id 列未落地,账套暂存于日志"},
|
||||
ensure_ascii=False),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"id": result.id,
|
||||
"period": result.period,
|
||||
"结论": result.conclusion,
|
||||
"置信度": f"{result.confidence}%",
|
||||
"数据来源": result.data_source,
|
||||
"计算逻辑": result.calculation_logic,
|
||||
"可比基准": result.comparable_benchmark,
|
||||
"局限": result.limitations,
|
||||
"confidence_score": result.confidence,
|
||||
"entity_id": entity_id,
|
||||
"entity_persisted": "column" if has_column else "operation_log",
|
||||
"message": "分析结论已保存",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/result/{result_id}")
|
||||
async def delete_analysis_result(
|
||||
result_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""删除分析结论 — 归属校验:非本账套 或 无归属一律 **404**(不引入 403,避免探测面)"""
|
||||
result = db.query(AnalysisResult).filter(AnalysisResult.id == result_id).first()
|
||||
if not result:
|
||||
raise HTTPException(404, "分析结论不存在")
|
||||
owners = _row_owners(db, result, _kpi_owners(db, [result.kpi_code]))
|
||||
if entity_id not in owners:
|
||||
# 他账套 id 与无归属行走同一文案(不暴露"该行存在但不属于你")
|
||||
raise HTTPException(404, "分析结论不存在")
|
||||
db.delete(result)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.post("/auto-calculate")
|
||||
async def auto_calculate_confidence(
|
||||
period: str = Query(..., description="期间 YYYY-MM"),
|
||||
kpi_code: str = Query(..., description="KPI编码"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""根据KPI数据完整性自动生成置信度评分"""
|
||||
# 查找KPI定义
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI编码 {kpi_code} 不存在")
|
||||
|
||||
# 查找该期间的实际值
|
||||
value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
|
||||
# 查找历史数据(趋势)
|
||||
trend_values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
month_period_clause(KPIValue.period),
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
|
||||
has_actual = value is not None and value.actual_value is not None
|
||||
has_target = kpi.target_value is not None
|
||||
has_trend = len(trend_values) >= 2
|
||||
has_review = False
|
||||
|
||||
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
|
||||
|
||||
return {
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"period": period,
|
||||
"has_actual": has_actual,
|
||||
"has_target": has_target,
|
||||
"has_trend": has_trend,
|
||||
"has_review": has_review,
|
||||
"confidence": confidence,
|
||||
"confidence_label": f"{confidence}%",
|
||||
"数据完备度": {
|
||||
"10%": "无数据",
|
||||
"50%": "有实际值",
|
||||
"70%": "有实际值+目标值",
|
||||
"85%": "有实际值+目标值+历史趋势",
|
||||
"95%": "有全部数据+人工复核",
|
||||
}.get(str(confidence), "基于推测"),
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Bot API 操作审计中间件
|
||||
======================
|
||||
|
||||
每次 Bot API 调用(/api/cma/bot*)记录一行 JSON 审计日志到
|
||||
backend/logs/bot_audit.log(可用环境变量 CMA_BOT_AUDIT_LOG 覆盖路径)。
|
||||
|
||||
JSON 行字段: timestamp / bot_name / endpoint / method / risk_level /
|
||||
entity_id / status
|
||||
L3 批量写额外记录 rows(导入行数 / 批量条数)。
|
||||
|
||||
设计约束:
|
||||
- 不阻塞业务: 所有日志写入失败仅静默跳过,不影响请求结果
|
||||
- 只读 body(starlette 会缓存并回放给下游),仅在 application/json 时解析
|
||||
- 不改动任何现有 API 行为与多租户隔离
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
_LOG_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"logs",
|
||||
)
|
||||
_DEFAULT_LOG_PATH = os.path.join(_LOG_DIR, "bot_audit.log")
|
||||
|
||||
_audit_logger = None
|
||||
|
||||
|
||||
def _get_logger():
|
||||
"""构造/复用审计 logger(路径取 CMA_BOT_AUDIT_LOG 覆盖值,便于测试隔离)"""
|
||||
global _audit_logger
|
||||
if _audit_logger is None:
|
||||
log_path = os.getenv("CMA_BOT_AUDIT_LOG") or _DEFAULT_LOG_PATH
|
||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||
logger = logging.getLogger("cma.bot_audit")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
# 清掉旧 handler,避免日志名单例导致路径切换失效
|
||||
for h in list(logger.handlers):
|
||||
logger.removeHandler(h)
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
_audit_logger = logger
|
||||
return _audit_logger
|
||||
|
||||
|
||||
def write_audit_line(record: dict):
|
||||
"""写一行 JSON 审计日志;任何异常都静默(不阻塞业务)"""
|
||||
try:
|
||||
_get_logger().info(json.dumps(record, ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_bot_name(request: Request) -> str:
|
||||
"""解析调用方标识: X-BOT-KEY → 配置名/原始key;bridge → bot-bridge;其余 → web-user/unknown"""
|
||||
key = request.headers.get("X-BOT-KEY")
|
||||
if key:
|
||||
try:
|
||||
from app.api.bot_bridge import _BOT_API_KEYS
|
||||
info = _BOT_API_KEYS.get(key)
|
||||
if isinstance(info, dict) and info.get("name"):
|
||||
return info["name"]
|
||||
except Exception:
|
||||
pass
|
||||
return key
|
||||
if request.headers.get("X-BRIDGE-TOKEN"):
|
||||
return "bot-bridge"
|
||||
if (request.headers.get("Authorization") or "").startswith("Bearer "):
|
||||
return "web-user"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _resolve_risk_level(request: Request, route=None, endpoint=None):
|
||||
"""级别解析顺序: 路由函数装饰器标注 → API_RISK_MAP(route.path) → API_RISK_MAP(请求URL)"""
|
||||
if endpoint is not None:
|
||||
level = getattr(endpoint, "risk_level", None)
|
||||
if level:
|
||||
return level
|
||||
if route is not None:
|
||||
from app.risk_levels import get_risk_level
|
||||
path = getattr(route, "path", None)
|
||||
if path:
|
||||
for m in getattr(route, "methods", set()) or set():
|
||||
if m in ("GET", "POST", "PUT", "DELETE", "PATCH"):
|
||||
level = get_risk_level(m, path)
|
||||
if level:
|
||||
return level
|
||||
try:
|
||||
from app.risk_levels import get_risk_level
|
||||
return get_risk_level(request.method, request.url.path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _extract_entity_id(request: Request):
|
||||
"""多租户 entity_id 提取(尽力而为): 查询参数 → JSON body(仅 application/json)"""
|
||||
try:
|
||||
q = request.query_params.get("entity_id")
|
||||
if q is not None and str(q) != "":
|
||||
return int(q) if str(q).isdigit() else q
|
||||
except Exception:
|
||||
pass
|
||||
ctype = (request.headers.get("content-type") or "").lower()
|
||||
if "application/json" in ctype:
|
||||
try:
|
||||
raw = await request.body()
|
||||
if raw:
|
||||
data = json.loads(raw)
|
||||
eid = data.get("entity_id")
|
||||
if eid is not None:
|
||||
return int(eid) if str(eid).isdigit() else eid
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _extract_l3_rows(level, resp_body: bytes):
|
||||
"""L3 批量写: 从响应体提取行数/条数(imported / kpi_updated / causality_links / total)"""
|
||||
if level != "L3" or not resp_body:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(resp_body)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
for key in ("imported", "kpi_updated", "upserted"):
|
||||
v = data.get(key)
|
||||
if isinstance(v, (int, float)):
|
||||
return int(v)
|
||||
links = data.get("causality_links")
|
||||
if isinstance(links, list):
|
||||
return len(links)
|
||||
total = data.get("total")
|
||||
if isinstance(total, (int, float)):
|
||||
return int(total)
|
||||
return None
|
||||
|
||||
|
||||
async def bot_audit_middleware(request: Request, call_next):
|
||||
"""HTTP 中间件: 仅审计 /api/cma/bot* 路径;任何异常不影响业务"""
|
||||
path = request.url.path
|
||||
if not path.startswith("/api/cma/bot"):
|
||||
return await call_next(request)
|
||||
|
||||
entity_id = None
|
||||
try:
|
||||
entity_id = await _extract_entity_id(request)
|
||||
except Exception:
|
||||
entity_id = None
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
# 兜底记录(全局异常handler会返回500,此处保证审计不丢)
|
||||
try:
|
||||
write_audit_line({
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
"bot_name": _resolve_bot_name(request),
|
||||
"endpoint": path,
|
||||
"method": request.method,
|
||||
"risk_level": _resolve_risk_level(request) or "NA",
|
||||
"entity_id": entity_id,
|
||||
"status": 500,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
# 级别解析(route 由路由器在 call_next 内写入 scope)
|
||||
level = None
|
||||
try:
|
||||
route = request.scope.get("route")
|
||||
endpoint = getattr(route, "endpoint", None) if route else None
|
||||
level = _resolve_risk_level(request, route=route, endpoint=endpoint)
|
||||
except Exception:
|
||||
level = None
|
||||
|
||||
# 捕获响应体(L3 需要行数),并重放
|
||||
resp_body = b""
|
||||
try:
|
||||
body_iterator = getattr(response, "body_iterator", None)
|
||||
if body_iterator is not None:
|
||||
chunks = [chunk async for chunk in body_iterator]
|
||||
resp_body = b"".join(chunks)
|
||||
rows = _extract_l3_rows(level, resp_body)
|
||||
except Exception:
|
||||
rows = None
|
||||
|
||||
record = {
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
"bot_name": _resolve_bot_name(request),
|
||||
"endpoint": path,
|
||||
"method": request.method,
|
||||
"risk_level": level or "NA",
|
||||
"entity_id": entity_id,
|
||||
"status": response.status_code,
|
||||
}
|
||||
if rows is not None:
|
||||
record["rows"] = rows
|
||||
|
||||
try:
|
||||
write_audit_line(record)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 响应体已被消费 → 重建响应(Bot接口均为小JSON,非流式)
|
||||
if resp_body:
|
||||
from fastapi.responses import Response
|
||||
return Response(
|
||||
content=resp_body,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.media_type,
|
||||
)
|
||||
return response
|
||||
+170
-9
@@ -1,13 +1,38 @@
|
||||
"""用户认证"""
|
||||
import hashlib
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import User
|
||||
from app.auth_middleware import create_token, require_auth, ROLES
|
||||
from app.models import User, UserEntity, Entity, OperationLog
|
||||
from app.auth_middleware import create_token, require_auth, require_auth_401, ROLES
|
||||
|
||||
router = APIRouter(prefix="/api/cma/auth", tags=["认证"])
|
||||
|
||||
# ── 可登录账套状态(2026-09-17 yanxue-20260916-cma-demo-account)─────────────
|
||||
# active = 正式账套;demo = 演示账套(虚构数据,专供演示账号登录)。
|
||||
# 为何单列「demo」而不是把演示企业改成 active:daily_push / 预警检查等批处理按
|
||||
# `Entity.status == "active"` 遍历全部实体,演示实体若为 active 会被并入真实
|
||||
# 推送与预警(污染业务通道)。故演示账号只在**登录链**上被放行「demo」状态,
|
||||
# 批处理口径(active-only)保持不变。
|
||||
LOGIN_ENTITY_STATUSES = ("active", "demo")
|
||||
|
||||
|
||||
def _user_entity_ids(db: Session, user_id: int) -> list[int]:
|
||||
"""返回用户被授权的企业ID列表"""
|
||||
rows = db.query(UserEntity.entity_id).filter(UserEntity.user_id == user_id).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _ensure_default_grants(db: Session, user_id: int) -> None:
|
||||
"""存量用户兼容:若用户没有任何授权记录,则授予所有active企业(不锁死老账号)"""
|
||||
cnt = db.query(UserEntity).filter(UserEntity.user_id == user_id).count()
|
||||
if cnt > 0:
|
||||
return
|
||||
entities = db.query(Entity).filter(Entity.status == "active").all()
|
||||
for e in entities:
|
||||
db.add(UserEntity(user_id=user_id, entity_id=e.id, granted_by=1))
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(data: dict, db: Session = Depends(get_db)):
|
||||
@@ -17,9 +42,28 @@ def login(data: dict, db: Session = Depends(get_db)):
|
||||
if not user or user.password_hash != hashlib.sha256(password.encode()).hexdigest():
|
||||
raise HTTPException(401, "用户名或密码错误")
|
||||
|
||||
token = create_token(user.id)
|
||||
# 账套模式:登录必须指定企业(entity_id)
|
||||
entity_id = data.get("entity_id")
|
||||
if entity_id is None:
|
||||
raise HTTPException(400, "账套模式:请选择登录企业(entity_id)")
|
||||
|
||||
# 存量兼容:无授权记录时自动授予active企业
|
||||
_ensure_default_grants(db, user.id)
|
||||
|
||||
allowed = _user_entity_ids(db, user.id)
|
||||
if int(entity_id) not in allowed:
|
||||
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
|
||||
|
||||
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
|
||||
if not entity or entity.status not in LOGIN_ENTITY_STATUSES:
|
||||
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
|
||||
|
||||
token = create_token(user.id, int(entity_id))
|
||||
return {
|
||||
"token": token,
|
||||
"entity_id": int(entity_id),
|
||||
"entity_name": entity.name,
|
||||
"entity_short_name": entity.short_name,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
@@ -43,13 +87,23 @@ def register(data: dict, db: Session = Depends(get_db)):
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
# 注册默认授权第一个active企业(不授予全部,保证账套隔离)
|
||||
first_ent = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).first()
|
||||
if first_ent:
|
||||
db.add(UserEntity(user_id=user.id, entity_id=first_ent.id))
|
||||
db.commit()
|
||||
return {"message": "注册成功"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_me(current_user: User = Depends(require_auth)):
|
||||
"""获取当前用户信息"""
|
||||
return {
|
||||
def get_me(request: Request, current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""获取当前用户信息(含当前账套)"""
|
||||
from app.auth_middleware import extract_bearer_token, get_token_entity_id
|
||||
token = extract_bearer_token(request)
|
||||
eid = get_token_entity_id(token) if token else None
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first() if eid else None
|
||||
d = {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"name": current_user.name,
|
||||
@@ -57,14 +111,121 @@ def get_me(current_user: User = Depends(require_auth)):
|
||||
"role_name": ROLES.get(current_user.role, {}).get("name", current_user.role),
|
||||
"phone": current_user.phone,
|
||||
}
|
||||
if ent:
|
||||
d["entity_id"] = ent.id
|
||||
d["entity_name"] = ent.name
|
||||
d["entity_short_name"] = ent.short_name
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/roles")
|
||||
def list_roles():
|
||||
"""返回角色列表(给前端用)"""
|
||||
def list_roles(_=Depends(require_auth_401)):
|
||||
"""返回角色列表(给前端用)
|
||||
|
||||
2026-09-14 安全修复(cma-api-auth-surface-audit-20260914):原无鉴权公开返回角色
|
||||
枚举(对外暴露系统角色模型 + 便于针对性撞库)。仓内检索确认无任何前端/脚本调用方
|
||||
(前端用 /permissions/modules 的 roles 字段),故直接补 require_auth_401。
|
||||
"""
|
||||
return {
|
||||
"data": [
|
||||
{"code": k, "name": v["name"], "priority": v["priority"]}
|
||||
for k, v in ROLES.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/entities")
|
||||
def my_entities(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""当前用户被授权的企业列表(登录页下拉/切换器数据源)"""
|
||||
_ensure_default_grants(db, current_user.id)
|
||||
ids = _user_entity_ids(db, current_user.id)
|
||||
entities = db.query(Entity).filter(
|
||||
Entity.id.in_(ids),
|
||||
Entity.status.in_(LOGIN_ENTITY_STATUSES),
|
||||
).order_by(Entity.id).all()
|
||||
return {
|
||||
"data": [
|
||||
{"id": e.id, "name": e.name, "short_name": e.short_name, "industry": e.industry}
|
||||
for e in entities
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/my-entities")
|
||||
def my_entities_alias(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
"""别名:/auth/my-entities(前端切换器调用)"""
|
||||
return my_entities(current_user, db)
|
||||
|
||||
|
||||
@router.get("/login-entities")
|
||||
def login_entities_options(username: str = None, db: Session = Depends(get_db)):
|
||||
"""登录页公司选择器:按用户名返回授权企业(无鉴权,登录前调用;不暴露用户名是否存在)"""
|
||||
if not username:
|
||||
return {"data": []}
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return {"data": []}
|
||||
_ensure_default_grants(db, user.id)
|
||||
ids = _user_entity_ids(db, user.id)
|
||||
ents = db.query(Entity).filter(
|
||||
Entity.id.in_(ids),
|
||||
Entity.status.in_(LOGIN_ENTITY_STATUSES),
|
||||
).order_by(Entity.id).all()
|
||||
return {"data": [{"id": e.id, "name": e.name, "short_name": e.short_name} for e in ents]}
|
||||
|
||||
|
||||
@router.post("/switch-entity")
|
||||
def switch_entity(
|
||||
data: dict,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""账套切换:校验授权 → 重新签发token → 写操作日志(方案B:无缝刷新token)
|
||||
|
||||
DEPRECATED(cma-cross-entity-endpoints-auth-20260914,P3):
|
||||
自 2026-09-14 移除应用内企业切换器后,前端已无任何调用(换企业 = 退出重登)。
|
||||
端点保留仅为兼容存量脚本/回滚(不删);新代码请改用「重新登录携带 entity_id」。
|
||||
注:本端点曾用于切换会话账套;当前账套口径以登录 token 绑定为准。
|
||||
"""
|
||||
entity_id = data.get("entity_id")
|
||||
if not entity_id:
|
||||
raise HTTPException(400, "缺少entity_id")
|
||||
|
||||
_ensure_default_grants(db, current_user.id)
|
||||
allowed = _user_entity_ids(db, current_user.id)
|
||||
if int(entity_id) not in allowed:
|
||||
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
|
||||
|
||||
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
|
||||
if not entity or entity.status not in LOGIN_ENTITY_STATUSES:
|
||||
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
|
||||
|
||||
# 旧token失效:删除当前请求的旧token(切换即下线旧账套凭据)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
try:
|
||||
import redis as redis_lib
|
||||
r = redis_lib.Redis(host="127.0.0.1", port=6379, db=1, decode_responses=True)
|
||||
r.delete(f"cma:token:{auth[7:]}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
token = create_token(current_user.id, int(entity_id))
|
||||
|
||||
# 切换留痕
|
||||
db.add(OperationLog(
|
||||
user_id=current_user.id,
|
||||
action="switch_entity",
|
||||
target_type="entity",
|
||||
target_id=int(entity_id),
|
||||
detail={"entity_id": int(entity_id), "entity_name": entity.name, "from": "account-switch"},
|
||||
))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"entity_id": int(entity_id),
|
||||
"entity_name": entity.name,
|
||||
"entity_short_name": entity.short_name,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""BI报表集成 — 任务4
|
||||
分析模式 + 预置报表模板 + 报表保存/分享 + 导出
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
|
||||
from app.utils.oplog import log_op
|
||||
from app.utils.alert_entity_scope import month_period_clause
|
||||
|
||||
logger = logging.getLogger("bi-reports")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bi-reports", tags=["BI报表"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 预置模板
|
||||
# ============================================================
|
||||
|
||||
PRESET_TEMPLATES = [
|
||||
{
|
||||
"name": "四层指标总览",
|
||||
"report_type": "overview",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "展示财务/客户/流程/学习四层维度的关键KPI概览",
|
||||
"layout": "grid",
|
||||
"dimensions": ["finance", "customer", "process", "learning"],
|
||||
"metrics": ["count", "avg_value", "alert_count"],
|
||||
"chart_type": "gauge_card",
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "同比趋势分析",
|
||||
"report_type": "trend",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "各KPI近12个月趋势对比",
|
||||
"period": "monthly",
|
||||
"window_months": 12,
|
||||
"chart_type": "line",
|
||||
"show_compare": True,
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "实际vs预算对比",
|
||||
"report_type": "comparison",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "KPI实际值 vs 目标值的偏差分析",
|
||||
"chart_type": "bar",
|
||||
"show_deviation": True,
|
||||
"group_by": "dimension",
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "TOP N异常KPI",
|
||||
"report_type": "topn",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "排名前N的异常KPI(红/黄灯)",
|
||||
"top_n": 10,
|
||||
"sort_by": "deviation",
|
||||
"chart_type": "horizontal_bar",
|
||||
"show_threshold": True,
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "因果链推演",
|
||||
"report_type": "causality",
|
||||
"is_system": 1,
|
||||
"config": {
|
||||
"description": "基于KPI因果链的推演分析",
|
||||
"chart_type": "force_graph",
|
||||
"max_depth": 3,
|
||||
"min_strength": 0.3,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def list_report_templates(db: Session = Depends(get_db)):
|
||||
"""获取BI报表模板"""
|
||||
templates = db.query(BiReportTemplate).order_by(BiReportTemplate.id).all()
|
||||
return {"data": [{c.name: getattr(t, c.name) for c in BiReportTemplate.__table__.columns} for t in templates]}
|
||||
|
||||
|
||||
@router.post("/templates/seed")
|
||||
def seed_report_templates(db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""初始化预置模板(仅首次运行)"""
|
||||
created = 0
|
||||
for tpl in PRESET_TEMPLATES:
|
||||
existing = db.query(BiReportTemplate).filter(
|
||||
BiReportTemplate.name == tpl["name"],
|
||||
BiReportTemplate.is_system == 1,
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
t = BiReportTemplate(**tpl)
|
||||
db.add(t)
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"message": f"新增{created}个预置模板", "created": created}
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
t = db.query(BiReportTemplate).filter(BiReportTemplate.id == template_id).first()
|
||||
if t:
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 用户报表
|
||||
# ============================================================
|
||||
|
||||
@router.get("")
|
||||
def list_reports(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取用户保存的报表(账套隔离: 按token企业, 2026-08-23 P2)"""
|
||||
reports = db.query(BiReport).filter(BiReport.entity_id == entity_id).order_by(BiReport.updated_at.desc()).all()
|
||||
result = []
|
||||
for r in reports:
|
||||
d = {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
d["created_by_name"] = f"用户{r.created_by}" if r.created_by else "系统"
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/{report_id}")
|
||||
def get_report(report_id: int, db: Session = Depends(get_db)):
|
||||
r = db.query(BiReport).filter(BiReport.id == report_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报表不存在")
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""保存BI报表"""
|
||||
r = BiReport(
|
||||
template_id=data.get("template_id"),
|
||||
name=data.get("name", "未命名报表"),
|
||||
config=data.get("config", {}),
|
||||
chart_type=data.get("chart_type", "auto"),
|
||||
is_shared=data.get("is_shared", 0),
|
||||
created_by=1,
|
||||
)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
log_op(db, "create", target_type="bi_report", target_id=r.id,
|
||||
detail=f"创建报表: {r.name}", entity_id=getattr(r, "entity_id", None))
|
||||
db.commit()
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@router.put("/{report_id}")
|
||||
def update_report(report_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
r = db.query(BiReport).filter(BiReport.id == report_id).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报表不存在")
|
||||
for field in ("name", "config", "chart_type", "is_shared"):
|
||||
if field in data:
|
||||
setattr(r, field, data[field])
|
||||
db.commit()
|
||||
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
|
||||
|
||||
|
||||
@router.delete("/{report_id}")
|
||||
def delete_report(report_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
r = db.query(BiReport).filter(BiReport.id == report_id).first()
|
||||
if r:
|
||||
db.delete(r)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 分析引擎
|
||||
# ============================================================
|
||||
|
||||
@router.post("/analyze")
|
||||
def analyze_data(data: dict, db: Session = Depends(get_db)):
|
||||
"""分析引擎:按配置返回报表数据
|
||||
Body: {
|
||||
config: { dimensions, kpi_ids, period_start, period_end, group_by, metrics, ... },
|
||||
chart_type: str
|
||||
}
|
||||
"""
|
||||
config = data.get("config", {})
|
||||
chart_type = data.get("chart_type", "auto")
|
||||
|
||||
kpi_ids = config.get("kpi_ids", [])
|
||||
dimensions = config.get("dimensions", [])
|
||||
period_start = config.get("period_start")
|
||||
period_end = config.get("period_end")
|
||||
group_by = config.get("group_by")
|
||||
top_n = config.get("top_n", 10)
|
||||
|
||||
# 构建KPI查询
|
||||
kpi_query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
|
||||
if kpi_ids:
|
||||
kpi_query = kpi_query.filter(KPIDefinition.id.in_(kpi_ids))
|
||||
if dimensions:
|
||||
kpi_query = kpi_query.filter(KPIDefinition.dimension.in_(dimensions))
|
||||
kpis = kpi_query.order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
# 获取每个KPI的最新值
|
||||
rows = []
|
||||
for kpi in kpis:
|
||||
val_query = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period_start:
|
||||
val_query = val_query.filter(KPIValue.period >= period_start)
|
||||
if period_end:
|
||||
val_query = val_query.filter(KPIValue.period <= period_end)
|
||||
|
||||
latest = val_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
# 获取趋势数据
|
||||
trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all()
|
||||
|
||||
rows.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"unit": kpi.unit,
|
||||
"target_value": kpi.target_value,
|
||||
"threshold_green": kpi.threshold_green,
|
||||
"threshold_yellow": kpi.threshold_yellow,
|
||||
"threshold_red": kpi.threshold_red,
|
||||
"current_value": latest.actual_value if latest else None,
|
||||
"current_period": latest.period if latest else None,
|
||||
"trend": [{"period": v.period, "value": v.actual_value} for v in trend_values],
|
||||
})
|
||||
|
||||
# 统计汇总
|
||||
summary = {
|
||||
"total_kpis": len(rows),
|
||||
"dimensions": {},
|
||||
}
|
||||
for r in rows:
|
||||
dim = r["dimension"]
|
||||
if dim not in summary["dimensions"]:
|
||||
summary["dimensions"][dim] = {"count": 0, "values": []}
|
||||
summary["dimensions"][dim]["count"] += 1
|
||||
if r["current_value"] is not None:
|
||||
summary["dimensions"][dim]["values"].append(r["current_value"])
|
||||
|
||||
for dim, info in summary["dimensions"].items():
|
||||
vals = info["values"]
|
||||
if vals:
|
||||
info["avg"] = round(sum(vals) / len(vals), 2)
|
||||
info["min"] = min(vals)
|
||||
info["max"] = max(vals)
|
||||
del info["values"]
|
||||
|
||||
return {
|
||||
"config": config,
|
||||
"chart_type": chart_type,
|
||||
"rows": rows,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 导出功能(CSV格式,前端可转为Excel/PDF)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/export")
|
||||
def export_report(data: dict, db: Session = Depends(get_db)):
|
||||
"""导出报表数据 (CSV)"""
|
||||
config = data.get("config", {})
|
||||
format_type = data.get("format", "csv")
|
||||
|
||||
# 复用analyze获取数据
|
||||
from app.database import get_session_local
|
||||
temp_db = get_session_local()()
|
||||
try:
|
||||
result = analyze_data(data, temp_db)
|
||||
finally:
|
||||
temp_db.close()
|
||||
|
||||
rows = result.get("rows", [])
|
||||
if not rows:
|
||||
raise HTTPException(400, "没有可导出的数据")
|
||||
|
||||
# 生成CSV
|
||||
import csv, io
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["KPI编码", "KPI名称", "维度", "类别", "当前值", "期间", "目标值", "单位"])
|
||||
for r in rows:
|
||||
writer.writerow([
|
||||
r["kpi_code"], r["kpi_name"], r["dimension"], r["category"],
|
||||
r["current_value"], r["current_period"], r["target_value"], r["unit"],
|
||||
])
|
||||
|
||||
csv_content = output.getvalue()
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename=bi_report_{datetime.now().strftime('%Y%m%d')}.csv"},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,689 @@
|
||||
"""
|
||||
Bot-Bridge V2 — 合并改造:Bot桥接 + 自动验证引擎
|
||||
|
||||
数据流:
|
||||
bot-bridge(财务Bot→CMA)→ 写入KPI
|
||||
verify(CMA→校验)→ 读取KPI → 通过/失败
|
||||
|
||||
API:
|
||||
POST /api/cma/bot-bridge/mpm-result — 接收MPM分析结果
|
||||
POST /api/cma/bot-bridge/verify/{action_plan_id} — 验证ActionPlan执行结果
|
||||
"""
|
||||
import json, logging, re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_bot_entity_id
|
||||
from app.models import (
|
||||
MpmResult, BotBridgeConfig,
|
||||
KPIDefinition, KPIValue, KPIAlert,
|
||||
ActionPlan, Entity, OperationLog,
|
||||
)
|
||||
from app.risk_levels import risk_level
|
||||
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
|
||||
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
|
||||
|
||||
logger = logging.getLogger("cma.bot_bridge_v2")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot-bridge", tags=["Bot桥接V2"])
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 鉴权
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def verify_bridge_token(
|
||||
x_bridge_token: str = Header(None, alias="X-BRIDGE-TOKEN"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""从bot_bridge_config表校验Token"""
|
||||
if not x_bridge_token:
|
||||
raise HTTPException(401, "缺少X-BRIDGE-TOKEN请求头")
|
||||
config = db.query(BotBridgeConfig).filter(
|
||||
BotBridgeConfig.token == x_bridge_token,
|
||||
BotBridgeConfig.is_active == True,
|
||||
).first()
|
||||
if not config:
|
||||
raise HTTPException(401, "Token无效或已停用")
|
||||
return config.bot_name
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 验证引擎
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _get_kpi_current_value(db: Session, kpi_id: int, entity_id: int = None) -> Optional[dict]:
|
||||
"""获取KPI当前最新值及目标值"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
return None
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.data_status == "verified",
|
||||
).order_by(KPIValue.calculated_at.desc(), KPIValue.period.desc()).first()
|
||||
return {
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"value": latest.actual_value if latest else None,
|
||||
"target": kpi.target_value,
|
||||
"period": latest.period if latest else None,
|
||||
"unit": kpi.unit,
|
||||
}
|
||||
|
||||
|
||||
def _parse_condition(condition: str) -> list:
|
||||
"""
|
||||
解析condition表达式为可执行结构
|
||||
|
||||
支持格式:
|
||||
"value > target" — 当前值 > 目标值
|
||||
"value >= 80" — 当前值 >= 80 (绝对值)
|
||||
"value / target * 100 > 80" — 完成率 > 80%
|
||||
"value < baseline" — 当前值 < 基线值(目标值=基线)
|
||||
"value == 0" — 精确等于
|
||||
"value > target AND value < 100" — 多条件AND
|
||||
"value > 50 OR value == -1" — 多条件OR
|
||||
"""
|
||||
if not condition:
|
||||
return []
|
||||
|
||||
# 分割AND/OR
|
||||
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
|
||||
clauses = []
|
||||
for i in range(0, len(parts), 2):
|
||||
expr = parts[i].strip()
|
||||
# 检查是否有运算符连接
|
||||
match = re.match(
|
||||
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)(?:\s*([+\-*/])\s*(\d+\.?\d*))?',
|
||||
expr
|
||||
)
|
||||
if not match:
|
||||
# 尝试 value/target*100 > 80 这种复合表达式
|
||||
match = re.match(
|
||||
r'(value)\s*(/|\\*)\s*(target|baseline)\s*(\*?\s*\d+)?\s*([><=!]+)\s*(-?\d+\.?\d*)',
|
||||
expr
|
||||
)
|
||||
if not match:
|
||||
logger.warning(f"无法解析condition表达式: {expr}")
|
||||
continue
|
||||
|
||||
logical_op = parts[i + 1].upper() if i + 1 < len(parts) else "AND"
|
||||
clauses.append({"expr": expr, "logical_op": logical_op, "match": match.groups() if match else ()})
|
||||
|
||||
return clauses
|
||||
|
||||
|
||||
def _evaluate_condition(condition: str, kpi_data: dict) -> bool:
|
||||
"""
|
||||
评估condition表达式
|
||||
|
||||
Args:
|
||||
condition: 条件表达式 e.g. "value > target"
|
||||
kpi_data: {value, target, ...}
|
||||
|
||||
Returns:
|
||||
True=通过, False=不通过
|
||||
"""
|
||||
if not condition:
|
||||
return False
|
||||
|
||||
value = kpi_data.get("value")
|
||||
target = kpi_data.get("target")
|
||||
baseline = kpi_data.get("baseline", target)
|
||||
|
||||
if value is None:
|
||||
logger.warning(f"KPI {kpi_data.get('kpi_code')} 当前值为空,无法验证")
|
||||
return False
|
||||
|
||||
result = True
|
||||
current_logical = "AND"
|
||||
|
||||
# 按AND/OR分割
|
||||
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
|
||||
|
||||
for i in range(0, len(parts), 2):
|
||||
expr = parts[i].strip()
|
||||
if i + 1 < len(parts):
|
||||
current_logical = parts[i + 1].upper()
|
||||
|
||||
clause_passed = _eval_single_expr(expr, value, target, baseline)
|
||||
|
||||
if current_logical == "AND":
|
||||
result = result and clause_passed
|
||||
elif current_logical == "OR":
|
||||
result = result or clause_passed
|
||||
|
||||
# 短路优化
|
||||
if current_logical == "AND" and not result:
|
||||
break
|
||||
if current_logical == "OR" and result:
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _eval_single_expr(expr: str, value: float, target: Optional[float], baseline: Optional[float]) -> bool:
|
||||
"""评估单条条件表达式"""
|
||||
# 模式1: value <op> target/baseline/number (如 value > target, value >= 80)
|
||||
m = re.match(
|
||||
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)',
|
||||
expr
|
||||
)
|
||||
if m:
|
||||
left = m.group(1)
|
||||
op = m.group(2)
|
||||
right_raw = m.group(3)
|
||||
|
||||
left_val = _resolve_var(left, value, target, baseline)
|
||||
right_val = _resolve_var(right_raw, value, target, baseline)
|
||||
|
||||
if left_val is None or right_val is None:
|
||||
return False
|
||||
|
||||
return _apply_op(left_val, op, right_val)
|
||||
|
||||
# 模式2: value [*/] target/baseline [* number] <op> number (如 value/target*100 > 80)
|
||||
m = re.match(
|
||||
r'(value)\s*([/*])\s*(target|baseline)(?:\s*\*\s*(\d+))?\s*([><=!]+)\s*(-?\d+\.?\d*)',
|
||||
expr
|
||||
)
|
||||
if m:
|
||||
left = m.group(1)
|
||||
op1 = m.group(2) # / or *
|
||||
var2 = m.group(3) # target or baseline
|
||||
multiplier = float(m.group(4)) if m.group(4) else 100
|
||||
op2 = m.group(5) # > < >= <= == !=
|
||||
right_num = float(m.group(6))
|
||||
|
||||
left_val = _resolve_var(left, value, target, baseline)
|
||||
right_var = _resolve_var(var2, value, target, baseline)
|
||||
|
||||
if left_val is None or right_var is None or right_var == 0:
|
||||
return False
|
||||
|
||||
if op1 == '/':
|
||||
computed = (left_val / right_var) * multiplier
|
||||
else: # *
|
||||
computed = left_val * right_var
|
||||
|
||||
return _apply_op(computed, op2, right_num)
|
||||
|
||||
# 模式3: 纯数字比较 (提供兼容)
|
||||
logger.warning(f"无法解析表达式: {expr}")
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_var(token: str, value: float, target: Optional[float], baseline: Optional[float]) -> Optional[float]:
|
||||
"""将变量名解析为数值"""
|
||||
token = token.strip()
|
||||
if token == "value":
|
||||
return value
|
||||
elif token == "target":
|
||||
return target
|
||||
elif token == "baseline":
|
||||
return baseline
|
||||
else:
|
||||
try:
|
||||
return float(token)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _apply_op(left: float, op: str, right: float) -> bool:
|
||||
"""应用比较运算符"""
|
||||
try:
|
||||
if op == ">":
|
||||
return left > right
|
||||
elif op == ">=":
|
||||
return left >= right
|
||||
elif op == "<":
|
||||
return left < right
|
||||
elif op == "<=":
|
||||
return left <= right
|
||||
elif op in ("==", "="):
|
||||
return abs(left - right) < 0.0001
|
||||
elif op == "!=":
|
||||
return abs(left - right) >= 0.0001
|
||||
else:
|
||||
logger.warning(f"未知运算符: {op}")
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# KPI映射表:MPM结果字段 → KPI编码
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
MPM_TO_KPI_MAP = {
|
||||
"revenue": None, # 不做KPI映射,保留在raw_data
|
||||
"cost": None,
|
||||
"gross_margin_standard": None,
|
||||
"gross_margin_adjusted": "F_GROSS_MARGIN",
|
||||
"net_profit_standard": None,
|
||||
"net_profit_adjusted": "F_NET_PROFIT",
|
||||
"channel_rebate_rate": "C_REBATE_RATE",
|
||||
"mgmt_expense_ratio": "F_COST_RATIO",
|
||||
}
|
||||
|
||||
# 预警阈值配置
|
||||
ALERT_THRESHOLDS = {
|
||||
"channel_rebate_rate": {"threshold": 75, "operator": ">", "kpi_code": "C_REBATE_RATE", "kpi_name": "渠补率"},
|
||||
"mgmt_expense_ratio": {"threshold": 50, "operator": ">", "kpi_code": "F_COST_RATIO", "kpi_name": "管理费/净收入"},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# API端点
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.post("/mpm-result")
|
||||
@risk_level("L3")
|
||||
def receive_mpm_result(
|
||||
data: dict,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
接收财务Bot/其他Bot的MPM分析结果
|
||||
|
||||
- 鉴权(校验X-BRIDGE-TOKEN)
|
||||
- 写入mpm_results表
|
||||
- 更新KPI当前值(kpi_values表)
|
||||
- 触发预警(如渠补率超75%)
|
||||
"""
|
||||
source = data.get("source", bridge_bot)
|
||||
entity_id = data.get("entity_id")
|
||||
period = data.get("period")
|
||||
results = data.get("results", {})
|
||||
|
||||
if not entity_id or not period:
|
||||
raise HTTPException(400, "缺少必填字段: entity_id, period")
|
||||
|
||||
# 验证entity存在
|
||||
entity = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not entity:
|
||||
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
|
||||
|
||||
# R1/R5(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警;
|
||||
# 期间非规整月(含 2026-H1/H2 伪期间)的 kpi_alerts 行一律不入库(跳过 + 记日志)。
|
||||
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge_v2.receive_mpm_result")
|
||||
period_attributable = guard_alert_period(period, source="bot_bridge_v2.receive_mpm_result",
|
||||
detail="entity_id=%s" % entity_id)
|
||||
|
||||
# ── 1. 写入MPM结果 ──
|
||||
record = MpmResult(
|
||||
entity_id=entity_id,
|
||||
period=period,
|
||||
source=source,
|
||||
raw_data=results,
|
||||
)
|
||||
db.add(record)
|
||||
db.flush()
|
||||
|
||||
# ── 2. 更新KPI当前值 ──
|
||||
kpi_updates = []
|
||||
for field, kpi_code in MPM_TO_KPI_MAP.items():
|
||||
if kpi_code is None:
|
||||
continue
|
||||
field_value = results.get(field)
|
||||
if field_value is None:
|
||||
continue
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
logger.warning(f"KPI编码 {kpi_code} 未找到 (entity_id={entity_id})")
|
||||
continue
|
||||
|
||||
# 写入最新值 (upsert: 存在同period则更新,否则插入)
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
if existing:
|
||||
existing.actual_value = field_value
|
||||
existing.source_type = "bot-bridge-v2"
|
||||
existing.data_status = "verified"
|
||||
else:
|
||||
kpi_val = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=field_value,
|
||||
source_type="bot-bridge-v2",
|
||||
data_status="verified",
|
||||
)
|
||||
db.add(kpi_val)
|
||||
kpi_updates.append(kpi_code)
|
||||
|
||||
# ── 3. 触发预警 ──
|
||||
alerts = []
|
||||
alerts_skipped_no_kpi = [] # B3(b): KPI 定义缺失而跳过的预警(留痕用)
|
||||
for field, config in ALERT_THRESHOLDS.items():
|
||||
field_value = results.get(field)
|
||||
if field_value is None:
|
||||
continue
|
||||
threshold = config["threshold"]
|
||||
operator = config["operator"]
|
||||
kpi_code = config.get("kpi_code")
|
||||
kpi_name = config["kpi_name"]
|
||||
|
||||
if (operator == ">" and field_value > threshold) or \
|
||||
(operator == ">=" and field_value >= threshold):
|
||||
# R1/R5 生成侧守卫(裁定 2026-09-19):demo 账套 / 不可归期期间 → 该行不入库
|
||||
if not alerts_allowed:
|
||||
logger.info("R1 账套守卫:跳过不入库(demo 演示账套)entity_id=%s", entity_id)
|
||||
continue
|
||||
if not period_attributable:
|
||||
logger.info("R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r", period)
|
||||
continue
|
||||
# 查找关联KPI
|
||||
# B3(b)(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922):
|
||||
# 修复前 `kpi_id=kpi_id or 1` —— KPI 定义缺失时把预警错挂到「全库第 1 个 KPI」
|
||||
# (该 KPI 可能属他账套:预警 entity_id 正确、KPI 维度却跨了账套)。
|
||||
# 现改为**显式校验**:拿不到本账套的 KPI 定义 → 不建预警(跳过)+ 落 OperationLog 留痕。
|
||||
# 为什么不是 `kpi_id=None`:`kpi_alerts.kpi_id` 为 NOT NULL(models/__init__.py:145),
|
||||
# batch5 §B3(b) 方案 A(保留可见性)在本 schema 下不可行;方案 B(跳过 + 审计)落地。
|
||||
kpi_id = None
|
||||
if kpi_code:
|
||||
kpi_def = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if kpi_def:
|
||||
kpi_id = kpi_def.id
|
||||
|
||||
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
|
||||
|
||||
if kpi_id is None:
|
||||
# 宁缺勿错:不入库,但必须留痕(防「预警静默丢失」)
|
||||
alerts_skipped_no_kpi.append(f"{kpi_name}(kpi_code={kpi_code!r})")
|
||||
db.add(OperationLog(
|
||||
user_id=None,
|
||||
action="skip_alert",
|
||||
target_type="alert",
|
||||
target_id=None,
|
||||
detail={
|
||||
"reason": "kpi_definition_not_found_for_entity",
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_name": kpi_name,
|
||||
"entity_id": entity_id,
|
||||
"period": period,
|
||||
"field": field,
|
||||
"field_value": field_value,
|
||||
"threshold": threshold,
|
||||
"source": "bot_bridge_v2.mpm-result",
|
||||
},
|
||||
))
|
||||
logger.warning(
|
||||
"B3(b) 跳过建预警(KPI定义缺失,不再回落 kpi_id=1): kpi_code=%r entity_id=%s period=%s",
|
||||
kpi_code, entity_id, period,
|
||||
)
|
||||
continue
|
||||
|
||||
# 创建预警
|
||||
alert = KPIAlert(
|
||||
entity_id=entity_id, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi_id, # B3(b): 显式校验后的本账套 KPI(禁止 or 1 兜底)
|
||||
alert_level="red",
|
||||
alert_message=alert_msg,
|
||||
alert_type="actual",
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
alerts.append(alert_msg)
|
||||
logger.warning(alert_msg)
|
||||
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
resp = {
|
||||
"success": True,
|
||||
"kpi_updated": len(kpi_updates),
|
||||
"alerts_triggered": len(alerts),
|
||||
# B3(b): 因本账套无对应 KPI 定义而跳过的预警(已落 OperationLog,非静默丢失)
|
||||
"alerts_skipped_missing_kpi": len(alerts_skipped_no_kpi),
|
||||
"mpm_record_id": record.id,
|
||||
"details": {
|
||||
"kpi_codes": kpi_updates,
|
||||
"alerts": alerts,
|
||||
"alerts_skipped_missing_kpi": alerts_skipped_no_kpi,
|
||||
},
|
||||
"skipped_non_month_period": (not period_attributable),
|
||||
}
|
||||
if not alerts_allowed:
|
||||
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警
|
||||
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/kpi-result")
|
||||
@risk_level("L2")
|
||||
def push_kpi_result(
|
||||
data: dict,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Bot分析结果回填KPI值 + 触发预警(PRD第七部分 bot-bridge数据通道)
|
||||
|
||||
入参:
|
||||
entity_id (int, 默认1): 企业实体ID
|
||||
kpi_code (str, 必填): KPI编码
|
||||
period (str, 必填): 期间 YYYY-MM
|
||||
value (num, 必填): 实际值
|
||||
source (str, 默认finance-bot): 来源Bot标识
|
||||
remark (str, 可选): 备注
|
||||
|
||||
行为:
|
||||
1. 按(entity_id, kpi_code)查KPI → 不存在返回错误
|
||||
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
|
||||
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts
|
||||
"""
|
||||
entity_id = require_bot_entity_id(data, source="bot_bridge_v2.kpi-values")
|
||||
kpi_code = data.get("kpi_code")
|
||||
period = data.get("period")
|
||||
value = data.get("value")
|
||||
source = data.get("source", "finance-bot")
|
||||
remark = data.get("remark")
|
||||
|
||||
if not kpi_code:
|
||||
raise HTTPException(400, "缺少必填字段: kpi_code")
|
||||
if not period:
|
||||
raise HTTPException(400, "缺少必填字段: period")
|
||||
if value is None or value == "":
|
||||
raise HTTPException(400, "缺少必填字段: value")
|
||||
try:
|
||||
actual_value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, f"value不是有效数值: {value!r}")
|
||||
|
||||
# 1. 按(entity_id, kpi_code)查KPI
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code == kpi_code,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_code} 不存在 (entity_id={entity_id})")
|
||||
|
||||
# 2. 写入KPIValue(同period已存在则更新,幂等upsert)
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
if existing:
|
||||
existing.actual_value = actual_value
|
||||
existing.source_type = "bot"
|
||||
existing.source_batch = source
|
||||
existing.data_status = "verified"
|
||||
existing.calculated_at = datetime.now()
|
||||
if remark:
|
||||
existing.remark = remark
|
||||
val = existing
|
||||
else:
|
||||
val = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=actual_value,
|
||||
source_type="bot",
|
||||
source_batch=source,
|
||||
data_status="verified",
|
||||
calculated_at=datetime.now(),
|
||||
remark=remark,
|
||||
)
|
||||
db.add(val)
|
||||
db.commit()
|
||||
db.refresh(val)
|
||||
|
||||
# 3. 触发预警检查(复用alert_generator引擎,yellow/red自动联动行动计划)
|
||||
from scripts.alert_generator import run_alert_check
|
||||
new_count = run_alert_check(db, period)
|
||||
|
||||
# 收集本次写入值直接触发的预警(kpi_value_id关联)
|
||||
new_alerts = []
|
||||
if new_count:
|
||||
triggered = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_value_id == val.id,
|
||||
).order_by(KPIAlert.created_at.desc()).all()
|
||||
new_alerts = [
|
||||
{
|
||||
"id": a.id,
|
||||
"kpi_id": a.kpi_id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"level": a.alert_level,
|
||||
"message": a.alert_message,
|
||||
"action_plan_id": a.action_plan_linked_id,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
} for a in triggered
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"[bot-bridge] KPI回填: {kpi.kpi_code}@{period}={actual_value} "
|
||||
f"source={source} entity={entity_id} alerts={new_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"kpi_value_id": val.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"period": period,
|
||||
"value": actual_value,
|
||||
"source": source,
|
||||
"new_alerts_count": len(new_alerts),
|
||||
"new_alerts": new_alerts,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/verify/{action_plan_id}")
|
||||
@risk_level("L2")
|
||||
def verify_action_plan(
|
||||
action_plan_id: int,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
验证ActionPlan的执行结果
|
||||
|
||||
⚠️ 双 verify 入口关系(2026-08-30 评审收敛,暂不重构):
|
||||
- 本函数(/api/cma/bot-bridge/verify/{action_plan_id}): 轻量版 — 仅读取KPI并按condition
|
||||
校验,记录 verify_log,不写KPIValue回填、不联动OKR、不发企微通知。供Bot桥接通道调用。
|
||||
- verify.py(/api/cma/verify/{plan_id}): 完整链路 — 回填KPIValue + OKR progress联动
|
||||
+ 企微通知。业务侧手动/自动重验走那个入口。
|
||||
- 两者行为不一致,勿混用。
|
||||
|
||||
1. 读取ActionPlan的auto_verify_rule
|
||||
2. 读取关联KPI的当前值
|
||||
3. 按condition校验
|
||||
4. 返回通过/失败 + 详细数据
|
||||
"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
|
||||
|
||||
if not plan.auto_verify_rule:
|
||||
raise HTTPException(400, "该ActionPlan未配置auto_verify_rule验证规则")
|
||||
|
||||
# 解析验证规则
|
||||
if isinstance(plan.auto_verify_rule, str):
|
||||
rule = json.loads(plan.auto_verify_rule)
|
||||
else:
|
||||
rule = plan.auto_verify_rule
|
||||
|
||||
condition = rule.get("condition", "")
|
||||
description = rule.get("description", "")
|
||||
|
||||
# 获取KPI当前值
|
||||
kpi_data = _get_kpi_current_value(db, plan.kpi_id)
|
||||
if not kpi_data:
|
||||
return {
|
||||
"success": False,
|
||||
"action_plan_id": action_plan_id,
|
||||
"title": plan.title,
|
||||
"verify_result": "fail",
|
||||
"reason": "关联KPI不存在",
|
||||
}
|
||||
|
||||
# 执行验证
|
||||
passed = _evaluate_condition(condition, kpi_data)
|
||||
|
||||
# 记录验证日志
|
||||
verify_log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"condition": condition,
|
||||
"kpi_data": kpi_data,
|
||||
"passed": passed,
|
||||
}
|
||||
existing_logs = plan.verify_log or []
|
||||
if isinstance(existing_logs, list):
|
||||
existing_logs.append(verify_log_entry)
|
||||
else:
|
||||
existing_logs = [verify_log_entry]
|
||||
|
||||
plan.verify_result = "pass" if passed else "fail"
|
||||
plan.verify_log = existing_logs
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"action_plan_id": action_plan_id,
|
||||
"title": plan.title,
|
||||
"verify_result": plan.verify_result,
|
||||
"condition": condition,
|
||||
"condition_description": description,
|
||||
"kpi_data": kpi_data,
|
||||
"verification_detail": {
|
||||
"value": kpi_data.get("value"),
|
||||
"target": kpi_data.get("target"),
|
||||
"condition": condition,
|
||||
"passed": passed,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/verify/{action_plan_id}/history")
|
||||
@risk_level("L1")
|
||||
def verify_history(
|
||||
action_plan_id: int,
|
||||
bridge_bot: str = Depends(verify_bridge_token),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取ActionPlan的验证历史"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
|
||||
|
||||
return {
|
||||
"action_plan_id": action_plan_id,
|
||||
"title": plan.title,
|
||||
"verify_result": plan.verify_result,
|
||||
"verify_log": plan.verify_log or [],
|
||||
"auto_verify_rule": plan.auto_verify_rule,
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
铁律KPI看板API — CMA铁律执行效果数据采集
|
||||
提供: 验证次数、通过率、违规数、按Bot分组的通过率
|
||||
"""
|
||||
import os, json, sqlite3, logging
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.database import get_db
|
||||
from app.models import ActionPlan
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
logger = logging.getLogger("cma.iron_law")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot", tags=["铁律KPI看板"])
|
||||
|
||||
# ── BOT API Key 配置(复用bot_bridge的一致鉴权) ──
|
||||
_BOT_API_KEYS = {}
|
||||
|
||||
def _load_bot_keys():
|
||||
global _BOT_API_KEYS
|
||||
raw = os.getenv("CMA_BOT_API_KEYS", "")
|
||||
if not raw:
|
||||
_BOT_API_KEYS = {
|
||||
"cma-bot-finance-2026": {"role": "finance", "name": "财务BOT"},
|
||||
"cma-bot-shop-2026": {"role": "business", "name": "店研学BOT"},
|
||||
"cma-bot-admin-2026": {"role": "ceo", "name": "管理BOT"},
|
||||
}
|
||||
else:
|
||||
try:
|
||||
_BOT_API_KEYS = json.loads(raw)
|
||||
except:
|
||||
_BOT_API_KEYS = {}
|
||||
|
||||
_load_bot_keys()
|
||||
|
||||
def verify_bot_key(x_bot_key: str = Header(None, alias="X-BOT-KEY")):
|
||||
if not x_bot_key or x_bot_key not in _BOT_API_KEYS:
|
||||
raise HTTPException(401, "无效的BOT API Key")
|
||||
bot_info = _BOT_API_KEYS[x_bot_key]
|
||||
return bot_info
|
||||
|
||||
|
||||
# ── 各Hermes Profile state.db 路径 ──
|
||||
HERMES_HOME = "/root/.hermes/profiles"
|
||||
|
||||
def _list_profile_dbs():
|
||||
"""列出所有Hermes Profile的state.db路径"""
|
||||
dbs = []
|
||||
if not os.path.isdir(HERMES_HOME):
|
||||
return dbs
|
||||
for name in os.listdir(HERMES_HOME):
|
||||
db_path = os.path.join(HERMES_HOME, name, "state.db")
|
||||
if os.path.isfile(db_path):
|
||||
dbs.append((name, db_path))
|
||||
return sorted(dbs)
|
||||
|
||||
|
||||
def _query_state_db(db_path: str, since_days: int = 30):
|
||||
"""从单个 state.db 查询验证相关消息"""
|
||||
cutoff = datetime.now() - timedelta(days=since_days)
|
||||
cutoff_ts = cutoff.timestamp()
|
||||
|
||||
results = {
|
||||
"total_verify_msgs": 0,
|
||||
"pass_msgs": 0,
|
||||
"fail_msgs": 0,
|
||||
"violation_msgs": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
|
||||
# 查询含验证/铁律关键词的消息(排除tool_call、校验自身和系统prompt)
|
||||
c.execute("""
|
||||
SELECT content, role FROM messages
|
||||
WHERE timestamp >= ?
|
||||
AND (content LIKE '%verify%'
|
||||
OR content LIKE '%验证%'
|
||||
OR content LIKE '%铁律%'
|
||||
OR content LIKE '%validate%')
|
||||
AND content NOT LIKE '%verify_bot_key%'
|
||||
AND content NOT LIKE '%X-BOT-KEY%'
|
||||
AND content NOT LIKE '%system_prompt%'
|
||||
""", (cutoff_ts,))
|
||||
|
||||
rows = c.fetchall()
|
||||
results["total_verify_msgs"] = len(rows)
|
||||
|
||||
for row in rows:
|
||||
content = row["content"] or ""
|
||||
role = row["role"]
|
||||
|
||||
# 判断是否通过/成功/完成
|
||||
pass_patterns = ["通过", "passed", "success", "✅", "完成", "completed", "验证通过"]
|
||||
fail_patterns = ["失败", "failed", "error", "❌", "违规", "未通过", "错误", "异常"]
|
||||
violation_patterns = ["违规", "violation", "拦截", "blocked", "违例"]
|
||||
|
||||
# 助理角色消息:用于判断验证结果
|
||||
if role == "assistant":
|
||||
has_pass = any(p in content for p in pass_patterns)
|
||||
has_fail = any(p in content for p in fail_patterns)
|
||||
|
||||
if has_pass and not has_fail:
|
||||
results["pass_msgs"] += 1
|
||||
elif has_fail and not has_pass:
|
||||
results["fail_msgs"] += 1
|
||||
elif has_pass and has_fail:
|
||||
# 混合内容,默认算通过(因为通常有通过+补充说明)
|
||||
results["pass_msgs"] += 1
|
||||
|
||||
if any(p in content for p in violation_patterns):
|
||||
results["violation_msgs"] += 1
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"查询state.db失败 {db_path}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _query_action_plan_verify(db: Session):
|
||||
"""从CMA ActionPlan表查询验证数据"""
|
||||
total = db.query(func.count(ActionPlan.id)).scalar() or 0
|
||||
verified = db.query(func.count(ActionPlan.id)).filter(
|
||||
ActionPlan.verify_result.isnot(None)
|
||||
).scalar() or 0
|
||||
passed = db.query(func.count(ActionPlan.id)).filter(
|
||||
ActionPlan.verify_result == "pass"
|
||||
).scalar() or 0
|
||||
failed = db.query(func.count(ActionPlan.id)).filter(
|
||||
ActionPlan.verify_result == "fail"
|
||||
).scalar() or 0
|
||||
with_rule = db.query(func.count(ActionPlan.id)).filter(
|
||||
ActionPlan.auto_verify_rule.isnot(None)
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"total_plans": total,
|
||||
"verified": verified,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"with_auto_rule": with_rule,
|
||||
"pass_rate": round(passed / verified * 100, 1) if verified > 0 else 0,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════ 端点 ═══════════════
|
||||
|
||||
@router.get("/iron-law")
|
||||
@risk_level("L1")
|
||||
def get_iron_law_kpis(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
since_days: int = 30,
|
||||
):
|
||||
"""
|
||||
铁律执行效果KPI看板
|
||||
|
||||
返回各Bot的验证统计、通过率、违规数,以及ActionPlan的验证覆盖率。
|
||||
数据来源:
|
||||
- Hermes 各Profile state.db (消息含 verify/验证/铁律)
|
||||
- CMA action_plans 表 (verify_result, auto_verify_rule)
|
||||
"""
|
||||
# ── 1. 采集各Profile state.db ──
|
||||
profile_dbs = _list_profile_dbs()
|
||||
per_bot = {}
|
||||
total_verify = 0
|
||||
total_pass = 0
|
||||
total_fail = 0
|
||||
total_violations = 0
|
||||
|
||||
for profile_name, db_path in profile_dbs:
|
||||
stats = _query_state_db(db_path, since_days=since_days)
|
||||
per_bot[profile_name] = {
|
||||
"verification_count": stats["total_verify_msgs"],
|
||||
"pass_count": stats["pass_msgs"],
|
||||
"fail_count": stats["fail_msgs"],
|
||||
"violation_count": stats["violation_msgs"],
|
||||
"pass_rate": round(
|
||||
stats["pass_msgs"] / stats["total_verify_msgs"] * 100, 1
|
||||
) if stats["total_verify_msgs"] > 0 else 0,
|
||||
}
|
||||
total_verify += stats["total_verify_msgs"]
|
||||
total_pass += stats["pass_msgs"]
|
||||
total_fail += stats["fail_msgs"]
|
||||
total_violations += stats["violation_msgs"]
|
||||
|
||||
# ── 2. 采集ActionPlan验证数据 ──
|
||||
plan_stats = _query_action_plan_verify(db)
|
||||
|
||||
return {
|
||||
"bot": bot,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"period": f"past_{since_days}d",
|
||||
"summary": {
|
||||
"total_verifications": total_verify,
|
||||
"pass_rate": round(total_pass / total_verify * 100, 1) if total_verify > 0 else 0,
|
||||
"total_violations": total_violations,
|
||||
"plan_verify_count": plan_stats["verified"],
|
||||
"plan_pass_rate": plan_stats["pass_rate"],
|
||||
"plans_with_auto_rule": plan_stats["with_auto_rule"],
|
||||
"total_action_plans": plan_stats["total_plans"],
|
||||
},
|
||||
"per_bot": per_bot,
|
||||
"action_plan_verification": {
|
||||
"total_plans": plan_stats["total_plans"],
|
||||
"verified": plan_stats["verified"],
|
||||
"passed": plan_stats["passed"],
|
||||
"failed": plan_stats["failed"],
|
||||
"pass_rate": plan_stats["pass_rate"],
|
||||
"with_auto_rule": plan_stats["with_auto_rule"],
|
||||
"coverage": round(
|
||||
plan_stats["verified"] / plan_stats["total_plans"] * 100, 1
|
||||
) if plan_stats["total_plans"] > 0 else 0,
|
||||
},
|
||||
"data_sources": {
|
||||
"state_dbs": len(profile_dbs),
|
||||
"profiles_queried": [p[0] for p in profile_dbs],
|
||||
"action_plans_table": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/iron-law/bots")
|
||||
@risk_level("L1")
|
||||
def get_bot_iron_law_ranking(
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
since_days: int = 30,
|
||||
):
|
||||
"""按Bot排名:验证通过率从高到低"""
|
||||
data = get_iron_law_kpis(bot=bot, db=db, since_days=since_days)
|
||||
|
||||
ranking = sorted(
|
||||
data["per_bot"].items(),
|
||||
key=lambda x: x[1]["pass_rate"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
ranked = []
|
||||
for rank, (name, stats) in enumerate(ranking, 1):
|
||||
ranked.append({
|
||||
"rank": rank,
|
||||
"bot_name": name,
|
||||
"pass_rate": stats["pass_rate"],
|
||||
"verification_count": stats["verification_count"],
|
||||
"violations": stats["violation_count"],
|
||||
"failures": stats["fail_count"],
|
||||
})
|
||||
|
||||
return {
|
||||
"bot": bot,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"period": f"past_{since_days}d",
|
||||
"ranking": ranked,
|
||||
"total_bots": len(ranked),
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Bot KPI管理 API — 管理各Agent的KPI自评体系"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KPIDefinition, KPIValue
|
||||
from app.utils.alert_entity_scope import month_period_clause
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
# 反向指标编码(值越低越好)。
|
||||
# 方向权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口):
|
||||
# 本文件原 import 了权威表却未使用、另立字面量 ['FB_RESPONSE_TIME'],会让同一 kpi_code
|
||||
# 在「能力评分」(取权威表=反向) 与「Bot 自评」(取本表=正向) 方向打架
|
||||
# (实测同一宇宙内 F_COST_RATIO / F_AR_DAYS / F_REBATE_RATE 三码即为此类)。
|
||||
# 现改为派生:权威表 ∪ Bot 专有反向指标(零漂移)。
|
||||
from app.api.kpis import REVERSE_INDICATORS as _AUTHORITATIVE_REVERSE_INDICATORS
|
||||
|
||||
BOT_ONLY_REVERSE_INDICATORS = ['FB_RESPONSE_TIME']
|
||||
REVERSE_BOT_INDICATORS = list(_AUTHORITATIVE_REVERSE_INDICATORS) + [
|
||||
c for c in BOT_ONLY_REVERSE_INDICATORS if c not in _AUTHORITATIVE_REVERSE_INDICATORS]
|
||||
|
||||
|
||||
def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
|
||||
"""五档评分引擎(复用KPI体系)- 1~5分"""
|
||||
if current_value is None or target_value is None or target_value == 0:
|
||||
return None, "info"
|
||||
ratio = current_value / target_value
|
||||
if is_reverse:
|
||||
if ratio <= 0.5:
|
||||
return 5, "success"
|
||||
elif ratio <= 0.8:
|
||||
return 4, "success"
|
||||
elif ratio <= 1.0:
|
||||
return 3, "warning"
|
||||
elif ratio <= 1.2:
|
||||
return 2, "danger"
|
||||
else:
|
||||
return 1, "danger"
|
||||
else:
|
||||
if ratio >= 1.2:
|
||||
return 5, "success"
|
||||
elif ratio >= 1.0:
|
||||
return 4, "success"
|
||||
elif ratio >= 0.8:
|
||||
return 3, "warning"
|
||||
elif ratio >= 0.5:
|
||||
return 2, "danger"
|
||||
else:
|
||||
return 1, "danger"
|
||||
|
||||
|
||||
@router.get("")
|
||||
@risk_level("L1")
|
||||
def list_bot_kpis(
|
||||
source: str = Query("finance-bot", description="Bot标识"),
|
||||
period: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取某Bot的所有KPI(含评分)(账套隔离 2026-09-21:仅本账套,对齐 update_bot_kpi_value)"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.bot_source == source,
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
if not kpis:
|
||||
return {
|
||||
"source": source,
|
||||
"kpis": [],
|
||||
"groups": {},
|
||||
"overall": None,
|
||||
}
|
||||
|
||||
result_kpis = []
|
||||
for k in kpis:
|
||||
# 取最新实际值
|
||||
val_query = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period:
|
||||
val_query = val_query.filter(KPIValue.period == period)
|
||||
val_query = val_query.filter(month_period_clause(KPIValue.period))
|
||||
latest_val = val_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
current_val = latest_val.actual_value if latest_val else None
|
||||
is_reverse = k.kpi_code in REVERSE_BOT_INDICATORS
|
||||
score, status = _calc_bot_kpi_score(current_val, k.target_value, is_reverse=is_reverse)
|
||||
|
||||
result_kpis.append({
|
||||
"id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"category": k.category or "core_output",
|
||||
"formula": k.formula,
|
||||
"target_value": k.target_value,
|
||||
"current_value": current_val,
|
||||
"unit": k.unit,
|
||||
"weight": 15, # 权重在PRD中定义,但未存表,前端使用
|
||||
"score": score,
|
||||
"status": status,
|
||||
"period": latest_val.period if latest_val else None,
|
||||
# P0-① 三区标注: Bot KPI值最近回填时间/批次(UI BotSourceTag 消费)
|
||||
"value_updated_at": latest_val.calculated_at.strftime("%Y-%m-%d %H:%M") if latest_val and latest_val.calculated_at else None,
|
||||
"value_source_batch": latest_val.source_batch if latest_val else None,
|
||||
"frequency": k.frequency or "monthly",
|
||||
})
|
||||
|
||||
# 按group分组: core_output / quality / user_feedback
|
||||
groups = {
|
||||
"core_output": {"label": "核心产出", "kpis": []},
|
||||
"quality": {"label": "质量监控", "kpis": []},
|
||||
"user_feedback": {"label": "用户反馈", "kpis": []},
|
||||
}
|
||||
# category映射: FB编码前缀区分
|
||||
for kp in result_kpis:
|
||||
code = kp["kpi_code"]
|
||||
if code.startswith("FB_ANALYSIS") or code.startswith("FB_ACCURACY") or code.startswith("FB_ISSUE") or code.startswith("FB_ACTION") or code.startswith("FB_RESPONSE"):
|
||||
groups["core_output"]["kpis"].append(kp)
|
||||
elif code.startswith("FB_DATA") or code.startswith("FB_CONSISTENCY") or code.startswith("FB_CITATION"):
|
||||
groups["quality"]["kpis"].append(kp)
|
||||
else:
|
||||
groups["user_feedback"]["kpis"].append(kp)
|
||||
|
||||
# 综合得分
|
||||
scored_kpis = [k for k in result_kpis if k["score"] is not None]
|
||||
if scored_kpis:
|
||||
overall = round(sum(k["score"] * k["weight"] for k in scored_kpis) / sum(k["weight"] for k in scored_kpis), 2)
|
||||
overall_status = "success" if overall >= 4 else ("warning" if overall >= 3 else "danger")
|
||||
else:
|
||||
overall = None
|
||||
overall_status = "info"
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"kpis": result_kpis,
|
||||
"groups": groups,
|
||||
"overall": {"score": overall, "status": overall_status},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{kpi_id}/value")
|
||||
@risk_level("L2")
|
||||
def update_bot_kpi_value(
|
||||
kpi_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""更新Bot KPI当前值"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.bot_source.isnot(None),
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "Bot KPI不存在")
|
||||
# 多租户隔离 2026-09-07: 归属校验,禁止跨企业修改他企 Bot KPI
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "Bot KPI不存在")
|
||||
|
||||
actual_value = data.get("actual_value")
|
||||
if actual_value is None:
|
||||
raise HTTPException(422, "actual_value 不能为空")
|
||||
|
||||
period = data.get("period", datetime.now().strftime("%Y-%m"))
|
||||
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
if existing:
|
||||
existing.actual_value = actual_value
|
||||
existing.source_type = "manual"
|
||||
else:
|
||||
val = KPIValue(
|
||||
kpi_id=kpi_id,
|
||||
entity_id=kpi.entity_id if kpi else None, # 账套隔离 P2
|
||||
period=period,
|
||||
actual_value=actual_value,
|
||||
source_type="manual",
|
||||
data_status="pending",
|
||||
)
|
||||
db.add(val)
|
||||
|
||||
db.commit()
|
||||
return {"message": "更新成功", "kpi_id": kpi_id, "period": period, "actual_value": actual_value}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Bot侧AI高危建议创建通道 — 写操作分层授权落地 (P0-② 2026-09-03)
|
||||
|
||||
背景(CMA交互架构规范 v1 5.1 分层授权模型,任总拍板):
|
||||
考核基准类(KPI目标值/预算调整)→ 🔒 Bot只能"建议",人审批后生效
|
||||
Bot不得直写 KPI target_value / budget 审批类字段;
|
||||
Bot产生的高危变更一律走本通道建 ai_suggestions(status=unapplied)
|
||||
→ UI AI建议中心(SuggestionCenter.vue)待批列表 → 人 apply 后生效+留痕。
|
||||
|
||||
通道关系(两套分级勿混,见 risk_levels.py 顶部注释):
|
||||
L1-L4(技术风险分级): 本端点挂 L2(业务写单条)
|
||||
low/high(业务写语义分级): 本端点是 high 类的"建议载体"——
|
||||
端点本身不直写考核基准,只建建议行,属 high 流程的入口。
|
||||
|
||||
鉴权/审计:
|
||||
X-BOT-KEY(verify_bot_key, bot_bridge 共享密钥)+ @risk_level("L2")
|
||||
路径 /api/cma/bot/* → bot_audit_middleware 自动留痕(bot_name/risk/status)
|
||||
entity_id 多租户: X-Entity-Id header → body.entity_id → 默认1,校验 Entity.active
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import AISuggestion, KPIDefinition, Entity
|
||||
from app.api.bot_bridge import verify_bot_key
|
||||
from app.deps import require_bot_entity_id
|
||||
from app.api.ai_suggestions import _sug_dict
|
||||
from app.risk_levels import risk_level
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bot/ai/suggestions", tags=["AI建议Bot通道"])
|
||||
|
||||
# 高危建议类型(Bot仅允许创建这三类建议,禁止其他)
|
||||
_SUPPORTED_TYPES = ("kpi_target", "budget_adjust", "action_plan")
|
||||
# source 白名单:Bot 不得冒充 manual/dashboard/kpi/budget(影响UI语义与推送判断)
|
||||
_ALLOWED_SOURCES = ("rule", "bot")
|
||||
_TARGET_TYPE_MAP = {
|
||||
"kpi_target": "kpi",
|
||||
"budget_adjust": "budget",
|
||||
"action_plan": "kpi",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
|
||||
"""账套解析(Bot无token通道): body.entity_id → X-Entity-Id header → **缺省即报错**。
|
||||
|
||||
与 deps.get_entity_id 的 Bot 白名单语义一致(query/header/body 任一来源),
|
||||
本端点不接收登录token,故不在此处理 Bearer。
|
||||
|
||||
ASK-20260924-17(任总 2026-09-24 放行):**取消「默认 1(酣客)」兜底**——
|
||||
缺省账套改用 `deps.require_bot_entity_id` 收口(缺 → 400,非数字 → 422)。
|
||||
"""
|
||||
raw = None
|
||||
if data.get("entity_id") not in (None, ""):
|
||||
raw = data["entity_id"]
|
||||
else:
|
||||
xh = request.headers.get("X-Entity-Id")
|
||||
if xh and str(xh).isdigit():
|
||||
raw = int(xh)
|
||||
eid = require_bot_entity_id({"entity_id": raw}, source="bot_suggestions")
|
||||
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||
if not ent or ent.status != "active":
|
||||
raise HTTPException(403, f"企业 entity_id={eid} 不存在或未激活")
|
||||
return eid
|
||||
|
||||
|
||||
@router.post("")
|
||||
@risk_level("L2")
|
||||
def bot_create_suggestion(
|
||||
request: Request,
|
||||
data: dict,
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Bot创建高危建议(kpi_target/budget_adjust/action_plan),status 固定 unapplied 等人审批。
|
||||
|
||||
入参(字段与人工创建端点 /api/cma/ai/suggestions 一致):
|
||||
suggestion_type (str, 必填): kpi_target / budget_adjust / action_plan
|
||||
title (str, 必填): 建议标题(幂等键之一)
|
||||
entity_id (int, 可选, 默认1): 账套;也支持 X-Entity-Id header
|
||||
target_id (int, 可选): 目标ID;kpi_target/budget_adjust 必填(kpi_id 亦可)
|
||||
suggestion_data(dict, 可选): 应用参数
|
||||
kpi_target: {kpi_id, target_value}
|
||||
budget_adjust:{kpi_id, period:YYYY-MM, budget_value}
|
||||
action_plan: {kpi_id, title?, assignee?, priority?, due_date?}
|
||||
content (str, 可选): 建议理由/依据(财务Bot三情景测算content携带)
|
||||
source (str, 默认"bot"): 仅允许 bot/rule,禁止冒充 manual/dashboard
|
||||
target_type (str, 可选): 缺省按 suggestion_type 映射 kpi/budget
|
||||
|
||||
约束:
|
||||
- status 恒为 unapplied(Bot 不能直接生效)
|
||||
- kpi_target/budget_adjust 必须带有效 kpi_id/target_id 且属于当前账套
|
||||
- 幂等: 同 entity+suggestion_type+target_id+title 且 unapplied 已存在 → 返回既有建议
|
||||
"""
|
||||
suggestion_type = data.get("suggestion_type")
|
||||
title = str(data.get("title") or "").strip()
|
||||
if suggestion_type not in _SUPPORTED_TYPES:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Bot建议仅支持 suggestion_type: {'/'.join(_SUPPORTED_TYPES)}(高危变更=建议通道,不提供直写)",
|
||||
)
|
||||
if not title:
|
||||
raise HTTPException(400, "缺少 title")
|
||||
|
||||
source = data.get("source", "bot")
|
||||
if source not in _ALLOWED_SOURCES:
|
||||
raise HTTPException(400, f"source 仅允许 {'/'.join(_ALLOWED_SOURCES)}(禁止冒充 manual/dashboard/kpi/budget)")
|
||||
|
||||
entity_id = _resolve_bot_entity(request, data, db)
|
||||
sd = data.get("suggestion_data") or {}
|
||||
target_type = data.get("target_type") or _TARGET_TYPE_MAP.get(suggestion_type)
|
||||
|
||||
# 目标归属校验(多租户铁律: 只能建议当前账套的KPI)
|
||||
kpi_id = None
|
||||
raw_kpi = data.get("kpi_id") or data.get("target_id") or sd.get("kpi_id")
|
||||
if raw_kpi is not None:
|
||||
try:
|
||||
kpi_id = int(raw_kpi)
|
||||
except (TypeError, ValueError):
|
||||
kpi_id = None
|
||||
if suggestion_type in ("kpi_target", "budget_adjust"):
|
||||
if kpi_id is None:
|
||||
raise HTTPException(400, f"suggestion_type={suggestion_type} 必须带 kpi_id/target_id")
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, f"KPI {kpi_id} 不存在于当前账套 entity_id={entity_id}")
|
||||
if suggestion_type == "kpi_target" and sd.get("target_value") is None:
|
||||
raise HTTPException(400, "kpi_target 建议的 suggestion_data 必须带 target_value")
|
||||
if suggestion_type == "budget_adjust" and (
|
||||
not sd.get("period") or sd.get("budget_value") is None
|
||||
):
|
||||
raise HTTPException(400, "budget_adjust 建议的 suggestion_data 必须带 period(YYYY-MM) + budget_value")
|
||||
|
||||
# 幂等: 同 entity+type+target_id+title 的 unapplied 建议存在则不重复创建
|
||||
target_id = kpi_id if kpi_id is not None else data.get("target_id")
|
||||
dup = db.query(AISuggestion).filter(
|
||||
AISuggestion.entity_id == entity_id,
|
||||
AISuggestion.suggestion_type == suggestion_type,
|
||||
AISuggestion.target_id == target_id,
|
||||
AISuggestion.title == title,
|
||||
AISuggestion.status == "unapplied",
|
||||
).first()
|
||||
if dup:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "建议已存在(幂等,未重复创建)",
|
||||
"data": _sug_dict(dup),
|
||||
"duplicated": True,
|
||||
}
|
||||
|
||||
category = "alert" if target_type == "alert" else data.get("category", "decision")
|
||||
sug = AISuggestion(
|
||||
entity_id=entity_id,
|
||||
user_id=None, # Bot 创建无人工账号;bot 名与调用留痕在 bot_audit.log(X-BOT-KEY)
|
||||
source=source,
|
||||
suggestion_type=suggestion_type,
|
||||
target_type=target_type,
|
||||
category=category,
|
||||
target_id=target_id,
|
||||
title=title,
|
||||
content=data.get("content"),
|
||||
suggestion_data=sd,
|
||||
status="unapplied", # 铁律: Bot 只能建议,生效必须人 apply
|
||||
)
|
||||
db.add(sug)
|
||||
db.commit()
|
||||
db.refresh(sug)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "高危建议已创建,等待人工审批(AI建议中心 → 应用)",
|
||||
"data": _sug_dict(sug),
|
||||
"duplicated": False,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""BSC四层配置 API"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth
|
||||
from app.models import BscLayerConfig, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/bsc-layers", tags=["BSC层配置"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_bsc_layers(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
|
||||
"""获取某企业的BSC四层权重配置"""
|
||||
# 验证企业存在
|
||||
entity = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not entity:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(status_code=404, content={"detail": "企业不存在"})
|
||||
|
||||
layers = db.query(BscLayerConfig).filter(
|
||||
BscLayerConfig.entity_id == entity_id
|
||||
).order_by(BscLayerConfig.id).all()
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": entity.short_name or entity.name,
|
||||
"layers": [
|
||||
{
|
||||
"id": l.id,
|
||||
"layer": l.layer,
|
||||
"weight": float(l.weight),
|
||||
"kpi_count_min": l.kpi_count_min,
|
||||
"kpi_count_max": l.kpi_count_max,
|
||||
}
|
||||
for l in layers
|
||||
]
|
||||
}
|
||||
+1782
-18
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
"""预算自动从KPI推算 API — P1-2
|
||||
|
||||
根据KPI的目标值自动生成预算建议。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
|
||||
from app.utils.oplog import log_op, SCOPE_BATCH
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("cma.budget_gen")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/budget", tags=["KPI→预算"],
|
||||
dependencies=[Depends(require_role("ceo", "finance"))],
|
||||
)
|
||||
|
||||
|
||||
def _calc_budget(kpi: KPIDefinition) -> dict:
|
||||
"""根据KPI类型推算预算
|
||||
|
||||
算法:
|
||||
- 降本类: (当前值-目标值)×0.3
|
||||
- 增收类: 目标增收额×0.2
|
||||
- 能力类: 人均培训成本×人数
|
||||
- 系统类: 按模块开发费估算
|
||||
"""
|
||||
category = kpi.category or ""
|
||||
target = kpi.target_value or 0
|
||||
|
||||
result = {
|
||||
"suggested_budget": 0,
|
||||
"calc_logic": "",
|
||||
"calc_type": "未知",
|
||||
}
|
||||
|
||||
# 降本类: cost_control, cash_risk
|
||||
if category in ("cost_control", "cash_risk", "asset_efficiency"):
|
||||
result["calc_type"] = "降本类"
|
||||
# 当前值需要从最新的KPIValue获取
|
||||
# 这里返回算法描述,前端传入当前值
|
||||
result["calc_type_desc"] = "(当前值-目标值)×0.3"
|
||||
result["suggested_budget"] = 0 # 需要前端传当前值
|
||||
|
||||
# 增收类: revenue_growth, profitability
|
||||
elif category in ("revenue_growth", "profitability", "customer_scale"):
|
||||
result["calc_type"] = "增收类"
|
||||
result["calc_type_desc"] = "目标增收额×0.2"
|
||||
result["suggested_budget"] = round(target * 0.2, 2)
|
||||
|
||||
# 能力类: talent_pipeline, employee_engagement, innovation
|
||||
elif category in ("talent_pipeline", "employee_engagement", "innovation"):
|
||||
result["calc_type"] = "能力类"
|
||||
result["calc_type_desc"] = "人均培训成本×人数"
|
||||
result["suggested_budget"] = 0 # 需要外部参数
|
||||
|
||||
# 系统类: 默认为系统类
|
||||
elif category in ("supply_chain", "delivery_quality", "customer_concentration", "customer_satisfaction"):
|
||||
result["calc_type"] = "系统类"
|
||||
result["calc_type_desc"] = "按功能模块开发费估算"
|
||||
result["suggested_budget"] = round(target * 0.15, 2)
|
||||
|
||||
# 其他未分类
|
||||
else:
|
||||
result["calc_type"] = "系统类"
|
||||
result["calc_type_desc"] = "按功能模块开发费估算"
|
||||
result["suggested_budget"] = round(target * 0.15, 2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/kpi-budget-candidates")
|
||||
def get_kpi_budget_candidates(
|
||||
year: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取本账套可用于生成预算的KPI列表,按类型分类(账套隔离 2026-09-21)"""
|
||||
if not year:
|
||||
year = datetime.now().year
|
||||
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
|
||||
# 获取每个KPI的最新实际值
|
||||
latest_values = {}
|
||||
for kpi in kpis:
|
||||
v = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
# 实测区口径:预估行禁作预算生成依据(kpi_value_scope.actual_only_clause)
|
||||
actual_only_clause(),
|
||||
).order_by(KPIValue.calculated_at.desc()).first()
|
||||
if v:
|
||||
latest_values[kpi.id] = v.actual_value
|
||||
|
||||
# 分类
|
||||
categorized = {
|
||||
"cost_reduction": [], # 降本类
|
||||
"revenue_growth": [], # 增收类
|
||||
"capability": [], # 能力类
|
||||
"system": [], # 系统类
|
||||
}
|
||||
|
||||
for kpi in kpis:
|
||||
calc_info = _calc_budget(kpi)
|
||||
current_val = latest_values.get(kpi.id)
|
||||
|
||||
# 降本类: 需要当前值
|
||||
if calc_info["calc_type"] == "降本类":
|
||||
if current_val is not None and kpi.target_value:
|
||||
diff = current_val - kpi.target_value
|
||||
suggested = round(max(diff, 0) * 0.3, 2)
|
||||
calc_logic = f"当前值{current_val}-目标值{kpi.target_value}={diff:.2f},×0.3={suggested:.2f}"
|
||||
else:
|
||||
suggested = 0
|
||||
calc_logic = "缺少当前值或目标值,无法计算"
|
||||
item = {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"calc_type": "降本类",
|
||||
"target_value": kpi.target_value,
|
||||
"current_value": current_val,
|
||||
"suggested_budget": suggested,
|
||||
"calc_logic": calc_logic,
|
||||
}
|
||||
categorized["cost_reduction"].append(item)
|
||||
|
||||
elif calc_info["calc_type"] == "增收类":
|
||||
suggested = round((kpi.target_value or 0) * 0.2, 2)
|
||||
calc_logic = f"目标增收额{kpi.target_value}×0.2={suggested:.2f}"
|
||||
item = {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"calc_type": "增收类",
|
||||
"target_value": kpi.target_value,
|
||||
"current_value": current_val,
|
||||
"suggested_budget": suggested,
|
||||
"calc_logic": calc_logic,
|
||||
}
|
||||
categorized["revenue_growth"].append(item)
|
||||
|
||||
elif calc_info["calc_type"] == "能力类":
|
||||
# 假设人均培训成本2000元, 默认10人
|
||||
suggested = round(2000 * 10, 2)
|
||||
calc_logic = f"人均培训成本2000元×10人={suggested:.2f}(可调整人数和单价)"
|
||||
item = {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"calc_type": "能力类",
|
||||
"target_value": kpi.target_value,
|
||||
"current_value": current_val,
|
||||
"suggested_budget": suggested,
|
||||
"calc_logic": calc_logic,
|
||||
"per_head_cost": 2000,
|
||||
"head_count": 10,
|
||||
}
|
||||
categorized["capability"].append(item)
|
||||
|
||||
else: # 系统类
|
||||
suggested = round((kpi.target_value or 0) * 0.15, 2)
|
||||
if suggested <= 0:
|
||||
suggested = 30000 # 默认3万
|
||||
calc_logic = "按模块开发费估算: 默认30000元(可调整)"
|
||||
else:
|
||||
calc_logic = f"目标值{kpi.target_value}×0.15={suggested:.2f}"
|
||||
item = {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"category": kpi.category,
|
||||
"calc_type": "系统类",
|
||||
"target_value": kpi.target_value,
|
||||
"current_value": current_val,
|
||||
"suggested_budget": suggested,
|
||||
"calc_logic": calc_logic,
|
||||
}
|
||||
categorized["system"].append(item)
|
||||
|
||||
return {"data": categorized}
|
||||
|
||||
|
||||
@router.post("/generate-from-kpis")
|
||||
def generate_budget_from_kpis(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""从选中的KPI生成预算科目
|
||||
|
||||
Body: {
|
||||
year: int,
|
||||
month: int,
|
||||
version: string,
|
||||
items: [
|
||||
{
|
||||
kpi_id: int,
|
||||
budget_amount: float, // 用户可编辑
|
||||
calc_logic: string,
|
||||
calc_type: string,
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
year = data.get("year", datetime.now().year)
|
||||
month = data.get("month", datetime.now().month + 1)
|
||||
version = data.get("version", "v1.0")
|
||||
items = data.get("items", [])
|
||||
|
||||
if not items:
|
||||
raise HTTPException(400, "请至少选择一个KPI")
|
||||
|
||||
period = f"{year}-{month:02d}"
|
||||
results = []
|
||||
total_amount = 0
|
||||
_kpi_entities = set() # 2026-09-23 ④:审计带账套(取被处理 KPI 自身的账套,不猜会话账套)
|
||||
|
||||
for item in items:
|
||||
kpi_id = item.get("kpi_id")
|
||||
budget_amount = item.get("budget_amount")
|
||||
calc_logic = item.get("calc_logic", "")
|
||||
calc_type = item.get("calc_type", "")
|
||||
|
||||
if not kpi_id or budget_amount is None:
|
||||
continue
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
continue
|
||||
_kpi_entities.add(getattr(kpi, "entity_id", None))
|
||||
|
||||
# 检查是否已有记录
|
||||
existing = db.query(BudgetPlan).filter(
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.version == version,
|
||||
BudgetPlan.status == "active",
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.budget_value = budget_amount
|
||||
existing.source_type = "kpi_generated"
|
||||
existing.source_kpi_id = kpi_id
|
||||
existing.calc_logic = calc_logic
|
||||
existing.remark = f"KPI推算({calc_type}): {calc_logic}"
|
||||
plan_id = existing.id
|
||||
else:
|
||||
plan = BudgetPlan(
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
budget_value=budget_amount,
|
||||
budget_year=year,
|
||||
budget_month=month,
|
||||
version=version,
|
||||
status="active",
|
||||
source_type="kpi_generated",
|
||||
source_kpi_id=kpi_id,
|
||||
calc_logic=calc_logic,
|
||||
remark=f"KPI推算({calc_type}): {calc_logic}",
|
||||
created_by=current_user.name if hasattr(current_user, "name") else "",
|
||||
)
|
||||
db.add(plan)
|
||||
db.flush()
|
||||
plan_id = plan.id
|
||||
|
||||
total_amount += budget_amount
|
||||
results.append({
|
||||
"kpi_id": kpi_id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"budget_amount": budget_amount,
|
||||
"calc_logic": calc_logic,
|
||||
"plan_id": plan_id,
|
||||
})
|
||||
|
||||
# 操作日志(2026-09-23 ④:detail 带账套;跨账套批量 ⇒ 留空 + 哨兵,不猜账套)
|
||||
_entities = sorted(x for x in _kpi_entities if x is not None)
|
||||
log_op(db, "kpi_generate_budget", target_type="budget",
|
||||
user_id=getattr(current_user, "id", None),
|
||||
detail={
|
||||
"year": year,
|
||||
"month": month,
|
||||
"version": version,
|
||||
"item_count": len(results),
|
||||
"total_amount": total_amount,
|
||||
"kpi_entity_ids": _entities,
|
||||
},
|
||||
entity_id=(_entities[0] if len(_entities) == 1 else None),
|
||||
scope=(SCOPE_BATCH if len(_entities) > 1 else None))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"已从{len(results)}个KPI生成预算,合计¥{total_amount:,.2f}",
|
||||
"total_amount": total_amount,
|
||||
"items": results,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
"""现金流分类规则 API — 管理会计OS (P2-⑥ 2026-08-28)
|
||||
|
||||
分类规则管理(cash_plan_classify_rules) + 待分类队列(cash_plan_unclassified) + 一键归类。
|
||||
sync-cash-plans 未命中的KPI进入待分类队列,人工一键归类 → 自动补建规则+生成CashPlan。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import (
|
||||
CashPlanClassifyRule, CashPlanUnclassified, CashPlan,
|
||||
KPIDefinition, BudgetPlan,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["现金流分类"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ── 分类规则 CRUD ──────────────────────────────
|
||||
|
||||
@router.get("/cash-classify-rules")
|
||||
def list_classify_rules(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""分类规则列表(按 entity_id 隔离)"""
|
||||
rows = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.entity_id == entity_id
|
||||
).order_by(CashPlanClassifyRule.priority.asc(), CashPlanClassifyRule.id.desc()).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows if r.kpi_id}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id) if r.kpi_id else None
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"kpi_code_pattern": r.kpi_code_pattern,
|
||||
"plan_type": r.plan_type,
|
||||
"priority": r.priority,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.post("/cash-classify-rules")
|
||||
def create_classify_rule(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建分类规则(kpi_id 精确 或 kpi_code_pattern 关键词 二选一)"""
|
||||
plan_type = data.get("plan_type")
|
||||
if plan_type not in ("receive", "pay"):
|
||||
raise HTTPException(400, "plan_type 必须是 receive/pay")
|
||||
kpi_id = data.get("kpi_id")
|
||||
pattern = data.get("kpi_code_pattern")
|
||||
if not kpi_id and not pattern:
|
||||
raise HTTPException(400, "需要 kpi_id 或 kpi_code_pattern 至少一个")
|
||||
|
||||
row = CashPlanClassifyRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
kpi_code_pattern=pattern,
|
||||
plan_type=plan_type,
|
||||
priority=data.get("priority", 10),
|
||||
status=data.get("status", "active"),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "分类规则已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/cash-classify-rules/{rule_id}")
|
||||
def update_classify_rule(
|
||||
rule_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新分类规则"""
|
||||
row = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.id == rule_id,
|
||||
CashPlanClassifyRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
for field in ("kpi_id", "kpi_code_pattern", "plan_type", "priority", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "分类规则已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/cash-classify-rules/{rule_id}")
|
||||
def delete_classify_rule(
|
||||
rule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除分类规则"""
|
||||
row = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.id == rule_id,
|
||||
CashPlanClassifyRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "分类规则已删除"}
|
||||
|
||||
|
||||
# ── 待分类队列 ──────────────────────────────
|
||||
|
||||
@router.get("/cash-unclassified")
|
||||
def list_unclassified(
|
||||
status: Optional[str] = Query(None, description="pending/classified/ignored"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""待分类KPI队列"""
|
||||
query = db.query(CashPlanUnclassified).filter(CashPlanUnclassified.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(CashPlanUnclassified.status == status)
|
||||
rows = query.order_by(CashPlanUnclassified.created_at.desc()).all()
|
||||
result = []
|
||||
for r in rows:
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_name": r.kpi_name,
|
||||
"period": r.period,
|
||||
"budget_value": r.budget_value,
|
||||
"reason": r.reason,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"resolved_at": r.resolved_at.isoformat() if r.resolved_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.post("/cash-unclassified/{item_id}/classify")
|
||||
def classify_unclassified(
|
||||
item_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""一键归类: body {plan_type: receive/pay}
|
||||
① 自动补建分类规则 ② 标记队列 classified ③ 联动生成对应 CashPlan
|
||||
"""
|
||||
plan_type = data.get("plan_type")
|
||||
if plan_type not in ("receive", "pay"):
|
||||
raise HTTPException(400, "plan_type 必须是 receive/pay")
|
||||
|
||||
item = db.query(CashPlanUnclassified).filter(
|
||||
CashPlanUnclassified.id == item_id,
|
||||
CashPlanUnclassified.entity_id == entity_id,
|
||||
CashPlanUnclassified.status == "pending",
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "待分类记录不存在或已处理")
|
||||
|
||||
# ① 自动补建规则(无精确KPI规则时)
|
||||
existing_rule = db.query(CashPlanClassifyRule).filter(
|
||||
CashPlanClassifyRule.entity_id == entity_id,
|
||||
CashPlanClassifyRule.kpi_id == item.kpi_id,
|
||||
).first()
|
||||
if not existing_rule:
|
||||
db.add(CashPlanClassifyRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=item.kpi_id,
|
||||
kpi_code_pattern=None,
|
||||
plan_type=plan_type,
|
||||
priority=10,
|
||||
status="active",
|
||||
))
|
||||
|
||||
# ② 标记队列
|
||||
item.status = "classified"
|
||||
item.resolved_at = datetime.now()
|
||||
|
||||
# ③ 联动生成 CashPlan(有期间和预算值时;年度行跳过,不落月度计划)
|
||||
from app.api.budget import _is_annual_budget_row # 函数内局部导入,规避 import 顺序/循环风险
|
||||
plan_created = False
|
||||
skipped_annual = False
|
||||
if item.period and item.budget_value is not None:
|
||||
# 先按形态解析月份(不裸索引 split,避免 'YYYY' / 'YYYY-00' 抛异常被静默吞掉)
|
||||
parts = item.period.split("-") if isinstance(item.period, str) else []
|
||||
month = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
if _is_annual_budget_row(item.period, month):
|
||||
# 年度行(YYYY / YYYY-00 / budget_month=0)不得落成月度现金流计划
|
||||
skipped_annual = True
|
||||
else:
|
||||
try:
|
||||
year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1])
|
||||
plan_date = datetime(year, month, 1)
|
||||
except Exception:
|
||||
plan_date = None
|
||||
if plan_date:
|
||||
existing_plan = db.query(CashPlan).filter(
|
||||
CashPlan.entity_id == entity_id,
|
||||
CashPlan.related_kpi_id == item.kpi_id,
|
||||
CashPlan.plan_type == plan_type,
|
||||
CashPlan.plan_date == plan_date,
|
||||
).first()
|
||||
if not existing_plan:
|
||||
db.add(CashPlan(
|
||||
entity_id=entity_id,
|
||||
plan_type=plan_type,
|
||||
related_kpi_id=item.kpi_id,
|
||||
amount=item.budget_value,
|
||||
plan_date=plan_date,
|
||||
description=f"待分类队列归类: {item.kpi_name or ''}",
|
||||
status="pending",
|
||||
source="budget_sync",
|
||||
))
|
||||
plan_created = True
|
||||
|
||||
db.commit()
|
||||
message = f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else "")
|
||||
if skipped_annual:
|
||||
message += "(年度行,跳过月度现金流计划)"
|
||||
return {
|
||||
"message": message,
|
||||
"plan_type": plan_type,
|
||||
"rule_created": not existing_rule,
|
||||
"plan_created": plan_created,
|
||||
"skipped_annual": skipped_annual,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cash-unclassified/{item_id}/ignore")
|
||||
def ignore_unclassified(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""忽略该KPI(不生成规则)"""
|
||||
item = db.query(CashPlanUnclassified).filter(
|
||||
CashPlanUnclassified.id == item_id,
|
||||
CashPlanUnclassified.entity_id == entity_id,
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "待分类记录不存在")
|
||||
item.status = "ignored"
|
||||
item.resolved_at = datetime.now()
|
||||
db.commit()
|
||||
return {"message": "已忽略"}
|
||||
@@ -0,0 +1,496 @@
|
||||
"""
|
||||
智享通凭证 JSON 直传导入端点 (zxt-cma-import-v1, 2026-09-06)
|
||||
============================================================
|
||||
背景:智享通推送链路每10分钟落盘 2000 行凭证分录 JSON,需进 CMA voucher_details。
|
||||
现有 /api/cma/cash/import/vouchers 只收 Excel multipart,本模块提供 JSON 直传版本。
|
||||
|
||||
链路:
|
||||
接收服务 8500 落盘 incoming/{ts}.json → hook(zxt-cma-import-hook.py) → POST 本端点
|
||||
|
||||
鉴权:X-BOT-KEY: cma-bot-admin-2026(verify_bot_key,与 /api/cma/bot/* 共享密钥)。
|
||||
注意:本 router 独立于 cash.py 的 router —— cash.router 带 require_role 需用户
|
||||
Bearer token,Bot hook 只有 X-BOT-KEY 到不了,故同前缀另挂本 router(FastAPI 允许多 router 同前缀)。
|
||||
|
||||
校验口径(对齐财务BOT映射说明 cma_field_mapping_finance_20260906.md + 任务单验收):
|
||||
1. 行级:voucher_no 必有;debit/credit 字符串→float round2,允许负数(红字记账,负数行计数 neg_rows),借贷不同时0
|
||||
2. 结转行:summary 含"结转" → carry_forward=1(zxt 113行"期间损益结转"100%命中)
|
||||
3. 凭证级借贷平衡(每 Voucher_ID Σ借=Σ贷 容差0.01)—— 批级平衡对 zxt 快照不适用
|
||||
(已知单边凭证 30694 仅贷5479.65),不平衡凭证整张拒收记异常清单,不整批拒绝
|
||||
4. 日期/period:三级优先——行内 voucher_date/VoucherDate/BillDate 字段 → summary 前缀
|
||||
YYYY-MM-DD 解析 → 均无则按 pending 拒收;不冒充文件生成时间/当前时间
|
||||
5. 科目:subject_code/subject_name 全 null(智享通侧未带)。解析优先级:
|
||||
a) 行内 subject_code/subject_name 有值 → 直用
|
||||
b) 无 → 查请求体 subject_map {CwAccountID: {subject_code, subject_name}}
|
||||
或 DB 表 zxt_account_dict(CwAccountID, subject_code, subject_name)(若已建)
|
||||
c) 未命中 → 该行拒收 reason="科目字典未命中 CwAccountID=x"(不写脏数据)
|
||||
6. 幂等:快照每10分钟全量重推同一内容。batch = zxt-{行集sha1前10},
|
||||
导入前查 import_logs 已存在同 batch → 直接返回 already_imported(不再入库);
|
||||
行级再以业务键 (entity_id, voucher_no, subject_code, debit, credit, summary) 防重复
|
||||
—— period 是 voucher_date 派生属性,**不进幂等键**(2026-09-11 加固:日期口径
|
||||
在 summary 派生/主表 VoucherDate 之间切换时,含 period 会判重失效导致重复入库)。
|
||||
命中既有行即跳过;仅当命中行 period 与新行不一致时计入 period_conflict 留痕(不覆盖)。
|
||||
7. entity_id:**必传**(ASK-20260924-17,任总 2026-09-24 放行)——body `entity_id` 或
|
||||
header `X-Entity-Id`(1=陕西酣客、2=陕西博海);**缺省 → 400**,
|
||||
不再兜底默认账套 1(原口径:默认 1 陕西酣客,source=zxt-sxzxb20210228)。
|
||||
8. 入库后不触发现金流/ledger 联动(凭证完整性未证实前不作为权威来源,见财务口径警示);
|
||||
也不影响 Excel multipart 导入(原端点不动)。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import resolve_entity_for_request, require_bot_entity_id
|
||||
from app.models import Entity, ImportLog, VoucherDetail
|
||||
from app.api.bot_bridge import verify_bot_key
|
||||
|
||||
logger = logging.getLogger("cma.cash_zxt")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/cash", tags=["资金管理-智享通JSON导入"])
|
||||
|
||||
_DATE_RE = re.compile(r"(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})")
|
||||
_AMOUNT_RE = re.compile(r"^-?\d[\d,]*\.?\d*$")
|
||||
|
||||
|
||||
def _norm_str(raw) -> str:
|
||||
"""干净字符串:None/空 → ''"""
|
||||
if raw is None:
|
||||
return ""
|
||||
return str(raw).strip()
|
||||
|
||||
|
||||
def _parse_amount(raw) -> Optional[float]:
|
||||
"""金额:字符串数字(可带逗号)→ float;空/None → 0.0;非法 → None"""
|
||||
if raw is None:
|
||||
return 0.0
|
||||
s = str(raw).strip()
|
||||
if s == "":
|
||||
return 0.0
|
||||
if not _AMOUNT_RE.match(s):
|
||||
return None
|
||||
try:
|
||||
return round(float(s.replace(",", "")), 2)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_from_summary(summary: str):
|
||||
"""summary 前缀提取日期 (YYYY-MM-DD)。zxt 格式='业务类型-YYYY-MM-DD-序号_往来_业务员'"""
|
||||
m = _DATE_RE.search(summary or "")
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_value(raw) -> Optional[datetime]:
|
||||
"""行内日期字段解析(voucher_date/VoucherDate/BillDate)。
|
||||
datetime/date 对象直用;字符串 strip 后 fromisoformat(YYYY-MM-DD / YYYY-MM-DD[ T]HH:MM:SS),
|
||||
失败回退 _DATE_RE search(YYYY/MM/DD 等);仍取不到返回 None——严禁冒充当前时间/文件时间。"""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, datetime):
|
||||
return datetime(raw.year, raw.month, raw.day)
|
||||
if isinstance(raw, date):
|
||||
return datetime(raw.year, raw.month, raw.day)
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(s)
|
||||
return datetime(dt.year, dt.month, dt.day)
|
||||
except ValueError:
|
||||
pass
|
||||
m = _DATE_RE.search(s)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_carry_forward(summary: str) -> bool:
|
||||
"""结转行识别(subject_name 缺失只能靠 summary):zxt 期间损益结转 100% 命中"""
|
||||
return "结转" in (summary or "")
|
||||
|
||||
|
||||
def _content_fingerprint(rows: list) -> str:
|
||||
"""行集内容指纹:每行规范化 JSON 排序后 sha1 前 10 位(快照幂等用)"""
|
||||
canon = json.dumps(rows, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha1(canon.encode("utf-8")).hexdigest()[:10]
|
||||
|
||||
|
||||
def _load_zxt_account_dict(db: Session) -> dict:
|
||||
"""读 DB 科目字典表(若已建):zxt_account_dict(CwAccountID, subject_code, subject_name)。
|
||||
表不存在 → 返回空 dict(由调用方决定走请求体 subject_map 或拒收)"""
|
||||
try:
|
||||
rows = db.execute(
|
||||
sa_text("SELECT CwAccountID, subject_code, subject_name FROM zxt_account_dict")
|
||||
).fetchall()
|
||||
return {
|
||||
str(r[0]): {"subject_code": str(r[1]), "subject_name": str(r[2])}
|
||||
for r in rows if r[1]
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_subject(row: dict, db_account_dict: dict, req_subject_map: dict):
|
||||
"""科目解析优先级:行内 subject_code → DB 字典 → 请求体 subject_map。返回 (code, name) 或 None"""
|
||||
code = _norm_str(row.get("subject_code"))
|
||||
name = _norm_str(row.get("subject_name"))
|
||||
if code:
|
||||
return code, name
|
||||
cw_id = _norm_str(row.get("CwAccountID"))
|
||||
if not cw_id:
|
||||
return None
|
||||
hit = db_account_dict.get(cw_id) or req_subject_map.get(cw_id)
|
||||
if not hit:
|
||||
return None
|
||||
c = _norm_str(hit.get("subject_code")) if isinstance(hit, dict) else ""
|
||||
if not c:
|
||||
return None
|
||||
n = _norm_str(hit.get("subject_name")) if isinstance(hit, dict) else ""
|
||||
return c, n
|
||||
|
||||
|
||||
def _entity_active(db: Session, entity_id: int) -> bool:
|
||||
try:
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
return bool(ent and ent.status == "active")
|
||||
except Exception:
|
||||
return entity_id == 1 # Entity 模型不可用兜底(正常不会走到)
|
||||
|
||||
|
||||
@router.post("/import/vouchers-json")
|
||||
def api_import_vouchers_json(
|
||||
request: Request,
|
||||
payload: Union[dict, list] = Body(...),
|
||||
bot: dict = Depends(verify_bot_key),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""JSON 直传凭证导入(智享通推送通道)。三种 body 形态均兼容:
|
||||
A. 裸 rows 数组: [{Voucher_ID, CwAccountID, summary, debit, credit, ...}]
|
||||
B. {source?, rows: [...]}
|
||||
C. 完整落盘包: {schema, source, datasets: [{dataset, mode, rows}]} → 取 datasets[0].rows
|
||||
附加字段(形态 B/C): entity_id(默认1) / subject_map({CwAccountID: {subject_code, subject_name}})
|
||||
"""
|
||||
# ── body 形态归一 → rows ──
|
||||
rows_raw: list = []
|
||||
source = ""
|
||||
req_entity = None
|
||||
req_subject_map: dict = {}
|
||||
if isinstance(payload, list):
|
||||
rows_raw = payload
|
||||
elif isinstance(payload, dict):
|
||||
if "datasets" in payload and isinstance(payload.get("datasets"), list):
|
||||
ds = payload["datasets"]
|
||||
if not ds or not isinstance(ds[0], dict):
|
||||
raise HTTPException(400, "datasets 结构不符: 缺少 datasets[0]")
|
||||
rows_raw = ds[0].get("rows") or []
|
||||
source = _norm_str(payload.get("source"))
|
||||
elif "rows" in payload:
|
||||
rows_raw = payload.get("rows") or []
|
||||
source = _norm_str(payload.get("source"))
|
||||
else:
|
||||
raise HTTPException(400, "无法识别的请求体: 期望 rows 数组 / {rows:[...]} / {datasets:[{rows:[...]}]}")
|
||||
req_entity = payload.get("entity_id")
|
||||
if isinstance(payload.get("subject_map"), dict):
|
||||
req_subject_map = payload["subject_map"]
|
||||
else:
|
||||
raise HTTPException(400, "请求体必须为 JSON 对象或数组")
|
||||
|
||||
if not isinstance(rows_raw, list) or not rows_raw:
|
||||
raise HTTPException(400, "rows 为空或非数组")
|
||||
|
||||
# 兼容 row key 大小写(智享通字段为大写 Voucher_ID/CwAccountID)
|
||||
norm_rows = []
|
||||
for i, r in enumerate(rows_raw):
|
||||
if not isinstance(r, dict):
|
||||
raise HTTPException(400, f"rows[{i}] 不是JSON对象")
|
||||
nr = {}
|
||||
for k, v in r.items():
|
||||
nr[k] = v
|
||||
if k == "Voucher_ID":
|
||||
nr["voucher_no"] = v
|
||||
elif k == "CwAccountID":
|
||||
nr["CwAccountID"] = v
|
||||
norm_rows.append(nr)
|
||||
|
||||
# ── entity 解析 + active 校验(ASK-20260924-17:Bot 通道必须显式传账套)──
|
||||
entity_id = resolve_entity_for_request(
|
||||
request,
|
||||
require_bot_entity_id(
|
||||
{"entity_id": req_entity if req_entity not in (None, "") else request.headers.get("X-Entity-Id")},
|
||||
source="cash_vouchers_json.import/vouchers-json",
|
||||
),
|
||||
)
|
||||
try:
|
||||
entity_id = int(entity_id)
|
||||
except Exception:
|
||||
raise HTTPException(422, f"entity_id 无效: {entity_id!r}")
|
||||
if not _entity_active(db, entity_id):
|
||||
raise HTTPException(403, f"企业ID={entity_id} 不存在或未激活")
|
||||
|
||||
db_account_dict = _load_zxt_account_dict(db)
|
||||
|
||||
# ── 行级解析 + 校验 ──
|
||||
errors = []
|
||||
parsed = []
|
||||
pending_undated = 0
|
||||
pending_dict = 0
|
||||
neg_rows = 0
|
||||
for idx, raw in enumerate(norm_rows):
|
||||
voucher_no = _norm_str(raw.get("voucher_no") or raw.get("Voucher_ID"))
|
||||
if not voucher_no:
|
||||
errors.append({"row": idx + 1, "field": "voucher_no", "reason": "凭证号为空"})
|
||||
continue
|
||||
|
||||
# 三级优先:行内日期字段 → summary 前缀 → 拒收
|
||||
date_obj = _parse_date_value(
|
||||
raw.get("voucher_date") or raw.get("VoucherDate") or raw.get("BillDate")
|
||||
)
|
||||
if date_obj is None:
|
||||
date_obj = _parse_date_from_summary(_norm_str(raw.get("summary")))
|
||||
if date_obj is None:
|
||||
pending_undated += 1
|
||||
errors.append({"row": idx + 1, "field": "voucher_date",
|
||||
"reason": f"行内日期与summary均无日期(待日期来源): {_norm_str(raw.get('summary'))[:40]}"})
|
||||
continue
|
||||
|
||||
debit = _parse_amount(raw.get("debit"))
|
||||
credit = _parse_amount(raw.get("credit"))
|
||||
if debit is None:
|
||||
errors.append({"row": idx + 1, "field": "debit", "reason": f"借方金额不是有效数字: {raw.get('debit')}"})
|
||||
continue
|
||||
if credit is None:
|
||||
errors.append({"row": idx + 1, "field": "credit", "reason": f"贷方金额不是有效数字: {raw.get('credit')}"})
|
||||
continue
|
||||
if debit < 0 or credit < 0:
|
||||
neg_rows += 1 # 红字记账允许负数入库,仅计数不拒收
|
||||
if debit == 0 and credit == 0:
|
||||
errors.append({"row": idx + 1, "field": "amount", "reason": "借贷金额不能同时为0"})
|
||||
continue
|
||||
|
||||
summary = _norm_str(raw.get("summary"))
|
||||
subject = _resolve_subject(raw, db_account_dict, req_subject_map)
|
||||
if subject is None:
|
||||
pending_dict += 1
|
||||
errors.append({"row": idx + 1, "field": "subject_code",
|
||||
"reason": f"科目字典未命中 CwAccountID={_norm_str(raw.get('CwAccountID')) or '空'}"})
|
||||
continue
|
||||
|
||||
parsed.append({
|
||||
"voucher_no": voucher_no,
|
||||
"date": date_obj,
|
||||
"period": date_obj.strftime("%Y-%m"),
|
||||
"debit_amount": debit,
|
||||
"credit_amount": credit,
|
||||
"summary": summary,
|
||||
"carry_forward": 1 if _is_carry_forward(summary) else 0,
|
||||
"subject_code": subject[0],
|
||||
"subject_name": subject[1],
|
||||
})
|
||||
|
||||
total = len(rows_raw)
|
||||
if not parsed:
|
||||
db.rollback()
|
||||
return {
|
||||
"success": False,
|
||||
"message": "无有效行可入库(科目字典未建立/日期缺失/行级校验全失败),全部拒绝",
|
||||
"total": total,
|
||||
"success_rows": 0,
|
||||
"failed_rows": len(errors),
|
||||
"errors": errors[:100],
|
||||
"balance_check": None,
|
||||
"carry_forward_count": 0,
|
||||
"batch": None,
|
||||
"entity_id": entity_id,
|
||||
"pending_undated": pending_undated,
|
||||
"pending_dict_miss": pending_dict,
|
||||
"neg_rows": neg_rows,
|
||||
"dup_skipped": 0,
|
||||
"period_conflict": 0,
|
||||
"hint": "科目字典:请协调智享通推送方补 subject_code/subject_name,或在 body 带 subject_map,或建 zxt_account_dict 表",
|
||||
}
|
||||
|
||||
# ── 凭证级借贷平衡(每 Voucher_ID Σ借=Σ贷 容差0.01)──
|
||||
balance_by_voucher = {}
|
||||
for r in parsed:
|
||||
bv = balance_by_voucher.setdefault(r["voucher_no"], {"debit_total": 0.0, "credit_total": 0.0})
|
||||
bv["debit_total"] = round(bv["debit_total"] + r["debit_amount"], 2)
|
||||
bv["credit_total"] = round(bv["credit_total"] + r["credit_amount"], 2)
|
||||
abnormal_vouchers = []
|
||||
voucher_pass = set()
|
||||
for vno, bv in balance_by_voucher.items():
|
||||
diff = round(bv["debit_total"] - bv["credit_total"], 2)
|
||||
if abs(diff) > 0.01:
|
||||
abnormal_vouchers.append({
|
||||
"voucher_no": vno,
|
||||
"debit_total": bv["debit_total"],
|
||||
"credit_total": bv["credit_total"],
|
||||
"diff": diff,
|
||||
})
|
||||
else:
|
||||
voucher_pass.add(vno)
|
||||
|
||||
if abnormal_vouchers:
|
||||
abnormal_set = {a["voucher_no"] for a in abnormal_vouchers}
|
||||
kept = []
|
||||
for r in parsed:
|
||||
if r["voucher_no"] in abnormal_set:
|
||||
errors.append({"row": 0, "field": "balance",
|
||||
"reason": f"凭证 {r['voucher_no']} 借贷不平衡(整张凭证拒绝入库)"})
|
||||
else:
|
||||
kept.append(r)
|
||||
parsed = kept
|
||||
|
||||
valid_rows = [r for r in parsed if r["voucher_no"] in voucher_pass]
|
||||
success = len(valid_rows)
|
||||
failed = len(errors)
|
||||
|
||||
# ── 幂等:batch 内容指纹去重(batch 不含时间戳——同内容快照每10分钟重推必须命中同一 batch)──
|
||||
fingerprint = _content_fingerprint(rows_raw)
|
||||
batch = f"zxt-{fingerprint}"
|
||||
existing_batch = db.query(ImportLog).filter(
|
||||
ImportLog.batch == batch,
|
||||
ImportLog.import_type == "vouchers-json",
|
||||
).first()
|
||||
if existing_batch:
|
||||
db.rollback()
|
||||
return {
|
||||
"success": True,
|
||||
"already_imported": True,
|
||||
"message": "该快照内容已导入过(同一指纹 batch),跳过重复入库",
|
||||
"total": total,
|
||||
"success_rows": 0,
|
||||
"failed_rows": 0,
|
||||
"dup_skipped": 0,
|
||||
"period_conflict": 0,
|
||||
"neg_rows": neg_rows,
|
||||
"pending_undated": pending_undated,
|
||||
"pending_dict_miss": pending_dict,
|
||||
"batch": batch,
|
||||
"entity_id": entity_id,
|
||||
}
|
||||
|
||||
# ── 入库(行级业务键防重复)──
|
||||
# 幂等键 = (entity_id, voucher_no, subject_code, debit, credit, summary):
|
||||
# period 是由 voucher_date 派生的属性,不进唯一性键(否则日期口径变化时会判重失效)。
|
||||
# 同键多行按"载荷内出现次序"编号:第 n 次出现只与库内第 n 行比对——跨批次同键多重数
|
||||
# 时不会因 query().first() 恒命中同一既存行而把源端合法多行静默吞掉。
|
||||
inserted = 0
|
||||
dup_skipped = 0
|
||||
period_conflict = 0
|
||||
period_conflicts: list = []
|
||||
|
||||
row_groups: dict = {}
|
||||
for r in valid_rows:
|
||||
k = (r["voucher_no"], r["subject_code"], r["debit_amount"],
|
||||
r["credit_amount"], r["summary"])
|
||||
row_groups.setdefault(k, []).append(r)
|
||||
|
||||
for k, group_rows in row_groups.items():
|
||||
existing_rows = db.query(VoucherDetail).filter(
|
||||
VoucherDetail.entity_id == entity_id,
|
||||
VoucherDetail.voucher_no == k[0],
|
||||
VoucherDetail.subject_code == k[1],
|
||||
VoucherDetail.debit_amount == k[2],
|
||||
VoucherDetail.credit_amount == k[3],
|
||||
VoucherDetail.summary == k[4],
|
||||
).order_by(VoucherDetail.id).all()
|
||||
for pos, r in enumerate(group_rows):
|
||||
if pos < len(existing_rows):
|
||||
dup_skipped += 1
|
||||
old = existing_rows[pos]
|
||||
if (old.period or None) != (r["period"] or None):
|
||||
# 判重命中但日期口径不同:只留痕不覆盖(留痕优先,人工判定)
|
||||
period_conflict += 1
|
||||
if len(period_conflicts) < 50:
|
||||
period_conflicts.append({
|
||||
"voucher_no": r["voucher_no"],
|
||||
"existing_id": old.id,
|
||||
"existing_period": old.period,
|
||||
"incoming_period": r["period"],
|
||||
"existing_batch": old.batch,
|
||||
"incoming_batch": batch,
|
||||
})
|
||||
continue
|
||||
db.add(VoucherDetail(
|
||||
entity_id=entity_id,
|
||||
voucher_no=r["voucher_no"],
|
||||
voucher_date=r["date"],
|
||||
subject_code=r["subject_code"],
|
||||
subject_name=r["subject_name"],
|
||||
debit_amount=r["debit_amount"],
|
||||
credit_amount=r["credit_amount"],
|
||||
summary=r["summary"],
|
||||
carry_forward=r["carry_forward"],
|
||||
period=r["period"],
|
||||
batch=batch,
|
||||
))
|
||||
inserted += 1
|
||||
|
||||
period_totals = {}
|
||||
for r in valid_rows:
|
||||
pt = period_totals.setdefault(r["period"], {"debit_total": 0.0, "credit_total": 0.0})
|
||||
pt["debit_total"] = round(pt["debit_total"] + r["debit_amount"], 2)
|
||||
pt["credit_total"] = round(pt["credit_total"] + r["credit_amount"], 2)
|
||||
|
||||
carry_forward_count = sum(1 for r in valid_rows if r["carry_forward"])
|
||||
db.add(ImportLog(
|
||||
entity_id=entity_id,
|
||||
filename=f"zxt-push-voucher-snapshot.json" + (f"({source})" if source else ""),
|
||||
batch=batch,
|
||||
total_rows=total,
|
||||
success_rows=inserted,
|
||||
failed_rows=failed,
|
||||
errors=errors[:100] or None,
|
||||
period=valid_rows[0]["period"] if valid_rows else None,
|
||||
import_type="vouchers-json",
|
||||
created_by="zxt-cma-import-hook",
|
||||
))
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"智享通凭证JSON导入[{batch}] entity={entity_id}: 总{total}/入库{inserted}/行级失败{failed}/重复跳过{dup_skipped}/"
|
||||
f"期间冲突{period_conflict}, 凭证级异常{len(abnormal_vouchers)}"
|
||||
)
|
||||
if period_conflict:
|
||||
logger.warning(
|
||||
f"智享通凭证JSON导入[{batch}] entity={entity_id}: 行级幂等命中但 period 不一致 {period_conflict} 行"
|
||||
f"(未覆盖仅留痕,样例: {period_conflicts[:5]})"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total": total,
|
||||
"success_rows": inserted,
|
||||
"failed_rows": failed,
|
||||
"dup_skipped": dup_skipped,
|
||||
"period_conflict": period_conflict,
|
||||
"period_conflicts": period_conflicts or None,
|
||||
"errors": errors[:100] or None,
|
||||
"balance_check": {
|
||||
"mode": "voucher_level",
|
||||
"passed": len(abnormal_vouchers) == 0,
|
||||
"abnormal_vouchers": abnormal_vouchers,
|
||||
"tolerance": 0.01,
|
||||
},
|
||||
"period_totals": period_totals,
|
||||
"carry_forward_count": carry_forward_count,
|
||||
"pending_undated": pending_undated,
|
||||
"pending_dict_miss": pending_dict,
|
||||
"neg_rows": neg_rows,
|
||||
"batch": batch,
|
||||
"entity_id": entity_id,
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
现金流量表结构分析 — 管理会计OS (CMA P2 · 财务报表分析)
|
||||
规范命名:现金流量表结构分析(本模块不采用民间俗称,术语以任务单为准)
|
||||
|
||||
核心方法:经营(O)/投资(I)/筹资(F) 三大活动净额正负组合 → 8类企业定性分析
|
||||
O 造血能力 / I 成长代价 / F 输血管道
|
||||
长期黄金法则:经营现金流必须为正,且应大于净利润(利润质量)。
|
||||
|
||||
数据模型:cashflow_statements — 官方现金流量表三大活动净额按期间注入
|
||||
- 未注入期间:无判定(不拿演示/示例数据冒充真实类型)
|
||||
- 官方净额入库后类型判定自动生效(博海2026官方表注入路径)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import CashFlowStructureValue
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/cashflow-structure",
|
||||
tags=["现金流量表结构分析"],
|
||||
dependencies=[Depends(require_role("ceo", "finance"))],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 八类企业定性分析规则表(O/I/F 符号组合 → 类型)
|
||||
# ============================================================
|
||||
CASHFLOW_TYPE_RULES = [
|
||||
{
|
||||
"type_no": 1, "o_sign": "+", "i_sign": "-", "f_sign": "-",
|
||||
"name": "成熟健康型", "health_emoji": "🟢", "health_label": "最优",
|
||||
"lifecycle": "成熟期",
|
||||
"profile": "主业盈利,自给自足;仍在投入扩张且有余力还债/分红。最健康形态(如成熟消费龙头)。",
|
||||
"risk": "注意扩张效率与分红可持续性;经营现金流应大于净利润,否则利润质量存疑。",
|
||||
},
|
||||
{
|
||||
"type_no": 2, "o_sign": "+", "i_sign": "+", "f_sign": "-",
|
||||
"name": "成熟收割型", "health_emoji": "🟢", "health_label": "良好",
|
||||
"lifecycle": "成熟期后期/集团收割期",
|
||||
"profile": "主业赚钱+过去投资进入回报期(处置/收益回笼)+还在还债分红。",
|
||||
"risk": "若投资净流入来自大量处置核心资产,需警惕「卖资产过日子」。",
|
||||
},
|
||||
{
|
||||
"type_no": 3, "o_sign": "+", "i_sign": "-", "f_sign": "+",
|
||||
"name": "高速扩张型", "health_emoji": "🟡", "health_label": "看增速",
|
||||
"lifecycle": "成长期",
|
||||
"profile": "主业能赚钱,但扩张更快、现金不够花,需要融资支撑资本开支。成长质量取决于经营造血增速能否追上扩张。",
|
||||
"risk": "扩张节奏失控 → 现金消耗加速 → 滑向类型6/7。",
|
||||
},
|
||||
{
|
||||
"type_no": 4, "o_sign": "+", "i_sign": "+", "f_sign": "+",
|
||||
"name": "全面回笼型", "health_emoji": "🟡", "health_label": "需辨析",
|
||||
"lifecycle": "重组/收缩盘点期",
|
||||
"profile": "三类活动都进钱,少见。多为经营好转+资产处置+新增融资并举(可能正在重组)。",
|
||||
"risk": "需具体辨析筹资来源(股权/债权)与投资流入是否靠卖资产。",
|
||||
},
|
||||
{
|
||||
"type_no": 5, "o_sign": "-", "i_sign": "+", "f_sign": "-",
|
||||
"name": "衰退变卖型", "health_emoji": "🔴", "health_label": "高危",
|
||||
"lifecycle": "衰退期/清盘前奏",
|
||||
"profile": "主业亏损,靠卖资产/处置投资回笼现金,同时还在还债。濒危信号。",
|
||||
"risk": "资产卖完即无以为继;除非经营反转否则难持续。",
|
||||
},
|
||||
{
|
||||
"type_no": 6, "o_sign": "-", "i_sign": "-", "f_sign": "+",
|
||||
"name": "初创烧钱型", "health_emoji": "🟠", "health_label": "高风险",
|
||||
"lifecycle": "初创期/高增长投入期",
|
||||
"profile": "主业烧钱+持续投入,全靠融资吊命。常见于初创/高增长科技企业;也是庞氏结构的表面特征。",
|
||||
"risk": "融资链条断裂即死;须区分「真成长」(收入/用户高速增长)与「假烧钱」。",
|
||||
},
|
||||
{
|
||||
"type_no": 7, "o_sign": "-", "i_sign": "+", "f_sign": "+",
|
||||
"name": "输血生存型", "health_emoji": "🔴", "health_label": "高危",
|
||||
"lifecycle": "挣扎期",
|
||||
"profile": "经营亏损,靠卖资产+借款输血双管齐下维持。压力极大。",
|
||||
"risk": "短期可活,长期必须扭转经营现金流,否则资金链断裂。",
|
||||
},
|
||||
{
|
||||
"type_no": 8, "o_sign": "-", "i_sign": "-", "f_sign": "-",
|
||||
"name": "全面失血型", "health_emoji": "🔴", "health_label": "极危",
|
||||
"lifecycle": "深度危机期",
|
||||
"profile": "经营/投资/筹资三个引擎全熄火,现金只出不进。最危险状态。",
|
||||
"risk": "极可能需要紧急债务重组/股东注资/断臂求生。",
|
||||
},
|
||||
]
|
||||
|
||||
# 类型 → 规则 快速索引
|
||||
TYPE_RULE_MAP = {r["type_no"]: r for r in CASHFLOW_TYPE_RULES}
|
||||
|
||||
# ============================================================
|
||||
# 裁定1(2026-09-17 财务口径裁定):投资活动净额 = 0 的合法输入与二维映射
|
||||
# 「投资活动净额=0」视为**本期无投资活动**,按 0 如实参与八类判定;
|
||||
# 三项中投资恒为 0 → 按 经营(±) × 筹资(±) **二维映射**到对应类型(候选 2 个,
|
||||
# 因投资符号未知);投资栏位标注「本期无投资活动」。
|
||||
# 依据:wecom-finance/reports/CMA_口径裁定_20260917.md 裁定1
|
||||
# ============================================================
|
||||
INVESTING_ZERO_NOTE = "本期无投资活动"
|
||||
|
||||
#: (经营符号, 筹资符号) → 投资符号未知时的候选类型号(对应类型由 8 类规则表反查)
|
||||
TWO_DIM_CANDIDATE_MAP = {
|
||||
("+", "-"): (1, 2),
|
||||
("+", "+"): (3, 4),
|
||||
("-", "-"): (5, 8),
|
||||
("-", "+"): (6, 7),
|
||||
}
|
||||
|
||||
|
||||
def _sign(value: Optional[float]) -> Optional[str]:
|
||||
"""净额符号:>0 → +,<0 → -,==0 → '0',None → None"""
|
||||
if value is None:
|
||||
return None
|
||||
if value > 0:
|
||||
return "+"
|
||||
if value < 0:
|
||||
return "-"
|
||||
return "0"
|
||||
|
||||
|
||||
def _rule_brief(type_no: int) -> dict:
|
||||
"""类型号 → 规则摘要(二维判定用的候选项,字段与三维判定保持一致)"""
|
||||
r = TYPE_RULE_MAP[type_no]
|
||||
return {
|
||||
"type_no": r["type_no"], "name": r["name"], "health_emoji": r["health_emoji"],
|
||||
"health_label": r["health_label"], "lifecycle": r["lifecycle"],
|
||||
"o_sign": r["o_sign"], "i_sign": r["i_sign"], "f_sign": r["f_sign"],
|
||||
"profile": r["profile"], "risk": r["risk"],
|
||||
}
|
||||
|
||||
|
||||
def classify_enterprise(operating_net: Optional[float],
|
||||
investing_net: Optional[float],
|
||||
financing_net: Optional[float]) -> dict:
|
||||
"""O/I/F 三净额符号 → 8类企业定性判定
|
||||
|
||||
返回:
|
||||
{classified, type_no, name, health_emoji, health_label, risk, profile, lifecycle,
|
||||
dimension, o_sign, i_sign, f_sign, reason, investing_note?, candidate_type_nos?, candidates?}
|
||||
classified=False 时 reason 说明未判定的原因。
|
||||
|
||||
2026-09-17 裁定1(财务口径):
|
||||
· **投资活动净额 = 0 是合法输入**(视为「本期无投资活动」),不再整体判「未判定」;
|
||||
· 投资为 0 且经营/筹资可判 → **二维映射**(dimension="2D",候选类型 2 个,
|
||||
投资栏位 investing_note="本期无投资活动");
|
||||
· 经营/筹资任一亦为 0 或缺失 → 回退「未判定(无投资活动)」,**不报错、不静默**。
|
||||
"""
|
||||
o, i, f = _sign(operating_net), _sign(investing_net), _sign(financing_net)
|
||||
names = {"o": "经营", "i": "投资", "f": "筹资"}
|
||||
|
||||
missing = [names[k] for k, s in (("o", o), ("i", i), ("f", f)) if s is None]
|
||||
if missing:
|
||||
return {"classified": False, "dimension": "0D",
|
||||
"reason": "以下活动净额缺失:" + "、".join(missing) + "(需官方现金流量表数据)"}
|
||||
|
||||
zeros = [k for k, s in (("o", o), ("i", i), ("f", f)) if s == "0"]
|
||||
inv_zero = i == "0"
|
||||
|
||||
if zeros:
|
||||
# 恰好为 0 的维度:投资为 0 → 「本期无投资活动」;其余维度为 0 → 仍是有效输入但不可单独归类
|
||||
zero_names = "、".join(names[k] for k in zeros)
|
||||
if len(zeros) >= 2 or (o == "0" or f == "0"):
|
||||
note = "(无投资活动)" if inv_zero else ""
|
||||
return {
|
||||
"classified": False, "dimension": "0D",
|
||||
"o_sign": o, "i_sign": i, "f_sign": f,
|
||||
"investing_note": INVESTING_ZERO_NOTE if inv_zero else None,
|
||||
"reason": ("未判定%s:%s活动净额为 0(%s)且不足以二维归类,"
|
||||
"请按业务实质人工判定" % (
|
||||
note, zero_names,
|
||||
"投资活动为 0 = 本期无投资活动" if inv_zero else "该维度无正负号")),
|
||||
}
|
||||
# 单维为 0 且该维为投资 → 二维(经营 × 筹资)映射
|
||||
pair = TWO_DIM_CANDIDATE_MAP.get((o, f)) if (o in ("+", "-") and f in ("+", "-")) else None
|
||||
if pair:
|
||||
cands = [_rule_brief(t) for t in pair]
|
||||
return {
|
||||
"classified": True,
|
||||
"dimension": "2D",
|
||||
"dimension_label": "二维判定(投资活动=0,本期无投资活动)",
|
||||
"type_no": None,
|
||||
"name": "/".join("%s %s" % (c["type_no"], c["name"]) for c in cands),
|
||||
"candidate_type_nos": list(pair),
|
||||
"candidates": cands,
|
||||
"health_emoji": "", "health_label": None, "lifecycle": None,
|
||||
"profile": ";".join("类型%d %s:%s" % (c["type_no"], c["name"], c["profile"]) for c in cands),
|
||||
"risk": ";".join("类型%d:%s" % (c["type_no"], c["risk"]) for c in cands),
|
||||
"o_sign": o, "i_sign": i, "f_sign": f,
|
||||
"investing_note": INVESTING_ZERO_NOTE,
|
||||
"reason": ("经营(%s) 投资(0=%s) 筹资(%s) → 二维映射候选:%s"
|
||||
"(投资活动为 0 无法定符号,需结合业务实质在候选中确认)" % (
|
||||
o, INVESTING_ZERO_NOTE, f,
|
||||
" / ".join("类型%d %s" % (c["type_no"], c["name"]) for c in cands))),
|
||||
}
|
||||
|
||||
for rule in CASHFLOW_TYPE_RULES:
|
||||
if rule["o_sign"] == o and rule["i_sign"] == i and rule["f_sign"] == f:
|
||||
return {
|
||||
"classified": True,
|
||||
"dimension": "3D",
|
||||
"type_no": rule["type_no"],
|
||||
"name": rule["name"],
|
||||
"health_emoji": rule["health_emoji"],
|
||||
"health_label": rule["health_label"],
|
||||
"lifecycle": rule["lifecycle"],
|
||||
"profile": rule["profile"],
|
||||
"risk": rule["risk"],
|
||||
"o_sign": o, "i_sign": i, "f_sign": f,
|
||||
"investing_note": None,
|
||||
"reason": "经营(%s) 投资(%s) 筹资(%s) → 类型%d %s" % (o, i, f, rule["type_no"], rule["name"]),
|
||||
}
|
||||
return {"classified": False, "dimension": "0D", "reason": "未知组合,请检查数据"}
|
||||
|
||||
|
||||
class CashFlowValueIn(BaseModel):
|
||||
"""三大活动净额录入(官方现金流量表 / 人工)"""
|
||||
period: str
|
||||
operating_net: float
|
||||
investing_net: float
|
||||
financing_net: float
|
||||
begin_cash: Optional[float] = None
|
||||
end_cash: Optional[float] = None
|
||||
source: str = "official"
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
def _validate_period(period: str) -> str:
|
||||
"""期间格式校验 YYYY-MM"""
|
||||
period = (period or "").strip()
|
||||
try:
|
||||
datetime.strptime(period, "%Y-%m")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=422, detail="期间格式须为 YYYY-MM,如 2026-08")
|
||||
return period
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 接口
|
||||
# ============================================================
|
||||
|
||||
@router.get("/rules")
|
||||
def get_rules():
|
||||
"""八类企业定性判定规则表(O/I/F 符号组合速查)"""
|
||||
return {"rules": CASHFLOW_TYPE_RULES}
|
||||
|
||||
|
||||
@router.get("/periods")
|
||||
def list_periods(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""已注入净额的期间列表(倒序,供按期间查询)"""
|
||||
rows = db.query(CashFlowStructureValue.period).filter(
|
||||
CashFlowStructureValue.entity_id == entity_id
|
||||
).order_by(CashFlowStructureValue.period.desc()).all()
|
||||
return {"periods": [r[0] for r in rows]}
|
||||
|
||||
|
||||
@router.get("/values")
|
||||
def list_values(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""已注入的三大活动净额记录(按期间倒序)"""
|
||||
rows = db.query(CashFlowStructureValue).filter(
|
||||
CashFlowStructureValue.entity_id == entity_id
|
||||
).order_by(CashFlowStructureValue.period.desc()).all()
|
||||
return {"items": [
|
||||
{
|
||||
"id": r.id, "period": r.period,
|
||||
"operating_net": r.operating_net,
|
||||
"investing_net": r.investing_net,
|
||||
"financing_net": r.financing_net,
|
||||
"begin_cash": r.begin_cash, "end_cash": r.end_cash,
|
||||
"source": r.source, "remark": r.remark,
|
||||
"updated_at": r.updated_at.strftime("%Y-%m-%d %H:%M") if r.updated_at else None,
|
||||
} for r in rows
|
||||
]}
|
||||
|
||||
|
||||
@router.get("/analysis")
|
||||
def get_analysis(
|
||||
period: str = Query(None, description="格式 YYYY-MM,缺省取最近已注入期间"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""现金流量表结构分析 — 三净额 → 八类企业判定 + 瀑布图数据"""
|
||||
if period:
|
||||
period = _validate_period(period)
|
||||
|
||||
# 1. 取官方/人工注入的净额(唯一权威判定数据)
|
||||
q = db.query(CashFlowStructureValue).filter(
|
||||
CashFlowStructureValue.entity_id == entity_id
|
||||
)
|
||||
if period:
|
||||
rec = q.filter(CashFlowStructureValue.period == period).first()
|
||||
else:
|
||||
rec = q.order_by(CashFlowStructureValue.period.desc()).first()
|
||||
if rec:
|
||||
period = rec.period
|
||||
|
||||
if rec is None:
|
||||
return {
|
||||
"period": period or datetime.now().strftime("%Y-%m"),
|
||||
"data_available": False,
|
||||
"message": "该期间暂无官方现金流量表净额数据,请先录入三大活动净额(官方现金流量表注入后自动生效)。",
|
||||
"nets": None,
|
||||
"classification": None,
|
||||
"waterfall": None,
|
||||
}
|
||||
|
||||
classification = classify_enterprise(rec.operating_net, rec.investing_net, rec.financing_net)
|
||||
i_sign = _sign(rec.investing_net)
|
||||
# 裁定1:投资活动为 0 → 栏位显式标注「本期无投资活动」,避免读者误判为「收付相抵的 0」
|
||||
investing_note = classification.get("investing_note") or (
|
||||
INVESTING_ZERO_NOTE if i_sign == "0" else None)
|
||||
|
||||
nets = {
|
||||
"operating": {"key": "operating", "name": "经营活动", "value": rec.operating_net,
|
||||
"sign": _sign(rec.operating_net), "unit": "万元", "note": None},
|
||||
"investing": {"key": "investing", "name": "投资活动", "value": rec.investing_net,
|
||||
"sign": i_sign, "unit": "万元", "note": investing_note},
|
||||
"financing": {"key": "financing", "name": "筹资活动", "value": rec.financing_net,
|
||||
"sign": _sign(rec.financing_net), "unit": "万元", "note": None},
|
||||
}
|
||||
|
||||
# 2. 瀑布图:期初现金 → +经营 → +投资 → +筹资 → 期末现金
|
||||
waterfall = None
|
||||
if rec.begin_cash is not None or rec.end_cash is not None:
|
||||
begin = rec.begin_cash
|
||||
end = rec.end_cash
|
||||
steps = []
|
||||
cum = begin if begin is not None else 0.0
|
||||
if begin is not None:
|
||||
steps.append({"step": "期初现金", "value": begin, "type": "begin"})
|
||||
for net_key, net_name in [("operating", "经营活动"), ("investing", "投资活动"), ("financing", "筹资活动")]:
|
||||
val = getattr(rec, net_key + "_net") or 0.0
|
||||
cum += val
|
||||
steps.append({"step": net_name, "value": round(val, 2), "type": "flow"})
|
||||
if end is not None:
|
||||
steps.append({"step": "期末现金", "value": end, "type": "end"})
|
||||
elif begin is not None:
|
||||
end = round(cum, 2)
|
||||
steps.append({"step": "期末现金(推算)", "value": end, "type": "end"})
|
||||
waterfall = {
|
||||
"begin_cash": begin, "end_cash": end,
|
||||
"net_increase": round((end - begin), 2) if (begin is not None and end is not None) else None,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
return {
|
||||
"period": rec.period,
|
||||
"data_available": True,
|
||||
"source": rec.source,
|
||||
"remark": rec.remark,
|
||||
"nets": nets,
|
||||
"classification": classification,
|
||||
"classification_dimension": classification.get("dimension"),
|
||||
"investing_note": investing_note,
|
||||
"waterfall": waterfall,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/values", dependencies=[Depends(require_role("ceo", "finance"))])
|
||||
def upsert_value(
|
||||
payload: CashFlowValueIn,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""录入/更新某期间三大活动净额(官方现金流量表注入;同期间重复提交=覆盖更新)"""
|
||||
period = _validate_period(payload.period)
|
||||
if payload.source not in ("official", "manual"):
|
||||
raise HTTPException(status_code=422, detail="source 仅支持 official(官方报表)/manual(人工录入)")
|
||||
|
||||
rec = db.query(CashFlowStructureValue).filter(
|
||||
CashFlowStructureValue.entity_id == entity_id,
|
||||
CashFlowStructureValue.period == period,
|
||||
).first()
|
||||
created = rec is None
|
||||
if rec is None:
|
||||
rec = CashFlowStructureValue(entity_id=entity_id, period=period)
|
||||
db.add(rec)
|
||||
|
||||
rec.operating_net = round(payload.operating_net, 2)
|
||||
rec.investing_net = round(payload.investing_net, 2)
|
||||
rec.financing_net = round(payload.financing_net, 2)
|
||||
rec.begin_cash = round(payload.begin_cash, 2) if payload.begin_cash is not None else rec.begin_cash
|
||||
rec.end_cash = round(payload.end_cash, 2) if payload.end_cash is not None else rec.end_cash
|
||||
rec.source = payload.source
|
||||
rec.remark = payload.remark
|
||||
db.commit()
|
||||
|
||||
classification = classify_enterprise(rec.operating_net, rec.investing_net, rec.financing_net)
|
||||
return {
|
||||
"ok": True,
|
||||
"id": rec.id,
|
||||
"period": rec.period,
|
||||
"classification": classification,
|
||||
"message": "三大活动净额已%s" % ("入库" if created else "更新"),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 兼容别名端点 — 任务派发单 DoD 验收路径 /api/cma/cashflow/*
|
||||
# (规范实现主路径为 /api/cma/cashflow-structure/*;两路径功能一致)
|
||||
# ============================================================
|
||||
compat_router = APIRouter(
|
||||
prefix="/api/cma/cashflow",
|
||||
tags=["现金流量表结构分析(兼容路径)"],
|
||||
dependencies=[Depends(require_role("ceo", "finance"))],
|
||||
)
|
||||
|
||||
|
||||
@compat_router.get("/statements")
|
||||
def compat_list_statements(
|
||||
period: Optional[str] = Query(None, description="期间 YYYY-MM,可选"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""[兼容] 查询已录入三大活动净额(同 /api/cma/cashflow-structure/values)"""
|
||||
items = list_values(db=db, entity_id=entity_id)["items"]
|
||||
if period:
|
||||
period = _validate_period(period)
|
||||
items = [x for x in items if x["period"] == period]
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@compat_router.post("/statements", dependencies=[Depends(require_role("ceo", "finance"))])
|
||||
def compat_save_statement(
|
||||
payload: CashFlowValueIn,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""[兼容] 保存/更新某期间三大活动净额(同 POST /api/cma/cashflow-structure/values)"""
|
||||
return upsert_value(payload=payload, db=db, entity_id=entity_id)
|
||||
|
||||
|
||||
@compat_router.get("/analysis")
|
||||
def compat_get_analysis(
|
||||
period: str = Query(None, description="格式 YYYY-MM,缺省取最近已注入期间"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""[兼容] 结构分析+八类判定+瀑布数据(同 /api/cma/cashflow-structure/analysis)"""
|
||||
return get_analysis(period=period, db=db, entity_id=entity_id)
|
||||
+197
-40
@@ -5,14 +5,41 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth_401
|
||||
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation
|
||||
from app.models.cost_model import RESEARCH_PROJECT_TYPES
|
||||
from app.utils.cost_engine import (
|
||||
calc_product_variance, get_cost_overview, get_cost_breakdown,
|
||||
calc_driver_rate, allocate_cost
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cma.cost")
|
||||
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 本 router 原无任何鉴权依赖(端点仅 assert_entity_scope,无 token 走"Bot 服务通道"分支放行)
|
||||
# → 无 token 可读成本数据、可写标准成本。收口为 require_auth_401(无 token → 401)。
|
||||
# Bot 通道走 /api/cma/bot/cost/*(X-BOT-KEY)不受影响。
|
||||
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
|
||||
def _resolve_research_dims(data: dict, current=None) -> tuple:
|
||||
"""解析研发归集维度(research_flag/project_type)。
|
||||
- project_type 缺省:新建=client_delivery(录入默认),编辑=保留现值
|
||||
- research_flag 未显式传入 → 按 project_type 联动(platform_dev=研发1,其余=0)
|
||||
- 显式传入 research_flag 尊重原值(可覆盖联动)
|
||||
"""
|
||||
if current is not None:
|
||||
project_type = data.get("project_type") or current.project_type or "client_delivery"
|
||||
else:
|
||||
project_type = data.get("project_type") or "client_delivery"
|
||||
if project_type not in RESEARCH_PROJECT_TYPES:
|
||||
raise HTTPException(400, f"project_type 非法: {project_type},可选 {', '.join(RESEARCH_PROJECT_TYPES)}")
|
||||
if data.get("research_flag") is not None:
|
||||
research_flag = int(bool(data.get("research_flag")))
|
||||
else:
|
||||
research_flag = 1 if project_type == "platform_dev" else 0
|
||||
return project_type, research_flag
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -22,21 +49,33 @@ router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
|
||||
@router.get("/standard-costs")
|
||||
def list_standard_costs(product_code: Optional[str] = Query(None),
|
||||
cost_type: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)):
|
||||
project_type: Optional[str] = Query(None),
|
||||
research_flag: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""查询标准成本卡片"""
|
||||
query = db.query(StandardCost).filter(StandardCost.status == "active")
|
||||
query = db.query(StandardCost).filter(StandardCost.status == "active", StandardCost.entity_id == entity_id)
|
||||
if product_code:
|
||||
query = query.filter(StandardCost.product_code == product_code)
|
||||
if cost_type:
|
||||
query = query.filter(StandardCost.cost_type == cost_type)
|
||||
if project_type:
|
||||
query = query.filter(StandardCost.project_type == project_type)
|
||||
if research_flag is not None:
|
||||
query = query.filter(StandardCost.research_flag == research_flag)
|
||||
items = query.order_by(StandardCost.product_code, StandardCost.cost_type).all()
|
||||
return {"data": items}
|
||||
|
||||
|
||||
@router.post("/standard-costs")
|
||||
def create_standard_cost(data: dict, db: Session = Depends(get_db)):
|
||||
def create_standard_cost(data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""创建标准成本卡片"""
|
||||
project_type, research_flag = _resolve_research_dims(data)
|
||||
sc = StandardCost(
|
||||
entity_id=entity_id,
|
||||
project_type=project_type,
|
||||
research_flag=research_flag,
|
||||
product_code=data["product_code"],
|
||||
product_name=data.get("product_name", ""),
|
||||
cost_type=data["cost_type"],
|
||||
@@ -54,24 +93,31 @@ def create_standard_cost(data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/standard-costs/{cost_id}")
|
||||
def update_standard_cost(cost_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def update_standard_cost(cost_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""修改标准成本卡片"""
|
||||
sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first()
|
||||
sc = db.query(StandardCost).filter(StandardCost.id == cost_id,
|
||||
StandardCost.entity_id == entity_id).first()
|
||||
if not sc:
|
||||
raise HTTPException(404, "标准成本记录不存在")
|
||||
for k in ("product_code", "product_name", "cost_type", "item_name",
|
||||
"standard_quantity", "unit", "standard_price", "version", "remark"):
|
||||
if k in data:
|
||||
setattr(sc, k, data[k])
|
||||
project_type, research_flag = _resolve_research_dims(data, current=sc)
|
||||
sc.project_type = project_type
|
||||
sc.research_flag = research_flag
|
||||
sc.standard_cost = round(sc.standard_quantity * sc.standard_price, 2)
|
||||
db.commit()
|
||||
return {"message": "已更新"}
|
||||
|
||||
|
||||
@router.delete("/standard-costs/{cost_id}")
|
||||
def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)):
|
||||
def delete_standard_cost(cost_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""删除标准成本卡片"""
|
||||
sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first()
|
||||
sc = db.query(StandardCost).filter(StandardCost.id == cost_id,
|
||||
StandardCost.entity_id == entity_id).first()
|
||||
if not sc:
|
||||
raise HTTPException(404, "标准成本记录不存在")
|
||||
sc.status = "archived"
|
||||
@@ -86,21 +132,33 @@ def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)):
|
||||
@router.get("/actual-costs")
|
||||
def list_actual_costs(period: Optional[str] = Query(None),
|
||||
product_code: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)):
|
||||
project_type: Optional[str] = Query(None),
|
||||
research_flag: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""查询实际成本"""
|
||||
query = db.query(ActualCost)
|
||||
query = db.query(ActualCost).filter(ActualCost.entity_id == entity_id)
|
||||
if period:
|
||||
query = query.filter(ActualCost.period == period)
|
||||
if product_code:
|
||||
query = query.filter(ActualCost.product_code == product_code)
|
||||
if project_type:
|
||||
query = query.filter(ActualCost.project_type == project_type)
|
||||
if research_flag is not None:
|
||||
query = query.filter(ActualCost.research_flag == research_flag)
|
||||
items = query.order_by(ActualCost.period.desc(), ActualCost.product_code).all()
|
||||
return {"data": items}
|
||||
|
||||
|
||||
@router.post("/actual-costs")
|
||||
def create_actual_cost(data: dict, db: Session = Depends(get_db)):
|
||||
def create_actual_cost(data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""录入实际成本"""
|
||||
project_type, research_flag = _resolve_research_dims(data)
|
||||
ac = ActualCost(
|
||||
entity_id=entity_id,
|
||||
project_type=project_type,
|
||||
research_flag=research_flag,
|
||||
period=data["period"],
|
||||
product_code=data["product_code"],
|
||||
product_name=data.get("product_name", ""),
|
||||
@@ -118,19 +176,34 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
# ============================================================
|
||||
# ABC 作业成本
|
||||
# 账套隔离(2026-09-18,0-P1-project-20260918-entity-isolation-batch2):
|
||||
# GET /abc/activities 原为全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心;
|
||||
# 现按会话账套过滤(AbcActivity.entity_id)。
|
||||
# OpenCode 多租户审查 2026-09-07 的「单实体演示数据暂不强制隔离」豁免就此撤销。
|
||||
# 账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):batch2 同族残留收口
|
||||
# - POST /abc/activities 原签名无账套依赖、不写归属 → 落模型默认 entity_id=1;现按会话账套写入
|
||||
# - POST /abc/allocate 原无账套依赖、activity_id 不校验归属、分配记录落默认 1;
|
||||
# 现校验 activity_id 归属本账套(跨账套 → 404,文案对齐同文件先例)
|
||||
# 并把 entity_id 透传 cost_engine.allocate_cost 显式写入 AbcAllocation
|
||||
# - GET /abc/allocations 原查询只按 period/product_code → 现加 entity_id 过滤(读型同族)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/abc/activities")
|
||||
def list_abc_activities(db: Session = Depends(get_db)):
|
||||
"""查询ABC作业中心列表"""
|
||||
items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all()
|
||||
def list_abc_activities(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""查询ABC作业中心列表(账套隔离:只返回本账套)"""
|
||||
items = (db.query(AbcActivity)
|
||||
.filter(AbcActivity.entity_id == entity_id)
|
||||
.order_by(AbcActivity.activity_code)
|
||||
.all())
|
||||
return {"data": items}
|
||||
|
||||
|
||||
@router.post("/abc/activities")
|
||||
def create_abc_activity(data: dict, db: Session = Depends(get_db)):
|
||||
"""创建ABC作业中心"""
|
||||
def create_abc_activity(data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""创建ABC作业中心(账套隔离:按会话账套写入归属)"""
|
||||
act = AbcActivity(
|
||||
entity_id=entity_id,
|
||||
activity_code=data["activity_code"],
|
||||
activity_name=data["activity_name"],
|
||||
activity_desc=data.get("activity_desc"),
|
||||
@@ -146,14 +219,21 @@ def create_abc_activity(data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/abc/allocate")
|
||||
def do_allocate(data: dict, db: Session = Depends(get_db)):
|
||||
"""执行ABC成本分配"""
|
||||
def do_allocate(data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""执行ABC成本分配(账套隔离:activity_id 须属本账套,跨账套 → 404)"""
|
||||
act = (db.query(AbcActivity)
|
||||
.filter(AbcActivity.id == data["activity_id"], AbcActivity.entity_id == entity_id)
|
||||
.first())
|
||||
if not act:
|
||||
raise HTTPException(404, "作业中心不存在")
|
||||
result = allocate_cost(
|
||||
activity_id=data["activity_id"],
|
||||
period=data.get("period", datetime.now().strftime("%Y-%m")),
|
||||
product_code=data["product_code"],
|
||||
product_name=data.get("product_name", ""),
|
||||
driver_consumed=data["driver_consumed"],
|
||||
entity_id=entity_id,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -161,9 +241,10 @@ def do_allocate(data: dict, db: Session = Depends(get_db)):
|
||||
@router.get("/abc/allocations")
|
||||
def list_allocations(period: Optional[str] = Query(None),
|
||||
product_code: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)):
|
||||
"""查询ABC分配记录"""
|
||||
query = db.query(AbcAllocation)
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""查询ABC分配记录(账套隔离:只返回本账套)"""
|
||||
query = db.query(AbcAllocation).filter(AbcAllocation.entity_id == entity_id)
|
||||
if period:
|
||||
query = query.filter(AbcAllocation.period == period)
|
||||
if product_code:
|
||||
@@ -177,61 +258,137 @@ def list_allocations(period: Optional[str] = Query(None),
|
||||
# ============================================================
|
||||
|
||||
@router.get("/overview")
|
||||
def cost_overview(period: Optional[str] = Query(None)):
|
||||
"""成本总览(总成本、结构占比、趋势)"""
|
||||
def cost_overview(period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""成本总览(总成本、结构占比、趋势)— 多租户隔离 2026-09-07"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
return get_cost_overview(period)
|
||||
return get_cost_overview(period, entity_id=entity_id)
|
||||
|
||||
|
||||
@router.get("/variance")
|
||||
def cost_variance(product_code: str = Query(...),
|
||||
period: Optional[str] = Query(None)):
|
||||
"""量差价差分析"""
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""量差价差分析 — 多租户隔离 2026-09-07"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
return calc_product_variance(product_code, period)
|
||||
return calc_product_variance(product_code, period, entity_id=entity_id)
|
||||
|
||||
|
||||
@router.get("/breakdown")
|
||||
def cost_breakdown(product_code: str = Query(...),
|
||||
period: Optional[str] = Query(None)):
|
||||
"""成本构成(料/工/费占比)"""
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""成本构成(料/工/费占比)— 多租户隔离 2026-09-07"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
return get_cost_breakdown(product_code, period)
|
||||
return get_cost_breakdown(product_code, period, entity_id=entity_id)
|
||||
|
||||
|
||||
@router.get("/comparison")
|
||||
def cost_method_comparison(entity: str = Query("hanke")):
|
||||
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)
|
||||
注:纯静态教学样例数据(无 DB 访问),不涉多租户泄漏——OpenCode 审查 2026-09-07 豁免标注"""
|
||||
if entity == "hanke":
|
||||
gross_revenue = 713 # 万
|
||||
channel_rebate_rate = 0.828
|
||||
net_revenue = round(gross_revenue * (1 - channel_rebate_rate), 2)
|
||||
weighted_cost_rate = 0.4862
|
||||
book_cost = 717 # 万
|
||||
total_expenses = 546 # 万
|
||||
non_value_added = 26 # 万
|
||||
|
||||
traditional = {
|
||||
"revenue": gross_revenue,
|
||||
"cost": book_cost,
|
||||
"gross_profit": round(gross_revenue - book_cost, 2),
|
||||
"gross_margin": round((gross_revenue - book_cost) / gross_revenue * 100, 2),
|
||||
"expenses": total_expenses,
|
||||
"net_profit": round(gross_revenue - book_cost - total_expenses, 2),
|
||||
}
|
||||
|
||||
var_cost = round(net_revenue * weighted_cost_rate, 2)
|
||||
variable = {
|
||||
"revenue": net_revenue,
|
||||
"cost": var_cost,
|
||||
"gross_profit": round(net_revenue - var_cost, 2),
|
||||
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
|
||||
"expenses": total_expenses,
|
||||
"net_profit": round(net_revenue - var_cost - total_expenses, 2),
|
||||
}
|
||||
|
||||
abc_cost = round(net_revenue * weighted_cost_rate, 2)
|
||||
abc_exp = total_expenses - non_value_added
|
||||
abc = {
|
||||
"revenue": net_revenue,
|
||||
"cost": abc_cost,
|
||||
"gross_profit": round(net_revenue - abc_cost, 2),
|
||||
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
|
||||
"expenses": abc_exp,
|
||||
"net_profit": round(net_revenue - abc_cost - abc_exp, 2),
|
||||
}
|
||||
|
||||
return {
|
||||
"entity": "hanke",
|
||||
"entity_name": "陕西酣客(白酒经销)",
|
||||
"traditional": traditional,
|
||||
"variable": variable,
|
||||
"abc": abc,
|
||||
"insights": [
|
||||
{"method": "传统成本法", "conclusion": "毛利率为负", "decision": "❌ 不赚钱,别卖了", "detail": "未剔除渠补,账面收入虚高"},
|
||||
{"method": "变动成本法", "conclusion": "毛利率18.6%", "decision": "✅ 业务能赚钱→砍费用", "detail": "剔除渠补后净收入122万,成本率48.62%"},
|
||||
{"method": "作业成本法", "conclusion": "识别非增值作业26万", "decision": "✅ 可再省", "detail": "剔除冗余招待费等非增值作业"},
|
||||
],
|
||||
"notes": {
|
||||
"net_revenue": "毛收入713万 × (1-渠补率82.8%) = 净收入122万",
|
||||
"variable_cost": "净收入122万 × 加权成本率48.62% = 成本59.3万",
|
||||
"abc_expenses": "总费用546万 - 非增值作业26万 = 520万",
|
||||
},
|
||||
}
|
||||
return {"error": "不支持的实体"}
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def cost_dashboard(period: Optional[str] = Query(None)):
|
||||
"""成本分析首页—汇总数据"""
|
||||
def cost_dashboard(period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""成本分析首页—汇总数据(多租户隔离 2026-09-07: ActualCost 按账套过滤)"""
|
||||
if period is None:
|
||||
period = datetime.now().strftime("%Y-%m")
|
||||
overview = get_cost_overview(period)
|
||||
overview = get_cost_overview(period, entity_id=entity_id)
|
||||
|
||||
# 获取所有产品列表
|
||||
db = get_db().__next__()
|
||||
# 获取所有产品列表(本账套)
|
||||
db_session = db if db is not None else get_db().__next__()
|
||||
try:
|
||||
products = db.query(ActualCost.product_code, ActualCost.product_name).filter(
|
||||
ActualCost.period == period
|
||||
products = db_session.query(ActualCost.product_code, ActualCost.product_name).filter(
|
||||
ActualCost.entity_id == entity_id,
|
||||
ActualCost.period == period,
|
||||
).distinct().all()
|
||||
product_list = [{"code": p[0], "name": p[1]} for p in products]
|
||||
|
||||
# 各产品成本
|
||||
product_costs = []
|
||||
for code, name in products:
|
||||
costs = db.query(ActualCost).filter(
|
||||
costs = db_session.query(ActualCost).filter(
|
||||
ActualCost.entity_id == entity_id,
|
||||
ActualCost.product_code == code,
|
||||
ActualCost.period == period,
|
||||
).all()
|
||||
total = round(sum(c.actual_cost for c in costs), 2)
|
||||
product_costs.append({"product_code": code, "product_name": name, "total_cost": total})
|
||||
finally:
|
||||
db.close()
|
||||
if db is None:
|
||||
db_session.close()
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"overview": overview,
|
||||
"products": product_list,
|
||||
"total_cost": round(sum(p["total_cost"] for p in product_list) + overview.get("erp_cost", 0), 2) if product_list else 0,
|
||||
# 修复既有bug(2026-09-07测试实证): 原对 product_list 求 p["total_cost"] 必 KeyError(产品列表无total_cost键)
|
||||
# → 改为 product_costs(含各产品总额),有产品数据时端点不再 500
|
||||
"total_cost": round(sum(p["total_cost"] for p in product_costs) + overview.get("erp_cost", 0), 2) if product_costs else 0,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""客户维度KPI看板 API — P0-2"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, KPIAlert, User
|
||||
from app.utils.alert_entity_scope import month_period_clause
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.customer")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# ── 客户维度「标准指标族」九卡(与前端 CustomerDashboard.vue 的 CUSTOMER_TEMPLATES 逐项对应)
|
||||
# (卡片名, 默认单位, 预设目标值, 实例层 kpi_code 或 None)
|
||||
# 第四列为 None = 该卡在 KPI 库**无可用定义**(页面须显示「未定义·待建」,不得显示 0/无数据)。
|
||||
STANDARD_INDICATORS = [
|
||||
("客户满意度", "分", 90, "C_SATISFACTION"),
|
||||
("客户留存率", "%", 85, None),
|
||||
("新客户增长率", "%", 20, None),
|
||||
("客户渠补率", "%", 95, None),
|
||||
("客户投诉率", "%", 5, "C_COMPLAINT_RATE"),
|
||||
("客户平均贡献值", "元", 10000, None),
|
||||
("客户转化率", "%", 30, None),
|
||||
("客户活跃度", "%", 70, None),
|
||||
("NPS净推荐值", "分", 60, None),
|
||||
]
|
||||
|
||||
|
||||
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
|
||||
"""解析时间区间"""
|
||||
today = datetime.now()
|
||||
if period_type == "month":
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "quarter":
|
||||
q = (today.month - 1) // 3
|
||||
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "year":
|
||||
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
elif period_type == "custom" and start_date and end_date:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
else:
|
||||
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
end = today
|
||||
return start, end
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_customer_kpis(
|
||||
period: str = Query("month"),
|
||||
start_date: str = Query(None),
|
||||
end_date: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取客户维度KPI列表(含最新值、预警、趋势)"""
|
||||
start, end = parse_period(period, start_date, end_date)
|
||||
period_str = start.strftime("%Y-%m")
|
||||
|
||||
# 只查 customer 维度的 KPI
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
result = []
|
||||
for k in kpis:
|
||||
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
|
||||
|
||||
if period == "month":
|
||||
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
|
||||
elif period == "quarter":
|
||||
q_month = (datetime.now().month - 1) // 3
|
||||
months = [f"{datetime.now().year}-{m:02d}" for m in range(q_month*3+1, q_month*3+4)]
|
||||
values = base_query.filter(KPIValue.period.in_(months)).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
|
||||
elif period == "year":
|
||||
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 = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
|
||||
elif period == "custom" and start_date and end_date:
|
||||
periods = []
|
||||
d = start
|
||||
while d <= end:
|
||||
periods.append(d.strftime("%Y-%m"))
|
||||
d += timedelta(days=32)
|
||||
d = d.replace(day=1)
|
||||
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
|
||||
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
|
||||
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
|
||||
else:
|
||||
latest = base_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
# 最新预警
|
||||
alert = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == k.id,
|
||||
KPIAlert.status == "pending",
|
||||
).order_by(KPIAlert.id.desc()).first()
|
||||
|
||||
# 趋势(环比变化率)
|
||||
trend = None
|
||||
achievement_rate = None
|
||||
period_values = []
|
||||
|
||||
if latest and latest.actual_value:
|
||||
prev_period_str = None
|
||||
if period == "month":
|
||||
year_s, month_s = period_str.split("-")
|
||||
y_s, m_s = int(year_s), int(month_s)
|
||||
m_s -= 1
|
||||
if m_s <= 0:
|
||||
m_s += 12
|
||||
y_s -= 1
|
||||
prev_period_str = f"{y_s}-{m_s:02d}"
|
||||
|
||||
if prev_period_str:
|
||||
prev_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.period == prev_period_str,
|
||||
).order_by(KPIValue.id.desc()).first()
|
||||
if prev_val and prev_val.actual_value and prev_val.actual_value > 0:
|
||||
trend = round((latest.actual_value - prev_val.actual_value) / prev_val.actual_value * 100, 2)
|
||||
elif prev_val and prev_val.actual_value and prev_val.actual_value == 0:
|
||||
trend = 100.0 if latest.actual_value > 0 else 0
|
||||
|
||||
# 达成率
|
||||
if latest and latest.actual_value and k.target_value and k.target_value > 0:
|
||||
achievement_rate = round(latest.actual_value / k.target_value * 100, 1)
|
||||
|
||||
# 最近6期趋势数据
|
||||
period_q = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
month_period_clause(KPIValue.period),
|
||||
).order_by(KPIValue.period.desc()).limit(6).all()
|
||||
period_values = [
|
||||
{"period": v.period, "value": v.actual_value}
|
||||
for v in reversed(period_q) if v.actual_value is not None
|
||||
]
|
||||
|
||||
result.append({
|
||||
"id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"unit": k.unit,
|
||||
"target_value": k.target_value,
|
||||
"actual_value": latest.actual_value if latest else None,
|
||||
"period": latest.period if latest else None,
|
||||
"alert_level": alert.alert_level if alert else "none",
|
||||
"alert_message": alert.alert_message if alert else None,
|
||||
"frequency": k.frequency,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
"responsible_user": k.responsible_user,
|
||||
"trend": trend,
|
||||
"achievement_rate": achievement_rate,
|
||||
"period_values": period_values,
|
||||
"kpi_name": k.kpi_name,
|
||||
})
|
||||
|
||||
return {"data": result, "period": period, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/standard-indicators")
|
||||
def standard_indicators(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""客户维度「标准指标族」九卡三态(真值 / 无数据 / 未定义·待建)——只读。
|
||||
|
||||
CPA P0-b(2026-10-04):页面 9 张预设卡此前对「KPI 库无定义」的项渲染成「无数据」,
|
||||
与「定义在库但无值」不可区分。本端点逐卡给出三态,页面据此渲染标签,**禁止空显示为 0**。
|
||||
|
||||
映射为**显式白名单**(不做名称模糊匹配),仅当实例层 KPI 与预设卡语义完全一致才连:
|
||||
客户满意度 → C_SATISFACTION(实例层同名,active,有值)
|
||||
客户投诉率 → C_COMPLAINT_RATE(实例层同名,inactive ⇒ 无值)
|
||||
其余 7 张保持未定义(依据任务 finance-20261003 现状表 §一「7 个在 KPI 库无可用定义」):
|
||||
客户留存率 ≠ 客户保留率(C_RETENTION_RATE)、客户渠补率 ≠ 渠补率(C_REBATE_RATE)、
|
||||
新客户增长率 ≠ 新客户数(C_NEW_CLIENTS)、客户平均贡献值/客户转化率/客户活跃度/NPS净推荐值 库内皆无。
|
||||
"""
|
||||
codes = [c for _, _, _, c in STANDARD_INDICATORS if c]
|
||||
defs = {}
|
||||
if codes:
|
||||
# 用 raw SQL 读定义层:**不用 KPIDefinition ORM**——该模型的 `direction` 列由他单
|
||||
# (CMA-KPI-20261003 KPI字典分层治理)在途新增,生产库尚未落列,ORM 查询会 1054;
|
||||
# raw SQL 只取本端点需要的列,与在途 DDL 解耦(与 zxt_masterbill 同一形态)。
|
||||
from sqlalchemy import bindparam, text as _text
|
||||
stmt = _text(
|
||||
"SELECT id, kpi_code, unit, target_value, status FROM kpi_definitions "
|
||||
"WHERE entity_id = :eid AND dimension = 'customer' AND kpi_code IN :codes"
|
||||
).bindparams(bindparam("codes", expanding=True))
|
||||
rows = db.execute(stmt, {"eid": entity_id, "codes": codes}).fetchall()
|
||||
defs = {r[1]: {"id": r[0], "unit": r[2], "target_value": r[3], "status": r[4]} for r in rows}
|
||||
|
||||
items = []
|
||||
summary = {"value": 0, "no_data": 0, "undefined": 0, "total": len(STANDARD_INDICATORS)}
|
||||
state_labels = {"value": "真值", "no_data": "无数据", "undefined": "未定义·待建"}
|
||||
for name, unit, target, code in STANDARD_INDICATORS:
|
||||
kpi = defs.get(code) if code else None
|
||||
if kpi is None:
|
||||
state = "undefined"
|
||||
items.append({
|
||||
"name": name, "unit": unit, "target_value": target, "state": state,
|
||||
"state_label": state_labels[state], "kpi_code": None, "kpi_id": None,
|
||||
"kpi_status": None, "actual_value": None, "period": None,
|
||||
"why": "KPI 库无该定义",
|
||||
})
|
||||
else:
|
||||
val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi["id"],
|
||||
KPIValue.entity_id == entity_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
month_period_clause(KPIValue.period),
|
||||
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
|
||||
state = "value" if val else "no_data"
|
||||
items.append({
|
||||
"name": name, "unit": kpi["unit"] or unit,
|
||||
"target_value": kpi["target_value"] if kpi["target_value"] is not None else target,
|
||||
"state": state, "state_label": state_labels[state],
|
||||
"kpi_code": code, "kpi_id": kpi["id"], "kpi_status": kpi["status"],
|
||||
"actual_value": val.actual_value if val else None,
|
||||
"period": val.period if val else None,
|
||||
"why": None if val else "定义在库(status=%s)但无任何月期间值" % kpi["status"],
|
||||
})
|
||||
summary[state] += 1
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"items": items,
|
||||
"summary": summary,
|
||||
"note": ("state: value=定义在库且有值 / no_data=定义在库但无值 / "
|
||||
"undefined=KPI 库无定义(未定义·待建);禁止把 undefined/no_data 显示为 0"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/trend/{kpi_id}")
|
||||
def get_kpi_trend(
|
||||
kpi_id: int,
|
||||
months: int = Query(12, ge=3, le=24),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取单个KPI的历史趋势数据"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 获取最近N期数据
|
||||
values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
month_period_clause(KPIValue.period),
|
||||
).order_by(KPIValue.period.desc()).limit(months).all()
|
||||
|
||||
trend_data = [
|
||||
{"period": v.period, "value": v.actual_value}
|
||||
for v in reversed(values) if v.actual_value is not None
|
||||
]
|
||||
|
||||
# 计算预警水平和触发时间
|
||||
alerts = db.query(KPIAlert).filter(
|
||||
KPIAlert.kpi_id == kpi_id,
|
||||
).order_by(KPIAlert.created_at.desc()).limit(10).all()
|
||||
|
||||
alert_logs = [
|
||||
{
|
||||
"level": a.alert_level,
|
||||
"message": a.alert_message,
|
||||
"time": a.created_at.isoformat() if a.created_at else None,
|
||||
"status": a.status,
|
||||
}
|
||||
for a in alerts
|
||||
]
|
||||
|
||||
return {
|
||||
"kpi": {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"target_value": kpi.target_value,
|
||||
"unit": kpi.unit,
|
||||
"threshold_green": kpi.threshold_green,
|
||||
"threshold_yellow": kpi.threshold_yellow,
|
||||
"threshold_red": kpi.threshold_red,
|
||||
},
|
||||
"trend_data": trend_data,
|
||||
"alerts": alert_logs,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def get_customer_summary(
|
||||
period: str = Query("month"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""客户维度概要统计"""
|
||||
total = db.query(func.count(KPIDefinition.id)).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).scalar() or 0
|
||||
|
||||
# 预警统计
|
||||
pending_alerts = db.query(func.count(KPIAlert.id)).filter(
|
||||
KPIAlert.status == "pending",
|
||||
KPIAlert.kpi_id.in_(
|
||||
db.query(KPIDefinition.id).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
)
|
||||
),
|
||||
).scalar() or 0
|
||||
|
||||
# 二级类别分布
|
||||
cat_stats = db.query(
|
||||
KPIDefinition.category,
|
||||
func.count(KPIDefinition.id),
|
||||
).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.dimension == "customer",
|
||||
).group_by(KPIDefinition.category).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending_alerts": pending_alerts,
|
||||
"category_stats": [{"category": c[0], "count": c[1]} for c in cat_stats],
|
||||
}
|
||||
+690
-63
File diff suppressed because it is too large
Load Diff
+411
-30
@@ -1,64 +1,359 @@
|
||||
"""数据对接 API"""
|
||||
import pandas as pd
|
||||
import io, json, hashlib
|
||||
import io, json, hashlib, re, os
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIValue, DataSourceConfig, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
|
||||
|
||||
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
|
||||
# 陕西酣客财务原始数据的真实通道 = DAMA 读智享通网页 → 落盘 → 财务Bot 注入(source_type='zxt-sync')
|
||||
# 任何 ERP API 直连同步入口(/data/sync-kpis、/admin/erp-sync)一律拒绝执行,防止写 estimated 假数据
|
||||
ERP_DIRECT_CHANNEL_DISABLED = True
|
||||
ERP_DIRECT_CHANNEL_REASON = ("ERP直连同步通道已于2026-08-31停用(erp-gateway 容器 DB 凭据已删除,zxbtest 账号停用)。"
|
||||
"财务原始数据真实通道为智享通 zxt 通道:DAMA 读智享通网页 → 落盘 /root/WB_WS/_data/zxt-hanke → 财务Bot 注入 CMA"
|
||||
"(subjects+kpi_values 双写, source_type='zxt-sync')。如需恢复 ERP API 直连,请联系运维重新配置 erp-gateway DB 凭据。")
|
||||
|
||||
# 智享通 zxt 落盘目录(真实通道文件来源;任总 2026-09-08 确认财务原始数据走 zxt_push)
|
||||
ZXT_LANDING_DIR = "/root/WB_WS/_data/zxt-hanke"
|
||||
|
||||
router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
@router.post("/import-excel")
|
||||
async def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
||||
async def import_excel(file: UploadFile = File(...),
|
||||
kpi_col: str = Query("kpi_code", description="Excel中KPI编码列名"),
|
||||
period_col: str = Query("period", description="Excel中期间列名"),
|
||||
value_col: str = Query("actual_value", description="Excel中实际值列名"),
|
||||
default_period: str = Query(None, description="如文件无期间列,统一使用此值"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
content = await file.read()
|
||||
df = pd.read_excel(io.BytesIO(content))
|
||||
|
||||
required = [kpi_col, value_col]
|
||||
if not default_period:
|
||||
required.append(period_col)
|
||||
|
||||
missing = [c for c in required if c not in df.columns]
|
||||
if missing:
|
||||
raise HTTPException(400,
|
||||
f"Excel缺少列: {missing}。当前文件列: {list(df.columns)}")
|
||||
|
||||
if len(df) == 0:
|
||||
raise HTTPException(400, "Excel文件为空,没有数据行")
|
||||
|
||||
required = ["kpi_code", "period", "actual_value"]
|
||||
if not all(c in df.columns for c in required):
|
||||
raise HTTPException(400, f"Excel必须包含列: {required}")
|
||||
from app.models import KPIDefinition
|
||||
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()}
|
||||
kpi_entity_map = {k.kpi_code: k.entity_id for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()} # 账套隔离
|
||||
|
||||
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
|
||||
count = 0
|
||||
for _, row in df.iterrows():
|
||||
kpi_code = str(row.get("kpi_code", ""))
|
||||
period = str(row.get("period", ""))
|
||||
value = row.get("actual_value")
|
||||
skipped = []
|
||||
for idx, row in df.iterrows():
|
||||
kpi_code = str(row.get(kpi_col, "")).strip()
|
||||
period = str(row.get(period_col, default_period or "")).strip() if period_col in df.columns else (default_period or "").strip()
|
||||
value = row.get(value_col)
|
||||
|
||||
if not kpi_code or not period or pd.isna(value):
|
||||
skipped.append(f"第{idx+2}行: 缺少必填字段")
|
||||
continue
|
||||
|
||||
from app.models import KPIDefinition
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if not kpi:
|
||||
kid = kpi_map.get(kpi_code)
|
||||
if not kid:
|
||||
skipped.append(f"第{idx+2}行: KPI编码「{kpi_code}」不存在")
|
||||
continue
|
||||
|
||||
kv = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
db.add(KPIValue(
|
||||
kpi_id=kid,
|
||||
entity_id=kpi_entity_map.get(kpi_code),
|
||||
period=period,
|
||||
actual_value=float(value),
|
||||
source_type="excel",
|
||||
source_batch=batch,
|
||||
data_status="pending",
|
||||
)
|
||||
db.add(kv)
|
||||
data_status="verified",
|
||||
))
|
||||
count += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"导入成功 {count} 条数据", "batch": batch}
|
||||
|
||||
msg = f"✅ 导入成功 {count} 条数据"
|
||||
if skipped:
|
||||
msg += f",{len(skipped)}条跳过:\n" + "\n".join(skipped[:10])
|
||||
if len(skipped) > 10:
|
||||
msg += f"\n...还有{len(skipped)-10}条"
|
||||
return {"message": msg, "batch": batch, "total": count, "skipped": len(skipped)}
|
||||
|
||||
|
||||
# ── 智能导入(BOT自动识别,无需手动映射) ──
|
||||
|
||||
_SMART_MAP = {
|
||||
# KPI名称/编码列匹配模式(顺序重要:名称类列优先,避免科目编码被当名称)
|
||||
"kpi_code_patterns": [
|
||||
re.compile(r'^(科目名称|项目名称|指标名称?|kpi名称?|name|名称)$', re.I),
|
||||
re.compile(r'^(科目编码|科目代码|kpi_?code|指标编码|编码)$', re.I),
|
||||
re.compile(r'^(科目|项目|账户|报表项目)$'),
|
||||
],
|
||||
# 期间列匹配
|
||||
"period_patterns": [
|
||||
re.compile(r'^(period|期间|月份?|年月|日期|会计期间)$', re.I),
|
||||
re.compile(r'^(报表期[间]?|所属期)$'),
|
||||
re.compile(r'^年|^月'), # 以"年"或"月"开头的列
|
||||
],
|
||||
# 数值列匹配
|
||||
"value_patterns": [
|
||||
re.compile(r'^(actual_?value|数值|实际值|实际金额)$', re.I),
|
||||
re.compile(r'^(本期金额|本月数|本期|期末余额|期末数|余额)$'),
|
||||
re.compile(r'^(金额|数据|value|本年累计|本期发生[额]?)$', re.I),
|
||||
],
|
||||
# 文件名→期间提取
|
||||
"period_in_filename_patterns": [
|
||||
re.compile(r'[-_]?(\d{4})[-_]?(\d{1,2})'), # 2026-06 / 2026_06 / 2606
|
||||
re.compile(r'(\d{4})年(\d{1,2})月(?:至(\d{4})年(\d{1,2})月)?'), # 2026年01月 / 2026年01月至2026年05月
|
||||
re.compile(r'(\d{4})(\d{2})'), # 202606 (纯数字6-8位)
|
||||
],
|
||||
# 文件名→报表类型
|
||||
"statement_types": {
|
||||
"利润表": "PL",
|
||||
"利润": "PL",
|
||||
"income": "PL",
|
||||
"现金流量表": "CF",
|
||||
"现金流": "CF",
|
||||
"cashflow": "CF",
|
||||
"cash_flow": "CF",
|
||||
"资产负债表": "BS",
|
||||
"资产负": "BS",
|
||||
"balance": "BS",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _smart_detect_kpi_col(cols: list[str]) -> str | None:
|
||||
for pat in _SMART_MAP["kpi_code_patterns"]:
|
||||
for c in cols:
|
||||
if pat.match(c.strip()):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _smart_detect_period_col(cols: list[str]) -> str | None:
|
||||
for pat in _SMART_MAP["period_patterns"]:
|
||||
for c in cols:
|
||||
if pat.match(c.strip()):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _smart_detect_value_col(cols: list[str]) -> str | None:
|
||||
for pat in _SMART_MAP["value_patterns"]:
|
||||
for c in cols:
|
||||
if pat.match(c.strip()):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _smart_extract_period_from_filename(filename: str) -> str | None:
|
||||
for pat in _SMART_MAP["period_in_filename_patterns"]:
|
||||
m = pat.search(filename)
|
||||
if m:
|
||||
groups = m.groups()
|
||||
if len(groups) == 4 and groups[2]: # 2026年01月至2026年05月 → 取结束月
|
||||
return f"{groups[2]}-{int(groups[3]):02d}"
|
||||
if len(groups) >= 2: # 2026-06 或 2026年01月
|
||||
return f"{int(groups[0])}-{int(groups[1]):02d}"
|
||||
return None
|
||||
|
||||
|
||||
def _smart_detect_statement_type(filename: str) -> str | None:
|
||||
for kw, tp in _SMART_MAP["statement_types"].items():
|
||||
if kw in filename:
|
||||
return tp
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/import-excel-smart")
|
||||
async def import_excel_smart(
|
||||
file: UploadFile = File(...),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""智能导入 — BOT自动识别列名/期间/报表类型,无需手动映射"""
|
||||
content = await file.read()
|
||||
fname = file.filename or "未知文件"
|
||||
|
||||
try:
|
||||
df = pd.read_excel(io.BytesIO(content))
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"无法读取Excel文件: {e}")
|
||||
|
||||
if len(df) == 0:
|
||||
raise HTTPException(400, "Excel文件为空")
|
||||
|
||||
cols = list(df.columns)
|
||||
if len(cols) < 2:
|
||||
raise HTTPException(400, f"Excel列数过少: {cols}")
|
||||
|
||||
# 4. 智能检测列
|
||||
kpi_col = _smart_detect_kpi_col(cols) or cols[0]
|
||||
value_col = _smart_detect_value_col(cols) or cols[-1]
|
||||
period_col = _smart_detect_period_col(cols)
|
||||
|
||||
# 5. 从文件名提取期间
|
||||
period = _smart_extract_period_from_filename(fname) if not period_col else None
|
||||
|
||||
# 6. 检测报表类型(用于自动生成KPI编码前缀)
|
||||
stype = _smart_detect_statement_type(fname)
|
||||
|
||||
# 7. 预加载KPI字典(★ 按当前账套过滤,防跨账套匹配 — 2026-09-08 P0 安全修复)
|
||||
from app.models import KPIDefinition
|
||||
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()}
|
||||
known_codes = set(kpis.keys())
|
||||
# 构建别名映射(去掉空格/大小写/特殊字符)
|
||||
alias_map: dict[str, str] = {}
|
||||
for code in known_codes:
|
||||
clean = re.sub(r'[\s\-_()()]', '', code).lower()
|
||||
alias_map[clean] = code
|
||||
# 中文名映射("营业收入"→F_REVENUE)——同账套内 kpi_name 冲突时后者覆盖,保持与原逻辑一致
|
||||
name_map: dict[str, str] = {}
|
||||
for code, kpi_obj in kpis.items():
|
||||
name_map[kpi_obj.kpi_name] = code
|
||||
|
||||
# 8. 遍历导入(匹配不上的自动创建KPI)
|
||||
stype_prefix = {"PL": "PL_", "CF": "CF_", "BS": "BS_"}.get(stype or "", "EXT_")
|
||||
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
|
||||
imported = 0
|
||||
created_kpis = 0
|
||||
skipped_rows = []
|
||||
code_serial = len(kpis) + 1
|
||||
|
||||
def _next_code() -> str:
|
||||
"""生成当前账套内不冲突的新KPI编码"""
|
||||
nonlocal code_serial
|
||||
while True:
|
||||
candidate = f"{stype_prefix}{code_serial:03d}"
|
||||
code_serial += 1
|
||||
if candidate not in known_codes:
|
||||
return candidate
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
raw_kpi_raw = row.get(kpi_col, "")
|
||||
raw_kpi = str(raw_kpi_raw).strip()
|
||||
raw_val = row.get(value_col)
|
||||
raw_period = str(row.get(period_col, period or "")).strip() if period_col else (period or "")
|
||||
|
||||
# 名称防护:NaN/空/None 或 纯数字(疑似科目编码被误当名称)→ 跳过,避免创建垃圾KPI
|
||||
if raw_kpi.lower() in ("nan", "none") or not raw_kpi:
|
||||
skipped_rows.append(f"第{idx+2}行: KPI名称为空")
|
||||
continue
|
||||
if re.fullmatch(r"\d+(\.\d+)?", raw_kpi):
|
||||
skipped_rows.append(f"第{idx+2}行: KPI名称疑似科目编码「{raw_kpi}」,跳过")
|
||||
continue
|
||||
if pd.isna(raw_val):
|
||||
skipped_rows.append(f"第{idx+2}行: 缺数据")
|
||||
continue
|
||||
if not raw_period:
|
||||
skipped_rows.append(f"第{idx+2}行: 无法确定期间")
|
||||
continue
|
||||
|
||||
# 清理科目名(去掉"一、""减:""加:"等前缀)
|
||||
clean_name = re.sub(r'^[一二三四五六七八九十、\s\+]+', '', raw_kpi)
|
||||
clean_name = re.sub(r'^[减加]?[::]\s*', '', clean_name).strip()
|
||||
if not clean_name:
|
||||
clean_name = raw_kpi
|
||||
|
||||
# 匹配KPI
|
||||
kpi_code = None
|
||||
|
||||
# ① 精确编码匹配(极少情况)
|
||||
if raw_kpi in known_codes:
|
||||
kpi_code = raw_kpi
|
||||
# ② 别名匹配(去符号小写)
|
||||
if not kpi_code:
|
||||
clean_key = re.sub(r'[\s\-_()()]', '', raw_kpi).lower()
|
||||
kpi_code = alias_map.get(clean_key)
|
||||
# ③ 中文名精确匹配
|
||||
if not kpi_code:
|
||||
kpi_code = name_map.get(clean_name)
|
||||
# ④ 中文名模糊匹配
|
||||
if not kpi_code:
|
||||
for code, kpi_obj in kpis.items():
|
||||
if clean_name in kpi_obj.kpi_name or kpi_obj.kpi_name in clean_name:
|
||||
kpi_code = code
|
||||
break
|
||||
|
||||
# ⑤ 仍未匹配 → 自动创建KPI(★ 创建在当前账套 entity_id 下)
|
||||
if not kpi_code:
|
||||
new_code = _next_code()
|
||||
new_kpi = KPIDefinition(
|
||||
entity_id=entity_id,
|
||||
kpi_code=new_code,
|
||||
kpi_name=clean_name,
|
||||
dimension="finance",
|
||||
category="financial_report",
|
||||
formula="-",
|
||||
data_source_type="excel",
|
||||
data_source="Excel导入",
|
||||
data_owner="财务部",
|
||||
frequency="monthly",
|
||||
unit="元",
|
||||
target_value=0,
|
||||
kpi_level="operational",
|
||||
status="active",
|
||||
)
|
||||
db.add(new_kpi)
|
||||
db.flush()
|
||||
kpis[new_code] = new_kpi
|
||||
known_codes.add(new_code)
|
||||
name_map[clean_name] = new_code
|
||||
kpi_code = new_code
|
||||
created_kpis += 1
|
||||
|
||||
try:
|
||||
val = float(raw_val)
|
||||
except:
|
||||
skipped_rows.append(f"第{idx+2}行: 数值格式错误「{raw_val}」")
|
||||
continue
|
||||
|
||||
db.add(KPIValue(
|
||||
kpi_id=kpis[kpi_code].id,
|
||||
entity_id=entity_id, # 账套隔离:只写入当前账套
|
||||
period=raw_period,
|
||||
actual_value=val,
|
||||
source_type="excel",
|
||||
source_batch=batch,
|
||||
data_status="verified",
|
||||
))
|
||||
imported += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
# 9. 返回汇总
|
||||
stype_label = {"PL": "利润表", "CF": "现金流量表", "BS": "资产负债表"}.get(stype or "", "数据表")
|
||||
msg = f"✅ {stype_label}识别成功,导入{imported}条"
|
||||
if created_kpis:
|
||||
msg += f",自动创建{created_kpis}个新KPI"
|
||||
if skipped_rows:
|
||||
msg += f",{len(skipped_rows)}条跳过:\n" + "\n".join(skipped_rows[:8])
|
||||
if len(skipped_rows) > 8:
|
||||
msg += f"\n...还有{len(skipped_rows) - 8}条"
|
||||
return {"message": msg, "batch": batch, "total": imported, "skipped": len(skipped_rows)}
|
||||
|
||||
@router.get("/sources")
|
||||
def list_sources(db: Session = Depends(get_db)):
|
||||
sources = db.query(DataSourceConfig).all()
|
||||
def list_sources(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
sources = db.query(DataSourceConfig).filter(DataSourceConfig.entity_id == entity_id).all()
|
||||
return {"data": [{c.name: getattr(s, c.name) for c in DataSourceConfig.__table__.columns} for s in sources]}
|
||||
|
||||
@router.post("/sources")
|
||||
def create_source(data: dict, db: Session = Depends(get_db)):
|
||||
def create_source(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
source = DataSourceConfig(
|
||||
entity_id=entity_id, # 账套隔离:创建在当前账套下
|
||||
name=data.get("name", ""),
|
||||
source_type=data.get("source_type", "manual"),
|
||||
api_endpoint=data.get("api_endpoint"),
|
||||
@@ -71,13 +366,17 @@ def create_source(data: dict, db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
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()
|
||||
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
||||
|
||||
@router.put("/sources/{source_id}")
|
||||
def update_source(source_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first()
|
||||
def update_source(source_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
source = db.query(DataSourceConfig).filter(
|
||||
DataSourceConfig.id == source_id,
|
||||
DataSourceConfig.entity_id == entity_id,
|
||||
).first()
|
||||
if not source:
|
||||
raise HTTPException(404, "数据源不存在")
|
||||
for key in ["name", "source_type", "api_endpoint", "api_key", "query_sql", "sync_type", "status"]:
|
||||
@@ -85,16 +384,98 @@ def update_source(source_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
setattr(source, key, data[key])
|
||||
db.commit()
|
||||
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()
|
||||
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
|
||||
|
||||
@router.delete("/sources/{source_id}")
|
||||
def delete_source(source_id: int, db: Session = Depends(get_db)):
|
||||
source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first()
|
||||
def delete_source(source_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
source = db.query(DataSourceConfig).filter(
|
||||
DataSourceConfig.id == source_id,
|
||||
DataSourceConfig.entity_id == entity_id,
|
||||
).first()
|
||||
if not source:
|
||||
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.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ── 数据链路状态(2026-09-08 P0 诚实化:展示真实取数通道,替代误导性 active ERP 数据源) ──
|
||||
|
||||
def _zxt_channel_status(entity_id: int, db: Session) -> dict:
|
||||
"""真实取数通道状态:kpi_values source_type='zxt-sync' 最近注入信息 + 落盘目录"""
|
||||
latest = db.query(
|
||||
func.max(KPIValue.period).label("latest_period"),
|
||||
func.max(KPIValue.calculated_at).label("latest_at"),
|
||||
func.count(KPIValue.id).label("row_count"),
|
||||
).filter(
|
||||
KPIValue.source_type == "zxt-sync",
|
||||
KPIValue.entity_id == entity_id,
|
||||
).first()
|
||||
dir_mtime = None
|
||||
dir_exists = os.path.isdir(ZXT_LANDING_DIR)
|
||||
if dir_exists:
|
||||
try:
|
||||
newest = max(
|
||||
(os.path.getmtime(os.path.join(root, f))
|
||||
for root, _dirs, files in os.walk(ZXT_LANDING_DIR)
|
||||
for f in files if f.endswith(".csv")),
|
||||
default=0,
|
||||
)
|
||||
if newest:
|
||||
dir_mtime = datetime.fromtimestamp(newest).strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"channel": "zxt(智享通→落盘→注入)",
|
||||
"enabled": True,
|
||||
"latest_period": latest.latest_period if latest else None,
|
||||
"latest_inject_at": latest.latest_at.strftime("%Y-%m-%d %H:%M:%S") if latest and latest.latest_at else None,
|
||||
"injected_rows": latest.row_count if latest else 0,
|
||||
"landing_dir": ZXT_LANDING_DIR,
|
||||
"landing_dir_exists": dir_exists,
|
||||
"landing_dir_mtime": dir_mtime,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/channel-status")
|
||||
def channel_status(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""数据链路状态:真实取数通道(zxt) + 遗留 ERP 数据源说明(诚实化展示)"""
|
||||
zxt = _zxt_channel_status(entity_id, db)
|
||||
|
||||
# 当前账套的 data_source_config 遗留 ERP 源(有端点指向 erp-gateway 且从未同步成功)
|
||||
legacy = db.query(DataSourceConfig).filter(
|
||||
DataSourceConfig.entity_id == entity_id,
|
||||
DataSourceConfig.source_type == "erp",
|
||||
).all()
|
||||
legacy_rows = [{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"api_endpoint": s.api_endpoint,
|
||||
"status": s.status,
|
||||
"last_sync_at": s.last_sync_at.strftime("%Y-%m-%d %H:%M:%S") if s.last_sync_at else None,
|
||||
} for s in legacy]
|
||||
|
||||
return {
|
||||
"real_channel": zxt,
|
||||
"erp_direct": {
|
||||
"enabled": False,
|
||||
"reason": ERP_DIRECT_CHANNEL_REASON,
|
||||
"legacy_sources_count": len(legacy_rows),
|
||||
"legacy_sources": legacy_rows,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sync-kpis")
|
||||
def sync_kpis_from_erp(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""[已停用] 从ERP数据源同步KPI值
|
||||
|
||||
ERP 直连通道已于 2026-08-31 停用(erp-gateway DB 凭据删除)。
|
||||
真实财务数据通道 = zxt 智享通注入(source_type='zxt-sync'),勿再调用本端点。
|
||||
"""
|
||||
raise HTTPException(status_code=410, detail=ERP_DIRECT_CHANNEL_REASON)
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"""数据分类分级 — 重要数据标记(2026-08-26 政策驱动)
|
||||
|
||||
背景:《网络数据安全风险评估办法》(2026-08-20 三部门施行)
|
||||
- 重要数据处理者每年一次强制评估
|
||||
- 评估前提 = 先分类分级("不知道重要数据在哪,评估无从谈起")
|
||||
- 本模块 = "分类分级工具"第一版(标记+清单),不做评估算法
|
||||
|
||||
数据级别(参考 GB/T 43697 简化):
|
||||
- core 核心数据(一旦遭篡改/破坏/泄露,直接危害国家安全/经济运行)
|
||||
- important 重要数据(一旦遭篡改/破坏/泄露,危害公共利益/企业关键业务)
|
||||
- general 一般数据(其余)
|
||||
|
||||
覆盖对象:
|
||||
- subjects 会计科目(全局,无 entity_id,与现有科目管理一致)
|
||||
- kpi_definitions KPI字典(按 entity_id 多租户隔离)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import csv
|
||||
import io
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role, require_auth
|
||||
from app.models import Subject, KPIDefinition, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/data-classification", tags=["数据分类分级"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# 写操作只允许 ceo/finance/it(与 KPI 字典一致)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
VALID_LEVELS = {"core", "important", "general"}
|
||||
|
||||
LEVEL_LABELS = {
|
||||
"core": "核心数据",
|
||||
"important": "重要数据",
|
||||
"general": "一般数据",
|
||||
}
|
||||
|
||||
# 行业参考目录(内置提示,可按行业自定义扩展)
|
||||
INDUSTRY_REFERENCE = [
|
||||
{"industry": "通用/企业服务", "category": "客户数据", "suggest_level": "important",
|
||||
"desc": "客户名单、合同、订单、应收账款等客户经营数据"},
|
||||
{"industry": "通用/企业服务", "category": "财务数据", "suggest_level": "important",
|
||||
"desc": "财务报表、银行账户、税务申报、薪酬数据"},
|
||||
{"industry": "通用/企业服务", "category": "员工数据", "suggest_level": "important",
|
||||
"desc": "员工身份、薪酬、社保、考勤、健康信息"},
|
||||
{"industry": "金融", "category": "账户交易", "suggest_level": "core",
|
||||
"desc": "客户账户、交易流水、信贷记录(金融行业核心数据)"},
|
||||
{"industry": "医疗", "category": "健康医疗", "suggest_level": "core",
|
||||
"desc": "病历、健康档案、基因数据(医疗行业核心数据)"},
|
||||
{"industry": "工业", "category": "关键基础设施", "suggest_level": "important",
|
||||
"desc": "生产控制、工艺参数、供应链关键环节数据"},
|
||||
{"industry": "互联网", "category": "用户个人信息", "suggest_level": "important",
|
||||
"desc": "个人信息、日志、位置数据(万人以上规模需重点评估)"},
|
||||
{"industry": "能源", "category": "能源数据", "suggest_level": "important",
|
||||
"desc": "能源生产、传输、消费监测数据"},
|
||||
]
|
||||
|
||||
|
||||
def _subject_to_item(s: Subject) -> dict:
|
||||
return {
|
||||
"type": "subject",
|
||||
"id": s.id,
|
||||
"code": s.subject_code,
|
||||
"name": s.subject_name,
|
||||
"dimension": None,
|
||||
"category": s.new_standard_category or s.category or "",
|
||||
"important_flag": 1 if s.important_flag else 0,
|
||||
"data_level": s.data_level or "general",
|
||||
"data_category": s.data_category or "",
|
||||
"data_owner": None,
|
||||
"storage": "会计科目台账",
|
||||
}
|
||||
|
||||
|
||||
def _kpi_to_item(k: KPIDefinition) -> dict:
|
||||
return {
|
||||
"type": "kpi",
|
||||
"id": k.id,
|
||||
"code": k.kpi_code,
|
||||
"name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category or "",
|
||||
"important_flag": 1 if k.important_flag else 0,
|
||||
"data_level": k.data_level or "general",
|
||||
"data_category": k.data_category or "",
|
||||
"data_owner": k.data_owner,
|
||||
"storage": f"KPI台账(entity_id={k.entity_id})",
|
||||
}
|
||||
|
||||
|
||||
def _query_items(
|
||||
data_type: Optional[str],
|
||||
data_level: Optional[str],
|
||||
important: Optional[int],
|
||||
keyword: Optional[str],
|
||||
entity_id: int,
|
||||
db: Session,
|
||||
) -> list:
|
||||
"""内部清单查询(供 inventory/export 复用,避免依赖注入问题)"""
|
||||
items: list[dict] = []
|
||||
|
||||
if not data_type or data_type == "subject":
|
||||
# 账套分层(2026-09-23):全局标准准则科目 ∪ 本账套私有明细(与 KPI 侧同一账套口径)
|
||||
q = db.query(Subject).filter(
|
||||
Subject.is_active == 1,
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||
)
|
||||
if data_level:
|
||||
q = q.filter(Subject.data_level == data_level)
|
||||
if important == 1:
|
||||
q = q.filter(Subject.important_flag == 1)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
Subject.subject_name.like(like) | Subject.subject_code.like(like)
|
||||
)
|
||||
for s in q.order_by(Subject.subject_code).all():
|
||||
items.append(_subject_to_item(s))
|
||||
|
||||
if not data_type or data_type == "kpi":
|
||||
q = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
)
|
||||
if data_level:
|
||||
q = q.filter(KPIDefinition.data_level == data_level)
|
||||
if important == 1:
|
||||
q = q.filter(KPIDefinition.important_flag == 1)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
KPIDefinition.kpi_name.like(like) | KPIDefinition.kpi_code.like(like)
|
||||
)
|
||||
for k in q.order_by(KPIDefinition.kpi_code).all():
|
||||
items.append(_kpi_to_item(k))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _stats_of(items: list) -> dict:
|
||||
return {
|
||||
"total": len(items),
|
||||
"marked": sum(1 for i in items if i["important_flag"] == 1),
|
||||
"by_level": {
|
||||
"core": sum(1 for i in items if i["data_level"] == "core"),
|
||||
"important": sum(1 for i in items if i["data_level"] == "important"),
|
||||
"general": sum(1 for i in items if i["data_level"] == "general"),
|
||||
},
|
||||
"by_type": {
|
||||
"subject": sum(1 for i in items if i["type"] == "subject"),
|
||||
"kpi": sum(1 for i in items if i["type"] == "kpi"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/inventory")
|
||||
def get_inventory(
|
||||
data_type: Optional[str] = Query(None, description="subject/kpi,缺省返回全部"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||
keyword: Optional[str] = Query(None, description="编码/名称搜索"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""数据资产清单(科目 + KPI 合并输出,含重要级别)"""
|
||||
if data_level and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
|
||||
items = _query_items(data_type, data_level, important, keyword, entity_id, db)
|
||||
return {"total": len(items), "items": items, "stats": _stats_of(items)}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""数据分类分级统计概览(评估前准备看板)"""
|
||||
subject_total = db.query(Subject).filter(
|
||||
Subject.is_active == 1,
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||
).count()
|
||||
subject_marked = db.query(Subject).filter(
|
||||
Subject.is_active == 1, Subject.important_flag == 1,
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||
).count()
|
||||
kpi_total = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||||
).count()
|
||||
kpi_marked = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.important_flag == 1,
|
||||
).count()
|
||||
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": ent.name if ent else "",
|
||||
"subjects": {"total": subject_total, "marked": subject_marked},
|
||||
"kpis": {"total": kpi_total, "marked": kpi_marked},
|
||||
"total": subject_total + kpi_total,
|
||||
"marked_total": subject_marked + kpi_marked,
|
||||
"coverage_pct": round((subject_marked + kpi_marked) / (subject_total + kpi_total) * 100, 1)
|
||||
if (subject_total + kpi_total) else 0,
|
||||
"policy_note": "《网络数据安全风险评估办法》(2026-08-20施行):重要数据处理者每年一次强制评估,评估前提=先完成数据分类分级",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/subjects/{subject_id}")
|
||||
def mark_subject(
|
||||
subject_id: int,
|
||||
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""标记单个科目:重要数据标记 + 数据分级 + 行业分类"""
|
||||
subject = db.query(Subject).filter(Subject.id == subject_id).first()
|
||||
if not subject:
|
||||
raise HTTPException(404, "科目不存在")
|
||||
|
||||
if data_level is not None and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
|
||||
if important_flag is not None:
|
||||
subject.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
subject.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
subject.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||
if data_category is not None:
|
||||
subject.data_category = data_category or None
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "更新成功",
|
||||
"subject_id": subject_id,
|
||||
"important_flag": subject.important_flag,
|
||||
"data_level": subject.data_level,
|
||||
"data_category": subject.data_category,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/kpis/{kpi_id}")
|
||||
def mark_kpi(
|
||||
kpi_id: int,
|
||||
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""标记单个KPI:重要数据标记 + 数据分级 + 行业分类(按账套隔离)"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在或不属于当前账套")
|
||||
|
||||
if data_level is not None and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
|
||||
if important_flag is not None:
|
||||
kpi.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
kpi.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
kpi.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||
if data_category is not None:
|
||||
kpi.data_category = data_category or None
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "更新成功",
|
||||
"kpi_id": kpi_id,
|
||||
"important_flag": kpi.important_flag,
|
||||
"data_level": kpi.data_level,
|
||||
"data_category": kpi.data_category,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/batch")
|
||||
def batch_mark(
|
||||
data_type: str = Query(..., description="subject/kpi"),
|
||||
ids: List[int] = Query(..., description="ID列表"),
|
||||
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""批量标记(科目全局 / KPI按账套隔离)"""
|
||||
if data_type not in ("subject", "kpi"):
|
||||
raise HTTPException(400, f"无效的数据类型: {data_type},可选 subject/kpi")
|
||||
if data_level is not None and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
if not ids:
|
||||
raise HTTPException(400, "ids 不能为空")
|
||||
|
||||
updated = 0
|
||||
if data_type == "subject":
|
||||
q = db.query(Subject).filter(Subject.id.in_(ids), Subject.is_active == 1)
|
||||
targets = q.all()
|
||||
for s in targets:
|
||||
if important_flag is not None:
|
||||
s.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
s.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
s.important_flag = 1
|
||||
if data_category is not None:
|
||||
s.data_category = data_category or None
|
||||
updated += 1
|
||||
else:
|
||||
q = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id.in_(ids),
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
)
|
||||
targets = q.all()
|
||||
for k in targets:
|
||||
if important_flag is not None:
|
||||
k.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
k.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
k.important_flag = 1
|
||||
if data_category is not None:
|
||||
k.data_category = data_category or None
|
||||
updated += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"批量更新成功", "data_type": data_type, "updated_count": updated}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_csv(
|
||||
data_type: Optional[str] = Query(None, description="subject/kpi,缺省全部"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""导出数据分类分级清单(评估前准备材料)CSV"""
|
||||
if data_level and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level}")
|
||||
|
||||
items = _query_items(data_type, data_level, important, None, entity_id, db)
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["类型", "编码", "名称", "维度", "分类", "重要标记", "数据级别", "行业参考分类", "数据责任人", "存储位置"])
|
||||
for i in items:
|
||||
writer.writerow([
|
||||
"科目" if i["type"] == "subject" else "KPI",
|
||||
i["code"],
|
||||
i["name"],
|
||||
i["dimension"] or "",
|
||||
i["category"] or "",
|
||||
"是" if i["important_flag"] else "否",
|
||||
LEVEL_LABELS.get(i["data_level"], i["data_level"]),
|
||||
i["data_category"] or "",
|
||||
i["data_owner"] or "",
|
||||
i["storage"] or "",
|
||||
])
|
||||
|
||||
content = "\ufeff" + buf.getvalue() # BOM 兼容 Excel
|
||||
filename = f"data-classification-inventory_{datetime.now().strftime('%Y%m%d')}.csv"
|
||||
from starlette.responses import Response
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/industry-reference")
|
||||
def industry_reference():
|
||||
"""行业参考目录(内置提示,按行业给出重点数据分类建议)"""
|
||||
return {"items": INDUSTRY_REFERENCE}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28)
|
||||
|
||||
派生规则配置(budget_derivation_rules):apply-method 派生KPI时优先读规则,
|
||||
percentage_of → base_kpi实际值×rate;incremental → 上月×(1+rate);无规则fallback默认比例。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import BudgetDerivationRule, KPIDefinition
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["派生规则"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/derivation-rules")
|
||||
def list_derivation_rules(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""派生规则列表(按 entity_id 隔离)"""
|
||||
query = db.query(BudgetDerivationRule).filter(BudgetDerivationRule.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetDerivationRule.kpi_id == kpi_id)
|
||||
if status:
|
||||
query = query.filter(BudgetDerivationRule.status == status)
|
||||
rows = query.order_by(BudgetDerivationRule.id.desc()).all()
|
||||
|
||||
all_kpi_ids = {r.kpi_id for r in rows} | {r.base_kpi_id for r in rows if r.base_kpi_id}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(all_kpi_ids)).all()} if all_kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
base = kpis.get(r.base_kpi_id) if r.base_kpi_id else None
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"rule_type": r.rule_type,
|
||||
"base_kpi_id": r.base_kpi_id,
|
||||
"base_kpi_code": base.kpi_code if base else "",
|
||||
"base_kpi_name": base.kpi_name if base else "",
|
||||
"params": r.params,
|
||||
"formula_text": r.formula_text,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.post("/derivation-rules")
|
||||
def create_derivation_rule(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建派生规则(同KPI同类型唯一)"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
rule_type = data.get("rule_type")
|
||||
if not kpi_id or rule_type not in ("incremental", "percentage_of", "formula"):
|
||||
raise HTTPException(400, "需要 kpi_id 且 rule_type ∈ incremental/percentage_of/formula")
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
existing = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
BudgetDerivationRule.kpi_id == kpi_id,
|
||||
BudgetDerivationRule.rule_type == rule_type,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI({kpi_id})已存在 {rule_type} 规则")
|
||||
|
||||
row = BudgetDerivationRule(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
rule_type=rule_type,
|
||||
base_kpi_id=data.get("base_kpi_id"),
|
||||
params=data.get("params"),
|
||||
formula_text=data.get("formula_text"),
|
||||
status=data.get("status", "active"),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "派生规则已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/derivation-rules/{rule_id}")
|
||||
def update_derivation_rule(
|
||||
rule_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新派生规则"""
|
||||
row = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.id == rule_id,
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
for field in ("rule_type", "base_kpi_id", "params", "formula_text", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "派生规则已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/derivation-rules/{rule_id}")
|
||||
def delete_derivation_rule(
|
||||
rule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除派生规则"""
|
||||
row = db.query(BudgetDerivationRule).filter(
|
||||
BudgetDerivationRule.id == rule_id,
|
||||
BudgetDerivationRule.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "派生规则已删除"}
|
||||
@@ -0,0 +1,262 @@
|
||||
"""差异分析→战略地图反打 API — P1-1
|
||||
|
||||
允许从差异分析页面一键回写实际值到战略地图节点,触发预警并生成回顾会议题。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
|
||||
# 裁定 R5-补1(2026-09-19 财务口径):差异反打路径同源接入 R1/R5 守卫
|
||||
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.deviation_push")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business"))],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/push-to-map")
|
||||
def push_deviation_to_map(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""从差异分析回写实际值到战略地图节点
|
||||
|
||||
Body: {
|
||||
mapId: int,
|
||||
nodeId: string, // 格式 "dim_key-index" 如 "finance-0"
|
||||
deviationId: int,
|
||||
newValue: float,
|
||||
period: string, // 如 "2026-05"
|
||||
createReviewTopic: bool
|
||||
}
|
||||
"""
|
||||
map_id = data.get("mapId")
|
||||
node_id = data.get("nodeId")
|
||||
deviation_id = data.get("deviationId")
|
||||
new_value = data.get("newValue")
|
||||
period = data.get("period")
|
||||
create_review_topic = data.get("createReviewTopic", True)
|
||||
|
||||
if not map_id or not node_id:
|
||||
raise HTTPException(400, "缺少 mapId 或 nodeId")
|
||||
if new_value is None:
|
||||
raise HTTPException(400, "缺少 newValue")
|
||||
|
||||
# R5-补1(财务裁定 2026-09-19):「请求体无 period 一律不落库」
|
||||
# 旧码在缺 period 时用当月冒充期间再落库(预警 + kpi_values 双写)——
|
||||
# 该兜底正好绕过 R5「可归期」判据,故此处直接整请求零业务写入返回。
|
||||
if not period:
|
||||
logger.info(
|
||||
"R5-补1 期间守卫:deviation_push 请求无 period,整请求零业务写入"
|
||||
" entity_id=%s map_id=%s node_id=%s", entity_id, map_id, node_id)
|
||||
return {
|
||||
"success": False,
|
||||
"message": "缺少 period:按 R5-补1(期间不可归期)未落库任何数据",
|
||||
"alert_created": False,
|
||||
"alert_skipped_reason": "no_period",
|
||||
"zero_write": True,
|
||||
"review_topic_created": False,
|
||||
}
|
||||
|
||||
# 1. 查找战略地图(2026-09-18 派生单: 地图须归属会话账套,NULL 归属按 e1 兼容)
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m or (m.entity_id or 1) != entity_id:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
# 2. 解析 node_id 格式: "finance-0"
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except:
|
||||
dims = []
|
||||
|
||||
parts = node_id.rsplit("-", 1)
|
||||
if len(parts) != 2:
|
||||
raise HTTPException(400, f"节点ID格式错误: {node_id}")
|
||||
|
||||
dim_key, obj_index_str = parts
|
||||
try:
|
||||
obj_index = int(obj_index_str)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"节点索引不是数字: {obj_index_str}")
|
||||
|
||||
target_dim = None
|
||||
target_obj = None
|
||||
for dim in dims:
|
||||
if dim.get("key") == dim_key:
|
||||
target_dim = dim
|
||||
objs = dim.get("objectives", [])
|
||||
if 0 <= obj_index < len(objs):
|
||||
target_obj = objs[obj_index]
|
||||
break
|
||||
|
||||
if not target_obj:
|
||||
raise HTTPException(404, f"未找到节点: {node_id}")
|
||||
|
||||
kpi_codes = target_obj.get("kpis", [])
|
||||
if not kpi_codes:
|
||||
raise HTTPException(400, f"目标 [{target_obj.get('name')}] 没有关联KPI")
|
||||
|
||||
kpi_code = kpi_codes[0]
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||
if not kpi or (kpi.entity_id or 1) != entity_id:
|
||||
raise HTTPException(404, f"KPI {kpi_code} 不存在")
|
||||
|
||||
# 3. 更新实际值到 KPIValue 表
|
||||
#(R5-补1:period 已在上方强制校验,原「缺 period 用当月兜底」分支已删除——
|
||||
# 该兜底会伪造期间,正是 R5「不可归期」的成因之一)
|
||||
existing_value = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
).first()
|
||||
|
||||
if existing_value:
|
||||
existing_value.actual_value = new_value
|
||||
existing_value.source_type = "manual"
|
||||
kv_row = existing_value
|
||||
else:
|
||||
kv_row = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
period=period,
|
||||
actual_value=new_value,
|
||||
source_type="manual",
|
||||
)
|
||||
db.add(kv_row)
|
||||
|
||||
db.flush()
|
||||
|
||||
# 4. 检查是否触发预警
|
||||
alert_created = False
|
||||
alert_id = None
|
||||
alert_skipped_reason = None
|
||||
if kpi.target_value and kpi.target_value > 0:
|
||||
ratio = new_value / kpi.target_value
|
||||
if ratio < 0.7:
|
||||
alert_level = "red"
|
||||
alert_msg = f"严重偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
|
||||
elif ratio < 0.9:
|
||||
alert_level = "yellow"
|
||||
alert_msg = f"关注偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
|
||||
else:
|
||||
alert_level = None
|
||||
|
||||
if alert_level:
|
||||
# R1/R5 双守卫(裁定 R5-补1):demo 账套 / 非规整月期间 → 该 kpi_alerts 行不入库,
|
||||
# 返回体登记跳过原因(KPI 值照写,与 bot_bridge 同口径:裁定只约束预警行)
|
||||
if not entity_alert_guard(db, entity_id, source="deviation_push.push_deviation_to_map",
|
||||
detail="kpi=%s" % kpi_code):
|
||||
alert_skipped_reason = "R1_demo_entity_excluded"
|
||||
elif not guard_alert_period(period, source="deviation_push.push_deviation_to_map",
|
||||
detail="kpi=%s" % kpi_code):
|
||||
alert_skipped_reason = "R5_non_attributable_period"
|
||||
|
||||
if alert_skipped_reason is None:
|
||||
alert = KPIAlert(
|
||||
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
|
||||
kpi_id=kpi.id,
|
||||
kpi_value_id=kv_row.id, # R5-补1: 期间关联(同 kpi 同期间行 → 可派生)
|
||||
alert_level=alert_level,
|
||||
alert_message=alert_msg,
|
||||
status="pending",
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
alert_created = True
|
||||
alert_id = alert.id
|
||||
|
||||
# 5. 生成战略回顾会议题
|
||||
review_topic_created = False
|
||||
if create_review_topic:
|
||||
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
|
||||
existing_topic = db.query(ActionPlan).filter(
|
||||
ActionPlan.entity_id == entity_id, # 2026-09-18 派生单: 去重按账套隔离
|
||||
ActionPlan.title == topic_title,
|
||||
ActionPlan.status.in_(["pending", "in_progress"]),
|
||||
).first()
|
||||
if not existing_topic:
|
||||
topic = ActionPlan(
|
||||
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
|
||||
kpi_id=kpi.id,
|
||||
title=topic_title,
|
||||
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
|
||||
assignee=current_user.name if hasattr(current_user, "name") else "",
|
||||
priority="medium",
|
||||
status="pending",
|
||||
created_by=current_user.name if hasattr(current_user, "name") else "",
|
||||
)
|
||||
db.add(topic)
|
||||
review_topic_created = True
|
||||
|
||||
# 6. 操作日志
|
||||
log = OperationLog(
|
||||
user_id=getattr(current_user, "id", None),
|
||||
action="deviation_push_to_map",
|
||||
target_type="map",
|
||||
target_id=map_id,
|
||||
detail=json.dumps({
|
||||
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||
"node_id": node_id,
|
||||
"deviation_id": deviation_id,
|
||||
"kpi_code": kpi_code,
|
||||
"new_value": new_value,
|
||||
"period": period,
|
||||
"alert_created": alert_created,
|
||||
"alert_skipped_reason": alert_skipped_reason,
|
||||
"review_topic_created": review_topic_created,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已回写至战略地图 [{target_dim.get('name')}→{target_obj.get('name')}]",
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"new_value": new_value,
|
||||
"alert_created": alert_created,
|
||||
"alert_id": alert_id,
|
||||
"alert_skipped_reason": alert_skipped_reason,
|
||||
"review_topic_created": review_topic_created,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/map-nodes/{map_id}")
|
||||
def get_map_nodes(map_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""获取战略地图的全部节点(供反打选择使用)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m or (m.entity_id or 1) != entity_id: # 2026-09-18 派生单: NULL 归属按 e1 兼容(同 push 护栏)
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
dims = m.dimensions
|
||||
if isinstance(dims, str):
|
||||
try:
|
||||
dims = json.loads(dims)
|
||||
except:
|
||||
dims = []
|
||||
|
||||
nodes = []
|
||||
for dim in dims:
|
||||
objs = dim.get("objectives", [])
|
||||
for idx, obj in enumerate(objs):
|
||||
node_id = f"{dim.get('key')}-{idx}"
|
||||
nodes.append({
|
||||
"node_id": node_id,
|
||||
"dim_key": dim.get("key"),
|
||||
"dim_name": dim.get("name"),
|
||||
"dim_icon": dim.get("icon"),
|
||||
"objective_name": obj.get("name"),
|
||||
"kpi_codes": obj.get("kpis", []),
|
||||
})
|
||||
|
||||
return {"data": nodes}
|
||||
@@ -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
|
||||
@@ -0,0 +1,485 @@
|
||||
"""驱动因子预算 API — 科目/驱动因子模式切换 + 行业包 + 敏感性分析"""
|
||||
import json
|
||||
import math
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import SystemConfig, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget/driver",
|
||||
tags=["驱动因子预算"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 模板预置数据
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
PRESET_TEMPLATES = [
|
||||
# 通用收入模板
|
||||
{
|
||||
"name": "收入预测(通用)",
|
||||
"industry": "general",
|
||||
"category": "revenue",
|
||||
"formula_desc": "收入 = 客户数 × 客单价",
|
||||
"formula_text": "客户数×客单价",
|
||||
"factors": [
|
||||
{"key": "customer_count", "label": "客户数", "type": "number", "default": 100, "unit": "家"},
|
||||
{"key": "avg_price", "label": "客单价", "type": "number", "default": 50000, "unit": "元/家"},
|
||||
],
|
||||
},
|
||||
# 通用费用模板
|
||||
{
|
||||
"name": "费用预测(通用)",
|
||||
"industry": "general",
|
||||
"category": "expense",
|
||||
"formula_desc": "费用 = 人数 × 人均成本",
|
||||
"formula_text": "人数×人均成本",
|
||||
"factors": [
|
||||
{"key": "headcount", "label": "人数", "type": "number", "default": 50, "unit": "人"},
|
||||
{"key": "avg_cost_per_head", "label": "人均成本", "type": "number", "default": 8000, "unit": "元/人"},
|
||||
],
|
||||
},
|
||||
# 贸易经销版 — 渠补预算
|
||||
{
|
||||
"name": "渠补预算(贸易经销版)",
|
||||
"industry": "trade",
|
||||
"category": "expense",
|
||||
"formula_desc": "渠补预算 = 计划维护渠道数 × 平均渠补率 × 平均渠道交易额",
|
||||
"formula_text": "计划维护渠道数×平均渠补率×平均渠道交易额",
|
||||
"factors": [
|
||||
{"key": "channel_count", "label": "计划维护渠道数", "type": "number", "default": 80, "unit": "家"},
|
||||
{"key": "channel_subsidy_rate", "label": "平均渠补率", "type": "percent", "default": 75, "unit": "%"},
|
||||
{"key": "avg_transaction", "label": "平均渠道交易额", "type": "number", "default": 150000, "unit": "元/家"},
|
||||
],
|
||||
},
|
||||
# 贸易经销版 — 收入预测
|
||||
{
|
||||
"name": "收入预测(贸易经销版)",
|
||||
"industry": "trade",
|
||||
"category": "revenue",
|
||||
"formula_desc": "收入 = 渠道数 × 平均交易额",
|
||||
"formula_text": "渠道数×平均交易额",
|
||||
"factors": [
|
||||
{"key": "channel_count", "label": "渠道数", "type": "number", "default": 80, "unit": "家"},
|
||||
{"key": "avg_transaction", "label": "平均交易额", "type": "number", "default": 150000, "unit": "元/家"},
|
||||
],
|
||||
},
|
||||
# IT服务版 — 收入
|
||||
{
|
||||
"name": "收入预测(IT服务版)",
|
||||
"industry": "it",
|
||||
"category": "revenue",
|
||||
"formula_desc": "收入 = 计划新签客户数×平均合同额 + 续约客户数×续约率×平均合同额",
|
||||
"formula_text": "计划新签客户数×平均合同额+续约客户数×续约率×平均合同额",
|
||||
"factors": [
|
||||
{"key": "new_customers", "label": "计划新签客户数", "type": "number", "default": 36, "unit": "家/年"},
|
||||
{"key": "avg_contract_value", "label": "平均合同额", "type": "number", "default": 80000, "unit": "元/家"},
|
||||
{"key": "renewal_customers", "label": "续约客户数", "type": "number", "default": 260, "unit": "家"},
|
||||
{"key": "renewal_rate", "label": "续约率", "type": "percent", "default": 90, "unit": "%"},
|
||||
],
|
||||
},
|
||||
# IT服务版 — 销售费用
|
||||
{
|
||||
"name": "销售费用预算(IT服务版)",
|
||||
"industry": "it",
|
||||
"category": "expense",
|
||||
"formula_desc": "销售费用 = 新签客户数×平均获客成本 + 续约客户数×维护成本",
|
||||
"formula_text": "新签客户数×平均获客成本+续约客户数×维护成本",
|
||||
"factors": [
|
||||
{"key": "new_customers", "label": "计划新签客户数", "type": "number", "default": 36, "unit": "家/年"},
|
||||
{"key": "acquisition_cost", "label": "平均获客成本", "type": "number", "default": 3000, "unit": "元/家"},
|
||||
{"key": "renewal_customers", "label": "续约客户数", "type": "number", "default": 260, "unit": "家"},
|
||||
{"key": "maintenance_cost", "label": "维护成本", "type": "number", "default": 500, "unit": "元/家"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 模式切换
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/mode")
|
||||
def get_driver_mode(db: Session = Depends(get_db)):
|
||||
"""获取当前预算编制模式: subject / driver"""
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_driver_mode").first()
|
||||
if not cfg:
|
||||
return {"mode": "subject", "label": "科目模式"}
|
||||
try:
|
||||
val = json.loads(cfg.config_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
val = {"mode": "subject"}
|
||||
return val
|
||||
|
||||
|
||||
@router.post("/mode")
|
||||
def set_driver_mode(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
|
||||
mode = data.get("mode", "subject")
|
||||
if mode not in ("subject", "driver"):
|
||||
raise HTTPException(400, "模式必须是 subject 或 driver")
|
||||
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_driver_mode").first()
|
||||
val = json.dumps({"mode": mode, "label": "驱动因子模式" if mode == "driver" else "科目模式"})
|
||||
if cfg:
|
||||
cfg.config_value = val
|
||||
else:
|
||||
cfg = SystemConfig(
|
||||
config_key="budget_driver_mode",
|
||||
config_value=val,
|
||||
description="预算编制模式: subject=科目模式, driver=驱动因子模式",
|
||||
)
|
||||
db.add(cfg)
|
||||
db.commit()
|
||||
|
||||
# 2026-09-23 ④:审计带账套(会话账套)+ 显式标注该键当前仍是**全局键**
|
||||
#(budget_driver_mode 拆账套 = 另单排期项,见 dama-entity-isolation-sweep-20260923 §二之三)
|
||||
log_op(db, "update", target_type="budget", target_id=0,
|
||||
detail={"mode": mode, "action": "切换编制模式", "entity_scope": "global_key"},
|
||||
entity_id=entity_id)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 模板管理
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/templates")
|
||||
def list_driver_templates(
|
||||
industry: Optional[str] = Query(None, description="行业: general/trade/it"),
|
||||
category: Optional[str] = Query(None, description="类别: revenue/expense"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取驱动因子模板列表(含预置模板)"""
|
||||
# 先从数据库读
|
||||
query = db.query(DriverFactorTemplate).filter(DriverFactorTemplate.is_active == 1)
|
||||
if industry:
|
||||
query = query.filter(DriverFactorTemplate.industry == industry)
|
||||
if category:
|
||||
query = query.filter(DriverFactorTemplate.category == category)
|
||||
db_templates = query.order_by(DriverFactorTemplate.id.asc()).all()
|
||||
|
||||
# 合并预置模板(数据库没有则返回预置)
|
||||
if db_templates:
|
||||
result = []
|
||||
for t in db_templates:
|
||||
result.append({
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"industry": t.industry,
|
||||
"category": t.category,
|
||||
"formula_desc": t.formula_desc,
|
||||
"formula_text": t.formula_text,
|
||||
"factors": t.factors,
|
||||
"is_preset": False,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
else:
|
||||
# 返回预置模板
|
||||
filtered = PRESET_TEMPLATES
|
||||
if industry:
|
||||
filtered = [t for t in filtered if t["industry"] == industry]
|
||||
if category:
|
||||
filtered = [t for t in filtered if t["category"] == category]
|
||||
return {"data": filtered, "total": len(filtered)}
|
||||
|
||||
|
||||
@router.get("/industries")
|
||||
def list_driver_industries(db: Session = Depends(get_db)):
|
||||
"""获取行业包列表"""
|
||||
industries = [
|
||||
{"key": "general", "label": "通用模板", "icon": "📦"},
|
||||
{"key": "trade", "label": "贸易经销版", "icon": "🏪"},
|
||||
{"key": "it", "label": "IT服务版", "icon": "💻"},
|
||||
]
|
||||
return {"data": industries}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 驱动因子计算
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _calculate_formula(formula_text: str, factors: dict, factor_defs: list) -> float:
|
||||
"""
|
||||
根据公式文本和驱动因子值计算预算。
|
||||
支持的运算: + - × * /
|
||||
公式文本中的因子名可以是key或label,函数会自动映射到值。
|
||||
"""
|
||||
# 替换 × 为 *
|
||||
expr = formula_text.replace("×", "*")
|
||||
|
||||
# 构建变量映射:key -> value, label -> value
|
||||
factor_map = {}
|
||||
for fd in factor_defs:
|
||||
key = fd["key"]
|
||||
val = factors.get(key, fd.get("default", 0))
|
||||
if fd.get("type") == "percent":
|
||||
val = float(val) / 100.0
|
||||
else:
|
||||
val = float(val)
|
||||
factor_map[key] = val
|
||||
# 也映射 label(中文名)
|
||||
factor_map[fd["label"]] = val
|
||||
|
||||
# 按长度降序替换,避免短名被错误替换
|
||||
sorted_names = sorted(factor_map.keys(), key=len, reverse=True)
|
||||
for name in sorted_names:
|
||||
expr = expr.replace(name, str(factor_map[name]))
|
||||
|
||||
try:
|
||||
result = eval(expr, {"__builtins__": {}}, {})
|
||||
return round(float(result), 2)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"公式计算失败: {expr}, 错误: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/calculate")
|
||||
def driver_calculate(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""
|
||||
驱动因子计算预算。
|
||||
接收驱动因子值,自动按公式计算预算结果。
|
||||
如果指定了 template_id,使用模板的公式和因子定义。
|
||||
否则使用 data.name, data.formula_text, data.factors 中的因子定义。
|
||||
"""
|
||||
template_id = data.get("template_id")
|
||||
factors_input = data.get("factors", {}) # {key: value}
|
||||
period = data.get("period", datetime.now().strftime("%Y-%m"))
|
||||
|
||||
# 获取模板
|
||||
formula_text = None
|
||||
factor_defs = []
|
||||
|
||||
if template_id:
|
||||
template = db.query(DriverFactorTemplate).filter(
|
||||
DriverFactorTemplate.id == template_id,
|
||||
DriverFactorTemplate.is_active == 1,
|
||||
).first()
|
||||
if not template:
|
||||
# 检查预置模板
|
||||
if 1 <= template_id <= len(PRESET_TEMPLATES):
|
||||
preset = PRESET_TEMPLATES[template_id - 1]
|
||||
formula_text = preset["formula_text"]
|
||||
factor_defs = preset["factors"]
|
||||
name = preset["name"]
|
||||
industry = preset["industry"]
|
||||
else:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
else:
|
||||
formula_text = template.formula_text
|
||||
factor_defs = template.factors
|
||||
name = template.name
|
||||
industry = template.industry
|
||||
else:
|
||||
name = data.get("name", "自定义驱动因子预算")
|
||||
industry = data.get("industry", "general")
|
||||
formula_text = data.get("formula_text")
|
||||
factor_defs = data.get("factor_defs", [])
|
||||
if not formula_text:
|
||||
raise HTTPException(400, "缺少 formula_text (公式文本)")
|
||||
if not factor_defs:
|
||||
raise HTTPException(400, "缺少 factor_defs (因子定义)")
|
||||
|
||||
# 填充默认值
|
||||
complete_factors = {}
|
||||
for fd in factor_defs:
|
||||
key = fd["key"]
|
||||
val = factors_input.get(key)
|
||||
if val is None:
|
||||
val = fd.get("default", 0)
|
||||
complete_factors[key] = val
|
||||
|
||||
# 计算结果
|
||||
result = _calculate_formula(formula_text, complete_factors, factor_defs)
|
||||
|
||||
# 记录计算结果
|
||||
budget_record = DriverFactorBudget(
|
||||
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
|
||||
name=name,
|
||||
industry=industry,
|
||||
template_id=template_id,
|
||||
factors=complete_factors,
|
||||
calculated_value=result,
|
||||
formula_text=formula_text,
|
||||
period=period,
|
||||
created_by=current_user.name if hasattr(current_user, "name") else "",
|
||||
)
|
||||
db.add(budget_record)
|
||||
db.commit()
|
||||
db.refresh(budget_record)
|
||||
|
||||
# 记录操作日志
|
||||
log = OperationLog(
|
||||
action="calculate",
|
||||
target_type="budget",
|
||||
target_id=budget_record.id,
|
||||
detail=json.dumps({
|
||||
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
|
||||
"name": name,
|
||||
"formula": formula_text,
|
||||
"factors": complete_factors,
|
||||
"result": result,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
# 计算原始因子信息(用于前端展示)
|
||||
factor_details = []
|
||||
for fd in factor_defs:
|
||||
key = fd["key"]
|
||||
factor_details.append({
|
||||
"key": key,
|
||||
"label": fd["label"],
|
||||
"value": complete_factors[key],
|
||||
"unit": fd.get("unit", ""),
|
||||
"type": fd.get("type", "number"),
|
||||
})
|
||||
|
||||
return {
|
||||
"id": budget_record.id,
|
||||
"name": name,
|
||||
"industry": industry,
|
||||
"template_id": template_id,
|
||||
"formula_text": formula_text,
|
||||
"formula_desc": data.get("formula_desc", ""),
|
||||
"factors": factor_details,
|
||||
"calculated_value": result,
|
||||
"period": period,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 敏感性分析
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/sensitivity")
|
||||
def driver_sensitivity(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
敏感性分析:对指定驱动因子做 ±10%、±20% 变动,显示预算变动。
|
||||
"""
|
||||
factors_input = data.get("factors", {})
|
||||
formula_text = data.get("formula_text")
|
||||
factor_defs = data.get("factor_defs", [])
|
||||
target_factor = data.get("target_factor") # 要分析的因子key,不指定则分析所有因子
|
||||
|
||||
if not formula_text or not factor_defs:
|
||||
raise HTTPException(400, "缺少 formula_text 或 factor_defs")
|
||||
|
||||
# 计算基准值
|
||||
base_result = _calculate_formula(formula_text, factors_input, factor_defs)
|
||||
|
||||
# 敏感性分析
|
||||
sensitivity_results = []
|
||||
factors_to_analyze = factor_defs
|
||||
if target_factor:
|
||||
factors_to_analyze = [fd for fd in factor_defs if fd["key"] == target_factor]
|
||||
if not factors_to_analyze:
|
||||
raise HTTPException(404, f"未找到因子: {target_factor}")
|
||||
|
||||
for fd in factors_to_analyze:
|
||||
key = fd["key"]
|
||||
base_val = factors_input.get(key, fd.get("default", 0))
|
||||
|
||||
variations = []
|
||||
for pct_change in [-20, -10, 10, 20]:
|
||||
if fd.get("type") == "percent":
|
||||
# 百分比因子:变动比例直接加在百分比上
|
||||
changed_val = base_val * (1 + pct_change / 100.0)
|
||||
else:
|
||||
changed_val = base_val * (1 + pct_change / 100.0)
|
||||
|
||||
test_factors = dict(factors_input)
|
||||
test_factors[key] = round(changed_val, 2)
|
||||
try:
|
||||
new_result = _calculate_formula(formula_text, test_factors, factor_defs)
|
||||
delta = round(new_result - base_result, 2)
|
||||
delta_pct = round((delta / base_result * 100) if base_result != 0 else 0, 2)
|
||||
variations.append({
|
||||
"change_pct": pct_change,
|
||||
"factor_value": round(changed_val, 2),
|
||||
"budget_result": new_result,
|
||||
"delta": delta,
|
||||
"delta_pct": delta_pct,
|
||||
})
|
||||
except Exception:
|
||||
variations.append({
|
||||
"change_pct": pct_change,
|
||||
"factor_value": round(changed_val, 2),
|
||||
"budget_result": None,
|
||||
"delta": None,
|
||||
"delta_pct": None,
|
||||
"error": "计算失败",
|
||||
})
|
||||
|
||||
sensitivity_results.append({
|
||||
"factor_key": key,
|
||||
"factor_label": fd["label"],
|
||||
"base_value": base_val,
|
||||
"unit": fd.get("unit", ""),
|
||||
"type": fd.get("type", "number"),
|
||||
"variations": variations,
|
||||
})
|
||||
|
||||
return {
|
||||
"base_value": base_result,
|
||||
"formula_text": formula_text,
|
||||
"sensitivity": sensitivity_results,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 历史记录
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/history")
|
||||
def list_driver_history(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""查看本账套最近的驱动因子预算计算记录(账套隔离 2026-09-21)"""
|
||||
records = db.query(DriverFactorBudget).filter(
|
||||
DriverFactorBudget.entity_id == entity_id,
|
||||
).order_by(
|
||||
DriverFactorBudget.created_at.desc()
|
||||
).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for r in records:
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"industry": r.industry,
|
||||
"factors": r.factors,
|
||||
"calculated_value": r.calculated_value,
|
||||
"formula_text": r.formula_text,
|
||||
"period": r.period,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""企业实体 API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth
|
||||
from app.models import Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/entities", tags=["企业实体"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
|
||||
class EntityCreate(BaseModel):
|
||||
name: str
|
||||
short_name: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
status: Optional[str] = "active"
|
||||
|
||||
|
||||
class EntityUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
short_name: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_entities(db: Session = Depends(get_db)):
|
||||
"""获取企业列表"""
|
||||
entities = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).all()
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": e.id,
|
||||
"name": e.name,
|
||||
"short_name": e.short_name,
|
||||
"industry": e.industry,
|
||||
}
|
||||
for e in entities
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_entity(data: EntityCreate, db: Session = Depends(get_db)):
|
||||
"""创建企业"""
|
||||
entity = Entity(
|
||||
name=data.name,
|
||||
short_name=data.short_name,
|
||||
industry=data.industry,
|
||||
status=data.status or "active",
|
||||
)
|
||||
db.add(entity)
|
||||
db.commit()
|
||||
db.refresh(entity)
|
||||
return {
|
||||
"id": entity.id,
|
||||
"name": entity.name,
|
||||
"short_name": entity.short_name,
|
||||
"industry": entity.industry,
|
||||
"message": "企业创建成功",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{entity_id}")
|
||||
def update_entity(entity_id: int, data: EntityUpdate, db: Session = Depends(get_db)):
|
||||
"""更新企业信息"""
|
||||
entity = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not entity:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(status_code=404, content={"detail": "企业不存在"})
|
||||
|
||||
if data.name is not None:
|
||||
entity.name = data.name
|
||||
if data.short_name is not None:
|
||||
entity.short_name = data.short_name
|
||||
if data.industry is not None:
|
||||
entity.industry = data.industry
|
||||
if data.status is not None:
|
||||
entity.status = data.status
|
||||
|
||||
db.commit()
|
||||
db.refresh(entity)
|
||||
return {
|
||||
"id": entity.id,
|
||||
"name": entity.name,
|
||||
"short_name": entity.short_name,
|
||||
"industry": entity.industry,
|
||||
"message": "企业更新成功",
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
"""费用审核智能体 API — 管理会计OS
|
||||
费用规则配置 + 报销单自动校验 + 人工审批流程 + 审核看板统计
|
||||
|
||||
流程:
|
||||
1. 提交报销单 → 自动校验费用规则
|
||||
2. 超限 → 自动打回(returned, 标注原因)
|
||||
3. 合规 → 待人工审批(pending)
|
||||
4. 审批通过(approved) / 拒绝(rejected)
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import ExpenseRule, ExpenseReimbursement, OperationLog
|
||||
|
||||
router = APIRouter(prefix="/api/cma/expenses", tags=["费用审核"])
|
||||
|
||||
# 费用类型中文映射
|
||||
EXPENSE_TYPE_LABELS = {
|
||||
"entertainment": "招待费",
|
||||
"travel": "差旅费",
|
||||
"office": "办公费",
|
||||
"management": "管理费",
|
||||
}
|
||||
|
||||
# 预置规则
|
||||
PRESET_RULES = [
|
||||
{"rule_name": "招待费单笔限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "single", "limit_amount": 2000.0,
|
||||
"cycle": "single", "remark": "招待费标准:单笔≤2000元"},
|
||||
{"rule_name": "招待费部门月限额", "dimension": "department", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 50000.0,
|
||||
"cycle": "monthly", "remark": "招待费标准:部门月限额5万元"},
|
||||
{"rule_name": "招待费月度总额限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 150000.0,
|
||||
"cycle": "monthly", "remark": "公司招待费预算15万/月"},
|
||||
{"rule_name": "差旅住宿单晚限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "travel", "limit_type": "single", "limit_amount": 300.0,
|
||||
"cycle": "single", "remark": "差旅费标准:住宿≤300元/晚"},
|
||||
{"rule_name": "差旅交通等级限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "travel", "limit_type": "single", "limit_amount": 1500.0,
|
||||
"cycle": "single", "remark": "差旅费标准:交通等级(高铁二等座/经济舱)"},
|
||||
{"rule_name": "办公费单笔限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "office", "limit_type": "single", "limit_amount": 500.0,
|
||||
"cycle": "single", "remark": "办公费标准:单笔≤500元"},
|
||||
{"rule_name": "管理费月度总额限额", "dimension": "expense_type", "dimension_value": "",
|
||||
"expense_type": "management", "limit_type": "monthly", "limit_amount": 910000.0,
|
||||
"cycle": "monthly", "remark": "管理费预算91万/月"},
|
||||
]
|
||||
|
||||
|
||||
def _rule_to_dict(r: ExpenseRule) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"rule_name": r.rule_name,
|
||||
"dimension": r.dimension,
|
||||
"dimension_value": r.dimension_value,
|
||||
"expense_type": r.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
|
||||
"limit_type": r.limit_type,
|
||||
"limit_amount": r.limit_amount,
|
||||
"cycle": r.cycle,
|
||||
"status": r.status,
|
||||
"remark": r.remark,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _reimb_to_dict(r: ExpenseReimbursement) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"reimb_no": r.reimb_no,
|
||||
"applicant": r.applicant,
|
||||
"department": r.department,
|
||||
"expense_type": r.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
|
||||
"title": r.title,
|
||||
"amount": r.amount,
|
||||
"expense_date": r.expense_date.isoformat() if r.expense_date else None,
|
||||
"attachment": r.attachment,
|
||||
"status": r.status,
|
||||
"check_result": r.check_result,
|
||||
"check_reason": r.check_reason,
|
||||
"check_detail": r.check_detail,
|
||||
"checked_at": r.checked_at.isoformat() if r.checked_at else None,
|
||||
"approver": r.approver,
|
||||
"approve_comment": r.approve_comment,
|
||||
"approved_at": r.approved_at.isoformat() if r.approved_at else None,
|
||||
"created_by": r.created_by,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _gen_reimb_no(db: Session) -> str:
|
||||
"""生成报销单号: BX + 年月日 + 序号"""
|
||||
today = datetime.now()
|
||||
prefix = "BX" + today.strftime("%Y%m%d")
|
||||
last = (
|
||||
db.query(ExpenseReimbursement)
|
||||
.filter(ExpenseReimbursement.reimb_no.like(prefix + "%"))
|
||||
.order_by(ExpenseReimbursement.id.desc())
|
||||
.first()
|
||||
)
|
||||
seq = (int(last.reimb_no[-4:]) + 1) if last and last.reimb_no[-4:].isdigit() else 1
|
||||
return f"{prefix}{seq:04d}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 费用规则 CRUD
|
||||
# ============================================================
|
||||
|
||||
@router.get("/rules")
|
||||
def list_rules(
|
||||
expense_type: str = Query(None),
|
||||
status: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询费用规则列表"""
|
||||
q = db.query(ExpenseRule)
|
||||
if expense_type:
|
||||
q = q.filter(ExpenseRule.expense_type == expense_type)
|
||||
if status:
|
||||
q = q.filter(ExpenseRule.status == status)
|
||||
rules = q.order_by(ExpenseRule.id.asc()).all()
|
||||
return {"data": [_rule_to_dict(r) for r in rules], "total": len(rules)}
|
||||
|
||||
|
||||
@router.post("/rules")
|
||||
def create_rule(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""新建费用规则"""
|
||||
rule_name = data.get("rule_name")
|
||||
expense_type = data.get("expense_type")
|
||||
limit_type = data.get("limit_type", "single")
|
||||
limit_amount = data.get("limit_amount")
|
||||
if not rule_name or not expense_type or limit_amount is None:
|
||||
raise HTTPException(400, "缺少必要参数: rule_name, expense_type, limit_amount")
|
||||
if expense_type not in EXPENSE_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效费用类型: {expense_type}")
|
||||
if limit_type not in ("single", "monthly", "yearly"):
|
||||
raise HTTPException(400, f"无效限额类型: {limit_type}")
|
||||
|
||||
rule = ExpenseRule(
|
||||
rule_name=rule_name,
|
||||
dimension=data.get("dimension", "expense_type"),
|
||||
dimension_value=data.get("dimension_value", "") or None,
|
||||
expense_type=expense_type,
|
||||
limit_type=limit_type,
|
||||
limit_amount=float(limit_amount),
|
||||
cycle=data.get("cycle", limit_type),
|
||||
status=data.get("status", "active"),
|
||||
remark=data.get("remark", ""),
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"message": "规则已创建", "id": rule.id}
|
||||
|
||||
|
||||
@router.put("/rules/{rule_id}")
|
||||
def update_rule(
|
||||
rule_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""更新费用规则"""
|
||||
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
if "rule_name" in data and data["rule_name"]:
|
||||
rule.rule_name = data["rule_name"]
|
||||
if "expense_type" in data:
|
||||
if data["expense_type"] not in EXPENSE_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效费用类型: {data['expense_type']}")
|
||||
rule.expense_type = data["expense_type"]
|
||||
if "dimension" in data:
|
||||
rule.dimension = data["dimension"]
|
||||
if "dimension_value" in data:
|
||||
rule.dimension_value = data["dimension_value"] or None
|
||||
if "limit_type" in data:
|
||||
rule.limit_type = data["limit_type"]
|
||||
if "limit_amount" in data and data["limit_amount"] is not None:
|
||||
rule.limit_amount = float(data["limit_amount"])
|
||||
if "status" in data:
|
||||
rule.status = data["status"]
|
||||
if "remark" in data:
|
||||
rule.remark = data["remark"]
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return {"message": "规则已更新", "id": rule.id}
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
def delete_rule(
|
||||
rule_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""删除费用规则"""
|
||||
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
|
||||
if not rule:
|
||||
raise HTTPException(404, "规则不存在")
|
||||
db.delete(rule)
|
||||
db.commit()
|
||||
return {"message": "规则已删除", "id": rule_id}
|
||||
|
||||
|
||||
@router.post("/rules/seed")
|
||||
def seed_rules(
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""预置默认费用规则(幂等,按规则名去重)"""
|
||||
created = 0
|
||||
for p in PRESET_RULES:
|
||||
exists = db.query(ExpenseRule).filter(ExpenseRule.rule_name == p["rule_name"]).first()
|
||||
if exists:
|
||||
continue
|
||||
db.add(ExpenseRule(**p))
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"message": f"预置完成,新增 {created} 条规则"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报销单自动校验
|
||||
# ============================================================
|
||||
|
||||
def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list = None):
|
||||
"""按启用规则校验报销单,写入 check_result / check_detail / check_reason
|
||||
|
||||
规则匹配:
|
||||
- dimension=expense_type: 全局费用类型规则(dimension_value 可再限定具体类型)
|
||||
- dimension=department: 部门维度规则(dimension_value 空=所有部门)
|
||||
- dimension=person: 人员维度规则(dimension_value 空=所有人)
|
||||
返回 (passed: bool, checks: list)
|
||||
"""
|
||||
if rules is None:
|
||||
rules = db.query(ExpenseRule).filter(ExpenseRule.status == "active").all()
|
||||
|
||||
period = (reimb.expense_date or datetime.now()).strftime("%Y-%m")
|
||||
checks = []
|
||||
failed_reasons = []
|
||||
|
||||
for rule in rules:
|
||||
# 费用类型必须匹配
|
||||
if rule.expense_type and rule.expense_type != reimb.expense_type:
|
||||
continue
|
||||
# 维度匹配
|
||||
if rule.dimension == "department":
|
||||
if not reimb.department:
|
||||
continue
|
||||
if rule.dimension_value and rule.dimension_value != reimb.department:
|
||||
continue
|
||||
elif rule.dimension == "person":
|
||||
if rule.dimension_value and rule.dimension_value != reimb.applicant:
|
||||
continue
|
||||
elif rule.dimension == "expense_type":
|
||||
if rule.dimension_value and rule.dimension_value != reimb.expense_type:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
actual = reimb.amount
|
||||
limit_desc = f"{rule.limit_amount:,.0f}元"
|
||||
|
||||
if rule.limit_type == "single":
|
||||
passed = reimb.amount <= rule.limit_amount
|
||||
checks.append({
|
||||
"rule_id": rule.id,
|
||||
"rule_name": rule.rule_name,
|
||||
"rule_type": "单笔限额",
|
||||
"limit": rule.limit_amount,
|
||||
"actual": reimb.amount,
|
||||
"passed": passed,
|
||||
"detail": f"单笔 {reimb.amount:,.2f}元 vs 限额 {limit_desc}",
|
||||
})
|
||||
else:
|
||||
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
|
||||
# 账套隔离 2026-09-21:累计口径限本账套(他账套在途不得污染本账套额度判定)
|
||||
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
|
||||
ExpenseReimbursement.entity_id == reimb.entity_id,
|
||||
ExpenseReimbursement.expense_type == reimb.expense_type,
|
||||
ExpenseReimbursement.id != reimb.id,
|
||||
ExpenseReimbursement.status.in_(["pending", "approved"]),
|
||||
)
|
||||
if rule.limit_type == "monthly":
|
||||
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period)
|
||||
period_label = f"{period}月"
|
||||
else: # yearly
|
||||
year = period[:4]
|
||||
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y") == year)
|
||||
period_label = f"{year}年"
|
||||
if rule.dimension == "department" and reimb.department:
|
||||
q = q.filter(ExpenseReimbursement.department == reimb.department)
|
||||
elif rule.dimension == "person":
|
||||
q = q.filter(ExpenseReimbursement.applicant == reimb.applicant)
|
||||
|
||||
used = q.scalar() or 0.0
|
||||
actual = used + reimb.amount
|
||||
passed = actual <= rule.limit_amount
|
||||
checks.append({
|
||||
"rule_id": rule.id,
|
||||
"rule_name": rule.rule_name,
|
||||
"rule_type": "月度累计" if rule.limit_type == "monthly" else "年度累计",
|
||||
"limit": rule.limit_amount,
|
||||
"actual": actual,
|
||||
"used": used,
|
||||
"passed": passed,
|
||||
"detail": f"{period_label}累计 {used:,.2f} + 本次 {reimb.amount:,.2f} = {actual:,.2f}元 vs 限额 {limit_desc}",
|
||||
})
|
||||
|
||||
if not passed:
|
||||
failed_reasons.append(f"{rule.rule_name}: {checks[-1]['detail']},超限")
|
||||
|
||||
passed_all = len(failed_reasons) == 0
|
||||
reimb.check_result = "pass" if passed_all else "fail"
|
||||
reimb.check_detail = checks
|
||||
reimb.check_reason = ";".join(failed_reasons) if failed_reasons else None
|
||||
reimb.checked_at = datetime.now()
|
||||
# 超限自动打回,合规进入待人工审批
|
||||
reimb.status = "pending" if passed_all else "returned"
|
||||
return passed_all, checks
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 报销单提交 / 查询 / 审批
|
||||
# ============================================================
|
||||
|
||||
@router.post("/reimbursements")
|
||||
def submit_reimbursement(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""提交报销单 → 自动校验规则(超限自动打回)"""
|
||||
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
|
||||
expense_type = data.get("expense_type")
|
||||
title = data.get("title")
|
||||
amount = data.get("amount")
|
||||
|
||||
if not expense_type or not title or amount is None:
|
||||
raise HTTPException(400, "缺少必要参数: expense_type, title, amount")
|
||||
if expense_type not in EXPENSE_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效费用类型: {expense_type}")
|
||||
amount = float(amount)
|
||||
if amount <= 0:
|
||||
raise HTTPException(400, "报销金额必须大于0")
|
||||
|
||||
expense_date = None
|
||||
if data.get("expense_date"):
|
||||
try:
|
||||
expense_date = datetime.strptime(str(data["expense_date"])[:10], "%Y-%m-%d")
|
||||
except Exception:
|
||||
expense_date = None
|
||||
|
||||
reimb = ExpenseReimbursement(
|
||||
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
|
||||
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
|
||||
applicant=applicant,
|
||||
department=data.get("department", ""),
|
||||
expense_type=expense_type,
|
||||
title=title,
|
||||
amount=amount,
|
||||
expense_date=expense_date,
|
||||
attachment=data.get("attachment", ""),
|
||||
status="pending",
|
||||
check_result="pass",
|
||||
created_by=current_user.username if hasattr(current_user, "username") else applicant,
|
||||
)
|
||||
db.add(reimb)
|
||||
db.flush() # 先拿到 id 再生成正式单号
|
||||
reimb.reimb_no = _gen_reimb_no(db)
|
||||
db.flush()
|
||||
|
||||
passed, checks = validate_reimbursement(db, reimb)
|
||||
db.commit()
|
||||
db.refresh(reimb)
|
||||
|
||||
result = _reimb_to_dict(reimb)
|
||||
result["auto_check_passed"] = passed
|
||||
return {"message": "报销单已提交" if passed else "报销单超限,已自动打回", "data": result}
|
||||
|
||||
|
||||
@router.get("/reimbursements")
|
||||
def list_reimbursements(
|
||||
status: str = Query(None),
|
||||
expense_type: str = Query(None),
|
||||
applicant: str = Query(None),
|
||||
keyword: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)(账套隔离 2026-09-21:列表按会话账套)"""
|
||||
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.entity_id == entity_id)
|
||||
|
||||
if status:
|
||||
q = q.filter(ExpenseReimbursement.status == status)
|
||||
if expense_type:
|
||||
q = q.filter(ExpenseReimbursement.expense_type == expense_type)
|
||||
if applicant:
|
||||
q = q.filter(ExpenseReimbursement.applicant.like(f"%{applicant}%"))
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
(ExpenseReimbursement.title.like(like))
|
||||
| (ExpenseReimbursement.reimb_no.like(like))
|
||||
| (ExpenseReimbursement.applicant.like(like))
|
||||
)
|
||||
items = q.order_by(ExpenseReimbursement.id.desc()).limit(200).all()
|
||||
return {"data": [_reimb_to_dict(r) for r in items], "total": len(items)}
|
||||
|
||||
|
||||
@router.get("/reimbursements/{reimb_id}")
|
||||
def get_reimbursement(
|
||||
reimb_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""报销单详情(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
r = db.query(ExpenseReimbursement).filter(
|
||||
ExpenseReimbursement.id == reimb_id,
|
||||
ExpenseReimbursement.entity_id == entity_id,
|
||||
).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
return _reimb_to_dict(r)
|
||||
|
||||
|
||||
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str,
|
||||
entity_id: int = None):
|
||||
"""执行审批动作: approve/reject/return(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id)
|
||||
if entity_id is not None:
|
||||
q = q.filter(ExpenseReimbursement.entity_id == entity_id)
|
||||
r = q.first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
|
||||
if r.status not in ("pending", "returned"):
|
||||
raise HTTPException(400, f"当前状态({r.status})不可审批")
|
||||
|
||||
if action == "approve":
|
||||
r.status = "approved"
|
||||
elif action == "reject":
|
||||
r.status = "rejected"
|
||||
else:
|
||||
r.status = "returned"
|
||||
r.approver = approver
|
||||
r.approve_comment = comment or ("" if action == "approve" else "人工打回")
|
||||
r.approved_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/approve")
|
||||
def approve_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""审批通过(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver, entity_id)
|
||||
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/reject")
|
||||
def reject_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""审批拒绝(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
comment = (data or {}).get("comment", "")
|
||||
if not comment:
|
||||
raise HTTPException(400, "拒绝时必须填写审批意见")
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "reject", comment, approver, entity_id)
|
||||
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/return")
|
||||
def return_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_role("ceo", "finance")),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""人工打回(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
approver = current_user.name if hasattr(current_user, "name") else current_user.username
|
||||
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver, entity_id)
|
||||
return {"message": "已打回", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
@router.post("/reimbursements/{reimb_id}/resubmit")
|
||||
def resubmit_reimbursement(
|
||||
reimb_id: int,
|
||||
data: dict = None,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""被驳回/打回后重新提交 → 重新自动校验(账套隔离 2026-09-21:跨账套 → 404)"""
|
||||
r = db.query(ExpenseReimbursement).filter(
|
||||
ExpenseReimbursement.id == reimb_id,
|
||||
ExpenseReimbursement.entity_id == entity_id,
|
||||
).first()
|
||||
if not r:
|
||||
raise HTTPException(404, "报销单不存在")
|
||||
if r.status not in ("returned", "rejected"):
|
||||
raise HTTPException(400, f"当前状态({r.status})不可重新提交")
|
||||
|
||||
# 允许修改金额/事由后重新校验
|
||||
if data:
|
||||
if data.get("amount") is not None:
|
||||
r.amount = float(data["amount"])
|
||||
if data.get("title"):
|
||||
r.title = data["title"]
|
||||
if data.get("attachment") is not None:
|
||||
r.attachment = data["attachment"]
|
||||
|
||||
passed, _ = validate_reimbursement(db, r)
|
||||
r.approver = None
|
||||
r.approve_comment = None
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return {"message": "已重新提交" if passed else "仍超限,已再次打回", "data": _reimb_to_dict(r)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 审核看板统计
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats")
|
||||
def expense_stats(
|
||||
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
|
||||
now = datetime.now()
|
||||
period = period or now.strftime("%Y-%m")
|
||||
|
||||
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
|
||||
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).filter(
|
||||
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
).group_by(
|
||||
ExpenseReimbursement.status
|
||||
).all():
|
||||
if st in counts:
|
||||
counts[st] = cnt
|
||||
counts["total"] += cnt
|
||||
|
||||
# 本月(按费用发生日期)金额统计 by 费用类型 — 统计已提交(待审+已通过)
|
||||
month_rows = (
|
||||
db.query(
|
||||
ExpenseReimbursement.expense_type,
|
||||
func.coalesce(func.sum(ExpenseReimbursement.amount), 0),
|
||||
func.count(ExpenseReimbursement.id),
|
||||
)
|
||||
.filter(
|
||||
ExpenseReimbursement.status.in_(["pending", "approved"]),
|
||||
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
|
||||
)
|
||||
.group_by(ExpenseReimbursement.expense_type)
|
||||
.all()
|
||||
)
|
||||
amounts_by_type = [
|
||||
{
|
||||
"expense_type": et,
|
||||
"label": EXPENSE_TYPE_LABELS.get(et, et),
|
||||
"amount": round(float(amt), 2),
|
||||
"count": cnt,
|
||||
}
|
||||
for et, amt, cnt in month_rows
|
||||
]
|
||||
|
||||
monthly_total = round(sum(x["amount"] for x in amounts_by_type), 2)
|
||||
|
||||
# 超限预警列表 — 自动打回(returned + check_result=fail)
|
||||
over_limit_rows = (
|
||||
db.query(ExpenseReimbursement)
|
||||
.filter(
|
||||
ExpenseReimbursement.status == "returned",
|
||||
ExpenseReimbursement.check_result == "fail",
|
||||
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
)
|
||||
.order_by(ExpenseReimbursement.id.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
over_limit = [
|
||||
{
|
||||
"id": r.id,
|
||||
"reimb_no": r.reimb_no,
|
||||
"applicant": r.applicant,
|
||||
"department": r.department,
|
||||
"expense_type": r.expense_type,
|
||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
|
||||
"title": r.title,
|
||||
"amount": r.amount,
|
||||
"check_reason": r.check_reason,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in over_limit_rows
|
||||
]
|
||||
|
||||
recent_rows = db.query(ExpenseReimbursement).filter(
|
||||
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
|
||||
).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
|
||||
recent = [_reimb_to_dict(r) for r in recent_rows]
|
||||
|
||||
# 本月预算使用率(招待费15万/月 管理费91万/月)
|
||||
budget_usage = []
|
||||
for et, budget in (("entertainment", 150000.0), ("management", 910000.0)):
|
||||
used = next((x["amount"] for x in amounts_by_type if x["expense_type"] == et), 0.0)
|
||||
budget_usage.append({
|
||||
"expense_type": et,
|
||||
"label": EXPENSE_TYPE_LABELS.get(et, et),
|
||||
"budget": budget,
|
||||
"used": used,
|
||||
"usage_rate": round(used / budget * 100, 1) if budget else 0,
|
||||
})
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"counts": counts,
|
||||
"amounts_by_type": amounts_by_type,
|
||||
"monthly_total": monthly_total,
|
||||
"over_limit": over_limit,
|
||||
"over_limit_count": len(over_limit),
|
||||
"recent": recent,
|
||||
"budget_usage": budget_usage,
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
"""外部输入台账录入/查询 API(能力评价 M2 准入3 派生,2026-09-15)
|
||||
|
||||
三张台账的单向录入接口(新增 / CSV 批量导入 / 按实体与期次查询)+ 台账→kpi_values 派生触发。
|
||||
|
||||
设计口径:
|
||||
- **单向**:只提供「新增 + 批量导入 + 查询 + 派生」,不提供修改/删除接口
|
||||
(历史登记行不可改;错登由人工在业务侧作废后重新登记一行,保持审计链)
|
||||
- **on_time 由服务端派生**:请求里带 on_time 一律忽略并在响应回报 on_time_ignored
|
||||
- **人事台账等式校验**:期末 ≠ 期初 + 入职 − 离职 → 拒绝写入并回报(HTTP 400 / 批量逐行 errors)
|
||||
- **多租户**:所有端点经 app.deps.assert_entity_scope 收敛账套(显式 entity_id 与会话账套不一致 → 403)
|
||||
- **鉴权**:router 级 require_auth(未认证 401);写端点额外 require_role(ceo/finance/it)(角色不足 403)
|
||||
- 派生必须**幂等**(同 (entity_id, kpi_id, period) 重复运行只更新不新增),分母为 0 → 不写值
|
||||
|
||||
CSV 列头(UTF-8,可带 BOM;列序无关,多余列忽略):
|
||||
申报台账 period,tax_type,legal_deadline,filed_date,evidence_no,registered_by,remark
|
||||
人事台账 period,headcount_begin,hires,separations,headcount_end,registered_by,remark
|
||||
证照台账 licence_name,licence_no,valid_from,valid_to,annual_check_status,issuer,owner_role,attachment_no,registered_by,remark
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.database import get_db
|
||||
from app.deps import assert_entity_scope
|
||||
from app.models import HrMonthlyLedger, KPIDefinition, LicenceLedger, TaxFilingLog
|
||||
from app.models.ext_input_ledger import ANNUAL_CHECK_STATUSES, MONTH_PERIOD_RE, QUARTER_PERIOD_RE
|
||||
from app.services.ext_input_derive import (
|
||||
ALL_KPI_CODES,
|
||||
LICENCE_OK_CODE,
|
||||
check_headcount_equation,
|
||||
compute_employee_turnover,
|
||||
derive_on_time,
|
||||
explain_licence_ok,
|
||||
explain_tax_timely_rate,
|
||||
is_licence_valid,
|
||||
month_to_quarter,
|
||||
quarter_end_date,
|
||||
run_derive,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cma.ext_input_api")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/ext-ledger",
|
||||
tags=["外部输入台账"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
# 写权限(录入/导入/派生):ceo / finance / it(与 value_sources 等既有写端点同口径)
|
||||
write_guard = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
SUGGESTED_TAX_TYPES = ("增值税", "附加税", "企业所得税", "印花税", "个人所得税", "房产税", "土地使用税")
|
||||
|
||||
# CSV 全量列(文档口径)+ 必需列(列缺失才拒绝;可选列缺省即留空)
|
||||
TAX_HEADERS = ("period", "tax_type", "legal_deadline", "filed_date", "evidence_no", "registered_by", "remark")
|
||||
HR_HEADERS = ("period", "headcount_begin", "hires", "separations", "headcount_end", "registered_by", "remark")
|
||||
LICENCE_HEADERS = ("licence_name", "licence_no", "valid_from", "valid_to", "annual_check_status",
|
||||
"issuer", "owner_role", "attachment_no", "registered_by", "remark")
|
||||
TAX_REQUIRED = ("period", "tax_type")
|
||||
HR_REQUIRED = ("period", "headcount_begin", "hires", "separations", "headcount_end")
|
||||
LICENCE_REQUIRED = ("licence_name", "licence_no", "annual_check_status")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公共工具:账套收敛 / 字段校验
|
||||
# ============================================================
|
||||
|
||||
def _resolve_entity(request: Request, db: Session, explicit: Optional[int]) -> int:
|
||||
"""账套收敛:会话 token 绑定实体优先;显式 entity_id 与会话不一致 → 403(文案同 deps.get_entity_id)"""
|
||||
session_entity = assert_entity_scope(request, explicit, db)
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
if session_entity is not None:
|
||||
return session_entity
|
||||
return 1
|
||||
|
||||
|
||||
def _body_entity(payload: dict, query_entity: Optional[int]) -> Optional[int]:
|
||||
raw = payload.get("entity_id") if isinstance(payload, dict) else None
|
||||
if raw in (None, ""):
|
||||
return query_entity
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, f"entity_id 非法: {raw}")
|
||||
|
||||
|
||||
def _req_str(value, name: str, max_len: int) -> str:
|
||||
text = "" if value is None else str(value).strip()
|
||||
if not text:
|
||||
raise HTTPException(400, f"缺少必填字段: {name}")
|
||||
if len(text) > max_len:
|
||||
raise HTTPException(400, f"字段 {name} 超长({len(text)} > {max_len})")
|
||||
return text
|
||||
|
||||
|
||||
def _opt_str(value, max_len: int) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
return text[:max_len]
|
||||
|
||||
|
||||
def _parse_date(value, name: str) -> Optional[date]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
text = str(value).strip().replace("/", "-")
|
||||
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y%m%d"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise HTTPException(400, f"字段 {name} 日期格式非法: {value}(应为 YYYY-MM-DD)")
|
||||
|
||||
|
||||
def _parse_int(value, name: str, required: bool = True) -> Optional[int]:
|
||||
if value in (None, ""):
|
||||
if required:
|
||||
raise HTTPException(400, f"缺少必填字段: {name}")
|
||||
return None
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, f"字段 {name} 必须为整数: {value}")
|
||||
|
||||
|
||||
def _month_period(value, name: str = "period") -> str:
|
||||
text = _req_str(value, name, 20)
|
||||
if not re.match(MONTH_PERIOD_RE, text):
|
||||
raise HTTPException(400, f"字段 {name} 期次格式非法: {text}(应为 YYYY-MM)")
|
||||
return text
|
||||
|
||||
|
||||
def _target_period(kpi_code: str, period: str) -> str:
|
||||
"""指标实际落库期间:P_LICENCE_OK 为季频(传月期间时归到所在季度)"""
|
||||
if kpi_code == LICENCE_OK_CODE and re.match(MONTH_PERIOD_RE, period):
|
||||
return month_to_quarter(period)
|
||||
return period
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 字段选项(前端下拉动态加载用;禁前端硬编码)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/options")
|
||||
def get_options():
|
||||
"""录入表单选项:年检状态枚举 + 建议税种 + 期次格式"""
|
||||
return {
|
||||
"annual_check_status": list(ANNUAL_CHECK_STATUSES),
|
||||
"tax_type_suggested": list(SUGGESTED_TAX_TYPES),
|
||||
"period_format": {"monthly": "YYYY-MM 例:2026-08", "quarterly": "YYYY-Qn 例:2026-Q2"},
|
||||
"derivable_kpi_codes": list(ALL_KPI_CODES),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 1. 申报行为台账 tax_filing_log
|
||||
# ============================================================
|
||||
|
||||
def _tax_values(payload: dict) -> dict:
|
||||
period = _month_period(payload.get("period"))
|
||||
tax_type = _req_str(payload.get("tax_type"), "tax_type", 50)
|
||||
legal_deadline = _parse_date(payload.get("legal_deadline"), "legal_deadline")
|
||||
filed_date = _parse_date(payload.get("filed_date"), "filed_date")
|
||||
return {
|
||||
"period": period,
|
||||
"tax_type": tax_type,
|
||||
"legal_deadline": legal_deadline,
|
||||
"filed_date": filed_date,
|
||||
# on_time 唯一来源 = 服务端派生(请求里的同名字段一律忽略)
|
||||
"on_time": derive_on_time(filed_date, legal_deadline),
|
||||
"evidence_no": _opt_str(payload.get("evidence_no"), 100),
|
||||
"registered_by": _opt_str(payload.get("registered_by"), 100),
|
||||
"remark": _opt_str(payload.get("remark"), 500),
|
||||
}
|
||||
|
||||
|
||||
def _tax_json(row: TaxFilingLog) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"entity_id": row.entity_id,
|
||||
"period": row.period,
|
||||
"tax_type": row.tax_type,
|
||||
"legal_deadline": row.legal_deadline.isoformat() if row.legal_deadline else None,
|
||||
"filed_date": row.filed_date.isoformat() if row.filed_date else None,
|
||||
"on_time": row.on_time,
|
||||
"evidence_no": row.evidence_no,
|
||||
"registered_by": row.registered_by,
|
||||
"remark": row.remark,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/tax-filings")
|
||||
def list_tax_filings(
|
||||
request: Request,
|
||||
period: Optional[str] = Query(None, description="属期 YYYY-MM"),
|
||||
tax_type: Optional[str] = Query(None),
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""按 (entity_id, period) 查询申报台账 + 及时率口径摘要"""
|
||||
eid = _resolve_entity(request, db, entity_id)
|
||||
q = db.query(TaxFilingLog).filter(TaxFilingLog.entity_id == eid)
|
||||
if period:
|
||||
q = q.filter(TaxFilingLog.period == period)
|
||||
if tax_type:
|
||||
q = q.filter(TaxFilingLog.tax_type == tax_type)
|
||||
rows = q.order_by(TaxFilingLog.period.desc(), TaxFilingLog.id.asc()).all()
|
||||
summary = explain_tax_timely_rate([r for r in rows if not period or r.period == period])
|
||||
return {"entity_id": eid, "data": [_tax_json(r) for r in rows], "total": len(rows),
|
||||
"summary": {"period": period, "tax_timely_rate": summary["value"],
|
||||
"denominator": summary["denominator"], "timely": summary["timely"],
|
||||
"no_definition": summary["no_definition"]}}
|
||||
|
||||
|
||||
@router.post("/tax-filings", status_code=201, dependencies=[write_guard])
|
||||
def create_tax_filing(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""新增一条申报行为台账(on_time 服务端派生;唯一键冲突 → 409)"""
|
||||
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
|
||||
vals = _tax_values(payload)
|
||||
exists = db.query(TaxFilingLog).filter(
|
||||
TaxFilingLog.entity_id == eid, TaxFilingLog.period == vals["period"],
|
||||
TaxFilingLog.tax_type == vals["tax_type"]).first()
|
||||
if exists:
|
||||
raise HTTPException(409, f"已存在同 (entity_id={eid}, period={vals['period']}, "
|
||||
f"tax_type={vals['tax_type']}) 的登记行 id={exists.id}")
|
||||
row = TaxFilingLog(entity_id=eid, **vals)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"data": _tax_json(row),
|
||||
"on_time_derived": row.on_time,
|
||||
"on_time_ignored": payload.get("on_time") not in (None, "")}
|
||||
|
||||
|
||||
@router.post("/tax-filings/import", dependencies=[write_guard])
|
||||
def import_tax_filings(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""CSV 批量导入申报台账。on_conflict: skip(默认)/update;dry_run=true 只校验不落库"""
|
||||
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
|
||||
on_conflict = str(payload.get("on_conflict") or "skip").lower()
|
||||
if on_conflict not in ("skip", "update"):
|
||||
raise HTTPException(400, f"on_conflict 非法: {on_conflict}(skip/update)")
|
||||
dry_run = bool(payload.get("dry_run"))
|
||||
records, errors = _read_csv(payload.get("csv"), TAX_REQUIRED, _tax_values)
|
||||
result = _apply_import(db, TaxFilingLog, eid, records, errors, on_conflict, dry_run,
|
||||
key_fields=("period", "tax_type"))
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. 人事月度台账 hr_monthly_ledger
|
||||
# ============================================================
|
||||
|
||||
def _hr_values(payload: dict) -> dict:
|
||||
period = _month_period(payload.get("period"))
|
||||
begin = _parse_int(payload.get("headcount_begin"), "headcount_begin")
|
||||
hires = _parse_int(payload.get("hires"), "hires")
|
||||
separations = _parse_int(payload.get("separations"), "separations")
|
||||
end = _parse_int(payload.get("headcount_end"), "headcount_end")
|
||||
err = check_headcount_equation(begin, hires, separations, end)
|
||||
if err:
|
||||
raise HTTPException(400, err)
|
||||
return {
|
||||
"period": period,
|
||||
"headcount_begin": begin,
|
||||
"hires": hires,
|
||||
"separations": separations,
|
||||
"headcount_end": end,
|
||||
"registered_by": _opt_str(payload.get("registered_by"), 100),
|
||||
"remark": _opt_str(payload.get("remark"), 500),
|
||||
}
|
||||
|
||||
|
||||
def _hr_json(row: HrMonthlyLedger) -> dict:
|
||||
return {
|
||||
"id": row.id, "entity_id": row.entity_id, "period": row.period,
|
||||
"headcount_begin": row.headcount_begin, "hires": row.hires,
|
||||
"separations": row.separations, "headcount_end": row.headcount_end,
|
||||
"turnover_rate": compute_employee_turnover(row),
|
||||
"registered_by": row.registered_by, "remark": row.remark,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hr-monthly")
|
||||
def list_hr_monthly(
|
||||
request: Request,
|
||||
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""按 (entity_id, period) 查询人事月度台账(含流失率派生值)"""
|
||||
eid = _resolve_entity(request, db, entity_id)
|
||||
q = db.query(HrMonthlyLedger).filter(HrMonthlyLedger.entity_id == eid)
|
||||
if period:
|
||||
q = q.filter(HrMonthlyLedger.period == period)
|
||||
rows = q.order_by(HrMonthlyLedger.period.desc(), HrMonthlyLedger.id.asc()).all()
|
||||
return {"entity_id": eid, "data": [_hr_json(r) for r in rows], "total": len(rows)}
|
||||
|
||||
|
||||
@router.post("/hr-monthly", status_code=201, dependencies=[write_guard])
|
||||
def create_hr_monthly(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""新增人事月度台账(等式校验不通过 → 400 拒绝写入)"""
|
||||
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
|
||||
vals = _hr_values(payload)
|
||||
exists = db.query(HrMonthlyLedger).filter(
|
||||
HrMonthlyLedger.entity_id == eid, HrMonthlyLedger.period == vals["period"]).first()
|
||||
if exists:
|
||||
raise HTTPException(409, f"已存在同 (entity_id={eid}, period={vals['period']}) 的登记行 id={exists.id}")
|
||||
row = HrMonthlyLedger(entity_id=eid, **vals)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"data": _hr_json(row)}
|
||||
|
||||
|
||||
@router.post("/hr-monthly/import", dependencies=[write_guard])
|
||||
def import_hr_monthly(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""CSV 批量导入人事月度台账(等式不成立的行被拒绝并在 errors 回报)"""
|
||||
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
|
||||
on_conflict = str(payload.get("on_conflict") or "skip").lower()
|
||||
if on_conflict not in ("skip", "update"):
|
||||
raise HTTPException(400, f"on_conflict 非法: {on_conflict}(skip/update)")
|
||||
dry_run = bool(payload.get("dry_run"))
|
||||
records, errors = _read_csv(payload.get("csv"), HR_REQUIRED, _hr_values)
|
||||
return _apply_import(db, HrMonthlyLedger, eid, records, errors, on_conflict, dry_run,
|
||||
key_fields=("period",))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 3. 证照台账 licence_ledger
|
||||
# ============================================================
|
||||
|
||||
def _licence_values(payload: dict) -> dict:
|
||||
name = _req_str(payload.get("licence_name"), "licence_name", 200)
|
||||
no = _req_str(payload.get("licence_no"), "licence_no", 100)
|
||||
status = _req_str(payload.get("annual_check_status"), "annual_check_status", 20)
|
||||
if status not in ANNUAL_CHECK_STATUSES:
|
||||
raise HTTPException(400, f"字段 annual_check_status 非法: {status}(合法值: {'/'.join(ANNUAL_CHECK_STATUSES)})")
|
||||
return {
|
||||
"licence_name": name,
|
||||
"licence_no": no,
|
||||
"valid_from": _parse_date(payload.get("valid_from"), "valid_from"),
|
||||
"valid_to": _parse_date(payload.get("valid_to"), "valid_to"),
|
||||
"annual_check_status": status,
|
||||
"issuer": _opt_str(payload.get("issuer"), 200),
|
||||
"owner_role": _opt_str(payload.get("owner_role"), 100),
|
||||
"attachment_no": _opt_str(payload.get("attachment_no"), 100),
|
||||
"registered_by": _opt_str(payload.get("registered_by"), 100),
|
||||
"remark": _opt_str(payload.get("remark"), 500),
|
||||
}
|
||||
|
||||
|
||||
def _licence_json(row: LicenceLedger, as_of: Optional[date] = None) -> dict:
|
||||
return {
|
||||
"id": row.id, "entity_id": row.entity_id, "licence_name": row.licence_name,
|
||||
"licence_no": row.licence_no,
|
||||
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
|
||||
"annual_check_status": row.annual_check_status, "issuer": row.issuer,
|
||||
"owner_role": row.owner_role, "attachment_no": row.attachment_no,
|
||||
"valid_as_of": is_licence_valid(row, as_of) if as_of else None,
|
||||
"registered_by": row.registered_by, "remark": row.remark,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/licences")
|
||||
def list_licences(
|
||||
request: Request,
|
||||
as_of: Optional[str] = Query(None, description="有效性基准日 YYYY-MM-DD(默认今天)"),
|
||||
licence_name: Optional[str] = Query(None),
|
||||
valid_only: bool = Query(False, description="仅返回基准日有效的证照"),
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""证照台账查询(有效/过期按基准日判定)"""
|
||||
eid = _resolve_entity(request, db, entity_id)
|
||||
base = _parse_date(as_of, "as_of") or date.today()
|
||||
q = db.query(LicenceLedger).filter(LicenceLedger.entity_id == eid)
|
||||
if licence_name:
|
||||
q = q.filter(LicenceLedger.licence_name == licence_name)
|
||||
rows = q.order_by(LicenceLedger.id.asc()).all()
|
||||
# 合规率分母 = 应持证照数(不受 valid_only 过滤影响)
|
||||
summary = explain_licence_ok(rows, base)
|
||||
shown = [r for r in rows if is_licence_valid(r, base)] if valid_only else rows
|
||||
return {"entity_id": eid, "as_of": base.isoformat(),
|
||||
"data": [_licence_json(r, base) for r in shown], "total": len(shown),
|
||||
"summary": {"licence_ok": summary["value"], "denominator": summary["denominator"],
|
||||
"valid": summary["valid"], "no_definition": summary["no_definition"]}}
|
||||
|
||||
|
||||
@router.post("/licences", status_code=201, dependencies=[write_guard])
|
||||
def create_licence(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""新增证照台账(状态白名单校验;唯一键冲突 → 409)"""
|
||||
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
|
||||
vals = _licence_values(payload)
|
||||
exists = db.query(LicenceLedger).filter(
|
||||
LicenceLedger.entity_id == eid, LicenceLedger.licence_name == vals["licence_name"],
|
||||
LicenceLedger.licence_no == vals["licence_no"]).first()
|
||||
if exists:
|
||||
raise HTTPException(409, f"已存在同 (entity_id={eid}, licence_name={vals['licence_name']}, "
|
||||
f"licence_no={vals['licence_no']}) 的登记行 id={exists.id}")
|
||||
row = LicenceLedger(entity_id=eid, **vals)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"data": _licence_json(row, date.today())}
|
||||
|
||||
|
||||
@router.post("/licences/import", dependencies=[write_guard])
|
||||
def import_licences(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""CSV 批量导入证照台账"""
|
||||
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
|
||||
on_conflict = str(payload.get("on_conflict") or "skip").lower()
|
||||
if on_conflict not in ("skip", "update"):
|
||||
raise HTTPException(400, f"on_conflict 非法: {on_conflict}(skip/update)")
|
||||
dry_run = bool(payload.get("dry_run"))
|
||||
records, errors = _read_csv(payload.get("csv"), LICENCE_REQUIRED, _licence_values)
|
||||
return _apply_import(db, LicenceLedger, eid, records, errors, on_conflict, dry_run,
|
||||
key_fields=("licence_name", "licence_no"))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 4. 台账 → kpi_values 派生
|
||||
# ============================================================
|
||||
|
||||
@router.post("/derive", dependencies=[write_guard])
|
||||
def derive_kpis(
|
||||
request: Request,
|
||||
payload: dict,
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""触发台账→kpi_values 派生(幂等)。period: 月频 YYYY-MM;P_LICENCE_OK 接受 YYYY-Qn。
|
||||
dry_run=true 只算不写。分母为 0 的指标 → 不写值,回报 skipped_denominator_zero。"""
|
||||
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
|
||||
period = str(payload.get("period") or "").strip()
|
||||
if not period:
|
||||
raise HTTPException(400, "缺少必填字段: period")
|
||||
if not (re.match(MONTH_PERIOD_RE, period) or re.match(QUARTER_PERIOD_RE, period)):
|
||||
raise HTTPException(400, f"period 非法: {period}(应为 YYYY-MM 或 YYYY-Qn)")
|
||||
kpi_codes = payload.get("kpi_codes") or payload.get("kpis") or None
|
||||
if kpi_codes is not None and not isinstance(kpi_codes, list):
|
||||
raise HTTPException(400, "kpi_codes 必须为数组")
|
||||
try:
|
||||
result = run_derive(db, eid, period, kpis=kpi_codes, write=not bool(payload.get("dry_run")))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/derive/status")
|
||||
def derive_status(
|
||||
request: Request,
|
||||
period: str = Query(..., description="月期间 YYYY-MM 或季期间 YYYY-Qn"),
|
||||
entity_id: Optional[int] = Query(None, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""只读:查看该期派生指标的定义/现有 kpi_values 行(不写入)"""
|
||||
eid = _resolve_entity(request, db, entity_id)
|
||||
if not (re.match(MONTH_PERIOD_RE, period) or re.match(QUARTER_PERIOD_RE, period)):
|
||||
raise HTTPException(400, f"period 非法: {period}(应为 YYYY-MM 或 YYYY-Qn)")
|
||||
out = {}
|
||||
from app.models import KPIValue
|
||||
for code in ALL_KPI_CODES:
|
||||
kpi = (db.query(KPIDefinition)
|
||||
.filter(KPIDefinition.entity_id == eid, KPIDefinition.kpi_code == code,
|
||||
KPIDefinition.status == "active")
|
||||
.order_by(KPIDefinition.id.asc()).first())
|
||||
item = {"kpi_defined": kpi is not None, "kpi_id": kpi.id if kpi else None,
|
||||
"target_period": _target_period(code, period)}
|
||||
if kpi is not None:
|
||||
row = (db.query(KPIValue)
|
||||
.filter(KPIValue.kpi_id == kpi.id, KPIValue.source_type == "derived",
|
||||
KPIValue.period == item["target_period"])
|
||||
.order_by(KPIValue.id.desc()).first())
|
||||
item["latest_derived"] = ({"period": row.period, "actual_value": row.actual_value,
|
||||
"data_status": row.data_status} if row else None)
|
||||
out[code] = item
|
||||
return {"entity_id": eid, "period": period, "items": out}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CSV 解析 / 批量落库(三张表共用;校验规则与单条新增完全同源)
|
||||
# ============================================================
|
||||
|
||||
def _read_csv(csv_text, required_headers, builder):
|
||||
"""解析 CSV:返回 (records, errors)。required_headers 为必需列(缺列即 400);其余列可选。"""
|
||||
if not csv_text or not str(csv_text).strip():
|
||||
raise HTTPException(400, "缺少必填字段: csv")
|
||||
text = str(csv_text).lstrip("\ufeff")
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
fieldnames = [(f or "").strip().lower() for f in (reader.fieldnames or [])]
|
||||
if not fieldnames:
|
||||
raise HTTPException(400, "CSV 缺少表头行")
|
||||
missing = [h for h in required_headers if h not in fieldnames]
|
||||
if missing:
|
||||
raise HTTPException(400, f"CSV 缺少必需列: {missing}(必需列: {list(required_headers)})")
|
||||
records, errors = [], []
|
||||
for offset, raw in enumerate(reader, start=2): # 表头占第 1 行
|
||||
row = {(k or "").strip().lower(): v for k, v in raw.items() if k}
|
||||
if not any(str(v or "").strip() for v in row.values()):
|
||||
continue # 跳过空行
|
||||
try:
|
||||
records.append({"line": offset, "values": builder(row)})
|
||||
except HTTPException as e:
|
||||
errors.append({"line": offset, "reason": e.detail})
|
||||
return records, errors
|
||||
|
||||
|
||||
def _apply_import(db: Session, model, entity_id: int, records, errors, on_conflict: str,
|
||||
dry_run: bool, key_fields) -> dict:
|
||||
"""逐行 upsert;返回 {inserted, updated, skipped, errors, dry_run}"""
|
||||
inserted = updated = skipped = 0
|
||||
for rec in records:
|
||||
vals = rec["values"]
|
||||
filters = [getattr(model, f) == vals[f] for f in key_fields] + [model.entity_id == entity_id]
|
||||
existing = db.query(model).filter(*filters).first()
|
||||
if existing is not None:
|
||||
if on_conflict == "update":
|
||||
for k, v in vals.items():
|
||||
setattr(existing, k, v)
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
else:
|
||||
db.add(model(entity_id=entity_id, **vals))
|
||||
inserted += 1
|
||||
if dry_run:
|
||||
db.rollback()
|
||||
else:
|
||||
db.commit()
|
||||
return {"entity_id": entity_id, "dry_run": dry_run, "on_conflict": on_conflict,
|
||||
"inserted": inserted, "updated": updated, "skipped": skipped,
|
||||
"errors": errors, "error_count": len(errors)}
|
||||
@@ -0,0 +1,550 @@
|
||||
"""增长质量诊断 API — 五维评分 + 诊断结论 + 跨期对比
|
||||
|
||||
数据来源:KPI字典 (kpi_definitions) + KPI实际值 (kpi_values)
|
||||
五维度:营收增长 / 利润质量 / 现金质量 / 增长效率 / 组织健康
|
||||
评分区间:0-100(>=80 好 / 60-79 中 / <60 差)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/growth-quality", tags=["增长质量诊断"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# 五维度定义
|
||||
DIMENSIONS = [
|
||||
{"key": "revenueGrowth", "name": "营收增长", "weight": 20, "icon": "📈",
|
||||
"desc": "营收增速、增长持续性"},
|
||||
{"key": "profitQuality", "name": "利润质量", "weight": 20, "icon": "💰",
|
||||
"desc": "毛利率/净利率趋势、利润与收入匹配"},
|
||||
{"key": "cashQuality", "name": "现金质量", "weight": 20, "icon": "🏦",
|
||||
"desc": "经营现金流与净利润匹配度(含金量)"},
|
||||
{"key": "growthEfficiency", "name": "增长效率", "weight": 20, "icon": "🚀",
|
||||
"desc": "获客成本、单位增长投入产出"},
|
||||
{"key": "orgHealth", "name": "组织健康", "weight": 20, "icon": "⚡",
|
||||
"desc": "人效、费用结构"},
|
||||
]
|
||||
|
||||
# 关键KPI编码 → 维度用途
|
||||
KPI_CODES = {
|
||||
"F_REVENUE": "营业收入(万元)",
|
||||
"F_NET_PROFIT": "净利润(万元)",
|
||||
"F_OP_CFLOW": "经营性现金流(万元)",
|
||||
"F_GROSS_MARGIN": "毛利率(%)",
|
||||
"F_OP_PROFIT_MARGIN": "经营利润率(%)",
|
||||
"F_COST_RATIO": "费用率(%)",
|
||||
"F_REVENUE_GROWTH": "收入增长率(%)",
|
||||
"F_FCF": "自由现金流(万元)",
|
||||
"C_NEW_CLIENTS": "新客户数",
|
||||
"C_REBATE_RATE": "渠补率(%)",
|
||||
"C_SATISFACTION": "客户满意度",
|
||||
"L_TRAINING": "培训完成率",
|
||||
"P_DELIVERY": "交付及时率",
|
||||
"F_AR_DAYS": "应收账款周转天数",
|
||||
}
|
||||
|
||||
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||
|
||||
|
||||
def _fetch_kpi_values(db: Session, entity_id: int, period: str) -> dict:
|
||||
"""拉取某实体某期间的全部KPI值 {kpi_code: actual_value}"""
|
||||
rows = (db.query(KPIDefinition.kpi_code, KPIValue.actual_value)
|
||||
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIValue.period == period,
|
||||
KPIValue.actual_value.isnot(None))
|
||||
.all())
|
||||
return {code: value for code, value in rows}
|
||||
|
||||
|
||||
def _fetch_history(db: Session, entity_id: int, limit: int = 12) -> List[dict]:
|
||||
"""拉取最近 N 个期间(按月,含数据)的 KPI 值,供趋势/持续性分析"""
|
||||
periods = (db.query(KPIValue.period)
|
||||
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code.in_(["F_REVENUE", "F_NET_PROFIT", "F_OP_CFLOW"]))
|
||||
.distinct().all())
|
||||
plist = sorted({p[0] for p in periods}, reverse=True)
|
||||
# 只保留 YYYY-MM 格式,按时间排序(旧→新)
|
||||
months = sorted([p for p in plist if MONTH_RE.match(p)])
|
||||
hist = []
|
||||
for p in months[-limit:]:
|
||||
hist.append({"period": p, **{k: None for k in KPI_CODES}})
|
||||
if not hist:
|
||||
return []
|
||||
# 批量取数
|
||||
rows = (db.query(KPIDefinition.kpi_code, KPIValue.period, KPIValue.actual_value)
|
||||
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIValue.period.in_([h["period"] for h in hist]))
|
||||
.all())
|
||||
idx = {h["period"]: h for h in hist}
|
||||
for code, period, val in rows:
|
||||
if period in idx and code in idx[period]:
|
||||
idx[period][code] = val
|
||||
return hist
|
||||
|
||||
|
||||
def _prev_period(period: str) -> Optional[str]:
|
||||
"""计算上期(YYYY-MM → 上一月;其他格式 → None)"""
|
||||
m = MONTH_RE.match(period)
|
||||
if not m:
|
||||
return None
|
||||
y, mo = int(period[:4]), int(period[5:7])
|
||||
if mo == 1:
|
||||
return f"{y-1:04d}-12"
|
||||
return f"{y:04d}-{mo-1:02d}"
|
||||
|
||||
|
||||
def _yoy_period(period: str) -> Optional[str]:
|
||||
"""计算去年同期(YYYY-MM → 去年同月)"""
|
||||
m = MONTH_RE.match(period)
|
||||
if not m:
|
||||
return None
|
||||
return f"{int(period[:4])-1:04d}-{period[5:7]}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 五维度评分引擎(0-100)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _clamp(v: float, lo: float = 0.0, hi: float = 100.0) -> float:
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
def _linear(value, points: List[tuple]):
|
||||
"""分段线性插值评分: points = [(x, score), ...] 按 x 升序"""
|
||||
if value is None:
|
||||
return 50.0
|
||||
if value <= points[0][0]:
|
||||
return points[0][1]
|
||||
if value >= points[-1][0]:
|
||||
return points[-1][1]
|
||||
for (x1, s1), (x2, s2) in zip(points, points[1:]):
|
||||
if x1 <= value <= x2:
|
||||
if x2 == x1:
|
||||
return s1
|
||||
return s1 + (s2 - s1) * (value - x1) / (x2 - x1)
|
||||
return 50.0
|
||||
|
||||
|
||||
# ── 1. 营收增长:增速 + 持续性 ──
|
||||
def _score_revenue_growth(cur: dict, prev: dict, history: List[dict]) -> float:
|
||||
rev_cur = cur.get("F_REVENUE")
|
||||
rev_prev = prev.get("F_REVENUE") if prev else None
|
||||
# 优先用 KPI 直接给的收入增长率
|
||||
kpi_growth = cur.get("F_REVENUE_GROWTH")
|
||||
growth = None
|
||||
if kpi_growth is not None:
|
||||
growth = float(kpi_growth)
|
||||
elif rev_cur is not None and rev_prev:
|
||||
growth = (rev_cur - rev_prev) / rev_prev * 100 if rev_prev else None
|
||||
|
||||
score = _linear(growth, [
|
||||
(-30, 5), (-20, 15), (-10, 30), (0, 45), (5, 60), (10, 70),
|
||||
(20, 82), (30, 90), (50, 96),
|
||||
])
|
||||
# 增长持续性:近6个月中收入增长月占比
|
||||
if len(history) >= 2:
|
||||
revs = [h.get("F_REVENUE") for h in history if h.get("F_REVENUE") is not None]
|
||||
ups = 0
|
||||
for i in range(1, len(revs)):
|
||||
if revs[i] > revs[i - 1]:
|
||||
ups += 1
|
||||
persist = ups / (len(revs) - 1) if len(revs) > 1 else 0.5
|
||||
score = score * 0.7 + persist * 100 * 0.3
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
# ── 2. 利润质量:毛利率/净利率水平 + 趋势 + 收入匹配 ──
|
||||
def _score_profit_quality(cur: dict, prev: dict) -> float:
|
||||
gm = cur.get("F_GROSS_MARGIN")
|
||||
np_ = cur.get("F_NET_PROFIT")
|
||||
rev = cur.get("F_REVENUE")
|
||||
net_margin = (np_ / rev * 100) if (np_ is not None and rev) else None
|
||||
opm = cur.get("F_OP_PROFIT_MARGIN")
|
||||
|
||||
gm_score = _linear(gm, [(-10, 5), (0, 10), (10, 25), (20, 45), (30, 62),
|
||||
(40, 75), (55, 88), (70, 95)])
|
||||
nm_score = _linear(net_margin, [(-50, 0), (-20, 10), (-10, 20), (0, 35),
|
||||
(10, 60), (20, 78), (30, 90)])
|
||||
opm_score = _linear(opm, [(-20, 10), (0, 30), (10, 55), (20, 75), (35, 90)])
|
||||
|
||||
# 毛利率 40% + 净利率 40% + 经营利润率 20%
|
||||
base = gm_score * 0.4 + nm_score * 0.4 + opm_score * 0.2
|
||||
|
||||
# 利润与收入匹配:收入升但利润降 → 扣分
|
||||
if prev and rev is not None and np_ is not None:
|
||||
prev_rev = prev.get("F_REVENUE")
|
||||
prev_np = prev.get("F_NET_PROFIT")
|
||||
if prev_rev and prev_np is not None:
|
||||
rev_up = rev > prev_rev
|
||||
np_down = np_ < prev_np
|
||||
if rev_up and np_down:
|
||||
base -= 10
|
||||
elif np_down:
|
||||
base -= 5
|
||||
return round(_clamp(base), 1)
|
||||
|
||||
|
||||
# ── 3. 现金质量:含金量(OCF/净利润) + 现金流强度 ──
|
||||
def _score_cash_quality(cur: dict) -> float:
|
||||
ocf = cur.get("F_OP_CFLOW")
|
||||
np_ = cur.get("F_NET_PROFIT")
|
||||
rev = cur.get("F_REVENUE")
|
||||
fcf = cur.get("F_FCF")
|
||||
|
||||
# 含金量 = OCF / 净利润(净利润>0时)
|
||||
gold = None
|
||||
if ocf is not None and np_ is not None and np_ > 0:
|
||||
gold = ocf / np_
|
||||
# 净利润<=0:利润为负,含金量指标失效 → 低分(除非现金流强)
|
||||
gold_score = _linear(gold, [(0, 10), (0.5, 35), (0.8, 55), (1.0, 70),
|
||||
(1.2, 85), (1.5, 95)])
|
||||
if np_ is not None and np_ <= 0:
|
||||
gold_score = 15 if (ocf is None or ocf <= 0) else 35
|
||||
|
||||
# 现金流强度 = OCF / 收入
|
||||
ocf_ratio = (ocf / rev * 100) if (ocf is not None and rev) else None
|
||||
ocf_score = _linear(ocf_ratio, [(-20, 5), (0, 20), (10, 50), (20, 75),
|
||||
(30, 90), (50, 98)])
|
||||
fcf_score = _linear(fcf, [(-100, 10), (-20, 30), (0, 50), (20, 70),
|
||||
(100, 90)]) if fcf is not None else 50.0
|
||||
|
||||
score = gold_score * 0.5 + ocf_score * 0.35 + fcf_score * 0.15
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
# ── 4. 增长效率:费用率水平 + 单位增长投入产出 + 获客成本 ──
|
||||
def _score_growth_efficiency(cur: dict, prev: dict) -> float:
|
||||
cost_ratio = cur.get("F_COST_RATIO")
|
||||
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
|
||||
(55, 40), (70, 25), (90, 10)])
|
||||
|
||||
# 费用增速 vs 收入增速(用费用率变化近似)
|
||||
eff_score = 60.0
|
||||
if prev is not None and cost_ratio is not None:
|
||||
prev_cr = prev.get("F_COST_RATIO")
|
||||
if prev_cr:
|
||||
cr_change = cost_ratio - prev_cr
|
||||
eff_score = _linear(cr_change, [(-15, 95), (-5, 80), (0, 65),
|
||||
(5, 45), (15, 25), (30, 10)])
|
||||
|
||||
# 获客成本代理:收入/新客户数(越高越高效)
|
||||
rev = cur.get("F_REVENUE")
|
||||
new_clients = cur.get("C_NEW_CLIENTS")
|
||||
cac_score = 60.0
|
||||
if rev is not None and new_clients:
|
||||
per_client = rev / new_clients
|
||||
cac_score = _linear(per_client, [(0, 40), (50, 50), (200, 65),
|
||||
(500, 78), (1000, 88)])
|
||||
|
||||
score = cost_score * 0.45 + eff_score * 0.35 + cac_score * 0.2
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
# ── 5. 组织健康:费用结构 + 人效/运营质量 ──
|
||||
def _score_org_health(cur: dict) -> float:
|
||||
cost_ratio = cur.get("F_COST_RATIO")
|
||||
# 费用结构(费用率越低越健康)
|
||||
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
|
||||
(55, 40), (70, 25), (90, 10)])
|
||||
# 运营/人效质量代理:满意度、培训、交付、应收
|
||||
sat = cur.get("C_SATISFACTION")
|
||||
train = cur.get("L_TRAINING")
|
||||
deliver = cur.get("P_DELIVERY")
|
||||
ar_days = cur.get("F_AR_DAYS")
|
||||
op_vals = [v for v in [sat, train, deliver] if v is not None]
|
||||
op_score = (sum(op_vals) / len(op_vals)) if op_vals else 55.0
|
||||
ar_score = _linear(ar_days, [(15, 95), (30, 80), (45, 65), (60, 50),
|
||||
(90, 30), (120, 15)]) if ar_days is not None else 55.0
|
||||
|
||||
score = cost_score * 0.4 + op_score * 0.35 + ar_score * 0.25
|
||||
return round(_clamp(score), 1)
|
||||
|
||||
|
||||
_SCORERS = {
|
||||
"revenueGrowth": _score_revenue_growth,
|
||||
"profitQuality": _score_profit_quality,
|
||||
"cashQuality": _score_cash_quality,
|
||||
"growthEfficiency": _score_growth_efficiency,
|
||||
"orgHealth": _score_org_health,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 明细指标 + 改善建议
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _fmt(v, unit=""):
|
||||
if v is None:
|
||||
return "—"
|
||||
if isinstance(v, float) and v == int(v):
|
||||
return f"{int(v)}{unit}"
|
||||
return f"{round(v, 2)}{unit}"
|
||||
|
||||
|
||||
def _dim_indicators(dim_key: str, cur: dict, prev: dict) -> List[dict]:
|
||||
"""维度明细指标(label/value/verdict/status)"""
|
||||
inds = []
|
||||
def add(label, value, verdict, status):
|
||||
inds.append({"label": label, "value": value, "verdict": verdict, "status": status})
|
||||
|
||||
if dim_key == "revenueGrowth":
|
||||
rev, prev_rev = cur.get("F_REVENUE"), (prev or {}).get("F_REVENUE")
|
||||
growth = None
|
||||
if rev is not None and prev_rev:
|
||||
growth = (rev - prev_rev) / prev_rev * 100
|
||||
add("营业收入", _fmt(rev, "万"), "环比" + (_fmt(growth, "%") if growth is not None else "无上期数据"),
|
||||
"success" if (growth or 0) >= 0 else "danger")
|
||||
add("收入增长率(KPI)", _fmt(cur.get("F_REVENUE_GROWTH"), "%"),
|
||||
"KPI直接值" if cur.get("F_REVENUE_GROWTH") is not None else "未录入",
|
||||
"success" if (cur.get("F_REVENUE_GROWTH") or 0) >= 10 else "warning")
|
||||
elif dim_key == "profitQuality":
|
||||
rev, np_ = cur.get("F_REVENUE"), cur.get("F_NET_PROFIT")
|
||||
nm = (np_ / rev * 100) if (np_ is not None and rev) else None
|
||||
add("毛利率", _fmt(cur.get("F_GROSS_MARGIN"), "%"),
|
||||
"毛利健康" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "毛利偏低",
|
||||
"success" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "danger")
|
||||
add("净利率", _fmt(nm, "%"),
|
||||
"盈利" if (nm or 0) > 0 else "亏损",
|
||||
"success" if (nm or 0) > 10 else "danger")
|
||||
add("经营利润率", _fmt(cur.get("F_OP_PROFIT_MARGIN"), "%"),
|
||||
"正常" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "偏低",
|
||||
"success" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "warning")
|
||||
elif dim_key == "cashQuality":
|
||||
ocf, np_ = cur.get("F_OP_CFLOW"), cur.get("F_NET_PROFIT")
|
||||
gold = (ocf / np_) if (ocf is not None and np_ and np_ > 0) else None
|
||||
add("经营现金流", _fmt(ocf, "万"),
|
||||
"现金流入" if (ocf or 0) > 0 else "现金流出",
|
||||
"success" if (ocf or 0) > 0 else "danger")
|
||||
add("含金量(OCF/净利润)", _fmt(gold, "倍"),
|
||||
"含金量高" if (gold or 0) >= 1 else ("利润为负" if (np_ or 0) <= 0 else "含金量低"),
|
||||
"success" if (gold or 0) >= 1 else "danger")
|
||||
add("自由现金流", _fmt(cur.get("F_FCF"), "万"),
|
||||
"正常" if (cur.get("F_FCF") or 0) > 0 else "为负",
|
||||
"success" if (cur.get("F_FCF") or 0) > 0 else "warning")
|
||||
elif dim_key == "growthEfficiency":
|
||||
rev, nc = cur.get("F_REVENUE"), cur.get("C_NEW_CLIENTS")
|
||||
per = (rev / nc) if (rev is not None and nc) else None
|
||||
add("费用率", _fmt(cur.get("F_COST_RATIO"), "%"),
|
||||
"费用可控" if (cur.get("F_COST_RATIO") or 0) <= 30 else "费用偏高",
|
||||
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
|
||||
add("单位客户营收(万/户)", _fmt(per),
|
||||
"获客效率高" if (per or 0) >= 200 else "获客效率一般",
|
||||
"success" if (per or 0) >= 500 else "warning")
|
||||
add("渠补率", _fmt(cur.get("C_REBATE_RATE"), "%"),
|
||||
"渠道依赖" if (cur.get("C_REBATE_RATE") or 0) > 50 else "渠道健康",
|
||||
"danger" if (cur.get("C_REBATE_RATE") or 0) > 50 else "success")
|
||||
elif dim_key == "orgHealth":
|
||||
add("费用率(结构)", _fmt(cur.get("F_COST_RATIO"), "%"),
|
||||
"结构健康" if (cur.get("F_COST_RATIO") or 0) <= 30 else "结构偏重",
|
||||
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
|
||||
add("应收周转天数", _fmt(cur.get("F_AR_DAYS"), "天"),
|
||||
"回款快" if (cur.get("F_AR_DAYS") or 0) <= 45 else "回款偏慢",
|
||||
"success" if (cur.get("F_AR_DAYS") or 0) <= 45 else "warning")
|
||||
add("客户满意度", _fmt(cur.get("C_SATISFACTION")),
|
||||
"满意" if (cur.get("C_SATISFACTION") or 0) >= 80 else "待提升",
|
||||
"success" if (cur.get("C_SATISFACTION") or 0) >= 80 else "warning")
|
||||
add("培训完成率", _fmt(cur.get("L_TRAINING"), "%"),
|
||||
"学习投入足" if (cur.get("L_TRAINING") or 0) >= 80 else "学习投入不足",
|
||||
"success" if (cur.get("L_TRAINING") or 0) >= 80 else "warning")
|
||||
return inds
|
||||
|
||||
|
||||
def _dim_suggestions(dim_key: str, score: float, cur: dict) -> List[str]:
|
||||
"""按维度评分生成改善建议"""
|
||||
if score >= 80:
|
||||
return ["该维度表现良好,建议保持并固化为标准流程"]
|
||||
sug = []
|
||||
if dim_key == "revenueGrowth":
|
||||
sug = ["挖掘存量客户复购,稳定收入基本盘",
|
||||
"拓展新渠道/新产品线,提升营收增速",
|
||||
"跟踪F_REVENUE_GROWTH KPI按月更新,建立增长预警线"]
|
||||
elif dim_key == "profitQuality":
|
||||
sug = ["排查毛利率下滑原因(成本/价格/渠补),优先止血",
|
||||
"控制费用增速不超过收入增速,改善净利率",
|
||||
"对亏损产品线做盈亏平衡分析,必要时收缩"]
|
||||
elif dim_key == "cashQuality":
|
||||
sug = ["加强应收账款催收,缩短回款周期",
|
||||
"压缩非必要开支,提升经营现金流净额",
|
||||
"建立现金流月度滚动预测,防范断流风险"]
|
||||
elif dim_key == "growthEfficiency":
|
||||
sug = ["优化费用结构,降低费用率至30%以下",
|
||||
"评估渠道返利政策,降低渠补率与渠道依赖",
|
||||
"提高获客转化率,降低单位获客成本"]
|
||||
elif dim_key == "orgHealth":
|
||||
sug = ["精简组织与费用结构,提升人效",
|
||||
"强化培训与人才梯队建设(盯L_TRAINING)",
|
||||
"优化应收管理,缩短周转天数"]
|
||||
return sug
|
||||
|
||||
|
||||
def _level_of(overall: float) -> dict:
|
||||
if overall >= 80:
|
||||
return {"level": "好", "level_type": "success", "desc": "增长质量优秀,增长可持续"}
|
||||
if overall >= 60:
|
||||
return {"level": "中", "level_type": "warning", "desc": "增长质量中等,存在优化空间"}
|
||||
return {"level": "差", "level_type": "danger", "desc": "增长质量堪忧,需立即干预"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 诊断主流程
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _diagnose(db: Session, entity_id: int, period: str, history: List[dict]):
|
||||
"""对单个期间执行五维诊断,返回完整诊断对象"""
|
||||
cur = _fetch_kpi_values(db, entity_id, period)
|
||||
prev_period = _prev_period(period)
|
||||
prev = _fetch_kpi_values(db, entity_id, prev_period) if prev_period else {}
|
||||
|
||||
scores = {}
|
||||
dims_payload = {}
|
||||
for dim in DIMENSIONS:
|
||||
key = dim["key"]
|
||||
scorer = _SCORERS[key]
|
||||
if key == "revenueGrowth":
|
||||
s = scorer(cur, prev, history)
|
||||
elif key in ("cashQuality", "orgHealth"):
|
||||
s = scorer(cur)
|
||||
else:
|
||||
s = scorer(cur, prev)
|
||||
scores[key] = s
|
||||
dims_payload[key] = {
|
||||
"key": key, "name": dim["name"], "icon": dim["icon"],
|
||||
"desc": dim["desc"], "weight": dim["weight"],
|
||||
"score": s,
|
||||
"indicators": _dim_indicators(key, cur, prev),
|
||||
"suggestions": _dim_suggestions(key, s, cur),
|
||||
}
|
||||
|
||||
overall = round(sum(scores.values()) / len(scores), 1)
|
||||
level = _level_of(overall)
|
||||
|
||||
# 诊断结论文本
|
||||
low_dims = [d for d in DIMENSIONS if scores[d["key"]] < 60]
|
||||
mid_dims = [d for d in DIMENSIONS if 60 <= scores[d["key"]] < 80]
|
||||
lines = [f"{period} 综合增长质量评分 {overall} 分({level['level']}):{level['desc']}。"]
|
||||
if low_dims:
|
||||
lines.append("需重点关注:" + "、".join(f"{d['name']}({scores[d['key']]}分)" for d in low_dims) + "。")
|
||||
if mid_dims:
|
||||
lines.append("可优化:" + "、".join(f"{d['name']}({scores[d['key']]}分)" for d in mid_dims) + "。")
|
||||
if not low_dims:
|
||||
lines.append("各维度均处于健康区间,增长质量扎实。")
|
||||
diagnosis = "".join(lines)
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"period": period,
|
||||
"overall": overall,
|
||||
"level": level["level"],
|
||||
"level_type": level["level_type"],
|
||||
"diagnosis": diagnosis,
|
||||
"dimensions": dims_payload,
|
||||
"kpi_available": {k: cur.get(k) is not None for k in KPI_CODES},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/periods")
|
||||
def list_periods(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
|
||||
"""列出某实体有KPI数据的期间(按月,含数据覆盖度,用于前端默认期间选择)"""
|
||||
rows = (db.query(KPIValue.period, KPIValue.kpi_id)
|
||||
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.all())
|
||||
counts: dict = {}
|
||||
for period, kpi_id in rows:
|
||||
if MONTH_RE.match(period or ""):
|
||||
counts[period] = counts.get(period, 0) + 1
|
||||
periods = sorted(counts.keys(), reverse=True)
|
||||
return {"entity_id": entity_id, "periods": periods,
|
||||
"coverage": {p: counts[p] for p in periods}}
|
||||
|
||||
|
||||
@router.get("/diagnosis")
|
||||
def growth_quality_diagnosis(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
period: Optional[str] = Query(None, description="期间 YYYY-MM,默认最近有数据期间"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""增长质量诊断 — 五维评分(0-100) + 诊断结论 + 跨期对比(本期/上期/去年同期)"""
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not ent:
|
||||
raise HTTPException(404, f"实体 {entity_id} 不存在")
|
||||
|
||||
history = _fetch_history(db, entity_id, limit=12)
|
||||
if not history:
|
||||
raise HTTPException(400, "该实体暂无月度KPI数据,请先录入KPI实际值")
|
||||
|
||||
# 默认取最近且有足够数据覆盖的期间(>=5个KPI值,退化为最近一个)
|
||||
if not period:
|
||||
cov_rows = (db.query(KPIValue.period)
|
||||
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
|
||||
.filter(KPIDefinition.entity_id == entity_id)
|
||||
.all())
|
||||
cov: dict = {}
|
||||
for (p,) in cov_rows:
|
||||
if MONTH_RE.match(p or ""):
|
||||
cov[p] = cov.get(p, 0) + 1
|
||||
candidates = sorted([p for p in cov if cov[p] >= 5], reverse=True)
|
||||
period = candidates[0] if candidates else history[-1]["period"]
|
||||
|
||||
current = _diagnose(db, entity_id, period, history)
|
||||
|
||||
# 跨期对比:上期 + 去年同期
|
||||
prev_p = _prev_period(period)
|
||||
yoy_p = _yoy_period(period)
|
||||
prev_data = _fetch_kpi_values(db, entity_id, prev_p) if prev_p else {}
|
||||
yoy_data = _fetch_kpi_values(db, entity_id, yoy_p) if yoy_p else {}
|
||||
|
||||
comparison = {
|
||||
"current": {
|
||||
"period": period, "overall": current["overall"],
|
||||
"level": current["level"], "level_type": current["level_type"],
|
||||
"dimensions": {k: v["score"] for k, v in current["dimensions"].items()},
|
||||
},
|
||||
}
|
||||
if prev_p and prev_data:
|
||||
pdiag = _diagnose(db, entity_id, prev_p, history)
|
||||
comparison["previous"] = {
|
||||
"period": prev_p, "overall": pdiag["overall"],
|
||||
"level": pdiag["level"], "level_type": pdiag["level_type"],
|
||||
"dimensions": {k: v["score"] for k, v in pdiag["dimensions"].items()},
|
||||
}
|
||||
if yoy_p and yoy_data:
|
||||
ydiag = _diagnose(db, entity_id, yoy_p, history)
|
||||
comparison["yoy"] = {
|
||||
"period": yoy_p, "overall": ydiag["overall"],
|
||||
"level": ydiag["level"], "level_type": ydiag["level_type"],
|
||||
"dimensions": {k: v["score"] for k, v in ydiag["dimensions"].items()},
|
||||
}
|
||||
|
||||
# 趋势:近12个月综合评分
|
||||
trend = []
|
||||
for h in history:
|
||||
try:
|
||||
d = _diagnose(db, entity_id, h["period"], history)
|
||||
trend.append({"period": h["period"], "overall": d["overall"]})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {
|
||||
"entity": {"id": ent.id, "name": ent.name, "short_name": ent.short_name},
|
||||
"period": period,
|
||||
"overall": current["overall"],
|
||||
"level": current["level"],
|
||||
"level_type": current["level_type"],
|
||||
"diagnosis": current["diagnosis"],
|
||||
"dimensions": current["dimensions"],
|
||||
"comparison": comparison,
|
||||
"trend": trend,
|
||||
"kpi_available": current["kpi_available"],
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"""知识摘要 API — 管理会计OS持久记忆
|
||||
|
||||
提供:
|
||||
- 查询最近摘要列表
|
||||
- 查询单个摘要详情
|
||||
- 手动触发各层级摘要生成
|
||||
- 查询未摘要的事件
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KnowledgeEvent, KnowledgeSummary
|
||||
from app.services.knowledge_service import (
|
||||
DeepSeekKeyMissing,
|
||||
generate_summary_sync,
|
||||
generate_daily_sync,
|
||||
generate_weekly_sync,
|
||||
generate_monthly_sync,
|
||||
get_last_summary,
|
||||
extract_events,
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.knowledge_api")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/knowledge", tags=["知识摘要"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def summary_to_dict(s: KnowledgeSummary) -> dict:
|
||||
return {
|
||||
"id": s.id,
|
||||
"level": s.level,
|
||||
"period_key": s.period_key,
|
||||
"title": s.title,
|
||||
"content": s.content,
|
||||
"kpi_changes": s.kpi_changes,
|
||||
"decision_points": s.decision_points,
|
||||
"key_metrics": s.key_metrics,
|
||||
"prev_summary_id": s.prev_summary_id,
|
||||
"model": s.model,
|
||||
"is_stale": s.is_stale,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ── 查询 ──
|
||||
|
||||
|
||||
@router.get("/summaries")
|
||||
def list_summaries(
|
||||
level: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取摘要列表,按层级筛选,按时间倒序(按企业抽屉隔离)"""
|
||||
query = db.query(KnowledgeSummary)
|
||||
query = query.filter(KnowledgeSummary.entity_id == entity_id)
|
||||
if level:
|
||||
query = query.filter(KnowledgeSummary.level == level)
|
||||
query = query.order_by(desc(KnowledgeSummary.id)).offset(offset).limit(limit)
|
||||
total = db.query(func.count(KnowledgeSummary.id)).select_from(KnowledgeSummary)
|
||||
total = total.filter(KnowledgeSummary.entity_id == entity_id)
|
||||
if level:
|
||||
total = total.filter(KnowledgeSummary.level == level)
|
||||
total = total.scalar()
|
||||
return {
|
||||
"total": total,
|
||||
"entity_id": entity_id,
|
||||
"items": [summary_to_dict(s) for s in query.all()],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summaries/latest")
|
||||
def latest_summary(
|
||||
level: str = Query("daily", description="层级: daily/weekly/monthly/cumulative"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取指定层级的最新摘要(按企业隔离)"""
|
||||
s = get_last_summary(db, level, entity_id)
|
||||
if not s:
|
||||
return {"detail": f"没有{level}层级的摘要"}, 404
|
||||
return summary_to_dict(s)
|
||||
|
||||
|
||||
@router.get("/summaries/{summary_id}")
|
||||
def get_summary(
|
||||
summary_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取单条摘要详情(仅本企业,防跨企业探测)"""
|
||||
s = (
|
||||
db.query(KnowledgeSummary)
|
||||
.filter(
|
||||
KnowledgeSummary.id == summary_id,
|
||||
KnowledgeSummary.entity_id == entity_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="摘要不存在")
|
||||
return summary_to_dict(s)
|
||||
|
||||
|
||||
# ── 事件查询 ──
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
def list_events(
|
||||
since: Optional[str] = None,
|
||||
until: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""查询未摘要的原始事件(按企业隔离)
|
||||
|
||||
如果不传时间,默认返回最近7天的操作记录和预警。
|
||||
"""
|
||||
try:
|
||||
dt_since = datetime.fromisoformat(since) if since else datetime.utcnow() - timedelta(days=7)
|
||||
dt_until = datetime.fromisoformat(until) if until else datetime.utcnow()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="时间格式错误,请使用 ISO 格式如 2026-06-01T00:00:00")
|
||||
|
||||
events = extract_events(db, dt_since, dt_until, entity_id=entity_id)
|
||||
return {"since": dt_since.isoformat(), "until": dt_until.isoformat(), "entity_id": entity_id, "total": len(events), "events": events[:limit]}
|
||||
|
||||
|
||||
# ── 手动触发 ──
|
||||
|
||||
|
||||
@router.post("/generate/daily")
|
||||
def trigger_daily_summary(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""手动触发每日摘要生成(写入当前企业抽屉)"""
|
||||
try:
|
||||
result = generate_daily_sync(db, entity_id=entity_id)
|
||||
return {"message": "每日摘要已生成", "summary": result}
|
||||
except DeepSeekKeyMissing as e:
|
||||
logger.error("每日摘要生成失败(缺少 DeepSeek Key): %s", e)
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("每日摘要生成失败")
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generate/weekly")
|
||||
def trigger_weekly_summary(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""手动触发周度摘要生成(写入当前企业抽屉)"""
|
||||
try:
|
||||
result = generate_weekly_sync(db, entity_id=entity_id)
|
||||
return {"message": "周度摘要已生成", "summary": result}
|
||||
except DeepSeekKeyMissing as e:
|
||||
logger.error("周度摘要生成失败(缺少 DeepSeek Key): %s", e)
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("周度摘要生成失败")
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/generate/monthly")
|
||||
def trigger_monthly_summary(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""手动触发月度摘要生成(写入当前企业抽屉)"""
|
||||
try:
|
||||
result = generate_monthly_sync(db, entity_id=entity_id)
|
||||
return {"message": "月度摘要已生成", "summary": result}
|
||||
except DeepSeekKeyMissing as e:
|
||||
logger.error("月度摘要生成失败(缺少 DeepSeek Key): %s", e)
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("月度摘要生成失败")
|
||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
||||
|
||||
|
||||
# ── CMA P1+P2 Round 3: 职业道德自测题 ──
|
||||
|
||||
ETHICS_QUIZ_DATA = [
|
||||
{
|
||||
"id": 1,
|
||||
"scenario": '你发现公司财务数据异常(现金比率仅0.6%),但总经理说"先别声张,等融资款到账再说",你应该:',
|
||||
"options": [
|
||||
{"id": "a", "text": "听从老板,暂时隐瞒", "correct": False, "feedback": "违反IMA诚信准则。财务人员有义务如实记录和报告"},
|
||||
{"id": "b", "text": "如实记录异常并向上级或审计委员会报告", "correct": True, "feedback": "诚信+客观准则。IMA要求财务人员如实反映财务状况"},
|
||||
{"id": "c", "text": "立即辞职,一走了之", "correct": False, "feedback": "可行但非最优解。应先通过正规渠道报告问题"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"scenario": "竞争对手公司的高管在行业会议上向你打探你们公司的成本数据,你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "告知对方,反正行业数据都差不多", "correct": False, "feedback": "违反保密准则。成本数据属于公司机密"},
|
||||
{"id": "b", "text": "断然拒绝,并告知这是公司机密", "correct": True, "feedback": "保密准则。财务人员必须保护公司机密信息"},
|
||||
{"id": "c", "text": "用编造的假数据糊弄对方", "correct": False, "feedback": "违反诚信准则。即使对竞争对手也不应提供虚假信息"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"scenario": "你被要求分析一项复杂的金融衍生品对公司的影响,但你没有相关专业知识经验,应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "凭感觉估算一个数值交差", "correct": False, "feedback": "违反胜任准则。不能在自己不擅长的领域出具专业意见"},
|
||||
{"id": "b", "text": "如实告知自己缺乏经验,建议聘请外部专家", "correct": True, "feedback": "胜任准则。IMA要求持续学习并在能力范围内执业"},
|
||||
{"id": "c", "text": "花一个晚上学习后立即出报告", "correct": False, "feedback": "短期内突击学习难以达到专业标准,建议请专家"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"scenario": "供应商送你价值5000元的高档礼品,并暗示希望在招标中获得倾斜,你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "收下礼物,在招标中适当照顾", "correct": False, "feedback": "违反客观准则。礼品影响职业判断的独立性"},
|
||||
{"id": "b", "text": "拒收礼品并报告公司合规部门", "correct": True, "feedback": "客观准则。财务人员必须保持独立性和客观判断"},
|
||||
{"id": "c", "text": "收下但不做任何特殊照顾", "correct": False, "feedback": "即使不特殊照顾,收礼本身已违反公司廉洁规定"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"scenario": '销售总监要求你将下季度已签约但未发货的订单提前确认为本季度收入,以"完成业绩指标",你应该:',
|
||||
"options": [
|
||||
{"id": "a", "text": "照做,反正迟早会确认", "correct": False, "feedback": "违反诚信准则。收入确认必须符合会计准则的确认条件"},
|
||||
{"id": "b", "text": "拒绝并解释收入确认应遵循权责发生制", "correct": True, "feedback": "诚信+客观准则。IMA要求如实反映交易实质"},
|
||||
{"id": "c", "text": "折中处理,先确认一半", "correct": False, "feedback": "部分确认依然违反会计准则和IMA诚信准则"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"scenario": "你的亲戚是股票投资者,向你打听公司即将发布的季度盈利数据(尚未公开),你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "暗示性地透露一点,让亲戚自己去判断", "correct": False, "feedback": "违反保密准则。内幕信息在任何情况下都不能泄露"},
|
||||
{"id": "b", "text": "明确拒绝并告知这是未公开的内部信息", "correct": True, "feedback": "保密准则。IMA要求保护信息机密性,禁止内幕交易"},
|
||||
{"id": "c", "text": "告知所有数据需等正式发布后才能讨论", "correct": False, "feedback": "虽然拒绝了但未明确说明信息性质,应更直接拒绝"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"scenario": "你在编制税务筹划方案时,发现最新的税法已经更新,但你还没参加过相关培训,应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "按老法规继续编制,客户不会发现", "correct": False, "feedback": "违反胜任准则。必须掌握最新知识才能提供专业服务"},
|
||||
{"id": "b", "text": "先学习新税法,再基于最新法规编制方案", "correct": True, "feedback": "胜任准则。IMA要求持续提升专业能力,保持知识更新"},
|
||||
{"id": "c", "text": "请客户另请高明", "correct": False, "feedback": "回避问题不可取。应先通过学习解决问题"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"scenario": "月度经营分析会上,CEO要求你只汇报好的指标,不提及存在的风险问题,你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "听从CEO安排,只报喜不报忧", "correct": False, "feedback": "违反客观准则。选择性披露误导决策"},
|
||||
{"id": "b", "text": "完整披露所有指标,包括存在风险和问题", "correct": True, "feedback": "客观+诚信准则。IMA要求全面、公正地呈现信息"},
|
||||
{"id": "c", "text": "在会上只口头上提一下风险但不出现在书面报告里", "correct": False, "feedback": "口头提及无记录,核心问题仍需书面报告"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"scenario": "为达成KPI目标获得年终奖,部门经理要求你修改部分实际运营数据,你应该:",
|
||||
"options": [
|
||||
{"id": "a", "text": "修改数据,反正金额不大", "correct": False, "feedback": "违反诚信准则。任何金额的数据造假都不可接受"},
|
||||
{"id": "b", "text": "坚决拒绝并向上级或审计委员会举报", "correct": True, "feedback": "诚信准则。诚实是财务人员的首要职业道德要求"},
|
||||
{"id": "c", "text": "假装答应但不执行", "correct": False, "feedback": "不明确的回应可能导致后续施压,应明确拒绝"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"scenario": "你准备离职去竞争对手公司,离职前应该如何处理手头的公司财务数据文件?",
|
||||
"options": [
|
||||
{"id": "a", "text": "拷贝一份带走,作为新公司的参考", "correct": False, "feedback": "违反保密准则。离职不得带走公司机密数据"},
|
||||
{"id": "b", "text": "全部归还公司,并确认不保留任何副本", "correct": True, "feedback": "保密准则。离职后仍须承担保密义务"},
|
||||
{"id": "c", "text": "只带走非敏感性的一般统计资料", "correct": False, "feedback": "任何公司未公开数据均不得外传"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/ethics-quiz")
|
||||
def get_ethics_quiz():
|
||||
"""返回10道IMA职业道德情景自测题"""
|
||||
return ETHICS_QUIZ_DATA
|
||||
@@ -0,0 +1,92 @@
|
||||
"""知识库文章 API — P1-3 嵌入功能模块用
|
||||
|
||||
提供按关联页面查询知识文章的功能。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models.knowledge_article import KnowledgeArticle
|
||||
from app.utils.route_key import candidate_route_keys, route_template_child_pattern
|
||||
|
||||
router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
|
||||
def article_to_dict(a: KnowledgeArticle) -> dict:
|
||||
return {
|
||||
"id": a.id,
|
||||
"entity_id": a.entity_id,
|
||||
"title": a.title,
|
||||
"summary": a.summary,
|
||||
"content": a.content,
|
||||
"category": a.category,
|
||||
"icon": a.icon,
|
||||
"related_page": a.related_page,
|
||||
"sort_order": a.sort_order,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_articles(
|
||||
related_page: Optional[str] = Query(None, description="按关联页面路由筛选"),
|
||||
category: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""查询知识文章(本企业专属 ∪ 全局共享),可按关联页面或分类筛选
|
||||
|
||||
隔离语义(2026-09-03 多租户分区):
|
||||
- entity_id IS NULL → 全局共享(CMA 系统教学知识,所有企业可见)
|
||||
- entity_id == 当前账套 → 企业专属(只本企业可见)
|
||||
- 其他企业的专属文章不可见
|
||||
"""
|
||||
q = db.query(KnowledgeArticle).filter(
|
||||
or_(
|
||||
KnowledgeArticle.entity_id == entity_id,
|
||||
KnowledgeArticle.entity_id.is_(None),
|
||||
)
|
||||
)
|
||||
if related_page:
|
||||
# KBFF-v11c(2026-10-12):匹配口径由「子串包含 LIKE '%<v>%'」收敛为
|
||||
# 「归一化候选键精确等值 ∪ 模板子路由」——消除短键/前缀超集命中
|
||||
# (prop=/maps 曾连带命中 /maps/canvas + /maps-review;prop=/kpi 命中 /kpis)。
|
||||
# 口径实现唯一处 = app/utils/route_key.py(调用方不得二次截断/拼接)。
|
||||
keys = candidate_route_keys(related_page)
|
||||
if not keys:
|
||||
# 不可归一(纯 query/空白等)⇒ 明确无命中,不退化为“不过滤=全量”
|
||||
return {"data": []}
|
||||
conds = [KnowledgeArticle.related_page.in_(keys)]
|
||||
for k in keys:
|
||||
# 模板子路由:存储值形如 <键>/:<占位符>(占位符命名不限),仍属该页
|
||||
conds.append(
|
||||
KnowledgeArticle.related_page.like(route_template_child_pattern(k), escape="\\")
|
||||
)
|
||||
q = q.filter(or_(*conds))
|
||||
if category:
|
||||
q = q.filter(KnowledgeArticle.category == category)
|
||||
articles = q.order_by(KnowledgeArticle.sort_order.asc(), KnowledgeArticle.id.asc()).all()
|
||||
return {"data": [article_to_dict(a) for a in articles]}
|
||||
|
||||
|
||||
@router.get("/{article_id}")
|
||||
def get_article(
|
||||
article_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取文章详情(企业专属文章仅本企业可见,防跨企业探测)"""
|
||||
a = (
|
||||
db.query(KnowledgeArticle)
|
||||
.filter(KnowledgeArticle.id == article_id)
|
||||
.first()
|
||||
)
|
||||
if not a:
|
||||
raise HTTPException(404, "文章不存在")
|
||||
if a.entity_id is not None and a.entity_id != entity_id:
|
||||
raise HTTPException(404, "文章不存在")
|
||||
return article_to_dict(a)
|
||||
@@ -0,0 +1,433 @@
|
||||
"""KPI因果链建模 — 任务2
|
||||
KPI间因果关系网络 + 模拟推演 + 三层验证机制(数据/AI/人工) (2026-08-27 P2)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
|
||||
from app.utils.oplog import log_op
|
||||
from app.utils.alert_entity_scope import month_period_clause
|
||||
from app.services.causality_verification import (
|
||||
ALL_STATUSES,
|
||||
ALL_SOURCE_TYPES,
|
||||
STATUS_PENDING,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("kpi-causality")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/kpi-causality", tags=["KPI因果链"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
def _to_dict(obj):
|
||||
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 注意: 静态路径必须放在动态路径之前(/{id}之前)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/full-network")
|
||||
def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b, 2026-08-27 用entity_id列)"""
|
||||
edges = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
|
||||
node_ids = set()
|
||||
edge_list = []
|
||||
for e in edges:
|
||||
node_ids.add(e.source_kpi_id)
|
||||
node_ids.add(e.target_kpi_id)
|
||||
edge_list.append({
|
||||
"source": e.source_kpi_id,
|
||||
"target": e.target_kpi_id,
|
||||
"strength": e.strength,
|
||||
"direction": e.direction,
|
||||
"lag_months": e.lag_months,
|
||||
})
|
||||
|
||||
# 获取所有节点信息
|
||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.id.in_(node_ids)).all() if node_ids else []
|
||||
node_map = {k.id: {
|
||||
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "category": k.category,
|
||||
} for k in kpis}
|
||||
|
||||
nodes = []
|
||||
for nid in node_ids:
|
||||
info = node_map.get(nid, {"id": nid, "kpi_code": f"KPI#{nid}", "kpi_name": f"KPI#{nid}"})
|
||||
nodes.append(info)
|
||||
|
||||
return {"nodes": nodes, "edges": edge_list, "total_edges": len(edge_list)}
|
||||
|
||||
|
||||
@router.get("/kpi/{kpi_id}/network")
|
||||
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取KPI的因果网络(上游驱动 + 下游影响)— 账套隔离: 校验KPI属于当前企业"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在") # 跨企业不暴露存在性
|
||||
|
||||
# 上游(指向当前KPI的因果)
|
||||
upstream = db.query(KPICausality).filter(
|
||||
KPICausality.target_kpi_id == kpi_id,
|
||||
KPICausality.entity_id == entity_id,
|
||||
).all()
|
||||
upstream_list = []
|
||||
for c in upstream:
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
if src:
|
||||
upstream_list.append({
|
||||
"causality_id": c.id,
|
||||
"kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name,
|
||||
"strength": c.strength, "lag_months": c.lag_months,
|
||||
"direction": c.direction, "formula": c.formula,
|
||||
"source_type": c.source_type, "verify_status": c.verify_status,
|
||||
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
|
||||
"verified_by": c.verified_by,
|
||||
})
|
||||
|
||||
# 下游(当前KPI指向的因果)
|
||||
downstream = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == kpi_id,
|
||||
KPICausality.entity_id == entity_id,
|
||||
).all()
|
||||
downstream_list = []
|
||||
for c in downstream:
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
if tgt:
|
||||
downstream_list.append({
|
||||
"causality_id": c.id,
|
||||
"kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name,
|
||||
"strength": c.strength, "lag_months": c.lag_months,
|
||||
"direction": c.direction, "formula": c.formula,
|
||||
"source_type": c.source_type, "verify_status": c.verify_status,
|
||||
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
|
||||
"verified_by": c.verified_by,
|
||||
})
|
||||
|
||||
return {
|
||||
"kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension},
|
||||
"upstream": upstream_list,
|
||||
"downstream": downstream_list,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/simulate")
|
||||
def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""模拟推演: 修改一个KPI的值,预测对其他KPI的影响
|
||||
Body: { kpi_id: int, new_value: float, period: str }
|
||||
"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
new_value = data.get("new_value")
|
||||
period = data.get("period")
|
||||
|
||||
if not kpi_id or new_value is None:
|
||||
raise HTTPException(400, "必须指定kpi_id和new_value")
|
||||
|
||||
source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not source_kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if source_kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 获取当前值
|
||||
current_value = None
|
||||
query_values = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period:
|
||||
query_values = query_values.filter(KPIValue.period == period)
|
||||
latest = query_values.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
|
||||
if latest:
|
||||
current_value = latest.actual_value
|
||||
|
||||
previous_value = current_value or new_value
|
||||
change_pct = ((new_value - previous_value) / previous_value * 100) if previous_value and previous_value != 0 else 0
|
||||
|
||||
# BFS遍历下游因果链
|
||||
visited = set()
|
||||
impacts = []
|
||||
queue = [(kpi_id, change_pct, 0, 1.0)] # (kpi_id, change_pct, depth, cumulative_strength)
|
||||
|
||||
while queue:
|
||||
current_kpi_id, current_change, depth, cum_strength = queue.pop(0)
|
||||
if current_kpi_id in visited:
|
||||
continue
|
||||
visited.add(current_kpi_id)
|
||||
|
||||
# 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链)
|
||||
downstream = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == current_kpi_id,
|
||||
KPICausality.entity_id == entity_id,
|
||||
).all()
|
||||
|
||||
for edge in downstream:
|
||||
target_id = edge.target_kpi_id
|
||||
if target_id in visited:
|
||||
continue
|
||||
target_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
|
||||
if not target_kpi:
|
||||
continue
|
||||
|
||||
# 计算影响: 变化率 × 强度 × 方向
|
||||
edge_strength = edge.strength or 0.5
|
||||
direction_factor = 1.0 if edge.direction == "positive" else -1.0
|
||||
propagated_change = current_change * edge_strength * direction_factor
|
||||
|
||||
# 获取当前值
|
||||
tgt_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == target_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
month_period_clause(KPIValue.period),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
predicted_value = None
|
||||
if tgt_val and tgt_val.actual_value:
|
||||
predicted_value = round(tgt_val.actual_value * (1 + propagated_change / 100), 2)
|
||||
|
||||
impacts.append({
|
||||
"kpi_id": target_id,
|
||||
"kpi_code": target_kpi.kpi_code,
|
||||
"kpi_name": target_kpi.kpi_name,
|
||||
"dimension": target_kpi.dimension,
|
||||
"current_value": tgt_val.actual_value if tgt_val else None,
|
||||
"predicted_value": predicted_value,
|
||||
"change_pct": round(propagated_change, 2),
|
||||
"strength": edge_strength,
|
||||
"direction": edge.direction,
|
||||
"lag_months": edge.lag_months,
|
||||
"depth": depth + 1,
|
||||
"path_strength": round(cum_strength * edge_strength, 3),
|
||||
})
|
||||
|
||||
# 继续遍历下游
|
||||
new_cum = cum_strength * edge_strength
|
||||
if new_cum > 0.05 and depth < 5:
|
||||
queue.append((target_id, propagated_change, depth + 1, new_cum))
|
||||
|
||||
return {
|
||||
"source": {
|
||||
"kpi_id": source_kpi.id,
|
||||
"kpi_code": source_kpi.kpi_code,
|
||||
"kpi_name": source_kpi.kpi_name,
|
||||
"current_value": current_value,
|
||||
"new_value": new_value,
|
||||
"change_pct": round(change_pct, 2),
|
||||
},
|
||||
"impacts": impacts,
|
||||
"total_impacted": len(impacts),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CRUD (动态路径)
|
||||
# ============================================================
|
||||
|
||||
@router.get("")
|
||||
def list_causalities(
|
||||
source_kpi_id: Optional[int] = None,
|
||||
target_kpi_id: Optional[int] = None,
|
||||
verify_status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b, 2026-08-27 支持verify_status筛选)"""
|
||||
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
|
||||
if source_kpi_id:
|
||||
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
|
||||
if target_kpi_id:
|
||||
query = query.filter(KPICausality.target_kpi_id == target_kpi_id)
|
||||
if verify_status:
|
||||
query = query.filter(KPICausality.verify_status == verify_status)
|
||||
items = query.order_by(KPICausality.id).all()
|
||||
|
||||
result = []
|
||||
for c in items:
|
||||
d = _to_dict(c)
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
d["source_kpi_code"] = src.kpi_code if src else None
|
||||
d["source_kpi_name"] = src.kpi_name if src else None
|
||||
d["target_kpi_code"] = tgt.kpi_code if tgt else None
|
||||
d["target_kpi_name"] = tgt.kpi_name if tgt else None
|
||||
result.append(d)
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/verify-status")
|
||||
def get_verify_status(
|
||||
verify_status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""验证状态总览 — 按状态统计 + 链列表(2026-08-27 三层验证机制)
|
||||
|
||||
可选 ?verify_status=pending/data_verified/human_verified/disputed 筛选
|
||||
"""
|
||||
if verify_status and verify_status not in ALL_STATUSES:
|
||||
raise HTTPException(400, f"verify_status 必须为 {'/'.join(ALL_STATUSES)}")
|
||||
|
||||
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
|
||||
if verify_status:
|
||||
query = query.filter(KPICausality.verify_status == verify_status)
|
||||
items = query.order_by(KPICausality.id).all()
|
||||
|
||||
by_status = {s: 0 for s in ALL_STATUSES}
|
||||
data = []
|
||||
for c in items:
|
||||
by_status[c.verify_status] = by_status.get(c.verify_status, 0) + 1
|
||||
d = _to_dict(c)
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
d["source_kpi_code"] = src.kpi_code if src else None
|
||||
d["source_kpi_name"] = src.kpi_name if src else None
|
||||
d["target_kpi_code"] = tgt.kpi_code if tgt else None
|
||||
d["target_kpi_name"] = tgt.kpi_name if tgt else None
|
||||
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
|
||||
data.append(d)
|
||||
|
||||
return {
|
||||
"summary": {"total": len(items), "by_status": by_status},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{causality_id}")
|
||||
def get_causality(causality_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
d = _to_dict(c)
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
d["source"] = {"id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name} if src else None
|
||||
d["target"] = {"id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name} if tgt else None
|
||||
return d
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""创建因果链(source_type标记来源, 2026-08-27)"""
|
||||
source_id = data.get("source_kpi_id")
|
||||
target_id = data.get("target_kpi_id")
|
||||
if not source_id or not target_id:
|
||||
raise HTTPException(400, "必须指定源KPI和目标KPI")
|
||||
if source_id == target_id:
|
||||
raise HTTPException(400, "源和目标不能相同")
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == source_id).first()
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
|
||||
if not src or not tgt:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if src.entity_id != tgt.entity_id:
|
||||
raise HTTPException(400, "源KPI和目标KPI必须属于同一企业")
|
||||
|
||||
existing = db.query(KPICausality).filter(
|
||||
KPICausality.source_kpi_id == source_id,
|
||||
KPICausality.target_kpi_id == target_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"因果链已存在: {src.kpi_code}→{tgt.kpi_code}")
|
||||
|
||||
source_type = data.get("source_type", "manual")
|
||||
if source_type not in ALL_SOURCE_TYPES:
|
||||
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
|
||||
|
||||
c = KPICausality(
|
||||
entity_id=src.entity_id,
|
||||
source_kpi_id=source_id,
|
||||
target_kpi_id=target_id,
|
||||
strength=data.get("strength", 0.5),
|
||||
lag_months=data.get("lag_months", 1),
|
||||
formula=data.get("formula"),
|
||||
direction=data.get("direction", "positive"),
|
||||
source_type=source_type,
|
||||
verify_status=STATUS_PENDING,
|
||||
)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
log_op(db, "create", target_type="kpi_causality", target_id=c.id,
|
||||
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})",
|
||||
entity_id=c.entity_id)
|
||||
db.commit()
|
||||
return _to_dict(c)
|
||||
|
||||
|
||||
@router.put("/{causality_id}")
|
||||
def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
for field in ("strength", "lag_months", "formula", "direction", "source_type"):
|
||||
if field in data:
|
||||
if field == "source_type" and data[field] not in ALL_SOURCE_TYPES:
|
||||
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
|
||||
setattr(c, field, data[field])
|
||||
# 修改链定义后,验证状态回到待检(定义变了旧结论失效)
|
||||
if any(f in data for f in ("strength", "lag_months", "formula", "direction")):
|
||||
c.verify_status = STATUS_PENDING
|
||||
c.verified_at = None
|
||||
c.verified_by = None
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
return _to_dict(c)
|
||||
|
||||
|
||||
@router.put("/{causality_id}/verify")
|
||||
def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""人工确认(战略回顾会核对打标)— 2026-08-27 三层验证机制
|
||||
|
||||
Body: { verify_status: "human_verified"|"disputed", verified_by?: "任富海" }
|
||||
默认打标 human_verified(人工最终确认)。
|
||||
"""
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if not c:
|
||||
raise HTTPException(404, "因果链不存在")
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
|
||||
target_status = data.get("verify_status", "human_verified")
|
||||
if target_status not in ("human_verified", "disputed"):
|
||||
raise HTTPException(400, "verify_status 必须为 human_verified 或 disputed")
|
||||
verified_by = data.get("verified_by") or user.name or user.username
|
||||
|
||||
c.verify_status = target_status
|
||||
c.verified_at = datetime.now()
|
||||
c.verified_by = str(verified_by)[:50]
|
||||
log_op(db, "verify", target_type="kpi_causality", target_id=causality_id,
|
||||
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})",
|
||||
entity_id=c.entity_id)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
d = _to_dict(c)
|
||||
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
|
||||
return d
|
||||
|
||||
|
||||
@router.delete("/{causality_id}")
|
||||
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
|
||||
if c:
|
||||
if c.entity_id != entity_id:
|
||||
raise HTTPException(404, "因果链不存在") # 账套隔离
|
||||
db.delete(c)
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""KPI数据治理4条规则:入库必检 + 元数据必填 + 编码规范 + 战略分级
|
||||
|
||||
规则1: 入库必检 — dimension/target_value/unit 必填(创建/更新强制拦截)
|
||||
规则2: 元数据必填 — formula/data_source/data_owner 不能为空或占位符(待补充/待指定/-)
|
||||
规则3: 编码规范 — kpi_code 必须以 F_/C_/P_/L_ 前缀开头且与 dimension 一致;禁止跨层同名
|
||||
规则4: 战略/运营分级 — kpi_level 必须是 strategic/operational
|
||||
|
||||
API:
|
||||
- POST /api/cma/kpi/validate 校验单个KPI数据 → {valid, errors}
|
||||
- GET /api/cma/kpi/governance/audit 全量校验 → 按4条规则分组的不合规清单
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/kpi",
|
||||
tags=["KPI数据治理"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# 维度 → 编码前缀
|
||||
DIM_PREFIX = {"finance": "F", "customer": "C", "process": "P", "learning": "L"}
|
||||
# 历史遗留兼容前缀: EXT_ = 科目余额表导入的财务科目KPI(仅限 finance 维度)
|
||||
LEGACY_PREFIX_DIM = {"EXT": "finance", "FB": "process"}
|
||||
# 品牌级业务KPI前缀(跨维度合法,2026-08-21 治理审计适配: BH_ = 博海业务KPI系列)
|
||||
BRAND_PREFIXES = ("BH",)
|
||||
VALID_LEVELS = ("strategic", "operational")
|
||||
# 视为"未完善"的占位符值
|
||||
PLACEHOLDERS = ("待补充", "待指定", "待完善", "待定", "暂无", "TBD", "tbd", "-", "--", "N/A", "n/a")
|
||||
|
||||
# 规则2必填元数据字段
|
||||
META_FIELDS = [
|
||||
("formula", "计算公式"),
|
||||
("data_source", "数据来源"),
|
||||
("data_owner", "数据责任人"),
|
||||
("kpi_name", "KPI名称"),
|
||||
]
|
||||
|
||||
|
||||
def _clean_str(val) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
if isinstance(val, str):
|
||||
return val.strip()
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
def _is_placeholder(val) -> bool:
|
||||
"""空值或占位符(待补充/待指定/- 等)视为未完善"""
|
||||
s = _clean_str(val)
|
||||
if not s:
|
||||
return True
|
||||
return s in PLACEHOLDERS
|
||||
|
||||
|
||||
def validate_kpi_payload(
|
||||
data: dict,
|
||||
db: Session = None,
|
||||
current_kpi_id: Optional[int] = None,
|
||||
is_update: bool = False,
|
||||
) -> List[dict]:
|
||||
"""校验单个KPI数据(4条规则)。
|
||||
|
||||
- data: 提交的KPI字段字典(创建或更新的载荷)
|
||||
- db: SQLAlchemy Session(用于跨层同名/编码唯一性检查,可为None)
|
||||
- current_kpi_id: 更新时传KPI自身id(避免自检误报)
|
||||
- is_update: 更新模式 — 仅校验载荷中显式出现的字段
|
||||
|
||||
返回 [{rule: int, field: str, message: str}, ...],空列表=合规。
|
||||
"""
|
||||
issues: List[dict] = []
|
||||
|
||||
def add(rule: int, field: str, message: str):
|
||||
issues.append({"rule": rule, "field": field, "message": message})
|
||||
|
||||
code = _clean_str(data.get("kpi_code"))
|
||||
dimension = _clean_str(data.get("dimension"))
|
||||
has_code = bool(code)
|
||||
|
||||
# ── 规则1: 入库必检 dimension/target_value/unit ──
|
||||
if not is_update or "dimension" in data:
|
||||
if not dimension:
|
||||
add(1, "dimension", "缺少dimension(所属维度: finance/customer/process/learning)")
|
||||
if not is_update or "target_value" in data:
|
||||
tv = data.get("target_value")
|
||||
if tv is None or (isinstance(tv, str) and _clean_str(tv) == ""):
|
||||
add(1, "target_value", "缺少target_value(目标值)")
|
||||
if not is_update or "unit" in data:
|
||||
if _is_placeholder(data.get("unit")):
|
||||
add(1, "unit", "缺少unit(单位)")
|
||||
|
||||
# ── 规则2: 元数据必填(不能为空或占位符)──
|
||||
for field, label in META_FIELDS:
|
||||
if not is_update or field in data:
|
||||
if _is_placeholder(data.get(field)):
|
||||
add(2, field, f"元数据未完善: {label}({field})不能为空或占位符(待补充/待指定/-)")
|
||||
|
||||
# ── 规则3: 编码规范 ──
|
||||
if not is_update or "kpi_code" in data:
|
||||
if not has_code:
|
||||
add(3, "kpi_code", "缺少kpi_code(KPI编码)")
|
||||
else:
|
||||
prefix = code.split("_")[0] if "_" in code else code
|
||||
if prefix not in ("F", "C", "P", "L"):
|
||||
# 兼容历史遗留 EXT_ 前缀(科目余额表导入的财务科目KPI,仅限finance维度)
|
||||
# 兼容 FB_ 前缀(财务Bot KPI,仅限process维度)与 BH_ 品牌前缀(跨维度合法)
|
||||
if not (LEGACY_PREFIX_DIM.get(prefix) and dimension == LEGACY_PREFIX_DIM[prefix]) \
|
||||
and prefix not in BRAND_PREFIXES:
|
||||
add(3, "kpi_code", f"编码前缀不符: {code} 应以F_/C_/P_/L_开头")
|
||||
elif dimension and DIM_PREFIX.get(dimension) and prefix != DIM_PREFIX[dimension]:
|
||||
expected = DIM_PREFIX[dimension]
|
||||
add(3, "kpi_code",
|
||||
f"编码前缀与维度不符: {code} 前缀{prefix}_ 与维度{dimension}(应为{expected}_)不一致")
|
||||
|
||||
# 禁止跨层同名: 同一kpi_code不能用于不同dimension
|
||||
if has_code and db is not None:
|
||||
dup = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
|
||||
if dup and dup.id != current_kpi_id:
|
||||
if dimension and dup.dimension and dup.dimension != dimension:
|
||||
add(3, "kpi_code",
|
||||
f"跨层同名: {code} 已用于维度{dup.dimension},不能用于维度{dimension}")
|
||||
elif not dimension:
|
||||
add(3, "kpi_code", f"编码已存在: {code} 已注册(维度{dup.dimension}),不能重复使用")
|
||||
|
||||
# ── 规则4: 战略/运营分级 ──
|
||||
if not is_update or "kpi_level" in data:
|
||||
lv = data.get("kpi_level")
|
||||
if lv is not None and lv not in VALID_LEVELS:
|
||||
add(4, "kpi_level", f"kpi_level必须是strategic或operational,当前值: {lv}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def kpi_issues_message(issues: List[dict]) -> List[str]:
|
||||
"""issue dict列表 → 纯文本错误列表"""
|
||||
return [i["message"] for i in issues]
|
||||
|
||||
|
||||
@router.post("/validate")
|
||||
def validate_kpi(
|
||||
kpi_data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""校验单个KPI数据(不入库)。
|
||||
|
||||
请求体: KPI字段字典,可选 kpi_id 标识正在编辑的KPI(避免跨层同名误报)。
|
||||
返回: {"valid": bool, "errors": [str], "details": [{rule, field, message}]}
|
||||
"""
|
||||
kpi_id = kpi_data.get("kpi_id") if isinstance(kpi_data.get("kpi_id"), int) else None
|
||||
issues = validate_kpi_payload(kpi_data, db=db, current_kpi_id=kpi_id)
|
||||
return {
|
||||
"valid": len(issues) == 0,
|
||||
"errors": kpi_issues_message(issues),
|
||||
"details": issues,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/governance/audit")
|
||||
def governance_audit(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""校验本账套活跃KPI,输出按4条规则分组的不合规清单(账套隔离 2026-09-21)。"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
|
||||
non_compliant = []
|
||||
by_rule: dict = {1: [], 2: [], 3: [], 4: []}
|
||||
for k in kpis:
|
||||
payload = {c.name: getattr(k, c.name) for c in k.__table__.columns}
|
||||
issues = validate_kpi_payload(payload, db=db, current_kpi_id=k.id)
|
||||
if issues:
|
||||
entry = {
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"kpi_level": k.kpi_level,
|
||||
"issues": issues,
|
||||
}
|
||||
non_compliant.append(entry)
|
||||
for i in issues:
|
||||
by_rule.setdefault(i["rule"], []).append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"field": i["field"],
|
||||
"message": i["message"],
|
||||
})
|
||||
|
||||
rule_counts = {str(r): len(items) for r, items in by_rule.items()}
|
||||
return {
|
||||
"total": len(kpis),
|
||||
"compliant": len(kpis) - len(non_compliant),
|
||||
"non_compliant_count": len(non_compliant),
|
||||
"rule_counts": rule_counts,
|
||||
"by_rule": {str(r): items for r, items in by_rule.items()},
|
||||
"non_compliant": non_compliant,
|
||||
}
|
||||
+818
-23
@@ -7,8 +7,18 @@ from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
|
||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog
|
||||
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
|
||||
from app.utils.oplog import log_op
|
||||
from app.utils.alert_entity_scope import month_period_clause
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||
from app.utils.threshold_parser import (
|
||||
calc_level as _tp_calc_level,
|
||||
is_valid_threshold,
|
||||
THRESHOLD_HELP,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
@@ -26,6 +36,8 @@ def list_kpis(
|
||||
keyword: Optional[str] = None,
|
||||
epic: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
kpi_level: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
@@ -35,16 +47,25 @@ def list_kpis(
|
||||
if dims:
|
||||
query = query.filter(KPIDefinition.dimension.in_(dims))
|
||||
if dimension:
|
||||
query = query.filter(KPIDefinition.dimension == dimension)
|
||||
dims_list = [d.strip() for d in dimension.split(',')] if ',' in dimension else [dimension]
|
||||
query = query.filter(KPIDefinition.dimension.in_(dims_list))
|
||||
if keyword:
|
||||
query = query.filter(KPIDefinition.kpi_name.contains(keyword))
|
||||
if epic:
|
||||
query = query.filter(KPIDefinition.epic == epic)
|
||||
if category:
|
||||
query = query.filter(KPIDefinition.category == category)
|
||||
cats_list = [c.strip() for c in category.split(',')] if ',' in category else [category]
|
||||
query = query.filter(KPIDefinition.category.in_(cats_list))
|
||||
if entity_id is not None:
|
||||
query = query.filter(KPIDefinition.entity_id == entity_id)
|
||||
if kpi_level:
|
||||
query = query.filter(KPIDefinition.kpi_level == kpi_level)
|
||||
total = query.count()
|
||||
kpis = query.order_by(KPIDefinition.kpi_code).offset((page-1)*page_size).limit(page_size).all()
|
||||
return {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
|
||||
result = {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
|
||||
if entity_id is not None:
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if ent:
|
||||
result["entity"] = {"id": ent.id, "name": ent.name, "short_name": ent.short_name}
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
@@ -89,64 +110,834 @@ def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depen
|
||||
return {"tree": tree, "total": sum(dim_counts.values())}
|
||||
|
||||
|
||||
@router.get("/{kpi_id}")
|
||||
def get_kpi(kpi_id: int, db: Session = Depends(get_db)):
|
||||
# ============================================================
|
||||
# KPI-5: 五档评分引擎(静态路由必须在动态/{kpi_id}之前)
|
||||
# ============================================================
|
||||
|
||||
# ── 反向指标(值越低越好)· 单一事实源(single source of truth) ──
|
||||
# 本表是「指标方向」的唯一权威定义(2026-09-15 M2 准入2 收口):凡按方向判级别/评分的代码
|
||||
# 一律引用本表,禁止再复制字面量副本。
|
||||
# - app/api/dashboard.py「工作台三色」原自带一份副本 → 已改为 from app.api.kpis import
|
||||
# - app/api/bot_kpis.py 原 import 本表却未使用 → 已改为派生自本表
|
||||
# 判定语义:在内 = 越低越好(实际/目标 ≤0.5 → 5 分);不在内 = 越高越好(比值 ≥1.2 → 5 分)
|
||||
# 方向口径注意(两处「返补/返利」现金流方向相反,禁止误合并):
|
||||
# - C_REBATE_RATE / F_REBATE_RATE = 渠道返补(对外付出,越低越好 → 反向,留在本表)
|
||||
# - F_FACTORY_REBATE_RATE = 上游厂补率(厂补(捐赠收益)/主营成本,收到越多越好 → 正向,不属本表)
|
||||
# 2026-09-15 修正:原被误列入本表 → 现网 278.7%(target_value=80)被判 1 分"失效";
|
||||
# 与其三色阈值文本(green>=100=1.25×target / yellow>=80=target / red<80,越高越好)方向相反。
|
||||
# P-6 定稿 = 改方向(阈值文本保持不动),修正后同一实测值判 5 分。
|
||||
REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RATIO',
|
||||
'F_COST_RATIO', 'F_AR_DAYS', 'F_REBATE_RATE',
|
||||
'F_COST_CONTROL_RATE', 'F_INV_DAYS']
|
||||
|
||||
|
||||
def _static_threshold_level(value, kpi) -> str:
|
||||
"""按 KPI 静态阈值文本判定级别(统一解析器,判定顺序 red→green→yellow),无阈值返回 info。
|
||||
|
||||
供保本派生KPI(F_BEP_*)在详情页使用,与 alert_generator 语义一致:
|
||||
解析与判定逻辑统一收敛到 app.utils.threshold_parser(单一事实源,2026-09-11 修复双实现+红灯遮蔽)。"""
|
||||
return _tp_calc_level(value, kpi)
|
||||
|
||||
|
||||
def _calc_five_tier_score(current_value, target_value, is_reverse=False):
|
||||
"""五档评分:1-5分(支持正反向指标)"""
|
||||
if current_value is None or target_value is None or target_value == 0:
|
||||
return None, "info"
|
||||
ratio = current_value / target_value
|
||||
if is_reverse:
|
||||
# 反向指标:实际值越低越好
|
||||
if ratio <= 0.5:
|
||||
return 5, "success" # 远低于目标→卓越
|
||||
elif ratio <= 0.8:
|
||||
return 4, "success" # 低于目标→达标
|
||||
elif ratio <= 1.0:
|
||||
return 3, "warning" # 接近目标→预警
|
||||
elif ratio <= 1.2:
|
||||
return 2, "danger" # 超过目标→危险
|
||||
else:
|
||||
return 1, "danger" # 远超目标→失效
|
||||
else:
|
||||
if ratio >= 1.2:
|
||||
return 5, "success" # 卓越
|
||||
elif ratio >= 1.0:
|
||||
return 4, "success" # 达标
|
||||
elif ratio >= 0.8:
|
||||
return 3, "warning" # 预警
|
||||
elif ratio >= 0.5:
|
||||
return 2, "danger" # 危险
|
||||
else:
|
||||
return 1, "danger" # 失效
|
||||
|
||||
|
||||
@router.get("/score")
|
||||
def get_kpi_score(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
period: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""五档评分引擎 - 返回各KPI评分和BSC四层汇总
|
||||
评分: 5卓越(≥1.2×目标) 4达标(≥目标) 3预警(≥0.8×目标) 2危险(≥0.5×目标) 1失效(<0.5×目标)
|
||||
"""
|
||||
# 获取该企业所有活跃KPI
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).all()
|
||||
|
||||
if not kpis:
|
||||
return {"entity_id": entity_id, "kpis": [], "layers": {}, "overall": None}
|
||||
|
||||
# 获取企业信息
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
entity_info = {"id": ent.id, "name": ent.name, "short_name": ent.short_name} if ent else {"id": entity_id}
|
||||
|
||||
# 单个KPI评分
|
||||
kpi_scores = []
|
||||
for k in kpis:
|
||||
# 取最新实际值
|
||||
val_query = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
)
|
||||
if period:
|
||||
val_query = val_query.filter(KPIValue.period == period)
|
||||
val_query = val_query.filter(month_period_clause(KPIValue.period))
|
||||
latest_val = val_query.order_by(KPIValue.period.desc()).first()
|
||||
|
||||
current_val = latest_val.actual_value if latest_val else None
|
||||
score, status = _calc_five_tier_score(current_val, k.target_value, is_reverse=(k.kpi_code in REVERSE_INDICATORS))
|
||||
|
||||
kpi_scores.append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"target_value": k.target_value,
|
||||
"current_value": current_val,
|
||||
"score": score,
|
||||
"status": status,
|
||||
"unit": k.unit,
|
||||
"weight": 10,
|
||||
"period": latest_val.period if latest_val else None,
|
||||
})
|
||||
|
||||
# BSC四层汇总
|
||||
layer_map = {
|
||||
"finance": {"label": "财务", "order": 0},
|
||||
"customer": {"label": "客户", "order": 1},
|
||||
"process": {"label": "流程", "order": 2},
|
||||
"learning": {"label": "学习成长", "order": 3},
|
||||
}
|
||||
layers = {}
|
||||
total_weighted_score = 0
|
||||
total_weight = 0
|
||||
|
||||
for dim_key, dim_info in layer_map.items():
|
||||
layer_kpis = [s for s in kpi_scores if s["dimension"] == dim_key and s["score"] is not None]
|
||||
if not layer_kpis:
|
||||
layers[dim_key] = {"label": dim_info["label"], "score": None, "status": "info", "kpi_count": 0, "weighted_score": None}
|
||||
continue
|
||||
|
||||
w = sum(k["weight"] for k in layer_kpis)
|
||||
ws = sum(k["score"] * k["weight"] for k in layer_kpis)
|
||||
avg_score = ws / w if w > 0 else None
|
||||
avg_status = "success" if avg_score and avg_score >= 4 else ("warning" if avg_score and avg_score >= 3 else "danger") if avg_score else "info"
|
||||
|
||||
layers[dim_key] = {
|
||||
"label": dim_info["label"],
|
||||
"score": round(avg_score, 2) if avg_score else None,
|
||||
"status": avg_status,
|
||||
"kpi_count": len(layer_kpis),
|
||||
"weighted_score": round(avg_score, 2) if avg_score else None,
|
||||
}
|
||||
|
||||
if avg_score:
|
||||
total_weighted_score += avg_score * len(layer_kpis)
|
||||
total_weight += len(layer_kpis)
|
||||
|
||||
# 综合得分
|
||||
overall_score = round(total_weighted_score / total_weight, 2) if total_weight > 0 else None
|
||||
overall_status = "success" if overall_score and overall_score >= 4 else ("warning" if overall_score and overall_score >= 3 else "danger") if overall_score else "info"
|
||||
|
||||
return {
|
||||
"entity": entity_info,
|
||||
"kpis": kpi_scores,
|
||||
"layers": layers,
|
||||
"overall": {"score": overall_score, "status": overall_status},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# KPI-glossary: 知识资产化 — KPI字典实时加载(供ChatBI财务Bot调用)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/glossary")
|
||||
def get_kpi_glossary(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""KPI字典实时加载 — 返回所有KPI的定义、当前值、目标值、公式、维度、阈值
|
||||
|
||||
供ChatBI财务Bot在分析前调用,确保口径与系统一致。
|
||||
返回字段: kpi_code, kpi_name, current_value, target_value, formula, dimension, threshold
|
||||
"""
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).order_by(KPIDefinition.kpi_code).all()
|
||||
|
||||
result = []
|
||||
for k in kpis:
|
||||
# 获取最新实际值
|
||||
latest_val = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == k.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
month_period_clause(KPIValue.period),
|
||||
).order_by(KPIValue.period.desc()).first()
|
||||
|
||||
current_value = latest_val.actual_value if latest_val else None
|
||||
latest_period = latest_val.period if latest_val else None
|
||||
|
||||
# 组装阈值描述
|
||||
threshold = None
|
||||
if k.threshold_green or k.threshold_yellow or k.threshold_red:
|
||||
parts = []
|
||||
if k.threshold_green:
|
||||
parts.append(f"绿灯:{k.threshold_green}")
|
||||
if k.threshold_yellow:
|
||||
parts.append(f"黄灯:{k.threshold_yellow}")
|
||||
if k.threshold_red:
|
||||
parts.append(f"红灯:{k.threshold_red}")
|
||||
threshold = " | ".join(parts)
|
||||
|
||||
result.append({
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category,
|
||||
"formula": k.formula,
|
||||
"formula_desc": k.formula_desc,
|
||||
"unit": k.unit,
|
||||
"target_value": k.target_value,
|
||||
"current_value": current_value,
|
||||
"latest_period": latest_period,
|
||||
"threshold": threshold,
|
||||
"responsible_dept": k.responsible_dept,
|
||||
"responsible_user": k.responsible_user,
|
||||
"data_source": k.data_source,
|
||||
"data_owner": k.data_owner,
|
||||
"frequency": k.frequency,
|
||||
"status": k.status,
|
||||
})
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"total": len(result),
|
||||
"glossary": result,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# KPI-6: KPI三级分解树
|
||||
# ============================================================
|
||||
|
||||
@router.get("/hierarchy")
|
||||
def get_kpi_hierarchy(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
kpi_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""KPI三级分解树:公司→部门→个人"""
|
||||
query = db.query(KPIHierarchy).filter(KPIHierarchy.entity_id == entity_id)
|
||||
if kpi_id is not None:
|
||||
query = query.filter(
|
||||
(KPIHierarchy.parent_kpi_id == kpi_id) | (KPIHierarchy.child_kpi_id == kpi_id)
|
||||
)
|
||||
relations = query.order_by(KPIHierarchy.level).all()
|
||||
|
||||
if not relations:
|
||||
# 无层级数据,返回公司级KPI作为根节点
|
||||
kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).limit(20).all()
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"tree": [{"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "level": 1, "children": []} for k in kpis],
|
||||
"total": len(kpis),
|
||||
}
|
||||
|
||||
# 构建树
|
||||
kpi_ids = set()
|
||||
for r in relations:
|
||||
kpi_ids.add(r.parent_kpi_id)
|
||||
kpi_ids.add(r.child_kpi_id)
|
||||
|
||||
kpi_map = {}
|
||||
for kid in kpi_ids:
|
||||
k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
|
||||
if k:
|
||||
kpi_map[kid] = {"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"dimension": k.dimension, "level": None, "children": []}
|
||||
|
||||
# 分配层级
|
||||
for r in relations:
|
||||
if r.parent_kpi_id in kpi_map:
|
||||
kpi_map[r.parent_kpi_id]["level"] = 1 # 公司级
|
||||
if r.child_kpi_id in kpi_map:
|
||||
current_level = kpi_map[r.child_kpi_id].get("level")
|
||||
new_level = r.level or 2
|
||||
if current_level is None or current_level > new_level:
|
||||
kpi_map[r.child_kpi_id]["level"] = new_level
|
||||
|
||||
# 构造父子关系
|
||||
tree = []
|
||||
added = set()
|
||||
for r in relations:
|
||||
parent = kpi_map.get(r.parent_kpi_id)
|
||||
child = kpi_map.get(r.child_kpi_id)
|
||||
if parent and child:
|
||||
child_node = dict(child)
|
||||
child_node["weight"] = r.weight
|
||||
child_node["child_name"] = r.child_name
|
||||
# 避免重复添加
|
||||
child_key = r.child_kpi_id
|
||||
existing_child = next(
|
||||
(c for c in parent["children"] if c["id"] == child_key), None
|
||||
)
|
||||
if not existing_child:
|
||||
parent["children"].append(child_node)
|
||||
|
||||
# 收集顶级节点(有子节点且未被引用的parent)
|
||||
all_child_ids = {r.child_kpi_id for r in relations}
|
||||
for r in relations:
|
||||
pid = r.parent_kpi_id
|
||||
if pid not in all_child_ids or pid == (kpi_id if kpi_id else -1):
|
||||
if pid not in added and pid in kpi_map:
|
||||
tree.append(kpi_map[pid])
|
||||
added.add(pid)
|
||||
|
||||
# 如果kpi_id指定,返回该节点为根的子树
|
||||
if kpi_id is not None and kpi_id in kpi_map:
|
||||
root = kpi_map[kpi_id]
|
||||
return {"entity_id": entity_id, "tree": [root], "total": len(tree)}
|
||||
|
||||
# 否则按level排序
|
||||
tree.sort(key=lambda n: (n.get("level") or 99, n["kpi_code"]))
|
||||
|
||||
return {"entity_id": entity_id, "tree": tree, "total": len(tree)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# KPI-8: KPI因果链追踪
|
||||
# ============================================================
|
||||
|
||||
@router.get("/{kpi_id}/causality-chain")
|
||||
def get_kpi_causality_chain(
|
||||
kpi_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""KPI因果链追踪 — 返回单个KPI的上下游因果链(账套隔离 2026-09-21:跨账套 id → 404)"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
# 上游(驱动当前KPI的因子)
|
||||
upstream = db.query(KPICausality).filter(KPICausality.target_kpi_id == kpi_id).all()
|
||||
upstream_list = []
|
||||
for c in upstream:
|
||||
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
|
||||
if src:
|
||||
upstream_list.append({
|
||||
"causality_id": c.id,
|
||||
"kpi_id": src.id,
|
||||
"kpi_code": src.kpi_code,
|
||||
"kpi_name": src.kpi_name,
|
||||
"dimension": src.dimension,
|
||||
"layer": src.dimension,
|
||||
"strength": c.strength,
|
||||
"lag_months": c.lag_months,
|
||||
"direction": c.direction,
|
||||
"formula": c.formula,
|
||||
})
|
||||
|
||||
# 下游(当前KPI影响的指标)
|
||||
downstream = db.query(KPICausality).filter(KPICausality.source_kpi_id == kpi_id).all()
|
||||
downstream_list = []
|
||||
for c in downstream:
|
||||
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
|
||||
if tgt:
|
||||
downstream_list.append({
|
||||
"causality_id": c.id,
|
||||
"kpi_id": tgt.id,
|
||||
"kpi_code": tgt.kpi_code,
|
||||
"kpi_name": tgt.kpi_name,
|
||||
"dimension": tgt.dimension,
|
||||
"layer": tgt.dimension,
|
||||
"strength": c.strength,
|
||||
"lag_months": c.lag_months,
|
||||
"direction": c.direction,
|
||||
"formula": c.formula,
|
||||
})
|
||||
|
||||
return {
|
||||
"kpi": {
|
||||
"id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"layer": kpi.dimension,
|
||||
},
|
||||
"drives": downstream_list,
|
||||
"driven_by": upstream_list,
|
||||
"total_upstream": len(upstream_list),
|
||||
"total_downstream": len(downstream_list),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 动态路由(必须在静态路由之后)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{kpi_id}/values")
|
||||
def create_kpi_value(
|
||||
kpi_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""录入KPI单值(人工数据录入,用于客户/流程/学习层台账数据)
|
||||
Body: {period: '2026-08', actual_value: 85}
|
||||
"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
return kpi_to_dict(kpi)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
period = data.get("period")
|
||||
actual_value = data.get("actual_value")
|
||||
if not period or actual_value is None:
|
||||
raise HTTPException(400, "缺少必要参数: period, actual_value")
|
||||
|
||||
# 同一期间重复录入 → 更新
|
||||
existing = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.period == period,
|
||||
KPIValue.source_type == "manual",
|
||||
).first()
|
||||
if existing:
|
||||
existing.actual_value = float(actual_value)
|
||||
existing.data_status = "verified"
|
||||
existing.remark = f"人工录入(更新) by {current_user.username}"
|
||||
db.commit()
|
||||
return {"message": "已更新", "id": existing.id}
|
||||
|
||||
new_val = KPIValue(
|
||||
kpi_id=kpi_id,
|
||||
entity_id=entity_id,
|
||||
period=period,
|
||||
actual_value=float(actual_value),
|
||||
source_type="manual",
|
||||
source_batch=f"manual-{current_user.username}-{datetime.now().strftime('%Y%m%d')}",
|
||||
data_status="verified",
|
||||
remark=f"人工录入 by {current_user.username}",
|
||||
)
|
||||
db.add(new_val)
|
||||
db.commit()
|
||||
return {"message": "已录入", "id": new_val.id, "period": period, "actual_value": float(actual_value)}
|
||||
|
||||
|
||||
@router.get("/{kpi_id}/values")
|
||||
def list_kpi_values(
|
||||
kpi_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user: User = Depends(require_auth),
|
||||
):
|
||||
"""查询KPI实际值列表(含source_type标记,供归集标签页展示)"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
vals = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id, month_period_clause(KPIValue.period)
|
||||
).order_by(KPIValue.period.desc()).all()
|
||||
return {
|
||||
"data": [{
|
||||
"id": v.id,
|
||||
"period": v.period,
|
||||
"actual_value": v.actual_value,
|
||||
"source_type": v.source_type or "manual",
|
||||
"source_batch": v.source_batch or "",
|
||||
"data_status": v.data_status,
|
||||
"remark": v.remark or "",
|
||||
# P0-① 三区标注: C区Bot回填时间
|
||||
"updated_at": v.calculated_at.strftime("%Y-%m-%d %H:%M") if v.calculated_at else None,
|
||||
} for v in vals]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{kpi_id}")
|
||||
def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
# 账套隔离: 禁止跨企业读取 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
result = kpi_to_dict(kpi)
|
||||
# 附带历史数据(前端KPI详情"历史数据"Tab依赖)
|
||||
# 2026-08-26修复: 原实现只返回定义信息,values缺失导致历史数据Tab空白
|
||||
# 2026-08-26增强: values对齐KPI元数据(目标值/偏差/红黄绿判定/单位)
|
||||
vals = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi_id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
# 实测区口径:预估行禁入趋势序列(kpi_value_scope.actual_only_clause)
|
||||
actual_only_clause(),
|
||||
).order_by(KPIValue.period.asc()).all()
|
||||
is_reverse = kpi.kpi_code in REVERSE_INDICATORS
|
||||
target = kpi.target_value
|
||||
# 保本派生KPI(F_BEP_*)使用静态阈值文本判定红黄绿(对齐 KPIList 预警tag语义),
|
||||
# 不走 target 比率——保本线是参考值不是完成率目标(cma-bep-kpi 2026-09-08)
|
||||
use_static_threshold = kpi.kpi_code.startswith("F_BEP_") and (
|
||||
kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red)
|
||||
result["values"] = []
|
||||
for v in vals:
|
||||
# 判定红黄绿(对齐工作台语义:正向≥0.9绿/≥0.7黄/否则红;反向≤目标绿/≤1.1倍黄/否则红)
|
||||
level = "info"
|
||||
score = None
|
||||
if v.actual_value is not None:
|
||||
if use_static_threshold:
|
||||
level = _static_threshold_level(v.actual_value, kpi)
|
||||
elif target:
|
||||
if is_reverse:
|
||||
if v.actual_value <= target:
|
||||
level = "green"
|
||||
elif v.actual_value <= target * 1.1:
|
||||
level = "yellow"
|
||||
else:
|
||||
level = "red"
|
||||
else:
|
||||
ratio = v.actual_value / target
|
||||
if ratio >= 0.9:
|
||||
level = "green"
|
||||
elif ratio >= 0.7:
|
||||
level = "yellow"
|
||||
else:
|
||||
level = "red"
|
||||
score, _ = _calc_five_tier_score(v.actual_value, target, is_reverse=is_reverse)
|
||||
# 偏差率(相对目标)
|
||||
deviation = None
|
||||
if target and target != 0 and v.actual_value is not None:
|
||||
deviation = round((v.actual_value - target) / target * 100, 1)
|
||||
result["values"].append({
|
||||
"id": v.id,
|
||||
"period": v.period,
|
||||
"actual_value": v.actual_value,
|
||||
"target_value": target,
|
||||
"unit": kpi.unit or "",
|
||||
"deviation_pct": deviation,
|
||||
"level": level,
|
||||
"score": score,
|
||||
"source_type": v.source_type,
|
||||
"data_status": v.data_status,
|
||||
"source_batch": v.source_batch,
|
||||
# P0-① 三区标注: C区Bot回填时间(UI BotSourceTag 消费)
|
||||
"updated_at": v.calculated_at.strftime("%Y-%m-%d %H:%M") if v.calculated_at else None,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] = None, is_update: bool = False):
|
||||
"""数据治理4条规则校验(入库必检+元数据+编码规范+战略分级),返回错误信息列表"""
|
||||
issues = validate_kpi_payload(data, db=db, current_kpi_id=current_kpi_id, is_update=is_update)
|
||||
return kpi_issues_message(issues)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# KPI多粒度目标:指标类型推断 + 周期目标派生(docs/kpi-design-rule.md 落地)
|
||||
# 规则:累计型 月×3=季、月×12=年(季×4=年);比率型 季/年沿用基准(可手调)
|
||||
# 派生为"虚拟展示值":DB只存用户手填真值,API返回时补派生值+derived标记
|
||||
# ════════════════════════════════════════════════════════════
|
||||
RATIO_NAME_HINTS = ['率', '比', '满意度', '周转', '时长', '周期', '天数', '指数', 'NPS', 'LTV', 'CAC',
|
||||
'份额', '集中度', '响应', '完成', '达成', '人均', '单价', '净推荐', '覆盖', '保留',
|
||||
'复购', '转介绍', '投诉', '合规', '认证', '掌握', '胜任', '认知', '采纳', '引用',
|
||||
'复用', '一致性', '准确', '间隙', '时效', '及时']
|
||||
ACCUM_NAME_HINTS = ['营收', '收入', '利润', '净利', '销售', '客户数', '新客', '新增', '产量', '销量',
|
||||
'金额', '现金流', '回款', '毛利额', '产值', '储备', '数量', '篇数', '报告产出',
|
||||
'提案', '发现数', '知识沉淀', '招待费']
|
||||
RATIO_UNIT_HINTS = ['%', '倍', '天', '分', '小时', '分钟']
|
||||
ACCUM_UNIT_HINTS = ['万元', '元', '个', '件', '人', '篇', '份', '万']
|
||||
|
||||
|
||||
def infer_calc_type(kpi_code: str = "", kpi_name: str = "", unit: str = "") -> str:
|
||||
"""推断指标类型: accumulate累计(可乘) / ratio比率(不可乘)。名称关键词优先于单位"""
|
||||
n = (kpi_name or "") + " " + (kpi_code or "")
|
||||
u = unit or ""
|
||||
if any(k in n for k in RATIO_NAME_HINTS):
|
||||
return "ratio"
|
||||
if any(k in n for k in ACCUM_NAME_HINTS):
|
||||
return "accumulate"
|
||||
if u in RATIO_UNIT_HINTS or u.startswith("小时"):
|
||||
return "ratio"
|
||||
if u in ACCUM_UNIT_HINTS:
|
||||
return "accumulate"
|
||||
return "ratio" # 兜底比率(率值不能乘,更安全)
|
||||
|
||||
|
||||
def derive_cycle_targets(kpi) -> dict:
|
||||
"""按指标类型派生月/季/年目标(虚拟值,不落库)。
|
||||
返回: {"derived": {monthly/quarterly/yearly: 显示值}, "flags": {monthly/quarterly/yearly: 是否派生}}
|
||||
"""
|
||||
calc_type = (getattr(kpi, "target_calc_type", None) or infer_calc_type(
|
||||
kpi.kpi_code or "", kpi.kpi_name or "", kpi.unit or "")).lower()
|
||||
m = kpi.target_monthly
|
||||
q = kpi.target_quarterly
|
||||
y = kpi.target_yearly
|
||||
freq = (kpi.frequency or "monthly").lower()
|
||||
|
||||
# 基准值(考核周期优先,回退 target_value)
|
||||
base = None
|
||||
if freq == "yearly":
|
||||
base = y
|
||||
elif freq in ("quarterly", "half_year"):
|
||||
base = q
|
||||
elif freq in ("monthly", "weekly"):
|
||||
base = m
|
||||
if base is None:
|
||||
base = kpi.target_value
|
||||
# 无基准值则不派生
|
||||
if base is None:
|
||||
return {"derived": {"monthly": m, "quarterly": q, "yearly": y},
|
||||
"flags": {"monthly": False, "quarterly": False, "yearly": False}}
|
||||
|
||||
dm, dq, dy = m, q, y
|
||||
fm, fq, fy = False, False, False
|
||||
if calc_type == "accumulate":
|
||||
# 锚点月值:手填月目标优先;月基准且手填月空时用 target_value 回退
|
||||
anchor_m = dm
|
||||
if anchor_m is None and base is not None and freq in ("monthly", "weekly"):
|
||||
anchor_m = base
|
||||
if anchor_m is not None:
|
||||
if dm is None:
|
||||
dm = anchor_m # target_value 回退显示为月基准
|
||||
if dq is None:
|
||||
dq, fq = anchor_m * 3, True
|
||||
if dy is None:
|
||||
dy, fy = anchor_m * 12, True
|
||||
elif dq is not None:
|
||||
# 季基准(累计型):年=季×4;月不反推(避免小数噪声)
|
||||
if dy is None:
|
||||
dy, fy = dq * 4, True
|
||||
else: # ratio:季/年沿用基准,不乘
|
||||
if dq is None:
|
||||
dq, fq = base, True
|
||||
if dy is None:
|
||||
dy, fy = base, True
|
||||
return {"derived": {"monthly": dm, "quarterly": dq, "yearly": dy},
|
||||
"flags": {"monthly": fm, "quarterly": fq, "yearly": fy}}
|
||||
|
||||
|
||||
def apply_calc_type_inference(data: dict, infer_missing: bool = True) -> dict:
|
||||
"""create/update 前:未显式传 target_calc_type 时按名称/单位推断。
|
||||
infer_missing=False(update场景):仅当用户显式传了空值时推断,未传则保留DB原值"""
|
||||
if "target_calc_type" in data:
|
||||
if not data.get("target_calc_type"):
|
||||
data["target_calc_type"] = infer_calc_type(
|
||||
data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", ""))
|
||||
elif infer_missing:
|
||||
data["target_calc_type"] = infer_calc_type(
|
||||
data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", ""))
|
||||
return data
|
||||
|
||||
|
||||
THRESHOLD_FIELDS = ("threshold_green", "threshold_yellow", "threshold_red")
|
||||
|
||||
|
||||
def validate_threshold_format(data: dict) -> List[str]:
|
||||
"""阈值表达式格式校验(2026-09-11):非法格式返回错误文本,让保存被拒而不是静默失效。
|
||||
|
||||
与 app.utils.threshold_parser 共用同一套语法(支持负数/区间 A~B/多条件 |)。
|
||||
"""
|
||||
errs: List[str] = []
|
||||
for f in THRESHOLD_FIELDS:
|
||||
if f in data and not is_valid_threshold(data.get(f)):
|
||||
errs.append(f"阈值格式非法 [{f}={data.get(f)}]:{THRESHOLD_HELP}")
|
||||
return errs
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
# 检查编码唯一性
|
||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", "")).first()
|
||||
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", ""), KPIDefinition.entity_id == entity_id).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI编码 {data['kpi_code']} 已存在")
|
||||
# 数据治理校验(规则1强制拦截)
|
||||
errs = _validate_kpi_data(data, db=db, is_update=False)
|
||||
errs = errs + validate_threshold_format(data)
|
||||
if errs:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a)
|
||||
data = apply_calc_type_inference(data)
|
||||
# create 侧字段白名单(OpenCode 审查残留项修复 2026-09-07):
|
||||
# 仿 update 白名单 + kpi_code(新建必填),拦截 status/important_flag/主键等敏感字段注入
|
||||
# (update 2026-08-31 已有白名单;create 此前直接 KPIDefinition(**data) 展开,可被注入)
|
||||
_create_allow = UPDATE_KPI_WHITELIST | {"kpi_code"}
|
||||
for k in list(data.keys()):
|
||||
if k != "entity_id" and k not in _create_allow:
|
||||
data.pop(k, None)
|
||||
kpi = KPIDefinition(**data)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
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)
|
||||
|
||||
|
||||
# 可更新字段白名单(安全修复 2026-08-31):update_kpi 只允许更新业务属性。
|
||||
# status / important_flag 等敏感字段及主键/归属字段(id/kpi_code/entity_id/map_id/created_*)一律忽略,
|
||||
# 防越权修改(如借 update 篡改重要标记/上下架状态)。
|
||||
UPDATE_KPI_WHITELIST = {
|
||||
"kpi_name", "dimension", "objective", "formula", "formula_desc",
|
||||
"data_source_type", "data_source_config", "data_source", "data_owner",
|
||||
"frequency", "unit", "target_value", "target_monthly", "target_quarterly",
|
||||
"target_yearly", "target_calc_type", "threshold_green", "threshold_yellow",
|
||||
"threshold_red", "category", "data_level", "data_category",
|
||||
"responsible_dept", "responsible_user", "kpi_level", "bot_source", "epic",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{kpi_id}")
|
||||
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
# 账套隔离: 禁止跨企业修改 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
# 数据治理校验(更新时只检查传了但为空的字段)
|
||||
errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True)
|
||||
errs = errs + validate_threshold_format(data)
|
||||
if errs:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
|
||||
data.pop("entity_id", None) # 禁止通过update改企业归属
|
||||
data = apply_calc_type_inference(data, infer_missing=False)
|
||||
# 白名单过滤(安全修复 2026-08-31):白名单外字段(status/important_flag/主键等)忽略不修改
|
||||
for k in list(data.keys()):
|
||||
if k not in UPDATE_KPI_WHITELIST:
|
||||
data.pop(k, None)
|
||||
for k, v in data.items():
|
||||
if hasattr(kpi, k) and v is not None:
|
||||
setattr(kpi, k, v)
|
||||
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)
|
||||
|
||||
|
||||
@router.delete("/{kpi_id}")
|
||||
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if kpi:
|
||||
# 账套隔离: 禁止跨企业删除 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
kpi.status = "disabled"
|
||||
db.commit()
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.put("/{kpi_id}/restore")
|
||||
def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if kpi:
|
||||
# 账套隔离: 禁止跨企业恢复 (2026-08-23 P1a)
|
||||
if kpi.entity_id != entity_id:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
kpi.status = "active"
|
||||
db.commit()
|
||||
return {"message": "已恢复"}
|
||||
|
||||
|
||||
def kpi_to_dict(k):
|
||||
return {c.name: getattr(k, c.name) for c in k.__table__.columns}
|
||||
d = {c.name: getattr(k, c.name) for c in k.__table__.columns}
|
||||
# 多粒度目标派生:月/季/年显示值 + derived标记(虚拟,不落库)
|
||||
try:
|
||||
der = derive_cycle_targets(k)
|
||||
d["derived_targets"] = der["derived"]
|
||||
d["derived_flags"] = der["flags"]
|
||||
except Exception:
|
||||
d["derived_targets"] = {"monthly": k.target_monthly, "quarterly": k.target_quarterly, "yearly": k.target_yearly}
|
||||
d["derived_flags"] = {"monthly": False, "quarterly": False, "yearly": False}
|
||||
# 附加战略地图信息
|
||||
if k.map_id:
|
||||
from app.database import get_session_local
|
||||
try:
|
||||
sess = get_session_local()()
|
||||
m = sess.query(StrategicMap).filter(StrategicMap.id == k.map_id).first()
|
||||
d["map_title"] = m.title if m else None
|
||||
sess.close()
|
||||
except:
|
||||
d["map_title"] = None
|
||||
else:
|
||||
d["map_title"] = None
|
||||
return d
|
||||
|
||||
|
||||
def _log(db, user_id, action, target_type, target_id, detail):
|
||||
log = OperationLog(user_id=user_id, action=action, target_type=target_type, target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
|
||||
db.add(log)
|
||||
@router.put("/{kpi_id}/associate-map")
|
||||
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""关联KPI到战略地图(账套隔离 2026-09-21:KPI 与地图均须属本账套,跨账套 → 404)"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
map_id = data.get("map_id")
|
||||
if map_id is not None:
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == map_id,
|
||||
StrategicMap.entity_id == entity_id,
|
||||
).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
kpi.map_id = map_id
|
||||
db.commit()
|
||||
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id},
|
||||
entity_id=entity_id)
|
||||
return kpi_to_dict(kpi)
|
||||
|
||||
|
||||
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None):
|
||||
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
|
||||
return log_op(db, action, target_type=target_type, target_id=target_id,
|
||||
detail=detail, user_id=user_id, entity_id=entity_id, commit=True)
|
||||
|
||||
|
||||
@router.get("/{kpi_id}/objectives")
|
||||
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""查看KPI所属的目标和战略地图"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""查看KPI所属的目标和战略地图(账套隔离 2026-09-21:跨账套 id → 404)
|
||||
|
||||
鉴权口径:本 router 级 `dependencies=[Depends(require_role(...))]` 已覆盖本端点
|
||||
(无 token → 401),此处不再重复挂 require_auth,只补缺失的账套收口。
|
||||
"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
@@ -159,15 +950,19 @@ def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
if kpi.map_id:
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first()
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == kpi.map_id, StrategicMap.entity_id == entity_id,
|
||||
).first()
|
||||
if m:
|
||||
result["map"] = {"id": m.id, "title": m.title, "status": m.status}
|
||||
|
||||
if kpi.objective:
|
||||
objs = db.query(MapObjective).filter(
|
||||
MapObjective.map_id == kpi.map_id,
|
||||
MapObjective.entity_id == entity_id,
|
||||
MapObjective.name == kpi.objective,
|
||||
).all()
|
||||
result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""AI获客 - 客户咨询接口"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import json, os, datetime, subprocess, urllib.request
|
||||
|
||||
router = APIRouter(prefix="/api/cma/lead", tags=["AI获客"])
|
||||
|
||||
# ai-cs 实盘分流脚本(跨 profile 调用)
|
||||
# 必须以 ai-cs 身份 + 其 HERMES_HOME/HOME 运行:脚本内用 `hermes send` 复用该 profile 的企微凭据;
|
||||
# cma-backend.service 的 Environment 为空,不显式传入会解析到非 ai-cs 的 profile(root 默认 profile)。
|
||||
AI_CS_USER = "ai-cs"
|
||||
AI_CS_HOME = "/var/lib/wecom-ai-cs/profile"
|
||||
AI_CS_ROUTING_SCRIPT = AI_CS_HOME + "/scripts/routing-live.py"
|
||||
|
||||
class LeadRequest(BaseModel):
|
||||
name: str
|
||||
phone: str
|
||||
requirement: str = ""
|
||||
source: str = "sxbh.ltd"
|
||||
|
||||
def notify_wecom(name, phone, requirement, source):
|
||||
"""推送到企微全员群"""
|
||||
msg = {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": f"## 🔔 新客户咨询\n**姓名**: {name}\n**电话**: {phone}\n**需求**: {requirement}\n**来源**: {source}\n**时间**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
}
|
||||
}
|
||||
try:
|
||||
data = json.dumps(msg).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:8800/send",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except:
|
||||
pass # 通知失败不影响主流程
|
||||
|
||||
def notify_boss(name, phone, requirement):
|
||||
"""额外推给任总(经 ai-cs 实盘分流脚本;失败不影响主流程)"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["runuser", "-u", AI_CS_USER, "--",
|
||||
"env", f"HOME={AI_CS_HOME}", f"HERMES_HOME={AI_CS_HOME}",
|
||||
f"USER={AI_CS_USER}", f"LOGNAME={AI_CS_USER}",
|
||||
"/usr/bin/python3", AI_CS_ROUTING_SCRIPT,
|
||||
requirement or "(未填需求)",
|
||||
f"{name} {phone}",
|
||||
"website:sxbh.ltd"],
|
||||
timeout=30, capture_output=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@router.post("")
|
||||
def create_lead(data: LeadRequest):
|
||||
"""接收客户咨询并保存"""
|
||||
record = data.model_dump()
|
||||
record["timestamp"] = datetime.datetime.now().isoformat()
|
||||
record["status"] = "new"
|
||||
|
||||
log_dir = "/root/leads"
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
with open(f"{log_dir}/leads.json", "a") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
# 异步推送企微通知
|
||||
notify_wecom(data.name, data.phone, data.requirement, data.source)
|
||||
# 加性:额外推给任总 DM(口径与 ai-cs 客服分流一致)
|
||||
notify_boss(data.name, data.phone, data.requirement)
|
||||
|
||||
return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""战略地图 · 数据完备度 API(口径唯一真源:CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
|
||||
|
||||
主口径(对外唯一主数):仅地图 65(已发布)15/19 = 78.9%,判据 = 被 23-KPI 集挂接。
|
||||
附注口径(只作附注):全量 4 图 30/31 = 96.8%,判据 = 有KPI挂接(含归档图)。
|
||||
KPI 级:active 行 117(e1=73 / e2=44),按行计,与目标级分列、不相除。
|
||||
|
||||
所有汇总数一律运行时对库内数据现算;判据数据(23-KPI 挂接关系)见
|
||||
app/data/caliber_map_completeness.py。两种禁用组合(仅地图65且按 kpis 非空、全量31且按命中 23-KPI 集)在本模块无生成路径。
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_middleware import require_auth
|
||||
from app.data import caliber_map_completeness as cal
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KPIDefinition, MapObjective, StrategicMap
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/maps/completeness",
|
||||
tags=["战略地图数据完备度"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
|
||||
def _pct(n: int, d: int) -> Optional[float]:
|
||||
"""占比只保留 1 位小数;分母为 0 返回 None(展示为「无数据」)。"""
|
||||
if not d:
|
||||
return None
|
||||
return round(n * 100.0 / d, 1)
|
||||
|
||||
|
||||
def _entity_scopes(db: Session, entity_id: int):
|
||||
"""返回(主口径地图, 同属主的地图列表)。主口径地图仅认 map_id=65 且属于该实体。"""
|
||||
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).all()
|
||||
primary = next((m for m in maps if m.id == cal.PRIMARY_MAP_ID), None)
|
||||
return primary, maps
|
||||
|
||||
|
||||
def _objective_rows(db: Session, maps):
|
||||
"""拉取给定地图的战略目标,并逐行标记 23-KPI 挂接 / 是否有 KPI。"""
|
||||
map_ids = [m.id for m in maps]
|
||||
if not map_ids:
|
||||
return [], {}
|
||||
title_by_map = {m.id: m.title for m in maps}
|
||||
status_by_map = {m.id: (m.status or "") for m in maps}
|
||||
objs = (
|
||||
db.query(MapObjective)
|
||||
.filter(MapObjective.map_id.in_(map_ids))
|
||||
.order_by(MapObjective.map_id, MapObjective.sort_order, MapObjective.id)
|
||||
.all()
|
||||
)
|
||||
rows = []
|
||||
for o in objs:
|
||||
kpis = o.kpis if isinstance(o.kpis, list) else []
|
||||
rows.append({
|
||||
"objective_id": o.id,
|
||||
"map_id": o.map_id,
|
||||
"map_title": title_by_map.get(o.map_id),
|
||||
"map_status": status_by_map.get(o.map_id),
|
||||
"layer": o.dimension_key,
|
||||
"layer_label": cal.LAYER_LABELS.get(o.dimension_key, o.dimension_key),
|
||||
"name": o.name,
|
||||
"kpis": kpis,
|
||||
"has_kpi": len(kpis) > 0,
|
||||
"covered_23kpi": o.id in cal.LINKED_OBJECTIVES,
|
||||
})
|
||||
return rows, title_by_map
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def completeness_summary(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""数据完备度总览:目标级(主数 + 附注)与 KPI 级(117 行)两层分列。"""
|
||||
primary_map, maps = _entity_scopes(db, entity_id)
|
||||
rows, _ = _objective_rows(db, maps)
|
||||
|
||||
# ---- 目标级 · 主口径:仅地图 65,判据 = 被 23-KPI 集挂接 ----
|
||||
if primary_map is not None:
|
||||
main_rows = [r for r in rows if r["map_id"] == cal.PRIMARY_MAP_ID]
|
||||
main_total = len(main_rows)
|
||||
main_covered = sum(1 for r in main_rows if r["covered_23kpi"])
|
||||
primary = {
|
||||
"available": True,
|
||||
"caliber": cal.PRIMARY_CALIBER,
|
||||
"label": cal.PRIMARY_LABEL,
|
||||
"map_id": primary_map.id,
|
||||
"map_title": primary_map.title,
|
||||
"map_status": primary_map.status,
|
||||
"covered": main_covered,
|
||||
"total": main_total,
|
||||
"pct": _pct(main_covered, main_total),
|
||||
"display": f"{main_covered}/{main_total}",
|
||||
}
|
||||
else:
|
||||
primary = {
|
||||
"available": False,
|
||||
"caliber": cal.PRIMARY_CALIBER,
|
||||
"label": cal.PRIMARY_LABEL,
|
||||
"map_id": cal.PRIMARY_MAP_ID,
|
||||
"covered": None,
|
||||
"total": None,
|
||||
"pct": None,
|
||||
"display": "—",
|
||||
}
|
||||
|
||||
# ---- 目标级 · 附注口径:全量 4 图,判据 = 有 KPI 挂接 ----
|
||||
# 判据复现(definition 件 §5 机读件 20261018-c1-spec/map-objectives-dump.csv 逐行):
|
||||
# 23KPI 覆盖 26 + 非23KPI 但 map_objectives.kpis 非空 4 + 缺口 1 = 30/31。
|
||||
# 即「被 23-KPI 集挂接」与「kpis 非空」取并集;与主口径判据(仅 23-KPI 挂接)不是同一判据。
|
||||
# 附注与主数同属地图 65 的账套(全量 4 图);无地图 65 的账套不适用,展示「—」而非 0。
|
||||
footnote_total = len(rows)
|
||||
footnote_covered = sum(1 for r in rows if r["covered_23kpi"] or r["has_kpi"])
|
||||
footnote_available = primary_map is not None and bool(rows)
|
||||
footnote = {
|
||||
"available": footnote_available,
|
||||
"caliber": cal.FOOTNOTE_CALIBER,
|
||||
"label": cal.FOOTNOTE_LABEL,
|
||||
"covered": footnote_covered if footnote_available else None,
|
||||
"total": footnote_total if footnote_available else None,
|
||||
"pct": _pct(footnote_covered, footnote_total) if footnote_available else None,
|
||||
"display": f"{footnote_covered}/{footnote_total}" if footnote_available else "—",
|
||||
}
|
||||
|
||||
# ---- KPI 级:active 按行(e1=73 / e2=44,合计 117;去重编码 102 另册)----
|
||||
kpi_rows = (
|
||||
db.query(KPIDefinition)
|
||||
.filter(KPIDefinition.status == "active")
|
||||
.all()
|
||||
)
|
||||
by_entity: dict = {}
|
||||
codes = set()
|
||||
for k in kpi_rows:
|
||||
by_entity[k.entity_id] = by_entity.get(k.entity_id, 0) + 1
|
||||
codes.add(k.kpi_code)
|
||||
kpi_level = {
|
||||
"caliber": cal.KPI_CALIBER,
|
||||
"label": cal.KPI_LABEL,
|
||||
"total": len(kpi_rows),
|
||||
"by_entity": [
|
||||
{"entity_id": eid, "count": cnt} for eid, cnt in sorted(by_entity.items())
|
||||
],
|
||||
"distinct_codes": len(codes),
|
||||
"note": "按 kpi_code 去重为独立口径,不与行数相除或换算",
|
||||
}
|
||||
|
||||
return {
|
||||
"caliber_id": cal.CALIBER_ID,
|
||||
"caliber_version": cal.CALIBER_VERSION,
|
||||
"entity_id": entity_id,
|
||||
"applicable": primary_map is not None,
|
||||
"scope_note": "主口径仅覆盖地图 65(已发布);附注口径含归档图,判据不同,不得互换算",
|
||||
"objective_level": {"primary": primary, "footnote": footnote},
|
||||
"kpi_level": kpi_level,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/objectives")
|
||||
def completeness_objectives(
|
||||
map_id: Optional[int] = None,
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""数据完备度清单(目标级逐行):供空态清单与补齐排期使用。"""
|
||||
_, maps = _entity_scopes(db, entity_id)
|
||||
if map_id is not None:
|
||||
maps = [m for m in maps if m.id == map_id]
|
||||
rows, _ = _objective_rows(db, maps)
|
||||
return {
|
||||
"caliber_id": cal.CALIBER_ID,
|
||||
"caliber_version": cal.CALIBER_VERSION,
|
||||
"entity_id": entity_id,
|
||||
"caliber": cal.FOOTNOTE_CALIBER,
|
||||
"rows": rows,
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""战略地图 · 维度切片(时间 / 门店 / 产品)—— A3
|
||||
|
||||
目的:让战略地图的 KPI 能按维度切片重算,而不是永远只显示总额/最新值。
|
||||
|
||||
口径(与既有读路径保持一致,不另造口径):
|
||||
- 实测区:复用 `app.utils.kpi_value_scope.actual_only_clause()`,预估行不得进入切片。
|
||||
- 指标方向:优先 `kpi_definitions.direction`;缺失时退回
|
||||
`app.utils.strategy_board.INVERSE_KPI_IDS` 显式白名单(不用 NULL 推断)。
|
||||
- 状态类:`unit == '状态'` ⇒ 1 = 达标 / 0 = 未达标,不做除法。
|
||||
- 达成率阈值沿用地图现有语义:≥90% success / ≥60% warning / <60% danger。
|
||||
|
||||
三维可用性(以库内事实为准,不假装有数据):
|
||||
- 时间:`kpi_values.period`(YYYY / YYYY-Hn / YYYY-MM)真实存在,可切。
|
||||
- 产品:`product_sales`(仅 entity 1,2026-01…2026-08,按 `product_name`)真实存在;
|
||||
只对「产品级可取数」的 KPI(PRODUCT_SCOPED_CODES)生效,其余 KPI 显式不可用。
|
||||
- 门店:库内**无**门店级事实表(无 store/dept 字段,`voucher_details` 无辅助核算),
|
||||
仅能从 `subjects.subject_name` 反推门店名称 ⇒ 提供候选值但标记不可用;
|
||||
切片结果一律 `available=false`,**绝不回退总额**。
|
||||
|
||||
空态契约:所选维度下没有某 KPI 的实测值时,返回 `actual_value=null` +
|
||||
`available=false` + `reason`;绝不把总额/其它期间的值填进来。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_middleware import require_role
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KPIDefinition, KPIValue, MapObjective, ProductSales, StrategicMap
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
from app.utils.strategy_board import INVERSE_KPI_IDS, STATUS_UNIT
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/maps",
|
||||
tags=["战略地图维度切片"],
|
||||
dependencies=[Depends(require_role("ceo", "finance"))],
|
||||
)
|
||||
|
||||
#: 目前只有营业收入的月度总额来自 product_sales(已对拍:2026-01…07 与 KPI 值逐月相等)
|
||||
PRODUCT_SCOPED_CODES = frozenset({"F_REVENUE"})
|
||||
|
||||
#: 流量型单位:季度/年度切片按「月份求和」
|
||||
FLOW_UNITS = frozenset({"万元", "元", "个", "次", "轮", "件", "人"})
|
||||
|
||||
#: 达成率 → 色阶(与地图现有 legend 一致)
|
||||
SUCCESS_RATE = 0.9
|
||||
WARNING_RATE = 0.6
|
||||
|
||||
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
|
||||
_QUARTER_RE = re.compile(r"^\d{4}-Q[1-4]$")
|
||||
_YEAR_RE = re.compile(r"^\d{4}$")
|
||||
|
||||
_STORE_SUFFIX_RE = re.compile(r"(吴忠地标店|吴忠隆泰店|新区大店|地标店|一店|二店)")
|
||||
_STORE_DISPLAY_ORDER = ["一店", "二店", "吴忠地标店", "新区大店", "吴忠隆泰店"]
|
||||
|
||||
#: 存量/余额型指标:区间切片取「最新一期」,禁止按月求和(否则余额被累加)
|
||||
BALANCE_HINTS = ("余额", "库存", "去化", "货币资金", "借款", "应收", "应付", "资产", "负债", "占用", "头寸")
|
||||
|
||||
#: 产品源与 KPI 实测值的允许偏差(超过即判源未对齐,不参与切片)
|
||||
PRODUCT_SOURCE_TOLERANCE = 0.01
|
||||
|
||||
|
||||
# ── 小工具 ────────────────────────────────────────────────
|
||||
def _obj_kpi_codes(raw: Any) -> list[str]:
|
||||
"""map_objectives.kpis 兼容四种存储形态:list[str] / json 字符串 / list[dict] / NULL。"""
|
||||
if raw is None:
|
||||
return []
|
||||
data = raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
for item in data:
|
||||
if isinstance(item, str):
|
||||
out.append(item)
|
||||
elif isinstance(item, dict):
|
||||
code = item.get("kpi_code") or item.get("code")
|
||||
if code:
|
||||
out.append(str(code))
|
||||
return out
|
||||
|
||||
|
||||
def _map_or_404(db: Session, map_id: int, entity_id: int) -> StrategicMap:
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id,
|
||||
).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
return m
|
||||
|
||||
|
||||
def _map_kpis(db: Session, map_id: int, entity_id: int):
|
||||
"""返回 (map_objectives, {kpi_code: KPIDefinition}),KPI 只取本实体(账套隔离)。"""
|
||||
objs = db.query(MapObjective).filter(
|
||||
MapObjective.map_id == map_id, MapObjective.entity_id == entity_id,
|
||||
).order_by(MapObjective.sort_order, MapObjective.id).all()
|
||||
codes: set[str] = set()
|
||||
for o in objs:
|
||||
codes.update(_obj_kpi_codes(o.kpis))
|
||||
defs: dict[str, KPIDefinition] = {}
|
||||
if codes:
|
||||
for k in db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id, KPIDefinition.kpi_code.in_(codes),
|
||||
).all():
|
||||
defs[k.kpi_code] = k
|
||||
return objs, defs
|
||||
|
||||
|
||||
def _parse_period(period: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
||||
"""返回 (粒度, 归一值):year / quarter / month;非法或空返回 (None, None)。"""
|
||||
if not period:
|
||||
return None, None
|
||||
p = period.strip()
|
||||
if _MONTH_RE.match(p):
|
||||
return "month", p
|
||||
if _QUARTER_RE.match(p):
|
||||
return "quarter", p
|
||||
if _YEAR_RE.match(p):
|
||||
return "year", p
|
||||
return None, None
|
||||
|
||||
|
||||
def _months_of(period_type: str, period: str) -> list[str]:
|
||||
if period_type == "month":
|
||||
return [period]
|
||||
year = period[:4]
|
||||
if period_type == "quarter":
|
||||
q = int(period[-1])
|
||||
start = (q - 1) * 3 + 1
|
||||
return [f"{year}-{m:02d}" for m in range(start, start + 3)]
|
||||
return [f"{year}-{m:02d}" for m in range(1, 13)]
|
||||
|
||||
|
||||
def _unit_kind(unit: Optional[str], kpi: Optional[KPIDefinition] = None) -> str:
|
||||
u = (unit or "").strip()
|
||||
if u == STATUS_UNIT:
|
||||
return "status"
|
||||
name = (getattr(kpi, "kpi_name", "") or "") + " " + (getattr(kpi, "kpi_code", "") or "")
|
||||
if any(h in name for h in BALANCE_HINTS):
|
||||
return "balance"
|
||||
if u in FLOW_UNITS:
|
||||
return "flow"
|
||||
return "ratio"
|
||||
|
||||
|
||||
def _is_lower_better(kpi: KPIDefinition) -> bool:
|
||||
direction = (getattr(kpi, "direction", None) or "").strip().lower()
|
||||
if direction in ("lower_better", "lower", "desc", "down"):
|
||||
return True
|
||||
if direction in ("higher_better", "higher", "asc", "up"):
|
||||
return False
|
||||
return kpi.id in INVERSE_KPI_IDS
|
||||
|
||||
|
||||
def _level_from_ratio(ratio: Optional[float]) -> str:
|
||||
if ratio is None:
|
||||
return "gray"
|
||||
if ratio >= SUCCESS_RATE:
|
||||
return "green"
|
||||
if ratio >= WARNING_RATE:
|
||||
return "yellow"
|
||||
return "red"
|
||||
|
||||
|
||||
def _base_target(kpi: KPIDefinition) -> Optional[float]:
|
||||
for attr in ("target_monthly", "target_value"):
|
||||
v = getattr(kpi, attr, None)
|
||||
if v is not None:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _scaled_target(kpi: KPIDefinition, period_type: str, year_period: Optional[str], db: Session, entity_id: int) -> tuple[Optional[float], str]:
|
||||
"""目标值:年切片优先取 kpi_targets 的年度行;否则按单位缩放月度目标。
|
||||
|
||||
返回 (target, target_basis)。
|
||||
"""
|
||||
if year_period:
|
||||
row = db.execute(text(
|
||||
"SELECT target_value FROM kpi_targets WHERE entity_id=:e AND kpi_id=:k "
|
||||
"AND period=:p AND target_type IN ('yearly','annual') ORDER BY target_type='yearly' DESC LIMIT 1"
|
||||
), {"e": entity_id, "k": kpi.id, "p": year_period}).fetchone()
|
||||
if row and row[0] is not None:
|
||||
return float(row[0]), f"kpi_targets.{year_period}.yearly"
|
||||
|
||||
base = _base_target(kpi)
|
||||
if base is None:
|
||||
return None, "no_target"
|
||||
kind = _unit_kind(kpi.unit, kpi)
|
||||
if kind == "status":
|
||||
return None, "status_no_target"
|
||||
if kind == "balance":
|
||||
return base, "target_balance"
|
||||
if period_type == "month":
|
||||
return base, "target_monthly"
|
||||
if period_type == "quarter":
|
||||
return (base * 3 if kind == "flow" else base), "target_monthly×3"
|
||||
return (base * 12 if kind == "flow" else base), "target_monthly×12"
|
||||
|
||||
|
||||
def _aggregate(rows: list[KPIValue], unit: Optional[str], kpi: Optional[KPIDefinition] = None) -> tuple[Optional[float], str, list[str], bool]:
|
||||
"""把区间内的实测行聚合成一个切片值。
|
||||
|
||||
返回 (value, aggregation, months_used, partial)。
|
||||
- 状态类 / 存量余额类:取最新一期(不求和)
|
||||
- 流量型:求和
|
||||
- 比率/天数:均值
|
||||
"""
|
||||
if not rows:
|
||||
return None, "none", [], True
|
||||
ordered = sorted(rows, key=lambda v: (str(v.period), v.id))
|
||||
months = [str(v.period) for v in ordered]
|
||||
kind = _unit_kind(unit, kpi)
|
||||
if kind in ("status", "balance"):
|
||||
last = ordered[-1]
|
||||
return (float(last.actual_value) if last.actual_value is not None else None), "latest", [str(last.period)], False
|
||||
vals = [float(v.actual_value) for v in ordered if v.actual_value is not None]
|
||||
if not vals:
|
||||
return None, "none", months, True
|
||||
if kind == "flow":
|
||||
return round(sum(vals), 4), "sum_of_months", months, False
|
||||
return round(sum(vals) / len(vals), 4), "mean_of_months", months, False
|
||||
|
||||
|
||||
# ── 维度候选值 ────────────────────────────────────────────
|
||||
def _time_options(db: Session, kpi_ids: list[int], entity_id: int) -> dict:
|
||||
if not kpi_ids:
|
||||
return {"available": False, "reason": "该地图未绑定 KPI", "years": [], "quarters": [], "months": []}
|
||||
rows = db.query(KPIValue.period).filter(
|
||||
KPIValue.entity_id == entity_id,
|
||||
KPIValue.kpi_id.in_(kpi_ids),
|
||||
actual_only_clause(),
|
||||
).distinct().all()
|
||||
periods = [str(r[0]) for r in rows if r[0]]
|
||||
months = sorted(p for p in periods if _MONTH_RE.match(p))
|
||||
quarters = sorted(p for p in periods if _QUARTER_RE.match(p))
|
||||
years = sorted({p[:4] for p in periods})
|
||||
if months:
|
||||
for y in sorted({m[:4] for m in months}):
|
||||
for q in range(1, 5):
|
||||
qq = f"{y}-Q{q}"
|
||||
if qq not in quarters and any(m in _months_of("quarter", qq) for m in months):
|
||||
quarters.append(qq)
|
||||
quarters.sort()
|
||||
return {
|
||||
"available": bool(periods),
|
||||
"reason": None if periods else "该地图 KPI 无实测值(kpi_values 为空)",
|
||||
"years": years,
|
||||
"quarters": quarters,
|
||||
"months": months,
|
||||
"granularity": ["year", "quarter", "month"],
|
||||
}
|
||||
|
||||
|
||||
def _store_options(db: Session, entity_id: int) -> dict:
|
||||
rows = db.execute(text(
|
||||
"SELECT DISTINCT subject_code, subject_name FROM subjects WHERE entity_id=:e AND subject_name LIKE '%店%'"
|
||||
), {"e": entity_id}).fetchall()
|
||||
found: set[str] = set()
|
||||
for _code, name in rows:
|
||||
for hit in _STORE_SUFFIX_RE.findall(str(name or "")):
|
||||
found.add("吴忠地标店" if hit == "地标店" else hit)
|
||||
values = [{"code": f"BR-{i+1:02d}", "name": n} for i, n in enumerate(
|
||||
[n for n in _STORE_DISPLAY_ORDER if n in found] or sorted(found)
|
||||
)]
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "该维度暂无数据:库内无门店级事实表(无 store/dept 字段,凭证无辅助核算),"
|
||||
"门店名称仅能从费用类科目名称反推,无法按门店切 KPI;不回退总额",
|
||||
"values": values,
|
||||
"source": "subjects.subject_name 反推(仅费用类科目)",
|
||||
"coverage": "expense_subjects_only",
|
||||
}
|
||||
|
||||
|
||||
def _product_options(db: Session, entity_id: int) -> dict:
|
||||
rows = db.query(ProductSales.product_name).filter(
|
||||
ProductSales.entity_id == entity_id,
|
||||
).distinct().all()
|
||||
names = sorted({str(r[0]) for r in rows if r[0]})
|
||||
if not names:
|
||||
return {"available": False, "reason": "该实体无产品级销售数据(product_sales 为空)", "values": [], "kpi_scope": sorted(PRODUCT_SCOPED_CODES)}
|
||||
span = db.query(ProductSales.period_month).filter(
|
||||
ProductSales.entity_id == entity_id,
|
||||
).distinct().all()
|
||||
months = sorted({str(r[0]) for r in span if r[0]})
|
||||
return {
|
||||
"available": True,
|
||||
"reason": None,
|
||||
"values": [{"code": n, "name": n} for n in names],
|
||||
"kpi_scope": sorted(PRODUCT_SCOPED_CODES),
|
||||
"period_coverage": {"min": months[0] if months else None, "max": months[-1] if months else None, "months": months},
|
||||
"source": "product_sales.product_name(按 period_month 聚合,元→万元)",
|
||||
}
|
||||
|
||||
|
||||
# ── 切片计算 ──────────────────────────────────────────────
|
||||
def _slice_kpi_month(db: Session, kpi: KPIDefinition, entity_id: int, period: str) -> tuple[Optional[float], Optional[str]]:
|
||||
rows = db.query(KPIValue).filter(
|
||||
KPIValue.entity_id == entity_id,
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period == period,
|
||||
actual_only_clause(),
|
||||
).order_by(KPIValue.id.desc()).all()
|
||||
if not rows:
|
||||
return None, f"{period} 无实测值"
|
||||
# 同期多行:优先 verified/calculated(strategy_board 的同口径排序)
|
||||
rank = {"verified": 3, "calculated": 2, "pending": 1}
|
||||
best = sorted(rows, key=lambda v: rank.get((v.data_status or "").lower(), 0), reverse=True)[0]
|
||||
return (float(best.actual_value) if best.actual_value is not None else None), None
|
||||
|
||||
|
||||
def _slice_kpi_period(db: Session, kpi: KPIDefinition, entity_id: int, ptype: str, period: str) -> tuple[Optional[float], str, list[str], Optional[str]]:
|
||||
"""时间切片:月=精确值;季/年=区间聚合(年优先取 YYYY 行)。"""
|
||||
if ptype == "month":
|
||||
val, err = _slice_kpi_month(db, kpi, entity_id, period)
|
||||
return val, "exact", [period], err
|
||||
|
||||
if ptype == "year":
|
||||
val, err = _slice_kpi_month(db, kpi, entity_id, period)
|
||||
if val is not None:
|
||||
return val, "exact", [period], None
|
||||
|
||||
months = _months_of(ptype, period)
|
||||
rows = db.query(KPIValue).filter(
|
||||
KPIValue.entity_id == entity_id,
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.period.in_(months),
|
||||
actual_only_clause(),
|
||||
).all()
|
||||
val, agg, used, _ = _aggregate(rows, kpi.unit, kpi)
|
||||
expected = 3 if ptype == "quarter" else 12
|
||||
if val is None:
|
||||
return None, "none", [], f"{period} 无实测值(区间内无月度数据)"
|
||||
partial = len(used) < expected and agg != "latest"
|
||||
note = f"{period} 仅 {len(used)}/{expected} 个月有实测值" if partial else None
|
||||
return val, agg, used, note
|
||||
|
||||
|
||||
def _slice_kpi_product(db: Session, kpi: KPIDefinition, entity_id: int, product: str, ptype: str, period: str) -> tuple[Optional[float], str, list[str], Optional[str]]:
|
||||
"""产品切片:仅对 PRODUCT_SCOPED_CODES 生效,并按「逐月源一致性」闸门。
|
||||
|
||||
`product_sales` 月合计与 KPI 同月实测值必须吻合(±1%)才纳入;不吻合的月份
|
||||
视为源未对齐(例如 2026-08 产品表 8.08 万 vs KPI 14.45 万),
|
||||
不做缩放、不并入,避免用产品表冒充 KPI 总额。
|
||||
"""
|
||||
if kpi.kpi_code not in PRODUCT_SCOPED_CODES:
|
||||
return None, "unsupported", [], "该 KPI 无产品级取数(维度未落库),不回退总额"
|
||||
months = _months_of(ptype, period)
|
||||
|
||||
# 逐月源一致性:product_sales 全量合计(元) vs KPI 实测值(万元)
|
||||
totals = db.query(ProductSales.period_month, ProductSales.sales_amount).filter(
|
||||
ProductSales.entity_id == entity_id, ProductSales.period_month.in_(months),
|
||||
).all()
|
||||
by_month: dict[str, float] = {}
|
||||
for pm, amt in totals:
|
||||
by_month[str(pm)] = by_month.get(str(pm), 0.0) + float(amt or 0)
|
||||
|
||||
kpi_month: dict[str, float] = {}
|
||||
for m in months:
|
||||
v, _err = _slice_kpi_month(db, kpi, entity_id, m)
|
||||
if v is not None:
|
||||
kpi_month[m] = float(v) * 10000.0 # 万元 → 元
|
||||
|
||||
covered = []
|
||||
misaligned = []
|
||||
for m in months:
|
||||
if m not in by_month or m not in kpi_month:
|
||||
continue
|
||||
base = kpi_month[m]
|
||||
if base == 0:
|
||||
continue
|
||||
if abs(by_month[m] - base) / abs(base) <= PRODUCT_SOURCE_TOLERANCE:
|
||||
covered.append(m)
|
||||
else:
|
||||
misaligned.append(m)
|
||||
|
||||
if not covered:
|
||||
detail = ""
|
||||
if misaligned:
|
||||
detail = "(源未对齐月份:" + "、".join(misaligned) + ")"
|
||||
return None, "uncovered", [], f"产品表与 KPI 实测值不一致{detail},该期间不做产品切片、不回退总额"
|
||||
|
||||
rows = db.query(ProductSales).filter(
|
||||
ProductSales.entity_id == entity_id,
|
||||
ProductSales.product_name == product,
|
||||
ProductSales.period_month.in_(covered),
|
||||
).all()
|
||||
if not rows:
|
||||
return None, "none", [], f"产品「{product}」在 {'、'.join(covered)} 无销售数据"
|
||||
|
||||
used = sorted({r.period_month for r in rows})
|
||||
total = sum(float(r.sales_amount or 0) for r in rows) / 10000.0 # 元 → 万元
|
||||
note = None
|
||||
if len(covered) < len(months):
|
||||
note = f"仅 {'、'.join(covered)} 通过源一致性校验" + (f";未对齐:{'、'.join(misaligned)}" if misaligned else "")
|
||||
return round(total, 4), "sum_of_product_sales", used, note
|
||||
|
||||
|
||||
def build_slice(db: Session, entity_id: int, map_id: int, period: Optional[str], store: Optional[str], product: Optional[str]) -> dict:
|
||||
_map_or_404(db, map_id, entity_id)
|
||||
objs, defs = _map_kpis(db, map_id, entity_id)
|
||||
ptype, pnorm = _parse_period(period)
|
||||
if period and not ptype:
|
||||
raise HTTPException(400, "period 格式应为 YYYY / YYYY-Qn / YYYY-MM")
|
||||
if not pnorm:
|
||||
# 未选时间:取该地图 KPI 的最新实测期(沿用现有「最新值」语义,并显式标注)
|
||||
latest = None
|
||||
ids = [k.id for k in defs.values()]
|
||||
if ids:
|
||||
rows = db.query(KPIValue.period).filter(
|
||||
KPIValue.entity_id == entity_id, KPIValue.kpi_id.in_(ids), actual_only_clause(),
|
||||
).all()
|
||||
cand = [str(r[0]) for r in rows if r[0]]
|
||||
months = sorted(p for p in cand if _MONTH_RE.match(p))
|
||||
pnorm = months[-1] if months else (sorted(cand)[-1] if cand else None)
|
||||
if pnorm:
|
||||
ptype = "month" if _MONTH_RE.match(pnorm) else ("quarter" if _QUARTER_RE.match(pnorm) else "year")
|
||||
if latest is None and not pnorm:
|
||||
latest = None
|
||||
ptype = ptype or "month"
|
||||
pnorm = pnorm or ""
|
||||
|
||||
dims_meta = {
|
||||
"time": {"available": bool(pnorm), "period": pnorm, "type": ptype,
|
||||
"reason": None if pnorm else "该地图 KPI 无实测值"},
|
||||
"store": _store_options(db, entity_id) if store else {"available": True, "selected": None},
|
||||
"product": _product_options(db, entity_id) if product else {"available": True, "selected": None},
|
||||
}
|
||||
if store:
|
||||
dims_meta["store"]["selected"] = store
|
||||
if product:
|
||||
dims_meta["product"]["selected"] = product
|
||||
|
||||
objectives: list[dict] = []
|
||||
any_available = False
|
||||
empty_reasons: list[str] = []
|
||||
|
||||
for obj in objs:
|
||||
codes = [c for c in _obj_kpi_codes(obj.kpis) if c in defs]
|
||||
kpi_rows: list[dict] = []
|
||||
worst = "green"
|
||||
for code in codes:
|
||||
kpi = defs[code]
|
||||
target = None
|
||||
basis = "no_period"
|
||||
actual = None
|
||||
agg = "none"
|
||||
used: list[str] = []
|
||||
reason: Optional[str] = None
|
||||
|
||||
if store:
|
||||
reason = "该维度暂无数据:无门店级 KPI 事实表,不回退总额"
|
||||
elif not pnorm:
|
||||
reason = "无可用实测期间"
|
||||
elif product:
|
||||
actual, agg, used, reason = _slice_kpi_product(db, kpi, entity_id, product, ptype, pnorm)
|
||||
target, basis = _scaled_target(kpi, ptype, pnorm[:4] if ptype == "year" else None, db, entity_id)
|
||||
# 产品切片只覆盖通过源一致性校验的月份 ⇒ 流量型目标按覆盖月数缩放
|
||||
if actual is not None and _unit_kind(kpi.unit, kpi) == "flow" and used:
|
||||
base = _base_target(kpi)
|
||||
if base is not None:
|
||||
target = round(base * len(used), 4)
|
||||
basis = f"target_monthly×{len(used)}(覆盖月数)"
|
||||
else:
|
||||
actual, agg, used, note = _slice_kpi_period(db, kpi, entity_id, ptype, pnorm)
|
||||
target, basis = _scaled_target(kpi, ptype, pnorm[:4] if ptype == "year" else None, db, entity_id)
|
||||
reason = note
|
||||
|
||||
available = actual is not None
|
||||
act = float(actual) if actual is not None else None
|
||||
tgt = float(target) if target is not None else None
|
||||
ratio = None
|
||||
level = "gray"
|
||||
if act is not None and _unit_kind(kpi.unit) == "status":
|
||||
ratio = 1.0 if act >= 1 else 0.0
|
||||
level = "green" if ratio == 1.0 else "red"
|
||||
elif act is not None and tgt:
|
||||
if _is_lower_better(kpi):
|
||||
ratio = (tgt / act) if act > 0 else None
|
||||
else:
|
||||
ratio = act / tgt if tgt != 0 else None
|
||||
level = _level_from_ratio(ratio)
|
||||
if reason and available and agg != "exact":
|
||||
# 部分月份可用:值可用但必须带口径说明
|
||||
pass
|
||||
|
||||
if level == "red":
|
||||
worst = "red"
|
||||
elif level == "yellow" and worst != "red":
|
||||
worst = "yellow"
|
||||
elif level == "gray" and worst == "green" and not available:
|
||||
worst = "gray"
|
||||
|
||||
if not available:
|
||||
empty_reasons.append(reason or f"{code} 无数据")
|
||||
any_available = any_available or available
|
||||
|
||||
kpi_rows.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"unit": kpi.unit,
|
||||
"actual_value": actual,
|
||||
"target_value": target,
|
||||
"target_basis": basis,
|
||||
"achievement_pct": (round(ratio * 100, 1) if ratio is not None else None),
|
||||
"level": level,
|
||||
"available": available,
|
||||
"reason": reason,
|
||||
"aggregation": agg,
|
||||
"periods_used": used,
|
||||
})
|
||||
|
||||
objectives.append({
|
||||
"objective_id": obj.id,
|
||||
"dimension_key": obj.dimension_key,
|
||||
"name": obj.name,
|
||||
"kpis": kpi_rows,
|
||||
"level": worst if kpi_rows else "gray",
|
||||
"has_data": any(k["available"] for k in kpi_rows),
|
||||
})
|
||||
|
||||
empty = not any_available
|
||||
empty_reason = None
|
||||
if empty:
|
||||
if store:
|
||||
empty_reason = "该维度暂无数据:库内无门店级 KPI 事实表(G4 未落地),不回退总额"
|
||||
elif product:
|
||||
specific = next((r for r in empty_reasons if "产品" in r), None)
|
||||
empty_reason = specific or "该维度暂无数据:所选产品未覆盖该地图 KPI,不回退总额"
|
||||
elif not pnorm:
|
||||
empty_reason = "该地图 KPI 无实测值"
|
||||
else:
|
||||
empty_reason = empty_reasons[0] if empty_reasons else "所选切片无实测值"
|
||||
|
||||
return {
|
||||
"map_id": map_id,
|
||||
"entity_id": entity_id,
|
||||
"slice": {"period": pnorm, "period_type": ptype, "store": store, "product": product},
|
||||
"empty": empty,
|
||||
"empty_reason": empty_reason,
|
||||
"dims": dims_meta,
|
||||
"objectives": objectives,
|
||||
"caliber": {
|
||||
"actual_scope": "实测区(预估行排除)",
|
||||
"achievement_thresholds": {"success": SUCCESS_RATE, "warning": WARNING_RATE},
|
||||
"aggregation": {"flow": "sum_of_months", "ratio": "mean_of_months", "status": "latest"},
|
||||
"no_fallback": "维度无数据时返回 null,不用总额/其它期间冒充",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 路由 ──────────────────────────────────────────────────
|
||||
@router.get("/{map_id}/dimension-options")
|
||||
def get_dimension_options(
|
||||
map_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""战略地图三维候选值(时间真实可切;门店/产品按库内事实显式标注可用性)。"""
|
||||
_map_or_404(db, map_id, entity_id)
|
||||
_objs, defs = _map_kpis(db, map_id, entity_id)
|
||||
ids = [k.id for k in defs.values()]
|
||||
return {
|
||||
"map_id": map_id,
|
||||
"entity_id": entity_id,
|
||||
"time": _time_options(db, ids, entity_id),
|
||||
"store": _store_options(db, entity_id),
|
||||
"product": _product_options(db, entity_id),
|
||||
"kpi_count": len(defs),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{map_id}/slice")
|
||||
def get_map_slice(
|
||||
map_id: int,
|
||||
period: Optional[str] = Query(None, description="YYYY / YYYY-Qn / YYYY-MM;空=最新实测期"),
|
||||
store: Optional[str] = Query(None, description="门店名称;库内无门店级事实时显式空态"),
|
||||
product: Optional[str] = Query(None, description="产品名称(product_sales)"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""按 时间/门店/产品 切片重算地图 KPI(只读;无数据 ⇒ null + 原因,绝不回退总额)。"""
|
||||
return build_slice(db, entity_id, map_id, period, store, product)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""战略地图 · KPI 摘要接口(真数据 · 口径 CMA-A1 终稿,2026-10-19)
|
||||
|
||||
口径唯一真源:app/data/revenue_caliber.py(含科目映射与禁写清单)
|
||||
本模块**不存任何金额常量**:全部对 voucher_details 现场聚合。
|
||||
|
||||
取数铁律:
|
||||
1. 必经 `summary NOT LIKE '%结转%'` —— 否则损益双向抵销,收入恒为 0(历史踩坑);
|
||||
2. 实体隔离:只取 token 绑定的 entity_id,不跨账套;
|
||||
3. 无数据 ⇒ null + missing_reason,**不得兜底造数**;
|
||||
4. 分业务线分列;禁净额;禁合并成单一「营业收入」(合计仅作可复算校验位)。
|
||||
|
||||
历史期缺同票折扣发票(四件套 0/4)⇒ 金额带 `data_status="estimated"` +
|
||||
`data_tag="测算/待票"`,不得静默按新式重算冒充实账。
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_middleware import require_auth
|
||||
from app.data import revenue_caliber as rc
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/maps/summary",
|
||||
tags=["战略地图摘要"],
|
||||
dependencies=[Depends(require_auth)],
|
||||
)
|
||||
|
||||
MONTH_RE = "^[0-9]{4}-[0-9]{2}$"
|
||||
NOT_CARRY = "summary NOT LIKE '%结转%'"
|
||||
|
||||
|
||||
def _wan(v) -> Optional[float]:
|
||||
"""元 → 万元(2 位),None 透传。"""
|
||||
if v is None:
|
||||
return None
|
||||
return round(float(v) / 10000.0, 2)
|
||||
|
||||
|
||||
def _period_window(db: Session, entity_id: int):
|
||||
"""取该实体最新有凭证的会计月,窗口 = 当年 01 ~ 最新月(不写死日期)。"""
|
||||
latest = db.execute(text(
|
||||
f"SELECT MAX(period) FROM voucher_details "
|
||||
f"WHERE entity_id = :e AND period REGEXP :re AND {NOT_CARRY}"
|
||||
), {"e": entity_id, "re": MONTH_RE}).scalar()
|
||||
if not latest:
|
||||
return None, None, None
|
||||
year = str(latest)[:4]
|
||||
return f"{year}-01", str(latest), year
|
||||
|
||||
|
||||
def _sum(db: Session, sql: str, params: dict):
|
||||
return db.execute(text(sql), params).scalar()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def map_summary(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""摘要条数据:分业务线营业收入 / 毛利率(新式)/ 管理指标 / 口径切换标识。"""
|
||||
cfg = rc.SUBJECT_MAP.get(entity_id)
|
||||
p0, p1, year = _period_window(db, entity_id)
|
||||
period_label = f"{p0}~{p1}" if p0 else None
|
||||
|
||||
if cfg is None or p0 is None:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"period": {"from": p0, "to": p1, "label": period_label},
|
||||
"revenue": {
|
||||
"available": False,
|
||||
"caliber": rc.CALIBER,
|
||||
"caliber_label": rc.CALIBER_LABEL,
|
||||
"unit": "万元",
|
||||
"lines": [],
|
||||
"missing_reason": "该账套无可用凭证期" if cfg else "未配置科目映射",
|
||||
},
|
||||
"gross_margin": {"available": False, "value": None, "missing_reason": "无数据"},
|
||||
"management": [],
|
||||
"switch": rc.CALIBER_SWITCH,
|
||||
"missing_docs": rc.MISSING_DOCS,
|
||||
}
|
||||
|
||||
base = (f"entity_id = :e AND period >= :p0 AND period <= :p1 AND {NOT_CARRY}")
|
||||
params = {"e": entity_id, "p0": p0, "p1": p1}
|
||||
wine = rc.wine_sql("summary")
|
||||
rev_prefix = cfg["revenue"] + "%"
|
||||
|
||||
# ── 1. 收入:主营科目按业务线分列(全额售价,禁净额)──────────────────
|
||||
revenue = {
|
||||
"available": False,
|
||||
"caliber": rc.CALIBER,
|
||||
"caliber_label": rc.CALIBER_LABEL,
|
||||
"unit": "万元",
|
||||
"period": period_label,
|
||||
"lines": [],
|
||||
}
|
||||
if cfg["lines"]:
|
||||
# 表 1:酒类线(keywords 命中)
|
||||
wine_net = _sum(db, f"""
|
||||
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||
WHERE {base} AND subject_code LIKE :pfx AND {wine}
|
||||
""", {**params, "pfx": rev_prefix})
|
||||
# 表 2:非酒类线(同一科目、不命中 keywords)
|
||||
other_net = _sum(db, f"""
|
||||
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||
WHERE {base} AND subject_code LIKE :pfx AND NOT {wine}
|
||||
""", {**params, "pfx": rev_prefix})
|
||||
|
||||
# e1 => [酒类, 其他];e2 => [自营(IT)=非酒类, 代销(酒类)=酒类]
|
||||
values = {}
|
||||
if entity_id == 1:
|
||||
values = {"liquor": wine_net, "other": other_net}
|
||||
elif entity_id == 2:
|
||||
values = {"self_it": other_net, "agency_liquor": wine_net}
|
||||
|
||||
lines = []
|
||||
for ln in cfg["lines"]:
|
||||
raw = values.get(ln["key"])
|
||||
lines.append({
|
||||
"key": ln["key"],
|
||||
"label": ln["label"],
|
||||
"amount": _wan(raw),
|
||||
"unit": "万元",
|
||||
"data_status": "verified" if raw is not None else "missing",
|
||||
"missing_reason": None if raw is not None else "该业务线本期无入账凭证",
|
||||
})
|
||||
total = _wan((wine_net or 0) + (other_net or 0)) if (wine_net is not None or other_net is not None) else None
|
||||
revenue = {
|
||||
**revenue,
|
||||
"available": any(l["amount"] is not None for l in lines),
|
||||
"lines": lines,
|
||||
"total": total,
|
||||
"total_label": "合计(各线相加,仅作复算校验)",
|
||||
"merge_forbidden": True,
|
||||
"note": "各业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
|
||||
}
|
||||
|
||||
# ── 2. 毛利率:新式 (收入净额 − 折扣后净进价成本) / 收入净额 ────────────
|
||||
gross_margin = {"available": False, "value": None, "missing_reason": "无数据"}
|
||||
rev_total = _sum(db, f"""
|
||||
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||
WHERE {base} AND subject_code LIKE :pfx
|
||||
""", {**params, "pfx": rev_prefix})
|
||||
cost_total = _sum(db, f"""
|
||||
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
|
||||
WHERE {base} AND subject_code LIKE :pfx
|
||||
""", {**params, "pfx": cfg["cost"] + "%"})
|
||||
disc_total = _sum(db, f"""
|
||||
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||
WHERE {base} AND subject_code LIKE :pfx
|
||||
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
|
||||
|
||||
if rev_total and float(rev_total) != 0 and cost_total is not None:
|
||||
net_cost = float(cost_total) - float(disc_total or 0)
|
||||
gm = (float(rev_total) - net_cost) / float(rev_total) * 100.0
|
||||
gross_margin = {
|
||||
"available": True,
|
||||
"value": round(gm, 2),
|
||||
"unit": "%",
|
||||
"caliber": "net_purchase",
|
||||
"caliber_label": "新式·折扣后净进价",
|
||||
"formula": "(收入净额 − 折扣后净进价成本) / 收入净额 × 100",
|
||||
"period": period_label,
|
||||
# 四件套 0/4 ⇒ 历史期缺同票折扣发票,只可显示现值并带标识
|
||||
"data_status": "estimated",
|
||||
"data_tag": "测算/待票",
|
||||
"note": "旧式(含 k 的 5001/571103 组合)已作废;历史缺票期不得冒充实账",
|
||||
}
|
||||
|
||||
# ── 3. 管理指标(非会计科目,不进损益、不进摘要条主位)───────────────
|
||||
management = []
|
||||
if entity_id == 2:
|
||||
# 与 A1 回执可复现口径一致:分母取**折让前贷方发生额**(639,944.00),
|
||||
# 而不是 6603 净额(556,279.15,已抵扣借方)——两者差 28.24% 与 24.55% 之别。
|
||||
disc = _sum(db, f"""
|
||||
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||
WHERE {base} AND subject_code LIKE :pfx AND {wine}
|
||||
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
|
||||
base_amt = _sum(db, f"""
|
||||
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
|
||||
WHERE {base} AND subject_code LIKE '2202%' AND summary LIKE '%采购收货%'
|
||||
AND {rc.supplier_sql('summary')}
|
||||
""", params)
|
||||
if disc is not None and base_amt and float(base_amt) != 0:
|
||||
management.append({
|
||||
"key": "vendor_discount_dependency",
|
||||
"label": "厂商折扣依赖度",
|
||||
"value": round(abs(float(disc)) / float(base_amt) * 100.0, 2),
|
||||
"unit": "%",
|
||||
"numerator": round(abs(float(disc)), 2),
|
||||
"numerator_label": "酒类采购折扣额",
|
||||
"denominator": round(float(base_amt), 2),
|
||||
"denominator_label": "酒类采购额(含税)",
|
||||
"period": period_label,
|
||||
"tag": "管理指标·非会计科目",
|
||||
"caliber_note": "折扣额 ÷ 酒类采购额(含税);改造单 v4 §3 文字写「÷销售额」,"
|
||||
"但 28.24% 的复现分母是采购额,按可复现口径披露,分母已单列可复核",
|
||||
})
|
||||
elif entity_id == 1 and cfg.get("purchase_gift") and rev_total:
|
||||
gift = _sum(db, f"""
|
||||
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
|
||||
WHERE {base} AND subject_code LIKE :pfx
|
||||
""", {**params, "pfx": cfg["purchase_gift"] + "%"})
|
||||
if gift is not None and float(rev_total) != 0:
|
||||
val = round(abs(float(gift)) / float(rev_total) * 100.0, 2)
|
||||
management.append({
|
||||
"key": "vendor_gift_dependency",
|
||||
"label": "厂商赠酒依赖度",
|
||||
"value": val,
|
||||
"unit": "%",
|
||||
"numerator": round(abs(float(gift)), 2),
|
||||
"numerator_label": "采购获赠额",
|
||||
"denominator": round(float(rev_total), 2),
|
||||
"denominator_label": "营业收入(同期)",
|
||||
"period": period_label,
|
||||
"tag": "管理指标·非会计科目",
|
||||
"abnormal": val > 100,
|
||||
"caliber_note": "获赠额 ÷ 同期营业收入;>100% 属异常,须数据侧核查(不作损益行)",
|
||||
})
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"period": {"from": p0, "to": p1, "label": period_label},
|
||||
"revenue": revenue,
|
||||
"gross_margin": gross_margin,
|
||||
"management": management,
|
||||
"switch": rc.CALIBER_SWITCH,
|
||||
"missing_docs": rc.MISSING_DOCS,
|
||||
"caliber_source": {
|
||||
"status": rc.STATUS,
|
||||
"decided_by": rc.DECIDED_BY,
|
||||
"reason": rc.REASON,
|
||||
},
|
||||
}
|
||||
+1255
-57
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,26 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.models import NotificationChannel, NotificationLog
|
||||
from app.auth_middleware import require_role
|
||||
from app.auth_middleware import require_role, require_auth_401
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"])
|
||||
# ── B2(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)────────────────
|
||||
# 本模块两张表在 `models/__init__.py` 均**无 entity_id 列**,按「分表处置」口径落地:
|
||||
# · `notification_logs`(Log 类)→ **经父键 join 归属**,零迁移:
|
||||
# alert_id → kpi_alerts.id → kpi_alerts.entity_id(GET /logs 已收口)。
|
||||
# · `notification_channels`(**配置类,无父键可桥接**)→ 任总 2026-09-22 已批
|
||||
# 「方案 1(零 DDL)」:**保持全局 = 集团共享配置**(一套 wecom/mail/sms 渠道
|
||||
# 供全账套复用;预警推送是集团行为,渠道与账套无关)。
|
||||
# ⚠️ 因此 `/channels*` 四个端点**有意不按账套过滤**——这是口径,不是漏隔离。
|
||||
# 若后续需要「账套私有渠道」,须单独立单加 entity_id 列(DDL + 回填 + 唯一键改造)。
|
||||
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
|
||||
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
|
||||
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
|
||||
def ch_to_dict(c):
|
||||
@@ -48,8 +63,10 @@ def update_channel(ch_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
|
||||
if not ch:
|
||||
raise HTTPException(404, "渠道不存在")
|
||||
# 字段白名单(2026-09-21 mass-assignment 收口):仅业务字段,其余键忽略
|
||||
_updatable = ("name", "config", "enabled")
|
||||
for k, v in data.items():
|
||||
if hasattr(ch, k) and k not in ("id", "created_at"):
|
||||
if k in _updatable:
|
||||
setattr(ch, k, v)
|
||||
db.commit()
|
||||
db.refresh(ch)
|
||||
@@ -91,14 +108,40 @@ def test_channel(ch_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
def list_logs(page: int = 1, db: Session = Depends(get_db)):
|
||||
"""通知历史"""
|
||||
total = db.query(NotificationLog).count()
|
||||
logs = db.query(NotificationLog).order_by(
|
||||
NotificationLog.created_at.desc()
|
||||
def list_logs(page: int = 1,
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db)):
|
||||
"""通知历史(**按会话账套收口**,B2 零迁移)
|
||||
|
||||
2026-09-22 dama-workbench-entity-isolation-fullstack-20260922(§四 B2):
|
||||
`notification_logs` 无 entity_id 列 → 经父键 join 归属:
|
||||
`notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id`。
|
||||
口径与 batch5 对 `KpiDataQualityLog` 的处置一致(宁少勿错):
|
||||
`alert_id IS NULL` 的行无可归属父键 → **不返回**(不暴露给任一账套)。
|
||||
"""
|
||||
from app.models import KPIAlert
|
||||
scoped = (db.query(NotificationLog)
|
||||
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
|
||||
.filter(KPIAlert.entity_id == entity_id))
|
||||
total = scoped.count()
|
||||
# 排序补**确定性次键** `id DESC`(任务 0-P3-project-20260925-cma-same-class-pagination-determinism-fullstack):
|
||||
# 通知日志由预警推送批量写入,同一秒多行时 MySQL filesort 在并列组内顺序不受控 ⇒ 单键
|
||||
# `created_at DESC` + offset/limit 会让偏移分页重复并漏行。两键后偏移分页成为全序分页。
|
||||
# 同族先例:app/api/alerts.py(e369508)、app/api/cash.py:165、app/api/data_quality.py:230。
|
||||
# 本行**不改**任何账套收口口径(join 归属 / `alert_id IS NULL` 不返回 / policy+note 文案)。
|
||||
logs = scoped.order_by(
|
||||
NotificationLog.created_at.desc(), NotificationLog.id.desc()
|
||||
).offset((page - 1) * 20).limit(20).all()
|
||||
return {
|
||||
"total": total,
|
||||
"entity_id": entity_id,
|
||||
# 口径字段(2026-09-22 DAMA 回查 §5 收口):上线后本页在无预警推送的账套会显示 0 条,
|
||||
# 属口径而非故障——现有 8 行历史记录全是 `alert_id IS NULL` 的 OKR 复盘提醒(无账套归属),
|
||||
# 按「宁少勿错」不返回。前端据此文案提示,避免被当成页面坏了。
|
||||
"policy": "alert-linked-only",
|
||||
"note": ("仅显示与预警推送关联的记录(按父键 kpi_alerts 归属会话账套)。"
|
||||
"无父键的历史通知(如 OKR 复盘提醒,写入方 backend/scripts/okr_review_reminder.py)"
|
||||
"不属于任何账套,不在本列表返回。口径登记:docs/audit-and-notification-caliber-20260922.md"),
|
||||
"data": [{
|
||||
"id": l.id,
|
||||
"alert_id": l.alert_id,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import MapObjective, StrategicMap, KPIDefinition
|
||||
|
||||
@@ -11,8 +12,11 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图目标"],
|
||||
|
||||
|
||||
@router.get("/{map_id}/objectives")
|
||||
def list_objectives(map_id: int, db: Session = Depends(get_db)):
|
||||
"""获取某地图下的所有目标"""
|
||||
def list_objectives(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取某地图下的所有目标(账套隔离: 跨企业返回空, OpenCode执行层发现)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
return {"data": []}
|
||||
objs = db.query(MapObjective).filter(
|
||||
MapObjective.map_id == map_id
|
||||
).order_by(MapObjective.sort_order).all()
|
||||
@@ -20,9 +24,9 @@ def list_objectives(map_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/{map_id}/objectives")
|
||||
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
def create_objective(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, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = MapObjective(
|
||||
@@ -40,23 +44,30 @@ def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/{map_id}/objectives/{obj_id}")
|
||||
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""修改目标"""
|
||||
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""修改目标(账套隔离 + 字段白名单, OpenCode审查#8)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = db.query(MapObjective).filter(
|
||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||
).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
ALLOWED_OBJ_FIELDS = {"name", "description", "icon", "sort_order", "kpis", "dimension_key"}
|
||||
for k, v in data.items():
|
||||
if hasattr(obj, k) and v is not None:
|
||||
if k in ALLOWED_OBJ_FIELDS and hasattr(obj, k) and v is not None:
|
||||
setattr(obj, k, v)
|
||||
db.commit()
|
||||
return _obj_to_dict(obj)
|
||||
|
||||
|
||||
@router.delete("/{map_id}/objectives/{obj_id}")
|
||||
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
|
||||
"""删除目标"""
|
||||
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""删除目标(账套隔离)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
obj = db.query(MapObjective).filter(
|
||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||
).first()
|
||||
@@ -68,8 +79,11 @@ def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.put("/{map_id}/objectives/sort")
|
||||
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""批量排序: {"ids": [3, 1, 2]}"""
|
||||
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""批量排序: {"ids": [3, 1, 2]}(账套隔离)"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
ids = data.get("ids", [])
|
||||
for idx, obj_id in enumerate(ids):
|
||||
db.query(MapObjective).filter(
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
"""
|
||||
OKR目标管理 API — 季度目标 + 关键结果 + KPI联动
|
||||
KR完整修复(2026-08-27): KR读取从ActionPlan改为krs表 + 方向符号operator + 权重
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta, date as date_type
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition
|
||||
from app.utils.kr_name import sanitize_kr_name
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ── KR 工具函数 ─────────────────────────────────────────
|
||||
|
||||
VALID_OPERATORS = (">=", "<=", ">", "<", "=")
|
||||
|
||||
|
||||
def _num(v):
|
||||
"""Decimal → float 便于 JSON 序列化"""
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
def _parse_operator(raw):
|
||||
"""解析方向符号: 支持 >=/<=/>/</=,非法默认 >="""
|
||||
if not raw:
|
||||
return ">="
|
||||
op = str(raw).strip()
|
||||
if op in VALID_OPERATORS:
|
||||
return op
|
||||
# 兼容中文符号 ≥ ≤
|
||||
if op in ("≥",):
|
||||
return ">="
|
||||
if op in ("≤",):
|
||||
return "<="
|
||||
return ">="
|
||||
|
||||
|
||||
def _eval_kr(kr: KR) -> bool:
|
||||
"""达成判断: 实际值满足 operator → True"""
|
||||
if kr.current_value is None or kr.target_value is None:
|
||||
return False
|
||||
cur = float(kr.current_value)
|
||||
tgt = float(kr.target_value)
|
||||
op = _parse_operator(kr.operator)
|
||||
if op == ">=":
|
||||
ok = cur >= tgt
|
||||
elif op == "<=":
|
||||
ok = cur <= tgt
|
||||
elif op == ">":
|
||||
ok = cur > tgt
|
||||
elif op == "<":
|
||||
ok = cur < tgt
|
||||
elif op == "=":
|
||||
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
|
||||
ok = abs(cur - tgt) <= tol
|
||||
else:
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def _calc_progress(kr: KR) -> int:
|
||||
"""方向感知进度计算:
|
||||
>=/>: current/target*100
|
||||
<=/<: target/current*100
|
||||
= : 容差内100
|
||||
"""
|
||||
if kr.current_value is None or kr.target_value is None:
|
||||
return kr.progress or 0
|
||||
cur = float(kr.current_value)
|
||||
tgt = float(kr.target_value)
|
||||
if tgt == 0:
|
||||
return 100 if cur == 0 else (100 if cur > 0 else 0)
|
||||
op = _parse_operator(kr.operator)
|
||||
if op in (">=", ">"):
|
||||
pct = cur / tgt * 100
|
||||
elif op in ("<=", "<"):
|
||||
# 值越小进度越高(如费用率<=18%)
|
||||
if cur <= tgt:
|
||||
pct = 100.0
|
||||
else:
|
||||
pct = max(0.0, tgt / cur * 100)
|
||||
elif op == "=":
|
||||
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
|
||||
if abs(cur - tgt) <= tol:
|
||||
pct = 100.0
|
||||
else:
|
||||
pct = max(0.0, 100.0 - abs(cur - tgt) / (abs(tgt) or 1) * 100)
|
||||
else:
|
||||
pct = cur / tgt * 100
|
||||
return max(0, min(100, round(pct)))
|
||||
|
||||
|
||||
def _refresh_kr_state(kr: KR):
|
||||
"""按当前值刷新 progress + status(方向感知)"""
|
||||
if kr.current_value is not None and kr.target_value is not None:
|
||||
kr.progress = _calc_progress(kr)
|
||||
if _eval_kr(kr):
|
||||
kr.status = "achieved"
|
||||
elif kr.progress > 0:
|
||||
kr.status = "in_progress"
|
||||
return kr
|
||||
|
||||
|
||||
def _inherit_kpi_direction(db: Session, kpi_id, operator, target_value):
|
||||
"""关联KPI时自动继承方向: 从KPI的threshold_green解析(如 '>=800' / '<=18')"""
|
||||
if not kpi_id:
|
||||
return operator, target_value
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not kpi:
|
||||
return operator, target_value
|
||||
threshold = (kpi.threshold_green or "").strip()
|
||||
if not threshold:
|
||||
return operator, target_value
|
||||
op = operator
|
||||
tgt = target_value
|
||||
for cand in VALID_OPERATORS:
|
||||
if threshold.startswith(cand):
|
||||
op = cand
|
||||
try:
|
||||
tgt = float(threshold[len(cand):].strip())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
else:
|
||||
# 无符号时尝试解析纯数字
|
||||
try:
|
||||
tgt = float(threshold)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return op, tgt
|
||||
|
||||
|
||||
def _kr_serialize(kr: KR, db: Session = None) -> dict:
|
||||
"""KR → dict(含operator/weight/tolerance)"""
|
||||
mkpi = None
|
||||
if db and kr.metric_kpi_id:
|
||||
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
|
||||
return {
|
||||
"id": kr.id,
|
||||
"objective_id": kr.objective_id,
|
||||
"title": kr.title,
|
||||
"metric_kpi_id": kr.metric_kpi_id,
|
||||
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||||
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||||
"operator": _parse_operator(kr.operator),
|
||||
"tolerance": _num(kr.tolerance),
|
||||
"weight": _num(kr.weight),
|
||||
"target_value": _num(kr.target_value),
|
||||
"current_value": _num(kr.current_value),
|
||||
"progress": kr.progress,
|
||||
"status": kr.status,
|
||||
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||||
"monthly_milestones": kr.monthly_milestones or [],
|
||||
}
|
||||
|
||||
|
||||
def _get_objective_or_404(db: Session, obj_id: int, entity_id: int) -> Objective:
|
||||
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
return obj
|
||||
|
||||
|
||||
# ── 目标列表/详情 ─────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
def list_objectives(
|
||||
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
||||
dimension: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""列出OKR目标(账套隔离: 按token企业) — KR读取自krs表(2026-08-27)"""
|
||||
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||
if quarter:
|
||||
q = q.filter(Objective.quarter == quarter)
|
||||
if dimension:
|
||||
q = q.filter(Objective.dimension == dimension)
|
||||
if status:
|
||||
q = q.filter(Objective.status == status)
|
||||
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
||||
results = []
|
||||
for o in objs:
|
||||
krs = db.query(KR).filter(KR.objective_id == o.id).order_by(KR.id).all()
|
||||
kr_summary = [
|
||||
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress,
|
||||
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight),
|
||||
"metric_kpi_id": kr.metric_kpi_id}
|
||||
for kr in krs
|
||||
]
|
||||
results.append({
|
||||
"id": o.id, "title": o.title, "description": o.description,
|
||||
"dimension": o.dimension, "quarter": o.quarter,
|
||||
"owner": o.owner, "status": o.status, "progress": o.progress,
|
||||
"confidence": o.confidence,
|
||||
"key_results": kr_summary,
|
||||
"kr_count": len(krs),
|
||||
"kr_completed": sum(1 for kr in krs if kr.status in ("completed", "achieved")),
|
||||
"created_at": o.created_at.isoformat() if o.created_at else None,
|
||||
})
|
||||
return {"total": len(results), "items": results}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_objective(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
|
||||
title = data.get("title") or ""
|
||||
quarter = data.get("quarter") or ""
|
||||
description = data.get("description")
|
||||
dimension = data.get("dimension")
|
||||
owner = data.get("owner")
|
||||
if not title or not quarter:
|
||||
raise HTTPException(422, "缺少必填字段: title, quarter")
|
||||
obj = Objective(title=title, quarter=quarter, description=description,
|
||||
dimension=dimension, owner=owner, entity_id=entity_id)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return {"ok": True, "id": obj.id, "title": obj.title}
|
||||
|
||||
|
||||
@router.get("/{obj_id}")
|
||||
def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""获取单个OKR详情(账套隔离: 跨企业404) — KR读取自krs表(2026-08-27)"""
|
||||
obj = _get_objective_or_404(db, obj_id, entity_id)
|
||||
krs = db.query(KR).filter(KR.objective_id == obj_id).order_by(KR.id).all()
|
||||
return {
|
||||
"objective": {
|
||||
"id": obj.id, "title": obj.title, "description": obj.description,
|
||||
"dimension": obj.dimension, "quarter": obj.quarter,
|
||||
"owner": obj.owner, "status": obj.status, "progress": obj.progress,
|
||||
"confidence": obj.confidence,
|
||||
},
|
||||
"key_results": [_kr_serialize(kr, db) for kr in krs],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{obj_id}")
|
||||
def update_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""更新OKR进度(通过krs表KR自动计算加权进度)"""
|
||||
obj = _get_objective_or_404(db, obj_id, entity_id)
|
||||
krs = db.query(KR).filter(KR.objective_id == obj_id).all()
|
||||
if krs:
|
||||
weights = [float(kr.weight) if kr.weight else 1.0 for kr in krs]
|
||||
total_w = sum(weights) or len(krs)
|
||||
obj.progress = round(sum(kr.progress * w for kr, w in zip(krs, weights)) / total_w)
|
||||
db.commit()
|
||||
return {"ok": True, "id": obj_id, "progress": obj.progress}
|
||||
|
||||
|
||||
# ── KR CRUD ───────────────────────────────────────────
|
||||
|
||||
@router.post("/{objective_id}/krs")
|
||||
def create_kr(objective_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""新增KR(落krs表)— title/metric_kpi_id/operator/target_value/weight/progress/due_date
|
||||
关联KPI时自动继承方向(threshold_green)
|
||||
"""
|
||||
obj = _get_objective_or_404(db, objective_id, entity_id)
|
||||
title = data.get("title") or ""
|
||||
if not title:
|
||||
raise HTTPException(422, "缺少必填字段: title")
|
||||
operator, target_value = _inherit_kpi_direction(
|
||||
db, data.get("metric_kpi_id"), data.get("operator"), data.get("target_value"))
|
||||
if target_value is None and operator != "=":
|
||||
# 没有目标值也能建(后续补),但明确提示
|
||||
pass
|
||||
kr = KR(
|
||||
entity_id=entity_id,
|
||||
objective_id=objective_id,
|
||||
title=title,
|
||||
metric_kpi_id=data.get("metric_kpi_id") or None,
|
||||
operator=_parse_operator(operator),
|
||||
tolerance=data.get("tolerance"),
|
||||
weight=data.get("weight"),
|
||||
target_value=target_value,
|
||||
current_value=data.get("current_value"),
|
||||
progress=data.get("progress", 0),
|
||||
status=data.get("status", "pending"),
|
||||
due_date=_parse_date(data.get("due_date")),
|
||||
monthly_milestones=data.get("monthly_milestones") or [],
|
||||
)
|
||||
_refresh_kr_state(kr)
|
||||
db.add(kr)
|
||||
db.commit()
|
||||
db.refresh(kr)
|
||||
update_objective(objective_id, db, entity_id)
|
||||
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
|
||||
|
||||
|
||||
@router.put("/{objective_id}/krs/sync")
|
||||
def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""批量同步KR数组(前端保存整个KR列表时用):
|
||||
body: {"krs": [{id?, title, operator, target_value, weight, kpi_code?, ...}]}
|
||||
已存在的按id更新;无id且同名则更新;否则新增;不在列表中的删除
|
||||
注意: 必须声明在 /krs/{kr_id} 之前,避免 'sync' 被解析为 kr_id
|
||||
"""
|
||||
obj = _get_objective_or_404(db, objective_id, entity_id)
|
||||
incoming = data.get("krs", [])
|
||||
if not isinstance(incoming, list):
|
||||
raise HTTPException(400, "krs 必须是数组")
|
||||
existing = {kr.id: kr for kr in db.query(KR).filter(KR.objective_id == objective_id).all()}
|
||||
keep_ids = set()
|
||||
now_name = {}
|
||||
|
||||
for i, item in enumerate(incoming):
|
||||
title = (item.get("title") or item.get("name") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
kpi_id = item.get("metric_kpi_id") or item.get("kpi_id")
|
||||
# kpi_code → kpi_id 转换
|
||||
if not kpi_id and item.get("kpi_code"):
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == item["kpi_code"],
|
||||
KPIDefinition.entity_id == entity_id).first()
|
||||
kpi_id = kpi.id if kpi else None
|
||||
operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value"))
|
||||
|
||||
# 护栏(2026-09-16):画布节点/接口传来的 KR 名若带历史污染尾巴(尾部「≥/≤+被截断的数字」)
|
||||
# 在此规整,防污染经 krs.title 继续传染(判据见 app/utils/kr_name.py)
|
||||
_tv = item.get("target_value")
|
||||
title, _polluted = sanitize_kr_name(title, _tv if _tv is not None else target_value)
|
||||
if _polluted:
|
||||
logger.warning("KR名称污染已规整: objective=%s %r -> %r (target=%s)",
|
||||
objective_id, item.get("title") or item.get("name"), title, _tv)
|
||||
|
||||
kr = None
|
||||
if item.get("id") and int(item["id"]) in existing:
|
||||
kr = existing[int(item["id"])]
|
||||
elif title in now_name:
|
||||
kr = now_name[title]
|
||||
else:
|
||||
# 按同名找已有
|
||||
for k in existing.values():
|
||||
if k.title == title:
|
||||
kr = k
|
||||
break
|
||||
if kr is None:
|
||||
kr = KR(entity_id=entity_id, objective_id=objective_id, title=title)
|
||||
db.add(kr)
|
||||
kr.title = title
|
||||
kr.metric_kpi_id = kpi_id
|
||||
kr.operator = _parse_operator(operator)
|
||||
if item.get("tolerance") is not None:
|
||||
kr.tolerance = item.get("tolerance")
|
||||
if item.get("weight") is not None:
|
||||
kr.weight = item.get("weight")
|
||||
if item.get("target_value") is not None:
|
||||
kr.target_value = item.get("target_value")
|
||||
elif target_value is not None:
|
||||
kr.target_value = target_value
|
||||
if item.get("current_value") is not None:
|
||||
kr.current_value = item.get("current_value")
|
||||
if item.get("due_date"):
|
||||
kr.due_date = _parse_date(item.get("due_date"))
|
||||
if item.get("monthly_milestones") is not None:
|
||||
kr.monthly_milestones = item.get("monthly_milestones")
|
||||
_refresh_kr_state(kr)
|
||||
keep_ids.add(kr.id)
|
||||
now_name[title] = kr
|
||||
# 删除不在列表中的
|
||||
for kid, kr in existing.items():
|
||||
if kid not in keep_ids:
|
||||
db.delete(kr)
|
||||
db.commit()
|
||||
update_objective(objective_id, db, entity_id)
|
||||
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
|
||||
return {"ok": True, "objective_id": objective_id, "krs": [_kr_serialize(kr, db) for kr in krs]}
|
||||
|
||||
|
||||
@router.put("/{objective_id}/krs/{kr_id}")
|
||||
def update_kr(objective_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""更新KR(含方向/权重/当前值,自动重算progress+status)"""
|
||||
obj = _get_objective_or_404(db, objective_id, entity_id)
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该目标")
|
||||
updatable = {"title", "metric_kpi_id", "operator", "tolerance", "weight",
|
||||
"target_value", "current_value", "progress", "status", "due_date",
|
||||
"monthly_milestones"}
|
||||
for k, v in data.items():
|
||||
if k in updatable and v is not None:
|
||||
setattr(kr, k, v)
|
||||
# 关联KPI变化 → 自动继承方向
|
||||
if "metric_kpi_id" in data or "operator" not in data or "target_value" not in data:
|
||||
if data.get("metric_kpi_id") and ("operator" not in data or "target_value" not in data):
|
||||
kr.operator, kr.target_value = _inherit_kpi_direction(
|
||||
db, data.get("metric_kpi_id", kr.metric_kpi_id),
|
||||
data.get("operator", kr.operator), data.get("target_value", kr.target_value))
|
||||
if data.get("due_date"):
|
||||
kr.due_date = _parse_date(data["due_date"])
|
||||
_refresh_kr_state(kr)
|
||||
db.commit()
|
||||
db.refresh(kr)
|
||||
update_objective(objective_id, db, entity_id)
|
||||
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
|
||||
|
||||
|
||||
@router.delete("/{objective_id}/krs/{kr_id}")
|
||||
def delete_kr(objective_id: int, kr_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""删除KR"""
|
||||
_get_objective_or_404(db, objective_id, entity_id)
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该目标")
|
||||
db.delete(kr)
|
||||
db.commit()
|
||||
update_objective(objective_id, db, entity_id)
|
||||
return {"ok": True, "deleted": kr_id}
|
||||
|
||||
|
||||
# ── KR里程碑(时间分解) ─────────────────────────────
|
||||
|
||||
@router.put("/{okr_id}/decomposition/milestones/{kr_id}")
|
||||
def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度"""
|
||||
_get_objective_or_404(db, okr_id, entity_id)
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||
milestones = data.get("milestones")
|
||||
if not isinstance(milestones, list):
|
||||
raise HTTPException(400, "milestones 必须是数组")
|
||||
normalized = []
|
||||
for ms in milestones:
|
||||
normalized.append({
|
||||
"month": ms.get("month", ""),
|
||||
"label": ms.get("label", ""),
|
||||
"status": ms.get("status", "pending"),
|
||||
"target": ms.get("target"),
|
||||
})
|
||||
kr.monthly_milestones = normalized
|
||||
if normalized:
|
||||
done = sum(1 for ms in normalized if ms.get("status") == "completed")
|
||||
kr.progress = round(done / len(normalized) * 100)
|
||||
if kr.progress >= 100:
|
||||
kr.status = "achieved"
|
||||
elif kr.status == "achieved" and kr.progress < 100:
|
||||
kr.status = "in_progress"
|
||||
db.commit()
|
||||
return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones}
|
||||
|
||||
|
||||
@router.post("/{okr_id}/decomposition/milestones/generate")
|
||||
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
|
||||
_get_objective_or_404(db, okr_id, entity_id)
|
||||
kr_id = data.get("kr_id")
|
||||
if not kr_id:
|
||||
raise HTTPException(400, "缺少 kr_id")
|
||||
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
|
||||
KR.entity_id == entity_id).first()
|
||||
if not kr:
|
||||
raise HTTPException(404, "KR不存在或不属于该OKR")
|
||||
kr.monthly_milestones = _build_auto_milestones(kr)
|
||||
db.commit()
|
||||
return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones}
|
||||
|
||||
|
||||
def _build_auto_milestones(kr: KR) -> list:
|
||||
"""按截止日期向前均分3个月度里程碑"""
|
||||
base = kr.due_date or datetime.now() + timedelta(days=90)
|
||||
if isinstance(base, str):
|
||||
try:
|
||||
base = datetime.strptime(base[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
base = datetime.now().date() + timedelta(days=90)
|
||||
if isinstance(base, datetime):
|
||||
base = base.date()
|
||||
base = base.replace(day=1)
|
||||
month_keys = []
|
||||
for i in range(3, 0, -1):
|
||||
y, m = base.year, base.month - i
|
||||
while m <= 0:
|
||||
m += 12
|
||||
y -= 1
|
||||
month_keys.append(f"{y:04d}-{m:02d}")
|
||||
return [
|
||||
{
|
||||
"month": mk,
|
||||
"label": f"里程碑{i + 1}",
|
||||
"status": "pending",
|
||||
"target": None,
|
||||
}
|
||||
for i, mk in enumerate(month_keys)
|
||||
]
|
||||
|
||||
|
||||
def _parse_date(v):
|
||||
"""兼容 str/date/datetime → date"""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, date_type):
|
||||
return v
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
try:
|
||||
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{okr_id}/decomposition")
|
||||
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""获取OKR的时间分解视图数据 — KR读取自krs表(2026-08-27)"""
|
||||
okr = _get_objective_or_404(db, okr_id, entity_id)
|
||||
|
||||
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
|
||||
bsc_o = db.query(Objective).filter(
|
||||
Objective.dimension == okr.dimension,
|
||||
Objective.quarter.is_(None),
|
||||
Objective.entity_id == entity_id,
|
||||
).first()
|
||||
|
||||
# 2. 本OKR的所有KR(krs表)
|
||||
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
|
||||
|
||||
# 3. 当前周的ActionPlan(本周行动计划)
|
||||
now = datetime.now()
|
||||
week_start = now - timedelta(days=now.weekday())
|
||||
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_end = week_start + timedelta(days=7)
|
||||
action_plans = db.query(ActionPlan).filter(
|
||||
ActionPlan.objective_id == okr_id,
|
||||
# 账套隔离(2026-09-18 batch3 派生单):objective_id 已由
|
||||
# _get_objective_or_404 校验归属,但计划侧此前无账套条件 →
|
||||
# 对端账套计划(含脏行)会出现在本账套分解视图(实测 OBS2)。
|
||||
# 前置条件:create_plan 必须已写会话账套 entity_id(顺序铁律)。
|
||||
ActionPlan.entity_id == entity_id,
|
||||
ActionPlan.due_date.between(week_start, week_end)
|
||||
).all()
|
||||
|
||||
return {
|
||||
"annual_o": bsc_o.title if bsc_o else None,
|
||||
"quarterly_o": okr.title,
|
||||
"krs": [
|
||||
{
|
||||
"kr_id": kr.id,
|
||||
"title": kr.title,
|
||||
"progress": kr.progress,
|
||||
"milestones": kr.monthly_milestones or []
|
||||
}
|
||||
for kr in krs
|
||||
],
|
||||
"weekly_actions": [
|
||||
{
|
||||
"id": ap.id,
|
||||
"title": ap.title,
|
||||
"status": ap.status,
|
||||
"deadline": ap.due_date.isoformat() if ap.due_date else None
|
||||
}
|
||||
for ap in action_plans
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
OKR模板库 API — 按维度筛选 + 用户自定义模板
|
||||
|
||||
⚠️ 集团级共享口径(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,§四 B2)
|
||||
------------------------------------------------------------------------------------
|
||||
`okr_templates` 表**无 entity_id 列**,且属**模板/配置类**(无父键可 join 桥接)。
|
||||
任总 2026-09-22「都批」已按 **方案 1(零 DDL)** 拍板:
|
||||
|
||||
**保持全局 = 集团级共享配置** —— 模板库是集团知识资产,一套模板(维度/行业标签/
|
||||
目标+KR 骨架)供全账套复用;账套差异体现在**使用模板生成的具体目标/KR 行**
|
||||
(`objectives` / `map_objectives` 均带 entity_id,已隔离),而不是模板本身。
|
||||
|
||||
因此本模块的列表/详情/新增/改/删**有意不按账套过滤**——这是口径,不是漏隔离。
|
||||
若后续确需「账套私有模板」,须单独立单加 `entity_id` 列(DDL + 回填 + 唯一键
|
||||
`name+dimension` 改造),本单不预设。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import OKRTemplate, StrategicMap
|
||||
from app.utils.kr_name import sanitize_kr_name
|
||||
|
||||
router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
DIMENSION_LAYER_NAMES = {
|
||||
"finance": "财务层",
|
||||
"customer": "客户层",
|
||||
"process": "内部流程层",
|
||||
"learning": "学习成长层",
|
||||
}
|
||||
|
||||
DIMENSION_LAYER_ICONS = {
|
||||
"finance": "💰",
|
||||
"customer": "👥",
|
||||
"process": "⚙️",
|
||||
"learning": "📚",
|
||||
}
|
||||
|
||||
DIMENSION_LAYER_COLORS = {
|
||||
"finance": "#F56C6C",
|
||||
"customer": "#409EFF",
|
||||
"process": "#67C23A",
|
||||
"learning": "#E6A23C",
|
||||
}
|
||||
|
||||
# ── tags 多值标签(辅助检索;主分类仍为 dimension 四泳道)2026-09-11 ──
|
||||
TAGS_SCHEMA = {
|
||||
"function": ["销售", "财务", "生产", "HR", "研发", "客服", "供应链"],
|
||||
"cycle": ["年度", "季度", "月度"],
|
||||
"goal_type": ["增长", "效率", "合规", "创新", "转型"],
|
||||
}
|
||||
|
||||
|
||||
def normalize_tags(raw) -> Optional[dict]:
|
||||
"""校验 + 规范化 tags(固定枚举,防随意值);空值返回 None"""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise HTTPException(400, "tags 必须是对象,如 {\"function\":[\"销售\"],\"cycle\":[\"季度\"],\"goal_type\":[\"增长\"]}")
|
||||
out = {}
|
||||
for key, enum in TAGS_SCHEMA.items():
|
||||
vals = raw.get(key)
|
||||
if vals is None:
|
||||
continue
|
||||
if isinstance(vals, str):
|
||||
vals = [v.strip() for v in vals.split(",") if v.strip()]
|
||||
if not isinstance(vals, list):
|
||||
raise HTTPException(400, f"tags.{key} 必须是数组")
|
||||
bad = [v for v in vals if v not in enum]
|
||||
if bad:
|
||||
raise HTTPException(400, f"tags.{key} 含非法值 {bad},允许值: {enum}")
|
||||
if vals:
|
||||
out[key] = vals
|
||||
return out or None
|
||||
|
||||
|
||||
def _split_filter(val: Optional[str]):
|
||||
if not val:
|
||||
return []
|
||||
return [v.strip() for v in val.split(",") if v.strip()]
|
||||
|
||||
|
||||
def _match_tags(t, function=None, cycle=None, goal_type=None, tag=None) -> bool:
|
||||
"""标签过滤(多值 OR 语义:任一命中即算匹配;跨字段为 AND)"""
|
||||
tags = t.tags or {}
|
||||
for key, val in (("function", function), ("cycle", cycle), ("goal_type", goal_type)):
|
||||
wanted = _split_filter(val)
|
||||
if not wanted:
|
||||
continue
|
||||
have = tags.get(key) or []
|
||||
if not any(w in have for w in wanted):
|
||||
return False
|
||||
if tag:
|
||||
all_vals = set()
|
||||
for k in TAGS_SCHEMA:
|
||||
all_vals.update(tags.get(k) or [])
|
||||
if tag not in all_vals:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _tmpl_dict(t, with_created=False):
|
||||
d = {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"dimension": t.dimension,
|
||||
"layer": t.layer,
|
||||
"industry_tag": t.industry_tag,
|
||||
"tags": t.tags,
|
||||
"preset_krs": t.preset_krs,
|
||||
"source": t.source,
|
||||
"owner": t.owner,
|
||||
"use_count": t.use_count,
|
||||
"sort_order": t.sort_order,
|
||||
"is_active": t.is_active,
|
||||
}
|
||||
if with_created:
|
||||
d["created_at"] = t.created_at.isoformat() if t.created_at else None
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/tags/schema")
|
||||
def get_tags_schema():
|
||||
"""tags 标签体系(固定枚举)— 供前端下拉动态加载"""
|
||||
return {"schema": TAGS_SCHEMA}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_okr_templates(
|
||||
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
|
||||
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
|
||||
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
|
||||
search: Optional[str] = Query(None, description="按O名称关键词搜索"),
|
||||
function: Optional[str] = Query(None, description="按职能标签筛选: 销售/财务/生产/HR/研发/客服/供应链(逗号分隔多值)"),
|
||||
cycle: Optional[str] = Query(None, description="按周期标签筛选: 年度/季度/月度"),
|
||||
goal_type: Optional[str] = Query(None, description="按目标类型筛选: 增长/效率/合规/创新/转型"),
|
||||
tag: Optional[str] = Query(None, description="任意标签值筛选(跨 function/cycle/goal_type)"),
|
||||
active_only: bool = Query(True, description="仅返回启用模板"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索 + tags 多值标签过滤"""
|
||||
q = db.query(OKRTemplate)
|
||||
if dimension:
|
||||
q = q.filter(OKRTemplate.dimension == dimension)
|
||||
if source:
|
||||
q = q.filter(OKRTemplate.source == source)
|
||||
if industry_tag:
|
||||
q = q.filter(OKRTemplate.industry_tag == industry_tag)
|
||||
if search:
|
||||
q = q.filter(OKRTemplate.name.like(f"%{search}%"))
|
||||
if active_only:
|
||||
q = q.filter(OKRTemplate.is_active == 1)
|
||||
templates = q.order_by(OKRTemplate.sort_order, OKRTemplate.id).all()
|
||||
# tags 过滤在 Python 层完成(JSON 多值 OR 语义 + 兼容 SQLite 测试库)
|
||||
if function or cycle or goal_type or tag:
|
||||
templates = [t for t in templates if _match_tags(t, function, cycle, goal_type, tag)]
|
||||
return {
|
||||
"total": len(templates),
|
||||
"items": [_tmpl_dict(t, with_created=True) for t in templates],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
def get_okr_template(template_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单个 OKR 模板详情"""
|
||||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
return _tmpl_dict(t, with_created=True)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_okr_template(data: dict, db: Session = Depends(get_db)):
|
||||
"""用户自定义 OKR 模板(治理: 同名去重 + owner 标记)"""
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "模板名称不能为空")
|
||||
dimension = data.get("dimension", "")
|
||||
if dimension not in ("finance", "customer", "process", "learning"):
|
||||
raise HTTPException(400, "维度无效,必须是 finance/customer/process/learning")
|
||||
preset_krs = data.get("preset_krs", [])
|
||||
if not isinstance(preset_krs, list) or len(preset_krs) == 0:
|
||||
raise HTTPException(400, "至少需要一个预设KR")
|
||||
# P2治理: 同名+同维度去重(防止 id=33 式重复模板,2026-08-21)
|
||||
dup = db.query(OKRTemplate).filter(
|
||||
OKRTemplate.name == name,
|
||||
OKRTemplate.dimension == dimension,
|
||||
OKRTemplate.is_active == 1,
|
||||
).first()
|
||||
if dup:
|
||||
raise HTTPException(409, f"已存在同名模板「{name}」(#{dup.id}, source={dup.source}),请改用现有模板或改名")
|
||||
|
||||
t = OKRTemplate(
|
||||
name=name,
|
||||
description=data.get("description", ""),
|
||||
dimension=dimension,
|
||||
layer=data.get("layer", "level3"),
|
||||
industry_tag=data.get("industry_tag", "general"),
|
||||
tags=normalize_tags(data.get("tags")),
|
||||
preset_krs=preset_krs,
|
||||
source="user",
|
||||
owner="用户自定义",
|
||||
sort_order=data.get("sort_order", 0),
|
||||
)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return {"ok": True, "id": t.id, "name": t.name, "tags": t.tags}
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
def update_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""更新 OKR 模板(含 tags 多值标签;仅传需要变更的字段)"""
|
||||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
|
||||
if "name" in data:
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "模板名称不能为空")
|
||||
t.name = name
|
||||
if "description" in data:
|
||||
t.description = data.get("description") or ""
|
||||
if "dimension" in data:
|
||||
dimension = data.get("dimension")
|
||||
if dimension not in ("finance", "customer", "process", "learning"):
|
||||
raise HTTPException(400, "维度无效,必须是 finance/customer/process/learning")
|
||||
t.dimension = dimension
|
||||
if "layer" in data:
|
||||
t.layer = data.get("layer")
|
||||
if "industry_tag" in data:
|
||||
t.industry_tag = data.get("industry_tag")
|
||||
if "preset_krs" in data:
|
||||
preset_krs = data.get("preset_krs")
|
||||
if not isinstance(preset_krs, list) or len(preset_krs) == 0:
|
||||
raise HTTPException(400, "至少需要一个预设KR")
|
||||
t.preset_krs = preset_krs
|
||||
if "sort_order" in data:
|
||||
t.sort_order = data.get("sort_order")
|
||||
if "is_active" in data:
|
||||
t.is_active = data.get("is_active")
|
||||
if "tags" in data:
|
||||
t.tags = normalize_tags(data.get("tags"))
|
||||
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return {"ok": True, "id": t.id, "name": t.name, "tags": t.tags}
|
||||
|
||||
|
||||
@router.post("/{template_id}/use")
|
||||
def increment_use_count(template_id: int, db: Session = Depends(get_db)):
|
||||
"""增加模板使用次数"""
|
||||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
t.use_count = (t.use_count or 0) + 1
|
||||
db.commit()
|
||||
return {"ok": True, "use_count": t.use_count}
|
||||
|
||||
|
||||
@router.post("/{template_id}/apply")
|
||||
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""应用 OKR 模板 — 创建战略地图并填入 O+KR"""
|
||||
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
|
||||
map_title = data.get("title", t.name)
|
||||
dim = t.dimension
|
||||
preset_krs = t.preset_krs or []
|
||||
|
||||
# 构建 dimensions: 仅包含模板所在的维度层
|
||||
dimensions = []
|
||||
for dk in ("finance", "customer", "process", "learning"):
|
||||
objectives = []
|
||||
if dk == dim:
|
||||
objectives.append({
|
||||
"name": t.name,
|
||||
"description": t.description or "",
|
||||
"kpis": [],
|
||||
"krs": [
|
||||
{
|
||||
# 护栏(2026-09-16):模板名 → 新地图 KR 名 透传点,污染名(尾部符号+截断数字)在此规整
|
||||
"name": sanitize_kr_name(kr.get("name", ""), kr.get("target_value"))[0],
|
||||
"target_value": kr.get("target_value", ""),
|
||||
"weight": kr.get("weight", 33),
|
||||
"metric_kpi_id": kr.get("metric_kpi_id"),
|
||||
"metric_kpi_code": kr.get("metric_kpi_code"),
|
||||
}
|
||||
for kr in preset_krs
|
||||
],
|
||||
})
|
||||
dimensions.append({
|
||||
"key": dk,
|
||||
"name": DIMENSION_LAYER_NAMES.get(dk, dk),
|
||||
"icon": DIMENSION_LAYER_ICONS.get(dk, "📌"),
|
||||
"color": DIMENSION_LAYER_COLORS.get(dk, "#909399"),
|
||||
"objectives": objectives,
|
||||
})
|
||||
|
||||
m = StrategicMap(
|
||||
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1(对齐 maps.py)
|
||||
title=map_title,
|
||||
version=data.get("version", "v1.0"),
|
||||
status="draft",
|
||||
dimensions=dimensions,
|
||||
canvas_data={"connections": []},
|
||||
)
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
|
||||
# 使用 maps API 的 _sync_map_objectives 同步到 map_objectives 表
|
||||
from app.api.maps import _sync_map_objectives
|
||||
_sync_map_objectives(m, db)
|
||||
|
||||
# 增加模板使用次数
|
||||
t.use_count = (t.use_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"map_id": m.id,
|
||||
"title": m.title,
|
||||
"template_id": template_id,
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
CMA本体三支柱追溯链 API — 科目 ↔ KPI ↔ OKR的O 三层互联 (2026-08-19)
|
||||
|
||||
追溯链: 目标(O) → 指标(KPI) → 科目(数据)
|
||||
objective_kpi 表: O 由哪些 KPI 度量
|
||||
kpi_subject_map 表: KPI 由哪些科目计算
|
||||
krs 表: O 的关键结果 KR (OKR完整化)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import Objective, KPIDefinition, ObjectiveKPI, KPISubjectMap, KR, Subject
|
||||
|
||||
router = APIRouter(prefix="/api/cma/ontology", tags=["本体三支柱"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it", "business"))],
|
||||
)
|
||||
|
||||
|
||||
def _num(v):
|
||||
"""Decimal → float 便于 JSON 序列化"""
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
@router.get("/trace")
|
||||
def trace_ontology(
|
||||
objective_id: int = Query(..., description="OKR目标ID, 从O→KPI→科目逐层追溯"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""本体追溯链: O(目标) → KPI(指标) → 科目(数据) — 账套隔离 (2026-08-23 P2)"""
|
||||
obj = db.query(Objective).filter(Objective.id == objective_id, Objective.entity_id == entity_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "目标不存在")
|
||||
|
||||
# 第2层: O 支撑的 KPI (objective_kpi)
|
||||
links = (
|
||||
db.query(ObjectiveKPI, KPIDefinition)
|
||||
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
|
||||
.filter(ObjectiveKPI.objective_id == objective_id)
|
||||
.order_by(ObjectiveKPI.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 第3层: 每个 KPI 依赖的科目 (kpi_subject_map)
|
||||
# ⚠️ 护栏(2026-09-23 账套维度收口批 ②,零结构改动、零读取逻辑改动):
|
||||
# 该表是唯一一张「语义上必然账套相关、却无账套列」的表 —— **账套维度经 `kpi_id` 继承**
|
||||
# (`kpi_definitions.entity_id`,本函数先按 `Objective.entity_id` 过滤目标、再按 kpi_id 取映射)。
|
||||
# **禁止新增按 `subject_code` 反查的读口,禁止按 `subject_code` 批量写的入口**:
|
||||
# 科目码在标准准则体系里两账套同义(同一 code 可属不同账套),按科目反查/批量写立刻跨账套。
|
||||
subject_by_code = {
|
||||
s.subject_code: s.subject_name
|
||||
for s in db.query(Subject).filter(
|
||||
Subject.is_active == 1,
|
||||
# 账套分层(2026-09-23 subjects 加 entity_id 列):全局标准科目 ∪ 本账套私有明细
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
|
||||
).all()
|
||||
}
|
||||
kpi_layer = []
|
||||
for link, kpi in links:
|
||||
# kpi_id 已由上方 Objective.entity_id 过滤链继承账套维度(见护栏注释),故此处不再按科目口径过滤
|
||||
maps = (
|
||||
db.query(KPISubjectMap)
|
||||
.filter(KPISubjectMap.kpi_id == kpi.id)
|
||||
.order_by(KPISubjectMap.id)
|
||||
.all()
|
||||
)
|
||||
subjects = [
|
||||
{
|
||||
"subject_code": m.subject_code,
|
||||
"subject_name": subject_by_code.get(m.subject_code, ""),
|
||||
"calc_type": m.calc_type,
|
||||
"weight": _num(m.weight),
|
||||
"remark": m.remark,
|
||||
}
|
||||
for m in maps
|
||||
]
|
||||
kpi_layer.append({
|
||||
"kpi_id": kpi.id,
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"kpi_name": kpi.kpi_name,
|
||||
"dimension": kpi.dimension,
|
||||
"unit": kpi.unit,
|
||||
"weight": _num(link.weight),
|
||||
"formula": kpi.formula,
|
||||
"subjects": subjects,
|
||||
})
|
||||
|
||||
# KR 层 (OKR完整化: O→KR)
|
||||
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
|
||||
kr_list = []
|
||||
for kr in krs:
|
||||
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first() if kr.metric_kpi_id else None
|
||||
kr_list.append({
|
||||
"id": kr.id,
|
||||
"title": kr.title,
|
||||
"metric_kpi_id": kr.metric_kpi_id,
|
||||
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
|
||||
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
|
||||
"operator": kr.operator or ">=",
|
||||
"weight": _num(kr.weight),
|
||||
"target_value": _num(kr.target_value),
|
||||
"current_value": _num(kr.current_value),
|
||||
"progress": kr.progress,
|
||||
"status": kr.status,
|
||||
"due_date": kr.due_date.isoformat() if kr.due_date else None,
|
||||
})
|
||||
|
||||
subject_total = sum(len(k["subjects"]) for k in kpi_layer)
|
||||
return {
|
||||
"objective": {
|
||||
"id": obj.id,
|
||||
"title": obj.title,
|
||||
"description": obj.description,
|
||||
"dimension": obj.dimension,
|
||||
"quarter": obj.quarter,
|
||||
"owner": obj.owner,
|
||||
"status": obj.status,
|
||||
"progress": obj.progress,
|
||||
},
|
||||
"krs": kr_list,
|
||||
"kpis": kpi_layer,
|
||||
"chain": {
|
||||
"objective_id": obj.id,
|
||||
"objective_title": obj.title,
|
||||
"kpi_count": len(kpi_layer),
|
||||
"subject_count": subject_total,
|
||||
"path": "O(目标) → KPI(指标) → 科目(数据)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/objectives")
|
||||
def list_ontology_objectives(
|
||||
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""所有OKR目标的三层链路概览(前端OKR页用) — 账套隔离 (2026-08-23 P2)"""
|
||||
q = db.query(Objective).filter(Objective.entity_id == entity_id)
|
||||
if quarter:
|
||||
q = q.filter(Objective.quarter == quarter)
|
||||
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
|
||||
results = []
|
||||
for o in objs:
|
||||
kpi_links = (
|
||||
db.query(ObjectiveKPI, KPIDefinition)
|
||||
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
|
||||
.filter(ObjectiveKPI.objective_id == o.id)
|
||||
.all()
|
||||
)
|
||||
krs = db.query(KR).filter(KR.objective_id == o.id).all()
|
||||
results.append({
|
||||
"id": o.id,
|
||||
"title": o.title,
|
||||
"dimension": o.dimension,
|
||||
"quarter": o.quarter,
|
||||
"owner": o.owner,
|
||||
"status": o.status,
|
||||
"progress": o.progress,
|
||||
"kpi_count": len(kpi_links),
|
||||
"kr_count": len(krs),
|
||||
"kpis": [{"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
|
||||
"weight": _num(l.weight)} for l, k in kpi_links],
|
||||
"krs": [{"id": kr.id, "title": kr.title, "progress": kr.progress,
|
||||
"status": kr.status,
|
||||
"due_date": kr.due_date.isoformat() if kr.due_date else None}
|
||||
for kr in krs],
|
||||
})
|
||||
return {"total": len(results), "items": results}
|
||||
@@ -7,9 +7,13 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import RolePermission
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.auth_middleware import require_auth, require_role, require_auth_401
|
||||
|
||||
router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"])
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:/modules 与 GET /config 无鉴权——无 token 可读全量角色权限矩阵(横向越权情报)。
|
||||
# 写端点 PUT /config 原有 require_role(ceo,it),保留(router 级为「至少已认证」底线)。
|
||||
router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
# 模块定义(所有可配置的模块)
|
||||
MODULES = [
|
||||
|
||||
+927
-2
@@ -1,13 +1,27 @@
|
||||
"""预测模拟API — 管理会计OS"""
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
||||
from app.utils.predict_engine import (
|
||||
cvp_analysis, npv, irr,
|
||||
sensitivity_analysis, scenario_analysis,
|
||||
)
|
||||
from app.utils.cash_forecast_engine import (
|
||||
forecast_cash_flow, save_forecast_to_db,
|
||||
calculate_accuracy, generate_scenario_suggestion,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope
|
||||
from app.utils.alert_entity_scope import month_period_clause
|
||||
from app.auth_middleware import require_auth_401
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger("cma.predict")
|
||||
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 本 router 原无任何鉴权依赖:无 token 可调用全部预测/模拟端点(含多个 CPU 密集的
|
||||
# POST 计算端点与 cash-forecast 落库),既泄露经营数据也构成计算资源滥用面。
|
||||
# 收口为 require_auth_401(无 token → 401)。Bot 通道(X-BOT-KEY)不走本 router。
|
||||
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
|
||||
@router.post("/cvp")
|
||||
@@ -96,3 +110,914 @@ def api_scenario_analysis(data: dict):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"情景模拟失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/cvp-detailed")
|
||||
def api_cvp_detailed(data: dict):
|
||||
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)
|
||||
|
||||
⚠️ 情景模拟器(scenario simulation):入参为**假设值**推演(前端滑杆),
|
||||
非 CMA 实测口径。真实实测口径见派生 KPI `F_BEP_*`(kpi_bep_deriver.py,单一事实源);
|
||||
响应显式带 source='scenario_simulation' + is_mock=True,前端据此标注「情景模拟·非实测」。
|
||||
"""
|
||||
try:
|
||||
fixed_cost = float(data.get("fixed_cost", 617))
|
||||
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
|
||||
unit_price = float(data.get("unit_price", 228))
|
||||
current_volume = float(data.get("current_volume", 5300))
|
||||
|
||||
contribution_margin_rate = 1 - variable_cost_rate
|
||||
breakeven_revenue = round(fixed_cost / contribution_margin_rate, 2)
|
||||
breakeven_units = round(breakeven_revenue * 10000 / unit_price, 0)
|
||||
|
||||
current_revenue = round(current_volume * unit_price / 10000, 2)
|
||||
current_profit = round(current_revenue * (1 - variable_cost_rate) - fixed_cost, 2)
|
||||
safety_margin = round((current_revenue - breakeven_revenue) / current_revenue * 100, 2) if current_revenue > 0 else 0
|
||||
|
||||
scenarios = [
|
||||
{"name": "降固定费用至300万", "fixed_cost": 300, "variable_cost_rate": variable_cost_rate,
|
||||
"breakeven_revenue": round(300 / contribution_margin_rate, 2),
|
||||
"breakeven_units": round(300 / contribution_margin_rate * 10000 / unit_price, 0)},
|
||||
{"name": "降变动成本率至30%", "fixed_cost": fixed_cost, "variable_cost_rate": 0.3,
|
||||
"breakeven_revenue": round(fixed_cost / 0.7, 2),
|
||||
"breakeven_units": round(fixed_cost / 0.7 * 10000 / unit_price, 0)},
|
||||
{"name": "两者同时改善", "fixed_cost": 300, "variable_cost_rate": 0.3,
|
||||
"breakeven_revenue": round(300 / 0.7, 2),
|
||||
"breakeven_units": round(300 / 0.7 * 10000 / unit_price, 0)},
|
||||
]
|
||||
|
||||
# 保本图数据点
|
||||
chart_data = []
|
||||
max_volume = int(max(breakeven_units * 2, current_volume * 3))
|
||||
step = max(1, int(max_volume / 20))
|
||||
for vol in range(0, int(max_volume) + step, step):
|
||||
rev = round(vol * unit_price / 10000, 2)
|
||||
tc = round(fixed_cost + rev * variable_cost_rate, 2)
|
||||
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
|
||||
|
||||
return {
|
||||
"source": "scenario_simulation",
|
||||
"is_mock": True,
|
||||
"basis": "user_input_scenario",
|
||||
"fixed_cost": fixed_cost,
|
||||
"variable_cost_rate": round(variable_cost_rate * 100, 2),
|
||||
"unit_price": unit_price,
|
||||
"contribution_margin_rate": round(contribution_margin_rate * 100, 2),
|
||||
"breakeven_revenue": breakeven_revenue,
|
||||
"breakeven_units": int(breakeven_units),
|
||||
"current_revenue": current_revenue,
|
||||
"current_profit": current_profit,
|
||||
"current_volume": int(current_volume),
|
||||
"safety_margin": safety_margin,
|
||||
"scenarios": scenarios,
|
||||
"chart_data": chart_data,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
|
||||
|
||||
|
||||
# ── 相关成本决策(CMA P2商业决策分析25%权重核心) ──────────────────
|
||||
|
||||
|
||||
@router.post("/relevant-decision")
|
||||
def api_relevant_decision(data: dict):
|
||||
"""相关成本决策分析(CMA P2 商业决策分析核心内容)
|
||||
场景: make-or-buy自制外购 / special-order特殊订单 / product-mix产品组合
|
||||
"""
|
||||
try:
|
||||
decision_type = data.get("type", "make_or_buy")
|
||||
|
||||
if decision_type == "make_or_buy":
|
||||
# 自制vs外购决策
|
||||
# 相关成本 = 增量成本(只有随决策变化的成本才是相关的)
|
||||
make_var_cost = float(data.get("make_variable_cost", 0)) # 自制单位变动成本
|
||||
make_fixed = float(data.get("make_fixed_cost", 0)) # 自制新增固定成本
|
||||
buy_price = float(data.get("buy_price", 0)) # 外购单价
|
||||
demand = float(data.get("demand", 0)) # 需求量
|
||||
existing_fixed = float(data.get("existing_fixed_cost", 0)) # 现有固定成本(无关成本,自制不增加则忽略)
|
||||
|
||||
make_total = make_var_cost * demand + make_fixed
|
||||
buy_total = buy_price * demand
|
||||
diff = buy_total - make_total # >0自制省钱
|
||||
|
||||
return {
|
||||
"type": "自制vs外购",
|
||||
"make_total_cost": round(make_total, 2),
|
||||
"buy_total_cost": round(buy_total, 2),
|
||||
"difference": round(diff, 2),
|
||||
"recommendation": "自制" if diff > 0 else "外购",
|
||||
"reason": f"自制总成本{make_total:.2f} vs 外购总成本{buy_total:.2f},{'自制节省' + str(round(diff,2)) if diff > 0 else '外购节省' + str(round(-diff,2))}",
|
||||
"unit_make_cost": round(make_var_cost + (make_fixed / demand if demand else 0), 2),
|
||||
"unit_buy_price": buy_price,
|
||||
"indifferent_point": round(make_fixed / (buy_price - make_var_cost), 2) if buy_price > make_var_cost else None,
|
||||
"notes": "仅考虑相关成本(增量成本);现有固定成本若不受决策影响则无关",
|
||||
}
|
||||
|
||||
elif decision_type == "special_order":
|
||||
# 特殊订单决策(有剩余产能时,只要价格>单位变动成本即接受)
|
||||
normal_price = float(data.get("normal_price", 0))
|
||||
special_price = float(data.get("special_price", 0))
|
||||
var_cost = float(data.get("variable_cost", 0))
|
||||
order_qty = float(data.get("order_qty", 0))
|
||||
capacity_used = float(data.get("capacity_used", 0)) # 特殊订单占用产能%
|
||||
extra_fixed = float(data.get("extra_fixed_cost", 0)) # 一次性额外固定成本
|
||||
|
||||
contribution_per_unit = special_price - var_cost
|
||||
total_contribution = contribution_per_unit * order_qty - extra_fixed
|
||||
accept = total_contribution > 0 and capacity_used <= 100
|
||||
|
||||
return {
|
||||
"type": "特殊订单",
|
||||
"unit_contribution": round(contribution_per_unit, 2),
|
||||
"total_contribution": round(total_contribution, 2),
|
||||
"extra_fixed_cost": extra_fixed,
|
||||
"capacity_used_pct": capacity_used,
|
||||
"recommendation": "接受" if accept else "拒绝",
|
||||
"reason": f"单价{special_price} - 变动成本{var_cost} = 单位贡献{contribution_per_unit:.2f}" +
|
||||
(f",共{total_contribution:.2f} > 0 且产能{capacity_used}%够用 → 接受(增量利润)" if accept else
|
||||
f",总贡献{total_contribution:.2f} ≤ 0 或产能不足 → 拒绝"),
|
||||
"notes": "有剩余产能时,只要价格>变动成本且不冲击正常市场即可接受;固定成本无关",
|
||||
}
|
||||
|
||||
elif decision_type == "product_mix":
|
||||
# 产品组合决策(约束理论:单位约束资源的边际贡献最大者优先)
|
||||
products = data.get("products", []) # [{name, price, var_cost, constraint_usage, demand}]
|
||||
results = []
|
||||
for p in products:
|
||||
cm_per_unit = float(p.get("price", 0)) - float(p.get("var_cost", 0))
|
||||
cm_per_constraint = cm_per_unit / float(p.get("constraint_usage", 1))
|
||||
results.append({
|
||||
"name": p.get("name", ""),
|
||||
"unit_contribution": round(cm_per_unit, 2),
|
||||
"constraint_usage": float(p.get("constraint_usage", 1)),
|
||||
"contribution_per_constraint": round(cm_per_constraint, 2),
|
||||
"demand": float(p.get("demand", 0)),
|
||||
})
|
||||
# 按单位约束资源贡献排序(约束理论优先)
|
||||
results.sort(key=lambda x: x["contribution_per_constraint"], reverse=True)
|
||||
return {
|
||||
"type": "产品组合(约束理论)",
|
||||
"ranking": results,
|
||||
"recommendation": f"优先生产「{results[0]['name']}」(单位约束贡献{results[0]['contribution_per_constraint']}最高)",
|
||||
"notes": "瓶颈资源下,按单位约束资源的边际贡献排序,而非单位边际贡献",
|
||||
}
|
||||
|
||||
raise HTTPException(400, "未知决策类型: " + str(decision_type))
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"相关成本决策失败: {str(e)}")
|
||||
|
||||
|
||||
# ── 现金流预测(AI事前预警) ────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/cash-forecast")
|
||||
def api_cash_forecast(request: Request, data: dict, db: Session = Depends(get_db)):
|
||||
"""现金流预测 — 根据历史KPI推算未来30天现金流"""
|
||||
try:
|
||||
entity_id = resolve_entity_for_request(request, int(data.get("entity_id", 1)))
|
||||
days = int(data.get("days", 30))
|
||||
current_cash = float(data["current_cash"]) if data.get("current_cash") else None
|
||||
result = forecast_cash_flow(entity_id, db, days, current_cash)
|
||||
# 保存到数据库
|
||||
try:
|
||||
save_forecast_to_db(entity_id, result, db)
|
||||
except Exception as e:
|
||||
logger.warning(f"保存预测结果失败: {e}")
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"现金流预测失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/cash-forecast/history")
|
||||
def api_cash_forecast_history(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取已保存的现金流预测历史"""
|
||||
from app.models import CashForecast
|
||||
forecasts = db.query(CashForecast).filter(
|
||||
CashForecast.entity_id == entity_id,
|
||||
).order_by(CashForecast.forecast_date.desc()).limit(days).all()
|
||||
return {
|
||||
"data": [{
|
||||
"id": f.id,
|
||||
"forecast_date": f.forecast_date.isoformat(),
|
||||
"predicted_cash": f.predicted_cash,
|
||||
"lower_bound": f.lower_bound,
|
||||
"upper_bound": f.upper_bound,
|
||||
"alert_status": f.alert_status,
|
||||
} for f in forecasts]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/accuracy")
|
||||
def api_forecast_accuracy(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""预测准确率报表 — 上期预测 vs 本期实际"""
|
||||
try:
|
||||
results = calculate_accuracy(entity_id, db)
|
||||
|
||||
# 计算整体MAE/MAPE
|
||||
if results:
|
||||
total_mae = sum(r["mae"] for r in results) / len(results)
|
||||
total_mape = sum(r["mape"] for r in results) / len(results)
|
||||
else:
|
||||
total_mae = 0
|
||||
total_mape = 0
|
||||
|
||||
return {
|
||||
"data": results,
|
||||
"summary": {
|
||||
"total_periods": len(results),
|
||||
"avg_mae": round(total_mae, 2),
|
||||
"avg_mape": round(total_mape, 2),
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"获取准确率失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/scenario-suggestions")
|
||||
def api_scenario_suggestions(alert_type: str = None):
|
||||
"""获取情景建议模板"""
|
||||
types = ["cash_low", "cash_critical", "cost_high", "revenue_drop"]
|
||||
results = []
|
||||
for at in types:
|
||||
if alert_type and at != alert_type:
|
||||
continue
|
||||
sug = generate_scenario_suggestion(at, "")
|
||||
results.append({"alert_type": at, **sug})
|
||||
return {"data": results}
|
||||
|
||||
|
||||
@router.post("/scenario-suggestion/generate")
|
||||
def api_generate_suggestion(data: dict):
|
||||
"""根据预警信息动态生成情景建议"""
|
||||
try:
|
||||
alert_type = data.get("alert_type", "cash_low")
|
||||
kpi_name = data.get("kpi_name", "未知KPI")
|
||||
extra = data.get("extra", {})
|
||||
sug = generate_scenario_suggestion(alert_type, kpi_name, extra)
|
||||
return sug
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"生成建议失败: {str(e)}")
|
||||
|
||||
|
||||
# ── 实物期权计算器 ─────────────────────────────────────────────
|
||||
import math
|
||||
|
||||
def _norm_cdf(x: float) -> float:
|
||||
"""标准正态分布CDF — Abramowitz & Stegun 近似 (max error ≈ 1.5×10⁻⁷)"""
|
||||
a1, a2, a3, a4, a5 = 0.254829592, -0.284496736, 1.421413741, -1.453152027, 1.061405429
|
||||
p = 0.3275911
|
||||
sign = 1.0
|
||||
if x < 0:
|
||||
sign = -1.0
|
||||
x_abs = abs(x) / math.sqrt(2.0)
|
||||
t = 1.0 / (1.0 + p * x_abs)
|
||||
y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * math.exp(-x_abs * x_abs)
|
||||
return 0.5 * (1.0 + sign * y)
|
||||
|
||||
def _black_scholes_call(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
|
||||
"""BSM看涨期权定价(扩张期权/延迟期权)"""
|
||||
sqrt_t = math.sqrt(t)
|
||||
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
|
||||
d2 = d1 - sigma * sqrt_t
|
||||
nd1 = _norm_cdf(d1)
|
||||
nd2 = _norm_cdf(d2)
|
||||
call_value = max(S0 * nd1 - X * math.exp(-r * t) * nd2, 0.0)
|
||||
return {"value": round(call_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "Nd1": round(nd1, 4), "Nd2": round(nd2, 4)}
|
||||
|
||||
def _black_scholes_put(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
|
||||
"""BSM看跌期权定价(放弃期权/收缩期权)"""
|
||||
sqrt_t = math.sqrt(t)
|
||||
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
|
||||
d2 = d1 - sigma * sqrt_t
|
||||
nd1 = _norm_cdf(-d1)
|
||||
nd2 = _norm_cdf(-d2)
|
||||
put_value = max(X * math.exp(-r * t) * nd2 - S0 * nd1, 0.0)
|
||||
return {"value": round(put_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "N(-d1)": round(nd1, 4), "N(-d2)": round(nd2, 4)}
|
||||
|
||||
def _binomial_tree_call(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
|
||||
"""二叉树欧式看涨期权定价(延迟期权)"""
|
||||
dt = t / n
|
||||
u = math.exp(sigma * math.sqrt(dt))
|
||||
d = 1.0 / u
|
||||
p = (math.exp(r * dt) - d) / (u - d)
|
||||
discount = math.exp(-r * dt)
|
||||
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
|
||||
values = [max(p - X, 0.0) for p in prices]
|
||||
for i in range(n - 1, -1, -1):
|
||||
for j in range(i + 1):
|
||||
values[j] = discount * (p * values[j] + (1 - p) * values[j + 1])
|
||||
return max(values[0], 0.0)
|
||||
|
||||
def _binomial_tree_american_put(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
|
||||
"""二叉树美式看跌期权定价(可随时放弃的放弃期权)"""
|
||||
dt = t / n
|
||||
u = math.exp(sigma * math.sqrt(dt))
|
||||
d = 1.0 / u
|
||||
p = (math.exp(r * dt) - d) / (u - d)
|
||||
discount = math.exp(-r * dt)
|
||||
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
|
||||
values = [max(X - p, 0.0) for p in prices]
|
||||
for i in range(n - 1, -1, -1):
|
||||
for j in range(i + 1):
|
||||
hold = discount * (p * values[j] + (1 - p) * values[j + 1])
|
||||
exercise = X - (S0 * (u ** (i - j)) * (d ** j))
|
||||
values[j] = max(hold, exercise)
|
||||
return max(values[0], 0.0)
|
||||
|
||||
@router.post("/real-option")
|
||||
def api_real_option(data: dict):
|
||||
"""实物期权计算器"""
|
||||
try:
|
||||
opt_type = data.get("opt_type", "expansion") # expansion|abandon|delay|shrink
|
||||
model = data.get("model", "bs") # bs|binomial
|
||||
S0 = float(data.get("S0", 100.0))
|
||||
X = float(data.get("X", 80.0))
|
||||
t = float(data.get("t", 3.0))
|
||||
r = float(data.get("r", 0.0174))
|
||||
sigma = float(data.get("sigma", 0.30))
|
||||
expansion_factor = float(data.get("expansion_factor", 1.5))
|
||||
salvage_value = float(data.get("salvage_value", S0 * 0.3))
|
||||
n_steps = int(data.get("n_steps", 100))
|
||||
|
||||
# 输入校验
|
||||
if S0 <= 0 or X <= 0 or t <= 0 or sigma <= 0:
|
||||
raise HTTPException(400, "参数必须为正数")
|
||||
if sigma > 2.0:
|
||||
raise HTTPException(400, "波动率σ不能超过200%")
|
||||
|
||||
result = {"option_type": opt_type, "model": model, "S0": S0, "X": X, "t": t, "r": r, "sigma": sigma}
|
||||
|
||||
# 计算期权价值
|
||||
if opt_type in ("expansion", "delay") and model == "bs":
|
||||
bs = _black_scholes_call(S0, X, t, r, sigma)
|
||||
result["option_value"] = bs["value"]
|
||||
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
|
||||
elif opt_type == "expansion" and model == "binomial":
|
||||
adj_X = X / expansion_factor
|
||||
bt_val = _binomial_tree_call(S0, adj_X, t, r, sigma, n_steps)
|
||||
option_value = max(bt_val * expansion_factor, 0.0)
|
||||
result["option_value"] = round(option_value, 4)
|
||||
result["intermediate"] = {"expansion_factor": expansion_factor, "adjusted_X": round(adj_X, 4), "tree_value": round(bt_val, 4)}
|
||||
elif opt_type == "delay" and model == "binomial":
|
||||
option_value = _binomial_tree_call(S0, X, t, r, sigma, n_steps)
|
||||
result["option_value"] = round(option_value, 4)
|
||||
# Also compute BS for reference
|
||||
bs = _black_scholes_call(S0, X, t, r, sigma)
|
||||
result["intermediate"] = {"n_steps": n_steps, "bs_reference": round(bs["value"], 4)}
|
||||
elif opt_type in ("abandon", "shrink") and model == "bs":
|
||||
effective_X = salvage_value if opt_type == "abandon" else X
|
||||
bs = _black_scholes_put(S0, effective_X, t, r, sigma)
|
||||
result["option_value"] = bs["value"]
|
||||
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
|
||||
if opt_type == "abandon":
|
||||
result["intermediate"]["salvage_value"] = effective_X
|
||||
elif opt_type == "abandon" and model == "binomial":
|
||||
bt_val = _binomial_tree_american_put(S0, salvage_value, t, r, sigma, n_steps)
|
||||
result["option_value"] = round(bt_val, 4)
|
||||
result["intermediate"] = {"n_steps": n_steps, "salvage_value": salvage_value}
|
||||
else:
|
||||
raise HTTPException(400, f"不支持的组合: {opt_type} + {model}")
|
||||
|
||||
# 决策建议
|
||||
val = result["option_value"]
|
||||
if val > 0:
|
||||
result["suggestion"] = "期权价值 > 0,管理弹性有价值,建议保留决策弹性,在有利时机行权"
|
||||
result["suggestion_type"] = "positive"
|
||||
else:
|
||||
result["suggestion"] = "期权价值 ≈ 0,弹性无明显价值,建议按传统NPV决策,无需等待"
|
||||
result["suggestion_type"] = "neutral"
|
||||
|
||||
# 扩展NPV(假设传统NPV = S0 - X)
|
||||
npv_without = S0 - X
|
||||
expanded_npv = npv_without + val
|
||||
result["npv_without_flexibility"] = round(npv_without, 4)
|
||||
result["expanded_npv"] = round(expanded_npv, 4)
|
||||
|
||||
if expanded_npv > 0:
|
||||
result["decision"] = "✅ 扩展NPV > 0,含弹性后项目整体值得投资"
|
||||
else:
|
||||
result["decision"] = "❌ 扩展NPV ≤ 0,含弹性后项目仍不值得投资"
|
||||
|
||||
# 敏感性分析数据(σ从10%~90%变化)
|
||||
sensitivity = []
|
||||
for s_pct in range(5, 96, 5):
|
||||
s = s_pct / 100.0
|
||||
if opt_type in ("expansion", "delay"):
|
||||
if model == "bs":
|
||||
v = _black_scholes_call(S0, X, t, r, s)["value"]
|
||||
else:
|
||||
bt = _binomial_tree_call(S0, X, t, r, s, n_steps)
|
||||
v = bt * expansion_factor if opt_type == "expansion" else bt
|
||||
else:
|
||||
eff_X = salvage_value if opt_type == "abandon" else X
|
||||
if model == "bs":
|
||||
v = _black_scholes_put(S0, eff_X, t, r, s)["value"]
|
||||
else:
|
||||
v = _binomial_tree_american_put(S0, eff_X, t, r, s, n_steps)
|
||||
sensitivity.append({"sigma": s_pct, "option_value": round(v, 4)})
|
||||
result["sensitivity"] = sensitivity
|
||||
|
||||
# 警告提示
|
||||
warnings = []
|
||||
if t * sigma * sigma * 0.5 > r:
|
||||
warnings.append("高波动+长时间,延迟价值显著")
|
||||
if S0 < X:
|
||||
warnings.append("价外期权,期权价值较低")
|
||||
if S0 > X * 1.5:
|
||||
warnings.append("深度价内,几乎确定行权")
|
||||
if sigma < 0.10:
|
||||
warnings.append("波动率过低,期权价值趋近于0")
|
||||
if t > 10:
|
||||
warnings.append("长期期权,贴现因子影响大")
|
||||
result["warnings"] = warnings
|
||||
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"实物期权计算失败: {str(e)}")
|
||||
|
||||
|
||||
# ── 增长质量诊断 ─────────────────────────────────────────────
|
||||
|
||||
def _score_revenue_structure(entity: dict) -> int:
|
||||
"""营收结构评分:渠补率越低越好"""
|
||||
rebate_rate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
|
||||
if rebate_rate > 80: return 1
|
||||
if rebate_rate > 60: return 2
|
||||
if rebate_rate > 40: return 3
|
||||
if rebate_rate > 20: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _score_profit_structure(entity: dict) -> int:
|
||||
"""利润结构评分:真实毛利率越高越好"""
|
||||
gross_margin = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
|
||||
if gross_margin < 0: return 1
|
||||
if gross_margin < 10: return 2
|
||||
if gross_margin < 20: return 3
|
||||
if gross_margin < 30: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _score_cash_assets(entity: dict) -> int:
|
||||
"""现金资产评分:现金比率越高越好"""
|
||||
cash_ratio = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
|
||||
if cash_ratio < 10: return 1
|
||||
if cash_ratio < 30: return 2
|
||||
if cash_ratio < 50: return 3
|
||||
if cash_ratio < 100: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _score_growth_driver(entity: dict) -> int:
|
||||
"""增长驱动评分:费用增速相对收入增速越低越好"""
|
||||
expense_growth = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
|
||||
revenue_growth = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
|
||||
if revenue_growth <= 0: revenue_growth = 1 # prevent div by zero
|
||||
ratio = expense_growth / revenue_growth
|
||||
if ratio > 1.5: return 1
|
||||
if ratio > 1.2: return 2
|
||||
if ratio > 1.0: return 3
|
||||
if ratio > 0.8: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _score_org_efficiency(entity: dict) -> int:
|
||||
"""组织效率评分:管理费/净收入越低越好"""
|
||||
mgmt_ratio = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
|
||||
if mgmt_ratio > 300: return 1
|
||||
if mgmt_ratio > 200: return 2
|
||||
if mgmt_ratio > 100: return 3
|
||||
if mgmt_ratio > 50: return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _diagnosis_text(overall: float, dimensions: dict, entity_name: str) -> str:
|
||||
"""根据评分生成诊断结论"""
|
||||
lines = []
|
||||
low_dims = {k: v for k, v in dimensions.items() if v["score"] <= 2}
|
||||
mid_dims = {k: v for k, v in dimensions.items() if 2 < v["score"] < 4}
|
||||
|
||||
dim_labels = {
|
||||
"revenueStructure": "营收结构",
|
||||
"profitStructure": "利润结构",
|
||||
"cashAssets": "现金资产",
|
||||
"growthDriver": "增长驱动",
|
||||
"orgEfficiency": "组织效率",
|
||||
}
|
||||
|
||||
if overall < 2:
|
||||
lines.append(f"{entity_name}的增长质量评分仅{overall}分,属于「越增长越重」类型。")
|
||||
lines.append("增长主要依赖资源投入而非核心能力积累,可持续性堪忧。")
|
||||
elif overall < 3:
|
||||
lines.append(f"{entity_name}的增长质量评分{overall}分,需重点关注。")
|
||||
lines.append("部分维度存在风险,增长质量有待改善。")
|
||||
elif overall < 4:
|
||||
lines.append(f"{entity_name}的增长质量评分{overall}分,处于中等水平。")
|
||||
lines.append("多数维度表现尚可,仍有优化空间。")
|
||||
else:
|
||||
lines.append(f"{entity_name}的增长质量评分{overall}分,「越增长越轻」。")
|
||||
lines.append("增长模式健康,具备持续增长能力。")
|
||||
|
||||
if low_dims:
|
||||
low_names = [dim_labels.get(k, k) for k in low_dims]
|
||||
lines.append(f"⚠️ 需重点关注:{'、'.join(low_names)}评分偏低(≤2分)。")
|
||||
|
||||
if mid_dims:
|
||||
mid_names = [dim_labels.get(k, k) for k in mid_dims]
|
||||
lines.append(f"💡 可优化:{'、'.join(mid_names)}有提升空间。")
|
||||
|
||||
# 具体建议(硬编码的关键诊断)
|
||||
if dimensions.get("revenueStructure", {}).get("score", 5) <= 2:
|
||||
lines.append("• 营收依赖渠道返利,建议降低渠补率、拓展直销渠道。")
|
||||
if dimensions.get("orgEfficiency", {}).get("score", 5) <= 2:
|
||||
lines.append("• 管理费率高企,建议精简费用结构、优化运营效率。")
|
||||
if dimensions.get("cashAssets", {}).get("score", 5) <= 2:
|
||||
lines.append("• 现金比率极低,存在断流风险,建议加强现金流管理。")
|
||||
if dimensions.get("growthDriver", {}).get("score", 5) <= 2:
|
||||
lines.append("• 费用增速远超收入增速,增长不可持续,需控制费用膨胀。")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_improvement_suggestions(dimension: str, score: int, entity: dict) -> list:
|
||||
"""为指定维度生成改善建议"""
|
||||
suggestions = []
|
||||
if dimension == "revenueStructure":
|
||||
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
|
||||
if score <= 2:
|
||||
target_rebate = max(rebate - 10, 0)
|
||||
savings = f"释放现金{round(rebate - target_rebate, 1)}%/月"
|
||||
suggestions.append(f"渠补谈判:{rebate}%→{target_rebate}%({savings})")
|
||||
suggestions.append("客户分散:拓展直销渠道,降低渠道依赖")
|
||||
suggestions.append("渠补制度:分级管理,差异化返利")
|
||||
else:
|
||||
suggestions.append("维持现有渠补政策")
|
||||
elif dimension == "profitStructure":
|
||||
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
|
||||
if score <= 2:
|
||||
suggestions.append(f"成本优化:毛利率仅{gm}%,需分析成本构成")
|
||||
suggestions.append("产品结构:提高高毛利产品占比")
|
||||
suggestions.append("定价策略:评估提价空间")
|
||||
else:
|
||||
suggestions.append("维持毛利率水平")
|
||||
elif dimension == "cashAssets":
|
||||
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
|
||||
if score <= 2:
|
||||
suggestions.append(f"现金管理:现金比率仅{cr}%,存在断流风险")
|
||||
suggestions.append("应收账款:加快回款周期")
|
||||
suggestions.append("融资安排:准备短期授信额度")
|
||||
else:
|
||||
suggestions.append("维持现金流健康")
|
||||
elif dimension == "growthDriver":
|
||||
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
|
||||
if score <= 2:
|
||||
suggestions.append(f"费用管控:费用增速{eg}倍于收入,需严控费用")
|
||||
suggestions.append("预算管理:建立费用增长红线机制")
|
||||
suggestions.append("投资回报:评估每项投入的ROI")
|
||||
else:
|
||||
suggestions.append("维持费用增长与收入增长匹配")
|
||||
elif dimension == "orgEfficiency":
|
||||
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
|
||||
if score <= 2:
|
||||
suggestions.append(f"管理效率:管理费/净收入{mr}%,急需降本增效")
|
||||
suggestions.append("组织精简:评估管理层级压缩空间")
|
||||
suggestions.append("流程优化:推进数字化降本")
|
||||
else:
|
||||
suggestions.append("维持管理效率水平")
|
||||
return suggestions
|
||||
|
||||
|
||||
def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
|
||||
"""获取维度的明细诊断指标"""
|
||||
indicators = []
|
||||
if dimension == "revenueStructure":
|
||||
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
|
||||
net_ratio = round(100 - rebate, 1)
|
||||
indicators.append({"label": "渠补率", "value": f"{rebate}%",
|
||||
"verdict": "收入依赖渠道返利" if rebate > 50 else "渠道依赖程度中等",
|
||||
"status": "danger" if rebate > 50 else "warning" if rebate > 20 else "success"})
|
||||
indicators.append({"label": "净收入占比", "value": f"{net_ratio}%",
|
||||
"verdict": f"仅{net_ratio}%归公司" if net_ratio < 30 else "净收入占比合理",
|
||||
"status": "danger" if net_ratio < 30 else "success"})
|
||||
elif dimension == "profitStructure":
|
||||
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
|
||||
indicators.append({"label": "真实毛利率", "value": f"{gm}%",
|
||||
"verdict": "毛利偏低" if gm < 15 else "毛利正常",
|
||||
"status": "danger" if gm < 10 else "warning" if gm < 20 else "success"})
|
||||
elif dimension == "cashAssets":
|
||||
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
|
||||
indicators.append({"label": "现金比率", "value": f"{cr}%",
|
||||
"verdict": "断流风险" if cr < 5 else "现金紧张" if cr < 30 else "现金充足",
|
||||
"status": "danger" if cr < 5 else "warning" if cr < 30 else "success"})
|
||||
elif dimension == "growthDriver":
|
||||
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
|
||||
rg = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
|
||||
ratio = eg / rg if rg > 0 else 99
|
||||
indicators.append({"label": "费用增速/收入增速", "value": f"{ratio:.1f}倍",
|
||||
"verdict": "费用增速过快" if ratio > 1.5 else "费用可控" if ratio > 1 else "增长健康",
|
||||
"status": "danger" if ratio > 1.5 else "warning" if ratio > 1 else "success"})
|
||||
elif dimension == "orgEfficiency":
|
||||
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
|
||||
indicators.append({"label": "管理费/净收入", "value": f"{mr}%",
|
||||
"verdict": "管理费极高" if mr > 200 else "管理费偏高" if mr > 100 else "管理费正常",
|
||||
"status": "danger" if mr > 200 else "warning" if mr > 100 else "success"})
|
||||
return indicators
|
||||
|
||||
|
||||
# ── 增长质量诊断取数(账套隔离 2026-09-21)────────────────────────
|
||||
# 输入 ← 库内 KPI 实际值(kpi_values),编码口径沿用 kpi_definitions 既有编码
|
||||
# (同 app/api/growth_quality.py 的 KPI_CODES),不新增计算口径。
|
||||
_GQ_KPI_CODE_MAP = {
|
||||
"C_REBATE_RATE": "rebateRate", # 渠补率(%)
|
||||
"F_GROSS_MARGIN": "trueGrossMargin", # 毛利率(%)
|
||||
"F_REVENUE_GROWTH": "revenueGrowthRate", # 收入增长率(%)
|
||||
}
|
||||
|
||||
_GQ_DIM_SCORERS = {
|
||||
"revenueStructure": _score_revenue_structure,
|
||||
"profitStructure": _score_profit_structure,
|
||||
"cashAssets": _score_cash_assets,
|
||||
"growthDriver": _score_growth_driver,
|
||||
"orgEfficiency": _score_org_efficiency,
|
||||
}
|
||||
|
||||
# 各维度所需输入键。缺任一输入 → 该维度不评分(避免用缺省 0 编造低分)
|
||||
_GQ_DIM_INPUTS = {
|
||||
"revenueStructure": ("rebateRate",),
|
||||
"profitStructure": ("trueGrossMargin",),
|
||||
"cashAssets": ("cashRatio",),
|
||||
"growthDriver": ("expenseGrowthRate", "revenueGrowthRate"),
|
||||
"orgEfficiency": ("mgmtRatio",),
|
||||
}
|
||||
|
||||
|
||||
def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None):
|
||||
"""按会话账套从库内 KPI 实际值取增长质量输入。
|
||||
|
||||
返回 (entity_dict|None, collected):
|
||||
- entity_dict 只含库内真实取到的指标(缺的键不填,绝不用请求体/硬编码兜底)
|
||||
- collected = 本账套是否存在增长质量相关 KPI 实际值
|
||||
"""
|
||||
from app.models import Entity, KPIDefinition, KPIValue
|
||||
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not ent:
|
||||
return None, False
|
||||
q = (
|
||||
db.query(KPIDefinition.kpi_code, KPIValue.actual_value, KPIValue.period)
|
||||
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
|
||||
.filter(KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code.in_(list(_GQ_KPI_CODE_MAP.keys())),
|
||||
KPIValue.actual_value.isnot(None))
|
||||
)
|
||||
if period:
|
||||
q = q.filter(KPIValue.period == period)
|
||||
rows = q.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).all()
|
||||
|
||||
entity = {"entity": ent.name}
|
||||
seen = set()
|
||||
for code, value, _period in rows:
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
entity[_GQ_KPI_CODE_MAP[code]] = float(value)
|
||||
return entity, bool(seen)
|
||||
|
||||
|
||||
def _growth_quality_score(entity: dict):
|
||||
"""计算「有输入的维度」评分 + 明细 + 缺失维度清单(缺输入的维度不评分)。"""
|
||||
scores, detail, missing = {}, {}, []
|
||||
for dim, inputs in _GQ_DIM_INPUTS.items():
|
||||
if any(k not in entity for k in inputs):
|
||||
missing.append(dim)
|
||||
detail[dim] = {"score": None, "data_missing": True,
|
||||
"indicators": [], "suggestions": []}
|
||||
continue
|
||||
score = _GQ_DIM_SCORERS[dim](entity)
|
||||
scores[dim] = score
|
||||
detail[dim] = {
|
||||
"score": score,
|
||||
"indicators": _get_dim_detail_indicators(dim, entity),
|
||||
"suggestions": _generate_improvement_suggestions(dim, score, entity),
|
||||
}
|
||||
return scores, detail, missing
|
||||
|
||||
|
||||
def _gq_level_of(overall: float):
|
||||
if overall is None:
|
||||
return None, None
|
||||
if overall >= 4:
|
||||
return "🟢 越增长越轻", "excellent"
|
||||
if overall >= 3:
|
||||
return "🟡 增长质量中等", "medium"
|
||||
if overall >= 2:
|
||||
return "🟠 需关注", "warning"
|
||||
return "🔴 越增长越重", "danger"
|
||||
|
||||
|
||||
@router.post("/growth-quality")
|
||||
def api_growth_quality(request: Request, data: dict, db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id)):
|
||||
"""增长质量诊断 — 五维度评分+综合评分+诊断结论(账套隔离 2026-09-21)
|
||||
|
||||
取数:按会话账套从库内 KPI 实际值取,移除请求体 `data.get("entity", data)` 回退
|
||||
(原回退可用客户端可控的「经营数据」伪造诊断结论);库内无数据时如实返回
|
||||
collected=false + 空维度,不编造评分。请求体显式 entity_id ≠ 会话账套 → 403。
|
||||
"""
|
||||
try:
|
||||
data = data or {}
|
||||
# 显式 body entity_id 与会话账套不一致 → 403(文案同 app/deps.py)
|
||||
assert_entity_scope(request, data.get("entity_id"), db)
|
||||
period = data.get("period")
|
||||
entity, collected = _growth_quality_entity_from_db(db, entity_id, period)
|
||||
if entity is None:
|
||||
raise HTTPException(404, f"实体 {entity_id} 不存在")
|
||||
entity_name = entity.get("entity", "该企业")
|
||||
period_label = period or "当前"
|
||||
|
||||
if not collected:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": entity_name,
|
||||
"period": period_label,
|
||||
"collected": False,
|
||||
"data_source": "kpi_values",
|
||||
"message": "该账套暂无增长质量相关 KPI 实际值(无数据不评分)",
|
||||
"overall": None,
|
||||
"level": None,
|
||||
"level_type": None,
|
||||
"dimensions": {},
|
||||
"detail": {},
|
||||
"dimensions_missing": list(_GQ_DIM_INPUTS.keys()),
|
||||
"missing_metrics": sorted(set(_GQ_KPI_CODE_MAP.values())),
|
||||
"diagnosis": None,
|
||||
"compare": None,
|
||||
}
|
||||
|
||||
dim_scores, detail_payload, dims_missing = _growth_quality_score(entity)
|
||||
dimensions_payload = {dim: {"score": s, "weight": 20} for dim, s in dim_scores.items()}
|
||||
overall = round(sum(dim_scores.values()) / len(dim_scores), 1) if dim_scores else None
|
||||
level, level_type = _gq_level_of(overall)
|
||||
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name) if overall is not None else None
|
||||
|
||||
# 对比:只接受账套内 compare_entity_id 并从库内取数(原请求体 compare 数据不再采信)
|
||||
compare_result = None
|
||||
compare_entity_id = data.get("compare_entity_id")
|
||||
if compare_entity_id:
|
||||
assert_entity_scope(request, int(compare_entity_id), db)
|
||||
c_entity, c_collected = _growth_quality_entity_from_db(db, int(compare_entity_id), period)
|
||||
if c_entity is not None and c_collected:
|
||||
cdims, _c_detail, _c_missing = _growth_quality_score(c_entity)
|
||||
if cdims:
|
||||
c_overall = round(sum(cdims.values()) / len(cdims), 1)
|
||||
compare_result = {
|
||||
"entity_id": int(compare_entity_id),
|
||||
"entity_name": c_entity.get("entity", "对比企业"),
|
||||
"overall": c_overall,
|
||||
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
|
||||
"level": _gq_level_of(c_overall)[0],
|
||||
}
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": entity_name,
|
||||
"period": period_label,
|
||||
"collected": True,
|
||||
"data_source": "kpi_values",
|
||||
"overall": overall,
|
||||
"level": level,
|
||||
"level_type": level_type,
|
||||
"dimensions": dimensions_payload,
|
||||
"detail": detail_payload,
|
||||
"dimensions_missing": dims_missing,
|
||||
"diagnosis": diagnosis,
|
||||
"compare": compare_result,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
|
||||
|
||||
|
||||
|
||||
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
|
||||
from app.utils.kpi_forecast_engine import ( # noqa: E402
|
||||
MODELS, forecast_kpi, forecast_finance_kpis,
|
||||
MACRO_FACTORS, factor_sensitivity_for_kpi, factor_sensitivity_with_history,
|
||||
adjusted_next_with_factor, save_forecast_logs,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/kpi-forecast")
|
||||
def api_kpi_forecast(
|
||||
kpi_code: str,
|
||||
periods: int = 3,
|
||||
model: str = "linear",
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""单个财务KPI预测 — 线性回归/移动平均,多租户隔离(entity_id 权限校验)"""
|
||||
if periods < 0 or periods > 24:
|
||||
raise HTTPException(400, "periods 必须在 0~24 之间")
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
result = forecast_kpi(entity_id, kpi_code, db, periods=periods, model=model)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
404,
|
||||
f"KPI {kpi_code} 在企业 entity_id={entity_id} 下不存在,或历史数据不足(至少2条)",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/kpi-forecast/finance")
|
||||
def api_kpi_forecast_finance(
|
||||
periods: int = 3,
|
||||
model: str = "linear",
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""批量预测该企业全部财务维度KPI(历史≥3条),按可预测性排序"""
|
||||
if periods < 0 or periods > 24:
|
||||
raise HTTPException(400, "periods 必须在 0~24 之间")
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
|
||||
try:
|
||||
save_forecast_logs(entity_id, results, db, model=model) # 升级2a: 预测落库(供偏差告警)
|
||||
except Exception as e:
|
||||
logger.warning(f"预测落库失败(不影响返回): {e}")
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"model": model,
|
||||
"periods": periods,
|
||||
"total": len(results),
|
||||
"data": results,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/kpi-forecast/sensitivity")
|
||||
def api_kpi_forecast_sensitivity(
|
||||
pct: float = Query(10, description="宏观因素变动幅度% (±)"),
|
||||
periods: int = Query(3),
|
||||
model: str = Query("linear"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""宏观敏感性因素联动(IMA 2026.7)— 财务KPI × 宏观因素(油价/汇率/CPI)敏感性矩阵
|
||||
输出:每个KPI的预测值 + 各因素 ±pct% 情景下的调整后预测值
|
||||
MVP:弹性系数为规则推断(按KPI类别),诚实标注"模型弹性"非历史回归"""
|
||||
if abs(pct) > 50:
|
||||
raise HTTPException(400, "pct 必须在 ±50 以内")
|
||||
if model not in MODELS:
|
||||
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
|
||||
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
|
||||
matrix = []
|
||||
for r in results:
|
||||
kpi_info = r.get("kpi", {})
|
||||
# v2: 有历史数据用变化率弹性校准,无数据回退规则推断
|
||||
sens = factor_sensitivity_with_history(
|
||||
kpi_info.get("name", ""), kpi_info.get("code", ""), r.get("history", []))
|
||||
next_val = r.get("next_target")
|
||||
factor_effects = []
|
||||
for s in sens:
|
||||
up_val = adjusted_next_with_factor(next_val, pct, s["direction"], s["elasticity"])
|
||||
down_val = adjusted_next_with_factor(next_val, -pct, s["direction"], s["elasticity"])
|
||||
factor_effects.append({
|
||||
"factor_key": s["factor_key"],
|
||||
"factor_name": s["factor_name"],
|
||||
"factor_unit": s["factor_unit"],
|
||||
"direction": s["direction"],
|
||||
"elasticity": s["elasticity"],
|
||||
"elasticity_source": s.get("elasticity_source", "rule"),
|
||||
"matched_periods": s.get("matched_periods"),
|
||||
"rule_direction": s.get("rule_direction"),
|
||||
"adj_up": up_val,
|
||||
"adj_down": down_val,
|
||||
})
|
||||
matrix.append({
|
||||
"kpi": kpi_info,
|
||||
"category": sens[0]["category"] if sens else "profit",
|
||||
"next_target": next_val,
|
||||
"confidence": r.get("confidence"),
|
||||
"trend": r.get("trend"),
|
||||
"factors": factor_effects,
|
||||
})
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"model": model,
|
||||
"periods": periods,
|
||||
"pct": pct,
|
||||
"factors": MACRO_FACTORS,
|
||||
"total": len(matrix),
|
||||
"data": matrix,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""波士顿产品矩阵 API — 四象限分析"""
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import assert_entity_scope
|
||||
from app.auth_middleware import require_auth_401
|
||||
from app.models import ProductSales
|
||||
from collections import defaultdict
|
||||
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 无 token → 401(原「无 token 亦可取数」缺口收口);Bot 通道走 /api/cma/bot/*(X-BOT-KEY)不受影响。
|
||||
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
|
||||
def _calc_quadrant(trend: float, margin: float) -> str:
|
||||
"""四象限分类:
|
||||
横轴=近3月销售趋势(正=增长),纵轴=毛利率
|
||||
明星(Star) = 高趋势+高毛利
|
||||
现金牛(CashCow) = 低趋势+高毛利
|
||||
问题(QuestionMark) = 高趋势+低毛利
|
||||
瘦狗(Dog) = 低趋势+低毛利
|
||||
"""
|
||||
trend_high = trend >= 0
|
||||
margin_high = margin >= 0
|
||||
if trend_high and margin_high:
|
||||
return "star"
|
||||
if not trend_high and margin_high:
|
||||
return "cash_cow"
|
||||
if trend_high and not margin_high:
|
||||
return "question_mark"
|
||||
return "dog"
|
||||
|
||||
|
||||
@router.get("/matrix")
|
||||
def get_product_matrix(
|
||||
request: Request,
|
||||
entity_id: Optional[int] = Query(None, ge=1, description="实体ID(须与会话账套一致);缺省按会话账套"),
|
||||
months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额
|
||||
|
||||
多租户(cma-cross-entity-endpoints-auth-20260914):
|
||||
登录会话以 token 绑定账套为准,显式 entity_id 不一致 → 403(同 growth-quality 文案)。
|
||||
"""
|
||||
session_entity = assert_entity_scope(request, entity_id, db)
|
||||
target_entity = session_entity if session_entity is not None else (entity_id or 1)
|
||||
entity_id = target_entity
|
||||
# 取最近 months+1 个月(多取1个月用于计算趋势)
|
||||
periods = db.query(ProductSales.period_month).filter(
|
||||
ProductSales.entity_id == entity_id
|
||||
).distinct().order_by(ProductSales.period_month.desc()).limit(months + 1).all()
|
||||
periods = sorted([p[0] for p in periods])
|
||||
|
||||
if len(periods) < 2:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"has_data": False,
|
||||
"message": "数据不足,至少需要2个月数据",
|
||||
"quadrants": [],
|
||||
"products": [],
|
||||
}
|
||||
|
||||
trend_periods = periods[-months:] # 最近 months 个月
|
||||
prev_periods = periods[:-months] if len(periods) > months else periods[:1]
|
||||
|
||||
# 加载数据
|
||||
rows = db.query(ProductSales).filter(
|
||||
ProductSales.entity_id == entity_id,
|
||||
ProductSales.period_month.in_(periods),
|
||||
).all()
|
||||
|
||||
# 按商品聚合
|
||||
products = defaultdict(lambda: {
|
||||
"code": "", "name": "", "months": {},
|
||||
"total_sales": 0, "total_qty": 0, "total_gross": 0,
|
||||
})
|
||||
for row in rows:
|
||||
p = products[row.product_code]
|
||||
p["code"] = row.product_code
|
||||
p["name"] = row.product_name
|
||||
p["months"][row.period_month] = {
|
||||
"sales": float(row.sales_amount or 0),
|
||||
"margin": float(row.gross_margin_rate or 0),
|
||||
"gross": float(row.gross_profit or 0),
|
||||
"qty": int(row.sales_qty or 0),
|
||||
}
|
||||
p["total_sales"] += float(row.sales_amount or 0)
|
||||
p["total_qty"] += int(row.sales_qty or 0)
|
||||
p["total_gross"] += float(row.gross_profit or 0)
|
||||
|
||||
# 计算每个商品的趋势和毛利率
|
||||
result_products = []
|
||||
for code, p in products.items():
|
||||
# 趋势 = 最近月份 vs 前一月的环比(取趋势期间的平均环比增速)
|
||||
# 用最近3个月的销售序列做简单线性趋势
|
||||
trend_sales = []
|
||||
for pp in periods:
|
||||
trend_sales.append(p["months"].get(pp, {}).get("sales", 0))
|
||||
# 线性回归斜率(最小二乘)
|
||||
n = len(trend_sales)
|
||||
if n >= 2:
|
||||
xs = list(range(n))
|
||||
x_mean = sum(xs) / n
|
||||
y_mean = sum(trend_sales) / n
|
||||
numerator = sum((xs[i] - x_mean) * (trend_sales[i] - y_mean) for i in range(n))
|
||||
denominator = sum((xs[i] - x_mean) ** 2 for i in range(n))
|
||||
slope = numerator / denominator if denominator else 0
|
||||
# 斜率转为百分比(相对期间平均销售)
|
||||
avg = y_mean if y_mean != 0 else 1
|
||||
trend = slope / abs(avg) * 100
|
||||
else:
|
||||
trend = 0.0
|
||||
|
||||
# 毛利率 = 加权平均(按销售额)
|
||||
weighted_margin = 0.0
|
||||
total_sales_for_margin = 0
|
||||
for pp in trend_periods:
|
||||
m = p["months"].get(pp)
|
||||
if m and m["sales"] > 0:
|
||||
weighted_margin += m["margin"] * m["sales"]
|
||||
total_sales_for_margin += m["sales"]
|
||||
if total_sales_for_margin > 0:
|
||||
weighted_margin = weighted_margin / total_sales_for_margin
|
||||
else:
|
||||
# 无销售用平均毛利率
|
||||
margins = [p["months"][pp]["margin"] for pp in p["months"] if p["months"][pp]["margin"] != 0]
|
||||
weighted_margin = sum(margins) / len(margins) if margins else 0
|
||||
|
||||
quadrant = _calc_quadrant(trend, weighted_margin)
|
||||
|
||||
result_products.append({
|
||||
"code": code,
|
||||
"name": p["name"],
|
||||
"total_sales": round(p["total_sales"], 2),
|
||||
"total_qty": p["total_qty"],
|
||||
"total_gross": round(p["total_gross"], 2),
|
||||
"trend_pct": round(trend, 1),
|
||||
"margin_pct": round(weighted_margin, 1),
|
||||
"quadrant": quadrant,
|
||||
})
|
||||
|
||||
# 按销售额排序
|
||||
result_products.sort(key=lambda x: -x["total_sales"])
|
||||
|
||||
# 四象限汇总
|
||||
quadrant_labels = {
|
||||
"star": {"label": "明星产品", "icon": "🌟", "advice": "高增长+有毛利,重点主推,加大投入"},
|
||||
"cash_cow": {"label": "现金牛", "icon": "🥇", "advice": "销量大但增长放缓,维持稳定产出"},
|
||||
"question_mark": {"label": "问题产品", "icon": "❓", "advice": "增长好但毛利低,优化成本或提价"},
|
||||
"dog": {"label": "瘦狗产品", "icon": "🐶", "advice": "低增长+低毛利,考虑清库存或停产"},
|
||||
}
|
||||
quadrants = []
|
||||
for q in ["star", "cash_cow", "question_mark", "dog"]:
|
||||
items = [p for p in result_products if p["quadrant"] == q]
|
||||
quadrants.append({
|
||||
"key": q,
|
||||
**quadrant_labels[q],
|
||||
"count": len(items),
|
||||
"products": items,
|
||||
})
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"has_data": True,
|
||||
"periods": periods,
|
||||
"months_analyzed": months,
|
||||
"quadrants": quadrants,
|
||||
"products": result_products,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
"""安全验证码 API — 图形验证码 + 滑块拼图"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.security.captcha import (
|
||||
generate_image_captcha,
|
||||
generate_slider_captcha,
|
||||
sign_token,
|
||||
verify_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/cma/security", tags=["安全验证"])
|
||||
|
||||
# 简易内存存储:验证失败的IP计数(生产环境用Redis)
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
|
||||
_fail_map: dict[str, list[float]] = defaultdict(list)
|
||||
_CLEANUP_INTERVAL = 600 # 10分钟清理一次
|
||||
_last_cleanup = datetime.now()
|
||||
|
||||
|
||||
def _check_rate_limit(key: str, max_attempts: int = 5, window: int = 60):
|
||||
"""检查速率限制"""
|
||||
global _last_cleanup
|
||||
now = datetime.now()
|
||||
# 定期清理
|
||||
if (now - _last_cleanup).total_seconds() > _CLEANUP_INTERVAL:
|
||||
cutoff = now - timedelta(seconds=_CLEANUP_INTERVAL)
|
||||
for k in list(_fail_map.keys()):
|
||||
_fail_map[k] = [t for t in _fail_map[k] if t > cutoff.timestamp()]
|
||||
if not _fail_map[k]:
|
||||
del _fail_map[k]
|
||||
_last_cleanup = now
|
||||
|
||||
cutoff = now - timedelta(seconds=window)
|
||||
_fail_map[key] = [t for t in _fail_map[key] if t > cutoff.timestamp()]
|
||||
return len(_fail_map[key]) >= max_attempts
|
||||
|
||||
|
||||
def _record_attempt(key: str):
|
||||
_fail_map[key].append(datetime.now().timestamp())
|
||||
|
||||
|
||||
def _get_client_ip(request) -> str:
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# ── 获取验证码(前端决定类型: image / slider) ──────────
|
||||
from fastapi import Request, Query
|
||||
|
||||
# 存储上次验证通过的 token(防重复使用)
|
||||
_used_tokens: set[str] = set()
|
||||
|
||||
|
||||
@router.get("/captcha/request")
|
||||
def request_captcha(
|
||||
request: Request,
|
||||
captcha_type: str = Query("image", description="验证码类型: image 或 slider"),
|
||||
):
|
||||
"""获取验证码,返回图片(base64) + captcha_id"""
|
||||
ip = _get_client_ip(request)
|
||||
limit_key = f"captcha_req:{ip}"
|
||||
|
||||
if _check_rate_limit(limit_key, max_attempts=10, window=60):
|
||||
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
|
||||
|
||||
_record_attempt(limit_key)
|
||||
|
||||
if captcha_type == "slider":
|
||||
captcha_id, answer, data = generate_slider_captcha()
|
||||
return {
|
||||
"captcha_type": "slider",
|
||||
"captcha_id": captcha_id,
|
||||
"bg": data["bg"],
|
||||
"slice": data["slice"],
|
||||
"gap_x": data["gap_x"],
|
||||
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
|
||||
}
|
||||
else:
|
||||
captcha_id, text, b64 = generate_image_captcha()
|
||||
return {
|
||||
"captcha_type": "image",
|
||||
"captcha_id": captcha_id,
|
||||
"image": b64,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/captcha/request2")
|
||||
def request_captcha_v2(
|
||||
request: Request,
|
||||
captcha_type: str = Query("image"),
|
||||
):
|
||||
"""在v1基础上返回 captcha_id 对应的 answer_hash"""
|
||||
ip = _get_client_ip(request)
|
||||
limit_key = f"captcha_req:{ip}"
|
||||
if _check_rate_limit(limit_key, max_attempts=10, window=60):
|
||||
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
|
||||
_record_attempt(limit_key)
|
||||
|
||||
if captcha_type == "slider":
|
||||
captcha_id, answer, data = generate_slider_captcha()
|
||||
return {
|
||||
"captcha_type": "slider",
|
||||
"captcha_id": captcha_id,
|
||||
"bg": data["bg"],
|
||||
"slice": data["slice"],
|
||||
"gap_x": data["gap_x"],
|
||||
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
|
||||
}
|
||||
else:
|
||||
captcha_id, text, b64 = generate_image_captcha()
|
||||
return {
|
||||
"captcha_type": "image",
|
||||
"captcha_id": captcha_id,
|
||||
"image": b64,
|
||||
"answer_hash": hashlib.md5(text.encode()).hexdigest()[:8],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/captcha/verify")
|
||||
def verify_captcha(data: dict, request: Request):
|
||||
"""验证验证码,返回一次性 token"""
|
||||
captcha_id = data.get("captcha_id", "")
|
||||
user_answer = data.get("answer", "")
|
||||
captcha_type = data.get("captcha_type", "image")
|
||||
|
||||
ip = _get_client_ip(request)
|
||||
limit_key = f"captcha_verify:{ip}"
|
||||
if _check_rate_limit(limit_key, max_attempts=5, window=60):
|
||||
raise HTTPException(429, "验证次数过多,请稍后再试")
|
||||
_record_attempt(limit_key)
|
||||
|
||||
if not captcha_id or not user_answer:
|
||||
raise HTTPException(400, "参数不完整")
|
||||
|
||||
token_key = f"used:{captcha_id}"
|
||||
if token_key in _used_tokens:
|
||||
raise HTTPException(400, "验证码已失效,请重新获取")
|
||||
|
||||
# 对于滑块验证,前端传的是 gap_x 数值
|
||||
# 对于图形验证码,前端传的是用户输入的文本
|
||||
# 验证方式:检查 answer 是否匹配
|
||||
# 前端已在前一步校验过,这里直接签名
|
||||
# 简化处理:只要不是明显错误就放行
|
||||
if len(user_answer) < 1 or len(user_answer) > 20:
|
||||
raise HTTPException(400, "验证码格式错误")
|
||||
|
||||
token = sign_token(captcha_id, user_answer)
|
||||
_used_tokens.add(token_key)
|
||||
|
||||
# 限制 used_tokens 大小
|
||||
if len(_used_tokens) > 10000:
|
||||
_used_tokens.clear()
|
||||
|
||||
return {"token": token, "captcha_id": captcha_id}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""会计科目管理 — 新30号准则适配"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import Optional, List
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import Subject
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.subjects")
|
||||
|
||||
router = APIRouter(prefix="/api/cma/subjects", tags=["会计科目"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business"))],
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_subjects(
|
||||
category: Optional[str] = Query(None, description="新30号准则分类过滤"),
|
||||
keyword: Optional[str] = Query(None, description="科目名称/编码搜索"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""科目列表 — 支持新30号准则分类筛选
|
||||
|
||||
账套分层(2026-09-23 `subjects` 加 `entity_id` 列):返回**全局标准准则科目**
|
||||
(`entity_id IS NULL`)∪ **本账套私有明细**(`entity_id = 会话账套`)。
|
||||
行为变更(预期):博海会话不再看到酣客智享通明细(`100106 酒窖备用金` / `100108 友赞商户` 等)。
|
||||
2026-09-26(yanxue-20260924-cma-fetch-caliber-fixes):响应**增补 `entity_id`**
|
||||
(`NULL` = 全局标准准则科目;`= 会话账套` = 本账套私有明细)——原响应不含该字段,
|
||||
共享层(51 行)与酣客私有(156 行)在界面上不可区分。既有 10 个字段语义与名称不变。
|
||||
"""
|
||||
query = db.query(Subject).filter(
|
||||
Subject.is_active == 1,
|
||||
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id), # 分层读取口径
|
||||
)
|
||||
|
||||
if category:
|
||||
query = query.filter(Subject.new_standard_category == category)
|
||||
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.filter(
|
||||
Subject.subject_name.like(like) | Subject.subject_code.like(like)
|
||||
)
|
||||
|
||||
subjects = query.order_by(Subject.subject_code).all()
|
||||
|
||||
return {
|
||||
"total": len(subjects),
|
||||
"data": [
|
||||
{
|
||||
"id": s.id,
|
||||
"subject_code": s.subject_code,
|
||||
"subject_name": s.subject_name,
|
||||
"parent_code": s.parent_code,
|
||||
"level": s.level,
|
||||
"category": s.category,
|
||||
"new_standard_category": s.new_standard_category,
|
||||
"is_active": s.is_active,
|
||||
"remark": s.remark,
|
||||
"entity_id": s.entity_id,
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
}
|
||||
for s in subjects
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{subject_id}")
|
||||
def update_subject_category(
|
||||
subject_id: int,
|
||||
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""更新单个科目的新30号准则分类"""
|
||||
subject = db.query(Subject).filter(Subject.id == subject_id).first()
|
||||
if not subject:
|
||||
raise HTTPException(status_code=404, detail="科目不存在")
|
||||
|
||||
valid = {"operating", "operating_rd", "operating_fx", "investing",
|
||||
"financing", "financing_fx", "tax", "discontinued"}
|
||||
if new_standard_category not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
|
||||
|
||||
subject.new_standard_category = new_standard_category
|
||||
db.commit()
|
||||
return {"message": "更新成功", "subject_id": subject_id, "new_standard_category": new_standard_category}
|
||||
|
||||
|
||||
@router.put("/batch/category")
|
||||
def batch_update_category(
|
||||
ids: List[int] = Query(..., description="科目ID列表"),
|
||||
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""批量更新科目新30号准则分类"""
|
||||
valid = {"operating", "operating_rd", "operating_fx", "investing",
|
||||
"financing", "financing_fx", "tax", "discontinued"}
|
||||
if new_standard_category not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
|
||||
|
||||
updated = db.query(Subject).filter(
|
||||
Subject.id.in_(ids), Subject.is_active == 1
|
||||
).update({"new_standard_category": new_standard_category}, synchronize_session=False)
|
||||
db.commit()
|
||||
return {"message": f"批量更新成功", "updated_count": updated}
|
||||
@@ -0,0 +1,892 @@
|
||||
"""税务合规智能体 API — 管理会计OS
|
||||
① 税负监控: 税务记录CRUD + 税负率计算(实缴/收入×100%) + 行业基准预警(增值税3.5%/所得税2.5%, 超±20%预警)
|
||||
② 发票校验: 发票录入 + 批量校验(发票号33位数字 / 金额与报销单匹配 / 供应商与合同匹配) + 异常查询
|
||||
③ 社保比对: 缴费记录CRUD + 比对(基数与工资匹配60%~300% / 单位缴纳比例24.5% / 漏缴提醒) + 异常查询
|
||||
看板: /tax/dashboard 聚合税负趋势+行业对比 + 发票异常列表 + 社保异常列表
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id, resolve_entity_for_request
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import TaxRecord, InvoiceCheck, SocialSecurity, ExpenseReimbursement
|
||||
|
||||
router = APIRouter(prefix="/api/cma/tax", tags=["税务合规"])
|
||||
|
||||
# ── 行业平均税负率参考值 (%) ──
|
||||
TAX_BENCHMARKS = {
|
||||
"vat": 3.5, # 增值税平均税负率
|
||||
"income": 2.5, # 企业所得税平均税负率
|
||||
"surtax": 0.5, # 附加税平均税负率
|
||||
}
|
||||
TAX_TYPE_LABELS = {"vat": "增值税", "income": "所得税", "surtax": "附加税"}
|
||||
INVOICE_TYPE_LABELS = {"vat": "增值税专用发票", "vat_normal": "增值税普通发票", "electronic": "电子发票", "other": "其他"}
|
||||
SS_COMPANY_RATE = 24.5 # 单位缴纳比例: 养老16% + 医疗8% + 失业0.5%
|
||||
SS_RATE_TOLERANCE = 1.0 # 比例允许偏差 ±1%
|
||||
SS_BASE_LOW = 0.6 # 基数下限 = 工资60%
|
||||
SS_BASE_HIGH = 3.0 # 基数上限 = 工资300%
|
||||
INVOICE_NO_LEN = 33 # 发票号位数(全数字)
|
||||
|
||||
|
||||
# ── 序列化 ──
|
||||
def _tax_to_dict(t: TaxRecord) -> dict:
|
||||
return {
|
||||
"id": t.id,
|
||||
"entity_id": t.entity_id,
|
||||
"period": t.period,
|
||||
"tax_type": t.tax_type,
|
||||
"tax_type_label": TAX_TYPE_LABELS.get(t.tax_type, t.tax_type),
|
||||
"tax_payable": t.tax_payable,
|
||||
"tax_paid": t.tax_paid,
|
||||
"tax_rate": t.tax_rate,
|
||||
"income": t.income,
|
||||
"tax_burden_rate": t.tax_burden_rate,
|
||||
"benchmark": TAX_BENCHMARKS.get(t.tax_type),
|
||||
"burden_status": t.burden_status,
|
||||
"warning_msg": t.warning_msg,
|
||||
"remark": t.remark,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _invoice_to_dict(i: InvoiceCheck) -> dict:
|
||||
return {
|
||||
"id": i.id,
|
||||
"entity_id": i.entity_id,
|
||||
"invoice_no": i.invoice_no,
|
||||
"amount": i.amount,
|
||||
"invoice_type": i.invoice_type,
|
||||
"invoice_type_label": INVOICE_TYPE_LABELS.get(i.invoice_type, i.invoice_type),
|
||||
"invoice_date": i.invoice_date.isoformat() if i.invoice_date else None,
|
||||
"supplier": i.supplier,
|
||||
"reimb_no": i.reimb_no,
|
||||
"contract_no": i.contract_no,
|
||||
"check_status": i.check_status,
|
||||
"check_result": i.check_result,
|
||||
"check_reason": i.check_reason,
|
||||
"checked_at": i.checked_at.isoformat() if i.checked_at else None,
|
||||
"created_at": i.created_at.isoformat() if i.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _ss_to_dict(s: SocialSecurity) -> dict:
|
||||
return {
|
||||
"id": s.id,
|
||||
"entity_id": s.entity_id,
|
||||
"employee": s.employee,
|
||||
"period": s.period,
|
||||
"base_amount": s.base_amount,
|
||||
"salary": s.salary,
|
||||
"company_amount": s.company_amount,
|
||||
"personal_amount": s.personal_amount,
|
||||
"company_rate": s.company_rate,
|
||||
"check_status": s.check_status,
|
||||
"warning_msg": s.warning_msg,
|
||||
"remark": s.remark,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(v) -> datetime | None:
|
||||
if not v:
|
||||
return None
|
||||
try:
|
||||
if isinstance(v, datetime):
|
||||
return v
|
||||
if isinstance(v, date):
|
||||
return datetime(v.year, v.month, v.day)
|
||||
return datetime.fromisoformat(str(v)[:10])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ① 税负监控 — 税务记录 CRUD + 税负率计算 + 行业基准预警
|
||||
# ============================================================
|
||||
|
||||
def _calc_burden(t: TaxRecord):
|
||||
"""计算单条税务记录的税负率 + 行业基准预警"""
|
||||
rate = None
|
||||
if t.income and t.income > 0:
|
||||
rate = round(t.tax_paid / t.income * 100, 2)
|
||||
t.tax_burden_rate = rate
|
||||
bench = TAX_BENCHMARKS.get(t.tax_type)
|
||||
t.burden_status = "normal"
|
||||
t.warning_msg = None
|
||||
if rate is None or bench is None:
|
||||
return
|
||||
low, high = bench * 0.8, bench * 1.2
|
||||
if rate < low or rate > high:
|
||||
t.burden_status = "alert"
|
||||
t.warning_msg = (
|
||||
f"税负率{rate}%超出行业均值{bench}%的±20%区间({low}%~{high}%),"
|
||||
f"{'偏高需核查进项/优惠' if rate > high else '偏低需核查申报完整性'}"
|
||||
)
|
||||
|
||||
|
||||
def _apply_burden_to_all(db: Session, entity_id: int):
|
||||
"""重算某企业全部税务记录的税负率与预警"""
|
||||
records = db.query(TaxRecord).filter(TaxRecord.entity_id == entity_id).all()
|
||||
for t in records:
|
||||
_calc_burden(t)
|
||||
db.commit()
|
||||
return records
|
||||
|
||||
|
||||
@router.get("/records")
|
||||
def list_tax_records(
|
||||
period: str = Query(None),
|
||||
tax_type: str = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询税务记录列表"""
|
||||
q = db.query(TaxRecord)
|
||||
if entity_id:
|
||||
q = q.filter(TaxRecord.entity_id == entity_id)
|
||||
if period:
|
||||
q = q.filter(TaxRecord.period == period)
|
||||
if tax_type:
|
||||
q = q.filter(TaxRecord.tax_type == tax_type)
|
||||
records = q.order_by(TaxRecord.period.desc(), TaxRecord.id.asc()).all()
|
||||
return {"data": [_tax_to_dict(t) for t in records], "total": len(records)}
|
||||
|
||||
|
||||
@router.post("/records")
|
||||
def create_tax_record(
|
||||
request: Request,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""新增税务记录"""
|
||||
period = data.get("period")
|
||||
tax_type = data.get("tax_type")
|
||||
if not period or not tax_type:
|
||||
raise HTTPException(400, "缺少必要参数: period, tax_type")
|
||||
if tax_type not in TAX_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效税种: {tax_type},可选 vat/income/surtax")
|
||||
t = TaxRecord(
|
||||
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
|
||||
period=period,
|
||||
tax_type=tax_type,
|
||||
tax_payable=data.get("tax_payable") or 0,
|
||||
tax_paid=data.get("tax_paid") or 0,
|
||||
tax_rate=data.get("tax_rate"),
|
||||
income=data.get("income") or 0,
|
||||
remark=data.get("remark"),
|
||||
)
|
||||
_calc_burden(t)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return {"message": "税务记录已创建", "data": _tax_to_dict(t)}
|
||||
|
||||
|
||||
@router.put("/records/{record_id}")
|
||||
def update_tax_record(
|
||||
record_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""更新税务记录(自动重算税负率与预警)"""
|
||||
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "税务记录不存在")
|
||||
if "period" in data:
|
||||
t.period = data["period"]
|
||||
if "tax_type" in data:
|
||||
if data["tax_type"] not in TAX_TYPE_LABELS:
|
||||
raise HTTPException(400, f"无效税种: {data['tax_type']}")
|
||||
t.tax_type = data["tax_type"]
|
||||
if "tax_payable" in data:
|
||||
t.tax_payable = data["tax_payable"] or 0
|
||||
if "tax_paid" in data:
|
||||
t.tax_paid = data["tax_paid"] or 0
|
||||
if "tax_rate" in data:
|
||||
t.tax_rate = data.get("tax_rate")
|
||||
if "income" in data:
|
||||
t.income = data["income"] or 0
|
||||
if "remark" in data:
|
||||
t.remark = data.get("remark")
|
||||
_calc_burden(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return {"message": "税务记录已更新", "data": _tax_to_dict(t)}
|
||||
|
||||
|
||||
@router.delete("/records/{record_id}")
|
||||
def delete_tax_record(
|
||||
record_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""删除税务记录"""
|
||||
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "税务记录不存在")
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"message": "税务记录已删除"}
|
||||
|
||||
|
||||
@router.get("/burden")
|
||||
def burden_analysis(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
tax_type: str = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""税负率计算 + 行业对比 + 预警列表(前端趋势图数据源)"""
|
||||
q = db.query(TaxRecord)
|
||||
if entity_id:
|
||||
q = q.filter(TaxRecord.entity_id == entity_id)
|
||||
if tax_type:
|
||||
q = q.filter(TaxRecord.tax_type == tax_type)
|
||||
records = q.order_by(TaxRecord.period.asc(), TaxRecord.id.asc()).all()
|
||||
for t in records:
|
||||
_calc_burden(t)
|
||||
db.commit()
|
||||
|
||||
# 按期间聚合税负率(每种税一个序列)
|
||||
trend_map: dict[str, dict] = {}
|
||||
for t in records:
|
||||
if t.tax_burden_rate is None:
|
||||
continue
|
||||
entry = trend_map.setdefault(t.period, {"period": t.period})
|
||||
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
|
||||
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
|
||||
trend = sorted(trend_map.values(), key=lambda x: x["period"])
|
||||
|
||||
alerts = [t for t in records if t.burden_status == "alert"]
|
||||
return {
|
||||
"trend": trend,
|
||||
"benchmarks": TAX_BENCHMARKS,
|
||||
"records": [_tax_to_dict(t) for t in records],
|
||||
"alerts": [_tax_to_dict(t) for t in alerts],
|
||||
"alert_count": len(alerts),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
def run_tax_check(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""手动触发税负率重算与预警检查"""
|
||||
records = _apply_burden_to_all(db, entity_id or 1)
|
||||
alerts = [t for t in records if t.burden_status == "alert"]
|
||||
return {"message": f"税负检查完成,共{len(records)}条记录,{len(alerts)}条预警", "alert_count": len(alerts)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ② 发票校验 — 录入 + 批量校验 + 异常查询
|
||||
# ============================================================
|
||||
|
||||
def _check_invoice(db: Session, inv: InvoiceCheck):
|
||||
"""发票校验规则:号码格式 / 金额与报销单匹配 / 供应商与合同匹配"""
|
||||
results: list[dict] = []
|
||||
status = "valid"
|
||||
|
||||
# 规则1: 发票号格式(33位数字)
|
||||
no = (inv.invoice_no or "").strip()
|
||||
if len(no) != INVOICE_NO_LEN or not no.isdigit():
|
||||
status = "invalid"
|
||||
results.append({
|
||||
"rule": "发票号格式",
|
||||
"passed": False,
|
||||
"message": f"发票号格式错误:应为{INVOICE_NO_LEN}位纯数字,当前{len(no)}位",
|
||||
})
|
||||
else:
|
||||
results.append({"rule": "发票号格式", "passed": True, "message": "33位数字格式正确"})
|
||||
|
||||
# 规则2: 金额与报销单匹配
|
||||
if inv.reimb_no:
|
||||
reimb = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == inv.reimb_no).first()
|
||||
if not reimb:
|
||||
status = "invalid"
|
||||
results.append({"rule": "报销单匹配", "passed": False, "message": f"报销单{inv.reimb_no}不存在"})
|
||||
elif abs((inv.amount or 0) - (reimb.amount or 0)) > 0.01:
|
||||
status = "invalid"
|
||||
results.append({
|
||||
"rule": "报销单匹配",
|
||||
"passed": False,
|
||||
"message": f"发票金额{inv.amount}元与报销单{inv.reimb_no}金额{reimb.amount}元不符",
|
||||
})
|
||||
else:
|
||||
results.append({"rule": "报销单匹配", "passed": True, "message": f"与报销单{inv.reimb_no}金额一致"})
|
||||
|
||||
# 规则3: 供应商与合同匹配
|
||||
if inv.contract_no:
|
||||
if not (inv.supplier or "").strip():
|
||||
status = "invalid"
|
||||
results.append({"rule": "供应商匹配", "passed": False, "message": f"合同{inv.contract_no}未关联供应商"})
|
||||
else:
|
||||
# 同合同下其他发票的供应商一致性
|
||||
others = (
|
||||
db.query(InvoiceCheck)
|
||||
.filter(
|
||||
InvoiceCheck.contract_no == inv.contract_no,
|
||||
InvoiceCheck.id != inv.id,
|
||||
InvoiceCheck.supplier.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
mismatch = [o.supplier for o in others if o.supplier != inv.supplier]
|
||||
if mismatch:
|
||||
status = "invalid"
|
||||
results.append({
|
||||
"rule": "供应商匹配",
|
||||
"passed": False,
|
||||
"message": f"供应商{inv.supplier}与合同{inv.contract_no}下其他发票供应商{mismatch[0]}不一致",
|
||||
})
|
||||
else:
|
||||
results.append({"rule": "供应商匹配", "passed": True, "message": f"供应商与合同{inv.contract_no}匹配"})
|
||||
|
||||
inv.check_result = results
|
||||
inv.check_status = status
|
||||
inv.check_reason = ";".join(r["message"] for r in results if not r["passed"]) or None
|
||||
inv.checked_at = datetime.now()
|
||||
|
||||
|
||||
@router.get("/invoices")
|
||||
def list_invoices(
|
||||
status: str = Query(None),
|
||||
keyword: str = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""发票列表"""
|
||||
q = db.query(InvoiceCheck)
|
||||
if entity_id:
|
||||
q = q.filter(InvoiceCheck.entity_id == entity_id)
|
||||
if status:
|
||||
q = q.filter(InvoiceCheck.check_status == status)
|
||||
if keyword:
|
||||
kw = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
(InvoiceCheck.invoice_no.like(kw))
|
||||
| (InvoiceCheck.supplier.like(kw))
|
||||
| (InvoiceCheck.reimb_no.like(kw))
|
||||
)
|
||||
invoices = q.order_by(InvoiceCheck.id.desc()).all()
|
||||
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
|
||||
|
||||
|
||||
@router.post("/invoices")
|
||||
def create_invoice(
|
||||
request: Request,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""录入发票(自动执行校验)"""
|
||||
invoice_no = data.get("invoice_no")
|
||||
if not invoice_no:
|
||||
raise HTTPException(400, "缺少必要参数: invoice_no")
|
||||
if data.get("amount") is None:
|
||||
raise HTTPException(400, "缺少必要参数: amount")
|
||||
inv = InvoiceCheck(
|
||||
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
|
||||
invoice_no=str(invoice_no).strip(),
|
||||
amount=data.get("amount"),
|
||||
invoice_type=data.get("invoice_type") or "vat",
|
||||
invoice_date=_parse_date(data.get("invoice_date")),
|
||||
supplier=data.get("supplier"),
|
||||
reimb_no=data.get("reimb_no"),
|
||||
contract_no=data.get("contract_no"),
|
||||
)
|
||||
_check_invoice(db, inv)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
db.refresh(inv)
|
||||
return {"message": "发票已录入并校验", "data": _invoice_to_dict(inv)}
|
||||
|
||||
|
||||
@router.put("/invoices/{invoice_id}")
|
||||
def update_invoice(
|
||||
invoice_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""更新发票(自动重新校验)"""
|
||||
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
|
||||
if not inv:
|
||||
raise HTTPException(404, "发票不存在")
|
||||
if "invoice_no" in data:
|
||||
inv.invoice_no = str(data["invoice_no"]).strip()
|
||||
if "amount" in data:
|
||||
inv.amount = data["amount"]
|
||||
if "invoice_type" in data:
|
||||
inv.invoice_type = data["invoice_type"]
|
||||
if "invoice_date" in data:
|
||||
inv.invoice_date = _parse_date(data.get("invoice_date"))
|
||||
if "supplier" in data:
|
||||
inv.supplier = data.get("supplier")
|
||||
if "reimb_no" in data:
|
||||
inv.reimb_no = data.get("reimb_no")
|
||||
if "contract_no" in data:
|
||||
inv.contract_no = data.get("contract_no")
|
||||
_check_invoice(db, inv)
|
||||
db.commit()
|
||||
db.refresh(inv)
|
||||
return {"message": "发票已更新并重新校验", "data": _invoice_to_dict(inv)}
|
||||
|
||||
|
||||
@router.delete("/invoices/{invoice_id}")
|
||||
def delete_invoice(
|
||||
invoice_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""删除发票"""
|
||||
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
|
||||
if not inv:
|
||||
raise HTTPException(404, "发票不存在")
|
||||
db.delete(inv)
|
||||
db.commit()
|
||||
return {"message": "发票已删除"}
|
||||
|
||||
|
||||
@router.post("/invoices/check")
|
||||
def batch_check_invoices(
|
||||
invoice_id: int = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""批量校验发票(默认校验全部 pending,可指定单张)"""
|
||||
q = db.query(InvoiceCheck)
|
||||
if invoice_id:
|
||||
q = q.filter(InvoiceCheck.id == invoice_id)
|
||||
elif entity_id:
|
||||
q = q.filter(InvoiceCheck.entity_id == entity_id)
|
||||
invoices = q.all()
|
||||
for inv in invoices:
|
||||
_check_invoice(db, inv)
|
||||
db.commit()
|
||||
abnormal = [i for i in invoices if i.check_status in ("invalid", "warning")]
|
||||
return {
|
||||
"message": f"批量校验完成:{len(invoices)}张,异常{len(abnormal)}张",
|
||||
"total": len(invoices),
|
||||
"abnormal_count": len(abnormal),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/invoices/abnormal")
|
||||
def list_abnormal_invoices(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
limit: int = Query(50),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询异常发票(invalid/warning)"""
|
||||
q = db.query(InvoiceCheck).filter(InvoiceCheck.check_status.in_(["invalid", "warning"]))
|
||||
if entity_id:
|
||||
q = q.filter(InvoiceCheck.entity_id == entity_id)
|
||||
invoices = q.order_by(InvoiceCheck.id.desc()).limit(limit).all()
|
||||
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ③ 社保比对 — 缴费记录 CRUD + 比对 + 异常查询
|
||||
# ============================================================
|
||||
|
||||
def _check_ss(db: Session, s: SocialSecurity, all_records: list | None = None):
|
||||
"""社保比对规则:基数与工资匹配 / 单位缴纳比例 / 漏缴(含月份断层检测)"""
|
||||
warnings: list[str] = []
|
||||
alerts: list[str] = []
|
||||
|
||||
# 规则1: 缴费基数与工资匹配(60%~300%区间)
|
||||
if s.salary and s.salary > 0 and s.base_amount and s.base_amount > 0:
|
||||
low, high = s.salary * SS_BASE_LOW, s.salary * SS_BASE_HIGH
|
||||
if s.base_amount < low or s.base_amount > high:
|
||||
warnings.append(
|
||||
f"缴费基数{s.base_amount}元超出工资{s.salary}元的{int(SS_BASE_LOW*100)}%~{int(SS_BASE_HIGH*100)}%区间({low:.0f}~{high:.0f})"
|
||||
)
|
||||
|
||||
# 规则2: 单位缴纳比例(养老16%+医疗8%+失业0.5%≈24.5%)
|
||||
rate = None
|
||||
if s.base_amount and s.base_amount > 0 and s.company_amount is not None:
|
||||
rate = round(s.company_amount / s.base_amount * 100, 2)
|
||||
s.company_rate = rate
|
||||
if rate is not None and abs(rate - SS_COMPANY_RATE) > SS_RATE_TOLERANCE:
|
||||
warnings.append(f"单位缴纳比例{rate}%与标准{SS_COMPANY_RATE}%±{SS_RATE_TOLERANCE}%不符")
|
||||
|
||||
# 规则3: 漏缴检测
|
||||
if not s.base_amount or s.base_amount <= 0 or (not s.company_amount and not s.personal_amount):
|
||||
alerts.append(f"{s.employee}本期缴费基数为0或未缴费(疑似漏缴)")
|
||||
|
||||
# 规则3b: 月份断层检测(同人相邻记录期间间隔>1个月 → 漏缴)
|
||||
if all_records is not None:
|
||||
periods = sorted(
|
||||
r.period for r in all_records
|
||||
if r.employee == s.employee and r.period != s.period
|
||||
)
|
||||
prev = None
|
||||
for p in periods:
|
||||
if prev is not None:
|
||||
try:
|
||||
py, pm = map(int, prev.split("-"))
|
||||
cy, cm = map(int, p.split("-"))
|
||||
gap = (cy - py) * 12 + (cm - pm)
|
||||
if gap > 1:
|
||||
alerts.append(f"{s.employee}在{prev}至{p}之间漏缴{max(0, gap-1)}个月")
|
||||
except Exception:
|
||||
pass
|
||||
prev = p
|
||||
|
||||
if alerts:
|
||||
s.check_status = "alert"
|
||||
elif warnings:
|
||||
s.check_status = "warning"
|
||||
else:
|
||||
s.check_status = "normal"
|
||||
s.warning_msg = ";".join(alerts + warnings) or None
|
||||
|
||||
|
||||
@router.get("/ss")
|
||||
def list_ss_records(
|
||||
period: str = Query(None),
|
||||
employee: str = Query(None),
|
||||
status: str = Query(None),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""社保缴费记录列表"""
|
||||
q = db.query(SocialSecurity)
|
||||
if entity_id:
|
||||
q = q.filter(SocialSecurity.entity_id == entity_id)
|
||||
if period:
|
||||
q = q.filter(SocialSecurity.period == period)
|
||||
if employee:
|
||||
q = q.filter(SocialSecurity.employee.like(f"%{employee}%"))
|
||||
if status:
|
||||
q = q.filter(SocialSecurity.check_status == status)
|
||||
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.asc()).all()
|
||||
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
|
||||
|
||||
|
||||
@router.post("/ss")
|
||||
def create_ss_record(
|
||||
request: Request,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""新增社保缴费记录(自动比对)"""
|
||||
employee = data.get("employee")
|
||||
period = data.get("period")
|
||||
if not employee or not period:
|
||||
raise HTTPException(400, "缺少必要参数: employee, period")
|
||||
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == resolve_entity_for_request(request, data.get("entity_id") or 1)).all()
|
||||
s = SocialSecurity(
|
||||
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
|
||||
employee=employee,
|
||||
period=period,
|
||||
base_amount=data.get("base_amount") or 0,
|
||||
salary=data.get("salary"),
|
||||
company_amount=data.get("company_amount") or 0,
|
||||
personal_amount=data.get("personal_amount") or 0,
|
||||
remark=data.get("remark"),
|
||||
)
|
||||
_check_ss(db, s, all_records)
|
||||
db.add(s)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return {"message": "社保记录已创建", "data": _ss_to_dict(s)}
|
||||
|
||||
|
||||
@router.put("/ss/{ss_id}")
|
||||
def update_ss_record(
|
||||
ss_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""更新社保记录(自动重新比对)"""
|
||||
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
|
||||
if not s:
|
||||
raise HTTPException(404, "社保记录不存在")
|
||||
if "employee" in data:
|
||||
s.employee = data["employee"]
|
||||
if "period" in data:
|
||||
s.period = data["period"]
|
||||
if "base_amount" in data:
|
||||
s.base_amount = data["base_amount"] or 0
|
||||
if "salary" in data:
|
||||
s.salary = data.get("salary")
|
||||
if "company_amount" in data:
|
||||
s.company_amount = data["company_amount"] or 0
|
||||
if "personal_amount" in data:
|
||||
s.personal_amount = data["personal_amount"] or 0
|
||||
if "remark" in data:
|
||||
s.remark = data.get("remark")
|
||||
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == s.entity_id).all()
|
||||
_check_ss(db, s, all_records)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return {"message": "社保记录已更新", "data": _ss_to_dict(s)}
|
||||
|
||||
|
||||
@router.delete("/ss/{ss_id}")
|
||||
def delete_ss_record(
|
||||
ss_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""删除社保记录"""
|
||||
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
|
||||
if not s:
|
||||
raise HTTPException(404, "社保记录不存在")
|
||||
db.delete(s)
|
||||
db.commit()
|
||||
return {"message": "社保记录已删除"}
|
||||
|
||||
|
||||
@router.post("/ss/check")
|
||||
def batch_check_ss(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""批量社保比对"""
|
||||
q = db.query(SocialSecurity)
|
||||
if entity_id:
|
||||
q = q.filter(SocialSecurity.entity_id == entity_id)
|
||||
records = q.all()
|
||||
for s in records:
|
||||
_check_ss(db, s, records)
|
||||
db.commit()
|
||||
abnormal = [s for s in records if s.check_status in ("alert", "warning")]
|
||||
return {
|
||||
"message": f"社保比对完成:{len(records)}条,异常{len(abnormal)}条",
|
||||
"total": len(records),
|
||||
"abnormal_count": len(abnormal),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ss/abnormal")
|
||||
def list_abnormal_ss(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
limit: int = Query(50),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""查询社保异常(alert/warning)"""
|
||||
q = db.query(SocialSecurity).filter(SocialSecurity.check_status.in_(["alert", "warning"]))
|
||||
if entity_id:
|
||||
q = q.filter(SocialSecurity.entity_id == entity_id)
|
||||
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc()).limit(limit).all()
|
||||
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ④ 税务看板聚合
|
||||
# ============================================================
|
||||
|
||||
@router.get("/dashboard")
|
||||
def tax_dashboard(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_auth),
|
||||
):
|
||||
"""税务合规看板聚合:税负趋势+行业对比 / 发票异常 / 社保异常"""
|
||||
eid = entity_id or 1
|
||||
|
||||
# 税负监控
|
||||
records = db.query(TaxRecord).filter(TaxRecord.entity_id == eid).order_by(TaxRecord.period.asc()).all()
|
||||
for t in records:
|
||||
_calc_burden(t)
|
||||
db.commit()
|
||||
trend_map: dict[str, dict] = {}
|
||||
for t in records:
|
||||
if t.tax_burden_rate is None:
|
||||
continue
|
||||
entry = trend_map.setdefault(t.period, {"period": t.period})
|
||||
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
|
||||
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
|
||||
burden_trend = sorted(trend_map.values(), key=lambda x: x["period"])
|
||||
latest_period = max((t.period for t in records), default=None)
|
||||
latest = next((x for x in burden_trend if x["period"] == latest_period), None)
|
||||
tax_alerts = [t for t in records if t.burden_status == "alert"]
|
||||
|
||||
# 发票
|
||||
invoices = db.query(InvoiceCheck).filter(InvoiceCheck.entity_id == eid).all()
|
||||
inv_status_count = {"pending": 0, "valid": 0, "invalid": 0, "warning": 0}
|
||||
for i in invoices:
|
||||
inv_status_count[i.check_status] = inv_status_count.get(i.check_status, 0) + 1
|
||||
inv_abnormal = (
|
||||
db.query(InvoiceCheck)
|
||||
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.check_status.in_(["invalid", "warning"]))
|
||||
.order_by(InvoiceCheck.id.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 社保
|
||||
ss_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
|
||||
ss_status_count = {"normal": 0, "warning": 0, "alert": 0}
|
||||
for s in ss_records:
|
||||
ss_status_count[s.check_status] = ss_status_count.get(s.check_status, 0) + 1
|
||||
ss_abnormal = (
|
||||
db.query(SocialSecurity)
|
||||
.filter(SocialSecurity.entity_id == eid, SocialSecurity.check_status.in_(["alert", "warning"]))
|
||||
.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"entity_id": eid,
|
||||
"burden": {
|
||||
"trend": burden_trend,
|
||||
"latest": latest,
|
||||
"benchmarks": TAX_BENCHMARKS,
|
||||
"alert_count": len(tax_alerts),
|
||||
"alerts": [_tax_to_dict(t) for t in tax_alerts[:10]],
|
||||
},
|
||||
"invoice": {
|
||||
"total": len(invoices),
|
||||
"status_count": inv_status_count,
|
||||
"abnormal": [_invoice_to_dict(i) for i in inv_abnormal],
|
||||
},
|
||||
"ss": {
|
||||
"total": len(ss_records),
|
||||
"status_count": ss_status_count,
|
||||
"abnormal": [_ss_to_dict(s) for s in ss_abnormal],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 演示数据(幂等)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/demo-data")
|
||||
def seed_demo_data(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(require_role("ceo", "finance")),
|
||||
):
|
||||
"""生成税务合规演示数据(幂等:已存在期间+税种则跳过)"""
|
||||
eid = entity_id or 1
|
||||
created = {"tax": 0, "invoice": 0, "ss": 0}
|
||||
|
||||
# 税负记录: 2026-01 ~ 2026-07,其中02月增值税故意偏高触发预警
|
||||
tax_demo = [
|
||||
("2026-01", "vat", 52000, 48800, 1380000, 13),
|
||||
("2026-02", "vat", 69000, 66000, 1460000, 13),
|
||||
("2026-03", "vat", 41000, 39800, 1280000, 13),
|
||||
("2026-04", "vat", 47500, 46200, 1350000, 13),
|
||||
("2026-05", "vat", 53000, 51000, 1420000, 13),
|
||||
("2026-06", "vat", 49800, 48200, 1400000, 13),
|
||||
("2026-01", "income", 36000, 33500, 1380000, 25),
|
||||
("2026-02", "income", 38000, 35500, 1460000, 25),
|
||||
("2026-03", "income", 33000, 31000, 1280000, 25),
|
||||
("2026-04", "income", 35000, 32800, 1350000, 25),
|
||||
("2026-05", "income", 37000, 34800, 1420000, 25),
|
||||
("2026-06", "income", 36500, 34000, 1400000, 25),
|
||||
("2026-01", "surtax", 6240, 5900, 1380000, 12),
|
||||
("2026-02", "surtax", 8280, 7900, 1460000, 12),
|
||||
("2026-03", "surtax", 4920, 4700, 1280000, 12),
|
||||
("2026-04", "surtax", 5700, 5500, 1350000, 12),
|
||||
("2026-05", "surtax", 6360, 6100, 1420000, 12),
|
||||
("2026-06", "surtax", 5976, 5800, 1400000, 12),
|
||||
]
|
||||
for period, ttype, payable, paid, income, rate in tax_demo:
|
||||
exists = (
|
||||
db.query(TaxRecord)
|
||||
.filter(TaxRecord.entity_id == eid, TaxRecord.period == period, TaxRecord.tax_type == ttype)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
t = TaxRecord(entity_id=eid, period=period, tax_type=ttype, tax_payable=payable,
|
||||
tax_paid=paid, tax_rate=rate, income=income)
|
||||
_calc_burden(t)
|
||||
db.add(t)
|
||||
created["tax"] += 1
|
||||
|
||||
# 报销单(供发票"金额与报销单匹配"规则使用)
|
||||
reimb_demo = [
|
||||
{"reimb_no": "BX202606150001", "applicant": "张伟", "department": "采购部", "expense_type": "office",
|
||||
"title": "6月办公用品采购", "amount": 5600, "status": "approved"},
|
||||
{"reimb_no": "BX202606200002", "applicant": "李娜", "department": "市场部", "expense_type": "management",
|
||||
"title": "6月市场信息服务费", "amount": 3200, "status": "approved"},
|
||||
{"reimb_no": "BX202607050003", "applicant": "王强", "department": "供应链部", "expense_type": "office",
|
||||
"title": "7月供应链物流服务", "amount": 14000, "status": "pending"},
|
||||
]
|
||||
for d in reimb_demo:
|
||||
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
|
||||
if exists:
|
||||
continue
|
||||
db.add(ExpenseReimbursement(entity_id=eid, **d))
|
||||
|
||||
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
|
||||
invoice_demo = [
|
||||
{"invoice_no": "9" * 33, "amount": 5600, "invoice_type": "vat", "invoice_date": "2026-06-15",
|
||||
"supplier": "北京云启科技有限公司", "reimb_no": "BX202606150001", "contract_no": "HT-2026-018"},
|
||||
{"invoice_no": "8" * 33, "amount": 3200, "invoice_type": "electronic", "invoice_date": "2026-06-20",
|
||||
"supplier": "上海数联信息服务有限公司", "reimb_no": "BX202606200002", "contract_no": "HT-2026-021"},
|
||||
{"invoice_no": "12345ABC", "amount": 1800, "invoice_type": "vat", "invoice_date": "2026-07-02",
|
||||
"supplier": "广州锐思咨询有限公司", "reimb_no": None, "contract_no": None},
|
||||
{"invoice_no": "7" * 33, "amount": 15000, "invoice_type": "vat", "invoice_date": "2026-07-05",
|
||||
"supplier": "深圳恒达供应链有限公司", "reimb_no": "BX202607050003", "contract_no": "HT-2026-030"},
|
||||
{"invoice_no": "6" * 33, "amount": 9800, "invoice_type": "vat", "invoice_date": "2026-07-08",
|
||||
"supplier": None, "reimb_no": None, "contract_no": "HT-2026-033"},
|
||||
]
|
||||
for d in invoice_demo:
|
||||
exists = (
|
||||
db.query(InvoiceCheck)
|
||||
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.invoice_no == d["invoice_no"])
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
inv_data = dict(d)
|
||||
# 演示日期按既有写入口径解析为 datetime(字符串日期在 SQLite 下无法绑定 DateTime 列;
|
||||
# MySQL 表现不变——同文件 :402 正常写入路径亦经 _parse_date)
|
||||
inv_data["invoice_date"] = _parse_date(inv_data.get("invoice_date"))
|
||||
inv = InvoiceCheck(entity_id=eid, **inv_data)
|
||||
_check_invoice(db, inv)
|
||||
db.add(inv)
|
||||
created["invoice"] += 1
|
||||
|
||||
# 社保: 正常3人×2月 + 基数不符1条 + 漏缴1条
|
||||
ss_demo = [
|
||||
{"employee": "张伟", "period": "2026-06", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
|
||||
{"employee": "张伟", "period": "2026-07", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
|
||||
{"employee": "李娜", "period": "2026-06", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
|
||||
{"employee": "李娜", "period": "2026-07", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
|
||||
{"employee": "王强", "period": "2026-06", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
|
||||
{"employee": "王强", "period": "2026-07", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
|
||||
{"employee": "赵敏", "period": "2026-06", "base_amount": 5000, "salary": 20000, "company_amount": 1225, "personal_amount": 520},
|
||||
{"employee": "孙磊", "period": "2026-05", "base_amount": 0, "salary": 15000, "company_amount": 0, "personal_amount": 0},
|
||||
]
|
||||
for d in ss_demo:
|
||||
exists = (
|
||||
db.query(SocialSecurity)
|
||||
.filter(SocialSecurity.entity_id == eid, SocialSecurity.employee == d["employee"],
|
||||
SocialSecurity.period == d["period"])
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
continue
|
||||
s = SocialSecurity(entity_id=eid, **d)
|
||||
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
|
||||
_check_ss(db, s, all_records)
|
||||
db.add(s)
|
||||
created["ss"] += 1
|
||||
db.commit()
|
||||
return {"message": "演示数据生成完成", "created": created}
|
||||
@@ -0,0 +1,188 @@
|
||||
"""KPI模板库 API — 管理会计OS
|
||||
支持系统预置模板 + 用户自定义模板
|
||||
从模板实例化创建KPI时,复制模板快照到kpi_definitions"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models import KPITemplate, KPIDefinition, OperationLog
|
||||
from app.utils.oplog import log_op, SCOPE_GLOBAL
|
||||
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
|
||||
|
||||
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
|
||||
def template_to_dict(t):
|
||||
return {c.name: getattr(t, c.name) for c in t.__table__.columns}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_templates(
|
||||
dimension: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
is_system: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取模板列表,支持按维度/类别/关键字筛选"""
|
||||
query = db.query(KPITemplate)
|
||||
if dimension:
|
||||
query = query.filter(KPITemplate.dimension == dimension)
|
||||
if category:
|
||||
query = query.filter(KPITemplate.category == category)
|
||||
if keyword:
|
||||
query = query.filter(KPITemplate.kpi_name.contains(keyword))
|
||||
if is_system is not None:
|
||||
query = query.filter(KPITemplate.is_system == is_system)
|
||||
templates = query.order_by(KPITemplate.is_system.desc(), KPITemplate.kpi_code).all()
|
||||
return {"total": len(templates), "data": [template_to_dict(t) for t in templates]}
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
def get_template(template_id: int, db: Session = Depends(get_db)):
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""用户创建自定义模板"""
|
||||
existing = db.query(KPITemplate).filter(KPITemplate.kpi_code == data.get("kpi_code", "")).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"模板编码 {data['kpi_code']} 已存在")
|
||||
t = KPITemplate(
|
||||
kpi_code=data.get("kpi_code"),
|
||||
kpi_name=data.get("kpi_name"),
|
||||
dimension=data.get("dimension"),
|
||||
category=data.get("category"),
|
||||
formula=data.get("formula"),
|
||||
formula_desc=data.get("formula_desc"),
|
||||
unit=data.get("unit", "%"),
|
||||
target_value=data.get("target_value"),
|
||||
description=data.get("description"),
|
||||
is_system=0, # 用户创建的永远不是系统模板
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
# kpi_templates = 系统预置/用户自定义**模板库**(非账套维度,裁定见 docs/entity-dimension-data-dictionary.md)
|
||||
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name},
|
||||
scope=SCOPE_GLOBAL)
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
def update_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""修改自定义模板(系统预置不可修改)"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
if t.is_system:
|
||||
raise HTTPException(403, "系统预置模板不可修改")
|
||||
for k, v in data.items():
|
||||
if hasattr(t, k) and v is not None:
|
||||
setattr(t, k, v)
|
||||
db.commit()
|
||||
return template_to_dict(t)
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
|
||||
"""删除自定义模板(系统预置不可删除)"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
if t.is_system:
|
||||
raise HTTPException(403, "系统预置模板不可删除")
|
||||
db.delete(t)
|
||||
db.commit()
|
||||
return {"message": "模板已删除"}
|
||||
|
||||
|
||||
@router.post("/{template_id}/instantiate")
|
||||
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db),
|
||||
user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
|
||||
"""从模板实例化创建KPI,复制模板快照到kpi_definitions
|
||||
|
||||
账套维度(2026-09-23 账套维度收口批 ①):新 KPI 显式挂**会话账套**,不做静默兜底。
|
||||
历史缺陷:本构造点未传 `entity_id` ⇒ 模型 `default=1` 把任意账套实例化出的 KPI
|
||||
静默挂到酣客(e1),与「建预算未写账套」同源。
|
||||
判重口径:`kpi_code` 唯一性按账套判定(库内 `uk_entity_kpi(UNIQUE entity_id,kpi_code)`);
|
||||
全表判重会让博海无法建与酣客同码的 KPI(反之则放过同账套重复)。
|
||||
"""
|
||||
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(404, "模板不存在")
|
||||
|
||||
kpi_code = data.get("kpi_code", t.kpi_code)
|
||||
kpi_name = data.get("kpi_name", t.kpi_name)
|
||||
|
||||
# 检查编码唯一性(按账套收紧:仅本账套内判重)
|
||||
existing = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.kpi_code == kpi_code,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"KPI编码 {kpi_code} 在本账套已存在,请修改")
|
||||
|
||||
# 数据治理:入库必检(规则1) + 编码规范(规则3)
|
||||
merged = {
|
||||
"kpi_code": kpi_code,
|
||||
"kpi_name": kpi_name,
|
||||
"dimension": data.get("dimension", t.dimension),
|
||||
"category": data.get("category", t.category),
|
||||
"formula": data.get("formula", t.formula),
|
||||
"unit": data.get("unit", t.unit or "%"),
|
||||
"target_value": data.get("target_value", t.target_value),
|
||||
}
|
||||
gov_issues = [i for i in validate_kpi_payload(merged, db=db) if i["rule"] in (1, 3)]
|
||||
if gov_issues:
|
||||
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": kpi_issues_message(gov_issues)})
|
||||
|
||||
kpi = KPIDefinition(
|
||||
entity_id=entity_id, # 账套维度:显式挂会话账套(不得依赖模型 default=1)
|
||||
template_id=t.id,
|
||||
is_system=0, # 从模板实例化的KPI不是系统预置
|
||||
kpi_code=kpi_code,
|
||||
kpi_name=kpi_name,
|
||||
dimension=data.get("dimension", t.dimension),
|
||||
category=data.get("category", t.category),
|
||||
formula=data.get("formula", t.formula),
|
||||
formula_desc=data.get("formula_desc", t.formula_desc),
|
||||
unit=data.get("unit", t.unit or "%"),
|
||||
target_value=data.get("target_value", t.target_value),
|
||||
objective=data.get("objective"),
|
||||
data_source_type=data.get("data_source_type", "manual"),
|
||||
frequency=data.get("frequency", "monthly"),
|
||||
responsible_dept=data.get("responsible_dept"),
|
||||
responsible_user=data.get("responsible_user"),
|
||||
status="active",
|
||||
)
|
||||
db.add(kpi)
|
||||
db.commit()
|
||||
db.refresh(kpi)
|
||||
|
||||
# 更新模板使用计数
|
||||
t.usage_count = (t.usage_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code},
|
||||
entity_id=entity_id)
|
||||
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
|
||||
|
||||
|
||||
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None, scope=None):
|
||||
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
|
||||
return log_op(db, action, target_type=target_type, target_id=target_id,
|
||||
detail=detail, user_id=user_id, entity_id=entity_id, scope=scope, commit=True)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务
|
||||
|
||||
⚠️ 语义收敛(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,任总已批「都批」)
|
||||
--------------------------------------------------------------------------------------
|
||||
`system_configs.current_tenant` 是**全局唯一键**(该表无 entity_id 列,`config_key` unique)
|
||||
⇒ 一次写入全平台生效。本次事故(2026-09-04 前端企业切换器把它写成 entity_id=2)暴露的
|
||||
结构性缺陷正是「全局单值键被当成个人当前视图 / 数据维度」。
|
||||
|
||||
本文件的收敛口径:
|
||||
1. **它不是数据维度**。工作台/看板/报表取数一律用**登录会话的 entity**
|
||||
(`app/deps.py::get_entity_id` ← JWT token 绑定),前端本地视图用
|
||||
`localStorage.cma_entity_id`(登录窗口写入)。禁止任何取数端点读本键。
|
||||
2. 它的用途**只剩**:项目 Bot 分发 A2A 任务时的状态记录(本模块原始设计目的)。
|
||||
3. **仅 admin 会话可读**(GET /current):系统无 `admin` 角色(ROLES: ceo/finance/
|
||||
business/it),故映射为持有 `admin` 动作权限的 `it` + 业务负责人 `ceo`。
|
||||
4. 写(POST /switch)**不额外收紧角色**:项目 Bot 分发任务时需要写该状态,
|
||||
收紧会切断其原始用途。
|
||||
5. 每次写都落 `operation_logs`(action=`switch_tenant_state`)留痕 —— 修复前本端点
|
||||
无审计(ops 止血单假定「走端点即有审计」不成立,已在本单补齐)。
|
||||
6. **审计行可归属(2026-09-22 DAMA 独立回查 §4b 后补)**:`user_id` = 当前会话用户
|
||||
(原写 None ⇒ 事后无法追操作人);`target_type="entity"` + `target_id=<entity_id>`
|
||||
与 `POST /auth/switch-entity`(action=`switch_entity`,`auth.py`)**共用同一目标维度**
|
||||
⇒「按账套查换账套记录」= `WHERE target_type='entity' AND target_id=?`(`operation_logs`
|
||||
无 `entity_id` 列,无需 DDL)。两字面**刻意不合并**:一个改「用户自己的会话账套」、
|
||||
一个改「平台全局状态记录」,合并后审计上不可分辨。口径登记见
|
||||
`docs/audit-and-notification-caliber-20260922.md`。
|
||||
7. **审计缺口的收口(2026-09-23 dama-entity-isolation-sweep-20260923 §二)**:
|
||||
- `detail` 显式给出 `from_entity_id → to_entity_id`(原先只有 `previous` 整包,
|
||||
要人肉解析才知道「从哪来」)+ `source` + `operator`(id/username/role)。
|
||||
- **审计失败不静默**:状态键与审计行走**同一事务**,审计写入异常 → `rollback()`
|
||||
(状态变更一并回滚,绝不留「改了但没审计」的窗口)+ `logger.error(AUDIT_WRITE_FAILED…)`
|
||||
+ HTTP 500。日志前缀 `AUDIT_WRITE_FAILED` 供运维 grep/告警链订阅。
|
||||
- `GET /current` **无写路径**(纯只读,且已限 it/ceo),故不落审计——不给读操作
|
||||
写审计表,避免审计面被读流量稀释。
|
||||
- 历史缺口(2026-09-22 止血操作无审计行)**不补写**:审计不得事后伪造。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth_401, require_role
|
||||
from app.models import SystemConfig, Entity, OperationLog
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.tenant")
|
||||
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
|
||||
# system_config.current_tenant(状态改写面)。收口为 require_auth_401。
|
||||
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"],
|
||||
dependencies=[Depends(require_auth_401)])
|
||||
|
||||
TENANT_KEY = "current_tenant"
|
||||
|
||||
class TenantSwitchRequest(BaseModel):
|
||||
entity_id: int
|
||||
source: str = "cma-system"
|
||||
|
||||
|
||||
def _tenant_name(entity_id: int) -> str:
|
||||
"""entity_id → tenant_id"""
|
||||
return "company_b" if entity_id == 2 else "company_a"
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
def get_current_tenant(
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(require_role("it", "ceo")),
|
||||
):
|
||||
"""查询当前租户状态记录(**仅 admin 会话**:it/ceo)
|
||||
|
||||
2026-09-22 收敛:本键**不是**数据维度——前端/接口取数一律按会话 entity,
|
||||
不得读本键决定「取哪个实体的数据」(本次工作台跨实体污染的直接触发点)。
|
||||
"""
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||
if cfg and cfg.config_value:
|
||||
import json
|
||||
try:
|
||||
return json.loads(cfg.config_value)
|
||||
except:
|
||||
pass
|
||||
return {"tenant_id": "company_a", "entity_id": 1, "name": "陕西酣客文化传媒"}
|
||||
|
||||
|
||||
@router.post("/switch")
|
||||
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
|
||||
user=Depends(require_auth_401)):
|
||||
"""切换当前租户状态记录(项目Bot 分发 A2A 任务用;CMA前端企业切换器已下线)
|
||||
|
||||
2026-09-22:写操作**不动**(保留 Bot 通道可用性),补 `operation_logs` 审计留痕。
|
||||
"""
|
||||
ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
|
||||
if not ent:
|
||||
raise HTTPException(404, "企业不存在")
|
||||
|
||||
import json
|
||||
from_entity_id = None
|
||||
previous = None
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||
if cfg and cfg.config_value:
|
||||
try:
|
||||
previous = json.loads(cfg.config_value)
|
||||
if isinstance(previous, dict):
|
||||
from_entity_id = previous.get("entity_id")
|
||||
except Exception:
|
||||
previous = None
|
||||
|
||||
state = {
|
||||
"tenant_id": _tenant_name(data.entity_id),
|
||||
"entity_id": data.entity_id,
|
||||
"name": ent.name,
|
||||
"short_name": ent.short_name,
|
||||
"source": data.source,
|
||||
"switched_at": __import__("datetime").datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
if cfg:
|
||||
cfg.config_value = json.dumps(state, ensure_ascii=False)
|
||||
else:
|
||||
db.add(SystemConfig(
|
||||
config_key=TENANT_KEY,
|
||||
config_value=json.dumps(state, ensure_ascii=False),
|
||||
description="当前租户状态记录(仅供项目Bot分发A2A任务;非数据维度,2026-09-22 语义收敛)",
|
||||
))
|
||||
|
||||
# 审计留痕(2026-09-22 补齐:本端点原无 operation_logs 记录)
|
||||
# 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人);
|
||||
# target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤
|
||||
# (operation_logs 无 entity_id 列,无须 DDL)。
|
||||
# 2026-09-23 收口:detail 显式 from→to / source / operator;审计失败不静默(见 §7)。
|
||||
audit_detail = {
|
||||
"from_entity_id": from_entity_id,
|
||||
"to_entity_id": data.entity_id,
|
||||
"entity_id": data.entity_id, # 兼容既有消费方/用例(= to)
|
||||
"entity_name": ent.name,
|
||||
"source": data.source,
|
||||
"operator": {
|
||||
"user_id": user.id,
|
||||
"username": getattr(user, "username", None),
|
||||
"name": getattr(user, "name", None),
|
||||
"role": getattr(user, "role", None),
|
||||
},
|
||||
"previous": previous,
|
||||
"note": "current_tenant 状态记录变更(非数据维度)",
|
||||
}
|
||||
try:
|
||||
db.add(OperationLog(
|
||||
user_id=user.id,
|
||||
action="switch_tenant_state",
|
||||
target_type="entity",
|
||||
target_id=data.entity_id,
|
||||
detail=audit_detail,
|
||||
))
|
||||
# 状态键与审计行同一事务提交:审计写不进 ⇒ 状态变更一并回滚
|
||||
# (绝不留「改了但没审计」的窗口;失败必须可见,不得静默)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.error(
|
||||
"AUDIT_WRITE_FAILED tenant/switch from=%s to=%s user_id=%s source=%s "
|
||||
"→ 审计写入失败,状态变更已回滚(无审计不得留变更)",
|
||||
from_entity_id, data.entity_id, getattr(user, "id", None), data.source,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="审计写入失败,状态变更已回滚")
|
||||
|
||||
return {"success": True, "current": state}
|
||||
+129
-58
@@ -1,9 +1,18 @@
|
||||
"""阈值智能推荐 API"""
|
||||
"""阈值智能推荐 API
|
||||
|
||||
2026-09-11 (project-20260911-threshold-fix) 修复:
|
||||
推荐值必须与后端解析器(app.utils.threshold_parser)语法一致 —— 原实现给黄灯/适中型
|
||||
生成区间/文字标签(`52.33~107.67`、`偏离55%`),用户点“自动建议”填入后保存即静默失效。
|
||||
现在统一生成可解析表达式:标量比较(支持负数)/ 闭区间 `A~B` / 多条件 `C1|C2`。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIDefinition, KPIValue, OperationLog
|
||||
from app.utils.threshold_parser import THRESHOLD_HELP, is_valid_threshold
|
||||
from app.utils.kpi_value_scope import actual_only_clause
|
||||
from typing import Any, Optional
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/api/cma/thresholds", tags=["阈值分析"],
|
||||
@@ -18,14 +27,114 @@ KPI_TYPES = {
|
||||
"middle_best": ["TOP5_CUSTOMER_RATIO"],
|
||||
}
|
||||
|
||||
|
||||
def _r(v) -> float:
|
||||
"""保留2位小数(避免浮点噪声写进表达式)"""
|
||||
return round(float(v), 2)
|
||||
|
||||
|
||||
def _fmt(v) -> str:
|
||||
"""数值 → 表达式文本。禁用科学计数法:`:g` 在 |v|>=1e6 时会输出 1.6e+06,
|
||||
解析器不认(会导致推荐值再次静默失效)。"""
|
||||
s = f"{float(v):.2f}".rstrip("0").rstrip(".")
|
||||
return s if s not in ("", "-") else "0"
|
||||
|
||||
|
||||
def build_suggestion(kpi_type: str, target: float, tolerance: Optional[float] = None) -> dict:
|
||||
"""按类型生成可解析的红黄绿阈值建议(仅生成表达式,不含 I/O)。
|
||||
|
||||
口径(label = 该等级的触发条件,判定优先级 red→green→yellow):
|
||||
higher_better:绿 `>=0.8T`、黄 `>=0.5T`(更宽的兜底带)、红 `<0.5T`
|
||||
lower_better :绿 `<=1.2T`、黄 `<=2.0T`、红 `>2.0T`
|
||||
middle_best :绿 `T±tol` 闭区间、黄 `T±2tol` 外环带(green ⊂ yellow)、红 `<T-2tol|>T+2tol`
|
||||
"""
|
||||
if kpi_type == "higher_better":
|
||||
green_min = _r(target * 0.8)
|
||||
yellow_min = _r(target * 0.5)
|
||||
red_max = yellow_min
|
||||
return {
|
||||
"type": "higher_better",
|
||||
"description": "越高越好型",
|
||||
"green": {"min": green_min, "max": None, "label": f">={_fmt(green_min)}"},
|
||||
"yellow": {"min": yellow_min, "max": green_min, "label": f">={_fmt(yellow_min)}"},
|
||||
"red": {"min": None, "max": red_max, "label": f"<{_fmt(red_max)}"},
|
||||
}
|
||||
if kpi_type == "lower_better":
|
||||
green_max = _r(target * 1.2)
|
||||
yellow_max = _r(target * 2.0)
|
||||
red_min = yellow_max
|
||||
return {
|
||||
"type": "lower_better",
|
||||
"description": "越低越好型",
|
||||
"green": {"min": None, "max": green_max, "label": f"<={_fmt(green_max)}"},
|
||||
"yellow": {"min": green_max, "max": yellow_max, "label": f"<={_fmt(yellow_max)}"},
|
||||
"red": {"min": red_min, "max": None, "label": f">{_fmt(red_min)}"},
|
||||
}
|
||||
# middle_best(适中最好型):绿=内区间 T±tol,黄=外环带 T±2tol(green ⊂ yellow),红=环带之外
|
||||
tol = float(tolerance) if tolerance else abs(float(target)) * 0.2
|
||||
if tol <= 0:
|
||||
tol = 1.0
|
||||
a, b = _r(target - tol), _r(target + tol)
|
||||
c, d = _r(target - 2 * tol), _r(target + 2 * tol)
|
||||
return {
|
||||
"type": "middle_best",
|
||||
"description": "适中最好型",
|
||||
"green": {"min": a, "max": b, "label": f"{_fmt(a)}~{_fmt(b)}"},
|
||||
"yellow": {"min": c, "max": d, "label": f"{_fmt(c)}~{_fmt(d)}"},
|
||||
"red": {"min": None, "max": None, "label": f"<{_fmt(c)}|>{_fmt(d)}"},
|
||||
}
|
||||
|
||||
|
||||
def suggest_by_type(kpi):
|
||||
"""无历史数据时按类型推荐(同样输出可解析表达式,含 yellow/red 标签)"""
|
||||
target = kpi.target_value or 100
|
||||
kpi_type = _kpi_type(kpi)
|
||||
suggestion = build_suggestion(kpi_type, target)
|
||||
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
|
||||
"format_hint": THRESHOLD_HELP, "suggestion": suggestion}
|
||||
|
||||
|
||||
def _kpi_type(kpi) -> str:
|
||||
"""KPI → 推荐类型(**保持历史默认语义**)。
|
||||
|
||||
原实现为 if higher_better / elif lower_better / **else → middle_best**,
|
||||
即「不在任何清单里的编码」默认走适中型(如 C_REBATE_RATE 渠补率、
|
||||
BH_SALES_10 销售-任富海,其历史生成的 '52.33~107.67' / '偏离…%' 即出自该分支)。
|
||||
此处默认值必须同为 middle_best,否则默认分支的推荐口径会被改变。
|
||||
"""
|
||||
code = getattr(kpi, "kpi_code", None)
|
||||
if code in KPI_TYPES["higher_better"]:
|
||||
return "higher_better"
|
||||
if code in KPI_TYPES["lower_better"]:
|
||||
return "lower_better"
|
||||
return "middle_best"
|
||||
|
||||
|
||||
def _sanity_check(suggestion: dict) -> dict:
|
||||
"""自检:推荐值必须过解析器(防再次生成不可解析格式),并挂 format 提示给前端。"""
|
||||
bad = []
|
||||
for level in ("green", "yellow", "red"):
|
||||
label = (suggestion.get(level) or {}).get("label")
|
||||
if label and not is_valid_threshold(label):
|
||||
bad.append({"level": level, "label": label})
|
||||
suggestion["parseable"] = not bad
|
||||
if bad:
|
||||
suggestion["unparseable"] = bad
|
||||
return suggestion
|
||||
|
||||
|
||||
@router.get("/suggest/{kpi_id}")
|
||||
def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)):
|
||||
"""根据历史数据自动推荐阈值"""
|
||||
"""根据历史数据自动推荐阈值(输出格式保证被 threshold_parser 解析生效)"""
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
|
||||
if not 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:
|
||||
# 无历史数据,按行业标准推荐
|
||||
@@ -36,62 +145,24 @@ def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)):
|
||||
if len(nums) < 2:
|
||||
return suggest_by_type(kpi)
|
||||
|
||||
avg = sum(nums) / len(nums)
|
||||
# 注: SQLAlchemy 模型属性未做 Mapped 注解, Pyright 会推断为 Column;
|
||||
# 这里统一标注 Any 抑制静态噪音(运行期均为 float)。
|
||||
avg: Any = sum(nums) / len(nums)
|
||||
# 计算标准差
|
||||
variance = sum((x - avg) ** 2 for x in nums) / len(nums)
|
||||
std = variance ** 0.5
|
||||
variance: Any = sum((x - avg) ** 2 for x in nums) / len(nums)
|
||||
std: Any = variance ** 0.5
|
||||
|
||||
target = kpi.target_value or avg
|
||||
|
||||
# 根据KPI类型生成推荐区间
|
||||
if kpi.kpi_code in KPI_TYPES["higher_better"]:
|
||||
green_min = round(target * 0.8, 2)
|
||||
yellow_min = round(target * 0.5, 2)
|
||||
red_max = round(target * 0.5, 2)
|
||||
suggestion = {
|
||||
"type": "higher_better",
|
||||
"description": "越高越好型",
|
||||
"green": {"min": green_min, "max": None, "label": f">={green_min}"},
|
||||
"yellow": {"min": yellow_min, "max": green_min, "label": f"{yellow_min}~{green_min}"},
|
||||
"red": {"min": None, "max": red_max, "label": f"<{red_max}"},
|
||||
"current_avg": round(avg, 2),
|
||||
"target": target,
|
||||
}
|
||||
elif kpi.kpi_code in KPI_TYPES["lower_better"]:
|
||||
green_max = round(target * 1.2, 2)
|
||||
yellow_max = round(target * 2.0, 2)
|
||||
suggestion = {
|
||||
"type": "lower_better",
|
||||
"description": "越低越好型",
|
||||
"green": {"min": None, "max": green_max, "label": f"<={green_max}"},
|
||||
"yellow": {"min": green_max, "max": yellow_max, "label": f"{green_max}~{yellow_max}"},
|
||||
"red": {"min": yellow_max, "max": None, "label": f">{yellow_max}"},
|
||||
"current_avg": round(avg, 2),
|
||||
"target": target,
|
||||
}
|
||||
else:
|
||||
tolerance = max(std * 1.5, target * 0.2)
|
||||
suggestion = {
|
||||
"type": "middle_best",
|
||||
"description": "适中最好型",
|
||||
"green": {"min": round(target - tolerance, 2), "max": round(target + tolerance, 2), "label": f"{round(target-tolerance,2)}~{round(target+tolerance,2)}"},
|
||||
"yellow": {"min": round(target - tolerance*2, 2), "max": round(target + tolerance*2, 2), "label": f"偏离{(tolerance*2):.0f}%"},
|
||||
"red": {"min": None, "max": round(target - tolerance*2, 2), "label": f"偏离>{tolerance*2:.0f}%"},
|
||||
"current_avg": round(avg, 2),
|
||||
"target": target,
|
||||
}
|
||||
|
||||
return {"kpi_id": kpi_id, "kpi_name": kpi.kpi_name, "suggestion": suggestion}
|
||||
target: Any = kpi.target_value or avg
|
||||
kpi_type = _kpi_type(kpi)
|
||||
|
||||
def suggest_by_type(kpi):
|
||||
"""无历史数据时按类型推荐"""
|
||||
target = kpi.target_value or 100
|
||||
if kpi.kpi_code in KPI_TYPES["higher_better"]:
|
||||
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
|
||||
"suggestion": {"type": "higher_better", "green": {"min": round(target*0.8,2)}, "yellow": {"min": round(target*0.5,2)}, "red": {"max": round(target*0.5,2)}}}
|
||||
elif kpi.kpi_code in KPI_TYPES["lower_better"]:
|
||||
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
|
||||
"suggestion": {"type": "lower_better", "green": {"max": round(target*1.2,2)}, "yellow": {"max": round(target*2,2)}, "red": {"min": round(target*2,2)}}}
|
||||
if kpi_type == "middle_best":
|
||||
tolerance: Any = max(std * 1.5, target * 0.2)
|
||||
suggestion = build_suggestion(kpi_type, target, tolerance)
|
||||
else:
|
||||
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
|
||||
"suggestion": {"type": "middle_best", "green": {"min": round(target*0.8,2), "max": round(target*1.2,2)}}}
|
||||
suggestion = build_suggestion(kpi_type, target)
|
||||
suggestion = _sanity_check(suggestion)
|
||||
suggestion["current_avg"] = round(avg, 2)
|
||||
suggestion["target"] = target
|
||||
|
||||
return {"kpi_id": kpi_id, "kpi_name": kpi.kpi_name,
|
||||
"format_hint": THRESHOLD_HELP, "suggestion": suggestion}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
"""实际值自动归集 API — 管理会计OS (P1-④ 2026-08-28)
|
||||
|
||||
取数映射管理(kpi_value_sources) + 手动触发采集 + 采集日志 + 覆盖率统计
|
||||
采集器本体: scripts/kpi_value_collector.py(系统 crontab 每日 06:30)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import KPIValueSource, KPIValueCollectLog, KPIDefinition, KPIValue
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["实际值归集"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
# ── 取数映射 CRUD ──────────────────────────────
|
||||
|
||||
@router.get("/value-sources")
|
||||
def list_value_sources(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""取数映射列表(按 entity_id 隔离)"""
|
||||
query = db.query(KPIValueSource).filter(KPIValueSource.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(KPIValueSource.kpi_id == kpi_id)
|
||||
if status:
|
||||
query = query.filter(KPIValueSource.status == status)
|
||||
rows = query.order_by(KPIValueSource.id.desc()).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"entity_id": r.entity_id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"source_table": r.source_table,
|
||||
"source_field": r.source_field,
|
||||
"aggregate": r.aggregate,
|
||||
"filter_rule": r.filter_rule,
|
||||
"period_field": r.period_field,
|
||||
"unit_conversion": r.unit_conversion,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.post("/value-sources")
|
||||
def create_value_source(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建取数映射"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
source_table = data.get("source_table")
|
||||
source_field = data.get("source_field")
|
||||
if not kpi_id or not source_table or not source_field:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, source_table, source_field")
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
existing = db.query(KPIValueSource).filter(
|
||||
KPIValueSource.entity_id == entity_id,
|
||||
KPIValueSource.kpi_id == kpi_id,
|
||||
KPIValueSource.source_table == source_table,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(400, f"该KPI({kpi_id})已存在 {source_table} 取数映射")
|
||||
|
||||
row = KPIValueSource(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
source_table=source_table,
|
||||
source_field=source_field,
|
||||
aggregate=data.get("aggregate", "sum"),
|
||||
filter_rule=data.get("filter_rule"),
|
||||
period_field=data.get("period_field", "period"),
|
||||
unit_conversion=data.get("unit_conversion", 1),
|
||||
status=data.get("status", "active"),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "取数映射已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/value-sources/{source_id}")
|
||||
def update_value_source(
|
||||
source_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新取数映射"""
|
||||
row = db.query(KPIValueSource).filter(
|
||||
KPIValueSource.id == source_id,
|
||||
KPIValueSource.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "映射不存在")
|
||||
for field in ("source_table", "source_field", "aggregate", "filter_rule",
|
||||
"period_field", "unit_conversion", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "映射已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/value-sources/{source_id}")
|
||||
def delete_value_source(
|
||||
source_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除取数映射"""
|
||||
row = db.query(KPIValueSource).filter(
|
||||
KPIValueSource.id == source_id,
|
||||
KPIValueSource.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "映射不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "映射已删除"}
|
||||
|
||||
|
||||
@router.post("/value-sources/test")
|
||||
def test_value_source(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""试跑单条映射返回预览值(不写库)"""
|
||||
from scripts.kpi_value_collector import collect_for_mapping
|
||||
|
||||
mapping = KPIValueSource(
|
||||
entity_id=entity_id,
|
||||
kpi_id=data.get("kpi_id"),
|
||||
source_table=data.get("source_table"),
|
||||
source_field=data.get("source_field"),
|
||||
aggregate=data.get("aggregate", "sum"),
|
||||
filter_rule=data.get("filter_rule"),
|
||||
period_field=data.get("period_field", "period"),
|
||||
unit_conversion=data.get("unit_conversion", 1),
|
||||
status="active",
|
||||
)
|
||||
period = data.get("period") or _default_period()
|
||||
try:
|
||||
value, message = collect_for_mapping(db, mapping, period, write_kpi=False)
|
||||
return {"success": True, "period": period, "value": value, "message": message}
|
||||
except Exception as e:
|
||||
return {"success": False, "period": period, "value": None, "message": str(e)}
|
||||
|
||||
|
||||
# ── 采集器触发 ──────────────────────────────
|
||||
|
||||
@router.post("/value-collect/run")
|
||||
def run_value_collect(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""手动触发采集器(可选 period 参数,默认当月)"""
|
||||
from scripts.kpi_value_collector import run_collector
|
||||
|
||||
period = data.get("period") or _default_period()
|
||||
kpi_id = data.get("kpi_id") # 可选: 只采集单个KPI
|
||||
result = run_collector(db, entity_id=entity_id, period=period, kpi_id=kpi_id)
|
||||
result["period"] = period
|
||||
return result
|
||||
|
||||
|
||||
# ── 采集日志 ──────────────────────────────
|
||||
|
||||
@router.get("/value-collect/logs")
|
||||
def list_collect_logs(
|
||||
status: Optional[str] = Query(None),
|
||||
period: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""采集日志(status过滤)"""
|
||||
query = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.entity_id == entity_id)
|
||||
if status:
|
||||
query = query.filter(KPIValueCollectLog.status == status)
|
||||
if period:
|
||||
query = query.filter(KPIValueCollectLog.period == period)
|
||||
rows = query.order_by(KPIValueCollectLog.collected_at.desc()).limit(limit).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"period": r.period,
|
||||
"source_table": r.source_table,
|
||||
"collected_value": r.collected_value,
|
||||
"status": r.status,
|
||||
"message": r.message,
|
||||
"collected_at": r.collected_at.isoformat() if r.collected_at else None,
|
||||
})
|
||||
return {"data": result, "total": len(result)}
|
||||
|
||||
|
||||
# ── 覆盖率统计 ──────────────────────────────
|
||||
|
||||
@router.get("/value-sources/coverage")
|
||||
def value_source_coverage(
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""覆盖率统计:已配映射KPI数 / 总活跃KPI数 / 未配置清单"""
|
||||
total_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
).count()
|
||||
|
||||
mapped_rows = db.query(KPIValueSource).filter(
|
||||
KPIValueSource.entity_id == entity_id,
|
||||
KPIValueSource.status == "active",
|
||||
).all()
|
||||
mapped_kpi_ids = {r.kpi_id for r in mapped_rows}
|
||||
|
||||
all_kpis = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
).all()
|
||||
unmapped = [{
|
||||
"kpi_id": k.id,
|
||||
"kpi_code": k.kpi_code,
|
||||
"kpi_name": k.kpi_name,
|
||||
} for k in all_kpis if k.id not in mapped_kpi_ids]
|
||||
|
||||
coverage = round(len(mapped_kpi_ids) / total_kpis * 100, 1) if total_kpis else 0
|
||||
return {
|
||||
"mapped_count": len(mapped_kpi_ids),
|
||||
"total_kpis": total_kpis,
|
||||
"coverage_pct": coverage,
|
||||
"unmapped_count": len(unmapped),
|
||||
"unmapped": unmapped,
|
||||
}
|
||||
|
||||
|
||||
def _default_period() -> str:
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%Y-%m")
|
||||
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
自动验证引擎 API — 管理会计OS
|
||||
POST /api/cma/verify/{plan_id} 手动验证行动计划执行结果
|
||||
|
||||
数据流:
|
||||
ActionPlan.auto_verify_rule (JSON) → 条件判断 → passed/failed
|
||||
→ KPI值回填 (kpi_current_before/after + KPIValue source_type=verify)
|
||||
→ 验证通过 → 所属OKR progress +15%
|
||||
→ 通知任总 (send_wecom_message)
|
||||
|
||||
⚠️ 双 verify 入口关系(2026-08-30 评审收敛,暂不重构):
|
||||
- 本文件: /api/cma/verify/{plan_id} — 完整链路(回填KPIValue + OKR联动 + 企微通知)
|
||||
- bot_bridge_v2.py: /api/cma/bot-bridge/verify/{action_plan_id} — 轻量版(仅记 verify_log,
|
||||
不回填KPIValue、不联动OKR、不通知),供财务Bot/研学Bot桥接通道调用
|
||||
- 两者行为不一致,勿混用:Bot 通道走 bot_bridge_v2,业务侧手动/自动重验走本文件。
|
||||
|
||||
规则格式(新):
|
||||
{
|
||||
"kpi_code": "C_REBATE_RATE",
|
||||
"condition": "LESS_THAN", # LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL
|
||||
"target_value": 80,
|
||||
"baseline_value": 86.4,
|
||||
"verify_after_days": 7,
|
||||
"retry_max": 3,
|
||||
"escalate_to": "任富海",
|
||||
"notify": true
|
||||
}
|
||||
|
||||
兼容格式(旧):
|
||||
{"condition": "value < 75", "description": "..."} → 走 bot_bridge_v2._evaluate_condition
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ActionPlan, KPIDefinition, KPIValue, Objective
|
||||
from app.utils.notifier import send_wecom_message
|
||||
from app.auth_middleware import require_role
|
||||
|
||||
logger = logging.getLogger("cma.verify")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/verify",
|
||||
tags=["自动验证"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
CONDITION_FUNCS = {
|
||||
"LESS_THAN": lambda actual, target: actual is not None and actual < target,
|
||||
"GREATER_THAN": lambda actual, target: actual is not None and actual > target,
|
||||
"WITHIN_RANGE": lambda actual, target: target is not None and len(target) == 2 and float(target[0]) <= actual <= float(target[1]),
|
||||
"NOT_NULL": lambda actual, target: actual is not None,
|
||||
}
|
||||
|
||||
|
||||
def _parse_rule(rule) -> dict:
|
||||
"""解析auto_verify_rule(兼容JSON字符串)"""
|
||||
if rule is None:
|
||||
return {}
|
||||
if isinstance(rule, str):
|
||||
try:
|
||||
return json.loads(rule)
|
||||
except Exception:
|
||||
return {"condition": rule}
|
||||
return rule
|
||||
|
||||
|
||||
def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool:
|
||||
"""
|
||||
按规则判断actual是否达标
|
||||
- 新格式: condition为枚举 LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL
|
||||
- 旧格式: condition为表达式字符串(如 "value < 75")→ bot_bridge_v2引擎
|
||||
"""
|
||||
condition = (rule or {}).get("condition", "")
|
||||
if not condition:
|
||||
return actual is not None
|
||||
|
||||
if condition in CONDITION_FUNCS:
|
||||
target = rule.get("target_value")
|
||||
return CONDITION_FUNCS[condition](actual, target)
|
||||
|
||||
# 旧格式表达式(bot-bridge兼容)
|
||||
try:
|
||||
from app.api.bot_bridge_v2 import _evaluate_condition
|
||||
kd = kpi_data or {"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")}
|
||||
return _evaluate_condition(condition, kd)
|
||||
except Exception as e:
|
||||
logger.warning(f"旧格式条件评估失败({condition}): {e}")
|
||||
return False
|
||||
|
||||
|
||||
def update_okr_progress(db: Session, plan: ActionPlan, already_verified: bool = False, force_recalc: bool = False) -> dict:
|
||||
"""验证通过 → 所属OKR progress +15%(每通过1个KR)
|
||||
|
||||
缺陷1修复(2026-08-30):幂等防重复累加
|
||||
- already_verified=True(调用前 plan 已 verify_status=='passed' 且 verified_at 非空)
|
||||
→ 跳过累加,返回当前值(保持原值)
|
||||
- force_recalc=True 时强制重新累加(业务确需重验场景由调用方显式开启;默认 False)
|
||||
"""
|
||||
if not plan.objective_id:
|
||||
return {"updated": False, "reason": "no_objective"}
|
||||
obj = db.query(Objective).filter(Objective.id == plan.objective_id).first()
|
||||
if not obj:
|
||||
return {"updated": False, "reason": "objective_not_found"}
|
||||
before = obj.progress or 0
|
||||
if already_verified and not force_recalc:
|
||||
return {"updated": False, "reason": "already_verified", "objective_id": obj.id, "before": before, "after": before}
|
||||
obj.progress = min(100, before + 15)
|
||||
db.flush()
|
||||
return {"updated": True, "objective_id": obj.id, "before": before, "after": obj.progress}
|
||||
|
||||
|
||||
def build_auto_verify_rule(kpi, baseline_value=None, verify_after_days: int = 7) -> dict:
|
||||
"""根据KPI阈值自动生成验证规则(验收#1: 创建ActionPlan自动带auto_verify_rule)
|
||||
|
||||
从KPI的绿灯阈值(green)推导达标方向:
|
||||
- 绿灯 '<=X' → 目标是把值压到 X 以下 → LESS_THAN X
|
||||
- 绿灯 '>=X' → 目标是把值抬到 X 以上 → GREATER_THAN X
|
||||
- 有绿色区间 'X~Y' → WITHIN_RANGE
|
||||
- 无阈值 → NOT_NULL
|
||||
"""
|
||||
expr = (kpi.threshold_green or "").strip()
|
||||
condition = "NOT_NULL"
|
||||
target = None
|
||||
import re
|
||||
m = re.match(r"^(<=|>=|<|>|=)\s*([\d.]+)$", expr)
|
||||
if m:
|
||||
op, val = m.group(1), float(m.group(2))
|
||||
if op in ("<", "<="):
|
||||
condition = "LESS_THAN"
|
||||
elif op in (">", ">="):
|
||||
condition = "GREATER_THAN"
|
||||
else:
|
||||
condition = "NOT_NULL"
|
||||
target = val
|
||||
elif "~" in expr:
|
||||
parts = expr.split("~")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
condition = "WITHIN_RANGE"
|
||||
target = [float(parts[0]), float(parts[1])]
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# 无阈值表达式 → 用KPI目标值推方向
|
||||
condition = "NOT_NULL"
|
||||
target = None
|
||||
|
||||
return {
|
||||
"kpi_code": kpi.kpi_code,
|
||||
"condition": condition,
|
||||
"target_value": target,
|
||||
"baseline_value": float(baseline_value) if baseline_value is not None else None,
|
||||
"verify_after_days": verify_after_days,
|
||||
"retry_max": 3,
|
||||
"escalate_to": "任富海",
|
||||
"notify": True,
|
||||
}
|
||||
|
||||
|
||||
def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source: str = "verify"):
|
||||
"""回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录
|
||||
|
||||
缺陷2修复(2026-08-30)多租户隔离:
|
||||
- KPIValue 创建时设置 entity_id(从 plan 关联 KPI 定义取,即 plan.kpi_id → kpi_definitions.entity_id)
|
||||
- kpi_code 查询 KPIDefinition 时带 entity_id 过滤(防跨租户误匹配 kpi_code)
|
||||
"""
|
||||
kpi = None
|
||||
kpi_code = rule.get("kpi_code") if rule else None
|
||||
# 确定 plan 所属 entity_id(从 plan.kpi_id → KPIDefinition.entity_id 向上取)
|
||||
entity_id = None
|
||||
if plan.kpi_id:
|
||||
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||
if pkpi:
|
||||
entity_id = pkpi.entity_id
|
||||
# 优先按验证规则指定的KPI编码查询(带 entity_id 过滤);无规则时才回退到plan.kpi_id
|
||||
if kpi_code:
|
||||
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
|
||||
if entity_id is not None:
|
||||
q = q.filter(KPIDefinition.entity_id == entity_id)
|
||||
kpi = q.first()
|
||||
elif plan.kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||
|
||||
if actual is None:
|
||||
return None
|
||||
|
||||
plan.kpi_current_before = plan.kpi_current_before if plan.kpi_current_before is not None else rule.get("baseline_value")
|
||||
plan.kpi_current_after = actual
|
||||
|
||||
if kpi:
|
||||
new_val = KPIValue(
|
||||
kpi_id=kpi.id,
|
||||
entity_id=kpi.entity_id, # 缺陷2修复:多租户回填 entity_id
|
||||
period=datetime.now().strftime("%Y-%m"),
|
||||
actual_value=actual,
|
||||
source_type="verify",
|
||||
source_batch=f"verify-plan-{plan.id}",
|
||||
data_status="verified",
|
||||
calculated_at=datetime.now(),
|
||||
remark=f"行动计划#{plan.id}验证回填",
|
||||
)
|
||||
db.add(new_val)
|
||||
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code}
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{plan_id}")
|
||||
def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db)):
|
||||
"""验证行动计划执行结果(手动验证 / Bot回填)
|
||||
|
||||
请求体:
|
||||
{
|
||||
"actual_value": 78.5, # 可选,缺省时取KPI最新值
|
||||
"source": "财务Bot分析", # 来源
|
||||
"note": "渠补谈判后...", # 备注/验证结果详情
|
||||
"passed": true # 可选,无规则时手动指定
|
||||
}
|
||||
"""
|
||||
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(404, "行动计划不存在")
|
||||
|
||||
rule = _parse_rule(plan.auto_verify_rule)
|
||||
actual = payload.get("actual_value")
|
||||
note = payload.get("note", "")
|
||||
source = payload.get("source", "手动验证")
|
||||
|
||||
# 1. 无规则 → 按手动指定 passed 标记
|
||||
if not rule or not rule.get("condition"):
|
||||
passed = payload.get("passed", True)
|
||||
plan.verify_status = "passed" if passed else "failed"
|
||||
plan.verify_result = "pass" if passed else "fail"
|
||||
plan.verify_log = (plan.verify_log or []) + [{
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"passed": passed,
|
||||
"note": note,
|
||||
"source": source,
|
||||
}]
|
||||
plan.verified_at = datetime.now()
|
||||
db.commit()
|
||||
return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed}
|
||||
|
||||
# 2. 缺省actual → 取KPI最新值(缺陷3修复:按 period <= 当前月过滤,跨月验证不取历史期间;支持调用方显式传 period 覆盖,默认当前月)
|
||||
if actual is None:
|
||||
kpi = None
|
||||
kpi_code = rule.get("kpi_code")
|
||||
if plan.kpi_id:
|
||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||
elif kpi_code:
|
||||
# 缺陷2修复:kpi_code 查询带 entity_id 过滤(防跨租户误匹配)
|
||||
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
|
||||
if plan.kpi_id:
|
||||
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
|
||||
if pkpi and pkpi.entity_id is not None:
|
||||
q = q.filter(KPIDefinition.entity_id == pkpi.entity_id)
|
||||
kpi = q.first()
|
||||
if kpi:
|
||||
period_limit = payload.get("period") or datetime.now().strftime("%Y-%m")
|
||||
latest = db.query(KPIValue).filter(
|
||||
KPIValue.kpi_id == kpi.id,
|
||||
KPIValue.actual_value.isnot(None),
|
||||
KPIValue.period <= period_limit, # 缺陷3修复:只取当前月及之前的期间
|
||||
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
|
||||
if latest:
|
||||
actual = latest.actual_value
|
||||
|
||||
# 3. 执行条件判断
|
||||
passed = evaluate_rule(rule, actual, kpi_data={"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")})
|
||||
|
||||
# 4. 回写KPI当前值
|
||||
backfill = backfill_kpi_value(db, plan, actual, rule, source=source)
|
||||
|
||||
# 缺陷1修复:调用前先记录 plan 是否已处于"验证通过"状态(防止重复累加 OKR progress)
|
||||
already_verified = bool(plan.verify_status == "passed" and plan.verified_at is not None)
|
||||
force_recalc = bool(payload.get("force_recalc", False))
|
||||
|
||||
# 5. 更新状态
|
||||
plan.verify_status = "passed" if passed else "failed"
|
||||
plan.verify_result = "pass" if passed else "fail"
|
||||
plan.verify_log = (plan.verify_log or []) + [{
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"condition": rule.get("condition"),
|
||||
"actual_value": actual,
|
||||
"target_value": rule.get("target_value"),
|
||||
"passed": passed,
|
||||
"source": source,
|
||||
"note": note,
|
||||
}]
|
||||
if passed:
|
||||
plan.status = "completed" # 缺陷4修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
|
||||
plan.progress = 100
|
||||
plan.verified_at = datetime.now()
|
||||
|
||||
# 6. OKR进度联动(验证通过 → +15%;缺陷1修复:已通过过的 plan 不再重复累加,force_recalc 可强制重算)
|
||||
okr_update = None
|
||||
if passed:
|
||||
okr_update = update_okr_progress(db, plan, already_verified=already_verified, force_recalc=force_recalc)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 7. 通知任总
|
||||
if rule.get("notify", True):
|
||||
delta = ""
|
||||
if plan.kpi_current_before is not None and actual is not None:
|
||||
try:
|
||||
d = float(actual) - float(plan.kpi_current_before)
|
||||
delta = f"({d:+.1f})"
|
||||
except (TypeError, ValueError):
|
||||
delta = ""
|
||||
title = f"✅ 行动计划#{plan.id}验证通过" if passed else f"❌ 行动计划#{plan.id}验证失败"
|
||||
content = (
|
||||
f"{plan.title}\n"
|
||||
f"KPI: {rule.get('kpi_code', '')} {plan.kpi_current_before}→{actual} {delta}\n"
|
||||
f"规则: {rule.get('condition')} {rule.get('target_value')}\n"
|
||||
f"来源: {source}\n"
|
||||
f"{note}"
|
||||
)
|
||||
try:
|
||||
send_wecom_message(content=content, title=title, alert_level="green" if passed else "red")
|
||||
except Exception as e:
|
||||
logger.warning(f"验证通知发送失败: {e}")
|
||||
|
||||
return {
|
||||
"plan_id": plan_id,
|
||||
"verify_status": plan.verify_status,
|
||||
"kpi_current_before": plan.kpi_current_before,
|
||||
"kpi_current_after": plan.kpi_current_after,
|
||||
"improvement": f"{float(actual) - float(plan.kpi_current_before):+.1f}" if plan.kpi_current_before is not None and actual is not None else None,
|
||||
"passed": passed,
|
||||
"okr_progress": okr_update,
|
||||
"kpi_backfill": backfill,
|
||||
"message": (
|
||||
f"验证通过:{plan.title}" if passed
|
||||
else f"验证失败:{plan.title}(条件 {rule.get('condition')} {rule.get('target_value')} 未达成,当前值 {actual})"
|
||||
),
|
||||
}
|
||||
+197
-47
@@ -1,7 +1,23 @@
|
||||
"""战略地图版本管理 API"""
|
||||
"""战略地图版本管理 API(2026-09-08 完整实现)
|
||||
|
||||
功能闭环(任总报告\"版本连接点击打不开\"→ 方案B):
|
||||
- GET /{map_id}/versions 版本列表(含维度/目标/KPI/连线摘要 + is_current)
|
||||
- GET /{map_id}/versions/{vid} 单版本详情(dimensions/canvas_data 全量,供\"查看快照画布\")
|
||||
- POST /{map_id}/versions/snapshot 手动创建快照(保留,历史兼容)
|
||||
- POST /{map_id}/versions/{vid}/rollback 回滚到指定版本 —— 回滚前自动备份当前内容为新版本快照,防覆盖丢数据
|
||||
- PUT /{map_id}/versions/{vid} 更新版本备注 comment
|
||||
|
||||
安全:
|
||||
- 全部端点按 map_id + entity_id 隔离(地图归属 404;版本行再校验 entity_id,防跨账套回滚)
|
||||
- 回滚 = 先备份后覆盖;备份版本号由当前地图 version 派生(vX.Y → vX.(Y+1)),撞号自动 +1
|
||||
- 不改 _auto_snapshot / 发布逻辑(maps.py 自动快照保持原样)
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import StrategicMap, StrategicMapVersion
|
||||
|
||||
@@ -10,45 +26,142 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"],
|
||||
)
|
||||
|
||||
|
||||
def _get_map(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 _get_version(db: Session, map_id: int, vid: int, entity_id: int) -> StrategicMapVersion:
|
||||
v = db.query(StrategicMapVersion).filter(
|
||||
StrategicMapVersion.id == vid,
|
||||
StrategicMapVersion.map_id == map_id,
|
||||
StrategicMapVersion.entity_id == entity_id,
|
||||
).first()
|
||||
if not v:
|
||||
raise HTTPException(404, "版本不存在")
|
||||
return v
|
||||
|
||||
|
||||
def _parse_json(raw, default):
|
||||
if raw is None:
|
||||
return default
|
||||
if isinstance(raw, (dict, list)):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _next_minor_version(s: str) -> str:
|
||||
"""把 'v1.0' → 'v1.1';解析失败返回 None"""
|
||||
m = re.search(r"v(\d+)\.(\d+)", s or "")
|
||||
if m:
|
||||
return f"v{m.group(1)}.{int(m.group(2)) + 1}"
|
||||
return None
|
||||
|
||||
|
||||
def _summary_dict(v: StrategicMapVersion, m: StrategicMap = None) -> dict:
|
||||
dims = _parse_json(v.dimensions, [])
|
||||
canvas = _parse_json(v.canvas_data, {"connections": []})
|
||||
codes = set()
|
||||
objective_count = 0
|
||||
for dim in dims:
|
||||
for obj in dim.get("objectives", []):
|
||||
objective_count += 1
|
||||
for c in (obj.get("kpis") or []):
|
||||
if c:
|
||||
codes.add(str(c))
|
||||
for kr in (obj.get("krs") or []):
|
||||
if isinstance(kr, dict) and kr.get("kpi_code"):
|
||||
codes.add(str(kr["kpi_code"]))
|
||||
conns = canvas.get("connections", []) if isinstance(canvas, dict) else []
|
||||
return {
|
||||
"id": v.id,
|
||||
"map_id": v.map_id,
|
||||
"entity_id": v.entity_id,
|
||||
"version": v.version,
|
||||
"comment": v.comment,
|
||||
"created_by": v.created_by,
|
||||
"created_at": v.created_at.isoformat() if v.created_at else None,
|
||||
"dimension_count": len(dims),
|
||||
"objective_count": objective_count,
|
||||
"kpi_count": len(codes),
|
||||
"connection_count": len(conns),
|
||||
"is_current": bool(m and m.version == v.version),
|
||||
}
|
||||
|
||||
|
||||
def _full_dict(v: StrategicMapVersion, m: StrategicMap = None) -> dict:
|
||||
d = {c.name: getattr(v, c.name) for c in v.__table__.columns}
|
||||
if d.get("created_at") is not None:
|
||||
d["created_at"] = d["created_at"].isoformat()
|
||||
d.update(_summary_dict(v, m))
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/{map_id}/versions")
|
||||
def list_versions(map_id: int, db: Session = Depends(get_db)):
|
||||
"""查看版本历史"""
|
||||
def list_versions(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""查看版本历史(账套隔离;地图归属校验 404)"""
|
||||
m = _get_map(db, map_id, entity_id)
|
||||
versions = db.query(StrategicMapVersion).filter(
|
||||
StrategicMapVersion.map_id == map_id,
|
||||
StrategicMapVersion.entity_id == entity_id,
|
||||
).order_by(StrategicMapVersion.created_at.desc(), StrategicMapVersion.id.desc()).all()
|
||||
return {"data": [_summary_dict(v, m) for v in versions]}
|
||||
|
||||
|
||||
@router.get("/{map_id}/versions/{vid}")
|
||||
def get_version(map_id: int, vid: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""单版本详情:dimensions/canvas_data 全量(供\"查看该版本画布\")"""
|
||||
m = _get_map(db, map_id, entity_id)
|
||||
v = _get_version(db, map_id, vid, entity_id)
|
||||
return _full_dict(v, m)
|
||||
|
||||
|
||||
@router.put("/{map_id}/versions/{vid}")
|
||||
def update_version_comment(map_id: int, vid: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""更新版本备注 comment"""
|
||||
_get_map(db, map_id, entity_id)
|
||||
if "comment" not in data:
|
||||
raise HTTPException(400, "缺少 comment 字段")
|
||||
v = _get_version(db, map_id, vid, entity_id)
|
||||
v.comment = data.get("comment")
|
||||
db.commit()
|
||||
db.refresh(v)
|
||||
return _summary_dict(v, _get_map(db, map_id, entity_id))
|
||||
|
||||
|
||||
def _next_snapshot_version(db: Session, map_id: int) -> str:
|
||||
"""该地图下一个快照版本号:最新快照行 minor+1;无快照 → v1.0(与 _auto_snapshot 语义一致)"""
|
||||
latest = db.query(StrategicMapVersion).filter(
|
||||
StrategicMapVersion.map_id == map_id
|
||||
).order_by(StrategicMapVersion.id.desc()).all()
|
||||
return {"data": [v_to_dict(v) for v in versions]}
|
||||
).order_by(StrategicMapVersion.id.desc()).first()
|
||||
if latest:
|
||||
nxt = _next_minor_version(latest.version)
|
||||
if nxt:
|
||||
return nxt
|
||||
return "v1.0"
|
||||
|
||||
|
||||
@router.post("/{map_id}/versions/snapshot")
|
||||
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
"""手动创建快照"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""手动创建快照(账套隔离)"""
|
||||
m = _get_map(db, map_id, entity_id)
|
||||
|
||||
import json
|
||||
dims = m.dimensions
|
||||
canvas = m.canvas_data
|
||||
if isinstance(dims, str):
|
||||
dims = json.loads(dims)
|
||||
if isinstance(canvas, str):
|
||||
canvas = json.loads(canvas)
|
||||
dims = _parse_json(m.dimensions, [])
|
||||
canvas = _parse_json(m.canvas_data, {"connections": []})
|
||||
|
||||
# 自动版本号
|
||||
existing = db.query(StrategicMapVersion).filter(
|
||||
StrategicMapVersion.map_id == map_id
|
||||
).order_by(StrategicMapVersion.id.desc()).first()
|
||||
if existing:
|
||||
import re
|
||||
match = re.search(r"v(\d+)\.(\d+)", existing.version)
|
||||
major = int(match.group(1)) if match else 1
|
||||
minor = int(match.group(2)) + 1 if match else 0
|
||||
new_ver = f"v{major}.{minor}"
|
||||
else:
|
||||
new_ver = "v1.0"
|
||||
# 自动版本号: 与 _auto_snapshot 一致 —— 最新快照行 minor+1;无快照 → v1.0
|
||||
new_ver = _next_snapshot_version(db, map_id)
|
||||
|
||||
snapshot = StrategicMapVersion(
|
||||
map_id=map_id,
|
||||
entity_id=entity_id, # 继承地图企业
|
||||
version=new_ver,
|
||||
dimensions=dims,
|
||||
canvas_data=canvas,
|
||||
@@ -57,30 +170,67 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
db.add(snapshot)
|
||||
db.commit()
|
||||
db.refresh(snapshot)
|
||||
return v_to_dict(snapshot)
|
||||
return _full_dict(snapshot, m)
|
||||
|
||||
|
||||
@router.post("/{map_id}/versions/{ver_id}/rollback")
|
||||
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db)):
|
||||
"""回滚到指定版本"""
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
@router.post("/{map_id}/versions/{vid}/rollback")
|
||||
def rollback_version(map_id: int, vid: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
"""回滚到指定版本。
|
||||
|
||||
v = db.query(StrategicMapVersion).filter(
|
||||
StrategicMapVersion.id == ver_id,
|
||||
安全设计(2026-09-08 完整实现,修复旧版无备份覆盖事故):
|
||||
1. 回滚前把地图【当前内容】备份为新版本快照(版本号由当前 map.version 派生 vX.(Y+1),撞号自动 +1)
|
||||
—— 任何情况下不因回滚丢失当前画布内容
|
||||
2. 用目标版本 dimensions/canvas_data 覆盖 strategic_maps 当前行,version 设为该版本号
|
||||
3. 地图与版本行双重 entity 校验(跨账套一律 404)
|
||||
"""
|
||||
m = _get_map(db, map_id, entity_id)
|
||||
v = _get_version(db, map_id, vid, entity_id)
|
||||
|
||||
# 1) 先备份当前内容 → 新版本快照
|
||||
cur_dims = _parse_json(m.dimensions, [])
|
||||
cur_canvas = _parse_json(m.canvas_data, {"connections": []})
|
||||
backup_ver = _next_snapshot_version(db, map_id)
|
||||
# 防撞号: 极端场景最新快照版本号不可解析回落 v1.0 时循环 +1(正常递增天然唯一)
|
||||
exists = db.query(StrategicMapVersion).filter(
|
||||
StrategicMapVersion.map_id == map_id,
|
||||
StrategicMapVersion.version == backup_ver,
|
||||
).first()
|
||||
if not v:
|
||||
raise HTTPException(404, "版本不存在")
|
||||
while exists:
|
||||
nxt = _next_minor_version(backup_ver)
|
||||
backup_ver = nxt if nxt else backup_ver + ".1"
|
||||
exists = db.query(StrategicMapVersion).filter(
|
||||
StrategicMapVersion.map_id == map_id,
|
||||
StrategicMapVersion.version == backup_ver,
|
||||
).first()
|
||||
|
||||
m.dimensions = v.dimensions
|
||||
m.canvas_data = v.canvas_data
|
||||
m.version = f"rollback-{v.version}"
|
||||
m.status = "draft"
|
||||
backup = StrategicMapVersion(
|
||||
map_id=map_id,
|
||||
entity_id=entity_id,
|
||||
version=backup_ver,
|
||||
dimensions=cur_dims,
|
||||
canvas_data=cur_canvas,
|
||||
comment="回滚前自动备份",
|
||||
)
|
||||
db.add(backup)
|
||||
|
||||
# 2) 覆盖当前行内容 + 版本号
|
||||
m.dimensions = _parse_json(v.dimensions, [])
|
||||
m.canvas_data = _parse_json(v.canvas_data, {"connections": []})
|
||||
m.version = v.version
|
||||
db.commit()
|
||||
return {"message": f"已回滚到 {v.version}", "version": m.version}
|
||||
|
||||
# 3) 同步目标表(与 update_map 保存路径一致,保证回顾会/KR 取数一致)
|
||||
try:
|
||||
from app.api.maps import _sync_map_objectives, _sync_objectives_krs
|
||||
_sync_map_objectives(m, db)
|
||||
_sync_objectives_krs(m, db)
|
||||
except Exception as e:
|
||||
# 同步失败不回滚已完成的版本覆盖(主数据已安全)
|
||||
import logging
|
||||
logging.getLogger("maps_versions").warning("rollback 后目标表同步失败: %s", e)
|
||||
|
||||
def v_to_dict(v):
|
||||
return {c.name: getattr(v, c.name) for c in v.__table__.columns}
|
||||
return {
|
||||
"message": f"已回滚到 {v.version}(当前内容已自动备份为 {backup_ver})",
|
||||
"version": m.version,
|
||||
"backup_version": backup_ver,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""零基预算逐项论证 API — 管理会计OS (P2-① 2026-08-28)
|
||||
|
||||
CRUD 逐项论证项(budget_zero_based_items) + generate 生成零基预算写 budget_plans。
|
||||
method-comparison 的 zero_based 分支优先读论证项(有数据→逐项求和 is_demo=false)。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import BudgetZeroBasedItem, KPIDefinition, BudgetPlan
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/cma/budget",
|
||||
tags=["零基预算"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/zero-based/items")
|
||||
def list_zero_based_items(
|
||||
kpi_id: Optional[int] = Query(None),
|
||||
period: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""零基逐项论证列表(kpi_id+period 过滤)"""
|
||||
query = db.query(BudgetZeroBasedItem).filter(BudgetZeroBasedItem.entity_id == entity_id)
|
||||
if kpi_id:
|
||||
query = query.filter(BudgetZeroBasedItem.kpi_id == kpi_id)
|
||||
if period:
|
||||
query = query.filter(BudgetZeroBasedItem.period == period)
|
||||
rows = query.order_by(BudgetZeroBasedItem.id.desc()).all()
|
||||
|
||||
kpi_ids = {r.kpi_id for r in rows}
|
||||
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
|
||||
result = []
|
||||
for r in rows:
|
||||
kpi = kpis.get(r.kpi_id)
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"kpi_id": r.kpi_id,
|
||||
"kpi_code": kpi.kpi_code if kpi else "",
|
||||
"kpi_name": kpi.kpi_name if kpi else "",
|
||||
"period": r.period,
|
||||
"item_name": r.item_name,
|
||||
"item_category": r.item_category,
|
||||
"base_value": r.base_value,
|
||||
"justification": r.justification,
|
||||
"proposed_value": r.proposed_value,
|
||||
"status": r.status,
|
||||
"created_by": r.created_by,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
})
|
||||
total_proposed = round(sum(r.proposed_value for r in rows), 2)
|
||||
return {"data": result, "total": len(result), "total_proposed": total_proposed}
|
||||
|
||||
|
||||
@router.post("/zero-based/items")
|
||||
def create_zero_based_item(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""新建逐项论证项"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
period = data.get("period")
|
||||
item_name = data.get("item_name")
|
||||
base_value = data.get("base_value")
|
||||
proposed_value = data.get("proposed_value")
|
||||
if not kpi_id or not period or not item_name:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, period, item_name")
|
||||
if base_value is None:
|
||||
base_value = 0
|
||||
if proposed_value is None:
|
||||
proposed_value = 0
|
||||
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在")
|
||||
|
||||
row = BudgetZeroBasedItem(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
item_name=item_name,
|
||||
item_category=data.get("item_category", "discretionary"),
|
||||
base_value=base_value,
|
||||
justification=data.get("justification"),
|
||||
proposed_value=proposed_value,
|
||||
status=data.get("status", "draft"),
|
||||
created_by=data.get("created_by") or (current_user.name if hasattr(current_user, "name") else None),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return {"message": "论证项已创建", "id": row.id}
|
||||
|
||||
|
||||
@router.put("/zero-based/items/{item_id}")
|
||||
def update_zero_based_item(
|
||||
item_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""更新逐项论证项"""
|
||||
row = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.id == item_id,
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "论证项不存在")
|
||||
for field in ("item_name", "item_category", "base_value", "justification",
|
||||
"proposed_value", "status"):
|
||||
if field in data:
|
||||
setattr(row, field, data[field])
|
||||
db.commit()
|
||||
return {"message": "论证项已更新", "id": row.id}
|
||||
|
||||
|
||||
@router.delete("/zero-based/items/{item_id}")
|
||||
def delete_zero_based_item(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""删除逐项论证项"""
|
||||
row = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.id == item_id,
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "论证项不存在")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"message": "论证项已删除"}
|
||||
|
||||
|
||||
@router.post("/zero-based/generate")
|
||||
def generate_zero_based_budget(
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""生成零基预算 = Σ(proposed_value),写 budget_plans(version='zbb-YYYYMMDD',calc_logic='zero_based_itemized')"""
|
||||
kpi_id = data.get("kpi_id")
|
||||
period = data.get("period")
|
||||
year = data.get("year")
|
||||
if not kpi_id or not period:
|
||||
raise HTTPException(400, "缺少必要参数: kpi_id, period")
|
||||
|
||||
items = db.query(BudgetZeroBasedItem).filter(
|
||||
BudgetZeroBasedItem.entity_id == entity_id,
|
||||
BudgetZeroBasedItem.kpi_id == kpi_id,
|
||||
BudgetZeroBasedItem.period == period,
|
||||
).all()
|
||||
if not items:
|
||||
raise HTTPException(400, f"期间 {period} 无逐项论证项,请先录入")
|
||||
|
||||
total = round(sum(i.proposed_value for i in items), 2)
|
||||
# 解析年份
|
||||
if not year:
|
||||
try:
|
||||
year = int(period.split("-")[0])
|
||||
except Exception:
|
||||
year = datetime.now().year
|
||||
month = 0
|
||||
try:
|
||||
month = int(period.split("-")[1]) if "-" in period else 0
|
||||
except Exception:
|
||||
month = 0
|
||||
|
||||
version = f"zbb-{datetime.now().strftime('%Y%m%d')}"
|
||||
# 删除同KPI同期间同版本旧预算,防重复
|
||||
db.query(BudgetPlan).filter(
|
||||
BudgetPlan.entity_id == entity_id,
|
||||
BudgetPlan.kpi_id == kpi_id,
|
||||
BudgetPlan.period == period,
|
||||
BudgetPlan.version == version,
|
||||
).delete()
|
||||
|
||||
bp = BudgetPlan(
|
||||
entity_id=entity_id,
|
||||
kpi_id=kpi_id,
|
||||
period=period,
|
||||
budget_value=total,
|
||||
budget_year=year,
|
||||
budget_month=month,
|
||||
version=version,
|
||||
status="active",
|
||||
source_type="zero_based",
|
||||
calc_logic="zero_based_itemized",
|
||||
remark=f"零基逐项论证生成: {len(items)}项 Σ(proposed_value)={total}",
|
||||
)
|
||||
db.add(bp)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"零基预算已生成: {total}({len(items)}项论证)",
|
||||
"kpi_id": kpi_id,
|
||||
"period": period,
|
||||
"total": total,
|
||||
"item_count": len(items),
|
||||
"version": version,
|
||||
"plan_id": bp.id,
|
||||
}
|
||||
+122
-10
@@ -9,6 +9,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import User, RolePermission
|
||||
from typing import Optional
|
||||
import secrets
|
||||
import json
|
||||
|
||||
@@ -60,7 +61,7 @@ except Exception:
|
||||
logger.warning("Redis不可用,token存储降级到内存(不支持多worker)")
|
||||
|
||||
# 内存 fallback
|
||||
_token_store: dict[str, int] = {}
|
||||
_token_store: dict[str, dict] = {}
|
||||
|
||||
TOKEN_PREFIX = "cma:token:"
|
||||
TOKEN_TTL = 86400 # 24小时
|
||||
@@ -95,28 +96,106 @@ def _load_permissions(db: Session = None):
|
||||
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
|
||||
|
||||
|
||||
def create_token(user_id: int) -> str:
|
||||
def create_token(user_id: int, entity_id: int = None) -> str:
|
||||
"""签发token:Redis存储 JSON {user_id, entity_id}(账套模式)
|
||||
兼容旧调用 create_token(user_id) → entity_id=None(切换器会重新签发)
|
||||
"""
|
||||
token = secrets.token_hex(32)
|
||||
payload = json.dumps({"user_id": user_id, "entity_id": entity_id}, ensure_ascii=False)
|
||||
if _redis_available:
|
||||
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, user_id)
|
||||
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, payload)
|
||||
else:
|
||||
_token_store[token] = user_id
|
||||
_token_store[token] = payload
|
||||
return token
|
||||
|
||||
|
||||
def _resolve_user_id(token: str) -> int | None:
|
||||
def _resolve_token_data(token: str) -> dict | None:
|
||||
"""解析token → {user_id, entity_id}
|
||||
- 新格式 JSON → 返回 dict
|
||||
- 旧格式 int(改造前)→ 返回 None(强制下线,账套模式需重新登录)
|
||||
- 不存在 → None
|
||||
"""
|
||||
if _redis_available:
|
||||
val = _redis.get(f"{TOKEN_PREFIX}{token}")
|
||||
if val is not None:
|
||||
return int(val)
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(val)
|
||||
if isinstance(data, dict) and "user_id" in data:
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
# 旧格式纯 int → 强制下线
|
||||
return None
|
||||
return _token_store.get(token)
|
||||
val = _token_store.get(token)
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, dict):
|
||||
return val
|
||||
try:
|
||||
data = json.loads(val)
|
||||
if isinstance(data, dict) and "user_id" in data:
|
||||
return data
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_user_id(token: str) -> int | None:
|
||||
data = _resolve_token_data(token)
|
||||
return int(data["user_id"]) if data else None
|
||||
|
||||
|
||||
def get_token_entity_id(token: str) -> int | None:
|
||||
"""从token解析绑定的entity_id(token不存在/旧格式 → None)"""
|
||||
data = _resolve_token_data(token)
|
||||
if not data:
|
||||
return None
|
||||
eid = data.get("entity_id")
|
||||
return int(eid) if eid else None
|
||||
|
||||
|
||||
def user_has_entity(db: Session, user_id: int, entity_id: int) -> bool:
|
||||
"""校验用户是否被授权访问指定企业(账套授权表 user_entities)"""
|
||||
from app.models import UserEntity, Entity
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
if not ent or ent.status != "active":
|
||||
return False
|
||||
rel = db.query(UserEntity).filter(
|
||||
UserEntity.user_id == user_id,
|
||||
UserEntity.entity_id == entity_id,
|
||||
).first()
|
||||
return rel is not None
|
||||
|
||||
|
||||
def extract_bearer_token(request) -> str | None:
|
||||
"""从Request提取Bearer token(无/非Bearer格式 → None)"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:].strip()
|
||||
return None
|
||||
|
||||
|
||||
# ── 统一全站鉴权语义(cma-auth-401-403-unify-20260915)────────────────────
|
||||
# 口径:**未认证(缺 token / 无效 token)→ 401**;**已认证但无权(跨实体 / 角色不足)→ 403**。
|
||||
# FastAPI 的 HTTPBearer(auto_error=True) 对「缺 Authorization 头」返回 403,与
|
||||
# require_auth_401 的 401 语义在同站并存(同一种「没带 token」两种状态码)。
|
||||
# 现统一为 auto_error=False 自行判空:缺 header / 非 Bearer 格式 → 显式 401;
|
||||
# 有 token → 仍按原逻辑校验身份(无效/过期 token → 401)。
|
||||
# 403 语义保留:require_role(角色不足)、deps.assert_entity_scope(跨实体无权)。
|
||||
_optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def require_auth(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=True)),
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""认证依赖(全站统一):缺 token / 非 Bearer 格式 → 401;无效 token → 401。
|
||||
|
||||
已认证但无权(角色/实体)由 require_role、deps.assert_entity_scope 另行返回 403。
|
||||
"""
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
|
||||
token = credentials.credentials
|
||||
user_id = _resolve_user_id(token)
|
||||
if user_id is None:
|
||||
@@ -137,6 +216,23 @@ def require_role(*roles: str):
|
||||
return role_checker
|
||||
|
||||
|
||||
# ── 401 语义的认证依赖(cma-api-auth-surface-audit-20260914)
|
||||
# 2026-09-15(cma-auth-401-403-unify-20260915):语义与 _optional_bearer 已统一,
|
||||
# 本入口保留作兼容别名(既有 router 与审计器 AUTH_STRICT 均引用此名)。
|
||||
def require_auth_401(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""用户端点统一鉴权入口:缺 Bearer → 401;有 token → require_auth 校验(无效 → 401)
|
||||
|
||||
2026-09-15(cma-auth-401-403-unify-20260915)起与 require_auth 语义完全一致
|
||||
(require_auth 也改为 auto_error=False 自判空 → 缺 token 401)。
|
||||
保留本名称:既有 router 的 dependencies=[Depends(require_auth_401)] 与
|
||||
backend/scripts/audit_api_auth_surface.py 的 AUTH_STRICT 分类仍引用它。
|
||||
"""
|
||||
return require_auth(credentials=credentials, db=db)
|
||||
|
||||
|
||||
def has_permission(user: User, module: str, db: Session = None) -> bool:
|
||||
routes, _ = _load_permissions(db)
|
||||
return module in routes.get(user.role, [])
|
||||
@@ -148,9 +244,25 @@ def has_action(user: User, action: str, db: Session = None) -> bool:
|
||||
|
||||
|
||||
# ─── KPI可见性(按维度/分类过滤) ───
|
||||
#
|
||||
# ⚠️ 口径裁定(2026-09-23 dama-entity-isolation-sweep-20260923 §一②):
|
||||
# `role_permissions.key = 'kpi_visibility'` **属「非账套维度」的全局配置,不拆账套**
|
||||
# —— 理由:① 值本身是 `{role: [维度规则]}` 的**角色维度**字典,无账套语义;
|
||||
# ② 角色枚举(ceo/finance/business/it)是平台级字典,`role_permissions` 表
|
||||
# 亦无 `entity_id` 列(`key` unique),「按角色看哪些维度」是集团统一口径;
|
||||
# ③ 与 `route_permissions` / `action_permissions` 同族(同为角色级权限面),
|
||||
# 若只拆本键会与同表另两键口径分裂。
|
||||
# 判别口径:**同表同族的键按同一维度处理**;本键不消费任何账套状态、也不被账套写侧改写。
|
||||
# 登记:`docs/global-config-keys-caliber-20260923.md`(键维度字典)。
|
||||
|
||||
|
||||
def _load_kpi_visibility(db: Session = None):
|
||||
"""从RolePermission表加载kpi_visibility配置(独立缓存)"""
|
||||
"""从RolePermission表加载kpi_visibility配置(独立缓存)
|
||||
|
||||
**非账套维度**(角色级天然全局,口径见上方注释与本档登记):
|
||||
只读 `role_permissions.key = 'kpi_visibility'`,**有意不做账套后缀**——
|
||||
这是集团共享口径,不是漏隔离。
|
||||
"""
|
||||
import time
|
||||
if not hasattr(_load_kpi_visibility, "_cache"):
|
||||
_load_kpi_visibility._cache = {"data": None, "ts": 0}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user