Author SHA1 Message Date
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
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
22 changed files with 1011 additions and 214 deletions
+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 = {} result = {}
for key in METRICS: for key in keys or METRICS:
c, b = current.get(key), baseline.get(key) 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 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 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 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): 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"]) qs = scoped_facts(ds, member, ctx["filters"])
formula = ds.mapping["roi_formula"] formula = ds.mapping["roi_formula"]
products = ds.mapping.get("self_operated_products", []) products = ds.mapping.get("self_operated_products", [])
@@ -190,8 +213,14 @@ def analyze(ds, member, body, preferences):
) )
comparable = not ctx["missing"] comparable = not ctx["missing"]
if set(ctx["current"]) - set(ds.periods): if set(ctx["current"]) - set(ds.periods):
total_c = {**total_c, **{key: None for key in METRICS}} total_c = {**total_c, **{key: None for key in value_keys}}
summary = comparison(total_c, total_b, comparable) 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"] group = "dimensions__" + ctx["group"]
buckets = {} buckets = {}
for side in ("current", "baseline"): for side in ("current", "baseline"):
@@ -205,7 +234,7 @@ def analyze(ds, member, body, preferences):
k: Decimal(0) k: Decimal(0)
if k in ("revenue", "cost", "gross", "customers", "self_revenue") and total.get(k) is not None if k in ("revenue", "cost", "gross", "customers", "self_revenue") and total.get(k) is not None
else 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(): for label, sides in buckets.items():
c, b = sides.get("current", empty_side(total_c)), sides.get("baseline", empty_side(total_b)) c, b = sides.get("current", empty_side(total_c)), sides.get("baseline", empty_side(total_b))
if set(ctx["current"]) - set(ds.periods): if set(ctx["current"]) - set(ds.periods):
c = {**c, **{key: None for key in METRICS}} c = {**c, **{key: None for key in value_keys}}
values = comparison(c, b, comparable) 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] v = values[metric]
change, delta = v["change"], v["delta"] change, delta = v["change"], v["delta"]
impact = ( impact = (
@@ -249,7 +280,10 @@ def analyze(ds, member, body, preferences):
) )
rows.sort(key=lambda r: (not r["anomaly"], -r["impact"], r["label"])) rows.sort(key=lambda r: (not r["anomaly"], -r["impact"], r["label"]))
observed = { 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)) for r in qs.values("period").annotate(**aggregations(products))
} }
trend = [] trend = []
@@ -257,7 +291,7 @@ def analyze(ds, member, body, preferences):
period = month_text(index) period = month_text(index)
values = observed.get(period) values = observed.get(period)
if values is None: 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}) trend.append({"period": period, **values})
return { return {
"dataset_id": str(ds.pk), "dataset_id": str(ds.pk),
+91 -13
View File
@@ -1,5 +1,6 @@
import hashlib import hashlib
import hmac import hmac
import logging
import os import os
import secrets import secrets
import warnings import warnings
@@ -11,22 +12,62 @@ from django.conf import settings
from django.utils import timezone from django.utils import timezone
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell 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 openpyxl.worksheet.table import Table, TableColumn, TableStyleInfo
from .analysis import METRICS, aggregations, context, dimensions, metrics, scoped_facts from .analysis import METRICS, aggregations, context, dimensions, metrics, scoped_facts
from .formula_columns import ( from .formula_columns import (
RESERVED_DATA_HEADERS, RESERVED_DATA_HEADERS,
ensure_available_metrics, ensure_available_metrics,
evaluate,
metrics_used, metrics_used,
to_excel, to_excel,
validate_column, validate_column,
) )
from .models import FormulaExport, Membership from .models import FormulaExport, Membership
# Keep the private test seam while sharing the single evaluator implementation.
_evaluate_expression = evaluate
FORMULA_VERSION = "3" FORMULA_VERSION = "3"
MAX_ROWS = 100_000 MAX_ROWS = 100_000
MAX_GROUPS = 200 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): def _excel_number(value):
@@ -269,6 +310,19 @@ def build_formula_workbook(ds, member, export_context, output):
), ),
) )
data.append(headers) 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) token_secret = secrets.token_bytes(32)
customer_roster = {} customer_roster = {}
gross_index = next((col["index"] for col in mapping["columns"] if col["role"] == "gross"), None) 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},""))' 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) data.append(row)
customer_headers = [ customer_headers = [
"Group", "Group",
@@ -408,7 +468,13 @@ def build_formula_workbook(ds, member, export_context, output):
], ],
["ROI 口径", mapping["roi_formula"], "按导入映射保存的公式分支", "否"], ["ROI 口径", mapping["roi_formula"], "按导入映射保存的公式分支", "否"],
["自营产品集合", _text_cell(parameters, ", ".join(products)), "升单率和自营收入口径", "否"], ["自营产品集合", _text_cell(parameters, ", ".join(products)), "升单率和自营收入口径", "否"],
["产品毛利率", str(rates), "直接毛利缺失时的可编辑计算输入", "是"], ["产品毛利率快照", str(rates), "导入时保存的只读口径;修改需重新确认导入映射", "否"],
[
"对账容差",
"人数 0;金额 0.01;比率 0.0001;自定义 0.01",
"服务器 Decimal 参考与 Excel 结果的绝对差;正式财务验收前需确认",
"否",
],
[ [
"Excel 数值精度", "Excel 数值精度",
"IEEE-754 双精度", "IEEE-754 双精度",
@@ -443,6 +509,10 @@ def build_formula_workbook(ds, member, export_context, output):
["变化率", "差额/ABS(基期);基期为0或期间缺失时不可用。"], ["变化率", "差额/ABS(基期);基期为0或期间缺失时不可用。"],
["精度", "原始 Decimal 以文本留存;Excel 双精度存在细微误差可能。"], ["精度", "原始 Decimal 以文本留存;Excel 双精度存在细微误差可能。"],
["隐私", "客户标识替换为本文件随机 token,文件仍按敏感财务信息保护。"], ["隐私", "客户标识替换为本文件随机 token,文件仍按敏感财务信息保护。"],
[
"编辑保护",
"工作表结构、人数去重、原始证据和公式列已保护;计算明细仅收入、成本、毛利输入、毛利率和原价金额可编辑。行结构变化请重新导出。",
],
]: ]:
instructions.append(row) instructions.append(row)
for definition in custom_columns: for definition in custom_columns:
@@ -559,6 +629,7 @@ def build_formula_workbook(ds, member, export_context, output):
for key, name in METRICS.items(): for key, name in METRICS.items():
formula_row = metric_rows[(label, key)] formula_row = metric_rows[(label, key)]
value = reference[key] value = reference[key]
tolerance = RECONCILE_TOLERANCES.get(key, DEFAULT_RECONCILE_TOLERANCE)
reconcile.append( reconcile.append(
[ [
name, name,
@@ -567,24 +638,30 @@ def build_formula_workbook(ds, member, export_context, output):
"" if value is None else str(value), "" if value is None else str(value),
f"='分析汇总'!{col}{formula_row}", f"='分析汇总'!{col}{formula_row}",
f'=IFERROR(E{reconcile_row}-D{reconcile_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 reconcile_row += 1
for index, definition in enumerate(custom_columns): for index, definition in enumerate(custom_columns):
formula_row = metric_rows[(label, f"custom_{index}")] formula_row = metric_rows[(label, f"custom_{index}")]
custom_reference = evaluate(definition["expression"], reference)
reconcile.append( reconcile.append(
[ [
_text_cell(reconcile, definition["name"]), _text_cell(reconcile, definition["name"]),
_text_cell(reconcile, label), _text_cell(reconcile, label),
side_name, side_name,
"自定义公式无服务器参考", "" if custom_reference is None else str(custom_reference),
f"='分析汇总'!{col}{formula_row}", 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 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.fullCalcOnLoad = True
wb.calculation.forceFullCalc = True wb.calculation.forceFullCalc = True
wb.calculation.calcMode = "auto" 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) job = FormulaExport.objects.select_related("dataset", "user").get(pk=export_id)
temporary = None temporary = None
target = None target = None
root = Path(settings.MEDIA_ROOT) / "formula-exports"
try: try:
root = Path(settings.MEDIA_ROOT) / "formula-exports"
if root.is_symlink(): if root.is_symlink():
raise ValueError("公式导出目录不能是符号链接") raise ValueError("公式导出目录不能是符号链接")
root.mkdir(parents=True, exist_ok=True, mode=0o700) root.mkdir(parents=True, exist_ok=True, mode=0o700)
@@ -609,6 +686,8 @@ def process_formula_export(export_id):
target.parent.chmod(0o700) target.parent.chmod(0o700)
temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp") temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp")
member = Membership.objects.get(user=job.user, organization=job.dataset.organization) 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): if set(member.departments) != set(job.departments):
raise PermissionError("科室授权已变更,请重新提交导出") raise PermissionError("科室授权已变更,请重新提交导出")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) 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"]) job.save(update_fields=["path", "status", "error", "finished_at", "expires_at"])
return stats return stats
except Exception as exc: except Exception as exc:
remaining = ""
for path in (temporary, target): for path in (temporary, target):
try: if path is not None and not _safe_unlink_export(path, root):
if path is not None and not path.is_symlink(): if path.exists() or path.is_symlink():
path.unlink(missing_ok=True) remaining = str(path)
except OSError: job.status, job.error, job.path = "failed", str(exc)[:500], remaining
pass
job.status, job.error, job.path = "failed", str(exc)[:500], ""
job.finished_at, job.expires_at = timezone.now(), None job.finished_at, job.expires_at = timezone.now(), None
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"]) job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
raise raise
+25
View File
@@ -1,4 +1,5 @@
import math import math
from decimal import Decimal, localcontext
from .analysis import METRICS from .analysis import METRICS
@@ -107,3 +108,27 @@ def to_excel(node, references):
if "number" in node: if "number" in node:
return str(node["number"]) return str(node["number"])
return f"({to_excel(node['left'], references)}{OPERATORS[node['operator']]}{to_excel(node['right'], references)})" 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 import httpx
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.db.models import Q
from django.http import FileResponse, JsonResponse from django.http import FileResponse, JsonResponse
from django.utils import timezone from django.utils import timezone
from .analysis import METRICS, context, dimensions from .analysis import METRICS, context, dimensions
from .formula_columns import available_metrics, ensure_available_metrics, validate_column 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 from .views import APIError, audit, body, dataset, endpoint, limit
@@ -190,13 +192,23 @@ def formula_definitions(request, dataset_id):
if request.member.departments: if request.member.departments:
raise APIError("科室授权账号不可管理跨范围公式定义", 403) raise APIError("科室授权账号不可管理跨范围公式定义", 403)
if request.method == "GET": 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( return JsonResponse(
{ {
"items": [ "items": [
{ {
"id": str(item.pk), "id": str(item.pk),
"definition": item.definition, "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(), "updated_at": item.updated_at.isoformat(),
} }
for item in items 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: try:
ensure_available_metrics(ds, definition) ensure_available_metrics(ds, definition)
except ValueError as exc: except ValueError as exc:
raise APIError(str(exc)) from exc raise APIError(str(exc)) from exc
normalized_name = definition["name"].strip().casefold() normalized_name = definition["name"].strip().casefold()
existing = FormulaDefinition.objects.filter( requested_revision = data.get("revision")
dataset=ds, user=request.user, normalized_name=normalized_name with transaction.atomic():
).first() Dataset.objects.select_for_update().get(pk=ds.pk)
if not existing and FormulaDefinition.objects.filter(dataset=ds, user=request.user).count() >= 50: existing = (
raise APIError("每个数据集最多保存 50 个公式定义") FormulaDefinition.objects.select_for_update()
item, created = FormulaDefinition.objects.update_or_create( .filter(dataset=ds, user=request.user, normalized_name=normalized_name)
dataset=ds, .first()
user=request.user, )
normalized_name=normalized_name, if existing:
defaults={"definition": definition}, if requested_revision is not None and (
) type(requested_revision) is not int or requested_revision != existing.revision
audit(request, "formula_definition.save", item.pk, {"dataset": str(ds.pk), "name": definition["name"]}) ):
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( return JsonResponse(
{ {
"id": str(item.pk), "id": str(item.pk),
"definition": item.definition, "definition": item.definition,
"revision": item.revision,
"shared": item.shared,
"owned": True,
"owner": request.user.username,
"history": item.history,
"updated_at": item.updated_at.isoformat(), "updated_at": item.updated_at.isoformat(),
}, },
status=201 if created else 200, status=201 if created else 200,
@@ -241,8 +299,10 @@ def formula_definition_delete(request, dataset_id, definition_id):
if not item: if not item:
raise APIError("公式定义不存在", 404) raise APIError("公式定义不存在", 404)
name = item.definition.get("name", "") name = item.definition.get("name", "")
audit(request, "formula_definition.delete", item.pk, {"dataset": str(ds.pk), "name": name}) with transaction.atomic():
item.delete() 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}) return JsonResponse({"deleted": True})
@@ -279,6 +339,8 @@ def formula_export_create(request, dataset_id):
def _owned_job(request, dataset_id, export_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( job = FormulaExport.objects.filter(
pk=export_id, pk=export_id,
dataset_id=dataset_id, dataset_id=dataset_id,
@@ -5,8 +5,10 @@ from pathlib import Path
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from finance.exporting import _safe_unlink_export
from finance.models import FormulaExport from finance.models import FormulaExport
@@ -29,22 +31,30 @@ class Command(BaseCommand):
export_root = Path(settings.MEDIA_ROOT) / "formula-exports" export_root = Path(settings.MEDIA_ROOT) / "formula-exports"
if export_root.is_symlink(): if export_root.is_symlink():
raise ValueError("公式导出目录不能是符号链接") raise ValueError("公式导出目录不能是符号链接")
root = export_root.resolve() root = export_root.absolute()
now = timezone.now() now = timezone.now()
jobs = files = 0 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 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: try:
with transaction.atomic(): with transaction.atomic():
if in_root and resolved.is_file(): deleted = bool(path and _safe_unlink_export(path, root))
resolved.unlink() 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 files += 1
job.status, job.path = "expired", "" job.path = ""
job.save(update_fields=["status", "path"]) job.save(update_fields=["path"])
jobs += 1 jobs += 1
except OSError as exc: except OSError as exc:
self.stderr.write(f"Could not remove expired export {job.pk}: {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") self.stdout.write("Another worker owns the database lock")
return return
try: try:
self.run_jobs(options, postgres) self.run_jobs(options, True)
finally: finally:
if postgres: if postgres:
with connection.cursor() as cursor: with connection.cursor() as cursor:
@@ -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),
),
]
+4 -1
View File
@@ -22,7 +22,7 @@ class Dataset(models.Model):
organization = models.ForeignKey(Organization, on_delete=models.PROTECT) organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
name = models.CharField(max_length=200) 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) sha256 = models.CharField(max_length=64)
status = models.CharField(max_length=16, default="mapping") status = models.CharField(max_length=16, default="mapping")
profile = models.JSONField(default=dict) profile = models.JSONField(default=dict)
@@ -133,6 +133,9 @@ class FormulaDefinition(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
normalized_name = models.CharField(max_length=320) normalized_name = models.CharField(max_length=320)
definition = models.JSONField() 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
+16 -4
View File
@@ -22,7 +22,7 @@ from django.utils import timezone
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell 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 .importing import file_digest, profile_file, suggest_mapping, validate_mapping
from .models import AIConfig, Audit, Dataset, Investigation, Membership, Preference, Throttle from .models import AIConfig, Audit, Dataset, Investigation, Membership, Preference, Throttle
@@ -297,6 +297,14 @@ def query(request, dataset_id):
return JsonResponse(analyze(ds, request.member, body(request), pref)) 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",)) @endpoint(("POST",))
def evidence(request, dataset_id): def evidence(request, dataset_id):
ds = dataset(request, dataset_id, True) ds = dataset(request, dataset_id, True)
@@ -314,6 +322,9 @@ def evidence(request, dataset_id):
"source_row", "period", "dimensions", "revenue", "cost", "gross", "raw" "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. # Raw patient/customer identifiers require an explicit administrator role.
if request.member.role != "admin": if request.member.role != "admin":
for row in rows: for row in rows:
@@ -412,9 +423,9 @@ def export(request, dataset_id):
row.source_row, row.source_row,
row.period, row.period,
*[row.dimensions.get(d["key"], "") for d in dims], *[row.dimensions.get(d["key"], "") for d in dims],
row.revenue, amount_text(row.revenue),
row.cost, amount_text(row.cost),
row.gross, amount_text(row.gross),
] ]
) )
if workbook: if workbook:
@@ -510,6 +521,7 @@ def investigations(request, dataset_id):
raise APIError("查看者不可修改调查状态", 403) raise APIError("查看者不可修改调查状态", 403)
data = body(request) data = body(request)
ctx = context(ds, data.get("context", {})) ctx = context(ds, data.get("context", {}))
ctx["custom_columns"] = custom_columns(ds, data.get("context", {}))
status, note = data.get("status"), data.get("note", "") status, note = data.get("status"), data.get("note", "")
if status not in ("open", "reviewing", "resolved") or not isinstance(note, str) or len(note) > 2000: if status not in ("open", "reviewing", "resolved") or not isinstance(note, str) or len(note) > 2000:
raise APIError("调查状态或备注无效") raise APIError("调查状态或备注无效")
+139 -82
View File
@@ -35,6 +35,7 @@ import { Chart } from "./Chart";
import { import {
AdminDialog, AdminDialog,
AIConfigDialog, AIConfigDialog,
FormulaColumnsDialog,
FormulaExportDialog, FormulaExportDialog,
Copilot, Copilot,
EvidenceDialog, EvidenceDialog,
@@ -45,9 +46,12 @@ import {
import { import {
format, format,
labels, labels,
roleLabels,
type Analysis, type Analysis,
type Context, type Context,
type Dataset, type Dataset,
type FormulaColumn,
type SavedFormulaDefinition,
type Metric, type Metric,
type Preferences, type Preferences,
type Row, type Row,
@@ -88,6 +92,7 @@ export function App() {
| "ai" | "ai"
| null | null
| "formula" | "formula"
| "columns"
>(null), >(null),
[evidenceCtx, setEvidenceCtx] = useState<Context>(), [evidenceCtx, setEvidenceCtx] = useState<Context>(),
[chat, setChat] = useState(false), [chat, setChat] = useState(false),
@@ -97,6 +102,7 @@ export function App() {
[note, setNote] = useState(""), [note, setNote] = useState(""),
[status, setStatus] = useState("open"), [status, setStatus] = useState("open"),
[saved, setSaved] = useState(false); [saved, setSaved] = useState(false);
const [formulaDefinitions, setFormulaDefinitions] = useState<SavedFormulaDefinition[]>([]);
const upload = useRef<HTMLInputElement>(null), const upload = useRef<HTMLInputElement>(null),
requestVersion = useRef(0); requestVersion = useRef(0);
const report = useCallback( const report = useCallback(
@@ -225,6 +231,8 @@ export function App() {
c.comparison === current.comparison && c.comparison === current.comparison &&
c.metric === current.metric && c.metric === current.metric &&
c.group === current.group && c.group === current.group &&
JSON.stringify(c.custom_columns || []) ===
JSON.stringify(current.custom_columns || []) &&
Object.keys(c.filters).length === Object.keys(c.filters).length ===
Object.keys(current.filters).length && Object.keys(current.filters).length &&
Object.entries(c.filters).every( Object.entries(c.filters).every(
@@ -308,6 +316,26 @@ export function App() {
setPath([]); setPath([]);
setModal(null); 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),
);
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>[]>( const columns = useMemo<ColDef<Row>[]>(
() => [ () => [
{ {
@@ -327,23 +355,14 @@ export function App() {
</button> </button>
), ),
}, },
...[ ...metricKeys.map((metric) => ({
...new Set<Metric>([
"revenue",
"gross",
"cost",
"roi",
"customers",
ctx?.metric || "revenue",
]),
].map((metric) => ({
colId: metric, colId: metric,
headerName: labels[metric], headerName: metricLabel(metric),
minWidth: 130, minWidth: 130,
valueGetter: (p: { data?: Row }) => valueGetter: (p: { data?: Row }) =>
p.data?.values[metric].current == null p.data?.values[metric]?.current == null
? null ? null
: Number(p.data.values[metric].current), : Number(p.data.values[metric]?.current),
valueFormatter: (p: { value: unknown }) => valueFormatter: (p: { value: unknown }) =>
format(p.value as number, metric), format(p.value as number, metric),
cellClass: metric === ctx?.metric ? "metric-column" : "", cellClass: metric === ctx?.metric ? "metric-column" : "",
@@ -396,24 +415,34 @@ export function App() {
), ),
}, },
], ],
[ds, ctx, path], [ds, ctx, path, metricKeys],
); );
const rows = (result?.rows || []).filter( const rows = (result?.rows || []).filter(
(r) => (screen !== "anomalies" || r.anomaly) && r.label.includes(search), (r) => (screen !== "anomalies" || r.anomaly) && r.label.includes(search),
); );
const metric = ctx?.metric || "revenue"; 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( const trend = useMemo(
() => ({ () => ({
color: ["#197568", "#5794c9"], color: ["#197568", "#5794c9"],
tooltip: { trigger: "axis" }, tooltip: { trigger: "axis" },
legend: { bottom: 0 }, legend: { bottom: 0 },
grid: { top: 25, left: 65, right: 25, bottom: 65 }, grid: { top: 25, left: 85, right: 25, bottom: 65 },
xAxis: { xAxis: {
type: "category", type: "category",
data: result?.trend.map((r) => r.period), 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: [ series: [
{ {
name: "实际收入", name: "实际收入",
@@ -443,7 +472,11 @@ export function App() {
() => ({ () => ({
tooltip: { trigger: "axis" }, tooltip: { trigger: "axis" },
grid: { top: 15, left: 95, right: 30, bottom: 30 }, 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: { yAxis: {
type: "category", type: "category",
inverse: true, inverse: true,
@@ -878,6 +911,8 @@ export function App() {
missing_gross: "毛利缺失", missing_gross: "毛利缺失",
missing_customer: "客户标识缺失", missing_customer: "客户标识缺失",
blank_rows: "空白行", blank_rows: "空白行",
g_l_mismatch: "应收金额两列不一致",
i_n_mismatch: "实收与实际收入不一致",
} as Record<string, string> } as Record<string, string>
)[k] || k} )[k] || k}
<strong>{v.toLocaleString()}</strong> <strong>{v.toLocaleString()}</strong>
@@ -1030,16 +1065,11 @@ export function App() {
)?.name )?.name
} }
</th> </th>
{( {metricKeys
[ .filter((m) => m !== "customers" && m in labels)
"revenue", .map((m) => (
"gross", <th key={m}>{metricLabel(m)}</th>
"cost", ))}
"roi",
] as Metric[]
).map((m) => (
<th key={m}>{labels[m]}</th>
))}
<th>状态</th> <th>状态</th>
</tr> </tr>
</thead> </thead>
@@ -1047,15 +1077,10 @@ export function App() {
{result.rows.slice(0, 12).map((r) => ( {result.rows.slice(0, 12).map((r) => (
<tr key={r.label}> <tr key={r.label}>
<th>{r.label}</th> <th>{r.label}</th>
{( {metricKeys
[ .filter((m) => m !== "customers" && m in labels)
"revenue", .map((m) => {
"gross", const v = r.values[m]?.change;
"cost",
"roi",
] as Metric[]
).map((m) => {
const v = r.values[m].change;
return ( return (
<td key={m}> <td key={m}>
<button <button
@@ -1069,7 +1094,7 @@ export function App() {
: "heat-positive" : "heat-positive"
} }
onClick={() => { onClick={() => {
setCtx({ ...ctx, metric: m }); setCtx({ ...ctx, metric: m as Metric });
setSelected(r); setSelected(r);
setScreen("anomalies"); setScreen("anomalies");
}} }}
@@ -1100,7 +1125,7 @@ export function App() {
<div className="investigation-band"> <div className="investigation-band">
<section> <section>
<div className="section-heading"> <div className="section-heading">
<h2>{labels[metric]}变化分解</h2> <h2>{metricLabel(metric)}变化分解</h2>
<span> <span>
{ctx.start} 至 {ctx.end} {ctx.start} 至 {ctx.end}
</span> </span>
@@ -1209,6 +1234,22 @@ export function App() {
> >
<Calculator /> <Calculator />
</button> </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 <button
className="icon" className="icon"
aria-label="导出当前范围" aria-label="导出当前范围"
@@ -1227,48 +1268,53 @@ export function App() {
</div> </div>
</div> </div>
<div className="grid-layout"> <div className="grid-layout">
<div className="grid"> {rows.length ? (
<AgGridReact<Row> <div className="grid">
theme={gridTheme} <AgGridReact<Row>
rowData={rows} theme={gridTheme}
columnDefs={columns} rowData={rows}
defaultColDef={{ columnDefs={columns}
sortable: true, defaultColDef={{
filter: true, sortable: true,
resizable: true, filter: true,
}} resizable: true,
pagination }}
paginationPageSize={20} pagination
paginationPageSizeSelector={[20, 50, 100]} paginationPageSize={20}
getRowId={(p) => p.data.label} paginationPageSizeSelector={[20, 50, 100]}
onRowClicked={(e) => setSelected(e.data)} getRowId={(p) => p.data.label}
localeText={{ onRowClicked={(e) => setSelected(e.data)}
noRowsToShow: "没有符合条件的分组", localeText={{
page: "页", page: "页",
of: "/", of: "/",
to: "至", to: "至",
more: "更多", more: "更多",
nextPage: "下一页", nextPage: "下一页",
previousPage: "上一页", previousPage: "上一页",
firstPage: "首页", firstPage: "首页",
lastPage: "末页", lastPage: "末页",
pageSizeSelectorLabel: "每页", pageSizeSelectorLabel: "每页",
}} }}
{...(enterprise {...(enterprise
? { ? {
cellSelection: true, cellSelection: true,
sideBar: "columns", sideBar: "columns",
statusBar: { statusBar: {
statusPanels: [ statusPanels: [
{ {
statusPanel: "agTotalRowCountComponent", statusPanel: "agTotalRowCountComponent",
}, },
], ],
}, },
} }
: {})} : {})}
/> />
</div> </div>
) : (
<p role="status" className="grid-empty">
没有符合条件的分组
</p>
)}
{selected && ( {selected && (
<aside className="explanation"> <aside className="explanation">
<div className="section-heading"> <div className="section-heading">
@@ -1423,6 +1469,17 @@ export function App() {
{modal === "formula" && ds && ctx && ( {modal === "formula" && ds && ctx && (
<FormulaExportDialog ds={ds} ctx={ctx} onClose={() => setModal(null)} /> <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 && ( {modal === "settings" && ds && (
<SettingsDialog <SettingsDialog
ds={ds} ds={ds}
@@ -1472,7 +1529,7 @@ export function App() {
<tr key={c.index}> <tr key={c.index}>
<td>{c.name}</td> <td>{c.name}</td>
<td>{c.description}</td> <td>{c.description}</td>
<td>{c.role}</td> <td>{roleLabels[c.role] || c.role}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+154 -35
View File
@@ -1,11 +1,22 @@
import { useEffect, useRef, useState } from "react"; 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 { api } from "./api";
import { import {
labels, labels,
roleLabels,
type AIConfig, type AIConfig,
type Context, type Context,
type Dataset, type Dataset,
type FormulaColumn,
type FormulaExpression,
type SavedFormulaDefinition,
type Metric, type Metric,
type Preferences, type Preferences,
type Session, type Session,
@@ -13,21 +24,6 @@ import {
type User = NonNullable<Session["user"]>; 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 { function describeExpression(expression: FormulaExpression): string {
if ("metric" in expression) return labels[expression.metric]; if ("metric" in expression) return labels[expression.metric];
if ("number" in expression) return String(expression.number); if ("number" in expression) return String(expression.number);
@@ -165,7 +161,9 @@ export function ImportDialog({
"dimension", "dimension",
"ignore", "ignore",
].map((r) => ( ].map((r) => (
<option key={r}>{r}</option> <option key={r} value={r}>
{roleLabels[r] || r}
</option>
))} ))}
</select> </select>
</td> </td>
@@ -367,6 +365,7 @@ export function EvidenceDialog({
}; };
}, [ds.id, ctx, side, page]); }, [ds.id, ctx, side, page]);
function selectSide(value: "current" | "baseline") { function selectSide(value: "current" | "baseline") {
if (value === side && page === 0) return;
setResult(undefined); setResult(undefined);
setPage(0); setPage(0);
setSide(value); setSide(value);
@@ -406,7 +405,7 @@ export function EvidenceDialog({
</p> </p>
)} )}
<div className="table-scroll"> <div className="table-scroll">
<table> <table className="evidence-table">
<thead> <thead>
<tr> <tr>
<th>来源行</th> <th>来源行</th>
@@ -823,7 +822,7 @@ export function FormulaExportDialog({
{ role: "user" | "assistant"; content: string }[] { role: "user" | "assistant"; content: string }[]
>([]), >([]),
[draft, setDraft] = useState(""), [draft, setDraft] = useState(""),
[column, setColumn] = useState<Record<string, unknown> | null>(null), [column, setColumn] = useState<FormulaColumn | null>(null),
[questions, setQuestions] = useState<string[]>([]), [questions, setQuestions] = useState<string[]>([]),
[task, setTask] = useState<{ [task, setTask] = useState<{
id: string; id: string;
@@ -832,9 +831,30 @@ export function FormulaExportDialog({
}>(), }>(),
[definitions, setDefinitions] = useState<SavedFormulaDefinition[]>([]), [definitions, setDefinitions] = useState<SavedFormulaDefinition[]>([]),
[selectedDefinition, setSelectedDefinition] = useState(""), [selectedDefinition, setSelectedDefinition] = useState(""),
[shared, setShared] = useState(false),
[loadedDefinition, setLoadedDefinition] = useState<FormulaColumn | null>(null),
[consent, setConsent] = useState(false), [consent, setConsent] = useState(false),
[busy, setBusy] = useState(false), [busy, setBusy] = useState(false),
[error, setError] = useState(""); [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(() => { useEffect(() => {
api<{ items: SavedFormulaDefinition[] }>( api<{ items: SavedFormulaDefinition[] }>(
`/datasets/${ds.id}/formula-definitions`, `/datasets/${ds.id}/formula-definitions`,
@@ -861,7 +881,10 @@ export function FormulaExportDialog({
async function send() { async function send() {
const text = draft.trim(); const text = draft.trim();
if (!text || !consent || userTurns >= 6) return; 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); setBusy(true);
setError(""); setError("");
setQuestions([]); setQuestions([]);
@@ -870,7 +893,7 @@ export function FormulaExportDialog({
const result = await api<{ const result = await api<{
complete: boolean; complete: boolean;
questions: string[]; questions: string[];
column: Record<string, unknown> | null; column: FormulaColumn | null;
}>(`/datasets/${ds.id}/formula-design`, "POST", { }>(`/datasets/${ds.id}/formula-design`, "POST", {
context: ctx, context: ctx,
messages: next, messages: next,
@@ -887,6 +910,7 @@ export function FormulaExportDialog({
]); ]);
setQuestions(result.questions); setQuestions(result.questions);
setColumn(result.column); setColumn(result.column);
setLoadedDefinition(null);
setDraft(""); setDraft("");
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@@ -895,7 +919,7 @@ export function FormulaExportDialog({
} }
} }
async function create() { async function create() {
if (!column) return; if (!column && !ctx.custom_columns?.length) return;
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
@@ -903,7 +927,12 @@ export function FormulaExportDialog({
await api<{ id: string; status: string }>( await api<{ id: string; status: string }>(
`/datasets/${ds.id}/formula-exports`, `/datasets/${ds.id}/formula-exports`,
"POST", "POST",
{ ...ctx, custom_columns: [column] }, {
...ctx,
custom_columns: column
? [...(ctx.custom_columns || []).filter((item) => item.name !== column.name), column]
: ctx.custom_columns,
},
), ),
); );
} catch (e) { } catch (e) {
@@ -920,11 +949,24 @@ export function FormulaExportDialog({
const saved = await api<SavedFormulaDefinition>( const saved = await api<SavedFormulaDefinition>(
`/datasets/${ds.id}/formula-definitions`, `/datasets/${ds.id}/formula-definitions`,
"POST", "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) => setDefinitions((items) =>
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) => [...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); setSelectedDefinition(saved.id);
@@ -937,8 +979,14 @@ export function FormulaExportDialog({
function loadDefinition(id: string) { function loadDefinition(id: string) {
setSelectedDefinition(id); setSelectedDefinition(id);
const saved = definitions.find((item) => item.id === id); const saved = definitions.find((item) => item.id === id);
if (!saved) return; if (!saved) {
setLoadedDefinition(null);
setColumn(null);
return;
}
setColumn(saved.definition); setColumn(saved.definition);
setShared(saved.owned && saved.shared);
setLoadedDefinition(saved.definition);
setMessages([]); setMessages([]);
setQuestions([]); setQuestions([]);
setDraft(""); setDraft("");
@@ -956,6 +1004,7 @@ export function FormulaExportDialog({
items.filter((item) => item.id !== selectedDefinition), items.filter((item) => item.id !== selectedDefinition),
); );
setSelectedDefinition(""); setSelectedDefinition("");
setLoadedDefinition(null);
setColumn(null); setColumn(null);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@@ -965,15 +1014,10 @@ export function FormulaExportDialog({
} }
async function getFile() { async function getFile() {
try { try {
const blob = await api<Blob>( const link = document.createElement("a");
`/datasets/${ds.id}/formula-exports/${task!.id}/download`, link.href = `/api/datasets/${ds.id}/formula-exports/${task!.id}/download`;
),
url = URL.createObjectURL(blob),
link = document.createElement("a");
link.href = url;
link.download = "traceiq-formula-analysis.xlsx"; link.download = "traceiq-formula-analysis.xlsx";
link.click(); link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} }
@@ -982,6 +1026,11 @@ export function FormulaExportDialog({
<Modal title="生成公式工作簿" onClose={onClose} wide> <Modal title="生成公式工作簿" onClose={onClose} wide>
<div className="dialog-body"> <div className="dialog-body">
<p>描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。</p> <p>描述新列含义、计算方式、适用范围,以及缺失值或零分母处理方式。</p>
{!column && !task && !!ctx.custom_columns?.length && (
<button disabled={busy} onClick={create}>
按网页已选 {ctx.custom_columns.length} 列生成工作簿
</button>
)}
{definitions.length > 0 && !task && ( {definitions.length > 0 && !task && (
<div className="form-row"> <div className="form-row">
<label> <label>
@@ -994,7 +1043,7 @@ export function FormulaExportDialog({
<option value="">选择定义</option> <option value="">选择定义</option>
{definitions.map((item) => ( {definitions.map((item) => (
<option key={item.id} value={item.id}> <option key={item.id} value={item.id}>
{String(item.definition.name)} {item.definition.name}{item.owned ? "" : `(${item.owner}共享)`}
</option> </option>
))} ))}
</select> </select>
@@ -1003,13 +1052,27 @@ export function FormulaExportDialog({
className="icon" className="icon"
aria-label="删除所选定义" aria-label="删除所选定义"
title="删除所选定义" title="删除所选定义"
disabled={busy || !selectedDefinition} disabled={busy || !definitions.find((item) => item.id === selectedDefinition)?.owned}
onClick={deleteDefinition} onClick={deleteDefinition}
> >
<Trash2 /> <Trash2 />
</button> </button>
</div> </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"> <label className="check">
<input <input
type="checkbox" type="checkbox"
@@ -1052,6 +1115,10 @@ export function FormulaExportDialog({
</p> </p>
<p>适用范围:{String(column.scope)}</p> <p>适用范围:{String(column.scope)}</p>
<p>缺失/零分母:{String(column.missing)}</p> <p>缺失/零分母:{String(column.missing)}</p>
<label className="check">
<input type="checkbox" checked={shared} onChange={(event) => setShared(event.target.checked)} />
保存时共享给当前数据集的其他全范围分析账号
</label>
<button <button
disabled={busy || Boolean(draft.trim())} disabled={busy || Boolean(draft.trim())}
onClick={saveDefinition} onClick={saveDefinition}
@@ -1098,3 +1165,55 @@ export function FormulaExportDialog({
</Modal> </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>
);
}
+22 -7
View File
@@ -484,7 +484,7 @@ a {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
padding: 20px 20px 16px; padding: 18px 16px 14px;
gap: 16px; gap: 16px;
background: white; background: white;
min-width: 0; min-width: 0;
@@ -510,12 +510,13 @@ a {
} }
.kpi strong { .kpi strong {
text-align: left; text-align: left;
font-size: 24px; font-size: 21px;
line-height: 1.25; line-height: 1.25;
font-weight: 620; font-weight: 620;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: normal; white-space: nowrap;
overflow-wrap: anywhere; overflow: hidden;
text-overflow: ellipsis;
} }
.kpi-bottom { .kpi-bottom {
display: flex; display: flex;
@@ -1271,9 +1272,6 @@ details {
.kpi { .kpi {
padding: 17px 13px; padding: 17px 13px;
} }
.kpi strong {
font-size: 21px;
}
.chart-band { .chart-band {
gap: 15px; gap: 15px;
} }
@@ -1562,3 +1560,20 @@ details {
scroll-behavior: auto !important; 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;
}
+41 -1
View File
@@ -32,6 +32,20 @@ export interface Column {
key: string; key: string;
confidence: number; confidence: number;
} }
export const roleLabels: Record<string, string> = {
period: "期间",
year: "年份",
month: "月份",
dimension: "维度",
customer: "客户标识",
quantity: "数量",
revenue: "实际收入",
cost: "成本",
gross: "毛利贡献",
list_amount: "应收金额",
discount: "折扣金额",
ignore: "未使用",
};
export interface Mapping { export interface Mapping {
sheet: string; sheet: string;
header: number; header: number;
@@ -84,6 +98,32 @@ export interface Context {
filters: Record<string, string>; filters: Record<string, string>;
group: string; group: string;
metric: Metric; 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 { export interface Value {
current: Numeric; current: Numeric;
@@ -93,7 +133,7 @@ export interface Value {
} }
export interface Row { export interface Row {
label: string; label: string;
values: Record<Metric, Value>; values: Record<string, Value>;
rows: number; rows: number;
baseline_rows: number; baseline_rows: number;
anomaly: boolean; anomaly: boolean;
+24 -3
View File
@@ -119,15 +119,36 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
); );
await dialog.getByLabel("关闭", { exact: true }).click(); 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(); await page.getByRole("button", { name: "生成公式工作簿" }).click();
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" }); const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });
await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" }); await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" });
await expect(reopened).toContainText("计算表达式:(实际收入 − 成本)"); await expect(reopened).toContainText("计算表达式:(实际收入 − 成本)");
await reopened.getByLabel("列计算描述").fill("把刚才公式乘以 100");
await expect(
reopened.getByRole("button", { name: "先发送补充内容" }),
).toBeDisabled();
await reopened await reopened
.getByRole("button", { name: "确认并生成公式工作簿" }) .getByLabel("同意将描述、指标定义和当前筛选范围发送至已配置的外部 AI 服务")
.click(); .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("生成完成", { await expect(reopened.getByRole("status")).toContainText("生成完成", {
timeout: 30000, timeout: 30000,
}); });
expect(designCalls).toBe(4); expect(designCalls).toBe(5);
}); });
+54 -23
View File
@@ -30,7 +30,9 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
}); });
await uploaded; await uploaded;
const dialog = page.getByRole("dialog"); 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 dialog.getByRole("button", { name: "确认并导入" }).click();
await expect( await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }), page.getByRole("heading", { name: "集团经营总览", exact: true }),
@@ -85,7 +87,14 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
await expect(evidenceDialog).toContainText("1 条"); await expect(evidenceDialog).toContainText("1 条");
await expect(evidenceDialog).toContainText("2026-08"); await expect(evidenceDialog).toContainText("2026-08");
await expect(evidenceDialog).toContainText("CSV"); 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 expect(evidenceDialog).toContainText("2025-08");
await evidenceDialog.getByLabel("关闭", { exact: true }).click(); await evidenceDialog.getByLabel("关闭", { exact: true }).click();
const evidenceOffsets: number[] = []; const evidenceOffsets: number[] = [];
@@ -111,10 +120,17 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
}); });
await page.getByLabel("查看医生A证据", { exact: true }).click(); await page.getByLabel("查看医生A证据", { exact: true }).click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条"); 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("第 51-51 条,共 51 条");
await expect(evidenceDialog).toContainText("2026-09"); await expect(evidenceDialog).toContainText("2026-09");
expect(evidenceOffsets).toEqual([0, 50]); 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 evidenceDialog.getByLabel("关闭", { exact: true }).click();
await page await page
.getByRole("navigation", { name: "分析路径" }) .getByRole("navigation", { name: "分析路径" })
@@ -164,38 +180,53 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
await page.route("**/formula-design", async (route) => { await page.route("**/formula-design", async (route) => {
designCalls++; designCalls++;
await route.fulfill({ await route.fulfill({
json: designCalls === 1 json:
? { complete: false, questions: ["缺失值和零分母如何处理?"], column: null } designCalls === 1
: { ? {
complete: true, complete: false,
questions: [], questions: ["缺失值和零分母如何处理?"],
column: { column: null,
name: "收入成本差", }
meaning: "实际收入减成本", : {
scope: "所选分组与期间", complete: true,
missing: "结果不可用(#N/A);零分母不可用", questions: [],
expression: { column: {
operator: "subtract", name: "收入成本差",
left: { metric: "revenue" }, meaning: "实际收入减成本",
right: { metric: "cost" }, scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
}, },
}, },
},
}); });
}); });
await page.getByRole("button", { name: "生成公式工作簿" }).click(); await page.getByRole("button", { name: "生成公式工作簿" }).click();
const formulaDialog = page.getByRole("dialog", { name: "生成公式工作簿" }); const formulaDialog = page.getByRole("dialog", { name: "生成公式工作簿" });
await formulaDialog.getByRole("checkbox").check(); await formulaDialog.getByRole("checkbox").check();
await formulaDialog.getByLabel("列计算描述").fill("新增收入成本差,缺失或零分母显示不可用"); await formulaDialog
.getByLabel("列计算描述")
.fill("新增收入成本差,缺失或零分母显示不可用");
await formulaDialog.getByRole("button", { name: "发送描述" }).click(); await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await expect(formulaDialog).toContainText("缺失值和零分母如何处理?"); 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 formulaDialog.getByRole("button", { name: "确认并生成公式工作簿" }).click(); await formulaDialog
await expect(formulaDialog.getByRole("status")).toContainText("生成完成", { timeout: 30000 }); .getByRole("button", { name: "确认并生成公式工作簿" })
.click();
await expect(formulaDialog.getByRole("status")).toContainText("生成完成", {
timeout: 30000,
});
const formulaDownload = page.waitForEvent("download"); const formulaDownload = page.waitForEvent("download");
await formulaDialog.getByRole("button", { name: "下载公式工作簿" }).click(); 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(); await formulaDialog.getByLabel("关闭").click();
}); });
test("手机:登录、总览、设置、Copilot 布局无横向溢出", async ({ page }) => { test("手机:登录、总览、设置、Copilot 布局无横向溢出", async ({ page }) => {
+42 -3
View File
@@ -331,19 +331,34 @@ def test_formula_definition_library_is_private_validated_and_audited(workspace):
"right": {"metric": "cost"}, "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.status_code == 201
assert saved.json()["shared"] is True
definition_id = saved.json()["id"] definition_id = saved.json()["id"]
assert client.get(url).json()["items"][0]["definition"] == definition assert client.get(url).json()["items"][0]["definition"] == definition
updated = client.post( updated = client.post(
url, url,
data={"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"}}, data={
"definition": {**definition, "name": " 收入成本差 ", "meaning": "更新说明"},
"shared": True,
"revision": saved.json()["revision"],
},
content_type="application/json", content_type="application/json",
) )
assert updated.status_code == 200 assert updated.status_code == 200
assert FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).count() == 1 assert FormulaDefinition.objects.filter(dataset=ds, user=ds.created_by).count() == 1
assert updated.json()["definition"]["meaning"] == "更新说明" 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 = Client()
other_user = ds.created_by.__class__.objects.create_user( other_user = ds.created_by.__class__.objects.create_user(
@@ -351,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") Membership.objects.create(user=other_user, organization=ds.organization, role="analyst")
assert other.login(username=other_user.username, password="Formula-Library-TestPassword991!") 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 assert other.delete(f"{url}/{definition_id}").status_code == 404
deleted = client.delete(f"{url}/{definition_id}") deleted = client.delete(f"{url}/{definition_id}")
@@ -385,3 +401,26 @@ def test_formula_definition_library_rejects_unavailable_metric(workspace):
) )
assert response.status_code == 400 assert response.status_code == 400
assert "成本" in response.json()["error"] 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()
+61 -2
View File
@@ -85,6 +85,36 @@ def test_metrics_and_analysis_preserve_roi_refunds_and_distinct_customers(worksp
assert unavailable["self_revenue"] == 0 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): def test_dataset_and_evidence_are_organization_and_department_scoped(workspace):
ds, _, _, _, other_client = workspace ds, _, _, _, other_client = workspace
url = f"/api/datasets/{ds.pk}" url = f"/api/datasets/{ds.pk}"
@@ -133,6 +163,35 @@ def test_evidence_pagination_returns_stable_pages_and_rejects_invalid_bounds(wor
assert response.status_code == 400 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): def test_settings_revision_and_invalid_metric_are_rejected(workspace):
ds, _, client, *_ = workspace ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/settings" url = f"/api/datasets/{ds.pk}/settings"
@@ -169,8 +228,8 @@ def test_csv_export_neutralizes_formula_injection_and_preserves_exact_values(wor
assert response.streaming assert response.streaming
content = b"".join(response.streaming_content).decode("utf-8-sig") content = b"".join(response.streaming_content).decode("utf-8-sig")
assert "'=1+1" in content assert "'=1+1" in content
assert ",-10.000000000000000000000000," in content assert ",'-10,5,'-15" in content
assert "'-10.000000000000000000000000" not in content assert "00000000000000" not in content
assert "traceiq-evidence.csv" in response["Content-Disposition"] assert "traceiq-evidence.csv" in response["Content-Disposition"]
xlsx = client.post( 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["自定义指标"] custom = workbook["自定义指标"]
assert custom["B1"].value == "收入成本差(本期)" assert custom["B1"].value == "收入成本差(本期)"
assert custom["B2"].value == f"='分析汇总'!C{metric_row}" assert custom["B2"].value == f"='分析汇总'!C{metric_row}"
assert "含义:实际收入减成本" in workbook["使用说明"]["B12"].value instruction_text = " ".join(str(cell.value) for row in workbook["使用说明"].iter_rows() for cell in row)
assert "范围:按所选分组及本期、基期计算" in workbook["使用说明"]["B12"].value 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(): 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" 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): def test_formula_workbook_rejects_unclassified_label_collision(workspace):
from decimal import Decimal 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"}, {"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "revenue"},
BytesIO(), 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" 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): def test_formula_export_status_download_and_access_controls(workspace, settings, tmp_path):
ds, _, client, _, other_client = workspace ds, _, client, _, other_client = workspace
url = f"/api/datasets/{ds.pk}" 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.status = "succeeded"
job.expires_at = timezone.now() + timedelta(hours=1) job.expires_at = timezone.now() + timedelta(hours=1)
target = tmp_path / "formula-exports" / str(ds.created_by_id) / f"{job.pk}.xlsx" 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") target.write_bytes(b"xlsx")
job.path = str(target) job.path = str(target)
job.save() 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 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): def test_formula_export_create_validates_definition_and_records_job(workspace):
ds, _, client, *_ = workspace ds, _, client, *_ = workspace
url = f"/api/datasets/{ds.pk}/formula-exports" url = f"/api/datasets/{ds.pk}/formula-exports"