Author SHA1 Message Date
Jayway Liu 509b406254 fix(formula): 导出文件安全、清理回收、撤权校验与定义库可靠性(F01/F02/F05-F08)
Verify / verify (push) Failing after 1s
Verify / verify (pull_request) Failing after 1s
AI Review / review (pull_request) Successful in 16s
- F01: _safe_unlink_export 不跟随符号链接、逐层校验父目录,导出失败路径与
  清理命令统一使用,只清理受控目录内本次创建的文件
- F02: 清理失败记录告警日志;清理命令回收 failed 任务残留文件
- F05: 定义保存事务内锁数据集行,配额原子检查;revision 冲突返回 409;
  保留最近 20 版历史;支持个人/团队共享;审计与变更同事务
- F06: 工作簿参数页毛利率改为只读快照;可编辑单元格解锁并加数值校验
- F07: 对账按指标分容差(人数 0、比率 0.0001、金额 0.01);自定义列使用
  服务端 Decimal 表达式求值参考
- F08: 导出任务执行/下载阶段重校验账号有效性与角色
- 对话草稿会话恢复;载入定义后追问携带原定义(F04);下载改浏览器直传
- 迁移 0006-0008;PostgreSQL 咨询锁语义修正(import_worker)
2026-10-11 09:43:27 +08:00
Jayway Liu 494490f6e6 fix(display): 证据金额去尾零与界面显示修复
Verify / verify (push) Failing after 1s
Verify / verify (pull_request) Failing after 0s
AI Review / review (pull_request) Successful in 12s
- 证据接口与 CSV/XLSX 导出去掉 NUMERIC(44,24) 存储尺度尾零(无损精度)
- KPI 大金额折行截断:字号收紧 + nowrap + 省略号兜底
- 贡献分解/走势图表轴标签重叠与 Y 轴裁剪:hideOverlap + 万/亿缩写
- 表格隐藏全不可用指标列,空态改为条件渲染(修复空表覆盖层残留)
- 数据质量与字段字典/导入确认的中文标签映射(不再暴露内部键名)
- 证据弹窗重复点击当前期间停在加载中(F03);证据表格最小宽度
- Playwright 覆盖 F03 重复点击与期间切换场景
2026-10-11 09:41:31 +08:00
Jayway Liu dd8912f7ae feat(ai): 页面可配置云端或本地 OpenAI 兼容模型
Verify / verify (pull_request) Failing after 0s
AI Review / review (pull_request) Successful in 9s
Verify / verify (push) Failing after 1s
- 新增 AIConfig 模型(每组织一份,迁移 0005)与 /api/ai-config、
  /api/ai-models 接口;密钥只存后端、读取脱敏、修改记审计
- 支持 OpenAI 及本地 OpenAI 兼容服务(Ollama/LM Studio/vLLM),
  可从 /v1/models 一键获取模型列表多选一;数据库配置优先于环境变量
