Author SHA1 Message Date
Jayway Liu 509b406254 fix(formula): 导出文件安全、清理回收、撤权校验与定义库可靠性(F01/F02/F05-F08)
Verify / verify (push) Failing after 1s
Verify / verify (pull_request) Failing after 1s
AI Review / review (pull_request) Successful in 16s
- F01: _safe_unlink_export 不跟随符号链接、逐层校验父目录,导出失败路径与
  清理命令统一使用,只清理受控目录内本次创建的文件
- F02: 清理失败记录告警日志;清理命令回收 failed 任务残留文件
- F05: 定义保存事务内锁数据集行,配额原子检查;revision 冲突返回 409;
  保留最近 20 版历史;支持个人/团队共享;审计与变更同事务
- F06: 工作簿参数页毛利率改为只读快照;可编辑单元格解锁并加数值校验
- F07: 对账按指标分容差(人数 0、比率 0.0001、金额 0.01);自定义列使用
  服务端 Decimal 表达式求值参考
- F08: 导出任务执行/下载阶段重校验账号有效性与角色
- 对话草稿会话恢复;载入定义后追问携带原定义(F04);下载改浏览器直传
- 迁移 0006-0008;PostgreSQL 咨询锁语义修正(import_worker)
2026-10-11 09:43:27 +08:00
Jayway Liu 494490f6e6 fix(display): 证据金额去尾零与界面显示修复
Verify / verify (push) Failing after 1s
Verify / verify (pull_request) Failing after 0s
AI Review / review (pull_request) Successful in 12s
- 证据接口与 CSV/XLSX 导出去掉 NUMERIC(44,24) 存储尺度尾零(无损精度)
- KPI 大金额折行截断:字号收紧 + nowrap + 省略号兜底
- 贡献分解/走势图表轴标签重叠与 Y 轴裁剪:hideOverlap + 万/亿缩写
- 表格隐藏全不可用指标列,空态改为条件渲染(修复空表覆盖层残留)
- 数据质量与字段字典/导入确认的中文标签映射(不再暴露内部键名)
- 证据弹窗重复点击当前期间停在加载中(F03);证据表格最小宽度
- Playwright 覆盖 F03 重复点击与期间切换场景
2026-10-11 09:41:31 +08:00
Jayway Liu dd8912f7ae feat(ai): 页面可配置云端或本地 OpenAI 兼容模型
Verify / verify (pull_request) Failing after 0s
AI Review / review (pull_request) Successful in 9s
Verify / verify (push) Failing after 1s
- 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、
  /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计
- 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM),
  可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量
