diff --git a/src/main/java/com/fowoco/server/task/api/TaskController.java b/src/main/java/com/fowoco/server/task/api/TaskController.java index 41c35d6b..1909a938 100644 --- a/src/main/java/com/fowoco/server/task/api/TaskController.java +++ b/src/main/java/com/fowoco/server/task/api/TaskController.java @@ -5,6 +5,7 @@ import com.fowoco.server.common.web.RequestMetadata; import com.fowoco.server.task.application.TaskResult; import com.fowoco.server.task.application.TaskWorkflowService; +import com.fowoco.server.task.application.action.TaskAvailableActionResolver; import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.task.domain.TaskTargetType; @@ -45,13 +46,16 @@ public class TaskController { private final TaskWorkflowService taskService; private final ActorContextProvider actorContextProvider; + private final TaskAvailableActionResolver actionResolver; public TaskController( TaskWorkflowService taskService, - ActorContextProvider actorContextProvider + ActorContextProvider actorContextProvider, + TaskAvailableActionResolver actionResolver ) { this.taskService = taskService; this.actorContextProvider = actorContextProvider; + this.actionResolver = actionResolver; } @Operation(operationId = "listTasks", summary = "업무카드 목록 조회") @@ -115,7 +119,7 @@ public ResponseEntity create( .path("/{taskId}") .buildAndExpand(result.task().taskId()) .toUri(); - return ResponseEntity.created(location).body(TaskDetailResponse.from(result)); + return ResponseEntity.created(location).body(response(result)); } @Operation(operationId = "getTask", summary = "업무카드 상세 조회") @@ -126,7 +130,7 @@ public ResponseEntity create( @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") @GetMapping(path = "/{taskId}", produces = MediaType.APPLICATION_JSON_VALUE) public TaskDetailResponse findById(@PathVariable UUID taskId) { - return TaskDetailResponse.from(taskService.findById(taskId, actor())); + return response(taskService.findById(taskId, actor())); } @Operation( @@ -151,7 +155,7 @@ public TaskDetailResponse update( @Valid @RequestBody UpdateTaskRequest request, HttpServletRequest servletRequest ) { - return TaskDetailResponse.from(taskService.update( + return response(taskService.update( taskId, request.toCommand(), actor(), @@ -183,7 +187,7 @@ public TaskDetailResponse changeAssignee( @Valid @RequestBody ChangeTaskAssigneeRequest request, HttpServletRequest servletRequest ) { - return TaskDetailResponse.from(taskService.changeAssignee( + return response(taskService.changeAssignee( taskId, request.toCommand(), actor(), @@ -210,7 +214,7 @@ public TaskDetailResponse updateChecklistItem( @Valid @RequestBody UpdateChecklistItemRequest request, HttpServletRequest servletRequest ) { - return TaskDetailResponse.from(taskService.updateChecklistItem( + return response(taskService.updateChecklistItem( taskId, itemId, request.toCommand(), @@ -237,7 +241,7 @@ public TaskDetailResponse cancel( @Valid @RequestBody CancelTaskRequest request, HttpServletRequest servletRequest ) { - return TaskDetailResponse.from(taskService.cancel( + return response(taskService.cancel( taskId, request.toCommand(), actor(), @@ -248,4 +252,8 @@ public TaskDetailResponse cancel( private ActorContext actor() { return actorContextProvider.requireCurrentActor(); } + + private TaskDetailResponse response(TaskResult result) { + return TaskDetailResponse.from(result, actionResolver.resolve(result)); + } } diff --git a/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java b/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java index 2fd67f75..a1db3854 100644 --- a/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java +++ b/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fowoco.server.task.application.TaskResult; +import com.fowoco.server.task.application.action.TaskActionDecision; +import com.fowoco.server.task.application.action.TaskAvailableAction; import com.fowoco.server.task.domain.Task; import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; @@ -34,12 +36,15 @@ public record TaskDetailResponse( long version, List missingRequiredSlots, List checklistItems, + TaskAvailableAction nextAction, + List availableActions, + String blockedReason, UUID createdBy, UUID updatedBy, Instant createdAt, Instant updatedAt ) { - static TaskDetailResponse from(TaskResult result) { + static TaskDetailResponse from(TaskResult result, TaskActionDecision actionDecision) { Task task = result.task(); return new TaskDetailResponse( task.taskId(), @@ -60,6 +65,9 @@ static TaskDetailResponse from(TaskResult result) { task.version(), result.missingRequiredSlots(), result.checklistItems().stream().map(TaskChecklistItemResponse::from).toList(), + actionDecision.nextAction(), + actionDecision.availableActions(), + actionDecision.blockedReason(), task.createdBy(), task.updatedBy(), task.createdAt(), diff --git a/src/main/java/com/fowoco/server/task/application/action/TaskActionDecision.java b/src/main/java/com/fowoco/server/task/application/action/TaskActionDecision.java new file mode 100644 index 00000000..bb9f2a36 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/action/TaskActionDecision.java @@ -0,0 +1,25 @@ +package com.fowoco.server.task.application.action; + +import java.util.List; + +public record TaskActionDecision( + TaskAvailableAction nextAction, + List availableActions, + String blockedReason +) { + public TaskActionDecision { + availableActions = List.copyOf(availableActions); + } + + public static TaskActionDecision of( + TaskAvailableAction nextAction, + String blockedReason, + TaskAvailableAction... availableActions + ) { + return new TaskActionDecision(nextAction, List.of(availableActions), blockedReason); + } + + public static TaskActionDecision terminal() { + return new TaskActionDecision(null, List.of(), "TASK_TERMINAL"); + } +} diff --git a/src/main/java/com/fowoco/server/task/application/action/TaskAvailableAction.java b/src/main/java/com/fowoco/server/task/application/action/TaskAvailableAction.java new file mode 100644 index 00000000..9a905452 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/action/TaskAvailableAction.java @@ -0,0 +1,15 @@ +package com.fowoco.server.task.application.action; + +public enum TaskAvailableAction { + RUN_RENEWAL, + PROVIDE_REQUIRED_INFORMATION, + COMPLETE_CHECKLIST, + REVIEW_OCR, + REVIEW_WORKER_GUIDE, + REVIEW_GENERATED_DOCUMENT, + REQUEST_APPROVAL, + APPROVE, + ISSUE_WORKER_LINK, + REVIEW_WORKER_RESPONSE, + COMPLETE_TASK +} diff --git a/src/main/java/com/fowoco/server/task/application/action/TaskAvailableActionResolver.java b/src/main/java/com/fowoco/server/task/application/action/TaskAvailableActionResolver.java new file mode 100644 index 00000000..c4d0e20c --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/action/TaskAvailableActionResolver.java @@ -0,0 +1,185 @@ +package com.fowoco.server.task.application.action; + +import com.fowoco.server.aiintegration.application.renewal.RenewalWorkflowPolicy; +import com.fowoco.server.task.application.TaskResult; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskChecklistItem; +import com.fowoco.server.task.domain.TaskTargetType; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +public class TaskAvailableActionResolver { + + private static final String DOCUMENT_OCR = "DOCUMENT_OCR"; + private static final String USER_INPUT = "USER_INPUT"; + + public TaskActionDecision resolve(TaskResult result) { + Task task = result.task(); + if (task.status().isTerminal()) { + return TaskActionDecision.terminal(); + } + + return switch (task.status()) { + case DRAFT, NEEDS_INFO -> resolvePreparation(result); + case READY_FOR_REVIEW -> TaskActionDecision.of( + TaskAvailableAction.APPROVE, + "HR_REVIEW_REQUIRED", + TaskAvailableAction.APPROVE + ); + case APPROVED -> TaskActionDecision.of( + TaskAvailableAction.ISSUE_WORKER_LINK, + null, + TaskAvailableAction.ISSUE_WORKER_LINK + ); + case WAITING_WORKER -> TaskActionDecision.of( + TaskAvailableAction.REVIEW_WORKER_RESPONSE, + "WORKER_RESPONSE_PENDING", + TaskAvailableAction.REVIEW_WORKER_RESPONSE + ); + case WAITING_EXTERNAL -> TaskActionDecision.of( + TaskAvailableAction.COMPLETE_TASK, + "EXTERNAL_EVIDENCE_REQUIRED", + TaskAvailableAction.COMPLETE_TASK + ); + case COMPLETED, CANCELLED -> TaskActionDecision.terminal(); + }; + } + + private TaskActionDecision resolvePreparation(TaskResult result) { + Task task = result.task(); + RenewalProgress renewal = RenewalProgress.from(result.businessData()); + boolean renewalSupported = supportsRenewal(task); + + if (renewalSupported && !renewal.executed()) { + return TaskActionDecision.of( + TaskAvailableAction.RUN_RENEWAL, + null, + TaskAvailableAction.RUN_RENEWAL + ); + } + if (renewalSupported && renewal.hasMissingSource(DOCUMENT_OCR)) { + return TaskActionDecision.of( + TaskAvailableAction.REVIEW_OCR, + "OCR_REVIEW_REQUIRED", + TaskAvailableAction.REVIEW_OCR + ); + } + if (renewalSupported && renewal.hasMissingSource(USER_INPUT)) { + return TaskActionDecision.of( + TaskAvailableAction.RUN_RENEWAL, + "RENEWAL_INPUT_REQUIRED", + TaskAvailableAction.RUN_RENEWAL + ); + } + if (!result.missingRequiredSlots().isEmpty()) { + return TaskActionDecision.of( + TaskAvailableAction.PROVIDE_REQUIRED_INFORMATION, + "REQUIRED_INFORMATION_MISSING", + TaskAvailableAction.PROVIDE_REQUIRED_INFORMATION + ); + } + if (hasIncompleteRequiredChecklist(result.checklistItems())) { + return TaskActionDecision.of( + TaskAvailableAction.COMPLETE_CHECKLIST, + "CHECKLIST_INCOMPLETE", + TaskAvailableAction.COMPLETE_CHECKLIST + ); + } + if (renewal.guideReviewRequired()) { + return TaskActionDecision.of( + TaskAvailableAction.REVIEW_WORKER_GUIDE, + "WORKER_GUIDE_REVIEW_REQUIRED", + TaskAvailableAction.REVIEW_WORKER_GUIDE + ); + } + + List available = new ArrayList<>(); + if (renewal.generatedDocumentPresent()) { + available.add(TaskAvailableAction.REVIEW_GENERATED_DOCUMENT); + } + available.add(TaskAvailableAction.REQUEST_APPROVAL); + return new TaskActionDecision( + TaskAvailableAction.REQUEST_APPROVAL, + available, + "APPROVAL_REQUIRED_BEFORE_CONTINUATION" + ); + } + + private boolean supportsRenewal(Task task) { + return task.targetType() == TaskTargetType.WORKER + && RenewalWorkflowPolicy.supports(task.taskType().name(), task.workflowId()); + } + + private boolean hasIncompleteRequiredChecklist(List items) { + return items.stream().anyMatch(item -> item.required() && !item.completed()); + } + + private record RenewalProgress( + boolean executed, + Set missingSlots, + Map sourceByField, + boolean guideReviewRequired, + boolean generatedDocumentPresent + ) { + private static RenewalProgress from(Map businessData) { + Object executionValue = businessData.get("renewal_execution"); + if (!(executionValue instanceof Map execution)) { + return new RenewalProgress(false, Set.of(), Map.of(), false, false); + } + + Set missingSlots = stringSet(execution.get("missing_slots")); + Map sources = requestedFieldSources(execution.get("requested_fields")); + boolean guideReviewRequired = Boolean.TRUE.equals(execution.get("guide_review_required")); + boolean generatedDocumentPresent = execution.get("generated_documents") instanceof List documents + && !documents.isEmpty(); + return new RenewalProgress( + true, + missingSlots, + sources, + guideReviewRequired, + generatedDocumentPresent + ); + } + + private boolean hasMissingSource(String source) { + return missingSlots.stream().anyMatch(slot -> source.equals(sourceByField.get(slot))); + } + + private static Set stringSet(Object value) { + if (!(value instanceof List values)) { + return Set.of(); + } + Set result = new LinkedHashSet<>(); + values.forEach(candidate -> { + if (candidate instanceof String text && !text.isBlank()) { + result.add(text); + } + }); + return Set.copyOf(result); + } + + private static Map requestedFieldSources(Object value) { + if (!(value instanceof List fields)) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + fields.forEach(candidate -> { + if (!(candidate instanceof Map field)) { + return; + } + Object key = field.get("key"); + Object source = field.get("source_hint"); + if (key instanceof String keyText && source instanceof String sourceText) { + result.put(keyText, sourceText); + } + }); + return Map.copyOf(result); + } + } +} diff --git a/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java b/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java index 55d2f4a3..c547397b 100644 --- a/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java +++ b/src/main/java/com/fowoco/server/task/application/error/TaskErrorCode.java @@ -37,6 +37,14 @@ public enum TaskErrorCode implements ApiErrorCode { HttpStatus.UNPROCESSABLE_CONTENT, "이 업무카드에서는 재계약·연장 Agent를 실행할 수 없습니다." ), + RENEWAL_REQUEST_CONTRACT_INVALID( + HttpStatus.UNPROCESSABLE_CONTENT, + "재계약·연장 Agent 요청 정보가 현재 계약과 일치하지 않습니다." + ), + RENEWAL_WORKFLOW_MISMATCH( + HttpStatus.UNPROCESSABLE_CONTENT, + "업무카드와 Agent가 선택한 Workflow가 일치하지 않습니다." + ), INVALID_RENEWAL_SLOT_ANSWER( HttpStatus.UNPROCESSABLE_CONTENT, "재계약·연장 업무에 제출한 추가 정보를 확인해 주세요." diff --git a/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionService.java b/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionService.java index 37df5531..408c8a70 100644 --- a/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionService.java +++ b/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionService.java @@ -205,9 +205,13 @@ private RenewalExecutionResult executeMeasured( } private ApiException contractFailure(AiRuntimeFailureCode code) { - if (code == AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT - || code == AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED - || code == AiRuntimeFailureCode.UNEXPECTED_WORKFLOW) { + if (code == AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT) { + return new ApiException(TaskErrorCode.RENEWAL_REQUEST_CONTRACT_INVALID); + } + if (code == AiRuntimeFailureCode.UNEXPECTED_WORKFLOW) { + return new ApiException(TaskErrorCode.RENEWAL_WORKFLOW_MISMATCH); + } + if (code == AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED) { return new ApiException(TaskErrorCode.RENEWAL_EXECUTION_NOT_ALLOWED); } return new ApiException(TaskErrorCode.RENEWAL_RUNTIME_UNAVAILABLE); diff --git a/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java b/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java index 7afd688f..c2c95dd1 100644 --- a/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java @@ -14,6 +14,8 @@ import ch.qos.logback.core.read.ListAppender; import com.fowoco.server.aiintegration.application.document.DocumentGenerationClient; import com.fowoco.server.aiintegration.application.document.GeneratedDocumentFile; +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; import com.fowoco.server.aiintegration.application.port.RenewalRuntimeClient; import com.fowoco.server.aiintegration.application.renewal.RenewalGeneratedDocument; import com.fowoco.server.aiintegration.application.renewal.RenewalRequestedField; @@ -180,6 +182,36 @@ void sendsServerContextAndRecordsMissingInformationWithoutAutomaticApproval() th )).isEqualTo(1); } + @Test + void distinguishesAnInvalidRenewalRequestContract() throws Exception { + when(runtimeClient.run(any(), any())).thenThrow(new AiRuntimeCallException( + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, + "invalid test contract" + )); + String token = login(HR_A_EMAIL); + + HttpResponse response = postRenewal(token, 0); + + assertThat(response.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("RENEWAL_REQUEST_CONTRACT_INVALID"); + } + + @Test + void distinguishesAnUnexpectedAgentWorkflow() throws Exception { + when(runtimeClient.run(any(), any())).thenThrow(new AiRuntimeCallException( + AiRuntimeFailureCode.UNEXPECTED_WORKFLOW, + "unexpected test workflow" + )); + String token = login(HR_A_EMAIL); + + HttpResponse response = postRenewal(token, 0); + + assertThat(response.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("RENEWAL_WORKFLOW_MISMATCH"); + } + @Test void storesAnAgentWorkerMessageAsAnUnsentDraft() throws Exception { when(runtimeClient.run(any(), any())).thenAnswer(invocation -> askWorkerResponse(invocation.getArgument(0))); diff --git a/src/test/java/com/fowoco/server/task/api/TaskOpenApiContractTest.java b/src/test/java/com/fowoco/server/task/api/TaskOpenApiContractTest.java index 833635ef..3ec701fd 100644 --- a/src/test/java/com/fowoco/server/task/api/TaskOpenApiContractTest.java +++ b/src/test/java/com/fowoco/server/task/api/TaskOpenApiContractTest.java @@ -109,6 +109,9 @@ void responseAndCatalogSchemasExposePinnedVersionsInSnakeCase() { assertThat(task.has("target_type")).isTrue(); assertThat(task.has("content_revision")).isTrue(); assertThat(task.has("missing_required_slots")).isTrue(); + assertThat(task.has("next_action")).isTrue(); + assertThat(task.has("available_actions")).isTrue(); + assertThat(task.has("blocked_reason")).isTrue(); assertThat(workflow.has("supported_task_types")).isTrue(); assertThat(workflow.has("source_ids")).isTrue(); } diff --git a/src/test/java/com/fowoco/server/task/application/action/TaskAvailableActionResolverTest.java b/src/test/java/com/fowoco/server/task/application/action/TaskAvailableActionResolverTest.java new file mode 100644 index 00000000..cab1ea99 --- /dev/null +++ b/src/test/java/com/fowoco/server/task/application/action/TaskAvailableActionResolverTest.java @@ -0,0 +1,179 @@ +package com.fowoco.server.task.application.action; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.task.application.TaskAssigneeView; +import com.fowoco.server.task.application.TaskResult; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskChecklistItem; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class TaskAvailableActionResolverTest { + + private static final UUID TASK_ID = UUID.fromString("20000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_ID = UUID.fromString("30000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_ID = UUID.fromString("40000000-0000-0000-0000-000000000001"); + private static final UUID CASE_ID = UUID.fromString("50000000-0000-0000-0000-000000000001"); + private static final UUID ACTOR_ID = UUID.fromString("60000000-0000-0000-0000-000000000001"); + private static final Instant NOW = Instant.parse("2026-08-20T00:00:00Z"); + + private final TaskAvailableActionResolver resolver = new TaskAvailableActionResolver(); + + @Test + void freshSupportedRenewalCanRunAgent() { + TaskActionDecision decision = resolver.resolve(result( + TaskStatus.DRAFT, + Map.of(), + List.of(completedChecklist()), + List.of() + )); + + assertThat(decision.nextAction()).isEqualTo(TaskAvailableAction.RUN_RENEWAL); + assertThat(decision.availableActions()).containsExactly(TaskAvailableAction.RUN_RENEWAL); + } + + @Test + void ocrMissingFieldMustBeReviewedBeforeManualRenewal() { + TaskActionDecision decision = resolver.resolve(result( + TaskStatus.NEEDS_INFO, + renewalExecution( + List.of("passport_number"), + List.of(Map.of("key", "passport_number", "source_hint", "DOCUMENT_OCR")), + List.of() + ), + List.of(completedChecklist()), + List.of() + )); + + assertThat(decision.nextAction()).isEqualTo(TaskAvailableAction.REVIEW_OCR); + assertThat(decision.availableActions()).doesNotContain(TaskAvailableAction.RUN_RENEWAL); + assertThat(decision.blockedReason()).isEqualTo("OCR_REVIEW_REQUIRED"); + } + + @Test + void completedRenewalPreparationRequiresApprovalInsteadOfAnotherRun() { + TaskActionDecision decision = resolver.resolve(result( + TaskStatus.DRAFT, + renewalExecution( + List.of(), + List.of(), + List.of(Map.of("stored_file_id", UUID.randomUUID().toString())) + ), + List.of(completedChecklist()), + List.of() + )); + + assertThat(decision.nextAction()).isEqualTo(TaskAvailableAction.REQUEST_APPROVAL); + assertThat(decision.availableActions()).containsExactly( + TaskAvailableAction.REVIEW_GENERATED_DOCUMENT, + TaskAvailableAction.REQUEST_APPROVAL + ); + assertThat(decision.availableActions()).doesNotContain(TaskAvailableAction.RUN_RENEWAL); + } + + @Test + void incompleteChecklistBlocksApproval() { + TaskActionDecision decision = resolver.resolve(result( + TaskStatus.NEEDS_INFO, + renewalExecution(List.of(), List.of(), List.of()), + List.of(incompleteChecklist()), + List.of() + )); + + assertThat(decision.nextAction()).isEqualTo(TaskAvailableAction.COMPLETE_CHECKLIST); + assertThat(decision.blockedReason()).isEqualTo("CHECKLIST_INCOMPLETE"); + } + + @Test + void terminalTaskHasNoAvailableAction() { + TaskActionDecision decision = resolver.resolve(result( + TaskStatus.COMPLETED, + renewalExecution(List.of(), List.of(), List.of()), + List.of(completedChecklist()), + List.of() + )); + + assertThat(decision.nextAction()).isNull(); + assertThat(decision.availableActions()).isEmpty(); + assertThat(decision.blockedReason()).isEqualTo("TASK_TERMINAL"); + } + + private TaskResult result( + TaskStatus status, + Map businessData, + List checklist, + List missingRequiredSlots + ) { + return new TaskResult( + task(status), + new TaskAssigneeView(ACTOR_ID, "테스트 담당자"), + businessData, + checklist, + missingRequiredSlots + ); + } + + private Task task(TaskStatus status) { + return new Task( + TASK_ID, + COMPANY_ID, + WORKER_ID, + CASE_ID, + TaskType.RECONTRACT, + "WF-CON-001", + "0.3.0", + "재계약 준비", + null, + "{}", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 0, + TaskSource.AI_CANDIDATE, + status, + LocalDate.of(2026, 8, 31), + ACTOR_ID, + ACTOR_ID, + NOW, + NOW, + 0 + ); + } + + private TaskChecklistItem incompleteChecklist() { + return TaskChecklistItem.create( + UUID.fromString("70000000-0000-0000-0000-000000000001"), + TASK_ID, + COMPANY_ID, + "CHECK_CONTRACT", + "계약 조건 확인", + true, + NOW + ); + } + + private TaskChecklistItem completedChecklist() { + TaskChecklistItem item = incompleteChecklist(); + item.updateCompletion(true, 0, ACTOR_ID, NOW.plusSeconds(1)); + return item; + } + + private Map renewalExecution( + List missingSlots, + List> requestedFields, + List> generatedDocuments + ) { + return Map.of("renewal_execution", Map.of( + "missing_slots", missingSlots, + "requested_fields", requestedFields, + "guide_review_required", false, + "generated_documents", generatedDocuments + )); + } +}