交互修复:F03/F04 证据弹窗加载状态与载入定义上下文 #2
+80
-21
@@ -1,7 +1,9 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import stat
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
@@ -28,6 +30,58 @@ FORMULA_VERSION = "3"
|
|||||||
MAX_ROWS = 100_000
|
MAX_ROWS = 100_000
|
||||||
MAX_GROUPS = 200
|
MAX_GROUPS = 200
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_export_directory(path, description):
|
||||||
|
"""导出目录必须不存在或为真实目录;符号链接一律拒绝并保持文件原样。"""
|
||||||
|
try:
|
||||||
|
info = os.lstat(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
path.mkdir(parents=True, mode=0o700)
|
||||||
|
info = os.lstat(path)
|
||||||
|
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
||||||
|
raise ValueError(f"{description}必须是真实目录,不能是符号链接")
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_export_target(user_dir, job):
|
||||||
|
"""目标文件若已存在必须是普通文件;符号链接或其他类型一律拒绝覆盖。"""
|
||||||
|
target = user_dir / f"{job.pk}.xlsx"
|
||||||
|
try:
|
||||||
|
info = os.lstat(target)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return target
|
||||||
|
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
||||||
|
raise ValueError("公式导出目标路径已存在且不是普通文件,已拒绝生成")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink_created_file(path):
|
||||||
|
"""只通过不跟随链接的目录句柄删除受控目录内的普通文件。
|
||||||
|
|
||||||
|
返回 "removed"(已删除)、"missing"(本就不存在)或 "blocked"(无法安全删除,
|
||||||
|
例如目录已被换成符号链接或权限不足),调用方据此保留失败任务与磁盘的一致性。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
dir_fd = os.open(path.parent, os.O_RDONLY | os.O_NOFOLLOW)
|
||||||
|
except OSError:
|
||||||
|
return "blocked"
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
info = os.lstat(path.name, dir_fd=dir_fd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "missing"
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
return "blocked"
|
||||||
|
os.unlink(path.name, dir_fd=dir_fd)
|
||||||
|
return "removed"
|
||||||
|
except OSError:
|
||||||
|
return "blocked"
|
||||||
|
finally:
|
||||||
|
os.close(dir_fd)
|
||||||
|
|
||||||
|
|
||||||
def _excel_number(value):
|
def _excel_number(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -594,41 +648,46 @@ def build_formula_workbook(ds, member, export_context, output):
|
|||||||
|
|
||||||
def process_formula_export(export_id):
|
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
|
|
||||||
target = None
|
target = None
|
||||||
|
created = []
|
||||||
try:
|
try:
|
||||||
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
|
||||||
if root.is_symlink():
|
|
||||||
raise ValueError("公式导出目录不能是符号链接")
|
|
||||||
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
root.chmod(0o700)
|
|
||||||
target = root / str(job.user_id) / f"{job.pk}.xlsx"
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
if target.parent.is_symlink() or target.is_symlink():
|
|
||||||
raise ValueError("公式导出路径不能是符号链接")
|
|
||||||
target.parent.chmod(0o700)
|
|
||||||
temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp")
|
|
||||||
member = Membership.objects.get(user=job.user, organization=job.dataset.organization)
|
member = Membership.objects.get(user=job.user, organization=job.dataset.organization)
|
||||||
|
# 执行阶段重新核验角色与账号状态:撤权或停用后排队中的任务不得继续发布敏感工作簿。
|
||||||
|
if not job.user.is_active:
|
||||||
|
raise PermissionError("账号已停用,公式导出已拒绝执行")
|
||||||
|
if 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("科室授权已变更,请重新提交导出")
|
||||||
|
root = _validated_export_directory(Path(settings.MEDIA_ROOT) / "formula-exports", "公式导出目录")
|
||||||
|
user_dir = _validated_export_directory(root / str(job.user_id), "公式导出用户目录")
|
||||||
|
target = _validated_export_target(user_dir, job)
|
||||||
|
# 路径完整验证通过后才把目标登记为可清理文件;此前任何失败都不会删除磁盘文件。
|
||||||
|
created.append(target)
|
||||||
|
temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp")
|
||||||
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)
|
||||||
|
created.append(temporary)
|
||||||
with os.fdopen(descriptor, "wb") as file:
|
with os.fdopen(descriptor, "wb") as file:
|
||||||
stats = build_formula_workbook(job.dataset, member, job.context, file)
|
stats = build_formula_workbook(job.dataset, member, job.context, file)
|
||||||
temporary.replace(target)
|
os.replace(temporary, target)
|
||||||
|
created = [target]
|
||||||
job.path, job.status, job.error = str(target), "succeeded", ""
|
job.path, job.status, job.error = str(target), "succeeded", ""
|
||||||
job.finished_at = timezone.now()
|
job.finished_at = timezone.now()
|
||||||
job.expires_at = timezone.now() + timedelta(hours=24)
|
job.expires_at = timezone.now() + timedelta(hours=24)
|
||||||
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:
|
||||||
for path in (temporary, target):
|
job.status, job.error, job.finished_at, job.expires_at = "failed", str(exc)[:500], timezone.now(), None
|
||||||
try:
|
# 只清理本次登记且确认位于受控目录内的文件;目录被换成符号链接时拒绝删除。
|
||||||
if path is not None and not path.is_symlink():
|
leftover = [path for path in created if _unlink_created_file(path) == "blocked"]
|
||||||
path.unlink(missing_ok=True)
|
if leftover:
|
||||||
except OSError:
|
# 日志只含任务编号与文件名,不包含敏感数据;残留文件由清理命令按保留期回收。
|
||||||
pass
|
logger.warning(
|
||||||
job.status, job.error, job.path = "failed", str(exc)[:500], ""
|
"公式导出任务 %s 失败后清理文件受阻,待清理命令回收:%s",
|
||||||
job.finished_at, job.expires_at = timezone.now(), None
|
job.pk,
|
||||||
|
"、".join(path.name for path in leftover),
|
||||||
|
)
|
||||||
|
job.path = str(target) if target in leftover else ""
|
||||||
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
|
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ def design_column(messages, ds, ctx):
|
|||||||
or set(item) != {"role", "content"}
|
or set(item) != {"role", "content"}
|
||||||
or item["role"] not in ("user", "assistant")
|
or item["role"] not in ("user", "assistant")
|
||||||
or not isinstance(item["content"], str)
|
or not isinstance(item["content"], str)
|
||||||
or len(item["content"]) > 1000
|
# 2000 字符容纳载入已保存定义后的完整对话上下文
|
||||||
|
or len(item["content"]) > 2000
|
||||||
for item in messages
|
for item in messages
|
||||||
):
|
):
|
||||||
raise APIError("对话消息内容无效")
|
raise APIError("对话消息内容无效")
|
||||||
@@ -292,7 +293,7 @@ def _owned_job(request, dataset_id, export_id):
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
@endpoint(("GET",))
|
@endpoint(("GET",), roles=("admin", "analyst"))
|
||||||
def formula_export_status(request, dataset_id, export_id):
|
def formula_export_status(request, dataset_id, export_id):
|
||||||
job = _owned_job(request, dataset_id, export_id)
|
job = _owned_job(request, dataset_id, export_id)
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
@@ -305,7 +306,7 @@ def formula_export_status(request, dataset_id, export_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@endpoint(("GET",))
|
@endpoint(("GET",), roles=("admin", "analyst"))
|
||||||
def formula_export_download(request, dataset_id, export_id):
|
def formula_export_download(request, dataset_id, export_id):
|
||||||
job = _owned_job(request, dataset_id, export_id)
|
job = _owned_job(request, dataset_id, export_id)
|
||||||
if job.status != "succeeded" or not job.path or not job.expires_at or job.expires_at <= timezone.now():
|
if job.status != "succeeded" or not job.path or not job.expires_at or job.expires_at <= timezone.now():
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -9,9 +10,11 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from finance.models import FormulaExport
|
from finance.models import FormulaExport
|
||||||
|
|
||||||
|
ORPHAN_RETENTION_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Remove expired formula workbooks and stale temporary files"
|
help = "Remove expired formula workbooks and reclaim orphan files"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("--loop", action="store_true")
|
parser.add_argument("--loop", action="store_true")
|
||||||
@@ -19,8 +22,10 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
while True:
|
while True:
|
||||||
jobs, files = self.clean_expired()
|
jobs, files, orphans = self.clean_expired()
|
||||||
self.stdout.write(f"Expired {jobs} jobs; removed {files} files")
|
self.stdout.write(
|
||||||
|
f"Expired {jobs} jobs; removed {files} files; reclaimed {orphans} orphan files"
|
||||||
|
)
|
||||||
if not options["loop"]:
|
if not options["loop"]:
|
||||||
return
|
return
|
||||||
time.sleep(max(60, options["interval"]))
|
time.sleep(max(60, options["interval"]))
|
||||||
@@ -31,7 +36,7 @@ class Command(BaseCommand):
|
|||||||
raise ValueError("公式导出目录不能是符号链接")
|
raise ValueError("公式导出目录不能是符号链接")
|
||||||
root = export_root.resolve()
|
root = export_root.resolve()
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
jobs = files = 0
|
jobs = files = orphans = 0
|
||||||
|
|
||||||
for job in FormulaExport.objects.filter(status="succeeded", expires_at__lte=now).iterator():
|
for job in FormulaExport.objects.filter(status="succeeded", expires_at__lte=now).iterator():
|
||||||
path = Path(job.path) if job.path else None
|
path = Path(job.path) if job.path else None
|
||||||
@@ -61,5 +66,41 @@ class Command(BaseCommand):
|
|||||||
files += 1
|
files += 1
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
self.stderr.write(f"Could not remove stale temporary export {path.name}: {exc}")
|
self.stderr.write(f"Could not remove stale temporary export {path.name}: {exc}")
|
||||||
|
orphans += self._reclaim_orphan_workbooks(user_dir, now)
|
||||||
|
|
||||||
return jobs, files
|
return jobs, files, orphans
|
||||||
|
|
||||||
|
def _reclaim_orphan_workbooks(self, user_dir, now):
|
||||||
|
"""回收超过保留期且不再属于任何有效导出任务的 .xlsx 孤儿文件。
|
||||||
|
|
||||||
|
有效任务指状态 succeeded、路径一致且尚未过期的任务;失败任务残留的文件和任务行
|
||||||
|
已被删除(如数据集级联删除)的文件在保留期后回收,绝不触碰活动任务的文件。
|
||||||
|
"""
|
||||||
|
retention = (now - timedelta(hours=ORPHAN_RETENTION_HOURS)).timestamp()
|
||||||
|
removed = 0
|
||||||
|
for path in user_dir.glob("*.xlsx"):
|
||||||
|
try:
|
||||||
|
if path.is_symlink() or not path.is_file() or path.stat().st_mtime > retention:
|
||||||
|
continue
|
||||||
|
job_id = uuid.UUID(path.stem)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
job = FormulaExport.objects.filter(pk=job_id).first()
|
||||||
|
if (
|
||||||
|
job is not None
|
||||||
|
and job.status == "succeeded"
|
||||||
|
and job.path == str(path)
|
||||||
|
and job.expires_at is not None
|
||||||
|
and job.expires_at > now
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
removed += 1
|
||||||
|
if job is not None and job.path == str(path):
|
||||||
|
job.path = ""
|
||||||
|
job.save(update_fields=["path"])
|
||||||
|
self.stdout.write(f"Reclaimed leftover workbook of job {job.pk}")
|
||||||
|
except OSError as exc:
|
||||||
|
self.stderr.write(f"Could not reclaim orphan export {path.stem}: {exc}")
|
||||||
|
return removed
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import time
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection, transaction
|
from django.db import connection, transaction
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from finance.exporting import process_formula_export
|
from finance.exporting import process_formula_export
|
||||||
from finance.importing import process_dataset
|
from finance.importing import process_dataset
|
||||||
from finance.models import Dataset, FormulaExport
|
from finance.models import Dataset, FormulaExport
|
||||||
|
|
||||||
|
# 无咨询锁的数据库(如 SQLite)上等待该时限后才重新认领 processing 任务,
|
||||||
|
# 避免与仍在运行的 worker 抢任务;PostgreSQL 由咨询锁保证单 worker,可立即恢复。
|
||||||
|
STALE_PROCESSING_SECONDS = 15 * 60
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Process durable import and formula-export jobs"
|
help = "Process durable import and formula-export jobs"
|
||||||
@@ -30,30 +36,35 @@ class Command(BaseCommand):
|
|||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute("SELECT pg_advisory_unlock(782146291)")
|
cursor.execute("SELECT pg_advisory_unlock(782146291)")
|
||||||
|
|
||||||
def run_jobs(self, options, recover):
|
def run_jobs(self, options, recover_immediately):
|
||||||
while True:
|
while True:
|
||||||
processed = self._next_import(recover)
|
processed = self._next_import(recover_immediately)
|
||||||
if not processed:
|
if not processed:
|
||||||
processed = self._next_export(recover)
|
processed = self._next_export(recover_immediately)
|
||||||
if options["once"]:
|
if options["once"]:
|
||||||
return
|
return
|
||||||
if not processed:
|
if not processed:
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
def _claim(self, model, states):
|
def _claim(self, model, immediate_recovery):
|
||||||
|
stale_before = timezone.now() - timedelta(seconds=STALE_PROCESSING_SECONDS)
|
||||||
|
claimable = Q(status="queued") | Q(status="processing", created_at__lt=stale_before)
|
||||||
|
if immediate_recovery:
|
||||||
|
claimable = Q(status="queued") | Q(status="processing")
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
qs = model.objects.filter(status__in=states).order_by("created_at")
|
qs = model.objects.filter(claimable).order_by("created_at")
|
||||||
if connection.features.has_select_for_update_skip_locked:
|
if connection.features.has_select_for_update_skip_locked:
|
||||||
qs = qs.select_for_update(skip_locked=True)
|
qs = qs.select_for_update(skip_locked=True)
|
||||||
item = qs.first()
|
item = qs.first()
|
||||||
if not item:
|
if not item:
|
||||||
return None
|
return None
|
||||||
updated = model.objects.filter(pk=item.pk, status__in=states).update(status="processing")
|
# CAS 认领 queued 任务互斥;processing 仅在超时或咨询锁保护下重认领,
|
||||||
return item if updated else None
|
# 重复发布对同一目标路径写入相同内容,不会产生重复文件。
|
||||||
|
claimed = model.objects.filter(pk=item.pk, status=item.status).update(status="processing")
|
||||||
|
return item if claimed else None
|
||||||
|
|
||||||
def _next_import(self, recover):
|
def _next_import(self, recover_immediately):
|
||||||
states = ["queued", "processing"] if recover else ["queued"]
|
item = self._claim(Dataset, recover_immediately)
|
||||||
item = self._claim(Dataset, states)
|
|
||||||
if not item:
|
if not item:
|
||||||
return False
|
return False
|
||||||
Dataset.objects.filter(pk=item.pk).update(started_at=timezone.now())
|
Dataset.objects.filter(pk=item.pk).update(started_at=timezone.now())
|
||||||
@@ -61,9 +72,8 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f"{result.pk}: {result.status}, {result.row_count} rows")
|
self.stdout.write(f"{result.pk}: {result.status}, {result.row_count} rows")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _next_export(self, recover):
|
def _next_export(self, recover_immediately):
|
||||||
states = ["queued", "processing"] if recover else ["queued"]
|
item = self._claim(FormulaExport, recover_immediately)
|
||||||
item = self._claim(FormulaExport, states)
|
|
||||||
if not item:
|
if not item:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
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,
|
||||||
@@ -34,6 +41,19 @@ function describeExpression(expression: FormulaExpression): string {
|
|||||||
return `(${describeExpression(expression.left)} ${operators[expression.operator]} ${describeExpression(expression.right)})`;
|
return `(${describeExpression(expression.left)} ${operators[expression.operator]} ${describeExpression(expression.right)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function definitionSummary(definition: Record<string, unknown>): string {
|
||||||
|
const text = [
|
||||||
|
`已载入已保存定义「${String(definition.name)}」`,
|
||||||
|
`含义:${String(definition.meaning)}`,
|
||||||
|
`适用范围:${String(definition.scope)}`,
|
||||||
|
`缺失/零分母处理:${String(definition.missing)}`,
|
||||||
|
`计算表达式:${JSON.stringify(definition.expression)}`,
|
||||||
|
].join(";");
|
||||||
|
return text.length <= 2000
|
||||||
|
? text
|
||||||
|
: `${text.slice(0, 2000)}…(内容过长已截断)`;
|
||||||
|
}
|
||||||
|
|
||||||
export function Modal({
|
export function Modal({
|
||||||
title,
|
title,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -349,7 +369,8 @@ export function EvidenceDialog({
|
|||||||
}>(),
|
}>(),
|
||||||
[side, setSide] = useState<"current" | "baseline">("current"),
|
[side, setSide] = useState<"current" | "baseline">("current"),
|
||||||
[page, setPage] = useState(0),
|
[page, setPage] = useState(0),
|
||||||
[error, setError] = useState("");
|
[error, setError] = useState(""),
|
||||||
|
[reloadKey, setReloadKey] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setResult(undefined);
|
setResult(undefined);
|
||||||
@@ -364,8 +385,14 @@ export function EvidenceDialog({
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [ds.id, ctx, side, page]);
|
}, [ds.id, ctx, side, page, reloadKey]);
|
||||||
function selectSide(value: "current" | "baseline") {
|
function selectSide(value: "current" | "baseline") {
|
||||||
|
if (value === side) {
|
||||||
|
// 重复点击当前期间:仅在不在第一页时回到第一页触发刷新,
|
||||||
|
// 同期间同页直接返回,避免清空列表后停在“加载中”。
|
||||||
|
if (page !== 0) setPage(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setResult(undefined);
|
setResult(undefined);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
setSide(value);
|
setSide(value);
|
||||||
@@ -401,7 +428,8 @@ export function EvidenceDialog({
|
|||||||
{user.role !== "admin" && <p>客户原始标识不包含在明细中。</p>}
|
{user.role !== "admin" && <p>客户原始标识不包含在明细中。</p>}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="error">
|
<p role="alert" className="error">
|
||||||
{error}
|
{error}{" "}
|
||||||
|
<button onClick={() => setReloadKey((n) => n + 1)}>重试</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
@@ -435,6 +463,11 @@ export function EvidenceDialog({
|
|||||||
<td>{String(r.gross ?? "")}</td>
|
<td>{String(r.gross ?? "")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{result && result.rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3 + ds.dimensions.length}>本期范围内没有数据</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -776,7 +809,10 @@ export function FormulaExportDialog({
|
|||||||
);
|
);
|
||||||
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);
|
||||||
@@ -789,9 +825,25 @@ 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) {
|
||||||
|
// 选择空选项时同步清理当前定义,避免下拉选择与预览不一致
|
||||||
|
setColumn(null);
|
||||||
|
setMessages([]);
|
||||||
|
setQuestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setColumn(saved.definition);
|
setColumn(saved.definition);
|
||||||
setMessages([]);
|
// 载入定义后把原定义显式放进可见、经同意的对话上下文,
|
||||||
|
// 使“把刚才公式改成乘以 100”这类指代能关联到已载入表达式;
|
||||||
|
// 修改结果仍需重新确认,不会静默沿用旧定义。
|
||||||
|
setMessages([
|
||||||
|
{ role: "user", content: definitionSummary(saved.definition) },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
"已载入该定义作为当前基准。请描述要调整的内容,我会在该定义基础上生成新的列定义,仍需你确认后再生成。",
|
||||||
|
},
|
||||||
|
]);
|
||||||
setQuestions([]);
|
setQuestions([]);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,22 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
|
|||||||
meaning: "实际收入减成本",
|
meaning: "实际收入减成本",
|
||||||
scope: "所选分组与期间",
|
scope: "所选分组与期间",
|
||||||
missing: "结果不可用(#N/A);零分母不可用",
|
missing: "结果不可用(#N/A);零分母不可用",
|
||||||
expression: {
|
expression:
|
||||||
operator: "subtract",
|
designCalls === 5
|
||||||
left: { metric: "revenue" },
|
? {
|
||||||
right: { metric: "cost" },
|
operator: "multiply",
|
||||||
},
|
left: {
|
||||||
|
operator: "subtract",
|
||||||
|
left: { metric: "revenue" },
|
||||||
|
right: { metric: "cost" },
|
||||||
|
},
|
||||||
|
right: { number: 100 },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
operator: "subtract",
|
||||||
|
left: { metric: "revenue" },
|
||||||
|
right: { metric: "cost" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -123,11 +134,33 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
|
|||||||
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
|
// F04 回归:载入定义后,原定义作为可见上下文进入对话
|
||||||
.getByRole("button", { name: "确认并生成公式工作簿" })
|
await expect(reopened).toContainText("已载入已保存定义「收入成本差」");
|
||||||
.click();
|
await expect(reopened).toContainText("我:已载入已保存定义");
|
||||||
|
await expect(reopened).toContainText("助手:已载入该定义作为当前基准");
|
||||||
|
// F04 回归:选择空选项时同步清理当前定义与上下文,选择状态与预览一致
|
||||||
|
await reopened.getByLabel("已保存定义").selectOption({ label: "选择定义" });
|
||||||
|
await expect(reopened.getByText("计算表达式:(实际收入 − 成本)")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await expect(reopened.getByText("已载入已保存定义")).toHaveCount(0);
|
||||||
|
|
||||||
|
await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" });
|
||||||
|
await reopened.getByRole("checkbox").check();
|
||||||
|
await reopened.getByLabel("列计算描述").fill("把刚才公式改成乘以 100");
|
||||||
|
await reopened.getByRole("button", { name: "发送描述" }).click();
|
||||||
|
await expect(reopened).toContainText(
|
||||||
|
"计算表达式:((实际收入 − 成本) × 100)",
|
||||||
|
);
|
||||||
|
// 模型请求必须包含载入的原定义上下文和新的修改描述
|
||||||
|
expect(sentMessages.at(0)?.content).toContain(
|
||||||
|
"已载入已保存定义「收入成本差」",
|
||||||
|
);
|
||||||
|
expect(sentMessages.at(0)?.content).toContain('"operator":"subtract"');
|
||||||
|
expect(sentMessages.at(-1)?.content).toBe("把刚才公式改成乘以 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);
|
||||||
});
|
});
|
||||||
|
|||||||
+94
-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,14 +87,27 @@ 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("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[] = [];
|
||||||
|
let evidenceFailOnce = false;
|
||||||
|
let evidenceEmpty = false;
|
||||||
await page.route("**/api/datasets/*/evidence", async (route) => {
|
await page.route("**/api/datasets/*/evidence", async (route) => {
|
||||||
const request = route.request().postDataJSON() as { offset?: number };
|
const request = route.request().postDataJSON() as { offset?: number };
|
||||||
const offset = request.offset || 0;
|
const offset = request.offset || 0;
|
||||||
|
if (evidenceFailOnce) {
|
||||||
|
evidenceFailOnce = false;
|
||||||
|
await route.fulfill({ status: 500, json: { error: "服务暂不可用" } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
evidenceOffsets.push(offset);
|
evidenceOffsets.push(offset);
|
||||||
|
if (evidenceEmpty) {
|
||||||
|
await route.fulfill({ json: { total: 0, sheet: "CSV", rows: [] } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const count = offset === 0 ? 50 : 1;
|
const count = offset === 0 ? 50 : 1;
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
@@ -111,10 +126,51 @@ 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]);
|
||||||
|
// F03 回归:第二页时重复点击当前期间应回到第一页并重新加载
|
||||||
|
await evidenceDialog
|
||||||
|
.getByRole("button", { name: "本期", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
|
||||||
|
expect(evidenceOffsets).toEqual([0, 50, 0]);
|
||||||
|
// F03 回归:第一页重复点击当前期间不清空列表、不重新请求、不停在“加载中”
|
||||||
|
await evidenceDialog
|
||||||
|
.getByRole("button", { name: "本期", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
|
||||||
|
await expect(evidenceDialog).not.toContainText("加载中");
|
||||||
|
expect(evidenceOffsets).toEqual([0, 50, 0]);
|
||||||
|
// F03 回归:快速本期/基期切换最终停留在当前期间的数据
|
||||||
|
await evidenceDialog
|
||||||
|
.getByRole("button", { name: "基期", exact: true })
|
||||||
|
.click();
|
||||||
|
await evidenceDialog
|
||||||
|
.getByRole("button", { name: "本期", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
|
||||||
|
await expect(evidenceDialog).not.toContainText("加载中");
|
||||||
|
// F03 回归:请求失败显示错误并可重试恢复
|
||||||
|
evidenceFailOnce = true;
|
||||||
|
await evidenceDialog
|
||||||
|
.getByRole("button", { name: "下一页", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(evidenceDialog).toContainText("服务暂不可用");
|
||||||
|
await expect(evidenceDialog).toContainText("加载失败");
|
||||||
|
await evidenceDialog.getByRole("button", { name: "重试" }).click();
|
||||||
|
await expect(evidenceDialog).toContainText("第 51-51 条,共 51 条");
|
||||||
|
// F03 回归:空结果显示空态而不是过期数据或加载中
|
||||||
|
evidenceEmpty = true;
|
||||||
|
await evidenceDialog
|
||||||
|
.getByRole("button", { name: "上一页", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(evidenceDialog).toContainText("本期范围内没有数据");
|
||||||
|
await expect(evidenceDialog).toContainText("共 0 条");
|
||||||
|
await expect(evidenceDialog).not.toContainText("加载中");
|
||||||
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
|
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
|
||||||
await page
|
await page
|
||||||
.getByRole("navigation", { name: "分析路径" })
|
.getByRole("navigation", { name: "分析路径" })
|
||||||
@@ -164,38 +220,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 }) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -27,6 +28,12 @@ def cleanup_root(settings, tmp_path):
|
|||||||
return tmp_path / "formula-exports", dataset, user
|
return tmp_path / "formula-exports", dataset, user
|
||||||
|
|
||||||
|
|
||||||
|
def _aged(path, hours=25):
|
||||||
|
stamp = (timezone.now() - timedelta(hours=hours)).timestamp()
|
||||||
|
os.utime(path, (stamp, stamp))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_expires_jobs_and_removes_files(cleanup_root):
|
def test_cleanup_expires_jobs_and_removes_files(cleanup_root):
|
||||||
root, dataset, user = cleanup_root
|
root, dataset, user = cleanup_root
|
||||||
path = root / str(user.pk) / "expired.xlsx"
|
path = root / str(user.pk) / "expired.xlsx"
|
||||||
@@ -55,8 +62,7 @@ def test_cleanup_removes_only_stale_temporary_files(cleanup_root):
|
|||||||
stale, recent = user_dir / "stale.tmp", user_dir / "recent.tmp"
|
stale, recent = user_dir / "stale.tmp", user_dir / "recent.tmp"
|
||||||
stale.touch()
|
stale.touch()
|
||||||
recent.touch()
|
recent.touch()
|
||||||
old = timezone.now().timestamp() - 25 * 3600
|
_aged(stale)
|
||||||
os.utime(stale, (old, old))
|
|
||||||
|
|
||||||
call_command("cleanup_formula_exports")
|
call_command("cleanup_formula_exports")
|
||||||
|
|
||||||
@@ -81,3 +87,71 @@ def test_cleanup_does_not_delete_files_outside_export_root(cleanup_root, tmp_pat
|
|||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
assert job.status == "expired"
|
assert job.status == "expired"
|
||||||
assert outside.read_bytes() == b"keep"
|
assert outside.read_bytes() == b"keep"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_reclaims_failed_job_leftover_after_retention(cleanup_root):
|
||||||
|
root, dataset, user = cleanup_root
|
||||||
|
job = FormulaExport.objects.create(dataset=dataset, user=user, status="failed")
|
||||||
|
path = root / str(user.pk) / f"{job.pk}.xlsx"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_bytes(b"leftover")
|
||||||
|
job.path = str(path)
|
||||||
|
job.save(update_fields=["path"])
|
||||||
|
_aged(path)
|
||||||
|
|
||||||
|
call_command("cleanup_formula_exports")
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert not path.exists()
|
||||||
|
assert job.path == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_keeps_workbook_of_unexpired_succeeded_job(cleanup_root):
|
||||||
|
root, dataset, user = cleanup_root
|
||||||
|
job = FormulaExport.objects.create(
|
||||||
|
dataset=dataset,
|
||||||
|
user=user,
|
||||||
|
status="succeeded",
|
||||||
|
expires_at=timezone.now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
path = root / str(user.pk) / f"{job.pk}.xlsx"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_bytes(b"active")
|
||||||
|
job.path = str(path)
|
||||||
|
job.save(update_fields=["path"])
|
||||||
|
_aged(path)
|
||||||
|
|
||||||
|
call_command("cleanup_formula_exports")
|
||||||
|
|
||||||
|
assert path.read_bytes() == b"active"
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.path == str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_reclaims_orphan_whose_job_row_was_deleted(cleanup_root):
|
||||||
|
root, _, user = cleanup_root
|
||||||
|
user_dir = root / str(user.pk)
|
||||||
|
user_dir.mkdir(parents=True)
|
||||||
|
orphan = user_dir / f"{uuid.uuid4()}.xlsx"
|
||||||
|
orphan.write_bytes(b"orphan")
|
||||||
|
recent = user_dir / f"{uuid.uuid4()}.xlsx"
|
||||||
|
recent.write_bytes(b"recent orphan")
|
||||||
|
_aged(orphan)
|
||||||
|
|
||||||
|
call_command("cleanup_formula_exports")
|
||||||
|
|
||||||
|
assert not orphan.exists()
|
||||||
|
assert recent.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_ignores_non_uuid_workbook_names(cleanup_root):
|
||||||
|
root, _, user = cleanup_root
|
||||||
|
user_dir = root / str(user.pk)
|
||||||
|
user_dir.mkdir(parents=True)
|
||||||
|
odd = user_dir / "notes.xlsx"
|
||||||
|
odd.write_bytes(b"keep")
|
||||||
|
_aged(odd)
|
||||||
|
|
||||||
|
call_command("cleanup_formula_exports")
|
||||||
|
|
||||||
|
assert odd.exists()
|
||||||
|
|||||||
@@ -200,6 +200,54 @@ def test_design_column_preserves_clarification_branch_and_full_conversation(form
|
|||||||
assert payload["input"][1:] == messages
|
assert payload["input"][1:] == messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_design_column_accepts_loaded_definition_context(formula_data, settings):
|
||||||
|
ds, _ = formula_data
|
||||||
|
settings.AI_ENABLED = True
|
||||||
|
settings.OPENAI_API_KEY = "test-key"
|
||||||
|
settings.OPENAI_MODEL = "test-model"
|
||||||
|
context = "已载入已保存定义「收入成本差」;计算表达式:" + '{"operator":"subtract",' * 45
|
||||||
|
assert 1000 < len(context) <= 2000
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": context},
|
||||||
|
{"role": "assistant", "content": "已载入该定义作为当前基准。"},
|
||||||
|
{"role": "user", "content": "把刚才公式改成乘以 100"},
|
||||||
|
]
|
||||||
|
clarification = {
|
||||||
|
"status": "completed",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": '{"complete":false,"questions":["要乘在整个表达式上吗?"],"column":null}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with patch("finance.formula_views.httpx.Client") as http:
|
||||||
|
http.return_value.__enter__.return_value.post.return_value = type(
|
||||||
|
"Response",
|
||||||
|
(),
|
||||||
|
{"raise_for_status": lambda self: None, "json": lambda self: clarification},
|
||||||
|
)()
|
||||||
|
result = design_column(
|
||||||
|
messages,
|
||||||
|
ds,
|
||||||
|
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
|
||||||
|
)
|
||||||
|
assert result["complete"] is False
|
||||||
|
payload = http.return_value.__enter__.return_value.post.call_args.kwargs["json"]
|
||||||
|
assert payload["input"][1:] == messages
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="对话消息内容无效"):
|
||||||
|
design_column(
|
||||||
|
[{"role": "user", "content": "y" * 2001}],
|
||||||
|
ds,
|
||||||
|
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_design_column_returns_only_validated_formula_definition(formula_data, settings):
|
def test_design_column_returns_only_validated_formula_definition(formula_data, settings):
|
||||||
ds, _ = formula_data
|
ds, _ = formula_data
|
||||||
settings.AI_ENABLED = True
|
settings.AI_ENABLED = True
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import stat
|
import stat
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -198,6 +199,170 @@ def test_formula_export_worker_records_path_preparation_failure(workspace, setti
|
|||||||
assert job.status == "failed"
|
assert job.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _queued_job(ds):
|
||||||
|
return FormulaExport.objects.create(
|
||||||
|
dataset=ds,
|
||||||
|
user=ds.created_by,
|
||||||
|
context={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_export_rejects_symlinked_user_directory_without_touching_files(workspace, settings):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _queued_job(ds)
|
||||||
|
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
protected = Path(settings.MEDIA_ROOT) / "protected"
|
||||||
|
protected.mkdir()
|
||||||
|
victim = protected / f"{job.pk}.xlsx"
|
||||||
|
victim.write_bytes(b"protected workbook")
|
||||||
|
(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 not job.path
|
||||||
|
assert victim.read_bytes() == b"protected workbook"
|
||||||
|
assert list(protected.glob("*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_export_rejects_symlinked_target_and_keeps_link(workspace, settings):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _queued_job(ds)
|
||||||
|
user_dir = Path(settings.MEDIA_ROOT) / "formula-exports" / str(job.user_id)
|
||||||
|
user_dir.mkdir(parents=True)
|
||||||
|
protected_file = Path(settings.MEDIA_ROOT) / "protected.xlsx"
|
||||||
|
protected_file.write_bytes(b"protected workbook")
|
||||||
|
(user_dir / f"{job.pk}.xlsx").symlink_to(protected_file)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="普通文件"):
|
||||||
|
process_formula_export(job.pk)
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.status == "failed"
|
||||||
|
assert protected_file.read_bytes() == b"protected workbook"
|
||||||
|
assert (user_dir / f"{job.pk}.xlsx").is_symlink()
|
||||||
|
assert list(user_dir.glob("*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_export_refuses_cleanup_when_parent_directory_swapped_to_symlink(
|
||||||
|
workspace, settings, monkeypatch
|
||||||
|
):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _queued_job(ds)
|
||||||
|
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||||
|
user_dir = root / str(job.user_id)
|
||||||
|
user_dir.mkdir(parents=True)
|
||||||
|
protected = Path(settings.MEDIA_ROOT) / "protected"
|
||||||
|
protected.mkdir()
|
||||||
|
victim = protected / f"{job.pk}.xlsx"
|
||||||
|
victim.write_bytes(b"protected workbook")
|
||||||
|
|
||||||
|
def swap_and_fail(*args, **kwargs):
|
||||||
|
args[-1].write(b"partial")
|
||||||
|
user_dir.rename(user_dir.with_name("swapped"))
|
||||||
|
user_dir.symlink_to(protected, target_is_directory=True)
|
||||||
|
raise ValueError("controlled failure after path validation")
|
||||||
|
|
||||||
|
monkeypatch.setattr("finance.exporting.build_formula_workbook", swap_and_fail)
|
||||||
|
with pytest.raises(ValueError, match="controlled failure"):
|
||||||
|
process_formula_export(job.pk)
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.status == "failed"
|
||||||
|
assert victim.read_bytes() == b"protected workbook"
|
||||||
|
assert user_dir.is_symlink()
|
||||||
|
# 本次创建的临时文件残留在被换名的原目录中,由清理命令按保留期回收。
|
||||||
|
assert list((root / "swapped").glob(f"{job.pk}.*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_formula_export_retry_after_failure_succeeds(workspace, monkeypatch):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _queued_job(ds)
|
||||||
|
|
||||||
|
def fail(*args, **kwargs):
|
||||||
|
args[-1].write(b"partial")
|
||||||
|
raise ValueError("controlled export failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr("finance.exporting.build_formula_workbook", fail)
|
||||||
|
with pytest.raises(ValueError, match="controlled export failure"):
|
||||||
|
process_formula_export(job.pk)
|
||||||
|
monkeypatch.undo()
|
||||||
|
|
||||||
|
stats = process_formula_export(job.pk)
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert stats["rows"] == 5
|
||||||
|
assert job.status == "succeeded"
|
||||||
|
assert Path(job.path).read_bytes()[:2] == b"PK"
|
||||||
|
assert not list(Path(job.path).parent.glob(f"{job.pk}.*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_failure_is_logged_and_reclaimed_after_permissions_restored(
|
||||||
|
workspace, settings, monkeypatch, caplog
|
||||||
|
):
|
||||||
|
import logging as logging_module
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _queued_job(ds)
|
||||||
|
user_dir = Path(settings.MEDIA_ROOT) / "formula-exports" / str(job.user_id)
|
||||||
|
user_dir.mkdir(parents=True)
|
||||||
|
target = user_dir / f"{job.pk}.xlsx"
|
||||||
|
target.write_bytes(b"stale workbook")
|
||||||
|
other = FormulaExport.objects.create(
|
||||||
|
dataset=ds,
|
||||||
|
user=ds.created_by,
|
||||||
|
context={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||||
|
status="succeeded",
|
||||||
|
expires_at=timezone.now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
kept = user_dir / f"{other.pk}.xlsx"
|
||||||
|
other.path = str(kept)
|
||||||
|
other.save(update_fields=["path"])
|
||||||
|
kept.write_bytes(b"active workbook")
|
||||||
|
|
||||||
|
def fail(*args, **kwargs):
|
||||||
|
args[-1].write(b"partial")
|
||||||
|
os.chmod(user_dir, 0o500)
|
||||||
|
raise ValueError("controlled export failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr("finance.exporting.build_formula_workbook", fail)
|
||||||
|
with pytest.raises(ValueError, match="controlled export failure"):
|
||||||
|
process_formula_export(job.pk)
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.status == "failed"
|
||||||
|
assert job.path == str(target)
|
||||||
|
warnings = [r for r in caplog.records if r.levelno == logging_module.WARNING]
|
||||||
|
assert warnings and all(str(job.pk) in record.getMessage() for record in warnings)
|
||||||
|
|
||||||
|
os.chmod(user_dir, 0o700)
|
||||||
|
old = (timezone.now() - timedelta(hours=25)).timestamp()
|
||||||
|
for leftover in [target, *user_dir.glob(f"{job.pk}.*.tmp"), kept]:
|
||||||
|
os.utime(leftover, (old, old))
|
||||||
|
call_command("cleanup_formula_exports")
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert not target.exists()
|
||||||
|
assert not list(user_dir.glob(f"{job.pk}.*.tmp"))
|
||||||
|
assert job.path == ""
|
||||||
|
assert kept.read_bytes() == b"active workbook"
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from finance.models import Dataset, FormulaExport
|
from finance.models import Dataset, FormulaExport
|
||||||
|
|
||||||
@@ -30,3 +33,118 @@ def test_worker_processes_queued_import_and_reports_export_failure(workspace):
|
|||||||
call_command("import_worker", once=True)
|
call_command("import_worker", once=True)
|
||||||
export.refresh_from_db()
|
export.refresh_from_db()
|
||||||
assert export.status == "processing"
|
assert export.status == "processing"
|
||||||
|
|
||||||
|
|
||||||
|
def _export_job(ds, **kwargs):
|
||||||
|
return FormulaExport.objects.create(
|
||||||
|
dataset=ds,
|
||||||
|
user=ds.created_by,
|
||||||
|
context={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_refuses_export_after_role_revocation(workspace):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, member, *_ = workspace
|
||||||
|
job = _export_job(ds, 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"
|
||||||
|
assert not job.path
|
||||||
|
assert not list(Path(ds.file.storage.location).rglob(f"{job.pk}*"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_refuses_export_after_account_deactivation(workspace):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, _, _, _, _ = workspace
|
||||||
|
user = ds.created_by
|
||||||
|
user.is_active = False
|
||||||
|
user.save(update_fields=["is_active"])
|
||||||
|
job = _export_job(ds, status="processing")
|
||||||
|
|
||||||
|
with pytest.raises(PermissionError, match="停用"):
|
||||||
|
process_formula_export(job.pk)
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_and_download_deny_revoked_viewer(workspace, settings):
|
||||||
|
ds, member, client, *_ = workspace
|
||||||
|
job = _export_job(
|
||||||
|
ds,
|
||||||
|
status="succeeded",
|
||||||
|
finished_at=timezone.now(),
|
||||||
|
expires_at=timezone.now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
target = Path(settings.MEDIA_ROOT) / "formula-exports" / str(job.user_id) / f"{job.pk}.xlsx"
|
||||||
|
target.parent.mkdir(parents=True)
|
||||||
|
target.write_bytes(b"PK workbook")
|
||||||
|
job.path = str(target)
|
||||||
|
job.save(update_fields=["path"])
|
||||||
|
|
||||||
|
assert client.get(f"/api/datasets/{ds.pk}/formula-exports/{job.pk}").status_code == 200
|
||||||
|
assert (
|
||||||
|
client.get(f"/api/datasets/{ds.pk}/formula-exports/{job.pk}/download").status_code == 200
|
||||||
|
)
|
||||||
|
|
||||||
|
member.role = "viewer"
|
||||||
|
member.save(update_fields=["role"])
|
||||||
|
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_half_finished_export_cannot_be_downloaded(workspace, settings):
|
||||||
|
ds, _, client, *_ = workspace
|
||||||
|
for status, code in (("processing", 409), ("failed", 409), ("queued", 409)):
|
||||||
|
job = _export_job(ds, status=status)
|
||||||
|
assert (
|
||||||
|
client.get(f"/api/datasets/{ds.pk}/formula-exports/{job.pk}/download").status_code
|
||||||
|
== code
|
||||||
|
), status
|
||||||
|
succeeded = _export_job(
|
||||||
|
ds,
|
||||||
|
status="succeeded",
|
||||||
|
path=str(Path(settings.MEDIA_ROOT) / "nowhere.xlsx"),
|
||||||
|
finished_at=timezone.now(),
|
||||||
|
expires_at=timezone.now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.get(f"/api/datasets/{ds.pk}/formula-exports/{succeeded.pk}/download").status_code
|
||||||
|
== 410
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_processing_export_is_reclaimed_after_timeout(workspace):
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _export_job(ds, status="processing")
|
||||||
|
FormulaExport.objects.filter(pk=job.pk).update(
|
||||||
|
created_at=timezone.now() - timedelta(minutes=20)
|
||||||
|
)
|
||||||
|
|
||||||
|
call_command("import_worker", once=True)
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.status == "succeeded"
|
||||||
|
assert Path(job.path).read_bytes()[:2] == b"PK"
|
||||||
|
assert not list(Path(job.path).parent.glob(f"{job.pk}.*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_processing_export_is_not_reclaimed(workspace):
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _export_job(ds, status="processing")
|
||||||
|
|
||||||
|
call_command("import_worker", once=True)
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.status == "processing"
|
||||||
|
|||||||
Reference in New Issue
Block a user