- 助手调用走生效配置,Copilot 文案同步更新
- 全权限管理员可在「管理 → AI 模型」配置;补接口级测试
2026-10-11 09:37:05 +08:00
Jayway Liu 05f2ee416e fix(dev): dev_up 以数据库为准幂等初始化管理员
Verify / verify (push) Successful in 5m1s
此前凭据文件存在但数据库重建时会跳过 init_local.py,导致本地无
admin 无法登录;改为每次启动都执行(脚本本身按数据库幂等)。
2026-09-26 20:45:22 +08:00
Jayway Liu 5e1437679b fix(ci): npm audit / pip-audit 网络抖动重试三次
Verify / verify (push) Successful in 15m25s
runner 到 registry.npmjs.org 的 TLS 连接偶发被断(audit endpoint returned an
error),上一轮测试全绿仍被 audit 步骤标红;改为最多重试 3 次、间隔 15 秒。
真实漏洞是确定性的,重试后依然失败,不会漏报
2026-09-26 16:30:39 +08:00
Jayway Liu fa047ed3ee fix(ci): upload-artifact 在 Gitea 上不支持,改为 continue-on-error
Verify / verify (push) Failing after 2m44s
Gitea 对 actions/upload-artifact@v4 报 GHESNotSupportedError(v4 artifact 协议
为 GitHub 专有),此前测试全绿仍被该步骤标红;上传失败不再影响 CI 结论,
浏览器产物在 Gitea 上暂不可下载,迁到 GitHub 托管时该步骤自动生效
2026-09-26 16:26:12 +08:00
Jayway Liu f9f9ad751d test(worker): 适配 PostgreSQL 咨询锁下的立即重认领语义
Verify / verify (push) Failing after 4m39s
PG 上 worker 持咨询锁会立即重认领 processing 任务,第二次 --once 被导入任务
抢占导致导出断言失败(该测试此前从未在 PG 上真正运行过)。第一次调用后把导入
任务置为 ready 模拟完成,两种后端行为一致
2026-09-26 16:19:02 +08:00
Jayway Liu e90e37b77e fix(ci): 工作流移入 .gitea/workflows,恢复 Gitea 调度
Verify / verify (push) Failing after 1m35s
新增 .gitea/workflows/ai-review.yml 后,本实例只再调度 .gitea/workflows 下的
工作流,.github/workflows/ci.yml 自 a3d91ad 起不再被检测(workflows API 仅剩
ai-review.yml,两次 push 均未产生 run)。将 ci.yml 移至 .gitea/workflows/ 并
同步 README 引用与说明
2026-09-26 16:12:57 +08:00
Jayway Liu e337d9c989 ci: 重新触发 verify(上次 push 事件疑似丢失) 2026-09-26 16:08:56 +08:00
Jayway Liu 35789379ab fix(ci): Gitea runner 下 PostgreSQL 服务容器主机名用服务名而非 localhost
act_runner 的 job 运行在容器内,与服务容器同处 gitea_gitea 网络,
localhost:5432 在 job 容器里无监听导致此前 8 次 verify 全部失败;
改用服务名 postgres 解析,并注释说明防止改回
2026-09-26 16:06:16 +08:00
Jayway Liu a3d91ad17f ci: 接入 vertai-tech/ai-review-engine 对抗式 AI PR 审查
- .gitea/workflows/ai-review.yml 原样拷贝引擎模板(含 pr_number 经 env 传参的
  注入防护),引擎钉 v1 移动标签,通过 REVIEW_ENGINE_TOKEN 只读 PAT 拉取
- .gitea/ai-review.md 按本仓库技术栈写审查侧重:金额精度(Decimal/NUMERIC)、
  缺失值语义与对账容差、文件路径符号链接安全、endpoint 角色与 worker 撤权核验、
  事务并发与 revision、Excel 公式注入与长度上限、前端异步状态、测试真实性
- 仓库级 Actions 配置(Gitea 设置,不在本提交内):REVIEW_ENGINE_TOKEN secret、
  REVIEW_BASE_URL/MODEL/PUBLIC_URL/PRICES、FIX_MODEL/FIX_MODEL2/FIX_PLAN_MODEL/
  FIX_PLAN_THINKING,以及新仓库推荐起点 REVIEW_FIX_SUGGEST_ONLY=1(只给改法)
2026-09-26 15:22:08 +08:00
Jayway Liu 0739bcc4fb feat(dev): scripts/dev_up.sh 一键启动本地后端与页面测试
Verify / verify (push) Failing after 48s
- 迁移 → 管理员初始化 → 前端构建 → API+worker 后台启动 → 健康检查 → 自动打开页面
- 支持 --stop 停止、--no-open 跳过打开浏览器;端口已在服务时直接复用
- 未设 DATABASE_URL 时使用 SQLite 轻量库并给出验收提示
- 修复 bash 3.2(macOS 自带)将紧贴 $VAR 的多字节字符并入变量名的问题:
  全角括号内的变量一律用 ${VAR} 显式界定
- README 本地运行章节补充快速启动说明
2026-09-26 07:32:27 +08:00
33 changed files with 1763 additions and 204 deletions
+1
View File
@@ -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=
+27
View File
@@ -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 的措辞与结构建议——它们记录的是验收状态,不是产品代码。
+202
View File
@@ -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: |
+10 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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
View File
@@ -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}")
+1 -1
View File
@@ -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:
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: {
+123
View File
@@ -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
+1
View File
@@ -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,
+3
View File
@@ -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),
+24 -3
View File
@@ -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);
});
+19 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+24 -2
View File
@@ -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():
+78
View File
@@ -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")
+41 -1
View File
@@ -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"
+5 -1
View File
@@ -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(