- 助手调用走生效配置,Copilot 文案同步更新
- 全权限管理员可在「管理 → AI 模型」配置;补接口级测试
2026-10-11 09:37:05 +08:00
28 changed files with 1373 additions and 199 deletions
+1
View File
@@ -12,4 +12,5 @@ MAX_IMPORT_ROWS=1000000
AI_ENABLED=0
OPENAI_API_KEY=
OPENAI_MODEL=
OPENAI_BASE_URL=https://api.openai.com/v1
VITE_AG_GRID_LICENSE_KEY=
+1 -1
View File
@@ -119,7 +119,7 @@ REFERENCE_DATASET_NAME='分析数据_原稿(1).xlsx' npm run test:e2e
## AI 与跨平台
默认 AI_ENABLED=0。启用外部模型需配置 `AI_ENABLED=1`、`OPENAI_API_KEY`、`OPENAI_MODEL`,并完成机构数据处理审批。密钥只在后端。用户自由输入的问题也可能含敏感信息,不能仅凭不发送原始行就认定无隐私风险。模型不能提交 SQL、修改金额或修改账号;输出只允许受控意图,异常/拒答关闭执行。
默认 AI_ENABLED=0。启用外部模型有两种方式,数据库配置优先:全权限管理员在页面「管理 → AI 模型」配置服务商(OpenAI 或本地 OpenAI 兼容服务如 Ollama / LM Studio / vLLM)、Base URL、API Key 与模型,可从 `/v1/models` 一键获取本地服务模型列表多选一;或配置环境变量 `AI_ENABLED=1`、`OPENAI_API_KEY`、`OPENAI_MODEL`(可选 `OPENAI_BASE_URL`)。均需完成机构数据处理审批。密钥只在后端:读取接口不回传密钥原文,配置修改记入审计(不含密钥)。用户自由输入的问题也可能含敏感信息,不能仅凭不发送原始行就认定无隐私风险。模型不能提交 SQL、修改金额或修改账号;输出只允许受控意图,异常/拒答关闭执行。
前端为标准 Web,API 无平台耦合,可由后续 PWA、Tauri 或 Capacitor 客户端复用。当前没有打包桌面/移动客户端,也不在浏览器离线缓存财务数据。
+149
View File
@@ -0,0 +1,149 @@
"""AI 模型配置:每个组织可在页面配置 OpenAI 或本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM 等)。
数据库配置优先;未配置时回退到环境变量(AI_ENABLED / OPENAI_*),保持旧行为兼容。
API Key 只存后端,读取接口只做脱敏回显,绝不把密钥下发到浏览器。
"""
import httpx
from django.conf import settings
from django.http import JsonResponse
from .models import AIConfig
from .views import APIError, audit, body, endpoint
PROVIDERS = ("openai", "local")
OPENAI_BASE_URL = "https://api.openai.com/v1"
def require_full_admin(request):
if request.member.departments:
raise APIError("没有配置 AI 模型的权限", 403)
def resolve(org):
"""返回生效的 AI 配置字典;未启用或配置不完整时返回 None。"""
cfg = AIConfig.objects.filter(organization=org).first()
if cfg:
base_url = (cfg.base_url or "").rstrip("/") or (OPENAI_BASE_URL if cfg.provider == "openai" else "")
key_required = cfg.provider == "openai"
if not cfg.enabled or not base_url or not cfg.model or (key_required and not cfg.api_key):
return None
return {
"enabled": True,
"provider": cfg.provider,
"base_url": base_url,
"api_key": cfg.api_key,
"model": cfg.model,
}
if settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL:
return {
"enabled": True,
"provider": "openai",
"base_url": (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"),
"api_key": settings.OPENAI_API_KEY,
"model": settings.OPENAI_MODEL,
}
return None
def org_models_endpoint(org):
"""组织配置或环境回退的 base_url/api_key,用于探测模型列表。"""
cfg = AIConfig.objects.filter(organization=org).first()
if cfg:
base_url = (cfg.base_url or "").rstrip("/") or (OPENAI_BASE_URL if cfg.provider == "openai" else "")
return base_url, cfg.api_key
return (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"), settings.OPENAI_API_KEY
@endpoint(("GET", "PUT"), roles=("admin",))
def ai_config(request):
require_full_admin(request)
org = request.member.organization
if request.method == "GET":
cfg = AIConfig.objects.filter(organization=org).first()
return JsonResponse(
{
"enabled": cfg.enabled if cfg else False,
"provider": cfg.provider if cfg else "local",
"base_url": cfg.base_url if cfg else "",
"model": cfg.model if cfg else "",
"api_key_set": bool(cfg and cfg.api_key),
"updated_at": cfg.updated_at.isoformat() if cfg else None,
}
)
data = body(request)
enabled = data.get("enabled")
provider = data.get("provider")
base_url = data.get("base_url", "")
api_key = data.get("api_key", "")
model = data.get("model", "")
if type(enabled) is not bool or provider not in PROVIDERS:
raise APIError("AI 配置参数无效")
if not isinstance(base_url, str) or len(base_url) > 500:
raise APIError("Base URL 无效")
base_url = base_url.rstrip("/")
if base_url and not base_url.startswith(("http://", "https://")):
raise APIError("Base URL 必须以 http:// 或 https:// 开头")
if not isinstance(api_key, str) or len(api_key) > 500:
raise APIError("API Key 无效")
if not isinstance(model, str) or len(model) > 200:
raise APIError("模型名无效")
cfg = AIConfig.objects.filter(organization=org).first()
if enabled:
if provider == "local" and not base_url:
raise APIError("本地服务需要填写 Base URL,例如 http://127.0.0.1:11434/v1")
if provider == "openai" and not (api_key or (cfg and cfg.api_key)):
raise APIError("OpenAI 服务需要 API Key")
if not model:
raise APIError("启用前需要填写模型名,可点击“获取可用模型”选择")
key_changed = bool(api_key)
defaults = {
"enabled": enabled,
"provider": provider,
"base_url": base_url,
"model": model,
}
if api_key:
defaults["api_key"] = api_key
if not cfg:
AIConfig.objects.create(organization=org, **defaults)
else:
for field, value in defaults.items():
setattr(cfg, field, value)
cfg.save()
audit(
request,
"ai_config.update",
details={
"provider": provider,
"model": model,
"enabled": enabled,
"key_changed": key_changed,
},
)
return JsonResponse({"ok": True})
@endpoint(roles=("admin",))
def ai_models(request):
require_full_admin(request)
base_url, api_key = org_models_endpoint(request.member.organization)
if not base_url:
raise APIError("请先保存 Base URL 再获取模型列表", 400)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
try:
with httpx.Client(timeout=10) as client:
response = client.get(f"{base_url}/models", headers=headers)
response.raise_for_status()
data = response.json()
items = data.get("data", [])
ids = [m.get("id") for m in items if isinstance(m, dict) and isinstance(m.get("id"), str)]
if not ids and isinstance(data.get("models"), list):
ids = [
m.get("name")
for m in data["models"]
if isinstance(m, dict) and isinstance(m.get("name"), str)
]
return JsonResponse({"items": ids[:200]})
except (httpx.HTTPError, ValueError, AttributeError) as exc:
raise APIError("无法从该服务获取模型列表,请检查 Base URL 与 API Key", 502) from exc
+17 -13
View File
@@ -1,14 +1,17 @@
import json
import httpx
from django.conf import settings
from django.http import JsonResponse
from .ai import resolve
from .analysis import METRICS, analyze, context, dimensions
from .views import APIError, audit, body, dataset, endpoint, limit, preferences
def model_intent(question, ds, ctx):
def model_intent(question, ds, ctx, cfg=None):
cfg = cfg or resolve(ds.organization)
if not cfg:
raise APIError("尚未配置外部 AI 服务", 503)
keys = [d["key"] for d in dimensions(ds)]
schema = {
"type": "object",
@@ -31,7 +34,7 @@ def model_intent(question, ds, ctx):
}
# Only schema, selected context and the user's question leave the server; never raw rows.
payload = {
"model": settings.OPENAI_MODEL,
"model": cfg["model"],
"store": False,
"max_output_tokens": 1200,
"input": [
@@ -55,8 +58,8 @@ def model_intent(question, ds, ctx):
try:
with httpx.Client(timeout=30) as client:
response = client.post(
"https://api.openai.com/v1/responses",
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
f"{cfg['base_url']}/responses",
headers={"Authorization": f"Bearer {cfg['api_key']}"} if cfg["api_key"] else {},
json=payload,
)
response.raise_for_status()
@@ -107,12 +110,15 @@ def assistant(request, dataset_id):
use_model = data.get("use_model", False)
if type(use_model) is not bool:
raise APIError("AI 模式必须为布尔值")
provider = "local"
if use_model:
if not (settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL):
raise APIError("尚未配置外部 AI 服务", 503)
cfg = resolve(request.member.organization)
if not cfg:
raise APIError("尚未配置外部 AI 服务;请管理员在「AI 模型」中配置", 503)
if data.get("consent") is not True:
raise APIError("请先同意发送问题与当前筛选上下文", 400)
intent = model_intent(question, ds, ctx)
provider = cfg["provider"]
intent = model_intent(question, ds, ctx, cfg)
else:
action = (
"evidence"
@@ -140,21 +146,19 @@ def assistant(request, dataset_id):
else:
change = value["change"]
change_text = f"{change:+.1%}" if change is not None else "不可计算(基期为零或数据缺失)"
answer = f"当前范围{METRICS[metric]}为 {value['current']:,.4f},较基期变化 {change_text}。按当前规则识别出 {result['anomaly_count']} 个异常分组。"
answer = f"当前范围{METRICS[metric]}为 {value['current']:,.2f},较基期变化 {change_text}。按当前规则识别出 {result['anomaly_count']} 个异常分组。"
candidates = [r for r in result["rows"] if r["anomaly"]][:3]
if candidates:
answer += " 优先核查:" + "、".join(r["label"] for r in candidates) + "。"
answer += " 这是数据贡献分解,不是因果结论。"
audit(
request, "assistant.query", ds.pk, {"provider": "openai" if use_model else "local", "intent": intent}
)
audit(request, "assistant.query", ds.pk, {"provider": provider, "intent": intent})
return JsonResponse(
{
"answer": answer,
"intent": intent,
"context": result["context"],
"version": ds.sha256,
"provider": "openai" if use_model else "local",
"provider": provider,
"evidence": [
{"label": r["label"], "filters": r["filters"], "values": r["values"][metric]}
for r in result["rows"][:5]
+91 -13
View File
@@ -1,5 +1,6 @@
import hashlib
import hmac
import logging
import os
import secrets
import warnings
@@ -11,22 +12,62 @@ from django.conf import settings
from django.utils import timezone
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell
from openpyxl.styles import Font, PatternFill
from openpyxl.styles import Font, PatternFill, Protection
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.worksheet.table import Table, TableColumn, TableStyleInfo
from .analysis import METRICS, aggregations, context, dimensions, metrics, scoped_facts
from .formula_columns import (
RESERVED_DATA_HEADERS,
ensure_available_metrics,
evaluate,
metrics_used,
to_excel,
validate_column,
)
from .models import FormulaExport, Membership
# Keep the private test seam while sharing the single evaluator implementation.
_evaluate_expression = evaluate
FORMULA_VERSION = "3"
MAX_ROWS = 100_000
MAX_GROUPS = 200
RECONCILE_TOLERANCES = {
"customers": Decimal("0"),
"margin": Decimal("0.0001"),
"discount_rate": Decimal("0.0001"),
"upgrade_rate": Decimal("0.0001"),
}
DEFAULT_RECONCILE_TOLERANCE = Decimal("0.01")
logger = logging.getLogger(__name__)
def _safe_unlink_export(path, root):
"""Delete only a file below the owned export root without following links."""
path, root = Path(path), Path(root).absolute()
if not path.is_absolute() or root.is_symlink() or path.is_symlink():
logger.warning("formula export cleanup refused unsafe path: %s", path)
return False
parent = path.parent
try:
if not parent.is_relative_to(root):
logger.warning("formula export cleanup refused out-of-root path: %s", path)
return False
except ValueError:
return False
current = parent
while current != root:
if current.is_symlink() or current.parent == current:
logger.warning("formula export cleanup refused linked parent: %s", path)
return False
current = current.parent
try:
path.unlink(missing_ok=True)
return True
except OSError:
logger.warning("formula export cleanup failed for %s", path, exc_info=True)
return False
def _excel_number(value):
@@ -269,6 +310,19 @@ def build_formula_workbook(ds, member, export_context, output):
),
)
data.append(headers)
editable_indices = {len(dims) + 2, len(dims) + 3, len(dims) + 4, len(dims) + 5, len(dims) + 7}
if count:
numeric_input = DataValidation(
type="decimal", operator="between", formula1="-1e15", formula2="1e15", allow_blank=True
)
numeric_input.showErrorMessage = True
numeric_input.errorStyle = "stop"
numeric_input.errorTitle = "请输入数值"
numeric_input.error = "计算输入只接受范围内的数值或空白;请勿粘贴公式或文本。"
for index in editable_indices:
column_name = _column_name(index + 1)
numeric_input.add(f"{column_name}2:{column_name}{count + 1}")
data.data_validations.append(numeric_input)
token_secret = secrets.token_bytes(32)
customer_roster = {}
gross_index = next((col["index"] for col in mapping["columns"] if col["role"] == "gross"), None)
@@ -329,6 +383,12 @@ def build_formula_workbook(ds, member, export_context, output):
f'{_column_name(len(dims) + 6)}{row_number},""))'
),
)
for index in editable_indices:
cell = row[index]
if not hasattr(cell, "protection"):
cell = WriteOnlyCell(data, value=cell)
cell.protection = Protection(locked=False)
row[index] = cell
data.append(row)
customer_headers = [
"Group",
@@ -408,7 +468,13 @@ def build_formula_workbook(ds, member, export_context, output):
],
["ROI 口径", mapping["roi_formula"], "按导入映射保存的公式分支", "否"],
["自营产品集合", _text_cell(parameters, ", ".join(products)), "升单率和自营收入口径", "否"],
["产品毛利率", str(rates), "直接毛利缺失时的可编辑计算输入", "是"],
["产品毛利率快照", str(rates), "导入时保存的只读口径;修改需重新确认导入映射", "否"],
[
"对账容差",
"人数 0;金额 0.01;比率 0.0001;自定义 0.01",
"服务器 Decimal 参考与 Excel 结果的绝对差;正式财务验收前需确认",
"否",
],
[
"Excel 数值精度",
"IEEE-754 双精度",
@@ -443,6 +509,10 @@ def build_formula_workbook(ds, member, export_context, output):
["变化率", "差额/ABS(基期);基期为0或期间缺失时不可用。"],
["精度", "原始 Decimal 以文本留存;Excel 双精度存在细微误差可能。"],
["隐私", "客户标识替换为本文件随机 token,文件仍按敏感财务信息保护。"],
[
"编辑保护",
"工作表结构、人数去重、原始证据和公式列已保护;计算明细仅收入、成本、毛利输入、毛利率和原价金额可编辑。行结构变化请重新导出。",
],
]:
instructions.append(row)
for definition in custom_columns:
@@ -559,6 +629,7 @@ def build_formula_workbook(ds, member, export_context, output):
for key, name in METRICS.items():
formula_row = metric_rows[(label, key)]
value = reference[key]
tolerance = RECONCILE_TOLERANCES.get(key, DEFAULT_RECONCILE_TOLERANCE)
reconcile.append(
[
name,
@@ -567,24 +638,30 @@ def build_formula_workbook(ds, member, export_context, output):
"" if value is None else str(value),
f"='分析汇总'!{col}{formula_row}",
f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")',
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<0.01,"通过","待复核"),"待复核"))',
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<={tolerance},"通过","待复核"),"待复核"))',
]
)
reconcile_row += 1
for index, definition in enumerate(custom_columns):
formula_row = metric_rows[(label, f"custom_{index}")]
custom_reference = evaluate(definition["expression"], reference)
reconcile.append(
[
_text_cell(reconcile, definition["name"]),
_text_cell(reconcile, label),
side_name,
"自定义公式无服务器参考",
"" if custom_reference is None else str(custom_reference),
f"='分析汇总'!{col}{formula_row}",
"",
"需业务验收",
f'=IFERROR(E{reconcile_row}-D{reconcile_row},"")',
f'=IF(D{reconcile_row}="","无参考",IF(ISNUMBER(F{reconcile_row}),IF(ABS(F{reconcile_row})<={DEFAULT_RECONCILE_TOLERANCE},"通过","待复核"),"待复核"))',
]
)
reconcile_row += 1
for sheet in wb.worksheets:
sheet.protection.sheet = True
sheet.protection.selectLockedCells = False
sheet.protection.selectUnlockedCells = True
wb.security.lockStructure = True
wb.calculation.fullCalcOnLoad = True
wb.calculation.forceFullCalc = True
wb.calculation.calcMode = "auto"
@@ -596,8 +673,8 @@ def process_formula_export(export_id):
job = FormulaExport.objects.select_related("dataset", "user").get(pk=export_id)
temporary = None
target = None
root = Path(settings.MEDIA_ROOT) / "formula-exports"
try:
root = Path(settings.MEDIA_ROOT) / "formula-exports"
if root.is_symlink():
raise ValueError("公式导出目录不能是符号链接")
root.mkdir(parents=True, exist_ok=True, mode=0o700)
@@ -609,6 +686,8 @@ def process_formula_export(export_id):
target.parent.chmod(0o700)
temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp")
member = Membership.objects.get(user=job.user, organization=job.dataset.organization)
if not job.user.is_active or member.role not in ("admin", "analyst"):
raise PermissionError("账号已撤权,请重新提交导出")
if set(member.departments) != set(job.departments):
raise PermissionError("科室授权已变更,请重新提交导出")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
@@ -621,13 +700,12 @@ def process_formula_export(export_id):
job.save(update_fields=["path", "status", "error", "finished_at", "expires_at"])
return stats
except Exception as exc:
remaining = ""
for path in (temporary, target):
try:
if path is not None and not path.is_symlink():
path.unlink(missing_ok=True)
except OSError:
pass
job.status, job.error, job.path = "failed", str(exc)[:500], ""
if path is not None and not _safe_unlink_export(path, root):
if path.exists() or path.is_symlink():
remaining = str(path)
job.status, job.error, job.path = "failed", str(exc)[:500], remaining
job.finished_at, job.expires_at = timezone.now(), None
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
raise
+25
View File
@@ -1,4 +1,5 @@
import math
from decimal import Decimal, localcontext
from .analysis import METRICS
@@ -107,3 +108,27 @@ def to_excel(node, references):
if "number" in node:
return str(node["number"])
return f"({to_excel(node['left'], references)}{OPERATORS[node['operator']]}{to_excel(node['right'], references)})"
def evaluate(node, references):
"""Evaluate a validated expression with Decimal values and #N/A semantics."""
with localcontext() as decimal_context:
decimal_context.prec = 64
return _evaluate(node, references)
def _evaluate(node, references):
if "metric" in node:
return references.get(node["metric"])
if "number" in node:
return Decimal(str(node["number"]))
left, right = _evaluate(node["left"], references), _evaluate(node["right"], references)
if left is None or right is None:
return None
if node["operator"] == "add":
return left + right
if node["operator"] == "subtract":
return left - right
if node["operator"] == "multiply":
return left * right
return None if right == 0 else left / right
+79 -17
View File
@@ -3,12 +3,14 @@ from pathlib import Path
import httpx
from django.conf import settings
from django.db import transaction
from django.db.models import Q
from django.http import FileResponse, JsonResponse
from django.utils import timezone
from .analysis import METRICS, context, dimensions
from .formula_columns import available_metrics, ensure_available_metrics, validate_column
from .models import FormulaDefinition, FormulaExport
from .models import Dataset, FormulaDefinition, FormulaExport
from .views import APIError, audit, body, dataset, endpoint, limit
@@ -190,13 +192,23 @@ def formula_definitions(request, dataset_id):
if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403)
if request.method == "GET":
items = FormulaDefinition.objects.filter(dataset=ds, user=request.user).order_by("normalized_name")
items = (
FormulaDefinition.objects.filter(dataset=ds)
.filter(Q(user=request.user) | Q(shared=True))
.select_related("user")
.order_by("normalized_name")
)
return JsonResponse(
{
"items": [
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"shared": item.shared,
"owned": item.user_id == request.user.pk,
"owner": item.user.username,
"history": item.history,
"updated_at": item.updated_at.isoformat(),
}
for item in items
@@ -204,28 +216,74 @@ def formula_definitions(request, dataset_id):
}
)
definition = validate_column(body(request).get("definition"))
data = body(request)
definition = validate_column(data.get("definition"))
shared = data.get("shared", False)
if type(shared) is not bool:
raise APIError("共享选项无效")
try:
ensure_available_metrics(ds, definition)
except ValueError as exc:
raise APIError(str(exc)) from exc
normalized_name = definition["name"].strip().casefold()
existing = FormulaDefinition.objects.filter(
dataset=ds, user=request.user, normalized_name=normalized_name
).first()
if not existing and FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
raise APIError("每个数据集最多保存 50 个公式定义")
item, created = FormulaDefinition.objects.update_or_create(
dataset=ds,
user=request.user,
normalized_name=normalized_name,
defaults={"definition": definition},
)
audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]})
requested_revision = data.get("revision")
with transaction.atomic():
Dataset.objects.select_for_update().get(pk=ds.pk)
existing = (
FormulaDefinition.objects.select_for_update()
.filter(dataset=ds, user=request.user, normalized_name=normalized_name)
.first()
)
if existing:
if requested_revision is not None and (
type(requested_revision) is not int or requested_revision != existing.revision
):
raise APIError("公式定义已在其他窗口更新,请重新载入", 409)
# ponytail: retain 20 revisions; use a separate history table if long-term retention is required.
existing.history = [
*existing.history,
{
"revision": existing.revision,
"definition": existing.definition,
"updated_at": existing.updated_at.isoformat(),
},
][-20:]
existing.definition = definition
existing.shared = shared if "shared" in data else existing.shared
existing.revision += 1
existing.save(update_fields=["definition", "revision", "shared", "history", "updated_at"])
item, created = existing, False
else:
if FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50:
raise APIError("每个数据集最多保存 50 个公式定义")
item = FormulaDefinition.objects.create(
dataset=ds,
user=request.user,
normalized_name=normalized_name,
definition=definition,
shared=shared,
)
created = True
audit(
request,
"formula_definition.save",
item.pk,
{
"dataset": str(ds.pk),
"name": definition["name"],
"shared": item.shared,
"revision": item.revision,
},
)
return JsonResponse(
{
"id": str(item.pk),
"definition": item.definition,
"revision": item.revision,
"shared": item.shared,
"owned": True,
"owner": request.user.username,
"history": item.history,
"updated_at": item.updated_at.isoformat(),
},
status=201 if created else 200,
@@ -241,8 +299,10 @@ def formula_definition_delete(request, dataset_id, definition_id):
if not item:
raise APIError("公式定义不存在", 404)
name = item.definition.get("name", "")
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
item.delete()
with transaction.atomic():
item = FormulaDefinition.objects.select_for_update().get(pk=item.pk)
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name})
item.delete()
return JsonResponse({"deleted": True})
@@ -279,6 +339,8 @@ def formula_export_create(request, dataset_id):
def _owned_job(request, dataset_id, export_id):
if not request.user.is_active or request.member.role not in ("admin", "analyst"):
raise APIError("没有此导出任务的访问权限", 403)
job = FormulaExport.objects.filter(
pk=export_id,
dataset_id=dataset_id,
@@ -5,8 +5,10 @@ from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from finance.exporting import _safe_unlink_export
from finance.models import FormulaExport
@@ -29,22 +31,30 @@ class Command(BaseCommand):
export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
if export_root.is_symlink():
raise ValueError("公式导出目录不能是符号链接")
root = export_root.resolve()
root = export_root.absolute()
now = timezone.now()
jobs = files = 0
for job in FormulaExport.objects.filter(status="succeeded", expires_at__lte=now).iterator():
jobs_qs = FormulaExport.objects.filter(
Q(status="succeeded", expires_at__lte=now) | Q(status="failed", path__gt="")
).iterator()
for job in jobs_qs:
path = Path(job.path) if job.path else None
resolved = path.resolve() if path and path.is_absolute() and not path.is_symlink() else None
in_root = bool(resolved and resolved.is_relative_to(root))
try:
with transaction.atomic():
if in_root and resolved.is_file():
resolved.unlink()
deleted = bool(path and _safe_unlink_export(path, root))
if job.status == "succeeded":
if deleted or not path or not path.is_absolute() or not path.is_relative_to(root):
if deleted:
files += 1
job.status, job.path = "expired", ""
job.save(update_fields=["status", "path"])
jobs += 1
elif deleted:
files += 1
job.status, job.path = "expired", ""
job.save(update_fields=["status", "path"])
jobs += 1
job.path = ""
job.save(update_fields=["path"])
jobs += 1
except OSError as exc:
self.stderr.write(f"Could not remove expired export {job.pk}: {exc}")
+1 -1
View File
@@ -24,7 +24,7 @@ class Command(BaseCommand):
self.stdout.write("Another worker owns the database lock")
return
try:
self.run_jobs(options, postgres)
self.run_jobs(options, True)
finally:
if postgres:
with connection.cursor() as cursor:
+43
View File
@@ -0,0 +1,43 @@
# Generated by Django 5.2.17 on 2026-09-26 13:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0004_formuladefinition"),
]
operations = [
migrations.CreateModel(
name="AIConfig",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("enabled", models.BooleanField(default=False)),
(
"provider",
models.CharField(
choices=[("openai", "OpenAI"), ("local", "本地 OpenAI 兼容服务")],
default="local",
max_length=16,
),
),
("base_url", models.CharField(blank=True, max_length=500)),
("api_key", models.CharField(blank=True, max_length=500)),
("model", models.CharField(blank=True, max_length=200)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"organization",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to="finance.organization"
),
),
],
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0005_aiconfig"),
]
operations = [
migrations.AddField(
model_name="formuladefinition",
name="revision",
field=models.PositiveIntegerField(default=1),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0006_formuladefinition_revision"),
]
operations = [
migrations.AlterField(
model_name="dataset",
name="file",
field=models.FileField(max_length=500, upload_to="sources/%Y/%m"),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-10-07 23:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("finance", "0007_alter_dataset_file"),
]
operations = [
migrations.AddField(
model_name="formuladefinition",
name="history",
field=models.JSONField(default=list),
),
migrations.AddField(
model_name="formuladefinition",
name="shared",
field=models.BooleanField(default=False),
),
]
+16 -1
View File
@@ -22,7 +22,7 @@ class Dataset(models.Model):
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
name = models.CharField(max_length=200)
file = models.FileField(upload_to="sources/%Y/%m")
file = models.FileField(upload_to="sources/%Y/%m", max_length=500)
sha256 = models.CharField(max_length=64)
status = models.CharField(max_length=16, default="mapping")
profile = models.JSONField(default=dict)
@@ -92,6 +92,18 @@ class Audit(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
class AIConfig(models.Model):
organization = models.OneToOneField(Organization, on_delete=models.CASCADE)
enabled = models.BooleanField(default=False)
provider = models.CharField(
max_length=16, choices=[("openai", "OpenAI"), ("local", "本地 OpenAI 兼容服务")], default="local"
)
base_url = models.CharField(max_length=500, blank=True)
api_key = models.CharField(max_length=500, blank=True)
model = models.CharField(max_length=200, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Throttle(models.Model):
key = models.CharField(max_length=64, primary_key=True)
count = models.PositiveIntegerField(default=0)
@@ -121,6 +133,9 @@ class FormulaDefinition(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
normalized_name = models.CharField(max_length=320)
definition = models.JSONField()
revision = models.PositiveIntegerField(default=1)
shared = models.BooleanField(default=False)
history = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+26 -5
View File
@@ -24,7 +24,7 @@ from openpyxl.cell import WriteOnlyCell
from .analysis import METRICS, analyze, context, default_preferences, dimensions, scoped_facts
from .importing import file_digest, profile_file, suggest_mapping, validate_mapping
from .models import Audit, Dataset, Investigation, Membership, Preference, Throttle
from .models import AIConfig, Audit, Dataset, Investigation, Membership, Preference, Throttle
class APIError(Exception):
@@ -130,10 +130,20 @@ def serialize_dataset(ds, detailed=False, restricted=False, raw_access=True):
@endpoint(public=True)
def session(request):
ai_enabled = settings.AI_ENABLED and bool(settings.OPENAI_API_KEY and settings.OPENAI_MODEL)
if request.user.is_authenticated:
try:
cfg = AIConfig.objects.filter(organization=request.user.membership.organization).first()
if cfg:
ai_enabled = (
cfg.enabled and bool(cfg.model) and (cfg.provider == "local" or bool(cfg.api_key))
)
except Membership.DoesNotExist:
pass
data = {
"csrf": get_token(request),
"user": None,
"ai_enabled": settings.AI_ENABLED and bool(settings.OPENAI_API_KEY and settings.OPENAI_MODEL),
"ai_enabled": ai_enabled,
}
if request.user.is_authenticated:
try:
@@ -287,6 +297,14 @@ def query(request, dataset_id):
return JsonResponse(analyze(ds, request.member, body(request), pref))
def amount_text(value):
"""金额展示文本:去掉定点数存储尺度产生的多余尾零(如 10.000000000000000000000000 → 10)。"""
if value is None:
return None
text = format(value.normalize(), "f")
return "0" if text == "-0" else text
@endpoint(("POST",))
def evidence(request, dataset_id):
ds = dataset(request, dataset_id, True)
@@ -304,6 +322,9 @@ def evidence(request, dataset_id):
"source_row", "period", "dimensions", "revenue", "cost", "gross", "raw"
)
)
for row in rows:
for field in ("revenue", "cost", "gross"):
row[field] = amount_text(row[field])
# Raw patient/customer identifiers require an explicit administrator role.
if request.member.role != "admin":
for row in rows:
@@ -402,9 +423,9 @@ def export(request, dataset_id):
row.source_row,
row.period,
*[row.dimensions.get(d["key"], "") for d in dims],
row.revenue,
row.cost,
row.gross,
amount_text(row.revenue),
amount_text(row.cost),
amount_text(row.gross),
]
)
if workbook:
+108 -80
View File
@@ -13,6 +13,7 @@ import {
Bell,
BookOpen,
ChevronRight,
Cpu,
Database,
Download,
Eye,
@@ -33,6 +34,7 @@ import { api, download, setCsrf } from "./api";
import { Chart } from "./Chart";
import {
AdminDialog,
AIConfigDialog,
FormulaExportDialog,
Copilot,
EvidenceDialog,
@@ -43,6 +45,7 @@ import {
import {
format,
labels,
roleLabels,
type Analysis,
type Context,
type Dataset,
@@ -83,6 +86,7 @@ export function App() {
| "dictionary"
| "users"
| "audit"
| "ai"
| null
| "formula"
>(null),
@@ -305,6 +309,23 @@ export function App() {
setPath([]);
setModal(null);
}
// 全部分组都为空的指标列(如数据集没有成本/ROI)不展示,避免占位挤压可用列。
const metricKeys = useMemo(() => {
const candidates = [
...new Set<Metric>([
"revenue",
"gross",
"cost",
"roi",
"customers",
ctx?.metric || "revenue",
]),
];
const available = candidates.filter(
(m) => m === "revenue" || (result?.rows || []).some((r) => r.values[m]?.current != null),
);
return available.length ? available : (["revenue"] as Metric[]);
}, [result, ctx]);
const columns = useMemo<ColDef<Row>[]>(
() => [
{
@@ -324,23 +345,14 @@ export function App() {
</button>
),
},
...[
...new Set<Metric>([
"revenue",
"gross",
"cost",
"roi",
"customers",
ctx?.metric || "revenue",
]),
].map((metric) => ({
...metricKeys.map((metric) => ({
colId: metric,
headerName: labels[metric],
minWidth: 130,
valueGetter: (p: { data?: Row }) =>
p.data?.values[metric].current == null
p.data?.values[metric]?.current == null
? null
: Number(p.data.values[metric].current),
: Number(p.data.values[metric]?.current),
valueFormatter: (p: { value: unknown }) =>
format(p.value as number, metric),
cellClass: metric === ctx?.metric ? "metric-column" : "",
@@ -393,24 +405,34 @@ export function App() {
),
},
],
[ds, ctx, path],
[ds, ctx, path, metricKeys],
);
const rows = (result?.rows || []).filter(
(r) => (screen !== "anomalies" || r.anomaly) && r.label.includes(search),
);
const metric = ctx?.metric || "revenue";
const compactAxis = (value: number) => {
const abs = Math.abs(value);
if (abs >= 1e8) return `${value / 1e8}亿`;
if (abs >= 1e4) return `${value / 1e4}万`;
return String(value);
};
const trend = useMemo(
() => ({
color: ["#197568", "#5794c9"],
tooltip: { trigger: "axis" },
legend: { bottom: 0 },
grid: { top: 25, left: 65, right: 25, bottom: 65 },
grid: { top: 25, left: 85, right: 25, bottom: 65 },
xAxis: {
type: "category",
data: result?.trend.map((r) => r.period),
axisLabel: { color: "#73827b" },
axisLabel: { color: "#73827b", hideOverlap: true },
},
yAxis: {
type: "value",
splitLine: { lineStyle: { color: "#ecf0ee" } },
axisLabel: { hideOverlap: true },
},
yAxis: { type: "value", splitLine: { lineStyle: { color: "#ecf0ee" } } },
series: [
{
name: "实际收入",
@@ -440,7 +462,11 @@ export function App() {
() => ({
tooltip: { trigger: "axis" },
grid: { top: 15, left: 95, right: 30, bottom: 30 },
xAxis: { type: "value", splitLine: { lineStyle: { color: "#ecf0ee" } } },
xAxis: {
type: "value",
splitLine: { lineStyle: { color: "#ecf0ee" } },
axisLabel: { hideOverlap: true, formatter: compactAxis },
},
yAxis: {
type: "category",
inverse: true,
@@ -543,6 +569,10 @@ export function App() {
<ShieldCheck />
操作审计
</button>
<button onClick={() => setModal("ai")}>
<Cpu />
AI 模型
</button>
</>
)}
</nav>
@@ -871,6 +901,8 @@ export function App() {
missing_gross: "毛利缺失",
missing_customer: "客户标识缺失",
blank_rows: "空白行",
g_l_mismatch: "应收金额两列不一致",
i_n_mismatch: "实收与实际收入不一致",
} as Record<string, string>
)[k] || k}
<strong>{v.toLocaleString()}</strong>
@@ -1023,16 +1055,11 @@ export function App() {
)?.name
}
</th>
{(
[
"revenue",
"gross",
"cost",
"roi",
] as Metric[]
).map((m) => (
<th key={m}>{labels[m]}</th>
))}
{metricKeys
.filter((m) => m !== "customers")
.map((m) => (
<th key={m}>{labels[m]}</th>
))}
<th>状态</th>
</tr>
</thead>
@@ -1040,15 +1067,10 @@ export function App() {
{result.rows.slice(0, 12).map((r) => (
<tr key={r.label}>
<th>{r.label}</th>
{(
[
"revenue",
"gross",
"cost",
"roi",
] as Metric[]
).map((m) => {
const v = r.values[m].change;
{metricKeys
.filter((m) => m !== "customers")
.map((m) => {
const v = r.values[m]?.change;
return (
<td key={m}>
<button
@@ -1062,7 +1084,7 @@ export function App() {
: "heat-positive"
}
onClick={() => {
setCtx({ ...ctx, metric: m });
setCtx({ ...ctx, metric: m as Metric });
setSelected(r);
setScreen("anomalies");
}}
@@ -1220,48 +1242,53 @@ export function App() {
</div>
</div>
<div className="grid-layout">
<div className="grid">
<AgGridReact<Row>
theme={gridTheme}
rowData={rows}
columnDefs={columns}
defaultColDef={{
sortable: true,
filter: true,
resizable: true,
}}
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
getRowId={(p) => p.data.label}
onRowClicked={(e) => setSelected(e.data)}
localeText={{
noRowsToShow: "没有符合条件的分组",
page: "页",
of: "/",
to: "至",
more: "更多",
nextPage: "下一页",
previousPage: "上一页",
firstPage: "首页",
lastPage: "末页",
pageSizeSelectorLabel: "每页",
}}
{...(enterprise
? {
cellSelection: true,
sideBar: "columns",
statusBar: {
statusPanels: [
{
statusPanel: "agTotalRowCountComponent",
},
],
},
}
: {})}
/>
</div>
{rows.length ? (
<div className="grid">
<AgGridReact<Row>
theme={gridTheme}
rowData={rows}
columnDefs={columns}
defaultColDef={{
sortable: true,
filter: true,
resizable: true,
}}
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
getRowId={(p) => p.data.label}
onRowClicked={(e) => setSelected(e.data)}
localeText={{
page: "页",
of: "/",
to: "至",
more: "更多",
nextPage: "下一页",
previousPage: "上一页",
firstPage: "首页",
lastPage: "末页",
pageSizeSelectorLabel: "每页",
}}
{...(enterprise
? {
cellSelection: true,
sideBar: "columns",
statusBar: {
statusPanels: [
{
statusPanel: "agTotalRowCountComponent",
},
],
},
}
: {})}
/>
</div>
) : (
<p role="status" className="grid-empty">
没有符合条件的分组
</p>
)}
{selected && (
<aside className="explanation">
<div className="section-heading">
@@ -1465,7 +1492,7 @@ export function App() {
<tr key={c.index}>
<td>{c.name}</td>
<td>{c.description}</td>
<td>{c.role}</td>
<td>{roleLabels[c.role] || c.role}</td>
</tr>
))}
</tbody>
@@ -1476,6 +1503,7 @@ export function App() {
{(modal === "users" || modal === "audit") && (
<AdminDialog mode={modal} onClose={() => setModal(null)} />
)}
{modal === "ai" && <AIConfigDialog onClose={() => setModal(null)} />}
</div>
);
}
+243 -35
View File
@@ -3,7 +3,12 @@ import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-rea
import { api } from "./api";
import {
labels,
roleLabels,
type AIConfig,
type Context,
type FormulaColumn,
type FormulaExpression,
type SavedFormulaDefinition,
type Dataset,
type Metric,
type Preferences,
@@ -12,21 +17,6 @@ import {
type User = NonNullable<Session["user"]>;
type FormulaExpression =
| { metric: Metric }
| { number: number }
| {
operator: "add" | "subtract" | "multiply" | "divide";
left: FormulaExpression;
right: FormulaExpression;
};
type SavedFormulaDefinition = {
id: string;
definition: Record<string, unknown>;
updated_at: string;
};
function describeExpression(expression: FormulaExpression): string {
if ("metric" in expression) return labels[expression.metric];
if ("number" in expression) return String(expression.number);
@@ -164,7 +154,9 @@ export function ImportDialog({
"dimension",
"ignore",
].map((r) => (
<option key={r}>{r}</option>
<option key={r} value={r}>
{roleLabels[r] || r}
</option>
))}
</select>
</td>
@@ -366,6 +358,7 @@ export function EvidenceDialog({
};
}, [ds.id, ctx, side, page]);
function selectSide(value: "current" | "baseline") {
if (value === side && page === 0) return;
setResult(undefined);
setPage(0);
setSide(value);
@@ -405,7 +398,7 @@ export function EvidenceDialog({
</p>
)}
<div className="table-scroll">
<table>
<table className="evidence-table">
<thead>
<tr>
<th>来源行</th>
@@ -471,6 +464,153 @@ export function EvidenceDialog({
);
}
export function AIConfigDialog({ onClose }: { onClose: () => void }) {
const [value, setValue] = useState<AIConfig>({
enabled: false,
provider: "local",
base_url: "",
model: "",
api_key_set: false,
updated_at: null,
}),
[key, setKey] = useState(""),
[models, setModels] = useState<string[]>([]),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[listing, setListing] = useState(false);
useEffect(() => {
api<AIConfig>("/ai-config")
.then((r) => setValue(r))
.catch((e) => setError((e as Error).message));
}, []);
async function fetchModels() {
setListing(true);
setError("");
try {
const r = await api<{ items: string[] }>("/ai-models");
setModels(r.items);
if (!r.items.length) setError("该服务没有返回可用模型");
} catch (e) {
setError((e as Error).message);
} finally {
setListing(false);
}
}
async function save() {
setBusy(true);
setError("");
try {
await api("/ai-config", "PUT", {
enabled: value.enabled,
provider: value.provider,
base_url: value.base_url,
model: value.model,
api_key: key,
});
setKey("");
setValue((v) => ({ ...v, api_key_set: v.api_key_set || Boolean(key) }));
if (value.base_url) await fetchModels();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Modal title="AI 模型配置" onClose={onClose} wide>
<div className="dialog-body">
<p className="muted">
配置财务分析助手使用的模型服务。数据库配置优先;未配置时回退到部署环境变量。
</p>
<label className="check">
<input
type="checkbox"
checked={value.enabled}
onChange={(e) => setValue({ ...value, enabled: e.target.checked })}
/>
启用 AI 模型理解问题
</label>
<label>
服务商
<select
value={value.provider}
onChange={(e) =>
setValue({
...value,
provider: e.target.value as AIConfig["provider"],
})
}
>
<option value="openai">OpenAI</option>
<option value="local">
本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM)
</option>
</select>
</label>
<label>
Base URL
<input
value={value.base_url}
placeholder={
value.provider === "local"
? "http://127.0.0.1:11434/v1"
: "https://api.openai.com/v1"
}
onChange={(e) => setValue({ ...value, base_url: e.target.value })}
/>
</label>
<label>
API Key
<input
type="password"
value={key}
placeholder={
value.api_key_set ? "已配置,留空保持不变" : "本地服务通常可留空"
}
onChange={(e) => setKey(e.target.value)}
/>
</label>
<label>
模型
<input
list="ai-model-list"
value={value.model}
placeholder="模型名,如 qwen2.5:7b"
onChange={(e) => setValue({ ...value, model: e.target.value })}
/>
<datalist id="ai-model-list">
{models.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</label>
<footer>
<button onClick={onClose}>取消</button>
<button
type="button"
disabled={listing || busy}
onClick={fetchModels}
>
{listing ? "获取中…" : "获取可用模型"}
</button>
<button className="primary" disabled={busy} onClick={save}>
{busy ? "保存中…" : "保存配置"}
</button>
</footer>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<p className="muted">
只向模型服务发送问题与维度/当前筛选,原始数据行不出服务器;密钥仅保存在后端,
配置修改会记入操作审计。
</p>
</div>
</Modal>
);
}
export function Copilot({
ds,
ctx,
@@ -550,7 +690,7 @@ export function Copilot({
checked={useModel}
onChange={(e) => setUseModel(e.target.checked)}
/>
使用外部 AI 理解问题
使用已配置的 AI 模型理解问题
</label>
{useModel && (
<label className="check">
@@ -675,7 +815,7 @@ export function FormulaExportDialog({
{ role: "user" | "assistant"; content: string }[]
>([]),
[draft, setDraft] = useState(""),
[column, setColumn] = useState<Record<string, unknown> | null>(null),
[column, setColumn] = useState<FormulaColumn | null>(null),
[questions, setQuestions] = useState<string[]>([]),
[task, setTask] = useState<{
id: string;
@@ -684,9 +824,30 @@ export function FormulaExportDialog({
}>(),
[definitions, setDefinitions] = useState<SavedFormulaDefinition[]>([]),
[selectedDefinition, setSelectedDefinition] = useState(""),
[shared, setShared] = useState(false),
[loadedDefinition, setLoadedDefinition] = useState<FormulaColumn | null>(null),
[consent, setConsent] = useState(false),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
const draftKey = `traceiq.formula-draft.${ds.id}`;
const [draftRestored, setDraftRestored] = useState(false);
useEffect(() => {
try {
const stored = sessionStorage.getItem(draftKey);
if (stored) {
const saved = JSON.parse(stored);
setDraft(saved.draft || "");
setMessages(saved.messages || []);
setColumn(saved.column || null);
}
} catch {
sessionStorage.removeItem(draftKey);
}
setDraftRestored(true);
}, [draftKey]);
useEffect(() => {
if (draftRestored) sessionStorage.setItem(draftKey, JSON.stringify({ draft, messages, column }));
}, [draftKey, draftRestored, draft, messages, column]);
useEffect(() => {
api<{ items: SavedFormulaDefinition[] }>(
`/datasets/${ds.id}/formula-definitions`,
@@ -713,7 +874,10 @@ export function FormulaExportDialog({
async function send() {
const text = draft.trim();
if (!text || !consent || userTurns >= 6) return;
const next = [...messages, { role: "user" as const, content: text }];
const content = loadedDefinition
? `已载入的原始定义:${JSON.stringify(loadedDefinition)}\n本次补充:${text}`
: text;
const next = [...messages, { role: "user" as const, content }];
setBusy(true);
setError("");
setQuestions([]);
@@ -722,7 +886,7 @@ export function FormulaExportDialog({
const result = await api<{
complete: boolean;
questions: string[];
column: Record<string, unknown> | null;
column: FormulaColumn | null;
}>(`/datasets/${ds.id}/formula-design`, "POST", {
context: ctx,
messages: next,
@@ -739,6 +903,7 @@ export function FormulaExportDialog({
]);
setQuestions(result.questions);
setColumn(result.column);
setLoadedDefinition(null);
setDraft("");
} catch (e) {
setError((e as Error).message);
@@ -747,7 +912,7 @@ export function FormulaExportDialog({
}
}
async function create() {
if (!column) return;
if (!column && !ctx.custom_columns?.length) return;
setBusy(true);
setError("");
try {
@@ -755,7 +920,12 @@ export function FormulaExportDialog({
await api<{ id: string; status: string }>(
`/datasets/${ds.id}/formula-exports`,
"POST",
{ ...ctx, custom_columns: [column] },
{
...ctx,
custom_columns: column
? [...(ctx.custom_columns || []).filter((item) => item.name !== column.name), column]
: ctx.custom_columns,
},
),
);
} catch (e) {
@@ -772,11 +942,24 @@ export function FormulaExportDialog({
const saved = await api<SavedFormulaDefinition>(
`/datasets/${ds.id}/formula-definitions`,
"POST",
{ definition: column },
{
definition: column,
shared,
revision: definitions.find(
(item) =>
(item.id === selectedDefinition && item.owned) ||
(item.owned &&
String(item.definition.name).trim().toLowerCase() ===
String(column.name).trim().toLowerCase()),
)?.revision,
},
);
setDefinitions((items) =>
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
String(a.definition.name).localeCompare(String(b.definition.name), "zh-CN"),
String(a.definition.name).localeCompare(
String(b.definition.name),
"zh-CN",
),
),
);
setSelectedDefinition(saved.id);
@@ -789,8 +972,14 @@ export function FormulaExportDialog({
function loadDefinition(id: string) {
setSelectedDefinition(id);
const saved = definitions.find((item) => item.id === id);
if (!saved) return;
if (!saved) {
setLoadedDefinition(null);
setColumn(null);
return;
}
setColumn(saved.definition);
setShared(saved.owned && saved.shared);
setLoadedDefinition(saved.definition);
setMessages([]);
setQuestions([]);
setDraft("");
@@ -808,6 +997,7 @@ export function FormulaExportDialog({
items.filter((item) => item.id !== selectedDefinition),
);
setSelectedDefinition("");
setLoadedDefinition(null);
setColumn(null);
} catch (e) {
setError((e as Error).message);
@@ -817,15 +1007,10 @@ export function FormulaExportDialog({
}
async function getFile() {
try {
const blob = await api<Blob>(
`/datasets/${ds.id}/formula-exports/${task!.id}/download`,
),
url = URL.createObjectURL(blob),
link = document.createElement("a");
link.href = url;
const link = document.createElement("a");
link.href = `/api/datasets/${ds.id}/formula-exports/${task!.id}/download`;
link.download = "traceiq-formula-analysis.xlsx";
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
setError((e as Error).message);
}
@@ -834,6 +1019,11 @@ export function FormulaExportDialog({
<Modal title="生成公式工作簿" onClose={onClose} wide>
<div className="dialog-body">
<p>描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。</p>
{!column && !task && !!ctx.custom_columns?.length && (
<button disabled={busy} onClick={create}>
按网页已选 {ctx.custom_columns.length} 列生成工作簿
</button>
)}
{definitions.length > 0 && !task && (
<div className="form-row">
<label>
@@ -846,7 +1036,7 @@ export function FormulaExportDialog({
<option value="">选择定义</option>
{definitions.map((item) => (
<option key={item.id} value={item.id}>
{String(item.definition.name)}
{item.definition.name}{item.owned ? "" : `(${item.owner}共享)`}
</option>
))}
</select>
@@ -855,13 +1045,27 @@ export function FormulaExportDialog({
className="icon"
aria-label="删除所选定义"
title="删除所选定义"
disabled={busy || !selectedDefinition}
disabled={busy || !definitions.find((item) => item.id === selectedDefinition)?.owned}
onClick={deleteDefinition}
>
<Trash2 />
</button>
</div>
)}
{definitions.find((item) => item.id === selectedDefinition)?.history.length ? (
<details>
<summary>版本历史(最近 20 次,可载入后另存)</summary>
{definitions.find((item) => item.id === selectedDefinition)!.history.map((version) => (
<button key={version.revision} disabled={busy} onClick={() => {
setColumn(version.definition);
setLoadedDefinition(version.definition);
setMessages([]);
}}>
载入版本 {version.revision} · {version.definition.meaning}
</button>
))}
</details>
) : null}
<label className="check">
<input
type="checkbox"
@@ -904,6 +1108,10 @@ export function FormulaExportDialog({
</p>
<p>适用范围:{String(column.scope)}</p>
<p>缺失/零分母:{String(column.missing)}</p>
<label className="check">
<input type="checkbox" checked={shared} onChange={(event) => setShared(event.target.checked)} />
保存时共享给当前数据集的其他全范围分析账号
</label>
<button
disabled={busy || Boolean(draft.trim())}
onClick={saveDefinition}
+22 -7
View File
@@ -484,7 +484,7 @@ a {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 20px 20px 16px;
padding: 18px 16px 14px;
gap: 16px;
background: white;
min-width: 0;
@@ -510,12 +510,13 @@ a {
}
.kpi strong {
text-align: left;
font-size: 24px;
font-size: 21px;
line-height: 1.25;
font-weight: 620;
font-variant-numeric: tabular-nums;
white-space: normal;
overflow-wrap: anywhere;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-bottom {
display: flex;
@@ -1271,9 +1272,6 @@ details {
.kpi {
padding: 17px 13px;
}
.kpi strong {
font-size: 21px;
}
.chart-band {
gap: 15px;
}
@@ -1562,3 +1560,20 @@ details {
scroll-behavior: auto !important;
}
}
.grid-empty {
margin: 0;
padding: 40px 0;
text-align: center;
color: var(--muted, #5b6675);
border: 1px dashed var(--line);
border-radius: 6px;
width: 100%;
}
.evidence-table {
min-width: 960px;
}
.evidence-table th {
white-space: nowrap;
}
+50 -2
View File
@@ -32,6 +32,20 @@ export interface Column {
key: string;
confidence: number;
}
export const roleLabels: Record<string, string> = {
period: "期间",
year: "年份",
month: "月份",
dimension: "维度",
customer: "客户标识",
quantity: "数量",
revenue: "实际收入",
cost: "成本",
gross: "毛利贡献",
list_amount: "应收金额",
discount: "折扣金额",
ignore: "未使用",
};
export interface Mapping {
sheet: string;
header: number;
@@ -84,6 +98,32 @@ export interface Context {
filters: Record<string, string>;
group: string;
metric: Metric;
custom_columns?: FormulaColumn[];
}
export type FormulaExpression =
| { metric: Metric }
| { number: number }
| {
operator: "add" | "subtract" | "multiply" | "divide";
left: FormulaExpression;
right: FormulaExpression;
};
export interface FormulaColumn {
name: string;
meaning: string;
scope: string;
missing: string;
expression: FormulaExpression;
}
export interface SavedFormulaDefinition {
id: string;
definition: FormulaColumn;
revision: number;
updated_at: string;
shared: boolean;
owned: boolean;
owner: string;
history: { revision: number; definition: FormulaColumn; updated_at: string }[];
}
export interface Value {
current: Numeric;
@@ -107,15 +147,23 @@ export interface Analysis {
current: string[];
baseline: string[];
};
summary: Record<Metric, Value>;
summary: Record<string, Value>;
rows: Row[];
trend: ({ period: string } & Record<Metric, Numeric>)[];
trend: ({ period: string } & Record<string, Numeric>)[];
row_count: number;
anomaly_count: number;
comparable: boolean;
notice: string;
version: string;
}
export interface AIConfig {
enabled: boolean;
provider: "openai" | "local";
base_url: string;
model: string;
api_key_set: boolean;
updated_at: string | null;
}
export interface Session {
csrf: string;
user: {
+1
View File
@@ -93,6 +93,7 @@ MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_BYTES", 64 * 1024 * 1024))
MAX_IMPORT_ROWS = int(os.environ.get("MAX_IMPORT_ROWS", 1000000))
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "")
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
AI_ENABLED = os.environ.get("AI_ENABLED", "0") == "1"
LOGGING = {
"version": 1,
+3
View File
@@ -1,6 +1,7 @@
from django.urls import path
from finance import formula_views, views
from finance.ai import ai_config, ai_models
from finance.assistant import assistant
urlpatterns = [
@@ -11,6 +12,8 @@ urlpatterns = [
path("api/datasets", views.datasets),
path("api/users", views.users),
path("api/audit", views.audit_log),
path("api/ai-config", ai_config),
path("api/ai-models", ai_models),
path("api/datasets/<uuid:dataset_id>", views.dataset_detail),
path("api/datasets/<uuid:dataset_id>/mapping", views.mapping),
path("api/datasets/<uuid:dataset_id>/query", views.query),
+24 -3
View File
@@ -119,15 +119,36 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
);
await dialog.getByLabel("关闭", { exact: true }).click();
await page.getByRole("button", { name: "选择网页自定义列" }).click();
const columns = page.getByRole("dialog", { name: "选择网页自定义列" });
await columns.getByRole("checkbox", { name: /收入成本差/ }).check();
const customQuery = page.waitForResponse(
(response) =>
response.url().includes("/query") && response.request().method() === "POST",
);
await columns.getByRole("button", { name: "应用", exact: true }).click();
const customResult = await (await customQuery).json();
expect(customResult.summary.custom_0.current).toBe("1000");
expect(customResult.rows[0].values.custom_0.current).toBe("1000");
await page.getByRole("button", { name: "生成公式工作簿" }).click();
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });
await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" });
await expect(reopened).toContainText("计算表达式:(实际收入 − 成本)");
await reopened.getByLabel("列计算描述").fill("把刚才公式乘以 100");
await expect(
reopened.getByRole("button", { name: "先发送补充内容" }),
).toBeDisabled();
await reopened
.getByRole("button", { name: "确认并生成公式工作簿" })
.click();
.getByLabel("同意将描述、指标定义和当前筛选范围发送至已配置的外部 AI 服务")
.check();
await reopened.getByRole("button", { name: "发送描述" }).click();
expect(sentMessages[0].content).toContain('"name":"收入成本差"');
expect(sentMessages[0].content).toContain('"operator":"subtract"');
expect(sentMessages[0].content).toContain("把刚才公式乘以 100");
await reopened.getByRole("button", { name: "确认并生成公式工作簿" }).click();
await expect(reopened.getByRole("status")).toContainText("生成完成", {
timeout: 30000,
});
expect(designCalls).toBe(4);
expect(designCalls).toBe(5);
});
+19 -3
View File
@@ -30,7 +30,9 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
});
await uploaded;
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("heading", { name: "确认导入字段" })).toBeVisible();
await expect(
dialog.getByRole("heading", { name: "确认导入字段" }),
).toBeVisible();
await dialog.getByRole("button", { name: "确认并导入" }).click();
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
@@ -85,7 +87,14 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
await expect(evidenceDialog).toContainText("1 条");
await expect(evidenceDialog).toContainText("2026-08");
await expect(evidenceDialog).toContainText("CSV");
await evidenceDialog.getByRole("button", { name: "基期", exact: true }).click();
await evidenceDialog
.getByRole("button", { name: "本期", exact: true })
.click();
await expect(evidenceDialog).toContainText("2026-08");
await expect(evidenceDialog).not.toContainText("加载中");
await evidenceDialog
.getByRole("button", { name: "基期", exact: true })
.click();
await expect(evidenceDialog).toContainText("2025-08");
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
const evidenceOffsets: number[] = [];
@@ -111,10 +120,17 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
});
await page.getByLabel("查看医生A证据", { exact: true }).click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
await evidenceDialog.getByRole("button", { name: "下一页", exact: true }).click();
await evidenceDialog
.getByRole("button", { name: "下一页", exact: true })
.click();
await expect(evidenceDialog).toContainText("第 51-51 条,共 51 条");
await expect(evidenceDialog).toContainText("2026-09");
expect(evidenceOffsets).toEqual([0, 50]);
await evidenceDialog
.getByRole("button", { name: "基期", exact: true })
.click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
await expect(evidenceDialog).not.toContainText("2026-09");
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
await page
.getByRole("navigation", { name: "分析路径" })
+209 -4
View File
@@ -1,17 +1,182 @@
import json
from unittest.mock import patch
import httpx
import pytest
from django.contrib.auth.models import User
from django.test import Client
from finance.assistant import model_intent
from finance.models import Audit, FormulaDefinition, FormulaExport, Membership
from finance.models import AIConfig, Audit, FormulaDefinition, FormulaExport, Membership
pytestmark = pytest.mark.django_db
def completed_response(intent=None):
class Response:
def raise_for_status(self):
return None
def json(self):
return {
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": json.dumps(
intent
or {
"action": "overview",
"metric": "revenue",
"group": "department",
"filters": [{"key": "department", "value": "儿科"}],
}
),
}
]
}
],
}
return Response()
def test_ai_config_requires_full_admin_and_masks_key(workspace):
ds, member, client, _, _ = workspace
url = "/api/ai-config"
viewer = User.objects.create_user("ai-viewer", password="Finance-TestPassword991!")
Membership.objects.create(user=viewer, organization=ds.organization, role="viewer")
viewer_client = Client()
assert viewer_client.login(username="ai-viewer", password="Finance-TestPassword991!")
assert viewer_client.get(url).status_code == 403
member.departments = ["儿科"]
member.save(update_fields=["departments"])
scoped = Client()
assert scoped.login(username=member.user.username, password="Finance-TestPassword991!")
assert scoped.get(url).status_code == 403
member.departments = []
member.save(update_fields=["departments"])
assert client.get(url).json() == {
"enabled": False,
"provider": "local",
"base_url": "",
"model": "",
"api_key_set": False,
"updated_at": None,
}
invalid = [
{"enabled": True, "provider": "local", "base_url": "", "model": "qwen2.5:7b"},
{"enabled": True, "provider": "openai", "base_url": "", "model": "gpt-4o"},
{"enabled": True, "provider": "local", "base_url": "ftp://x", "model": "m"},
{"enabled": True, "provider": "local", "base_url": "http://x/v1", "model": ""},
]
for payload in invalid:
assert client.put(url, data=payload, content_type="application/json").status_code == 400
saved = client.put(
url,
data={
"enabled": True,
"provider": "local",
"base_url": "http://127.0.0.1:11434/v1",
"api_key": "ollama-secret",
"model": "qwen2.5:7b",
},
content_type="application/json",
)
assert saved.status_code == 200
shown = client.get(url).json()
assert shown["api_key_set"] is True
assert shown["enabled"] is True and shown["provider"] == "local"
assert "ollama-secret" not in json.dumps(shown)
assert client.get("/api/session").json()["ai_enabled"] is True
assert Audit.objects.filter(action="ai_config.update").exists()
def test_ai_models_lists_local_models(workspace):
ds, _, client, _, _ = workspace
client.put(
"/api/ai-config",
data={
"enabled": False,
"provider": "local",
"base_url": "http://127.0.0.1:11434/v1",
"model": "",
},
content_type="application/json",
)
with patch("finance.ai.httpx.Client") as http:
http.return_value.__enter__.return_value.get.return_value = type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {"data": [{"id": "qwen2.5:7b"}, {"id": "llama3.1:8b"}]},
},
)()
listed = client.get("/api/ai-models")
assert listed.status_code == 200
assert listed.json()["items"] == ["qwen2.5:7b", "llama3.1:8b"]
call = http.return_value.__enter__.return_value.get.call_args
assert call.args[0] == "http://127.0.0.1:11434/v1/models"
with patch("finance.ai.httpx.Client") as http:
http.return_value.__enter__.return_value.get.side_effect = httpx.ConnectError("boom")
failed = client.get("/api/ai-models")
assert failed.status_code == 502
def test_assistant_uses_org_db_config(workspace):
ds, _, client, *_ = workspace
client.put(
"/api/ai-config",
data={
"enabled": True,
"provider": "local",
"base_url": "http://127.0.0.1:11434/v1",
"model": "qwen2.5:7b",
},
content_type="application/json",
)
with patch("finance.assistant.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = completed_response()
response = client.post(
f"/api/datasets/{ds.pk}/assistant",
data={
"question": "分析儿科收入",
"context": {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
"use_model": True,
"consent": True,
},
content_type="application/json",
)
assert response.status_code == 200
assert response.json()["provider"] == "local"
call = http.return_value.__enter__.return_value.post.call_args
assert call.args[0] == "http://127.0.0.1:11434/v1/responses"
assert call.kwargs["json"]["model"] == "qwen2.5:7b"
assert "Authorization" not in call.kwargs["headers"]
disabled = AIConfig.objects.get(organization=ds.organization)
disabled.enabled = False
disabled.save(update_fields=["enabled"])
denied = client.post(
f"/api/datasets/{ds.pk}/assistant",
data={"question": "分析儿科收入", "use_model": True, "consent": True},
content_type="application/json",
)
assert denied.status_code == 503
def test_model_intent_accepts_only_valid_dimensions_and_sends_no_rows(workspace, settings):
ds, _, _, _, _ = workspace
settings.AI_ENABLED = True
settings.OPENAI_MODEL = "test-model"
settings.OPENAI_API_KEY = "test-key"
@@ -58,6 +223,7 @@ def test_model_intent_accepts_only_valid_dimensions_and_sends_no_rows(workspace,
def test_model_intent_rejects_unknown_filter_and_incomplete_provider_response(workspace, settings):
ds, *_ = workspace
settings.AI_ENABLED = True
settings.OPENAI_MODEL = "test-model"
settings.OPENAI_API_KEY = "test-key"
@@ -165,19 +331,34 @@ def test_formula_definition_library_is_private_validated_and_audited(workspace):
"right": {"metric": "cost"},
},
}
saved = client.post(url, data={"definition": definition}, content_type="application/json")
saved = client.post(url, data={"definition": definition, "shared": True}, content_type="application/json")
assert saved.status_code == 201
assert saved.json()["shared"] is True
definition_id = saved.json()["id"]
assert client.get(url).json()["items"][0]["definition"] == definition
updated = client.post(
url,
data={"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"}},
data={
"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"},
"shared": True,
"revision": saved.json()["revision"],
},
content_type="application/json",
)
assert updated.status_code == 200
assert FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).count() == 1
assert updated.json()["definition"]["meaning"] == "更新说明"
assert updated.json()["revision"] == 2
assert len(updated.json()["history"]) == 1
stale = client.post(
url,
data={"definition": {**definition, "meaning": "旧标签页覆盖"}, "revision": 1},
content_type="application/json",
)
assert stale.status_code == 409
assert FormulaDefinition.objects.get(pk=definition_id).definition["meaning"] == "更新说明"
other = Client()
other_user = ds.created_by.__class__.objects.create_user(
@@ -185,7 +366,8 @@ def test_formula_definition_library_is_private_validated_and_audited(workspace):
)
Membership.objects.create(user=other_user, organization=ds.organization, role="analyst")
assert other.login(username=other_user.username, password="Formula-Library-TestPassword991!")
assert other.get(url).json()["items"] == []
shared = other.get(url).json()["items"]
assert len(shared) == 1 and shared[0]["owned"] is False and shared[0]["shared"] is True
assert other.delete(f"{url}/{definition_id}").status_code == 404
deleted = client.delete(f"{url}/{definition_id}")
@@ -219,3 +401,26 @@ def test_formula_definition_library_rejects_unavailable_metric(workspace):
)
assert response.status_code == 400
assert "成本" in response.json()["error"]
def test_formula_definition_audit_failure_rolls_back_definition(workspace, monkeypatch):
ds, _, client, *_ = workspace
definition = {
"name": "审计回滚",
"meaning": "实际收入",
"scope": "所选分组与期间",
"missing": "结果不可用(#N/A);零分母不可用",
"expression": {"metric": "revenue"},
}
def fail(*_args, **_kwargs):
raise RuntimeError("audit unavailable")
monkeypatch.setattr("finance.formula_views.audit", fail)
with pytest.raises(RuntimeError, match="audit unavailable"):
client.post(
f"/api/datasets/{ds.pk}/formula-definitions",
data={"definition": definition},
content_type="application/json",
)
assert not FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).exists()
+31 -2
View File
@@ -133,6 +133,35 @@ def test_evidence_pagination_returns_stable_pages_and_rejects_invalid_bounds(wor
assert response.status_code == 400
def test_evidence_amounts_trim_storage_scale(workspace):
from decimal import Decimal
from finance.views import amount_text
assert amount_text(Decimal("10.000000000000000000000000")) == "10"
assert amount_text(Decimal("10.500000000000000000000000")) == "10.5"
assert amount_text(Decimal("0E-24")) == "0"
assert amount_text(None) is None
ds, _, client, *_ = workspace
response = client.post(
f"/api/datasets/{ds.pk}/evidence",
data={
"start": "2026-08",
"end": "2026-08",
"comparison": "yoy",
"group": "department",
"side": "current",
"limit": 1,
},
content_type="application/json",
)
row = response.json()["rows"][0]
assert row["revenue"] == "80"
assert row["cost"] == "30"
assert row["gross"] == "50"
def test_settings_revision_and_invalid_metric_are_rejected(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/settings"
@@ -169,8 +198,8 @@ def test_csv_export_neutralizes_formula_injection_and_preserves_exact_values(wor
assert response.streaming
content = b"".join(response.streaming_content).decode("utf-8-sig")
assert "'=1+1" in content
assert ",-10.000000000000000000000000," in content
assert "'-10.000000000000000000000000" not in content
assert ",'-10,5,'-15" in content
assert "00000000000000" not in content
assert "traceiq-evidence.csv" in response["Content-Disposition"]
xlsx = client.post(
+24 -2
View File
@@ -97,8 +97,30 @@ def test_formula_workbook_exposes_custom_metric_as_recalculable_column(formula_d
custom = workbook["自定义指标"]
assert custom["B1"].value == "收入成本差(本期)"
assert custom["B2"].value == f"='分析汇总'!C{metric_row}"
assert "含义:实际收入减成本" in workbook["使用说明"]["B12"].value
assert "范围:按所选分组及本期、基期计算" in workbook["使用说明"]["B12"].value
instruction_text = " ".join(str(cell.value) for row in workbook["使用说明"].iter_rows() for cell in row)
assert "含义:实际收入减成本" in instruction_text
assert "范围:按所选分组及本期、基期计算" in instruction_text
def test_formula_workbook_protects_structure_and_unlocks_calculation_inputs(formula_data):
ds, member = formula_data
output = BytesIO()
build_formula_workbook(
ds,
member,
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
output,
)
workbook = load_workbook(BytesIO(output.getvalue()), data_only=False)
data = workbook["计算明细"]
assert data.protection.sheet
assert data["D2"].protection.locked is False
assert data["F2"].protection.locked is False
assert data["G2"].protection.locked is False
assert data["I2"].protection.locked is False
assert data["C2"].protection.locked is True
assert data["M2"].protection.locked is True
assert workbook["原始证据"].protection.sheet
def test_custom_expression_rejects_formula_text_and_unknown_metrics():
+78
View File
@@ -198,6 +198,67 @@ def test_formula_export_worker_records_path_preparation_failure(workspace, setti
assert job.status == "failed"
def test_formula_export_failure_does_not_follow_a_symlinked_user_directory(workspace, settings):
from finance.exporting import process_formula_export
ds, _, client, *_ = workspace
response = client.post(
f"/api/datasets/{ds.pk}/formula-exports",
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
content_type="application/json",
)
job = FormulaExport.objects.get(pk=response.json()["id"])
root = Path(settings.MEDIA_ROOT) / "formula-exports"
protected = Path(settings.MEDIA_ROOT) / "protected"
protected.mkdir()
target = protected / f"{job.pk}.xlsx"
target.write_bytes(b"keep")
root.mkdir()
(root / str(job.user_id)).symlink_to(protected, target_is_directory=True)
with pytest.raises(ValueError, match="符号链接"):
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "failed"
assert target.read_bytes() == b"keep"
def test_formula_export_failed_cleanup_is_observable_and_reclaimed(workspace, monkeypatch):
from django.core.management import call_command
from finance.exporting import process_formula_export
ds, _, client, *_ = workspace
response = client.post(
f"/api/datasets/{ds.pk}/formula-exports",
data={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
content_type="application/json",
)
job = FormulaExport.objects.get(pk=response.json()["id"])
target = Path(job.dataset.file.storage.location) / "formula-exports" / str(job.user_id) / f"{job.pk}.xlsx"
target.parent.mkdir(parents=True)
target.write_bytes(b"orphan")
monkeypatch.setattr(
"finance.exporting.build_formula_workbook",
lambda *_a, **_k: (_ for _ in ()).throw(ValueError("retry failed")),
)
real_unlink = Path.unlink
monkeypatch.setattr(Path, "unlink", lambda *_a, **_k: (_ for _ in ()).throw(OSError("read-only")))
with pytest.raises(ValueError, match="retry failed"):
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "failed"
assert job.path == str(target)
monkeypatch.setattr(Path, "unlink", real_unlink)
call_command("cleanup_formula_exports")
job.refresh_from_db()
assert job.path == ""
assert target.exists() is False
def test_formula_workbook_rejects_unclassified_label_collision(workspace):
from decimal import Decimal
@@ -374,3 +435,20 @@ def test_formula_workbook_rejects_dimension_keys_that_collide_with_reserved_head
{"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "revenue"},
BytesIO(),
)
def test_custom_reconcile_reference_preserves_large_decimal_and_zero_division():
from finance.exporting import _evaluate_expression
expression = {
"operator": "divide",
"left": {"metric": "revenue"},
"right": {"number": 0},
}
assert (
_evaluate_expression(expression, {"revenue": Decimal("1.2345678901234567890123456789012345")}) is None
)
assert _evaluate_expression(
{"operator": "add", "left": {"metric": "revenue"}, "right": {"number": 1}},
{"revenue": Decimal("1234567890123456789012345678901234")},
) == Decimal("1234567890123456789012345678901235")
+41 -1
View File
@@ -46,6 +46,23 @@ def test_worker_processes_queued_import_and_formula_export(workspace):
assert job.status == "processing"
def test_worker_reclaims_processing_formula_export_after_restart(workspace):
ds, _, _, *_ = workspace
job = FormulaExport.objects.create(
dataset=ds,
user=ds.created_by,
context={"custom_columns": []},
status="processing",
)
from unittest.mock import patch
with patch(
"finance.management.commands.import_worker.process_formula_export", return_value={"rows": 5}
) as process:
call_command("import_worker", once=True)
process.assert_called_once_with(job.pk)
def test_formula_export_status_download_and_access_controls(workspace, settings, tmp_path):
ds, _, client, _, other_client = workspace
url = f"/api/datasets/{ds.pk}"
@@ -58,7 +75,7 @@ def test_formula_export_status_download_and_access_controls(workspace, settings,
job.status = "succeeded"
job.expires_at = timezone.now() + timedelta(hours=1)
target = tmp_path / "formula-exports" / str(ds.created_by_id) / f"{job.pk}.xlsx"
target.parent.mkdir(parents=True)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"xlsx")
job.path = str(target)
job.save()
@@ -85,6 +102,29 @@ def test_formula_export_status_download_and_access_controls(workspace, settings,
assert client.get(status_url).status_code == 403
def test_formula_export_rechecks_role_when_worker_runs_and_file_is_downloaded(workspace, settings, tmp_path):
from finance.exporting import process_formula_export
ds, member, client, _, _ = workspace
job = FormulaExport.objects.create(dataset=ds, user=ds.created_by, context={}, status="processing")
member.role = "viewer"
member.save(update_fields=["role"])
with pytest.raises(PermissionError, match="撤权"):
process_formula_export(job.pk)
job.refresh_from_db()
assert job.status == "failed"
job.status = "succeeded"
job.expires_at = timezone.now() + timedelta(hours=1)
target = tmp_path / "formula-exports" / str(ds.created_by_id) / f"{job.pk}.xlsx"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"xlsx")
job.path = str(target)
job.save(update_fields=["status", "expires_at", "path"])
assert client.get(f"/api/datasets/{ds.pk}/formula-exports/{job.pk}").status_code == 403
assert client.get(f"/api/datasets/{ds.pk}/formula-exports/{job.pk}/download").status_code == 403
def test_formula_export_create_validates_definition_and_records_job(workspace):
ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/formula-exports"