commit 0ac0c4eb56c471072d5f7947c40deef4241a0809
Author: Jayway Liu <14022498+bluejayway@user.noreply.gitee.com>
Date: Sat Sep 26 01:12:10 2026 +0800
chore: import existing codebase as baseline
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..f53c63c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+.git
+.venv
+node_modules
+.env
+var
+staticfiles
+frontend/dist
+test-results
+playwright-report
+.coverage
+__pycache__
+*.pyc
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..cebb790
--- /dev/null
+++ b/.env.example
@@ -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=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..14f3cb7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8b11da4
--- /dev/null
+++ b/.gitignore
@@ -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
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..383ee09
--- /dev/null
+++ b/Dockerfile
@@ -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", "-"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7975490
--- /dev/null
+++ b/README.md
@@ -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 依赖就声称已完成这些功能。
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..17918d3
--- /dev/null
+++ b/compose.yaml
@@ -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:
diff --git a/deploy/Caddyfile b/deploy/Caddyfile
new file mode 100644
index 0000000..b1afc77
--- /dev/null
+++ b/deploy/Caddyfile
@@ -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
+}
diff --git a/docs/acceptance.md b/docs/acceptance.md
new file mode 100644
index 0000000..6a961c5
--- /dev/null
+++ b/docs/acceptance.md
@@ -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 均为历史基线,不能替代当前版本的目标环境验收。
diff --git a/docs/excel-formula-export-plan.html b/docs/excel-formula-export-plan.html
new file mode 100644
index 0000000..524d466
--- /dev/null
+++ b/docs/excel-formula-export-plan.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+财务 Agent · Excel 公式导出实施计划
+
+
+
+
+
+
+
+
+01 / 核查结论:目前不含公式
当前 XLSX 导出不满足本次需求。现有实现将每个单元格转换为字符串,并明确设置为文本类型。导出的收入、成本和毛利贡献是结果快照,没有可重算公式,也没有完整的行列字典和操作说明。
| 核查对象 | 已存在的行为 | 与目标的差距 |
|---|
finance/views.py · export() | 一张“证据明细”;前四行含版本 SHA、筛选上下文、ROI 口径、导出时间;所有值写为文本。 | 没有公式计算层、汇总报表或说明页。 |
tests/test_finance.py · test_excel_export_precision_and_formula_safety | 明确断言所有单元格都不是公式,验证高精度金额及注入保护。 | 只能证明证据快照行为正确,不能证明公式导出正确。 |
frontend/src/dialogs.tsx · EvidenceDialog | 选择 XLSX 下载证据明细。 | 没有公式版选择、兼容性说明、精度提示或异步状态。 |
| 规模与参考附件 | 现有单次导出上限 100,000 行;附件共有 332,830 行。 | 不能通过现有接口一次导出原附件全部明细。 |
已运行的两项针对性测试:test_excel_export_precision_and_formula_safety、test_xlsx_double_header_and_lookup,结果 2 passed(1.59 秒)。后者验证导入双表头与毛利率匹配,不是 Excel 公式重算测试。
保留金额文本并非无意义限制:系统使用高精度 Decimal,而 Excel 普通数值只有约 15 位有效数字。新方案必须保留证据精度,不能直接把所有字符串改成浮点数。
+02 / 设计选择与推荐
| 决策 | 选项与代价 | 推荐 |
|---|
| 导出模式 | A. 替换现有证据导出,破坏旧语义。 B. 保留证据快照,新增公式工作簿。 | B。界面清楚区分“证据快照”和“公式分析工作簿”;CSV 始终是快照,不承诺公式。 |
| 精度 | A. 全部按 Excel 浮点数,丢失高精度证据。 B. 精确文本证据 + 数值计算层 + Decimal 对账。 C. 宏或自定义高精度插件,兼容、部署及安全成本高。 | B。明确公式值遵守批准后的精度政策,不声称原生公式保持 24 位小数精确运算。 |
| 公式兼容性 | A. Microsoft 365,支持动态数组去重。 B. 旧版 Excel / WPS / LibreOffice,需另做兼容实现及验证。 | 先以 Microsoft 365 桌面 Windows / Mac 为明确验收目标;网页版另测。其他引擎通过验证后再声明支持,不能以应用跨平台替代工作簿兼容性。 |
| 人数计算 | A. UNIQUE/FILTER 真正按范围去重。 B. 服务端预制去重辅助表,修改客户或分组后可能失效。 C. 每行 COUNTIFS 扫全表,规模大时开销很高。 | 公式版选 A,限定报表分组数量并压测;B 仅作明确只读的兼容快照,不能冒充完全可重算。避免 C。 |
| 生成与重算 | A. 现有 openpyxl 写公式,打开 Excel 时重算。 B. 导出后由经验证的受控引擎重算并保存缓存。 C. 添加写缓存库,但缓存仍是服务器结果。 | 第一阶段 A + 真实 Excel 验收;首次打开前预览可能空白,必须告知。若业务要求下载即带可信缓存,则 B 是上线前置条件;不把 C 当作重算证明。 |
| 编辑能力 | A. 任意插行、改维度和期间并自动重建全部报表。 B. 固定导出范围,允许指定输入修改,结构性变化重新导出。 | 首版 B,边界容易验证;完整可变结构作为后续独立需求,不暗示已经支持。 |
| 大文件 | A. 同步放宽现有上限。 B. 数据库任务 + 独立 worker + 私有文件下载。 | 全量附件采用 B;复用项目已有任务处理模式,不预先引入新的消息队列。 |
+03 / 工作簿结构与行列说明
输出放在前面,输入和证据放在后面。仅创建实际需要的页,不按每个指标拆页。所有名称均为规划,尚未实现。
| 工作表 | 行与列的含义 | 内容与用途 |
|---|
| 分析汇总 | 每行一个指标或已选维度分组;列为本期、基期、差额、变化率、单位、可用状态。 | 汇总金额、去重人数、比率由明细或计算结果引用产生;非加法指标不合计子组值。页首注明范围、期间、版本及模拟状态。 |
| 使用说明 | 按主题分行:范围、粒度、字段、公式、操作、限制。 | 字典列包括名称、业务含义、数据类型、单位、来源字段、可否修改、空值含义、公式与示例;另附版本、时区和支持的 Excel 版本。 |
| 参数与口径 | 每行一个参数或产品毛利率;列为当前值、含义、来源、是否可改。 | 收入映射、ROI 分支、自营产品集合、毛利率、精度政策;首版期间及筛选锁定。允许的模拟参数必须明确标识。 |
| 计算明细 | 一行对应一个导入后的 Fact,保留来源定位键;不是一位客户或一次就诊。 | 期间、维度、去标识客户 token、基础数值、原价、毛利来源、适用毛利率、毛利公式、缺失标志等;Excel Table 管理引用范围。 |
| 原始证据 | 与计算明细一一对应;金额和标识保留精确文本。 | 授权范围内的来源行、字段映射及原值;不可把完整患者原始信息无差别复制出去。工作表保护只防误操作,不是加密或权限控制。 |
| 对账检查 | 每行一个指标及范围;列为服务器精确文本、批准精度的参考数值、公式结果、差异、阈值和检查状态。 | 包含行数、缺失数、总分一致性、公式覆盖与精度警告;此页仅观察结果,不反向驱动业务公式。 |
说明页必须回答的问题
- 本文件覆盖哪个数据版本、期间、组织及筛选?这是授权范围快照,不代表整院所有数据。
- 金额单位是什么?百分比以小数储存;升单率变化“百分点”与相对变化率分列,不混用。
- 收入、实收、原价、毛利贡献分别指什么?毛利贡献不是净利润,ROI 成本的定义必须写明。
- 哪些单元格可改?颜色之外同时提供文字标签、批注和数据验证;公式格保护防止误覆盖。
- 如何启用自动计算、重新计算和查看公式?不需要启用宏,不连接外部数据,不回写业务系统。
- 空值、0、不可用、缺期和超精度分别是什么意思?遇到不支持函数时不要把错误当成 0。
- 修改后如何核对?本地修改是模拟结果,服务器原始快照及其 SHA 不因此变成修改后内容的认证。
+
+05 / 精度与财务对账
推荐:原始精确文本不可变,计算数值有明确精度契约。“显示两位小数”不是“计算只用两位小数”。原附件有 11,990 行收入超过四位小数,不能在导出时静默按行截断。
- 证据层:保留 Decimal 原始字符串及来源映射;不经 JavaScript Number 中转。完整金额不可因导出而发生舍入。
- 计算层:先检测有效数字、数量级和累计误差风险,再转换 Excel 数值。把转换策略、舍入位数、舍入模式和发生时点写入文件。
- 推荐默认:分析公式不逐行强制 ROUND;展示金额两位,最终财务比较按批准的规则舍入。若业务要求“每行先舍入再汇总”,另设明确版本口径,不能混用。
- 两套参照:Decimal 对原始业务口径的精确结果用于证据核验;按相同批准数值政策得到的参考结果用于 Excel 计算对账。区别“政策导致的差异”与“实现错误”。
- 误差门槛:逐项规定金额绝对误差、比率误差及人数严格相等。建议金额以最终批准币种最小单位一致为目标;具体阈值必须经真实样本和财务确认,不能先放宽到让测试通过。
- 边界处理:超过可接受数值范围、舍入临界点或差异超阈值时阻止宣称“精确一致”,给出可定位的警告或拒绝公式版,证据版仍保留。
不推荐启用 Excel“以显示精度为准”,因为它会改变存储值。原生数值公式不能承诺任意 24 位小数精确运算;若确有此硬要求,应保留服务端 Decimal 为权威计算引擎,并另行评估高精度插件,不在首版隐式引入宏。
+06 / 修改后究竟会重算什么
| 操作 | 首版承诺 | 限制 |
|---|
| 修改允许编辑的收入、成本、原价、直接毛利 | 行级毛利、汇总、比率、差额及对账随公式重算。 | 原始证据不变;明确标记为本地模拟,不回写服务器。 |
| 修改已有产品的毛利率 | 仅 lookup 来源行重算;direct 来源行不受影响。 | 不自动新增产品,不静默替换原始毛利。 |
| 筛选或隐藏明细行 | 默认仅改变可见行,不改变汇总范围。 | 明确 SUMIFS 不随 AutoFilter 改变;若未来要“仅可见行汇总”,作为独立模式实现及测试。 |
| 改变期间、维度、客户、产品集合,追加或删除行 | 首版不承诺;从系统重新选择范围并导出。 | Table 能扩展不等于分组报表、去重及状态范围全部正确扩展;锁定结构以防误解。 |
| 修改公式 | 默认保护防误操作;允许授权副本解除保护后自行分析。 | 解除保护后的结果不属于系统验证范围;工作表保护不是安全边界。 |
源事实 ID 或稳定行键只用于追溯;本地模拟状态可通过可编辑输入与导出初值比较生成。任何数据版本 SHA 都只标识服务器来源,不能认证已被本地修改的文件。
+07 / 安全、权限与 33 万行性能
可信公式与不可信内容严格分离
- 公式只由受控模板生成;原始文本、列名、维度标签保持文本类型。保留并扩展当前 =、+、-、@、制表符及换行注入测试。
- 无 VBA、DDE、外部工作簿链接或未经允许的 HYPERLINK;扫描 OOXML 关系和公式,不只检查文件扩展名。
- 客户去重使用工作簿内稳定的去标识 token。推荐每次导出独立密钥 HMAC,在所有页统一使用,密钥不写入文件;仍按敏感数据管理,不能称为不可逆匿名化。
- 申请、执行、下载三个阶段复核组织和科室权限;执行快照只包含授权行,下载前权限被撤销必须拒绝。原始证据页也不能绕过字段权限。
- 下载文件私有存储、短期保留、自动清理;审计记录版本、范围、模式、公式版本、结果及文件哈希,不记录患者原值。
任务化导出
保留旧同步证据入口的行为;新增公式版任务状态:queued → running → succeeded / failed / expired。请求校验后冻结数据版本、映射、筛选、权限范围、参数和公式版本。worker 用分块迭代处理,临时文件原子完成后才发布;支持超时、失败重试、去重请求、崩溃恢复和过期清理。
真实附件 332,830 行未超过 Excel 单表 1,048,576 行的限制,但当前接口的 100,000 行限制仍然存在。完整公式版必须另做容量评估,超过支持范围应显式拒绝或提示缩小范围,不能悄悄截断。
性能基准至少覆盖 1,000 / 100,000 / 332,830 行,测导出耗时、峰值内存、文件大小、首次打开、改值重算、并发排队。记录设备、Excel 版本和数据分组基数;本轮不虚构 SLA。高基数分组达到预算时提示缩小报表范围,不降级为静态结果却仍标“公式版”。
+08 / 文件级实施步骤
| 阶段 | 工作与目标文件 | 完成条件 / 依赖 |
|---|
P0 · 口径冻结 估计 1–2 人日 | 复核 finance/analysis.py、finance/importing.py 与原附件;定义精度政策、公式清单、编辑边界及兼容矩阵;确认毛利来源恢复。 | 财务确认收入 N / 实收 I、原价、退款、自营产品、成本与 ROI;否则不可冻结黄金结果。 |
P1 · 最小公式工作簿 估计 2–4 人日 | 新增 finance/exporting.py 集中生成模板、状态和公式;复用现有分析口径,不引入通用公式 DSL。先实现小样本汇总、明细、参数、说明和对账。 | 每个衍生单元格可追溯;输入/公式/文本写入类型分离;小样本真实 Excel 重算通过。 |
P2 · 精度与隐私 估计 1–2 人日 | 同模块实现精确文本、数值转换预检、毛利来源、客户 token、来源定位和注入防护;增加针对性单元测试。 | Decimal 对账、去重、缺失、恶意文本及越权用例通过;不破坏证据版。 |
P3 · 任务与 API 估计 2–3 人日 | 在 finance/models.py 增加必要的导出任务与迁移;在 finance/views.py 及现有 URL 配置增加提交/查询/下载入口;新增导出 worker 命令,复用导入 worker 的部署模式。 | 完整附件能异步生成;崩溃恢复、重试、权限撤销、留存清理可验证。schema_version、mode、context、compatibility 等字段由服务端白名单校验。 |
P4 · 网页交互 估计 1–2 人日 | frontend/src/dialogs.tsx、types.ts、api.ts 按需调整:模式选择、兼容性与精度说明、任务状态、错误反馈、下载和重试。 | 桌面/手机可用;不显示无依据百分比;错误不丢失筛选上下文;CSV 与旧 XLSX 行为清晰保留。 |
P5 · 引擎与全量验收 估计 3–5 人日 | 新增 tests/test_exporting.py、扩展 tests/e2e/workspace.spec.ts;编写受控重算与差异报告脚本;更新 docs/acceptance.md。 | 下节所有适用门禁有可复现记录;33 万行性能验证及财务签字完成。 |
合计粗估 10–18 人日,用于排期,不是承诺。受控 Excel 测试环境、财务确认和超预算性能优化可能增加日历时间。P1 小样本原型通过后再投入大文件任务流程;各阶段保持可单独审查和回滚。
兼容 API:旧 format=xlsx 默认仍为证据快照;新公式模式显式申请任务并返回任务 ID。服务端返回实际导出模式与版本,前端不凭文件后缀推断。
+09 / 严格测试与上线门禁
写入公式不等于公式算对。openpyxl 不执行计算;设置自动重算或 fullCalcOnLoad 也不是测试结果。必须在目标 Excel 引擎执行、保存,再同时读取公式和结果缓存验证。
| 测试层 | 必须覆盖的场景 | 通过标准 |
|---|
| 结构与公式覆盖 | 每页、命名范围、Table、单元格类型、公式依赖、说明及数据验证;空数据导出。 | 衍生区域全覆盖真实公式,基础输入无意外公式;无循环引用、损坏修复提示或外部链接。 |
| 财务黄金样例 | 两种 ROI、direct/lookup 毛利、退款、负数、0、缺成本、部分缺字段、缺期、基期0、新增/消失分组。 | 与独立 Decimal 预期一致到批准精度;人数完全相等;缺失不能变0。 |
| 客户去重 | 同人跨科室/产品/月,重复明细、自营与非自营交集、空客户字段。 | 总体不相加子组人数;升单率分子去重正确;缺客户状态与系统一致。 |
| 真实引擎重算 | Excel Windows / Mac;打开重算保存;分别读取 formula 与 data_only;修改收入、成本、原价及毛利率后再次计算。 | 受影响链路变化符合独立预期,非依赖指标不误变。扫描 #REF!、#DIV/0!、#VALUE!、#NAME?、#SPILL!;仅允许明确缺失状态对应的 #N/A。 |
| 精度极限 | 15位边界、24位小数、极大金额、正负抵消、舍入临界值、33万行累计差异。 | 精确文本不变;超出政策明确报警或拒绝;不通过放宽误差掩盖错误。 |
| 注入与越权 | 恶意标签/原始公式、工作表名特殊字符、跨组织/科室、任务 ID 猜测、下载前撤权。 | 无执行不可信公式、外链或敏感原值泄露;未授权下载拒绝并审计。 |
| 任务可靠性 | 重复提交、worker中断、磁盘不足、超时、并发、到期清理。 | 无半成品下载、孤儿文件和静默截断;失败可解释,恢复无重复发布。 |
| 前端与兼容回归 | 两模式选择、状态、错误重试、下载、移动端;现有 CSV/XLSX。 | 浏览器端到端通过;旧证据版仍保持精度与公式注入防护。新公式版测试只允许白名单公式,不删除旧安全断言。 |
| 真实附件全量 | 332,830行;2025-08/2026-08及原映射;全量与分组对账。 | 行数不缺失,金额满足政策,人数为33,147 / 38,361;缺成本 ROI 不可用,未配置自营范围不擅自补值。 |
发布清单(全部待实施验证)
- □ 口径、精度、编辑范围、支持引擎由财务及产品负责人确认。
- □ 所有指标有公式覆盖与独立数值断言,修改输入后的重算链通过。
- □ 真实 Excel 重算证据包含引擎版本、脚本或步骤、输入哈希、结果与误差报告;LibreOffice 只能证明其自身兼容性。
- □ 原附件全量导出、权限、安全和资源预算通过;大文件首开及改值重算满足商定目标。
- □ 使用说明、列字典、异常状态与原始证据齐全,无患者原始标识泄露。
- □ 灰度开关、失败回退、审计、清理、任务运行监控与运维说明就绪。
上线先小范围授权用户试用公式模式,旧证据导出保留作为回退。只有 Excel 导出门禁通过,不意味着整个财务系统所有商业上线门禁自动满足。
+10 / 需确认的业务决策
可直接采用的工程默认
- 双模式并存,公式版 XLSX 不含宏和外链。
- 精确证据 + 数值计算 + 对账,不牺牲原始精度。
- Microsoft 365 为首版公式验收目标。
- 固定范围、指定输入可改,结构变化重新导出。
- 大文件异步、授权下载、客户去标识。
上线前必须由财务确认
- 收入继续使用 N 列,还是另需 I 列实收口径?
- 舍入在每行还是最终汇总发生?使用哪种舍入规则与容差?
- 毛利率允许本地模拟吗?直接毛利是否允许修改?
- 自营产品集合、退款计人、成本归属及 ROI 口径是什么?
- 是否必须支持旧版 Excel/WPS,以及首次打开前就有结果缓存?
建议实施顺序:先确认口径与 Excel 版本 → 小样本真正公式与重算验证 → 33 万行任务化及性能测试 → 前端完整交互与财务签字。不要先把导出值改成数值、加几条 SUM,就标记需求完成。
+
+
+
diff --git a/docs/improvement-plan.html b/docs/improvement-plan.html
new file mode 100644
index 0000000..0ae7886
--- /dev/null
+++ b/docs/improvement-plan.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+TraceIQ 完善项与执行路线图
+
+
+
+
+
+本轮基线:后端 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 check --deploy 零告警。Docker daemon 未运行,本机无 LibreOffice。Microsoft Excel、332,830 行公式工作簿、真实 AI/合规及商业部署仍未验收。
+P0 · 先确保验收证据可信
+P0.1 · 已完成 / 已复核
维护真实的源码与验收基线
旧文档曾错误声称 tests/test_finance.py 为空并把公式导出标记为未实现;当前源码有 40 项后端测试,公式工作簿、说明页、对话新增列、追问与下载均已实现。本轮路线图按当前代码、测试结果和外部门禁重建。
+P0.2 · 已修复并通过专项回归
隐藏导出任务的内部异常
状态接口不再把 worker 异常文本发给用户,只返回通用失败提示;前端显示任务编号,数据库保留内部诊断。失败任务脱敏、临时文件删除及状态访问专项 8 passed。
+P0.3 · 本机生产配置检查通过
验证 production 部署安全配置
production 模式、有效测试密钥、受信 HTTPS 代理下 manage.py check --deploy 零告警。真实域名、TLS 终止及代理头行为仍需部署环境验收。
+P0.4 · 外部目标引擎
Microsoft Excel 公式重算与兼容验收
目标 Excel Windows/Mac 打开、重算、保存并核对公式及缓存;覆盖缺期、零分母、退款/负值、ROI、毛利来源和输入修改依赖。LibreOffice/openpyxl 不能替代目标 Excel 证据。
+P0.5 · 真实数据与资源基准
332,830 行完整公式工作簿
公式导出当前限制 100,000 行/200 组。用原附件和可靠 PostgreSQL 验证行数、金额、人数、耗时、峰值内存、文件大小及 Excel 首开/重算;未完成前保持显式拒绝,不静默截断或放宽上限。
+P0.6 · 外部业务/安全责任人
财务口径、真实 AI 与商业发布批准
签署实际收入与实收、退款、成本、毛利、ROI、自营集合及舍入规则;审批外部模型数据处理/出境并演练故障;完成生产密钥、TLS、备份恢复、SLA、渗透测试和商业许可证核验。审批前 AI 保持关闭。
+
+P1 · 代码层可靠性与可用性
+P1.1 · Docker daemon 未运行
镜像与 Compose 运行验收
Docker CLI 可用但 daemon 不可连接,本机暂不能构建或启动。daemon 可用后验证镜像、健康检查、worker、清理服务与重启恢复;远端 CI 需仓库平台实际触发。
+P1.2 · 产品范围待确认
自定义指标定义保存与复用
定义当前只用于当次工作簿,不持久化到主分析页或跨会话定义库。需明确个人/组织共享、版本审批、删除和审计规则,再开发持久化。
+P1.3 · 需产品确认后开发
大数据与高基数交互
当前汇总按请求返回分组、证据使用分页;AG Grid SSRM/Pivot/Tree Data 等计划功能尚未实现。先以目标数据规模和典型交互决定是否投入服务端行模型,避免未经验证引入商业授权与运维负担。
+
+P2 · 范围扩展
+P2.1 · 财务定义确认后
扩展公式语言和缺失策略
当前白名单仅支持现有指标/常数和加减乘除,最大 8 层;不接受任意 Excel 文本、字段引用、跨明细新聚合、按零替代或跳过缺失。每项扩展需独立财务黄金样例、注入测试和公式重算验证。
+P2.2 · 目标明确后
分析及工作簿操作增强
可评估历史分位异常、告警、证据期间切换、明细表结构变更后的公式扩展、模板版本和工作簿内定义管理;需先给出场景和验收指标。
+
+本轮执行结果
| 项目 | 结果 | 剩余门禁 |
|---|
| 源码与路线图基线 | 已完成;纠正文档过时状态 | 以当前代码/测试为准 |
| 导出错误脱敏 | 8 项专项后端测试通过 | 内部诊断仅保留服务端 |
| Production deploy checks | 零告警 | 真实域名、TLS/代理验收 |
| 全量本机回归 | 40 passed / 86.72%;Playwright 3 passed、1 skipped | 原附件场景需配置隔离数据集 |
| 商业发布门禁 | 尚未通过 | Excel、真实规模、业务/AI审批、容器和灾备 |
+发布判定原则
本地质量门禁全部通过,只证明自动化覆盖范围内的代码状态。商用发布还需要 Microsoft Excel 结果、真实规模资源记录、财务口径签字、AI 数据处理批准和目标基础设施安全/灾备验证。任何一项未满足时,状态保持“未完成发布验收”。
+
+
+
diff --git a/docs/improvement-roadmap-2026-09-24-history.html b/docs/improvement-roadmap-2026-09-24-history.html
new file mode 100644
index 0000000..56e75e6
--- /dev/null
+++ b/docs/improvement-roadmap-2026-09-24-history.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+财务 Agent 改进路线图
+
+
+
+
+
+当前结论:对话新增指标、缺项追问、白名单公式编译与公式工作簿已实现;本轮专项后端 6 项、Ruff、前端类型检查及生产构建通过。生产发布仍被空的历史测试源码、公式引擎重算、真实附件规模和财务/AI 合规审批阻断。
+P0 · 发布阻断
+P0.1 · 阻断中
恢复完整源码测试基线
tests/test_finance.py 当前为 0 字节,导致既有财务、权限、导入、分析与公式导出回归测试缺失。先从可靠备份恢复,再运行 pytest、Ruff、Django、迁移、生产构建及 Playwright。旧 .pyc 的 69 passed、1 failed、1 skipped 使用旧断言,不是当前源码验收证据。
+P0.2 · LibreOffice 自定义公式与缓存已验证;Excel 待验
公式/缓存一致性验证
人数逻辑使用 CustomerRoster + COUNTIFS。本轮从当前源码生成基础及新增列工作簿,交由 LibreOffice 重算后读取;含自定义列的文件保留 136 个公式单元格,自定义“收入成本差”公式缓存及汇总引用缓存均为 2,000。仍需补足缺失期、零分母及负值等边界对账,并在 Microsoft Excel 桌面版复验。
+P0.3 · 待目标环境
Microsoft Excel Windows/Mac 验收
在目标桌面 Excel 打开、强制重算、保存,读取公式和结果缓存;覆盖缺期、零人数、退款/负数、两种 ROI、毛利来源及编辑金额后的依赖变化。
+P0.4 · 范围阻断
332,830 行附件全量导出与资源预算
当前上限 100,000 行/200 组。用原映射和可靠 PostgreSQL 验证完整导出、行数/金额/人数对账、耗时、峰值内存、文件体积及 Excel 首开/重算;未经实测不放宽限制,不静默截断。
+P0.5 · 外部决策
财务口径与 AI 合规
签署实际收入/实收、退款、成本归属、毛利、ROI、自营范围和舍入口径;完成外部模型联调、隐私/数据出境审批及故障演练。批准前保持外部 AI 关闭。
+
+P1 · 可靠性与运行质量
+P1.1 · 已实现;专项 3 项通过
过期工作簿与孤儿临时文件清理
新增 cleanup_formula_exports 管理命令和 Compose 常驻清理服务。仅清理过期成品及超过 24 小时的临时文件,限制导出根目录,跳过符号链接;清理异常保留重试线索。已覆盖过期文件、临时文件年龄和目录外路径测试。
+P1.2 · 功能实现;单测及新增列 E2E 通过
对话定义新增汇总列并导出公式
用户可描述列名、含义、计算、范围及缺失/零分母策略;缺项时模型必须追问,确认后服务端只接受白名单表达式树并编译为 Excel 公式。工作簿包含汇总及自定义指标页、使用说明和对账页。专项单测覆盖追问、表达式拒绝与公式输出;本轮 Playwright 已验证登录、字段导入、缺项追问、补充确认、工作簿生成和下载。
+P1.3 · 待目标环境
部署、安全与灾备验收
完成镜像构建、TLS、数据库认证、备份恢复和 RPO/RTO 演练、并发/SLA 测试、渗透测试、密钥轮换、审计告警及商用依赖许可证核查。
+
+P2 · 产品能力扩展
+P2.1
已签口径下补充分析能力
多收入字段与消费比率、生效期毛利率、退款切换、结构占比、历史异常阈值和告警投递。
+P2.2
自定义指标定义复用
定义保存/版本与差异审阅;新运算符、聚合或缺失策略须通过白名单、独立财务样例和安全审查。
+P2.3
工作簿及跨平台体验
证据期间切换、平板与无障碍优化、可读的公式错误状态、更多电子表格引擎兼容验证。
+
+完成门禁
| 门禁 | 证据 | 状态 |
+| 源码测试 | 恢复原测试文件并完整执行 | 阻断:源码缺失 |
+| 静态与构建 | Ruff、Django、迁移、前端构建 | 本轮独立复跑通过;源码恢复后仍需联跑 |
+| 网页工作流 | 新增指标、桌面、手机 Playwright | 3 项通过,1 项原附件全量场景按配置跳过 |
+| 公式重算 | 当前版本 LibreOffice 与 Microsoft Excel | LibreOffice 自定义公式重算/缓存通过;边界样例与 Excel 待验 |
+| 真实规模 | 原附件全量导出与资源报告 | 待验;当前上限 100,000 行 |
+| 商业发布 | 财务签字、AI 合规、安全与灾备 | 待外部审批/目标环境 |
+
+
+
+
diff --git a/docs/improvement-roadmap.html b/docs/improvement-roadmap.html
new file mode 100644
index 0000000..7ba55bf
--- /dev/null
+++ b/docs/improvement-roadmap.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+TraceIQ 财务 Agent 改进路线图
+
+
+
+
+
+当前结论:公式导出审查已修复文本条件、未分类对账、公式注入、任务路径、文件权限与失败恢复问题,并改为流式写入;后端 61 passed、finance 覆盖率 87.66%,LibreOfficeDev 公式重算共 4 项通过。100,000 行产品上限实测峰值 RSS 约 136.4 MB。Microsoft Excel 实机、原附件 332,830 行范围方案、真实 AI/财务审批和目标环境验收仍未完成,不能认定具备生产商用资格。
+P0 · 正确性与发布阻断
+P0.1 · 本机 LibreOffice 回归通过
公式重算建立可重复黄金测试
当前共 4 项 LibreOfficeDev 重算测试:test_formula_recalculation.py 3 项、test_formula_recalculation_gross.py 1 项,覆盖自定义汇总及变化率、明细收入编辑联动、零成本 ROI、缺失基期及 direct/lookup 毛利来源。它们只证明这些具体样例在 LibreOffice 中的结果,不代表 Microsoft Excel 兼容或财务签字。
+P0.2 · 目标环境门禁
Microsoft Excel Windows / Mac 实机验收
本机曾以 Microsoft Excel 打开测试工作簿,公式栏确认汇总公式包含 COUNTIFS / SUMIFS;但 Excel 显示只读(View Only / 需激活 Microsoft 365),无法保存重算副本,且检查到的样例分组为 Test、与预期不匹配。故仅有“公式可见”的证据,Excel 重算结果、缓存和业务样例均未验收。后续须在可编辑的目标 Windows/Mac Excel 中用受控正确样例强制重算、保存,并核验函数兼容、数据表引用、错误单元格和输入修改依赖链。
+P0.3 · 100,000 行产品上限已优化并实测
流式公式工作簿与原附件范围决策
公式版本 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 行不在当前产品范围,下一步必须选择可审计分卷或明确提高上限后再做原附件流式资源、打开和重算验收。
+P0.4 · 财务审批
冻结收入、退款、毛利及 ROI 口径
由财务负责人签署 N 列实际收入与 I 列实收、原价、退款、成本归属、毛利率、自营产品集、升单定义与舍入精度。原附件成本全缺,ROI 应维持不可用;不得以技术测试替代业务签字。
+P0.5 · 隐私 / 模型外部审批
外部 AI 真实联调与数据处理审批
在批准前保持 AI 关闭。审批后验证模型版本、超时、限流、拒答、审计脱敏、数据处理与出境依据;模型只提出列定义,服务端继续严格校验白名单表达式并要求用户确认。
+
+P1 · 商用运维与可恢复性
+P1.1 · 本地待执行 / 环境受限
Docker 镜像与生产 Compose 验收
本轮完整生产变量下 Django check --deploy 无告警,依赖审计为零已知漏洞;Docker daemon 当前不可连接,镜像构建尚未验证。daemon 恢复后构建镜像、运行迁移和健康检查,确认非 root、只读根文件系统、卷权限及 worker。
+P1.2 · 目标环境门禁
备份恢复、并发和安全运营
验收 TLS、密钥轮换、最小权限、加密备份及恢复 RPO/RTO、队列积压/失败告警、访问日志脱敏、容量阈值、并发 SLA 和独立安全审查;AG Grid Enterprise 使用前确认商业许可。
+P1.3 · 部分已实现;范围受限
对话定义新增汇总列并导出公式
用户可描述列名、含义、计算、适用范围及缺失处理;信息不全时追问并结合完整对话复核。公式只使用数据集当前可计算的既有指标与白名单运算,服务端再次校验数据映射;确认后写入公式工作簿并带说明。任意 Excel 函数、跨明细新聚合、定义保存复用及导出后自动扩行未实现。
+
+P2 · 扩展能力
+P2.1
签字口径下扩展分析
多收入字段与消费比率、生效期毛利率、退款策略、结构占比、历史异常阈值和告警投递。
+P2.2
跨平台与辅助功能
补齐平板、键盘/屏幕阅读器、证据期间切换和目标表格软件兼容矩阵;Web 可复用不等同于 Excel 文件跨引擎兼容。
+
+本轮证据
| 项目 | 结果 | 边界 |
+| 后端及覆盖率 | 61 passed;finance 87.66% | SQLite 测试;包含流式工作簿、公式语义、注入防护、任务路径/权限/恢复及 LibreOffice 重算;未替代 PostgreSQL CI |
+| LibreOffice 公式重算 | 共 4 项通过(test_formula_recalculation.py 3 项、test_formula_recalculation_gross.py 1 项),direct/lookup 毛利来源测试包含在这 4 项内 | LibreOfficeDev;Microsoft Excel 仅确认公式可见,未完成可保存重算验收 |
+| 流式容量基准 | 100,000 行:25.96 秒、16.22 MB、峰值 RSS 136.4 MB;20,000 行:5.26 秒、3.27 MB、峰值 RSS 77.9 MB | 隔离 SQLite 合成数据,本机单次测量;生产上限保持 100,000 行,不能替代目标环境并发 SLA |
+| 浏览器回归 | 3 passed,1 skipped | 跳过需显式指定附件的原附件 E2E |
+| 构建 / 静态检查 | Ruff、Prettier、TypeScript/Vite、Django check、迁移检查通过 | 依赖审计报告 0 个已知漏洞;不等于渗透测试 |
+| 生产配置检查 | 完整临时生产配置下 check --deploy 无告警 | 不代表目标基础设施上线验收 |
+| Compose / Docker | 未通过 | 仓库无真实 .env;Docker daemon 未运行,未构建镜像 |
+| Excel / 原附件公式 | Excel 打开样本并确认公式存在;PostgreSQL 对账完成;旧非流式版本曾生成 332,830 行 | Excel 只读,无法保存重算副本;所见分组与预期不匹配。原附件超过 100,000 行产品上限,公式版本 3 尚未对 332,830 行做越界放宽测试 |
+
+执行顺序与完成规则
顺序:维护 P0.1 黄金重算 → P0.2 Microsoft Excel → P0.3 资源优化 → P0.4 / P0.5 外部审批 → P1 → P2。本轮补齐了可重复的金额编辑、零成本与缺失基期重算测试,并复核新增列映射保护。LibreOffice 不替代 Excel,财务/AI 审批和目标基础设施测试需对应责任方及环境。
测试必须覆盖代码实际行为;每项门禁记录日期、环境、版本、样例、结果和限制。不能用 mock、静态公式存在或 build 通过声称生产已验收。
+
+
+
diff --git a/docs/remaining-issues-2026-09-26.html b/docs/remaining-issues-2026-09-26.html
new file mode 100644
index 0000000..4910419
--- /dev/null
+++ b/docs/remaining-issues-2026-09-26.html
@@ -0,0 +1,77 @@
+
+
+
+
+
+财务 Agent:剩余问题与修复清单 · 2026-09-26
+
+
+
+
+
+
+一、结论与阅读方式
+目前不能认定达到生产环境商用标准。公式导出、说明页、对话追问、新增公式列、个人定义保存复用与证据分页已经实现;仍有需要修复的安全/交互边界、未完成的产品能力,以及必须在目标 Excel、数据库、基础设施和业务审批环境关闭的门禁。
+本次交付仅新增本报告,不修改业务代码、不执行新的数据库迁移。以下“已完成”包括此前连续开发完成的改动;“静态确认”指源码可直接看出的行为,尚未新增专项复现测试,不能冒充测试复现结果。
+- P0:影响敏感文件安全、核心财务正确性或生产准入,正式上线前必须关闭。
- P1:影响日常操作可靠性、可解释性、协作和可维护性,应在受控试用前或紧接其后完成。
- P2:扩展能力,按合同范围和真实业务优先级开发,不因存在依赖或设计稿就视为已实现。
+
+二、当前已经完成什么
+| 能力 | 已完成状态 | 不能据此推断的内容 |
+| 公式工作簿 | 版本 3,流式生成计算明细、汇总、使用说明、参数口径、人数去重、原始证据、对账;有自定义列时增加自定义指标页。 | 不是任意 Excel 模板生成器;不是全部输入/结构变化都能正确重算。 |
+| 对话新增列 | 描述不全时追问,补充后确认;服务端校验表达式树及数据映射可用性。支持既有指标、常数、加减乘除。 | 真实外部模型未完成联调;自然语言适用范围不等于执行任意筛选、聚合规则。 |
+| 个人定义库 | 按用户与数据集隔离,保存、同名更新、列出、载入、删除;保存和导出时校验;迁移文件 0004 已存在,隔离测试库已执行。 | 不是团队共享定义库;没有草稿续聊、版本审批、并发编辑冲突保护。非测试环境是否迁移仍需核对。 |
+| 证据与文件导出 | 本期/基期切换、每页 50 条及翻页;CSV/XLSX 证据以临时文件和 FileResponse 分块发送;金额文本和公式注入防护保留。 | 前端仍用 Blob 接收完整下载文件;临时磁盘/tmpfs、并发容量尚无生产 SLA。 |
+| 导出可靠性 | 任务专属路径、0600 文件与 0700 目录、失败状态恢复、随机临时文件、授权范围检查、过期清理。 | 并非所有符号链接异常路径都安全,见 F01;也未完成磁盘故障及生产崩溃恢复演练。 |
+
+最近完整测试记录
+- 后端:65 passed,95.12 秒;finance 覆盖率 87.63%,85% 门槛通过。SQLite 测试,包含定义库及导出回归。
- 真实公式重算:4 项 LibreOffice 测试通过。覆盖自定义汇总、变化率、金额编辑联动、零成本、缺基期和 direct/lookup 毛利。不能替代 Microsoft Excel 实机。
- 浏览器:3 passed,1 skipped,24.7 秒。新隔离数据库,覆盖追问、保存定义、关闭重开、复用后生成,另含桌面、手机、证据翻页及下载。AI 响应采用 mock;分页第 51 条交互使用 mock 响应,后端分页另有 API 测试。
- 跳过项:未设置
REFERENCE_DATASET_NAME 的原附件全量 UI 用例,不属于通过项。 - 此前已通过 Ruff lint/format、TypeScript/Vite、Django check、迁移一致性检查;2026-09-24 的 npm 生产依赖审计与 pip-audit 为零已知漏洞。这些不是本报告生成时重新运行的结果。
- 历史合成容量基准:100,000 行生成 25.96 秒,产物约 16.22 MB,峰值 RSS 约 136.4 MB;单机单次、非并发、非目标 Excel 重算性能。
+现有 acceptance.md / improvement-roadmap.html 仍存在 61 项测试、“没有定义库”等旧表述;历史 Excel 计划也有相互矛盾的状态提示。当前状态以本报告与最近完成的实测为准,后续应统一维护单一验收入口。
+
+三、需要继续修复的代码问题
+F01 · P0 · 静态确认 · 文件安全
失败清理可能沿符号链接父目录删除非任务文件
位置:finance/exporting.py → process_formula_export()。先设置 target,再检查 target.parent 是否为符号链接;拒绝后进入异常分支,仅检查 path.is_symlink(),未检查父目录。父目录为链接而最终文件本身为普通文件时,unlink 会跟随父目录删除链接目标下同名文件。
影响:需要本地存储目录被操纵这一前提,不能描述为普通远程用户已可利用;但违背“异常清理只删除自己创建的文件”的边界。
推荐修复:路径完整验证前不要将目标登记为可清理文件;只清理本次实际创建、且位于受控目录中的文件,必要时用目录描述符及不跟随链接的文件操作。不要用失败清理代替旧孤儿文件回收。
验收:父目录链接到受保护目录,预置同名文件;执行必须失败且受保护文件字节不变,任务为 failed;覆盖根目录链接、目标链接、写盘失败与正常重试。
+F02 · P1 · 静态确认 · 故障清理
清理失败被忽略,敏感孤儿文件缺少回收路径
位置:finance/exporting.py 与 cleanup_formula_exports.py。异常清理吞掉 OSError 后清空 job.path;清理器主要处理已成功且过期的文件及旧 .tmp,失败任务残留 .xlsx 可能不再被追踪。
修复:记录不含敏感数据的清理失败日志及任务编号,为安全范围内的孤儿 .xlsx 制定保留期与回收规则;不要删除仍处于活动任务的文件。
验收:注入 unlink 权限错误,任务失败状态仍保存;可观察到告警,恢复权限后回收成功,其他任务文件不受影响。
+F03 · P1 · 静态确认 · 证据弹窗
重复点击当前期间可能让已加载列表停在“加载中”
位置:frontend/src/dialogs.tsx → EvidenceDialog.selectSide()。第 0 页点击当前已选期间会清空 result,但 side/page 没变化,依赖这些值的 effect 不会重新请求。
修复:同期间同页直接返回,或明确触发刷新;补加载、空集、失败重试状态。
验收:当前期间重复点击、快速本期/基期切换、第二页切期间、空列表和请求失败均不显示过期数据或永久加载。
+F04 · P1 · 静态确认 · 定义复用后的对话
载入已保存定义后追问,没有把原定义带给模型
位置:FormulaExportDialog.loadDefinition()/send()。载入定义会清空 messages,随后只发送新增描述;“把刚才公式改成乘以 100”之类指代不能可靠关联已载入表达式。
修复:在可见、经同意的对话上下文中明确附带原定义,或建立服务端定义引用;仍要求重新确认,不让旧定义静默覆盖新解释。选择空选项也应同步清理当前定义,避免选择状态与预览不一致。
验收:保存 A、重开载入 A、发送依赖 A 的修改,检查模型请求实际包含 A;未发送补充时禁止生成,修改后生成的公式符合新定义。
+F05 · P1 · 静态确认 · 定义库并发与审计
50 条上限不是原子约束,同名更新没有冲突检测
位置:finance/formula_views.py → formula_definitions()。先 count 再 update_or_create,并发不同名字可能突破上限;同名更新采用最后写入覆盖,未携带 revision。保存/删除与审计也不在同一外层事务内。
修复:事务内锁定稳定的用户/数据集所属行,原子检查配额;引入 revision 和 409 冲突响应;变更和审计一同提交,保存增加合理限流。前端明确同名更新行为。
验收:PostgreSQL 并发保存不能产生第 51 条;两个标签页更新不静默丢失;模拟审计写入失败后定义变更回滚。
+F06 · P1 · 静态确认 · 工作簿可解释性
“产品毛利率可编辑”说明与实际公式引用不一致
位置:finance/exporting.py → 参数与口径 / GrossCalc。参数页毛利率写成字典文本并标为可编辑,实际明细公式引用每行 MarginRate,修改参数页文本不会联动。说明页还提到“本页分组维度”,但没有完整的维度键与显示名称字典。
推荐:近期明确参数页为只读快照,写清需要修改计算明细的哪类输入;如必须按产品统一模拟,则实现真实产品毛利率参数表与 lookup 依赖,并验证 direct 毛利不受影响。
验收:每个标为可编辑的输入都对应真实依赖;收入、成本、原价、直接毛利、lookup 毛利率分别改值重算,说明页列出字段、单位、来源、缺失含义和固定结构边界。
+F07 · P1 · 静态确认 · 财务校验范围
对账采用统一 0.01 阈值,自定义公式没有独立服务器参考
位置:finance/exporting.py → 对账检查。人数、金额、比率共享差异阈值;对小数形式的比率,0.01 是一个百分点,未必适用业务。自定义列标记“需业务验收”,不是自动对账通过。
修复:财务批准后按指标配置比较规则:人数严格相等、金额按币种和舍入精度、比率独立阈值;用 Decimal 白名单表达式解释器计算自定义指标参考,不能执行任意代码。保持原始金额文本不变。
验收:阈值边界、大数、抵消、极小值、除零、缺失和多层表达式独立黄金样例通过;不得为消除报错而放宽容差。
+F08 · P1 · 验证缺口 · 权限撤销与任务生命周期
执行/下载阶段需要覆盖角色撤销、超时与崩溃恢复
当前有组织、任务所有者、科室范围检查,但 worker 不重新要求 admin/analyst 角色;下载接口也未声明同创建接口的角色白名单。是否允许被降为 viewer 的账号下载旧导出,需要明确政策,不能默认授权语义已完整。
推荐:敏感公式工作簿在申请、执行、下载时统一核验角色、组织、账户有效性和科室;补任务超时、重复请求、过期下载、worker 发布后崩溃的测试及运维告警。
验收:排队后撤权、成功后撤权均按签署政策处理;半成品不能下载;重启不重复发布或无限停留 processing。生产 PostgreSQL 锁语义单独验证。
+
+四、尚未完成的功能与产品边界
+| 优先级 / 功能 | 当前缺口 | 建议与完成标准 |
+| P1 · 新列出现在网页汇总表 | 当前新增指标主要进入导出的自定义指标页,并未成为主分析表持久化可计算列。 | 若原需求包含网页表格,需新增受控服务器计算、列选择与展示;网页结果、导出公式、Decimal 参考使用同一语义,缺失不转零。 |
+| P1 · 多列编辑与定义生命周期 | API 最多 10 列,但对话弹窗生成时仅提交当前单列;未完成草稿续聊、版本历史、团队共享和审批。 | 先做当前数据集的多列列表与显式确认,后做草稿。个人定义保存复用已完成,不应重复开发。团队共享另设权限和版本策略。 |
+| P1 · 适用范围与表达式能力 | scope 为说明文本,实际按当前分组、本期/基期计算;不能表达任意条件、跨明细新聚合、任意 Excel 函数、维度列或其他缺失策略。 | 模型明确拒绝不支持范围,不用自然语言说明冒充已执行规则。扩展时结构化范围并加入服务端白名单及黄金测试。 |
+| P1 · Excel 编辑防误操作 | 人数辅助表、期间、身份、维度为固定快照;追加/删除明细不能保证全部汇总与去重同步。输入验证、公式保护、完整字段字典未全部实现。 | 首版推荐固定结构、明确可编辑金额列并防误操作;结构改变需重新导出。表保护不是加密或真正权限控制。 |
+| P1 · 原附件 332,830 行导出 | 当前产品仍限制 100,000 行 / 200 组;不能一次完整导出原附件。 | 推荐可审计分卷并保留全局汇总/去重口径,不能把分卷人数简单相加。备选是正式提高上限后验收内存、并发、Excel 首开和改值重算,不能仅改常量。 |
+| P1 · 结果缓存与大文件下载 | openpyxl 不执行公式;未重算文件的预览/缓存读取可能为空。浏览器 Blob 下载完整驻留内存;生产 /tmp 为 tmpfs,临时文件仍计入容器内存。 | 明确必须由目标引擎重算;如需要服务器生成已缓存副本,独立评估计算引擎。大文件直接附件下载及临时存储配额需实测。 |
+| P1 · 高基数与可访问性 | 聚合分组全量返回;缺高基数结果窗口、平板专项、键盘/屏幕阅读器专项。浏览器自动测试不等于人工可访问性验收。 | 先测真实高基数与并发,再决定分页或 SSRM;补 focus、Esc 关闭、错误提示、表头关联与触控专项。 |
+| P2 · 扩展业务能力 | 多收入字段口径、消费比率、生效期毛利率、退款策略、结构占比、历史阈值、告警渠道和团队调查分派未完整实现。 | 先冻结业务定义,再逐项开发,不擅自以现有收入字段替代不同含义。 |
+| P2 · 跨平台与高级网格 | 已有响应式 Web,没有原生客户端;AG Grid SSRM/Pivot/Tree Data/Master-Detail 未完整接入。 | Web 是复用基础,不等于原生交付。按真实需求和商业授权选择 PWA/桌面封装或高级网格。 |
+
+
+五、必须外部完成的发布门禁
+| 门禁 | 现状 / 责任方 | 关闭证据 |
+| P0 · Microsoft Excel 实机 | 历史只读 Excel 仅确认公式可见,无法保存重算;样例分组也与预期不符。财务、QA、目标终端负责人。 | 在可编辑 Windows/Mac Excel 用受控样例打开、强制重算、保存;读取公式和结果缓存,改值后依赖链正确,无非预期错误与修复提示。 |
+| P0 · 财务签字与精度政策 | 原附件收入/实收定义有歧义,成本全缺;财务负责人。 | 签署收入、退款、原价、成本归属、毛利、ROI、自营、升单和容差。缺成本 ROI 保持不可用;不以技术测试替代业务认可。 |
+| P0 · AI 真实联调及隐私 | 外部响应主要以 mock 验证;对话文本与筛选标签可能包含敏感信息。合规、数据负责人、AI 服务负责人。 | 审批数据处理/出境、模型版本、留存策略;真实验证超时、拒答、限流、非法输出、复杂 schema 接受度。批准前保持 AI 关闭。 |
+| P0 · 目标基础设施 | 历史 Docker daemon 不可用、真实 .env 不存在;配置已交付,不等于构建和运行已验收。运维、安全。 | 目标镜像构建、迁移 0004、非 root/卷权限、TLS、代理、数据库认证、队列、备份恢复、日志脱敏、安全扫描与许可证通过。 |
+| P1 · 当前版本 PostgreSQL 与 SLA | 最近 65 项全量是 SQLite;历史 PostgreSQL 对账/测试不能替代本版并发和锁语义。 | 在独立 PostgreSQL 执行当前测试、并发定义保存、worker 恢复、导出压力;记录 P95、资源预算、备份恢复 RPO/RTO。 |
+| P1 · 安全运营和许可 | 没有完整 SSO/MFA、自助找回、数据库 RLS、不可变审计和外部告警;并非所有产品必须全选,但必须作安全决策。 | 风险评估明确必需项;验证加密存储、密钥轮换、权限回收和安全审查;启用 Enterprise 前取得对应商业许可。 |
+
+
+六、推荐修复顺序
+- 安全收尾:先 F01、F02、F08,明确文件清理边界与撤权政策,添加失败注入和越权回归。
- 日常交互:修 F03、F04;补定义库删除、空选项、失败重试、快速切换和重复点击 E2E。
- 持久化可靠性:修 F05,做 PostgreSQL 事务、配额和冲突测试;迁移验证应覆盖既有数据库。
- 工作簿真实性:修 F06,完善输入/参数字典与保护;F07 的容差需先由财务确认,再增加独立数值对账。
- 补齐原始用户目标:确认网页主汇总表也需要新列后,实现网页计算、多列选择与导出一致性;完成草稿续聊。
- 发布前:解决原附件范围,执行 Excel、真实 AI、财务、安全、基础设施和并发验收。任何一项未关闭都保留明确限制,不标记“全功能商用完成”。
+没有给出未经测量的工期承诺。前三项主要由工程承担;财务口径、数据处理审批和目标环境依赖必须明确责任人,不能通过延长本地编码替代。
+
+七、下一轮验收与交付清单
+- 为每个修复加入“修复前失败、修复后通过”的专项断言;全量后端覆盖率不低于 85%,同时保留财务黄金样例。
- 安全回归包含父目录链接、文件权限、清理失败、撤权后执行/下载、同名定义并发更新与配额边界。
- LibreOffice 和目标 Excel 分别记录版本、输入样例、公式、缓存结果与修改后差异,不合并为笼统的“Excel 测试通过”。
- 运行桌面/手机 E2E;指定隔离原附件数据集后执行目前跳过的用例。不得用真实生产账号或生产数据环境运行写入型测试。
- Ruff、格式、TypeScript、构建、Django、迁移一致性、依赖审计通过;Docker/PostgreSQL 等环境受限项明确记录未执行原因。
- 交付更新后的测试记录、迁移说明、部署/回滚步骤、问题状态及新 HTML。保留历史基准,但标题应明确“历史”,消除多个文档的冲突状态。
+源码与相关材料
+公式生成与任务发布 · 定义库与导出 API · 证据导出与分页 · 网页对话与证据交互 · 测试目录
+既有验收记录(部分状态待同步) · 历史路线图 · 最初 Excel 实施方案(历史设计,不代表现状)
+
+
+
+
diff --git a/finance/__init__.py b/finance/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/finance/analysis.py b/finance/analysis.py
new file mode 100644
index 0000000..70f5bce
--- /dev/null
+++ b/finance/analysis.py
@@ -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,
+ }
diff --git a/finance/assistant.py b/finance/assistant.py
new file mode 100644
index 0000000..fdeb134
--- /dev/null
+++ b/finance/assistant.py
@@ -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]
+ ],
+ }
+ )
diff --git a/finance/exporting.py b/finance/exporting.py
new file mode 100644
index 0000000..28e5e18
--- /dev/null
+++ b/finance/exporting.py
@@ -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
+ )
diff --git a/finance/formula_columns.py b/finance/formula_columns.py
new file mode 100644
index 0000000..9981ceb
--- /dev/null
+++ b/finance/formula_columns.py
@@ -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)})"
diff --git a/finance/formula_views.py b/finance/formula_views.py
new file mode 100644
index 0000000..898b741
--- /dev/null
+++ b/finance/formula_views.py
@@ -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")
diff --git a/finance/importing.py b/finance/importing.py
new file mode 100644
index 0000000..63c47c7
--- /dev/null
+++ b/finance/importing.py
@@ -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()
diff --git a/finance/management/__init__.py b/finance/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/finance/management/commands/__init__.py b/finance/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/finance/management/commands/bootstrap.py b/finance/management/commands/bootstrap.py
new file mode 100644
index 0000000..b2e6fda
--- /dev/null
+++ b/finance/management/commands/bootstrap.py
@@ -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")
diff --git a/finance/management/commands/cleanup_formula_exports.py b/finance/management/commands/cleanup_formula_exports.py
new file mode 100644
index 0000000..692e1aa
--- /dev/null
+++ b/finance/management/commands/cleanup_formula_exports.py
@@ -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
diff --git a/finance/management/commands/import_file.py b/finance/management/commands/import_file.py
new file mode 100644
index 0000000..e08a7ec
--- /dev/null
+++ b/finance/management/commands/import_file.py
@@ -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")
diff --git a/finance/management/commands/import_worker.py b/finance/management/commands/import_worker.py
new file mode 100644
index 0000000..469b76b
--- /dev/null
+++ b/finance/management/commands/import_worker.py
@@ -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
diff --git a/finance/middleware.py b/finance/middleware.py
new file mode 100644
index 0000000..5b7e8fe
--- /dev/null
+++ b/finance/middleware.py
@@ -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
diff --git a/finance/migrations/0001_initial.py b/finance/migrations/0001_initial.py
new file mode 100644
index 0000000..d2b2bcb
--- /dev/null
+++ b/finance/migrations/0001_initial.py
@@ -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"),
+ ),
+ ]
diff --git a/finance/migrations/0002_alter_fact_cost_alter_fact_discount_alter_fact_gross_and_more.py b/finance/migrations/0002_alter_fact_cost_alter_fact_discount_alter_fact_gross_and_more.py
new file mode 100644
index 0000000..7dc75c3
--- /dev/null
+++ b/finance/migrations/0002_alter_fact_cost_alter_fact_discount_alter_fact_gross_and_more.py
@@ -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),
+ ),
+ ]
diff --git a/finance/migrations/0003_formulaexport.py b/finance/migrations/0003_formulaexport.py
new file mode 100644
index 0000000..95329bb
--- /dev/null
+++ b/finance/migrations/0003_formulaexport.py
@@ -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"),
+ ),
+ ]
diff --git a/finance/migrations/0004_formuladefinition.py b/finance/migrations/0004_formuladefinition.py
new file mode 100644
index 0000000..45f44c8
--- /dev/null
+++ b/finance/migrations/0004_formuladefinition.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/finance/migrations/__init__.py b/finance/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/finance/models.py b/finance/models.py
new file mode 100644
index 0000000..1de5920
--- /dev/null
+++ b/finance/models.py
@@ -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"
+ )
+ ]
diff --git a/finance/views.py b/finance/views.py
new file mode 100644
index 0000000..4f78477
--- /dev/null
+++ b/finance/views.py
@@ -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")
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..9e1037d
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ TraceIQ | 财务分析工作台
+
+
+
+
+
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..cd41397
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,1552 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { AgGridReact } from "ag-grid-react";
+import {
+ themeQuartz,
+ type ColDef,
+ type ICellRendererParams,
+} from "ag-grid-community";
+import {
+ Activity,
+ Calculator,
+ ArrowDownRight,
+ ArrowUpRight,
+ Bell,
+ BookOpen,
+ ChevronRight,
+ Database,
+ Download,
+ Eye,
+ FileSpreadsheet,
+ LayoutDashboard,
+ LogOut,
+ Menu,
+ MessageSquare,
+ RefreshCw,
+ Search,
+ Settings,
+ ShieldCheck,
+ Upload,
+ Users,
+ X,
+} from "lucide-react";
+import { api, download, setCsrf } from "./api";
+import { Chart } from "./Chart";
+import {
+ AdminDialog,
+ FormulaExportDialog,
+ Copilot,
+ EvidenceDialog,
+ ImportDialog,
+ Modal,
+ SettingsDialog,
+} from "./dialogs";
+import {
+ format,
+ labels,
+ type Analysis,
+ type Context,
+ type Dataset,
+ type Metric,
+ type Preferences,
+ type Row,
+ type Session,
+} from "./types";
+
+const gridTheme = themeQuartz.withParams({
+ accentColor: "#167366",
+ backgroundColor: "#ffffff",
+ foregroundColor: "#24332f",
+ borderColor: "#e0e7e4",
+ headerBackgroundColor: "#f3f6f5",
+ fontFamily: "system-ui, sans-serif",
+ fontSize: 13,
+ rowHeight: 49,
+ headerHeight: 43,
+ borderRadius: 0,
+});
+type Screen = "overview" | "anomalies" | "investigate" | "data";
+export function App() {
+ const [session, setSession] = useState(),
+ [datasets, setDatasets] = useState([]),
+ [ds, setDs] = useState(),
+ [ctx, setCtx] = useState(),
+ [result, setResult] = useState(),
+ [screen, setScreen] = useState("overview"),
+ [path, setPath] = useState<{ label: string; ctx: Context }[]>([]),
+ [selected, setSelected] = useState(),
+ [error, setError] = useState(""),
+ [loading, setLoading] = useState(false),
+ [refresh, setRefresh] = useState(0),
+ [modal, setModal] = useState<
+ | "import"
+ | "settings"
+ | "dictionary"
+ | "users"
+ | "audit"
+ | null
+ | "formula"
+ >(null),
+ [evidenceCtx, setEvidenceCtx] = useState(),
+ [chat, setChat] = useState(false),
+ [source, setSource] = useState(false),
+ [search, setSearch] = useState(""),
+ [mobileNav, setMobileNav] = useState(false),
+ [note, setNote] = useState(""),
+ [status, setStatus] = useState("open"),
+ [saved, setSaved] = useState(false);
+ const upload = useRef(null),
+ requestVersion = useRef(0);
+ const report = useCallback(
+ (e: unknown) => setError((e as Error).message),
+ [],
+ );
+ const loadSession = useCallback(async () => {
+ const s = await api("/session");
+ setCsrf(s.csrf);
+ setSession(s);
+ }, []);
+ useEffect(() => {
+ loadSession().catch(report);
+ }, [loadSession, report]);
+ const loadDataset = useCallback(
+ async (id: string) => {
+ const ticket = ++requestVersion.current;
+ setLoading(true);
+ try {
+ const d = await api("/datasets/" + id);
+ if (ticket !== requestVersion.current) return;
+ setDs(d);
+ localStorage.setItem("traceiq.dataset", d.id);
+ setModal(null);
+ setPath([]);
+ setSelected(undefined);
+ setResult(undefined);
+ setEvidenceCtx(undefined);
+ setChat(false);
+ setError("");
+ if (d.status === "ready") {
+ const p = d.preferences;
+ setCtx({
+ start: d.periods.at(-1)!,
+ end: d.periods.at(-1)!,
+ comparison: p.comparison,
+ filters: {},
+ group: p.hierarchy[0],
+ metric: p.metric,
+ });
+ setScreen(p.entry);
+ } else {
+ setCtx(undefined);
+ setScreen("data");
+ if (["mapping", "failed"].includes(d.status)) setModal("import");
+ }
+ } catch (e) {
+ report(e);
+ } finally {
+ if (ticket === requestVersion.current) setLoading(false);
+ }
+ },
+ [report],
+ );
+ const loadList = useCallback(async () => {
+ const data = await api<{ items: Dataset[] }>("/datasets");
+ setDatasets(data.items);
+ return data.items;
+ }, []);
+ useEffect(() => {
+ if (session?.user) {
+ loadList()
+ .then((items) => {
+ const last = localStorage.getItem("traceiq.dataset");
+ if (items.length)
+ loadDataset(
+ items.find((item) => item.id === last)?.id || items[0].id,
+ );
+ })
+ .catch(report);
+ }
+ }, [session?.user?.id, loadList, loadDataset, report]);
+ useEffect(() => {
+ if (!ds || !["queued", "processing"].includes(ds.status)) return;
+ const timer = setInterval(() => {
+ api("/datasets/" + ds.id)
+ .then((d) => {
+ setDs(d);
+ if (d.status === "ready") {
+ loadList();
+ loadDataset(d.id);
+ }
+ })
+ .catch(report);
+ }, 2000);
+ return () => clearInterval(timer);
+ }, [ds?.id, ds?.status, loadList, loadDataset, report]);
+ useEffect(() => {
+ if (!ds || !ctx || ds.status !== "ready") return;
+ let active = true;
+ setLoading(true);
+ setError("");
+ setResult(undefined);
+ setSelected(undefined);
+ api(`/datasets/${ds.id}/query`, "POST", ctx)
+ .then((data) => {
+ if (active) setResult(data);
+ })
+ .catch((e) => {
+ if (active) report(e);
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [ds?.id, ctx, refresh, report]);
+ useEffect(() => {
+ setNote("");
+ setStatus("open");
+ setSaved(false);
+ if (!ds || !result || screen !== "investigate") return;
+ let active = true;
+ const current = result.context;
+ api<{ items: { context: Context; status: string; note: string }[] }>(
+ `/datasets/${ds.id}/investigations`,
+ )
+ .then(({ items }) => {
+ if (!active) return;
+ const match = items.find(
+ ({ context: c }) =>
+ c.start === current.start &&
+ c.end === current.end &&
+ c.baseline_end === current.baseline_end &&
+ c.comparison === current.comparison &&
+ c.metric === current.metric &&
+ c.group === current.group &&
+ Object.keys(c.filters).length ===
+ Object.keys(current.filters).length &&
+ Object.entries(c.filters).every(
+ ([k, v]) => current.filters[k] === v,
+ ),
+ );
+ if (match) {
+ setNote(match.note);
+ setStatus(match.status);
+ setSaved(true);
+ }
+ })
+ .catch(report);
+ return () => {
+ active = false;
+ };
+ }, [ds?.id, result, screen, report]);
+ async function doUpload(file: File) {
+ setLoading(true);
+ setError("");
+ try {
+ const form = new FormData();
+ form.append("file", file);
+ const d = await api("/datasets", "POST", form);
+ setSource(true);
+ await loadList();
+ await loadDataset(d.id);
+ } catch (e) {
+ report(e);
+ } finally {
+ setLoading(false);
+ if (upload.current) upload.current.value = "";
+ }
+ }
+ function home(target: Screen = "overview") {
+ if (ds?.status === "ready" && ctx) {
+ setCtx({ ...ctx, filters: {}, group: ds.preferences.hierarchy[0] });
+ setPath([]);
+ }
+ setScreen(target);
+ setMobileNav(false);
+ }
+ function drill(row: Row) {
+ if (!ctx || !ds) return;
+ const hierarchy = ds.preferences.hierarchy;
+ const index = hierarchy.indexOf(ctx.group);
+ const next = hierarchy[index + 1];
+ if (!next) {
+ setEvidenceCtx({ ...ctx, filters: row.filters });
+ return;
+ }
+ setPath([...path, { label: row.label, ctx: { ...ctx } }]);
+ setCtx({ ...ctx, filters: row.filters, group: next });
+ setScreen("investigate");
+ setSelected(undefined);
+ }
+ function crumb(index: number) {
+ if (!ctx || !ds) return;
+ if (index === 0) {
+ home();
+ return;
+ }
+ const previous = path[index];
+ if (previous) {
+ setCtx(previous.ctx);
+ setPath(path.slice(0, index));
+ setScreen("investigate");
+ }
+ }
+ function savePreferences(p: Preferences, revision: number) {
+ if (!ds || !ctx) return;
+ setDs({ ...ds, preferences: p, revision });
+ setCtx({
+ ...ctx,
+ metric: p.metric,
+ comparison: p.comparison,
+ filters: {},
+ group: p.hierarchy[0],
+ });
+ setScreen(p.entry);
+ setPath([]);
+ setModal(null);
+ }
+ const columns = useMemo[]>(
+ () => [
+ {
+ field: "label",
+ headerName:
+ ds?.dimensions.find((d) => d.key === ctx?.group)?.name || "分组",
+ pinned: "left",
+ minWidth: 160,
+ flex: 1,
+ cellRenderer: (p: ICellRendererParams) => (
+
+ ),
+ },
+ ...[
+ ...new Set([
+ "revenue",
+ "gross",
+ "cost",
+ "roi",
+ "customers",
+ ctx?.metric || "revenue",
+ ]),
+ ].map((metric) => ({
+ colId: metric,
+ headerName: labels[metric],
+ minWidth: 130,
+ valueGetter: (p: { data?: Row }) =>
+ p.data?.values[metric].current == null
+ ? null
+ : Number(p.data.values[metric].current),
+ valueFormatter: (p: { value: unknown }) =>
+ format(p.value as number, metric),
+ cellClass: metric === ctx?.metric ? "metric-column" : "",
+ type: "numericColumn",
+ })),
+ {
+ colId: "change",
+ headerName: "重点指标变化",
+ minWidth: 145,
+ valueGetter: (p) =>
+ p.data?.values[ctx?.metric || "revenue"].change == null
+ ? null
+ : Number(p.data.values[ctx?.metric || "revenue"].change),
+ valueFormatter: (p) => format(p.value, "change"),
+ cellClassRules: {
+ negative: (p) => p.value != null && p.value < 0,
+ positive: (p) => p.value != null && p.value > 0,
+ },
+ },
+ {
+ colId: "actions",
+ headerName: "调查",
+ width: 155,
+ pinned: "right",
+ sortable: false,
+ filter: false,
+ cellRenderer: (p: ICellRendererParams) => (
+
+
+
+
+ ),
+ },
+ ],
+ [ds, ctx, path],
+ );
+ const rows = (result?.rows || []).filter(
+ (r) => (screen !== "anomalies" || r.anomaly) && r.label.includes(search),
+ );
+ const metric = ctx?.metric || "revenue";
+ const trend = useMemo(
+ () => ({
+ color: ["#197568", "#5794c9"],
+ tooltip: { trigger: "axis" },
+ legend: { bottom: 0 },
+ grid: { top: 25, left: 65, right: 25, bottom: 65 },
+ xAxis: {
+ type: "category",
+ data: result?.trend.map((r) => r.period),
+ axisLabel: { color: "#73827b" },
+ },
+ yAxis: { type: "value", splitLine: { lineStyle: { color: "#ecf0ee" } } },
+ series: [
+ {
+ name: "实际收入",
+ type: "line",
+ showSymbol: true,
+ symbolSize: 7,
+ connectNulls: false,
+ data: result?.trend.map((r) =>
+ r.revenue == null ? null : Number(r.revenue),
+ ),
+ },
+ {
+ name: "毛利贡献",
+ type: "line",
+ showSymbol: true,
+ symbolSize: 6,
+ connectNulls: false,
+ data: result?.trend.map((r) =>
+ r.gross == null ? null : Number(r.gross),
+ ),
+ },
+ ],
+ }),
+ [result],
+ );
+ const contribution = useMemo(
+ () => ({
+ tooltip: { trigger: "axis" },
+ grid: { top: 15, left: 95, right: 30, bottom: 30 },
+ xAxis: { type: "value", splitLine: { lineStyle: { color: "#ecf0ee" } } },
+ yAxis: {
+ type: "category",
+ inverse: true,
+ data: result?.rows.slice(0, 8).map((r) => r.label),
+ },
+ series: [
+ {
+ type: "bar",
+ barMaxWidth: 20,
+ data: result?.rows.slice(0, 8).map((r) => ({
+ value:
+ r.values[metric].delta == null
+ ? null
+ : Number(r.values[metric].delta),
+ itemStyle: {
+ color: Number(r.values[metric].delta) < 0 ? "#cd665a" : "#308c7c",
+ },
+ })),
+ },
+ ],
+ }),
+ [result, metric],
+ );
+ const enterprise = !!import.meta.env.VITE_AG_GRID_LICENSE_KEY;
+ if (!session)
+ return (
+
+
+ {error || "正在连接工作台"}
+ {error && (
+
+ )}
+
+ );
+ if (!session.user) return ;
+ const user = session.user;
+ const canUpload = user.role !== "viewer" && !user.departments.length;
+ return (
+
+
+
+
+
+
+ 工作台
+
+
+ {screen === "data"
+ ? "数据管理"
+ : screen === "overview"
+ ? "经营总览"
+ : screen === "anomalies"
+ ? "异常追踪"
+ : "异常调查"}
+
+
+
+
+
+ 已连接
+
+
+ {ds?.preferences?.alerts && (
+
+ )}
+
+
+
+
+
+
FINANCIAL INTELLIGENCE
+
+ {screen === "overview"
+ ? "集团经营总览"
+ : screen === "anomalies"
+ ? "异常追踪"
+ : screen === "data"
+ ? "数据管理"
+ : `${path.at(-1)?.label || "当前范围"} · 异常调查`}
+
+
+ {screen === "data"
+ ? "数据源、导入版本与质量状态"
+ : `${user.organization} / ${ds?.name || "尚未选择数据集"}`}
+
+
+
+ {screen !== "overview" && screen !== "data" && (
+
+ )}
+ {canUpload && (
+
+ )}
+ {
+ if (e.target.files?.[0]) doUpload(e.target.files[0]);
+ }}
+ />
+
+
+ {error && (
+
+ {error}
+
+
+ )}
+ {!datasets.length && !loading && (
+
+
+ 尚无财务数据
+ {canUpload && (
+
+ )}
+
+ )}
+ {ds && (
+
+
+ {ctx && ds.status === "ready" && (
+ <>
+
+
+
+ {[
+ ["yoy", "同比"],
+ ["mom", "环比"],
+ ["custom", "自定义"],
+ ].map(([k, v]) => (
+
+ ))}
+
+ {ctx.comparison === "custom" && (
+
+ )}
+ >
+ )}
+
+
+ )}
+ {ds && source && (
+
+
+
+ {ds.name}
+
+ {ds.row_count?.toLocaleString() ?? "受限"} 行 ·{" "}
+ {ds.periods.length} 个期间 · 版本 {ds.version.slice(0, 12)}
+
+
+
+
+
+ )}
+ {screen === "data" && (
+
+
+
导入版本
+ {datasets.length} 个数据集
+
+
+
+
+
+ | 文件 |
+ 状态 |
+ 明细行 |
+ 可用期间 |
+ 版本 |
+ 操作 |
+
+
+
+ {datasets.map((d) => (
+
+ |
+
+ {d.name}
+ |
+
+
+ {
+ {
+ mapping: "待确认",
+ queued: "等待导入",
+ processing: "正在导入",
+ ready: "已就绪",
+ failed: "导入失败",
+ }[d.status]
+ }
+
+ |
+ {d.row_count?.toLocaleString() ?? "受限"} |
+ {d.periods.join(" / ") || "等待解析"} |
+
+ {d.version.slice(0, 12)}
+ |
+
+
+ |
+
+ ))}
+
+
+
+ {ds && (
+ <>
+ 数据质量
+
+ {Object.entries(ds.quality).map(([k, v]) => (
+
+ {(
+ {
+ negative_revenue: "负收入记录",
+ missing_cost: "成本缺失",
+ missing_gross: "毛利缺失",
+ missing_customer: "客户标识缺失",
+ blank_rows: "空白行",
+ } as Record
+ )[k] || k}
+ {v.toLocaleString()}
+
+ ))}
+
+ {ds.error && {ds.error}
}
+ {["mapping", "failed"].includes(ds.status) && canUpload && (
+
+ )}
+ {["queued", "processing"].includes(ds.status) && (
+
+ 导入任务
+ {ds.status === "queued" ? "等待后台处理" : "处理中"}
+ ,页面会自动更新。
+
+ )}
+ >
+ )}
+
+ )}
+ {screen !== "data" && ds && ctx && (
+ <>
+ {path.length > 0 && (
+
+ )}
+ {loading && (
+
+
+ 正在计算分析结果
+
+ )}
+ {result && (
+ <>
+ {!result.comparable && (
+
+ 缺少期间:{result.context.missing.join("、")}
+ 。比较与异常结论不可用。
+
+ )}
+
+ {(
+ [
+ "revenue",
+ "gross",
+ "cost",
+ "roi",
+ "customers",
+ ] as Metric[]
+ ).map((m) => (
+
+ ))}
+
+ {screen === "overview" ? (
+ <>
+
+
+
+
+
变化贡献
+ {labels[metric]}
+
+ {
+ const row = result.rows.find(
+ (r) => r.label === name,
+ );
+ if (row) drill(row);
+ }}
+ />
+
+
+ {ds.preferences.show_heatmap && (
+
+
+
经营指标矩阵
+ 本期与基期变化
+
+
+
+
+
+ |
+ {
+ ds.dimensions.find(
+ (d) => d.key === ctx.group,
+ )?.name
+ }
+ |
+ {(
+ [
+ "revenue",
+ "gross",
+ "cost",
+ "roi",
+ ] as Metric[]
+ ).map((m) => (
+ {labels[m]} |
+ ))}
+ 状态 |
+
+
+
+ {result.rows.slice(0, 12).map((r) => (
+
+ | {r.label} |
+ {(
+ [
+ "revenue",
+ "gross",
+ "cost",
+ "roi",
+ ] as Metric[]
+ ).map((m) => {
+ const v = r.values[m].change;
+ return (
+
+
+ |
+ );
+ })}
+
+
+ {r.anomaly ? "待调查" : "未触发"}
+
+ |
+
+ ))}
+
+
+
+
+ )}
+ >
+ ) : screen === "investigate" ? (
+
+
+
+
{labels[metric]}变化分解
+
+ {ctx.start} 至 {ctx.end}
+
+
+
+
+
+ INVESTIGATION
+
+ {Object.values(ctx.filters).join(" / ") || "当前范围"}
+
+
+ 本期 {format(result.summary[metric].current, metric)}{" "}
+ / 基期{" "}
+ {format(result.summary[metric].baseline, metric)}
+
+ {result.notice}
+
+
+ {user.role !== "viewer" && (
+
+ )}
+
+
+ ) : (
+
+
+
+
{result.anomaly_count} 个分组需要关注
+
+ {labels[metric]} · 不利变化 ≥{" "}
+ {Math.round(ds.preferences.threshold * 100)}% ·
+ 最小影响额 {format(ds.preferences.min_impact)}
+
+
+
+ )}
+ {(screen !== "overview" || ds.preferences.show_queue) && (
+
+
+
+ {screen === "overview"
+ ? "分组调查清单"
+ : screen === "anomalies"
+ ? "异常调查清单"
+ : "下一层分析"}
+ {rows.length}
+
+
+
+ {user.role !== "viewer" && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+ theme={gridTheme}
+ rowData={rows}
+ columnDefs={columns}
+ defaultColDef={{
+ sortable: true,
+ filter: true,
+ resizable: true,
+ }}
+ pagination
+ paginationPageSize={20}
+ paginationPageSizeSelector={[20, 50, 100]}
+ getRowId={(p) => p.data.label}
+ onRowClicked={(e) => setSelected(e.data)}
+ localeText={{
+ noRowsToShow: "没有符合条件的分组",
+ page: "页",
+ of: "/",
+ to: "至",
+ more: "更多",
+ nextPage: "下一页",
+ previousPage: "上一页",
+ firstPage: "首页",
+ lastPage: "末页",
+ pageSizeSelectorLabel: "每页",
+ }}
+ {...(enterprise
+ ? {
+ cellSelection: true,
+ sideBar: "columns",
+ statusBar: {
+ statusPanels: [
+ {
+ statusPanel: "agTotalRowCountComponent",
+ },
+ ],
+ },
+ }
+ : {})}
+ />
+
+ {selected && (
+
+ )}
+
+
+ )}
+
+
+
+ {result.row_count.toLocaleString()} 条本期记录 · 数据版本{" "}
+ {result.version.slice(0, 12)} · {result.notice}
+
+
+ >
+ )}
+ >
+ )}
+
+
+
+ {ds?.status === "ready" &&
+ ds.preferences.show_copilot &&
+ ctx &&
+ !chat && (
+
+ )}
+ {chat && ds && ctx && (
+
setChat(false)}
+ onApply={(reply) => {
+ setCtx(reply.context);
+ setPath(
+ Object.entries(reply.context.filters).map(
+ ([key, value], index, entries) => ({
+ label: value,
+ ctx: {
+ ...reply.context,
+ group: key,
+ filters: Object.fromEntries(entries.slice(0, index)),
+ },
+ }),
+ ),
+ );
+ setScreen(
+ reply.intent.action === "overview"
+ ? "overview"
+ : reply.intent.action === "anomalies"
+ ? "anomalies"
+ : "investigate",
+ );
+ if (reply.intent.action === "evidence")
+ setEvidenceCtx(reply.context);
+ }}
+ />
+ )}
+ {modal === "import" && ds && (
+ setModal(null)}
+ onDone={() => {
+ setModal(null);
+ loadList();
+ loadDataset(ds.id);
+ }}
+ />
+ )}
+ {modal === "formula" && ds && ctx && (
+ setModal(null)} />
+ )}
+ {modal === "settings" && ds && (
+ setModal(null)}
+ onSave={savePreferences}
+ />
+ )}{" "}
+ {evidenceCtx && ds && (
+ setEvidenceCtx(undefined)}
+ />
+ )}{" "}
+ {modal === "dictionary" && ds && (
+ setModal(null)} wide>
+
+
+ ROI:
+ {ds.mapping.roi_formula === "profit_over_cost"
+ ? "(收入 − 成本)/ 成本"
+ : "(毛利贡献 − 可归属成本)/ 可归属成本"}
+
+
{ds.mapping.description}
+
+ 消费人数按当前筛选范围去重;客单价 = 实际收入 /
+ 消费人数;原价客单价 = 应收金额 / 消费人数;产品扣率 = 实际收入 /
+ 应收金额。
+
+
+ 自营产品范围:
+ {ds.mapping.self_operated_products?.join("、") || "未配置"}
+ 。自营收入为该范围收入合计;升单率为购买过该范围产品的去重客户数 /
+ 总消费人数,含退款净额口径。
+
+
+
+
+ | 字段 |
+ 原始解释 |
+ 角色 |
+
+
+
+ {ds.mapping.columns.map((c) => (
+
+ | {c.name} |
+ {c.description} |
+ {c.role} |
+
+ ))}
+
+
+
+
+ )}
+ {(modal === "users" || modal === "audit") && (
+ setModal(null)} />
+ )}
+
+ );
+}
+function Login({ onLogin }: { onLogin: () => Promise }) {
+ const [username, setUsername] = useState(""),
+ [password, setPassword] = useState(""),
+ [error, setError] = useState(""),
+ [busy, setBusy] = useState(false);
+ async function submit() {
+ setBusy(true);
+ setError("");
+ try {
+ const res = await api<{ csrf: string }>("/login", "POST", {
+ username,
+ password,
+ });
+ setCsrf(res.csrf);
+ await onLogin();
+ } catch (e) {
+ setError((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+
+
+
+
+ 授权访问 · 数据隔离 · 审计留痕
+
+
+ );
+}
diff --git a/frontend/src/Chart.tsx b/frontend/src/Chart.tsx
new file mode 100644
index 0000000..a3e5e1c
--- /dev/null
+++ b/frontend/src/Chart.tsx
@@ -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(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 ;
+}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
new file mode 100644
index 0000000..54a27dc
--- /dev/null
+++ b/frontend/src/api.ts
@@ -0,0 +1,38 @@
+let csrf = "";
+export function setCsrf(value: string) {
+ csrf = value;
+}
+export async function api(
+ path: string,
+ method = "GET",
+ data?: unknown,
+): Promise {
+ 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(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);
+}
diff --git a/frontend/src/dialogs.tsx b/frontend/src/dialogs.tsx
new file mode 100644
index 0000000..0e52997
--- /dev/null
+++ b/frontend/src/dialogs.tsx
@@ -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;
+
+type FormulaExpression =
+ | { metric: Metric }
+ | { number: number }
+ | {
+ operator: "add" | "subtract" | "multiply" | "divide";
+ left: FormulaExpression;
+ right: FormulaExpression;
+ };
+
+type SavedFormulaDefinition = {
+ id: string;
+ definition: Record;
+ 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(null);
+ useEffect(() => {
+ const dialog = ref.current;
+ if (!dialog) return;
+ dialog.showModal();
+ return () => dialog.close();
+ }, []);
+ return (
+
+ );
+}
+
+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 (
+
+
+
{ds.name} · 检查识别字段及财务口径后确认导入。
+
+
+
+
+
+
+ | 源字段 |
+ 角色 |
+ 维度键 |
+ 说明 |
+
+
+
+ {mapping.columns.map((c) => (
+
+ | {c.name} |
+
+
+ |
+
+ {c.role === "dimension" && (
+
+ update(c.index, { key: e.target.value })
+ }
+ />
+ )}
+ |
+
+
+ update(c.index, { description: e.target.value })
+ }
+ />
+ |
+
+ ))}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
+
+export function EvidenceDialog({
+ ds,
+ ctx,
+ user,
+ onClose,
+}: {
+ ds: Dataset;
+ ctx: Context;
+ user: NonNullable;
+ onClose: () => void;
+}) {
+ const pageSize = 50;
+ const [result, setResult] = useState<{
+ rows: Record[];
+ 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[]; 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 (
+
+
+
+
+
+
+
+ 来源工作表:{result?.sheet || "加载中"} · {result?.total ?? 0} 条
+
+ {user.role !== "admin" &&
客户原始标识不包含在明细中。
}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ | 来源行 |
+ 期间 |
+ {ds.dimensions.map((d) => (
+ {d.name} |
+ ))}
+ 收入 |
+ 成本 |
+ 毛利 |
+
+
+
+ {result?.rows.map((r, i) => (
+
+ | {String(r.source_row)} |
+ {String(r.period)} |
+ {ds.dimensions.map((d) => (
+
+ {String(
+ (r.dimensions as Record)[d.key] || "",
+ )}
+ |
+ ))}
+ {String(r.revenue)} |
+ {String(r.cost ?? "")} |
+ {String(r.gross ?? "")} |
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+export function AdminDialog({
+ mode,
+ onClose,
+}: {
+ mode: "users" | "audit";
+ onClose: () => void;
+}) {
+ const [items, setItems] = useState[]>([]),
+ [error, setError] = useState("");
+ useEffect(() => {
+ api<{ items: Record[] }>(
+ mode === "users" ? "/users" : "/audit",
+ )
+ .then((r) => setItems(r.items))
+ .catch((e) => setError(e.message));
+ }, [mode]);
+ return (
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ {mode === "users" ? (
+ <>
+ | 账号 |
+ 角色 |
+ 状态 |
+ 科室 |
+ >
+ ) : (
+ <>
+ 时间 |
+ 用户 |
+ 操作 |
+ 资源 |
+ 详情 |
+ >
+ )}
+
+
+
+ {items.map((r, i) => (
+
+ {mode === "users" ? (
+ <>
+ | {String(r.username)} |
+ {String(r.role)} |
+ {r.active ? "启用" : "停用"} |
+
+ {String(
+ ((r.departments || []) as string[]).join("、") ||
+ "全部",
+ )}
+ |
+ >
+ ) : (
+ <>
+ {String(r.created_at)} |
+ {String(r.user__username)} |
+ {String(r.action)} |
+ {String(r.resource)} |
+ {JSON.stringify(r.details)} |
+ >
+ )}
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+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 | null>(null),
+ [questions, setQuestions] = useState([]),
+ [task, setTask] = useState<{
+ id: string;
+ status: string;
+ error?: string;
+ }>(),
+ [definitions, setDefinitions] = useState([]),
+ [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 | 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(
+ `/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(
+ `/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 (
+
+
+
描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。
+ {definitions.length > 0 && !task && (
+
+
+
+
+ )}
+
+
+ {messages.map((m, i) => (
+
+ {m.role === "user" ? "我" : "助手"}:
+ {m.content}
+
+ ))}
+
+
本轮描述 {userTurns}/6
+ {!task && (
+ <>
+
+
+ );
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..edb1311
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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 ? (
+
+ 页面出现错误
+
+
+ ) : (
+ this.props.children
+ );
+ }
+}
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/frontend/src/style.css b/frontend/src/style.css
new file mode 100644
index 0000000..eea92b0
--- /dev/null
+++ b/frontend/src/style.css
@@ -0,0 +1,1564 @@
+@import url("ag-grid-community/styles/ag-grid.css");
+:root {
+ font-family:
+ Inter,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ "PingFang SC",
+ "Microsoft YaHei",
+ sans-serif;
+ color: #26362f;
+ background: #f7f9f8;
+ font-synthesis: none;
+ font-size: 14px;
+ letter-spacing: 0;
+ --green: #166c5d;
+ --muted: #77847d;
+ --line: #e1e7e3;
+ --red: #bd524a;
+ --blue: #347fac;
+}
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+}
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ border: 1px solid #dbe3de;
+ background: white;
+ border-radius: 5px;
+ padding: 8px 12px;
+ color: #35483e;
+ cursor: pointer;
+ white-space: nowrap;
+ min-height: 35px;
+}
+button:hover {
+ background: #f0f5f2;
+ border-color: #a3bdb0;
+}
+button:disabled {
+ opacity: 0.48;
+ cursor: not-allowed;
+}
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible,
+a:focus-visible {
+ outline: 3px solid #76adcc;
+ outline-offset: 2px;
+}
+button svg {
+ width: 17px;
+ height: 17px;
+ flex-shrink: 0;
+}
+button.primary {
+ background: var(--green);
+ border-color: var(--green);
+ color: white;
+}
+button.primary:hover {
+ background: #115448;
+}
+.icon {
+ width: 34px;
+ height: 34px;
+ min-height: 34px;
+ padding: 7px;
+ flex: none;
+ background: transparent;
+ border-color: transparent;
+}
+.icon:hover {
+ background: #e9f1ec;
+}
+.icon svg {
+ width: 17px;
+ height: 17px;
+}
+input,
+select,
+textarea {
+ min-width: 0;
+ max-width: 100%;
+ border: 1px solid #dce4df;
+ border-radius: 4px;
+ padding: 8px 10px;
+ background: white;
+ color: #293e33;
+ min-height: 36px;
+}
+input[type="checkbox"] {
+ min-height: auto;
+ width: 15px;
+ height: 15px;
+ accent-color: var(--green);
+}
+textarea {
+ resize: vertical;
+ min-height: 74px;
+}
+label {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ font-size: 12px;
+ color: #607169;
+}
+h1,
+h2,
+h3,
+p {
+ margin-top: 0;
+}
+h1 {
+ font-size: 27px;
+ line-height: 1.3;
+ font-weight: 650;
+ margin-bottom: 9px;
+}
+h2 {
+ font-size: 17px;
+ line-height: 1.4;
+ font-weight: 650;
+}
+h3 {
+ font-size: 15px;
+ line-height: 1.4;
+ margin: 22px 0 13px;
+}
+small {
+ font-size: 11px;
+ color: var(--muted);
+}
+code {
+ font-family: ui-monospace, SFMono-Regular, monospace;
+ font-size: 11px;
+ overflow-wrap: anywhere;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+.muted {
+ color: var(--muted);
+}
+.positive {
+ color: #238272;
+}
+.negative {
+ color: var(--red);
+}
+.eyebrow {
+ font-size: 10px;
+ font-weight: 650;
+ color: #7e9387;
+ margin-bottom: 11px;
+}
+.tag {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 4px;
+ background: #edf4f0;
+ color: #367a5a;
+ font-size: 11px;
+ padding: 4px 8px;
+ white-space: nowrap;
+}
+.tag.danger {
+ background: #fcedea;
+ color: #b65246;
+}
+.app {
+ display: flex;
+ min-height: 100vh;
+}
+.sidebar {
+ width: 218px;
+ flex: none;
+ background: #fff;
+ border-right: 1px solid var(--line);
+ display: flex;
+ flex-direction: column;
+ position: fixed;
+ inset: 0 auto 0 0;
+ z-index: 25;
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 28px 23px 26px;
+ font-size: 24px;
+ font-weight: 730;
+}
+.brand-symbol {
+ width: 32px;
+ height: 36px;
+ background: var(--green);
+ border-radius: 7px;
+ display: grid;
+ place-items: center;
+ color: white;
+}
+.brand-symbol svg {
+ width: 22px;
+}
+.brand-iq {
+ color: var(--green);
+}
+.brand small {
+ display: block;
+ font-size: 10px;
+ font-weight: 400;
+ margin-top: 2px;
+}
+.organization {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 14px 0;
+ margin: 0 19px 22px;
+ border-block: 1px solid var(--line);
+ font-size: 12px;
+ font-weight: 550;
+}
+.organization small {
+ display: block;
+ margin-top: 4px;
+ font-weight: 400;
+}
+.org-avatar {
+ background: #eef2ef;
+ border: 1px solid #dce5df;
+ width: 30px;
+ height: 32px;
+ border-radius: 4px;
+ display: grid;
+ place-items: center;
+ font-size: 12px;
+ color: #4f6859;
+ flex: none;
+}
+.nav-heading {
+ font-size: 10px;
+ color: #8f9a93;
+ padding: 0 25px;
+ margin: 0 0 10px;
+}
+.sidebar nav {
+ display: grid;
+ gap: 5px;
+ padding: 0 12px 26px;
+}
+.sidebar nav button {
+ justify-content: flex-start;
+ border: 0;
+ border-radius: 5px;
+ min-height: 42px;
+ background: none;
+ color: #66756b;
+ padding-left: 13px;
+ font-size: 13px;
+}
+.sidebar nav button svg {
+ width: 17px;
+ margin-right: 4px;
+}
+.sidebar nav button.active {
+ color: var(--green);
+ background: #eaf3ee;
+ font-weight: 600;
+}
+.nav-count {
+ margin-left: auto;
+ background: #d9e9df;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+}
+.sidebar-bottom {
+ margin: auto 22px 22px;
+ display: flex;
+ gap: 9px;
+ align-items: center;
+ color: #758b7c;
+ font-size: 11px;
+}
+.sidebar-bottom svg {
+ width: 17px;
+}
+.sidebar-bottom small {
+ display: block;
+ margin-top: 3px;
+ font-size: 10px;
+}
+.user {
+ display: flex;
+ align-items: center;
+ padding: 17px 18px;
+ gap: 9px;
+ border-top: 1px solid var(--line);
+ font-size: 12px;
+}
+.user small {
+ display: block;
+ margin-top: 3px;
+}
+.user .icon {
+ margin-left: auto;
+}
+.avatar {
+ display: grid;
+ place-items: center;
+ width: 31px;
+ height: 31px;
+ border-radius: 50%;
+ background: #e2ebe5;
+ color: #53705e;
+}
+.workspace {
+ margin-left: 218px;
+ min-width: 0;
+ width: calc(100% - 218px);
+}
+.topbar {
+ height: 64px;
+ background: #fff;
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32px;
+ font-size: 12px;
+}
+.topbar-left,
+.topbar-right {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+}
+.topbar-left > svg {
+ width: 12px;
+ color: #9eaaa2;
+}
+.connection {
+ font-size: 11px;
+ color: #7c8e82;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ margin-right: 6px;
+}
+.connection i {
+ width: 5px;
+ height: 5px;
+ background: #53a581;
+ border-radius: 50%;
+}
+.mobile-menu {
+ display: none;
+}
+.workspace main {
+ max-width: 1780px;
+ margin: auto;
+ padding: 30px 32px 12px;
+}
+.page-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 24px;
+}
+.page-heading p {
+ font-size: 11px;
+ color: #7d8d82;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+.heading-actions {
+ display: flex;
+ gap: 8px;
+}
+.heading-actions .primary {
+ font-size: 12px;
+ padding: 10px 14px;
+}
+.filters {
+ display: flex;
+ align-items: end;
+ gap: 15px;
+ flex-wrap: wrap;
+ padding-bottom: 23px;
+ border-bottom: 1px solid var(--line);
+ margin-bottom: 24px;
+}
+.filters label {
+ font-size: 10px;
+ gap: 6px;
+}
+.filters select {
+ max-width: 270px;
+ font-size: 12px;
+}
+.filters input {
+ font-size: 12px;
+ width: 145px;
+}
+.segmented {
+ display: inline-flex;
+ padding: 3px;
+ background: #eaf0ec;
+ border-radius: 5px;
+ gap: 2px;
+ height: 36px;
+}
+.segmented button {
+ min-height: 28px;
+ padding: 4px 14px;
+ background: transparent;
+ font-size: 12px;
+ border: 0;
+}
+.segmented button.active {
+ background: white;
+ color: var(--green);
+ box-shadow: 0 1px 3px #26382b13;
+}
+.filters > .icon {
+ margin-left: auto;
+}
+.source-strip {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ border-bottom: 1px solid var(--line);
+ padding: 0 0 18px;
+ margin-bottom: 20px;
+ font-size: 12px;
+}
+.source-strip > svg {
+ color: #387859;
+ width: 24px;
+ flex: none;
+}
+.source-strip > div {
+ flex: 1;
+ overflow-wrap: anywhere;
+}
+.source-strip small {
+ display: block;
+ margin-top: 5px;
+}
+.source-strip button {
+ font-size: 11px;
+}
+.kpis {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ margin: 0 0 26px;
+ border: 1px solid var(--line);
+ background: white;
+ border-radius: 6px;
+ overflow: hidden;
+}
+.kpi {
+ border: 0;
+ border-right: 1px solid var(--line);
+ border-radius: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ padding: 20px 20px 16px;
+ gap: 16px;
+ background: white;
+ min-width: 0;
+ position: relative;
+}
+.kpi:last-child {
+ border: 0;
+}
+.kpi.selected:before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: var(--green);
+}
+.kpi > span:first-child {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ color: #677c6e;
+}
+.kpi strong {
+ text-align: left;
+ font-size: 24px;
+ line-height: 1.25;
+ font-weight: 620;
+ font-variant-numeric: tabular-nums;
+ white-space: normal;
+ overflow-wrap: anywhere;
+}
+.kpi-bottom {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+}
+.kpi-bottom > span {
+ display: flex;
+ align-items: center;
+ font-size: 11px;
+ gap: 3px;
+}
+.kpi-bottom svg {
+ width: 12px;
+ height: 12px;
+}
+.kpi-bottom small {
+ font-size: 10px;
+}
+.chart-band {
+ display: grid;
+ grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
+ border-bottom: 1px solid var(--line);
+ margin-bottom: 25px;
+ gap: 28px;
+}
+.chart-band section + section {
+ border-left: 1px solid var(--line);
+ padding-left: 24px;
+}
+.section-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 15px;
+ margin-bottom: 15px;
+}
+.section-heading h2 {
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 15px;
+}
+.section-heading > span {
+ color: #8c9b91;
+ font-size: 10px;
+}
+.chart {
+ height: 275px;
+ width: 100%;
+ min-width: 0;
+}
+.table-scroll {
+ overflow: auto;
+ max-width: 100%;
+}
+table {
+ border-collapse: collapse;
+ width: 100%;
+ font-size: 12px;
+}
+th {
+ text-align: left;
+ color: #6c7f71;
+ font-size: 11px;
+ font-weight: 550;
+ background: #f1f5f2;
+}
+th,
+td {
+ border-bottom: 1px solid #e9eeea;
+ padding: 12px 14px;
+ vertical-align: middle;
+}
+td {
+ color: #4b5e51;
+}
+td small {
+ display: block;
+ margin-top: 4px;
+}
+td.description {
+ max-width: 260px;
+ white-space: normal;
+ font-size: 11px;
+ line-height: 1.6;
+}
+.heatmap-section {
+ margin-bottom: 27px;
+}
+.heatmap th:first-child {
+ width: 23%;
+}
+.heatmap td {
+ padding: 5px 9px;
+ text-align: center;
+}
+.heatmap th:not(:first-child) {
+ text-align: center;
+}
+.heatmap tbody th {
+ background: transparent;
+ color: #4d6153;
+ font-size: 12px;
+}
+.heatmap button {
+ width: 100%;
+ border: 0;
+ min-height: 30px;
+ border-radius: 3px;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+.heat-negative {
+ background: #f9e9e5;
+ color: #b25147;
+}
+.heat-positive {
+ background: #e5f1ea;
+ color: #357956;
+}
+.heatmap .neutral {
+ color: #99a79d;
+ background: #eef2ef;
+}
+.count {
+ font-size: 10px;
+ background: #e8eeea;
+ border-radius: 4px;
+ padding: 3px 6px;
+ color: #728579;
+}
+.table-tools {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.search-input {
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+.search-input svg {
+ position: absolute;
+ left: 10px;
+ width: 13px;
+ color: #8a9a8f;
+}
+.search-input input {
+ font-size: 11px;
+ padding-left: 30px;
+ width: 185px;
+ min-height: 32px;
+}
+.grid-layout {
+ display: flex;
+ min-width: 0;
+ background: white;
+}
+.grid {
+ height: 400px;
+ flex: 1;
+ min-width: 0;
+}
+.row-label {
+ border: 0;
+ padding: 0;
+ min-height: 30px;
+ background: none;
+ font-size: 12px;
+ justify-content: flex-start;
+ color: #3d5747;
+}
+.signal {
+ width: 5px;
+ height: 5px;
+ background: #c86657;
+ border-radius: 50%;
+ flex: none;
+}
+.row-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ height: 100%;
+}
+.row-actions .icon {
+ width: 28px;
+ height: 28px;
+ min-height: 28px;
+ border: 1px solid #e0e7e2;
+ border-radius: 4px;
+}
+.row-actions svg {
+ width: 13px;
+ height: 13px;
+}
+.metric-column {
+ font-weight: 600;
+}
+.explanation {
+ width: 260px;
+ flex: none;
+ padding: 15px 18px;
+ border: 1px solid var(--line);
+ border-left: 0;
+ font-size: 12px;
+}
+.explanation h3 {
+ font-size: 18px;
+ margin: 14px 0 12px;
+}
+.explanation > strong {
+ font-size: 23px;
+ display: block;
+ margin-bottom: 13px;
+}
+.explanation p {
+ font-size: 11px;
+}
+.explanation dl {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 13px;
+ font-size: 11px;
+ padding-block: 15px;
+ border-block: 1px solid var(--line);
+}
+.explanation dt {
+ color: #7d8c82;
+}
+.explanation dd {
+ margin: 0;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+.explanation > button {
+ width: 100%;
+ font-size: 11px;
+ margin-top: 8px;
+}
+.analysis-footnote {
+ display: flex;
+ align-items: start;
+ gap: 7px;
+ font-size: 10px;
+ color: #91a095;
+ margin: 18px 0;
+}
+.analysis-footnote svg {
+ width: 13px;
+ height: 13px;
+ flex: none;
+}
+.app-footer {
+ display: flex;
+ justify-content: space-between;
+ border-top: 1px solid var(--line);
+ margin: 16px 32px 0;
+ padding: 16px 0 20px;
+ font-size: 10px;
+ color: #97a49c;
+}
+.app-footer span:first-child {
+ font-weight: 600;
+ color: #7b8f80;
+}
+.banner {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.error {
+ background: #fff0ee;
+ color: #a43932;
+ padding: 12px 15px;
+ font-size: 12px;
+ border-radius: 4px;
+ overflow-wrap: anywhere;
+}
+.notice {
+ background: #fff7dc;
+ color: #856629;
+ font-size: 12px;
+ padding: 13px 16px;
+ border-left: 3px solid #caaa50;
+ margin-bottom: 22px;
+}
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 420px;
+ gap: 20px;
+ color: #90a194;
+}
+.empty-state h2 {
+ color: #4c6355;
+}
+.empty,
+.loading {
+ text-align: center;
+ padding: 24px;
+ color: #71897a;
+}
+.loading {
+ display: flex;
+ gap: 10px;
+ justify-content: center;
+}
+.loading svg {
+ width: 18px;
+ animation: spin 2s linear infinite;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.breadcrumbs {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 20px;
+}
+.breadcrumbs > span {
+ display: flex;
+ align-items: center;
+}
+.breadcrumbs svg {
+ width: 12px;
+ color: #9bad9e;
+}
+.breadcrumbs button {
+ border: 0;
+ background: none;
+ padding: 4px 7px;
+ font-size: 12px;
+ min-height: 28px;
+}
+.breadcrumbs button:disabled {
+ opacity: 1;
+ color: #1b7761;
+ font-weight: 600;
+}
+.investigation-band {
+ display: grid;
+ grid-template-columns: minmax(0, 1.5fr) minmax(260px, 1fr);
+ gap: 30px;
+ border-bottom: 1px solid var(--line);
+ padding-bottom: 24px;
+ margin-bottom: 25px;
+}
+.investigation-summary {
+ border-left: 1px solid var(--line);
+ padding-left: 25px;
+ font-size: 12px;
+}
+.investigation-summary label {
+ margin: 12px 0;
+}
+.investigation-summary h2 {
+ font-size: 18px;
+}
+.investigation-summary p {
+ line-height: 1.7;
+}
+.investigation-summary textarea {
+ min-height: 55px;
+}
+.anomaly-intro {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ margin: 28px 0;
+ padding: 0 0 24px;
+ border-bottom: 1px solid var(--line);
+}
+.anomaly-intro > svg {
+ width: 27px;
+ color: #be7562;
+}
+.anomaly-intro h2 {
+ margin-bottom: 8px;
+}
+.anomaly-intro p {
+ margin: 0;
+ color: #88948b;
+ font-size: 12px;
+}
+.data-section h3 {
+ margin-top: 30px;
+}
+.inline-icon {
+ width: 15px;
+ height: 15px;
+ vertical-align: middle;
+ margin-right: 8px;
+ color: #558667;
+}
+.quality-list {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-bottom: 22px;
+}
+.quality-list span {
+ display: flex;
+ justify-content: space-between;
+ padding: 12px;
+ border-bottom: 1px solid var(--line);
+ font-size: 12px;
+ color: #7c8c81;
+}
+.quality-list strong {
+ color: #4d6957;
+}
+dialog {
+ position: fixed;
+ z-index: 40;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ width: min(700px, calc(100% - 30px));
+ max-height: 90dvh;
+ padding: 0;
+ color: inherit;
+ box-shadow: 0 25px 80px #152a2430;
+}
+dialog.wide {
+ width: min(1100px, calc(100% - 30px));
+}
+dialog::backdrop {
+ background: #162c2460;
+ backdrop-filter: blur(2px);
+}
+dialog > header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 17px 23px;
+ border-bottom: 1px solid var(--line);
+ position: sticky;
+ top: 0;
+ background: white;
+ z-index: 3;
+}
+dialog header h2 {
+ margin: 0;
+ font-size: 18px;
+}
+.dialog-body {
+ padding: 23px;
+}
+.dialog-body table input {
+ width: 145px;
+}
+.dialog-body table select {
+ min-width: 130px;
+}
+.dialog-body summary {
+ cursor: pointer;
+ margin: 16px 0;
+ font-size: 12px;
+ color: #5f7969;
+}
+dialog footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+ position: sticky;
+ bottom: 0;
+ background: white;
+ border-top: 1px solid var(--line);
+ padding: 15px 23px;
+}
+dialog footer > span {
+ margin-right: auto;
+ font-size: 12px;
+}
+.source-meta {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 22px;
+ font-size: 12px;
+ flex-wrap: wrap;
+}
+.source-meta strong {
+ overflow-wrap: anywhere;
+}
+.source-meta code {
+ margin-left: auto;
+ color: #90a395;
+}
+.form-row {
+ display: flex;
+ gap: 14px;
+ align-items: end;
+ flex-wrap: wrap;
+ margin: 18px 0;
+}
+.form-row label {
+ flex: 1;
+ min-width: 130px;
+}
+.settings-layout {
+ display: grid;
+ grid-template-columns: 1.6fr 1fr;
+ gap: 32px;
+}
+.settings-layout h3:first-child {
+ margin-top: 0;
+}
+.settings-preview {
+ border-left: 1px solid var(--line);
+ padding: 15px 0 15px 28px;
+ line-height: 1.9;
+ color: #607b69;
+}
+.settings-preview strong {
+ font-size: 21px;
+ font-weight: 550;
+}
+.settings-preview hr {
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ margin: 25px 0;
+}
+.settings-preview .tag {
+ margin-right: 8px;
+}
+.hierarchy {
+ list-style: none;
+ padding: 0;
+ counter-reset: level;
+}
+.hierarchy li {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 5px 8px;
+ border-bottom: 1px solid var(--line);
+ counter-increment: level;
+ font-size: 12px;
+}
+.hierarchy li:before {
+ content: counter(level);
+ width: 20px;
+ color: #9aafa0;
+}
+.hierarchy li > span {
+ flex: 1;
+}
+.check {
+ display: inline-flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ margin-right: 18px;
+ line-height: 1.5;
+}
+.copilot-launch {
+ position: fixed;
+ right: 27px;
+ bottom: 23px;
+ background: #1d584a;
+ color: white;
+ border: 0;
+ border-radius: 6px;
+ padding: 13px 20px;
+ box-shadow: 0 5px 25px #183d3b25;
+ z-index: 30;
+ font-size: 12px;
+}
+.copilot-launch:hover {
+ background: #174b3e;
+}
+.copilot {
+ position: fixed;
+ right: 20px;
+ bottom: 20px;
+ width: 375px;
+ height: min(690px, calc(100dvh - 95px));
+ background: white;
+ border: 1px solid #c8d8cc;
+ box-shadow: 0 12px 50px #183c3225;
+ border-radius: 8px;
+ z-index: 35;
+ display: flex;
+ flex-direction: column;
+}
+.copilot header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 17px 18px;
+ border-bottom: 1px solid var(--line);
+}
+.copilot header small {
+ display: block;
+ margin-top: 5px;
+}
+.chat-messages {
+ flex: 1;
+ overflow: auto;
+ padding: 18px;
+}
+.dialog-body .chat-messages p {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.context-tag {
+ font-size: 10px;
+ background: #eef4f0;
+ color: #7b9282;
+ padding: 8px;
+}
+.suggestion {
+ width: 100%;
+ justify-content: space-between;
+ font-size: 12px;
+ margin-bottom: 7px;
+}
+.message {
+ padding-block: 10px;
+ border-top: 1px solid var(--line);
+ font-size: 12px;
+ line-height: 1.9;
+}
+.message p {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.message .question {
+ background: #e9f2ec;
+ padding: 10px 12px;
+ border-radius: 5px;
+ margin-left: 25px;
+}
+.message > button {
+ font-size: 11px;
+ width: 100%;
+ margin-top: 10px;
+}
+.copilot form {
+ padding: 14px;
+ border-top: 1px solid var(--line);
+}
+.copilot .check {
+ font-size: 10px;
+ margin-bottom: 10px;
+}
+.chat-input {
+ display: flex;
+ gap: 5px;
+}
+.chat-input input {
+ flex: 1;
+}
+.fatal {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ flex-direction: column;
+}
+.login {
+ min-height: 100dvh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ background: #f3f6f3;
+}
+.login-brand {
+ font-size: 29px;
+ font-weight: 650;
+ color: #23694e;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 35px;
+}
+.login-brand svg {
+ width: 29px;
+}
+.login form {
+ width: min(380px, calc(100% - 40px));
+ padding: 0;
+}
+.login h1 {
+ font-size: 24px;
+ margin: 0 0 28px;
+}
+.login label {
+ margin-bottom: 19px;
+}
+.login input {
+ height: 44px;
+}
+.login form > .primary {
+ width: 100%;
+ margin-top: 10px;
+ height: 44px;
+ justify-content: space-between;
+}
+.login-footer {
+ font-size: 10px;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ color: #8fa194;
+ margin: 37px 0;
+}
+.login-footer svg {
+ width: 13px;
+}
+details {
+ overflow-wrap: anywhere;
+}
+@media (min-width: 1600px) {
+ .workspace main {
+ padding-inline: 42px;
+ }
+ .kpi strong {
+ font-size: 28px;
+ }
+ .chart {
+ height: 300px;
+ }
+}
+@media (max-width: 1200px) {
+ .sidebar {
+ width: 185px;
+ }
+ .brand {
+ padding-left: 18px;
+ }
+ .workspace {
+ margin-left: 185px;
+ width: calc(100% - 185px);
+ }
+ .workspace main {
+ padding: 24px 21px;
+ }
+ .kpi {
+ padding: 17px 13px;
+ }
+ .kpi strong {
+ font-size: 21px;
+ }
+ .chart-band {
+ gap: 15px;
+ }
+ .chart-band section + section {
+ padding-left: 14px;
+ }
+ .topbar {
+ padding-inline: 22px;
+ }
+ .explanation {
+ width: 235px;
+ }
+ .kpi-bottom {
+ flex-wrap: wrap;
+ }
+ .filters {
+ gap: 10px;
+ }
+ .filters select {
+ max-width: 225px;
+ }
+}
+@media (max-width: 900px) {
+ .sidebar {
+ width: 175px;
+ }
+ .workspace {
+ margin-left: 175px;
+ width: calc(100% - 175px);
+ }
+ .brand {
+ font-size: 21px;
+ gap: 7px;
+ }
+ .brand-symbol {
+ width: 28px;
+ }
+ .page-heading {
+ align-items: start;
+ }
+ .heading-actions {
+ flex-direction: column;
+ }
+ .kpis {
+ grid-template-columns: repeat(3, 1fr);
+ border-radius: 4px;
+ }
+ .kpi {
+ border-bottom: 1px solid var(--line);
+ }
+ .kpi strong {
+ font-size: 22px;
+ }
+ .chart-band {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .chart-band section + section {
+ border-left: 0;
+ border-top: 1px solid var(--line);
+ padding: 18px 0 0;
+ }
+ .grid-layout {
+ flex-direction: column;
+ }
+ .explanation {
+ width: 100%;
+ border: 1px solid var(--line);
+ }
+ .explanation dl {
+ grid-template-columns: 1fr 1fr;
+ }
+ .investigation-band {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .investigation-summary {
+ border-left: 0;
+ border-top: 1px solid var(--line);
+ padding: 20px 0 0;
+ }
+ .settings-layout {
+ grid-template-columns: 1fr;
+ }
+ .settings-preview {
+ border-left: 0;
+ border-top: 1px solid var(--line);
+ padding: 20px 0;
+ }
+ .quality-list {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+@media (max-width: 640px) {
+ .sidebar {
+ display: none;
+ }
+ .sidebar.open {
+ display: flex;
+ width: 218px;
+ box-shadow: 0 0 0 100vmax #132c2440;
+ }
+ .workspace {
+ margin-left: 0;
+ width: 100%;
+ }
+ .mobile-menu {
+ display: inline-flex;
+ }
+ .topbar {
+ height: 56px;
+ padding: 0 14px;
+ }
+ .topbar-left {
+ gap: 7px;
+ }
+ .topbar-right {
+ gap: 2px;
+ }
+ .connection {
+ display: none;
+ }
+ .workspace main {
+ padding: 22px 15px;
+ }
+ .page-heading {
+ gap: 12px;
+ margin-bottom: 23px;
+ }
+ .page-heading h1 {
+ font-size: 23px;
+ }
+ .page-heading p {
+ font-size: 10px;
+ line-height: 1.5;
+ }
+ .eyebrow {
+ font-size: 9px;
+ }
+ .heading-actions .primary {
+ font-size: 11px;
+ padding: 8px 10px;
+ }
+ .heading-actions .primary svg {
+ width: 14px;
+ }
+ .filters {
+ gap: 11px;
+ padding-bottom: 18px;
+ }
+ .filters label:first-child {
+ width: 100%;
+ }
+ .filters select {
+ max-width: 100%;
+ width: 100%;
+ }
+ .filters input {
+ width: 140px;
+ }
+ .filters .icon {
+ margin-left: 0;
+ }
+ .kpis {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-bottom: 24px;
+ }
+ .kpi {
+ padding: 16px 13px;
+ gap: 14px;
+ }
+ .kpi strong {
+ font-size: 23px;
+ }
+ .kpi:last-child {
+ grid-column: 1/-1;
+ border-top: 1px solid var(--line);
+ }
+ .kpi:last-child strong {
+ font-size: 22px;
+ }
+ .section-heading {
+ gap: 8px;
+ }
+ .section-heading h2 {
+ font-size: 14px;
+ }
+ .chart {
+ height: 250px;
+ }
+ .table-tools {
+ gap: 3px;
+ }
+ .search-input input {
+ width: 118px;
+ }
+ .heatmap th,
+ .heatmap td {
+ min-width: 87px;
+ padding: 7px 6px;
+ }
+ .heatmap th:first-child {
+ min-width: 105px;
+ }
+ .grid {
+ height: 430px;
+ }
+ .grid-section .section-heading {
+ flex-wrap: wrap;
+ }
+ .source-strip {
+ flex-wrap: wrap;
+ }
+ .source-strip > div {
+ flex-basis: 70%;
+ }
+ .app-footer {
+ margin: 14px 15px 60px;
+ }
+ .copilot-launch {
+ right: 15px;
+ bottom: 16px;
+ }
+ .copilot {
+ right: 10px;
+ bottom: 10px;
+ width: calc(100% - 20px);
+ height: calc(100dvh - 75px);
+ }
+ dialog,
+ .dialog.wide {
+ width: calc(100% - 16px);
+ max-height: 94dvh;
+ }
+ dialog > header {
+ padding: 14px 15px;
+ }
+ .dialog-body {
+ padding: 15px;
+ }
+ dialog footer {
+ padding: 12px;
+ flex-wrap: wrap;
+ }
+ dialog footer > span {
+ width: 100%;
+ }
+ .form-row {
+ gap: 12px;
+ }
+ .form-row label {
+ flex-basis: 100%;
+ }
+ .dialog-body .table-scroll {
+ max-height: 48dvh;
+ }
+ .quality-list {
+ grid-template-columns: 1fr;
+ }
+ .data-section table {
+ min-width: 700px;
+ }
+ .anomaly-intro h2 {
+ font-size: 16px;
+ }
+ .anomaly-intro p {
+ font-size: 10px;
+ line-height: 1.7;
+ }
+ .table-scroll th,
+ .table-scroll td {
+ white-space: nowrap;
+ }
+ .dialog-body td.description {
+ white-space: normal;
+ min-width: 180px;
+ }
+ .source-meta code {
+ margin-left: 0;
+ }
+ .login form {
+ padding: 10px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation: none !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
new file mode 100644
index 0000000..79cfa75
--- /dev/null
+++ b/frontend/src/types.ts
@@ -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 = {
+ 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;
+ 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;
+ 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;
+ group: string;
+ metric: Metric;
+}
+export interface Value {
+ current: Numeric;
+ baseline: Numeric;
+ delta: Numeric;
+ change: Numeric;
+}
+export interface Row {
+ label: string;
+ values: Record;
+ rows: number;
+ baseline_rows: number;
+ anomaly: boolean;
+ impact: Numeric;
+ contribution: Numeric;
+ filters: Record;
+}
+export interface Analysis {
+ context: Context & {
+ missing: string[];
+ current: string[];
+ baseline: string[];
+ };
+ summary: Record;
+ rows: Row[];
+ trend: ({ period: string } & Record)[];
+ 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 });
diff --git a/manage.py b/manage.py
new file mode 100644
index 0000000..1e8d34c
--- /dev/null
+++ b/manage.py
@@ -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)
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b3af995
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2080 @@
+{
+ "name": "traceiq",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "traceiq",
+ "version": "0.1.0",
+ "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"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
+ "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",
+ "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",
+ "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",
+ "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",
+ "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",
+ "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",
+ "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",
+ "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",
+ "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",
+ "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",
+ "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",
+ "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",
+ "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",
+ "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",
+ "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",
+ "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",
+ "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",
+ "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",
+ "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",
+ "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",
+ "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",
+ "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",
+ "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",
+ "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",
+ "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.4",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.4.tgz",
+ "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ag-charts-community": {
+ "version": "12.3.1",
+ "resolved": "https://registry.npmmirror.com/ag-charts-community/-/ag-charts-community-12.3.1.tgz",
+ "integrity": "sha512-uRaUFmCl8e0Y3KxjaHUYlkCPS5OtwtfTChkdpoZuBKDXqerCPTjPu+uvlun3rYUSYxScwVf2LZCI0Hfw4Vc+WQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ag-charts-core": "12.3.1",
+ "ag-charts-locale": "12.3.1",
+ "ag-charts-types": "12.3.1"
+ }
+ },
+ "node_modules/ag-charts-core": {
+ "version": "12.3.1",
+ "resolved": "https://registry.npmmirror.com/ag-charts-core/-/ag-charts-core-12.3.1.tgz",
+ "integrity": "sha512-711UJ0fXengb8+4PEW4nlzWDowmbYymPcjW2eJWHRzzvttUf14hnh+wP/l/s3EGVgYkEHe9vkXFwmeOJUlkC0Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ag-charts-types": "12.3.1"
+ }
+ },
+ "node_modules/ag-charts-enterprise": {
+ "version": "12.3.1",
+ "resolved": "https://registry.npmmirror.com/ag-charts-enterprise/-/ag-charts-enterprise-12.3.1.tgz",
+ "integrity": "sha512-2sQIwLfksRTcI4JVRjgQkyGq6z68UVTO/I91HKmtpukrQLsW/o0TzzHmgTtVJOXd9yO9gx2KWa2DMg0+FKdXEQ==",
+ "license": "Commercial",
+ "optional": true,
+ "dependencies": {
+ "ag-charts-community": "12.3.1",
+ "ag-charts-core": "12.3.1"
+ }
+ },
+ "node_modules/ag-charts-locale": {
+ "version": "12.3.1",
+ "resolved": "https://registry.npmmirror.com/ag-charts-locale/-/ag-charts-locale-12.3.1.tgz",
+ "integrity": "sha512-dCn7oHh3xLI576FT514aBedNQgtb5zwh/Gcj7jHvjOWYRnfH8kaekZzLzntITA6dF6E78okJfoI7CUCbYduQ4Q==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/ag-charts-types": {
+ "version": "12.3.1",
+ "resolved": "https://registry.npmmirror.com/ag-charts-types/-/ag-charts-types-12.3.1.tgz",
+ "integrity": "sha512-5216xYoawnvMXDFI6kTpPku+mH0Csiwu/FE7lsAm8Z22HEN6ciSG/V7g+IrpLWncELqksgENebCTP75PZ3CsHA==",
+ "license": "MIT"
+ },
+ "node_modules/ag-grid-community": {
+ "version": "34.3.1",
+ "resolved": "https://registry.npmmirror.com/ag-grid-community/-/ag-grid-community-34.3.1.tgz",
+ "integrity": "sha512-PwlrPudsFOzGumphi2y9ihWeaUlIwKhOra/MXu2LjeV2U8DgLLcYS8CartE5Hszhn1poJHawwI9HWrxlKliwdw==",
+ "license": "MIT",
+ "dependencies": {
+ "ag-charts-types": "12.3.1"
+ }
+ },
+ "node_modules/ag-grid-enterprise": {
+ "version": "34.3.1",
+ "resolved": "https://registry.npmmirror.com/ag-grid-enterprise/-/ag-grid-enterprise-34.3.1.tgz",
+ "integrity": "sha512-pee4Zh0gLeQED+RM+ofxNun9JSrYrE0ZVw90BibuBkhBCLXrtXtjGPiwM+Ylntl+GwJUuCXrTcVLXRHpdXVYbQ==",
+ "license": "Commercial",
+ "dependencies": {
+ "ag-grid-community": "34.3.1"
+ },
+ "optionalDependencies": {
+ "ag-charts-community": "12.3.1",
+ "ag-charts-enterprise": "12.3.1"
+ }
+ },
+ "node_modules/ag-grid-react": {
+ "version": "34.3.1",
+ "resolved": "https://registry.npmmirror.com/ag-grid-react/-/ag-grid-react-34.3.1.tgz",
+ "integrity": "sha512-1UTlBT+xJkjNZAuf7RxK61mgxKGTPB+6XR99oIHq7cYC89kJmLbWqhHt/1XqRWF5cAgSKk8u+HtOQaN8tAZStw==",
+ "license": "MIT",
+ "dependencies": {
+ "ag-grid-community": "34.3.1",
+ "prop-types": "^15.8.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.25",
+ "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
+ "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.0",
+ "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.29.0.tgz",
+ "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.23",
+ "caniuse-lite": "^1.0.30001810",
+ "electron-to-chromium": "^1.5.427",
+ "node-releases": "^2.0.55",
+ "update-browserslist-db": "^1.3.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/echarts": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz",
+ "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "2.3.0",
+ "zrender": "6.1.0"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.434",
+ "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.434.tgz",
+ "integrity": "sha512-7EeFW9OLf1NN9NKQP9xdOE/XKqjxv7JDPP6AEAkHli11+Fae1OaLoNuAf13hQv1PPNtuES0pLQSPk2+fS3s8ww==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.468.0",
+ "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.468.0.tgz",
+ "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.56",
+ "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.56.tgz",
+ "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.8",
+ "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.8.tgz",
+ "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmmirror.com/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.4",
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.4.tgz",
+ "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.4",
+ "@rollup/rollup-android-arm64": "4.63.4",
+ "@rollup/rollup-darwin-arm64": "4.63.4",
+ "@rollup/rollup-darwin-x64": "4.63.4",
+ "@rollup/rollup-freebsd-arm64": "4.63.4",
+ "@rollup/rollup-freebsd-x64": "4.63.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.4",
+ "@rollup/rollup-linux-arm64-musl": "4.63.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.4",
+ "@rollup/rollup-linux-loong64-musl": "4.63.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.4",
+ "@rollup/rollup-linux-x64-gnu": "4.63.4",
+ "@rollup/rollup-linux-x64-musl": "4.63.4",
+ "@rollup/rollup-openbsd-x64": "4.63.4",
+ "@rollup/rollup-openharmony-arm64": "4.63.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.4",
+ "@rollup/rollup-win32-x64-gnu": "4.63.4",
+ "@rollup/rollup-win32-x64-msvc": "4.63.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zrender": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz",
+ "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tslib": "2.3.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5bd8733
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..f10b87e
--- /dev/null
+++ b/playwright.config.ts
@@ -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" }]],
+});
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..a226c1e
--- /dev/null
+++ b/pyproject.toml
@@ -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"]
diff --git a/scripts/init_local.py b/scripts/init_local.py
new file mode 100644
index 0000000..d6af0ef
--- /dev/null
+++ b/scripts/init_local.py
@@ -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")
diff --git a/scripts/inspect_source.py b/scripts/inspect_source.py
new file mode 100644
index 0000000..f2d45a0
--- /dev/null
+++ b/scripts/inspect_source.py
@@ -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()
diff --git a/scripts/reconcile_reference.py b/scripts/reconcile_reference.py
new file mode 100644
index 0000000..f1f36a8
--- /dev/null
+++ b/scripts/reconcile_reference.py
@@ -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))
diff --git a/server/__init__.py b/server/__init__.py
new file mode 100644
index 0000000..b1f3463
--- /dev/null
+++ b/server/__init__.py
@@ -0,0 +1,4 @@
+from decimal import DefaultContext, getcontext
+
+DefaultContext.prec = 60
+getcontext().prec = 60
diff --git a/server/settings.py b/server/settings.py
new file mode 100644
index 0000000..b543b8a
--- /dev/null
+++ b/server/settings.py
@@ -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"},
+}
diff --git a/server/urls.py b/server/urls.py
new file mode 100644
index 0000000..9760687
--- /dev/null
+++ b/server/urls.py
@@ -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/", views.dataset_detail),
+ path("api/datasets//mapping", views.mapping),
+ path("api/datasets//query", views.query),
+ path("api/datasets//evidence", views.evidence),
+ path("api/datasets//export", views.export),
+ path("api/datasets//formula-design", formula_views.formula_design),
+ path(
+ "api/datasets//formula-definitions",
+ formula_views.formula_definitions,
+ ),
+ path(
+ "api/datasets//formula-definitions/",
+ formula_views.formula_definition_delete,
+ ),
+ path("api/datasets//formula-exports", formula_views.formula_export_create),
+ path(
+ "api/datasets//formula-exports/", formula_views.formula_export_status
+ ),
+ path(
+ "api/datasets//formula-exports//download",
+ formula_views.formula_export_download,
+ ),
+ path("api/datasets//settings", views.settings_view),
+ path("api/datasets//investigations", views.investigations),
+ path("api/datasets//assistant", assistant),
+ path("", views.index),
+]
diff --git a/server/wsgi.py b/server/wsgi.py
new file mode 100644
index 0000000..fb6e225
--- /dev/null
+++ b/server/wsgi.py
@@ -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()
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..b355c9c
--- /dev/null
+++ b/tests/conftest.py
@@ -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
diff --git a/tests/e2e/formula-export.spec.ts b/tests/e2e/formula-export.spec.ts
new file mode 100644
index 0000000..ede33c5
--- /dev/null
+++ b/tests/e2e/formula-export.spec.ts
@@ -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);
+});
diff --git a/tests/e2e/workspace.spec.ts b/tests/e2e/workspace.spec.ts
new file mode 100644
index 0000000..20f26af
--- /dev/null
+++ b/tests/e2e/workspace.spec.ts
@@ -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([]);
+});
diff --git a/tests/test_assistant_api.py b/tests/test_assistant_api.py
new file mode 100644
index 0000000..62d9895
--- /dev/null
+++ b/tests/test_assistant_api.py
@@ -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"]
diff --git a/tests/test_finance.py b/tests/test_finance.py
new file mode 100644
index 0000000..b7376d0
--- /dev/null
+++ b/tests/test_finance.py
@@ -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
diff --git a/tests/test_formula_cleanup.py b/tests/test_formula_cleanup.py
new file mode 100644
index 0000000..19d5475
--- /dev/null
+++ b/tests/test_formula_cleanup.py
@@ -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"
diff --git a/tests/test_formula_columns.py b/tests/test_formula_columns.py
new file mode 100644
index 0000000..d9a9f8b
--- /dev/null
+++ b/tests/test_formula_columns.py
@@ -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
diff --git a/tests/test_formula_design_safety.py b/tests/test_formula_design_safety.py
new file mode 100644
index 0000000..67adfe0
--- /dev/null
+++ b/tests/test_formula_design_safety.py
@@ -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"
diff --git a/tests/test_formula_export_reliability.py b/tests/test_formula_export_reliability.py
new file mode 100644
index 0000000..0530117
--- /dev/null
+++ b/tests/test_formula_export_reliability.py
@@ -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(),
+ )
diff --git a/tests/test_formula_metric_availability.py b/tests/test_formula_metric_availability.py
new file mode 100644
index 0000000..1abd050
--- /dev/null
+++ b/tests/test_formula_metric_availability.py
@@ -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"]
diff --git a/tests/test_formula_recalculation.py b/tests/test_formula_recalculation.py
new file mode 100644
index 0000000..e2646e8
--- /dev/null
+++ b/tests/test_formula_recalculation.py
@@ -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"}
diff --git a/tests/test_formula_recalculation_edges.py b/tests/test_formula_recalculation_edges.py
new file mode 100644
index 0000000..2e8992a
--- /dev/null
+++ b/tests/test_formula_recalculation_edges.py
@@ -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"
diff --git a/tests/test_formula_recalculation_gross.py b/tests/test_formula_recalculation_gross.py
new file mode 100644
index 0000000..f0254b8
--- /dev/null
+++ b/tests/test_formula_recalculation_gross.py
@@ -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"}
diff --git a/tests/test_formula_reconcile_guard.py b/tests/test_formula_reconcile_guard.py
new file mode 100644
index 0000000..a4b9131
--- /dev/null
+++ b/tests/test_formula_reconcile_guard.py
@@ -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)
diff --git a/tests/test_importing.py b/tests/test_importing.py
new file mode 100644
index 0000000..0df263a
--- /dev/null
+++ b/tests/test_importing.py
@@ -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",)]
diff --git a/tests/test_operations.py b/tests/test_operations.py
new file mode 100644
index 0000000..d8ac7de
--- /dev/null
+++ b/tests/test_operations.py
@@ -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()
diff --git a/tests/test_upload_limits.py b/tests/test_upload_limits.py
new file mode 100644
index 0000000..f0da964
--- /dev/null
+++ b/tests/test_upload_limits.py
@@ -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
diff --git a/tests/test_views_workflows.py b/tests/test_views_workflows.py
new file mode 100644
index 0000000..a9a897d
--- /dev/null
+++ b/tests/test_views_workflows.py
@@ -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")
diff --git a/tests/test_worker_paths.py b/tests/test_worker_paths.py
new file mode 100644
index 0000000..61e1402
--- /dev/null
+++ b/tests/test_worker_paths.py
@@ -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"
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..7975126
--- /dev/null
+++ b/tsconfig.json
@@ -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"]
+}
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..c5305b0
--- /dev/null
+++ b/uv.lock
@@ -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" },
+]
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..cceb023
--- /dev/null
+++ b/vite.config.ts
@@ -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 },
+});