From 8f80744d637c2b6bef7403327bbef2e22bae6cdd Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Wed, 30 Sep 2026 12:38:47 +0800
Subject: [PATCH] =?UTF-8?q?feat(task):=20=E8=A7=84=E5=88=99=E6=A8=A1?=
=?UTF-8?q?=E6=9D=BF=20FORM=20=E7=9C=9F=E6=AD=A3=E5=8F=AF=E5=A5=97?=
=?UTF-8?q?=E7=94=A8=20+=20=E5=B7=B2=E5=BD=92=E6=A1=A3=E4=BB=BB=E5=8A=A1?=
=?UTF-8?q?=E5=8F=AF=E5=88=A0=EF=BC=88=E5=AE=A2=E6=88=B7=E7=A1=AE=E8=AE=A4?=
=?UTF-8?q?=E7=9A=84=E4=B8=A4=E9=A1=B9=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
一、规则模板的 FORM 部分(F-57/F-64)—— 此前只能「跳过并说明」,等于装饰
· FormSchemaService.saveRawVersion:用已存在的 schemaJson 存为新版本。
与手工保存的区别:内容不再校验/归一化(它当初就是按本服务规则存出来的,
再走一遍解析→序列化只会丢未建模字段);状态检查与版本管理保留 —— 版本化
逻辑只有一份
· RuleTemplateService 的 FORM 分支接上它(不再 skipped)
· 顺带补上另一半:saveAsTemplate 此前不存表单(存模板不存、套模板用不上,
两头各缺一半)—— 现在把源任务的当前表单一并带走;没存过 = null =
套用时按「用系统默认,不覆盖」跳过
二、已归档任务可删(F-14 / §17.2 D-9)—— 此前「已发布即删不掉」,
演示/测试任务只能改名挪开,库只增不减
· DELETE /tasks/{id} 支持 status=3:收集中/已截止仍拒(归档是最后的后悔药),
拒绝的话说清「先归档」
· 子表级联物理删 14 张表(TaskCascadeMapper 集中放置,表清单来自
information_schema 的事实核对)—— 大头是名册(证件号密文)、材料、审核记录
· ★ 任务行本身**软删**:它必须占住 task_code —— maxSeqByCodePrefix 靠
「含软删行的最大序号」保证编码永不复用(编码同时是文件目录名,重号会把
两批任务的文件混进同一目录)。第一版做成了物理删,e2e-m1-task 的
「编码递增」断言当场抓到重号 —— 这条回归现在被 e2e-m1-task-delete
的「删除后新建不重号」断言守住
· 物理文件尽力删(先取路径再删行,顺序反了文件就永远留在磁盘上;
删不掉只记日志不阻断);全程留痕(sys_operation_log 记 DELETE_TASK +
行数/文件数)
验证:e2e-m7-taskconfig → 75/75(新增 FORM 完整链路 7 条断言)
e2e-m1-task-delete(新增)→ 20/20(拒删/删净/占号软删/留痕/再删报错/草稿直删)
e2e-m1-task → 71/71(旧口径断言更新为新口径)
---
.../form/service/FormSchemaService.java | 36 +++
.../log/service/OperationLogService.java | 2 +
.../task/controller/TaskController.java | 5 +-
.../module/task/mapper/TaskCascadeMapper.java | 95 +++++++
.../task/service/RuleTemplateService.java | 7 +-
.../module/task/service/TaskCloneService.java | 10 +
.../fill/module/task/service/TaskService.java | 99 ++++++-
scripts/e2e-m1-task-delete.py | 256 ++++++++++++++++++
scripts/e2e-m1-task.py | 5 +-
scripts/e2e-m7-taskconfig.py | 45 ++-
10 files changed, 550 insertions(+), 10 deletions(-)
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskCascadeMapper.java
create mode 100644 scripts/e2e-m1-task-delete.py
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/FormSchemaService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/FormSchemaService.java
index 69853d9..aee3ed0 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/FormSchemaService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/form/service/FormSchemaService.java
@@ -90,6 +90,42 @@ public class FormSchemaService {
// ── 保存 ──────────────────────────────────────
+ /**
+ * 用一份**已经存在**的 schemaJson 作为新版本保存(规则模板套用 / 任务克隆场景)。
+ *
+ *
与 {@link #save(Long, FormSchemaSaveReq)} 的区别:内容不再校验与归一化 ——
+ * 它当初就是从某个任务按本服务的规则存出来的,再走一遍"解析 → 对象 → 序列化"
+ * 只会丢掉没建模的字段(与 {@code TaskCloneService} 复制表单是同一个理由)。
+ * 状态检查与版本管理保留(与手工保存同一套,两处必须一致)。
+ *
+ * @return 新版本号
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public int saveRawVersion(Long taskId, String schemaJson) {
+ if (schemaJson == null || schemaJson.isBlank()) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED, "表单定义为空,无可保存内容");
+ }
+ CollectTask task = requireTask(taskId);
+ int status = task.getStatus() == null ? -1 : task.getStatus();
+ if (!EDITABLE_TASK_STATUS.contains(status)) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "任务已" + statusText(status) + ",表单不可再改");
+ }
+ int nextVersion = latestVersion(taskId) + 1;
+ formSchemaMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(CollectFormSchema::getTaskId, taskId)
+ .set(CollectFormSchema::getIsCurrent, CollectFormSchema.HISTORY));
+ CollectFormSchema entity = new CollectFormSchema();
+ entity.setTaskId(taskId);
+ entity.setVersion(nextVersion);
+ entity.setSchemaJson(schemaJson);
+ entity.setIsCurrent(CollectFormSchema.CURRENT);
+ formSchemaMapper.insert(entity);
+ log.info("任务 {}({})表单按已存定义保存为新版本 v{}(套用模板/克隆)",
+ taskId, task.getTaskCode(), nextVersion);
+ return nextVersion;
+ }
+
@Transactional(rollbackFor = Exception.class)
public FormSchemaVO save(Long taskId, FormSchemaSaveReq req) {
CollectTask task = requireTask(taskId);
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/OperationLogService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/OperationLogService.java
index 42463c7..d8599aa 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/OperationLogService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/log/service/OperationLogService.java
@@ -79,6 +79,8 @@ public class OperationLogService {
/** 截止任务。同理:契约表里没有,但它是不可逆的时点,必须留痕。 */
public static final String ACTION_CLOSE_TASK = "CLOSE_TASK";
public static final String ACTION_ARCHIVE_TASK = "ARCHIVE_TASK";
+ /** 删除已归档任务(2026-09-30 客户确认,§17.2 D-9):级联软删 + 尽力删文件,必须留痕。 */
+ public static final String ACTION_DELETE_TASK = "DELETE_TASK";
public static final String ACTION_REVOKE_AUDIT = "REVOKE_AUDIT";
public static final String ACTION_EXPORT_CREATE = "EXPORT_CREATE";
public static final String ACTION_EXPORT_DOWNLOAD = "EXPORT_DOWNLOAD";
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java
index c34ff1b..56116e5 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java
@@ -107,7 +107,10 @@ public class TaskController {
@DeleteMapping("/{id}")
@RequirePerm(PermissionDef.TASK_MANAGE)
- @Operation(summary = "删除任务", description = "只有草稿可以删除;已发布的请用截止或归档")
+ @Operation(summary = "删除任务",
+ description = "草稿直接删;**已归档**的任务可删(2026-09-30 客户确认,§17.2 D-9):"
+ + "级联软删名册/填报单/材料/审核记录并尽力删除文件,全程留痕。"
+ + "收集中/已截止的先「归档」再删 —— 归档是最后的后悔药")
public ApiResult delete(@PathVariable Long id) {
taskService.delete(id);
return ApiResult.ok();
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskCascadeMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskCascadeMapper.java
new file mode 100644
index 0000000..5bd40cf
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/mapper/TaskCascadeMapper.java
@@ -0,0 +1,95 @@
+package cn.tianbaotong.fill.module.task.mapper;
+
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+/**
+ * 删除**已归档任务**时的级联清理({@code TaskService.delete},2026-09-30 客户确认,
+ * 见《03-接口设计》§17.2 D-9 与《08》F-14 —— 此前"已发布的任务删不掉",
+ * 演示/测试任务只能改名挪开,库会一直堆)。
+ *
+ * 为什么集中在这么一个文件里
+ *
+ * 级联删除是"一次动作动十四张表"的地方,散在各 Mapper 里会让人
+ * 只看到其中一截而以为删干净了。集中放置 + 每个方法一行,
+ * review 时一眼能数全(与 {@code MpSceneMapper} 集中放匿名 SQL 是同一个道理)。
+ *
+ *
表清单来自事实({@code information_schema.columns} 里带 {@code task_id} 列的
+ * 全部业务表,2026-09-30 核对为 14 张):任务本身不在此列(由 {@code TaskService} 删);
+ * {@code collect_message} 没有 {@code task_id} 列,学生收件箱是历史,保留。
+ *
+ *
为什么子表物理删、任务行却软删:F-14 的动机是"库会一直堆"(演示/测试任务
+ * 只能改名挪开)—— 大头在子表(2000 行名册、材料、审核记录),物理删才真清掉;
+ * 而 {@code collect_task} 行本身必须软删保留,因为它要继续占住 {@code task_code}
+ * ({@code maxSeqByCodePrefix} 靠"含软删行的最大序号"保证编码不复用 ——
+ * 编码同时是文件目录名)。删除动作的留痕在 {@code sys_operation_log}
+ * ({@code DELETE_TASK},记了谁删的、删了多少行多少文件)。
+ *
+ *
⚠️ 全部 SQL 走 MyBatis-Plus 租户拦截器(自动补 {@code tenant_id},这些表都有该列),
+ * 删除动作在管理员的租户上下文里执行。
+ */
+public interface TaskCascadeMapper {
+
+ // ── 文件路径(先取再删行:物理删除要用,删完行就查不到了)──────
+
+ /** 材料文件路径(std 与 raw 用 | 分隔,可能为空串)。 */
+ @Select("""
+ SELECT coalesce(std_path, '') || '|' || coalesce(raw_path, '')
+ FROM collect_file WHERE task_id = #{taskId}
+ """)
+ List filePaths(@Param("taskId") Long taskId);
+
+ /** 导出产物路径。 */
+ @Select("""
+ SELECT file_path FROM collect_export_file
+ WHERE task_id = #{taskId} AND file_path IS NOT NULL
+ """)
+ List exportPaths(@Param("taskId") Long taskId);
+
+ // ── 物理删(这 5 张表没有 deleted 列)────────────────
+
+ @Delete("DELETE FROM collect_validation_issue WHERE task_id = #{taskId}")
+ int deleteValidationIssues(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_audit_record WHERE task_id = #{taskId}")
+ int deleteAuditRecords(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_notify_record WHERE task_id = #{taskId}")
+ int deleteNotifyRecords(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_open_push_log WHERE task_id = #{taskId}")
+ int deleteOpenPushLogs(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_file WHERE task_id = #{taskId}")
+ int deleteFiles(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_export_file WHERE task_id = #{taskId}")
+ int deleteExportFiles(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_export_task WHERE task_id = #{taskId}")
+ int deleteExportTasks(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_submission WHERE task_id = #{taskId}")
+ int deleteSubmissions(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_join_request WHERE task_id = #{taskId}")
+ int deleteJoinRequests(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_roster WHERE task_id = #{taskId}")
+ int deleteRoster(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_form_schema WHERE task_id = #{taskId}")
+ int deleteFormSchemas(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_material_def WHERE task_id = #{taskId}")
+ int deleteMaterialDefs(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_notify_template WHERE task_id = #{taskId}")
+ int deleteNotifyTemplates(@Param("taskId") Long taskId);
+
+ @Delete("DELETE FROM collect_task_org WHERE task_id = #{taskId}")
+ int deleteTaskOrgs(@Param("taskId") Long taskId);
+}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/RuleTemplateService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/RuleTemplateService.java
index 43782e3..dd11fd2 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/RuleTemplateService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/RuleTemplateService.java
@@ -58,6 +58,7 @@ public class RuleTemplateService {
private final TaskMapper taskMapper;
private final MaterialDefMapper materialDefMapper;
private final MaterialDefService materialDefService;
+ private final cn.tianbaotong.fill.module.form.service.FormSchemaService formSchemaService;
/** 可应用的三个部分(契约 §7.13 的 `parts`)。 */
public static final Set ALL_PARTS = new LinkedHashSet<>(
@@ -126,8 +127,12 @@ public class RuleTemplateService {
// 空 = 用系统默认表单,**不覆盖**目标任务的既有表单
r.getSkipped().add("FORM(模板没带表单定义,表示「用系统默认」,因此跳过)");
} else {
+ // 2026-09-30 起接上真正的应用路径(此前只能"跳过并说明",见 F-57/F-64)。
+ // 走 FormSchemaService 的版本化保存:它保留"已发布任务不可改表单"的检查
+ // 与版本管理 —— 这里**不再自己写表**,两处逻辑必须只有一份。
assertNotPublished(target, "表单定义");
- r.getSkipped().add("FORM(一期还没有「保存表单定义」的服务端入口,见 F-57)");
+ formSchemaService.saveRawVersion(target.getId(), tpl.getFormSchema());
+ r.getApplied().add("FORM");
}
}
if (want.contains("MATERIALS")) {
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskCloneService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskCloneService.java
index 37d720d..6b58be7 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskCloneService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskCloneService.java
@@ -258,6 +258,16 @@ public class TaskCloneService {
row.setCertType(t.getCertType());
row.setMaterials(arr.toString());
row.setNamingTemplate(t.getNamingTemplate());
+ // 表单定义:来源任务存过版本化表单就一并带上(2026-09-30 与「FORM 套用」一起补上 ——
+ // 此前存模板不存表单、套模板又用不上表单,两头各缺一半,见 F-57/F-64)。
+ // 没存过 = 用系统默认 → 存 null(套用时按「用默认,不覆盖」跳过)
+ var currentForm = formSchemaMapper.selectOne(Wrappers
+ .lambdaQuery()
+ .eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getTaskId, taskId)
+ .eq(cn.tianbaotong.fill.module.form.entity.CollectFormSchema::getIsCurrent,
+ cn.tianbaotong.fill.module.form.entity.CollectFormSchema.CURRENT)
+ .last("limit 1"));
+ row.setFormSchema(currentForm == null ? null : currentForm.getSchemaJson());
row.setIsPublic(pub ? CollectRuleTemplate.PUBLIC_YES : CollectRuleTemplate.PUBLIC_NO);
row.setUseCount(0);
ruleTemplateMapper.insert(row);
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java
index 1e40275..d71eab1 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskService.java
@@ -73,6 +73,8 @@ public class TaskService {
private final TaskReadinessMapper readinessMapper;
private final cn.tianbaotong.fill.module.material.service.MaterialDefService materialDefService;
private final cn.tianbaotong.fill.module.log.service.OperationLogService operationLog;
+ private final cn.tianbaotong.fill.module.task.mapper.TaskCascadeMapper cascadeMapper;
+ private final cn.tianbaotong.fill.module.file.storage.StorageService storageService;
// ── 列表 ──────────────────────────────────────
@@ -369,17 +371,104 @@ public class TaskService {
taskMapper.updateById(patch);
}
- // ── 删除(仅草稿)────────────────────────────
+ // ── 删除(草稿直接删;已归档的级联删,2026-09-30 客户确认)──────
+ /**
+ * 删除任务(《03-接口设计》§17.2 D-9;原口径"已发布即不可删"会让任务列表只增不减,
+ * 演示/测试数据只能改名挪开 —— 见《08》F-14)。
+ *
+ *
+ * - 草稿:直接删(没有业务数据,与原来一样);
+ * - 已归档(status=3):管理员显式删除 —— 子表级联物理删
+ * (名册/填报单/材料/审核记录等 14 张表,清掉的是大头:证件号密文、材料、审核记录),
+ * 但任务行本身软删 —— 它必须留下来占住 {@code task_code}:
+ * {@code TaskMapper.maxSeqByCodePrefix} 靠"含软删行的最大序号"保证
+ * 编码用过的永不复用(编码同时是文件目录名,复用会把两批任务的文件
+ * 混进同一个目录)。E2E 抓到过这个坑:物理删任务行后新任务拿到重号。
+ * 物理文件尽力删(删不掉只记日志不阻断),全程留痕
+ * (sys_operation_log 记 DELETE_TASK + 行数/文件数);
+ * - 收集中/已截止:仍然拒绝 —— 归档是"最后的后悔药",
+ * 直接删"还活着的数据"这个口子不开。
+ *
+ */
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
CollectTask exist = requireTask(id);
- if (exist.getStatus() == null || exist.getStatus() != CollectTask.STATUS_DRAFT) {
- throw new BizException(ErrorCode.VALIDATION_BLOCKED,
- "只有草稿状态的任务可以删除;已发布的任务请用「截止」或「归档」");
+ int st = exist.getStatus() == null ? -1 : exist.getStatus();
+
+ if (st == CollectTask.STATUS_DRAFT) {
+ taskMapper.deleteById(id);
+ log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
+ return;
}
+ if (st != CollectTask.STATUS_ARCHIVED) {
+ throw new BizException(ErrorCode.VALIDATION_BLOCKED,
+ "只有草稿或已归档的任务可以删除;收集中/已截止的任务请先「归档」再删"
+ + "(删除会连带清掉名册、填报单、材料与文件,不可恢复)");
+ }
+
+ // ★ 先取文件路径再删行 —— 行删了就查不到了(顺序反了 = 文件永远留在磁盘上)
+ List filePaths = cascadeMapper.filePaths(id);
+ List exportPaths = cascadeMapper.exportPaths(id);
+
+ int rows = cascadeMapper.deleteValidationIssues(id)
+ + cascadeMapper.deleteAuditRecords(id)
+ + cascadeMapper.deleteNotifyRecords(id)
+ + cascadeMapper.deleteOpenPushLogs(id)
+ + cascadeMapper.deleteFiles(id)
+ + cascadeMapper.deleteExportFiles(id)
+ + cascadeMapper.deleteExportTasks(id)
+ + cascadeMapper.deleteSubmissions(id)
+ + cascadeMapper.deleteJoinRequests(id)
+ + cascadeMapper.deleteRoster(id)
+ + cascadeMapper.deleteFormSchemas(id)
+ + cascadeMapper.deleteMaterialDefs(id)
+ + cascadeMapper.deleteNotifyTemplates(id)
+ + cascadeMapper.deleteTaskOrgs(id);
+ // ★ 任务行**软删**(不是物理删):唯一约束 uk_task_tenant_code 不看 deleted,
+ // 软删行继续占住编码 → maxSeqByCodePrefix 的"编码不复用"不变量保持成立。
+ // 物理删它的话,下一个同批次任务会拿到重号(e2e-m1-task 的断言抓到过)。
taskMapper.deleteById(id);
- log.info("删除草稿任务 {}(id={})", exist.getTaskCode(), id);
+
+ int removed = deleteFilesQuietly(filePaths, exportPaths);
+
+ operationLog.record(
+ cn.tianbaotong.fill.module.log.service.OperationLogService.MODULE_TASK,
+ cn.tianbaotong.fill.module.log.service.OperationLogService.ACTION_DELETE_TASK,
+ "TASK", id,
+ "任务「" + exist.getTaskName() + "」(" + exist.getTaskCode() + ")已归档后删除",
+ "级联删除 " + rows + " 行;物理删除文件 " + removed + " 个");
+ log.info("删除已归档任务 {}(id={}):级联 {} 行,文件 {} 个,经办人={}",
+ exist.getTaskCode(), id, rows, removed, UserContext.uid());
+ }
+
+ /** 尽力删除物理文件(本地磁盘或对象存储)。失败只记日志 —— 行已删,别让一个文件拖垮整个删除。 */
+ private int deleteFilesQuietly(List filePaths, List exportPaths) {
+ int ok = 0;
+ for (String pair : filePaths) {
+ // 形如 "std/1/xx.jpg|raw/1/xx.jpg"(两段都可能是空串)
+ for (String p : pair.split("\\|")) {
+ if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
+ ok++;
+ }
+ }
+ }
+ for (String p : exportPaths) {
+ if (p != null && !p.isBlank() && deleteOneQuietly(p)) {
+ ok++;
+ }
+ }
+ return ok;
+ }
+
+ private boolean deleteOneQuietly(String path) {
+ try {
+ storageService.delete(path);
+ return true;
+ } catch (Exception e) { // noqa: BLE001
+ log.warn("删除任务时清理文件失败(不影响删除本身):{} → {}", path, e.getMessage());
+ return false;
+ }
}
// ── 发布 ──────────────────────────────────────
diff --git a/scripts/e2e-m1-task-delete.py b/scripts/e2e-m1-task-delete.py
new file mode 100644
index 0000000..281fa8c
--- /dev/null
+++ b/scripts/e2e-m1-task-delete.py
@@ -0,0 +1,256 @@
+# -*- coding: utf-8 -*-
+"""
+已归档任务可删(§17.2 D-9 / F-14,2026-09-30 客户确认)的 E2E。
+
+## 背景
+原口径"已发布的任务删不掉也改不了名(40001)"—— 于是演示/测试任务只能改名挪开,
+任务列表只增不减。现在:**草稿直接删;已归档的显式删(级联软删 + 尽力删文件 + 留痕);
+收集中/已截止仍然拒绝**(归档是"最后的后悔药")。
+
+## 本脚本验什么
+1. 收集中 → 删被拒,提示要说清"先归档"(不是一句笼统的 40001);
+2. 已截止 → 仍然拒(活数据不提供删除口子);
+3. 已归档 → 删成功:**子表级联物理删净**(名册/材料/覆盖班级/审核记录——大头清掉),
+ **任务行本身软删**(占住 `task_code` 不复用:编码是文件目录名,重号会混两批文件),
+ 且 `sys_operation_log` 有一条 `DELETE_TASK`;
+4. **删除后新建任务不重号**(编码不复用 —— 第一版做成物理删时,就是这条被
+ `e2e-m1-task` 的"编码递增"断言抓到的);
+5. 删完再删 → 明确报错(不存在),不是 500;
+6. 草稿 → 直接删(回归原行为)。
+
+跑法:python scripts/e2e-m1-task-delete.py(后端 8080 + xt-pg 在跑)
+"""
+import argparse
+import importlib.util
+import json
+import os
+import sys
+
+sys.stdout.reconfigure(encoding="utf-8")
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+BASE = "http://localhost:8080"
+SUFFIX = os.urandom(3).hex()
+TMP_ROSTER = os.path.join(ROOT, "scripts", "_e2e-roster-del-%s.xlsx" % SUFFIX)
+
+PASSED = []
+FAILED = []
+CREATED = []
+
+
+def _load(name, fn):
+ spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", fn))
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+R = _load("e2e_m2_roster", "e2e-m2-roster.py")
+
+
+def ok(name):
+ PASSED.append(name)
+ print(" [OK] " + name)
+
+
+def fail(name, detail):
+ FAILED.append((name, detail))
+ print(" [FAIL] " + name + "\n " + detail)
+
+
+def check(name, cond, detail=""):
+ if cond:
+ ok(name)
+ else:
+ fail(name, detail or "断言不成立")
+ return bool(cond)
+
+
+def sql(stmt):
+ _, out = R.sql(stmt)
+ return out
+
+
+def body_of(method, path, token=None, payload=None):
+ _, b = R.call(method, BASE + path, token, payload)
+ return b
+
+
+def build_roster(rows):
+ from openpyxl import Workbook
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "删除班"
+ header = ["序号", "姓名", "身份证号", "学历", "联系电话", "工作单位",
+ "报考职业", "报考工种", "报考等级", "是否补考",
+ "补考理论", "补考实操", "补考综合", "是否在校生",
+ "在读学校", "所学专业", "入学时间"]
+ for c, t in enumerate(header, start=1):
+ ws.cell(row=1, column=c, value=t)
+ for i, (name, idc) in enumerate(rows):
+ vals = [i + 1, name, idc, "中专", "1380013770%d" % i, "无",
+ "车工", "数控车工", "四级", "否", "", "", "", "是",
+ "示范职业学校", "数控加工", "2024-09-01"]
+ for c, v in enumerate(vals, start=1):
+ if v != "":
+ ws.cell(row=i + 2, column=c, value=v)
+ wb.save(TMP_ROSTER)
+
+
+def main():
+ argparse.ArgumentParser(description="已归档任务删除 E2E").parse_args()
+
+ print("=" * 74)
+ print(" 已归档任务删除(§17.2 D-9 / F-14)E2E")
+ print("=" * 74)
+
+ _, adm = R.login(BASE, "admin", "Admin@123", "demo")
+ if adm.get("code") != 0:
+ print(" x 管理员登录失败:" + str(adm))
+ return 2
+ at = adm["data"]["token"]
+
+ # ── 0. 夹具:一个已发布的任务(材料 + 班级 + 名册 2 人)────────
+ print("\n[0] 摆夹具")
+ _, b = R.call("POST", BASE + "/api/v1/tasks", at, {
+ "taskName": "E2E删除 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工",
+ "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2099-12-31"
+ })
+ # ⚠️ 每一步都查 code:夹具不成立时越早报清楚,越好排查(F-96/F-99 的教训)
+ if b.get("code") != 0:
+ print(" x 建任务失败:" + json.dumps(b, ensure_ascii=False)[:200])
+ return 2
+ tid = b["data"]["id"]
+ CREATED.append(tid)
+
+ _, b = R.call("GET", BASE + "/api/v1/orgs/tree", at)
+
+ classes = []
+
+ def walk(nodes):
+ # 班级(orgType=3)在树的**下层**,只扫顶层会一个都找不到
+ for n in nodes or []:
+ if n.get("orgType") == 3:
+ classes.append(n)
+ walk(n.get("children"))
+
+ walk(b.get("data") or [])
+ org = classes[0]
+ body_of("PUT", "/api/v1/tasks/%d/orgs" % tid, at,
+ {"orgs": [{"orgId": org["id"], "expectedCount": 2}]})
+ body_of("POST", "/api/v1/tasks/%d/materials/apply-template" % tid, at,
+ {"templateCode": "TPL_SCHOOL_ROLL"})
+ build_roster([("删除甲", R.with_checksum("36102920080909031")),
+ ("删除乙", R.with_checksum("36102920080909032"))])
+ body = R.upload_roster(BASE, at, tid, TMP_ROSTER,
+ extra={"dryRun": "false", "mode": "APPEND", "orgId": org["id"]})
+ check("夹具:名册 2 人导入", body.get("code") == 0
+ and (body.get("data") or {}).get("created") == 2,
+ json.dumps(body, ensure_ascii=False)[:200])
+ body_of("POST", "/api/v1/tasks/%d/publish" % tid, at, {"force": True})
+ st = sql("select status from collect_task where id=%d;" % tid).strip()
+ check("夹具:任务已发布(status=1)", st == "1", "status=%s" % st)
+
+ # ── 1. 收集中:删除被拒,且提示要说清怎么办 ─────────────────
+ print("\n[1] 收集中 → 拒删")
+ d1 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
+ check("收集中删除被拒", d1.get("code") != 0, json.dumps(d1, ensure_ascii=False)[:200])
+ check("⭐ 拒绝的话说清「先归档」(不是一句笼统的失败)",
+ "归档" in (d1.get("msg") or ""), "msg=%s" % d1.get("msg"))
+ check("任务还在(拒绝不是误删)",
+ sql("select count(*) from collect_task where id=%d and deleted=0;" % tid).strip() == "1")
+
+ # ── 2. 截止(status=2):仍然拒 ────────────────────────────
+ print("\n[2] 已截止 → 仍拒(活数据不给删除口子)")
+ body_of("POST", "/api/v1/tasks/%d/close" % tid, at, {})
+ st2 = sql("select status from collect_task where id=%d;" % tid).strip()
+ check("截止成功(status=2)", st2 == "2", "status=%s" % st2)
+ d2 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
+ check("已截止仍然拒删", d2.get("code") != 0, json.dumps(d2, ensure_ascii=False)[:200])
+
+ # ── 3. 归档(status=3)→ 删除成功且级联 ────────────────────
+ print("\n[3] 已归档 → 删除成功(级联软删 + 留痕)")
+ body_of("POST", "/api/v1/tasks/%d/archive" % tid, at, {})
+ st3 = sql("select status from collect_task where id=%d;" % tid).strip()
+ check("归档成功(status=3)", st3 == "3", "status=%s" % st3)
+
+ d3 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
+ check("删除成功", d3.get("code") == 0, json.dumps(d3, ensure_ascii=False)[:200])
+
+ # ★ 任务行**软删**而不是物理删:它要继续占住 task_code ——
+ # maxSeqByCodePrefix 靠"含软删行的最大序号"保证编码不复用(编码是文件目录名)。
+ # (第一版做成了物理删,e2e-m1-task 的"编码递增"断言当场抓到重号 —— 这就是那条回归)
+ check("⭐ 任务行软删(deleted=1,占住编码不复用;子表才是物理删)",
+ sql("select deleted from collect_task where id=%d;" % tid).strip() == "1")
+ check("⭐ 名册级联删净(含证件号密文的学生数据不留残留)",
+ sql("select count(*) from collect_roster where task_id=%d;" % tid).strip() == "0",
+ "残留=%s" % sql("select count(*) from collect_roster where task_id=%d;" % tid).strip())
+ check("⭐ 材料清单级联删净",
+ sql("select count(*) from collect_material_def where task_id=%d;" % tid).strip() == "0")
+ check("⭐ 覆盖班级级联删净",
+ sql("select count(*) from collect_task_org where task_id=%d;" % tid).strip() == "0")
+ check("⭐ 审核记录也一并删净(这些表没有 deleted 列,物理删;留痕在操作日志)",
+ sql("select count(*) from collect_audit_record where task_id=%d;" % tid).strip() == "0")
+ log_row = sql("select count(*) from sys_operation_log where action='DELETE_TASK' "
+ "and target_id=%d;" % tid).strip()
+ check("⭐ 留痕:sys_operation_log 有 DELETE_TASK(谁删的、删了多少,要答得出来)",
+ log_row >= "1", "行数=%s" % log_row)
+
+ # ── 3b. 编码不复用(删除腾出来的号不能发给下一个任务)────────
+ old_code = sql("select task_code from collect_task where id=%d;" % tid).strip()
+ _, nb = R.call("POST", BASE + "/api/v1/tasks", at, {
+ "taskName": "E2E删除后新建 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工",
+ "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
+ })
+ new_code = ((nb.get("data") or {}).get("taskCode") or "")
+ check("⭐ 删除后新建任务不重号(编码不复用 —— 它是文件目录名,重号会混两批文件)",
+ nb.get("code") == 0 and new_code != "" and new_code != old_code,
+ f"删的={old_code} 新的={new_code}")
+ if (nb.get("data") or {}).get("id"):
+ CREATED.append(nb["data"]["id"])
+
+ # ── 4. 删完再删 → 明确报错,不是 500 ──────────────────────
+ print("\n[4] 删完再删")
+ d4 = body_of("DELETE", "/api/v1/tasks/%d" % tid, at)
+ check("再删 → 明确报错(不存在/已删)", d4.get("code") != 0,
+ json.dumps(d4, ensure_ascii=False)[:200])
+ check("报的不是系统错误(50000)", d4.get("code") != 50000,
+ "code=%s" % d4.get("code"))
+
+ # ── 5. 草稿:直接删(回归原行为)─────────────────────────
+ print("\n[5] 草稿 → 直接删")
+ _, b5 = R.call("POST", BASE + "/api/v1/tasks", at, {
+ "taskName": "E2E删除草稿 %s" % SUFFIX, "batchNo": "202609", "majorName": "数控加工",
+ "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
+ })
+ did = (b5.get("data") or {}).get("id")
+ check("草稿建好", did is not None, json.dumps(b5, ensure_ascii=False)[:160])
+ d5 = body_of("DELETE", "/api/v1/tasks/%d" % did, at)
+ check("草稿直接删成功", d5.get("code") == 0, json.dumps(d5, ensure_ascii=False)[:160])
+
+ cleanup()
+
+ print("\n" + "=" * 74)
+ print(" 通过 %d 失败 %d 共 %d" % (len(PASSED), len(FAILED),
+ len(PASSED) + len(FAILED)))
+ print("=" * 74)
+ return 1 if FAILED else 0
+
+
+def cleanup():
+ # 正常路径已经把任务删干净了;这里兜底失败/中断的残留
+ for tid in CREATED:
+ for tbl in ("collect_validation_issue", "collect_audit_record", "collect_notify_record",
+ "collect_file", "collect_submission", "collect_roster",
+ "collect_task_org", "collect_material_def"):
+ R.sql("delete from %s where task_id=%d;" % (tbl, tid))
+ R.sql("delete from collect_task where id=%d;" % tid)
+ if CREATED:
+ R.sql("delete from sys_operation_log where action='DELETE_TASK' "
+ "and target_id in (%s);" % ",".join(str(t) for t in CREATED))
+ if os.path.exists(TMP_ROSTER):
+ os.remove(TMP_ROSTER)
+ print(" 已清理:任务与级联行 / 操作日志 / 临时 Excel")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/e2e-m1-task.py b/scripts/e2e-m1-task.py
index 90d2c35..4491a42 100644
--- a/scripts/e2e-m1-task.py
+++ b/scripts/e2e-m1-task.py
@@ -436,7 +436,10 @@ def main() -> int:
f"code={body.get('code')} msg={body.get('msg')}")
status, body = call("DELETE", f"{base}/api/v1/tasks/{task_id}", token)
- check("已归档不可删除", body.get("code") != 0, f"code={body.get('code')}")
+ # 2026-09-30 客户确认(§17.2 D-9):已归档的任务**可以**删除(子表级联物理删、
+ # 任务行软删占住编码)。旧断言"已归档不可删除"随旧口径一起作废。
+ check("已归档可以删除(2026-09-30 起,级联清理 + 留痕)", body.get("code") == 0,
+ f"code={body.get('code')} msg={body.get('msg')}")
# ── 8. 草稿路径与删除 ───────────────────────────────
print("\n[8] 草稿路径与删除")
diff --git a/scripts/e2e-m7-taskconfig.py b/scripts/e2e-m7-taskconfig.py
index bc83958..93db353 100644
--- a/scripts/e2e-m7-taskconfig.py
+++ b/scripts/e2e-m7-taskconfig.py
@@ -262,12 +262,52 @@ def main():
check("⭐ 模板的 useCount 被 +1(用户能看出哪个模板常用)",
use_after == use_before + 1, "前 %d → 后 %d" % (use_before, use_after))
- # 只应用 FORM:应该 skipped 且说明原因
+ # 只应用 FORM(公共模板没带表单定义):应该明确 skipped 且说明原因
ap2 = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": tgt, "parts": ["FORM"]})
check("⭐ 只应用 FORM 时明确 skipped + 原因(公共模板的 form_schema 为空)",
len((ap2.get("data") or {}).get("skipped") or []) >= 1,
json.dumps(ap2.get("data"), ensure_ascii=False)[:300])
+
+ # ── FORM 的完整链路(2026-09-30 接上,F-64:此前只能"跳过并说明",模板的 FORM 部分是装饰)──
+ print(" FORM 完整链路:存表单 → 存模板(带表单)→ 套用到另一个草稿")
+ fs = body_of("GET", "/api/v1/tasks/%d/form-schema" % tgt, at).get("data") or {}
+ save_fs = body_of("PUT", "/api/v1/tasks/%d/form-schema" % tgt, at, {
+ "baseVersion": fs.get("version"),
+ "fields": fs.get("fields") or [],
+ # ⚠️ 扩展字段的 key 必须以 extra_ 开头(服务端规则,写错会 40001)
+ "extras": [{"key": "extra_e2e", "label": "E2E扩展字段", "type": "text"}],
+ })
+ check("⭐ 给草稿任务存一份表单(v1,带一个扩展字段)",
+ save_fs.get("code") == 0 and (save_fs.get("data") or {}).get("version") == 1,
+ json.dumps(save_fs, ensure_ascii=False)[:200])
+ tpl2 = body_of("POST", "/api/v1/tasks/%d/save-as-template" % tgt, at,
+ {"templateName": "E2E表单模板" + suffix})
+ check("存为模板成功", tpl2.get("code") == 0, json.dumps(tpl2, ensure_ascii=False)[:160])
+ tpl2_id = ((tpl2.get("data") or {}).get("templateId"))
+ has_fs = sql("select (form_schema is not null)::text from collect_rule_template where id=%s;"
+ % tpl2_id).strip()
+ check("⭐ 模板把表单定义也存进去了(此前存模板不存表单,F-64 的前一半)",
+ has_fs == "true", "form_schema is not null = %s" % has_fs)
+ _, b3 = R.call("POST", BASE + "/api/v1/tasks", at, {
+ "taskName": "E2E套表单 " + suffix, "batchNo": "202609", "majorName": "数控加工",
+ "collectMode": 1, "certType": 1, "startTime": "2026-01-01", "endTime": "2026-12-31"
+ })
+ tgt2 = b3["data"]["id"]
+ CREATED_TASK_IDS.append(tgt2)
+ apf = body_of("POST", "/api/v1/rule-templates/%s/apply" % tpl2_id, at,
+ {"targetTaskId": tgt2, "parts": ["FORM"]})
+ check("⭐ FORM 部分真的应用了(不再「跳过并说明」)",
+ "FORM" in ((apf.get("data") or {}).get("applied") or []),
+ json.dumps(apf.get("data"), ensure_ascii=False)[:200])
+ rows2 = sql("select count(*) from collect_form_schema where task_id=%d;" % tgt2).strip()
+ check("⭐ 目标任务有了版本化的表单行(套用走 FormSchemaService 的版本化保存)",
+ rows2 >= "1", "collect_form_schema 行数=%s" % rows2)
+ # schema_json 是 jsonb:LIKE 之前要 ::text
+ got_extra = sql("select (schema_json::text like '%%extra_e2e%%')::text "
+ "from collect_form_schema where task_id=%d "
+ "order by version desc limit 1;" % tgt2).strip()
+ check("套过去的就是源任务那份定义(含 extra_e2e 扩展字段)", got_extra == "true", got_extra)
bad_parts = body_of("POST", "/api/v1/rule-templates/%d/apply" % tpl_id, at,
{"targetTaskId": tgt, "parts": ["NOPE"]})
check("parts 写错 → 明确报错并列出合法值",
@@ -568,9 +608,10 @@ def cleanup():
R.sql("delete from collect_roster where task_id=%d;" % tid)
R.sql("delete from collect_task_org where task_id=%d;" % tid)
R.sql("delete from collect_material_def where task_id=%d;" % tid)
+ R.sql("delete from collect_form_schema where task_id=%d;" % tid)
R.sql("delete from collect_task where id=%d;" % tid)
R.sql("delete from collect_rule_template where template_name like 'E2E自建模板%' "
- "or template_name like 'E2E公共尝试%';")
+ "or template_name like 'E2E公共尝试%' or template_name like 'E2E表单模板%';")
R.sql("update collect_rule_template set use_count=0 where id in (1,2);")
R.sql("delete from collect_message where user_id in (995111, 995112);")
R.sql("delete from sys_user_role where user_id = 995101;")