This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
var
|
||||||
|
staticfiles
|
||||||
|
frontend/dist
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
.coverage
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: Verify
|
||||||
|
on: [push, pull_request]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_PASSWORD: ci-only-password
|
||||||
|
POSTGRES_DB: traceiq
|
||||||
|
ports: ['5432:5432']
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready --health-interval 5s
|
||||||
|
--health-timeout 5s --health-retries 10
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://postgres:ci-only-password@localhost:5432/traceiq
|
||||||
|
E2E_PASSWORD: CI-Test-Only-SecurePassword882!
|
||||||
|
PYTHONPATH: .
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v6
|
||||||
|
with:
|
||||||
|
version: '0.10.12'
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
- run: uv sync --frozen --python 3.12
|
||||||
|
- run: npm ci --registry=https://registry.npmjs.org
|
||||||
|
- run: uv run ruff check server finance tests scripts
|
||||||
|
- run: uv run ruff format --check server finance tests scripts
|
||||||
|
- run: uv run python manage.py makemigrations --check --dry-run
|
||||||
|
- run: uv run pytest --cov=finance --cov-report=term-missing --cov-fail-under=85
|
||||||
|
- run: npm run build
|
||||||
|
- run: npm audit --omit=dev --registry=https://registry.npmjs.org
|
||||||
|
- run: uv run pip-audit
|
||||||
|
- run: uv run python manage.py migrate --noinput
|
||||||
|
- run: uv run python manage.py collectstatic --noinput
|
||||||
|
- run: uv run python manage.py bootstrap --username admin --organization CI
|
||||||
|
env:
|
||||||
|
BOOTSTRAP_PASSWORD: ${{ env.E2E_PASSWORD }}
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
- name: Browser regression
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
uv run python manage.py runserver 127.0.0.1:8000 --noreload > /tmp/web.log 2>&1 &
|
||||||
|
web=$!
|
||||||
|
uv run python manage.py import_worker > /tmp/worker.log 2>&1 &
|
||||||
|
worker=$!
|
||||||
|
trap 'kill "$web" "$worker"' EXIT
|
||||||
|
for i in {1..30}; do curl -fsS http://127.0.0.1:8000/api/health && break; sleep 1; done
|
||||||
|
npm run test:e2e
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: browser-results
|
||||||
|
path: |
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.env
|
||||||
|
*.sqlite3
|
||||||
|
var/
|
||||||
|
staticfiles/
|
||||||
|
frontend/dist/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.DS_Store
|
||||||
+25
@@ -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", "-"]
|
||||||
@@ -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 依赖就声称已完成这些功能。
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 均为历史基线,不能替代当前版本的目标环境验收。
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>财务 Agent · Excel 公式导出实施计划</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:light;--ink:#202725;--muted:#58645f;--line:#d8dfdc;--green:#17664f;--red:#ac3440;--bg:#f5f7f6}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.8 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;letter-spacing:0}header{background:#fff;border-bottom:3px solid var(--green);padding:40px 24px 28px}main,header>div,footer{max-width:1120px;margin:auto}h1{font-size:32px;line-height:1.35;margin:8px 0 16px}h2{font-size:24px;margin:0 0 18px}h3{font-size:18px;margin:24px 0 8px}p{margin:10px 0}small,.muted{color:var(--muted)}main{padding:0 24px}section{padding:32px 0;border-bottom:1px solid var(--line);scroll-margin-top:16px}nav{padding:22px 0;border-bottom:1px solid var(--line);display:flex;gap:12px 24px;flex-wrap:wrap}a{color:var(--green);text-underline-offset:4px}ul,ol{padding-left:24px}li{margin:8px 0}.notice{border-left:4px solid var(--red);padding:10px 18px;background:#fff}.recommend{border-left:4px solid var(--green);padding:10px 18px;background:#fff}.table-wrap{overflow:auto;max-width:100%;border:1px solid var(--line)}table{border-collapse:collapse;width:100%;min-width:720px;background:#fff;font-size:14px}th,td{text-align:left;vertical-align:top;padding:13px 15px;border-bottom:1px solid var(--line)}th{background:#eaf0ed}tr:last-child td{border-bottom:0}code{font:13px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere}pre{padding:16px;background:#eaf0ed;overflow:auto;max-width:100%;line-height:1.7}pre code{white-space:pre;overflow-wrap:normal}.tag{font-size:13px;font-weight:700;color:var(--red)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}.grid>div{min-width:0}footer{padding:28px 24px 48px;color:var(--muted);font-size:14px}strong{font-weight:650}@media(max-width:650px){header{padding:26px 18px}main{padding:0 18px}h1{font-size:26px}h2{font-size:21px}.grid{grid-template-columns:1fr;gap:0}section{padding:25px 0}}@media print{body{background:white;font-size:11pt}header{padding:0 0 16px}main{padding:0}nav{display:none}section{break-inside:auto;padding:18px 0}h2,h3{break-after:avoid}tr{break-inside:avoid}.table-wrap{overflow:visible}table{min-width:0;font-size:9pt}pre{white-space:pre-wrap}pre code{white-space:pre-wrap}footer{padding:16px 0}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside style="max-width:1120px;margin:16px auto;padding:16px 24px;background:#fff4e5;border-left:4px solid #a95712"><strong>当前状态复核 · 2026-09-24:</strong>本页下方保留 2026-09-23 的历史实施方案,不能再据此判断当前功能状态。方案中的公式工作簿、说明页、异步导出和对话新增列已经实现;LibreOfficeDev 小样本重算已验证,Microsoft Excel Windows/Mac 尚未验收。当前限制为 100,000 行/200 组,332,830 行原附件公式工作簿、真实 AI 合规、财务签字和商业部署仍未通过。详情见 <a href="acceptance.md">验收状态与剩余门禁</a>。</aside>
|
||||||
|
<aside style="max-width:1120px;margin:16px auto;padding:16px 24px;background:#fff4e5;border-left:4px solid #a95712"><strong>状态更新 · 2026-09-24:</strong>本计划中的公式工作簿、说明页、异步导出及对话新增公式列已实现。新增列追问/导出专项、workspace 桌面和手机测试及后端测试通过。下方内容是实施前方案记录;目前仍未通过 Microsoft Excel 动态数组重算验收,LibreOffice 人数公式存在兼容错误;公式导出限制 100,000 行/200 组,无法一次覆盖 332,830 行附件;真实 AI 联调、财务口径签字及商业部署安全门禁未完成。因此公式版尚不具备生产发布资格。详见 <a href="acceptance.md">验收状态与剩余门禁</a>。</aside>
|
||||||
|
<header><div><small>TRACEIQ / 财务 Agent / 工程实施与验收方案</small><h1>Excel 公式导出实施计划</h1><p>从只读证据快照,升级到可解释、可重算、可对账的财务工作簿。</p><p class="tag">状态:待实施 · 本文不是功能已完成或生产验收通过的证明</p><small>核查日期:2026-09-23 | 交付范围:计划文档,不修改现有导出逻辑</small></div></header>
|
||||||
|
<main>
|
||||||
|
<nav aria-label="目录"><a href="#current">现状证据</a><a href="#decisions">设计选择</a><a href="#workbook">工作簿结构</a><a href="#formulas">公式与口径</a><a href="#precision">精度策略</a><a href="#editing">可重算边界</a><a href="#security">安全与性能</a><a href="#implementation">实施步骤</a><a href="#tests">测试与门禁</a><a href="#questions">待确认事项</a></nav>
|
||||||
|
<section id="current"><h2>01 / 核查结论:目前不含公式</h2><div class="notice"><strong>当前 XLSX 导出不满足本次需求。</strong><p>现有实现将每个单元格转换为字符串,并明确设置为文本类型。导出的收入、成本和毛利贡献是结果快照,没有可重算公式,也没有完整的行列字典和操作说明。</p></div><div class="table-wrap"><table><thead><tr><th>核查对象</th><th>已存在的行为</th><th>与目标的差距</th></tr></thead><tbody><tr><td><code>finance/views.py · export()</code></td><td>一张“证据明细”;前四行含版本 SHA、筛选上下文、ROI 口径、导出时间;所有值写为文本。</td><td>没有公式计算层、汇总报表或说明页。</td></tr><tr><td><code>tests/test_finance.py · test_excel_export_precision_and_formula_safety</code></td><td>明确断言所有单元格都不是公式,验证高精度金额及注入保护。</td><td>只能证明证据快照行为正确,不能证明公式导出正确。</td></tr><tr><td><code>frontend/src/dialogs.tsx · EvidenceDialog</code></td><td>选择 XLSX 下载证据明细。</td><td>没有公式版选择、兼容性说明、精度提示或异步状态。</td></tr><tr><td>规模与参考附件</td><td>现有单次导出上限 100,000 行;附件共有 332,830 行。</td><td>不能通过现有接口一次导出原附件全部明细。</td></tr></tbody></table></div><p>已运行的两项针对性测试:<code>test_excel_export_precision_and_formula_safety</code>、<code>test_xlsx_double_header_and_lookup</code>,结果 <strong>2 passed(1.59 秒)</strong>。后者验证导入双表头与毛利率匹配,不是 Excel 公式重算测试。</p><p>保留金额文本并非无意义限制:系统使用高精度 Decimal,而 Excel 普通数值只有约 15 位有效数字。新方案必须保留证据精度,不能直接把所有字符串改成浮点数。</p></section>
|
||||||
|
<section id="decisions"><h2>02 / 设计选择与推荐</h2><div class="table-wrap"><table><thead><tr><th>决策</th><th>选项与代价</th><th>推荐</th></tr></thead><tbody><tr><td>导出模式</td><td>A. 替换现有证据导出,破坏旧语义。<br>B. 保留证据快照,新增公式工作簿。</td><td><strong>B</strong>。界面清楚区分“证据快照”和“公式分析工作簿”;CSV 始终是快照,不承诺公式。</td></tr><tr><td>精度</td><td>A. 全部按 Excel 浮点数,丢失高精度证据。<br>B. 精确文本证据 + 数值计算层 + Decimal 对账。<br>C. 宏或自定义高精度插件,兼容、部署及安全成本高。</td><td><strong>B</strong>。明确公式值遵守批准后的精度政策,不声称原生公式保持 24 位小数精确运算。</td></tr><tr><td>公式兼容性</td><td>A. Microsoft 365,支持动态数组去重。<br>B. 旧版 Excel / WPS / LibreOffice,需另做兼容实现及验证。</td><td>先以 <strong>Microsoft 365 桌面 Windows / Mac</strong> 为明确验收目标;网页版另测。其他引擎通过验证后再声明支持,不能以应用跨平台替代工作簿兼容性。</td></tr><tr><td>人数计算</td><td>A. UNIQUE/FILTER 真正按范围去重。<br>B. 服务端预制去重辅助表,修改客户或分组后可能失效。<br>C. 每行 COUNTIFS 扫全表,规模大时开销很高。</td><td>公式版选 <strong>A</strong>,限定报表分组数量并压测;B 仅作明确只读的兼容快照,不能冒充完全可重算。避免 C。</td></tr><tr><td>生成与重算</td><td>A. 现有 openpyxl 写公式,打开 Excel 时重算。<br>B. 导出后由经验证的受控引擎重算并保存缓存。<br>C. 添加写缓存库,但缓存仍是服务器结果。</td><td>第一阶段 <strong>A + 真实 Excel 验收</strong>;首次打开前预览可能空白,必须告知。若业务要求下载即带可信缓存,则 B 是上线前置条件;不把 C 当作重算证明。</td></tr><tr><td>编辑能力</td><td>A. 任意插行、改维度和期间并自动重建全部报表。<br>B. 固定导出范围,允许指定输入修改,结构性变化重新导出。</td><td>首版 <strong>B</strong>,边界容易验证;完整可变结构作为后续独立需求,不暗示已经支持。</td></tr><tr><td>大文件</td><td>A. 同步放宽现有上限。<br>B. 数据库任务 + 独立 worker + 私有文件下载。</td><td>全量附件采用 <strong>B</strong>;复用项目已有任务处理模式,不预先引入新的消息队列。</td></tr></tbody></table></div></section>
|
||||||
|
<section id="workbook"><h2>03 / 工作簿结构与行列说明</h2><p>输出放在前面,输入和证据放在后面。仅创建实际需要的页,不按每个指标拆页。所有名称均为规划,尚未实现。</p><div class="table-wrap"><table><thead><tr><th>工作表</th><th>行与列的含义</th><th>内容与用途</th></tr></thead><tbody><tr><td>分析汇总</td><td>每行一个指标或已选维度分组;列为本期、基期、差额、变化率、单位、可用状态。</td><td>汇总金额、去重人数、比率由明细或计算结果引用产生;非加法指标不合计子组值。页首注明范围、期间、版本及模拟状态。</td></tr><tr><td>使用说明</td><td>按主题分行:范围、粒度、字段、公式、操作、限制。</td><td>字典列包括名称、业务含义、数据类型、单位、来源字段、可否修改、空值含义、公式与示例;另附版本、时区和支持的 Excel 版本。</td></tr><tr><td>参数与口径</td><td>每行一个参数或产品毛利率;列为当前值、含义、来源、是否可改。</td><td>收入映射、ROI 分支、自营产品集合、毛利率、精度政策;首版期间及筛选锁定。允许的模拟参数必须明确标识。</td></tr><tr><td>计算明细</td><td>一行对应一个导入后的 Fact,保留来源定位键;不是一位客户或一次就诊。</td><td>期间、维度、去标识客户 token、基础数值、原价、毛利来源、适用毛利率、毛利公式、缺失标志等;Excel Table 管理引用范围。</td></tr><tr><td>原始证据</td><td>与计算明细一一对应;金额和标识保留精确文本。</td><td>授权范围内的来源行、字段映射及原值;不可把完整患者原始信息无差别复制出去。工作表保护只防误操作,不是加密或权限控制。</td></tr><tr><td>对账检查</td><td>每行一个指标及范围;列为服务器精确文本、批准精度的参考数值、公式结果、差异、阈值和检查状态。</td><td>包含行数、缺失数、总分一致性、公式覆盖与精度警告;此页仅观察结果,不反向驱动业务公式。</td></tr></tbody></table></div><h3>说明页必须回答的问题</h3><ul><li>本文件覆盖哪个数据版本、期间、组织及筛选?这是授权范围快照,不代表整院所有数据。</li><li>金额单位是什么?百分比以小数储存;升单率变化“百分点”与相对变化率分列,不混用。</li><li>收入、实收、原价、毛利贡献分别指什么?毛利贡献不是净利润,ROI 成本的定义必须写明。</li><li>哪些单元格可改?颜色之外同时提供文字标签、批注和数据验证;公式格保护防止误覆盖。</li><li>如何启用自动计算、重新计算和查看公式?不需要启用宏,不连接外部数据,不回写业务系统。</li><li>空值、0、不可用、缺期和超精度分别是什么意思?遇到不支持函数时不要把错误当成 0。</li><li>修改后如何核对?本地修改是模拟结果,服务器原始快照及其 SHA 不因此变成修改后内容的认证。</li></ul></section>
|
||||||
|
<section id="formulas"><h2>04 / 公式契约:每个衍生结果可追溯</h2><p>基础事实可以是输入常量;新增衍生金额、计数、比率和差异必须来自真正的公式依赖。禁止用 <code>=12345</code> 或引用隐藏的静态答案来伪装计算。建立“指标 → 输入 → 公式 → 单位 → 缺失策略 → 测试”的显式清单。</p><div class="table-wrap"><table><thead><tr><th>指标</th><th>计算定义</th><th>边界与验收重点</th></tr></thead><tbody><tr><td>收入 / 成本 / 原价</td><td>在指定范围汇总对应基础字段。</td><td>有数据但全为 0 与无数据不同;成本或原价部分缺失时依赖指标不可用,不能只合计非空项。</td></tr><tr><td>毛利贡献</td><td>原始毛利有值时使用该输入;否则收入 × 已匹配产品毛利率;汇总逐行结果。</td><td>必须恢复并记录 direct / lookup / missing 来源。Fact.gross 已是结果,不能直接把它当作原始毛利来伪造推导。必要时从保留的 raw 与映射提取,先做一致性测试。</td></tr><tr><td>ROI</td><td>profit_over_cost:(收入 − 成本) / 成本;gross_less_cost_over_cost:(毛利贡献 − 成本) / 成本。</td><td>遵从数据版本映射;成本 ≤ 0 或任何必要字段缺失时不可用。</td></tr><tr><td>消费人数 / 客单价</td><td>范围内客户 token 去重计数;收入 / 消费人数。</td><td>跨月、跨产品、跨科室重复客户只计一次;有任一缺失客户时与现有后端口径一致地不可用。</td></tr><tr><td>毛利率 / 产品扣率</td><td>毛利贡献 / 收入;收入 / 原价金额。</td><td>先汇总再相除,不能平均行级比率;分母必须大于 0;不把扣率解释为“1 − 比值”。</td></tr><tr><td>自营收入 / 升单率</td><td>配置自营产品范围内收入;该范围客户去重数 / 全部客户去重数。</td><td>产品集合未配置时不可用;不能以自营收入占比替代升单率。</td></tr><tr><td>原价客单价</td><td>原价金额 / 去重人数。</td><td>原价或客户缺失时不可用。</td></tr><tr><td>差额 / 变化率</td><td>本期 − 基期;差额 / ABS(基期)。</td><td>基期 0 不计算变化率;缺期不比较;比率指标同时注明差额单位。</td></tr><tr><td>贡献率</td><td>子组金额差额 / 父组金额差额。</td><td>仅收入、成本、毛利、自营收入等可加指标;父级差额 0 不计算,不为人数或比率伪造加法贡献。</td></tr></tbody></table></div><h3>公式示例与生成规则</h3><p>下列为设计样例。<code>Data</code> 表名、英文列名和命名范围须由模板定义,不能让用户字符串直接拼入公式;状态判断在拥有该指标的计算层生成。公式使用英文函数名和 OOXML 语法,实际写入动态数组需要验证序列化及兼容前缀。</p><pre><code>收入(已确认该期间有记录):
|
||||||
|
=SUMIFS(Data[Revenue],Data[Period],CurrentPeriod)
|
||||||
|
|
||||||
|
成本完整性检查后计算 ROI:
|
||||||
|
=IF(CostStatus<>"OK",NA(),IF(Cost<=0,NA(),(Revenue-Cost)/Cost))
|
||||||
|
|
||||||
|
变化率(先确认本期与基期可比较):
|
||||||
|
=IF(Comparable=0,NA(),IF(BaselineValue=0,NA(),
|
||||||
|
(CurrentValue-BaselineValue)/ABS(BaselineValue)))
|
||||||
|
|
||||||
|
365 去重人数(先确认范围有记录,且该范围客户字段完整):
|
||||||
|
=IF(CustomerStatus<>"OK",NA(),
|
||||||
|
ROWS(UNIQUE(FILTER(Data[CustomerToken],Data[Period]=CurrentPeriod))))
|
||||||
|
|
||||||
|
毛利计算分支:
|
||||||
|
=IF([@GrossSource]="direct",[@GrossInput],
|
||||||
|
IF([@RateStatus]="OK",[@Revenue]*[@MarginRate],NA()))</code></pre><p>多月、多维度条件需生成对应掩码;上述单月例子不能直接替代完整上下文。全量、大量分组不允许每个明细行扫描全表。公式构建须限制报告维度及单元格数量。</p><p><strong>缺失表达推荐:</strong>计算层用预期的 <code>NA()</code> 阻止错误结果传播;输出页用配套状态显示“不可用:缺成本”等原因。只对已知状态做展示转换,不用 <code>IFERROR(...,0)</code> 吞掉真实公式错误。</p></section>
|
||||||
|
<section id="precision"><h2>05 / 精度与财务对账</h2><div class="recommend"><strong>推荐:原始精确文本不可变,计算数值有明确精度契约。</strong><p>“显示两位小数”不是“计算只用两位小数”。原附件有 11,990 行收入超过四位小数,不能在导出时静默按行截断。</p></div><ol><li><strong>证据层:</strong>保留 Decimal 原始字符串及来源映射;不经 JavaScript Number 中转。完整金额不可因导出而发生舍入。</li><li><strong>计算层:</strong>先检测有效数字、数量级和累计误差风险,再转换 Excel 数值。把转换策略、舍入位数、舍入模式和发生时点写入文件。</li><li><strong>推荐默认:</strong>分析公式不逐行强制 ROUND;展示金额两位,最终财务比较按批准的规则舍入。若业务要求“每行先舍入再汇总”,另设明确版本口径,不能混用。</li><li><strong>两套参照:</strong>Decimal 对原始业务口径的精确结果用于证据核验;按相同批准数值政策得到的参考结果用于 Excel 计算对账。区别“政策导致的差异”与“实现错误”。</li><li><strong>误差门槛:</strong>逐项规定金额绝对误差、比率误差及人数严格相等。建议金额以最终批准币种最小单位一致为目标;具体阈值必须经真实样本和财务确认,不能先放宽到让测试通过。</li><li><strong>边界处理:</strong>超过可接受数值范围、舍入临界点或差异超阈值时阻止宣称“精确一致”,给出可定位的警告或拒绝公式版,证据版仍保留。</li></ol><p>不推荐启用 Excel“以显示精度为准”,因为它会改变存储值。原生数值公式不能承诺任意 24 位小数精确运算;若确有此硬要求,应保留服务端 Decimal 为权威计算引擎,并另行评估高精度插件,不在首版隐式引入宏。</p></section>
|
||||||
|
<section id="editing"><h2>06 / 修改后究竟会重算什么</h2><div class="table-wrap"><table><thead><tr><th>操作</th><th>首版承诺</th><th>限制</th></tr></thead><tbody><tr><td>修改允许编辑的收入、成本、原价、直接毛利</td><td>行级毛利、汇总、比率、差额及对账随公式重算。</td><td>原始证据不变;明确标记为本地模拟,不回写服务器。</td></tr><tr><td>修改已有产品的毛利率</td><td>仅 lookup 来源行重算;direct 来源行不受影响。</td><td>不自动新增产品,不静默替换原始毛利。</td></tr><tr><td>筛选或隐藏明细行</td><td>默认仅改变可见行,不改变汇总范围。</td><td>明确 SUMIFS 不随 AutoFilter 改变;若未来要“仅可见行汇总”,作为独立模式实现及测试。</td></tr><tr><td>改变期间、维度、客户、产品集合,追加或删除行</td><td>首版不承诺;从系统重新选择范围并导出。</td><td>Table 能扩展不等于分组报表、去重及状态范围全部正确扩展;锁定结构以防误解。</td></tr><tr><td>修改公式</td><td>默认保护防误操作;允许授权副本解除保护后自行分析。</td><td>解除保护后的结果不属于系统验证范围;工作表保护不是安全边界。</td></tr></tbody></table></div><p>源事实 ID 或稳定行键只用于追溯;本地模拟状态可通过可编辑输入与导出初值比较生成。任何数据版本 SHA 都只标识服务器来源,不能认证已被本地修改的文件。</p></section>
|
||||||
|
<section id="security"><h2>07 / 安全、权限与 33 万行性能</h2><h3>可信公式与不可信内容严格分离</h3><ul><li>公式只由受控模板生成;原始文本、列名、维度标签保持文本类型。保留并扩展当前 =、+、-、@、制表符及换行注入测试。</li><li>无 VBA、DDE、外部工作簿链接或未经允许的 HYPERLINK;扫描 OOXML 关系和公式,不只检查文件扩展名。</li><li>客户去重使用工作簿内稳定的去标识 token。推荐每次导出独立密钥 HMAC,在所有页统一使用,密钥不写入文件;仍按敏感数据管理,不能称为不可逆匿名化。</li><li>申请、执行、下载三个阶段复核组织和科室权限;执行快照只包含授权行,下载前权限被撤销必须拒绝。原始证据页也不能绕过字段权限。</li><li>下载文件私有存储、短期保留、自动清理;审计记录版本、范围、模式、公式版本、结果及文件哈希,不记录患者原值。</li></ul><h3>任务化导出</h3><p>保留旧同步证据入口的行为;新增公式版任务状态:queued → running → succeeded / failed / expired。请求校验后冻结数据版本、映射、筛选、权限范围、参数和公式版本。worker 用分块迭代处理,临时文件原子完成后才发布;支持超时、失败重试、去重请求、崩溃恢复和过期清理。</p><p>真实附件 332,830 行未超过 Excel 单表 1,048,576 行的限制,但当前接口的 100,000 行限制仍然存在。完整公式版必须另做容量评估,超过支持范围应显式拒绝或提示缩小范围,不能悄悄截断。</p><p>性能基准至少覆盖 1,000 / 100,000 / 332,830 行,测导出耗时、峰值内存、文件大小、首次打开、改值重算、并发排队。记录设备、Excel 版本和数据分组基数;本轮不虚构 SLA。高基数分组达到预算时提示缩小报表范围,不降级为静态结果却仍标“公式版”。</p></section>
|
||||||
|
<section id="implementation"><h2>08 / 文件级实施步骤</h2><div class="table-wrap"><table><thead><tr><th>阶段</th><th>工作与目标文件</th><th>完成条件 / 依赖</th></tr></thead><tbody><tr><td>P0 · 口径冻结<br>估计 1–2 人日</td><td>复核 <code>finance/analysis.py</code>、<code>finance/importing.py</code> 与原附件;定义精度政策、公式清单、编辑边界及兼容矩阵;确认毛利来源恢复。</td><td>财务确认收入 N / 实收 I、原价、退款、自营产品、成本与 ROI;否则不可冻结黄金结果。</td></tr><tr><td>P1 · 最小公式工作簿<br>估计 2–4 人日</td><td>新增 <code>finance/exporting.py</code> 集中生成模板、状态和公式;复用现有分析口径,不引入通用公式 DSL。先实现小样本汇总、明细、参数、说明和对账。</td><td>每个衍生单元格可追溯;输入/公式/文本写入类型分离;小样本真实 Excel 重算通过。</td></tr><tr><td>P2 · 精度与隐私<br>估计 1–2 人日</td><td>同模块实现精确文本、数值转换预检、毛利来源、客户 token、来源定位和注入防护;增加针对性单元测试。</td><td>Decimal 对账、去重、缺失、恶意文本及越权用例通过;不破坏证据版。</td></tr><tr><td>P3 · 任务与 API<br>估计 2–3 人日</td><td>在 <code>finance/models.py</code> 增加必要的导出任务与迁移;在 <code>finance/views.py</code> 及现有 URL 配置增加提交/查询/下载入口;新增导出 worker 命令,复用导入 worker 的部署模式。</td><td>完整附件能异步生成;崩溃恢复、重试、权限撤销、留存清理可验证。schema_version、mode、context、compatibility 等字段由服务端白名单校验。</td></tr><tr><td>P4 · 网页交互<br>估计 1–2 人日</td><td><code>frontend/src/dialogs.tsx</code>、<code>types.ts</code>、<code>api.ts</code> 按需调整:模式选择、兼容性与精度说明、任务状态、错误反馈、下载和重试。</td><td>桌面/手机可用;不显示无依据百分比;错误不丢失筛选上下文;CSV 与旧 XLSX 行为清晰保留。</td></tr><tr><td>P5 · 引擎与全量验收<br>估计 3–5 人日</td><td>新增 <code>tests/test_exporting.py</code>、扩展 <code>tests/e2e/workspace.spec.ts</code>;编写受控重算与差异报告脚本;更新 <code>docs/acceptance.md</code>。</td><td>下节所有适用门禁有可复现记录;33 万行性能验证及财务签字完成。</td></tr></tbody></table></div><p>合计粗估 <strong>10–18 人日</strong>,用于排期,不是承诺。受控 Excel 测试环境、财务确认和超预算性能优化可能增加日历时间。P1 小样本原型通过后再投入大文件任务流程;各阶段保持可单独审查和回滚。</p><p>兼容 API:旧 <code>format=xlsx</code> 默认仍为证据快照;新公式模式显式申请任务并返回任务 ID。服务端返回实际导出模式与版本,前端不凭文件后缀推断。</p></section>
|
||||||
|
<section id="tests"><h2>09 / 严格测试与上线门禁</h2><div class="notice"><strong>写入公式不等于公式算对。</strong><p>openpyxl 不执行计算;设置自动重算或 fullCalcOnLoad 也不是测试结果。必须在目标 Excel 引擎执行、保存,再同时读取公式和结果缓存验证。</p></div><div class="table-wrap"><table><thead><tr><th>测试层</th><th>必须覆盖的场景</th><th>通过标准</th></tr></thead><tbody><tr><td>结构与公式覆盖</td><td>每页、命名范围、Table、单元格类型、公式依赖、说明及数据验证;空数据导出。</td><td>衍生区域全覆盖真实公式,基础输入无意外公式;无循环引用、损坏修复提示或外部链接。</td></tr><tr><td>财务黄金样例</td><td>两种 ROI、direct/lookup 毛利、退款、负数、0、缺成本、部分缺字段、缺期、基期0、新增/消失分组。</td><td>与独立 Decimal 预期一致到批准精度;人数完全相等;缺失不能变0。</td></tr><tr><td>客户去重</td><td>同人跨科室/产品/月,重复明细、自营与非自营交集、空客户字段。</td><td>总体不相加子组人数;升单率分子去重正确;缺客户状态与系统一致。</td></tr><tr><td>真实引擎重算</td><td>Excel Windows / Mac;打开重算保存;分别读取 formula 与 data_only;修改收入、成本、原价及毛利率后再次计算。</td><td>受影响链路变化符合独立预期,非依赖指标不误变。扫描 #REF!、#DIV/0!、#VALUE!、#NAME?、#SPILL!;仅允许明确缺失状态对应的 #N/A。</td></tr><tr><td>精度极限</td><td>15位边界、24位小数、极大金额、正负抵消、舍入临界值、33万行累计差异。</td><td>精确文本不变;超出政策明确报警或拒绝;不通过放宽误差掩盖错误。</td></tr><tr><td>注入与越权</td><td>恶意标签/原始公式、工作表名特殊字符、跨组织/科室、任务 ID 猜测、下载前撤权。</td><td>无执行不可信公式、外链或敏感原值泄露;未授权下载拒绝并审计。</td></tr><tr><td>任务可靠性</td><td>重复提交、worker中断、磁盘不足、超时、并发、到期清理。</td><td>无半成品下载、孤儿文件和静默截断;失败可解释,恢复无重复发布。</td></tr><tr><td>前端与兼容回归</td><td>两模式选择、状态、错误重试、下载、移动端;现有 CSV/XLSX。</td><td>浏览器端到端通过;旧证据版仍保持精度与公式注入防护。新公式版测试只允许白名单公式,不删除旧安全断言。</td></tr><tr><td>真实附件全量</td><td>332,830行;2025-08/2026-08及原映射;全量与分组对账。</td><td>行数不缺失,金额满足政策,人数为33,147 / 38,361;缺成本 ROI 不可用,未配置自营范围不擅自补值。</td></tr></tbody></table></div><h3>发布清单(全部待实施验证)</h3><ul><li>□ 口径、精度、编辑范围、支持引擎由财务及产品负责人确认。</li><li>□ 所有指标有公式覆盖与独立数值断言,修改输入后的重算链通过。</li><li>□ 真实 Excel 重算证据包含引擎版本、脚本或步骤、输入哈希、结果与误差报告;LibreOffice 只能证明其自身兼容性。</li><li>□ 原附件全量导出、权限、安全和资源预算通过;大文件首开及改值重算满足商定目标。</li><li>□ 使用说明、列字典、异常状态与原始证据齐全,无患者原始标识泄露。</li><li>□ 灰度开关、失败回退、审计、清理、任务运行监控与运维说明就绪。</li></ul><p>上线先小范围授权用户试用公式模式,旧证据导出保留作为回退。只有 Excel 导出门禁通过,不意味着整个财务系统所有商业上线门禁自动满足。</p></section>
|
||||||
|
<section id="questions"><h2>10 / 需确认的业务决策</h2><div class="grid"><div><h3>可直接采用的工程默认</h3><ul><li>双模式并存,公式版 XLSX 不含宏和外链。</li><li>精确证据 + 数值计算 + 对账,不牺牲原始精度。</li><li>Microsoft 365 为首版公式验收目标。</li><li>固定范围、指定输入可改,结构变化重新导出。</li><li>大文件异步、授权下载、客户去标识。</li></ul></div><div><h3>上线前必须由财务确认</h3><ul><li>收入继续使用 N 列,还是另需 I 列实收口径?</li><li>舍入在每行还是最终汇总发生?使用哪种舍入规则与容差?</li><li>毛利率允许本地模拟吗?直接毛利是否允许修改?</li><li>自营产品集合、退款计人、成本归属及 ROI 口径是什么?</li><li>是否必须支持旧版 Excel/WPS,以及首次打开前就有结果缓存?</li></ul></div></div><p class="recommend"><strong>建议实施顺序:</strong>先确认口径与 Excel 版本 → 小样本真正公式与重算验证 → 33 万行任务化及性能测试 → 前端完整交互与财务签字。不要先把导出值改成数值、加几条 SUM,就标记需求完成。</p></section>
|
||||||
|
</main><footer>本文件是实施计划。现有 XLSX 导出仍为文本证据快照;本轮没有新增公式导出功能,也没有执行尚未实现功能的验收。</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>TraceIQ 完善项与执行路线图</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:light;--ink:#202725;--muted:#58645f;--line:#d8dfdc;--green:#17664f;--amber:#9a5200;--red:#a72f3d;--bg:#f5f7f6;--blue:#315d78}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.7 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}header{background:#fff;border-bottom:3px solid var(--green);padding:28px 20px}main,header>div,footer{max-width:1120px;margin:auto}main{padding:0 20px 40px}h1{font-size:30px;margin:4px 0 8px}h2{font-size:21px;margin:0 0 12px}h3{font-size:17px;margin:0 0 7px}.meta,.muted{color:var(--muted);font-size:13px}.notice{padding:15px 18px;margin:20px 0;background:#fff4e5;border-left:4px solid var(--amber)}section{padding:24px 0;border-bottom:1px solid var(--line)}.item{background:#fff;padding:16px 18px;margin:12px 0;border-left:4px solid var(--line)}.p0{border-color:var(--red)}.p1{border-color:var(--amber)}.p2{border-color:var(--green)}.tag{font-size:12px;font-weight:700;color:var(--muted)}table{border-collapse:collapse;width:100%;background:#fff}th,td{padding:10px 12px;border:1px solid var(--line);text-align:left;vertical-align:top}th{background:#eaf0ed}code{overflow-wrap:anywhere}footer{padding:22px 20px 36px;color:var(--muted)}@media(max-width:680px){h1{font-size:25px}table{font-size:13px}th,td{padding:8px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><div><div class="meta">TRACEIQ / 优先级核查与执行 / 2026-09-24</div><h1>完善项与执行路线图</h1><p>以当前源码、自动化测试和可访问环境为依据。测试通过不等同于业务签字或生产上线。</p></div></header>
|
||||||
|
<main>
|
||||||
|
<div class="notice"><strong>本轮基线:</strong>后端 40 passed,finance 覆盖率 86.72%(85% 门槛);隔离 SQLite Playwright 3 passed、原附件专测 1 skipped;Ruff、格式、Django system check、迁移检查、TypeScript/Vite 构建、npm audit(0 漏洞)与 pip-audit 通过。production 模式 Django <code>check --deploy</code> 零告警。Docker daemon 未运行,本机无 LibreOffice。Microsoft Excel、332,830 行公式工作簿、真实 AI/合规及商业部署仍未验收。</div>
|
||||||
|
<section><h2>P0 · 先确保验收证据可信</h2>
|
||||||
|
<article class="item p0"><div class="tag">P0.1 · 已完成 / 已复核</div><h3>维护真实的源码与验收基线</h3><p>旧文档曾错误声称 <code>tests/test_finance.py</code> 为空并把公式导出标记为未实现;当前源码有 40 项后端测试,公式工作簿、说明页、对话新增列、追问与下载均已实现。本轮路线图按当前代码、测试结果和外部门禁重建。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.2 · 已修复并通过专项回归</div><h3>隐藏导出任务的内部异常</h3><p>状态接口不再把 worker 异常文本发给用户,只返回通用失败提示;前端显示任务编号,数据库保留内部诊断。失败任务脱敏、临时文件删除及状态访问专项 8 passed。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.3 · 本机生产配置检查通过</div><h3>验证 production 部署安全配置</h3><p>production 模式、有效测试密钥、受信 HTTPS 代理下 <code>manage.py check --deploy</code> 零告警。真实域名、TLS 终止及代理头行为仍需部署环境验收。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.4 · 外部目标引擎</div><h3>Microsoft Excel 公式重算与兼容验收</h3><p>目标 Excel Windows/Mac 打开、重算、保存并核对公式及缓存;覆盖缺期、零分母、退款/负值、ROI、毛利来源和输入修改依赖。LibreOffice/openpyxl 不能替代目标 Excel 证据。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.5 · 真实数据与资源基准</div><h3>332,830 行完整公式工作簿</h3><p>公式导出当前限制 100,000 行/200 组。用原附件和可靠 PostgreSQL 验证行数、金额、人数、耗时、峰值内存、文件大小及 Excel 首开/重算;未完成前保持显式拒绝,不静默截断或放宽上限。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.6 · 外部业务/安全责任人</div><h3>财务口径、真实 AI 与商业发布批准</h3><p>签署实际收入与实收、退款、成本、毛利、ROI、自营集合及舍入规则;审批外部模型数据处理/出境并演练故障;完成生产密钥、TLS、备份恢复、SLA、渗透测试和商业许可证核验。审批前 AI 保持关闭。</p></article>
|
||||||
|
</section>
|
||||||
|
<section><h2>P1 · 代码层可靠性与可用性</h2>
|
||||||
|
<article class="item p1"><div class="tag">P1.1 · Docker daemon 未运行</div><h3>镜像与 Compose 运行验收</h3><p>Docker CLI 可用但 daemon 不可连接,本机暂不能构建或启动。daemon 可用后验证镜像、健康检查、worker、清理服务与重启恢复;远端 CI 需仓库平台实际触发。</p></article>
|
||||||
|
<article class="item p1"><div class="tag">P1.2 · 产品范围待确认</div><h3>自定义指标定义保存与复用</h3><p>定义当前只用于当次工作簿,不持久化到主分析页或跨会话定义库。需明确个人/组织共享、版本审批、删除和审计规则,再开发持久化。</p></article>
|
||||||
|
<article class="item p1"><div class="tag">P1.3 · 需产品确认后开发</div><h3>大数据与高基数交互</h3><p>当前汇总按请求返回分组、证据使用分页;AG Grid SSRM/Pivot/Tree Data 等计划功能尚未实现。先以目标数据规模和典型交互决定是否投入服务端行模型,避免未经验证引入商业授权与运维负担。</p></article>
|
||||||
|
</section>
|
||||||
|
<section><h2>P2 · 范围扩展</h2>
|
||||||
|
<article class="item p2"><div class="tag">P2.1 · 财务定义确认后</div><h3>扩展公式语言和缺失策略</h3><p>当前白名单仅支持现有指标/常数和加减乘除,最大 8 层;不接受任意 Excel 文本、字段引用、跨明细新聚合、按零替代或跳过缺失。每项扩展需独立财务黄金样例、注入测试和公式重算验证。</p></article>
|
||||||
|
<article class="item p2"><div class="tag">P2.2 · 目标明确后</div><h3>分析及工作簿操作增强</h3><p>可评估历史分位异常、告警、证据期间切换、明细表结构变更后的公式扩展、模板版本和工作簿内定义管理;需先给出场景和验收指标。</p></article>
|
||||||
|
</section>
|
||||||
|
<section><h2>本轮执行结果</h2><table><thead><tr><th>项目</th><th>结果</th><th>剩余门禁</th></tr></thead><tbody><tr><td>源码与路线图基线</td><td>已完成;纠正文档过时状态</td><td>以当前代码/测试为准</td></tr><tr><td>导出错误脱敏</td><td>8 项专项后端测试通过</td><td>内部诊断仅保留服务端</td></tr><tr><td>Production deploy checks</td><td>零告警</td><td>真实域名、TLS/代理验收</td></tr><tr><td>全量本机回归</td><td>40 passed / 86.72%;Playwright 3 passed、1 skipped</td><td>原附件场景需配置隔离数据集</td></tr><tr><td>商业发布门禁</td><td>尚未通过</td><td>Excel、真实规模、业务/AI审批、容器和灾备</td></tr></tbody></table></section>
|
||||||
|
<section><h2>发布判定原则</h2><p>本地质量门禁全部通过,只证明自动化覆盖范围内的代码状态。商用发布还需要 Microsoft Excel 结果、真实规模资源记录、财务口径签字、AI 数据处理批准和目标基础设施安全/灾备验证。任何一项未满足时,状态保持“未完成发布验收”。</p></section>
|
||||||
|
</main><footer>本文件按核查日建立,执行结果以随后实际运行的命令和目标环境记录更新。</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>财务 Agent 改进路线图</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:light;--ink:#202725;--muted:#58645f;--line:#d8dfdc;--green:#17664f;--amber:#9a5200;--red:#a72f3d;--bg:#f5f7f6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.75 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}header{background:#fff;border-bottom:3px solid var(--green);padding:28px 20px}main,header>div,footer{max-width:1080px;margin:auto}main{padding:0 20px 36px}h1{font-size:30px;margin:4px 0 8px}h2{font-size:21px;margin:0 0 12px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0}.meta{color:var(--muted);font-size:13px}.status{padding:14px 18px;margin:20px 0;background:#fff4e5;border-left:4px solid var(--amber)}section{padding:24px 0;border-bottom:1px solid var(--line)}.item{padding:16px 18px;margin:12px 0;background:#fff;border-left:4px solid var(--line)}.p0{border-color:var(--red)}.p1{border-color:var(--amber)}.p2{border-color:var(--green)}.tag{font-size:12px;font-weight:700;color:var(--muted)}table{border-collapse:collapse;width:100%;background:#fff}th,td{padding:10px 12px;border:1px solid var(--line);text-align:left;vertical-align:top}th{background:#eaf0ed}code{overflow-wrap:anywhere}footer{padding:22px 20px 36px;color:var(--muted)}@media(max-width:640px){h1{font-size:25px}table{font-size:13px}th,td{padding:8px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><div><div class="meta">TRACEIQ / 工程质量与产品改进 / 2026-09-24</div><h1>财务 Agent 改进路线图</h1><p>按财务正确性、测试可信度、业务覆盖和发布风险排序;实现状态与验收状态分开记录。</p></div></header>
|
||||||
|
<main>
|
||||||
|
<div class="status"><strong>当前结论:</strong>对话新增指标、缺项追问、白名单公式编译与公式工作簿已实现;本轮专项后端 6 项、Ruff、前端类型检查及生产构建通过。生产发布仍被空的历史测试源码、公式引擎重算、真实附件规模和财务/AI 合规审批阻断。</div>
|
||||||
|
<section><h2>P0 · 发布阻断</h2>
|
||||||
|
<article class="item p0"><div class="tag">P0.1 · 阻断中</div><h3>恢复完整源码测试基线</h3><p><code>tests/test_finance.py</code> 当前为 0 字节,导致既有财务、权限、导入、分析与公式导出回归测试缺失。先从可靠备份恢复,再运行 pytest、Ruff、Django、迁移、生产构建及 Playwright。旧 .pyc 的 69 passed、1 failed、1 skipped 使用旧断言,不是当前源码验收证据。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.2 · LibreOffice 自定义公式与缓存已验证;Excel 待验</div><h3>公式/缓存一致性验证</h3><p>人数逻辑使用 CustomerRoster + <code>COUNTIFS</code>。本轮从当前源码生成基础及新增列工作簿,交由 LibreOffice 重算后读取;含自定义列的文件保留 136 个公式单元格,自定义“收入成本差”公式缓存及汇总引用缓存均为 2,000。仍需补足缺失期、零分母及负值等边界对账,并在 Microsoft Excel 桌面版复验。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.3 · 待目标环境</div><h3>Microsoft Excel Windows/Mac 验收</h3><p>在目标桌面 Excel 打开、强制重算、保存,读取公式和结果缓存;覆盖缺期、零人数、退款/负数、两种 ROI、毛利来源及编辑金额后的依赖变化。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.4 · 范围阻断</div><h3>332,830 行附件全量导出与资源预算</h3><p>当前上限 100,000 行/200 组。用原映射和可靠 PostgreSQL 验证完整导出、行数/金额/人数对账、耗时、峰值内存、文件体积及 Excel 首开/重算;未经实测不放宽限制,不静默截断。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.5 · 外部决策</div><h3>财务口径与 AI 合规</h3><p>签署实际收入/实收、退款、成本归属、毛利、ROI、自营范围和舍入口径;完成外部模型联调、隐私/数据出境审批及故障演练。批准前保持外部 AI 关闭。</p></article>
|
||||||
|
</section>
|
||||||
|
<section><h2>P1 · 可靠性与运行质量</h2>
|
||||||
|
<article class="item p1"><div class="tag">P1.1 · 已实现;专项 3 项通过</div><h3>过期工作簿与孤儿临时文件清理</h3><p>新增 <code>cleanup_formula_exports</code> 管理命令和 Compose 常驻清理服务。仅清理过期成品及超过 24 小时的临时文件,限制导出根目录,跳过符号链接;清理异常保留重试线索。已覆盖过期文件、临时文件年龄和目录外路径测试。</p></article>
|
||||||
|
<article class="item p1"><div class="tag">P1.2 · 功能实现;单测及新增列 E2E 通过</div><h3>对话定义新增汇总列并导出公式</h3><p>用户可描述列名、含义、计算、范围及缺失/零分母策略;缺项时模型必须追问,确认后服务端只接受白名单表达式树并编译为 Excel 公式。工作簿包含汇总及自定义指标页、使用说明和对账页。专项单测覆盖追问、表达式拒绝与公式输出;本轮 Playwright 已验证登录、字段导入、缺项追问、补充确认、工作簿生成和下载。</p></article>
|
||||||
|
<article class="item p1"><div class="tag">P1.3 · 待目标环境</div><h3>部署、安全与灾备验收</h3><p>完成镜像构建、TLS、数据库认证、备份恢复和 RPO/RTO 演练、并发/SLA 测试、渗透测试、密钥轮换、审计告警及商用依赖许可证核查。</p></article>
|
||||||
|
</section>
|
||||||
|
<section><h2>P2 · 产品能力扩展</h2>
|
||||||
|
<article class="item p2"><div class="tag">P2.1</div><h3>已签口径下补充分析能力</h3><p>多收入字段与消费比率、生效期毛利率、退款切换、结构占比、历史异常阈值和告警投递。</p></article>
|
||||||
|
<article class="item p2"><div class="tag">P2.2</div><h3>自定义指标定义复用</h3><p>定义保存/版本与差异审阅;新运算符、聚合或缺失策略须通过白名单、独立财务样例和安全审查。</p></article>
|
||||||
|
<article class="item p2"><div class="tag">P2.3</div><h3>工作簿及跨平台体验</h3><p>证据期间切换、平板与无障碍优化、可读的公式错误状态、更多电子表格引擎兼容验证。</p></article>
|
||||||
|
</section>
|
||||||
|
<section><h2>完成门禁</h2><table><thead><tr><th>门禁</th><th>证据</th><th>状态</th></tr></thead><tbody>
|
||||||
|
<tr><td>源码测试</td><td>恢复原测试文件并完整执行</td><td>阻断:源码缺失</td></tr>
|
||||||
|
<tr><td>静态与构建</td><td>Ruff、Django、迁移、前端构建</td><td>本轮独立复跑通过;源码恢复后仍需联跑</td></tr>
|
||||||
|
<tr><td>网页工作流</td><td>新增指标、桌面、手机 Playwright</td><td>3 项通过,1 项原附件全量场景按配置跳过</td></tr>
|
||||||
|
<tr><td>公式重算</td><td>当前版本 LibreOffice 与 Microsoft Excel</td><td>LibreOffice 自定义公式重算/缓存通过;边界样例与 Excel 待验</td></tr>
|
||||||
|
<tr><td>真实规模</td><td>原附件全量导出与资源报告</td><td>待验;当前上限 100,000 行</td></tr>
|
||||||
|
<tr><td>商业发布</td><td>财务签字、AI 合规、安全与灾备</td><td>待外部审批/目标环境</td></tr>
|
||||||
|
</tbody></table></section>
|
||||||
|
</main><footer>执行顺序:P0.1 → P0.2 → P0.3/P0.4 → P0.5 → P1 → P2。外部环境和审批不可用时保留为明确门禁,不以模拟结果替代验收。</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>TraceIQ 财务 Agent 改进路线图</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:light;--ink:#202725;--muted:#58645f;--line:#d8dfdc;--green:#17664f;--amber:#9a5200;--red:#a72f3d;--bg:#f5f7f6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.7 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}header{background:#fff;border-bottom:3px solid var(--green);padding:26px 20px}header>div,main,footer{max-width:1080px;margin:auto}main{padding:0 20px 36px}h1{font-size:30px;margin:4px 0 8px}h2{font-size:21px;margin:0 0 12px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0}.meta,.tag{color:var(--muted);font-size:13px}.status{padding:14px 18px;margin:20px 0;background:#fff4e5;border-left:4px solid var(--amber)}section{padding:22px 0;border-bottom:1px solid var(--line)}.item{padding:15px 18px;margin:12px 0;background:#fff;border-left:4px solid var(--line)}.p0{border-color:var(--red)}.p1{border-color:var(--amber)}.p2{border-color:var(--green)}.tag{font-weight:700}table{border-collapse:collapse;width:100%;background:#fff}th,td{padding:9px 11px;border:1px solid var(--line);text-align:left;vertical-align:top}th{background:#eaf0ed}code{overflow-wrap:anywhere}footer{padding:22px 20px 36px;color:var(--muted)}@media(max-width:640px){h1{font-size:25px}table{font-size:13px}th,td{padding:7px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><div><div class="meta">TRACEIQ / 工程质量与产品改进 / 2026-09-24</div><h1>财务 Agent 改进路线图</h1><p>依据当前仓库和本轮实测重排;实现状态、外部验收与生产资格分别记录。</p></div></header>
|
||||||
|
<main>
|
||||||
|
<div class="status"><strong>当前结论:</strong>公式导出审查已修复文本条件、未分类对账、公式注入、任务路径、文件权限与失败恢复问题,并改为流式写入;后端 61 passed、finance 覆盖率 87.66%,LibreOfficeDev 公式重算共 4 项通过。100,000 行产品上限实测峰值 RSS 约 136.4 MB。Microsoft Excel 实机、原附件 332,830 行范围方案、真实 AI/财务审批和目标环境验收仍未完成,不能认定具备生产商用资格。</div>
|
||||||
|
<section><h2>P0 · 正确性与发布阻断</h2>
|
||||||
|
<article class="item p0"><div class="tag">P0.1 · 本机 LibreOffice 回归通过</div><h3>公式重算建立可重复黄金测试</h3><p>当前共 4 项 LibreOfficeDev 重算测试:test_formula_recalculation.py 3 项、test_formula_recalculation_gross.py 1 项,覆盖自定义汇总及变化率、明细收入编辑联动、零成本 ROI、缺失基期及 direct/lookup 毛利来源。它们只证明这些具体样例在 LibreOffice 中的结果,不代表 Microsoft Excel 兼容或财务签字。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.2 · 目标环境门禁</div><h3>Microsoft Excel Windows / Mac 实机验收</h3><p>本机曾以 Microsoft Excel 打开测试工作簿,公式栏确认汇总公式包含 COUNTIFS / SUMIFS;但 Excel 显示只读(View Only / 需激活 Microsoft 365),无法保存重算副本,且检查到的样例分组为 Test、与预期不匹配。故仅有“公式可见”的证据,Excel 重算结果、缓存和业务样例均未验收。后续须在可编辑的目标 Windows/Mac Excel 中用受控正确样例强制重算、保存,并核验函数兼容、数据表引用、错误单元格和输入修改依赖链。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.3 · 100,000 行产品上限已优化并实测</div><h3>流式公式工作簿与原附件范围决策</h3><p>公式版本 3 已将所有工作表改为 openpyxl write-only 顺序写入,同时保留 Data 与 CustomerRoster 表格、结构化引用、说明、证据和对账。隔离 SQLite 合成基准:20,000 行 5.26 秒、3.27 MB、峰值 RSS 77.9 MB;100,000 行 25.96 秒、16.22 MB、峰值 RSS 136.4 MB,ZIP/OOXML 完整性及公式读取通过。生产限制仍为 100,000 行/200 组;真实附件 332,830 行不在当前产品范围,下一步必须选择可审计分卷或明确提高上限后再做原附件流式资源、打开和重算验收。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.4 · 财务审批</div><h3>冻结收入、退款、毛利及 ROI 口径</h3><p>由财务负责人签署 N 列实际收入与 I 列实收、原价、退款、成本归属、毛利率、自营产品集、升单定义与舍入精度。原附件成本全缺,ROI 应维持不可用;不得以技术测试替代业务签字。</p></article>
|
||||||
|
<article class="item p0"><div class="tag">P0.5 · 隐私 / 模型外部审批</div><h3>外部 AI 真实联调与数据处理审批</h3><p>在批准前保持 AI 关闭。审批后验证模型版本、超时、限流、拒答、审计脱敏、数据处理与出境依据;模型只提出列定义,服务端继续严格校验白名单表达式并要求用户确认。</p></article>
|
||||||
|
</section>
|
||||||
|
<section><h2>P1 · 商用运维与可恢复性</h2>
|
||||||
|
<article class="item p1"><div class="tag">P1.1 · 本地待执行 / 环境受限</div><h3>Docker 镜像与生产 Compose 验收</h3><p>本轮完整生产变量下 Django <code>check --deploy</code> 无告警,依赖审计为零已知漏洞;Docker daemon 当前不可连接,镜像构建尚未验证。daemon 恢复后构建镜像、运行迁移和健康检查,确认非 root、只读根文件系统、卷权限及 worker。</p></article>
|
||||||
|
<article class="item p1"><div class="tag">P1.2 · 目标环境门禁</div><h3>备份恢复、并发和安全运营</h3><p>验收 TLS、密钥轮换、最小权限、加密备份及恢复 RPO/RTO、队列积压/失败告警、访问日志脱敏、容量阈值、并发 SLA 和独立安全审查;AG Grid Enterprise 使用前确认商业许可。</p></article>
|
||||||
|
<article class="item p1"><div class="tag">P1.3 · 部分已实现;范围受限</div><h3>对话定义新增汇总列并导出公式</h3><p>用户可描述列名、含义、计算、适用范围及缺失处理;信息不全时追问并结合完整对话复核。公式只使用数据集当前可计算的既有指标与白名单运算,服务端再次校验数据映射;确认后写入公式工作簿并带说明。任意 Excel 函数、跨明细新聚合、定义保存复用及导出后自动扩行未实现。</p></article>
|
||||||
|
</section>
|
||||||
|
<section><h2>P2 · 扩展能力</h2>
|
||||||
|
<article class="item p2"><div class="tag">P2.1</div><h3>签字口径下扩展分析</h3><p>多收入字段与消费比率、生效期毛利率、退款策略、结构占比、历史异常阈值和告警投递。</p></article>
|
||||||
|
<article class="item p2"><div class="tag">P2.2</div><h3>跨平台与辅助功能</h3><p>补齐平板、键盘/屏幕阅读器、证据期间切换和目标表格软件兼容矩阵;Web 可复用不等同于 Excel 文件跨引擎兼容。</p></article>
|
||||||
|
</section>
|
||||||
|
<section><h2>本轮证据</h2><table><thead><tr><th>项目</th><th>结果</th><th>边界</th></tr></thead><tbody>
|
||||||
|
<tr><td>后端及覆盖率</td><td>61 passed;finance 87.66%</td><td>SQLite 测试;包含流式工作簿、公式语义、注入防护、任务路径/权限/恢复及 LibreOffice 重算;未替代 PostgreSQL CI</td></tr>
|
||||||
|
<tr><td>LibreOffice 公式重算</td><td>共 4 项通过(test_formula_recalculation.py 3 项、test_formula_recalculation_gross.py 1 项),direct/lookup 毛利来源测试包含在这 4 项内</td><td>LibreOfficeDev;Microsoft Excel 仅确认公式可见,未完成可保存重算验收</td></tr>
|
||||||
|
<tr><td>流式容量基准</td><td>100,000 行:25.96 秒、16.22 MB、峰值 RSS 136.4 MB;20,000 行:5.26 秒、3.27 MB、峰值 RSS 77.9 MB</td><td>隔离 SQLite 合成数据,本机单次测量;生产上限保持 100,000 行,不能替代目标环境并发 SLA</td></tr>
|
||||||
|
<tr><td>浏览器回归</td><td>3 passed,1 skipped</td><td>跳过需显式指定附件的原附件 E2E</td></tr>
|
||||||
|
<tr><td>构建 / 静态检查</td><td>Ruff、Prettier、TypeScript/Vite、Django check、迁移检查通过</td><td>依赖审计报告 0 个已知漏洞;不等于渗透测试</td></tr>
|
||||||
|
<tr><td>生产配置检查</td><td>完整临时生产配置下 <code>check --deploy</code> 无告警</td><td>不代表目标基础设施上线验收</td></tr>
|
||||||
|
<tr><td>Compose / Docker</td><td>未通过</td><td>仓库无真实 .env;Docker daemon 未运行,未构建镜像</td></tr>
|
||||||
|
<tr><td>Excel / 原附件公式</td><td>Excel 打开样本并确认公式存在;PostgreSQL 对账完成;旧非流式版本曾生成 332,830 行</td><td>Excel 只读,无法保存重算副本;所见分组与预期不匹配。原附件超过 100,000 行产品上限,公式版本 3 尚未对 332,830 行做越界放宽测试</td></tr>
|
||||||
|
</tbody></table></section>
|
||||||
|
<section><h2>执行顺序与完成规则</h2><p>顺序:维护 P0.1 黄金重算 → P0.2 Microsoft Excel → P0.3 资源优化 → P0.4 / P0.5 外部审批 → P1 → P2。本轮补齐了可重复的金额编辑、零成本与缺失基期重算测试,并复核新增列映射保护。LibreOffice 不替代 Excel,财务/AI 审批和目标基础设施测试需对应责任方及环境。</p><p>测试必须覆盖代码实际行为;每项门禁记录日期、环境、版本、样例、结果和限制。不能用 mock、静态公式存在或 build 通过声称生产已验收。</p></section>
|
||||||
|
</main><footer>路线图是执行与验收记录,不是生产准入授权。详见 <a href="acceptance.md">验收状态与上线门禁</a>。</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>财务 Agent:剩余问题与修复清单 · 2026-09-26</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:light;--ink:#202925;--muted:#57635d;--line:#d7dfda;--green:#17664f;--red:#a52b40;--amber:#875408}*{box-sizing:border-box}body{margin:0;background:#f7f9f8;color:var(--ink);font:15px/1.8 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;letter-spacing:0}header{background:#fff;border-bottom:3px solid var(--green);padding:32px 22px}header>div,main,footer{max-width:1120px;margin:auto}main{padding:0 22px 40px}h1{font-size:30px;line-height:1.4;margin:8px 0}h2{font-size:23px;margin:0 0 14px}h3{font-size:18px;margin:0 0 10px}p{margin:8px 0}small,.muted{color:var(--muted)}nav{display:flex;flex-wrap:wrap;gap:10px 22px;padding:18px 0}a{color:var(--green);text-underline-offset:3px}section{padding:25px 0;border-bottom:1px solid var(--line)}article{padding:18px 0;border-top:1px solid var(--line)}article:first-of-type{border-top:0}.notice{padding:16px 20px;border-left:4px solid var(--amber);background:#fff4de;margin:20px 0}.critical{border-color:var(--red);background:#fff0f1}.ok{color:var(--green)}.p0{color:var(--red)}.p1{color:var(--amber)}.tag{font-size:13px;font-weight:700}table{width:100%;border-collapse:collapse;background:#fff}th,td{padding:11px;border:1px solid var(--line);text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#eaf0ed}ul,ol{padding-left:23px}li{margin:6px 0}code{font-size:13px;overflow-wrap:anywhere}pre{white-space:pre-wrap;background:#edf1ef;padding:14px;overflow-wrap:anywhere}.table-wrap{overflow-x:auto}footer{padding:24px 22px;color:var(--muted)}@media(max-width:640px){h1{font-size:25px}h2{font-size:21px}main{padding-inline:16px}th,td{padding:8px;font-size:13px}.table-wrap table{min-width:650px}}@media print{body{background:#fff}nav{display:none}section,article{break-inside:auto}h2,h3{break-after:avoid}.table-wrap{overflow:visible}a{color:inherit}header{padding:10px 0}main{padding:0}.notice{border:1px solid #777}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><div><small>TRACEIQ / 工程现状与发布门禁 / 2026-09-26 / Asia/Singapore</small><h1>财务 Agent:剩余问题与修复清单</h1><p>面向下一轮开发、财务验收及生产部署。按当前源码和最近完成的测试记录整理,不将历史计划或测试通过等同于商用准入。</p></div></header>
|
||||||
|
<main>
|
||||||
|
<nav aria-label="报告目录"><a href="#summary">结论</a><a href="#baseline">已完成与测试</a><a href="#bugs">需要修复的代码问题</a><a href="#gaps">剩余功能</a><a href="#gates">外部门禁</a><a href="#sequence">推荐顺序</a><a href="#acceptance">验收清单</a></nav>
|
||||||
|
<section id="summary"><h2>一、结论与阅读方式</h2>
|
||||||
|
<div class="notice critical"><strong>目前不能认定达到生产环境商用标准。</strong><p>公式导出、说明页、对话追问、新增公式列、个人定义保存复用与证据分页已经实现;仍有需要修复的安全/交互边界、未完成的产品能力,以及必须在目标 Excel、数据库、基础设施和业务审批环境关闭的门禁。</p></div>
|
||||||
|
<p><strong>本次交付仅新增本报告,不修改业务代码、不执行新的数据库迁移。</strong>以下“已完成”包括此前连续开发完成的改动;“静态确认”指源码可直接看出的行为,尚未新增专项复现测试,不能冒充测试复现结果。</p>
|
||||||
|
<ul><li><strong>P0:</strong>影响敏感文件安全、核心财务正确性或生产准入,正式上线前必须关闭。</li><li><strong>P1:</strong>影响日常操作可靠性、可解释性、协作和可维护性,应在受控试用前或紧接其后完成。</li><li><strong>P2:</strong>扩展能力,按合同范围和真实业务优先级开发,不因存在依赖或设计稿就视为已实现。</li></ul>
|
||||||
|
</section>
|
||||||
|
<section id="baseline"><h2>二、当前已经完成什么</h2>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>能力</th><th>已完成状态</th><th>不能据此推断的内容</th></tr></thead><tbody>
|
||||||
|
<tr><td>公式工作簿</td><td>版本 3,流式生成计算明细、汇总、使用说明、参数口径、人数去重、原始证据、对账;有自定义列时增加自定义指标页。</td><td>不是任意 Excel 模板生成器;不是全部输入/结构变化都能正确重算。</td></tr>
|
||||||
|
<tr><td>对话新增列</td><td>描述不全时追问,补充后确认;服务端校验表达式树及数据映射可用性。支持既有指标、常数、加减乘除。</td><td>真实外部模型未完成联调;自然语言适用范围不等于执行任意筛选、聚合规则。</td></tr>
|
||||||
|
<tr><td>个人定义库</td><td>按用户与数据集隔离,保存、同名更新、列出、载入、删除;保存和导出时校验;迁移文件 0004 已存在,隔离测试库已执行。</td><td>不是团队共享定义库;没有草稿续聊、版本审批、并发编辑冲突保护。非测试环境是否迁移仍需核对。</td></tr>
|
||||||
|
<tr><td>证据与文件导出</td><td>本期/基期切换、每页 50 条及翻页;CSV/XLSX 证据以临时文件和 FileResponse 分块发送;金额文本和公式注入防护保留。</td><td>前端仍用 Blob 接收完整下载文件;临时磁盘/tmpfs、并发容量尚无生产 SLA。</td></tr>
|
||||||
|
<tr><td>导出可靠性</td><td>任务专属路径、0600 文件与 0700 目录、失败状态恢复、随机临时文件、授权范围检查、过期清理。</td><td>并非所有符号链接异常路径都安全,见 F01;也未完成磁盘故障及生产崩溃恢复演练。</td></tr>
|
||||||
|
</tbody></table></div>
|
||||||
|
<h3>最近完整测试记录</h3>
|
||||||
|
<ul><li><strong>后端:65 passed,95.12 秒;finance 覆盖率 87.63%,85% 门槛通过。</strong>SQLite 测试,包含定义库及导出回归。</li><li><strong>真实公式重算:4 项 LibreOffice 测试通过。</strong>覆盖自定义汇总、变化率、金额编辑联动、零成本、缺基期和 direct/lookup 毛利。不能替代 Microsoft Excel 实机。</li><li><strong>浏览器:3 passed,1 skipped,24.7 秒。</strong>新隔离数据库,覆盖追问、保存定义、关闭重开、复用后生成,另含桌面、手机、证据翻页及下载。AI 响应采用 mock;分页第 51 条交互使用 mock 响应,后端分页另有 API 测试。</li><li>跳过项:未设置 <code>REFERENCE_DATASET_NAME</code> 的原附件全量 UI 用例,不属于通过项。</li><li>此前已通过 Ruff lint/format、TypeScript/Vite、Django check、迁移一致性检查;2026-09-24 的 npm 生产依赖审计与 pip-audit 为零已知漏洞。这些不是本报告生成时重新运行的结果。</li><li>历史合成容量基准:100,000 行生成 25.96 秒,产物约 16.22 MB,峰值 RSS 约 136.4 MB;单机单次、非并发、非目标 Excel 重算性能。</li></ul>
|
||||||
|
<p class="muted">现有 acceptance.md / improvement-roadmap.html 仍存在 61 项测试、“没有定义库”等旧表述;历史 Excel 计划也有相互矛盾的状态提示。当前状态以本报告与最近完成的实测为准,后续应统一维护单一验收入口。</p>
|
||||||
|
</section>
|
||||||
|
<section id="bugs"><h2>三、需要继续修复的代码问题</h2>
|
||||||
|
<article><div class="tag p0">F01 · P0 · 静态确认 · 文件安全</div><h3>失败清理可能沿符号链接父目录删除非任务文件</h3><p><strong>位置:</strong><code>finance/exporting.py → process_formula_export()</code>。先设置 target,再检查 target.parent 是否为符号链接;拒绝后进入异常分支,仅检查 path.is_symlink(),未检查父目录。父目录为链接而最终文件本身为普通文件时,unlink 会跟随父目录删除链接目标下同名文件。</p><p><strong>影响:</strong>需要本地存储目录被操纵这一前提,不能描述为普通远程用户已可利用;但违背“异常清理只删除自己创建的文件”的边界。</p><p><strong>推荐修复:</strong>路径完整验证前不要将目标登记为可清理文件;只清理本次实际创建、且位于受控目录中的文件,必要时用目录描述符及不跟随链接的文件操作。不要用失败清理代替旧孤儿文件回收。</p><p><strong>验收:</strong>父目录链接到受保护目录,预置同名文件;执行必须失败且受保护文件字节不变,任务为 failed;覆盖根目录链接、目标链接、写盘失败与正常重试。</p></article>
|
||||||
|
<article><div class="tag p1">F02 · P1 · 静态确认 · 故障清理</div><h3>清理失败被忽略,敏感孤儿文件缺少回收路径</h3><p><strong>位置:</strong><code>finance/exporting.py</code> 与 <code>cleanup_formula_exports.py</code>。异常清理吞掉 OSError 后清空 job.path;清理器主要处理已成功且过期的文件及旧 .tmp,失败任务残留 .xlsx 可能不再被追踪。</p><p><strong>修复:</strong>记录不含敏感数据的清理失败日志及任务编号,为安全范围内的孤儿 .xlsx 制定保留期与回收规则;不要删除仍处于活动任务的文件。</p><p><strong>验收:</strong>注入 unlink 权限错误,任务失败状态仍保存;可观察到告警,恢复权限后回收成功,其他任务文件不受影响。</p></article>
|
||||||
|
<article><div class="tag p1">F03 · P1 · 静态确认 · 证据弹窗</div><h3>重复点击当前期间可能让已加载列表停在“加载中”</h3><p><strong>位置:</strong><code>frontend/src/dialogs.tsx → EvidenceDialog.selectSide()</code>。第 0 页点击当前已选期间会清空 result,但 side/page 没变化,依赖这些值的 effect 不会重新请求。</p><p><strong>修复:</strong>同期间同页直接返回,或明确触发刷新;补加载、空集、失败重试状态。</p><p><strong>验收:</strong>当前期间重复点击、快速本期/基期切换、第二页切期间、空列表和请求失败均不显示过期数据或永久加载。</p></article>
|
||||||
|
<article><div class="tag p1">F04 · P1 · 静态确认 · 定义复用后的对话</div><h3>载入已保存定义后追问,没有把原定义带给模型</h3><p><strong>位置:</strong><code>FormulaExportDialog.loadDefinition()/send()</code>。载入定义会清空 messages,随后只发送新增描述;“把刚才公式改成乘以 100”之类指代不能可靠关联已载入表达式。</p><p><strong>修复:</strong>在可见、经同意的对话上下文中明确附带原定义,或建立服务端定义引用;仍要求重新确认,不让旧定义静默覆盖新解释。选择空选项也应同步清理当前定义,避免选择状态与预览不一致。</p><p><strong>验收:</strong>保存 A、重开载入 A、发送依赖 A 的修改,检查模型请求实际包含 A;未发送补充时禁止生成,修改后生成的公式符合新定义。</p></article>
|
||||||
|
<article><div class="tag p1">F05 · P1 · 静态确认 · 定义库并发与审计</div><h3>50 条上限不是原子约束,同名更新没有冲突检测</h3><p><strong>位置:</strong><code>finance/formula_views.py → formula_definitions()</code>。先 count 再 update_or_create,并发不同名字可能突破上限;同名更新采用最后写入覆盖,未携带 revision。保存/删除与审计也不在同一外层事务内。</p><p><strong>修复:</strong>事务内锁定稳定的用户/数据集所属行,原子检查配额;引入 revision 和 409 冲突响应;变更和审计一同提交,保存增加合理限流。前端明确同名更新行为。</p><p><strong>验收:</strong>PostgreSQL 并发保存不能产生第 51 条;两个标签页更新不静默丢失;模拟审计写入失败后定义变更回滚。</p></article>
|
||||||
|
<article><div class="tag p1">F06 · P1 · 静态确认 · 工作簿可解释性</div><h3>“产品毛利率可编辑”说明与实际公式引用不一致</h3><p><strong>位置:</strong><code>finance/exporting.py → 参数与口径 / GrossCalc</code>。参数页毛利率写成字典文本并标为可编辑,实际明细公式引用每行 MarginRate,修改参数页文本不会联动。说明页还提到“本页分组维度”,但没有完整的维度键与显示名称字典。</p><p><strong>推荐:</strong>近期明确参数页为只读快照,写清需要修改计算明细的哪类输入;如必须按产品统一模拟,则实现真实产品毛利率参数表与 lookup 依赖,并验证 direct 毛利不受影响。</p><p><strong>验收:</strong>每个标为可编辑的输入都对应真实依赖;收入、成本、原价、直接毛利、lookup 毛利率分别改值重算,说明页列出字段、单位、来源、缺失含义和固定结构边界。</p></article>
|
||||||
|
<article><div class="tag p1">F07 · P1 · 静态确认 · 财务校验范围</div><h3>对账采用统一 0.01 阈值,自定义公式没有独立服务器参考</h3><p><strong>位置:</strong><code>finance/exporting.py → 对账检查</code>。人数、金额、比率共享差异阈值;对小数形式的比率,0.01 是一个百分点,未必适用业务。自定义列标记“需业务验收”,不是自动对账通过。</p><p><strong>修复:</strong>财务批准后按指标配置比较规则:人数严格相等、金额按币种和舍入精度、比率独立阈值;用 Decimal 白名单表达式解释器计算自定义指标参考,不能执行任意代码。保持原始金额文本不变。</p><p><strong>验收:</strong>阈值边界、大数、抵消、极小值、除零、缺失和多层表达式独立黄金样例通过;不得为消除报错而放宽容差。</p></article>
|
||||||
|
<article><div class="tag p1">F08 · P1 · 验证缺口 · 权限撤销与任务生命周期</div><h3>执行/下载阶段需要覆盖角色撤销、超时与崩溃恢复</h3><p>当前有组织、任务所有者、科室范围检查,但 worker 不重新要求 admin/analyst 角色;下载接口也未声明同创建接口的角色白名单。是否允许被降为 viewer 的账号下载旧导出,需要明确政策,不能默认授权语义已完整。</p><p><strong>推荐:</strong>敏感公式工作簿在申请、执行、下载时统一核验角色、组织、账户有效性和科室;补任务超时、重复请求、过期下载、worker 发布后崩溃的测试及运维告警。</p><p><strong>验收:</strong>排队后撤权、成功后撤权均按签署政策处理;半成品不能下载;重启不重复发布或无限停留 processing。生产 PostgreSQL 锁语义单独验证。</p></article>
|
||||||
|
</section>
|
||||||
|
<section id="gaps"><h2>四、尚未完成的功能与产品边界</h2>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>优先级 / 功能</th><th>当前缺口</th><th>建议与完成标准</th></tr></thead><tbody>
|
||||||
|
<tr><td>P1 · 新列出现在网页汇总表</td><td>当前新增指标主要进入导出的自定义指标页,并未成为主分析表持久化可计算列。</td><td>若原需求包含网页表格,需新增受控服务器计算、列选择与展示;网页结果、导出公式、Decimal 参考使用同一语义,缺失不转零。</td></tr>
|
||||||
|
<tr><td>P1 · 多列编辑与定义生命周期</td><td>API 最多 10 列,但对话弹窗生成时仅提交当前单列;未完成草稿续聊、版本历史、团队共享和审批。</td><td>先做当前数据集的多列列表与显式确认,后做草稿。个人定义保存复用已完成,不应重复开发。团队共享另设权限和版本策略。</td></tr>
|
||||||
|
<tr><td>P1 · 适用范围与表达式能力</td><td>scope 为说明文本,实际按当前分组、本期/基期计算;不能表达任意条件、跨明细新聚合、任意 Excel 函数、维度列或其他缺失策略。</td><td>模型明确拒绝不支持范围,不用自然语言说明冒充已执行规则。扩展时结构化范围并加入服务端白名单及黄金测试。</td></tr>
|
||||||
|
<tr><td>P1 · Excel 编辑防误操作</td><td>人数辅助表、期间、身份、维度为固定快照;追加/删除明细不能保证全部汇总与去重同步。输入验证、公式保护、完整字段字典未全部实现。</td><td>首版推荐固定结构、明确可编辑金额列并防误操作;结构改变需重新导出。表保护不是加密或真正权限控制。</td></tr>
|
||||||
|
<tr><td>P1 · 原附件 332,830 行导出</td><td>当前产品仍限制 100,000 行 / 200 组;不能一次完整导出原附件。</td><td>推荐可审计分卷并保留全局汇总/去重口径,不能把分卷人数简单相加。备选是正式提高上限后验收内存、并发、Excel 首开和改值重算,不能仅改常量。</td></tr>
|
||||||
|
<tr><td>P1 · 结果缓存与大文件下载</td><td>openpyxl 不执行公式;未重算文件的预览/缓存读取可能为空。浏览器 Blob 下载完整驻留内存;生产 /tmp 为 tmpfs,临时文件仍计入容器内存。</td><td>明确必须由目标引擎重算;如需要服务器生成已缓存副本,独立评估计算引擎。大文件直接附件下载及临时存储配额需实测。</td></tr>
|
||||||
|
<tr><td>P1 · 高基数与可访问性</td><td>聚合分组全量返回;缺高基数结果窗口、平板专项、键盘/屏幕阅读器专项。浏览器自动测试不等于人工可访问性验收。</td><td>先测真实高基数与并发,再决定分页或 SSRM;补 focus、Esc 关闭、错误提示、表头关联与触控专项。</td></tr>
|
||||||
|
<tr><td>P2 · 扩展业务能力</td><td>多收入字段口径、消费比率、生效期毛利率、退款策略、结构占比、历史阈值、告警渠道和团队调查分派未完整实现。</td><td>先冻结业务定义,再逐项开发,不擅自以现有收入字段替代不同含义。</td></tr>
|
||||||
|
<tr><td>P2 · 跨平台与高级网格</td><td>已有响应式 Web,没有原生客户端;AG Grid SSRM/Pivot/Tree Data/Master-Detail 未完整接入。</td><td>Web 是复用基础,不等于原生交付。按真实需求和商业授权选择 PWA/桌面封装或高级网格。</td></tr>
|
||||||
|
</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
<section id="gates"><h2>五、必须外部完成的发布门禁</h2>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>门禁</th><th>现状 / 责任方</th><th>关闭证据</th></tr></thead><tbody>
|
||||||
|
<tr><td>P0 · Microsoft Excel 实机</td><td>历史只读 Excel 仅确认公式可见,无法保存重算;样例分组也与预期不符。财务、QA、目标终端负责人。</td><td>在可编辑 Windows/Mac Excel 用受控样例打开、强制重算、保存;读取公式和结果缓存,改值后依赖链正确,无非预期错误与修复提示。</td></tr>
|
||||||
|
<tr><td>P0 · 财务签字与精度政策</td><td>原附件收入/实收定义有歧义,成本全缺;财务负责人。</td><td>签署收入、退款、原价、成本归属、毛利、ROI、自营、升单和容差。缺成本 ROI 保持不可用;不以技术测试替代业务认可。</td></tr>
|
||||||
|
<tr><td>P0 · AI 真实联调及隐私</td><td>外部响应主要以 mock 验证;对话文本与筛选标签可能包含敏感信息。合规、数据负责人、AI 服务负责人。</td><td>审批数据处理/出境、模型版本、留存策略;真实验证超时、拒答、限流、非法输出、复杂 schema 接受度。批准前保持 AI 关闭。</td></tr>
|
||||||
|
<tr><td>P0 · 目标基础设施</td><td>历史 Docker daemon 不可用、真实 .env 不存在;配置已交付,不等于构建和运行已验收。运维、安全。</td><td>目标镜像构建、迁移 0004、非 root/卷权限、TLS、代理、数据库认证、队列、备份恢复、日志脱敏、安全扫描与许可证通过。</td></tr>
|
||||||
|
<tr><td>P1 · 当前版本 PostgreSQL 与 SLA</td><td>最近 65 项全量是 SQLite;历史 PostgreSQL 对账/测试不能替代本版并发和锁语义。</td><td>在独立 PostgreSQL 执行当前测试、并发定义保存、worker 恢复、导出压力;记录 P95、资源预算、备份恢复 RPO/RTO。</td></tr>
|
||||||
|
<tr><td>P1 · 安全运营和许可</td><td>没有完整 SSO/MFA、自助找回、数据库 RLS、不可变审计和外部告警;并非所有产品必须全选,但必须作安全决策。</td><td>风险评估明确必需项;验证加密存储、密钥轮换、权限回收和安全审查;启用 Enterprise 前取得对应商业许可。</td></tr>
|
||||||
|
</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
<section id="sequence"><h2>六、推荐修复顺序</h2>
|
||||||
|
<ol><li><strong>安全收尾:</strong>先 F01、F02、F08,明确文件清理边界与撤权政策,添加失败注入和越权回归。</li><li><strong>日常交互:</strong>修 F03、F04;补定义库删除、空选项、失败重试、快速切换和重复点击 E2E。</li><li><strong>持久化可靠性:</strong>修 F05,做 PostgreSQL 事务、配额和冲突测试;迁移验证应覆盖既有数据库。</li><li><strong>工作簿真实性:</strong>修 F06,完善输入/参数字典与保护;F07 的容差需先由财务确认,再增加独立数值对账。</li><li><strong>补齐原始用户目标:</strong>确认网页主汇总表也需要新列后,实现网页计算、多列选择与导出一致性;完成草稿续聊。</li><li><strong>发布前:</strong>解决原附件范围,执行 Excel、真实 AI、财务、安全、基础设施和并发验收。任何一项未关闭都保留明确限制,不标记“全功能商用完成”。</li></ol>
|
||||||
|
<p>没有给出未经测量的工期承诺。前三项主要由工程承担;财务口径、数据处理审批和目标环境依赖必须明确责任人,不能通过延长本地编码替代。</p>
|
||||||
|
</section>
|
||||||
|
<section id="acceptance"><h2>七、下一轮验收与交付清单</h2>
|
||||||
|
<ul><li>为每个修复加入“修复前失败、修复后通过”的专项断言;全量后端覆盖率不低于 85%,同时保留财务黄金样例。</li><li>安全回归包含父目录链接、文件权限、清理失败、撤权后执行/下载、同名定义并发更新与配额边界。</li><li>LibreOffice 和目标 Excel 分别记录版本、输入样例、公式、缓存结果与修改后差异,不合并为笼统的“Excel 测试通过”。</li><li>运行桌面/手机 E2E;指定隔离原附件数据集后执行目前跳过的用例。不得用真实生产账号或生产数据环境运行写入型测试。</li><li>Ruff、格式、TypeScript、构建、Django、迁移一致性、依赖审计通过;Docker/PostgreSQL 等环境受限项明确记录未执行原因。</li><li>交付更新后的测试记录、迁移说明、部署/回滚步骤、问题状态及新 HTML。保留历史基准,但标题应明确“历史”,消除多个文档的冲突状态。</li></ul>
|
||||||
|
<h3>源码与相关材料</h3>
|
||||||
|
<p><a href="../finance/exporting.py">公式生成与任务发布</a> · <a href="../finance/formula_views.py">定义库与导出 API</a> · <a href="../finance/views.py">证据导出与分页</a> · <a href="../frontend/src/dialogs.tsx">网页对话与证据交互</a> · <a href="../tests/">测试目录</a></p>
|
||||||
|
<p><a href="acceptance.md">既有验收记录(部分状态待同步)</a> · <a href="improvement-roadmap.html">历史路线图</a> · <a href="excel-formula-export-plan.html">最初 Excel 实施方案(历史设计,不代表现状)</a></p>
|
||||||
|
</section>
|
||||||
|
</main><footer>本报告不包含患者明细、凭据或真实财务原值。源码静态判断、已完成自动化测试、产品范围选择和正式生产验收分别记录;本报告不是上线授权。</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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)})"
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from finance.models import Membership, Organization
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Create the initial organization administrator without a default password"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--username", required=True)
|
||||||
|
parser.add_argument("--organization", required=True)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
username = options["username"]
|
||||||
|
if User.objects.filter(username=username).exists():
|
||||||
|
raise CommandError("Username already exists; no changes made")
|
||||||
|
password = os.environ.get("BOOTSTRAP_PASSWORD") or getpass.getpass("Administrator password: ")
|
||||||
|
validate_password(password, User(username=username))
|
||||||
|
with transaction.atomic():
|
||||||
|
org = Organization.objects.create(name=options["organization"])
|
||||||
|
user = User.objects.create_user(username=username, password=password)
|
||||||
|
Membership.objects.create(user=user, organization=org, role="admin")
|
||||||
|
self.stdout.write("Administrator created")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from finance.models import FormulaExport
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Remove expired formula workbooks and stale temporary files"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--loop", action="store_true")
|
||||||
|
parser.add_argument("--interval", type=int, default=3600)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
while True:
|
||||||
|
jobs, files = self.clean_expired()
|
||||||
|
self.stdout.write(f"Expired {jobs} jobs; removed {files} files")
|
||||||
|
if not options["loop"]:
|
||||||
|
return
|
||||||
|
time.sleep(max(60, options["interval"]))
|
||||||
|
|
||||||
|
def clean_expired(self):
|
||||||
|
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||||
|
if export_root.is_symlink():
|
||||||
|
raise ValueError("公式导出目录不能是符号链接")
|
||||||
|
root = export_root.resolve()
|
||||||
|
now = timezone.now()
|
||||||
|
jobs = files = 0
|
||||||
|
|
||||||
|
for job in FormulaExport.objects.filter(status="succeeded", expires_at__lte=now).iterator():
|
||||||
|
path = Path(job.path) if job.path else None
|
||||||
|
resolved = path.resolve() if path and path.is_absolute() and not path.is_symlink() else None
|
||||||
|
in_root = bool(resolved and resolved.is_relative_to(root))
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
if in_root and resolved.is_file():
|
||||||
|
resolved.unlink()
|
||||||
|
files += 1
|
||||||
|
job.status, job.path = "expired", ""
|
||||||
|
job.save(update_fields=["status", "path"])
|
||||||
|
jobs += 1
|
||||||
|
except OSError as exc:
|
||||||
|
self.stderr.write(f"Could not remove expired export {job.pk}: {exc}")
|
||||||
|
|
||||||
|
cutoff = (now - timedelta(hours=24)).timestamp()
|
||||||
|
if root.is_dir() and not root.is_symlink():
|
||||||
|
for user_dir in root.iterdir():
|
||||||
|
if user_dir.is_symlink() or not user_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for path in user_dir.glob("*.tmp"):
|
||||||
|
try:
|
||||||
|
if path.is_symlink() or not path.is_file() or path.stat().st_mtime > cutoff:
|
||||||
|
continue
|
||||||
|
path.unlink()
|
||||||
|
files += 1
|
||||||
|
except OSError as exc:
|
||||||
|
self.stderr.write(f"Could not remove stale temporary export {path.name}: {exc}")
|
||||||
|
|
||||||
|
return jobs, files
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.files import File
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from finance.importing import file_digest, profile_file, suggest_mapping, validate_mapping
|
||||||
|
from finance.models import Audit, Dataset
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Import a local source using reviewed field aliases and lookup rates"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("path")
|
||||||
|
parser.add_argument("--username", required=True)
|
||||||
|
parser.add_argument("--confirm-mapping", action="store_true")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
user = User.objects.get(username=options["username"])
|
||||||
|
if user.membership.role != "admin" or user.membership.departments:
|
||||||
|
raise CommandError("Full administrator required")
|
||||||
|
path = Path(options["path"])
|
||||||
|
profile = profile_file(path)
|
||||||
|
mapping = validate_mapping(suggest_mapping(profile), profile)
|
||||||
|
if not options["confirm_mapping"]:
|
||||||
|
self.stdout.write(str(mapping))
|
||||||
|
self.stdout.write("Review mapping, then rerun with --confirm-mapping")
|
||||||
|
return
|
||||||
|
with path.open("rb") as source:
|
||||||
|
file = File(source, name=path.name)
|
||||||
|
digest = file_digest(file)
|
||||||
|
ds = Dataset.objects.filter(organization=user.membership.organization, sha256=digest).first()
|
||||||
|
if ds:
|
||||||
|
self.stdout.write(f"Existing version: {ds.pk} {ds.status}")
|
||||||
|
return
|
||||||
|
ds = Dataset.objects.create(
|
||||||
|
organization=user.membership.organization,
|
||||||
|
created_by=user,
|
||||||
|
name=path.name,
|
||||||
|
file=file,
|
||||||
|
sha256=digest,
|
||||||
|
mapping=mapping,
|
||||||
|
profile=profile,
|
||||||
|
status="queued",
|
||||||
|
)
|
||||||
|
Audit.objects.create(
|
||||||
|
organization=user.membership.organization,
|
||||||
|
user=user,
|
||||||
|
action="dataset.cli_import",
|
||||||
|
resource=str(ds.pk),
|
||||||
|
details={"mapping": mapping},
|
||||||
|
)
|
||||||
|
self.stdout.write(f"{ds.pk}: queued; import_worker will process this version")
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import connection, transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
from finance.importing import process_dataset
|
||||||
|
from finance.models import Dataset, FormulaExport
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Process durable import and formula-export jobs"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--once", action="store_true")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
postgres = connection.vendor == "postgresql"
|
||||||
|
if postgres:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT pg_try_advisory_lock(782146291)")
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
self.stdout.write("Another worker owns the database lock")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.run_jobs(options, postgres)
|
||||||
|
finally:
|
||||||
|
if postgres:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT pg_advisory_unlock(782146291)")
|
||||||
|
|
||||||
|
def run_jobs(self, options, recover):
|
||||||
|
while True:
|
||||||
|
processed = self._next_import(recover)
|
||||||
|
if not processed:
|
||||||
|
processed = self._next_export(recover)
|
||||||
|
if options["once"]:
|
||||||
|
return
|
||||||
|
if not processed:
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def _claim(self, model, states):
|
||||||
|
with transaction.atomic():
|
||||||
|
qs = model.objects.filter(status__in=states).order_by("created_at")
|
||||||
|
if connection.features.has_select_for_update_skip_locked:
|
||||||
|
qs = qs.select_for_update(skip_locked=True)
|
||||||
|
item = qs.first()
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
updated = model.objects.filter(pk=item.pk, status__in=states).update(status="processing")
|
||||||
|
return item if updated else None
|
||||||
|
|
||||||
|
def _next_import(self, recover):
|
||||||
|
states = ["queued", "processing"] if recover else ["queued"]
|
||||||
|
item = self._claim(Dataset, states)
|
||||||
|
if not item:
|
||||||
|
return False
|
||||||
|
Dataset.objects.filter(pk=item.pk).update(started_at=timezone.now())
|
||||||
|
result = process_dataset(item.pk)
|
||||||
|
self.stdout.write(f"{result.pk}: {result.status}, {result.row_count} rows")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _next_export(self, recover):
|
||||||
|
states = ["queued", "processing"] if recover else ["queued"]
|
||||||
|
item = self._claim(FormulaExport, states)
|
||||||
|
if not item:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
stats = process_formula_export(item.pk)
|
||||||
|
self.stdout.write(f"{item.pk}: formula export ready, {stats['rows']} rows")
|
||||||
|
except Exception as exc:
|
||||||
|
self.stderr.write(f"Formula export {item.pk} failed: {exc}")
|
||||||
|
return True
|
||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
+42
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("finance", "0002_alter_fact_cost_alter_fact_discount_alter_fact_gross_and_more"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FormulaExport",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
("context", models.JSONField(default=dict)),
|
||||||
|
("departments", models.JSONField(default=list)),
|
||||||
|
("status", models.CharField(default="queued", max_length=16)),
|
||||||
|
("path", models.CharField(blank=True, max_length=512)),
|
||||||
|
("error", models.CharField(blank=True, max_length=500)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("finished_at", models.DateTimeField(null=True)),
|
||||||
|
("expires_at", models.DateTimeField(null=True)),
|
||||||
|
(
|
||||||
|
"dataset",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="formula_exports",
|
||||||
|
to="finance.dataset",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="formulaexport",
|
||||||
|
index=models.Index(fields=["status", "created_at"], name="finance_for_status_fa1c07_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("finance", "0003_formulaexport"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FormulaDefinition",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
("normalized_name", models.CharField(max_length=320)),
|
||||||
|
("definition", models.JSONField()),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"dataset",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="formula_definitions",
|
||||||
|
to="finance.dataset",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="formuladefinition",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("dataset", "user", "normalized_name"),
|
||||||
|
name="unique_formula_definition_name",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#16665b" />
|
||||||
|
<title>TraceIQ | 财务分析工作台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import * as echarts from "echarts/core";
|
||||||
|
import { BarChart, LineChart, HeatmapChart } from "echarts/charts";
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
VisualMapComponent,
|
||||||
|
AriaComponent,
|
||||||
|
} from "echarts/components";
|
||||||
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
|
echarts.use([
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
HeatmapChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
VisualMapComponent,
|
||||||
|
AriaComponent,
|
||||||
|
CanvasRenderer,
|
||||||
|
]);
|
||||||
|
export function Chart({
|
||||||
|
option,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
option: echarts.EChartsCoreOption;
|
||||||
|
label: string;
|
||||||
|
onClick?: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const chart = echarts.init(ref.current!);
|
||||||
|
chart.setOption({ ...option, aria: { enabled: true }, animation: false });
|
||||||
|
if (onClick) chart.on("click", (p) => onClick(p.name));
|
||||||
|
const observer = new ResizeObserver(() => chart.resize());
|
||||||
|
observer.observe(ref.current!);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
chart.dispose();
|
||||||
|
};
|
||||||
|
}, [option, onClick]);
|
||||||
|
return <div className="chart" ref={ref} role="img" aria-label={label} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
let csrf = "";
|
||||||
|
export function setCsrf(value: string) {
|
||||||
|
csrf = value;
|
||||||
|
}
|
||||||
|
export async function api<T>(
|
||||||
|
path: string,
|
||||||
|
method = "GET",
|
||||||
|
data?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const form = data instanceof FormData;
|
||||||
|
const response = await fetch("/api" + path, {
|
||||||
|
method,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
...(method !== "GET" ? { "X-CSRFToken": csrf } : {}),
|
||||||
|
...(!form && data ? { "Content-Type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: data ? (form ? data : JSON.stringify(data)) : undefined,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: `请求失败 (${response.status})` }));
|
||||||
|
throw new Error(error.error || "请求失败");
|
||||||
|
}
|
||||||
|
if (response.headers.get("Content-Disposition")?.includes("attachment"))
|
||||||
|
return (await response.blob()) as T;
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
export async function download(path: string, data: unknown, kind = "csv") {
|
||||||
|
const blob = await api<Blob>(path, "POST", data);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `traceiq-evidence.${kind}`;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,952 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-react";
|
||||||
|
import { api } from "./api";
|
||||||
|
import {
|
||||||
|
labels,
|
||||||
|
type Context,
|
||||||
|
type Dataset,
|
||||||
|
type Metric,
|
||||||
|
type Preferences,
|
||||||
|
type Session,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type User = NonNullable<Session["user"]>;
|
||||||
|
|
||||||
|
type FormulaExpression =
|
||||||
|
| { metric: Metric }
|
||||||
|
| { number: number }
|
||||||
|
| {
|
||||||
|
operator: "add" | "subtract" | "multiply" | "divide";
|
||||||
|
left: FormulaExpression;
|
||||||
|
right: FormulaExpression;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SavedFormulaDefinition = {
|
||||||
|
id: string;
|
||||||
|
definition: Record<string, unknown>;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function describeExpression(expression: FormulaExpression): string {
|
||||||
|
if ("metric" in expression) return labels[expression.metric];
|
||||||
|
if ("number" in expression) return String(expression.number);
|
||||||
|
const operators = { add: "+", subtract: "−", multiply: "×", divide: "÷" };
|
||||||
|
return `(${describeExpression(expression.left)} ${operators[expression.operator]} ${describeExpression(expression.right)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
wide = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
wide?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = ref.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
dialog.showModal();
|
||||||
|
return () => dialog.close();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<dialog ref={ref} className={wide ? "wide" : ""} aria-label={title}>
|
||||||
|
<header>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button className="icon" aria-label="关闭" onClick={onClose}>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportDialog({
|
||||||
|
ds,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
ds: Dataset;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [mapping, setMapping] = useState(ds.mapping),
|
||||||
|
[error, setError] = useState(""),
|
||||||
|
[busy, setBusy] = useState(false);
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api(`/datasets/${ds.id}/mapping`, "POST", mapping);
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function update(
|
||||||
|
index: number,
|
||||||
|
patch: Partial<(typeof mapping.columns)[number]>,
|
||||||
|
) {
|
||||||
|
setMapping({
|
||||||
|
...mapping,
|
||||||
|
columns: mapping.columns.map((c) =>
|
||||||
|
c.index === index ? { ...c, ...patch } : c,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal title="确认导入字段" onClose={onClose} wide>
|
||||||
|
<div className="dialog-body">
|
||||||
|
<p>{ds.name} · 检查识别字段及财务口径后确认导入。</p>
|
||||||
|
<label>
|
||||||
|
工作表
|
||||||
|
<select
|
||||||
|
value={mapping.sheet}
|
||||||
|
onChange={(e) => setMapping({ ...mapping, sheet: e.target.value })}
|
||||||
|
>
|
||||||
|
{ds.profile?.sheets.map((s) => (
|
||||||
|
<option key={s.name}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
ROI 口径
|
||||||
|
<select
|
||||||
|
value={mapping.roi_formula}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMapping({ ...mapping, roi_formula: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="profit_over_cost">(收入 - 成本)/ 成本</option>
|
||||||
|
<option value="gross_less_cost_over_cost">
|
||||||
|
(毛利贡献 - 成本)/ 成本
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>源字段</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>维度键</th>
|
||||||
|
<th>说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{mapping.columns.map((c) => (
|
||||||
|
<tr key={c.index}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={c.role}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(c.index, { role: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
"period",
|
||||||
|
"year",
|
||||||
|
"month",
|
||||||
|
"revenue",
|
||||||
|
"cost",
|
||||||
|
"gross",
|
||||||
|
"quantity",
|
||||||
|
"list_amount",
|
||||||
|
"discount",
|
||||||
|
"customer",
|
||||||
|
"dimension",
|
||||||
|
"ignore",
|
||||||
|
].map((r) => (
|
||||||
|
<option key={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{c.role === "dimension" && (
|
||||||
|
<input
|
||||||
|
value={c.key}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(c.index, { key: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
value={c.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(c.index, { description: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<footer>
|
||||||
|
<span>导入后该版本映射将锁定。</span>
|
||||||
|
<button onClick={onClose}>取消</button>
|
||||||
|
<button className="primary" disabled={busy} onClick={submit}>
|
||||||
|
{busy ? "提交中…" : "确认并导入"}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsDialog({
|
||||||
|
ds,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
ds: Dataset;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (value: Preferences, revision: number) => void;
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(ds.preferences),
|
||||||
|
[revision, setRevision] = useState(ds.revision || 0),
|
||||||
|
[error, setError] = useState(""),
|
||||||
|
[busy, setBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
api<{ value: Preferences; revision: number }>(`/datasets/${ds.id}/settings`)
|
||||||
|
.then((r) => {
|
||||||
|
setValue(r.value);
|
||||||
|
setRevision(r.revision);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, [ds.id]);
|
||||||
|
async function save() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api<{ value: Preferences; revision: number }>(
|
||||||
|
`/datasets/${ds.id}/settings`,
|
||||||
|
"PUT",
|
||||||
|
{ value, revision },
|
||||||
|
);
|
||||||
|
onSave(result.value, result.revision);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal title="分析设置" onClose={onClose} wide>
|
||||||
|
<div className="dialog-body">
|
||||||
|
<label>
|
||||||
|
默认比较
|
||||||
|
<select
|
||||||
|
value={value.comparison}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValue({
|
||||||
|
...value,
|
||||||
|
comparison: e.target.value as Preferences["comparison"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="yoy">同比</option>
|
||||||
|
<option value="mom">环比</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
默认指标
|
||||||
|
<select
|
||||||
|
value={value.metric}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValue({ ...value, metric: e.target.value as Metric })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.entries(labels).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
异常阈值
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.01"
|
||||||
|
value={value.threshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValue({ ...value, threshold: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
最小影响额
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={value.min_impact}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValue({ ...value, min_impact: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
最小样本数
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={value.min_samples}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValue({ ...value, min_samples: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<footer>
|
||||||
|
<button onClick={onClose}>取消</button>
|
||||||
|
<button className="primary" disabled={busy} onClick={save}>
|
||||||
|
{busy ? "保存中…" : "保存设置"}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EvidenceDialog({
|
||||||
|
ds,
|
||||||
|
ctx,
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
ds: Dataset;
|
||||||
|
ctx: Context;
|
||||||
|
user: NonNullable<Session["user"]>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const pageSize = 50;
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
total: number;
|
||||||
|
sheet: string;
|
||||||
|
}>(),
|
||||||
|
[side, setSide] = useState<"current" | "baseline">("current"),
|
||||||
|
[page, setPage] = useState(0),
|
||||||
|
[error, setError] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setResult(undefined);
|
||||||
|
setError("");
|
||||||
|
api<{ rows: Record<string, unknown>[]; total: number; sheet: string }>(
|
||||||
|
`/datasets/${ds.id}/evidence`,
|
||||||
|
"POST",
|
||||||
|
{ ...ctx, side, offset: page * pageSize, limit: pageSize },
|
||||||
|
)
|
||||||
|
.then((value) => active && setResult(value))
|
||||||
|
.catch((e) => active && setError(e.message));
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [ds.id, ctx, side, page]);
|
||||||
|
function selectSide(value: "current" | "baseline") {
|
||||||
|
setResult(undefined);
|
||||||
|
setPage(0);
|
||||||
|
setSide(value);
|
||||||
|
}
|
||||||
|
function selectPage(value: number) {
|
||||||
|
setResult(undefined);
|
||||||
|
setPage(value);
|
||||||
|
}
|
||||||
|
const first = result?.total ? page * pageSize + 1 : 0;
|
||||||
|
const last = result ? Math.min((page + 1) * pageSize, result.total) : 0;
|
||||||
|
return (
|
||||||
|
<Modal title="证据明细" onClose={onClose} wide>
|
||||||
|
<div className="dialog-body">
|
||||||
|
<div className="segmented" role="group" aria-label="证据期间">
|
||||||
|
<button
|
||||||
|
className={side === "current" ? "active" : ""}
|
||||||
|
aria-pressed={side === "current"}
|
||||||
|
onClick={() => selectSide("current")}
|
||||||
|
>
|
||||||
|
本期
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={side === "baseline" ? "active" : ""}
|
||||||
|
aria-pressed={side === "baseline"}
|
||||||
|
onClick={() => selectSide("baseline")}
|
||||||
|
>
|
||||||
|
基期
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
来源工作表:{result?.sheet || "加载中"} · {result?.total ?? 0} 条
|
||||||
|
</p>
|
||||||
|
{user.role !== "admin" && <p>客户原始标识不包含在明细中。</p>}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>来源行</th>
|
||||||
|
<th>期间</th>
|
||||||
|
{ds.dimensions.map((d) => (
|
||||||
|
<th key={d.key}>{d.name}</th>
|
||||||
|
))}
|
||||||
|
<th>收入</th>
|
||||||
|
<th>成本</th>
|
||||||
|
<th>毛利</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result?.rows.map((r, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{String(r.source_row)}</td>
|
||||||
|
<td>{String(r.period)}</td>
|
||||||
|
{ds.dimensions.map((d) => (
|
||||||
|
<td key={d.key}>
|
||||||
|
{String(
|
||||||
|
(r.dimensions as Record<string, string>)[d.key] || "",
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td>{String(r.revenue)}</td>
|
||||||
|
<td>{String(r.cost ?? "")}</td>
|
||||||
|
<td>{String(r.gross ?? "")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span>
|
||||||
|
{result
|
||||||
|
? `第 ${first}-${last} 条,共 ${result.total} 条`
|
||||||
|
: error
|
||||||
|
? "加载失败"
|
||||||
|
: "加载中"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="icon"
|
||||||
|
aria-label="上一页"
|
||||||
|
title="上一页"
|
||||||
|
disabled={!result || page === 0}
|
||||||
|
onClick={() => selectPage(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon"
|
||||||
|
aria-label="下一页"
|
||||||
|
title="下一页"
|
||||||
|
disabled={!result || (page + 1) * pageSize >= result.total}
|
||||||
|
onClick={() => selectPage(page + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose}>关闭</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Copilot({
|
||||||
|
ds,
|
||||||
|
ctx,
|
||||||
|
ai,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
ds: Dataset;
|
||||||
|
ctx: Context;
|
||||||
|
ai: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (reply: { context: Context; intent: { action: string } }) => void;
|
||||||
|
}) {
|
||||||
|
const [question, setQuestion] = useState(""),
|
||||||
|
[consent, setConsent] = useState(false),
|
||||||
|
[useModel, setUseModel] = useState(false),
|
||||||
|
[answer, setAnswer] = useState(""),
|
||||||
|
[error, setError] = useState(""),
|
||||||
|
[busy, setBusy] = useState(false);
|
||||||
|
async function submit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const r = await api<{
|
||||||
|
answer: string;
|
||||||
|
context: Context;
|
||||||
|
intent: { action: string };
|
||||||
|
}>(`/datasets/${ds.id}/assistant`, "POST", {
|
||||||
|
question,
|
||||||
|
context: ctx,
|
||||||
|
use_model: useModel,
|
||||||
|
consent,
|
||||||
|
});
|
||||||
|
setAnswer(r.answer);
|
||||||
|
onApply(r);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<aside className="copilot">
|
||||||
|
<header>
|
||||||
|
<strong>财务分析助手</strong>
|
||||||
|
<button className="icon" aria-label="关闭" onClick={onClose}>
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="context-tag">
|
||||||
|
当前范围:{Object.values(ctx.filters).join(" / ") || "全部"} · {ds.name}
|
||||||
|
</div>
|
||||||
|
<div className="chat-messages" aria-live="polite">
|
||||||
|
{answer && <p className="message">{answer}</p>}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<label>
|
||||||
|
描述要分析的问题
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
maxLength={2000}
|
||||||
|
value={question}
|
||||||
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{ai && (
|
||||||
|
<>
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useModel}
|
||||||
|
onChange={(e) => setUseModel(e.target.checked)}
|
||||||
|
/>
|
||||||
|
使用外部 AI 理解问题
|
||||||
|
</label>
|
||||||
|
{useModel && (
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={consent}
|
||||||
|
onChange={(e) => setConsent(e.target.checked)}
|
||||||
|
/>
|
||||||
|
同意发送问题和筛选上下文
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={busy || !question.trim() || (useModel && !consent)}
|
||||||
|
>
|
||||||
|
{busy ? "分析中…" : "发送"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDialog({
|
||||||
|
mode,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
mode: "users" | "audit";
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<Record<string, unknown>[]>([]),
|
||||||
|
[error, setError] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
api<{ items: Record<string, unknown>[] }>(
|
||||||
|
mode === "users" ? "/users" : "/audit",
|
||||||
|
)
|
||||||
|
.then((r) => setItems(r.items))
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, [mode]);
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={mode === "users" ? "账号与权限" : "操作审计"}
|
||||||
|
onClose={onClose}
|
||||||
|
wide
|
||||||
|
>
|
||||||
|
<div className="dialog-body">
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{mode === "users" ? (
|
||||||
|
<>
|
||||||
|
<th>账号</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>科室</th>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>操作</th>
|
||||||
|
<th>资源</th>
|
||||||
|
<th>详情</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((r, i) => (
|
||||||
|
<tr key={String(r.id ?? i)}>
|
||||||
|
{mode === "users" ? (
|
||||||
|
<>
|
||||||
|
<td>{String(r.username)}</td>
|
||||||
|
<td>{String(r.role)}</td>
|
||||||
|
<td>{r.active ? "启用" : "停用"}</td>
|
||||||
|
<td>
|
||||||
|
{String(
|
||||||
|
((r.departments || []) as string[]).join("、") ||
|
||||||
|
"全部",
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td>{String(r.created_at)}</td>
|
||||||
|
<td>{String(r.user__username)}</td>
|
||||||
|
<td>{String(r.action)}</td>
|
||||||
|
<td>{String(r.resource)}</td>
|
||||||
|
<td>{JSON.stringify(r.details)}</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<button onClick={onClose}>关闭</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormulaExportDialog({
|
||||||
|
ds,
|
||||||
|
ctx,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
ds: Dataset;
|
||||||
|
ctx: Context;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [messages, setMessages] = useState<
|
||||||
|
{ role: "user" | "assistant"; content: string }[]
|
||||||
|
>([]),
|
||||||
|
[draft, setDraft] = useState(""),
|
||||||
|
[column, setColumn] = useState<Record<string, unknown> | null>(null),
|
||||||
|
[questions, setQuestions] = useState<string[]>([]),
|
||||||
|
[task, setTask] = useState<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
error?: string;
|
||||||
|
}>(),
|
||||||
|
[definitions, setDefinitions] = useState<SavedFormulaDefinition[]>([]),
|
||||||
|
[selectedDefinition, setSelectedDefinition] = useState(""),
|
||||||
|
[consent, setConsent] = useState(false),
|
||||||
|
[busy, setBusy] = useState(false),
|
||||||
|
[error, setError] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
api<{ items: SavedFormulaDefinition[] }>(
|
||||||
|
`/datasets/${ds.id}/formula-definitions`,
|
||||||
|
)
|
||||||
|
.then((result) => setDefinitions(result.items))
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, [ds.id]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!task || !["queued", "processing"].includes(task.status)) return;
|
||||||
|
const timer = window.setInterval(
|
||||||
|
() =>
|
||||||
|
api<{ status: string; error: string }>(
|
||||||
|
`/datasets/${ds.id}/formula-exports/${task.id}`,
|
||||||
|
)
|
||||||
|
.then((r) => setTask((old) => old && { ...old, ...r }))
|
||||||
|
.catch((e) => setError(e.message)),
|
||||||
|
1500,
|
||||||
|
);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [ds.id, task]);
|
||||||
|
const userTurns = messages.filter(
|
||||||
|
(message) => message.role === "user",
|
||||||
|
).length;
|
||||||
|
async function send() {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text || !consent || userTurns >= 6) return;
|
||||||
|
const next = [...messages, { role: "user" as const, content: text }];
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setQuestions([]);
|
||||||
|
setColumn(null);
|
||||||
|
try {
|
||||||
|
const result = await api<{
|
||||||
|
complete: boolean;
|
||||||
|
questions: string[];
|
||||||
|
column: Record<string, unknown> | null;
|
||||||
|
}>(`/datasets/${ds.id}/formula-design`, "POST", {
|
||||||
|
context: ctx,
|
||||||
|
messages: next,
|
||||||
|
consent: true,
|
||||||
|
});
|
||||||
|
setMessages([
|
||||||
|
...next,
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: result.complete
|
||||||
|
? `已整理列定义:${String(result.column?.name)};含义:${String(result.column?.meaning)};适用范围:${String(result.column?.scope)};缺失处理:${String(result.column?.missing)};表达式:${JSON.stringify(result.column?.expression)}`
|
||||||
|
: result.questions.join("\n"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setQuestions(result.questions);
|
||||||
|
setColumn(result.column);
|
||||||
|
setDraft("");
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function create() {
|
||||||
|
if (!column) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setTask(
|
||||||
|
await api<{ id: string; status: string }>(
|
||||||
|
`/datasets/${ds.id}/formula-exports`,
|
||||||
|
"POST",
|
||||||
|
{ ...ctx, custom_columns: [column] },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveDefinition() {
|
||||||
|
if (!column) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const saved = await api<SavedFormulaDefinition>(
|
||||||
|
`/datasets/${ds.id}/formula-definitions`,
|
||||||
|
"POST",
|
||||||
|
{ definition: column },
|
||||||
|
);
|
||||||
|
setDefinitions((items) =>
|
||||||
|
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
|
||||||
|
String(a.definition.name).localeCompare(String(b.definition.name), "zh-CN"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setSelectedDefinition(saved.id);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function loadDefinition(id: string) {
|
||||||
|
setSelectedDefinition(id);
|
||||||
|
const saved = definitions.find((item) => item.id === id);
|
||||||
|
if (!saved) return;
|
||||||
|
setColumn(saved.definition);
|
||||||
|
setMessages([]);
|
||||||
|
setQuestions([]);
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
async function deleteDefinition() {
|
||||||
|
if (!selectedDefinition) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api(
|
||||||
|
`/datasets/${ds.id}/formula-definitions/${selectedDefinition}`,
|
||||||
|
"DELETE",
|
||||||
|
);
|
||||||
|
setDefinitions((items) =>
|
||||||
|
items.filter((item) => item.id !== selectedDefinition),
|
||||||
|
);
|
||||||
|
setSelectedDefinition("");
|
||||||
|
setColumn(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function getFile() {
|
||||||
|
try {
|
||||||
|
const blob = await api<Blob>(
|
||||||
|
`/datasets/${ds.id}/formula-exports/${task!.id}/download`,
|
||||||
|
),
|
||||||
|
url = URL.createObjectURL(blob),
|
||||||
|
link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "traceiq-formula-analysis.xlsx";
|
||||||
|
link.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal title="生成公式工作簿" onClose={onClose} wide>
|
||||||
|
<div className="dialog-body">
|
||||||
|
<p>描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。</p>
|
||||||
|
{definitions.length > 0 && !task && (
|
||||||
|
<div className="form-row">
|
||||||
|
<label>
|
||||||
|
已保存定义
|
||||||
|
<select
|
||||||
|
value={selectedDefinition}
|
||||||
|
onChange={(event) => loadDefinition(event.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<option value="">选择定义</option>
|
||||||
|
{definitions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{String(item.definition.name)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="icon"
|
||||||
|
aria-label="删除所选定义"
|
||||||
|
title="删除所选定义"
|
||||||
|
disabled={busy || !selectedDefinition}
|
||||||
|
onClick={deleteDefinition}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={consent}
|
||||||
|
onChange={(e) => setConsent(e.target.checked)}
|
||||||
|
/>
|
||||||
|
同意将描述、指标定义和当前筛选范围发送至已配置的外部 AI 服务
|
||||||
|
</label>
|
||||||
|
<div className="chat-messages" aria-live="polite">
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<p key={i}>
|
||||||
|
<strong>{m.role === "user" ? "我" : "助手"}:</strong>
|
||||||
|
{m.content}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="muted">本轮描述 {userTurns}/6</p>
|
||||||
|
{!task && (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
aria-label="列计算描述"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value);
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
rows={3}
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
<button disabled={busy || !consent || !draft.trim()} onClick={send}>
|
||||||
|
{busy ? "处理中…" : userTurns >= 6 ? "已达对话上限" : "发送描述"}
|
||||||
|
</button>
|
||||||
|
{column && (
|
||||||
|
<section>
|
||||||
|
<h3>{String(column.name)}</h3>
|
||||||
|
<p>{String(column.meaning)}</p>
|
||||||
|
<p>
|
||||||
|
计算表达式:
|
||||||
|
{describeExpression(column.expression as FormulaExpression)}
|
||||||
|
</p>
|
||||||
|
<p>适用范围:{String(column.scope)}</p>
|
||||||
|
<p>缺失/零分母:{String(column.missing)}</p>
|
||||||
|
<button
|
||||||
|
disabled={busy || Boolean(draft.trim())}
|
||||||
|
onClick={saveDefinition}
|
||||||
|
>
|
||||||
|
<Save />
|
||||||
|
保存到定义库
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busy || Boolean(draft.trim())}
|
||||||
|
onClick={create}
|
||||||
|
>
|
||||||
|
{draft.trim() ? "先发送补充内容" : "确认并生成公式工作簿"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{questions.length > 0 && (
|
||||||
|
<p className="notice">补充信息后再生成公式。</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{task && (
|
||||||
|
<p role="status">
|
||||||
|
{task.status === "queued"
|
||||||
|
? "排队中"
|
||||||
|
: task.status === "processing"
|
||||||
|
? "正在生成"
|
||||||
|
: task.status === "succeeded"
|
||||||
|
? "生成完成"
|
||||||
|
: `生成失败:${task.error || "请重试"}(任务编号:${task.id})`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{task?.status === "succeeded" && (
|
||||||
|
<button onClick={getFile}>
|
||||||
|
<Download />
|
||||||
|
下载公式工作簿
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { ModuleRegistry, AllCommunityModule } from "ag-grid-community";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./style.css";
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
if (import.meta.env.VITE_AG_GRID_LICENSE_KEY) {
|
||||||
|
const { AllEnterpriseModule, LicenseManager } =
|
||||||
|
await import("ag-grid-enterprise");
|
||||||
|
LicenseManager.setLicenseKey(import.meta.env.VITE_AG_GRID_LICENSE_KEY);
|
||||||
|
ModuleRegistry.registerModules([AllEnterpriseModule]);
|
||||||
|
}
|
||||||
|
class Boundary extends React.Component<
|
||||||
|
{ children: React.ReactNode },
|
||||||
|
{ failed: boolean }
|
||||||
|
> {
|
||||||
|
state = { failed: false };
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { failed: true };
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return this.state.failed ? (
|
||||||
|
<main className="fatal">
|
||||||
|
<h1>页面出现错误</h1>
|
||||||
|
<button onClick={() => location.reload()}>重新加载</button>
|
||||||
|
</main>
|
||||||
|
) : (
|
||||||
|
this.props.children
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Boundary>
|
||||||
|
<App />
|
||||||
|
</Boundary>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
|||||||
|
export type Numeric = string | number | null;
|
||||||
|
export type Metric =
|
||||||
|
| "revenue"
|
||||||
|
| "cost"
|
||||||
|
| "gross"
|
||||||
|
| "roi"
|
||||||
|
| "customers"
|
||||||
|
| "basket"
|
||||||
|
| "margin"
|
||||||
|
| "discount_rate"
|
||||||
|
| "self_revenue"
|
||||||
|
| "upgrade_rate"
|
||||||
|
| "list_basket";
|
||||||
|
export const labels: Record<Metric, string> = {
|
||||||
|
revenue: "实际收入",
|
||||||
|
cost: "成本",
|
||||||
|
gross: "毛利贡献",
|
||||||
|
roi: "ROI",
|
||||||
|
customers: "消费人数",
|
||||||
|
basket: "客单价",
|
||||||
|
margin: "毛利率",
|
||||||
|
discount_rate: "产品扣率",
|
||||||
|
self_revenue: "自营收入",
|
||||||
|
upgrade_rate: "升单率",
|
||||||
|
list_basket: "原价客单价",
|
||||||
|
};
|
||||||
|
export interface Column {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
role: string;
|
||||||
|
key: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
export interface Mapping {
|
||||||
|
sheet: string;
|
||||||
|
header: number;
|
||||||
|
columns: Column[];
|
||||||
|
margin_rates: Record<string, number>;
|
||||||
|
self_operated_products?: string[];
|
||||||
|
roi_formula: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
export interface Preferences {
|
||||||
|
entry: "overview" | "anomalies";
|
||||||
|
comparison: "yoy" | "mom";
|
||||||
|
hierarchy: string[];
|
||||||
|
metric: Metric;
|
||||||
|
threshold: number;
|
||||||
|
min_impact: number;
|
||||||
|
min_samples: number;
|
||||||
|
show_heatmap: boolean;
|
||||||
|
show_queue: boolean;
|
||||||
|
show_copilot: boolean;
|
||||||
|
alerts: boolean;
|
||||||
|
}
|
||||||
|
export interface Dataset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
version: string;
|
||||||
|
row_count: number | null;
|
||||||
|
periods: string[];
|
||||||
|
error: string;
|
||||||
|
quality: Record<string, number>;
|
||||||
|
dimensions: { key: string; name: string; description: string }[];
|
||||||
|
profile?: {
|
||||||
|
sheets: {
|
||||||
|
name: string;
|
||||||
|
rows: number;
|
||||||
|
columns: number;
|
||||||
|
preview: string[][];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
mapping: Mapping;
|
||||||
|
preferences: Preferences;
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
export interface Context {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
comparison: string;
|
||||||
|
baseline_end?: string;
|
||||||
|
filters: Record<string, string>;
|
||||||
|
group: string;
|
||||||
|
metric: Metric;
|
||||||
|
}
|
||||||
|
export interface Value {
|
||||||
|
current: Numeric;
|
||||||
|
baseline: Numeric;
|
||||||
|
delta: Numeric;
|
||||||
|
change: Numeric;
|
||||||
|
}
|
||||||
|
export interface Row {
|
||||||
|
label: string;
|
||||||
|
values: Record<Metric, Value>;
|
||||||
|
rows: number;
|
||||||
|
baseline_rows: number;
|
||||||
|
anomaly: boolean;
|
||||||
|
impact: Numeric;
|
||||||
|
contribution: Numeric;
|
||||||
|
filters: Record<string, string>;
|
||||||
|
}
|
||||||
|
export interface Analysis {
|
||||||
|
context: Context & {
|
||||||
|
missing: string[];
|
||||||
|
current: string[];
|
||||||
|
baseline: string[];
|
||||||
|
};
|
||||||
|
summary: Record<Metric, Value>;
|
||||||
|
rows: Row[];
|
||||||
|
trend: ({ period: string } & Record<Metric, Numeric>)[];
|
||||||
|
row_count: number;
|
||||||
|
anomaly_count: number;
|
||||||
|
comparable: boolean;
|
||||||
|
notice: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
export interface Session {
|
||||||
|
csrf: string;
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
organization: string;
|
||||||
|
departments: string[];
|
||||||
|
} | null;
|
||||||
|
ai_enabled: boolean;
|
||||||
|
}
|
||||||
|
export const format = (v: Numeric | undefined, metric?: string) =>
|
||||||
|
v == null
|
||||||
|
? "不可用"
|
||||||
|
: [
|
||||||
|
"roi",
|
||||||
|
"margin",
|
||||||
|
"discount_rate",
|
||||||
|
"upgrade_rate",
|
||||||
|
"change",
|
||||||
|
"contribution",
|
||||||
|
].includes(metric || "")
|
||||||
|
? `${(Number(v) * 100).toFixed(1)}%`
|
||||||
|
: Number(v).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||||
@@ -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)
|
||||||
Generated
+2080
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }]],
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from decimal import DefaultContext, getcontext
|
||||||
|
|
||||||
|
DefaultContext.prec = 60
|
||||||
|
getcontext().prec = 60
|
||||||
@@ -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"},
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from finance import formula_views, views
|
||||||
|
from finance.assistant import assistant
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("api/session", views.session),
|
||||||
|
path("api/login", views.sign_in),
|
||||||
|
path("api/logout", views.sign_out),
|
||||||
|
path("api/health", views.health),
|
||||||
|
path("api/datasets", views.datasets),
|
||||||
|
path("api/users", views.users),
|
||||||
|
path("api/audit", views.audit_log),
|
||||||
|
path("api/datasets/<uuid:dataset_id>", views.dataset_detail),
|
||||||
|
path("api/datasets/<uuid:dataset_id>/mapping", views.mapping),
|
||||||
|
path("api/datasets/<uuid:dataset_id>/query", views.query),
|
||||||
|
path("api/datasets/<uuid:dataset_id>/evidence", views.evidence),
|
||||||
|
path("api/datasets/<uuid:dataset_id>/export", views.export),
|
||||||
|
path("api/datasets/<uuid:dataset_id>/formula-design", formula_views.formula_design),
|
||||||
|
path(
|
||||||
|
"api/datasets/<uuid:dataset_id>/formula-definitions",
|
||||||
|
formula_views.formula_definitions,
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"api/datasets/<uuid:dataset_id>/formula-definitions/<uuid:definition_id>",
|
||||||
|
formula_views.formula_definition_delete,
|
||||||
|
),
|
||||||
|
path("api/datasets/<uuid:dataset_id>/formula-exports", formula_views.formula_export_create),
|
||||||
|
path(
|
||||||
|
"api/datasets/<uuid:dataset_id>/formula-exports/<uuid:export_id>", formula_views.formula_export_status
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"api/datasets/<uuid:dataset_id>/formula-exports/<uuid:export_id>/download",
|
||||||
|
formula_views.formula_export_download,
|
||||||
|
),
|
||||||
|
path("api/datasets/<uuid:dataset_id>/settings", views.settings_view),
|
||||||
|
path("api/datasets/<uuid:dataset_id>/investigations", views.investigations),
|
||||||
|
path("api/datasets/<uuid:dataset_id>/assistant", assistant),
|
||||||
|
path("", views.index),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
@@ -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"}
|
||||||
@@ -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"
|
||||||
@@ -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"}
|
||||||
@@ -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)
|
||||||
@@ -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",)]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user