Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adce23bbd9 | ||
|
|
509b406254 | ||
|
|
494490f6e6 |
+44
-10
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user