Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
509b406254 | ||
|
|
494490f6e6 | ||
|
|
dd8912f7ae | ||
|
|
05f2ee416e | ||
|
|
5e1437679b | ||
|
|
fa047ed3ee | ||
|
|
f9f9ad751d | ||
|
|
e90e37b77e | ||
|
|
e337d9c989 | ||
|
|
35789379ab | ||
|
|
a3d91ad17f | ||
|
|
0739bcc4fb |
@@ -12,4 +12,5 @@ MAX_IMPORT_ROWS=1000000
|
||||
AI_ENABLED=0
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
VITE_AG_GRID_LICENSE_KEY=
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# fa-agent (TraceIQ 财务分析) review profile
|
||||
|
||||
## Stack and verification
|
||||
|
||||
- Django 5.2 后端(PostgreSQL 生产 / SQLite 仅开发),金额一律 `NUMERIC(44,24)` + Python `Decimal`(精度 60);JSON 中金额为字符串。
|
||||
- React 19 + TypeScript + Vite 前端(AG Grid、ECharts),`npm run build` 含 `tsc --noEmit`。
|
||||
- openpyxl 流式生成公式工作簿(SUMIFS/COUNTIFS),公式正确性靠 LibreOffice 无头重算验证。
|
||||
- pytest(`--cov-fail-under=85`)+ Playwright E2E;CI 在 PostgreSQL 17 服务容器上跑全量。
|
||||
- 验证命令:`uv run pytest`、`uv run ruff check server finance tests scripts`、`npm run typecheck`、`python manage.py makemigrations --check`。
|
||||
|
||||
## Review priorities
|
||||
|
||||
1. **金额精度**:财务计算、持久化、Excel 公式生成路径上出现 `float()`、`round()`、Decimal 与 float 混算、或金额以数字(非字符串)进 JSON,必须报。浏览器 `Number` 只允许用于绘图与显示。
|
||||
2. **缺失值语义**:成本/毛利/人数等依赖字段缺失时必须传播为不可用(服务端 `None`、Excel `#N/A`),不得静默转 0 或跳过;比率分母为零/非正同样不可用。对账容差按指标类型独立(人数严格相等、金额 0.01、比率 0.0001),不得为消除报错统一放宽容差。
|
||||
3. **文件与路径安全**:清理/下载/回收文件前必须 `lstat` 校验且拒绝符号链接(目录用 `O_NOFOLLOW` 打开),只删除本次任务在受控目录内创建的文件;清理失败要留日志与可追踪状态,不得静默吞掉 `OSError`。
|
||||
4. **越权与生命周期**:每个 endpoint 必须声明 roles 白名单;worker 执行阶段要重新核验角色、`is_active`、组织与科室范围(不能只信创建时的检查);卡死任务要有超时恢复,且恢复不得与活跃 worker 抢任务。
|
||||
5. **事务与并发**:配额/唯一性检查必须在事务内并锁定稳定的所属行(如 Membership),审计与业务变更同事务提交;乐观并发用 revision + 409,不得静默后写覆盖。
|
||||
6. **Excel 公式注入与上限**:不可信文本写入单元格必须强制文本类型;CSV/公式中 `= + - @` 开头加 `'` 前缀;SUMIFS/COUNTIFS criteria 必须转义 `~ * ? "` 并检查 255 字符上限、公式 8192 字符上限。
|
||||
7. **前端异步状态**:useEffect 依赖未变时不得清空已加载数据(同状态重复点击不能停在“加载中”);并发请求要有过期响应丢弃(active flag/AbortController);失败路径要有可见错误与重试。
|
||||
8. **测试真实性**:行为修复必须带“修复前失败、修复后通过”的回归测试;环境门禁(LibreOffice、PostgreSQL 并发、真实 AI)必须显式 skip 并注明原因,不得用 mock 通过冒充真实验收。
|
||||
|
||||
## Avoid reporting
|
||||
|
||||
- 纯格式与风格问题(ruff / prettier 已在 CI 把关)。
|
||||
- 改动行为已被等价集成测试或 E2E 覆盖时,仅因缺少同名单测而报缺测试。
|
||||
- 没有具体并发路径的假想竞态(SQLite 单写者语义下的理论交错不算,除非改动显式依赖 PostgreSQL 行为)。
|
||||
- 对 `docs/` 报告类 HTML/Markdown 的措辞与结构建议——它们记录的是验收状态,不是产品代码。
|
||||
@@ -0,0 +1,202 @@
|
||||
name: AI Review
|
||||
|
||||
# 对抗式 PR 审查:先一遍找出候选缺陷,再对每一条派一个反驳者去证伪它,只有
|
||||
# 活下来的才会发到 PR 上。这个不对称是刻意的——AI 审查员死于误报远早于死于漏
|
||||
# 报,所以反驳阶段默认站在"这是误报"一边,"什么都没发现"是可接受的结果。
|
||||
#
|
||||
# 引擎不在本仓库,在 vertai-tech/ai-review-engine。本文件只负责把三样东西摆到
|
||||
# 位再调它:被审查的代码、本仓库的审查侧重、引擎本身。
|
||||
#
|
||||
# 接入一个仓库需要(各一次):
|
||||
# 1. 引擎仓库的 templates/ai-review.yml -> 本仓库 .gitea/workflows/ai-review.yml
|
||||
# 2. 引擎仓库的 templates/ai-review.md -> 本仓库 .gitea/ai-review.md,按本仓库
|
||||
# 的技术栈改写
|
||||
# 3. 下面这些组织级机密 / 变量(组织设置 → Actions)配好之后,新仓库就不用再
|
||||
# 单独配任何东西:
|
||||
# secret REVIEW_API_KEY 模型网关的 key
|
||||
# secret REVIEW_ENGINE_TOKEN 只读 PAT,用于拉私有的引擎仓库
|
||||
# variable REVIEW_BASE_URL 任意 OpenAI 格式的根地址
|
||||
# variable REVIEW_MODEL 支持 function calling 的模型
|
||||
#
|
||||
# 自动修复默认是关的。要开,在**本仓库**的 设置 → Actions → 变量 里加一个(这两条
|
||||
# 都不要配到组织级,那样等于给所有仓库都开了),并在组织级配一次
|
||||
# REVIEW_FIX_MODEL:
|
||||
# REVIEW_AUTOFIX=1 改代码、提交、推回 PR 分支
|
||||
# REVIEW_FIX_SUGGEST_ONLY=1 只把改法写进评论,一个字节都不落盘
|
||||
# 两个都配的时候,不动仓库的那个赢。fork 来的 PR 推不回去,apply 会自动降级成
|
||||
# 只给改法。
|
||||
#
|
||||
# 默认只修一轮:修完推一个 commit,那次推送起的下一轮只审不改。要"一直修到没
|
||||
# 意见",把仓库变量 REVIEW_FIX_ROUNDS 调大——它数的是这个分支已经被自动修过几
|
||||
# 轮,轮次写在提交消息里。
|
||||
#
|
||||
# 改法本身是两步定出来的:一个模型决定这条该不该由机器改、以及改完长什么样,另一
|
||||
# 个模型把它落成逐字命中的替换。想让这两步用不同的模型,就再配一个组织级的
|
||||
# REVIEW_FIX_PLAN_MODEL——判断那步值得用贵的,抄写那步不值得。不配也照样分两步,
|
||||
# 只是两步都用 REVIEW_FIX_MODEL。
|
||||
#
|
||||
# 成本由模型决定而不是由这个设计决定:同一个 PR 在便宜模型上是几分钱,在旗舰
|
||||
# 模型上是几美元。换模型前先看一眼那家的每 token 价格。
|
||||
# 换了模型记得同步组织变量 REVIEW_PRICES,缺单价的那轮评论不报金额。
|
||||
#
|
||||
# 只发 COMMENT 类型的 review,从不 approve,也从不 request changes。所有发现集中
|
||||
# 在一条评论里按 1、2、3 编号,不再逐条钉成行内评论。
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# 没有 ready_for_review:Gitea Actions 不实现这个活动类型(它支持的是
|
||||
# opened / edited / closed / reopened / assigned / unassigned /
|
||||
# synchronize / labeled / unlabeled / milestoned / demilestoned /
|
||||
# review_requested / review_request_removed)。写上去只会让人以为有这个
|
||||
# 覆盖,实际没有。
|
||||
#
|
||||
# 留下的缺口:草稿 PR 会被下面的 draft == false 挡掉,所以"取消草稿但不再
|
||||
# 推代码"不会触发任何东西。再推一次,或者用下面的 workflow_dispatch 手动
|
||||
# 指定 PR 号——那条路就是为这个场景留的。
|
||||
#
|
||||
# 用 edited 能覆盖取消草稿,但它同时会在每次改标题、改描述时触发,而每一
|
||||
# 次都是一整轮 find + refute 的钱。为一个"再推一次就解决"的场景不值。
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to review"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# 连着推两次的 PR 只该被审一次——取消在飞的那轮,而不是花钱买一份关于旧代码
|
||||
# 的报告。
|
||||
concurrency:
|
||||
group: ai-review-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# 草稿按定义就是没写完的东西;手动 dispatch 是明确的请求,所以绕过这条。
|
||||
# vars 那两个条件意味着本文件在网关配好之前合入也是安全的——job 直接跳过。
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false)
|
||||
&& vars.REVIEW_BASE_URL != ''
|
||||
&& vars.REVIEW_MODEL != ''
|
||||
runs-on: ubuntu-latest
|
||||
# 不设的话是 Gitea 默认的 6 小时,而下面的 continue-on-error 帮不上忙——它
|
||||
# 兜的是失败,不是挂起——于是一次卡在不返回的 API 调用上的审查会一直占着
|
||||
# runner,PR 的 check 一下午都是 pending。fantap-server 真踩过这个。
|
||||
#
|
||||
# 60 分钟是照实测定的:一次阶段一实测 710 秒、29k 输出 token(其中 28.7k
|
||||
# 是推理),阶段二还要在这之上再加最多八条反驳,开了自动修复的话阶段三再加
|
||||
# 最多八条的规划加执行。阶段三从每条一次调用变成每条两次(执行没对上文件还
|
||||
# 会重试)之后,原来的 45 分钟只在八条全中时才不够,但不够的表现是卡在超时
|
||||
# 上而不是干净报错,所以往上留一档。上面 concurrency 的 cancel-in-progress
|
||||
# 负责拦住堆积。
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
# write 只有 REVIEW_AUTOFIX 那个模式才用得上——它要把改动推回 PR 分支。
|
||||
# 没开自动修复、或者只开了 REVIEW_FIX_SUGGEST_ONLY 的仓库,把这一行改回
|
||||
# read 就是了:只给改法的模式一个字节都不落盘,不需要写权限。留着 write
|
||||
# 也没事,引擎只在 apply 模式下才提交任何东西。
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
# 一、被审查的代码。反驳阶段要读 PR head 上的整份文件,所以检出 head,
|
||||
# 不是 merge commit。
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || format('refs/pull/{0}/head', inputs.pr_number) }}
|
||||
|
||||
# 二、本仓库的审查侧重(.gitea/ai-review.md),取默认分支的那一份,不取
|
||||
# PR 分支的——否则一个 PR 可以先改掉审查重点,再让改过的重点来审自己。
|
||||
# || 'main' 是给 Gitea 兜底:它的 workflow_dispatch 载荷不总是带
|
||||
# github.event.repository。
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch || 'main' }}
|
||||
sparse-checkout: .gitea
|
||||
path: .review-profile
|
||||
|
||||
# 三、引擎。跟着 v1 这个移动标签走:引擎修了 bug 移一次标签,所有接入的
|
||||
# 仓库同时生效。某个仓库被新版引擎坑到时,把这里单独钉到具体 commit 就能
|
||||
# 脱身,不用回滚全局。
|
||||
#
|
||||
# 引擎仓库是私有的,Gitea 的 GITHUB_TOKEN 只对本仓库有效,拉不动它,所以
|
||||
# 要一个组织级的只读 PAT。continue-on-error 在这里是必要的:PAT 没配或者
|
||||
# 过期时,这一步失败不该把别人的 PR 弄红——下面那步会说明情况然后干净退出。
|
||||
- uses: actions/checkout@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
repository: vertai-tech/ai-review-engine
|
||||
ref: v1
|
||||
path: .review-engine
|
||||
token: ${{ secrets.REVIEW_ENGINE_TOKEN }}
|
||||
|
||||
- name: Adversarial review
|
||||
# 设计上就是建议性的:引擎自己的错误已经会干净退出,这里兜的是剩下的
|
||||
# (进程被杀、脚本没看见的 curl 失败)。审查机器人不该让人的 PR 变红。
|
||||
continue-on-error: true
|
||||
# 比 job 的预算小一档,这样超时是记在这一步上,而不是整个 job 被砍——
|
||||
# 后者在界面上看不出是谁超的时。引擎内部 curl 的 max-time 比这再小一档
|
||||
# (1200s),所以正常顺序是那一层带着响应体和日志先报错,而不是这一层
|
||||
# 无声地杀掉进程。
|
||||
timeout-minutes: 55
|
||||
env:
|
||||
REVIEW_API_KEY: ${{ secrets.REVIEW_API_KEY }}
|
||||
REVIEW_BASE_URL: ${{ vars.REVIEW_BASE_URL }}
|
||||
REVIEW_MODEL: ${{ vars.REVIEW_MODEL }}
|
||||
# 可选,组织级变量。网关不在响应里报金额时(sub2api 就不报),评论按这张
|
||||
# 单价表算金额;不配就只报 token。格式见引擎 README「每轮花了多少」。
|
||||
REVIEW_PRICES: ${{ vars.REVIEW_PRICES }}
|
||||
# 上游报过载 / 限流时每次重试前等几秒。不配就是 10,30,60,写 0 关掉。
|
||||
REVIEW_BUSY_RETRY_WAITS: ${{ vars.REVIEW_BUSY_RETRY_WAITS }}
|
||||
# 评论里行号链接用的站点地址,如 https://git.sciark.cn。runner 在容器里访问
|
||||
# Gitea 时,API 返回的 html_url 和 GITHUB_SERVER_URL 都可能是容器内地址,
|
||||
# 不配这个链接就打不开。组织级配一次。
|
||||
REVIEW_PUBLIC_URL: ${{ vars.REVIEW_PUBLIC_URL }}
|
||||
# PR 号和仓库名经 env 进脚本,不直接拼进 run:手动 dispatch 的 pr_number
|
||||
# 是任意字符串,表达式在 shell 解析之前就被展开,带引号就能注入命令。
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
REVIEW_REPO: ${{ github.repository }}
|
||||
# 引擎的 Gitea REST helper 读这个。GITHUB_API_URL 由 act_runner 导出,
|
||||
# 已经指向 <instance>/api/v1。
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# 自动修复。REVIEW_AUTOFIX 没配或不是 1 的话,下面几个都没有意义。
|
||||
REVIEW_AUTOFIX: ${{ vars.REVIEW_AUTOFIX }}
|
||||
REVIEW_FIX_SUGGEST_ONLY: ${{ vars.REVIEW_FIX_SUGGEST_ONLY }}
|
||||
REVIEW_FIX_MODEL: ${{ vars.REVIEW_FIX_MODEL }}
|
||||
# 一个分支最多自动修复几轮。不配就是 1:修一次,下一轮只审,跟从前
|
||||
# 一样。配大了才是"一直修到没意见",代价是往作者分支上推更多 commit、
|
||||
# 一个 PR 烧更多轮的钱。硬上限在引擎里。
|
||||
REVIEW_FIX_ROUNDS: ${{ vars.REVIEW_FIX_ROUNDS }}
|
||||
# 规划用哪个模型。这不是开关——不配也照样先规划再执行,只是两步同一个
|
||||
# 模型。配了才是"贵的判断、便宜的抄写"那种分工。
|
||||
REVIEW_FIX_PLAN_MODEL: ${{ vars.REVIEW_FIX_PLAN_MODEL }}
|
||||
# 只有当写修复的模型不在审查那个网关上时,才需要这两个。
|
||||
REVIEW_FIX_BASE_URL: ${{ vars.REVIEW_FIX_BASE_URL }}
|
||||
REVIEW_FIX_API_KEY: ${{ secrets.REVIEW_FIX_API_KEY }}
|
||||
# 同上,只有当规划的模型连修复那个网关都不在时才需要。
|
||||
REVIEW_FIX_PLAN_BASE_URL: ${{ vars.REVIEW_FIX_PLAN_BASE_URL }}
|
||||
REVIEW_FIX_PLAN_API_KEY: ${{ secrets.REVIEW_FIX_PLAN_API_KEY }}
|
||||
# 规划的思考强度。不配就是模型默认;写 high 之类的词按 reasoning_effort
|
||||
# 发,写 JSON 对象就原样并进请求。各家写法见引擎 README。
|
||||
REVIEW_FIX_PLAN_THINKING: ${{ vars.REVIEW_FIX_PLAN_THINKING }}
|
||||
# 对照用的第二个执行模型。只在直接改的模式下跑,改动不落盘,只和
|
||||
# REVIEW_FIX_MODEL 的改动一起贴进评论,方便比较哪个模型抄得好。
|
||||
REVIEW_FIX_MODEL2: ${{ vars.REVIEW_FIX_MODEL2 }}
|
||||
REVIEW_FIX_MODEL2_BASE_URL: ${{ vars.REVIEW_FIX_MODEL2_BASE_URL }}
|
||||
REVIEW_FIX_MODEL2_API_KEY: ${{ secrets.REVIEW_FIX_MODEL2_API_KEY }}
|
||||
# 只有当上面那个自动 token 推不动分支时,才需要这个写权限 PAT。
|
||||
REVIEW_FIX_TOKEN: ${{ secrets.REVIEW_FIX_TOKEN }}
|
||||
run: |
|
||||
ENGINE=.review-engine/scripts/ai_review.py
|
||||
if [ ! -f "$ENGINE" ]; then
|
||||
# 拉引擎那步失败了。说清楚原因,否则这里的沉默会被读成"没发现问题"。
|
||||
echo "::warning::引擎没拉下来(多半是组织级 secret REVIEW_ENGINE_TOKEN 没配或已过期),本轮未审查。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$PR_NUMBER" in
|
||||
''|*[!0-9]*)
|
||||
echo "::warning::PR 号必须是数字,收到的是:$PR_NUMBER。本轮未审查。"
|
||||
exit 0 ;;
|
||||
esac
|
||||
python3 "$ENGINE" --repo "$REVIEW_REPO" --pr "$PR_NUMBER"
|
||||
|
||||
@@ -16,7 +16,9 @@ jobs:
|
||||
--health-cmd pg_isready --health-interval 5s
|
||||
--health-timeout 5s --health-retries 10
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:ci-only-password@localhost:5432/traceiq
|
||||
# Gitea act_runner 的 job 跑在容器里,与服务容器同网络:主机名必须用服务名
|
||||
# postgres(GitHub 托管 runner 的 localhost 语义在这里连不通,勿改回)。
|
||||
DATABASE_URL: postgresql://postgres:ci-only-password@postgres:5432/traceiq
|
||||
E2E_PASSWORD: CI-Test-Only-SecurePassword882!
|
||||
PYTHONPATH: .
|
||||
steps:
|
||||
@@ -35,8 +37,24 @@ jobs:
|
||||
- run: uv run python manage.py makemigrations --check --dry-run
|
||||
- run: uv run pytest --cov=finance --cov-report=term-missing --cov-fail-under=85
|
||||
- run: npm run build
|
||||
- run: npm audit --omit=dev --registry=https://registry.npmjs.org
|
||||
- run: uv run pip-audit
|
||||
# 审计端点在 runner 网络下偶发 TLS 断开(npm error audit endpoint returned
|
||||
# an error),重试区分网络抖动与真实漏洞:漏洞是确定性的,重试后照样红。
|
||||
- name: npm audit (production deps)
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
npm audit --omit=dev --registry=https://registry.npmjs.org && exit 0
|
||||
echo "::warning::npm audit 第 ${attempt} 次失败,15 秒后重试"
|
||||
sleep 15
|
||||
done
|
||||
exit 1
|
||||
- name: pip-audit
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
uv run pip-audit && exit 0
|
||||
echo "::warning::pip-audit 第 ${attempt} 次失败,15 秒后重试"
|
||||
sleep 15
|
||||
done
|
||||
exit 1
|
||||
- run: uv run python manage.py migrate --noinput
|
||||
- run: uv run python manage.py collectstatic --noinput
|
||||
- run: uv run python manage.py bootstrap --username admin --organization CI
|
||||
@@ -53,8 +71,11 @@ jobs:
|
||||
trap 'kill "$web" "$worker"' EXIT
|
||||
for i in {1..30}; do curl -fsS http://127.0.0.1:8000/api/health && break; sleep 1; done
|
||||
npm run test:e2e
|
||||
# Gitea 不支持 upload-artifact@v4 的 artifact 协议(GHESNotSupportedError),
|
||||
# 上传失败不该把已经全绿的测试标红;迁到 GitHub 托管时此步骤自动生效。
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: browser-results
|
||||
path: |
|
||||
@@ -33,6 +33,14 @@
|
||||
|
||||
需要 Node.js 22、uv、Python 3.12、PostgreSQL 17。所有示例从仓库根目录执行。
|
||||
|
||||
快速启动本地页面(迁移、管理员初始化、前端构建、API 与导入 worker 一条命令完成,健康检查通过后自动打开 `http://127.0.0.1:8000`;`Ctrl-C` 或 `scripts/dev_up.sh --stop` 停止,`--no-open` 跳过自动打开浏览器):
|
||||
|
||||
```bash
|
||||
scripts/dev_up.sh
|
||||
```
|
||||
|
||||
未设置 `DATABASE_URL` 时该脚本使用 SQLite 轻量开发库 `var/db.sqlite3`,仅限页面测试与开发回归;金额聚合与财务验收需按下方配置 PostgreSQL。手动分步流程如下:
|
||||
|
||||
```bash
|
||||
uv sync --frozen --python 3.12
|
||||
npm ci --registry=https://registry.npmjs.org
|
||||
@@ -99,7 +107,7 @@ npm audit --omit=dev --registry=https://registry.npmjs.org
|
||||
uv run pip-audit
|
||||
```
|
||||
|
||||
Playwright 需要正在运行的本地 API 和 worker,可用 E2E_PASSWORD 指定测试 admin 密码,默认读取本地凭据文件。首次执行 `npx playwright install chromium`。浏览器 fixture 测试会新建 `e2e-finance.csv` 数据集,不可指向正式生产环境。`.github/workflows/ci.yml` 在独立 PostgreSQL 上执行同样流程,尚未推送到远端触发。
|
||||
Playwright 需要正在运行的本地 API 和 worker,可用 E2E_PASSWORD 指定测试 admin 密码,默认读取本地凭据文件。首次执行 `npx playwright install chromium`。浏览器 fixture 测试会新建 `e2e-finance.csv` 数据集,不可指向正式生产环境。`.gitea/workflows/ci.yml` 在独立 PostgreSQL 服务容器上执行同样流程(Gitea act_runner 的 job 跑在容器内,服务主机名用服务名 `postgres` 而非 localhost)。`.gitea/workflows/ai-review.yml` 对每个 PR 运行对抗式 AI 审查与自动修复,引擎在 vertai-tech/ai-review-engine(v1 标签),本仓库审查侧重见 `.gitea/ai-review.md`。
|
||||
|
||||
原附件只在本机使用,不在仓库中:
|
||||
|
||||
@@ -111,7 +119,7 @@ REFERENCE_DATASET_NAME='分析数据_原稿(1).xlsx' npm run test:e2e
|
||||
|
||||
## AI 与跨平台
|
||||
|
||||
默认 AI_ENABLED=0。启用外部模型需配置 `AI_ENABLED=1`、`OPENAI_API_KEY`、`OPENAI_MODEL`,并完成机构数据处理审批。密钥只在后端。用户自由输入的问题也可能含敏感信息,不能仅凭不发送原始行就认定无隐私风险。模型不能提交 SQL、修改金额或修改账号;输出只允许受控意图,异常/拒答关闭执行。
|
||||
默认 AI_ENABLED=0。启用外部模型有两种方式,数据库配置优先:全权限管理员在页面「管理 → AI 模型」配置服务商(OpenAI 或本地 OpenAI 兼容服务如 Ollama / LM Studio / vLLM)、Base URL、API Key 与模型,可从 `/v1/models` 一键获取本地服务模型列表多选一;或配置环境变量 `AI_ENABLED=1`、`OPENAI_API_KEY`、`OPENAI_MODEL`(可选 `OPENAI_BASE_URL`)。均需完成机构数据处理审批。密钥只在后端:读取接口不回传密钥原文,配置修改记入审计(不含密钥)。用户自由输入的问题也可能含敏感信息,不能仅凭不发送原始行就认定无隐私风险。模型不能提交 SQL、修改金额或修改账号;输出只允许受控意图,异常/拒答关闭执行。
|
||||
|
||||
前端为标准 Web,API 无平台耦合,可由后续 PWA、Tauri 或 Capacitor 客户端复用。当前没有打包桌面/移动客户端,也不在浏览器离线缓存财务数据。
|
||||
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""AI 模型配置:每个组织可在页面配置 OpenAI 或本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM 等)。
|
||||
|
||||
数据库配置优先;未配置时回退到环境变量(AI_ENABLED / OPENAI_*),保持旧行为兼容。
|
||||
API Key 只存后端,读取接口只做脱敏回显,绝不把密钥下发到浏览器。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
|
||||
from .models import AIConfig
|
||||
from .views import APIError, audit, body, endpoint
|
||||
|
||||
PROVIDERS = ("openai", "local")
|
||||
OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
|
||||
|
||||
def require_full_admin(request):
|
||||
if request.member.departments:
|
||||
raise APIError("没有配置 AI 模型的权限", 403)
|
||||
|
||||
|
||||
def resolve(org):
|
||||
"""返回生效的 AI 配置字典;未启用或配置不完整时返回 None。"""
|
||||
cfg = AIConfig.objects.filter(organization=org).first()
|
||||
if cfg:
|
||||
base_url = (cfg.base_url or "").rstrip("/") or (OPENAI_BASE_URL if cfg.provider == "openai" else "")
|
||||
key_required = cfg.provider == "openai"
|
||||
if not cfg.enabled or not base_url or not cfg.model or (key_required and not cfg.api_key):
|
||||
return None
|
||||
return {
|
||||
"enabled": True,
|
||||
"provider": cfg.provider,
|
||||
"base_url": base_url,
|
||||
"api_key": cfg.api_key,
|
||||
"model": cfg.model,
|
||||
}
|
||||
if settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL:
|
||||
return {
|
||||
"enabled": True,
|
||||
"provider": "openai",
|
||||
"base_url": (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"),
|
||||
"api_key": settings.OPENAI_API_KEY,
|
||||
"model": settings.OPENAI_MODEL,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def org_models_endpoint(org):
|
||||
"""组织配置或环境回退的 base_url/api_key,用于探测模型列表。"""
|
||||
cfg = AIConfig.objects.filter(organization=org).first()
|
||||
if cfg:
|
||||
base_url = (cfg.base_url or "").rstrip("/") or (OPENAI_BASE_URL if cfg.provider == "openai" else "")
|
||||
return base_url, cfg.api_key
|
||||
return (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"), settings.OPENAI_API_KEY
|
||||
|
||||
|
||||
@endpoint(("GET", "PUT"), roles=("admin",))
|
||||
def ai_config(request):
|
||||
require_full_admin(request)
|
||||
org = request.member.organization
|
||||
if request.method == "GET":
|
||||
cfg = AIConfig.objects.filter(organization=org).first()
|
||||
return JsonResponse(
|
||||
{
|
||||
"enabled": cfg.enabled if cfg else False,
|
||||
"provider": cfg.provider if cfg else "local",
|
||||
"base_url": cfg.base_url if cfg else "",
|
||||
"model": cfg.model if cfg else "",
|
||||
"api_key_set": bool(cfg and cfg.api_key),
|
||||
"updated_at": cfg.updated_at.isoformat() if cfg else None,
|
||||
}
|
||||
)
|
||||
data = body(request)
|
||||
enabled = data.get("enabled")
|
||||
provider = data.get("provider")
|
||||
base_url = data.get("base_url", "")
|
||||
api_key = data.get("api_key", "")
|
||||
model = data.get("model", "")
|
||||
if type(enabled) is not bool or provider not in PROVIDERS:
|
||||
raise APIError("AI 配置参数无效")
|
||||
if not isinstance(base_url, str) or len(base_url) > 500:
|
||||
raise APIError("Base URL 无效")
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url and not base_url.startswith(("http://", "https://")):
|
||||
raise APIError("Base URL 必须以 http:// 或 https:// 开头")
|
||||
if not isinstance(api_key, str) or len(api_key) > 500:
|
||||
raise APIError("API Key 无效")
|
||||
if not isinstance(model, str) or len(model) > 200:
|
||||
raise APIError("模型名无效")
|
||||
cfg = AIConfig.objects.filter(organization=org).first()
|
||||
if enabled:
|
||||
if provider == "local" and not base_url:
|
||||
raise APIError("本地服务需要填写 Base URL,例如 http://127.0.0.1:11434/v1")
|
||||
if provider == "openai" and not (api_key or (cfg and cfg.api_key)):
|
||||
raise APIError("OpenAI 服务需要 API Key")
|
||||
if not model:
|
||||
raise APIError("启用前需要填写模型名,可点击“获取可用模型”选择")
|
||||
key_changed = bool(api_key)
|
||||
defaults = {
|
||||
"enabled": enabled,
|
||||
"provider": provider,
|
||||
"base_url": base_url,
|
||||
"model": model,
|
||||
}
|
||||
if api_key:
|
||||
defaults["api_key"] = api_key
|
||||
if not cfg:
|
||||
AIConfig.objects.create(organization=org, **defaults)
|
||||
else:
|
||||
for field, value in defaults.items():
|
||||
setattr(cfg, field, value)
|
||||
cfg.save()
|
||||
audit(
|
||||
request,
|
||||
"ai_config.update",
|
||||
details={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"enabled": enabled,
|
||||
"key_changed": key_changed,
|
||||
},
|
||||
)
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
|
||||
@endpoint(roles=("admin",))
|
||||
def ai_models(request):
|
||||
require_full_admin(request)
|
||||
base_url, api_key = org_models_endpoint(request.member.organization)
|
||||
if not base_url:
|
||||
raise APIError("请先保存 Base URL 再获取模型列表", 400)
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
try:
|
||||
with httpx.Client(timeout=10) as client:
|
||||
response = client.get(f"{base_url}/models", headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
items = data.get("data", [])
|
||||
ids = [m.get("id") for m in items if isinstance(m, dict) and isinstance(m.get("id"), str)]
|
||||
if not ids and isinstance(data.get("models"), list):
|
||||
ids = [
|
||||
m.get("name")
|
||||
for m in data["models"]
|
||||
if isinstance(m, dict) and isinstance(m.get("name"), str)
|
||||
]
|
||||
return JsonResponse({"items": ids[:200]})
|
||||
except (httpx.HTTPError, ValueError, AttributeError) as exc:
|
||||
raise APIError("无法从该服务获取模型列表,请检查 Base URL 与 API Key", 502) from exc
|
||||
+17
-13
@@ -1,14 +1,17 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
|
||||
from .ai import resolve
|
||||
from .analysis import METRICS, analyze, context, dimensions
|
||||
from .views import APIError, audit, body, dataset, endpoint, limit, preferences
|
||||
|
||||
|
||||
def model_intent(question, ds, ctx):
|
||||
def model_intent(question, ds, ctx, cfg=None):
|
||||
cfg = cfg or resolve(ds.organization)
|
||||
if not cfg:
|
||||
raise APIError("尚未配置外部 AI 服务", 503)
|
||||
keys = [d["key"] for d in dimensions(ds)]
|
||||
schema = {
|
||||
"type": "object",
|
||||
@@ -31,7 +34,7 @@ def model_intent(question, ds, ctx):
|
||||
}
|
||||
# Only schema, selected context and the user's question leave the server; never raw rows.
|
||||
payload = {
|
||||
"model": settings.OPENAI_MODEL,
|
||||
"model": cfg["model"],
|
||||
"store": False,
|
||||
"max_output_tokens": 1200,
|
||||
"input": [
|
||||
@@ -55,8 +58,8 @@ def model_intent(question, ds, ctx):
|
||||
try:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
response = client.post(
|
||||
"https://api.openai.com/v1/responses",
|
||||
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
|
||||
f"{cfg['base_url']}/responses",
|
||||
headers={"Authorization": f"Bearer {cfg['api_key']}"} if cfg["api_key"] else {},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -107,12 +110,15 @@ def assistant(request, dataset_id):
|
||||
use_model = data.get("use_model", False)
|
||||
if type(use_model) is not bool:
|
||||
raise APIError("AI 模式必须为布尔值")
|
||||
provider = "local"
|
||||
if use_model:
|
||||
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
|
||||
raise APIError("尚未配置外部 AI 服务", 503)
|
||||
cfg = resolve(request.member.organization)
|
||||
if not cfg:
|
||||
raise APIError("尚未配置外部 AI 服务;请管理员在「AI 模型」中配置", 503)
|
||||
if data.get("consent") is not True:
|
||||
raise APIError("请先同意发送问题与当前筛选上下文", 400)
|
||||
intent = model_intent(question, ds, ctx)
|
||||
provider = cfg["provider"]
|
||||
intent = model_intent(question, ds, ctx, cfg)
|
||||
else:
|
||||
action = (
|
||||
"evidence"
|
||||
@@ -140,21 +146,19 @@ def assistant(request, dataset_id):
|
||||
else:
|
||||
change = value["change"]
|
||||
change_text = f"{change:+.1%}" if change is not None else "不可计算(基期为零或数据缺失)"
|
||||
answer = f"当前范围{METRICS[metric]}为 {value['current']:,.4f},较基期变化 {change_text}。按当前规则识别出 {result['anomaly_count']} 个异常分组。"
|
||||
answer = f"当前范围{METRICS[metric]}为 {value['current']:,.2f},较基期变化 {change_text}。按当前规则识别出 {result['anomaly_count']} 个异常分组。"
|
||||
candidates = [r for r in result["rows"] if r["anomaly"]][:3]
|
||||
if candidates:
|
||||
answer += " 优先核查:" + "、".join(r["label"] for r in candidates) + "。"
|
||||
answer += " 这是数据贡献分解,不是因果结论。"
|
||||
audit(
|
||||
request, "assistant.query", ds.pk, {"provider": "openai" if use_model else "local", "intent": intent}
|
||||
)
|
||||
audit(request, "assistant.query", ds.pk, {"provider": provider, "intent": intent})
|
||||
return JsonResponse(
|
||||
{
|
||||
"answer": answer,
|
||||
"intent": intent,
|
||||
"context": result["context"],
|
||||
"version": ds.sha256,
|
||||
"provider": "openai" if use_model else "local",
|
||||
"provider": provider,
|
||||
"evidence": [
|
||||
{"label": r["label"], "filters": r["filters"], "values": r["values"][metric]}
|
||||
for r in result["rows"][:5]
|
||||
|
||||
+91
-13
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import warnings
|
||||
@@ -11,22 +12,62 @@ from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell import WriteOnlyCell
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
from openpyxl.styles import Font, PatternFill, Protection
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from openpyxl.worksheet.table import Table, TableColumn, TableStyleInfo
|
||||
|
||||
from .analysis import METRICS, aggregations, context, dimensions, metrics, scoped_facts
|
||||
from .formula_columns import (
|
||||
RESERVED_DATA_HEADERS,
|
||||
ensure_available_metrics,
|
||||
evaluate,
|
||||
metrics_used,
|
||||
to_excel,
|
||||
validate_column,
|
||||
)
|
||||
from .models import FormulaExport, Membership
|
||||
|
||||
# Keep the private test seam while sharing the single evaluator implementation.
|
||||
_evaluate_expression = evaluate
|
||||
|
||||
FORMULA_VERSION = "3"
|
||||
MAX_ROWS = 100_000
|
||||
MAX_GROUPS = 200
|
||||
RECONCILE_TOLERANCES = {
|
||||
"customers": Decimal("0"),
|
||||
"margin": Decimal("0.0001"),
|
||||
"discount_rate": Decimal("0.0001"),
|
||||
"upgrade_rate": Decimal("0.0001"),
|
||||
}
|
||||
DEFAULT_RECONCILE_TOLERANCE = Decimal("0.01")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_unlink_export(path, root):
|
||||
"""Delete only a file below the owned export root without following links."""
|
||||
path, root = Path(path), Path(root).absolute()
|
||||
if not path.is_absolute() or root.is_symlink() or path.is_symlink():
|
||||
logger.warning("formula export cleanup refused unsafe path: %s", path)
|
||||
return False
|
||||
parent = path.parent
|
||||
try:
|
||||
if not parent.is_relative_to(root):
|
||||
logger.warning("formula export cleanup refused out-of-root path: %s", path)
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
current = parent
|
||||
while current != root:
|
||||
if current.is_symlink() or current.parent == current:
|
||||
logger.warning("formula export cleanup refused linked parent: %s", path)
|
||||
return False
|
||||
current = current.parent
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError:
|
||||
logger.warning("formula export cleanup failed for %s", path, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _excel_number(value):
|
||||
@@ -269,6 +310,19 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
),
|
||||
)
|
||||
data.append(headers)
|
||||
editable_indices = {len(dims) + 2, len(dims) + 3, len(dims) + 4, len(dims) + 5, len(dims) + 7}
|
||||
if count:
|
||||
numeric_input = DataValidation(
|
||||
type="decimal", operator="between", formula1="-1e15", formula2="1e15", allow_blank=True
|
||||
)
|
||||
numeric_input.showErrorMessage = True
|
||||
numeric_input.errorStyle = "stop"
|
||||
numeric_input.errorTitle = "请输入数值"
|
||||
numeric_input.error = "计算输入只接受范围内的数值或空白;请勿粘贴公式或文本。"
|
||||
for index in editable_indices:
|
||||
column_name = _column_name(index + 1)
|
||||
numeric_input.add(f"{column_name}2:{column_name}{count + 1}")
|
||||
data.data_validations.append(numeric_input)
|
||||
token_secret = secrets.token_bytes(32)
|
||||
customer_roster = {}
|
||||
gross_index = next((col["index"] for col in mapping["columns"] if col["role"] == "gross"), None)
|
||||
@@ -329,6 +383,12 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
f'{_column_name(len(dims) + 6)}{row_number},""))'
|
||||
),
|
||||
)
|
||||
for index in editable_indices:
|
||||
cell = row[index]
|
||||
if not hasattr(cell, "protection"):
|
||||
cell = WriteOnlyCell(data, value=cell)
|
||||
cell.protection = Protection(locked=False)
|
||||
row[index] = cell
|
||||
data.append(row)
|
||||
customer_headers = [
|
||||
"Group",
|
||||
@@ -408,7 +468,13 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
],
|
||||
["ROI 口径", mapping["roi_formula"], "按导入映射保存的公式分支", "否"],
|
||||
["自营产品集合", _text_cell(parameters, ", ".join(products)), "升单率和自营收入口径", "否"],
|
||||
["产品毛利率", str(rates), "直接毛利缺失时的可编辑计算输入", "是"],
|
||||
["产品毛利率快照", str(rates), "导入时保存的只读口径;修改需重新确认导入映射", "否"],
|
||||
[
|
||||
"对账容差",
|
||||
"人数 0;金额 0.01;比率 0.0001;自定义 0.01",
|
||||
"服务器 Decimal 参考与 Excel 结果的绝对差;正式财务验收前需确认",
|
||||
"否",
|
||||
],
|
||||
[
|
||||
"Excel 数值精度",
|
||||
"IEEE-754 双精度",
|
||||
@@ -443,6 +509,10 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
["变化率", "差额/ABS(基期);基期为0或期间缺失时不可用。"],
|
||||
["精度", "原始 Decimal 以文本留存;Excel 双精度存在细微误差可能。"],
|
||||
["隐私", "客户标识替换为本文件随机 token,文件仍按敏感财务信息保护。"],
|
||||
[
|
||||
"编辑保护",
|
||||
"工作表结构、人数去重、原始证据和公式列已保护;计算明细仅收入、成本、毛利输入、毛利率和原价金额可编辑。行结构变化请重新导出。",
|
||||
],
|
||||
]:
|
||||
instructions.append(row)
|
||||
for definition in custom_columns:
|
||||
@@ -559,6 +629,7 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
for key, name in METRICS.items():
|
||||
formula_row = metric_rows[(label, key)]
|
||||
value = reference[key]
|
||||
tolerance = RECONCILE_TOLERANCES.get(key, DEFAULT_RECONCILE_TOLERANCE)
|
||||
reconcile.append(
|
||||
[
|
||||
name,
|
||||
@@ -567,24 +638,30 @@ def build_formula_workbook(ds, member, export_context, output):
|
||||
"" if value is None else str(value),
|
||||
f"='分析汇总'!{col}{formula_row}",
|
||||
f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")',
|
||||
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<0.01,"通过","待复核"),"待复核"))',
|
||||
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<={tolerance},"通过","待复核"),"待复核"))',
|
||||
]
|
||||
)
|
||||
reconcile_row += 1
|
||||
for index, definition in enumerate(custom_columns):
|
||||
formula_row = metric_rows[(label, f"custom_{index}")]
|
||||
custom_reference = evaluate(definition["expression"], reference)
|
||||
reconcile.append(
|
||||
[
|
||||
_text_cell(reconcile, definition["name"]),
|
||||
_text_cell(reconcile, label),
|
||||
side_name,
|
||||
"自定义公式无服务器参考",
|
||||
"" if custom_reference is None else str(custom_reference),
|
||||
f"='分析汇总'!{col}{formula_row}",
|
||||
"",
|
||||
"需业务验收",
|
||||
f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")',
|
||||
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<={DEFAULT_RECONCILE_TOLERANCE},"通过","待复核"),"待复核"))',
|
||||
]
|
||||
)
|
||||
reconcile_row += 1
|
||||
for sheet in wb.worksheets:
|
||||
sheet.protection.sheet = True
|
||||
sheet.protection.selectLockedCells = False
|
||||
sheet.protection.selectUnlockedCells = True
|
||||
wb.security.lockStructure = True
|
||||
wb.calculation.fullCalcOnLoad = True
|
||||
wb.calculation.forceFullCalc = True
|
||||
wb.calculation.calcMode = "auto"
|
||||
@@ -596,8 +673,8 @@ def process_formula_export(export_id):
|
||||
job = FormulaExport.objects.select_related("dataset", "user").get(pk=export_id)
|
||||
temporary = None
|
||||
target = None
|
||||
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||
try:
|
||||
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||
if root.is_symlink():
|
||||
raise ValueError("公式导出目录不能是符号链接")
|
||||
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
@@ -609,6 +686,8 @@ def process_formula_export(export_id):
|
||||
target.parent.chmod(0o700)
|
||||
temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp")
|
||||
member = Membership.objects.get(user=job.user, organization=job.dataset.organization)
|
||||
if not job.user.is_active or member.role not in ("admin", "analyst"):
|
||||
raise PermissionError("账号已撤权,请重新提交导出")
|
||||
if set(member.departments) != set(job.departments):
|
||||
raise PermissionError("科室授权已变更,请重新提交导出")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
@@ -621,13 +700,12 @@ def process_formula_export(export_id):
|
||||
job.save(update_fields=["path", "status", "error", "finished_at", "expires_at"])
|
||||
return stats
|
||||
except Exception as exc:
|
||||
remaining = ""
|
||||
for path in (temporary, target):
|
||||
try:
|
||||
if path is not None and not path.is_symlink():
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
job.status, job.error, job.path = "failed", str(exc)[:500], ""
|
||||
if path is not None and not _safe_unlink_export(path, root):
|
||||
if path.exists() or path.is_symlink():
|
||||
remaining = str(path)
|
||||
job.status, job.error, job.path = "failed", str(exc)[:500], remaining
|
||||
job.finished_at, job.expires_at = timezone.now(), None
|
||||
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
|
||||
raise
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import math
|
||||
from decimal import Decimal, localcontext
|
||||
|
||||
from .analysis import METRICS
|
||||
|
||||
@@ -107,3 +108,27 @@ def to_excel(node, references):
|
||||
if "number" in node:
|
||||
return str(node["number"])
|
||||
return f"({to_excel(node['left'], references)}{OPERATORS[node['operator']]}{to_excel(node['right'], references)})"
|
||||
|
||||
|
||||
def evaluate(node, references):
|
||||
"""Evaluate a validated expression with Decimal values and #N/A semantics."""
|
||||
with localcontext() as decimal_context:
|
||||
decimal_context.prec = 64
|
||||
return _evaluate(node, references)
|
||||
|
||||
|
||||
def _evaluate(node, references):
|
||||
if "metric" in node:
|
||||
return references.get(node["metric"])
|
||||
if "number" in node:
|
||||
return Decimal(str(node["number"]))
|
||||
left, right = _evaluate(node["left"], references), _evaluate(node["right"], references)
|
||||
if left is None or right is None:
|
||||
return None
|
||||
if node["operator"] == "add":
|
||||
return left + right
|
||||
if node["operator"] == "subtract":
|
||||
return left - right
|
||||
if node["operator"] == "multiply":
|
||||
return left * right
|
||||
return None if right == 0 else left / right
|
||||
|
||||
+79
-17
@@ -3,12 +3,14 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.http import FileResponse, JsonResponse
|
||||
from django.utils import timezone
|
||||
|
||||
from .analysis import METRICS, context, dimensions
|
||||
from .formula_columns import available_metrics, ensure_available_metrics, validate_column
|
||||
from .models import FormulaDefinition, FormulaExport
|
||||
from .models import Dataset, FormulaDefinition, FormulaExport
|
||||
from .views import APIError, audit, body, dataset, endpoint, limit
|
||||
|
||||
|
||||
@@ -190,13 +192,23 @@ def formula_definitions(request, dataset_id):
|
||||
if request.member.departments:
|
||||
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
|
||||
if request.method == "GET":
|
||||
items = FormulaDefinition.objects.filter(dataset=ds, user=request.user).order_by("normalized_name")
|
||||
items = (
|
||||
FormulaDefinition.objects.filter(dataset=ds)
|
||||
.filter(Q(user=request.user) | Q(shared=True))
|
||||
.select_related("user")
|
||||
.order_by("normalized_name")
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": str(item.pk),
|
||||
"definition": item.definition,
|
||||
"revision": item.revision,
|
||||
"shared": item.shared,
|
||||
"owned": item.user_id == request.user.pk,
|
||||
"owner": item.user.username,
|
||||
"history": item.history,
|
||||
"updated_at": item.updated_at.isoformat(),
|
||||
}
|
||||
for item in items
|
||||
@@ -204,28 +216,74 @@ def formula_definitions(request, dataset_id):
|
||||
}
|
||||
)
|
||||
|
||||
definition = validate_column(body(request).get("definition"))
|
||||
data = body(request)
|
||||
definition = validate_column(data.get("definition"))
|
||||
shared = data.get("shared", False)
|
||||
if type(shared) is not bool:
|
||||
raise APIError("共享选项无效")
|
||||
try:
|
||||
ensure_available_metrics(ds, definition)
|
||||
except ValueError as exc:
|
||||
raise APIError(str(exc)) from exc
|
||||
normalized_name = definition["name"].strip().casefold()
|
||||
existing = FormulaDefinition.objects.filter(
|
||||
dataset=ds, user=request.user, normalized_name=normalized_name
|
||||
).first()
|
||||
if not existing and FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
|
||||
raise APIError("每个数据集最多保存 50 个公式定义")
|
||||
item, created = FormulaDefinition.objects.update_or_create(
|
||||
dataset=ds,
|
||||
user=request.user,
|
||||
normalized_name=normalized_name,
|
||||
defaults={"definition": definition},
|
||||
)
|
||||
audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]})
|
||||
requested_revision = data.get("revision")
|
||||
with transaction.atomic():
|
||||
Dataset.objects.select_for_update().get(pk=ds.pk)
|
||||
existing = (
|
||||
FormulaDefinition.objects.select_for_update()
|
||||
.filter(dataset=ds, user=request.user, normalized_name=normalized_name)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
if requested_revision is not None and (
|
||||
type(requested_revision) is not int or requested_revision != existing.revision
|
||||
):
|
||||
raise APIError("公式定义已在其他窗口更新,请重新载入", 409)
|
||||
# ponytail: retain 20 revisions; use a separate history table if long-term retention is required.
|
||||
existing.history = [
|
||||
*existing.history,
|
||||
{
|
||||
"revision": existing.revision,
|
||||
"definition": existing.definition,
|
||||
"updated_at": existing.updated_at.isoformat(),
|
||||
},
|
||||
][-20:]
|
||||
existing.definition = definition
|
||||
existing.shared = shared if "shared" in data else existing.shared
|
||||
existing.revision += 1
|
||||
existing.save(update_fields=["definition", "revision", "shared", "history", "updated_at"])
|
||||
item, created = existing, False
|
||||
else:
|
||||
if FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
|
||||
raise APIError("每个数据集最多保存 50 个公式定义")
|
||||
item = FormulaDefinition.objects.create(
|
||||
dataset=ds,
|
||||
user=request.user,
|
||||
normalized_name=normalized_name,
|
||||
definition=definition,
|
||||
shared=shared,
|
||||
)
|
||||
created = True
|
||||
audit(
|
||||
request,
|
||||
"formula_definition.save",
|
||||
item.pk,
|
||||
{
|
||||
"dataset": str(ds.pk),
|
||||
"name": definition["name"],
|
||||
"shared": item.shared,
|
||||
"revision": item.revision,
|
||||
},
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"id": str(item.pk),
|
||||
"definition": item.definition,
|
||||
"revision": item.revision,
|
||||
"shared": item.shared,
|
||||
"owned": True,
|
||||
"owner": request.user.username,
|
||||
"history": item.history,
|
||||
"updated_at": item.updated_at.isoformat(),
|
||||
},
|
||||
status=201 if created else 200,
|
||||
@@ -241,8 +299,10 @@ def formula_definition_delete(request, dataset_id, definition_id):
|
||||
if not item:
|
||||
raise APIError("公式定义不存在", 404)
|
||||
name = item.definition.get("name", "")
|
||||
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
|
||||
item.delete()
|
||||
with transaction.atomic():
|
||||
item = FormulaDefinition.objects.select_for_update().get(pk=item.pk)
|
||||
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
|
||||
item.delete()
|
||||
return JsonResponse({"deleted": True})
|
||||
|
||||
|
||||
@@ -279,6 +339,8 @@ def formula_export_create(request, dataset_id):
|
||||
|
||||
|
||||
def _owned_job(request, dataset_id, export_id):
|
||||
if not request.user.is_active or request.member.role not in ("admin", "analyst"):
|
||||
raise APIError("没有此导出任务的访问权限", 403)
|
||||
job = FormulaExport.objects.filter(
|
||||
pk=export_id,
|
||||
dataset_id=dataset_id,
|
||||
|
||||
@@ -5,8 +5,10 @@ from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from finance.exporting import _safe_unlink_export
|
||||
from finance.models import FormulaExport
|
||||
|
||||
|
||||
@@ -29,22 +31,30 @@ class Command(BaseCommand):
|
||||
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||
if export_root.is_symlink():
|
||||
raise ValueError("公式导出目录不能是符号链接")
|
||||
root = export_root.resolve()
|
||||
root = export_root.absolute()
|
||||
now = timezone.now()
|
||||
jobs = files = 0
|
||||
|
||||
for job in FormulaExport.objects.filter(status="succeeded", expires_at__lte=now).iterator():
|
||||
jobs_qs = FormulaExport.objects.filter(
|
||||
Q(status="succeeded", expires_at__lte=now) | Q(status="failed", path__gt="")
|
||||
).iterator()
|
||||
for job in jobs_qs:
|
||||
path = Path(job.path) if job.path else None
|
||||
resolved = path.resolve() if path and path.is_absolute() and not path.is_symlink() else None
|
||||
in_root = bool(resolved and resolved.is_relative_to(root))
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if in_root and resolved.is_file():
|
||||
resolved.unlink()
|
||||
deleted = bool(path and _safe_unlink_export(path, root))
|
||||
if job.status == "succeeded":
|
||||
if deleted or not path or not path.is_absolute() or not path.is_relative_to(root):
|
||||
if deleted:
|
||||
files += 1
|
||||
job.status, job.path = "expired", ""
|
||||
job.save(update_fields=["status", "path"])
|
||||
jobs += 1
|
||||
elif deleted:
|
||||
files += 1
|
||||
job.status, job.path = "expired", ""
|
||||
job.save(update_fields=["status", "path"])
|
||||
jobs += 1
|
||||
job.path = ""
|
||||
job.save(update_fields=["path"])
|
||||
jobs += 1
|
||||
except OSError as exc:
|
||||
self.stderr.write(f"Could not remove expired export {job.pk}: {exc}")
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write("Another worker owns the database lock")
|
||||
return
|
||||
try:
|
||||
self.run_jobs(options, postgres)
|
||||
self.run_jobs(options, True)
|
||||
finally:
|
||||
if postgres:
|
||||
with connection.cursor() as cursor:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-26 13:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("finance", "0004_formuladefinition"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AIConfig",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("enabled", models.BooleanField(default=False)),
|
||||
(
|
||||
"provider",
|
||||
models.CharField(
|
||||
choices=[("openai", "OpenAI"), ("local", "本地 OpenAI 兼容服务")],
|
||||
default="local",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("base_url", models.CharField(blank=True, max_length=500)),
|
||||
("api_key", models.CharField(blank=True, max_length=500)),
|
||||
("model", models.CharField(blank=True, max_length=200)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"organization",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="finance.organization"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("finance", "0005_aiconfig"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="formuladefinition",
|
||||
name="revision",
|
||||
field=models.PositiveIntegerField(default=1),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("finance", "0006_formuladefinition_revision"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="dataset",
|
||||
name="file",
|
||||
field=models.FileField(max_length=500, upload_to="sources/%Y/%m"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-07 23:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("finance", "0007_alter_dataset_file"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="formuladefinition",
|
||||
name="history",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="formuladefinition",
|
||||
name="shared",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
+16
-1
@@ -22,7 +22,7 @@ class Dataset(models.Model):
|
||||
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
|
||||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||
name = models.CharField(max_length=200)
|
||||
file = models.FileField(upload_to="sources/%Y/%m")
|
||||
file = models.FileField(upload_to="sources/%Y/%m", max_length=500)
|
||||
sha256 = models.CharField(max_length=64)
|
||||
status = models.CharField(max_length=16, default="mapping")
|
||||
profile = models.JSONField(default=dict)
|
||||
@@ -92,6 +92,18 @@ class Audit(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class AIConfig(models.Model):
|
||||
organization = models.OneToOneField(Organization, on_delete=models.CASCADE)
|
||||
enabled = models.BooleanField(default=False)
|
||||
provider = models.CharField(
|
||||
max_length=16, choices=[("openai", "OpenAI"), ("local", "本地 OpenAI 兼容服务")], default="local"
|
||||
)
|
||||
base_url = models.CharField(max_length=500, blank=True)
|
||||
api_key = models.CharField(max_length=500, blank=True)
|
||||
model = models.CharField(max_length=200, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class Throttle(models.Model):
|
||||
key = models.CharField(max_length=64, primary_key=True)
|
||||
count = models.PositiveIntegerField(default=0)
|
||||
@@ -121,6 +133,9 @@ class FormulaDefinition(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
normalized_name = models.CharField(max_length=320)
|
||||
definition = models.JSONField()
|
||||
revision = models.PositiveIntegerField(default=1)
|
||||
shared = models.BooleanField(default=False)
|
||||
history = models.JSONField(default=list)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
+26
-5
@@ -24,7 +24,7 @@ from openpyxl.cell import WriteOnlyCell
|
||||
|
||||
from .analysis import METRICS, analyze, context, default_preferences, dimensions, scoped_facts
|
||||
from .importing import file_digest, profile_file, suggest_mapping, validate_mapping
|
||||
from .models import Audit, Dataset, Investigation, Membership, Preference, Throttle
|
||||
from .models import AIConfig, Audit, Dataset, Investigation, Membership, Preference, Throttle
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
@@ -130,10 +130,20 @@ def serialize_dataset(ds, detailed=False, restricted=False, raw_access=True):
|
||||
|
||||
@endpoint(public=True)
|
||||
def session(request):
|
||||
ai_enabled = settings.AI_ENABLED and bool(settings.OPENAI_API_KEY and settings.OPENAI_MODEL)
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
cfg = AIConfig.objects.filter(organization=request.user.membership.organization).first()
|
||||
if cfg:
|
||||
ai_enabled = (
|
||||
cfg.enabled and bool(cfg.model) and (cfg.provider == "local" or bool(cfg.api_key))
|
||||
)
|
||||
except Membership.DoesNotExist:
|
||||
pass
|
||||
data = {
|
||||
"csrf": get_token(request),
|
||||
"user": None,
|
||||
"ai_enabled": settings.AI_ENABLED and bool(settings.OPENAI_API_KEY and settings.OPENAI_MODEL),
|
||||
"ai_enabled": ai_enabled,
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
@@ -287,6 +297,14 @@ def query(request, dataset_id):
|
||||
return JsonResponse(analyze(ds, request.member, body(request), pref))
|
||||
|
||||
|
||||
def amount_text(value):
|
||||
"""金额展示文本:去掉定点数存储尺度产生的多余尾零(如 10.000000000000000000000000 → 10)。"""
|
||||
if value is None:
|
||||
return None
|
||||
text = format(value.normalize(), "f")
|
||||
return "0" if text == "-0" else text
|
||||
|
||||
|
||||
@endpoint(("POST",))
|
||||
def evidence(request, dataset_id):
|
||||
ds = dataset(request, dataset_id, True)
|
||||
@@ -304,6 +322,9 @@ def evidence(request, dataset_id):
|
||||
"source_row", "period", "dimensions", "revenue", "cost", "gross", "raw"
|
||||
)
|
||||
)
|
||||
for row in rows:
|
||||
for field in ("revenue", "cost", "gross"):
|
||||
row[field] = amount_text(row[field])
|
||||
# Raw patient/customer identifiers require an explicit administrator role.
|
||||
if request.member.role != "admin":
|
||||
for row in rows:
|
||||
@@ -402,9 +423,9 @@ def export(request, dataset_id):
|
||||
row.source_row,
|
||||
row.period,
|
||||
*[row.dimensions.get(d["key"], "") for d in dims],
|
||||
row.revenue,
|
||||
row.cost,
|
||||
row.gross,
|
||||
amount_text(row.revenue),
|
||||
amount_text(row.cost),
|
||||
amount_text(row.gross),
|
||||
]
|
||||
)
|
||||
if workbook:
|
||||
|
||||
+108
-80
@@ -13,6 +13,7 @@ import {
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -33,6 +34,7 @@ import { api, download, setCsrf } from "./api";
|
||||
import { Chart } from "./Chart";
|
||||
import {
|
||||
AdminDialog,
|
||||
AIConfigDialog,
|
||||
FormulaExportDialog,
|
||||
Copilot,
|
||||
EvidenceDialog,
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
import {
|
||||
format,
|
||||
labels,
|
||||
roleLabels,
|
||||
type Analysis,
|
||||
type Context,
|
||||
type Dataset,
|
||||
@@ -83,6 +86,7 @@ export function App() {
|
||||
| "dictionary"
|
||||
| "users"
|
||||
| "audit"
|
||||
| "ai"
|
||||
| null
|
||||
| "formula"
|
||||
>(null),
|
||||
@@ -305,6 +309,23 @@ export function App() {
|
||||
setPath([]);
|
||||
setModal(null);
|
||||
}
|
||||
// 全部分组都为空的指标列(如数据集没有成本/ROI)不展示,避免占位挤压可用列。
|
||||
const metricKeys = useMemo(() => {
|
||||
const candidates = [
|
||||
...new Set<Metric>([
|
||||
"revenue",
|
||||
"gross",
|
||||
"cost",
|
||||
"roi",
|
||||
"customers",
|
||||
ctx?.metric || "revenue",
|
||||
]),
|
||||
];
|
||||
const available = candidates.filter(
|
||||
(m) => m === "revenue" || (result?.rows || []).some((r) => r.values[m]?.current != null),
|
||||
);
|
||||
return available.length ? available : (["revenue"] as Metric[]);
|
||||
}, [result, ctx]);
|
||||
const columns = useMemo<ColDef<Row>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -324,23 +345,14 @@ export function App() {
|
||||
</button>
|
||||
),
|
||||
},
|
||||
...[
|
||||
...new Set<Metric>([
|
||||
"revenue",
|
||||
"gross",
|
||||
"cost",
|
||||
"roi",
|
||||
"customers",
|
||||
ctx?.metric || "revenue",
|
||||
]),
|
||||
].map((metric) => ({
|
||||
...metricKeys.map((metric) => ({
|
||||
colId: metric,
|
||||
headerName: labels[metric],
|
||||
minWidth: 130,
|
||||
valueGetter: (p: { data?: Row }) =>
|
||||
p.data?.values[metric].current == null
|
||||
p.data?.values[metric]?.current == null
|
||||
? null
|
||||
: Number(p.data.values[metric].current),
|
||||
: Number(p.data.values[metric]?.current),
|
||||
valueFormatter: (p: { value: unknown }) =>
|
||||
format(p.value as number, metric),
|
||||
cellClass: metric === ctx?.metric ? "metric-column" : "",
|
||||
@@ -393,24 +405,34 @@ export function App() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[ds, ctx, path],
|
||||
[ds, ctx, path, metricKeys],
|
||||
);
|
||||
const rows = (result?.rows || []).filter(
|
||||
(r) => (screen !== "anomalies" || r.anomaly) && r.label.includes(search),
|
||||
);
|
||||
const metric = ctx?.metric || "revenue";
|
||||
const compactAxis = (value: number) => {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1e8) return `${value / 1e8}亿`;
|
||||
if (abs >= 1e4) return `${value / 1e4}万`;
|
||||
return String(value);
|
||||
};
|
||||
const trend = useMemo(
|
||||
() => ({
|
||||
color: ["#197568", "#5794c9"],
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { bottom: 0 },
|
||||
grid: { top: 25, left: 65, right: 25, bottom: 65 },
|
||||
grid: { top: 25, left: 85, right: 25, bottom: 65 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: result?.trend.map((r) => r.period),
|
||||
axisLabel: { color: "#73827b" },
|
||||
axisLabel: { color: "#73827b", hideOverlap: true },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
splitLine: { lineStyle: { color: "#ecf0ee" } },
|
||||
axisLabel: { hideOverlap: true },
|
||||
},
|
||||
yAxis: { type: "value", splitLine: { lineStyle: { color: "#ecf0ee" } } },
|
||||
series: [
|
||||
{
|
||||
name: "实际收入",
|
||||
@@ -440,7 +462,11 @@ export function App() {
|
||||
() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { top: 15, left: 95, right: 30, bottom: 30 },
|
||||
xAxis: { type: "value", splitLine: { lineStyle: { color: "#ecf0ee" } } },
|
||||
xAxis: {
|
||||
type: "value",
|
||||
splitLine: { lineStyle: { color: "#ecf0ee" } },
|
||||
axisLabel: { hideOverlap: true, formatter: compactAxis },
|
||||
},
|
||||
yAxis: {
|
||||
type: "category",
|
||||
inverse: true,
|
||||
@@ -543,6 +569,10 @@ export function App() {
|
||||
<ShieldCheck />
|
||||
操作审计
|
||||
</button>
|
||||
<button onClick={() => setModal("ai")}>
|
||||
<Cpu />
|
||||
AI 模型
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
@@ -871,6 +901,8 @@ export function App() {
|
||||
missing_gross: "毛利缺失",
|
||||
missing_customer: "客户标识缺失",
|
||||
blank_rows: "空白行",
|
||||
g_l_mismatch: "应收金额两列不一致",
|
||||
i_n_mismatch: "实收与实际收入不一致",
|
||||
} as Record<string, string>
|
||||
)[k] || k}
|
||||
<strong>{v.toLocaleString()}</strong>
|
||||
@@ -1023,16 +1055,11 @@ export function App() {
|
||||
)?.name
|
||||
}
|
||||
</th>
|
||||
{(
|
||||
[
|
||||
"revenue",
|
||||
"gross",
|
||||
"cost",
|
||||
"roi",
|
||||
] as Metric[]
|
||||
).map((m) => (
|
||||
<th key={m}>{labels[m]}</th>
|
||||
))}
|
||||
{metricKeys
|
||||
.filter((m) => m !== "customers")
|
||||
.map((m) => (
|
||||
<th key={m}>{labels[m]}</th>
|
||||
))}
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1040,15 +1067,10 @@ export function App() {
|
||||
{result.rows.slice(0, 12).map((r) => (
|
||||
<tr key={r.label}>
|
||||
<th>{r.label}</th>
|
||||
{(
|
||||
[
|
||||
"revenue",
|
||||
"gross",
|
||||
"cost",
|
||||
"roi",
|
||||
] as Metric[]
|
||||
).map((m) => {
|
||||
const v = r.values[m].change;
|
||||
{metricKeys
|
||||
.filter((m) => m !== "customers")
|
||||
.map((m) => {
|
||||
const v = r.values[m]?.change;
|
||||
return (
|
||||
<td key={m}>
|
||||
<button
|
||||
@@ -1062,7 +1084,7 @@ export function App() {
|
||||
: "heat-positive"
|
||||
}
|
||||
onClick={() => {
|
||||
setCtx({ ...ctx, metric: m });
|
||||
setCtx({ ...ctx, metric: m as Metric });
|
||||
setSelected(r);
|
||||
setScreen("anomalies");
|
||||
}}
|
||||
@@ -1220,48 +1242,53 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-layout">
|
||||
<div className="grid">
|
||||
<AgGridReact<Row>
|
||||
theme={gridTheme}
|
||||
rowData={rows}
|
||||
columnDefs={columns}
|
||||
defaultColDef={{
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
}}
|
||||
pagination
|
||||
paginationPageSize={20}
|
||||
paginationPageSizeSelector={[20, 50, 100]}
|
||||
getRowId={(p) => p.data.label}
|
||||
onRowClicked={(e) => setSelected(e.data)}
|
||||
localeText={{
|
||||
noRowsToShow: "没有符合条件的分组",
|
||||
page: "页",
|
||||
of: "/",
|
||||
to: "至",
|
||||
more: "更多",
|
||||
nextPage: "下一页",
|
||||
previousPage: "上一页",
|
||||
firstPage: "首页",
|
||||
lastPage: "末页",
|
||||
pageSizeSelectorLabel: "每页",
|
||||
}}
|
||||
{...(enterprise
|
||||
? {
|
||||
cellSelection: true,
|
||||
sideBar: "columns",
|
||||
statusBar: {
|
||||
statusPanels: [
|
||||
{
|
||||
statusPanel: "agTotalRowCountComponent",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
{rows.length ? (
|
||||
<div className="grid">
|
||||
<AgGridReact<Row>
|
||||
theme={gridTheme}
|
||||
rowData={rows}
|
||||
columnDefs={columns}
|
||||
defaultColDef={{
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
}}
|
||||
pagination
|
||||
paginationPageSize={20}
|
||||
paginationPageSizeSelector={[20, 50, 100]}
|
||||
getRowId={(p) => p.data.label}
|
||||
onRowClicked={(e) => setSelected(e.data)}
|
||||
localeText={{
|
||||
page: "页",
|
||||
of: "/",
|
||||
to: "至",
|
||||
more: "更多",
|
||||
nextPage: "下一页",
|
||||
previousPage: "上一页",
|
||||
firstPage: "首页",
|
||||
lastPage: "末页",
|
||||
pageSizeSelectorLabel: "每页",
|
||||
}}
|
||||
{...(enterprise
|
||||
? {
|
||||
cellSelection: true,
|
||||
sideBar: "columns",
|
||||
statusBar: {
|
||||
statusPanels: [
|
||||
{
|
||||
statusPanel: "agTotalRowCountComponent",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p role="status" className="grid-empty">
|
||||
没有符合条件的分组
|
||||
</p>
|
||||
)}
|
||||
{selected && (
|
||||
<aside className="explanation">
|
||||
<div className="section-heading">
|
||||
@@ -1465,7 +1492,7 @@ export function App() {
|
||||
<tr key={c.index}>
|
||||
<td>{c.name}</td>
|
||||
<td>{c.description}</td>
|
||||
<td>{c.role}</td>
|
||||
<td>{roleLabels[c.role] || c.role}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1476,6 +1503,7 @@ export function App() {
|
||||
{(modal === "users" || modal === "audit") && (
|
||||
<AdminDialog mode={modal} onClose={() => setModal(null)} />
|
||||
)}
|
||||
{modal === "ai" && <AIConfigDialog onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+243
-35
@@ -3,7 +3,12 @@ import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-rea
|
||||
import { api } from "./api";
|
||||
import {
|
||||
labels,
|
||||
roleLabels,
|
||||
type AIConfig,
|
||||
type Context,
|
||||
type FormulaColumn,
|
||||
type FormulaExpression,
|
||||
type SavedFormulaDefinition,
|
||||
type Dataset,
|
||||
type Metric,
|
||||
type Preferences,
|
||||
@@ -12,21 +17,6 @@ import {
|
||||
|
||||
type User = NonNullable<Session["user"]>;
|
||||
|
||||
type FormulaExpression =
|
||||
| { metric: Metric }
|
||||
| { number: number }
|
||||
| {
|
||||
operator: "add" | "subtract" | "multiply" | "divide";
|
||||
left: FormulaExpression;
|
||||
right: FormulaExpression;
|
||||
};
|
||||
|
||||
type SavedFormulaDefinition = {
|
||||
id: string;
|
||||
definition: Record<string, unknown>;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function describeExpression(expression: FormulaExpression): string {
|
||||
if ("metric" in expression) return labels[expression.metric];
|
||||
if ("number" in expression) return String(expression.number);
|
||||
@@ -164,7 +154,9 @@ export function ImportDialog({
|
||||
"dimension",
|
||||
"ignore",
|
||||
].map((r) => (
|
||||
<option key={r}>{r}</option>
|
||||
<option key={r} value={r}>
|
||||
{roleLabels[r] || r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
@@ -366,6 +358,7 @@ export function EvidenceDialog({
|
||||
};
|
||||
}, [ds.id, ctx, side, page]);
|
||||
function selectSide(value: "current" | "baseline") {
|
||||
if (value === side && page === 0) return;
|
||||
setResult(undefined);
|
||||
setPage(0);
|
||||
setSide(value);
|
||||
@@ -405,7 +398,7 @@ export function EvidenceDialog({
|
||||
</p>
|
||||
)}
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<table className="evidence-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>来源行</th>
|
||||
@@ -471,6 +464,153 @@ export function EvidenceDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function AIConfigDialog({ onClose }: { onClose: () => void }) {
|
||||
const [value, setValue] = useState<AIConfig>({
|
||||
enabled: false,
|
||||
provider: "local",
|
||||
base_url: "",
|
||||
model: "",
|
||||
api_key_set: false,
|
||||
updated_at: null,
|
||||
}),
|
||||
[key, setKey] = useState(""),
|
||||
[models, setModels] = useState<string[]>([]),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false),
|
||||
[listing, setListing] = useState(false);
|
||||
useEffect(() => {
|
||||
api<AIConfig>("/ai-config")
|
||||
.then((r) => setValue(r))
|
||||
.catch((e) => setError((e as Error).message));
|
||||
}, []);
|
||||
async function fetchModels() {
|
||||
setListing(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await api<{ items: string[] }>("/ai-models");
|
||||
setModels(r.items);
|
||||
if (!r.items.length) setError("该服务没有返回可用模型");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setListing(false);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api("/ai-config", "PUT", {
|
||||
enabled: value.enabled,
|
||||
provider: value.provider,
|
||||
base_url: value.base_url,
|
||||
model: value.model,
|
||||
api_key: key,
|
||||
});
|
||||
setKey("");
|
||||
setValue((v) => ({ ...v, api_key_set: v.api_key_set || Boolean(key) }));
|
||||
if (value.base_url) await fetchModels();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal title="AI 模型配置" onClose={onClose} wide>
|
||||
<div className="dialog-body">
|
||||
<p className="muted">
|
||||
配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。
|
||||
</p>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.enabled}
|
||||
onChange={(e) => setValue({ ...value, enabled: e.target.checked })}
|
||||
/>
|
||||
启用 AI 模型理解问题
|
||||
</label>
|
||||
<label>
|
||||
服务商
|
||||
<select
|
||||
value={value.provider}
|
||||
onChange={(e) =>
|
||||
setValue({
|
||||
...value,
|
||||
provider: e.target.value as AIConfig["provider"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="local">
|
||||
本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Base URL
|
||||
<input
|
||||
value={value.base_url}
|
||||
placeholder={
|
||||
value.provider === "local"
|
||||
? "http://127.0.0.1:11434/v1"
|
||||
: "https://api.openai.com/v1"
|
||||
}
|
||||
onChange={(e) => setValue({ ...value, base_url: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
API Key
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
placeholder={
|
||||
value.api_key_set ? "已配置,留空保持不变" : "本地服务通常可留空"
|
||||
}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
模型
|
||||
<input
|
||||
list="ai-model-list"
|
||||
value={value.model}
|
||||
placeholder="模型名,如 qwen2.5:7b"
|
||||
onChange={(e) => setValue({ ...value, model: e.target.value })}
|
||||
/>
|
||||
<datalist id="ai-model-list">
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<footer>
|
||||
<button onClick={onClose}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={listing || busy}
|
||||
onClick={fetchModels}
|
||||
>
|
||||
{listing ? "获取中…" : "获取可用模型"}
|
||||
</button>
|
||||
<button className="primary" disabled={busy} onClick={save}>
|
||||
{busy ? "保存中…" : "保存配置"}
|
||||
</button>
|
||||
</footer>
|
||||
{error && (
|
||||
<p role="alert" className="error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<p className="muted">
|
||||
只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端,
|
||||
配置修改会记入操作审计。
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Copilot({
|
||||
ds,
|
||||
ctx,
|
||||
@@ -550,7 +690,7 @@ export function Copilot({
|
||||
checked={useModel}
|
||||
onChange={(e) => setUseModel(e.target.checked)}
|
||||
/>
|
||||
使用外部 AI 理解问题
|
||||
使用已配置的 AI 模型理解问题
|
||||
</label>
|
||||
{useModel && (
|
||||
<label className="check">
|
||||
@@ -675,7 +815,7 @@ export function FormulaExportDialog({
|
||||
{ role: "user" | "assistant"; content: string }[]
|
||||
>([]),
|
||||
[draft, setDraft] = useState(""),
|
||||
[column, setColumn] = useState<Record<string, unknown> | null>(null),
|
||||
[column, setColumn] = useState<FormulaColumn | null>(null),
|
||||
[questions, setQuestions] = useState<string[]>([]),
|
||||
[task, setTask] = useState<{
|
||||
id: string;
|
||||
@@ -684,9 +824,30 @@ export function FormulaExportDialog({
|
||||
}>(),
|
||||
[definitions, setDefinitions] = useState<SavedFormulaDefinition[]>([]),
|
||||
[selectedDefinition, setSelectedDefinition] = useState(""),
|
||||
[shared, setShared] = useState(false),
|
||||
[loadedDefinition, setLoadedDefinition] = useState<FormulaColumn | null>(null),
|
||||
[consent, setConsent] = useState(false),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const draftKey = `traceiq.formula-draft.${ds.id}`;
|
||||
const [draftRestored, setDraftRestored] = useState(false);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(draftKey);
|
||||
if (stored) {
|
||||
const saved = JSON.parse(stored);
|
||||
setDraft(saved.draft || "");
|
||||
setMessages(saved.messages || []);
|
||||
setColumn(saved.column || null);
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(draftKey);
|
||||
}
|
||||
setDraftRestored(true);
|
||||
}, [draftKey]);
|
||||
useEffect(() => {
|
||||
if (draftRestored) sessionStorage.setItem(draftKey, JSON.stringify({ draft, messages, column }));
|
||||
}, [draftKey, draftRestored, draft, messages, column]);
|
||||
useEffect(() => {
|
||||
api<{ items: SavedFormulaDefinition[] }>(
|
||||
`/datasets/${ds.id}/formula-definitions`,
|
||||
@@ -713,7 +874,10 @@ export function FormulaExportDialog({
|
||||
async function send() {
|
||||
const text = draft.trim();
|
||||
if (!text || !consent || userTurns >= 6) return;
|
||||
const next = [...messages, { role: "user" as const, content: text }];
|
||||
const content = loadedDefinition
|
||||
? `已载入的原始定义:${JSON.stringify(loadedDefinition)}\n本次补充:${text}`
|
||||
: text;
|
||||
const next = [...messages, { role: "user" as const, content }];
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setQuestions([]);
|
||||
@@ -722,7 +886,7 @@ export function FormulaExportDialog({
|
||||
const result = await api<{
|
||||
complete: boolean;
|
||||
questions: string[];
|
||||
column: Record<string, unknown> | null;
|
||||
column: FormulaColumn | null;
|
||||
}>(`/datasets/${ds.id}/formula-design`, "POST", {
|
||||
context: ctx,
|
||||
messages: next,
|
||||
@@ -739,6 +903,7 @@ export function FormulaExportDialog({
|
||||
]);
|
||||
setQuestions(result.questions);
|
||||
setColumn(result.column);
|
||||
setLoadedDefinition(null);
|
||||
setDraft("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -747,7 +912,7 @@ export function FormulaExportDialog({
|
||||
}
|
||||
}
|
||||
async function create() {
|
||||
if (!column) return;
|
||||
if (!column && !ctx.custom_columns?.length) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -755,7 +920,12 @@ export function FormulaExportDialog({
|
||||
await api<{ id: string; status: string }>(
|
||||
`/datasets/${ds.id}/formula-exports`,
|
||||
"POST",
|
||||
{ ...ctx, custom_columns: [column] },
|
||||
{
|
||||
...ctx,
|
||||
custom_columns: column
|
||||
? [...(ctx.custom_columns || []).filter((item) => item.name !== column.name), column]
|
||||
: ctx.custom_columns,
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -772,11 +942,24 @@ export function FormulaExportDialog({
|
||||
const saved = await api<SavedFormulaDefinition>(
|
||||
`/datasets/${ds.id}/formula-definitions`,
|
||||
"POST",
|
||||
{ definition: column },
|
||||
{
|
||||
definition: column,
|
||||
shared,
|
||||
revision: definitions.find(
|
||||
(item) =>
|
||||
(item.id === selectedDefinition && item.owned) ||
|
||||
(item.owned &&
|
||||
String(item.definition.name).trim().toLowerCase() ===
|
||||
String(column.name).trim().toLowerCase()),
|
||||
)?.revision,
|
||||
},
|
||||
);
|
||||
setDefinitions((items) =>
|
||||
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
|
||||
String(a.definition.name).localeCompare(String(b.definition.name), "zh-CN"),
|
||||
String(a.definition.name).localeCompare(
|
||||
String(b.definition.name),
|
||||
"zh-CN",
|
||||
),
|
||||
),
|
||||
);
|
||||
setSelectedDefinition(saved.id);
|
||||
@@ -789,8 +972,14 @@ export function FormulaExportDialog({
|
||||
function loadDefinition(id: string) {
|
||||
setSelectedDefinition(id);
|
||||
const saved = definitions.find((item) => item.id === id);
|
||||
if (!saved) return;
|
||||
if (!saved) {
|
||||
setLoadedDefinition(null);
|
||||
setColumn(null);
|
||||
return;
|
||||
}
|
||||
setColumn(saved.definition);
|
||||
setShared(saved.owned && saved.shared);
|
||||
setLoadedDefinition(saved.definition);
|
||||
setMessages([]);
|
||||
setQuestions([]);
|
||||
setDraft("");
|
||||
@@ -808,6 +997,7 @@ export function FormulaExportDialog({
|
||||
items.filter((item) => item.id !== selectedDefinition),
|
||||
);
|
||||
setSelectedDefinition("");
|
||||
setLoadedDefinition(null);
|
||||
setColumn(null);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -817,15 +1007,10 @@ export function FormulaExportDialog({
|
||||
}
|
||||
async function getFile() {
|
||||
try {
|
||||
const blob = await api<Blob>(
|
||||
`/datasets/${ds.id}/formula-exports/${task!.id}/download`,
|
||||
),
|
||||
url = URL.createObjectURL(blob),
|
||||
link = document.createElement("a");
|
||||
link.href = url;
|
||||
const link = document.createElement("a");
|
||||
link.href = `/api/datasets/${ds.id}/formula-exports/${task!.id}/download`;
|
||||
link.download = "traceiq-formula-analysis.xlsx";
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
@@ -834,6 +1019,11 @@ export function FormulaExportDialog({
|
||||
<Modal title="生成公式工作簿" onClose={onClose} wide>
|
||||
<div className="dialog-body">
|
||||
<p>描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。</p>
|
||||
{!column && !task && !!ctx.custom_columns?.length && (
|
||||
<button disabled={busy} onClick={create}>
|
||||
按网页已选 {ctx.custom_columns.length} 列生成工作簿
|
||||
</button>
|
||||
)}
|
||||
{definitions.length > 0 && !task && (
|
||||
<div className="form-row">
|
||||
<label>
|
||||
@@ -846,7 +1036,7 @@ export function FormulaExportDialog({
|
||||
<option value="">选择定义</option>
|
||||
{definitions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{String(item.definition.name)}
|
||||
{item.definition.name}{item.owned ? "" : `(${item.owner}共享)`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -855,13 +1045,27 @@ export function FormulaExportDialog({
|
||||
className="icon"
|
||||
aria-label="删除所选定义"
|
||||
title="删除所选定义"
|
||||
disabled={busy || !selectedDefinition}
|
||||
disabled={busy || !definitions.find((item) => item.id === selectedDefinition)?.owned}
|
||||
onClick={deleteDefinition}
|
||||
>
|
||||
<Trash2 />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{definitions.find((item) => item.id === selectedDefinition)?.history.length ? (
|
||||
<details>
|
||||
<summary>版本历史(最近 20 次,可载入后另存)</summary>
|
||||
{definitions.find((item) => item.id === selectedDefinition)!.history.map((version) => (
|
||||
<button key={version.revision} disabled={busy} onClick={() => {
|
||||
setColumn(version.definition);
|
||||
setLoadedDefinition(version.definition);
|
||||
setMessages([]);
|
||||
}}>
|
||||
载入版本 {version.revision} · {version.definition.meaning}
|
||||
</button>
|
||||
))}
|
||||
</details>
|
||||
) : null}
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -904,6 +1108,10 @@ export function FormulaExportDialog({
|
||||
</p>
|
||||
<p>适用范围:{String(column.scope)}</p>
|
||||
<p>缺失/零分母:{String(column.missing)}</p>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={shared} onChange={(event) => setShared(event.target.checked)} />
|
||||
保存时共享给当前数据集的其他全范围分析账号
|
||||
</label>
|
||||
<button
|
||||
disabled={busy || Boolean(draft.trim())}
|
||||
onClick={saveDefinition}
|
||||
|
||||
+22
-7
@@ -484,7 +484,7 @@ a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 20px 20px 16px;
|
||||
padding: 18px 16px 14px;
|
||||
gap: 16px;
|
||||
background: white;
|
||||
min-width: 0;
|
||||
@@ -510,12 +510,13 @@ a {
|
||||
}
|
||||
.kpi strong {
|
||||
text-align: left;
|
||||
font-size: 24px;
|
||||
font-size: 21px;
|
||||
line-height: 1.25;
|
||||
font-weight: 620;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.kpi-bottom {
|
||||
display: flex;
|
||||
@@ -1271,9 +1272,6 @@ details {
|
||||
.kpi {
|
||||
padding: 17px 13px;
|
||||
}
|
||||
.kpi strong {
|
||||
font-size: 21px;
|
||||
}
|
||||
.chart-band {
|
||||
gap: 15px;
|
||||
}
|
||||
@@ -1562,3 +1560,20 @@ details {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-empty {
|
||||
margin: 0;
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--muted, #5b6675);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.evidence-table {
|
||||
min-width: 960px;
|
||||
}
|
||||
.evidence-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
+50
-2
@@ -32,6 +32,20 @@ export interface Column {
|
||||
key: string;
|
||||
confidence: number;
|
||||
}
|
||||
export const roleLabels: Record<string, string> = {
|
||||
period: "期间",
|
||||
year: "年份",
|
||||
month: "月份",
|
||||
dimension: "维度",
|
||||
customer: "客户标识",
|
||||
quantity: "数量",
|
||||
revenue: "实际收入",
|
||||
cost: "成本",
|
||||
gross: "毛利贡献",
|
||||
list_amount: "应收金额",
|
||||
discount: "折扣金额",
|
||||
ignore: "未使用",
|
||||
};
|
||||
export interface Mapping {
|
||||
sheet: string;
|
||||
header: number;
|
||||
@@ -84,6 +98,32 @@ export interface Context {
|
||||
filters: Record<string, string>;
|
||||
group: string;
|
||||
metric: Metric;
|
||||
custom_columns?: FormulaColumn[];
|
||||
}
|
||||
export type FormulaExpression =
|
||||
| { metric: Metric }
|
||||
| { number: number }
|
||||
| {
|
||||
operator: "add" | "subtract" | "multiply" | "divide";
|
||||
left: FormulaExpression;
|
||||
right: FormulaExpression;
|
||||
};
|
||||
export interface FormulaColumn {
|
||||
name: string;
|
||||
meaning: string;
|
||||
scope: string;
|
||||
missing: string;
|
||||
expression: FormulaExpression;
|
||||
}
|
||||
export interface SavedFormulaDefinition {
|
||||
id: string;
|
||||
definition: FormulaColumn;
|
||||
revision: number;
|
||||
updated_at: string;
|
||||
shared: boolean;
|
||||
owned: boolean;
|
||||
owner: string;
|
||||
history: { revision: number; definition: FormulaColumn; updated_at: string }[];
|
||||
}
|
||||
export interface Value {
|
||||
current: Numeric;
|
||||
@@ -107,15 +147,23 @@ export interface Analysis {
|
||||
current: string[];
|
||||
baseline: string[];
|
||||
};
|
||||
summary: Record<Metric, Value>;
|
||||
summary: Record<string, Value>;
|
||||
rows: Row[];
|
||||
trend: ({ period: string } & Record<Metric, Numeric>)[];
|
||||
trend: ({ period: string } & Record<string, Numeric>)[];
|
||||
row_count: number;
|
||||
anomaly_count: number;
|
||||
comparable: boolean;
|
||||
notice: string;
|
||||
version: string;
|
||||
}
|
||||
export interface AIConfig {
|
||||
enabled: boolean;
|
||||
provider: "openai" | "local";
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key_set: boolean;
|
||||
updated_at: string | null;
|
||||
}
|
||||
export interface Session {
|
||||
csrf: string;
|
||||
user: {
|
||||
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键启动本地开发环境:迁移 → 初始化管理员 → 构建前端 → 启动 API+worker → 打开页面。
|
||||
# 默认使用 SQLite 轻量开发库(var/db.sqlite3);设置 DATABASE_URL 则使用 PostgreSQL。
|
||||
# Ctrl-C 或 --stop 停止本脚本启动的进程。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
PORT="${PORT:-8000}"
|
||||
SERVER_PID_FILE="var/dev-server.pid"
|
||||
WORKER_PID_FILE="var/dev-worker.pid"
|
||||
SERVER_LOG="var/dev-server.log"
|
||||
WORKER_LOG="var/dev-worker.log"
|
||||
|
||||
die() { echo "错误:$*" >&2; exit 1; }
|
||||
|
||||
detect_python() {
|
||||
if [ -x ".venv/bin/python" ]; then
|
||||
PY=".venv/bin/python"
|
||||
elif command -v uv >/dev/null 2>&1; then
|
||||
PY="uv run python"
|
||||
else
|
||||
die "未找到 .venv/bin/python 或 uv,请先安装依赖(uv sync)"
|
||||
fi
|
||||
}
|
||||
|
||||
read_pidfile() { [ -f "$1" ] && cat "$1" || true; }
|
||||
|
||||
stop_all() {
|
||||
local stopped=0 pid
|
||||
for file in "$SERVER_PID_FILE" "$WORKER_PID_FILE"; do
|
||||
pid="$(read_pidfile "$file")"
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
stopped=1
|
||||
fi
|
||||
rm -f "$file"
|
||||
done
|
||||
[ "$stopped" = 1 ] && echo "已停止本地开发服务" || echo "没有正在运行的开发服务"
|
||||
}
|
||||
|
||||
health_ok() { curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; }
|
||||
|
||||
open_page() {
|
||||
local url="http://127.0.0.1:$PORT/"
|
||||
if command -v open >/dev/null 2>&1; then open "$url"
|
||||
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--stop)
|
||||
stop_all
|
||||
exit 0
|
||||
;;
|
||||
"" | --no-open) ;;
|
||||
*) die "未知参数:$1(支持 --stop / --no-open)" ;;
|
||||
esac
|
||||
|
||||
detect_python
|
||||
mkdir -p var
|
||||
|
||||
if health_ok; then
|
||||
echo "已在 $PORT 端口运行,直接打开页面"
|
||||
[ "${1:-}" != "--no-open" ] && open_page
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
echo "未设置 DATABASE_URL,使用 SQLite 轻量开发库 var/db.sqlite3(仅限页面测试与开发回归)"
|
||||
fi
|
||||
|
||||
echo "执行数据库迁移…"
|
||||
$PY manage.py migrate --no-input >/dev/null
|
||||
|
||||
# init_local.py 以数据库为准幂等执行:缺 admin 时创建并写凭据文件,已存在则跳过
|
||||
echo "初始化本地管理员(已存在则跳过)…"
|
||||
PYTHONPATH="$ROOT" $PY scripts/init_local.py
|
||||
|
||||
if [ ! -d frontend/dist ]; then
|
||||
echo "构建前端(首次)…"
|
||||
npm run build >/dev/null
|
||||
fi
|
||||
|
||||
echo "启动 worker(日志 ${WORKER_LOG})…"
|
||||
nohup $PY manage.py import_worker >"$WORKER_LOG" 2>&1 &
|
||||
echo $! >"$WORKER_PID_FILE"
|
||||
|
||||
echo "启动 API 服务…"
|
||||
nohup $PY manage.py runserver "127.0.0.1:$PORT" --noreload >"$SERVER_LOG" 2>&1 &
|
||||
echo $! >"$SERVER_PID_FILE"
|
||||
|
||||
cleanup() {
|
||||
stop_all >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup INT TERM
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
if health_ok; then
|
||||
break
|
||||
fi
|
||||
if [ -n "$(read_pidfile "$SERVER_PID_FILE")" ] && ! kill -0 "$(read_pidfile "$SERVER_PID_FILE")" 2>/dev/null; then
|
||||
tail -20 "$SERVER_LOG" >&2
|
||||
die "API 服务启动失败(完整日志:${SERVER_LOG})"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
health_ok || { tail -20 "$SERVER_LOG" >&2; die "健康检查超时(日志:${SERVER_LOG})"; }
|
||||
|
||||
echo
|
||||
echo "本地开发环境已就绪:http://127.0.0.1:$PORT"
|
||||
[ -f var/local-access.txt ] && echo "登录凭据:var/local-access.txt(用户名 admin,权限 0600)"
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
echo "提示:当前为 SQLite 轻量模式;高精度金额聚合与生产验收请按 README 配置 PostgreSQL。"
|
||||
fi
|
||||
echo "日志:${SERVER_LOG} / ${WORKER_LOG} · Ctrl-C 或 scripts/dev_up.sh --stop 停止"
|
||||
echo
|
||||
|
||||
[ "${1:-}" != "--no-open" ] && open_page
|
||||
|
||||
wait "$(read_pidfile "$SERVER_PID_FILE")" || true
|
||||
cleanup
|
||||
@@ -93,6 +93,7 @@ MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_BYTES", 64 * 1024 * 1024))
|
||||
MAX_IMPORT_ROWS = int(os.environ.get("MAX_IMPORT_ROWS", 1000000))
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
||||
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "")
|
||||
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
AI_ENABLED = os.environ.get("AI_ENABLED", "0") == "1"
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from finance import formula_views, views
|
||||
from finance.ai import ai_config, ai_models
|
||||
from finance.assistant import assistant
|
||||
|
||||
urlpatterns = [
|
||||
@@ -11,6 +12,8 @@ urlpatterns = [
|
||||
path("api/datasets", views.datasets),
|
||||
path("api/users", views.users),
|
||||
path("api/audit", views.audit_log),
|
||||
path("api/ai-config", ai_config),
|
||||
path("api/ai-models", ai_models),
|
||||
path("api/datasets/<uuid:dataset_id>", views.dataset_detail),
|
||||
path("api/datasets/<uuid:dataset_id>/mapping", views.mapping),
|
||||
path("api/datasets/<uuid:dataset_id>/query", views.query),
|
||||
|
||||
@@ -119,15 +119,36 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
|
||||
);
|
||||
await dialog.getByLabel("关闭", { exact: true }).click();
|
||||
|
||||
await page.getByRole("button", { name: "选择网页自定义列" }).click();
|
||||
const columns = page.getByRole("dialog", { name: "选择网页自定义列" });
|
||||
await columns.getByRole("checkbox", { name: /收入成本差/ }).check();
|
||||
const customQuery = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/query") && response.request().method() === "POST",
|
||||
);
|
||||
await columns.getByRole("button", { name: "应用", exact: true }).click();
|
||||
const customResult = await (await customQuery).json();
|
||||
expect(customResult.summary.custom_0.current).toBe("1000");
|
||||
expect(customResult.rows[0].values.custom_0.current).toBe("1000");
|
||||
|
||||
await page.getByRole("button", { name: "生成公式工作簿" }).click();
|
||||
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });
|
||||
await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" });
|
||||
await expect(reopened).toContainText("计算表达式:(实际收入 − 成本)");
|
||||
await reopened.getByLabel("列计算描述").fill("把刚才公式乘以 100");
|
||||
await expect(
|
||||
reopened.getByRole("button", { name: "先发送补充内容" }),
|
||||
).toBeDisabled();
|
||||
await reopened
|
||||
.getByRole("button", { name: "确认并生成公式工作簿" })
|
||||
.click();
|
||||
.getByLabel("同意将描述、指标定义和当前筛选范围发送至已配置的外部 AI 服务")
|
||||
.check();
|
||||
await reopened.getByRole("button", { name: "发送描述" }).click();
|
||||
expect(sentMessages[0].content).toContain('"name":"收入成本差"');
|
||||
expect(sentMessages[0].content).toContain('"operator":"subtract"');
|
||||
expect(sentMessages[0].content).toContain("把刚才公式乘以 100");
|
||||
await reopened.getByRole("button", { name: "确认并生成公式工作簿" }).click();
|
||||
await expect(reopened.getByRole("status")).toContainText("生成完成", {
|
||||
timeout: 30000,
|
||||
});
|
||||
expect(designCalls).toBe(4);
|
||||
expect(designCalls).toBe(5);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,9 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
|
||||
});
|
||||
await uploaded;
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("heading", { name: "确认导入字段" })).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "确认导入字段" }),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "确认并导入" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "集团经营总览", exact: true }),
|
||||
@@ -85,7 +87,14 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
|
||||
await expect(evidenceDialog).toContainText("1 条");
|
||||
await expect(evidenceDialog).toContainText("2026-08");
|
||||
await expect(evidenceDialog).toContainText("CSV");
|
||||
await evidenceDialog.getByRole("button", { name: "基期", exact: true }).click();
|
||||
await evidenceDialog
|
||||
.getByRole("button", { name: "本期", exact: true })
|
||||
.click();
|
||||
await expect(evidenceDialog).toContainText("2026-08");
|
||||
await expect(evidenceDialog).not.toContainText("加载中");
|
||||
await evidenceDialog
|
||||
.getByRole("button", { name: "基期", exact: true })
|
||||
.click();
|
||||
await expect(evidenceDialog).toContainText("2025-08");
|
||||
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
|
||||
const evidenceOffsets: number[] = [];
|
||||
@@ -111,10 +120,17 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
|
||||
});
|
||||
await page.getByLabel("查看医生A证据", { exact: true }).click();
|
||||
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
|
||||
await evidenceDialog.getByRole("button", { name: "下一页", exact: true }).click();
|
||||
await evidenceDialog
|
||||
.getByRole("button", { name: "下一页", exact: true })
|
||||
.click();
|
||||
await expect(evidenceDialog).toContainText("第 51-51 条,共 51 条");
|
||||
await expect(evidenceDialog).toContainText("2026-09");
|
||||
expect(evidenceOffsets).toEqual([0, 50]);
|
||||
await evidenceDialog
|
||||
.getByRole("button", { name: "基期", exact: true })
|
||||
.click();
|
||||
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
|
||||
await expect(evidenceDialog).not.toContainText("2026-09");
|
||||
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "分析路径" })
|
||||
|
||||
+209
-4
@@ -1,17 +1,182 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import Client
|
||||
|
||||
from finance.assistant import model_intent
|
||||
from finance.models import Audit, FormulaDefinition, FormulaExport, Membership
|
||||
from finance.models import AIConfig, Audit, FormulaDefinition, FormulaExport, Membership
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def completed_response(intent=None):
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": json.dumps(
|
||||
intent
|
||||
or {
|
||||
"action": "overview",
|
||||
"metric": "revenue",
|
||||
"group": "department",
|
||||
"filters": [{"key": "department", "value": "儿科"}],
|
||||
}
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
return Response()
|
||||
|
||||
|
||||
def test_ai_config_requires_full_admin_and_masks_key(workspace):
|
||||
ds, member, client, _, _ = workspace
|
||||
url = "/api/ai-config"
|
||||
|
||||
viewer = User.objects.create_user("ai-viewer", password="Finance-TestPassword991!")
|
||||
Membership.objects.create(user=viewer, organization=ds.organization, role="viewer")
|
||||
viewer_client = Client()
|
||||
assert viewer_client.login(username="ai-viewer", password="Finance-TestPassword991!")
|
||||
assert viewer_client.get(url).status_code == 403
|
||||
|
||||
member.departments = ["儿科"]
|
||||
member.save(update_fields=["departments"])
|
||||
scoped = Client()
|
||||
assert scoped.login(username=member.user.username, password="Finance-TestPassword991!")
|
||||
assert scoped.get(url).status_code == 403
|
||||
member.departments = []
|
||||
member.save(update_fields=["departments"])
|
||||
|
||||
assert client.get(url).json() == {
|
||||
"enabled": False,
|
||||
"provider": "local",
|
||||
"base_url": "",
|
||||
"model": "",
|
||||
"api_key_set": False,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
invalid = [
|
||||
{"enabled": True, "provider": "local", "base_url": "", "model": "qwen2.5:7b"},
|
||||
{"enabled": True, "provider": "openai", "base_url": "", "model": "gpt-4o"},
|
||||
{"enabled": True, "provider": "local", "base_url": "ftp://x", "model": "m"},
|
||||
{"enabled": True, "provider": "local", "base_url": "http://x/v1", "model": ""},
|
||||
]
|
||||
for payload in invalid:
|
||||
assert client.put(url, data=payload, content_type="application/json").status_code == 400
|
||||
|
||||
saved = client.put(
|
||||
url,
|
||||
data={
|
||||
"enabled": True,
|
||||
"provider": "local",
|
||||
"base_url": "http://127.0.0.1:11434/v1",
|
||||
"api_key": "ollama-secret",
|
||||
"model": "qwen2.5:7b",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
shown = client.get(url).json()
|
||||
assert shown["api_key_set"] is True
|
||||
assert shown["enabled"] is True and shown["provider"] == "local"
|
||||
assert "ollama-secret" not in json.dumps(shown)
|
||||
assert client.get("/api/session").json()["ai_enabled"] is True
|
||||
assert Audit.objects.filter(action="ai_config.update").exists()
|
||||
|
||||
|
||||
def test_ai_models_lists_local_models(workspace):
|
||||
ds, _, client, _, _ = workspace
|
||||
client.put(
|
||||
"/api/ai-config",
|
||||
data={
|
||||
"enabled": False,
|
||||
"provider": "local",
|
||||
"base_url": "http://127.0.0.1:11434/v1",
|
||||
"model": "",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
with patch("finance.ai.httpx.Client") as http:
|
||||
http.return_value.__enter__.return_value.get.return_value = type(
|
||||
"Response",
|
||||
(),
|
||||
{
|
||||
"raise_for_status": lambda self: None,
|
||||
"json": lambda self: {"data": [{"id": "qwen2.5:7b"}, {"id": "llama3.1:8b"}]},
|
||||
},
|
||||
)()
|
||||
listed = client.get("/api/ai-models")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["items"] == ["qwen2.5:7b", "llama3.1:8b"]
|
||||
call = http.return_value.__enter__.return_value.get.call_args
|
||||
assert call.args[0] == "http://127.0.0.1:11434/v1/models"
|
||||
|
||||
with patch("finance.ai.httpx.Client") as http:
|
||||
http.return_value.__enter__.return_value.get.side_effect = httpx.ConnectError("boom")
|
||||
failed = client.get("/api/ai-models")
|
||||
assert failed.status_code == 502
|
||||
|
||||
|
||||
def test_assistant_uses_org_db_config(workspace):
|
||||
ds, _, client, *_ = workspace
|
||||
client.put(
|
||||
"/api/ai-config",
|
||||
data={
|
||||
"enabled": True,
|
||||
"provider": "local",
|
||||
"base_url": "http://127.0.0.1:11434/v1",
|
||||
"model": "qwen2.5:7b",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
with patch("finance.assistant.httpx.Client") as http:
|
||||
http.return_value.__enter__.return_value.post.return_value = completed_response()
|
||||
response = client.post(
|
||||
f"/api/datasets/{ds.pk}/assistant",
|
||||
data={
|
||||
"question": "分析儿科收入",
|
||||
"context": {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||
"use_model": True,
|
||||
"consent": True,
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["provider"] == "local"
|
||||
call = http.return_value.__enter__.return_value.post.call_args
|
||||
assert call.args[0] == "http://127.0.0.1:11434/v1/responses"
|
||||
assert call.kwargs["json"]["model"] == "qwen2.5:7b"
|
||||
assert "Authorization" not in call.kwargs["headers"]
|
||||
|
||||
disabled = AIConfig.objects.get(organization=ds.organization)
|
||||
disabled.enabled = False
|
||||
disabled.save(update_fields=["enabled"])
|
||||
denied = client.post(
|
||||
f"/api/datasets/{ds.pk}/assistant",
|
||||
data={"question": "分析儿科收入", "use_model": True, "consent": True},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert denied.status_code == 503
|
||||
|
||||
|
||||
def test_model_intent_accepts_only_valid_dimensions_and_sends_no_rows(workspace, settings):
|
||||
ds, _, _, _, _ = workspace
|
||||
settings.AI_ENABLED = True
|
||||
settings.OPENAI_MODEL = "test-model"
|
||||
settings.OPENAI_API_KEY = "test-key"
|
||||
|
||||
@@ -58,6 +223,7 @@ def test_model_intent_accepts_only_valid_dimensions_and_sends_no_rows(workspace,
|
||||
|
||||
def test_model_intent_rejects_unknown_filter_and_incomplete_provider_response(workspace, settings):
|
||||
ds, *_ = workspace
|
||||
settings.AI_ENABLED = True
|
||||
settings.OPENAI_MODEL = "test-model"
|
||||
settings.OPENAI_API_KEY = "test-key"
|
||||
|
||||
@@ -165,19 +331,34 @@ def test_formula_definition_library_is_private_validated_and_audited(workspace):
|
||||
"right": {"metric": "cost"},
|
||||
},
|
||||
}
|
||||
saved = client.post(url, data={"definition": definition}, content_type="application/json")
|
||||
saved = client.post(url, data={"definition": definition, "shared": True}, content_type="application/json")
|
||||
assert saved.status_code == 201
|
||||
assert saved.json()["shared"] is True
|
||||
definition_id = saved.json()["id"]
|
||||
assert client.get(url).json()["items"][0]["definition"] == definition
|
||||
|
||||
updated = client.post(
|
||||
url,
|
||||
data={"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"}},
|
||||
data={
|
||||
"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"},
|
||||
"shared": True,
|
||||
"revision": saved.json()["revision"],
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).count() == 1
|
||||
assert updated.json()["definition"]["meaning"] == "更新说明"
|
||||
assert updated.json()["revision"] == 2
|
||||
assert len(updated.json()["history"]) == 1
|
||||
|
||||
stale = client.post(
|
||||
url,
|
||||
data={"definition": {**definition, "meaning": "旧标签页覆盖"}, "revision": 1},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert FormulaDefinition.objects.get(pk=definition_id).definition["meaning"] == "更新说明"
|
||||
|
||||
other = Client()
|
||||
other_user = ds.created_by.__class__.objects.create_user(
|
||||
@@ -185,7 +366,8 @@ def test_formula_definition_library_is_private_validated_and_audited(workspace):
|
||||
)
|
||||
Membership.objects.create(user=other_user, organization=ds.organization, role="analyst")
|
||||
assert other.login(username=other_user.username, password="Formula-Library-TestPassword991!")
|
||||
assert other.get(url).json()["items"] == []
|
||||
shared = other.get(url).json()["items"]
|
||||
assert len(shared) == 1 and shared[0]["owned"] is False and shared[0]["shared"] is True
|
||||
assert other.delete(f"{url}/{definition_id}").status_code == 404
|
||||
|
||||
deleted = client.delete(f"{url}/{definition_id}")
|
||||
@@ -219,3 +401,26 @@ def test_formula_definition_library_rejects_unavailable_metric(workspace):
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "成本" in response.json()["error"]
|
||||
|
||||
|
||||
def test_formula_definition_audit_failure_rolls_back_definition(workspace, monkeypatch):
|
||||
ds, _, client, *_ = workspace
|
||||
definition = {
|
||||
"name": "审计回滚",
|
||||
"meaning": "实际收入",
|
||||
"scope": "所选分组与期间",
|
||||
"missing": "结果不可用(#N/A);零分母不可用",
|
||||
"expression": {"metric": "revenue"},
|
||||
}
|
||||
|
||||
def fail(*_args, **_kwargs):
|
||||
raise RuntimeError("audit unavailable")
|
||||
|
||||
monkeypatch.setattr("finance.formula_views.audit", fail)
|
||||
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||
client.post(
|
||||
f"/api/datasets/{ds.pk}/formula-definitions",
|
||||
data={"definition": definition},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert not FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).exists()
|
||||
|
||||
+31
-2
@@ -133,6 +133,35 @@ def test_evidence_pagination_returns_stable_pages_and_rejects_invalid_bounds(wor
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_evidence_amounts_trim_storage_scale(workspace):
|
||||
from decimal import Decimal
|
||||
|
||||
from finance.views import amount_text
|
||||
|
||||
assert amount_text(Decimal("10.000000000000000000000000")) == "10"
|
||||
assert amount_text(Decimal("10.500000000000000000000000")) == "10.5"
|
||||
assert amount_text(Decimal("0E-24")) == "0"
|
||||
assert amount_text(None) is None
|
||||
|
||||
ds, _, client, *_ = workspace
|
||||
response = client.post(
|
||||
f"/api/datasets/{ds.pk}/evidence",
|
||||
data={
|
||||
"start": "2026-08",
|
||||
"end": "2026-08",
|
||||
"comparison": "yoy",
|
||||
"group": "department",
|
||||
"side": "current",
|
||||
"limit": 1,
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
row = response.json()["rows"][0]
|
||||
assert row["revenue"] == "80"
|
||||
assert row["cost"] == "30"
|
||||
assert row["gross"] == "50"
|
||||
|
||||
|
||||
def test_settings_revision_and_invalid_metric_are_rejected(workspace):
|
||||
ds, _, client, *_ = workspace
|
||||
url = f"/api/datasets/{ds.pk}/settings"
|
||||
@@ -169,8 +198,8 @@ def test_csv_export_neutralizes_formula_injection_and_preserves_exact_values(wor
|
||||
assert response.streaming
|
||||
content = b"".join(response.streaming_content).decode("utf-8-sig")
|
||||
assert "'=1+1" in content
|
||||
assert ",-10.000000000000000000000000," in content
|
||||
assert "'-10.000000000000000000000000" not in content
|
||||
assert ",'-10,5,'-15" in content
|
||||
assert "00000000000000" not in content
|
||||
assert "traceiq-evidence.csv" in response["Content-Disposition"]
|
||||
|
||||
xlsx = client.post(
|
||||
|
||||
@@ -97,8 +97,30 @@ def test_formula_workbook_exposes_custom_metric_as_recalculable_column(formula_d
|
||||
custom = workbook["自定义指标"]
|
||||
assert custom["B1"].value == "收入成本差(本期)"
|
||||
assert custom["B2"].value == f"='分析汇总'!C{metric_row}"
|
||||
assert "含义:实际收入减成本" in workbook["使用说明"]["B12"].value
|
||||
assert "范围:按所选分组及本期、基期计算" in workbook["使用说明"]["B12"].value
|
||||
instruction_text = " ".join(str(cell.value) for row in workbook["使用说明"].iter_rows() for cell in row)
|
||||
assert "含义:实际收入减成本" in instruction_text
|
||||
assert "范围:按所选分组及本期、基期计算" in instruction_text
|
||||
|
||||
|
||||
def test_formula_workbook_protects_structure_and_unlocks_calculation_inputs(formula_data):
|
||||
ds, member = formula_data
|
||||
output = BytesIO()
|
||||
build_formula_workbook(
|
||||
ds,
|
||||
member,
|
||||
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||
output,
|
||||
)
|
||||
workbook = load_workbook(BytesIO(output.getvalue()), data_only=False)
|
||||
data = workbook["计算明细"]
|
||||
assert data.protection.sheet
|
||||
assert data["D2"].protection.locked is False
|
||||
assert data["F2"].protection.locked is False
|
||||
assert data["G2"].protection.locked is False
|
||||
assert data["I2"].protection.locked is False
|
||||
assert data["C2"].protection.locked is True
|
||||
assert data["M2"].protection.locked is True
|
||||
assert workbook["原始证据"].protection.sheet
|
||||
|
||||
|
||||
def test_custom_expression_rejects_formula_text_and_unknown_metrics():
|
||||
|
||||
@@ -198,6 +198,67 @@ def test_formula_export_worker_records_path_preparation_failure(workspace, setti
|
||||
assert job.status == "failed"
|
||||
|
||||
|
||||
def test_formula_export_failure_does_not_follow_a_symlinked_user_directory(workspace, settings):
|
||||
from finance.exporting import process_formula_export
|
||||
|
||||
ds, _, client, *_ = workspace
|
||||
response = client.post(
|
||||
f"/api/datasets/{ds.pk}/formula-exports",
|
||||
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||
content_type="application/json",
|
||||
)
|
||||
job = FormulaExport.objects.get(pk=response.json()["id"])
|
||||
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||
protected = Path(settings.MEDIA_ROOT) / "protected"
|
||||
protected.mkdir()
|
||||
target = protected / f"{job.pk}.xlsx"
|
||||
target.write_bytes(b"keep")
|
||||
root.mkdir()
|
||||
(root / str(job.user_id)).symlink_to(protected, target_is_directory=True)
|
||||
|
||||
with pytest.raises(ValueError, match="符号链接"):
|
||||
process_formula_export(job.pk)
|
||||
|
||||
job.refresh_from_db()
|
||||
assert job.status == "failed"
|
||||
assert target.read_bytes() == b"keep"
|
||||
|
||||
|
||||
def test_formula_export_failed_cleanup_is_observable_and_reclaimed(workspace, monkeypatch):
|
||||
from django.core.management import call_command
|
||||
|
||||
from finance.exporting import process_formula_export
|
||||
|
||||
ds, _, client, *_ = workspace
|
||||
response = client.post(
|
||||
f"/api/datasets/{ds.pk}/formula-exports",
|
||||
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||
content_type="application/json",
|
||||
)
|
||||
job = FormulaExport.objects.get(pk=response.json()["id"])
|
||||
target = Path(job.dataset.file.storage.location) / "formula-exports" / str(job.user_id) / f"{job.pk}.xlsx"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_bytes(b"orphan")
|
||||
monkeypatch.setattr(
|
||||
"finance.exporting.build_formula_workbook",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(ValueError("retry failed")),
|
||||
)
|
||||
real_unlink = Path.unlink
|
||||
monkeypatch.setattr(Path, "unlink", lambda *_a, **_k: (_ for _ in ()).throw(OSError("read-only")))
|
||||
|
||||
with pytest.raises(ValueError, match="retry failed"):
|
||||
process_formula_export(job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.status == "failed"
|
||||
assert job.path == str(target)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", real_unlink)
|
||||
call_command("cleanup_formula_exports")
|
||||
job.refresh_from_db()
|
||||
assert job.path == ""
|
||||
assert target.exists() is False
|
||||
|
||||
|
||||
def test_formula_workbook_rejects_unclassified_label_collision(workspace):
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -374,3 +435,20 @@ def test_formula_workbook_rejects_dimension_keys_that_collide_with_reserved_head
|
||||
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "revenue"},
|
||||
BytesIO(),
|
||||
)
|
||||
|
||||
|
||||
def test_custom_reconcile_reference_preserves_large_decimal_and_zero_division():
|
||||
from finance.exporting import _evaluate_expression
|
||||
|
||||
expression = {
|
||||
"operator": "divide",
|
||||
"left": {"metric": "revenue"},
|
||||
"right": {"number": 0},
|
||||
}
|
||||
assert (
|
||||
_evaluate_expression(expression, {"revenue": Decimal("1.2345678901234567890123456789012345")}) is None
|
||||
)
|
||||
assert _evaluate_expression(
|
||||
{"operator": "add", "left": {"metric": "revenue"}, "right": {"number": 1}},
|
||||
{"revenue": Decimal("1234567890123456789012345678901234")},
|
||||
) == Decimal("1234567890123456789012345678901235")
|
||||
|
||||
@@ -46,6 +46,23 @@ def test_worker_processes_queued_import_and_formula_export(workspace):
|
||||
assert job.status == "processing"
|
||||
|
||||
|
||||
def test_worker_reclaims_processing_formula_export_after_restart(workspace):
|
||||
ds, _, _, *_ = workspace
|
||||
job = FormulaExport.objects.create(
|
||||
dataset=ds,
|
||||
user=ds.created_by,
|
||||
context={"custom_columns": []},
|
||||
status="processing",
|
||||
)
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"finance.management.commands.import_worker.process_formula_export", return_value={"rows": 5}
|
||||
) as process:
|
||||
call_command("import_worker", once=True)
|
||||
process.assert_called_once_with(job.pk)
|
||||
|
||||
|
||||
def test_formula_export_status_download_and_access_controls(workspace, settings, tmp_path):
|
||||
ds, _, client, _, other_client = workspace
|
||||
url = f"/api/datasets/{ds.pk}"
|
||||
@@ -58,7 +75,7 @@ def test_formula_export_status_download_and_access_controls(workspace, settings,
|
||||
job.status = "succeeded"
|
||||
job.expires_at = timezone.now() + timedelta(hours=1)
|
||||
target = tmp_path / "formula-exports" / str(ds.created_by_id) / f"{job.pk}.xlsx"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(b"xlsx")
|
||||
job.path = str(target)
|
||||
job.save()
|
||||
@@ -85,6 +102,29 @@ def test_formula_export_status_download_and_access_controls(workspace, settings,
|
||||
assert client.get(status_url).status_code == 403
|
||||
|
||||
|
||||
def test_formula_export_rechecks_role_when_worker_runs_and_file_is_downloaded(workspace, settings, tmp_path):
|
||||
from finance.exporting import process_formula_export
|
||||
|
||||
ds, member, client, _, _ = workspace
|
||||
job = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={}, status="processing")
|
||||
member.role = "viewer"
|
||||
member.save(update_fields=["role"])
|
||||
with pytest.raises(PermissionError, match="撤权"):
|
||||
process_formula_export(job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.status == "failed"
|
||||
|
||||
job.status = "succeeded"
|
||||
job.expires_at = timezone.now() + timedelta(hours=1)
|
||||
target = tmp_path / "formula-exports" / str(ds.created_by_id) / f"{job.pk}.xlsx"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(b"xlsx")
|
||||
job.path = str(target)
|
||||
job.save(update_fields=["status", "expires_at", "path"])
|
||||
assert client.get(f"/api/datasets/{ds.pk}/formula-exports/{job.pk}").status_code == 403
|
||||
assert client.get(f"/api/datasets/{ds.pk}/formula-exports/{job.pk}/download").status_code == 403
|
||||
|
||||
|
||||
def test_formula_export_create_validates_definition_and_records_job(workspace):
|
||||
ds, _, client, *_ = workspace
|
||||
url = f"/api/datasets/{ds.pk}/formula-exports"
|
||||
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from finance.models import FormulaExport
|
||||
from finance.models import Dataset, FormulaExport
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -17,6 +17,10 @@ def test_worker_processes_queued_import_and_reports_export_failure(workspace):
|
||||
) as process_import:
|
||||
call_command("import_worker", once=True)
|
||||
process_import.assert_called_once_with(ds.pk)
|
||||
# PostgreSQL 上 worker 持咨询锁会立即重认领 processing 任务:模拟导入完成,
|
||||
# 否则第二次 --once 又被它抢占,轮不到下面的导出任务(SQLite 无咨询锁,
|
||||
# 不走立即恢复路径,两种后端行为一致)。
|
||||
Dataset.objects.filter(pk=ds.pk).update(status="ready")
|
||||
|
||||
export = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={})
|
||||
with patch(
|
||||
|
||||
Reference in New Issue
Block a user