Author SHA1 Message Date
Jayway Liu cd07ba1887 docs: 使用文档与未实现功能/测试指南
Verify / verify (push) Failing after 1s
AI Review / review (pull_request) Successful in 14s
Verify / verify (pull_request) Failing after 1s
- user-guide.html:面向使用者的完整操作说明(含 AI 模型配置)
- gaps-and-testing.html:未实现功能清单、F01-F08 状态、发布门禁与
  分层测试方法、验收矩阵、当前基线
- README 加入口链接
2026-10-11 09:45:22 +08:00
Jayway Liu adce23bbd9 feat(analysis): 网页汇总表自定义列
Verify / verify (push) Failing after 1s
AI Review / review (pull_request) Successful in 14s
Verify / verify (pull_request) Failing after 0s
- analyze 支持 custom_columns:白名单表达式树由服务端 Decimal 求值,
  自定义列进入汇总、分组行与明细(缺失保持不可用,不转零)
- 新增「选择网页自定义列」弹窗:从定义库(含共享)多选最多 10 列,
  应用后进入主分析表格与热力图;公式工作簿可一键导出网页已选列
- 请求去重纳入自定义列;Playwright 覆盖选择后显示与数值断言
2026-10-11 09:45:22 +08:00
10 changed files with 855 additions and 41 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# TraceIQ 财务分析工作台
响应式 Web 前端 + Django API + PostgreSQL。基于分享会话、医院财务 Excel 原始附件及 AG Grid 开发计划实现,当前交付是可运行、经过本地回归的核心业务版本,不等于全部需求完成或已获生产上线认证。缺口与上线门禁见 [验收状态](docs/acceptance.md)。
响应式 Web 前端 + Django API + PostgreSQL。基于分享会话、医院财务 Excel 原始附件及 AG Grid 开发计划实现,当前交付是可运行、经过本地回归的核心业务版本,不等于全部需求完成或已获生产上线认证。缺口与上线门禁见 [验收状态](docs/acceptance.md) 与 [未实现功能与测试指南](docs/gaps-and-testing.html),使用者操作说明见 [使用文档](docs/user-guide.html)。
## 已实现
+322
View File
@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TraceIQ 未实现功能与测试指南</title>
<style>
:root {
--ink: #1a2332;
--muted: #5b6675;
--accent: #1f5fa8;
--accent-soft: #eaf2fb;
--line: #dfe5ec;
--bg: #f6f8fa;
--card: #ffffff;
--p0: #b3261e;
--p0-bg: #fdeceb;
--p1: #9a5b00;
--p1-bg: #fdf3e3;
--p2: #1f5fa8;
--p2-bg: #eaf2fb;
--ok: #1d6b4f;
--ok-bg: #e7f4ee;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
color: var(--ink);
background: var(--bg);
line-height: 1.7;
}
header {
background: linear-gradient(135deg, #16324f, #1f5fa8);
color: #fff;
padding: 48px 24px 40px;
}
header .inner, main { max-width: 960px; margin: 0 auto; }
header h1 { margin: 0 0 8px; font-size: 28px; }
header p { margin: 0; opacity: .85; }
.layout { max-width: 1160px; margin: 0 auto; display: flex; gap: 32px; padding: 32px 24px 64px; }
nav.toc {
flex: 0 0 220px;
position: sticky; top: 24px; align-self: flex-start;
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 16px 18px; font-size: 14px;
}
nav.toc strong { display: block; margin-bottom: 8px; }
nav.toc a { display: block; color: var(--accent); text-decoration: none; padding: 3px 0; }
nav.toc a:hover { text-decoration: underline; }
main { flex: 1; min-width: 0; }
section {
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 24px 28px; margin-bottom: 24px;
}
h2 { font-size: 20px; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 2px solid var(--accent-soft); }
h3 { font-size: 16px; margin: 20px 0 8px; }
p { margin: 8px 0; }
ul, ol { margin: 8px 0; padding-left: 22px; }
li { margin: 4px 0; }
code {
background: #eef1f5; border-radius: 4px; padding: 1px 6px;
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: .92em;
}
pre {
background: #16202e; color: #e6edf3; border-radius: 8px;
padding: 14px 16px; overflow-x: auto; font-size: 13px; line-height: 1.6;
}
pre code { background: none; padding: 0; color: inherit; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid var(--line); padding: 8px 12px; text-align: left; vertical-align: top; }
th { background: var(--accent-soft); }
tr:nth-child(even) td { background: #fafcfe; }
.note {
background: var(--accent-soft); border-left: 4px solid var(--accent);
border-radius: 0 8px 8px 0; padding: 10px 16px; margin: 12px 0; font-size: 14px;
}
.warn {
background: var(--p1-bg); border-left: 4px solid var(--p1);
border-radius: 0 8px 8px 0; padding: 10px 16px; margin: 12px 0; font-size: 14px;
}
.tag {
display: inline-block; border-radius: 4px; padding: 0 8px;
font-size: 12px; font-weight: 600; line-height: 20px; white-space: nowrap;
}
.tag.p0 { background: var(--p0-bg); color: var(--p0); }
.tag.p1 { background: var(--p1-bg); color: var(--p1); }
.tag.p2 { background: var(--p2-bg); color: var(--p2); }
.tag.ok { background: var(--ok-bg); color: var(--ok); }
.tag.todo { background: #f1f3f5; color: var(--muted); }
footer { text-align: center; color: var(--muted); font-size: 13px; padding: 24px; }
@media (max-width: 860px) {
.layout { flex-direction: column; }
nav.toc { position: static; flex: none; }
}
</style>
</head>
<body>
<header>
<div class="inner">
<h1>TraceIQ 未实现功能与测试指南</h1>
<p>距离生产商用还差什么 · 每一项怎么测 · 2026-10-08 更新</p>
</div>
</header>
<div class="layout">
<nav class="toc">
<strong>目录</strong>
<a href="#summary">1. 结论摘要</a>
<a href="#codes">2. 代码问题与修复</a>
<a href="#features">3. 未实现功能</a>
<a href="#gates">4. 发布门禁(外部)</a>
<a href="#env">5. 测试环境准备</a>
<a href="#how">6. 分层测试方法</a>
<a href="#matrix">7. 验收矩阵</a>
<a href="#baseline">8. 当前基线</a>
</nav>
<main>
<section id="summary">
<h2>1. 结论摘要</h2>
<p>当前版本<strong>不能认定为生产商用完成</strong>。核心分析链路(导入 → 总览 → 异常 → 下钻 → 证据 → 导出)已经实现并通过自动化回归;仍有<strong>一批 P1/P2 产品功能缺口</strong>,以及<strong>必须由外部环境和业务方关闭的发布门禁</strong>(Excel 实机、财务签字、真实 AI 合规、目标基础设施)。</p>
<div class="note"><strong>2026-10-08 工程整改:</strong>F01–F08 已完成代码修复并补充专项回归;本轮又完成网页自定义列、定义草稿恢复、版本历史、个人/团队共享、公式工作簿输入校验与保护、大文件下载直传。Excel 实机仍是独立门禁。</div>
<div class="note"><strong>2026-10-11 复核:</strong>对当前工作区重新全量验证——ruff/格式/迁移检查通过,SQLite 后端 73 passed(4 skipped),前端构建通过,Playwright 4/4 通过(含原附件验收用例,原附件数据集仍在本机库中)。F01–F08 修复确认有效,无需重复修复;剩余开放项见第 3、4 节。</div>
<p>状态口径:本文区分"源码静态确认"(读代码即可判断、尚无专项复现测试)与"实测通过"(有自动化结果)。清单整合自 <code>docs/acceptance.md</code>、<code>docs/remaining-issues-2026-09-26.html</code> 与 2026-09-27 的修复记录;细节以那两个文档为准,本文是单一入口。</p>
<table>
<tr><th>类别</th><th>数量</th><th>最高优先级</th></tr>
<tr><td>已关闭代码问题(F01–F08)</td><td>8</td><td><span class="tag ok">专项回归通过</span></td></tr>
<tr><td>未实现功能(产品边界)</td><td>约 7 项</td><td><span class="tag p1">P1</span></td></tr>
<tr><td>发布门禁(外部责任)</td><td>5 组</td><td><span class="tag p0">P0 ×3</span></td></tr>
</table>
<div class="note">2026-09-27 已完成、不再属于"未实现"的内容:AI 模型页面配置(云端/本地多模型选择)、证据金额 24 位小数修复、KPI 折行、图表轴标签、表格列遮挡、内部键名暴露等 UI 问题修复。详见第 8 节基线。</div>
</section>
<section id="codes">
<h2>2. 代码问题与修复记录(F01–F08)</h2>
<p>以下是历史问题及其当前修复后的验收方式;详细位置与原始建议见 <a href="remaining-issues-2026-09-26.html">remaining-issues-2026-09-26.html</a>。</p>
<table>
<tr><th>编号</th><th>级别</th><th>问题</th><th>修复后怎么测</th></tr>
<tr>
<td>F01</td><td><span class="tag p0">P0</span></td>
<td>公式导出失败清理可能沿符号链接父目录删除非任务文件(<code>finance/exporting.py</code>)</td>
<td>父目录链接到受保护目录并预置同名文件:执行必须失败、受保护文件字节不变、任务标记 failed;覆盖根目录链接/目标链接/写盘失败/正常重试</td>
</tr>
<tr>
<td>F02</td><td><span class="tag ok">已修复</span></td>
<td>清理失败被静默吞掉,失败任务的孤儿 .xlsx 缺少回收路径</td>
<td>注入 unlink 权限错误:任务仍 failed、有可观察告警;恢复权限后回收成功且不影响其他任务文件</td>
</tr>
<tr>
<td>F03</td><td><span class="tag ok">已修复</span></td>
<td>证据弹窗重复点击当前期间会让列表停在"加载中"(<code>EvidenceDialog.selectSide</code>)</td>
<td>重复点击当前期间、快速本期/基期切换、第二页切期间、空列表、请求失败——均不显示过期数据或永久加载(Playwright E2E)</td>
</tr>
<tr>
<td>F04</td><td><span class="tag ok">已修复</span></td>
<td>载入已保存定义后追问,没有把原定义带给模型,指代修改不可靠</td>
<td>保存定义 A → 重开载入 A → 发送依赖 A 的修改:检查模型请求实际包含 A;未发送补充时禁止生成</td>
</tr>
<tr>
<td>F05</td><td><span class="tag ok">已修复</span></td>
<td>定义库 50 条上限非原子约束;同名更新最后写入覆盖、无 revision 冲突检测;审计不在同一事务</td>
<td>PostgreSQL 下并发保存不得产生第 51 条;两个标签页更新不静默丢失;模拟审计写入失败后定义变更回滚</td>
</tr>
<tr>
<td>F06</td><td><span class="tag ok">已修复</span></td>
<td>工作簿参数页把毛利率标为"可编辑"但实际公式引用每行 MarginRate,修改不联动</td>
<td>每个标为可编辑的输入都对应真实依赖;收入/成本/原价/直接毛利/lookup 毛利率分别改值重算验证</td>
</tr>
<tr>
<td>F07</td><td><span class="tag ok">已修复</span></td>
<td>对账统一用 0.01 阈值,人数/金额/比率混用;自定义列无独立服务器参考值</td>
<td>阈值边界、大数、抵消、极小值、除零、缺失、多层表达式独立黄金样例通过;容差需先经财务确认</td>
</tr>
<tr>
<td>F08</td><td><span class="tag ok">已修复</span></td>
<td>导出任务执行/下载阶段不重新校验角色;撤权后的下载政策未定义;缺超时/崩溃恢复测试</td>
<td>排队后撤权、成功后撤权按签署政策处理;半成品不可下载;worker 崩溃重启不重复发布或卡死在 processing</td>
</tr>
</table>
<p>F01–F08 当前均有代码修复和自动化回归;后续验收重点转为 PostgreSQL 并发、Excel 实机和目标部署环境。</p>
</section>
<section id="features">
<h2>3. 未实现功能(产品边界)</h2>
<table>
<tr><th>级别</th><th>功能</th><th>当前缺口</th></tr>
<tr><td><span class="tag ok">已实现</span></td><td>新列进入网页汇总表</td><td>从定义库选择的列由服务端安全计算并显示在主分析表、汇总和分组行;E2E 覆盖选择后显示</td></tr>
<tr><td><span class="tag ok">已实现</span></td><td>多列编辑与定义生命周期</td><td>网页最多选择 10 列;对话草稿会话恢复,定义支持最近 20 个版本、个人更新与全范围账号共享</td></tr>
<tr><td><span class="tag p1">P1</span></td><td>适用范围与表达式能力</td><td>scope 只是说明文本;不支持任意条件、跨明细新聚合、Excel 函数、维度列、缺失替代策略</td></tr>
<tr><td><span class="tag ok">部分完成</span></td><td>Excel 内编辑防误操作</td><td>结构、证据、人数和公式工作表已保护;计算明细仅收入/成本/毛利输入/毛利率/原价金额解锁并带数值校验。增删明细行仍需重新导出</td></tr>
<tr><td><span class="tag p1">P1</span></td><td>原附件 332,830 行全量导出</td><td>产品上限 100,000 行/200 组,原附件一次导不全;需可审计分卷或正式提高上限后再验收</td></tr>
<tr><td><span class="tag p1">P1</span></td><td>结果缓存与大文件下载</td><td>公式下载已改为浏览器直传,避免前端 Blob 全量驻留;openpyxl/LibreOffice 计算缓存与 /tmp 容量仍需目标环境方案</td></tr>
<tr><td><span class="tag p1">P1</span></td><td>高基数与可访问性</td><td>聚合分组全量返回,无 SSRM/结果窗口;无键盘/屏幕阅读器/平板专项</td></tr>
<tr><td><span class="tag p2">P2</span></td><td>扩展业务能力</td><td>多收入字段口径、消费比率、生效期毛利率、退款策略、结构占比、历史阈值、告警渠道、团队调查分派</td></tr>
<tr><td><span class="tag p2">P2</span></td><td>跨平台与高级网格</td><td>无原生客户端;AG Grid SSRM/Pivot/Tree Data/Master-Detail 未接入(需 Enterprise 许可)</td></tr>
</table>
<div class="note"><strong>本轮 P1 完成:</strong>网页自定义列、多列选择、定义草稿恢复/版本历史/共享、Excel 受保护输入与校验、公式下载直传。<strong>仍开放:</strong>任意条件与跨明细聚合、33 万行分卷/容量、结果缓存、高基数窗口、无障碍专项,以及第 4 节外部发布门禁。</div>
<div class="warn">另外:趋势/贡献图为 ECharts 折线+横条,不是完整瀑布/Pareto/散点套件;异常规则只有变化率+影响额+样本数,无结构占比、历史分位数与告警投递;无团队工单分派。这些属于需求范围决策,开发前先冻结业务定义。</div>
</section>
<section id="gates">
<h2>4. 发布门禁(必须由外部关闭)</h2>
<table>
<tr><th>级别</th><th>门禁</th><th>责任方</th><th>关闭证据</th></tr>
<tr>
<td><span class="tag p0">P0</span></td><td>Microsoft Excel 实机验收</td>
<td>财务、QA、目标终端负责人</td>
<td>可编辑 Windows/Mac Excel 打开受控样例 → 强制重算 → 保存;改值后依赖链正确;覆盖人数、升单率、空集、缺期</td>
</tr>
<tr>
<td><span class="tag p0">P0</span></td><td>财务业务口径签字</td>
<td>财务负责人</td>
<td>签署收入/实收/原价、退款、成本归属、毛利、ROI、自营、升单与容差;原附件成本全缺,ROI 保持不可用</td>
</tr>
<tr>
<td><span class="tag p0">P0</span></td><td>真实 AI 联调与数据合规</td>
<td>合规、数据负责人、AI 服务负责人</td>
<td>数据处理/出境审批、模型版本与留存策略;真实验证超时/拒答/限流/非法输出。批准前保持 AI 关闭</td>
</tr>
<tr>
<td><span class="tag p1">P1</span></td><td>目标基础设施验收</td>
<td>运维、安全</td>
<td>镜像构建、迁移、非 root/卷权限、TLS、代理、数据库认证、队列、备份恢复、日志脱敏、安全扫描、许可证</td>
</tr>
<tr>
<td><span class="tag p1">P1</span></td><td>当前版本 PostgreSQL 与 SLA</td>
<td>工程 + 运维</td>
<td>在独立 PostgreSQL 跑当前全量测试、并发定义保存、worker 恢复、导出压力;记录 P95、资源预算、RPO/RTO</td>
</tr>
</table>
<div class="note">AI 联调现在可以在页面完成配置:管理 → AI 模型,选择 OpenAI 或本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM),"获取可用模型"可多选本地模型。配置入口已实现(见使用文档第 9 节),但"真实模型联调 + 合规审批"这一门禁仍然开放。SQLite 仅用于开发回归,金额验收必须用 PostgreSQL。</div>
</section>
<section id="env">
<h2>5. 测试环境准备</h2>
<h3>5.1 快速启动(SQLite 轻量开发库)</h3>
<pre><code>scripts/dev_up.sh # 迁移 + 管理员 + 前端构建 + API + worker,自动健康检查
scripts/dev_up.sh --stop # 停止</code></pre>
<p>登录凭据在 <code>var/local-access.txt</code>(用户名 <code>admin</code>,随机密码,权限 0600)。</p>
<h3>5.2 PostgreSQL(财务验收必需)</h3>
<pre><code>/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'</code></pre>
<div class="warn">SQLite 不保证高精度金额聚合,禁止用于财务验收和生产。对账脚本 <code>scripts/reconcile_reference.py</code> 明确要求 PostgreSQL。</div>
<h3>5.3 Playwright 浏览器</h3>
<pre><code>npx playwright install chromium # 首次执行</code></pre>
</section>
<section id="how">
<h2>6. 分层测试方法</h2>
<h3>6.1 静态检查与后端单测(每次改动都跑)</h3>
<pre><code>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</code></pre>
<h3>6.2 前端构建与浏览器 E2E</h3>
<pre><code>npm run build
E2E_PASSWORD='&lt;测试口令&gt;' npm run test:e2e</code></pre>
<p>E2E 需要正在运行的本地 API + worker;会真实导入测试 CSV 数据集,<strong>不可指向生产环境</strong>。覆盖:导入确认、总览 KPI、异常下钻、调查保存、证据分页、设置乐观锁、Copilot、公式工作簿追问与下载、手机布局无横向溢出。</p>
<h3>6.3 原附件验收用例(本机显式启用)</h3>
<pre><code>REFERENCE_DATASET_NAME='分析数据_原稿(1).xlsx' E2E_PASSWORD='&lt;口令&gt;' npm run test:e2e</code></pre>
<p>断言:KPI 精确值(收入 152,419,115.35、毛利 17,868,580.85、人数 38,361)、环比缺 2026-07 禁比、同比 -32.4%、移动端无溢出、无页面脚本错误。附件文件不在仓库中,需本机提供。</p>
<h3>6.4 真实数据对账(PostgreSQL)</h3>
<pre><code>PYTHONPATH=. uv run python scripts/reconcile_reference.py</code></pre>
<p>断言 332,830 行、收入/毛利/人数与历史独立对账一致(误差 0)。原附件 SHA-256:<code>98ef41e898c2…</code>。</p>
<h3>6.5 公式工作簿重算</h3>
<pre><code>uv run pytest tests/test_formula_recalculation.py \
tests/test_formula_recalculation_gross.py # LibreOffice 重算,7 项</code></pre>
<p>LibreOffice 通过 ≠ Excel 通过;Microsoft Excel 实机重算是独立门禁(第 4 节)。</p>
<h3>6.6 安全与部署检查</h3>
<pre><code>uv run pip-audit
npm audit --omit=dev
uv run python manage.py check --deploy # 需完整生产环境变量</code></pre>
<div class="note">依赖审计为 0 漏洞 ≠ 渗透测试通过;<code>check --deploy</code> 无告警 ≠ 基础设施验收通过。</div>
</section>
<section id="matrix">
<h2>7. 验收矩阵:每项怎么算"完成"</h2>
<table>
<tr><th>目标</th><th>通过标准</th><th>手段</th></tr>
<tr><td>F01–F08 修复</td><td>各有"修复前失败、修复后通过"的专项断言;覆盖率 ≥ 85%</td><td>pytest + Playwright(见第 2 节每行)</td></tr>
<tr><td>AI 配置功能</td><td>保存/脱敏回显/权限/模型列表/助手走配置,接口级测试通过</td><td><code>tests/test_assistant_api.py</code>(已实现)</td></tr>
<tr><td>金额精度</td><td>证据与导出无存储尺度尾零;对账 0 误差</td><td><code>test_evidence_amounts_trim_storage_scale</code> + reconcile_reference.py</td></tr>
<tr><td>Excel 引擎门禁</td><td>目标 Excel 改值重算、保存、重开一致</td><td>人工按受控样例执行并记录版本与结果(第 4 节)</td></tr>
<tr><td>财务口径门禁</td><td>财务负责人签字确认口径与容差</td><td>签字文件;不以技术测试替代</td></tr>
<tr><td>AI 合规门禁</td><td>审批完成 + 真实模型故障演练通过</td><td>页面配置真实模型后做超时/拒答/限流演练</td></tr>
<tr><td>基础设施门禁</td><td>镜像构建、TLS、备份恢复演练、RPO/RTO 记录</td><td>docker compose + 恢复演练</td></tr>
<tr><td>性能/SLA</td><td>目标并发下导入/查询 P95 达标;高基数有方案</td><td>压力测试;高基数维度需 SSRM 或服务端窗口</td></tr>
</table>
</section>
<section id="baseline">
<h2>8. 当前基线(最近一次实测)</h2>
<table>
<tr><th>项目</th><th>结果</th><th>日期 / 环境</th></tr>
<tr><td>后端单测(本轮复核)</td><td><span class="tag ok">73 passed</span>,4 skipped</td><td>2026-10-11,SQLite 测试库</td></tr>
<tr><td>Playwright E2E(本轮复核)</td><td><span class="tag ok">4 passed</span>(桌面综合、手机布局、公式导出、原附件验收)</td><td>2026-10-11,本地 API + worker</td></tr>
<tr><td>后端单测</td><td><span class="tag ok">77 passed</span>,finance 覆盖率 88.45%(门槛 85%)</td><td>2026-10-08,SQLite;PostgreSQL 77 passed</td></tr>
<tr><td>Playwright E2E</td><td>3 passed,1 skipped(原附件用例未启用)</td><td>2026-10-08,本地 API + worker</td></tr>
<tr><td>本轮修复</td><td>F01–F08、网页自定义列、多列选择、草稿恢复、版本历史与共享、Excel 输入校验/公式保护、公式下载直传;PostgreSQL 运行与 Excel 实机仍需门禁</td><td>2026-10-08</td></tr>
<tr><td>历史基线(勿直接引用)</td><td>PostgreSQL 67 项、61 项、44/54 项等旧记录,以及 332,830 行旧峰值 RSS 3.31 GB</td><td>历史,见 acceptance.md</td></tr>
</table>
<h3>相关文档</h3>
<ul>
<li><a href="acceptance.md">acceptance.md</a> — 验收状态与上线门禁(最详细)</li>
<li><a href="remaining-issues-2026-09-26.html">remaining-issues-2026-09-26.html</a> — F01–F08 代码问题细节</li>
<li><a href="user-guide.html">user-guide.html</a> — 使用者操作说明(含 AI 模型配置)</li>
<li><a href="improvement-roadmap.html">improvement-roadmap.html</a> / <a href="improvement-plan.html">improvement-plan.html</a> — 改进计划(部分状态已过期,以本文为准)</li>
</ul>
<div class="warn">多个历史文档之间存在状态冲突(如"没有定义库"等旧表述)。后续应统一维护单一验收入口,历史文档标题注明"历史"。本文不是上线授权。</div>
</section>
</main>
</div>
<footer>TraceIQ 未实现功能与测试指南 · 2026-10-11 复核更新 · 状态口径:实测 &gt; 静态确认 &gt; 历史记录</footer>
</body>
</html>
+316
View File
@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TraceIQ 财务分析工作台 · 使用文档</title>
<style>
:root {
--ink: #1a2332;
--muted: #5b6675;
--accent: #1f5fa8;
--accent-soft: #eaf2fb;
--line: #dfe5ec;
--bg: #f6f8fa;
--card: #ffffff;
--warn: #9a5b00;
--warn-bg: #fdf3e3;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
color: var(--ink);
background: var(--bg);
line-height: 1.7;
}
header {
background: linear-gradient(135deg, #16324f, #1f5fa8);
color: #fff;
padding: 48px 24px 40px;
}
header .inner, main { max-width: 960px; margin: 0 auto; }
header h1 { margin: 0 0 8px; font-size: 28px; }
header p { margin: 0; opacity: .85; }
.layout { max-width: 1160px; margin: 0 auto; display: flex; gap: 32px; padding: 32px 24px 64px; }
nav.toc {
flex: 0 0 220px;
position: sticky; top: 24px; align-self: flex-start;
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 16px 18px; font-size: 14px;
}
nav.toc strong { display: block; margin-bottom: 8px; }
nav.toc a { display: block; color: var(--accent); text-decoration: none; padding: 3px 0; }
nav.toc a:hover { text-decoration: underline; }
main { flex: 1; min-width: 0; }
section {
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 24px 28px; margin-bottom: 24px;
}
h2 { font-size: 20px; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 2px solid var(--accent-soft); }
h3 { font-size: 16px; margin: 20px 0 8px; }
p { margin: 8px 0; }
ul, ol { margin: 8px 0; padding-left: 22px; }
li { margin: 4px 0; }
code {
background: #eef1f5; border-radius: 4px; padding: 1px 6px;
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: .92em;
}
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid var(--line); padding: 8px 12px; text-align: left; }
th { background: var(--accent-soft); }
tr:nth-child(even) td { background: #fafcfe; }
.note {
background: var(--accent-soft); border-left: 4px solid var(--accent);
border-radius: 0 8px 8px 0; padding: 10px 16px; margin: 12px 0; font-size: 14px;
}
.warn {
background: var(--warn-bg); border-left: 4px solid var(--warn);
border-radius: 0 8px 8px 0; padding: 10px 16px; margin: 12px 0; font-size: 14px;
}
.kbd {
display: inline-block; border: 1px solid var(--line); border-bottom-width: 2px;
border-radius: 4px; background: #fff; padding: 0 6px; font-size: 13px;
font-family: "SF Mono", Menlo, Consolas, monospace;
}
footer { text-align: center; color: var(--muted); font-size: 13px; padding: 24px; }
@media (max-width: 860px) {
.layout { flex-direction: column; }
nav.toc { position: static; flex: none; }
}
</style>
</head>
<body>
<header>
<div class="inner">
<h1>TraceIQ 财务分析工作台 · 使用文档</h1>
<p>面向使用者:导入财务表格 → 总览与对比 → 异常追踪与下钻 → 证据核查与导出</p>
</div>
</header>
<div class="layout">
<nav class="toc">
<strong>目录</strong>
<a href="#start">1. 快速开始</a>
<a href="#layout">2. 界面导览</a>
<a href="#import">3. 导入数据</a>
<a href="#overview">4. 经营总览</a>
<a href="#anomalies">5. 异常追踪与下钻</a>
<a href="#contribution">6. 贡献分解</a>
<a href="#evidence">7. 证据与导出</a>
<a href="#settings">8. 分析设置</a>
<a href="#copilot">9. 助手与 AI 模型配置</a>
<a href="#admin">10. 账号与审计</a>
<a href="#notes">11. 口径与注意事项</a>
</nav>
<main>
<section id="start">
<h2>1. 快速开始</h2>
<p>TraceIQ 是证据优先的财务分析工作台:所有指标都能下钻到原始表格行,操作全程留痕。本地开发环境启动方式:</p>
<ol>
<li>在仓库根目录执行 <code>scripts/dev_up.sh</code>(自动完成迁移、管理员初始化、前端构建并启动服务)。</li>
<li>浏览器访问 <code>http://127.0.0.1:8000</code>。</li>
<li>使用管理员账号登录(本地凭据见 <code>var/local-access.txt</code>,文件权限 0600)。</li>
</ol>
<table>
<tr><th>环境</th><th>用户名</th><th>密码</th></tr>
<tr><td>本地开发(本机)</td><td><code>admin</code></td><td>见 <code>var/local-access.txt</code>(每次初始化随机生成)</td></tr>
<tr><td>自动化测试</td><td><code>admin</code></td><td>测试专用库随机生成,可用环境变量 <code>E2E_PASSWORD</code> 指定</td></tr>
</table>
<div class="note">停止服务:在运行 <code>dev_up.sh</code> 的终端按 <span class="kbd">Ctrl-C</span>,或另开终端执行 <code>scripts/dev_up.sh --stop</code>。</div>
</section>
<section id="layout">
<h2>2. 界面导览</h2>
<p>登录后左侧为导航栏,分两组:</p>
<table>
<tr><th>分组</th><th>入口</th><th>说明</th></tr>
<tr><td rowspan="3">工作台</td><td>经营总览</td><td>核心指标、走势、指标矩阵与同比/环比对比</td></tr>
<tr><td>异常追踪</td><td>超出阈值的分组列表,可进入调查;有新异常时入口显示数量角标</td></tr>
<tr><td>数据管理</td><td>数据集列表、导入新表格、数据来源与字段字典、公式工作簿导出</td></tr>
<tr><td rowspan="4">管理</td><td>分析设置</td><td>默认比较方式、默认指标、异常阈值等个人偏好</td></tr>
<tr><td>账号与权限</td><td>仅全权限管理员可见:查看账号、角色、状态、科室范围</td></tr>
<tr><td>操作审计</td><td>仅全权限管理员可见:时间、用户、操作、资源、详情</td></tr>
<tr><td>AI 模型</td><td>仅全权限管理员可见:配置助手使用的云端或本地模型服务</td></tr>
</table>
<p>顶部右侧为<strong>财务分析助手</strong>入口和<strong>退出登录</strong>。页面为响应式布局,手机浏览器可直接使用。</p>
</section>
<section id="import">
<h2>3. 导入数据</h2>
<h3>3.1 支持的文件</h3>
<ul>
<li>XLSX(Excel)与 UTF-8 编码 CSV。</li>
<li>支持双行表头:第二行表头作为字段语义说明一并保留。</li>
<li>单文件不超过 64 MiB(反向代理层限制 66 MB)。</li>
</ul>
<h3>3.2 导入流程</h3>
<ol>
<li>在「数据管理」页点击 <strong>导入新表格</strong>,选择文件上传。</li>
<li>系统在弹窗中展示识别出的字段,请逐项<strong>确认导入字段</strong>映射:期间、科室等业务维度、实际收入、成本、毛利贡献、客户编码、应收金额等。</li>
<li>选择 ROI 公式口径与自营产品范围后,点击 <strong>确认并导入</strong>。</li>
<li>导入由后台 worker 异步处理,完成后数据自动出现在总览中;列表中可查看状态、数据质量与导入版本。</li>
</ol>
<div class="note">
<strong>去重:</strong>同一组织内完全相同的文件(SHA-256)不会重复导入。<br>
<strong>原始解释:</strong>原始表头说明随数据保留,可在「数据来源与字段字典」中查看,保证指标口径可追溯。
</div>
<div class="warn">原附件中没有的维度(如医生、成本、连续月度数据)不会产生对应排名或环比结论;系统不会虚构数据。</div>
</section>
<section id="overview">
<h2>4. 经营总览</h2>
<p>总览页展示所选数据集团范围的核心经营结果:</p>
<ul>
<li><strong>KPI 卡片:</strong>实际收入、成本、毛利、ROI、客单价、毛利率、去重人数、自营收入、升单率等可用指标(取决于数据中有哪些字段)。</li>
<li><strong>收入与毛利走势:</strong>按月份的趋势图;缺月份的区间趋势自动断开,不会用直线伪造连续性。</li>
<li><strong>经营指标矩阵:</strong>按维度(如科室、产品分类)展开的指标对照表。</li>
</ul>
<h3>比较方式</h3>
<ul>
<li><strong>同比</strong> / <strong>环比</strong>:一键切换,可自定义开始/结束期间。</li>
<li>比较必须满足等长月份范围;<strong>缺基期时禁止比较</strong>,对应卡片显示不可用而不是给出误导数字。</li>
<li>缺成本的数据不显示 ROI 等依赖成本的指标。</li>
</ul>
</section>
<section id="anomalies">
<h2>5. 异常追踪与下钻</h2>
<h3>5.1 异常列表</h3>
<p>「异常追踪」列出超出分析设置中阈值与最小影响额的分组,按影响额排序。点击分组进入调查视图:</p>
<ul>
<li><strong>调查状态:</strong>待调查 / 核查中 / 已处理,保存后持久化。</li>
<li><strong>调查备注:</strong>自由文本,随状态一并保存。</li>
</ul>
<h3>5.2 下钻与面包屑</h3>
<ul>
<li>在调查视图可沿自定义层级<strong>继续下钻</strong>(如 科室 → 学科 → 医生等级)。</li>
<li>顶部<strong>分析路径</strong>面包屑记录当前下钻位置,点击任意一级可跳回。</li>
<li>列表支持<strong>搜索分组</strong>,方便在大体量维度中定位。</li>
</ul>
</section>
<section id="contribution">
<h2>6. 贡献分解</h2>
<p>调查视图提供两张贡献图,回答"变化是谁带来的":</p>
<ul>
<li><strong>分组变化贡献图:</strong>当前层级各分组对本期与基期差额(变化额/变化贡献)的分解。</li>
<li><strong>调查范围子级贡献图:</strong>在所选分组内部,按下一级维度继续分解。</li>
</ul>
<div class="note">贡献分解只基于权限范围内的数据;科室范围受限的账号看不到范围外的分组。</div>
</section>
<section id="evidence">
<h2>7. 证据与导出</h2>
<h3>7.1 证据明细</h3>
<ul>
<li>任意数字都可通过<strong>查看证据</strong>打开原始行明细弹窗,逐条对应到源文件行号。</li>
<li>证据按<strong>本期 / 基期</strong>切换,每页最多 200 行,上一页/下一页翻页加载。</li>
</ul>
<h3>7.2 导出</h3>
<ul>
<li><strong>导出当前范围:</strong>将当前筛选与下钻范围内的证据导出为 CSV 或 XLSX;Excel 中精确金额以文本单元格导出,避免 15 位有效数字精度丢失。</li>
<li><strong>生成公式工作簿:</strong>在「数据管理」中生成带公式的 Excel 工作簿,含列计算描述,便于财务同事复核口径。</li>
</ul>
<div class="note">客户端不会一次加载全部明细(大文件可达 33 万行),只按需拉取聚合结果与分页证据。</div>
</section>
<section id="settings">
<h2>8. 分析设置</h2>
<p>「分析设置」是个人偏好,随账号保存,字段包括:</p>
<table>
<tr><th>设置项</th><th>作用</th></tr>
<tr><td>默认比较</td><td>同比 / 环比</td></tr>
<tr><td>默认指标</td><td>总览与矩阵默认展示的指标</td></tr>
<tr><td>异常阈值</td><td>判定异常的变化幅度阈值</td></tr>
<tr><td>最小影响额</td><td>低于该金额的变化不列为异常,避免噪声</td></tr>
<tr><td>最小样本数</td><td>样本量过小的分组不参与异常判定</td></tr>
</table>
<p>保存使用乐观锁:若他人在你打开设置期间已保存,系统会提示冲突而不是静默覆盖。页面上一般提供保存 / 取消 / 预览能力。</p>
</section>
<section id="copilot">
<h2>9. 财务分析助手与 AI 模型配置</h2>
<h3>9.1 使用助手</h3>
<ul>
<li>默认启用<strong>本地规则助手</strong>:直接回答指标口径、功能用法等问题,数据不出本机。</li>
<li>勾选「使用已配置的 AI 模型理解问题」并同意后,问题与维度/当前筛选会发送到你配置的模型服务,<strong>原始数据行不出服务器</strong>。</li>
<li>无论本地还是外部模型,都只能返回受控意图(如跳转页面、设置筛选),<strong>不能直接改金额、不能提交 SQL、不能改账号</strong>;查询数据仍受后端权限约束。</li>
<li>模型输出异常或被拒答时,相关执行自动关闭。</li>
</ul>
<h3>9.2 配置 AI 模型(全权限管理员)</h3>
<p>在左侧「管理 → AI 模型」中配置,支持两类服务商:</p>
<table>
<tr><th>服务商</th><th>说明</th><th>Base URL 示例</th><th>API Key</th></tr>
<tr>
<td>OpenAI</td>
<td>OpenAI Responses 接口</td>
<td><code>https://api.openai.com/v1</code>(可留空使用默认)</td>
<td>必填</td>
</tr>
<tr>
<td>本地 OpenAI 兼容服务</td>
<td>Ollama、LM Studio、vLLM 等任何兼容 OpenAI 协议的服务</td>
<td>Ollama:<code>http://127.0.0.1:11434/v1</code><br>LM Studio:<code>http://127.0.0.1:1234/v1</code></td>
<td>通常可留空</td>
</tr>
</table>
<ol>
<li>选择服务商,填写 Base URL(本地服务必填)。</li>
<li>填写 API Key(本地服务通常可留空)。</li>
<li>点击<strong>获取可用模型</strong>:系统从服务的 <code>/v1/models</code> 接口拉取模型列表。本地服务装了多个模型(如 Ollama 里的 qwen2.5:7b、llama3.1:8b)时,可在下拉列表中挑选,不用手敲名字。</li>
<li>选中模型后勾选「启用 AI 模型理解问题」,点击<strong>保存配置</strong>。</li>
<li>回到页面顶部打开「财务分析助手」,勾选「使用已配置的 AI 模型理解问题」并同意后即可提问。</li>
</ol>
<div class="note">
<strong>配置规则:</strong>每个组织一份配置,保存在数据库中,优先于部署环境变量(未配置时回退到
<code>AI_ENABLED</code> / <code>OPENAI_API_KEY</code> / <code>OPENAI_MODEL</code> / <code>OPENAI_BASE_URL</code>)。
仅全权限管理员(未限科室范围的管理员角色)可查看和修改;限域管理员、分析师、查看者无此入口。
</div>
<div class="note">
<strong>密钥安全:</strong>API Key 只保存在后端,页面只显示「已配置」,读取接口不回传密钥原文;
再次保存时留空表示保持原密钥不变。每次配置修改都会记入操作审计(只记服务商、模型与是否启用,不记密钥)。
</div>
<h3>9.3 常见问题</h3>
<ul>
<li><strong>「获取可用模型」失败?</strong>确认本地服务已启动且 Base URL 正确(Ollama 默认 <code>http://127.0.0.1:11434/v1</code>),并先点击「保存配置」再获取。</li>
<li><strong>助手提示「尚未配置外部 AI 服务」?</strong>检查「AI 模型」中是否已启用、模型名是否已填写;OpenAI 还需 API Key。</li>
<li><strong>想停用 AI?</strong>取消勾选「启用 AI 模型理解问题」并保存,助手回到纯本地规则模式。</li>
</ul>
</section>
<section id="admin">
<h2>10. 账号与审计(管理员)</h2>
<h3>10.1 账号与权限</h3>
<ul>
<li>全权限管理员(未限科室范围)可在「账号与权限」查看所有账号的<strong>角色</strong>(查看者 / 分析师 / 管理员)、<strong>启用状态</strong>与<strong>科室范围</strong>。</li>
<li>限域管理员只能管理自己科室范围内的成员,不能管理账号或查看全局审计。</li>
<li>角色能力概览:查看者可看不可改;分析师可做分析调查;管理员可导入数据并管理账号。</li>
</ul>
<h3>10.2 操作审计</h3>
<p>「操作审计」记录登录、导入、导出、设置修改、调查保存等操作的时间、用户、动作与详情,网页端只读。需要不可抵赖审计时应外送到独立追加写日志平台(数据库管理员理论上可改库内记录)。</p>
</section>
<section id="notes">
<h2>11. 口径与注意事项</h2>
<ul>
<li><strong>毛利口径:</strong>毛利是按产品分类毛利率计算的贡献值,<strong>不等同于已审计净利润</strong>。</li>
<li><strong>ROI 口径:</strong>提供两种明确公式(如 (收入 − 成本)/ 成本),导入时选择并可在字段字典中回看。</li>
<li><strong>精度:</strong>后端金额使用高精度定点数存储与计算,JSON 中金额为字符串;浏览器 Number 仅用于绘图显示。</li>
<li><strong>缺数据原则:</strong>缺基期禁止比较、缺成本显示不可用、缺月份趋势断开——系统宁可显示"不可用"也不给误导数字。</li>
<li><strong>安全:</strong>登录有频率限制;原始文件私有存储、按组织隔离;审计留痕。</li>
<li><strong>本地 SQLite 模式</strong>仅限页面测试与开发回归,高精度金额聚合与财务验收请使用 PostgreSQL 环境(见 README)。</li>
</ul>
<div class="warn">本文档面向使用者描述当前交付能力;全部需求完成情况与上线门禁以 <code>docs/acceptance.md</code> 为准。</div>
</section>
</main>
</div>
<footer>TraceIQ 使用文档 · 2026-09-26 · 与代码当前版本同步维护</footer>
</body>
</html>
+44 -10
View File
@@ -167,9 +167,9 @@ def metrics(agg, formula, products=None):
}
def comparison(current, baseline, comparable):
def comparison(current, baseline, comparable, keys=None):
result = {}
for key in METRICS:
for key in keys or 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
@@ -177,8 +177,31 @@ def comparison(current, baseline, comparable):
return result
def custom_columns(ds, body):
from .formula_columns import ensure_available_metrics, validate_column
columns = body.get("custom_columns", [])
if columns is None:
return []
if not isinstance(columns, list) or len(columns) > 10:
raise ValueError("自定义列最多 10 个")
result = [validate_column(column) for column in columns]
for column in result:
ensure_available_metrics(ds, column)
return result
def custom_values(values, columns):
from .formula_columns import evaluate
return {f"custom_{index}": evaluate(column["expression"], values) for index, column in enumerate(columns)}
def analyze(ds, member, body, preferences):
ctx = context(ds, body)
columns = custom_columns(ds, body)
ctx = {**context(ds, body), "custom_columns": columns}
custom_keys = [f"custom_{index}" for index in range(len(columns))]
value_keys = [*METRICS, *custom_keys]
qs = scoped_facts(ds, member, ctx["filters"])
formula = ds.mapping["roi_formula"]
products = ds.mapping.get("self_operated_products", [])
@@ -190,8 +213,14 @@ def analyze(ds, member, body, preferences):
)
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)
total_c = {**total_c, **{key: None for key in value_keys}}
current_missing = bool(set(ctx["current"]) - set(ds.periods))
total_c = {
**total_c,
**({key: None for key in custom_keys} if current_missing else custom_values(total_c, columns)),
}
total_b = {**total_b, **custom_values(total_b, columns)}
summary = comparison(total_c, total_b, comparable, value_keys)
group = "dimensions__" + ctx["group"]
buckets = {}
for side in ("current", "baseline"):
@@ -205,7 +234,7 @@ def analyze(ds, member, body, preferences):
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
for k in value_keys
},
}
@@ -217,8 +246,10 @@ def analyze(ds, member, body, preferences):
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)
c = {**c, **{key: None for key in value_keys}}
c = {**c, **({key: None for key in custom_keys} if current_missing else custom_values(c, columns))}
b = {**b, **custom_values(b, columns)}
values = comparison(c, b, comparable, value_keys)
v = values[metric]
change, delta = v["change"], v["delta"]
impact = (
@@ -249,7 +280,10 @@ def analyze(ds, member, body, preferences):
)
rows.sort(key=lambda r: (not r["anomaly"], -r["impact"], r["label"]))
observed = {
r["period"]: metrics(r, formula, products)
r["period"]: {
**(values := metrics(r, formula, products)),
**custom_values(values, columns),
}
for r in qs.values("period").annotate(**aggregations(products))
}
trend = []
@@ -257,7 +291,7 @@ def analyze(ds, member, body, preferences):
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}
values = empty_side(total_c) if period in ds.periods else {key: None for key in value_keys}
trend.append({"period": period, **values})
return {
"dataset_id": str(ds.pk),
+2 -1
View File
@@ -22,7 +22,7 @@ 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 .analysis import METRICS, analyze, context, custom_columns, default_preferences, dimensions, scoped_facts
from .importing import file_digest, profile_file, suggest_mapping, validate_mapping
from .models import AIConfig, Audit, Dataset, Investigation, Membership, Preference, Throttle
@@ -521,6 +521,7 @@ def investigations(request, dataset_id):
raise APIError("查看者不可修改调查状态", 403)
data = body(request)
ctx = context(ds, data.get("context", {}))
ctx["custom_columns"] = custom_columns(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("调查状态或备注无效")
+43 -6
View File
@@ -35,6 +35,7 @@ import { Chart } from "./Chart";
import {
AdminDialog,
AIConfigDialog,
FormulaColumnsDialog,
FormulaExportDialog,
Copilot,
EvidenceDialog,
@@ -49,6 +50,8 @@ import {
type Analysis,
type Context,
type Dataset,
type FormulaColumn,
type SavedFormulaDefinition,
type Metric,
type Preferences,
type Row,
@@ -89,6 +92,7 @@ export function App() {
| "ai"
| null
| "formula"
| "columns"
>(null),
[evidenceCtx, setEvidenceCtx] = useState<Context>(),
[chat, setChat] = useState(false),
@@ -98,6 +102,7 @@ export function App() {
[note, setNote] = useState(""),
[status, setStatus] = useState("open"),
[saved, setSaved] = useState(false);
const [formulaDefinitions, setFormulaDefinitions] = useState<SavedFormulaDefinition[]>([]);
const upload = useRef<HTMLInputElement>(null),
requestVersion = useRef(0);
const report = useCallback(
@@ -226,6 +231,8 @@ export function App() {
c.comparison === current.comparison &&
c.metric === current.metric &&
c.group === current.group &&
JSON.stringify(c.custom_columns || []) ===
JSON.stringify(current.custom_columns || []) &&
Object.keys(c.filters).length ===
Object.keys(current.filters).length &&
Object.entries(c.filters).every(
@@ -324,8 +331,11 @@ export function App() {
const available = candidates.filter(
(m) => m === "revenue" || (result?.rows || []).some((r) => r.values[m]?.current != null),
);
return available.length ? available : (["revenue"] as Metric[]);
const custom = (ctx?.custom_columns || []).map((_, index) => `custom_${index}`);
return [...(available.length ? available : ["revenue"]), ...custom];
}, [result, ctx]);
const metricLabel = (metric: string) =>
labels[metric as Metric] || ctx?.custom_columns?.[Number(metric.slice(7))]?.name || metric;
const columns = useMemo<ColDef<Row>[]>(
() => [
{
@@ -347,7 +357,7 @@ export function App() {
},
...metricKeys.map((metric) => ({
colId: metric,
headerName: labels[metric],
headerName: metricLabel(metric),
minWidth: 130,
valueGetter: (p: { data?: Row }) =>
p.data?.values[metric]?.current == null
@@ -1056,9 +1066,9 @@ export function App() {
}
</th>
{metricKeys
.filter((m) => m !== "customers")
.filter((m) => m !== "customers" && m in labels)
.map((m) => (
<th key={m}>{labels[m]}</th>
<th key={m}>{metricLabel(m)}</th>
))}
<th>状态</th>
</tr>
@@ -1068,7 +1078,7 @@ export function App() {
<tr key={r.label}>
<th>{r.label}</th>
{metricKeys
.filter((m) => m !== "customers")
.filter((m) => m !== "customers" && m in labels)
.map((m) => {
const v = r.values[m]?.change;
return (
@@ -1115,7 +1125,7 @@ export function App() {
<div className="investigation-band">
<section>
<div className="section-heading">
<h2>{labels[metric]}变化分解</h2>
<h2>{metricLabel(metric)}变化分解</h2>
<span>
{ctx.start} 至 {ctx.end}
</span>
@@ -1224,6 +1234,22 @@ export function App() {
>
<Calculator />
</button>
{!user.departments.length && (
<button
aria-label="选择网页自定义列"
title="选择网页自定义列"
onClick={() =>
api<{ items: SavedFormulaDefinition[] }>(`/datasets/${ds.id}/formula-definitions`)
.then((data) => {
setFormulaDefinitions(data.items);
setModal("columns");
})
.catch(report)
}
>
自定义列
</button>
)}
<button
className="icon"
aria-label="导出当前范围"
@@ -1443,6 +1469,17 @@ export function App() {
{modal === "formula" && ds && ctx && (
<FormulaExportDialog ds={ds} ctx={ctx} onClose={() => setModal(null)} />
)}
{modal === "columns" && ds && ctx && (
<FormulaColumnsDialog
definitions={formulaDefinitions}
selected={ctx.custom_columns || []}
onClose={() => setModal(null)}
onApply={(columns: FormulaColumn[]) => {
setCtx({ ...ctx, custom_columns: columns });
setModal(null);
}}
/>
)}
{modal === "settings" && ds && (
<SettingsDialog
ds={ds}
+61 -2
View File
@@ -1,15 +1,22 @@
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Download,
Save,
Trash2,
X,
} from "lucide-react";
import { api } from "./api";
import {
labels,
roleLabels,
type AIConfig,
type Context,
type Dataset,
type FormulaColumn,
type FormulaExpression,
type SavedFormulaDefinition,
type Dataset,
type Metric,
type Preferences,
type Session,
@@ -1158,3 +1165,55 @@ export function FormulaExportDialog({
</Modal>
);
}
export function FormulaColumnsDialog({
definitions,
selected,
onApply,
onClose,
}: {
definitions: SavedFormulaDefinition[];
selected: FormulaColumn[];
onApply: (columns: FormulaColumn[]) => void;
onClose: () => void;
}) {
const selectedNames = new Set(selected.map((column) => column.name));
const [checked, setChecked] = useState(
() => new Set(definitions.filter((item) => selectedNames.has(item.definition.name)).map((item) => item.id)),
);
function toggle(id: string) {
setChecked((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else if (next.size < 10) next.add(id);
return next;
});
}
return (
<Modal title="选择网页自定义列" onClose={onClose}>
<div className="dialog-body">
<p>已保存定义会由服务端统一计算并显示在分析表格中,最多选择 10 列。</p>
{!definitions.length && <p className="muted">暂无已保存定义,请先在公式工作簿中保存。</p>}
{definitions.map((item) => (
<label className="check" key={item.id}>
<input
type="checkbox"
checked={checked.has(item.id)}
onChange={() => toggle(item.id)}
/>
{item.definition.name}:{item.definition.meaning}{item.owned ? "" : `(${item.owner}共享)`}
</label>
))}
<footer>
<button onClick={onClose}>取消</button>
<button
className="primary"
onClick={() => onApply(definitions.filter((item) => checked.has(item.id)).map((item) => item.definition))}
>
应用
</button>
</footer>
</div>
</Modal>
);
}
+1 -1
View File
@@ -133,7 +133,7 @@ export interface Value {
}
export interface Row {
label: string;
values: Record<Metric, Value>;
values: Record<string, Value>;
rows: number;
baseline_rows: number;
anomaly: boolean;
+35 -20
View File
@@ -180,38 +180,53 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
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" },
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
.getByLabel("列计算描述")
.fill("新增收入成本差,缺失或零分母显示不可用");
await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await expect(formulaDialog).toContainText("缺失值和零分母如何处理?");
await formulaDialog.getByLabel("列计算描述").fill("缺失项和零分母都按不可用处理");
await formulaDialog
.getByLabel("列计算描述")
.fill("缺失项和零分母都按不可用处理");
await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await formulaDialog.getByRole("button", { name: "确认并生成公式工作簿" }).click();
await expect(formulaDialog.getByRole("status")).toContainText("生成完成", { timeout: 30000 });
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");
expect((await formulaDownload).suggestedFilename()).toBe(
"traceiq-formula-analysis.xlsx",
);
await formulaDialog.getByLabel("关闭").click();
});
test("手机:登录、总览、设置、Copilot 布局无横向溢出", async ({ page }) => {
+30
View File
@@ -85,6 +85,36 @@ def test_metrics_and_analysis_preserve_roi_refunds_and_distinct_customers(worksp
assert unavailable["self_revenue"] == 0
def test_analysis_computes_saved_custom_columns_in_summary_and_rows(workspace):
ds, member, *_ = workspace
column = {
"name": "收入成本差",
"meaning": "收入减成本",
"scope": "按分组计算",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {
"operator": "subtract",
"left": {"metric": "revenue"},
"right": {"metric": "cost"},
},
}
result = analyze(
ds,
member,
{
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"custom_columns": [column],
},
default_preferences(ds),
)
assert result["summary"]["custom_0"]["current"] == Decimal("45")
assert result["summary"]["custom_0"]["baseline"] == Decimal("90")
assert result["rows"][0]["values"]["custom_0"]["current"] == Decimal("60")
def test_dataset_and_evidence_are_organization_and_department_scoped(workspace):
ds, _, _, _, other_client = workspace
url = f"/api/datasets/{ds.pk}"