From 98ec87554da1c8751365384af2fa8878086044fb Mon Sep 17 00:00:00 2001 From: krestar Date: Tue, 18 Aug 2026 18:55:53 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=97=85=EB=AC=B4=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=20=EC=8B=9C=20Case=20=EC=83=9D=EB=AA=85=EC=A3=BC=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JdbcTaskCaseLifecycleUpdater.java | 33 ++++++ .../task/application/TaskWorkflowService.java | 20 +++- .../port/TaskCaseLifecycleUpdater.java | 2 + ...V59__reconcile_terminal_workflow_cases.sql | 39 +++++++ .../task/TaskWorkflowIntegrationTest.java | 100 ++++++++++++++++++ 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V59__reconcile_terminal_workflow_cases.sql diff --git a/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseLifecycleUpdater.java b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseLifecycleUpdater.java index e1981393..4cf7031f 100644 --- a/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseLifecycleUpdater.java +++ b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseLifecycleUpdater.java @@ -48,4 +48,37 @@ AND unfinished_task.status NOT IN ('COMPLETED', 'CANCELLED') ); return updated == 1; } + + @Override + public boolean cancelIfAllTasksCancelled(UUID caseId, UUID companyId, Instant cancelledAt) { + int updated = jdbcTemplate.update( + """ + UPDATE workflow_case workflow_case + SET lifecycle_status = 'CANCELLED', + updated_at = ?, + version = version + 1 + WHERE workflow_case.case_id = ? + AND workflow_case.company_id = ? + AND workflow_case.lifecycle_status = 'ACTIVE' + AND EXISTS ( + SELECT 1 + FROM task cancelled_task + WHERE cancelled_task.case_id = workflow_case.case_id + AND cancelled_task.company_id = workflow_case.company_id + AND cancelled_task.status = 'CANCELLED' + ) + AND NOT EXISTS ( + SELECT 1 + FROM task remaining_task + WHERE remaining_task.case_id = workflow_case.case_id + AND remaining_task.company_id = workflow_case.company_id + AND remaining_task.status <> 'CANCELLED' + ) + """, + Timestamp.from(cancelledAt), + caseId, + companyId + ); + return updated == 1; + } } diff --git a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java index a885cffa..1fe9c080 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -18,8 +18,9 @@ import com.fowoco.server.reliability.application.port.DomainEventPublisher; import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent; import com.fowoco.server.task.application.error.TaskErrorCode; -import com.fowoco.server.task.application.port.TaskChecklistRepository; +import com.fowoco.server.task.application.port.TaskCaseLifecycleUpdater; import com.fowoco.server.task.application.port.TaskCaseRegistrar; +import com.fowoco.server.task.application.port.TaskChecklistRepository; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.application.port.TaskRepository.TaskPage; import com.fowoco.server.task.application.port.TaskRepository.TaskSearchCriteria; @@ -54,6 +55,7 @@ public class TaskWorkflowService { private final TenantDatabaseContext tenantDatabaseContext; private final TaskRepository taskRepository; private final TaskChecklistRepository checklistRepository; + private final TaskCaseLifecycleUpdater taskCaseLifecycleUpdater; private final TaskCaseRegistrar taskCaseRegistrar; private final TaskTransitionRecorder transitionRecorder; private final WorkerTaskContextReader workerReader; @@ -71,6 +73,7 @@ public TaskWorkflowService( TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, TaskChecklistRepository checklistRepository, + TaskCaseLifecycleUpdater taskCaseLifecycleUpdater, TaskCaseRegistrar taskCaseRegistrar, TaskTransitionRecorder transitionRecorder, WorkerTaskContextReader workerReader, @@ -87,6 +90,7 @@ public TaskWorkflowService( this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; this.checklistRepository = checklistRepository; + this.taskCaseLifecycleUpdater = taskCaseLifecycleUpdater; this.taskCaseRegistrar = taskCaseRegistrar; this.transitionRecorder = transitionRecorder; this.workerReader = workerReader; @@ -506,6 +510,20 @@ public TaskResult cancel( metadata, now )); + if (savedTask.caseId() != null) { + boolean completed = taskCaseLifecycleUpdater.completeIfAllTasksFinished( + savedTask.caseId(), + savedTask.companyId(), + now + ); + if (!completed) { + taskCaseLifecycleUpdater.cancelIfAllTasksCancelled( + savedTask.caseId(), + savedTask.companyId(), + now + ); + } + } return toResult( savedTask, checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()), diff --git a/src/main/java/com/fowoco/server/task/application/port/TaskCaseLifecycleUpdater.java b/src/main/java/com/fowoco/server/task/application/port/TaskCaseLifecycleUpdater.java index d5647057..e09d3daf 100644 --- a/src/main/java/com/fowoco/server/task/application/port/TaskCaseLifecycleUpdater.java +++ b/src/main/java/com/fowoco/server/task/application/port/TaskCaseLifecycleUpdater.java @@ -6,4 +6,6 @@ public interface TaskCaseLifecycleUpdater { boolean completeIfAllTasksFinished(UUID caseId, UUID companyId, Instant completedAt); + + boolean cancelIfAllTasksCancelled(UUID caseId, UUID companyId, Instant cancelledAt); } diff --git a/src/main/resources/db/migration/V59__reconcile_terminal_workflow_cases.sql b/src/main/resources/db/migration/V59__reconcile_terminal_workflow_cases.sql new file mode 100644 index 00000000..99d85798 --- /dev/null +++ b/src/main/resources/db/migration/V59__reconcile_terminal_workflow_cases.sql @@ -0,0 +1,39 @@ +UPDATE workflow_case + SET lifecycle_status = 'COMPLETED', + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + WHERE lifecycle_status = 'ACTIVE' + AND EXISTS ( + SELECT 1 + FROM task completed_task + WHERE completed_task.case_id = workflow_case.case_id + AND completed_task.company_id = workflow_case.company_id + AND completed_task.status = 'COMPLETED' + ) + AND NOT EXISTS ( + SELECT 1 + FROM task unfinished_task + WHERE unfinished_task.case_id = workflow_case.case_id + AND unfinished_task.company_id = workflow_case.company_id + AND unfinished_task.status NOT IN ('COMPLETED', 'CANCELLED') + ); + +UPDATE workflow_case + SET lifecycle_status = 'CANCELLED', + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + WHERE lifecycle_status = 'ACTIVE' + AND EXISTS ( + SELECT 1 + FROM task cancelled_task + WHERE cancelled_task.case_id = workflow_case.case_id + AND cancelled_task.company_id = workflow_case.company_id + AND cancelled_task.status = 'CANCELLED' + ) + AND NOT EXISTS ( + SELECT 1 + FROM task remaining_task + WHERE remaining_task.case_id = workflow_case.case_id + AND remaining_task.company_id = workflow_case.company_id + AND remaining_task.status <> 'CANCELLED' + ); diff --git a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java index 945cef68..35328db1 100644 --- a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java @@ -339,6 +339,106 @@ void missingRequiredSlotCreatesNeedsInfoAndCannotRequestApproval() throws Except .containsExactly("due_at"); } + @Test + void cancellingTheOnlyTaskCancelsItsCaseProjection() throws Exception { + String token = login(HR_A_EMAIL); + HttpResponse created = post("/api/v1/tasks", validCreateBody(), token); + UUID taskId = UUID.fromString(JsonPath.read(created.body(), "$.task_id")); + UUID caseId = UUID.fromString(JsonPath.read(created.body(), "$.case_id")); + + assertThat(post( + "/api/v1/tasks/" + taskId + "/cancel", + "{\"expected_version\":0,\"reason\":\"업무 계획 취소\"}", + token + ).statusCode()).isEqualTo(200); + + HttpResponse projection = get( + "/api/v1/cases/" + caseId + "/projection", + token + ); + assertThat(projection.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(projection.body(), "$.lifecycle_status")) + .isEqualTo("CANCELLED"); + assertThat(JsonPath.read(projection.body(), "$.display_status")) + .isEqualTo("CANCELLED"); + assertThat(JsonPath.read(projection.body(), "$.current_task")).isNull(); + + HttpResponse page = get("/api/v1/cases?page=0&size=20", token); + assertThat(page.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(page.body(), "$.items[0].display_status")) + .isEqualTo("CANCELLED"); + assertThat(JsonPath.read(page.body(), "$.items[0].current_task")).isNull(); + } + + @Test + void cancellingOneTaskKeepsCaseActiveWhileAnotherTaskIsNonTerminal() throws Exception { + String token = login(HR_A_EMAIL); + HttpResponse firstCreated = post("/api/v1/tasks", validCreateBody(), token); + UUID firstTaskId = UUID.fromString(JsonPath.read(firstCreated.body(), "$.task_id")); + UUID caseId = UUID.fromString(JsonPath.read(firstCreated.body(), "$.case_id")); + HttpResponse secondCreated = post( + "/api/v1/tasks", + createBody(caseId, "후속 재계약 확인"), + token + ); + UUID secondTaskId = UUID.fromString(JsonPath.read(secondCreated.body(), "$.task_id")); + + assertThat(post( + "/api/v1/tasks/" + firstTaskId + "/cancel", + "{\"expected_version\":0,\"reason\":\"첫 업무만 취소\"}", + token + ).statusCode()).isEqualTo(200); + + HttpResponse projection = get( + "/api/v1/cases/" + caseId + "/projection", + token + ); + assertThat(JsonPath.read(projection.body(), "$.lifecycle_status")) + .isEqualTo("ACTIVE"); + assertThat(JsonPath.read(projection.body(), "$.current_task.task_id")) + .isEqualTo(secondTaskId.toString()); + } + + @Test + void cancellingLastNonTerminalTaskCompletesCaseWhenAnotherTaskIsCompleted() throws Exception { + String token = login(HR_A_EMAIL); + HttpResponse completedTaskCreated = post("/api/v1/tasks", validCreateBody(), token); + UUID completedTaskId = UUID.fromString(JsonPath.read( + completedTaskCreated.body(), + "$.task_id" + )); + UUID caseId = UUID.fromString(JsonPath.read(completedTaskCreated.body(), "$.case_id")); + HttpResponse cancelledTaskCreated = post( + "/api/v1/tasks", + createBody(caseId, "취소할 후속 업무"), + token + ); + UUID cancelledTaskId = UUID.fromString(JsonPath.read( + cancelledTaskCreated.body(), + "$.task_id" + )); + jdbcTemplate.update( + "UPDATE task SET status = 'COMPLETED' WHERE task_id = ?", + completedTaskId + ); + + assertThat(post( + "/api/v1/tasks/" + cancelledTaskId + "/cancel", + "{\"expected_version\":0,\"reason\":\"불필요한 후속 업무\"}", + token + ).statusCode()).isEqualTo(200); + + HttpResponse projection = get( + "/api/v1/cases/" + caseId + "/projection", + token + ); + assertThat(JsonPath.read(projection.body(), "$.lifecycle_status")) + .isEqualTo("COMPLETED"); + assertThat(JsonPath.read(projection.body(), "$.display_status")) + .isEqualTo("COMPLETED"); + assertThat(JsonPath.read(projection.body(), "$.current_task")).isNull(); + } + @Test void createsACompanyOnboardingTaskWithoutWorkerOrCase() throws Exception { String token = login(HR_A_EMAIL);