chore: import existing codebase as baseline
Verify / verify (push) Failing after 1m20s

This commit is contained in:
Jayway Liu
2026-09-26 01:12:10 +08:00
commit 0ac0c4eb56
77 changed files with 14155 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.venv
node_modules
.env
var
staticfiles
frontend/dist
test-results
playwright-report
.coverage
__pycache__
*.pyc
+15
View File
@@ -0,0 +1,15 @@
APP_ENV=production
SECRET_KEY=REPLACE_WITH_AT_LEAST_50_RANDOM_CHARACTERS
POSTGRES_PASSWORD=REPLACE_WITH_RANDOM_HEX_PASSWORD
DATABASE_URL=postgresql://traceiq:REPLACE_WITH_RANDOM_HEX_PASSWORD@db:5432/traceiq
DOMAIN=finance.example.com
ALLOWED_HOSTS=finance.example.com,localhost
CSRF_TRUSTED_ORIGINS=https://finance.example.com
TRUST_PROXY=1
DB_SSLMODE=disable
MAX_UPLOAD_BYTES=67108864
MAX_IMPORT_ROWS=1000000
AI_ENABLED=0
OPENAI_API_KEY=
OPENAI_MODEL=
VITE_AG_GRID_LICENSE_KEY=
+62
View File
@@ -0,0 +1,62 @@
name: Verify
on: [push, pull_request]
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: ci-only-password
POSTGRES_DB: traceiq
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 5s
--health-timeout 5s --health-retries 10
env:
DATABASE_URL: postgresql://postgres:ci-only-password@localhost:5432/traceiq
E2E_PASSWORD: CI-Test-Only-SecurePassword882!
PYTHONPATH: .
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
version: '0.10.12'
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: uv sync --frozen --python 3.12
- run: npm ci --registry=https://registry.npmjs.org
- run: uv run ruff check server finance tests scripts
- run: uv run ruff format --check server finance tests scripts
- 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
- 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
env:
BOOTSTRAP_PASSWORD: ${{ env.E2E_PASSWORD }}
- run: npx playwright install --with-deps chromium
- name: Browser regression
shell: bash
run: |
uv run python manage.py runserver 127.0.0.1:8000 --noreload > /tmp/web.log 2>&1 &
web=$!
uv run python manage.py import_worker > /tmp/worker.log 2>&1 &
worker=$!
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
- uses: actions/upload-artifact@v4
if: always()
with:
name: browser-results
path: |
playwright-report
test-results
+16
View File
@@ -0,0 +1,16 @@
.venv/
node_modules/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
.env
*.sqlite3
var/
staticfiles/
frontend/dist/
test-results/
playwright-report/
.DS_Store
+25
View File
@@ -0,0 +1,25 @@
FROM node:22-bookworm-slim AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --registry=https://registry.npmjs.org
COPY tsconfig.json vite.config.ts ./
COPY frontend ./frontend
ARG VITE_AG_GRID_LICENSE_KEY=""
ENV VITE_AG_GRID_LICENSE_KEY=$VITE_AG_GRID_LICENSE_KEY
RUN npm run build
FROM ghcr.io/astral-sh/uv:0.10.12 AS uv
FROM python:3.12-slim-bookworm
COPY --from=uv /uv /usr/local/bin/uv
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 UV_COMPILE_BYTECODE=1 PATH="/app/.venv/bin:$PATH"
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY manage.py ./
COPY server ./server
COPY finance ./finance
COPY --from=frontend /app/frontend/dist ./frontend/dist
RUN mkdir -p /app/var/uploads && python manage.py collectstatic --noinput && useradd --uid 10001 --create-home app && chown -R app:app /app/var
USER app
EXPOSE 8000
CMD ["gunicorn", "server.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--threads", "2", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-"]
+118
View File
@@ -0,0 +1,118 @@
# TraceIQ 财务分析工作台
响应式 Web 前端 + Django API + PostgreSQL。基于分享会话、医院财务 Excel 原始附件及 AG Grid 开发计划实现,当前交付是可运行、经过本地回归的核心业务版本,不等于全部需求完成或已获生产上线认证。缺口与上线门禁见 [验收状态](docs/acceptance.md)。
## 已实现
- XLSX / UTF-8 CSV 导入、双行表头、原始解释保留、字段语义确认、产品毛利率及自营产品范围配置。
- 原始文件私有存储、组织内文件 SHA-256 去重、导入事务、持久队列、PostgreSQL worker 崩溃恢复。
- 实际收入、成本、毛利、两种明确 ROI 公式、去重人数、客单价、毛利率、产品扣率、自营收入、升单率、原价客单价。
- 同比、环比、自定义等长月份范围;缺基期禁止比较,缺成本显示不可用,缺月份趋势断开。
- 总览、异常追踪、贡献分解、自定义下钻层级、面包屑、原始行证据分页、CSV / XLSX 证据导出。
- 个人设置保存/取消/预览与乐观锁;调查备注与状态持久化;账号启停、角色及科室范围、操作审计。
- 本地规则助手;可选 OpenAI Responses 结构化意图接口,用户同意后仅发送问题与维度/当前筛选,查询仍受后端权限约束。
当前原附件没有医生、成本和连续月度数据,不会生成虚构的医生排名、ROI 或环比结论。毛利是根据产品分类毛利率计算的贡献值,不等同于已审计净利润。
## 技术结构
| 目录 | 职责 |
| --- | --- |
| `frontend/src` | React 19 / TypeScript、AG Grid、ECharts、响应式 UI |
| `finance/importing.py` | 解析、映射、Decimal 校验、批量导入 |
| `finance/analysis.py` | 权限内聚合、比较、异常与贡献 |
| `finance/views.py` | 身份认证、受控 API、导出、设置、账号、审计 |
| `finance/assistant.py` | 规则助手及外部模型意图校验 |
| `server` | Django 配置、路由、WSGI |
| `tests` | PostgreSQL 集成测试、Playwright 浏览器回归 |
| `scripts` | 本地账号初始化、原始附件独立对账 |
后端金额使用 `NUMERIC(44,24)`,Python Decimal 精度 60;JSON 金额为字符串。浏览器 Number 仅用于绘图与显示,不作为账务计算或持久化来源。Excel 精确金额以文本单元格导出,避免 Excel 的 15 位有效数字上限。客户端不会一次加载 33 万条明细,只加载聚合结果,证据查询每页最多 200 行。
## 本地运行
需要 Node.js 22、uv、Python 3.12、PostgreSQL 17。所有示例从仓库根目录执行。
```bash
uv sync --frozen --python 3.12
npm ci --registry=https://registry.npmjs.org
export DATABASE_URL='postgresql://USER:PASSWORD@127.0.0.1:5432/traceiq'
uv run python manage.py migrate
PYTHONPATH=. uv run python scripts/init_local.py
npm run build
uv run python manage.py collectstatic --noinput
uv run python manage.py runserver 127.0.0.1:8000 --noreload
```
另一个终端启动导入 worker:
```bash
export DATABASE_URL='postgresql://USER:PASSWORD@127.0.0.1:5432/traceiq'
uv run python manage.py import_worker
```
访问 `http://127.0.0.1:8000`,本地随机账号保存在 `var/local-access.txt`,文件权限 0600。该脚本拒绝生产模式。开发热更新可在第三个终端运行 `npm run dev`,Vite 将 `/api` 代理到 8000。
本次本机验证使用专属集群 `var/postgres`、端口 55432、库 traceiq:
```bash
/opt/homebrew/opt/postgresql@17/bin/pg_ctl -D var/postgres -l var/postgres.log -o '-h 127.0.0.1 -p 55432' start
export DATABASE_URL='postgresql://jianwel@127.0.0.1:55432/traceiq'
```
该集群只监听本机,使用开发认证策略,不可公开端口或复制到生产。没有设置开机自启。SQLite 仅作轻量开发后备,不保证高精度金额聚合,禁止财务验收和生产使用。
## 生产部署
1. 在独立服务器配置域名 DNS、防火墙及 TLS,创建 `.env`,参考 `.env.example`。生成至少 50 字符随机 SECRET_KEY、数据库强密码,两处数据库密码需一致;密码有特殊字符时 DATABASE_URL 中必须 URL 编码。
2. 设置准确的 `DOMAIN`、`ALLOWED_HOSTS`、`CSRF_TRUSTED_ORIGINS`。仅开放 Caddy 80/443;数据库与 Gunicorn 不发布主机端口。
3. 执行 `docker compose --env-file .env config --quiet`,再执行 `docker compose up --build -d`。migrate 服务成功后才启动 web/worker,Caddy 自动申请 HTTPS 证书。
4. 执行 `docker compose exec web python manage.py bootstrap --username admin --organization '医院财务中心'`,交互输入管理员密码,无默认生产密码。
5. 执行 `docker compose exec web python manage.py check --deploy`,在隔离预生产环境运行验收,完成下面运维门禁再上线。
默认单个导入 worker 用 PostgreSQL advisory lock 防止重复处理;其他 worker 获取不到锁会退出。worker 异常退出,数据库回滚未完成的 Fact 事务并释放锁,下次启动会重新处理 processing。需要受控停机时先等任务 ready 再停,强制停止会导致整个文件重新导入。通过 `import_file PATH --username admin --confirm-mapping` 的 CLI 导入也入队,不绕过 worker。
`TRUST_PROXY=1` 仅用于 web 无公网直连、反向代理覆盖 X-Forwarded-Proto 和 X-Real-IP 的部署。登录 IP 限流在此模式使用经过校验的 X-Real-IP,未启用时忽略该头。`.env.example` 的 DB_SSLMODE=disable 仅适用同宿主私有容器网络;跨主机数据库用 verify-full 与受信证书。代理上传上限 66 MB、应用文件上限 64 MiB。生产使用环境变量/密钥服务注入密码,不提交 `.env`。
## 备份与运维
- 同时备份 PostgreSQL 与 uploads 卷,不能只备份其中一个。uploads 包含原始财务/客户标识,需加密磁盘、受控访问及留存期限。
- 低频窗口暂停新上传,等待当前导入完成并停止 worker;用 `pg_dump -Fc` 创建数据库备份,再快照 uploads,记录同一备份批次。将备份放在加密、异地主机或受控对象存储。
- 恢复到**隔离新库和新卷**,使用 `pg_restore --exit-on-error` 后检查迁移、账号范围、导入状态,执行金额/人数对账,抽查原始行与导出,再切换流量。未完成恢复演练不能上线。
- 定时执行 `python manage.py clearsessions`;监控 `/api/health`、HTTP 5xx、查询 P95、导入队列等待时间、processing 持续时间、磁盘/数据库容量、失败审计与备份新鲜度。当前未配置外部告警渠道。
- 管理员忘记密码由运维执行 `python manage.py changepassword USERNAME`,现阶段没有邮件找回、自助密码修改、SSO 或 MFA。
- 审计记录对网页用户只读,但数据库管理员仍可修改;需要不可抵赖审计时,应外送到独立追加写日志平台。
- 发布前备份并固定镜像 digest;失败时回滚应用镜像。涉及不可逆数据迁移不得直接反向迁移,按批准的数据库恢复方案操作。
## 测试
测试数据库需要 CREATE DATABASE 权限。测试账号和 fixture 均为虚构数据。
```bash
uv run ruff check server finance tests scripts
uv run ruff format --check server finance tests scripts
uv run python manage.py makemigrations --check --dry-run
uv run pytest --cov=finance --cov-report=term-missing --cov-fail-under=85
npm run build
npm run test:e2e
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 上执行同样流程,尚未推送到远端触发。
原附件只在本机使用,不在仓库中:
```bash
uv run python scripts/inspect_source.py '/Users/jianwel/Downloads/分析数据_原稿(1).xlsx'
PYTHONPATH=. uv run python scripts/reconcile_reference.py
REFERENCE_DATASET_NAME='分析数据_原稿(1).xlsx' npm run test:e2e
```
## AI 与跨平台
默认 AI_ENABLED=0。启用外部模型需配置 `AI_ENABLED=1`、`OPENAI_API_KEY`、`OPENAI_MODEL`,并完成机构数据处理审批。密钥只在后端。用户自由输入的问题也可能含敏感信息,不能仅凭不发送原始行就认定无隐私风险。模型不能提交 SQL、修改金额或修改账号;输出只允许受控意图,异常/拒答关闭执行。
前端为标准 Web,API 无平台耦合,可由后续 PWA、Tauri 或 Capacitor 客户端复用。当前没有打包桌面/移动客户端,也不在浏览器离线缓存财务数据。
AG Grid Community 默认可运行;设置 `VITE_AG_GRID_LICENSE_KEY` 后注册 Enterprise 模块并启用已接入的侧栏/状态栏。必须自行取得合适的商用许可证。SSRM / Pivot / Master-Detail 尚未接入,不能因存在 Enterprise 依赖就声称已完成这些功能。
+78
View File
@@ -0,0 +1,78 @@
name: traceiq
x-app: &app
build:
context: .
args:
VITE_AG_GRID_LICENSE_KEY: ${VITE_AG_GRID_LICENSE_KEY:-}
image: traceiq:local
env_file: .env
volumes:
- uploads:/app/var/uploads
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:size=1g,mode=1777
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
services:
db:
image: postgres:17-bookworm
environment:
POSTGRES_DB: traceiq
POSTGRES_USER: traceiq
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
volumes:
- postgres:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: [CMD-SHELL, 'pg_isready -U traceiq -d traceiq']
interval: 5s
retries: 20
migrate:
<<: *app
command: [python, manage.py, migrate, --noinput]
restart: "no"
depends_on:
db:
condition: service_healthy
web:
<<: *app
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen(urllib.request.Request('http://localhost:8000/api/health', headers={'X-Forwarded-Proto':'https'}), timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
worker:
<<: *app
command: [python, manage.py, import_worker]
depends_on:
migrate:
condition: service_completed_successfully
cleanup:
<<: *app
command: [python, manage.py, cleanup_formula_exports, --loop]
depends_on:
migrate:
condition: service_completed_successfully
proxy:
image: caddy:2.10-alpine
environment:
DOMAIN: ${DOMAIN:?Set DOMAIN}
ports: ['80:80', '443:443']
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
depends_on:
web:
condition: service_healthy
volumes:
postgres:
uploads:
caddy_data:
caddy_config:
+10
View File
@@ -0,0 +1,10 @@
{$DOMAIN} {
request_body {
max_size 66MB
}
reverse_proxy web:8000 {
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
log
}
+113
View File
@@ -0,0 +1,113 @@
# 验收状态与上线门禁
日期:2026-09-24,Asia/Singapore。本文区分实现状态和验证状态,不把编译成功等同于生产商用验收。
## 公式工作簿与对话新增列
本轮新增校验:对话模型、导出 API 及最终工作簿生成器均只允许引用当前数据映射可计算的指标;模型误报完整或客户端绕过对话直接提交时,服务端仍会拒绝缺少来源字段的自定义列。
> **当前发布判断:** 本轮后端 61 passed、finance 覆盖率 87.66%;公式工作簿版本 3 已改为流式写入,100,000 行产品上限实测峰值 RSS 约 136.4 MB。新增列流程覆盖追问、补充、定义确认、数据映射可用性防护及公式工作簿下载。LibreOffice 重算测试覆盖特定黄金样例;Microsoft Excel、财务口径签字、真实 AI 合规、原附件 332,830 行范围方案及目标环境部署仍未验收。
| 需求 | 状态 | 实现 / 尚缺内容 |
| --- | --- | --- |
| 对话定义自定义列 | 已实现;mock 端到端通过 | 用户描述列名、含义、范围及计算;缺项时模型主动追问,补充后结合全对话重审;只接受既有指标/常数和白名单运算树。编辑补充时保留当前定义供核对,未发送的草稿会禁用确认;最多 6 轮,限 10 列、8 层表达式。真实外部模型联调未做。 |
| 自定义列公式导出 | 已实现;小样本验证通过 | 异步任务生成 XLSX;自定义列引用汇总指标公式,并包含使用说明、参数口径、计算明细、原始证据和对账页;另保留原有证据快照导出。 |
| Excel 公式引擎验收 | 尚未通过发布门禁 | 消费人数与升单率改用随机去标识 CustomerRoster 固定人数表 + COUNTIFS,不依赖 UNIQUE/FILTER;4 项 LibreOfficeDev 重算测试已通过。Microsoft Excel 仅打开过样本并在公式栏看到 COUNTIFS/SUMIFS;Excel 为只读无法保存重算副本,且所见分组与预期不匹配,故 Excel 实机重算结果仍未验收。身份、分组和期间是固定快照,编辑金额可重算,不支持通过改客户身份改变人数。 |
| 生产上线资格 | 未完成 | 公式导出最大 100,000 行/200 组,真实附件 332,830 行尚不能一次导出;真实 AI 服务联调、数据出境审批、目标 Excel 兼容、性能及安全审查均未完成。 |
## 最新验证(2026-09-24)
- 对话新增列及公式导出回归:后端全量 `61 passed`,finance 覆盖率 `87.66%`;新增用例覆盖流式工作簿、精确文本条件、未分类对账、Excel 长度限制、公式注入、保留列冲突、任务文件权限/路径、授权快照顺序和路径准备失败恢复。
- 当前源码自动化基线(2026-09-24):61 passed,finance 覆盖率 87.66%(85% 门槛);包含列定义追问、数据映射可用性防护、流式导出和 LibreOffice 公式重算回归。Ruff lint/format、TypeScript/Vite 构建、Django check、迁移检查通过。
- 本轮隔离 SQLite Playwright:3 passed、1 skipped;新增列追问与下载、桌面综合工作流、手机布局通过;原附件 UI 用例因未设置 `REFERENCE_DATASET_NAME` 跳过。隔离数据库和媒体目录位于 `/tmp/caiwu-roadmap-e2e*`;登录成功的正式用例使用临时口令环境变量。
- 完整临时生产配置下 `check --deploy` 无告警;pip-audit 和 npm audit 均为 0 已知漏洞。Docker 镜像构建未运行(Docker daemon 不可连接),Compose 校验未运行(真实 `.env` 不存在)。
- 真实附件 PostgreSQL 17 对账通过:332,830 行、5 个分组,收入/毛利/人数与历史独立对账一致,缺成本 332,830 行、ROI 不可用。完整公式工作簿生成基准 96.94 秒、51,532,457 字节、峰值 RSS 3,308,617,728 字节;单机单次生成不等于资源/SLA 通过。
- 本轮以安装的 LibreOfficeDev 执行 `tests/test_formula_recalculation.py`(3 项)与 `tests/test_formula_recalculation_gross.py`(1 项):共 4 项通过,覆盖自定义汇总/变化率、编辑明细金额后的联动、零成本 ROI、缺失基期,以及 direct/lookup 毛利来源。该结果不是 Microsoft Excel 实机验收。
- 流式容量基准:公式版本 3 的 20,000 行合成工作簿生成 5.26 秒、3,265,273 字节、峰值 RSS 77,922,304 字节;100,000 行生成 25.96 秒、16,217,767 字节、峰值 RSS 136,429,568 字节。100,000 行产物 ZIP/OOXML 完整性、7 个工作表、表格关系、版本及汇总公式读取通过;这是本机隔离 SQLite 单次基准,不是目标环境并发 SLA。
- Microsoft Excel 桌面检查记录:在只读 Excel 中打开 `/tmp/caiwu-excel-validation.xlsx`,公式栏可见 COUNTIFS/SUMIFS;无法保存重算副本,且所选样例分组为 `Test`、与预期不匹配。结果仅证明样本中存在公式,不证明 Excel 计算正确,发布门禁维持未通过。
- `compose.yaml` 已配置 `cleanup_formula_exports --loop` 常驻清理服务,清理逻辑有 3 项测试覆盖;目标环境仍需确认运行与告警。
## 剩余功能与发布阻断
| P0 | Microsoft Excel 公式引擎验收 | 已有 LibreOfficeDev 小样本公式缓存证据,仍需目标 Excel Windows/Mac 重算和兼容性验收。 |
| P0 | 真实附件范围与业务口径 | 100,000 行/200 组产品上限已用流式版本实测;原附件 332,830 行仍超范围,需选择可审计分卷或正式提高上限后再验。财务签字仍未完成。 |
| P0 | 真实 AI 合规与商用环境 | 真实模型联调、数据出境/隐私审批和目标环境安全验证尚缺。 |
| 优先级 | 未完成项 | 发布影响 / 下一步 |
| --- | --- | --- |
| P0 | Microsoft Excel 公式引擎验收 | 人数公式使用静态去重辅助表 + COUNTIFS;仍需目标 Excel Windows/Mac 打开、重算、保存,覆盖人数、升单率、空集、缺期及金额编辑后的计算。 |
| P0 | 真实附件全量公式导出 | 当前上限 100,000 明细行/200 分组,原附件 332,830 行;100,000 行流式资源压测已完成,仍需定义切分导出并保证跨文件口径,或批准扩大产品范围后做 332,830 行目标引擎验收。 |
| P0 | 财务业务口径签字 | 确认 N 列实际收入与 I 列实收、退款、成本归属、毛利、ROI、升单、自营产品集和舍入规则;真实附件成本缺失且 ROI 不可用。 |
| P0 | 外部 AI 合规与真实联调 | 完成真实模型超时/限额/故障演练、数据出境和隐私审批;未批准前保持 AI 关闭。 |
| P1 | 对话定义可扩展性 | 当前仅支持既有指标/常数与加减乘除;不支持任意 Excel 函数、跨明细新聚合、按零替代/跳过缺失、生成维度列或保存复用定义。新能力需逐项安全与黄金样例验证。 |
| P1 | 工作簿操作完整性 | 当前固定导出范围,不支持在 Excel 增加明细行后自动扩展所有报表;结构变化需重新导出。验证参数编辑、结构变化提示、模板版本和列字典。 |
| P1 | 定义体验差距 | 证据弹窗已支持本期/基期切换并通过 E2E;公式定义对话仍没有持久化草稿/定义库。确认复用范围后实现并补 E2E。 |
| P1 | 商业部署验收 | Docker 构建、HTTPS/证书、PostgreSQL/TLS、备份恢复、并发/SLA、审计监控、渗透测试、AG Grid Enterprise 商用许可需在目标环境验证/审批。 |
## 需求覆盖
| 需求 | 状态 | 实现 / 尚缺内容 |
| --- | --- | --- |
| 网页前端、手机适配 | 已实现,本地浏览器回归 | React + 响应式 CSS,无原生客户端 |
| XLSX/CSV、双行表头、解释型映射 | 已实现 | 标准别名建议 + 人工确认,不宣称通用 AI 自动识别全部表格 |
| 原始文件版本、重复上传、失败回滚 | 已实现并测试 | SHA-256 组织内唯一,源文件私有,成功版本不可修改 |
| 收入/毛利/成本/ROI/人数/客单价 | 已实现并测试 | 缺字段返回不可用;ROI 在导入时明确公式 |
| 自营收入/升单率/原价客单价 | 已实现并测试 | 新导入配置 self_operated_products;旧版本未配置时不可用,不偷偷改历史口径 |
| 同比/环比/自定义多月 | 已实现并测试 | 基期等长且不能重叠,缺失期间不比较 |
| 趋势/贡献/矩阵/异常列表 | 已实现 | ECharts 折线/横条图,非完整瀑布/Pareto/散点套件 |
| 动态下钻/面包屑/证据 | 已实现并测试 | 来源、区域、科室、学科、产品等映射维度;医生仅有字段才出现 |
| 设置保存/取消/预览、调查状态 | 已实现并测试 | 个人设置版本锁,调查按上下文持久化;没有团队工单分派 |
| CSV/Excel 导出 | 双模式已实现 | 既有 CSV/XLSX 为证据快照;公式 XLSX 为独立异步工作簿,含自定义公式列、说明页和固定人数去重辅助表。Microsoft Excel 引擎和全量附件门禁未通过,不可视为生产验收 |
| 账号/组织/科室权限/CSRF/审计 | 已实现并测试 | 应用层隔离;无数据库 RLS、SSO、MFA、自助密码找回 |
| Copilot | 本地规则助手可用;外部接口已实现 | 外部模型成功/拒答/非法意图以 mock 测试,尚未真实 API 联调 |
| AG Grid Enterprise | 部分接入 | 授权后注册模块;SSRM/Pivot/Tree Data/Master-Detail 未实现;现在使用服务端聚合 + 分页证据 |
| 全部过渡表口径重建 | 未完成 | 消费比率、实收/实际收入多口径、跨表 lookup 编辑、生效期毛利率、退款口径切换仍缺 |
| 异常规则 | 基础实现 | 不利变化率 + 影响金额 + 两期样本数;结构占比、历史分位数、告警投递尚未实现 |
| 原始附件 | 全量已导入并对账 | 不含医生/成本;只有两个单月,不允许虚构连续期间 |
| Docker/HTTPS/CI | 配置已交付,验证不完整 | Compose 使用示例环境文件替换后的配置校验通过;基础镜像 metadata 下载长时间无响应,已取消构建;无正式域名证书与远端 CI 运行记录 |
原始附件“过渡表2”同时使用“实收金额”“收入”“实际收入”字样,产品扣率分子存在歧义。当前产品扣率与客单价明确使用选定的实际收入字段,原附件采用 N 列;不能直接当作 I 列实收口径通过财务签字。旧导入版本也不会因为新代码增加自营产品范围而静默更改映射。
## 真实数据对账
来源 SHA-256:`98ef41e898c24f0929a9da6ec185b7331f69f0478b52056cbc7834e1ef5877ec`。
| 指标 | 2025-08 | 2026-08 |
| --- | ---: | ---: |
| 明细行数 | 157512 | 175318 |
| N 列实际收入 | 225550255.4187610638720 | 152419115.348455502666069 |
| 产品毛利贡献 | 24790335.405853982758000 | 17868580.85186759073400145 |
| 去重消费人数 | 33147 | 38361 |
总计 332830 行;收入同比 -32.4234%,毛利贡献同比 -27.9212%;ROI 不可用。独立逐行读取与数据库精确聚合一致,科室贡献变化合计等于总体变化。原数据 11990 行收入超过 4 位小数,未在落库时四舍五入到 4 位。
质量核查:负收入 12338 行、G/L 应收差异 14154 行、I/N 实收与实际收入差异 11991 行、成本缺失 332830 行、毛利率未匹配 0 行。本机单次完整查询约 3 秒,仅为一次开发机测量,不是并发性能 SLA。
## 测试记录
- 本轮源码测试:44 passed,finance 覆盖率 86.95%;常规 pytest 在本机 SQLite 测试配置运行,独立 PostgreSQL CI 未启动;另在本机 PostgreSQL 17 隔离库执行原附件对账和完整公式工作簿生成基准。
- 本轮浏览器回归:完整隔离 SQLite Playwright 3 passed、1 skipped;跳过未配置 `REFERENCE_DATASET_NAME` 的原附件 UI 专测。
- PostgreSQL 17:67 项后端测试通过(22.32 秒),finance 总覆盖率 89.17%,analysis 98%、assistant 90%、importing 86%、views 84%;CI 覆盖率门槛 85%。
- 检查内容:Decimal 金额、人数去重、加权比率、零分母、退款、缺期、新增/消失分组贡献、XLSX 表头与 lookup、导入原子性、重复上传、非法映射、跨组织/科室隔离、CSRF、会话、登录限流、设置冲突、调查记录、导出公式注入、模型意图白名单与敏感字段最小化、worker 恢复、CLI 初始化。
- TypeScript / Vite 生产构建通过;Django `check`、迁移检查及完整临时生产变量下的 `check --deploy` 均通过。目标部署环境仍未验收。
- Python pip-audit 与前端 npm audit(生产依赖)均报告 0 个已知漏洞;这不是渗透测试或未来无漏洞保证。
- 既有 Playwright 历史记录:3 项用例通过;当前分支 E2E 覆盖见上方 2026-09-24 最新验证,不能用历史结果替代本轮重跑。
- 回归发现并修复调查控件标签定位与图表网格最小宽度问题。原附件截图为 `reference-desktop.png` / `reference-mobile.png`;截图文件已生成,但工具未可靠展示图片供逐像素人工复核,视觉精修仍应由产品人员验收。
- 新增可信代理登录限流测试:未启用信任时忽略伪造客户端头,启用后仅接受合法 IP。生产 Caddy 覆盖 X-Real-IP,web 不得直接暴露公网。
- 产物位于本机 `test-results/`、`playwright-report/`,未提交真实财务数据和凭据。
## 必须完成的上线门禁
1. 财务负责人确认收入/实收/原价、退款、升单、自营、成本归属与 ROI 定义;补齐缺失的业务口径和字段。
2. 若要求原开发计划全部功能,补齐上述未完成项并逐项签字。当前版本不能标记为“所有前后端需求已完成”。
3. 在目标基础设施完成 Docker 镜像构建、HTTPS、数据库认证/TLS、上传限制、非 root 容器及重启恢复验收。
4. 用目标并发用户数、最大文件/维度基数做导入与查询压力测试,确定 SLA 和资源配额。当前聚合会返回所有分组,高基数维度需 SSRM 或服务端结果窗口。
5. 完成真实 OpenAI 接口联调、超时/限额演练和数据出境/隐私审批;没有审批时保持关闭。
6. 外部安全审查与渗透测试、最小权限数据库账号、密钥轮换、加密存储、访问日志脱敏、不可变审计与告警接入。
7. 执行数据库 + 源文件一致性备份及恢复演练,记录 RPO/RTO,确定留存和删除流程。
8. AG Grid Enterprise 若启用须有适用商业许可;完成所有依赖许可证盘点。
以上商用门禁不因本地自动化测试通过而自动满足;较早的 44、54 项测试及旧非流式 332,830 行峰值 RSS 约 3.31 GB 均为历史基线,不能替代当前版本的目标环境验收。
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>财务 Agent · Excel 公式导出实施计划</title>
<style>
:root{color-scheme:light;--ink:#202725;--muted:#58645f;--line:#d8dfdc;--green:#17664f;--red:#ac3440;--bg:#f5f7f6}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.8 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;letter-spacing:0}header{background:#fff;border-bottom:3px solid var(--green);padding:40px 24px 28px}main,header>div,footer{max-width:1120px;margin:auto}h1{font-size:32px;line-height:1.35;margin:8px 0 16px}h2{font-size:24px;margin:0 0 18px}h3{font-size:18px;margin:24px 0 8px}p{margin:10px 0}small,.muted{color:var(--muted)}main{padding:0 24px}section{padding:32px 0;border-bottom:1px solid var(--line);scroll-margin-top:16px}nav{padding:22px 0;border-bottom:1px solid var(--line);display:flex;gap:12px 24px;flex-wrap:wrap}a{color:var(--green);text-underline-offset:4px}ul,ol{padding-left:24px}li{margin:8px 0}.notice{border-left:4px solid var(--red);padding:10px 18px;background:#fff}.recommend{border-left:4px solid var(--green);padding:10px 18px;background:#fff}.table-wrap{overflow:auto;max-width:100%;border:1px solid var(--line)}table{border-collapse:collapse;width:100%;min-width:720px;background:#fff;font-size:14px}th,td{text-align:left;vertical-align:top;padding:13px 15px;border-bottom:1px solid var(--line)}th{background:#eaf0ed}tr:last-child td{border-bottom:0}code{font:13px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere}pre{padding:16px;background:#eaf0ed;overflow:auto;max-width:100%;line-height:1.7}pre code{white-space:pre;overflow-wrap:normal}.tag{font-size:13px;font-weight:700;color:var(--red)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}.grid>div{min-width:0}footer{padding:28px 24px 48px;color:var(--muted);font-size:14px}strong{font-weight:650}@media(max-width:650px){header{padding:26px 18px}main{padding:0 18px}h1{font-size:26px}h2{font-size:21px}.grid{grid-template-columns:1fr;gap:0}section{padding:25px 0}}@media print{body{background:white;font-size:11pt}header{padding:0 0 16px}main{padding:0}nav{display:none}section{break-inside:auto;padding:18px 0}h2,h3{break-after:avoid}tr{break-inside:avoid}.table-wrap{overflow:visible}table{min-width:0;font-size:9pt}pre{white-space:pre-wrap}pre code{white-space:pre-wrap}footer{padding:16px 0}}
</style>
</head>
<body>
<aside style="max-width:1120px;margin:16px auto;padding:16px 24px;background:#fff4e5;border-left:4px solid #a95712"><strong>当前状态复核 · 2026-09-24:</strong>本页下方保留 2026-09-23 的历史实施方案,不能再据此判断当前功能状态。方案中的公式工作簿、说明页、异步导出和对话新增列已经实现;LibreOfficeDev 小样本重算已验证,Microsoft Excel Windows/Mac 尚未验收。当前限制为 100,000 行/200 组,332,830 行原附件公式工作簿、真实 AI 合规、财务签字和商业部署仍未通过。详情见 <a href="acceptance.md">验收状态与剩余门禁</a>。</aside>
<aside style="max-width:1120px;margin:16px auto;padding:16px 24px;background:#fff4e5;border-left:4px solid #a95712"><strong>状态更新 · 2026-09-24:</strong>本计划中的公式工作簿、说明页、异步导出及对话新增公式列已实现。新增列追问/导出专项、workspace 桌面和手机测试及后端测试通过。下方内容是实施前方案记录;目前仍未通过 Microsoft Excel 动态数组重算验收,LibreOffice 人数公式存在兼容错误;公式导出限制 100,000 行/200 组,无法一次覆盖 332,830 行附件;真实 AI 联调、财务口径签字及商业部署安全门禁未完成。因此公式版尚不具备生产发布资格。详见 <a href="acceptance.md">验收状态与剩余门禁</a>。</aside>
<header><div><small>TRACEIQ / 财务 Agent / 工程实施与验收方案</small><h1>Excel 公式导出实施计划</h1><p>从只读证据快照,升级到可解释、可重算、可对账的财务工作簿。</p><p class="tag">状态:待实施 · 本文不是功能已完成或生产验收通过的证明</p><small>核查日期:2026-09-23 | 交付范围:计划文档,不修改现有导出逻辑</small></div></header>
<main>
<nav aria-label="目录"><a href="#current">现状证据</a><a href="#decisions">设计选择</a><a href="#workbook">工作簿结构</a><a href="#formulas">公式与口径</a><a href="#precision">精度策略</a><a href="#editing">可重算边界</a><a href="#security">安全与性能</a><a href="#implementation">实施步骤</a><a href="#tests">测试与门禁</a><a href="#questions">待确认事项</a></nav>
<section id="current"><h2>01 / 核查结论:目前不含公式</h2><div class="notice"><strong>当前 XLSX 导出不满足本次需求。</strong><p>现有实现将每个单元格转换为字符串,并明确设置为文本类型。导出的收入、成本和毛利贡献是结果快照,没有可重算公式,也没有完整的行列字典和操作说明。</p></div><div class="table-wrap"><table><thead><tr><th>核查对象</th><th>已存在的行为</th><th>与目标的差距</th></tr></thead><tbody><tr><td><code>finance/views.py · export()</code></td><td>一张“证据明细”;前四行含版本 SHA、筛选上下文、ROI 口径、导出时间;所有值写为文本。</td><td>没有公式计算层、汇总报表或说明页。</td></tr><tr><td><code>tests/test_finance.py · test_excel_export_precision_and_formula_safety</code></td><td>明确断言所有单元格都不是公式,验证高精度金额及注入保护。</td><td>只能证明证据快照行为正确,不能证明公式导出正确。</td></tr><tr><td><code>frontend/src/dialogs.tsx · EvidenceDialog</code></td><td>选择 XLSX 下载证据明细。</td><td>没有公式版选择、兼容性说明、精度提示或异步状态。</td></tr><tr><td>规模与参考附件</td><td>现有单次导出上限 100,000 行;附件共有 332,830 行。</td><td>不能通过现有接口一次导出原附件全部明细。</td></tr></tbody></table></div><p>已运行的两项针对性测试:<code>test_excel_export_precision_and_formula_safety</code>、<code>test_xlsx_double_header_and_lookup</code>,结果 <strong>2 passed(1.59 秒)</strong>。后者验证导入双表头与毛利率匹配,不是 Excel 公式重算测试。</p><p>保留金额文本并非无意义限制:系统使用高精度 Decimal,而 Excel 普通数值只有约 15 位有效数字。新方案必须保留证据精度,不能直接把所有字符串改成浮点数。</p></section>
<section id="decisions"><h2>02 / 设计选择与推荐</h2><div class="table-wrap"><table><thead><tr><th>决策</th><th>选项与代价</th><th>推荐</th></tr></thead><tbody><tr><td>导出模式</td><td>A. 替换现有证据导出,破坏旧语义。<br>B. 保留证据快照,新增公式工作簿。</td><td><strong>B</strong>。界面清楚区分“证据快照”和“公式分析工作簿”;CSV 始终是快照,不承诺公式。</td></tr><tr><td>精度</td><td>A. 全部按 Excel 浮点数,丢失高精度证据。<br>B. 精确文本证据 + 数值计算层 + Decimal 对账。<br>C. 宏或自定义高精度插件,兼容、部署及安全成本高。</td><td><strong>B</strong>。明确公式值遵守批准后的精度政策,不声称原生公式保持 24 位小数精确运算。</td></tr><tr><td>公式兼容性</td><td>A. Microsoft 365,支持动态数组去重。<br>B. 旧版 Excel / WPS / LibreOffice,需另做兼容实现及验证。</td><td>先以 <strong>Microsoft 365 桌面 Windows / Mac</strong> 为明确验收目标;网页版另测。其他引擎通过验证后再声明支持,不能以应用跨平台替代工作簿兼容性。</td></tr><tr><td>人数计算</td><td>A. UNIQUE/FILTER 真正按范围去重。<br>B. 服务端预制去重辅助表,修改客户或分组后可能失效。<br>C. 每行 COUNTIFS 扫全表,规模大时开销很高。</td><td>公式版选 <strong>A</strong>,限定报表分组数量并压测;B 仅作明确只读的兼容快照,不能冒充完全可重算。避免 C。</td></tr><tr><td>生成与重算</td><td>A. 现有 openpyxl 写公式,打开 Excel 时重算。<br>B. 导出后由经验证的受控引擎重算并保存缓存。<br>C. 添加写缓存库,但缓存仍是服务器结果。</td><td>第一阶段 <strong>A + 真实 Excel 验收</strong>;首次打开前预览可能空白,必须告知。若业务要求下载即带可信缓存,则 B 是上线前置条件;不把 C 当作重算证明。</td></tr><tr><td>编辑能力</td><td>A. 任意插行、改维度和期间并自动重建全部报表。<br>B. 固定导出范围,允许指定输入修改,结构性变化重新导出。</td><td>首版 <strong>B</strong>,边界容易验证;完整可变结构作为后续独立需求,不暗示已经支持。</td></tr><tr><td>大文件</td><td>A. 同步放宽现有上限。<br>B. 数据库任务 + 独立 worker + 私有文件下载。</td><td>全量附件采用 <strong>B</strong>;复用项目已有任务处理模式,不预先引入新的消息队列。</td></tr></tbody></table></div></section>
<section id="workbook"><h2>03 / 工作簿结构与行列说明</h2><p>输出放在前面,输入和证据放在后面。仅创建实际需要的页,不按每个指标拆页。所有名称均为规划,尚未实现。</p><div class="table-wrap"><table><thead><tr><th>工作表</th><th>行与列的含义</th><th>内容与用途</th></tr></thead><tbody><tr><td>分析汇总</td><td>每行一个指标或已选维度分组;列为本期、基期、差额、变化率、单位、可用状态。</td><td>汇总金额、去重人数、比率由明细或计算结果引用产生;非加法指标不合计子组值。页首注明范围、期间、版本及模拟状态。</td></tr><tr><td>使用说明</td><td>按主题分行:范围、粒度、字段、公式、操作、限制。</td><td>字典列包括名称、业务含义、数据类型、单位、来源字段、可否修改、空值含义、公式与示例;另附版本、时区和支持的 Excel 版本。</td></tr><tr><td>参数与口径</td><td>每行一个参数或产品毛利率;列为当前值、含义、来源、是否可改。</td><td>收入映射、ROI 分支、自营产品集合、毛利率、精度政策;首版期间及筛选锁定。允许的模拟参数必须明确标识。</td></tr><tr><td>计算明细</td><td>一行对应一个导入后的 Fact,保留来源定位键;不是一位客户或一次就诊。</td><td>期间、维度、去标识客户 token、基础数值、原价、毛利来源、适用毛利率、毛利公式、缺失标志等;Excel Table 管理引用范围。</td></tr><tr><td>原始证据</td><td>与计算明细一一对应;金额和标识保留精确文本。</td><td>授权范围内的来源行、字段映射及原值;不可把完整患者原始信息无差别复制出去。工作表保护只防误操作,不是加密或权限控制。</td></tr><tr><td>对账检查</td><td>每行一个指标及范围;列为服务器精确文本、批准精度的参考数值、公式结果、差异、阈值和检查状态。</td><td>包含行数、缺失数、总分一致性、公式覆盖与精度警告;此页仅观察结果,不反向驱动业务公式。</td></tr></tbody></table></div><h3>说明页必须回答的问题</h3><ul><li>本文件覆盖哪个数据版本、期间、组织及筛选?这是授权范围快照,不代表整院所有数据。</li><li>金额单位是什么?百分比以小数储存;升单率变化“百分点”与相对变化率分列,不混用。</li><li>收入、实收、原价、毛利贡献分别指什么?毛利贡献不是净利润,ROI 成本的定义必须写明。</li><li>哪些单元格可改?颜色之外同时提供文字标签、批注和数据验证;公式格保护防止误覆盖。</li><li>如何启用自动计算、重新计算和查看公式?不需要启用宏,不连接外部数据,不回写业务系统。</li><li>空值、0、不可用、缺期和超精度分别是什么意思?遇到不支持函数时不要把错误当成 0。</li><li>修改后如何核对?本地修改是模拟结果,服务器原始快照及其 SHA 不因此变成修改后内容的认证。</li></ul></section>
<section id="formulas"><h2>04 / 公式契约:每个衍生结果可追溯</h2><p>基础事实可以是输入常量;新增衍生金额、计数、比率和差异必须来自真正的公式依赖。禁止用 <code>=12345</code> 或引用隐藏的静态答案来伪装计算。建立“指标 → 输入 → 公式 → 单位 → 缺失策略 → 测试”的显式清单。</p><div class="table-wrap"><table><thead><tr><th>指标</th><th>计算定义</th><th>边界与验收重点</th></tr></thead><tbody><tr><td>收入 / 成本 / 原价</td><td>在指定范围汇总对应基础字段。</td><td>有数据但全为 0 与无数据不同;成本或原价部分缺失时依赖指标不可用,不能只合计非空项。</td></tr><tr><td>毛利贡献</td><td>原始毛利有值时使用该输入;否则收入 × 已匹配产品毛利率;汇总逐行结果。</td><td>必须恢复并记录 direct / lookup / missing 来源。Fact.gross 已是结果,不能直接把它当作原始毛利来伪造推导。必要时从保留的 raw 与映射提取,先做一致性测试。</td></tr><tr><td>ROI</td><td>profit_over_cost:(收入 − 成本) / 成本;gross_less_cost_over_cost:(毛利贡献 − 成本) / 成本。</td><td>遵从数据版本映射;成本 ≤ 0 或任何必要字段缺失时不可用。</td></tr><tr><td>消费人数 / 客单价</td><td>范围内客户 token 去重计数;收入 / 消费人数。</td><td>跨月、跨产品、跨科室重复客户只计一次;有任一缺失客户时与现有后端口径一致地不可用。</td></tr><tr><td>毛利率 / 产品扣率</td><td>毛利贡献 / 收入;收入 / 原价金额。</td><td>先汇总再相除,不能平均行级比率;分母必须大于 0;不把扣率解释为“1 − 比值”。</td></tr><tr><td>自营收入 / 升单率</td><td>配置自营产品范围内收入;该范围客户去重数 / 全部客户去重数。</td><td>产品集合未配置时不可用;不能以自营收入占比替代升单率。</td></tr><tr><td>原价客单价</td><td>原价金额 / 去重人数。</td><td>原价或客户缺失时不可用。</td></tr><tr><td>差额 / 变化率</td><td>本期 − 基期;差额 / ABS(基期)。</td><td>基期 0 不计算变化率;缺期不比较;比率指标同时注明差额单位。</td></tr><tr><td>贡献率</td><td>子组金额差额 / 父组金额差额。</td><td>仅收入、成本、毛利、自营收入等可加指标;父级差额 0 不计算,不为人数或比率伪造加法贡献。</td></tr></tbody></table></div><h3>公式示例与生成规则</h3><p>下列为设计样例。<code>Data</code> 表名、英文列名和命名范围须由模板定义,不能让用户字符串直接拼入公式;状态判断在拥有该指标的计算层生成。公式使用英文函数名和 OOXML 语法,实际写入动态数组需要验证序列化及兼容前缀。</p><pre><code>收入(已确认该期间有记录):
=SUMIFS(Data[Revenue],Data[Period],CurrentPeriod)
成本完整性检查后计算 ROI:
=IF(CostStatus&lt;&gt;"OK",NA(),IF(Cost&lt;=0,NA(),(Revenue-Cost)/Cost))
变化率(先确认本期与基期可比较):
=IF(Comparable=0,NA(),IF(BaselineValue=0,NA(),
(CurrentValue-BaselineValue)/ABS(BaselineValue)))
365 去重人数(先确认范围有记录,且该范围客户字段完整):
=IF(CustomerStatus&lt;&gt;"OK",NA(),
ROWS(UNIQUE(FILTER(Data[CustomerToken],Data[Period]=CurrentPeriod))))
毛利计算分支:
=IF([@GrossSource]="direct",[@GrossInput],
IF([@RateStatus]="OK",[@Revenue]*[@MarginRate],NA()))</code></pre><p>多月、多维度条件需生成对应掩码;上述单月例子不能直接替代完整上下文。全量、大量分组不允许每个明细行扫描全表。公式构建须限制报告维度及单元格数量。</p><p><strong>缺失表达推荐:</strong>计算层用预期的 <code>NA()</code> 阻止错误结果传播;输出页用配套状态显示“不可用:缺成本”等原因。只对已知状态做展示转换,不用 <code>IFERROR(...,0)</code> 吞掉真实公式错误。</p></section>
<section id="precision"><h2>05 / 精度与财务对账</h2><div class="recommend"><strong>推荐:原始精确文本不可变,计算数值有明确精度契约。</strong><p>“显示两位小数”不是“计算只用两位小数”。原附件有 11,990 行收入超过四位小数,不能在导出时静默按行截断。</p></div><ol><li><strong>证据层:</strong>保留 Decimal 原始字符串及来源映射;不经 JavaScript Number 中转。完整金额不可因导出而发生舍入。</li><li><strong>计算层:</strong>先检测有效数字、数量级和累计误差风险,再转换 Excel 数值。把转换策略、舍入位数、舍入模式和发生时点写入文件。</li><li><strong>推荐默认:</strong>分析公式不逐行强制 ROUND;展示金额两位,最终财务比较按批准的规则舍入。若业务要求“每行先舍入再汇总”,另设明确版本口径,不能混用。</li><li><strong>两套参照:</strong>Decimal 对原始业务口径的精确结果用于证据核验;按相同批准数值政策得到的参考结果用于 Excel 计算对账。区别“政策导致的差异”与“实现错误”。</li><li><strong>误差门槛:</strong>逐项规定金额绝对误差、比率误差及人数严格相等。建议金额以最终批准币种最小单位一致为目标;具体阈值必须经真实样本和财务确认,不能先放宽到让测试通过。</li><li><strong>边界处理:</strong>超过可接受数值范围、舍入临界点或差异超阈值时阻止宣称“精确一致”,给出可定位的警告或拒绝公式版,证据版仍保留。</li></ol><p>不推荐启用 Excel“以显示精度为准”,因为它会改变存储值。原生数值公式不能承诺任意 24 位小数精确运算;若确有此硬要求,应保留服务端 Decimal 为权威计算引擎,并另行评估高精度插件,不在首版隐式引入宏。</p></section>
<section id="editing"><h2>06 / 修改后究竟会重算什么</h2><div class="table-wrap"><table><thead><tr><th>操作</th><th>首版承诺</th><th>限制</th></tr></thead><tbody><tr><td>修改允许编辑的收入、成本、原价、直接毛利</td><td>行级毛利、汇总、比率、差额及对账随公式重算。</td><td>原始证据不变;明确标记为本地模拟,不回写服务器。</td></tr><tr><td>修改已有产品的毛利率</td><td>仅 lookup 来源行重算;direct 来源行不受影响。</td><td>不自动新增产品,不静默替换原始毛利。</td></tr><tr><td>筛选或隐藏明细行</td><td>默认仅改变可见行,不改变汇总范围。</td><td>明确 SUMIFS 不随 AutoFilter 改变;若未来要“仅可见行汇总”,作为独立模式实现及测试。</td></tr><tr><td>改变期间、维度、客户、产品集合,追加或删除行</td><td>首版不承诺;从系统重新选择范围并导出。</td><td>Table 能扩展不等于分组报表、去重及状态范围全部正确扩展;锁定结构以防误解。</td></tr><tr><td>修改公式</td><td>默认保护防误操作;允许授权副本解除保护后自行分析。</td><td>解除保护后的结果不属于系统验证范围;工作表保护不是安全边界。</td></tr></tbody></table></div><p>源事实 ID 或稳定行键只用于追溯;本地模拟状态可通过可编辑输入与导出初值比较生成。任何数据版本 SHA 都只标识服务器来源,不能认证已被本地修改的文件。</p></section>
<section id="security"><h2>07 / 安全、权限与 33 万行性能</h2><h3>可信公式与不可信内容严格分离</h3><ul><li>公式只由受控模板生成;原始文本、列名、维度标签保持文本类型。保留并扩展当前 =、+、-、@、制表符及换行注入测试。</li><li>无 VBA、DDE、外部工作簿链接或未经允许的 HYPERLINK;扫描 OOXML 关系和公式,不只检查文件扩展名。</li><li>客户去重使用工作簿内稳定的去标识 token。推荐每次导出独立密钥 HMAC,在所有页统一使用,密钥不写入文件;仍按敏感数据管理,不能称为不可逆匿名化。</li><li>申请、执行、下载三个阶段复核组织和科室权限;执行快照只包含授权行,下载前权限被撤销必须拒绝。原始证据页也不能绕过字段权限。</li><li>下载文件私有存储、短期保留、自动清理;审计记录版本、范围、模式、公式版本、结果及文件哈希,不记录患者原值。</li></ul><h3>任务化导出</h3><p>保留旧同步证据入口的行为;新增公式版任务状态:queued → running → succeeded / failed / expired。请求校验后冻结数据版本、映射、筛选、权限范围、参数和公式版本。worker 用分块迭代处理,临时文件原子完成后才发布;支持超时、失败重试、去重请求、崩溃恢复和过期清理。</p><p>真实附件 332,830 行未超过 Excel 单表 1,048,576 行的限制,但当前接口的 100,000 行限制仍然存在。完整公式版必须另做容量评估,超过支持范围应显式拒绝或提示缩小范围,不能悄悄截断。</p><p>性能基准至少覆盖 1,000 / 100,000 / 332,830 行,测导出耗时、峰值内存、文件大小、首次打开、改值重算、并发排队。记录设备、Excel 版本和数据分组基数;本轮不虚构 SLA。高基数分组达到预算时提示缩小报表范围,不降级为静态结果却仍标“公式版”。</p></section>
<section id="implementation"><h2>08 / 文件级实施步骤</h2><div class="table-wrap"><table><thead><tr><th>阶段</th><th>工作与目标文件</th><th>完成条件 / 依赖</th></tr></thead><tbody><tr><td>P0 · 口径冻结<br>估计 1–2 人日</td><td>复核 <code>finance/analysis.py</code>、<code>finance/importing.py</code> 与原附件;定义精度政策、公式清单、编辑边界及兼容矩阵;确认毛利来源恢复。</td><td>财务确认收入 N / 实收 I、原价、退款、自营产品、成本与 ROI;否则不可冻结黄金结果。</td></tr><tr><td>P1 · 最小公式工作簿<br>估计 2–4 人日</td><td>新增 <code>finance/exporting.py</code> 集中生成模板、状态和公式;复用现有分析口径,不引入通用公式 DSL。先实现小样本汇总、明细、参数、说明和对账。</td><td>每个衍生单元格可追溯;输入/公式/文本写入类型分离;小样本真实 Excel 重算通过。</td></tr><tr><td>P2 · 精度与隐私<br>估计 1–2 人日</td><td>同模块实现精确文本、数值转换预检、毛利来源、客户 token、来源定位和注入防护;增加针对性单元测试。</td><td>Decimal 对账、去重、缺失、恶意文本及越权用例通过;不破坏证据版。</td></tr><tr><td>P3 · 任务与 API<br>估计 2–3 人日</td><td>在 <code>finance/models.py</code> 增加必要的导出任务与迁移;在 <code>finance/views.py</code> 及现有 URL 配置增加提交/查询/下载入口;新增导出 worker 命令,复用导入 worker 的部署模式。</td><td>完整附件能异步生成;崩溃恢复、重试、权限撤销、留存清理可验证。schema_version、mode、context、compatibility 等字段由服务端白名单校验。</td></tr><tr><td>P4 · 网页交互<br>估计 1–2 人日</td><td><code>frontend/src/dialogs.tsx</code>、<code>types.ts</code>、<code>api.ts</code> 按需调整:模式选择、兼容性与精度说明、任务状态、错误反馈、下载和重试。</td><td>桌面/手机可用;不显示无依据百分比;错误不丢失筛选上下文;CSV 与旧 XLSX 行为清晰保留。</td></tr><tr><td>P5 · 引擎与全量验收<br>估计 3–5 人日</td><td>新增 <code>tests/test_exporting.py</code>、扩展 <code>tests/e2e/workspace.spec.ts</code>;编写受控重算与差异报告脚本;更新 <code>docs/acceptance.md</code>。</td><td>下节所有适用门禁有可复现记录;33 万行性能验证及财务签字完成。</td></tr></tbody></table></div><p>合计粗估 <strong>10–18 人日</strong>,用于排期,不是承诺。受控 Excel 测试环境、财务确认和超预算性能优化可能增加日历时间。P1 小样本原型通过后再投入大文件任务流程;各阶段保持可单独审查和回滚。</p><p>兼容 API:旧 <code>format=xlsx</code> 默认仍为证据快照;新公式模式显式申请任务并返回任务 ID。服务端返回实际导出模式与版本,前端不凭文件后缀推断。</p></section>
<section id="tests"><h2>09 / 严格测试与上线门禁</h2><div class="notice"><strong>写入公式不等于公式算对。</strong><p>openpyxl 不执行计算;设置自动重算或 fullCalcOnLoad 也不是测试结果。必须在目标 Excel 引擎执行、保存,再同时读取公式和结果缓存验证。</p></div><div class="table-wrap"><table><thead><tr><th>测试层</th><th>必须覆盖的场景</th><th>通过标准</th></tr></thead><tbody><tr><td>结构与公式覆盖</td><td>每页、命名范围、Table、单元格类型、公式依赖、说明及数据验证;空数据导出。</td><td>衍生区域全覆盖真实公式,基础输入无意外公式;无循环引用、损坏修复提示或外部链接。</td></tr><tr><td>财务黄金样例</td><td>两种 ROI、direct/lookup 毛利、退款、负数、0、缺成本、部分缺字段、缺期、基期0、新增/消失分组。</td><td>与独立 Decimal 预期一致到批准精度;人数完全相等;缺失不能变0。</td></tr><tr><td>客户去重</td><td>同人跨科室/产品/月,重复明细、自营与非自营交集、空客户字段。</td><td>总体不相加子组人数;升单率分子去重正确;缺客户状态与系统一致。</td></tr><tr><td>真实引擎重算</td><td>Excel Windows / Mac;打开重算保存;分别读取 formula 与 data_only;修改收入、成本、原价及毛利率后再次计算。</td><td>受影响链路变化符合独立预期,非依赖指标不误变。扫描 #REF!、#DIV/0!、#VALUE!、#NAME?、#SPILL!;仅允许明确缺失状态对应的 #N/A。</td></tr><tr><td>精度极限</td><td>15位边界、24位小数、极大金额、正负抵消、舍入临界值、33万行累计差异。</td><td>精确文本不变;超出政策明确报警或拒绝;不通过放宽误差掩盖错误。</td></tr><tr><td>注入与越权</td><td>恶意标签/原始公式、工作表名特殊字符、跨组织/科室、任务 ID 猜测、下载前撤权。</td><td>无执行不可信公式、外链或敏感原值泄露;未授权下载拒绝并审计。</td></tr><tr><td>任务可靠性</td><td>重复提交、worker中断、磁盘不足、超时、并发、到期清理。</td><td>无半成品下载、孤儿文件和静默截断;失败可解释,恢复无重复发布。</td></tr><tr><td>前端与兼容回归</td><td>两模式选择、状态、错误重试、下载、移动端;现有 CSV/XLSX。</td><td>浏览器端到端通过;旧证据版仍保持精度与公式注入防护。新公式版测试只允许白名单公式,不删除旧安全断言。</td></tr><tr><td>真实附件全量</td><td>332,830行;2025-08/2026-08及原映射;全量与分组对账。</td><td>行数不缺失,金额满足政策,人数为33,147 / 38,361;缺成本 ROI 不可用,未配置自营范围不擅自补值。</td></tr></tbody></table></div><h3>发布清单(全部待实施验证)</h3><ul><li>□ 口径、精度、编辑范围、支持引擎由财务及产品负责人确认。</li><li>□ 所有指标有公式覆盖与独立数值断言,修改输入后的重算链通过。</li><li>□ 真实 Excel 重算证据包含引擎版本、脚本或步骤、输入哈希、结果与误差报告;LibreOffice 只能证明其自身兼容性。</li><li>□ 原附件全量导出、权限、安全和资源预算通过;大文件首开及改值重算满足商定目标。</li><li>□ 使用说明、列字典、异常状态与原始证据齐全,无患者原始标识泄露。</li><li>□ 灰度开关、失败回退、审计、清理、任务运行监控与运维说明就绪。</li></ul><p>上线先小范围授权用户试用公式模式,旧证据导出保留作为回退。只有 Excel 导出门禁通过,不意味着整个财务系统所有商业上线门禁自动满足。</p></section>
<section id="questions"><h2>10 / 需确认的业务决策</h2><div class="grid"><div><h3>可直接采用的工程默认</h3><ul><li>双模式并存,公式版 XLSX 不含宏和外链。</li><li>精确证据 + 数值计算 + 对账,不牺牲原始精度。</li><li>Microsoft 365 为首版公式验收目标。</li><li>固定范围、指定输入可改,结构变化重新导出。</li><li>大文件异步、授权下载、客户去标识。</li></ul></div><div><h3>上线前必须由财务确认</h3><ul><li>收入继续使用 N 列,还是另需 I 列实收口径?</li><li>舍入在每行还是最终汇总发生?使用哪种舍入规则与容差?</li><li>毛利率允许本地模拟吗?直接毛利是否允许修改?</li><li>自营产品集合、退款计人、成本归属及 ROI 口径是什么?</li><li>是否必须支持旧版 Excel/WPS,以及首次打开前就有结果缓存?</li></ul></div></div><p class="recommend"><strong>建议实施顺序:</strong>先确认口径与 Excel 版本 → 小样本真正公式与重算验证 → 33 万行任务化及性能测试 → 前端完整交互与财务签字。不要先把导出值改成数值、加几条 SUM,就标记需求完成。</p></section>
</main><footer>本文件是实施计划。现有 XLSX 导出仍为文本证据快照;本轮没有新增公式导出功能,也没有执行尚未实现功能的验收。</footer>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TraceIQ 完善项与执行路线图</title>
<style>
:root{color-scheme:light;--ink:#202725;--muted:#58645f;--line:#d8dfdc;--green:#17664f;--amber:#9a5200;--red:#a72f3d;--bg:#f5f7f6;--blue:#315d78}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.7 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}header{background:#fff;border-bottom:3px solid var(--green);padding:28px 20px}main,header>div,footer{max-width:1120px;margin:auto}main{padding:0 20px 40px}h1{font-size:30px;margin:4px 0 8px}h2{font-size:21px;margin:0 0 12px}h3{font-size:17px;margin:0 0 7px}.meta,.muted{color:var(--muted);font-size:13px}.notice{padding:15px 18px;margin:20px 0;background:#fff4e5;border-left:4px solid var(--amber)}section{padding:24px 0;border-bottom:1px solid var(--line)}.item{background:#fff;padding:16px 18px;margin:12px 0;border-left:4px solid var(--line)}.p0{border-color:var(--red)}.p1{border-color:var(--amber)}.p2{border-color:var(--green)}.tag{font-size:12px;font-weight:700;color:var(--muted)}table{border-collapse:collapse;width:100%;background:#fff}th,td{padding:10px 12px;border:1px solid var(--line);text-align:left;vertical-align:top}th{background:#eaf0ed}code{overflow-wrap:anywhere}footer{padding:22px 20px 36px;color:var(--muted)}@media(max-width:680px){h1{font-size:25px}table{font-size:13px}th,td{padding:8px}}
</style>
</head>
<body>
<header><div><div class="meta">TRACEIQ / 优先级核查与执行 / 2026-09-24</div><h1>完善项与执行路线图</h1><p>以当前源码、自动化测试和可访问环境为依据。测试通过不等同于业务签字或生产上线。</p></div></header>
<main>
<div class="notice"><strong>本轮基线:</strong>后端 40 passed,finance 覆盖率 86.72%(85% 门槛);隔离 SQLite Playwright 3 passed、原附件专测 1 skipped;Ruff、格式、Django system check、迁移检查、TypeScript/Vite 构建、npm audit(0 漏洞)与 pip-audit 通过。production 模式 Django <code>check --deploy</code> 零告警。Docker daemon 未运行,本机无 LibreOffice。Microsoft Excel、332,830 行公式工作簿、真实 AI/合规及商业部署仍未验收。</div>
<section><h2>P0 · 先确保验收证据可信</h2>
<article class="item p0"><div class="tag">P0.1 · 已完成 / 已复核</div><h3>维护真实的源码与验收基线</h3><p>旧文档曾错误声称 <code>tests/test_finance.py</code> 为空并把公式导出标记为未实现;当前源码有 40 项后端测试,公式工作簿、说明页、对话新增列、追问与下载均已实现。本轮路线图按当前代码、测试结果和外部门禁重建。</p></article>
<article class="item p0"><div class="tag">P0.2 · 已修复并通过专项回归</div><h3>隐藏导出任务的内部异常</h3><p>状态接口不再把 worker 异常文本发给用户,只返回通用失败提示;前端显示任务编号,数据库保留内部诊断。失败任务脱敏、临时文件删除及状态访问专项 8 passed。</p></article>
<article class="item p0"><div class="tag">P0.3 · 本机生产配置检查通过</div><h3>验证 production 部署安全配置</h3><p>production 模式、有效测试密钥、受信 HTTPS 代理下 <code>manage.py check --deploy</code> 零告警。真实域名、TLS 终止及代理头行为仍需部署环境验收。</p></article>
<article class="item p0"><div class="tag">P0.4 · 外部目标引擎</div><h3>Microsoft Excel 公式重算与兼容验收</h3><p>目标 Excel Windows/Mac 打开、重算、保存并核对公式及缓存;覆盖缺期、零分母、退款/负值、ROI、毛利来源和输入修改依赖。LibreOffice/openpyxl 不能替代目标 Excel 证据。</p></article>
<article class="item p0"><div class="tag">P0.5 · 真实数据与资源基准</div><h3>332,830 行完整公式工作簿</h3><p>公式导出当前限制 100,000 行/200 组。用原附件和可靠 PostgreSQL 验证行数、金额、人数、耗时、峰值内存、文件大小及 Excel 首开/重算;未完成前保持显式拒绝,不静默截断或放宽上限。</p></article>
<article class="item p0"><div class="tag">P0.6 · 外部业务/安全责任人</div><h3>财务口径、真实 AI 与商业发布批准</h3><p>签署实际收入与实收、退款、成本、毛利、ROI、自营集合及舍入规则;审批外部模型数据处理/出境并演练故障;完成生产密钥、TLS、备份恢复、SLA、渗透测试和商业许可证核验。审批前 AI 保持关闭。</p></article>
</section>
<section><h2>P1 · 代码层可靠性与可用性</h2>
<article class="item p1"><div class="tag">P1.1 · Docker daemon 未运行</div><h3>镜像与 Compose 运行验收</h3><p>Docker CLI 可用但 daemon 不可连接,本机暂不能构建或启动。daemon 可用后验证镜像、健康检查、worker、清理服务与重启恢复;远端 CI 需仓库平台实际触发。</p></article>
<article class="item p1"><div class="tag">P1.2 · 产品范围待确认</div><h3>自定义指标定义保存与复用</h3><p>定义当前只用于当次工作簿,不持久化到主分析页或跨会话定义库。需明确个人/组织共享、版本审批、删除和审计规则,再开发持久化。</p></article>
<article class="item p1"><div class="tag">P1.3 · 需产品确认后开发</div><h3>大数据与高基数交互</h3><p>当前汇总按请求返回分组、证据使用分页;AG Grid SSRM/Pivot/Tree Data 等计划功能尚未实现。先以目标数据规模和典型交互决定是否投入服务端行模型,避免未经验证引入商业授权与运维负担。</p></article>
</section>
<section><h2>P2 · 范围扩展</h2>
<article class="item p2"><div class="tag">P2.1 · 财务定义确认后</div><h3>扩展公式语言和缺失策略</h3><p>当前白名单仅支持现有指标/常数和加减乘除,最大 8 层;不接受任意 Excel 文本、字段引用、跨明细新聚合、按零替代或跳过缺失。每项扩展需独立财务黄金样例、注入测试和公式重算验证。</p></article>
<article class="item p2"><div class="tag">P2.2 · 目标明确后</div><h3>分析及工作簿操作增强</h3><p>可评估历史分位异常、告警、证据期间切换、明细表结构变更后的公式扩展、模板版本和工作簿内定义管理;需先给出场景和验收指标。</p></article>
</section>
<section><h2>本轮执行结果</h2><table><thead><tr><th>项目</th><th>结果</th><th>剩余门禁</th></tr></thead><tbody><tr><td>源码与路线图基线</td><td>已完成;纠正文档过时状态</td><td>以当前代码/测试为准</td></tr><tr><td>导出错误脱敏</td><td>8 项专项后端测试通过</td><td>内部诊断仅保留服务端</td></tr><tr><td>Production deploy checks</td><td>零告警</td><td>真实域名、TLS/代理验收</td></tr><tr><td>全量本机回归</td><td>40 passed / 86.72%;Playwright 3 passed、1 skipped</td><td>原附件场景需配置隔离数据集</td></tr><tr><td>商业发布门禁</td><td>尚未通过</td><td>Excel、真实规模、业务/AI审批、容器和灾备</td></tr></tbody></table></section>
<section><h2>发布判定原则</h2><p>本地质量门禁全部通过,只证明自动化覆盖范围内的代码状态。商用发布还需要 Microsoft Excel 结果、真实规模资源记录、财务口径签字、AI 数据处理批准和目标基础设施安全/灾备验证。任何一项未满足时,状态保持“未完成发布验收”。</p></section>
</main><footer>本文件按核查日建立,执行结果以随后实际运行的命令和目标环境记录更新。</footer>
</body>
</html>
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>财务 Agent 改进路线图</title>
<style>
:root{color-scheme:light;--ink:#202725;--muted:#58645f;--line:#d8dfdc;--green:#17664f;--amber:#9a5200;--red:#a72f3d;--bg:#f5f7f6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.75 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}header{background:#fff;border-bottom:3px solid var(--green);padding:28px 20px}main,header>div,footer{max-width:1080px;margin:auto}main{padding:0 20px 36px}h1{font-size:30px;margin:4px 0 8px}h2{font-size:21px;margin:0 0 12px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0}.meta{color:var(--muted);font-size:13px}.status{padding:14px 18px;margin:20px 0;background:#fff4e5;border-left:4px solid var(--amber)}section{padding:24px 0;border-bottom:1px solid var(--line)}.item{padding:16px 18px;margin:12px 0;background:#fff;border-left:4px solid var(--line)}.p0{border-color:var(--red)}.p1{border-color:var(--amber)}.p2{border-color:var(--green)}.tag{font-size:12px;font-weight:700;color:var(--muted)}table{border-collapse:collapse;width:100%;background:#fff}th,td{padding:10px 12px;border:1px solid var(--line);text-align:left;vertical-align:top}th{background:#eaf0ed}code{overflow-wrap:anywhere}footer{padding:22px 20px 36px;color:var(--muted)}@media(max-width:640px){h1{font-size:25px}table{font-size:13px}th,td{padding:8px}}
</style>
</head>
<body>
<header><div><div class="meta">TRACEIQ / 工程质量与产品改进 / 2026-09-24</div><h1>财务 Agent 改进路线图</h1><p>按财务正确性、测试可信度、业务覆盖和发布风险排序;实现状态与验收状态分开记录。</p></div></header>
<main>
<div class="status"><strong>当前结论:</strong>对话新增指标、缺项追问、白名单公式编译与公式工作簿已实现;本轮专项后端 6 项、Ruff、前端类型检查及生产构建通过。生产发布仍被空的历史测试源码、公式引擎重算、真实附件规模和财务/AI 合规审批阻断。</div>
<section><h2>P0 · 发布阻断</h2>
<article class="item p0"><div class="tag">P0.1 · 阻断中</div><h3>恢复完整源码测试基线</h3><p><code>tests/test_finance.py</code> 当前为 0 字节,导致既有财务、权限、导入、分析与公式导出回归测试缺失。先从可靠备份恢复,再运行 pytest、Ruff、Django、迁移、生产构建及 Playwright。旧 .pyc 的 69 passed、1 failed、1 skipped 使用旧断言,不是当前源码验收证据。</p></article>
<article class="item p0"><div class="tag">P0.2 · LibreOffice 自定义公式与缓存已验证;Excel 待验</div><h3>公式/缓存一致性验证</h3><p>人数逻辑使用 CustomerRoster + <code>COUNTIFS</code>。本轮从当前源码生成基础及新增列工作簿,交由 LibreOffice 重算后读取;含自定义列的文件保留 136 个公式单元格,自定义“收入成本差”公式缓存及汇总引用缓存均为 2,000。仍需补足缺失期、零分母及负值等边界对账,并在 Microsoft Excel 桌面版复验。</p></article>
<article class="item p0"><div class="tag">P0.3 · 待目标环境</div><h3>Microsoft Excel Windows/Mac 验收</h3><p>在目标桌面 Excel 打开、强制重算、保存,读取公式和结果缓存;覆盖缺期、零人数、退款/负数、两种 ROI、毛利来源及编辑金额后的依赖变化。</p></article>
<article class="item p0"><div class="tag">P0.4 · 范围阻断</div><h3>332,830 行附件全量导出与资源预算</h3><p>当前上限 100,000 行/200 组。用原映射和可靠 PostgreSQL 验证完整导出、行数/金额/人数对账、耗时、峰值内存、文件体积及 Excel 首开/重算;未经实测不放宽限制,不静默截断。</p></article>
<article class="item p0"><div class="tag">P0.5 · 外部决策</div><h3>财务口径与 AI 合规</h3><p>签署实际收入/实收、退款、成本归属、毛利、ROI、自营范围和舍入口径;完成外部模型联调、隐私/数据出境审批及故障演练。批准前保持外部 AI 关闭。</p></article>
</section>
<section><h2>P1 · 可靠性与运行质量</h2>
<article class="item p1"><div class="tag">P1.1 · 已实现;专项 3 项通过</div><h3>过期工作簿与孤儿临时文件清理</h3><p>新增 <code>cleanup_formula_exports</code> 管理命令和 Compose 常驻清理服务。仅清理过期成品及超过 24 小时的临时文件,限制导出根目录,跳过符号链接;清理异常保留重试线索。已覆盖过期文件、临时文件年龄和目录外路径测试。</p></article>
<article class="item p1"><div class="tag">P1.2 · 功能实现;单测及新增列 E2E 通过</div><h3>对话定义新增汇总列并导出公式</h3><p>用户可描述列名、含义、计算、范围及缺失/零分母策略;缺项时模型必须追问,确认后服务端只接受白名单表达式树并编译为 Excel 公式。工作簿包含汇总及自定义指标页、使用说明和对账页。专项单测覆盖追问、表达式拒绝与公式输出;本轮 Playwright 已验证登录、字段导入、缺项追问、补充确认、工作簿生成和下载。</p></article>
<article class="item p1"><div class="tag">P1.3 · 待目标环境</div><h3>部署、安全与灾备验收</h3><p>完成镜像构建、TLS、数据库认证、备份恢复和 RPO/RTO 演练、并发/SLA 测试、渗透测试、密钥轮换、审计告警及商用依赖许可证核查。</p></article>
</section>
<section><h2>P2 · 产品能力扩展</h2>
<article class="item p2"><div class="tag">P2.1</div><h3>已签口径下补充分析能力</h3><p>多收入字段与消费比率、生效期毛利率、退款切换、结构占比、历史异常阈值和告警投递。</p></article>
<article class="item p2"><div class="tag">P2.2</div><h3>自定义指标定义复用</h3><p>定义保存/版本与差异审阅;新运算符、聚合或缺失策略须通过白名单、独立财务样例和安全审查。</p></article>
<article class="item p2"><div class="tag">P2.3</div><h3>工作簿及跨平台体验</h3><p>证据期间切换、平板与无障碍优化、可读的公式错误状态、更多电子表格引擎兼容验证。</p></article>
</section>
<section><h2>完成门禁</h2><table><thead><tr><th>门禁</th><th>证据</th><th>状态</th></tr></thead><tbody>
<tr><td>源码测试</td><td>恢复原测试文件并完整执行</td><td>阻断:源码缺失</td></tr>
<tr><td>静态与构建</td><td>Ruff、Django、迁移、前端构建</td><td>本轮独立复跑通过;源码恢复后仍需联跑</td></tr>
<tr><td>网页工作流</td><td>新增指标、桌面、手机 Playwright</td><td>3 项通过,1 项原附件全量场景按配置跳过</td></tr>
<tr><td>公式重算</td><td>当前版本 LibreOffice 与 Microsoft Excel</td><td>LibreOffice 自定义公式重算/缓存通过;边界样例与 Excel 待验</td></tr>
<tr><td>真实规模</td><td>原附件全量导出与资源报告</td><td>待验;当前上限 100,000 行</td></tr>
<tr><td>商业发布</td><td>财务签字、AI 合规、安全与灾备</td><td>待外部审批/目标环境</td></tr>
</tbody></table></section>
</main><footer>执行顺序:P0.1 → P0.2 → P0.3/P0.4 → P0.5 → P1 → P2。外部环境和审批不可用时保留为明确门禁,不以模拟结果替代验收。</footer>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TraceIQ 财务 Agent 改进路线图</title>
<style>
:root{color-scheme:light;--ink:#202725;--muted:#58645f;--line:#d8dfdc;--green:#17664f;--amber:#9a5200;--red:#a72f3d;--bg:#f5f7f6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.7 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}header{background:#fff;border-bottom:3px solid var(--green);padding:26px 20px}header>div,main,footer{max-width:1080px;margin:auto}main{padding:0 20px 36px}h1{font-size:30px;margin:4px 0 8px}h2{font-size:21px;margin:0 0 12px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0}.meta,.tag{color:var(--muted);font-size:13px}.status{padding:14px 18px;margin:20px 0;background:#fff4e5;border-left:4px solid var(--amber)}section{padding:22px 0;border-bottom:1px solid var(--line)}.item{padding:15px 18px;margin:12px 0;background:#fff;border-left:4px solid var(--line)}.p0{border-color:var(--red)}.p1{border-color:var(--amber)}.p2{border-color:var(--green)}.tag{font-weight:700}table{border-collapse:collapse;width:100%;background:#fff}th,td{padding:9px 11px;border:1px solid var(--line);text-align:left;vertical-align:top}th{background:#eaf0ed}code{overflow-wrap:anywhere}footer{padding:22px 20px 36px;color:var(--muted)}@media(max-width:640px){h1{font-size:25px}table{font-size:13px}th,td{padding:7px}}
</style>
</head>
<body>
<header><div><div class="meta">TRACEIQ / 工程质量与产品改进 / 2026-09-24</div><h1>财务 Agent 改进路线图</h1><p>依据当前仓库和本轮实测重排;实现状态、外部验收与生产资格分别记录。</p></div></header>
<main>
<div class="status"><strong>当前结论:</strong>公式导出审查已修复文本条件、未分类对账、公式注入、任务路径、文件权限与失败恢复问题,并改为流式写入;后端 61 passed、finance 覆盖率 87.66%,LibreOfficeDev 公式重算共 4 项通过。100,000 行产品上限实测峰值 RSS 约 136.4 MB。Microsoft Excel 实机、原附件 332,830 行范围方案、真实 AI/财务审批和目标环境验收仍未完成,不能认定具备生产商用资格。</div>
<section><h2>P0 · 正确性与发布阻断</h2>
<article class="item p0"><div class="tag">P0.1 · 本机 LibreOffice 回归通过</div><h3>公式重算建立可重复黄金测试</h3><p>当前共 4 项 LibreOfficeDev 重算测试:test_formula_recalculation.py 3 项、test_formula_recalculation_gross.py 1 项,覆盖自定义汇总及变化率、明细收入编辑联动、零成本 ROI、缺失基期及 direct/lookup 毛利来源。它们只证明这些具体样例在 LibreOffice 中的结果,不代表 Microsoft Excel 兼容或财务签字。</p></article>
<article class="item p0"><div class="tag">P0.2 · 目标环境门禁</div><h3>Microsoft Excel Windows / Mac 实机验收</h3><p>本机曾以 Microsoft Excel 打开测试工作簿,公式栏确认汇总公式包含 COUNTIFS / SUMIFS;但 Excel 显示只读(View Only / 需激活 Microsoft 365),无法保存重算副本,且检查到的样例分组为 Test、与预期不匹配。故仅有“公式可见”的证据,Excel 重算结果、缓存和业务样例均未验收。后续须在可编辑的目标 Windows/Mac Excel 中用受控正确样例强制重算、保存,并核验函数兼容、数据表引用、错误单元格和输入修改依赖链。</p></article>
<article class="item p0"><div class="tag">P0.3 · 100,000 行产品上限已优化并实测</div><h3>流式公式工作簿与原附件范围决策</h3><p>公式版本 3 已将所有工作表改为 openpyxl write-only 顺序写入,同时保留 Data 与 CustomerRoster 表格、结构化引用、说明、证据和对账。隔离 SQLite 合成基准:20,000 行 5.26 秒、3.27 MB、峰值 RSS 77.9 MB;100,000 行 25.96 秒、16.22 MB、峰值 RSS 136.4 MB,ZIP/OOXML 完整性及公式读取通过。生产限制仍为 100,000 行/200 组;真实附件 332,830 行不在当前产品范围,下一步必须选择可审计分卷或明确提高上限后再做原附件流式资源、打开和重算验收。</p></article>
<article class="item p0"><div class="tag">P0.4 · 财务审批</div><h3>冻结收入、退款、毛利及 ROI 口径</h3><p>由财务负责人签署 N 列实际收入与 I 列实收、原价、退款、成本归属、毛利率、自营产品集、升单定义与舍入精度。原附件成本全缺,ROI 应维持不可用;不得以技术测试替代业务签字。</p></article>
<article class="item p0"><div class="tag">P0.5 · 隐私 / 模型外部审批</div><h3>外部 AI 真实联调与数据处理审批</h3><p>在批准前保持 AI 关闭。审批后验证模型版本、超时、限流、拒答、审计脱敏、数据处理与出境依据;模型只提出列定义,服务端继续严格校验白名单表达式并要求用户确认。</p></article>
</section>
<section><h2>P1 · 商用运维与可恢复性</h2>
<article class="item p1"><div class="tag">P1.1 · 本地待执行 / 环境受限</div><h3>Docker 镜像与生产 Compose 验收</h3><p>本轮完整生产变量下 Django <code>check --deploy</code> 无告警,依赖审计为零已知漏洞;Docker daemon 当前不可连接,镜像构建尚未验证。daemon 恢复后构建镜像、运行迁移和健康检查,确认非 root、只读根文件系统、卷权限及 worker。</p></article>
<article class="item p1"><div class="tag">P1.2 · 目标环境门禁</div><h3>备份恢复、并发和安全运营</h3><p>验收 TLS、密钥轮换、最小权限、加密备份及恢复 RPO/RTO、队列积压/失败告警、访问日志脱敏、容量阈值、并发 SLA 和独立安全审查;AG Grid Enterprise 使用前确认商业许可。</p></article>
<article class="item p1"><div class="tag">P1.3 · 部分已实现;范围受限</div><h3>对话定义新增汇总列并导出公式</h3><p>用户可描述列名、含义、计算、适用范围及缺失处理;信息不全时追问并结合完整对话复核。公式只使用数据集当前可计算的既有指标与白名单运算,服务端再次校验数据映射;确认后写入公式工作簿并带说明。任意 Excel 函数、跨明细新聚合、定义保存复用及导出后自动扩行未实现。</p></article>
</section>
<section><h2>P2 · 扩展能力</h2>
<article class="item p2"><div class="tag">P2.1</div><h3>签字口径下扩展分析</h3><p>多收入字段与消费比率、生效期毛利率、退款策略、结构占比、历史异常阈值和告警投递。</p></article>
<article class="item p2"><div class="tag">P2.2</div><h3>跨平台与辅助功能</h3><p>补齐平板、键盘/屏幕阅读器、证据期间切换和目标表格软件兼容矩阵;Web 可复用不等同于 Excel 文件跨引擎兼容。</p></article>
</section>
<section><h2>本轮证据</h2><table><thead><tr><th>项目</th><th>结果</th><th>边界</th></tr></thead><tbody>
<tr><td>后端及覆盖率</td><td>61 passed;finance 87.66%</td><td>SQLite 测试;包含流式工作簿、公式语义、注入防护、任务路径/权限/恢复及 LibreOffice 重算;未替代 PostgreSQL CI</td></tr>
<tr><td>LibreOffice 公式重算</td><td>共 4 项通过(test_formula_recalculation.py 3 项、test_formula_recalculation_gross.py 1 项),direct/lookup 毛利来源测试包含在这 4 项内</td><td>LibreOfficeDev;Microsoft Excel 仅确认公式可见,未完成可保存重算验收</td></tr>
<tr><td>流式容量基准</td><td>100,000 行:25.96 秒、16.22 MB、峰值 RSS 136.4 MB;20,000 行:5.26 秒、3.27 MB、峰值 RSS 77.9 MB</td><td>隔离 SQLite 合成数据,本机单次测量;生产上限保持 100,000 行,不能替代目标环境并发 SLA</td></tr>
<tr><td>浏览器回归</td><td>3 passed,1 skipped</td><td>跳过需显式指定附件的原附件 E2E</td></tr>
<tr><td>构建 / 静态检查</td><td>Ruff、Prettier、TypeScript/Vite、Django check、迁移检查通过</td><td>依赖审计报告 0 个已知漏洞;不等于渗透测试</td></tr>
<tr><td>生产配置检查</td><td>完整临时生产配置下 <code>check --deploy</code> 无告警</td><td>不代表目标基础设施上线验收</td></tr>
<tr><td>Compose / Docker</td><td>未通过</td><td>仓库无真实 .env;Docker daemon 未运行,未构建镜像</td></tr>
<tr><td>Excel / 原附件公式</td><td>Excel 打开样本并确认公式存在;PostgreSQL 对账完成;旧非流式版本曾生成 332,830 行</td><td>Excel 只读,无法保存重算副本;所见分组与预期不匹配。原附件超过 100,000 行产品上限,公式版本 3 尚未对 332,830 行做越界放宽测试</td></tr>
</tbody></table></section>
<section><h2>执行顺序与完成规则</h2><p>顺序:维护 P0.1 黄金重算 → P0.2 Microsoft Excel → P0.3 资源优化 → P0.4 / P0.5 外部审批 → P1 → P2。本轮补齐了可重复的金额编辑、零成本与缺失基期重算测试,并复核新增列映射保护。LibreOffice 不替代 Excel,财务/AI 审批和目标基础设施测试需对应责任方及环境。</p><p>测试必须覆盖代码实际行为;每项门禁记录日期、环境、版本、样例、结果和限制。不能用 mock、静态公式存在或 build 通过声称生产已验收。</p></section>
</main><footer>路线图是执行与验收记录,不是生产准入授权。详见 <a href="acceptance.md">验收状态与上线门禁</a>。</footer>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>财务 Agent:剩余问题与修复清单 · 2026-09-26</title>
<style>
:root{color-scheme:light;--ink:#202925;--muted:#57635d;--line:#d7dfda;--green:#17664f;--red:#a52b40;--amber:#875408}*{box-sizing:border-box}body{margin:0;background:#f7f9f8;color:var(--ink);font:15px/1.8 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;letter-spacing:0}header{background:#fff;border-bottom:3px solid var(--green);padding:32px 22px}header>div,main,footer{max-width:1120px;margin:auto}main{padding:0 22px 40px}h1{font-size:30px;line-height:1.4;margin:8px 0}h2{font-size:23px;margin:0 0 14px}h3{font-size:18px;margin:0 0 10px}p{margin:8px 0}small,.muted{color:var(--muted)}nav{display:flex;flex-wrap:wrap;gap:10px 22px;padding:18px 0}a{color:var(--green);text-underline-offset:3px}section{padding:25px 0;border-bottom:1px solid var(--line)}article{padding:18px 0;border-top:1px solid var(--line)}article:first-of-type{border-top:0}.notice{padding:16px 20px;border-left:4px solid var(--amber);background:#fff4de;margin:20px 0}.critical{border-color:var(--red);background:#fff0f1}.ok{color:var(--green)}.p0{color:var(--red)}.p1{color:var(--amber)}.tag{font-size:13px;font-weight:700}table{width:100%;border-collapse:collapse;background:#fff}th,td{padding:11px;border:1px solid var(--line);text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#eaf0ed}ul,ol{padding-left:23px}li{margin:6px 0}code{font-size:13px;overflow-wrap:anywhere}pre{white-space:pre-wrap;background:#edf1ef;padding:14px;overflow-wrap:anywhere}.table-wrap{overflow-x:auto}footer{padding:24px 22px;color:var(--muted)}@media(max-width:640px){h1{font-size:25px}h2{font-size:21px}main{padding-inline:16px}th,td{padding:8px;font-size:13px}.table-wrap table{min-width:650px}}@media print{body{background:#fff}nav{display:none}section,article{break-inside:auto}h2,h3{break-after:avoid}.table-wrap{overflow:visible}a{color:inherit}header{padding:10px 0}main{padding:0}.notice{border:1px solid #777}}
</style>
</head>
<body>
<header><div><small>TRACEIQ / 工程现状与发布门禁 / 2026-09-26 / Asia/Singapore</small><h1>财务 Agent:剩余问题与修复清单</h1><p>面向下一轮开发、财务验收及生产部署。按当前源码和最近完成的测试记录整理,不将历史计划或测试通过等同于商用准入。</p></div></header>
<main>
<nav aria-label="报告目录"><a href="#summary">结论</a><a href="#baseline">已完成与测试</a><a href="#bugs">需要修复的代码问题</a><a href="#gaps">剩余功能</a><a href="#gates">外部门禁</a><a href="#sequence">推荐顺序</a><a href="#acceptance">验收清单</a></nav>
<section id="summary"><h2>一、结论与阅读方式</h2>
<div class="notice critical"><strong>目前不能认定达到生产环境商用标准。</strong><p>公式导出、说明页、对话追问、新增公式列、个人定义保存复用与证据分页已经实现;仍有需要修复的安全/交互边界、未完成的产品能力,以及必须在目标 Excel、数据库、基础设施和业务审批环境关闭的门禁。</p></div>
<p><strong>本次交付仅新增本报告,不修改业务代码、不执行新的数据库迁移。</strong>以下“已完成”包括此前连续开发完成的改动;“静态确认”指源码可直接看出的行为,尚未新增专项复现测试,不能冒充测试复现结果。</p>
<ul><li><strong>P0:</strong>影响敏感文件安全、核心财务正确性或生产准入,正式上线前必须关闭。</li><li><strong>P1:</strong>影响日常操作可靠性、可解释性、协作和可维护性,应在受控试用前或紧接其后完成。</li><li><strong>P2:</strong>扩展能力,按合同范围和真实业务优先级开发,不因存在依赖或设计稿就视为已实现。</li></ul>
</section>
<section id="baseline"><h2>二、当前已经完成什么</h2>
<div class="table-wrap"><table><thead><tr><th>能力</th><th>已完成状态</th><th>不能据此推断的内容</th></tr></thead><tbody>
<tr><td>公式工作簿</td><td>版本 3,流式生成计算明细、汇总、使用说明、参数口径、人数去重、原始证据、对账;有自定义列时增加自定义指标页。</td><td>不是任意 Excel 模板生成器;不是全部输入/结构变化都能正确重算。</td></tr>
<tr><td>对话新增列</td><td>描述不全时追问,补充后确认;服务端校验表达式树及数据映射可用性。支持既有指标、常数、加减乘除。</td><td>真实外部模型未完成联调;自然语言适用范围不等于执行任意筛选、聚合规则。</td></tr>
<tr><td>个人定义库</td><td>按用户与数据集隔离,保存、同名更新、列出、载入、删除;保存和导出时校验;迁移文件 0004 已存在,隔离测试库已执行。</td><td>不是团队共享定义库;没有草稿续聊、版本审批、并发编辑冲突保护。非测试环境是否迁移仍需核对。</td></tr>
<tr><td>证据与文件导出</td><td>本期/基期切换、每页 50 条及翻页;CSV/XLSX 证据以临时文件和 FileResponse 分块发送;金额文本和公式注入防护保留。</td><td>前端仍用 Blob 接收完整下载文件;临时磁盘/tmpfs、并发容量尚无生产 SLA。</td></tr>
<tr><td>导出可靠性</td><td>任务专属路径、0600 文件与 0700 目录、失败状态恢复、随机临时文件、授权范围检查、过期清理。</td><td>并非所有符号链接异常路径都安全,见 F01;也未完成磁盘故障及生产崩溃恢复演练。</td></tr>
</tbody></table></div>
<h3>最近完整测试记录</h3>
<ul><li><strong>后端:65 passed,95.12 秒;finance 覆盖率 87.63%,85% 门槛通过。</strong>SQLite 测试,包含定义库及导出回归。</li><li><strong>真实公式重算:4 项 LibreOffice 测试通过。</strong>覆盖自定义汇总、变化率、金额编辑联动、零成本、缺基期和 direct/lookup 毛利。不能替代 Microsoft Excel 实机。</li><li><strong>浏览器:3 passed,1 skipped,24.7 秒。</strong>新隔离数据库,覆盖追问、保存定义、关闭重开、复用后生成,另含桌面、手机、证据翻页及下载。AI 响应采用 mock;分页第 51 条交互使用 mock 响应,后端分页另有 API 测试。</li><li>跳过项:未设置 <code>REFERENCE_DATASET_NAME</code> 的原附件全量 UI 用例,不属于通过项。</li><li>此前已通过 Ruff lint/format、TypeScript/Vite、Django check、迁移一致性检查;2026-09-24 的 npm 生产依赖审计与 pip-audit 为零已知漏洞。这些不是本报告生成时重新运行的结果。</li><li>历史合成容量基准:100,000 行生成 25.96 秒,产物约 16.22 MB,峰值 RSS 约 136.4 MB;单机单次、非并发、非目标 Excel 重算性能。</li></ul>
<p class="muted">现有 acceptance.md / improvement-roadmap.html 仍存在 61 项测试、“没有定义库”等旧表述;历史 Excel 计划也有相互矛盾的状态提示。当前状态以本报告与最近完成的实测为准,后续应统一维护单一验收入口。</p>
</section>
<section id="bugs"><h2>三、需要继续修复的代码问题</h2>
<article><div class="tag p0">F01 · P0 · 静态确认 · 文件安全</div><h3>失败清理可能沿符号链接父目录删除非任务文件</h3><p><strong>位置:</strong><code>finance/exporting.py → process_formula_export()</code>。先设置 target,再检查 target.parent 是否为符号链接;拒绝后进入异常分支,仅检查 path.is_symlink(),未检查父目录。父目录为链接而最终文件本身为普通文件时,unlink 会跟随父目录删除链接目标下同名文件。</p><p><strong>影响:</strong>需要本地存储目录被操纵这一前提,不能描述为普通远程用户已可利用;但违背“异常清理只删除自己创建的文件”的边界。</p><p><strong>推荐修复:</strong>路径完整验证前不要将目标登记为可清理文件;只清理本次实际创建、且位于受控目录中的文件,必要时用目录描述符及不跟随链接的文件操作。不要用失败清理代替旧孤儿文件回收。</p><p><strong>验收:</strong>父目录链接到受保护目录,预置同名文件;执行必须失败且受保护文件字节不变,任务为 failed;覆盖根目录链接、目标链接、写盘失败与正常重试。</p></article>
<article><div class="tag p1">F02 · P1 · 静态确认 · 故障清理</div><h3>清理失败被忽略,敏感孤儿文件缺少回收路径</h3><p><strong>位置:</strong><code>finance/exporting.py</code> 与 <code>cleanup_formula_exports.py</code>。异常清理吞掉 OSError 后清空 job.path;清理器主要处理已成功且过期的文件及旧 .tmp,失败任务残留 .xlsx 可能不再被追踪。</p><p><strong>修复:</strong>记录不含敏感数据的清理失败日志及任务编号,为安全范围内的孤儿 .xlsx 制定保留期与回收规则;不要删除仍处于活动任务的文件。</p><p><strong>验收:</strong>注入 unlink 权限错误,任务失败状态仍保存;可观察到告警,恢复权限后回收成功,其他任务文件不受影响。</p></article>
<article><div class="tag p1">F03 · P1 · 静态确认 · 证据弹窗</div><h3>重复点击当前期间可能让已加载列表停在“加载中”</h3><p><strong>位置:</strong><code>frontend/src/dialogs.tsx → EvidenceDialog.selectSide()</code>。第 0 页点击当前已选期间会清空 result,但 side/page 没变化,依赖这些值的 effect 不会重新请求。</p><p><strong>修复:</strong>同期间同页直接返回,或明确触发刷新;补加载、空集、失败重试状态。</p><p><strong>验收:</strong>当前期间重复点击、快速本期/基期切换、第二页切期间、空列表和请求失败均不显示过期数据或永久加载。</p></article>
<article><div class="tag p1">F04 · P1 · 静态确认 · 定义复用后的对话</div><h3>载入已保存定义后追问,没有把原定义带给模型</h3><p><strong>位置:</strong><code>FormulaExportDialog.loadDefinition()/send()</code>。载入定义会清空 messages,随后只发送新增描述;“把刚才公式改成乘以 100”之类指代不能可靠关联已载入表达式。</p><p><strong>修复:</strong>在可见、经同意的对话上下文中明确附带原定义,或建立服务端定义引用;仍要求重新确认,不让旧定义静默覆盖新解释。选择空选项也应同步清理当前定义,避免选择状态与预览不一致。</p><p><strong>验收:</strong>保存 A、重开载入 A、发送依赖 A 的修改,检查模型请求实际包含 A;未发送补充时禁止生成,修改后生成的公式符合新定义。</p></article>
<article><div class="tag p1">F05 · P1 · 静态确认 · 定义库并发与审计</div><h3>50 条上限不是原子约束,同名更新没有冲突检测</h3><p><strong>位置:</strong><code>finance/formula_views.py → formula_definitions()</code>。先 count 再 update_or_create,并发不同名字可能突破上限;同名更新采用最后写入覆盖,未携带 revision。保存/删除与审计也不在同一外层事务内。</p><p><strong>修复:</strong>事务内锁定稳定的用户/数据集所属行,原子检查配额;引入 revision 和 409 冲突响应;变更和审计一同提交,保存增加合理限流。前端明确同名更新行为。</p><p><strong>验收:</strong>PostgreSQL 并发保存不能产生第 51 条;两个标签页更新不静默丢失;模拟审计写入失败后定义变更回滚。</p></article>
<article><div class="tag p1">F06 · P1 · 静态确认 · 工作簿可解释性</div><h3>“产品毛利率可编辑”说明与实际公式引用不一致</h3><p><strong>位置:</strong><code>finance/exporting.py → 参数与口径 / GrossCalc</code>。参数页毛利率写成字典文本并标为可编辑,实际明细公式引用每行 MarginRate,修改参数页文本不会联动。说明页还提到“本页分组维度”,但没有完整的维度键与显示名称字典。</p><p><strong>推荐:</strong>近期明确参数页为只读快照,写清需要修改计算明细的哪类输入;如必须按产品统一模拟,则实现真实产品毛利率参数表与 lookup 依赖,并验证 direct 毛利不受影响。</p><p><strong>验收:</strong>每个标为可编辑的输入都对应真实依赖;收入、成本、原价、直接毛利、lookup 毛利率分别改值重算,说明页列出字段、单位、来源、缺失含义和固定结构边界。</p></article>
<article><div class="tag p1">F07 · P1 · 静态确认 · 财务校验范围</div><h3>对账采用统一 0.01 阈值,自定义公式没有独立服务器参考</h3><p><strong>位置:</strong><code>finance/exporting.py → 对账检查</code>。人数、金额、比率共享差异阈值;对小数形式的比率,0.01 是一个百分点,未必适用业务。自定义列标记“需业务验收”,不是自动对账通过。</p><p><strong>修复:</strong>财务批准后按指标配置比较规则:人数严格相等、金额按币种和舍入精度、比率独立阈值;用 Decimal 白名单表达式解释器计算自定义指标参考,不能执行任意代码。保持原始金额文本不变。</p><p><strong>验收:</strong>阈值边界、大数、抵消、极小值、除零、缺失和多层表达式独立黄金样例通过;不得为消除报错而放宽容差。</p></article>
<article><div class="tag p1">F08 · P1 · 验证缺口 · 权限撤销与任务生命周期</div><h3>执行/下载阶段需要覆盖角色撤销、超时与崩溃恢复</h3><p>当前有组织、任务所有者、科室范围检查,但 worker 不重新要求 admin/analyst 角色;下载接口也未声明同创建接口的角色白名单。是否允许被降为 viewer 的账号下载旧导出,需要明确政策,不能默认授权语义已完整。</p><p><strong>推荐:</strong>敏感公式工作簿在申请、执行、下载时统一核验角色、组织、账户有效性和科室;补任务超时、重复请求、过期下载、worker 发布后崩溃的测试及运维告警。</p><p><strong>验收:</strong>排队后撤权、成功后撤权均按签署政策处理;半成品不能下载;重启不重复发布或无限停留 processing。生产 PostgreSQL 锁语义单独验证。</p></article>
</section>
<section id="gaps"><h2>四、尚未完成的功能与产品边界</h2>
<div class="table-wrap"><table><thead><tr><th>优先级 / 功能</th><th>当前缺口</th><th>建议与完成标准</th></tr></thead><tbody>
<tr><td>P1 · 新列出现在网页汇总表</td><td>当前新增指标主要进入导出的自定义指标页,并未成为主分析表持久化可计算列。</td><td>若原需求包含网页表格,需新增受控服务器计算、列选择与展示;网页结果、导出公式、Decimal 参考使用同一语义,缺失不转零。</td></tr>
<tr><td>P1 · 多列编辑与定义生命周期</td><td>API 最多 10 列,但对话弹窗生成时仅提交当前单列;未完成草稿续聊、版本历史、团队共享和审批。</td><td>先做当前数据集的多列列表与显式确认,后做草稿。个人定义保存复用已完成,不应重复开发。团队共享另设权限和版本策略。</td></tr>
<tr><td>P1 · 适用范围与表达式能力</td><td>scope 为说明文本,实际按当前分组、本期/基期计算;不能表达任意条件、跨明细新聚合、任意 Excel 函数、维度列或其他缺失策略。</td><td>模型明确拒绝不支持范围,不用自然语言说明冒充已执行规则。扩展时结构化范围并加入服务端白名单及黄金测试。</td></tr>
<tr><td>P1 · Excel 编辑防误操作</td><td>人数辅助表、期间、身份、维度为固定快照;追加/删除明细不能保证全部汇总与去重同步。输入验证、公式保护、完整字段字典未全部实现。</td><td>首版推荐固定结构、明确可编辑金额列并防误操作;结构改变需重新导出。表保护不是加密或真正权限控制。</td></tr>
<tr><td>P1 · 原附件 332,830 行导出</td><td>当前产品仍限制 100,000 行 / 200 组;不能一次完整导出原附件。</td><td>推荐可审计分卷并保留全局汇总/去重口径,不能把分卷人数简单相加。备选是正式提高上限后验收内存、并发、Excel 首开和改值重算,不能仅改常量。</td></tr>
<tr><td>P1 · 结果缓存与大文件下载</td><td>openpyxl 不执行公式;未重算文件的预览/缓存读取可能为空。浏览器 Blob 下载完整驻留内存;生产 /tmp 为 tmpfs,临时文件仍计入容器内存。</td><td>明确必须由目标引擎重算;如需要服务器生成已缓存副本,独立评估计算引擎。大文件直接附件下载及临时存储配额需实测。</td></tr>
<tr><td>P1 · 高基数与可访问性</td><td>聚合分组全量返回;缺高基数结果窗口、平板专项、键盘/屏幕阅读器专项。浏览器自动测试不等于人工可访问性验收。</td><td>先测真实高基数与并发,再决定分页或 SSRM;补 focus、Esc 关闭、错误提示、表头关联与触控专项。</td></tr>
<tr><td>P2 · 扩展业务能力</td><td>多收入字段口径、消费比率、生效期毛利率、退款策略、结构占比、历史阈值、告警渠道和团队调查分派未完整实现。</td><td>先冻结业务定义,再逐项开发,不擅自以现有收入字段替代不同含义。</td></tr>
<tr><td>P2 · 跨平台与高级网格</td><td>已有响应式 Web,没有原生客户端;AG Grid SSRM/Pivot/Tree Data/Master-Detail 未完整接入。</td><td>Web 是复用基础,不等于原生交付。按真实需求和商业授权选择 PWA/桌面封装或高级网格。</td></tr>
</tbody></table></div>
</section>
<section id="gates"><h2>五、必须外部完成的发布门禁</h2>
<div class="table-wrap"><table><thead><tr><th>门禁</th><th>现状 / 责任方</th><th>关闭证据</th></tr></thead><tbody>
<tr><td>P0 · Microsoft Excel 实机</td><td>历史只读 Excel 仅确认公式可见,无法保存重算;样例分组也与预期不符。财务、QA、目标终端负责人。</td><td>在可编辑 Windows/Mac Excel 用受控样例打开、强制重算、保存;读取公式和结果缓存,改值后依赖链正确,无非预期错误与修复提示。</td></tr>
<tr><td>P0 · 财务签字与精度政策</td><td>原附件收入/实收定义有歧义,成本全缺;财务负责人。</td><td>签署收入、退款、原价、成本归属、毛利、ROI、自营、升单和容差。缺成本 ROI 保持不可用;不以技术测试替代业务认可。</td></tr>
<tr><td>P0 · AI 真实联调及隐私</td><td>外部响应主要以 mock 验证;对话文本与筛选标签可能包含敏感信息。合规、数据负责人、AI 服务负责人。</td><td>审批数据处理/出境、模型版本、留存策略;真实验证超时、拒答、限流、非法输出、复杂 schema 接受度。批准前保持 AI 关闭。</td></tr>
<tr><td>P0 · 目标基础设施</td><td>历史 Docker daemon 不可用、真实 .env 不存在;配置已交付,不等于构建和运行已验收。运维、安全。</td><td>目标镜像构建、迁移 0004、非 root/卷权限、TLS、代理、数据库认证、队列、备份恢复、日志脱敏、安全扫描与许可证通过。</td></tr>
<tr><td>P1 · 当前版本 PostgreSQL 与 SLA</td><td>最近 65 项全量是 SQLite;历史 PostgreSQL 对账/测试不能替代本版并发和锁语义。</td><td>在独立 PostgreSQL 执行当前测试、并发定义保存、worker 恢复、导出压力;记录 P95、资源预算、备份恢复 RPO/RTO。</td></tr>
<tr><td>P1 · 安全运营和许可</td><td>没有完整 SSO/MFA、自助找回、数据库 RLS、不可变审计和外部告警;并非所有产品必须全选,但必须作安全决策。</td><td>风险评估明确必需项;验证加密存储、密钥轮换、权限回收和安全审查;启用 Enterprise 前取得对应商业许可。</td></tr>
</tbody></table></div>
</section>
<section id="sequence"><h2>六、推荐修复顺序</h2>
<ol><li><strong>安全收尾:</strong>先 F01、F02、F08,明确文件清理边界与撤权政策,添加失败注入和越权回归。</li><li><strong>日常交互:</strong>修 F03、F04;补定义库删除、空选项、失败重试、快速切换和重复点击 E2E。</li><li><strong>持久化可靠性:</strong>修 F05,做 PostgreSQL 事务、配额和冲突测试;迁移验证应覆盖既有数据库。</li><li><strong>工作簿真实性:</strong>修 F06,完善输入/参数字典与保护;F07 的容差需先由财务确认,再增加独立数值对账。</li><li><strong>补齐原始用户目标:</strong>确认网页主汇总表也需要新列后,实现网页计算、多列选择与导出一致性;完成草稿续聊。</li><li><strong>发布前:</strong>解决原附件范围,执行 Excel、真实 AI、财务、安全、基础设施和并发验收。任何一项未关闭都保留明确限制,不标记“全功能商用完成”。</li></ol>
<p>没有给出未经测量的工期承诺。前三项主要由工程承担;财务口径、数据处理审批和目标环境依赖必须明确责任人,不能通过延长本地编码替代。</p>
</section>
<section id="acceptance"><h2>七、下一轮验收与交付清单</h2>
<ul><li>为每个修复加入“修复前失败、修复后通过”的专项断言;全量后端覆盖率不低于 85%,同时保留财务黄金样例。</li><li>安全回归包含父目录链接、文件权限、清理失败、撤权后执行/下载、同名定义并发更新与配额边界。</li><li>LibreOffice 和目标 Excel 分别记录版本、输入样例、公式、缓存结果与修改后差异,不合并为笼统的“Excel 测试通过”。</li><li>运行桌面/手机 E2E;指定隔离原附件数据集后执行目前跳过的用例。不得用真实生产账号或生产数据环境运行写入型测试。</li><li>Ruff、格式、TypeScript、构建、Django、迁移一致性、依赖审计通过;Docker/PostgreSQL 等环境受限项明确记录未执行原因。</li><li>交付更新后的测试记录、迁移说明、部署/回滚步骤、问题状态及新 HTML。保留历史基准,但标题应明确“历史”,消除多个文档的冲突状态。</li></ul>
<h3>源码与相关材料</h3>
<p><a href="../finance/exporting.py">公式生成与任务发布</a> · <a href="../finance/formula_views.py">定义库与导出 API</a> · <a href="../finance/views.py">证据导出与分页</a> · <a href="../frontend/src/dialogs.tsx">网页对话与证据交互</a> · <a href="../tests/">测试目录</a></p>
<p><a href="acceptance.md">既有验收记录(部分状态待同步)</a> · <a href="improvement-roadmap.html">历史路线图</a> · <a href="excel-formula-export-plan.html">最初 Excel 实施方案(历史设计,不代表现状)</a></p>
</section>
</main><footer>本报告不包含患者明细、凭据或真实财务原值。源码静态判断、已完成自动化测试、产品范围选择和正式生产验收分别记录;本报告不是上线授权。</footer>
</body>
</html>
View File
+275
View File
@@ -0,0 +1,275 @@
import re
from decimal import Decimal
from django.db.models import Count, Q, Sum
from .models import Fact
METRICS = {
"revenue": "实际收入",
"cost": "成本",
"gross": "毛利贡献",
"roi": "ROI",
"customers": "消费人数",
"basket": "客单价",
"margin": "毛利率",
"discount_rate": "产品扣率",
"self_revenue": "自营收入",
"upgrade_rate": "升单率",
"list_basket": "原价客单价",
}
def dimensions(ds):
return [
{"key": c["key"], "name": c["name"], "description": c.get("description", "")}
for c in ds.mapping["columns"]
if c["role"] == "dimension"
]
def default_preferences(ds):
dims = [d["key"] for d in dimensions(ds)]
preferred = [
d
for d in ["department", "subject", "doctor_level", "doctor", "product", "product_subcategory"]
if d in dims
]
return {
"entry": "overview",
"comparison": "yoy",
"hierarchy": preferred or dims[:5],
"metric": "revenue",
"threshold": 0.15,
"min_impact": 1000,
"min_samples": 5,
"show_heatmap": True,
"show_queue": True,
"show_copilot": True,
"alerts": True,
}
def month_index(s):
if not isinstance(s, str) or not re.fullmatch(r"\d{4}-\d{2}", s):
raise ValueError("期间格式必须为 YYYY-MM")
year, month = map(int, s.split("-"))
if not 1900 <= year <= 2200 or not 1 <= month <= 12:
raise ValueError("期间无效")
return year * 12 + month - 1
def month_text(index):
year, month = divmod(index, 12)
return f"{year:04d}-{month + 1:02d}"
def context(ds, body):
if not isinstance(body, dict):
raise ValueError("查询格式错误")
end = body.get("end", ds.periods[-1])
start = body.get("start", end)
a, b = month_index(start), month_index(end)
if b < a or b - a > 59:
raise ValueError("期间范围必须为 1 至 60 个月")
mode = body.get("comparison", "yoy")
if mode not in ("yoy", "mom", "custom"):
raise ValueError("比较口径无效")
length = b - a + 1
base_end = (
month_index(body.get("baseline_end", ""))
if mode == "custom"
else b - (12 if mode == "yoy" else length)
)
current = [month_text(i) for i in range(a, b + 1)]
baseline = [month_text(i) for i in range(base_end - length + 1, base_end + 1)]
if set(current) & set(baseline):
raise ValueError("本期与基期不能重叠")
filters = body.get("filters", {})
allowed = {d["key"] for d in dimensions(ds)}
if not isinstance(filters, dict) or any(
k not in allowed or not isinstance(v, str) or len(v) > 256 for k, v in filters.items()
):
raise ValueError("筛选维度无效")
group = body.get("group", next(iter(dimensions(ds)))["key"])
if not isinstance(group, str) or group not in allowed:
raise ValueError("分组维度不存在")
metric = body.get("metric", "revenue")
if not isinstance(metric, str) or metric not in METRICS:
raise ValueError("指标不存在")
missing = sorted(set(current + baseline) - set(ds.periods))
return {
"start": start,
"end": end,
"comparison": mode,
"baseline_end": baseline[-1],
"current": current,
"baseline": baseline,
"missing": missing,
"filters": filters,
"group": group,
"metric": metric,
}
def scoped_facts(ds, member, filters=None):
qs = Fact.objects.filter(dataset=ds)
if member.departments:
qs = qs.filter(dimensions__department__in=member.departments)
for key, value in (filters or {}).items():
qs = qs.filter(**{f"dimensions__{key}": value})
return qs
def aggregations(products=None):
return {
"rows": Count("id"),
"revenue_sum": Sum("revenue"),
"cost_sum": Sum("cost"),
"gross_sum": Sum("gross"),
"cost_n": Count("cost"),
"gross_n": Count("gross"),
"customer_n": Count("customer"),
"customers": Count("customer", distinct=True),
"list_sum": Sum("list_amount"),
"list_n": Count("list_amount"),
"self_sum": Sum("revenue", filter=Q(dimensions__product__in=products or [])),
"upgraded": Count("customer", distinct=True, filter=Q(dimensions__product__in=products or [])),
}
def ratio(a, b):
return a / b if a is not None and b is not None and b > 0 else None
def metrics(agg, formula, products=None):
n = agg["rows"]
revenue = agg["revenue_sum"] or Decimal(0)
cost = (agg["cost_sum"] or Decimal(0)) if n and agg["cost_n"] == n else None
gross = (agg["gross_sum"] or Decimal(0)) if n and agg["gross_n"] == n else None
customers = agg["customers"] if n and agg["customer_n"] == n else None
profit = revenue - cost if cost is not None else None
if formula == "gross_less_cost_over_cost":
profit = gross - cost if gross is not None and cost is not None else None
return {
"rows": n,
"revenue": revenue,
"cost": cost,
"gross": gross,
"customers": customers,
"roi": ratio(profit, cost),
"basket": ratio(revenue, customers),
"margin": ratio(gross, revenue),
"discount_rate": ratio(revenue, agg["list_sum"]) if n and agg["list_n"] == n else None,
"self_revenue": (agg["self_sum"] or Decimal(0)) if products else None,
"upgrade_rate": ratio(Decimal(agg["upgraded"]), customers) if products else None,
"list_basket": ratio(agg["list_sum"], customers) if n and agg["list_n"] == n else None,
}
def comparison(current, baseline, comparable):
result = {}
for key in METRICS:
c, b = current.get(key), baseline.get(key)
delta = c - b if comparable and c is not None and b is not None else None
change = Decimal(delta) / abs(Decimal(b)) if delta is not None and b != 0 else None
result[key] = {"current": c, "baseline": b if comparable else None, "delta": delta, "change": change}
return result
def analyze(ds, member, body, preferences):
ctx = context(ds, body)
qs = scoped_facts(ds, member, ctx["filters"])
formula = ds.mapping["roi_formula"]
products = ds.mapping.get("self_operated_products", [])
total_c = metrics(
qs.filter(period__in=ctx["current"]).aggregate(**aggregations(products)), formula, products
)
total_b = metrics(
qs.filter(period__in=ctx["baseline"]).aggregate(**aggregations(products)), formula, products
)
comparable = not ctx["missing"]
if set(ctx["current"]) - set(ds.periods):
total_c = {**total_c, **{key: None for key in METRICS}}
summary = comparison(total_c, total_b, comparable)
group = "dimensions__" + ctx["group"]
buckets = {}
for side in ("current", "baseline"):
for row in qs.filter(period__in=ctx[side]).values(group).annotate(**aggregations(products)):
buckets.setdefault(row[group], {})[side] = metrics(row, formula, products)
def empty_side(total):
return {
"rows": 0,
**{
k: Decimal(0)
if k in ("revenue", "cost", "gross", "customers", "self_revenue") and total.get(k) is not None
else None
for k in METRICS
},
}
rows = []
metric = ctx["metric"]
parent_delta = summary[metric]["delta"]
threshold = Decimal(str(preferences["threshold"]))
impact_floor = Decimal(str(preferences["min_impact"]))
for label, sides in buckets.items():
c, b = sides.get("current", empty_side(total_c)), sides.get("baseline", empty_side(total_b))
if set(ctx["current"]) - set(ds.periods):
c = {**c, **{key: None for key in METRICS}}
values = comparison(c, b, comparable)
v = values[metric]
change, delta = v["change"], v["delta"]
impact = (
abs(delta)
if metric in ("revenue", "gross", "cost", "self_revenue") and delta is not None
else abs(values["revenue"]["delta"] or 0)
)
adverse = change is not None and (change >= threshold if metric == "cost" else change <= -threshold)
anomaly = bool(
adverse and impact >= impact_floor and min(c["rows"], b["rows"]) >= preferences["min_samples"]
)
contribution = (
delta / parent_delta
if delta is not None and parent_delta and metric in ("revenue", "cost", "gross", "self_revenue")
else None
)
rows.append(
{
"label": label or "(未分类)",
"values": values,
"rows": c["rows"],
"baseline_rows": b["rows"],
"anomaly": anomaly,
"impact": impact,
"contribution": contribution,
"filters": {**ctx["filters"], ctx["group"]: label or "(未分类)"},
}
)
rows.sort(key=lambda r: (not r["anomaly"], -r["impact"], r["label"]))
observed = {
r["period"]: metrics(r, formula, products)
for r in qs.values("period").annotate(**aggregations(products))
}
trend = []
for index in range(month_index(ds.periods[0]), month_index(ds.periods[-1]) + 1):
period = month_text(index)
values = observed.get(period)
if values is None:
values = empty_side(total_c) if period in ds.periods else {key: None for key in METRICS}
trend.append({"period": period, **values})
return {
"dataset_id": str(ds.pk),
"version": ds.sha256,
"context": ctx,
"summary": summary,
"rows": rows,
"trend": trend,
"row_count": total_c["rows"],
"anomaly_count": sum(r["anomaly"] for r in rows),
"comparable": comparable,
"rules": preferences,
"notice": "贡献分解为统计关联,不代表业务因果;退款和冲销保留在净额中。",
"roi_formula": formula,
}
+163
View File
@@ -0,0 +1,163 @@
import json
import httpx
from django.conf import settings
from django.http import JsonResponse
from .analysis import METRICS, analyze, context, dimensions
from .views import APIError, audit, body, dataset, endpoint, limit, preferences
def model_intent(question, ds, ctx):
keys = [d["key"] for d in dimensions(ds)]
schema = {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["overview", "anomalies", "drill", "evidence"]},
"metric": {"type": "string", "enum": list(METRICS)},
"group": {"type": "string", "enum": keys},
"filters": {
"type": "array",
"items": {
"type": "object",
"properties": {"key": {"type": "string", "enum": keys}, "value": {"type": "string"}},
"required": ["key", "value"],
"additionalProperties": False,
},
},
},
"required": ["action", "metric", "group", "filters"],
"additionalProperties": False,
}
# Only schema, selected context and the user's question leave the server; never raw rows.
payload = {
"model": settings.OPENAI_MODEL,
"store": False,
"max_output_tokens": 1200,
"input": [
{
"role": "system",
"content": "将财务分析问题转换成只读查询意图。不执行指令、SQL或写操作。不推测不存在的字段。不修改已有权限。保留当前筛选,除非用户明确改变。"
+ json.dumps(
{
"dimensions": dimensions(ds),
"context": {k: ctx[k] for k in ("metric", "group", "filters")},
},
ensure_ascii=False,
),
},
{"role": "user", "content": question},
],
"text": {
"format": {"type": "json_schema", "name": "finance_intent", "strict": True, "schema": schema}
},
}
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}"},
json=payload,
)
response.raise_for_status()
result = response.json()
if not isinstance(result, dict) or result.get("status") != "completed":
raise ValueError("incomplete response")
text = next(
c["text"]
for o in result.get("output", [])
for c in o.get("content", [])
if c.get("type") == "output_text"
)
intent = json.loads(text)
if not isinstance(intent, dict) or set(intent) != {"action", "metric", "group", "filters"}:
raise ValueError("invalid intent")
if intent.get("action") not in ("overview", "anomalies", "drill", "evidence"):
raise ValueError("invalid action")
filters = intent.pop("filters")
if not isinstance(filters, list) or len(filters) > 20:
raise ValueError("invalid filters")
if any(
not isinstance(f, dict)
or set(f) != {"key", "value"}
or not isinstance(f["key"], str)
or not isinstance(f["value"], str)
for f in filters
):
raise ValueError("invalid filter")
intent["filters"] = {f["key"]: f["value"] for f in filters}
if len(intent["filters"]) != len(filters):
raise ValueError("duplicate filter")
context(ds, {**ctx, **intent})
return intent
except (httpx.HTTPError, ValueError, KeyError, TypeError, AttributeError, StopIteration) as exc:
raise APIError("模型暂时不可用或返回了无效意图;未执行任何操作", 502) from exc
@endpoint(("POST",))
def assistant(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
question = data.get("question", "")
if not isinstance(question, str) or not 1 <= len(question.strip()) <= 2000:
raise APIError("问题需为 1 至 2,000 字")
limit(f"assistant:{request.user.pk}", 20, 60)
ctx = context(ds, data.get("context", {}))
pref, _ = preferences(request, ds)
use_model = data.get("use_model", False)
if type(use_model) is not bool:
raise APIError("AI 模式必须为布尔值")
if use_model:
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
raise APIError("尚未配置外部 AI 服务", 503)
if data.get("consent") is not True:
raise APIError("请先同意发送问题与当前筛选上下文", 400)
intent = model_intent(question, ds, ctx)
else:
action = (
"evidence"
if "明细" in question or "证据" in question
else "anomalies"
if "异常" in question
else "drill"
if "下钻" in question
else "overview"
)
intent = {"action": action, "metric": ctx["metric"], "group": ctx["group"], "filters": ctx["filters"]}
for k, label in METRICS.items():
if label in question:
intent["metric"] = k
for d in dimensions(ds):
if d["name"] in question:
intent["group"] = d["key"]
result = analyze(ds, request.member, {**ctx, **intent}, pref)
metric = intent["metric"]
value = result["summary"][metric]
if not result["comparable"]:
answer = "缺少期间 " + "、".join(result["context"]["missing"]) + ",不能给出比较或异常结论。"
elif value["current"] is None:
answer = f"当前数据缺少计算{METRICS[metric]}所需的完整字段。"
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']} 个异常分组。"
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}
)
return JsonResponse(
{
"answer": answer,
"intent": intent,
"context": result["context"],
"version": ds.sha256,
"provider": "openai" if use_model else "local",
"evidence": [
{"label": r["label"], "filters": r["filters"], "values": r["values"][metric]}
for r in result["rows"][:5]
],
}
)
+644
View File
@@ -0,0 +1,644 @@
import hashlib
import hmac
import os
import secrets
import warnings
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from pathlib import Path
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.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,
metrics_used,
to_excel,
validate_column,
)
from .models import FormulaExport, Membership
FORMULA_VERSION = "3"
MAX_ROWS = 100_000
MAX_GROUPS = 200
def _excel_number(value):
if value is None:
return None
number = float(value)
if not Decimal(str(number)).is_finite():
raise ValueError("金额超出 Excel 数值范围,公式工作簿已拒绝生成")
return number
def _text_cell(sheet, value):
cell = WriteOnlyCell(sheet, value="" if value is None else str(value))
cell.data_type = "s"
return cell
def _styled_row(sheet, values, fill, font):
cells = []
for value in values:
cell = value if hasattr(value, "data_type") else WriteOnlyCell(sheet, value=value)
cell.fill, cell.font = fill, font
cells.append(cell)
return cells
def _table(name, ref, headers, style):
table = Table(displayName=name, ref=ref)
table.tableColumns = [TableColumn(id=index, name=header) for index, header in enumerate(headers, 1)]
table.tableStyleInfo = TableStyleInfo(name=style, showRowStripes=True, showColumnStripes=False)
return table
def _add_table(sheet, table):
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="In write-only mode you must add table columns manually")
sheet.add_table(table)
def _quote(value):
escaped = str(value).replace("~", "~~").replace("*", "~*").replace("?", "~?").replace('"', '""')
return '"' + escaped + '"'
def _text_criterion(value):
criterion = "=" + str(value)
escaped = criterion.replace("~", "~~").replace("*", "~*").replace("?", "~?")
if len(escaped) > 255:
raise ValueError("分组或筛选值超过 Excel criteria 的 255 字符上限,请缩短标签")
return _quote(criterion)
def _checked_formula(value):
if len(value) > 8192:
raise ValueError("生成公式超过 Excel 8,192 字符上限,请减少筛选条件或缩短标签")
return value
def _criteria(filters, group_key, label, periods):
terms = []
for key, value in sorted(filters.items()):
terms.extend((f"Data[{key}]", _text_criterion(value)))
terms.extend((f"Data[{group_key}]", _text_criterion(label)))
terms.extend(("Data[Period]", _quote(">=" + periods[0]), "Data[Period]", _quote("<=" + periods[-1])))
return ",".join(terms)
def _sum(column, criteria):
return f"SUMIFS(Data[{column}],{criteria})"
def _column_name(index):
name = ""
while index:
index, remainder = divmod(index - 1, 26)
name = chr(65 + remainder) + name
return name
def _metric_formula(key, label, periods, side_col, metric_rows, filters, group_key, products, roi_formula):
criteria = _criteria(filters, group_key, label, periods)
rows = f"COUNTIFS({criteria})"
revenue, cost = _sum("Revenue", criteria), _sum("Cost", criteria)
gross, list_sum = _sum("GrossCalc", criteria), _sum("ListAmount", criteria)
cost_n = f'COUNTIFS({criteria},Data[Cost],"<>")'
gross_n = f'COUNTIFS({criteria},Data[GrossCalc],"<>")'
list_n = f'COUNTIFS({criteria},Data[ListAmount],"<>")'
customer_period = "Current" if side_col == "C" else "Baseline"
customer_upgrade = f"{customer_period}Upgraded"
customers = (
f"COUNTIFS(CustomerRoster[Group],{_text_criterion(label)},CustomerRoster[{customer_period}],1)"
)
if key == "revenue":
return f"=IF({rows}=0,0,{revenue})"
if key == "cost":
return f"=IF({rows}=0,0,IF({cost_n}={rows},{cost},NA()))"
if key == "gross":
return f"=IF({rows}=0,0,IF({gross_n}={rows},{gross},NA()))"
if key == "customers":
return f"=IF({rows}=0,NA(),IF(COUNTIFS({criteria},Data[CustomerPresent],0)>0,NA(),{customers}))"
if key == "basket":
ref = f"{side_col}{metric_rows[(label, 'customers')]}"
return f"=IF(OR({rows}=0,ISNA({ref}),{ref}=0),NA(),{revenue}/{ref})"
if key == "margin":
return f"=IF(OR({rows}=0,{revenue}<=0,{gross_n}<>{rows}),NA(),{gross}/{revenue})"
if key == "discount_rate":
return f"=IF(OR({rows}=0,{list_n}<>{rows},{list_sum}<=0),NA(),{revenue}/{list_sum})"
if key == "self_revenue":
return (
"=NA()"
if not products
else f"=IF({rows}=0,0,SUMIFS(Data[Revenue],{criteria},Data[SelfOperated],1))"
)
if key == "upgrade_rate":
if not products:
return "=NA()"
ref = f"{side_col}{metric_rows[(label, 'customers')]}"
upgraded = (
f"COUNTIFS(CustomerRoster[Group],{_text_criterion(label)},CustomerRoster[{customer_upgrade}],1)"
)
return f"=IF(OR(ISNA({ref}),{ref}<=0),NA(),{upgraded}/{ref})"
if key == "list_basket":
ref = f"{side_col}{metric_rows[(label, 'customers')]}"
return f"=IF(OR({rows}=0,{list_n}<>{rows},ISNA({ref}),{ref}<=0),NA(),{list_sum}/{ref})"
numerator = revenue if roi_formula == "profit_over_cost" else gross
complete = "FALSE" if roi_formula == "profit_over_cost" else f"{gross_n}<>{rows}"
return f"=IF(OR({rows}=0,{cost_n}<>{rows},{cost}<=0,{complete}),NA(),({numerator}-{cost})/{cost})"
def build_formula_workbook(ds, member, export_context, output):
custom_columns = [validate_column(item) for item in export_context.get("custom_columns", [])]
for item in custom_columns:
ensure_available_metrics(ds, item)
if len(custom_columns) > 10:
raise ValueError("每次最多添加 10 个自定义指标列")
ctx = context(ds, export_context)
qs = (
scoped_facts(ds, member, ctx["filters"])
.filter(period__in=ctx["current"] + ctx["baseline"])
.order_by("source_row")
)
count = qs.count()
if count > MAX_ROWS:
raise ValueError(f"公式工作簿最多支持 {MAX_ROWS:,} 行,请缩小范围")
group_key = ctx["group"]
groups = list(
qs.order_by().values_list(f"dimensions__{group_key}", flat=True).distinct()[: MAX_GROUPS + 1]
)
group_values = {}
excel_labels = {}
for value in groups:
label = str(value or "(未分类)")
if label in group_values and group_values[label] != value:
raise ValueError("分组存在与系统缺失值标签冲突的真实值,公式工作簿已拒绝生成")
folded = label.casefold()
if folded in excel_labels and excel_labels[folded] != label:
raise ValueError("分组名称仅大小写不同,Excel 无法可靠区分,公式工作簿已拒绝生成")
group_values[label] = value
excel_labels[folded] = label
if len(groups) > MAX_GROUPS:
raise ValueError(f"所选维度超过 {MAX_GROUPS} 个分组,请缩小范围")
labels = sorted(group_values)
mapping = ds.mapping
products, rates = mapping.get("self_operated_products", []), mapping.get("margin_rates", {})
if len(products) > 100 or len(rates) > 2000:
raise ValueError("产品口径超过公式工作簿的支持上限")
dims = dimensions(ds)
dim_headers = [d["key"] for d in dims]
dim_names = {d["key"]: d["name"] for d in dims}
if RESERVED_DATA_HEADERS.intersection(key.casefold() for key in dim_headers):
raise ValueError("维度标识与公式工作簿保留列名冲突")
wb = Workbook(write_only=True)
summary = wb.create_sheet("分析汇总")
instructions = wb.create_sheet("使用说明")
parameters = wb.create_sheet("参数与口径")
data = wb.create_sheet("计算明细")
customers_sheet = wb.create_sheet("人数去重")
evidence = wb.create_sheet("原始证据")
reconcile = wb.create_sheet("对账检查")
custom_sheet = wb.create_sheet("自定义指标") if custom_columns else None
green, white = PatternFill("solid", fgColor="17664F"), Font(color="FFFFFF", bold=True)
summary.freeze_panes = "A2"
summary.sheet_view.showGridLines = False
for sheet in (instructions, parameters, reconcile):
sheet.freeze_panes = "A2"
sheet.sheet_view.showGridLines = False
data.freeze_panes = "A2"
customers_sheet.freeze_panes = "A2"
customers_sheet.sheet_view.showGridLines = False
if custom_sheet:
custom_sheet.freeze_panes = "B2"
custom_sheet.sheet_view.showGridLines = False
custom_sheet.auto_filter.ref = f"A1:{_column_name(1 + 4 * len(custom_columns))}{len(labels) + 1}"
summary.append(_styled_row(summary, ["公式分析工作簿", "本地可重算副本", "修改不回写系统"], green, white))
summary.append(["数据版本", ds.sha256, "公式版本", FORMULA_VERSION])
summary.append(["筛选范围", str(ctx["filters"]), "分组维度", _text_cell(summary, dim_names[group_key])])
summary.append(
[
"本期",
f"{ctx['current'][0]} 至 {ctx['current'][-1]}",
"基期",
f"{ctx['baseline'][0]} 至 {ctx['baseline'][-1]}",
]
)
summary.append(
_styled_row(summary, ["指标", "分组", "本期", "基期", "差额", "变化率", "状态"], green, white)
)
all_metrics = {**METRICS, **{f"custom_{i}": item["name"] for i, item in enumerate(custom_columns)}}
metric_rows = {}
row = 6
for label in labels:
for key in all_metrics:
metric_rows[(label, key)] = row
row += 1
headers = [
"Period",
*dim_headers,
"CustomerToken",
"Revenue",
"Cost",
"GrossInput",
"MarginRate",
"GrossSource",
"ListAmount",
"SelfOperated",
"CustomerPresent",
"RowCount",
"GrossCalc",
]
data.freeze_panes = "A2"
if count:
_add_table(
data,
_table(
"Data",
f"A1:{_column_name(len(headers))}{count + 1}",
headers,
"TableStyleMedium2",
),
)
data.append(headers)
token_secret = secrets.token_bytes(32)
customer_roster = {}
gross_index = next((col["index"] for col in mapping["columns"] if col["role"] == "gross"), None)
for row_number, fact in enumerate(qs.iterator(chunk_size=2000), start=2):
product = fact.dimensions.get("product", "")
direct = None
if (
gross_index is not None
and gross_index < len(fact.raw)
and fact.raw[gross_index] not in (None, "")
):
try:
direct = _excel_number(Decimal(str(fact.raw[gross_index]).replace(",", "")))
except (InvalidOperation, ValueError):
pass
rate = _excel_number(Decimal(str(rates[product]))) if product in rates else None
customer = fact.customer or ""
token = hmac.new(token_secret, customer.encode(), hashlib.sha256).hexdigest()[:24] if customer else ""
if customer:
group_label = str(fact.dimensions.get(group_key) or "(未分类)")
record = customer_roster.setdefault(
(group_label, token),
{"current": False, "baseline": False, "current_upgraded": False, "baseline_upgraded": False},
)
if fact.period in ctx["current"]:
record["current"] = True
record["current_upgraded"] |= product in products
if fact.period in ctx["baseline"]:
record["baseline"] = True
record["baseline_upgraded"] |= product in products
dim_values = [fact.dimensions.get(d["key"], "(未分类)") or "(未分类)" for d in dims]
values = [
fact.period,
*dim_values,
token,
_excel_number(fact.revenue),
_excel_number(fact.cost),
direct,
rate,
"direct" if direct is not None else ("lookup" if rate is not None else "missing"),
_excel_number(fact.list_amount),
int(product in products),
int(bool(customer)),
1,
None,
]
row = [
_text_cell(data, value) if i <= len(dims) + 1 or i == len(dims) + 6 else value
for i, value in enumerate(values)
]
row[-1] = WriteOnlyCell(
data,
value=(
f'=IF({_column_name(len(dims) + 7)}{row_number}="direct",'
f"{_column_name(len(dims) + 5)}{row_number},"
f'IF({_column_name(len(dims) + 7)}{row_number}="lookup",'
f"{_column_name(len(dims) + 3)}{row_number}*"
f'{_column_name(len(dims) + 6)}{row_number},""))'
),
)
data.append(row)
customer_headers = [
"Group",
"CustomerToken",
"Current",
"Baseline",
"CurrentUpgraded",
"BaselineUpgraded",
]
_add_table(
customers_sheet,
_table(
"CustomerRoster",
f"A1:F{len(customer_roster) + 1 if customer_roster else 2}",
customer_headers,
"TableStyleMedium4",
),
)
customers_sheet.append(customer_headers)
for (group_label, token), flags in customer_roster.items():
customers_sheet.append(
[
_text_cell(customers_sheet, group_label),
_text_cell(customers_sheet, token),
int(flags["current"]),
int(flags["baseline"]),
int(flags["current_upgraded"]),
int(flags["baseline_upgraded"]),
]
)
if not customer_roster:
customers_sheet.append(["", "", 0, 0, 0, 0])
evidence.append(
[
"来源工作表",
"来源行号",
*[_text_cell(evidence, d["name"]) for d in dims],
"期间",
"客户 Token",
"收入原值",
"成本原值",
"毛利原值",
"原价原值",
]
)
for source_row, period, values, customer, revenue, cost, gross, list_amount in qs.values_list(
"source_row", "period", "dimensions", "customer", "revenue", "cost", "gross", "list_amount"
).iterator(chunk_size=2000):
token = (
hmac.new(token_secret, (customer or "").encode(), hashlib.sha256).hexdigest()[:24]
if customer
else ""
)
values = [
mapping["sheet"],
source_row,
*[values.get(d["key"], "(未分类)") for d in dims],
period,
token,
str(revenue),
"" if cost is None else str(cost),
"" if gross is None else str(gross),
"" if list_amount is None else str(list_amount),
]
evidence.append([_text_cell(evidence, value) for value in values])
parameters.append(_styled_row(parameters, ["参数", "值", "说明", "可编辑"], green, white))
for row in [
[
"收入字段",
_text_cell(
parameters,
next((c["name"] for c in mapping["columns"] if c["role"] == "revenue"), "未命名"),
),
"使用导入时确认的收入字段",
"否",
],
["ROI 口径", mapping["roi_formula"], "按导入映射保存的公式分支", "否"],
["自营产品集合", _text_cell(parameters, ", ".join(products)), "升单率和自营收入口径", "否"],
["产品毛利率", str(rates), "直接毛利缺失时的可编辑计算输入", "是"],
[
"Excel 数值精度",
"IEEE-754 双精度",
"精确 Decimal 在证据页按文本保存;公式不保证 15 位以上有效数字",
"否",
],
["重算范围", f"最多 {MAX_ROWS:,} 行、{MAX_GROUPS} 组", "超限需缩小导出范围", "否"],
["兼容性", "SUMIFS/COUNTIFS", "移除动态数组人数公式;仍需目标 Excel 桌面版本验收", "否"],
]:
parameters.append(row)
for definition in custom_columns:
parameters.append(
[
"自定义列",
_text_cell(parameters, definition["name"]),
_text_cell(parameters, definition["meaning"]),
"公式由服务端生成",
]
)
instructions.append(_styled_row(instructions, ["主题", "说明"], green, white))
for row in [
["用途", "公式分析工作簿;本地修改不回写系统。"],
["行粒度", "计算明细每行对应一个 Fact,不等同患者就诊数。"],
["维度列", "列名为数据导入映射中的维度键;显示名称见本页分组维度。"],
["毛利", "直接毛利优先,否则收入×产品毛利率;无法计算则不可用。毛利贡献不代表净利润。"],
["ROI", "按选定口径;缺成本、成本非正或所需指标缺失时不可用。"],
["人数", "客户 token 跨行去重;缺客户编码时不可用。"],
[
"人数去重",
"人数去重页按分组保存随机 token 及本期/基期/升单标记;身份、分组和期间标记属于固定快照,不可修改。",
],
["变化率", "差额/ABS(基期);基期为0或期间缺失时不可用。"],
["精度", "原始 Decimal 以文本留存;Excel 双精度存在细微误差可能。"],
["隐私", "客户标识替换为本文件随机 token,文件仍按敏感财务信息保护。"],
]:
instructions.append(row)
for definition in custom_columns:
instructions.append(
[
_text_cell(instructions, definition["name"]),
_text_cell(
instructions,
f"含义:{definition['meaning']};范围:{definition['scope']};缺失/零分母:{definition['missing']}。",
),
]
)
if custom_sheet:
custom_sheet.append(
[
"分组",
*[
_text_cell(custom_sheet, f"{item['name']}({suffix})")
for item in custom_columns
for suffix in ("本期", "基期", "差额", "变化率")
],
]
)
for label in labels:
values = [_text_cell(custom_sheet, label)]
for index, _definition in enumerate(custom_columns):
formula_row = metric_rows[(label, f"custom_{index}")]
values.extend(
[
f"='分析汇总'!C{formula_row}",
f"='分析汇总'!D{formula_row}",
f"='分析汇总'!E{formula_row}",
f"='分析汇总'!F{formula_row}",
]
)
custom_sheet.append(values)
for label in labels:
for key in METRICS:
row = metric_rows[(label, key)]
formulas = []
for side, periods in enumerate((ctx["current"], ctx["baseline"])):
if set(periods) & set(ctx["missing"]):
formulas.append("=NA()")
continue
col = "C" if side == 0 else "D"
formulas.append(
_checked_formula(
_metric_formula(
key,
label,
periods,
col,
metric_rows,
ctx["filters"],
group_key,
products,
mapping["roi_formula"],
)
)
)
summary.append(
[
METRICS[key],
_text_cell(summary, label),
formulas[0],
formulas[1],
f"=IFERROR(C{row}-D{row},NA())",
f"=IFERROR(IF(D{row}=0,NA(),E{row}/ABS(D{row})),NA())",
f'=IF(ISNA(C{row}),"本期不可用",IF(ISNA(D{row}),"基期不可用","可用"))',
]
)
for index, definition in enumerate(custom_columns):
key, row = f"custom_{index}", metric_rows[(label, f"custom_{index}")]
formulas = []
for side in range(2):
periods = ctx["current"] if side == 0 else ctx["baseline"]
if set(periods) & set(ctx["missing"]):
formulas.append("=NA()")
continue
col = "C" if side == 0 else "D"
refs = {metric: f"{col}{metric_rows[(label, metric)]}" for metric in METRICS}
expr = to_excel(definition["expression"], refs)
used = metrics_used(definition["expression"])
invalid = ",".join(f"ISNA({refs[metric]})" for metric in sorted(used))
formula = (
f"IF(OR({invalid}),NA(),IFERROR({expr},NA()))" if invalid else f"IFERROR({expr},NA())"
)
formulas.append(_checked_formula(f"={formula}"))
summary.append(
[
_text_cell(summary, definition["name"]),
_text_cell(summary, label),
formulas[0],
formulas[1],
f"=IFERROR(C{row}-D{row},NA())",
f"=IFERROR(IF(D{row}=0,NA(),E{row}/ABS(D{row})),NA())",
f'=IF(ISNA(C{row}),"本期不可用",IF(ISNA(D{row}),"基期不可用","可用"))',
]
)
reconcile.append(
_styled_row(
reconcile,
["指标", "分组", "期间侧", "服务器 Decimal 参考", "公式结果", "差异", "状态"],
green,
white,
)
)
reconcile_row = 2
for label in labels:
for side_name, periods, col in (("本期", ctx["current"], "C"), ("基期", ctx["baseline"], "D")):
side_qs = qs.filter(period__in=periods, **{f"dimensions__{group_key}": group_values[label]})
reference = metrics(side_qs.aggregate(**aggregations(products)), mapping["roi_formula"], products)
for key, name in METRICS.items():
formula_row = metric_rows[(label, key)]
value = reference[key]
reconcile.append(
[
name,
_text_cell(reconcile, label),
side_name,
"" 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,"通过","待复核"),"待复核"))',
]
)
reconcile_row += 1
for index, definition in enumerate(custom_columns):
formula_row = metric_rows[(label, f"custom_{index}")]
reconcile.append(
[
_text_cell(reconcile, definition["name"]),
_text_cell(reconcile, label),
side_name,
"自定义公式无服务器参考",
f"='分析汇总'!{col}{formula_row}",
"",
"需业务验收",
]
)
reconcile_row += 1
wb.calculation.fullCalcOnLoad = True
wb.calculation.forceFullCalc = True
wb.calculation.calcMode = "auto"
wb.save(output)
return {"rows": count, "groups": len(labels), "formula_version": FORMULA_VERSION}
def process_formula_export(export_id):
job = FormulaExport.objects.select_related("dataset", "user").get(pk=export_id)
temporary = None
target = None
try:
root = Path(settings.MEDIA_ROOT) / "formula-exports"
if root.is_symlink():
raise ValueError("公式导出目录不能是符号链接")
root.mkdir(parents=True, exist_ok=True, mode=0o700)
root.chmod(0o700)
target = root / str(job.user_id) / f"{job.pk}.xlsx"
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if target.parent.is_symlink() or target.is_symlink():
raise ValueError("公式导出路径不能是符号链接")
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 set(member.departments) != set(job.departments):
raise PermissionError("科室授权已变更,请重新提交导出")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as file:
stats = build_formula_workbook(job.dataset, member, job.context, file)
temporary.replace(target)
job.path, job.status, job.error = str(target), "succeeded", ""
job.finished_at = timezone.now()
job.expires_at = timezone.now() + timedelta(hours=24)
job.save(update_fields=["path", "status", "error", "finished_at", "expires_at"])
return stats
except Exception as exc:
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], ""
job.finished_at, job.expires_at = timezone.now(), None
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
raise
def csv_safe(value):
text = str(value) if value is not None else ""
if not isinstance(value, str):
return text
return (
"'" + text
if text.startswith(("\t", "\r", "\n")) or text.lstrip().startswith(("=", "+", "-", "@"))
else text
)
+109
View File
@@ -0,0 +1,109 @@
import math
from .analysis import METRICS
RESERVED_DATA_HEADERS = {
"period",
"customertoken",
"revenue",
"cost",
"grossinput",
"marginrate",
"grosssource",
"listamount",
"selfoperated",
"customerpresent",
"rowcount",
"grosscalc",
}
def available_metrics(ds):
roles = {column["role"] for column in ds.mapping["columns"]}
dimensions = {column.get("key") for column in ds.mapping["columns"] if column["role"] == "dimension"}
products = bool(ds.mapping.get("self_operated_products")) and "product" in dimensions
gross = "gross" in roles or (bool(ds.mapping.get("margin_rates")) and "product" in dimensions)
customer, list_amount, cost = "customer" in roles, "list_amount" in roles, "cost" in roles
available = {"revenue"}
if cost:
available.add("cost")
if gross:
available.update(("gross", "margin"))
if customer:
available.update(("customers", "basket"))
if list_amount:
available.add("discount_rate")
if customer and list_amount:
available.add("list_basket")
if cost and (ds.mapping.get("roi_formula") == "profit_over_cost" or gross):
available.add("roi")
if products:
available.add("self_revenue")
if customer:
available.add("upgrade_rate")
return {key: value for key, value in METRICS.items() if key in available}
OPERATORS = {"add": "+", "subtract": "-", "multiply": "*", "divide": "/"}
def validate_column(value):
if not isinstance(value, dict) or set(value) != {"name", "meaning", "scope", "missing", "expression"}:
raise ValueError("自定义列定义字段无效")
for key, limit in (("name", 80), ("meaning", 500), ("scope", 200), ("missing", 300)):
text = value[key]
if not isinstance(text, str) or not text.strip() or len(text) > limit:
raise ValueError(f"自定义列{key}说明不完整")
if value["missing"] != "结果不可用(#N/A);零分母不可用":
raise ValueError("暂不支持自定义缺失值策略;请选择结果不可用(#N/A)及零分母不可用")
_validate_expression(value["expression"])
return value
def _validate_expression(node, depth=0):
if depth > 8 or not isinstance(node, dict):
raise ValueError("自定义计算公式结构无效")
if set(node) == {"metric"}:
if not isinstance(node["metric"], str) or node["metric"] not in METRICS:
raise ValueError("公式引用了不支持的指标")
return
if set(node) == {"number"}:
if (
type(node["number"]) not in (int, float)
or not math.isfinite(node["number"])
or abs(node["number"]) > 1e12
):
raise ValueError("公式常数无效")
return
if (
set(node) == {"operator", "left", "right"}
and isinstance(node["operator"], str)
and node["operator"] in OPERATORS
):
_validate_expression(node["left"], depth + 1)
_validate_expression(node["right"], depth + 1)
return
raise ValueError("公式包含不支持的字段或运算")
def metrics_used(node):
if "metric" in node:
return {node["metric"]}
if "number" in node:
return set()
return metrics_used(node["left"]) | metrics_used(node["right"])
def ensure_available_metrics(ds, column):
unavailable = metrics_used(column["expression"]) - available_metrics(ds).keys()
if unavailable:
names = "、".join(METRICS[key] for key in sorted(unavailable))
raise ValueError(f"当前数据集未映射计算“{names}”所需字段,不能生成此自定义列")
def to_excel(node, references):
if "metric" in node:
return references[node["metric"]]
if "number" in node:
return str(node["number"])
return f"({to_excel(node['left'], references)}{OPERATORS[node['operator']]}{to_excel(node['right'], references)})"
+328
View File
@@ -0,0 +1,328 @@
import json
from pathlib import Path
import httpx
from django.conf import settings
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 .views import APIError, audit, body, dataset, endpoint, limit
def design_column(messages, ds, ctx):
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
raise APIError("尚未配置外部 AI 服务", 503)
if not isinstance(messages, list) or not 1 <= len(messages) <= 12:
raise APIError("对话消息格式无效")
if any(
not isinstance(item, dict)
or set(item) != {"role", "content"}
or item["role"] not in ("user", "assistant")
or not isinstance(item["content"], str)
or len(item["content"]) > 1000
for item in messages
):
raise APIError("对话消息内容无效")
if (
messages[0]["role"] != "user"
or messages[-1]["role"] != "user"
or any(left["role"] == right["role"] for left, right in zip(messages, messages[1:]))
):
raise APIError("对话消息必须按用户描述、助手追问、用户补充的顺序提交")
available = available_metrics(ds)
def expression_schema(depth=0):
leaf = [
{
"type": "object",
"properties": {"metric": {"type": "string", "enum": list(available)}},
"required": ["metric"],
"additionalProperties": False,
},
{
"type": "object",
"properties": {
"number": {"type": "number", "minimum": -1000000000000, "maximum": 1000000000000}
},
"required": ["number"],
"additionalProperties": False,
},
]
if depth >= 8:
return {"anyOf": leaf}
return {
"anyOf": [
*leaf,
{
"type": "object",
"properties": {
"operator": {"type": "string", "enum": ["add", "subtract", "multiply", "divide"]},
"left": expression_schema(depth + 1),
"right": expression_schema(depth + 1),
},
"required": ["operator", "left", "right"],
"additionalProperties": False,
},
]
}
schema = {
"type": "object",
"properties": {
"complete": {"type": "boolean"},
"questions": {"type": "array", "items": {"type": "string"}},
"column": {
"anyOf": [
{"type": "null"},
{
"type": "object",
"properties": {
"name": {"type": "string"},
"meaning": {"type": "string"},
"scope": {"type": "string"},
"missing": {"type": "string", "enum": ["结果不可用(#N/A);零分母不可用"]},
"expression": expression_schema(),
},
"required": ["name", "meaning", "scope", "missing", "expression"],
"additionalProperties": False,
},
]
},
},
"required": ["complete", "questions", "column"],
"additionalProperties": False,
}
system = (
"你是财务计算定义助手。只可用给定指标生成表达式树,不可输出公式文本、SQL或新字段。"
"available_metrics 是当前数据集可计算指标;其他指标依赖字段未映射,必须说明并追问,不可猜测或替代。"
"必须确认列名、含义、适用范围和缺失值/零分母处理。缺少任何信息时 complete=false、column=null,提出具体中文问题。"
"指标节点{metric:指标key},常数{number:数值},运算{operator:add|subtract|multiply|divide,left:...,right:...}。"
"本系统当前仅支持结果不可用(Excel #N/A)且零分母不可用,不支持按零替代或跳过缺失项。"
"用户补充信息后结合整段对话重新判断,只有公式含义无歧义且能表达时 complete=true。"
+ json.dumps(
{
"metrics": METRICS,
"available_metrics": available,
"unavailable_metrics": {key: value for key, value in METRICS.items() if key not in available},
"dimensions": dimensions(ds),
"context": {k: ctx[k] for k in ("current", "baseline", "filters", "group")},
},
ensure_ascii=False,
)
)
payload = {
"model": settings.OPENAI_MODEL,
"store": False,
"max_output_tokens": 1400,
"input": [{"role": "system", "content": system}, *messages],
"text": {
"format": {
"type": "json_schema",
"name": "formula_column_design",
"strict": True,
"schema": schema,
}
},
}
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}"},
json=payload,
)
response.raise_for_status()
result = response.json()
if not isinstance(result, dict) or result.get("status") != "completed":
raise ValueError("incomplete response")
text = next(c["text"] for o in result["output"] for c in o["content"] if c["type"] == "output_text")
design = json.loads(text)
if (
set(design) != {"complete", "questions", "column"}
or type(design["complete"]) is not bool
or not isinstance(design["questions"], list)
or len(design["questions"]) > 8
):
raise ValueError("invalid response")
if design["complete"]:
if design["column"] is None:
raise ValueError("complete response has no column")
design["column"] = validate_column(design["column"])
try:
ensure_available_metrics(ds, design["column"])
except ValueError as exc:
return {"complete": False, "questions": [str(exc)], "column": None}
if design["questions"]:
raise ValueError("complete response contains questions")
elif (
design["column"] is not None
or not design["questions"]
or any(not isinstance(q, str) or not q.strip() or len(q) > 500 for q in design["questions"])
):
raise ValueError("incomplete response malformed")
return design
except (httpx.HTTPError, ValueError, KeyError, TypeError, StopIteration) as exc:
raise APIError("模型暂时不可用或返回了不安全的计算定义;未创建工作簿", 502) from exc
@endpoint(("POST",), roles=("admin", "analyst"))
def formula_design(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
if request.member.departments:
raise APIError("科室授权账号不可定义跨范围公式导出", 403)
ctx = context(ds, data.get("context", {}))
if data.get("consent") is not True:
raise APIError("请先同意发送列描述和筛选范围到外部 AI", 400)
limit(f"formula-design:{request.user.pk}", 20, 60)
result = design_column(data.get("messages"), ds, ctx)
audit(request, "formula_export.design", ds.pk, {"complete": result["complete"]})
return JsonResponse(result)
@endpoint(("GET", "POST"), roles=("admin", "analyst"))
def formula_definitions(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
if request.method == "GET":
items = FormulaDefinition.objects.filter(dataset=ds, user=request.user).order_by("normalized_name")
return JsonResponse(
{
"items": [
{
"id": str(item.pk),
"definition": item.definition,
"updated_at": item.updated_at.isoformat(),
}
for item in items
]
}
)
definition = validate_column(body(request).get("definition"))
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"]})
return JsonResponse(
{
"id": str(item.pk),
"definition": item.definition,
"updated_at": item.updated_at.isoformat(),
},
status=201 if created else 200,
)
@endpoint(("DELETE",), roles=("admin", "analyst"))
def formula_definition_delete(request, dataset_id, definition_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
item = FormulaDefinition.objects.filter(pk=definition_id, dataset=ds, user=request.user).first()
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()
return JsonResponse({"deleted": True})
@endpoint(("POST",), roles=("admin", "analyst"))
def formula_export_create(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.member.departments:
raise APIError("科室授权账号不可创建公式导出", 403)
data = body(request)
ctx = context(ds, data)
definitions = data.get("custom_columns", [])
if not isinstance(definitions, list) or len(definitions) > 10:
raise APIError("自定义列最多 10 个")
for item in definitions:
validate_column(item)
try:
ensure_available_metrics(ds, item)
except ValueError as exc:
raise APIError(str(exc)) from exc
limit(f"formula-export:{request.user.pk}", 5, 3600)
job = FormulaExport.objects.create(
dataset=ds,
user=request.user,
context={**ctx, "custom_columns": definitions},
departments=list(request.member.departments),
)
audit(
request,
"formula_export.create",
job.pk,
{"dataset": str(ds.pk), "columns": [x["name"] for x in definitions]},
)
return JsonResponse({"id": str(job.pk), "status": job.status}, status=202)
def _owned_job(request, dataset_id, export_id):
job = FormulaExport.objects.filter(
pk=export_id,
dataset_id=dataset_id,
dataset__organization=request.member.organization,
user=request.user,
).first()
if not job:
raise APIError("导出任务不存在", 404)
if set(request.member.departments) != set(job.departments):
raise APIError("授权范围已变化,不能访问此导出", 403)
return job
@endpoint(("GET",))
def formula_export_status(request, dataset_id, export_id):
job = _owned_job(request, dataset_id, export_id)
return JsonResponse(
{
"id": str(job.pk),
"status": job.status,
"error": "生成失败,请联系管理员并提供任务编号" if job.status == "failed" else "",
"expires_at": job.expires_at.isoformat() if job.expires_at else None,
}
)
@endpoint(("GET",))
def formula_export_download(request, dataset_id, export_id):
job = _owned_job(request, dataset_id, export_id)
if job.status != "succeeded" or not job.path or not job.expires_at or job.expires_at <= timezone.now():
raise APIError("文件尚未就绪或已过期", 410 if job.status == "succeeded" else 409)
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
candidate = Path(job.path)
expected = export_root / str(job.user_id) / f"{job.pk}.xlsx"
root = export_root.resolve()
path = candidate.resolve()
if (
export_root.is_symlink()
or candidate.is_symlink()
or candidate.parent.is_symlink()
or path != expected.resolve()
or not path.is_relative_to(root)
or not path.is_file()
):
raise APIError("导出文件不存在", 410)
audit(request, "formula_export.download", job.pk)
return FileResponse(path.open("rb"), as_attachment=True, filename="traceiq-formula-analysis.xlsx")
+378
View File
@@ -0,0 +1,378 @@
import csv
import hashlib
import re
import zipfile
from datetime import date, datetime
from decimal import Decimal, InvalidOperation, getcontext
from itertools import islice
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from openpyxl import load_workbook
from .formula_columns import RESERVED_DATA_HEADERS
from .models import Dataset, Fact
getcontext().prec = 60
ROLES = {
"period",
"year",
"month",
"revenue",
"cost",
"gross",
"quantity",
"customer",
"list_amount",
"discount",
"dimension",
"ignore",
}
ALIASES = {
"period": ["期间", "日期", "period", "date"],
"year": ["年份", "年", "year"],
"month": ["月份", "月", "month"],
"revenue": ["实际收入", "收入", "revenue", "actual_revenue"],
"cost": ["成本", "cost"],
"gross": ["毛利", "毛利贡献", "gross_profit"],
"quantity": ["数量", "quantity"],
"customer": ["客户姓名", "客户编码", "customer_id"],
"list_amount": ["应收金额", "list_amount"],
"discount": ["折扣金额(去税)", "discount"],
}
DIMENSIONS = {
"科室(重分类)": "department",
"科室": "department",
"department": "department",
"学科分类": "subject",
"学科": "subject",
"医生等级": "doctor_level",
"医生": "doctor",
"医生ID": "doctor",
"产品一级分类": "product",
"产品二级分类": "product_subcategory",
"区域": "region",
"顾客来源": "source",
}
def safe_text(value):
if value is None:
return ""
if isinstance(value, (datetime, date)):
return value.isoformat()
return str(value)[:4000]
def validate_archive(file):
file.seek(0)
with zipfile.ZipFile(file) as archive:
infos = archive.infolist()
if len(infos) > 5000 or sum(i.file_size for i in infos) > 1024 * 1024 * 1024:
raise ValueError("工作簿解压体积超过安全限制")
if any(i.file_size / max(i.compress_size, 1) > 1000 for i in infos):
raise ValueError("工作簿压缩比异常")
if any("vbaProject" in i.filename or "externalLinks/" in i.filename for i in infos):
raise ValueError("不接受宏或外部链接工作簿")
file.seek(0)
def rows_for(path, sheet=None, formulas=False):
if str(path).lower().endswith(".csv"):
with open(path, encoding="utf-8-sig", newline="") as file:
yield from csv.reader(file)
else:
wb = load_workbook(path, read_only=True, data_only=not formulas, keep_links=False)
try:
yield from wb[sheet].iter_rows(values_only=True)
finally:
wb.close()
def profile_file(path):
if str(path).lower().endswith(".csv"):
sheets = [{"name": "CSV", "rows": None, "columns": None}]
else:
with open(path, "rb") as file:
validate_archive(file)
wb = load_workbook(path, read_only=True, data_only=True, keep_links=False)
sheets = [{"name": s.title, "rows": s.max_row, "columns": s.max_column} for s in wb]
wb.close()
previews = []
for sheet in sheets:
preview_count = 600 if sheet["name"] == "产品毛利率" else 8
rows = [list(map(safe_text, r)) for r in islice(rows_for(path, sheet["name"]), preview_count)]
if any(len(r) > 200 for r in rows):
raise ValueError("最多支持 200 列")
previews.append({**sheet, "preview": rows})
return {"sheets": previews}
def suggest_mapping(profile, sheet=None, header=None):
s = next((s for s in profile["sheets"] if s["name"] == sheet), None)
s = s or next((s for s in profile["sheets"] if s["name"] == "明细"), profile["sheets"][0])
rows = s["preview"]
if not rows:
raise ValueError("工作表为空")
if header is None:
def score(row):
names = set(row)
return sum(any(a in names for a in aliases) for aliases in ALIASES.values())
header = max(range(min(5, len(rows))), key=lambda i: score(rows[i])) + 1
if not isinstance(header, int) or not 1 <= header <= len(rows):
raise ValueError("表头行超出预览范围")
columns, used = [], set()
for i, name in enumerate(rows[header - 1]):
role = next(
(r for r, names in ALIASES.items() if name in names),
"ignore" if name in {"单价", "折扣金额", "实收金额"} else "dimension",
)
if role != "dimension" and role in used:
role = "ignore"
# The reference workbook's L column is the agreed list-amount source.
if name == "应收金额" and i == 11:
for c in columns:
if c["role"] == "list_amount":
c["role"] = "ignore"
role = "list_amount"
used.add(role)
columns.append(
{
"index": i,
"name": name or f"列{i + 1}",
"description": rows[header - 2][i] if header > 1 and i < len(rows[header - 2]) else "",
"role": role,
"key": DIMENSIONS.get(name, f"field_{i}"),
"confidence": 1 if name in DIMENSIONS or role not in ("dimension", "ignore") else 0.5,
}
)
rates = {}
for lookup in profile["sheets"]:
if lookup["name"] != "产品毛利率":
continue
name_index = rate_index = None
for row in lookup["preview"]:
if "产品分类" in row and "毛利率" in row:
name_index, rate_index = row.index("产品分类"), row.index("毛利率")
elif name_index is not None and len(row) > max(name_index, rate_index) and row[name_index]:
try:
rate = decimal(row[rate_index])
if rate is not None and 0 <= rate <= 1:
rates[row[name_index]] = str(rate)
except ValueError:
pass
return {
"sheet": s["name"],
"header": header,
"columns": columns,
"margin_rates": rates,
"self_operated_products": [p for p in ("保健品", "检验检查", "医疗服务", "中药") if p in rates],
"roi_formula": "profit_over_cost",
"description": "",
}
def validate_mapping(mapping, profile):
if not isinstance(mapping, dict):
raise ValueError("映射格式错误")
sheets = {s["name"]: s for s in profile["sheets"]}
if (
not isinstance(mapping.get("sheet"), str)
or mapping["sheet"] not in sheets
or type(mapping.get("header")) is not int
or not 1 <= mapping["header"] <= 8
):
raise ValueError("工作表或表头无效")
columns = mapping.get("columns", [])
if not isinstance(columns, list) or not 1 <= len(columns) <= 200:
raise ValueError("映射列数无效")
roles, keys, indexes = [], [], []
for c in columns:
if (
not isinstance(c, dict)
or not isinstance(c.get("role"), str)
or c.get("role") not in ROLES
or type(c.get("index")) is not int
or not 0 <= c["index"] < 200
or not isinstance(c.get("name"), str)
or not 1 <= len(c["name"]) <= 256
or not isinstance(c.get("description", ""), str)
or len(c.get("description", "")) > 4000
):
raise ValueError("字段映射无效")
indexes.append(c["index"])
if c["role"] not in ("ignore", "dimension"):
roles.append(c["role"])
if c["role"] == "dimension":
key = c.get("key", "")
if not isinstance(key, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,49}", key) or "__" in key:
raise ValueError("维度标识只允许小写字母、数字和单下划线")
keys.append(key)
if len(set(roles)) != len(roles) or len(set(keys)) != len(keys) or len(set(indexes)) != len(indexes):
raise ValueError("指标、维度标识或列索引重复")
if RESERVED_DATA_HEADERS.intersection(key.casefold() for key in keys):
raise ValueError("维度标识与公式工作簿保留列名冲突")
if "revenue" not in roles or not ("period" in roles or {"year", "month"}.issubset(roles)):
raise ValueError("必须映射实际收入以及期间(或年份和月份)")
if not keys:
raise ValueError("至少保留一个分析维度")
rates = mapping.get("margin_rates", {})
if not isinstance(rates, dict) or len(rates) > 500:
raise ValueError("毛利率映射无效")
for key, value in rates.items():
if not isinstance(key, str) or not 1 <= len(key) <= 256:
raise ValueError("产品分类名称无效")
rate = decimal(value)
if rate is None or not Decimal(0) <= rate <= Decimal(1):
raise ValueError("毛利率必须在 0 到 1 之间")
if rates and "product" not in keys:
raise ValueError("毛利率映射需要 product 维度")
if mapping.get("roi_formula") not in ("profit_over_cost", "gross_less_cost_over_cost"):
raise ValueError("请选择 ROI 口径")
products = mapping.get("self_operated_products", [])
if (
not isinstance(products, list)
or len(products) > 500
or any(not isinstance(p, str) or not 1 <= len(p) <= 256 for p in products)
):
raise ValueError("自营产品范围无效")
if products and "product" not in keys:
raise ValueError("自营产品范围需要 product 维度")
return mapping
def decimal(value, required=False):
if value is None or str(value).strip() == "":
if required:
raise ValueError("必填金额为空")
return None
try:
number = Decimal(str(value).replace(",", "").strip())
if not number.is_finite() or abs(number) >= Decimal("1e16"):
raise ValueError("金额超出范围")
if number != number.quantize(Decimal("1e-24")):
raise ValueError("金额精度超过二十四位小数")
return number
except InvalidOperation as exc:
raise ValueError("不是有效金额") from exc
def period_value(values):
if values.get("period"):
value = values["period"]
if isinstance(value, (date, datetime)):
return value.strftime("%Y-%m")
text = str(value).strip().replace("/", "-")
match = re.fullmatch(r"(\d{4})-(\d{1,2})(?:-\d{1,2}(?:T.*)?)?", text)
if not match:
raise ValueError("期间必须是 YYYY-MM 或日期")
year, month = map(int, match.groups())
else:
year = int(str(values.get("year", "")).removesuffix("年"))
month = int(str(values.get("month", "")).removesuffix("月"))
if not 1900 <= year <= 2200 or not 1 <= month <= 12:
raise ValueError("期间超出有效范围")
return f"{year:04d}-{month:02d}"
def process_dataset(dataset_id):
ds = Dataset.objects.get(pk=dataset_id)
mapping = ds.mapping
quality = {
"negative_revenue": 0,
"missing_cost": 0,
"missing_gross": 0,
"missing_customer": 0,
"blank_rows": 0,
"g_l_mismatch": 0,
"i_n_mismatch": 0,
}
names = {c["index"]: c["name"] for c in mapping["columns"]}
reference_layout = names.get(6) == names.get(11) == "应收金额" and names.get(13) == "实际收入"
periods, batch, count = set(), [], 0
try:
with transaction.atomic():
if ds.facts.exists():
raise ValueError("数据版本已包含明细,禁止覆盖")
for row_no, row in enumerate(rows_for(ds.file.path, mapping["sheet"]), 1):
if row_no <= mapping["header"]:
continue
if not any(v is not None and str(v).strip() for v in row):
quality["blank_rows"] += 1
continue
count += 1
if count > settings.MAX_IMPORT_ROWS:
raise ValueError("数据行超过配置上限")
values, dims = {}, {}
for col in mapping["columns"]:
v = row[col["index"]] if col["index"] < len(row) else None
if col["role"] == "dimension":
dims[col["key"]] = safe_text(v)[:256] or "(未分类)"
elif col["role"] != "ignore":
values[col["role"]] = v
try:
period = period_value(values)
revenue = decimal(values.get("revenue"), required=True)
nums = {
k: decimal(values.get(k))
for k in ("cost", "gross", "quantity", "list_amount", "discount")
}
if nums["gross"] is None and dims.get("product") in mapping.get("margin_rates", {}):
nums["gross"] = (
revenue * Decimal(str(mapping["margin_rates"][dims["product"]]))
).quantize(Decimal("1e-24"))
customer = safe_text(values.get("customer"))[:256] or None
except (ValueError, TypeError, OverflowError) as exc:
raise ValueError(f"第 {row_no} 行:{exc}") from exc
quality["negative_revenue"] += revenue < 0
if reference_layout and len(row) >= 14:
quality["g_l_mismatch"] += row[6] != row[11]
quality["i_n_mismatch"] += row[8] != row[13]
quality["missing_cost"] += nums["cost"] is None
quality["missing_gross"] += nums["gross"] is None
quality["missing_customer"] += customer is None
periods.add(period)
batch.append(
Fact(
dataset=ds,
period=period,
revenue=revenue,
dimensions=dims,
customer=customer,
source_row=row_no,
raw=list(map(safe_text, row)),
**nums,
)
)
if len(batch) >= 2000:
Fact.objects.bulk_create(batch, batch_size=500)
batch.clear()
if not count:
raise ValueError("工作表没有数据行")
Fact.objects.bulk_create(batch, batch_size=500)
ds.status, ds.row_count, ds.periods, ds.quality = "ready", count, sorted(periods), quality
ds.error, ds.finished_at = "", timezone.now()
ds.save()
except Exception as exc:
Dataset.objects.filter(pk=ds.pk).update(
status="failed",
error=str(exc)[:500] if isinstance(exc, ValueError) else "导入失败,请查看服务端日志",
finished_at=timezone.now(),
)
if not isinstance(exc, ValueError):
import logging
logging.getLogger(__name__).exception("Import failed for %s", ds.pk)
return Dataset.objects.get(pk=ds.pk)
def file_digest(file):
digest = hashlib.sha256()
for chunk in file.chunks():
digest.update(chunk)
file.seek(0)
return digest.hexdigest()
View File
+29
View File
@@ -0,0 +1,29 @@
import getpass
import os
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from finance.models import Membership, Organization
class Command(BaseCommand):
help = "Create the initial organization administrator without a default password"
def add_arguments(self, parser):
parser.add_argument("--username", required=True)
parser.add_argument("--organization", required=True)
def handle(self, *args, **options):
username = options["username"]
if User.objects.filter(username=username).exists():
raise CommandError("Username already exists; no changes made")
password = os.environ.get("BOOTSTRAP_PASSWORD") or getpass.getpass("Administrator password: ")
validate_password(password, User(username=username))
with transaction.atomic():
org = Organization.objects.create(name=options["organization"])
user = User.objects.create_user(username=username, password=password)
Membership.objects.create(user=user, organization=org, role="admin")
self.stdout.write("Administrator created")
@@ -0,0 +1,65 @@
import time
from datetime import timedelta
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from finance.models import FormulaExport
class Command(BaseCommand):
help = "Remove expired formula workbooks and stale temporary files"
def add_arguments(self, parser):
parser.add_argument("--loop", action="store_true")
parser.add_argument("--interval", type=int, default=3600)
def handle(self, *args, **options):
while True:
jobs, files = self.clean_expired()
self.stdout.write(f"Expired {jobs} jobs; removed {files} files")
if not options["loop"]:
return
time.sleep(max(60, options["interval"]))
def clean_expired(self):
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
if export_root.is_symlink():
raise ValueError("公式导出目录不能是符号链接")
root = export_root.resolve()
now = timezone.now()
jobs = files = 0
for job in FormulaExport.objects.filter(status="succeeded", expires_at__lte=now).iterator():
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()
files += 1
job.status, job.path = "expired", ""
job.save(update_fields=["status", "path"])
jobs += 1
except OSError as exc:
self.stderr.write(f"Could not remove expired export {job.pk}: {exc}")
cutoff = (now - timedelta(hours=24)).timestamp()
if root.is_dir() and not root.is_symlink():
for user_dir in root.iterdir():
if user_dir.is_symlink() or not user_dir.is_dir():
continue
for path in user_dir.glob("*.tmp"):
try:
if path.is_symlink() or not path.is_file() or path.stat().st_mtime > cutoff:
continue
path.unlink()
files += 1
except OSError as exc:
self.stderr.write(f"Could not remove stale temporary export {path.name}: {exc}")
return jobs, files
@@ -0,0 +1,54 @@
from pathlib import Path
from django.contrib.auth.models import User
from django.core.files import File
from django.core.management.base import BaseCommand, CommandError
from finance.importing import file_digest, profile_file, suggest_mapping, validate_mapping
from finance.models import Audit, Dataset
class Command(BaseCommand):
help = "Import a local source using reviewed field aliases and lookup rates"
def add_arguments(self, parser):
parser.add_argument("path")
parser.add_argument("--username", required=True)
parser.add_argument("--confirm-mapping", action="store_true")
def handle(self, *args, **options):
user = User.objects.get(username=options["username"])
if user.membership.role != "admin" or user.membership.departments:
raise CommandError("Full administrator required")
path = Path(options["path"])
profile = profile_file(path)
mapping = validate_mapping(suggest_mapping(profile), profile)
if not options["confirm_mapping"]:
self.stdout.write(str(mapping))
self.stdout.write("Review mapping, then rerun with --confirm-mapping")
return
with path.open("rb") as source:
file = File(source, name=path.name)
digest = file_digest(file)
ds = Dataset.objects.filter(organization=user.membership.organization, sha256=digest).first()
if ds:
self.stdout.write(f"Existing version: {ds.pk} {ds.status}")
return
ds = Dataset.objects.create(
organization=user.membership.organization,
created_by=user,
name=path.name,
file=file,
sha256=digest,
mapping=mapping,
profile=profile,
status="queued",
)
Audit.objects.create(
organization=user.membership.organization,
user=user,
action="dataset.cli_import",
resource=str(ds.pk),
details={"mapping": mapping},
)
self.stdout.write(f"{ds.pk}: queued; import_worker will process this version")
@@ -0,0 +1,74 @@
import time
from django.core.management.base import BaseCommand
from django.db import connection, transaction
from django.utils import timezone
from finance.exporting import process_formula_export
from finance.importing import process_dataset
from finance.models import Dataset, FormulaExport
class Command(BaseCommand):
help = "Process durable import and formula-export jobs"
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true")
def handle(self, *args, **options):
postgres = connection.vendor == "postgresql"
if postgres:
with connection.cursor() as cursor:
cursor.execute("SELECT pg_try_advisory_lock(782146291)")
if not cursor.fetchone()[0]:
self.stdout.write("Another worker owns the database lock")
return
try:
self.run_jobs(options, postgres)
finally:
if postgres:
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(782146291)")
def run_jobs(self, options, recover):
while True:
processed = self._next_import(recover)
if not processed:
processed = self._next_export(recover)
if options["once"]:
return
if not processed:
time.sleep(2)
def _claim(self, model, states):
with transaction.atomic():
qs = model.objects.filter(status__in=states).order_by("created_at")
if connection.features.has_select_for_update_skip_locked:
qs = qs.select_for_update(skip_locked=True)
item = qs.first()
if not item:
return None
updated = model.objects.filter(pk=item.pk, status__in=states).update(status="processing")
return item if updated else None
def _next_import(self, recover):
states = ["queued", "processing"] if recover else ["queued"]
item = self._claim(Dataset, states)
if not item:
return False
Dataset.objects.filter(pk=item.pk).update(started_at=timezone.now())
result = process_dataset(item.pk)
self.stdout.write(f"{result.pk}: {result.status}, {result.row_count} rows")
return True
def _next_export(self, recover):
states = ["queued", "processing"] if recover else ["queued"]
item = self._claim(FormulaExport, states)
if not item:
return False
try:
stats = process_formula_export(item.pk)
self.stdout.write(f"{item.pk}: formula export ready, {stats['rows']} rows")
except Exception as exc:
self.stderr.write(f"Formula export {item.pk} failed: {exc}")
return True
+14
View File
@@ -0,0 +1,14 @@
class SecurityHeaders:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
)
response["Referrer-Policy"] = "same-origin"
response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
if request.path.startswith("/api/"):
response["Cache-Control"] = "no-store"
return response
+224
View File
@@ -0,0 +1,224 @@
# Generated by Django 5.2.17 on 2026-09-22 16:35
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Organization",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("name", models.CharField(max_length=160)),
],
),
migrations.CreateModel(
name="Throttle",
fields=[
("key", models.CharField(max_length=64, primary_key=True, serialize=False)),
("count", models.PositiveIntegerField(default=0)),
("expires_at", models.DateTimeField()),
],
),
migrations.CreateModel(
name="Dataset",
fields=[
(
"id",
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
("name", models.CharField(max_length=200)),
("file", models.FileField(upload_to="sources/%Y/%m")),
("sha256", models.CharField(max_length=64)),
("status", models.CharField(default="mapping", max_length=16)),
("profile", models.JSONField(default=dict)),
("mapping", models.JSONField(default=dict)),
("quality", models.JSONField(default=dict)),
("row_count", models.PositiveIntegerField(default=0)),
("periods", models.JSONField(default=list)),
("error", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("started_at", models.DateTimeField(null=True)),
("finished_at", models.DateTimeField(null=True)),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
),
(
"organization",
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="finance.organization"),
),
],
),
migrations.CreateModel(
name="Membership",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"role",
models.CharField(
choices=[("admin", "管理员"), ("analyst", "分析师"), ("viewer", "查看者")],
max_length=16,
),
),
("departments", models.JSONField(default=list)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
(
"organization",
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="finance.organization"),
),
],
),
migrations.CreateModel(
name="Audit",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("action", models.CharField(max_length=64)),
("resource", models.CharField(blank=True, max_length=100)),
("details", models.JSONField(default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
),
(
"organization",
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="finance.organization"),
),
],
),
migrations.CreateModel(
name="Preference",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("value", models.JSONField(default=dict)),
("revision", models.PositiveIntegerField(default=1)),
(
"dataset",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="finance.dataset"),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.CreateModel(
name="Fact",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("period", models.CharField(max_length=7)),
("dimensions", models.JSONField(default=dict)),
("customer", models.CharField(max_length=256, null=True)),
("revenue", models.DecimalField(decimal_places=4, max_digits=22)),
("cost", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("gross", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("quantity", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("list_amount", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("discount", models.DecimalField(decimal_places=4, max_digits=22, null=True)),
("source_row", models.PositiveIntegerField()),
("raw", models.JSONField(default=list)),
(
"dataset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="facts",
to="finance.dataset",
),
),
],
options={
"indexes": [
models.Index(fields=["dataset", "period"], name="finance_fac_dataset_69c9ce_idx")
],
"constraints": [
models.UniqueConstraint(fields=("dataset", "source_row"), name="unique_source_row")
],
},
),
migrations.CreateModel(
name="Investigation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("key", models.CharField(max_length=64)),
("context", models.JSONField(default=dict)),
("status", models.CharField(default="open", max_length=16)),
("note", models.TextField(blank=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"dataset",
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="finance.dataset"),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
),
],
options={
"constraints": [
models.UniqueConstraint(fields=("dataset", "user", "key"), name="unique_investigation")
],
},
),
migrations.AddConstraint(
model_name="dataset",
constraint=models.UniqueConstraint(fields=("organization", "sha256"), name="unique_org_source"),
),
migrations.AddConstraint(
model_name="preference",
constraint=models.UniqueConstraint(fields=("dataset", "user"), name="unique_preference"),
),
]
@@ -0,0 +1,42 @@
# Generated by Django 5.2.17 on 2026-09-22 16:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="fact",
name="cost",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="discount",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="gross",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="list_amount",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="quantity",
field=models.DecimalField(decimal_places=24, max_digits=44, null=True),
),
migrations.AlterField(
model_name="fact",
name="revenue",
field=models.DecimalField(decimal_places=24, max_digits=44),
),
]
+50
View File
@@ -0,0 +1,50 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0002_alter_fact_cost_alter_fact_discount_alter_fact_gross_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="FormulaExport",
fields=[
(
"id",
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
("context", models.JSONField(default=dict)),
("departments", models.JSONField(default=list)),
("status", models.CharField(default="queued", max_length=16)),
("path", models.CharField(blank=True, max_length=512)),
("error", models.CharField(blank=True, max_length=500)),
("created_at", models.DateTimeField(auto_now_add=True)),
("finished_at", models.DateTimeField(null=True)),
("expires_at", models.DateTimeField(null=True)),
(
"dataset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="formula_exports",
to="finance.dataset",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.AddIndex(
model_name="formulaexport",
index=models.Index(fields=["status", "created_at"], name="finance_for_status_fa1c07_idx"),
),
]
@@ -0,0 +1,50 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0003_formulaexport"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="FormulaDefinition",
fields=[
(
"id",
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
("normalized_name", models.CharField(max_length=320)),
("definition", models.JSONField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"dataset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="formula_definitions",
to="finance.dataset",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="formuladefinition",
constraint=models.UniqueConstraint(
fields=("dataset", "user", "normalized_name"),
name="unique_formula_definition_name",
),
),
]
View File
+132
View File
@@ -0,0 +1,132 @@
import uuid
from django.conf import settings
from django.db import models
class Organization(models.Model):
name = models.CharField(max_length=160)
class Membership(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
role = models.CharField(
max_length=16, choices=[("admin", "管理员"), ("analyst", "分析师"), ("viewer", "查看者")]
)
departments = models.JSONField(default=list)
class Dataset(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
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")
sha256 = models.CharField(max_length=64)
status = models.CharField(max_length=16, default="mapping")
profile = models.JSONField(default=dict)
mapping = models.JSONField(default=dict)
quality = models.JSONField(default=dict)
row_count = models.PositiveIntegerField(default=0)
periods = models.JSONField(default=list)
error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True)
finished_at = models.DateTimeField(null=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["organization", "sha256"], name="unique_org_source")]
class Fact(models.Model):
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name="facts")
period = models.CharField(max_length=7)
dimensions = models.JSONField(default=dict)
customer = models.CharField(max_length=256, null=True)
revenue = models.DecimalField(max_digits=44, decimal_places=24)
cost = models.DecimalField(max_digits=44, decimal_places=24, null=True)
gross = models.DecimalField(max_digits=44, decimal_places=24, null=True)
quantity = models.DecimalField(max_digits=44, decimal_places=24, null=True)
list_amount = models.DecimalField(max_digits=44, decimal_places=24, null=True)
discount = models.DecimalField(max_digits=44, decimal_places=24, null=True)
source_row = models.PositiveIntegerField()
raw = models.JSONField(default=list)
class Meta:
indexes = [models.Index(fields=["dataset", "period"])]
constraints = [models.UniqueConstraint(fields=["dataset", "source_row"], name="unique_source_row")]
class Preference(models.Model):
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
value = models.JSONField(default=dict)
revision = models.PositiveIntegerField(default=1)
class Meta:
constraints = [models.UniqueConstraint(fields=["dataset", "user"], name="unique_preference")]
class Investigation(models.Model):
dataset = models.ForeignKey(Dataset, on_delete=models.PROTECT)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
key = models.CharField(max_length=64)
context = models.JSONField(default=dict)
status = models.CharField(max_length=16, default="open")
note = models.TextField(blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["dataset", "user", "key"], name="unique_investigation")
]
class Audit(models.Model):
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
action = models.CharField(max_length=64)
resource = models.CharField(max_length=100, blank=True)
details = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Throttle(models.Model):
key = models.CharField(max_length=64, primary_key=True)
count = models.PositiveIntegerField(default=0)
expires_at = models.DateTimeField()
class FormulaExport(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name="formula_exports")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
context = models.JSONField(default=dict)
departments = models.JSONField(default=list)
status = models.CharField(max_length=16, default="queued")
path = models.CharField(max_length=512, blank=True)
error = models.CharField(max_length=500, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True)
expires_at = models.DateTimeField(null=True)
class Meta:
indexes = [models.Index(fields=["status", "created_at"], name="finance_for_status_fa1c07_idx")]
class FormulaDefinition(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name="formula_definitions")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
normalized_name = models.CharField(max_length=320)
definition = models.JSONField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["dataset", "user", "normalized_name"], name="unique_formula_definition_name"
)
]
+610
View File
@@ -0,0 +1,610 @@
import csv
import hashlib
import io
import ipaddress
import json
import math
import tempfile
import uuid
from datetime import timedelta
from functools import wraps
from pathlib import Path
from django.conf import settings
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import IntegrityError, connection, transaction
from django.http import FileResponse, HttpResponse, JsonResponse
from django.middleware.csrf import get_token
from django.utils import timezone
from openpyxl import Workbook
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
class APIError(Exception):
def __init__(self, message, status=400):
self.message, self.status = message, status
def endpoint(methods=("GET",), roles=None, public=False):
def decorate(fn):
@wraps(fn)
def view(request, *args, **kwargs):
try:
if request.method not in methods:
raise APIError("请求方法不允许", 405)
if not public:
if not request.user.is_authenticated:
raise APIError("请先登录", 401)
try:
request.member = request.user.membership
except Membership.DoesNotExist:
raise APIError("账号尚未分配组织", 403)
if roles and request.member.role not in roles:
raise APIError("没有此操作的权限", 403)
return fn(request, *args, **kwargs)
except APIError as exc:
return JsonResponse({"error": exc.message}, status=exc.status)
except (ValueError, ValidationError, json.JSONDecodeError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
return view
return decorate
def body(request):
if request.content_type != "application/json":
raise APIError("请求必须使用 application/json")
value = json.loads(request.body or b"{}")
if not isinstance(value, dict):
raise APIError("请求必须是 JSON 对象")
return value
def audit(request, action, resource="", details=None):
Audit.objects.create(
organization=request.member.organization,
user=request.user,
action=action,
resource=str(resource),
details=details or {},
)
def limit(key, count=10, seconds=900):
key = hashlib.sha256(key.encode()).hexdigest()
now = timezone.now()
with transaction.atomic():
item, _ = Throttle.objects.get_or_create(
key=key, defaults={"expires_at": now + timedelta(seconds=seconds)}
)
item = Throttle.objects.select_for_update().get(pk=key)
if item.expires_at <= now:
item.count, item.expires_at = 0, now + timedelta(seconds=seconds)
if item.count >= count:
raise APIError("请求过于频繁,请稍后重试", 429)
item.count += 1
item.save()
def dataset(request, dataset_id, ready=False):
ds = Dataset.objects.filter(pk=dataset_id, organization=request.member.organization).first()
if not ds:
raise APIError("数据集不存在", 404)
if ready and ds.status != "ready":
raise APIError("数据集尚未导入完成", 409)
return ds
def preferences(request, ds):
pref = Preference.objects.filter(dataset=ds, user=request.user).first()
return {**default_preferences(ds), **(pref.value if pref else {})}, pref.revision if pref else 0
def serialize_dataset(ds, detailed=False, restricted=False, raw_access=True):
out = {
"id": str(ds.pk),
"name": ds.name,
"status": ds.status,
"version": ds.sha256,
"row_count": None if restricted else ds.row_count,
"periods": ds.periods,
"created_at": ds.created_at.isoformat(),
"error": ds.error if not restricted else "",
"quality": ds.quality if not restricted else {},
"dimensions": dimensions(ds) if ds.mapping else [],
}
if detailed:
out.update(mapping=ds.mapping)
if not restricted and raw_access:
out["profile"] = ds.profile
return out
@endpoint(public=True)
def session(request):
data = {
"csrf": get_token(request),
"user": None,
"ai_enabled": settings.AI_ENABLED and bool(settings.OPENAI_API_KEY and settings.OPENAI_MODEL),
}
if request.user.is_authenticated:
try:
m = request.user.membership
data["user"] = {
"id": request.user.pk,
"name": request.user.username,
"role": m.role,
"organization": m.organization.name,
"departments": m.departments,
}
except Membership.DoesNotExist:
pass
return JsonResponse(data)
@endpoint(("POST",), public=True)
def sign_in(request):
data = body(request)
username, password = data.get("username", ""), data.get("password", "")
if (
not isinstance(username, str)
or not isinstance(password, str)
or len(username) > 150
or len(password) > 1024
):
raise APIError("登录参数无效")
address = request.META.get("REMOTE_ADDR", "unknown")
if settings.TRUST_PROXY and request.META.get("HTTP_X_REAL_IP"):
try:
address = str(ipaddress.ip_address(request.META["HTTP_X_REAL_IP"]))
except ValueError:
raise APIError("代理来源地址无效")
limit("login-ip:" + address, 30)
limit("login-user:" + username.casefold(), 10)
user = authenticate(request, username=username, password=password)
if not user or not Membership.objects.filter(user=user).exists():
raise APIError("用户名或密码错误", 401)
login(request, user)
request.member = user.membership
audit(request, "auth.login")
return JsonResponse({"ok": True, "csrf": get_token(request)})
@endpoint(("POST",))
def sign_out(request):
audit(request, "auth.logout")
logout(request)
return JsonResponse({"ok": True})
@endpoint(("GET", "POST"))
def datasets(request):
if request.method == "GET":
return JsonResponse(
{
"items": [
serialize_dataset(ds, restricted=bool(request.member.departments))
for ds in Dataset.objects.filter(organization=request.member.organization).order_by(
"-created_at"
)[:100]
]
}
)
if request.member.role not in ("admin", "analyst") or request.member.departments:
raise APIError("仅有完整数据权限的管理员或分析师可导入", 403)
limit(f"upload:{request.user.pk}", 20, 3600)
file = request.FILES.get("file")
if not file or not 0 < file.size <= settings.MAX_UPLOAD_BYTES:
raise APIError("文件为空或超过上传大小限制")
ext = Path(file.name).suffix.lower()
if ext not in (".xlsx", ".csv"):
raise APIError("仅接受 .xlsx 或 UTF-8 CSV")
digest = file_digest(file)
existing = Dataset.objects.filter(organization=request.member.organization, sha256=digest).first()
if existing:
return JsonResponse(
serialize_dataset(
existing,
True,
raw_access=request.member.role == "admin"
or (existing.created_by_id == request.user.pk and existing.status in ("mapping", "failed")),
),
status=200,
)
ds = Dataset(
organization=request.member.organization,
created_by=request.user,
name=Path(file.name).name[:200],
sha256=digest,
)
ds.file.save(str(uuid.uuid4()) + ext, file, save=False)
try:
ds.profile = profile_file(ds.file.path)
ds.mapping = suggest_mapping(ds.profile)
ds.save()
except IntegrityError:
ds.file.delete(save=False)
return JsonResponse(
serialize_dataset(
Dataset.objects.get(organization=request.member.organization, sha256=digest),
True,
raw_access=request.member.role == "admin",
)
)
except Exception as exc:
ds.file.delete(save=False)
raise APIError(
"无法读取文件,请检查格式或文件安全限制:"
+ (str(exc)[:160] if isinstance(exc, (ValueError, UnicodeError)) else "文件损坏")
) from exc
audit(request, "dataset.upload", ds.pk, {"sha256": digest})
return JsonResponse(serialize_dataset(ds, True), status=201)
@endpoint()
def dataset_detail(request, dataset_id):
ds = dataset(request, dataset_id)
raw_access = request.member.role == "admin" or (
ds.created_by_id == request.user.pk and ds.status in ("mapping", "failed")
)
result = serialize_dataset(ds, True, bool(request.member.departments), raw_access)
if ds.status == "ready":
result["preferences"], result["revision"] = preferences(request, ds)
return JsonResponse(result)
@endpoint(("POST",), roles=("admin", "analyst"))
def mapping(request, dataset_id):
ds = dataset(request, dataset_id)
if request.member.departments:
raise APIError("没有修改导入映射的权限", 403)
data = body(request)
if data.get("suggest"):
return JsonResponse(suggest_mapping(ds.profile, data.get("sheet"), data.get("header")))
value = validate_mapping(data, ds.profile)
updated = Dataset.objects.filter(pk=ds.pk, status__in=["mapping", "failed"]).update(
mapping=value, status="queued", error="", started_at=None, finished_at=None
)
if not updated:
raise APIError("数据版本已锁定,不能重复导入或修改", 409)
audit(request, "dataset.confirm_mapping", ds.pk, {"mapping": value})
return JsonResponse({"status": "queued"}, status=202)
@endpoint(("POST",))
def query(request, dataset_id):
ds = dataset(request, dataset_id, True)
pref, _ = preferences(request, ds)
limit(f"analysis:{request.user.pk}", 120, 60)
return JsonResponse(analyze(ds, request.member, body(request), pref))
@endpoint(("POST",))
def evidence(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
ctx = context(ds, data)
offset, size = data.get("offset", 0), data.get("limit", 50)
if type(offset) is not int or type(size) is not int or not 0 <= offset <= 1000000 or not 1 <= size <= 200:
raise APIError("分页参数无效")
side = data.get("side", "current")
if side not in ("current", "baseline"):
raise APIError("期间选择无效")
qs = scoped_facts(ds, request.member, ctx["filters"]).filter(period__in=ctx[side]).order_by("source_row")
rows = list(
qs[offset : offset + size].values(
"source_row", "period", "dimensions", "revenue", "cost", "gross", "raw"
)
)
# Raw patient/customer identifiers require an explicit administrator role.
if request.member.role != "admin":
for row in rows:
row.pop("raw")
audit(
request,
"evidence.read",
ds.pk,
{"context": ctx, "side": side, "offset": offset, "limit": size},
)
return JsonResponse(
{
"rows": rows,
"total": qs.count(),
"context": ctx,
"version": ds.sha256,
"sheet": ds.mapping["sheet"],
}
)
def csv_safe(value):
text = str(value) if value is not None else ""
if not isinstance(value, str):
return text
return (
"'" + text
if text.startswith(("\t", "\r", "\n")) or text.lstrip().startswith(("=", "+", "-", "@"))
else text
)
@endpoint(("POST",), roles=("admin", "analyst"))
def export(request, dataset_id):
ds = dataset(request, dataset_id, True)
data = body(request)
ctx = context(ds, data)
limit(f"export:{request.user.pk}", 10, 3600)
kind = data.get("format", "csv")
if kind not in ("csv", "xlsx"):
raise APIError("导出格式无效")
qs = (
scoped_facts(ds, request.member, ctx["filters"])
.filter(period__in=ctx["current"] + ctx["baseline"])
.order_by("source_row")
)
if qs.count() > 100000:
raise APIError("单次导出上限 100,000 行,请缩小期间或筛选范围")
output = tempfile.TemporaryFile(mode="w+b")
csv_stream = None
if kind == "csv":
output.write(b"\xef\xbb\xbf")
csv_stream = io.TextIOWrapper(output, encoding="utf-8", newline="", write_through=True)
csv_writer = csv.writer(csv_stream) if csv_stream else None
workbook = Workbook(write_only=True) if kind == "xlsx" else None
sheet = workbook.create_sheet("证据明细") if workbook else None
def write_row(values):
if sheet is None:
csv_writer.writerow(csv_safe(value) for value in values)
else:
cells = []
for value in values:
# Excel has only 15 significant digits; evidence amounts stay exact text.
cell = WriteOnlyCell(sheet, value=str(value) if value is not None else "")
cell.data_type = "s"
cells.append(cell)
sheet.append(cells)
try:
write_row(
[
"TraceIQ 数据版本",
ds.sha256,
]
)
write_row(["筛选上下文", json.dumps(ctx, ensure_ascii=False)])
write_row(["ROI 口径", ds.mapping["roi_formula"]])
write_row(["导出时间 UTC", timezone.now().isoformat()])
dims = dimensions(ds)
write_row(
[
"原始工作表",
"原始行号",
"期间",
*[d["name"] for d in dims],
"实际收入",
"成本",
"毛利贡献",
]
)
for row in qs.iterator(chunk_size=1000):
write_row(
[
ds.mapping["sheet"],
row.source_row,
row.period,
*[row.dimensions.get(d["key"], "") for d in dims],
row.revenue,
row.cost,
row.gross,
]
)
if workbook:
workbook.save(output)
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
csv_stream.flush()
csv_stream.detach()
csv_stream = None
content_type = "text/csv; charset=utf-8"
output.seek(0)
audit(request, "evidence.export", ds.pk, {"context": ctx, "format": kind})
return FileResponse(
output,
as_attachment=True,
filename=f"traceiq-evidence.{kind}",
content_type=content_type,
)
except Exception:
if csv_stream is not None:
csv_stream.close()
else:
output.close()
raise
@endpoint(("GET", "PUT"))
def settings_view(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.method == "GET":
value, revision = preferences(request, ds)
return JsonResponse({"value": value, "revision": revision})
data = body(request)
value = data.get("value")
defaults = default_preferences(ds)
if not isinstance(value, dict) or set(value) != set(defaults):
raise APIError("设置字段不完整")
allowed = {d["key"] for d in dimensions(ds)}
hierarchy = value["hierarchy"]
if (
not isinstance(hierarchy, list)
or not hierarchy
or any(not isinstance(k, str) or k not in allowed for k in hierarchy)
or len(set(hierarchy)) != len(hierarchy)
):
raise APIError("下钻层级无效或重复")
if (
value["entry"] not in ("overview", "anomalies")
or value["comparison"] not in ("yoy", "mom")
or not isinstance(value["metric"], str)
or value["metric"] not in METRICS
):
raise APIError("默认视图或指标无效")
for field, lo, hi in [("threshold", 0, 10), ("min_impact", 0, 1e15), ("min_samples", 1, 1000000)]:
v = value[field]
if type(v) not in (int, float) or not math.isfinite(v) or not lo <= v <= hi:
raise APIError("异常阈值无效")
if type(value["min_samples"]) is not int:
raise APIError("最小样本数必须为整数")
for field in ["show_heatmap", "show_queue", "show_copilot", "alerts"]:
if type(value[field]) is not bool:
raise APIError("显示设置必须为布尔值")
with transaction.atomic():
pref = Preference.objects.select_for_update().filter(dataset=ds, user=request.user).first()
if data.get("revision") != (pref.revision if pref else 0):
raise APIError("设置已在其他窗口更新,请重新打开", 409)
if pref:
pref.value, pref.revision = value, pref.revision + 1
pref.save()
else:
try:
pref = Preference.objects.create(dataset=ds, user=request.user, value=value)
except IntegrityError as exc:
raise APIError("设置发生并发更新,请重新打开", 409) from exc
audit(request, "settings.update", ds.pk, {"revision": pref.revision, "value": value})
return JsonResponse({"value": pref.value, "revision": pref.revision})
@endpoint(("GET", "POST"))
def investigations(request, dataset_id):
ds = dataset(request, dataset_id, True)
if request.method == "GET":
return JsonResponse(
{
"items": list(
Investigation.objects.filter(dataset=ds, user=request.user).values(
"id", "key", "context", "status", "note", "updated_at"
)
)
}
)
if request.member.role == "viewer":
raise APIError("查看者不可修改调查状态", 403)
data = body(request)
ctx = context(ds, data.get("context", {}))
status, note = data.get("status"), data.get("note", "")
if status not in ("open", "reviewing", "resolved") or not isinstance(note, str) or len(note) > 2000:
raise APIError("调查状态或备注无效")
key = hashlib.sha256(json.dumps(ctx, sort_keys=True).encode()).hexdigest()
item, _ = Investigation.objects.update_or_create(
dataset=ds, user=request.user, key=key, defaults={"context": ctx, "status": status, "note": note}
)
audit(request, "investigation.update", ds.pk, {"key": key, "status": status})
return JsonResponse({"id": item.pk, "key": key, "status": status})
@endpoint(("GET", "POST", "PATCH"), roles=("admin",))
def users(request):
if request.member.departments:
raise APIError("限域管理员不能管理账号", 403)
if request.method == "GET":
return JsonResponse(
{
"items": [
{
"id": m.user_id,
"username": m.user.username,
"role": m.role,
"active": m.user.is_active,
"departments": m.departments,
}
for m in Membership.objects.filter(
organization=request.member.organization
).select_related("user")
]
}
)
data = body(request)
role, departments = data.get("role"), data.get("departments", [])
if (
role not in ("admin", "analyst", "viewer")
or not isinstance(departments, list)
or any(not isinstance(d, str) or not d or len(d) > 256 for d in departments)
or len(departments) > 100
):
raise APIError("角色或科室范围无效")
with transaction.atomic():
if request.method == "POST":
username, password = data.get("username"), data.get("password")
if (
not isinstance(username, str)
or not 1 <= len(username) <= 150
or not isinstance(password, str)
):
raise APIError("账号格式无效")
validate_password(password, User(username=username))
try:
user = User.objects.create_user(username=username, password=password)
except IntegrityError as exc:
raise APIError("用户名已存在", 409) from exc
Membership.objects.create(
user=user, organization=request.member.organization, role=role, departments=departments
)
else:
if type(data.get("id")) is not int or data["id"] <= 0:
raise APIError("账号 ID 无效")
membership = (
Membership.objects.filter(user_id=data.get("id"), organization=request.member.organization)
.select_related("user")
.first()
)
if not membership:
raise APIError("账号不存在", 404)
if membership.user_id == request.user.pk:
raise APIError("不可在此修改自己的权限")
if type(data.get("active")) is not bool:
raise APIError("账号状态无效")
user = membership.user
user.is_active = data["active"]
user.save(update_fields=["is_active"])
membership.role, membership.departments = role, departments
membership.save()
audit(request, "user.update", user.pk, {"role": role, "departments": departments})
return JsonResponse({"ok": True})
@endpoint(roles=("admin",))
def audit_log(request):
if request.member.departments:
raise APIError("没有组织审计权限", 403)
return JsonResponse(
{
"items": list(
Audit.objects.filter(organization=request.member.organization)
.order_by("-id")
.values("id", "user__username", "action", "resource", "details", "created_at")[:200]
)
}
)
@endpoint(public=True)
def health(request):
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
return JsonResponse({"status": "ok"})
def index(request):
path = settings.BASE_DIR / "frontend/dist/index.html"
if not path.exists():
return HttpResponse("Frontend not built. Run npm run build.", status=503)
return FileResponse(path.open("rb"), content_type="text/html")
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#16665b" />
<title>TraceIQ | 财务分析工作台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1552
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { BarChart, LineChart, HeatmapChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
LegendComponent,
VisualMapComponent,
AriaComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([
BarChart,
LineChart,
HeatmapChart,
GridComponent,
TooltipComponent,
LegendComponent,
VisualMapComponent,
AriaComponent,
CanvasRenderer,
]);
export function Chart({
option,
label,
onClick,
}: {
option: echarts.EChartsCoreOption;
label: string;
onClick?: (name: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const chart = echarts.init(ref.current!);
chart.setOption({ ...option, aria: { enabled: true }, animation: false });
if (onClick) chart.on("click", (p) => onClick(p.name));
const observer = new ResizeObserver(() => chart.resize());
observer.observe(ref.current!);
return () => {
observer.disconnect();
chart.dispose();
};
}, [option, onClick]);
return <div className="chart" ref={ref} role="img" aria-label={label} />;
}
+38
View File
@@ -0,0 +1,38 @@
let csrf = "";
export function setCsrf(value: string) {
csrf = value;
}
export async function api<T>(
path: string,
method = "GET",
data?: unknown,
): Promise<T> {
const form = data instanceof FormData;
const response = await fetch("/api" + path, {
method,
credentials: "same-origin",
headers: {
...(method !== "GET" ? { "X-CSRFToken": csrf } : {}),
...(!form && data ? { "Content-Type": "application/json" } : {}),
},
body: data ? (form ? data : JSON.stringify(data)) : undefined,
});
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ error: `请求失败 (${response.status})` }));
throw new Error(error.error || "请求失败");
}
if (response.headers.get("Content-Disposition")?.includes("attachment"))
return (await response.blob()) as T;
return response.json();
}
export async function download(path: string, data: unknown, kind = "csv") {
const blob = await api<Blob>(path, "POST", data);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `traceiq-evidence.${kind}`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+952
View File
@@ -0,0 +1,952 @@
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-react";
import { api } from "./api";
import {
labels,
type Context,
type Dataset,
type Metric,
type Preferences,
type Session,
} from "./types";
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);
const operators = { add: "+", subtract: "−", multiply: "×", divide: "÷" };
return `(${describeExpression(expression.left)} ${operators[expression.operator]} ${describeExpression(expression.right)})`;
}
export function Modal({
title,
onClose,
wide = false,
children,
}: {
title: string;
onClose: () => void;
wide?: boolean;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
dialog.showModal();
return () => dialog.close();
}, []);
return (
<dialog ref={ref} className={wide ? "wide" : ""} aria-label={title}>
<header>
<h2>{title}</h2>
<button className="icon" aria-label="关闭" onClick={onClose}>
<X />
</button>
</header>
{children}
</dialog>
);
}
export function ImportDialog({
ds,
onClose,
onDone,
}: {
ds: Dataset;
onClose: () => void;
onDone: () => void;
}) {
const [mapping, setMapping] = useState(ds.mapping),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
async function submit() {
setBusy(true);
setError("");
try {
await api(`/datasets/${ds.id}/mapping`, "POST", mapping);
onDone();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function update(
index: number,
patch: Partial<(typeof mapping.columns)[number]>,
) {
setMapping({
...mapping,
columns: mapping.columns.map((c) =>
c.index === index ? { ...c, ...patch } : c,
),
});
}
return (
<Modal title="确认导入字段" onClose={onClose} wide>
<div className="dialog-body">
<p>{ds.name} · 检查识别字段及财务口径后确认导入。</p>
<label>
工作表
<select
value={mapping.sheet}
onChange={(e) => setMapping({ ...mapping, sheet: e.target.value })}
>
{ds.profile?.sheets.map((s) => (
<option key={s.name}>{s.name}</option>
))}
</select>
</label>
<label>
ROI 口径
<select
value={mapping.roi_formula}
onChange={(e) =>
setMapping({ ...mapping, roi_formula: e.target.value })
}
>
<option value="profit_over_cost">(收入 - 成本)/ 成本</option>
<option value="gross_less_cost_over_cost">
(毛利贡献 - 成本)/ 成本
</option>
</select>
</label>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>源字段</th>
<th>角色</th>
<th>维度键</th>
<th>说明</th>
</tr>
</thead>
<tbody>
{mapping.columns.map((c) => (
<tr key={c.index}>
<td>{c.name}</td>
<td>
<select
value={c.role}
onChange={(e) =>
update(c.index, { role: e.target.value })
}
>
{[
"period",
"year",
"month",
"revenue",
"cost",
"gross",
"quantity",
"list_amount",
"discount",
"customer",
"dimension",
"ignore",
].map((r) => (
<option key={r}>{r}</option>
))}
</select>
</td>
<td>
{c.role === "dimension" && (
<input
value={c.key}
onChange={(e) =>
update(c.index, { key: e.target.value })
}
/>
)}
</td>
<td>
<input
value={c.description}
onChange={(e) =>
update(c.index, { description: e.target.value })
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<footer>
<span>导入后该版本映射将锁定。</span>
<button onClick={onClose}>取消</button>
<button className="primary" disabled={busy} onClick={submit}>
{busy ? "提交中…" : "确认并导入"}
</button>
</footer>
</div>
</Modal>
);
}
export function SettingsDialog({
ds,
onClose,
onSave,
}: {
ds: Dataset;
onClose: () => void;
onSave: (value: Preferences, revision: number) => void;
}) {
const [value, setValue] = useState(ds.preferences),
[revision, setRevision] = useState(ds.revision || 0),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
useEffect(() => {
api<{ value: Preferences; revision: number }>(`/datasets/${ds.id}/settings`)
.then((r) => {
setValue(r.value);
setRevision(r.revision);
})
.catch((e) => setError(e.message));
}, [ds.id]);
async function save() {
setBusy(true);
setError("");
try {
const result = await api<{ value: Preferences; revision: number }>(
`/datasets/${ds.id}/settings`,
"PUT",
{ value, revision },
);
onSave(result.value, result.revision);
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Modal title="分析设置" onClose={onClose} wide>
<div className="dialog-body">
<label>
默认比较
<select
value={value.comparison}
onChange={(e) =>
setValue({
...value,
comparison: e.target.value as Preferences["comparison"],
})
}
>
<option value="yoy">同比</option>
<option value="mom">环比</option>
</select>
</label>
<label>
默认指标
<select
value={value.metric}
onChange={(e) =>
setValue({ ...value, metric: e.target.value as Metric })
}
>
{Object.entries(labels).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</label>
<label>
异常阈值
<input
type="number"
min="0"
max="10"
step="0.01"
value={value.threshold}
onChange={(e) =>
setValue({ ...value, threshold: Number(e.target.value) })
}
/>
</label>
<label>
最小影响额
<input
type="number"
min="0"
value={value.min_impact}
onChange={(e) =>
setValue({ ...value, min_impact: Number(e.target.value) })
}
/>
</label>
<label>
最小样本数
<input
type="number"
min="1"
value={value.min_samples}
onChange={(e) =>
setValue({ ...value, min_samples: Number(e.target.value) })
}
/>
</label>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<footer>
<button onClick={onClose}>取消</button>
<button className="primary" disabled={busy} onClick={save}>
{busy ? "保存中…" : "保存设置"}
</button>
</footer>
</div>
</Modal>
);
}
export function EvidenceDialog({
ds,
ctx,
user,
onClose,
}: {
ds: Dataset;
ctx: Context;
user: NonNullable<Session["user"]>;
onClose: () => void;
}) {
const pageSize = 50;
const [result, setResult] = useState<{
rows: Record<string, unknown>[];
total: number;
sheet: string;
}>(),
[side, setSide] = useState<"current" | "baseline">("current"),
[page, setPage] = useState(0),
[error, setError] = useState("");
useEffect(() => {
let active = true;
setResult(undefined);
setError("");
api<{ rows: Record<string, unknown>[]; total: number; sheet: string }>(
`/datasets/${ds.id}/evidence`,
"POST",
{ ...ctx, side, offset: page * pageSize, limit: pageSize },
)
.then((value) => active && setResult(value))
.catch((e) => active && setError(e.message));
return () => {
active = false;
};
}, [ds.id, ctx, side, page]);
function selectSide(value: "current" | "baseline") {
setResult(undefined);
setPage(0);
setSide(value);
}
function selectPage(value: number) {
setResult(undefined);
setPage(value);
}
const first = result?.total ? page * pageSize + 1 : 0;
const last = result ? Math.min((page + 1) * pageSize, result.total) : 0;
return (
<Modal title="证据明细" onClose={onClose} wide>
<div className="dialog-body">
<div className="segmented" role="group" aria-label="证据期间">
<button
className={side === "current" ? "active" : ""}
aria-pressed={side === "current"}
onClick={() => selectSide("current")}
>
本期
</button>
<button
className={side === "baseline" ? "active" : ""}
aria-pressed={side === "baseline"}
onClick={() => selectSide("baseline")}
>
基期
</button>
</div>
<p>
来源工作表:{result?.sheet || "加载中"} · {result?.total ?? 0} 条
</p>
{user.role !== "admin" && <p>客户原始标识不包含在明细中。</p>}
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<div className="table-scroll">
<table>
<thead>
<tr>
<th>来源行</th>
<th>期间</th>
{ds.dimensions.map((d) => (
<th key={d.key}>{d.name}</th>
))}
<th>收入</th>
<th>成本</th>
<th>毛利</th>
</tr>
</thead>
<tbody>
{result?.rows.map((r, i) => (
<tr key={i}>
<td>{String(r.source_row)}</td>
<td>{String(r.period)}</td>
{ds.dimensions.map((d) => (
<td key={d.key}>
{String(
(r.dimensions as Record<string, string>)[d.key] || "",
)}
</td>
))}
<td>{String(r.revenue)}</td>
<td>{String(r.cost ?? "")}</td>
<td>{String(r.gross ?? "")}</td>
</tr>
))}
</tbody>
</table>
</div>
<footer>
<span>
{result
? `第 ${first}-${last} 条,共 ${result.total} 条`
: error
? "加载失败"
: "加载中"}
</span>
<button
className="icon"
aria-label="上一页"
title="上一页"
disabled={!result || page === 0}
onClick={() => selectPage(page - 1)}
>
<ChevronLeft />
</button>
<button
className="icon"
aria-label="下一页"
title="下一页"
disabled={!result || (page + 1) * pageSize >= result.total}
onClick={() => selectPage(page + 1)}
>
<ChevronRight />
</button>
<button onClick={onClose}>关闭</button>
</footer>
</div>
</Modal>
);
}
export function Copilot({
ds,
ctx,
ai,
onClose,
onApply,
}: {
ds: Dataset;
ctx: Context;
ai: boolean;
onClose: () => void;
onApply: (reply: { context: Context; intent: { action: string } }) => void;
}) {
const [question, setQuestion] = useState(""),
[consent, setConsent] = useState(false),
[useModel, setUseModel] = useState(false),
[answer, setAnswer] = useState(""),
[error, setError] = useState(""),
[busy, setBusy] = useState(false);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
const r = await api<{
answer: string;
context: Context;
intent: { action: string };
}>(`/datasets/${ds.id}/assistant`, "POST", {
question,
context: ctx,
use_model: useModel,
consent,
});
setAnswer(r.answer);
onApply(r);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<aside className="copilot">
<header>
<strong>财务分析助手</strong>
<button className="icon" aria-label="关闭" onClick={onClose}>
<X />
</button>
</header>
<div className="context-tag">
当前范围:{Object.values(ctx.filters).join(" / ") || "全部"} · {ds.name}
</div>
<div className="chat-messages" aria-live="polite">
{answer && <p className="message">{answer}</p>}
{error && (
<p role="alert" className="error">
{error}
</p>
)}
</div>
<form onSubmit={submit}>
<label>
描述要分析的问题
<textarea
required
maxLength={2000}
value={question}
onChange={(e) => setQuestion(e.target.value)}
/>
</label>
{ai && (
<>
<label className="check">
<input
type="checkbox"
checked={useModel}
onChange={(e) => setUseModel(e.target.checked)}
/>
使用外部 AI 理解问题
</label>
{useModel && (
<label className="check">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
同意发送问题和筛选上下文
</label>
)}
</>
)}
<button
className="primary"
disabled={busy || !question.trim() || (useModel && !consent)}
>
{busy ? "分析中…" : "发送"}
</button>
</form>
</aside>
);
}
export function AdminDialog({
mode,
onClose,
}: {
mode: "users" | "audit";
onClose: () => void;
}) {
const [items, setItems] = useState<Record<string, unknown>[]>([]),
[error, setError] = useState("");
useEffect(() => {
api<{ items: Record<string, unknown>[] }>(
mode === "users" ? "/users" : "/audit",
)
.then((r) => setItems(r.items))
.catch((e) => setError(e.message));
}, [mode]);
return (
<Modal
title={mode === "users" ? "账号与权限" : "操作审计"}
onClose={onClose}
wide
>
<div className="dialog-body">
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<div className="table-scroll">
<table>
<thead>
<tr>
{mode === "users" ? (
<>
<th>账号</th>
<th>角色</th>
<th>状态</th>
<th>科室</th>
</>
) : (
<>
<th>时间</th>
<th>用户</th>
<th>操作</th>
<th>资源</th>
<th>详情</th>
</>
)}
</tr>
</thead>
<tbody>
{items.map((r, i) => (
<tr key={String(r.id ?? i)}>
{mode === "users" ? (
<>
<td>{String(r.username)}</td>
<td>{String(r.role)}</td>
<td>{r.active ? "启用" : "停用"}</td>
<td>
{String(
((r.departments || []) as string[]).join("、") ||
"全部",
)}
</td>
</>
) : (
<>
<td>{String(r.created_at)}</td>
<td>{String(r.user__username)}</td>
<td>{String(r.action)}</td>
<td>{String(r.resource)}</td>
<td>{JSON.stringify(r.details)}</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
<footer>
<button onClick={onClose}>关闭</button>
</footer>
</div>
</Modal>
);
}
export function FormulaExportDialog({
ds,
ctx,
onClose,
}: {
ds: Dataset;
ctx: Context;
onClose: () => void;
}) {
const [messages, setMessages] = useState<
{ role: "user" | "assistant"; content: string }[]
>([]),
[draft, setDraft] = useState(""),
[column, setColumn] = useState<Record<string, unknown> | null>(null),
[questions, setQuestions] = useState<string[]>([]),
[task, setTask] = useState<{
id: string;
status: string;
error?: string;
}>(),
[definitions, setDefinitions] = useState<SavedFormulaDefinition[]>([]),
[selectedDefinition, setSelectedDefinition] = useState(""),
[consent, setConsent] = useState(false),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
useEffect(() => {
api<{ items: SavedFormulaDefinition[] }>(
`/datasets/${ds.id}/formula-definitions`,
)
.then((result) => setDefinitions(result.items))
.catch((e) => setError(e.message));
}, [ds.id]);
useEffect(() => {
if (!task || !["queued", "processing"].includes(task.status)) return;
const timer = window.setInterval(
() =>
api<{ status: string; error: string }>(
`/datasets/${ds.id}/formula-exports/${task.id}`,
)
.then((r) => setTask((old) => old && { ...old, ...r }))
.catch((e) => setError(e.message)),
1500,
);
return () => window.clearInterval(timer);
}, [ds.id, task]);
const userTurns = messages.filter(
(message) => message.role === "user",
).length;
async function send() {
const text = draft.trim();
if (!text || !consent || userTurns >= 6) return;
const next = [...messages, { role: "user" as const, content: text }];
setBusy(true);
setError("");
setQuestions([]);
setColumn(null);
try {
const result = await api<{
complete: boolean;
questions: string[];
column: Record<string, unknown> | null;
}>(`/datasets/${ds.id}/formula-design`, "POST", {
context: ctx,
messages: next,
consent: true,
});
setMessages([
...next,
{
role: "assistant",
content: result.complete
? `已整理列定义:${String(result.column?.name)};含义:${String(result.column?.meaning)};适用范围:${String(result.column?.scope)};缺失处理:${String(result.column?.missing)};表达式:${JSON.stringify(result.column?.expression)}`
: result.questions.join("\n"),
},
]);
setQuestions(result.questions);
setColumn(result.column);
setDraft("");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function create() {
if (!column) return;
setBusy(true);
setError("");
try {
setTask(
await api<{ id: string; status: string }>(
`/datasets/${ds.id}/formula-exports`,
"POST",
{ ...ctx, custom_columns: [column] },
),
);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function saveDefinition() {
if (!column) return;
setBusy(true);
setError("");
try {
const saved = await api<SavedFormulaDefinition>(
`/datasets/${ds.id}/formula-definitions`,
"POST",
{ definition: column },
);
setDefinitions((items) =>
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
String(a.definition.name).localeCompare(String(b.definition.name), "zh-CN"),
),
);
setSelectedDefinition(saved.id);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function loadDefinition(id: string) {
setSelectedDefinition(id);
const saved = definitions.find((item) => item.id === id);
if (!saved) return;
setColumn(saved.definition);
setMessages([]);
setQuestions([]);
setDraft("");
}
async function deleteDefinition() {
if (!selectedDefinition) return;
setBusy(true);
setError("");
try {
await api(
`/datasets/${ds.id}/formula-definitions/${selectedDefinition}`,
"DELETE",
);
setDefinitions((items) =>
items.filter((item) => item.id !== selectedDefinition),
);
setSelectedDefinition("");
setColumn(null);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
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;
link.download = "traceiq-formula-analysis.xlsx";
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
setError((e as Error).message);
}
}
return (
<Modal title="生成公式工作簿" onClose={onClose} wide>
<div className="dialog-body">
<p>描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。</p>
{definitions.length > 0 && !task && (
<div className="form-row">
<label>
已保存定义
<select
value={selectedDefinition}
onChange={(event) => loadDefinition(event.target.value)}
disabled={busy}
>
<option value="">选择定义</option>
{definitions.map((item) => (
<option key={item.id} value={item.id}>
{String(item.definition.name)}
</option>
))}
</select>
</label>
<button
className="icon"
aria-label="删除所选定义"
title="删除所选定义"
disabled={busy || !selectedDefinition}
onClick={deleteDefinition}
>
<Trash2 />
</button>
</div>
)}
<label className="check">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
同意将描述、指标定义和当前筛选范围发送至已配置的外部 AI 服务
</label>
<div className="chat-messages" aria-live="polite">
{messages.map((m, i) => (
<p key={i}>
<strong>{m.role === "user" ? "我" : "助手"}:</strong>
{m.content}
</p>
))}
</div>
<p className="muted">本轮描述 {userTurns}/6</p>
{!task && (
<>
<textarea
aria-label="列计算描述"
value={draft}
onChange={(e) => {
setDraft(e.target.value);
}}
disabled={busy}
rows={3}
maxLength={1000}
/>
<button disabled={busy || !consent || !draft.trim()} onClick={send}>
{busy ? "处理中…" : userTurns >= 6 ? "已达对话上限" : "发送描述"}
</button>
{column && (
<section>
<h3>{String(column.name)}</h3>
<p>{String(column.meaning)}</p>
<p>
计算表达式:
{describeExpression(column.expression as FormulaExpression)}
</p>
<p>适用范围:{String(column.scope)}</p>
<p>缺失/零分母:{String(column.missing)}</p>
<button
disabled={busy || Boolean(draft.trim())}
onClick={saveDefinition}
>
<Save />
保存到定义库
</button>
<button
disabled={busy || Boolean(draft.trim())}
onClick={create}
>
{draft.trim() ? "先发送补充内容" : "确认并生成公式工作簿"}
</button>
</section>
)}
{questions.length > 0 && (
<p className="notice">补充信息后再生成公式。</p>
)}
</>
)}
{task && (
<p role="status">
{task.status === "queued"
? "排队中"
: task.status === "processing"
? "正在生成"
: task.status === "succeeded"
? "生成完成"
: `生成失败:${task.error || "请重试"}(任务编号:${task.id})`}
</p>
)}
{task?.status === "succeeded" && (
<button onClick={getFile}>
<Download />
下载公式工作簿
</button>
)}
{error && (
<p role="alert" className="error">
{error}
</p>
)}
</div>
</Modal>
);
}
+38
View File
@@ -0,0 +1,38 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { ModuleRegistry, AllCommunityModule } from "ag-grid-community";
import { App } from "./App";
import "./style.css";
ModuleRegistry.registerModules([AllCommunityModule]);
if (import.meta.env.VITE_AG_GRID_LICENSE_KEY) {
const { AllEnterpriseModule, LicenseManager } =
await import("ag-grid-enterprise");
LicenseManager.setLicenseKey(import.meta.env.VITE_AG_GRID_LICENSE_KEY);
ModuleRegistry.registerModules([AllEnterpriseModule]);
}
class Boundary extends React.Component<
{ children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render() {
return this.state.failed ? (
<main className="fatal">
<h1>页面出现错误</h1>
<button onClick={() => location.reload()}>重新加载</button>
</main>
) : (
this.props.children
);
}
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Boundary>
<App />
</Boundary>
</React.StrictMode>,
);
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
export type Numeric = string | number | null;
export type Metric =
| "revenue"
| "cost"
| "gross"
| "roi"
| "customers"
| "basket"
| "margin"
| "discount_rate"
| "self_revenue"
| "upgrade_rate"
| "list_basket";
export const labels: Record<Metric, string> = {
revenue: "实际收入",
cost: "成本",
gross: "毛利贡献",
roi: "ROI",
customers: "消费人数",
basket: "客单价",
margin: "毛利率",
discount_rate: "产品扣率",
self_revenue: "自营收入",
upgrade_rate: "升单率",
list_basket: "原价客单价",
};
export interface Column {
index: number;
name: string;
description: string;
role: string;
key: string;
confidence: number;
}
export interface Mapping {
sheet: string;
header: number;
columns: Column[];
margin_rates: Record<string, number>;
self_operated_products?: string[];
roi_formula: string;
description: string;
}
export interface Preferences {
entry: "overview" | "anomalies";
comparison: "yoy" | "mom";
hierarchy: string[];
metric: Metric;
threshold: number;
min_impact: number;
min_samples: number;
show_heatmap: boolean;
show_queue: boolean;
show_copilot: boolean;
alerts: boolean;
}
export interface Dataset {
id: string;
name: string;
status: string;
version: string;
row_count: number | null;
periods: string[];
error: string;
quality: Record<string, number>;
dimensions: { key: string; name: string; description: string }[];
profile?: {
sheets: {
name: string;
rows: number;
columns: number;
preview: string[][];
}[];
};
mapping: Mapping;
preferences: Preferences;
revision: number;
}
export interface Context {
start: string;
end: string;
comparison: string;
baseline_end?: string;
filters: Record<string, string>;
group: string;
metric: Metric;
}
export interface Value {
current: Numeric;
baseline: Numeric;
delta: Numeric;
change: Numeric;
}
export interface Row {
label: string;
values: Record<Metric, Value>;
rows: number;
baseline_rows: number;
anomaly: boolean;
impact: Numeric;
contribution: Numeric;
filters: Record<string, string>;
}
export interface Analysis {
context: Context & {
missing: string[];
current: string[];
baseline: string[];
};
summary: Record<Metric, Value>;
rows: Row[];
trend: ({ period: string } & Record<Metric, Numeric>)[];
row_count: number;
anomaly_count: number;
comparable: boolean;
notice: string;
version: string;
}
export interface Session {
csrf: string;
user: {
id: number;
name: string;
role: string;
organization: string;
departments: string[];
} | null;
ai_enabled: boolean;
}
export const format = (v: Numeric | undefined, metric?: string) =>
v == null
? "不可用"
: [
"roi",
"margin",
"discount_rate",
"upgrade_rate",
"change",
"contribution",
].includes(metric || "")
? `${(Number(v) * 100).toFixed(1)}%`
: Number(v).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python
import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
+2080
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "traceiq",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"ag-grid-community": "^34.3.0",
"ag-grid-enterprise": "^34.3.0",
"ag-grid-react": "^34.3.0",
"echarts": "^6.0.0",
"lucide-react": "^0.468.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@types/node": "^22.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.0.0",
"prettier": "^3.9.8",
"typescript": "^5.9.0",
"vite": "^7.1.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
workers: 1,
timeout: 120000,
expect: { timeout: 15000 },
use: {
baseURL: process.env.BASE_URL || "http://127.0.0.1:8000",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
reporter: [["list"], ["html", { open: "never" }]],
});
+28
View File
@@ -0,0 +1,28 @@
[project]
name = "traceiq"
version = "0.1.0"
description = "Evidence-first financial analysis workspace"
requires-python = ">=3.12,<3.14"
dependencies = [
"Django>=5.2,<5.3",
"psycopg[binary]>=3.2,<4",
"openpyxl>=3.1.5,<4",
"defusedxml>=0.7.1,<1",
"gunicorn>=23,<24",
"httpx>=0.28,<1",
"whitenoise>=6.9,<7",
]
[dependency-groups]
dev = ["pytest>=9.0.3,<10", "pytest-django>=4.11,<5", "pytest-cov>=6,<7", "ruff>=0.12", "pip-audit>=2.9"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "server.settings"
python_files = ["test_*.py"]
testpaths = ["tests"]
[tool.ruff]
line-length = 110
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"scripts/init_local.py" = ["E402"]
"scripts/reconcile_reference.py" = ["E402"]
+28
View File
@@ -0,0 +1,28 @@
"""Initialize local development credentials once; never used in production."""
import os
import secrets
from pathlib import Path
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
import django
django.setup()
from django.conf import settings
from django.contrib.auth.models import User
from finance.models import Membership, Organization
if settings.PRODUCTION:
raise SystemExit("Use the bootstrap command for production")
if not User.objects.filter(username="admin").exists():
password = secrets.token_urlsafe(18)
org = Organization.objects.create(name="集团财务中心")
user = User.objects.create_user(username="admin", password=password)
Membership.objects.create(user=user, organization=org, role="admin")
path = Path("var/local-access.txt")
path.write_text(f"本地地址:http://127.0.0.1:8000\n用户名:admin\n密码:{password}\n", encoding="utf-8")
path.chmod(0o600)
print("Created admin; credentials in var/local-access.txt (mode 0600)")
else:
print("Administrator already exists; unchanged")
+49
View File
@@ -0,0 +1,49 @@
"""Read-only source reconciliation, without patient-level output."""
import json
import sys
from collections import Counter, defaultdict
from decimal import Decimal
from openpyxl import load_workbook
wb = load_workbook(sys.argv[1], read_only=True, data_only=True)
rates = {
str(row[3]): Decimal(str(row[4]))
for row in wb["产品毛利率"].iter_rows(min_row=6, values_only=True)
if row[3] and row[4] is not None
}
totals = defaultdict(lambda: {"rows": 0, "revenue": Decimal(0), "gross": Decimal(0), "customers": set()})
precision = Counter()
quality = Counter()
for row in wb["明细"].iter_rows(min_row=3, values_only=True):
period = f"{int(row[0])}-{int(str(row[1]).removesuffix('月')):02d}"
v = Decimal(str(row[13]))
t = totals[period]
t["rows"] += 1
t["revenue"] += v
t["gross"] += v * rates.get(row[14], Decimal(0))
t["customers"].add(row[3])
for i in [5, 11, 12, 13]:
if row[i] is not None:
n = Decimal(str(row[i]))
if n != n.quantize(Decimal("0.0001")):
precision[i] += 1
quality["negative_revenue"] += v < 0
quality["g_l_mismatch"] += row[6] != row[11]
quality["i_n_mismatch"] += row[8] != row[13]
quality["missing_margin"] += row[14] not in rates
print(
json.dumps(
{
"totals": {p: {**v, "customers": len(v["customers"])} for p, v in totals.items()},
"precision_exceeds_four": dict(precision),
"quality": dict(quality),
"rates": rates,
},
default=str,
ensure_ascii=False,
indent=2,
)
)
wb.close()
+53
View File
@@ -0,0 +1,53 @@
import json
import os
from decimal import Decimal
from time import perf_counter
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
import django
django.setup()
from django.contrib.auth.models import User
from django.db import connection
from finance.analysis import analyze, default_preferences
from finance.models import Dataset
if connection.vendor != "postgresql":
raise SystemExit("Reconciliation requires PostgreSQL exact NUMERIC aggregation")
ds = Dataset.objects.get(sha256="98ef41e898c24f0929a9da6ec185b7331f69f0478b52056cbc7834e1ef5877ec")
member = User.objects.get(username="admin").membership
start = perf_counter()
result = analyze(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"metric": "revenue",
"filters": {},
},
default_preferences(ds),
)
assert ds.row_count == 332830
assert result["summary"]["revenue"]["current"] == Decimal("152419115.348455502666069")
assert result["summary"]["revenue"]["baseline"] == Decimal("225550255.4187610638720")
assert result["summary"]["gross"]["current"] == Decimal("17868580.85186759073400145")
assert result["summary"]["gross"]["baseline"] == Decimal("24790335.405853982758000")
assert result["summary"]["customers"]["current"] == 38361
assert result["summary"]["customers"]["baseline"] == 33147
assert result["summary"]["roi"]["current"] is None
assert sum(r["values"]["revenue"]["delta"] for r in result["rows"]) == result["summary"]["revenue"]["delta"]
report = {
"status": "passed",
"source": ds.name,
"sha256": ds.sha256,
"rows": ds.row_count,
"query_seconds": round(perf_counter() - start, 3),
"summary": result["summary"],
"quality": ds.quality,
"groups": len(result["rows"]),
}
print(json.dumps(report, ensure_ascii=False, default=str, indent=2))
+4
View File
@@ -0,0 +1,4 @@
from decimal import DefaultContext, getcontext
DefaultContext.prec = 60
getcontext().prec = 60
+102
View File
@@ -0,0 +1,102 @@
import os
from pathlib import Path
from urllib.parse import unquote, urlparse
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
PRODUCTION = os.environ.get("APP_ENV") == "production"
SECRET_KEY = os.environ.get("SECRET_KEY", "development-only-not-for-production-traceiq")
if PRODUCTION and (len(SECRET_KEY) < 50 or SECRET_KEY.startswith("development")):
raise ImproperlyConfigured("Set a random SECRET_KEY of at least 50 characters")
DEBUG = not PRODUCTION
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,testserver").split(",")
CSRF_TRUSTED_ORIGINS = [x for x in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if x]
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.staticfiles",
"finance",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"finance.middleware.SecurityHeaders",
]
ROOT_URLCONF = "server.urls"
WSGI_APPLICATION = "server.wsgi.application"
url = os.environ.get("DATABASE_URL")
if url:
db = urlparse(url)
if db.scheme not in ("postgres", "postgresql"):
raise ImproperlyConfigured("DATABASE_URL must be PostgreSQL")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": db.path[1:],
"USER": unquote(db.username or ""),
"PASSWORD": unquote(db.password or ""),
"HOST": db.hostname,
"PORT": db.port or 5432,
"CONN_MAX_AGE": 60,
"OPTIONS": {"sslmode": os.environ.get("DB_SSLMODE", "prefer")},
}
}
else:
if PRODUCTION:
raise ImproperlyConfigured("Production requires DATABASE_URL")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.environ.get("SQLITE_PATH", str(BASE_DIR / "var/db.sqlite3")),
"OPTIONS": {"timeout": 30},
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 12}},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "UTC"
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_AGE = 8 * 3600
SESSION_COOKIE_SECURE = PRODUCTION
CSRF_COOKIE_SECURE = PRODUCTION
SECURE_SSL_REDIRECT = PRODUCTION and os.environ.get("SECURE_SSL_REDIRECT", "1") == "1"
SECURE_HSTS_SECONDS = 31536000 if PRODUCTION else 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = PRODUCTION
SECURE_HSTS_PRELOAD = PRODUCTION
# Enable only behind the private reverse proxy which overwrites this header.
TRUST_PROXY = os.environ.get("TRUST_PROXY", "0") == "1"
if TRUST_PROXY:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
DATA_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", BASE_DIR / "var/uploads"))
STATIC_URL = "/assets/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "frontend/dist/assets"] if (BASE_DIR / "frontend/dist/assets").exists() else []
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", "")
AI_ENABLED = os.environ.get("AI_ENABLED", "0") == "1"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"root": {"handlers": ["console"], "level": "INFO"},
}
+40
View File
@@ -0,0 +1,40 @@
from django.urls import path
from finance import formula_views, views
from finance.assistant import assistant
urlpatterns = [
path("api/session", views.session),
path("api/login", views.sign_in),
path("api/logout", views.sign_out),
path("api/health", views.health),
path("api/datasets", views.datasets),
path("api/users", views.users),
path("api/audit", views.audit_log),
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),
path("api/datasets/<uuid:dataset_id>/evidence", views.evidence),
path("api/datasets/<uuid:dataset_id>/export", views.export),
path("api/datasets/<uuid:dataset_id>/formula-design", formula_views.formula_design),
path(
"api/datasets/<uuid:dataset_id>/formula-definitions",
formula_views.formula_definitions,
),
path(
"api/datasets/<uuid:dataset_id>/formula-definitions/<uuid:definition_id>",
formula_views.formula_definition_delete,
),
path("api/datasets/<uuid:dataset_id>/formula-exports", formula_views.formula_export_create),
path(
"api/datasets/<uuid:dataset_id>/formula-exports/<uuid:export_id>", formula_views.formula_export_status
),
path(
"api/datasets/<uuid:dataset_id>/formula-exports/<uuid:export_id>/download",
formula_views.formula_export_download,
),
path("api/datasets/<uuid:dataset_id>/settings", views.settings_view),
path("api/datasets/<uuid:dataset_id>/investigations", views.investigations),
path("api/datasets/<uuid:dataset_id>/assistant", assistant),
path("", views.index),
]
+6
View File
@@ -0,0 +1,6 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
application = get_wsgi_application()
+68
View File
@@ -0,0 +1,68 @@
import pytest
from django.contrib.auth.models import User
from django.test import Client
from finance.models import Dataset, Fact, Membership, Organization
@pytest.fixture
def workspace(tmp_path, settings):
settings.MEDIA_ROOT = tmp_path
org = Organization.objects.create(name="Finance test")
user = User.objects.create_user("finance-admin", password="Finance-TestPassword991!")
member = Membership.objects.create(user=user, organization=org, role="admin")
other_org = Organization.objects.create(name="Other organization")
other_user = User.objects.create_user("finance-other", password="Finance-TestPassword991!")
other_member = Membership.objects.create(user=other_user, organization=other_org, role="admin")
mapping = {
"sheet": "CSV",
"header": 1,
"columns": [
{"index": 0, "name": "期间", "role": "period"},
{"index": 1, "name": "科室", "role": "dimension", "key": "department"},
{"index": 2, "name": "产品一级分类", "role": "dimension", "key": "product"},
{"index": 3, "name": "实际收入", "role": "revenue"},
{"index": 4, "name": "成本", "role": "cost"},
{"index": 5, "name": "毛利贡献", "role": "gross"},
{"index": 6, "name": "客户编码", "role": "customer"},
{"index": 7, "name": "应收金额", "role": "list_amount"},
],
"roi_formula": "profit_over_cost",
"self_operated_products": ["医疗服务"],
}
ds = Dataset.objects.create(
organization=org,
created_by=user,
name="finance.csv",
sha256="a" * 64,
status="ready",
mapping=mapping,
periods=["2025-08", "2026-08"],
)
facts = [
("2025-08", "儿科", "医疗服务", 100, 40, 60, "C1", 120),
("2025-08", "儿科", "检查", 50, 20, 30, "C2", 60),
("2026-08", "儿科", "医疗服务", 80, 30, 50, "C1", 100),
("2026-08", "儿科", "医疗服务", 20, 10, 10, "C3", 25),
("2026-08", "外科", "检查", -10, 5, -15, "C4", 0),
]
for index, (period, department, product, revenue, cost, gross, customer, list_amount) in enumerate(
facts, 2
):
Fact.objects.create(
dataset=ds,
period=period,
dimensions={"department": department, "product": product},
revenue=revenue,
cost=cost,
gross=gross,
customer=customer,
list_amount=list_amount,
source_row=index,
raw=[period, department, product, revenue, cost, gross, customer, list_amount],
)
client = Client()
assert client.login(username=user.username, password="Finance-TestPassword991!")
other_client = Client()
assert other_client.login(username=other_user.username, password="Finance-TestPassword991!")
return ds, member, client, other_member, other_client
+133
View File
@@ -0,0 +1,133 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
const password =
process.env.E2E_PASSWORD ||
fs.readFileSync("var/local-access.txt", "utf8").match(/密码:(.+)/)![1];
const csv = (customerCode: string) =>
`期间,科室,学科,医生等级,医生,产品一级分类,实际收入,成本,毛利贡献,客户编码\n2025-08,测试儿保,普儿,高级,医生A,医疗服务,10000,4000,6000,${customerCode}\n2026-08,测试儿保,普儿,高级,医生A,医疗服务,5000,4000,3000,${customerCode}\n`;
test("新增列:补充信息触发追问并重新确认后生成公式工作簿", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/");
await page.getByLabel("用户名", { exact: true }).fill("admin");
await page.getByLabel("密码", { exact: true }).fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.getByRole("button", { name: "导入新表格" })).toBeVisible();
const uploaded = page.waitForResponse(
(response) =>
response.url().endsWith("/api/datasets") &&
response.request().method() === "POST",
);
await page.getByLabel("上传财务表格").setInputFiles({
name: `formula-e2e-${Date.now()}.csv`,
mimeType: "text/csv",
buffer: Buffer.from(csv(`E2E-${Date.now()}`)),
});
await uploaded;
await page
.getByRole("dialog", { name: "确认导入字段" })
.getByRole("button", { name: "确认并导入" })
.click();
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
).toBeVisible({ timeout: 60000 });
let designCalls = 0;
let sentMessages: { role: string; content: string }[] = [];
await page.route("**/formula-design", async (route) => {
sentMessages = JSON.parse(route.request().postData()!).messages;
designCalls++;
await route.fulfill({
json:
designCalls === 1 || designCalls === 3
? {
complete: false,
questions: [
designCalls === 1
? "缺失值和零分母如何处理?"
: "请说明新增边界条件的计算口径。",
],
column: null,
}
: {
complete: true,
questions: [],
column: {
name: "收入成本差",
meaning: "实际收入减成本",
scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
},
},
});
});
await page.getByRole("button", { name: "生成公式工作簿" }).click();
const dialog = page.getByRole("dialog", { name: "生成公式工作簿" });
await dialog.getByRole("checkbox").check();
await dialog
.getByLabel("列计算描述")
.fill("新增收入成本差;请询问缺失值和零分母处理方式");
await dialog.getByRole("button", { name: "发送描述" }).click();
await expect(dialog).toContainText("缺失值和零分母如何处理?");
await dialog.getByLabel("列计算描述").fill("都显示不可用");
await dialog.getByRole("button", { name: "发送描述" }).click();
await expect(dialog.getByText("收入成本差", { exact: true })).toBeVisible();
await expect(dialog).toContainText("计算表达式:(实际收入 − 成本)");
await expect(dialog).toContainText("本轮描述 2/6");
await dialog.getByLabel("列计算描述").fill("增加一项特殊边界条件");
await dialog.getByRole("button", { name: "发送描述" }).click();
await expect(dialog).toContainText("请说明新增边界条件的计算口径。");
await expect(
dialog.getByRole("button", { name: "确认并生成公式工作簿" }),
).toHaveCount(0);
await dialog.getByLabel("列计算描述").fill("边界条件按收入减成本计算");
await dialog.getByRole("button", { name: "发送描述" }).click();
await expect(
dialog.getByRole("button", { name: "确认并生成公式工作簿" }),
).toBeVisible();
await expect(dialog).toContainText("本轮描述 4/6");
expect(sentMessages.map((message) => message.role)).toEqual([
"user",
"assistant",
"user",
"assistant",
"user",
"assistant",
"user",
]);
expect(sentMessages.at(-1)?.content).toContain("边界条件按收入减成本");
await dialog.getByRole("button", { name: "保存到定义库" }).click();
await expect(dialog.getByLabel("已保存定义")).toHaveValue(/.+/);
await dialog.getByRole("button", { name: "确认并生成公式工作簿" }).click();
await expect(dialog.getByRole("status")).toContainText("生成完成", {
timeout: 30000,
});
const download = page.waitForEvent("download");
await dialog.getByRole("button", { name: "下载公式工作簿" }).click();
expect((await download).suggestedFilename()).toBe(
"traceiq-formula-analysis.xlsx",
);
await dialog.getByLabel("关闭", { exact: true }).click();
await page.getByRole("button", { name: "生成公式工作簿" }).click();
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });
await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" });
await expect(reopened).toContainText("计算表达式:(实际收入 − 成本)");
await reopened
.getByRole("button", { name: "确认并生成公式工作簿" })
.click();
await expect(reopened.getByRole("status")).toContainText("生成完成", {
timeout: 30000,
});
expect(designCalls).toBe(4);
});
+273
View File
@@ -0,0 +1,273 @@
import { test, expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import fs from "node:fs";
const password =
process.env.E2E_PASSWORD ||
fs.readFileSync("var/local-access.txt", "utf8").match(/密码:(.+)/)![1];
const csv =
"期间,科室,学科,医生等级,医生,产品一级分类,实际收入,成本,毛利贡献,客户编码\n2025-08,测试儿保,普儿,高级,医生A,医疗服务,10000,4000,6000,C1\n2025-08,测试儿保,普儿,高级,医生B,医疗服务,10000,4000,6000,C2\n2026-08,测试儿保,普儿,高级,医生A,医疗服务,5000,4000,3000,C1\n2026-08,测试儿保,普儿,高级,医生B,医疗服务,5000,4000,3000,C2\n";
async function login(page: Page) {
await page.goto("/");
await page.getByLabel("用户名", { exact: true }).fill("admin");
await page.getByLabel("密码", { exact: true }).fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.getByRole("button", { name: "导入新表格" })).toBeVisible();
}
test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导出", async ({
page,
}) => {
await page.setViewportSize({ width: 1512, height: 982 });
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await login(page);
const uploaded = page.waitForResponse(
(r) => r.url().endsWith("/api/datasets") && r.request().method() === "POST",
);
await page.getByLabel("上传财务表格").setInputFiles({
name: "e2e-finance.csv",
mimeType: "text/csv",
buffer: Buffer.from(csv.replaceAll("C2", `C2-${Date.now()}`)),
});
await uploaded;
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("heading", { name: "确认导入字段" })).toBeVisible();
await dialog.getByRole("button", { name: "确认并导入" }).click();
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
).toBeVisible({ timeout: 60000 });
await expect(page.locator(".kpi").first()).toContainText("10,000");
await page.getByRole("button", { name: "分析设置", exact: true }).click();
await page.getByLabel("最小样本数").fill("1");
await page.getByRole("button", { name: "保存设置", exact: true }).click();
await expect(page.locator(".kpi").first()).toContainText("10,000");
await page.screenshot({
path: "test-results/desktop-overview.png",
fullPage: true,
});
await page
.getByRole("button", { name: "异常追踪", exact: false })
.first()
.click();
await expect(
page.getByRole("heading", { name: "1 个分组需要关注" }),
).toBeVisible();
await page.getByLabel("下钻测试儿保", { exact: true }).click();
await expect(
page.getByRole("heading", { name: "测试儿保 · 异常调查" }),
).toBeVisible();
await page.getByLabel("下钻普儿", { exact: true }).click();
await expect(
page.getByRole("heading", { name: "普儿 · 异常调查" }),
).toBeVisible();
await page.getByLabel("下钻高级", { exact: true }).click();
await expect(
page.getByRole("heading", { name: "高级 · 异常调查" }),
).toBeVisible();
await page.getByLabel("调查状态", { exact: true }).selectOption("reviewing");
await page.getByLabel("调查备注", { exact: true }).fill("核对退款与产品口径");
await page.getByRole("button", { name: "保存调查记录", exact: true }).click();
await expect(
page.getByRole("button", { name: "已保存", exact: true }),
).toBeVisible();
await page
.getByRole("navigation", { name: "分析路径" })
.getByRole("button", { name: "普儿", exact: true })
.click();
await page.getByLabel("下钻高级", { exact: true }).click();
await expect(page.getByLabel("调查备注", { exact: true })).toHaveValue(
"核对退款与产品口径",
);
await expect(page.getByLabel("调查状态", { exact: true })).toHaveValue(
"reviewing",
);
await page.getByLabel("查看医生A证据", { exact: true }).click();
const evidenceDialog = page.getByRole("dialog");
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 expect(evidenceDialog).toContainText("2025-08");
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
const evidenceOffsets: number[] = [];
await page.route("**/api/datasets/*/evidence", async (route) => {
const request = route.request().postDataJSON() as { offset?: number };
const offset = request.offset || 0;
evidenceOffsets.push(offset);
const count = offset === 0 ? 50 : 1;
await route.fulfill({
json: {
total: 51,
sheet: "CSV",
rows: Array.from({ length: count }, (_, index) => ({
source_row: offset + index + 1,
period: offset === 0 ? "2026-08" : "2026-09",
dimensions: {},
revenue: "1",
cost: "1",
gross: "0",
})),
},
});
});
await page.getByLabel("查看医生A证据", { exact: true }).click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
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.getByLabel("关闭", { exact: true }).click();
await page
.getByRole("navigation", { name: "分析路径" })
.getByRole("button", { name: "集团总览", exact: true })
.click();
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "环比", exact: true }).click();
await expect(
page.getByRole("status").filter({ hasText: "缺少期间" }),
).toContainText("2026-07");
await page.getByRole("button", { name: "同比", exact: true }).click();
await expect(page.locator(".kpi").first()).toContainText("10,000");
await page.getByRole("button", { name: "分析设置", exact: true }).click();
await page.getByLabel("异常阈值").fill("0.8");
await page.getByRole("button", { name: "取消", exact: true }).click();
await page.getByRole("button", { name: "分析设置", exact: true }).click();
await expect(page.getByLabel("异常阈值")).toHaveValue("0.15");
await page.getByRole("button", { name: "取消", exact: true }).click();
await page.getByRole("button", { name: "Copilot", exact: true }).click();
await page.getByLabel("描述要分析的问题").fill("查看异常");
await page.locator(".copilot").getByRole("button", { name: "发送" }).click();
await expect(page.locator(".message")).toContainText("按当前规则识别出");
await expect(
page.getByRole("heading", { name: "异常追踪", exact: true }),
).toBeVisible();
await page.locator(".copilot").getByLabel("关闭", { exact: true }).click();
const downloaded = page.waitForEvent("download");
await page.getByLabel("导出当前范围").click();
const file = await downloaded;
expect(file.suggestedFilename()).toBe("traceiq-evidence.csv");
await page.getByRole("button", { name: "经营总览", exact: true }).click();
await expect(page.locator("canvas").first()).toBeVisible();
const pixels = await page
.locator("canvas")
.first()
.evaluate((canvas: HTMLCanvasElement) => {
const ctx = canvas.getContext("2d")!;
const p = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let n = 0;
for (let i = 3; i < p.length; i += 4) if (p[i] > 0) n++;
return n;
});
expect(pixels).toBeGreaterThan(1000);
let designCalls = 0;
await page.route("**/formula-design", async (route) => {
designCalls++;
await route.fulfill({
json: designCalls === 1
? { complete: false, questions: ["缺失值和零分母如何处理?"], column: null }
: {
complete: true,
questions: [],
column: {
name: "收入成本差",
meaning: "实际收入减成本",
scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
},
},
});
});
await page.getByRole("button", { name: "生成公式工作簿" }).click();
const formulaDialog = page.getByRole("dialog", { name: "生成公式工作簿" });
await formulaDialog.getByRole("checkbox").check();
await formulaDialog.getByLabel("列计算描述").fill("新增收入成本差,缺失或零分母显示不可用");
await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await expect(formulaDialog).toContainText("缺失值和零分母如何处理?");
await formulaDialog.getByLabel("列计算描述").fill("缺失项和零分母都按不可用处理");
await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await formulaDialog.getByRole("button", { name: "确认并生成公式工作簿" }).click();
await expect(formulaDialog.getByRole("status")).toContainText("生成完成", { timeout: 30000 });
const formulaDownload = page.waitForEvent("download");
await formulaDialog.getByRole("button", { name: "下载公式工作簿" }).click();
expect((await formulaDownload).suggestedFilename()).toBe("traceiq-formula-analysis.xlsx");
await formulaDialog.getByLabel("关闭").click();
});
test("手机:登录、总览、设置、Copilot 布局无横向溢出", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await expect(page.locator(".kpi").first()).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
await page.screenshot({
path: "test-results/mobile-overview.png",
fullPage: true,
});
await page.getByLabel("展开导航").click();
await page.getByRole("button", { name: "分析设置", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.screenshot({
path: "test-results/mobile-settings.png",
fullPage: true,
});
await page.getByRole("button", { name: "取消", exact: true }).click();
await page.getByRole("button", { name: "经营总览", exact: true }).click();
await page.getByRole("button", { name: "Copilot", exact: true }).click();
await expect(page.getByLabel("描述要分析的问题")).toBeVisible();
expect(
await page
.locator(".copilot")
.evaluate((el) => el.getBoundingClientRect().right <= innerWidth),
).toBe(true);
await page.locator(".copilot").getByLabel("关闭", { exact: true }).click();
});
test("原附件:全量导入结果在桌面及手机正确显示", async ({ page }) => {
test.skip(!process.env.REFERENCE_DATASET_NAME, "仅本机显式启用原附件验收");
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.setViewportSize({ width: 1512, height: 982 });
await login(page);
await page
.getByLabel("选择数据集")
.selectOption({ label: process.env.REFERENCE_DATASET_NAME! });
await expect(page.locator(".kpi").nth(0)).toContainText("152,419,115.35");
await expect(page.locator(".kpi").nth(1)).toContainText("17,868,580.85");
await expect(page.locator(".kpi").nth(3)).toContainText("不可用");
await expect(page.locator(".kpi").nth(4)).toContainText("38,361");
await page.screenshot({
path: "test-results/reference-desktop.png",
fullPage: true,
});
await page.getByRole("button", { name: "环比", exact: true }).click();
await expect(
page.getByRole("status").filter({ hasText: "缺少期间" }),
).toContainText("2026-07");
await page.getByRole("button", { name: "同比", exact: true }).click();
await expect(page.locator(".kpi").first()).toContainText("-32.4%");
await page.setViewportSize({ width: 390, height: 844 });
await expect
.poll(() =>
page.evaluate(
() =>
document.documentElement.scrollWidth <= innerWidth &&
[...document.querySelectorAll(".chart canvas")].every(
(canvas) => canvas.getBoundingClientRect().right <= innerWidth,
),
),
)
.toBe(true);
await page.screenshot({
path: "test-results/reference-mobile.png",
fullPage: true,
});
expect(errors).toEqual([]);
});
+221
View File
@@ -0,0 +1,221 @@
import json
from unittest.mock import patch
import pytest
from django.test import Client
from finance.assistant import model_intent
from finance.models import Audit, FormulaDefinition, FormulaExport, Membership
pytestmark = pytest.mark.django_db
def test_model_intent_accepts_only_valid_dimensions_and_sends_no_rows(workspace, settings):
ds, _, _, _, _ = workspace
settings.OPENAI_MODEL = "test-model"
settings.OPENAI_API_KEY = "test-key"
class Response:
def raise_for_status(self):
return None
def json(self):
return {
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": json.dumps(
{
"action": "overview",
"metric": "revenue",
"group": "department",
"filters": [{"key": "department", "value": "儿科"}],
}
),
}
]
}
],
}
with patch("finance.assistant.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = Response()
intent = model_intent("看儿科收入", ds, {"metric": "revenue", "group": "department", "filters": {}})
assert intent == {
"action": "overview",
"metric": "revenue",
"group": "department",
"filters": {"department": "儿科"},
}
payload = http.return_value.__enter__.return_value.post.call_args.kwargs["json"]
assert payload["store"] is False
assert "C1" not in json.dumps(payload)
def test_model_intent_rejects_unknown_filter_and_incomplete_provider_response(workspace, settings):
ds, *_ = workspace
settings.OPENAI_MODEL = "test-model"
settings.OPENAI_API_KEY = "test-key"
class Response:
def raise_for_status(self):
return None
def json(self):
return {
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": '{"action":"overview","metric":"revenue","group":"department","filters":[{"key":"__class__","value":"x"}]}',
}
]
}
],
}
response = Response()
with patch("finance.assistant.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = response
with pytest.raises(Exception, match="无效意图"):
model_intent("分析收入", ds, {"metric": "revenue", "group": "department", "filters": {}})
with patch("finance.assistant.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = type(
"Response",
(),
{"raise_for_status": lambda self: None, "json": lambda self: {"status": "in_progress"}},
)()
with pytest.raises(Exception, match="无效意图"):
model_intent("分析收入", ds, {"metric": "revenue", "group": "department", "filters": {}})
def test_assistant_local_response_and_external_consent_guard(workspace, settings):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/assistant"
payload = {
"question": "查看实际收入",
"context": {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
}
response = client.post(url, data=payload, content_type="application/json")
assert response.status_code == 200
assert response.json()["provider"] == "local"
assert Audit.objects.filter(action="assistant.query").exists()
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
denied = client.post(
url,
data={**payload, "use_model": True, "consent": False},
content_type="application/json",
)
assert denied.status_code == 400
assert "同意" in denied.json()["error"]
def test_formula_design_and_export_api_validate_consent_and_scope(workspace, settings):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}"
payload = {
"context": {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
"messages": [{"role": "user", "content": "收入成本差"}],
"consent": False,
}
denied = client.post(f"{url}/formula-design", data=payload, content_type="application/json")
assert denied.status_code == 400
export = client.post(
f"{url}/formula-exports",
data={**payload["context"], "custom_columns": [{"expression": {}}]},
content_type="application/json",
)
assert export.status_code == 400
assert FormulaExport.objects.count() == 0
scoped = Client()
user = ds.created_by
user.membership.departments = ["儿科"]
user.membership.save(update_fields=["departments"])
assert scoped.login(username=user.username, password="Finance-TestPassword991!")
forbidden = scoped.post(
f"{url}/formula-exports", data=payload["context"], content_type="application/json"
)
assert forbidden.status_code == 403
assert scoped.get(f"{url}/formula-definitions").status_code == 403
def test_formula_definition_library_is_private_validated_and_audited(workspace):
ds, _, client, _, _ = workspace
url = f"/api/datasets/{ds.pk}/formula-definitions"
definition = {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "所选分组与期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
saved = client.post(url, data={"definition": definition}, content_type="application/json")
assert saved.status_code == 201
definition_id = saved.json()["id"]
assert client.get(url).json()["items"][0]["definition"] == definition
updated = client.post(
url,
data={"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"}},
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"] == "更新说明"
other = Client()
other_user = ds.created_by.__class__.objects.create_user(
"formula-library-other", password="Formula-Library-TestPassword991!"
)
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"] == []
assert other.delete(f"{url}/{definition_id}").status_code == 404
deleted = client.delete(f"{url}/{definition_id}")
assert deleted.status_code == 200
assert not FormulaDefinition.objects.filter(pk=definition_id).exists()
assert Audit.objects.filter(action="formula_definition.save").count() == 2
assert Audit.objects.filter(action="formula_definition.delete").count() == 1
def test_formula_definition_library_rejects_unavailable_metric(workspace):
ds, _, client, *_ = workspace
mapping = {**ds.mapping, "columns": [c for c in ds.mapping["columns"] if c["role"] != "cost"]}
ds.mapping = mapping
ds.save(update_fields=["mapping"])
response = client.post(
f"/api/datasets/{ds.pk}/formula-definitions",
data={
"definition": {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "所选分组与期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
},
content_type="application/json",
)
assert response.status_code == 400
assert "成本" in response.json()["error"]
+222
View File
@@ -0,0 +1,222 @@
from decimal import Decimal
from io import BytesIO
import pytest
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.test import Client
from openpyxl import load_workbook
from finance.analysis import analyze, context, default_preferences, metrics
from finance.importing import decimal, period_value, process_dataset, validate_mapping
from finance.models import Fact, Membership
pytestmark = pytest.mark.django_db
def test_money_and_period_parsing_reject_invalid_values():
assert decimal("1,234.50") == Decimal("1234.50")
assert decimal("", required=False) is None
assert period_value({"period": "2026/8/1"}) == "2026-08"
assert period_value({"year": "2026年", "month": "8月"}) == "2026-08"
for value in ("NaN", "1e16", "0.0000000000000000000000001"):
with pytest.raises(ValueError):
decimal(value)
with pytest.raises(ValueError, match="必填"):
decimal(None, required=True)
with pytest.raises(ValueError, match="期间"):
period_value({"period": "2026-13"})
def test_mapping_and_context_enforce_ranges_and_dimensions(workspace):
ds, *_ = workspace
valid = validate_mapping(ds.mapping, {"sheets": [{"name": "CSV"}]})
assert valid["roi_formula"] == "profit_over_cost"
with pytest.raises(ValueError, match="ROI"):
validate_mapping({**ds.mapping, "roi_formula": "=1+1"}, {"sheets": [{"name": "CSV"}]})
reserved = {**ds.mapping, "columns": [dict(column) for column in ds.mapping["columns"]]}
reserved["columns"][1]["key"] = "revenue"
with pytest.raises(ValueError, match="保留列名冲突"):
validate_mapping(reserved, {"sheets": [{"name": "CSV"}]})
with pytest.raises(ValueError, match="维度"):
context(ds, {"filters": {"unknown": "x"}})
with pytest.raises(ValueError, match="重叠"):
context(ds, {"comparison": "custom", "start": "2026-08", "end": "2026-08", "baseline_end": "2026-08"})
with pytest.raises(ValueError, match="60"):
context(ds, {"start": "2019-01", "end": "2026-08"})
def test_metrics_and_analysis_preserve_roi_refunds_and_distinct_customers(workspace):
ds, member, *_ = workspace
prefs = default_preferences(ds)
result = analyze(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
prefs,
)
assert result["summary"]["revenue"]["current"] == Decimal("90")
assert result["summary"]["customers"]["current"] == 3
assert result["summary"]["roi"]["current"] == Decimal("1")
assert result["summary"]["discount_rate"]["current"] == Decimal("90") / Decimal("125")
assert result["context"]["missing"] == []
assert {row["label"] for row in result["rows"]} == {"儿科", "外科"}
unavailable = metrics(
{
"rows": 1,
"revenue_sum": Decimal(5),
"cost_sum": None,
"gross_sum": None,
"cost_n": 0,
"gross_n": 0,
"customer_n": 0,
"customers": 0,
"list_sum": None,
"list_n": 0,
"self_sum": None,
"upgraded": 0,
},
"gross_less_cost_over_cost",
["医疗服务"],
)
assert unavailable["roi"] is None
assert unavailable["customers"] is None
assert unavailable["self_revenue"] == 0
def test_dataset_and_evidence_are_organization_and_department_scoped(workspace):
ds, _, _, _, other_client = workspace
url = f"/api/datasets/{ds.pk}"
assert other_client.get(url).status_code == 404
dept_user = User.objects.create_user("finance-dept", password="Finance-TestPassword991!")
Membership.objects.create(
user=dept_user,
organization=ds.organization,
role="analyst",
departments=["儿科"],
)
scoped = Client()
assert scoped.login(username=dept_user.username, password="Finance-TestPassword991!")
assert scoped.get(url).json()["row_count"] is None
evidence = scoped.post(
f"{url}/evidence",
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
content_type="application/json",
)
assert evidence.status_code == 200
assert evidence.json()["total"] == 2
assert all("raw" not in row for row in evidence.json()["rows"])
def test_evidence_pagination_returns_stable_pages_and_rejects_invalid_bounds(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/evidence"
payload = {
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"side": "current",
"offset": 1,
"limit": 1,
}
page = client.post(url, data=payload, content_type="application/json")
assert page.status_code == 200
assert page.json()["total"] == 3
assert len(page.json()["rows"]) == 1
assert page.json()["rows"][0]["source_row"] == 5
for invalid in ({"offset": -1}, {"limit": 201}, {"offset": True}):
response = client.post(url, data={**payload, **invalid}, content_type="application/json")
assert response.status_code == 400
def test_settings_revision_and_invalid_metric_are_rejected(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/settings"
initial = client.get(url).json()
value = initial["value"]
value["min_samples"] = 1
saved = client.put(
url,
data={"value": value, "revision": initial["revision"]},
content_type="application/json",
)
assert saved.status_code == 200
assert saved.json()["revision"] == 1
conflict = client.put(
url,
data={"value": value, "revision": 0},
content_type="application/json",
)
assert conflict.status_code == 409
value["metric"] = "unknown"
invalid = client.put(url, data={"value": value, "revision": 1}, content_type="application/json")
assert invalid.status_code == 400
def test_csv_export_neutralizes_formula_injection_and_preserves_exact_values(workspace):
ds, _, client, *_ = workspace
Fact.objects.filter(dataset=ds).update(dimensions={"department": "=1+1", "product": "检查"})
response = client.post(
f"/api/datasets/{ds.pk}/export",
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
content_type="application/json",
)
assert response.status_code == 200
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 "traceiq-evidence.csv" in response["Content-Disposition"]
xlsx = client.post(
f"/api/datasets/{ds.pk}/export",
data={
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"format": "xlsx",
},
content_type="application/json",
)
assert xlsx.streaming
workbook = load_workbook(BytesIO(b"".join(xlsx.streaming_content)), data_only=False)
cell = workbook["证据明细"]["D6"]
assert cell.value == "=1+1"
assert cell.data_type == "s"
def test_invalid_import_rolls_back_facts_and_records_failure(workspace, tmp_path):
ds, *_ = workspace
ds.facts.all().delete()
ds.status = "queued"
ds.mapping = {
"header": 1,
"sheet": "CSV",
"columns": [
{"index": 0, "name": "期间", "role": "period"},
{"index": 1, "name": "科室", "role": "dimension", "key": "department"},
{"index": 2, "name": "实际收入", "role": "revenue"},
],
"roi_formula": "profit_over_cost",
}
ds.save()
csv_path = tmp_path / "invalid.csv"
csv_path.write_text("期间,科室,实际收入\n2026-08,儿科,10\n2026-13,儿科,20\n", encoding="utf-8")
ds.file.name = str(csv_path)
ds.save(update_fields=["file"])
result = process_dataset(ds.pk)
assert result.status == "failed"
assert result.facts.count() == 0
assert "第 3 行" in result.error
def test_upload_requires_role_and_rejects_unsupported_file(workspace):
_, _, client, _, _ = workspace
response = client.post("/api/datasets", data={"file": ContentFile(b"not a workbook", name="bad.txt")})
assert response.status_code == 403 or response.status_code == 400
+83
View File
@@ -0,0 +1,83 @@
import os
from datetime import timedelta
import pytest
from django.contrib.auth.models import User
from django.core.management import call_command
from django.utils import timezone
from finance.models import Dataset, FormulaExport, Membership, Organization
pytestmark = pytest.mark.django_db
@pytest.fixture
def cleanup_root(settings, tmp_path):
settings.MEDIA_ROOT = tmp_path
org = Organization.objects.create(name="Cleanup test")
user = User.objects.create_user("cleanup", password="Cleanup-TestPassword991!")
Membership.objects.create(user=user, organization=org, role="admin")
dataset = Dataset.objects.create(
organization=org,
created_by=user,
name="fixture.csv",
sha256="a" * 64,
status="ready",
)
return tmp_path / "formula-exports", dataset, user
def test_cleanup_expires_jobs_and_removes_files(cleanup_root):
root, dataset, user = cleanup_root
path = root / str(user.pk) / "expired.xlsx"
path.parent.mkdir(parents=True)
path.write_bytes(b"workbook")
job = FormulaExport.objects.create(
dataset=dataset,
user=user,
status="succeeded",
path=str(path),
expires_at=timezone.now() - timedelta(seconds=1),
)
call_command("cleanup_formula_exports")
job.refresh_from_db()
assert job.status == "expired"
assert job.path == ""
assert not path.exists()
def test_cleanup_removes_only_stale_temporary_files(cleanup_root):
root, _, user = cleanup_root
user_dir = root / str(user.pk)
user_dir.mkdir(parents=True)
stale, recent = user_dir / "stale.tmp", user_dir / "recent.tmp"
stale.touch()
recent.touch()
old = timezone.now().timestamp() - 25 * 3600
os.utime(stale, (old, old))
call_command("cleanup_formula_exports")
assert not stale.exists()
assert recent.exists()
def test_cleanup_does_not_delete_files_outside_export_root(cleanup_root, tmp_path):
root, dataset, user = cleanup_root
outside = tmp_path / "protected.xlsx"
outside.write_bytes(b"keep")
job = FormulaExport.objects.create(
dataset=dataset,
user=user,
status="succeeded",
path=str(outside),
expires_at=timezone.now() - timedelta(seconds=1),
)
call_command("cleanup_formula_exports")
job.refresh_from_db()
assert job.status == "expired"
assert outside.read_bytes() == b"keep"
+246
View File
@@ -0,0 +1,246 @@
import json
from decimal import Decimal
from io import BytesIO
from unittest.mock import patch
import pytest
from django.contrib.auth.models import User
from openpyxl import load_workbook
from finance.exporting import build_formula_workbook
from finance.formula_columns import validate_column
from finance.formula_views import design_column
from finance.models import Dataset, Fact, Membership, Organization
pytestmark = pytest.mark.django_db
@pytest.fixture
def formula_data():
org = Organization.objects.create(name="Formula tests")
user = User.objects.create_user("formula", password="Formula-TestPassword991!")
member = Membership.objects.create(user=user, organization=org, role="admin")
mapping = {
"sheet": "CSV",
"columns": [
{"index": 0, "name": "Period", "role": "period"},
{"index": 1, "name": "Department", "role": "dimension", "key": "department"},
{"index": 2, "name": "Revenue", "role": "revenue"},
{"index": 3, "name": "Cost", "role": "cost"},
],
"roi_formula": "profit_over_cost",
}
ds = Dataset.objects.create(
organization=org,
created_by=user,
name="formula.csv",
sha256="b" * 64,
status="ready",
mapping=mapping,
periods=["2025-08", "2026-08"],
)
for period, revenue, cost in (("2025-08", 80, 30), ("2026-08", 100, 40)):
Fact.objects.create(
dataset=ds,
period=period,
dimensions={"department": "儿科"},
revenue=Decimal(revenue),
cost=Decimal(cost),
gross=Decimal(revenue - cost),
customer=f"{period}-patient",
source_row=1 if period == "2025-08" else 2,
raw=[period, "儿科", revenue, cost],
)
return ds, member
def custom_column():
return {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "按所选分组及本期、基期计算",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
def test_formula_workbook_exposes_custom_metric_as_recalculable_column(formula_data):
ds, member = formula_data
output = BytesIO()
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [custom_column()],
},
output,
)
workbook = load_workbook(BytesIO(output.getvalue()), data_only=False)
assert workbook["计算明细"]["M2"].value == '=IF(H2="direct",F2,IF(H2="lookup",D2*G2,""))'
assert workbook["计算明细"]["I2"].data_type == "n"
summary = workbook["分析汇总"]
metric_row = next(
row for row in range(6, summary.max_row + 1) if summary.cell(row, 1).value == "收入成本差"
)
formula = summary.cell(metric_row, 3).value
assert formula.startswith("=IF(OR(ISNA(C")
assert "IFERROR((C" in formula and "NA())" in formula
assert "自定义指标" in workbook.sheetnames
custom = workbook["自定义指标"]
assert custom["B1"].value == "收入成本差(本期)"
assert custom["B2"].value == f"='分析汇总'!C{metric_row}"
assert "含义:实际收入减成本" in workbook["使用说明"]["B12"].value
assert "范围:按所选分组及本期、基期计算" in workbook["使用说明"]["B12"].value
def test_custom_expression_rejects_formula_text_and_unknown_metrics():
from finance.formula_columns import metrics_used, to_excel
expression = {
"operator": "divide",
"left": {"metric": "revenue"},
"right": {
"operator": "add",
"left": {"metric": "cost"},
"right": {"number": 2.5},
},
}
validate_column({**custom_column(), "expression": expression})
assert metrics_used(expression) == {"revenue", "cost"}
assert to_excel(expression, {"revenue": "C6", "cost": "D6"}) == "(C6/(D6+2.5))"
column = custom_column()
column["expression"] = {"metric": "__import__('os')"}
with pytest.raises(ValueError, match="不支持的指标"):
validate_column(column)
column = custom_column()
column["expression"] = {"formula": "=HYPERLINK(...)"}
with pytest.raises(ValueError, match="不支持的字段或运算"):
validate_column(column)
def test_design_column_preserves_clarification_branch_and_full_conversation(formula_data, settings):
ds, _ = formula_data
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
clarification = {
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": '{"complete":false,"questions":["请说明零分母如何处理。"],"column":null}',
}
]
}
],
}
with patch("finance.formula_views.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = type(
"Response",
(),
{"raise_for_status": lambda self: None, "json": lambda self: clarification},
)()
incomplete = design_column(
[{"role": "user", "content": "新增利润率列"}],
ds,
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
)
assert incomplete == {
"complete": False,
"questions": ["请说明零分母如何处理。"],
"column": None,
}
class Response:
def raise_for_status(self):
pass
def json(self):
return {
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": '{"complete":false,"questions":["零分母如何处理?"],"column":null}',
}
]
}
],
}
messages = [
{"role": "user", "content": "增加收入成本差列"},
{"role": "assistant", "content": "请说明计算方式和缺失值处理。"},
{"role": "user", "content": "收入减成本,缺失时不可用"},
]
with patch("finance.formula_views.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = Response()
result = design_column(
messages,
ds,
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
)
assert result == {"complete": False, "questions": ["零分母如何处理?"], "column": None}
payload = client.return_value.__enter__.return_value.post.call_args.kwargs["json"]
assert payload["input"][1:] == messages
def test_design_column_returns_only_validated_formula_definition(formula_data, settings):
ds, _ = formula_data
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
definition = custom_column()
response = type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": json.dumps(
{"complete": True, "questions": [], "column": definition},
ensure_ascii=False,
),
}
]
}
],
},
},
)()
with patch("finance.formula_views.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = response
result = design_column(
[
{"role": "user", "content": "增加收入成本差列"},
{"role": "assistant", "content": "请补充计算口径"},
{"role": "user", "content": "收入减成本,按分组选期间计算"},
],
ds,
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
)
assert result == {"complete": True, "questions": [], "column": definition}
payload = http.return_value.__enter__.return_value.post.call_args.kwargs["json"]
assert payload["store"] is False
assert payload["text"]["format"]["strict"] is True
+75
View File
@@ -0,0 +1,75 @@
from io import BytesIO
from unittest.mock import patch
import pytest
from openpyxl import load_workbook
from finance.exporting import build_formula_workbook
from finance.formula_columns import validate_column
from finance.formula_views import design_column
from finance.views import APIError
pytestmark = pytest.mark.django_db
def custom_column():
return {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "按所选分组及本期、基期计算",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
def test_design_column_requires_alternating_messages_and_completed_response(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
ctx = {"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"}
with pytest.raises(APIError, match="顺序"):
design_column([{"role": "assistant", "content": "需要补充"}], ds, ctx)
with pytest.raises(APIError, match="顺序"):
design_column([{"role": "user", "content": "新增列"}, {"role": "user", "content": "再补充"}], ds, ctx)
response = type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {"status": "in_progress", "output": []},
},
)()
with patch("finance.formula_views.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = response
with pytest.raises(APIError, match="未创建工作簿"):
design_column([{"role": "user", "content": "新增列"}], ds, ctx)
def test_formula_labels_from_ai_are_written_as_plain_text(workspace):
ds, member, *_ = workspace
definition = custom_column()
definition["name"] = '=HYPERLINK("https://invalid")'
validate_column(definition)
output = BytesIO()
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [definition],
},
output,
)
workbook = load_workbook(BytesIO(output.getvalue()), data_only=False)
summary = workbook["分析汇总"]
cell = next(cell for row in summary.iter_rows() for cell in row if cell.value == definition["name"])
assert cell.data_type == "s"
+376
View File
@@ -0,0 +1,376 @@
import stat
from decimal import Decimal
from io import BytesIO
from pathlib import Path
import pytest
from finance.exporting import build_formula_workbook
from finance.models import Fact, FormulaExport
def custom_column():
return {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "按所选分组及本期、基期计算",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
pytestmark = pytest.mark.django_db
def test_formula_workbook_fails_closed_when_row_or_group_limit_is_exceeded(workspace, monkeypatch):
import finance.exporting as exporting
ds, member, *_ = workspace
args = (
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
BytesIO(),
)
monkeypatch.setattr(exporting, "MAX_ROWS", 0)
with pytest.raises(ValueError, match="最多支持"):
build_formula_workbook(*args)
monkeypatch.setattr(exporting, "MAX_ROWS", 100_000)
monkeypatch.setattr(exporting, "MAX_GROUPS", 0)
with pytest.raises(ValueError, match="分组"):
build_formula_workbook(*args)
def test_formula_workbook_uses_streaming_writer(workspace, monkeypatch):
import finance.exporting as exporting
ds, member, *_ = workspace
real_workbook = exporting.Workbook
options = {}
def workbook(*args, **kwargs):
options.update(kwargs)
return real_workbook(*args, **kwargs)
monkeypatch.setattr(exporting, "Workbook", workbook)
build_formula_workbook(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
BytesIO(),
)
assert options == {"write_only": True}
def test_formula_export_worker_publishes_complete_file_and_removes_temp_file(workspace):
from finance.exporting import process_formula_export
ds, _, client, *_ = workspace
context = {
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [custom_column()],
}
response = client.post(
f"/api/datasets/{ds.pk}/formula-exports", data=context, content_type="application/json"
)
job = FormulaExport.objects.get(pk=response.json()["id"])
stats = process_formula_export(job.pk)
job.refresh_from_db()
assert stats["rows"] == 5
assert stats["formula_version"] == "3"
assert job.status == "succeeded"
assert job.expires_at is not None
assert not job.path.endswith(".tmp")
assert Path(job.path).read_bytes()[:2] == b"PK"
assert stat.S_IMODE(Path(job.path).stat().st_mode) == 0o600
assert stat.S_IMODE(Path(job.path).parent.stat().st_mode) == 0o700
assert stat.S_IMODE(Path(job.path).parent.parent.stat().st_mode) == 0o700
assert not Path(job.path).with_suffix(".tmp").exists()
assert not list(Path(job.path).parent.glob(f"{job.pk}.*.tmp"))
def test_formula_export_worker_records_failure_and_removes_partial_file(workspace, monkeypatch):
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"])
def fail(*args, **kwargs):
args[-1].write(b"partial")
raise ValueError("controlled export failure")
monkeypatch.setattr("finance.exporting.build_formula_workbook", fail)
with pytest.raises(ValueError, match="controlled export failure"):
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "failed"
assert job.error == "controlled export failure"
response = client.get(f"/api/datasets/{ds.pk}/formula-exports/{job.pk}")
assert response.json()["error"] == "生成失败,请联系管理员并提供任务编号"
assert "controlled export failure" not in response.content.decode()
assert not job.path
temporary = (
Path(job.dataset.file.storage.location) / "formula-exports" / str(job.user_id) / f"{job.pk}.tmp"
)
assert not temporary.exists()
def test_formula_export_worker_removes_stale_published_file_after_failed_retry(workspace, monkeypatch):
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"stale workbook")
monkeypatch.setattr(
"finance.exporting.build_formula_workbook",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("retry failed")),
)
with pytest.raises(ValueError, match="retry failed"):
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "failed"
assert job.path == ""
assert job.expires_at is None
assert not target.exists()
def test_formula_export_worker_treats_department_order_as_the_same_scope(workspace):
from finance.exporting import process_formula_export
ds, member, *_ = workspace
member.departments = ["外科", "儿科"]
member.save(update_fields=["departments"])
job = FormulaExport.objects.create(
dataset=ds,
user=ds.created_by,
context={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
departments=["外科", "儿科"],
status="processing",
)
member.departments = ["儿科", "外科"]
member.save(update_fields=["departments"])
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "succeeded"
def test_formula_export_worker_records_path_preparation_failure(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"])
job.status = "processing"
job.save(update_fields=["status"])
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
export_root.symlink_to(Path(settings.MEDIA_ROOT) / "elsewhere", target_is_directory=True)
with pytest.raises(ValueError, match="符号链接"):
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "failed"
def test_formula_workbook_rejects_unclassified_label_collision(workspace):
from decimal import Decimal
ds, member, *_ = workspace
Fact.objects.create(
dataset=ds,
period="2025-08",
dimensions={"department": None, "product": "医疗服务"},
revenue=Decimal("10"),
cost=Decimal("4"),
gross=Decimal("6"),
customer="missing-department",
source_row=20,
)
Fact.objects.create(
dataset=ds,
period="2025-08",
dimensions={"department": "(未分类)", "product": "医疗服务"},
revenue=Decimal("10"),
cost=Decimal("4"),
gross=Decimal("6"),
customer="literal-unclassified",
source_row=21,
)
with pytest.raises(ValueError, match="与系统缺失值标签冲突"):
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
},
BytesIO(),
)
def test_formula_criteria_escape_excel_wildcards_and_quotes():
from finance.exporting import _checked_formula, _criteria
criteria = _criteria(
{"subject": 'A*?~"科'},
"department",
"儿科?*",
["2026-08", "2026-09"],
)
assert 'Data[subject],"=A~*~?~~""科"' in criteria
assert 'Data[department],"=儿科~?~*"' in criteria
assert 'Data[Period],">=2026-08"' in criteria
assert _criteria({}, "department", ">高值", ["2026-08"]).startswith('Data[department],"=>高值"')
with pytest.raises(ValueError, match="8,192"):
_checked_formula("=" + "A" * 8192)
with pytest.raises(ValueError, match="255"):
_criteria({}, "department", "科" * 255, ["2026-08"])
def test_formula_workbook_rejects_case_insensitive_group_collision(workspace):
ds, member, *_ = workspace
for source_row, label in ((20, "Team"), (21, "team")):
Fact.objects.create(
dataset=ds,
period="2026-08",
dimensions={"department": label, "product": "医疗服务"},
revenue=Decimal(10),
cost=Decimal(4),
gross=Decimal(6),
customer=f"case-{source_row}",
source_row=source_row,
)
with pytest.raises(ValueError, match="大小写"):
build_formula_workbook(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
BytesIO(),
)
def test_formula_workbook_omits_default_sheet_and_writes_untrusted_labels_as_text(workspace):
from openpyxl import load_workbook
ds, member, *_ = workspace
dangerous = '=HYPERLINK("https://invalid")'
mapping = {**ds.mapping}
mapping["sheet"] = dangerous
mapping["columns"] = [dict(column) for column in mapping["columns"]]
mapping["columns"][1]["name"] = dangerous
mapping["columns"][3]["name"] = dangerous
mapping["self_operated_products"] = [dangerous]
ds.mapping = mapping
ds.save(update_fields=["mapping"])
ds.facts.update(dimensions={"department": dangerous, "product": "医疗服务"})
definition = custom_column()
definition["name"] = dangerous
output = BytesIO()
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [definition],
},
output,
)
workbook = load_workbook(BytesIO(output.getvalue()), data_only=False)
assert "Sheet" not in workbook.sheetnames
cells = [
workbook["分析汇总"]["D3"],
workbook["原始证据"]["A2"],
workbook["原始证据"]["C1"],
workbook["参数与口径"]["B2"],
workbook["参数与口径"]["B4"],
workbook["对账检查"]["B2"],
]
custom_reconcile = next(
cell for row in workbook["对账检查"].iter_rows() for cell in row if cell.value == dangerous
)
cells.append(custom_reconcile)
assert all(cell.value == dangerous and cell.data_type == "s" for cell in cells)
def test_formula_reconcile_uses_literal_unclassified_group(workspace):
from openpyxl import load_workbook
ds, member, *_ = workspace
Fact.objects.create(
dataset=ds,
period="2026-08",
dimensions={"department": "(未分类)", "product": "医疗服务"},
revenue=Decimal(25),
cost=Decimal(10),
gross=Decimal(15),
customer="unclassified",
list_amount=Decimal(30),
source_row=20,
raw=["2026-08", "", "医疗服务", 25, 10, 15, "unclassified", 30],
)
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)
reference = next(
row[3].value
for row in workbook["对账检查"].iter_rows(min_row=2)
if row[0].value == "实际收入" and row[1].value == "(未分类)" and row[2].value == "本期"
)
assert Decimal(reference) == Decimal(25)
def test_formula_workbook_rejects_dimension_keys_that_collide_with_reserved_headers(workspace):
ds, member, *_ = workspace
mapping = {**ds.mapping}
mapping["columns"] = [dict(column) for column in mapping["columns"]]
mapping["columns"][1]["key"] = "revenue"
ds.mapping = mapping
ds.save(update_fields=["mapping"])
with pytest.raises(ValueError, match="保留列名冲突"):
build_formula_workbook(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "revenue"},
BytesIO(),
)
+126
View File
@@ -0,0 +1,126 @@
import json
from unittest.mock import patch
import pytest
from finance.formula_columns import available_metrics, ensure_available_metrics
from finance.formula_views import design_column
pytestmark = pytest.mark.django_db
def model_response(design):
return type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {
"status": "completed",
"output": [{"content": [{"type": "output_text", "text": json.dumps(design)}]}],
},
},
)()
def test_available_metrics_follow_dataset_mapping(workspace):
ds, *_ = workspace
assert set(available_metrics(ds)) == {
"revenue",
"cost",
"gross",
"roi",
"customers",
"basket",
"margin",
"discount_rate",
"self_revenue",
"upgrade_rate",
"list_basket",
}
ds.mapping = {
**ds.mapping,
"columns": [
column
for column in ds.mapping["columns"]
if column["role"] not in {"cost", "gross", "customer", "list_amount"}
],
"self_operated_products": [],
"margin_rates": {},
}
assert set(available_metrics(ds)) == {"revenue"}
with pytest.raises(ValueError, match="消费人数"):
ensure_available_metrics(ds, {"expression": {"metric": "customers"}})
def test_model_cannot_complete_definition_using_unavailable_metrics(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
definition = {
"name": "收入乘消费人数",
"meaning": "实际收入乘消费人数",
"scope": "按所选分组和期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "multiply",
"left": {"metric": "revenue"},
"right": {"metric": "customers"},
},
}
ds.mapping = {
**ds.mapping,
"columns": [column for column in ds.mapping["columns"] if column["role"] != "customer"],
}
with patch("finance.formula_views.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = model_response(
{"complete": True, "questions": [], "column": definition}
)
result = design_column(
[{"role": "user", "content": "增加收入乘消费人数列"}],
ds,
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
)
assert result["complete"] is False
assert result["column"] is None
assert "消费人数" in result["questions"][0]
payload = client.return_value.__enter__.return_value.post.call_args.kwargs["json"]
system = payload["input"][0]["content"]
assert '"customers": "消费人数"' in system.split('"unavailable_metrics":', 1)[1]
metric_schema = payload["text"]["format"]["schema"]["properties"]["column"]["anyOf"][1]["properties"][
"expression"
]["anyOf"][0]["properties"]["metric"]
assert "customers" not in metric_schema["enum"]
assert "revenue" in metric_schema["enum"]
def test_export_api_rejects_unavailable_metric_even_without_design_dialog(workspace):
ds, _, client, *_ = workspace
definition = {
"name": "人数列",
"meaning": "消费人数",
"scope": "按分组期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {"metric": "customers"},
}
ds.mapping = {
**ds.mapping,
"columns": [column for column in ds.mapping["columns"] if column["role"] != "customer"],
}
ds.save(update_fields=["mapping"])
response = client.post(
f"/api/datasets/{ds.pk}/formula-exports",
data={
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [definition],
},
content_type="application/json",
)
assert response.status_code == 400
assert "消费人数" in response.json()["error"]
+183
View File
@@ -0,0 +1,183 @@
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from openpyxl import load_workbook
from finance.exporting import build_formula_workbook
from tests.test_formula_export_reliability import custom_column
pytestmark = pytest.mark.django_db
def test_formula_workbook_recalculates_with_libreoffice(workspace, tmp_path):
executable = os.environ.get("SOFFICE_BIN") or shutil.which("soffice")
if not executable:
pytest.skip("set SOFFICE_BIN to run the LibreOffice recalculation check")
ds, member, *_ = workspace
source = tmp_path / "source"
output = tmp_path / "recalculated"
source.mkdir()
output.mkdir()
workbook_path = source / "formula.xlsx"
with workbook_path.open("wb") as workbook_file:
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [custom_column()],
},
workbook_file,
)
profile = tmp_path / "lo-profile"
result = subprocess.run(
[
executable,
"--headless",
f"-env:UserInstallation={profile.as_uri()}",
"--convert-to",
"xlsx",
"--outdir",
str(output),
str(workbook_path),
],
capture_output=True,
text=True,
timeout=120,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
recalculated = output / workbook_path.name
assert recalculated.is_file(), result.stderr or result.stdout
formulas = load_workbook(recalculated, data_only=False)
values = load_workbook(recalculated, data_only=True)
summary_formulas = formulas["分析汇总"]
summary_values = values["分析汇总"]
row = next(
row
for row in range(6, summary_formulas.max_row + 1)
if summary_formulas.cell(row, 1).value == "收入成本差"
)
assert summary_formulas.cell(row, 3).value.startswith("=")
assert summary_values.cell(row, 3).value == pytest.approx(60)
assert summary_values.cell(row, 4).value == pytest.approx(90)
assert summary_values.cell(row, 5).value == pytest.approx(-30)
assert summary_values.cell(row, 6).value == pytest.approx(-1 / 3)
errors = {
cell.value
for sheet in values
for row_cells in sheet.iter_rows()
for cell in row_cells
if cell.data_type == "e"
}
assert errors <= {"#N/A"}
def _recalculate(workbook_path, output, profile, executable):
result = subprocess.run(
[
executable,
"--headless",
f"-env:UserInstallation={profile.as_uri()}",
"--convert-to",
"xlsx",
"--outdir",
str(output),
str(workbook_path),
],
capture_output=True,
text=True,
timeout=120,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
recalculated = output / Path(workbook_path).name
assert recalculated.is_file(), result.stderr or result.stdout
return load_workbook(recalculated, data_only=True)
def test_custom_column_recalculates_after_detail_amount_edit(workspace, tmp_path):
executable = os.environ.get("SOFFICE_BIN") or shutil.which("soffice")
if not executable:
pytest.skip("set SOFFICE_BIN to run the LibreOffice recalculation check")
ds, member, *_ = workspace
source, output = tmp_path / "source", tmp_path / "recalculated"
source.mkdir()
output.mkdir()
workbook_path = source / "edited-input.xlsx"
with workbook_path.open("wb") as workbook_file:
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [custom_column()],
},
workbook_file,
)
edited = load_workbook(workbook_path)
edited["计算明细"]["E4"] = 140
edited.save(workbook_path)
values = _recalculate(workbook_path, output, tmp_path / "edit-profile", executable)
summary = values["分析汇总"]
row = next(
row
for row in range(6, summary.max_row + 1)
if summary.cell(row, 1).value == "收入成本差" and summary.cell(row, 2).value == "儿科"
)
assert summary.cell(row, 3).value == pytest.approx(120)
assert summary.cell(row, 5).value == pytest.approx(30)
def test_roi_zero_cost_and_missing_baseline_return_excel_na(workspace, tmp_path):
executable = os.environ.get("SOFFICE_BIN") or shutil.which("soffice")
if not executable:
pytest.skip("set SOFFICE_BIN to run the LibreOffice recalculation check")
ds, member, *_ = workspace
ds.facts.filter(period="2026-08").update(cost=0)
source, output = tmp_path / "source", tmp_path / "recalculated"
source.mkdir()
output.mkdir()
workbook_path = source / "roi-edges.xlsx"
with workbook_path.open("wb") as workbook_file:
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "custom",
"baseline_end": "2024-08",
"group": "department",
},
workbook_file,
)
values = _recalculate(workbook_path, output, tmp_path / "roi-profile", executable)
summary = values["分析汇总"]
roi_row = next(
row
for row in range(6, summary.max_row + 1)
if summary.cell(row, 1).value == "ROI" and summary.cell(row, 2).value == "儿科"
)
assert summary.cell(roi_row, 3).value == "#N/A"
assert summary.cell(roi_row, 4).value == "#N/A"
errors = {
cell.value
for sheet in values
for row_cells in sheet.iter_rows()
for cell in row_cells
if cell.data_type == "e"
}
assert errors <= {"#N/A"}
+109
View File
@@ -0,0 +1,109 @@
import json
from io import BytesIO
from unittest.mock import patch
import pytest
from openpyxl import load_workbook
from finance.exporting import build_formula_workbook
from finance.formula_columns import validate_column
from finance.formula_views import design_column
from finance.views import APIError
pytestmark = pytest.mark.django_db
def custom_column():
return {
"name": "收入成本差",
"meaning": "实际收入减成本",
"scope": "按所选分组及本期、基期计算",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
def model_response(design):
return type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {
"status": "completed",
"output": [{"content": [{"type": "output_text", "text": json.dumps(design)}]}],
},
},
)()
def test_design_column_asks_for_missing_details_then_returns_safe_definition(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
ctx = {"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"}
missing = {"complete": False, "questions": ["请补充缺失值和零分母的处理方式。"], "column": None}
complete = {"complete": True, "questions": [], "column": custom_column()}
with patch("finance.formula_views.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = model_response(missing)
first = design_column([{"role": "user", "content": "增加收入减成本的新列"}], ds, ctx)
assert first == missing
messages = [
{"role": "user", "content": "增加收入减成本的新列"},
{"role": "assistant", "content": first["questions"][0]},
{"role": "user", "content": "缺失或零分母显示不可用"},
]
client.return_value.__enter__.return_value.post.return_value = model_response(complete)
result = design_column(messages, ds, ctx)
assert result == complete
payload = client.return_value.__enter__.return_value.post.call_args.kwargs["json"]
assert payload["input"][-1] == messages[-1]
validate_column(result["column"])
def test_design_column_rejects_unsafe_model_definitions(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
ctx = {"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"}
invalid = {
"complete": True,
"questions": [],
"column": {**custom_column(), "expression": "=HYPERLINK()"},
}
with patch("finance.formula_views.httpx.Client") as client:
client.return_value.__enter__.return_value.post.return_value = model_response(invalid)
with pytest.raises(APIError, match="未创建工作簿"):
design_column([{"role": "user", "content": "随意算一个值"}], ds, ctx)
def test_formula_labels_from_ai_are_written_as_plain_text(workspace):
ds, member, *_ = workspace
definition = custom_column()
definition["name"] = '=HYPERLINK("https://invalid")'
validate_column(definition)
workbook_bytes = BytesIO()
build_formula_workbook(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [definition],
},
workbook_bytes,
)
workbook = load_workbook(BytesIO(workbook_bytes.getvalue()), data_only=False)
summary = workbook["分析汇总"]
cell = next(cell for row in summary.iter_rows() for cell in row if cell.value == definition["name"])
assert cell.data_type == "s"
+86
View File
@@ -0,0 +1,86 @@
import os
import shutil
import subprocess
import pytest
from openpyxl import load_workbook
from finance.exporting import build_formula_workbook
pytestmark = pytest.mark.django_db
def _recalculate(executable, source, output, profile):
result = subprocess.run(
[
executable,
"--headless",
f"-env:UserInstallation={profile.as_uri()}",
"--convert-to",
"xlsx",
"--outdir",
str(output),
str(source),
],
capture_output=True,
text=True,
timeout=120,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
recalculated = output / source.name
assert recalculated.is_file(), result.stderr or result.stdout
return recalculated
def test_direct_and_lookup_gross_recalculate_and_roll_up(workspace, tmp_path):
executable = os.environ.get("SOFFICE_BIN") or shutil.which("soffice")
if not executable:
pytest.skip("set SOFFICE_BIN to run the LibreOffice recalculation check")
ds, member, *_ = workspace
ds.mapping = {**ds.mapping, "margin_rates": {"医疗服务": "0.25"}}
ds.save(update_fields=["mapping"])
lookup_fact = ds.facts.get(period="2026-08", source_row=5)
lookup_fact.raw[5] = ""
lookup_fact.save(update_fields=["raw"])
source_dir, output_dir = tmp_path / "source", tmp_path / "recalculated"
source_dir.mkdir()
output_dir.mkdir()
workbook_path = source_dir / "gross-sources.xlsx"
with workbook_path.open("wb") as workbook_file:
build_formula_workbook(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
workbook_file,
)
recalculated = _recalculate(executable, workbook_path, output_dir, tmp_path / "lo-profile-gross")
formulas = load_workbook(recalculated, data_only=False)
values = load_workbook(recalculated, data_only=True)
details = values["计算明细"]
assert details["I4"].value == "direct"
assert details["I5"].value == "lookup"
assert details["N4"].value == pytest.approx(50)
assert details["N5"].value == pytest.approx(5)
summary_formula = formulas["分析汇总"]
summary_values = values["分析汇总"]
row = next(
row
for row in range(6, summary_formula.max_row + 1)
if summary_formula.cell(row, 1).value == "毛利贡献"
)
assert summary_formula.cell(row, 3).value.startswith("=")
assert summary_values.cell(row, 3).value == pytest.approx(55)
errors = {
cell.value
for sheet in values
for row_cells in sheet.iter_rows()
for cell in row_cells
if cell.data_type == "e"
}
assert errors <= {"#N/A"}
+30
View File
@@ -0,0 +1,30 @@
from io import BytesIO
import pytest
from openpyxl import load_workbook
from finance.exporting import build_formula_workbook
pytestmark = pytest.mark.django_db
def test_reconcile_status_guards_non_numeric_difference(workspace):
ds, member, *_ = workspace
ds.periods = ["2025-08"]
ds.save(update_fields=["periods"])
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)
formulas = [
row[6].value
for row in workbook["对账检查"].iter_rows(min_row=2)
if isinstance(row[6].value, str) and row[6].value.startswith("=IF(D")
]
assert formulas
assert all("ISNUMBER(F" in formula for formula in formulas)
assert all('"通过"' in formula for formula in formulas)
+125
View File
@@ -0,0 +1,125 @@
import hashlib
import zipfile
from datetime import date
from decimal import Decimal
from io import BytesIO
import pytest
from django.contrib.auth.models import User
from finance.importing import (
decimal,
file_digest,
period_value,
process_dataset,
profile_file,
rows_for,
safe_text,
suggest_mapping,
validate_archive,
validate_mapping,
)
from finance.models import Dataset, Membership, Organization
pytestmark = pytest.mark.django_db
@pytest.fixture
def import_dataset(tmp_path, settings):
settings.MEDIA_ROOT = tmp_path
org = Organization.objects.create(name="Import tests")
user = User.objects.create_user("importer", password="Import-TestPassword991!")
Membership.objects.create(user=user, organization=org, role="admin")
ds = Dataset.objects.create(
organization=org,
created_by=user,
name="sample.csv",
sha256="c" * 64,
status="queued",
mapping={
"sheet": "CSV",
"header": 1,
"columns": [
{"index": 0, "name": "期间", "role": "period"},
{"index": 1, "name": "科室", "role": "dimension", "key": "department"},
{"index": 2, "name": "产品一级分类", "role": "dimension", "key": "product"},
{"index": 3, "name": "实际收入", "role": "revenue"},
{"index": 4, "name": "毛利贡献", "role": "gross"},
{"index": 5, "name": "客户编码", "role": "customer"},
],
"roi_formula": "profit_over_cost",
"margin_rates": {"医疗服务": "0.6"},
},
)
source = tmp_path / "sample.csv"
source.write_text(
"期间,科室,产品一级分类,实际收入,毛利贡献,客户编码\n"
"2026-08,儿科,医疗服务,100,60,C1\n"
"2026-08,儿科,未知产品,25,,\n"
"\n",
encoding="utf-8",
)
ds.file.name = str(source)
ds.save(update_fields=["file"])
return ds
def test_csv_import_commits_rows_quality_and_derived_gross(import_dataset):
ds = process_dataset(import_dataset.pk)
assert ds.status == "ready"
assert ds.row_count == 2
assert ds.periods == ["2026-08"]
assert ds.quality["missing_cost"] == 2
assert ds.quality["missing_gross"] == 1
assert ds.quality["missing_customer"] == 1
assert ds.quality["blank_rows"] == 1
assert list(ds.facts.order_by("source_row").values_list("gross", flat=True)) == [
Decimal("60.000000000000000000000000"),
None,
]
assert ds.facts.get(source_row=2).dimensions["department"] == "儿科"
def test_profile_csv_mapping_and_file_digest(tmp_path):
source = tmp_path / "input.csv"
source.write_text("期间,科室,实际收入\n2026-08,儿科,12\n", encoding="utf-8")
profile = profile_file(source)
mapping = suggest_mapping(profile)
assert mapping["columns"][0]["role"] == "period"
assert mapping["columns"][2]["role"] == "revenue"
assert validate_mapping(mapping, profile) == mapping
from django.core.files.uploadedfile import SimpleUploadedFile
upload = SimpleUploadedFile("input.csv", source.read_bytes(), content_type="text/csv")
assert file_digest(upload) == hashlib.sha256(source.read_bytes()).hexdigest()
def test_safe_text_and_period_inputs():
assert safe_text(date(2026, 8, 3)) == "2026-08-03"
assert safe_text("x" * 5000) == "x" * 4000
assert period_value({"period": date(2026, 8, 3)}) == "2026-08"
assert period_value({"period": "2026-8-19T12:00:00"}) == "2026-08"
with pytest.raises(ValueError, match="精度"):
decimal("0.0000000000000000000000001")
def test_archive_rejects_macro_and_external_links():
for member in ("xl/vbaProject.bin", "xl/externalLinks/externalLink1.xml"):
archive_file = BytesIO()
with zipfile.ZipFile(archive_file, "w") as archive:
archive.writestr(member, "blocked")
archive_file.seek(0)
with pytest.raises(ValueError, match="宏或外部链接"):
validate_archive(archive_file)
def test_workbook_rows_can_read_formulas_from_selected_sheet(tmp_path):
from openpyxl import Workbook
path = tmp_path / "book.xlsx"
workbook = Workbook()
workbook.active.title = "Data"
workbook.active.append(["value"])
workbook.active.append(["=1+1"])
workbook.save(path)
assert list(rows_for(path, "Data", formulas=True)) == [("value",), ("=1+1",)]
+94
View File
@@ -0,0 +1,94 @@
from datetime import timedelta
import pytest
from django.contrib.auth.models import User
from django.core.management import call_command
from django.core.management.base import CommandError
from django.utils import timezone
from finance.models import Audit, FormulaExport
pytestmark = pytest.mark.django_db
def test_bootstrap_creates_admin_once_and_rejects_duplicate(workspace, monkeypatch):
monkeypatch.setenv("BOOTSTRAP_PASSWORD", "Bootstrap-TestPassword991!")
with pytest.raises(CommandError, match="already exists"):
call_command("bootstrap", username="finance-admin", organization="Duplicate")
user_count = User.objects.count()
call_command("bootstrap", username="new-admin", organization="New Organization")
user = User.objects.get(username="new-admin")
assert user.check_password("Bootstrap-TestPassword991!")
assert user.membership.role == "admin"
assert User.objects.count() == user_count + 1
def test_cli_import_previews_then_queues_and_deduplicates(workspace, tmp_path):
ds, _, _, _, _ = workspace
source = tmp_path / "cli.csv"
source.write_text("期间,科室,实际收入\n2026-08,儿科,10\n", encoding="utf-8")
call_command("import_file", str(source), username=ds.created_by.username)
assert not Audit.objects.filter(action="dataset.cli_import").exists()
call_command("import_file", str(source), username=ds.created_by.username, confirm_mapping=True)
assert Audit.objects.filter(action="dataset.cli_import").count() == 1
call_command("import_file", str(source), username=ds.created_by.username, confirm_mapping=True)
assert Audit.objects.filter(action="dataset.cli_import").count() == 1
def test_worker_processes_queued_import_and_formula_export(workspace):
ds, _, _, *_ = workspace
job = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={"custom_columns": []})
from unittest.mock import patch
with patch("finance.management.commands.import_worker.process_formula_export", return_value={"rows": 5}):
call_command("import_worker", once=True)
job.refresh_from_db()
assert job.status == "processing"
def test_formula_export_status_download_and_access_controls(workspace, settings, tmp_path):
ds, _, client, _, other_client = workspace
url = f"/api/datasets/{ds.pk}"
job = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={}, status="queued")
status_url = f"{url}/formula-exports/{job.pk}"
assert client.get(status_url).json()["status"] == "queued"
assert client.get(f"{status_url}/download").status_code == 409
assert other_client.get(status_url).status_code == 404
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.write_bytes(b"xlsx")
job.path = str(target)
job.save()
response = client.get(f"{status_url}/download")
assert response.status_code == 200
assert response["Content-Disposition"].endswith('"traceiq-formula-analysis.xlsx"')
other_target = target.with_name("another-task.xlsx")
other_target.write_bytes(b"other")
job.path = str(other_target)
job.save(update_fields=["path"])
assert client.get(f"{status_url}/download").status_code == 410
job.path = str(target)
job.save(update_fields=["path"])
target.unlink()
target.symlink_to(other_target)
assert client.get(f"{status_url}/download").status_code == 410
target.unlink()
target.write_bytes(b"xlsx")
job.departments = ["儿科"]
job.save(update_fields=["departments"])
assert client.get(status_url).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"
ctx = {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"}
response = client.post(url, data=ctx, content_type="application/json")
assert response.status_code == 202
assert FormulaExport.objects.filter(dataset=ds, status="queued").exists()
+19
View File
@@ -0,0 +1,19 @@
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
pytestmark = pytest.mark.django_db
def test_upload_rejects_empty_oversized_and_unsupported_files(workspace, settings):
_, _, client, *_ = workspace
settings.MAX_UPLOAD_BYTES = 4
assert (
client.post("/api/datasets", data={"file": SimpleUploadedFile("empty.csv", b"")}).status_code == 400
)
assert (
client.post("/api/datasets", data={"file": SimpleUploadedFile("large.csv", b"12345")}).status_code
== 400
)
settings.MAX_UPLOAD_BYTES = 1024
response = client.post("/api/datasets", data={"file": SimpleUploadedFile("invalid.txt", b"abc")})
assert response.status_code == 400
+144
View File
@@ -0,0 +1,144 @@
import json
import pytest
from django.contrib.auth.models import User
from django.test import Client
from finance.models import Audit, Investigation, Membership
def test_api_auth_method_and_request_shape_guards(workspace):
ds, _, client, *_ = workspace
anonymous = Client()
assert anonymous.get(f"/api/datasets/{ds.pk}").status_code == 401
assert client.get(f"/api/datasets/{ds.pk}/query").status_code == 405
assert (
client.post(f"/api/datasets/{ds.pk}/query", data="[]", content_type="application/json").status_code
== 400
)
assert (
client.post(f"/api/datasets/{ds.pk}/query", data="{}", content_type="text/plain").status_code == 400
)
bad_login = anonymous.post(
"/api/login", data=json.dumps({"username": "x", "password": "wrong"}), content_type="application/json"
)
assert bad_login.status_code == 401
assert anonymous.post("/api/logout").status_code == 401
pytestmark = pytest.mark.django_db
def test_session_login_logout_and_health_contract(workspace):
ds, _, _, _, _ = workspace
client = Client()
assert client.get("/api/health").json() == {"status": "ok"}
session = client.get("/api/session").json()
assert session["user"] is None
login = client.post(
"/api/login",
data=json.dumps({"username": ds.created_by.username, "password": "Finance-TestPassword991!"}),
content_type="application/json",
)
assert login.status_code == 200
assert client.get("/api/session").json()["user"]["role"] == "admin"
assert client.post("/api/logout").json() == {"ok": True}
assert client.get("/api/session").json()["user"] is None
assert Audit.objects.filter(action__in=["auth.login", "auth.logout"]).count() == 2
def test_user_management_enforces_org_scope_and_self_protection(workspace):
ds, _, client, _, _ = workspace
base = {"role": "analyst", "departments": ["儿科"]}
created = client.post(
"/api/users",
data=json.dumps({**base, "username": "managed-user", "password": "Managed-TestPassword991!"}),
content_type="application/json",
)
assert created.status_code == 200
managed = User.objects.get(username="managed-user")
membership = Membership.objects.get(user=managed)
assert membership.organization == ds.organization
assert membership.departments == ["儿科"]
denied = client.post(
"/api/users",
data=json.dumps({**base, "username": "weak-user", "password": "123"}),
content_type="application/json",
)
assert denied.status_code == 400
assert (
client.patch(
"/api/users",
data=json.dumps({"id": ds.created_by_id, "active": False, **base}),
content_type="application/json",
).status_code
== 400
)
assert (
client.patch(
"/api/users",
data=json.dumps({"id": managed.pk, "active": False, "role": "viewer", "departments": []}),
content_type="application/json",
).status_code
== 200
)
managed.refresh_from_db()
membership.refresh_from_db()
assert not managed.is_active
assert membership.role == "viewer"
assert client.get("/api/users").status_code == 200
assert client.get("/api/audit").status_code == 200
assert Audit.objects.filter(action="user.update").count() == 2
def test_investigation_lifecycle_and_viewer_write_denial(workspace):
ds, _, client, _, _ = workspace
context = {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"}
url = f"/api/datasets/{ds.pk}/investigations"
saved = client.post(
url,
data=json.dumps({**context, "status": "reviewing", "note": "复核收入成本"}),
content_type="application/json",
)
assert saved.status_code == 200
assert Investigation.objects.filter(dataset=ds, status="reviewing", note="复核收入成本").exists()
assert client.get(url).json()["items"][0]["status"] == "reviewing"
invalid = client.post(
url,
data=json.dumps({**context, "status": "closed", "note": ""}),
content_type="application/json",
)
assert invalid.status_code == 400
viewer = User.objects.create_user("finance-viewer", password="Finance-TestPassword991!")
Membership.objects.create(user=viewer, organization=ds.organization, role="viewer")
readonly = Client()
assert readonly.login(username=viewer.username, password="Finance-TestPassword991!")
denied = readonly.post(
url,
data=json.dumps({**context, "status": "resolved", "note": ""}),
content_type="application/json",
)
assert denied.status_code == 403
def test_evidence_rejects_bad_pagination_and_exports_exact_xlsx(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/evidence"
invalid = client.post(
url,
data=json.dumps({"start": "2026-08", "end": "2026-08", "limit": 201}),
content_type="application/json",
)
assert invalid.status_code == 400
exported = client.post(
f"/api/datasets/{ds.pk}/export",
data=json.dumps({"start": "2026-08", "end": "2026-08", "format": "xlsx", "group": "department"}),
content_type="application/json",
)
assert exported.status_code == 200
assert exported.streaming
assert exported["Content-Disposition"].endswith('"traceiq-evidence.xlsx"')
assert b"".join(exported.streaming_content).startswith(b"PK")
+28
View File
@@ -0,0 +1,28 @@
from unittest.mock import patch
import pytest
from django.core.management import call_command
from finance.models import FormulaExport
pytestmark = pytest.mark.django_db
def test_worker_processes_queued_import_and_reports_export_failure(workspace):
ds, *_ = workspace
ds.status = "queued"
ds.save(update_fields=["status"])
with patch(
"finance.management.commands.import_worker.process_dataset", return_value=ds
) as process_import:
call_command("import_worker", once=True)
process_import.assert_called_once_with(ds.pk)
export = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={})
with patch(
"finance.management.commands.import_worker.process_formula_export",
side_effect=ValueError("invalid export"),
):
call_command("import_worker", once=True)
export.refresh_from_db()
assert export.status == "processing"
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"types": ["vite/client"]
},
"include": ["frontend/src", "vite.config.ts"]
}
Generated
+766
View File
@@ -0,0 +1,766 @@
version = 1
revision = 3
requires-python = ">=3.12, <3.14"
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "asgiref"
version = "3.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" },
]
[[package]]
name = "boolean-py"
version = "5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
]
[[package]]
name = "cachecontrol"
version = "0.14.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "msgpack" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
]
[package.optional-dependencies]
filecache = [
{ name = "filelock" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
{ url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
{ url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
{ url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
{ url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
{ url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
{ url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
{ url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
{ url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
{ url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
{ url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
{ url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
{ url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
{ url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
{ url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
{ url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
{ url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
{ url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" },
{ url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" },
{ url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" },
{ url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" },
{ url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" },
{ url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" },
{ url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" },
{ url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" },
{ url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" },
{ url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" },
{ url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" },
{ url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" },
{ url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" },
{ url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" },
{ url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" },
{ url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" },
{ url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" },
{ url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" },
{ url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" },
{ url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" },
{ url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" },
{ url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" },
{ url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" },
{ url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" },
]
[[package]]
name = "cyclonedx-python-lib"
version = "11.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "license-expression" },
{ name = "packageurl-python" },
{ name = "py-serializable" },
{ name = "sortedcontainers" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]]
name = "django"
version = "5.2.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
]
[[package]]
name = "filelock"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/38/88cd6eda96c40594a1e3da7d8b40f04bc40ace5a6aef9ac5cb407540f173/filelock-4.0.1.tar.gz", hash = "sha256:fdefc3f3e87716d855ae2b732c1cfd521dd99799ef2b4d00e8c0d4dcdc7cc94b", size = 238888, upload-time = "2026-09-19T01:08:15.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/33/af0635ab07fe83b1788a1dbe370ff3e226062495a998335cb18a1cac81aa/filelock-4.0.1-py3-none-any.whl", hash = "sha256:481a321a27bef441e23c53371c6abc8d7d16e26b97090074ba44f7538a3fd55a", size = 106219, upload-time = "2026-09-19T01:08:14.49Z" },
]
[[package]]
name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "license-expression"
version = "30.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boolean-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "msgpack"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/78/90c15bebb1a72667349ca62d4507e9d9369e7f8f76b95f490b823d3622e5/msgpack-1.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc", size = 84275, upload-time = "2026-08-27T10:02:07.106Z" },
{ url = "https://files.pythonhosted.org/packages/88/88/c2b6d8e81571da87aa232c0e34a3f3a0e618e6235892065ec82d1d81fc7a/msgpack-1.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3", size = 83970, upload-time = "2026-08-27T10:02:08.488Z" },
{ url = "https://files.pythonhosted.org/packages/da/c0/d3ede9f5d16acb4c05a9281859f1e99ef9f877a928eb78454c37f70db001/msgpack-1.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba", size = 409401, upload-time = "2026-08-27T10:02:09.877Z" },
{ url = "https://files.pythonhosted.org/packages/41/f0/29f591bea185616cf417645ac03bd3ad9b317483ad8572160e325f7fe777/msgpack-1.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923", size = 420619, upload-time = "2026-08-27T10:02:11.526Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8e/c70c8c9180c5ddf4440eb8658ebead98e22e7686fbf84f6b165031430750/msgpack-1.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f", size = 379747, upload-time = "2026-08-27T10:02:13.345Z" },
{ url = "https://files.pythonhosted.org/packages/50/9a/f10ce11fa62700c9ab87a22e65b9ca272f7f673ddd31aeb2de6ae272ad35/msgpack-1.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367", size = 398944, upload-time = "2026-08-27T10:02:15.006Z" },
{ url = "https://files.pythonhosted.org/packages/82/fe/d7be978456ff8552e69a8e270d882e7530e01513c096b293d83df03753ea/msgpack-1.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc", size = 373979, upload-time = "2026-08-27T10:02:16.618Z" },
{ url = "https://files.pythonhosted.org/packages/be/af/91b0d8d3fb3063e259daee3ea8515cea6282f68f4b0e5f0b6fea25762c6e/msgpack-1.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3", size = 417781, upload-time = "2026-08-27T10:02:18.293Z" },
{ url = "https://files.pythonhosted.org/packages/8e/3c/ce8e9efe1fd9e95c78b3705e4300ba7feba3dc6c00fb76259895db155518/msgpack-1.2.2-cp312-cp312-win32.whl", hash = "sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4", size = 65267, upload-time = "2026-08-27T10:02:19.869Z" },
{ url = "https://files.pythonhosted.org/packages/85/98/a33b8b4af14e3476bb0da1b8c36ef7a0f28dcf95db1c5e68ff88cb89d591/msgpack-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3", size = 72275, upload-time = "2026-08-27T10:02:21.141Z" },
{ url = "https://files.pythonhosted.org/packages/df/5e/2f323a33a6aba5bd4b2d8b430e4fab21d92cd91c093b49ee287bc166ee54/msgpack-1.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640", size = 65488, upload-time = "2026-08-27T10:02:22.575Z" },
{ url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" },
{ url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" },
{ url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" },
{ url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" },
{ url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" },
{ url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "packageurl-python"
version = "0.17.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pip"
version = "26.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" },
]
[[package]]
name = "pip-api"
version = "0.0.35"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pip" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/08/47dfc28aa16ef0dbc38dd7c6e4fe24d124c3ed3a722a4a3e5021584a588c/pip_api-0.0.35.tar.gz", hash = "sha256:14dea05d7b3b68f4c7582213e3ae8932bbba8e1f754bf06b95263d5fa6ab4363", size = 123884, upload-time = "2026-09-08T19:35:12.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/2e/d424c5b5877be862dd9a2c8b7e9ddb13382bb25e132e6dd50cc09c0a7dde/pip_api-0.0.35-py3-none-any.whl", hash = "sha256:66990d0da25378555cbb538db79384bab92d4617c6350dfd7a2126ba2a3a9894", size = 123943, upload-time = "2026-09-08T19:35:11.537Z" },
]
[[package]]
name = "pip-audit"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachecontrol", extra = ["filecache"] },
{ name = "cyclonedx-python-lib" },
{ name = "packaging" },
{ name = "pip-api" },
{ name = "pip-requirements-parser" },
{ name = "platformdirs" },
{ name = "requests" },
{ name = "rich" },
{ name = "tomli" },
{ name = "tomli-w" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" },
]
[[package]]
name = "pip-requirements-parser"
version = "32.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "pyparsing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" },
]
[[package]]
name = "platformdirs"
version = "4.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/dd/65804b0c2925a1c821a05502ea57517b69a073ff400d25ab9faa3a2cf012/platformdirs-4.11.12.tar.gz", hash = "sha256:e8dc1cb58f1153fd7f61db1374317770baababec2480b37b8f01c6cc25b45267", size = 40016, upload-time = "2026-09-22T01:02:36.973Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/2b/69de6317fbf48865bb12aa56c9cfcfff46b3add0cd45250725d96146dce0/platformdirs-4.11.12-py3-none-any.whl", hash = "sha256:b1ba966ac76153d9985a0787ffab951e7c18416189873d4e4ddb2c8a04a0a254", size = 24976, upload-time = "2026-09-22T01:02:35.742Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psycopg"
version = "3.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.6"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" },
{ url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" },
{ url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" },
{ url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" },
{ url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" },
{ url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" },
{ url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" },
{ url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" },
{ url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" },
{ url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" },
{ url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" },
{ url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" },
{ url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" },
{ url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" },
{ url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" },
{ url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" },
{ url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" },
]
[[package]]
name = "py-serializable"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "defusedxml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-cov"
version = "6.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" },
]
[[package]]
name = "pytest-django"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.16.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
{ url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
{ url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
{ url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
{ url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
{ url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
{ url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
{ url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "sqlparse"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "tomli-w"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
]
[[package]]
name = "traceiq"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "defusedxml" },
{ name = "django" },
{ name = "gunicorn" },
{ name = "httpx" },
{ name = "openpyxl" },
{ name = "psycopg", extra = ["binary"] },
{ name = "whitenoise" },
]
[package.dev-dependencies]
dev = [
{ name = "pip-audit" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "defusedxml", specifier = ">=0.7.1,<1" },
{ name = "django", specifier = ">=5.2,<5.3" },
{ name = "gunicorn", specifier = ">=23,<24" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "openpyxl", specifier = ">=3.1.5,<4" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
{ name = "whitenoise", specifier = ">=6.9,<7" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pip-audit", specifier = ">=2.9" },
{ name = "pytest", specifier = ">=9.0.3,<10" },
{ name = "pytest-cov", specifier = ">=6,<7" },
{ name = "pytest-django", specifier = ">=4.11,<5" },
{ name = "ruff", specifier = ">=0.12" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "tzdata"
version = "2026.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]
name = "whitenoise"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
]
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
root: "frontend",
plugins: [react()],
server: {
port: 5173,
strictPort: true,
proxy: { "/api": "http://127.0.0.1:8000" },
},
build: { outDir: "dist", chunkSizeWarningLimit: 2000 },
});