diff --git a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java index df6d13da..ccf6920c 100644 --- a/src/main/java/com/fowoco/server/approval/application/ApprovalService.java +++ b/src/main/java/com/fowoco/server/approval/application/ApprovalService.java @@ -433,7 +433,11 @@ public Task replaceReviewAfterCriticalChange( ); Map hrSnapshot = new LinkedHashMap<>(); - hrSnapshot.put("worker_id", savedTask.workerId().toString()); + hrSnapshot.put( + "worker_id", + savedTask.workerId() == null ? null : savedTask.workerId().toString() + ); + hrSnapshot.put("target_type", savedTask.targetType().name()); hrSnapshot.put("task_type", savedTask.taskType().name()); hrSnapshot.put("workflow_id", savedTask.workflowId()); hrSnapshot.put("title", savedTask.title()); diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditSeedCatalog.java index 03c70dfa..57ae0425 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditSeedCatalog.java @@ -239,7 +239,8 @@ private static ActorType actorType(TaskSource source) { return switch (source) { case AI_CANDIDATE -> ActorType.AI_AGENT; case SYSTEM_DDAY -> ActorType.SYSTEM_RULE; - case MANUAL -> ActorType.HR_USER; + case MANUAL, FILE_IMPORT -> ActorType.HR_USER; + case WORKER_RESPONSE -> ActorType.WORKER_LINK; }; } diff --git a/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java b/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java index 6879b686..b4463596 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentReadinessService.java @@ -55,10 +55,15 @@ public DocumentReadinessResult calculate(UUID taskId, ActorContext actor) { Set requiredTypes = requiredDocumentTypes(taskId, companyId); LocalDate today = LocalDate.now(clock); - WorkerDocumentSearchQuery allDocumentsQuery = new WorkerDocumentSearchQuery( - task.workerId(), null, null, null, null, 0, 100 - ); - List workerDocuments = workerDocumentRepository.findPage(companyId, allDocumentsQuery); + List workerDocuments; + if (task.workerId() == null) { + workerDocuments = List.of(); + } else { + WorkerDocumentSearchQuery allDocumentsQuery = new WorkerDocumentSearchQuery( + task.workerId(), null, null, null, null, 0, 100 + ); + workerDocuments = workerDocumentRepository.findPage(companyId, allDocumentsQuery); + } Set available = EnumSet.noneOf(DocumentType.class); Set expired = EnumSet.noneOf(DocumentType.class); diff --git a/src/main/java/com/fowoco/server/task/api/CreateTaskRequest.java b/src/main/java/com/fowoco/server/task/api/CreateTaskRequest.java index beb00d55..53ccf3e1 100644 --- a/src/main/java/com/fowoco/server/task/api/CreateTaskRequest.java +++ b/src/main/java/com/fowoco/server/task/api/CreateTaskRequest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import com.fowoco.server.task.application.CreateTaskCommand; +import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -13,7 +14,8 @@ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record CreateTaskRequest( - @NotNull UUID workerId, + TaskTargetType targetType, + UUID workerId, UUID caseId, @NotNull TaskType taskType, @NotBlank @Size(max = 100) String workflowId, @@ -24,6 +26,9 @@ public record CreateTaskRequest( ) { CreateTaskCommand toCommand() { return new CreateTaskCommand( + targetType == null + ? (workerId == null ? TaskTargetType.COMPANY : TaskTargetType.WORKER) + : targetType, workerId, caseId, taskType, 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 723bd976..b2083530 100644 --- a/src/main/java/com/fowoco/server/task/api/TaskController.java +++ b/src/main/java/com/fowoco/server/task/api/TaskController.java @@ -5,7 +5,9 @@ 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.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -62,6 +64,8 @@ public TaskController( public TaskPageResponse findAll( @RequestParam(required = false) TaskStatus status, @RequestParam(required = false) TaskType taskType, + @RequestParam(name = "target_type", required = false) TaskTargetType targetType, + @RequestParam(required = false) TaskSource source, @RequestParam(required = false) UUID workerId, @Parameter(description = "Case ID 필터") @RequestParam(name = "case_id", required = false) UUID caseId, @@ -74,6 +78,8 @@ public TaskPageResponse findAll( return TaskPageResponse.from(taskService.findAll( status, taskType, + targetType, + source, workerId, caseId, dueFrom, 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 02dc7985..b05e250a 100644 --- a/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java +++ b/src/main/java/com/fowoco/server/task/api/TaskDetailResponse.java @@ -6,6 +6,7 @@ import com.fowoco.server.task.domain.Task; import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import java.time.Instant; import java.time.LocalDate; @@ -16,6 +17,7 @@ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record TaskDetailResponse( UUID taskId, + TaskTargetType targetType, UUID workerId, UUID caseId, TaskType taskType, @@ -40,6 +42,7 @@ static TaskDetailResponse from(TaskResult result) { Task task = result.task(); return new TaskDetailResponse( task.taskId(), + task.targetType(), task.workerId(), task.caseId(), task.taskType(), diff --git a/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java b/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java index 86b88cfc..c17e3d90 100644 --- a/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java +++ b/src/main/java/com/fowoco/server/task/api/TaskSummaryResponse.java @@ -5,6 +5,7 @@ import com.fowoco.server.task.domain.Task; import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import java.time.Instant; import java.time.LocalDate; @@ -13,6 +14,7 @@ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record TaskSummaryResponse( UUID taskId, + TaskTargetType targetType, UUID workerId, UUID caseId, TaskType taskType, @@ -30,6 +32,7 @@ public record TaskSummaryResponse( static TaskSummaryResponse from(Task task) { return new TaskSummaryResponse( task.taskId(), + task.targetType(), task.workerId(), task.caseId(), task.taskType(), diff --git a/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java b/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java index 4ae79c2e..32569674 100644 --- a/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java +++ b/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java @@ -311,6 +311,7 @@ private int order(TaskType taskType) { case RECONTRACT -> 1; case STAY_PERIOD_EXTENSION -> 2; case EMPLOYMENT_PERIOD_EXTENSION -> 3; + default -> throw new ApiException(TaskErrorCode.INVALID_AI_CANDIDATE_TASK_DATA); }; } @@ -319,6 +320,7 @@ private String title(TaskType taskType) { case RECONTRACT -> "재계약 조건 확인"; case STAY_PERIOD_EXTENSION -> "체류기간 연장 준비"; case EMPLOYMENT_PERIOD_EXTENSION -> "취업활동기간 연장 준비"; + default -> throw new ApiException(TaskErrorCode.INVALID_AI_CANDIDATE_TASK_DATA); }; } @@ -327,6 +329,7 @@ private String description(TaskType taskType) { case RECONTRACT -> "재계약 조건과 계속 고용 의사를 검토합니다."; case STAY_PERIOD_EXTENSION -> "체류기간 연장에 필요한 정보와 서류를 확인합니다."; case EMPLOYMENT_PERIOD_EXTENSION -> "재계약 결과를 바탕으로 취업활동기간 연장을 준비합니다."; + default -> throw new ApiException(TaskErrorCode.INVALID_AI_CANDIDATE_TASK_DATA); }; } diff --git a/src/main/java/com/fowoco/server/task/application/CreateTaskCommand.java b/src/main/java/com/fowoco/server/task/application/CreateTaskCommand.java index 921a5299..3fd83f49 100644 --- a/src/main/java/com/fowoco/server/task/application/CreateTaskCommand.java +++ b/src/main/java/com/fowoco/server/task/application/CreateTaskCommand.java @@ -1,11 +1,13 @@ package com.fowoco.server.task.application; +import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import java.time.LocalDate; import java.util.Map; import java.util.UUID; public record CreateTaskCommand( + TaskTargetType targetType, UUID workerId, UUID caseId, TaskType taskType, diff --git a/src/main/java/com/fowoco/server/task/application/TaskContentCodec.java b/src/main/java/com/fowoco/server/task/application/TaskContentCodec.java index 898517bc..ebe6371d 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskContentCodec.java +++ b/src/main/java/com/fowoco/server/task/application/TaskContentCodec.java @@ -2,6 +2,7 @@ import com.fowoco.server.common.error.ApiException; import com.fowoco.server.task.application.error.TaskErrorCode; +import com.fowoco.server.task.domain.TaskTargetType; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -63,6 +64,7 @@ public TaskContentCodec(ObjectMapper objectMapper) { } public EncodedTaskContent encode( + TaskTargetType targetType, UUID workerId, String workflowId, String taskType, @@ -77,8 +79,9 @@ public EncodedTaskContent encode( approvalTarget.put("description", normalizeNullableText(description, 2000)); approvalTarget.put("due_date", dueDate == null ? null : dueDate.toString()); approvalTarget.put("task_type", normalizeText(taskType, 40)); + approvalTarget.put("target_type", targetType.name()); approvalTarget.put("title", normalizeText(title, 160)); - approvalTarget.put("worker_id", workerId.toString()); + approvalTarget.put("worker_id", workerId == null ? null : workerId.toString()); approvalTarget.put("workflow_id", normalizeText(workflowId, 100)); try { String businessDataJson = objectMapper.writeValueAsString(normalizedBusinessData); @@ -89,6 +92,27 @@ public EncodedTaskContent encode( } } + public EncodedTaskContent encode( + UUID workerId, + String workflowId, + String taskType, + String title, + String description, + LocalDate dueDate, + Map businessData + ) { + return encode( + TaskTargetType.WORKER, + workerId, + workflowId, + taskType, + title, + description, + dueDate, + businessData + ); + } + @SuppressWarnings("unchecked") public Map decodeBusinessData(String businessDataJson) { try { diff --git a/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java b/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java index bed35159..aa5bf74a 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java +++ b/src/main/java/com/fowoco/server/task/application/TaskDomainEvents.java @@ -18,6 +18,7 @@ final class TaskDomainEvents { private static final String AGGREGATE_TYPE = "Task"; private static final Set TASK_CREATED_FIELDS = Set.of( "task_type", + "target_type", "status", "workflow_id", "workflow_catalog_version", @@ -49,6 +50,7 @@ static DomainEventEnvelope taskCreated( TASK_CREATED_FIELDS, Map.of( "task_type", task.taskType(), + "target_type", task.targetType(), "status", task.status(), "workflow_id", task.workflowId(), "workflow_catalog_version", task.workflowCatalogVersion(), 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 8574c70a..30824324 100644 --- a/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java +++ b/src/main/java/com/fowoco/server/task/application/TaskWorkflowService.java @@ -26,6 +26,7 @@ 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.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import com.fowoco.server.worker.application.WorkerTaskContext; import com.fowoco.server.worker.application.port.WorkerTaskContextReader; @@ -105,10 +106,7 @@ public TaskResult create( if (!workflow.supportedTaskTypes().contains(command.taskType())) { throw new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH); } - WorkerTaskContext worker = requireWorker(command.workerId(), actor.companyId()); - if (!worker.canReceiveNewTask()) { - throw new ApiException(TaskErrorCode.WORKER_NOT_ELIGIBLE); - } + WorkerTaskContext worker = resolveCreateTarget(command, workflow, actor.companyId()); Map businessData = command.businessData() == null ? Map.of() : command.businessData(); List missingSlots = missingRequiredSlots( @@ -118,6 +116,7 @@ public TaskResult create( businessData ); EncodedTaskContent content = contentCodec.encode( + command.targetType(), command.workerId(), workflow.workflowId(), command.taskType().name(), @@ -128,10 +127,13 @@ public TaskResult create( ); Instant now = Instant.now(clock); UUID taskId = uuidGenerator.generate(); - UUID caseId = command.caseId() == null ? uuidGenerator.generate() : command.caseId(); + UUID caseId = command.targetType() == TaskTargetType.WORKER + ? (command.caseId() == null ? uuidGenerator.generate() : command.caseId()) + : null; Task task = Task.create( taskId, actor.companyId(), + command.targetType(), command.workerId(), caseId, command.taskType(), @@ -147,7 +149,9 @@ public TaskResult create( actor.actorId(), now ); - taskCaseRegistrar.register(task, workflow, LocalDate.now(clock)); + if (task.targetType() == TaskTargetType.WORKER) { + taskCaseRegistrar.register(task, workflow, LocalDate.now(clock)); + } Task savedTask = taskRepository.save(task); List checklistItems = checklistRepository.saveAll( workflow.checklistItems().stream() @@ -184,6 +188,8 @@ public TaskResult create( public TaskPageResult findAll( TaskStatus status, TaskType taskType, + TaskTargetType targetType, + TaskSource source, UUID workerId, UUID caseId, LocalDate dueFrom, @@ -202,6 +208,8 @@ public TaskPageResult findAll( actor.companyId(), status, taskType, + targetType, + source, workerId, caseId, dueFrom, @@ -227,7 +235,7 @@ public TaskResult findById(UUID taskId, ActorContext actor) { return toResult( task, checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()), - requireWorker(task.workerId(), actor.companyId()), + findWorker(task, actor.companyId()), catalogService.requireWorkflow(task.workflowId()) ); } @@ -243,7 +251,7 @@ public TaskResult update( actorAuthorizer.requireHrWrite(actor); Task task = requireTask(taskId, actor.companyId()); WorkflowDefinition workflow = catalogService.requireWorkflow(task.workflowId()); - WorkerTaskContext worker = requireWorker(task.workerId(), actor.companyId()); + WorkerTaskContext worker = findWorker(task, actor.companyId()); Map businessData = command.businessData() == null ? Map.of() : command.businessData(); List missingSlots = missingRequiredSlots( @@ -257,6 +265,7 @@ public TaskResult update( boolean checklistSatisfied = checklistItems.stream() .noneMatch(item -> item.required() && !item.completed()); EncodedTaskContent content = contentCodec.encode( + task.targetType(), task.workerId(), task.workflowId(), task.taskType().name(), @@ -350,7 +359,7 @@ public TaskResult updateChecklistItem( } List items = checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()); - WorkerTaskContext worker = requireWorker(task.workerId(), actor.companyId()); + WorkerTaskContext worker = findWorker(task, actor.companyId()); WorkflowDefinition workflow = catalogService.requireWorkflow(task.workflowId()); Map businessData = contentCodec.decodeBusinessData(task.businessDataJson()); boolean slotsSatisfied = missingRequiredSlots( @@ -447,7 +456,7 @@ public TaskResult cancel( return toResult( savedTask, checklistRepository.findAllByTaskIdAndCompanyId(taskId, actor.companyId()), - requireWorker(task.workerId(), actor.companyId()), + findWorker(task, actor.companyId()), catalogService.requireWorkflow(task.workflowId()) ); } @@ -476,11 +485,11 @@ private List missingRequiredSlots( List missing = new ArrayList<>(); workflow.requiredSlots().stream().sorted().forEach(slot -> { boolean present = switch (slot) { - case "worker_id" -> worker.workerId() != null; + case "worker_id" -> worker != null && worker.workerId() != null; case "due_at", "due_date" -> dueDate != null; - case "contract_start_date" -> worker.contractStartDate() != null; - case "contract_end_date" -> worker.contractEndDate() != null; - case "stay_expiry_date" -> worker.stayExpiryDate() != null; + case "contract_start_date" -> worker != null && worker.contractStartDate() != null; + case "contract_end_date" -> worker != null && worker.contractEndDate() != null; + case "stay_expiry_date" -> worker != null && worker.stayExpiryDate() != null; default -> hasBusinessValue(businessData.get(slot)); }; if (!present) { @@ -502,6 +511,35 @@ private WorkerTaskContext requireWorker(UUID workerId, UUID companyId) { .orElseThrow(() -> new ApiException(TaskErrorCode.WORKER_NOT_FOUND)); } + private WorkerTaskContext findWorker(Task task, UUID companyId) { + return task.targetType() == TaskTargetType.WORKER + ? requireWorker(task.workerId(), companyId) + : null; + } + + private WorkerTaskContext resolveCreateTarget( + CreateTaskCommand command, + WorkflowDefinition workflow, + UUID companyId + ) { + if (command.targetType() == TaskTargetType.COMPANY) { + if (command.workerId() != null + || command.caseId() != null + || workflow.requiredSlots().contains("worker_id")) { + throw new ApiException(TaskErrorCode.INVALID_TASK_TARGET); + } + return null; + } + if (command.workerId() == null) { + throw new ApiException(TaskErrorCode.INVALID_TASK_TARGET); + } + WorkerTaskContext worker = requireWorker(command.workerId(), companyId); + if (!worker.canReceiveNewTask()) { + throw new ApiException(TaskErrorCode.WORKER_NOT_ELIGIBLE); + } + return worker; + } + private Task requireTask(UUID taskId, UUID companyId) { return taskRepository.findByIdAndCompanyId(taskId, companyId) .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); 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 f6915feb..69392c08 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 @@ -11,6 +11,10 @@ public enum TaskErrorCode implements ApiErrorCode { HttpStatus.UNPROCESSABLE_CONTENT, "선택한 Workflow에서 지원하지 않는 업무 유형입니다." ), + INVALID_TASK_TARGET( + HttpStatus.UNPROCESSABLE_CONTENT, + "업무 대상과 근로자·Case 정보의 조합을 확인해 주세요." + ), INVALID_TASK_FILTER(HttpStatus.BAD_REQUEST, "업무카드 조회 조건을 확인해 주세요."), WORKER_NOT_ELIGIBLE( HttpStatus.UNPROCESSABLE_CONTENT, diff --git a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java index c74a7d97..20e4b644 100644 --- a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java +++ b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java @@ -1,7 +1,9 @@ package com.fowoco.server.task.application.port; import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import java.time.LocalDate; import java.util.List; @@ -20,6 +22,8 @@ record TaskSearchCriteria( UUID companyId, TaskStatus status, TaskType taskType, + TaskTargetType targetType, + TaskSource source, UUID workerId, UUID caseId, LocalDate dueFrom, diff --git a/src/main/java/com/fowoco/server/task/domain/Task.java b/src/main/java/com/fowoco/server/task/domain/Task.java index b180a943..89efbbb6 100644 --- a/src/main/java/com/fowoco/server/task/domain/Task.java +++ b/src/main/java/com/fowoco/server/task/domain/Task.java @@ -11,6 +11,7 @@ public final class Task { private final UUID taskId; private final UUID companyId; + private final TaskTargetType targetType; private final UUID workerId; private final UUID caseId; private final TaskType taskType; @@ -33,6 +34,7 @@ public final class Task { public Task( UUID taskId, UUID companyId, + TaskTargetType targetType, UUID workerId, UUID caseId, TaskType taskType, @@ -54,8 +56,10 @@ public Task( ) { this.taskId = Objects.requireNonNull(taskId); this.companyId = Objects.requireNonNull(companyId); - this.workerId = Objects.requireNonNull(workerId); - this.caseId = Objects.requireNonNull(caseId); + this.targetType = Objects.requireNonNull(targetType); + this.workerId = workerId; + this.caseId = caseId; + requireTarget(targetType, workerId, caseId); this.taskType = Objects.requireNonNull(taskType); this.workflowId = requireText(workflowId, "workflowId"); this.workflowCatalogVersion = requireText(workflowCatalogVersion, "workflowCatalogVersion"); @@ -77,9 +81,57 @@ public Task( this.version = version; } + public Task( + UUID taskId, + UUID companyId, + UUID workerId, + UUID caseId, + TaskType taskType, + String workflowId, + String workflowCatalogVersion, + String title, + String description, + String businessDataJson, + String criticalFingerprint, + long contentRevision, + TaskSource source, + TaskStatus status, + LocalDate dueDate, + UUID createdBy, + UUID updatedBy, + Instant createdAt, + Instant updatedAt, + long version + ) { + this( + taskId, + companyId, + TaskTargetType.WORKER, + workerId, + caseId, + taskType, + workflowId, + workflowCatalogVersion, + title, + description, + businessDataJson, + criticalFingerprint, + contentRevision, + source, + status, + dueDate, + createdBy, + updatedBy, + createdAt, + updatedAt, + version + ); + } + public static Task create( UUID taskId, UUID companyId, + TaskTargetType targetType, UUID workerId, UUID caseId, TaskType taskType, @@ -101,6 +153,7 @@ public static Task create( return new Task( taskId, companyId, + targetType, workerId, caseId, taskType, @@ -122,6 +175,45 @@ public static Task create( ); } + public static Task create( + UUID taskId, + UUID companyId, + UUID workerId, + UUID caseId, + TaskType taskType, + String workflowId, + String workflowCatalogVersion, + String title, + String description, + String businessDataJson, + String criticalFingerprint, + TaskSource source, + TaskStatus initialStatus, + LocalDate dueDate, + UUID actorId, + Instant now + ) { + return create( + taskId, + companyId, + TaskTargetType.WORKER, + workerId, + caseId, + taskType, + workflowId, + workflowCatalogVersion, + title, + description, + businessDataJson, + criticalFingerprint, + source, + initialStatus, + dueDate, + actorId, + now + ); + } + public TaskStatus requestReview(boolean requirementsSatisfied, long expectedVersion, UUID actorId, Instant now) { requireVersion(expectedVersion); if (status != TaskStatus.DRAFT && status != TaskStatus.NEEDS_INFO) { @@ -299,6 +391,20 @@ private static String requireFingerprint(String value) { return fingerprint; } + private static void requireTarget( + TaskTargetType targetType, + UUID workerId, + UUID caseId + ) { + boolean valid = switch (targetType) { + case WORKER -> workerId != null && caseId != null; + case COMPANY -> workerId == null && caseId == null; + }; + if (!valid) { + throw new ApiException(TaskErrorCode.INVALID_TASK_TARGET); + } + } + public UUID taskId() { return taskId; } @@ -307,6 +413,10 @@ public UUID companyId() { return companyId; } + public TaskTargetType targetType() { + return targetType; + } + public UUID workerId() { return workerId; } diff --git a/src/main/java/com/fowoco/server/task/domain/TaskSource.java b/src/main/java/com/fowoco/server/task/domain/TaskSource.java index 613550a0..bc3bfbc2 100644 --- a/src/main/java/com/fowoco/server/task/domain/TaskSource.java +++ b/src/main/java/com/fowoco/server/task/domain/TaskSource.java @@ -3,5 +3,7 @@ public enum TaskSource { MANUAL, SYSTEM_DDAY, - AI_CANDIDATE + AI_CANDIDATE, + FILE_IMPORT, + WORKER_RESPONSE } diff --git a/src/main/java/com/fowoco/server/task/domain/TaskTargetType.java b/src/main/java/com/fowoco/server/task/domain/TaskTargetType.java new file mode 100644 index 00000000..e1579c86 --- /dev/null +++ b/src/main/java/com/fowoco/server/task/domain/TaskTargetType.java @@ -0,0 +1,6 @@ +package com.fowoco.server.task.domain; + +public enum TaskTargetType { + WORKER, + COMPANY +} diff --git a/src/main/java/com/fowoco/server/task/domain/TaskType.java b/src/main/java/com/fowoco/server/task/domain/TaskType.java index 34c11a09..17059cef 100644 --- a/src/main/java/com/fowoco/server/task/domain/TaskType.java +++ b/src/main/java/com/fowoco/server/task/domain/TaskType.java @@ -3,5 +3,10 @@ public enum TaskType { RECONTRACT, EMPLOYMENT_PERIOD_EXTENSION, - STAY_PERIOD_EXTENSION + STAY_PERIOD_EXTENSION, + DOCUMENT_REQUEST, + WORKER_ONBOARDING, + PAYROLL_EXPLANATION, + EMPLOYMENT_CHANGE, + WORK_INSTRUCTION } diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java index 663b16e9..da7c0b09 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java @@ -29,6 +29,8 @@ public TaskPage findAll(TaskSearchCriteria criteria) { criteria.companyId(), criteria.status(), criteria.taskType(), + criteria.targetType(), + criteria.source(), criteria.workerId(), criteria.caseId(), criteria.dueFrom(), diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java index b9b8bdae..f99a8255 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java @@ -3,6 +3,8 @@ import java.util.Optional; import java.util.UUID; import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import java.time.LocalDate; import org.springframework.data.domain.Page; @@ -21,6 +23,8 @@ interface SpringDataTaskJpaRepository extends JpaRepository WHERE task.companyId = :companyId AND (:status IS NULL OR task.status = :status) AND (:taskType IS NULL OR task.taskType = :taskType) + AND (:targetType IS NULL OR task.targetType = :targetType) + AND (:source IS NULL OR task.source = :source) AND (:workerId IS NULL OR task.workerId = :workerId) AND (:caseId IS NULL OR task.caseId = :caseId) AND (:dueFrom IS NULL OR task.dueDate >= :dueFrom) @@ -36,6 +40,8 @@ Page search( @Param("companyId") UUID companyId, @Param("status") TaskStatus status, @Param("taskType") TaskType taskType, + @Param("targetType") TaskTargetType targetType, + @Param("source") TaskSource source, @Param("workerId") UUID workerId, @Param("caseId") UUID caseId, @Param("dueFrom") LocalDate dueFrom, diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskJpaEntity.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskJpaEntity.java index c8d4b67f..560c69e3 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskJpaEntity.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/TaskJpaEntity.java @@ -3,6 +3,7 @@ import com.fowoco.server.task.domain.Task; import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskTargetType; import com.fowoco.server.task.domain.TaskType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -31,9 +32,12 @@ public class TaskJpaEntity { private UUID taskId; @Column(name = "company_id", nullable = false, updatable = false) private UUID companyId; - @Column(name = "worker_id", nullable = false, updatable = false) + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 20, updatable = false) + private TaskTargetType targetType; + @Column(name = "worker_id", updatable = false) private UUID workerId; - @Column(name = "case_id", nullable = false, updatable = false) + @Column(name = "case_id", updatable = false) private UUID caseId; @Enumerated(EnumType.STRING) @Column(name = "task_type", nullable = false, length = 40, updatable = false) @@ -95,6 +99,7 @@ public void apply(Task task) { private void copyFrom(Task task) { this.taskId = task.taskId(); this.companyId = task.companyId(); + this.targetType = task.targetType(); this.workerId = task.workerId(); this.caseId = task.caseId(); this.taskType = task.taskType(); @@ -118,6 +123,7 @@ public Task toDomain() { return new Task( taskId, companyId, + targetType, workerId, caseId, taskType, diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java index 22fb2d10..3feb842e 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentService.java @@ -23,6 +23,7 @@ import java.time.Clock; import java.time.Instant; import java.util.Comparator; +import java.util.Objects; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -167,7 +168,7 @@ private void validateTaskAssignment(WorkerDocumentCreateCommand command, UUID co } Task task = taskRepository.findByIdAndCompanyId(command.taskId(), companyId) .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); - if (!task.workerId().equals(command.workerId())) { + if (!Objects.equals(task.workerId(), command.workerId())) { throw new ApiException(WorkerErrorCode.WORKER_DOCUMENT_TASK_WORKER_MISMATCH); } } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java index 8c2a3073..b67967a3 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkService.java @@ -60,6 +60,9 @@ public WorkerLinkIssueResult issue(WorkerLinkIssueCommand command, ActorContext Task task = taskRepository.findByIdAndCompanyId(command.taskId(), actor.companyId()) .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.TASK_NOT_FOUND)); + if (task.workerId() == null) { + throw new ApiException(WorkerLinkErrorCode.TASK_WORKER_TARGET_REQUIRED); + } ApprovalRequest approval = approvalRequestRepository .findLatestApprovedByTaskIdAndCompanyId(command.taskId(), actor.companyId()) diff --git a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java index 7bd9eb8d..0f2cda34 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java @@ -9,6 +9,10 @@ public enum WorkerLinkErrorCode implements ApiErrorCode { HttpStatus.UNPROCESSABLE_CONTENT, "승인된 업무카드에서만 근로자 링크를 발급할 수 있습니다." ), + TASK_WORKER_TARGET_REQUIRED( + HttpStatus.UNPROCESSABLE_CONTENT, + "근로자 대상 업무카드에서만 근로자 링크를 발급할 수 있습니다." + ), WORKER_LINK_ISSUANCE_CONFLICT( HttpStatus.CONFLICT, "이미 유효한 근로자 링크가 있습니다. rotateExisting=true로 재발급해 주세요." diff --git a/src/main/resources/db/migration/V28__extend_task_types_and_targets.sql b/src/main/resources/db/migration/V28__extend_task_types_and_targets.sql new file mode 100644 index 00000000..d4b9266a --- /dev/null +++ b/src/main/resources/db/migration/V28__extend_task_types_and_targets.sql @@ -0,0 +1,48 @@ +ALTER TABLE task + ADD COLUMN target_type VARCHAR(20) NOT NULL DEFAULT 'WORKER'; + +ALTER TABLE task + ALTER COLUMN worker_id DROP NOT NULL; + +ALTER TABLE task + ALTER COLUMN case_id DROP NOT NULL; + +ALTER TABLE task + DROP CONSTRAINT ck_task_type; + +ALTER TABLE task + ADD CONSTRAINT ck_task_type CHECK ( + task_type IN ( + 'RECONTRACT', + 'EMPLOYMENT_PERIOD_EXTENSION', + 'STAY_PERIOD_EXTENSION', + 'DOCUMENT_REQUEST', + 'WORKER_ONBOARDING', + 'PAYROLL_EXPLANATION', + 'EMPLOYMENT_CHANGE', + 'WORK_INSTRUCTION' + ) + ); + +ALTER TABLE task + DROP CONSTRAINT ck_task_source; + +ALTER TABLE task + ADD CONSTRAINT ck_task_source CHECK ( + source IN ( + 'MANUAL', + 'SYSTEM_DDAY', + 'AI_CANDIDATE', + 'FILE_IMPORT', + 'WORKER_RESPONSE' + ) + ); + +ALTER TABLE task + ADD CONSTRAINT ck_task_target CHECK ( + (target_type = 'WORKER' AND worker_id IS NOT NULL AND case_id IS NOT NULL) + OR (target_type = 'COMPANY' AND worker_id IS NULL AND case_id IS NULL) + ); + +CREATE INDEX idx_task_company_target_source + ON task (company_id, target_type, source, created_at DESC); diff --git a/src/main/resources/workflow/catalog-projection.local.json b/src/main/resources/workflow/catalog-projection.local.json index e3b50d7a..c1431f39 100644 --- a/src/main/resources/workflow/catalog-projection.local.json +++ b/src/main/resources/workflow/catalog-projection.local.json @@ -5,6 +5,71 @@ "source_repository": "fowoco/knowledge", "generated_at": "2026-07-23T00:00:00Z", "workflows": [ + { + "workflow_id": "WF-WRK-001", + "name": "문서 기반 근로자 등록 초안", + "intent": "WORKER_ONBOARDING", + "sensitivity": "high", + "supported_task_types": ["WORKER_ONBOARDING"], + "required_slots": ["source_document_id"], + "allowed_slot_keys": ["source_document_id"], + "resolvable_slot_keys": [], + "checklist_items": [], + "completion_evidence": ["hr_approval_id", "worker_id"], + "source_ids": ["SRC-MOEL-CONTRACT-2026", "SRC-EPS-FORMS"] + }, + { + "workflow_id": "WF-DOC-001", + "name": "근로자 서류 요청 및 제출 추적", + "intent": "DOCUMENT_REQUEST", + "sensitivity": "medium", + "supported_task_types": ["DOCUMENT_REQUEST"], + "required_slots": ["worker_id", "document_type", "due_at", "submission_channel"], + "allowed_slot_keys": ["worker_id", "document_type", "due_at", "submission_channel"], + "resolvable_slot_keys": ["worker_id"], + "checklist_items": [], + "completion_evidence": ["approved_notice_id", "verified_document_id"], + "source_ids": ["SRC-KEIS-REQUIRED-DOCS", "SRC-EPS-LANGUAGE"] + }, + { + "workflow_id": "WF-PAY-001", + "name": "급여명세서 차이 설명", + "intent": "PAYROLL_EXPLANATION", + "sensitivity": "high", + "supported_task_types": ["PAYROLL_EXPLANATION"], + "required_slots": ["worker_id", "pay_period", "source_document_id"], + "allowed_slot_keys": ["worker_id", "pay_period", "source_document_id", "comparison_period", "pay_item"], + "resolvable_slot_keys": ["worker_id", "source_document_id"], + "checklist_items": [], + "completion_evidence": ["calculation_trace_id", "hr_approval_id"], + "source_ids": ["SRC-MOEL-WAGE-CALCULATOR"] + }, + { + "workflow_id": "WF-INS-001", + "name": "업무·근무일정 안내", + "intent": "WORK_INSTRUCTION", + "sensitivity": "medium", + "supported_task_types": ["WORK_INSTRUCTION"], + "required_slots": ["worker_id", "effective_at", "work_action"], + "allowed_slot_keys": ["worker_id", "effective_at", "work_action", "work_location", "quantity", "quantity_unit"], + "resolvable_slot_keys": ["worker_id"], + "checklist_items": [], + "completion_evidence": ["approved_notice_id", "acknowledged_or_resolved_response"], + "source_ids": ["SRC-EPS-LANGUAGE"] + }, + { + "workflow_id": "WF-CHG-001", + "name": "고용변동 사건 확인·기한 계산·신고 추적", + "intent": "EMPLOYMENT_CHANGE", + "sensitivity": "critical", + "supported_task_types": ["EMPLOYMENT_CHANGE"], + "required_slots": ["worker_id", "change_type", "incident_at"], + "allowed_slot_keys": ["worker_id", "change_type", "incident_at", "reason", "awareness_at"], + "resolvable_slot_keys": ["worker_id"], + "checklist_items": [], + "completion_evidence": ["confirmed_incident_id", "external_receipt_or_manual_note"], + "source_ids": ["SRC-EPS", "SRC-EPS-FORMS", "SRC-MOEL-1350-EMPLOYMENT-CHANGE"] + }, { "workflow_id": "WF-CON-001", "name": "근로계약 갱신과 고용허가기간 연장 준비", diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 0dfbf555..49c2e55b 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -172,7 +172,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { assertThat(columnSpecs(connection, "task")) .containsEntry("task_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) - .containsEntry("worker_id", new ColumnSpec("uuid", false)) + .containsEntry("target_type", new ColumnSpec("varchar", false)) + .containsEntry("worker_id", new ColumnSpec("uuid", true)) + .containsEntry("case_id", new ColumnSpec("uuid", true)) .containsEntry("content_revision", new ColumnSpec("int8", false)) .containsEntry("critical_fingerprint", new ColumnSpec("varchar", false)) .containsEntry("version", new ColumnSpec("int8", false)); @@ -305,6 +307,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pk_stored_file", "fk_stored_file_company", "fk_task_worker_company", + "ck_task_target", "fk_task_created_by_company", "fk_approval_request_task_company", "fk_approval_request_requester_company", @@ -373,6 +376,7 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_worker_document_company_status", "idx_stored_file_company", "idx_task_company_status_due", + "idx_task_company_target_source", "idx_approval_request_task_status", "idx_audit_event_company_time", "idx_event_publication_claim", @@ -533,6 +537,19 @@ INSERT INTO task ( CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) """.formatted(TASK_A, COMPANY_A, WORKER_A, "f".repeat(64), USER_A, USER_A)); + execute(connection, """ + INSERT INTO task ( + task_id, company_id, target_type, worker_id, case_id, task_type, + workflow_id, workflow_catalog_version, title, + business_data_json, critical_fingerprint, content_revision, + source, status, created_by, updated_by, created_at, updated_at + ) VALUES ( + '13000000-0000-0000-0000-000000000002', '%s', 'COMPANY', NULL, NULL, + 'WORKER_ONBOARDING', 'WF-WRK-001', '0.2.0', 'Onboarding draft', + '{}', '%s', 0, 'FILE_IMPORT', 'DRAFT', '%s', '%s', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """.formatted(COMPANY_A, "e".repeat(64), USER_A, USER_A)); execute(connection, """ INSERT INTO worker_link ( worker_link_id, task_id, company_id, token_hash, expires_at, @@ -751,6 +768,32 @@ INSERT INTO refresh_token ( "23503", "DELETE FROM company WHERE company_id = '%s'".formatted(COMPANY_A) ); + assertSqlState(connection, "23514", """ + INSERT INTO task ( + task_id, company_id, target_type, worker_id, case_id, task_type, + workflow_id, workflow_catalog_version, title, + business_data_json, critical_fingerprint, source, status, + created_by, updated_by, created_at, updated_at + ) VALUES ( + '13000000-0000-0000-0000-000000000003', '%s', 'COMPANY', '%s', NULL, + 'WORKER_ONBOARDING', 'WF-WRK-001', '0.2.0', 'Invalid company target', + '{}', '%s', 'MANUAL', 'DRAFT', '%s', '%s', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """.formatted(COMPANY_A, WORKER_A, "d".repeat(64), USER_A, USER_A)); + assertSqlState(connection, "23514", """ + INSERT INTO task ( + task_id, company_id, target_type, worker_id, case_id, task_type, + workflow_id, workflow_catalog_version, title, + business_data_json, critical_fingerprint, source, status, + created_by, updated_by, created_at, updated_at + ) VALUES ( + '13000000-0000-0000-0000-000000000004', '%s', 'WORKER', NULL, NULL, + 'WORKER_ONBOARDING', 'WF-WRK-001', '0.2.0', 'Invalid worker target', + '{}', '%s', 'MANUAL', 'DRAFT', '%s', '%s', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """.formatted(COMPANY_A, "c".repeat(64), USER_A, USER_A)); assertSqlState(connection, "23503", """ INSERT INTO workflow_case ( case_id, company_id, worker_id, title, lifecycle_status, diff --git a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java index 3781a480..44bd38fa 100644 --- a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java @@ -91,7 +91,7 @@ void supportsTheCatalogTaskChecklistAndCancelApiFlow() throws Exception { assertThat(catalog.statusCode()).isEqualTo(200); assertThat(JsonPath.read(catalog.body(), "$.source_repository")) .isEqualTo("fowoco/knowledge"); - assertThat(JsonPath.>read(catalog.body(), "$.workflows")).hasSize(2); + assertThat(JsonPath.>read(catalog.body(), "$.workflows")).hasSize(7); HttpResponse created = post("/api/v1/tasks", validCreateBody(), token); assertThat(created.statusCode()).isEqualTo(201); @@ -99,6 +99,7 @@ void supportsTheCatalogTaskChecklistAndCancelApiFlow() throws Exception { UUID taskId = UUID.fromString(JsonPath.read(created.body(), "$.task_id")); UUID caseId = UUID.fromString(JsonPath.read(created.body(), "$.case_id")); assertThat(JsonPath.read(created.body(), "$.status")).isEqualTo("DRAFT"); + assertThat(JsonPath.read(created.body(), "$.target_type")).isEqualTo("WORKER"); assertThat(JsonPath.read(created.body(), "$.workflow_catalog_version")) .isEqualTo("0.2.0"); assertThat(jdbcTemplate.queryForList( @@ -221,6 +222,113 @@ void missingRequiredSlotCreatesNeedsInfoAndCannotRequestApproval() throws Except .containsExactly("due_at"); } + @Test + void createsACompanyOnboardingTaskWithoutWorkerOrCase() throws Exception { + String token = login(HR_A_EMAIL); + + HttpResponse created = post( + "/api/v1/tasks", + """ + { + "target_type":"COMPANY", + "task_type":"WORKER_ONBOARDING", + "workflow_id":"WF-WRK-001", + "title":"신규 근로자 등록 초안", + "description":"업로드 문서에서 등록 초안을 준비합니다.", + "business_data":{"source_document_id":"file-demo-001"} + } + """, + token + ); + + assertThat(created.statusCode()).isEqualTo(201); + assertThat(JsonPath.read(created.body(), "$.target_type")).isEqualTo("COMPANY"); + assertThat(JsonPath.read(created.body(), "$.worker_id")).isNull(); + assertThat(JsonPath.read(created.body(), "$.case_id")).isNull(); + assertThat(JsonPath.read(created.body(), "$.status")).isEqualTo("DRAFT"); + UUID taskId = UUID.fromString(JsonPath.read(created.body(), "$.task_id")); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM workflow_case", + Integer.class + )).isZero(); + + HttpResponse filtered = get( + "/api/v1/tasks?target_type=COMPANY&source=MANUAL&task_type=WORKER_ONBOARDING", + token + ); + assertThat(filtered.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(filtered.body(), "$.items[*].task_id")) + .containsExactly(taskId.toString()); + } + + @Test + void rejectsInvalidCompanyAndWorkerTargetCombinations() throws Exception { + String token = login(HR_A_EMAIL); + String companyWithWorker = validCreateBody().replace( + "{\n", + "{\n \"target_type\":\"COMPANY\",\n" + ); + String workerWithoutWorkerId = """ + { + "target_type":"WORKER", + "task_type":"WORKER_ONBOARDING", + "workflow_id":"WF-WRK-001", + "title":"잘못된 근로자 대상", + "business_data":{"source_document_id":"file-demo-001"} + } + """; + + for (String body : List.of(companyWithWorker, workerWithoutWorkerId)) { + HttpResponse response = post("/api/v1/tasks", body, token); + assertThat(response.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("INVALID_TASK_TARGET"); + } + } + + @Test + void createsEveryWorkerTargetTaskTypeFromTheCanonicalCatalog() throws Exception { + String token = login(HR_A_EMAIL); + List requestBodies = List.of( + workerTaskBody( + "DOCUMENT_REQUEST", + "WF-DOC-001", + "서류 요청", + "\"document_type\":\"PASSPORT_COPY\",\"submission_channel\":\"SECURE_LINK\"" + ), + workerTaskBody( + "PAYROLL_EXPLANATION", + "WF-PAY-001", + "급여 설명", + "\"pay_period\":\"2026-07\",\"source_document_id\":\"file-pay-001\"" + ), + workerTaskBody( + "WORK_INSTRUCTION", + "WF-INS-001", + "근무 안내", + "\"effective_at\":\"2026-08-10T07:00:00+09:00\",\"work_action\":\"2공장 출근\"" + ), + workerTaskBody( + "EMPLOYMENT_CHANGE", + "WF-CHG-001", + "고용변동 확인", + "\"change_type\":\"RESIGNATION\",\"incident_at\":\"2026-08-05\"" + ) + ); + + for (String body : requestBodies) { + HttpResponse created = post("/api/v1/tasks", body, token); + assertThat(created.statusCode()).isEqualTo(201); + assertThat(JsonPath.read(created.body(), "$.target_type")) + .isEqualTo("WORKER"); + assertThat(JsonPath.read(created.body(), "$.status")).isEqualTo("DRAFT"); + } + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM task", Integer.class)) + .isEqualTo(4); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM workflow_case", Integer.class)) + .isEqualTo(4); + } + @Test void viewerCannotWriteAndAnotherCompanyCannotDiscoverTask() throws Exception { String hrToken = login(HR_A_EMAIL); @@ -490,6 +598,24 @@ private String createBody(UUID caseId, String title) { """.formatted(WORKER_A, caseId, title); } + private String workerTaskBody( + String taskType, + String workflowId, + String title, + String additionalBusinessData + ) { + return """ + { + "worker_id":"%s", + "task_type":"%s", + "workflow_id":"%s", + "title":"%s", + "due_date":"2026-08-20", + "business_data":{%s} + } + """.formatted(WORKER_A, taskType, workflowId, title, additionalBusinessData); + } + private String login(String email) throws Exception { HttpResponse response = post( "/api/v1/auth/login", 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 05c96627..833635ef 100644 --- a/src/test/java/com/fowoco/server/task/api/TaskOpenApiContractTest.java +++ b/src/test/java/com/fowoco/server/task/api/TaskOpenApiContractTest.java @@ -106,6 +106,7 @@ void responseAndCatalogSchemasExposePinnedVersionsInSnakeCase() { ); assertThat(task.has("workflow_catalog_version")).isTrue(); + assertThat(task.has("target_type")).isTrue(); assertThat(task.has("content_revision")).isTrue(); assertThat(task.has("missing_required_slots")).isTrue(); assertThat(workflow.has("supported_task_types")).isTrue(); @@ -125,6 +126,18 @@ void taskListPublishesTheOptionalCaseIdFilter() { }); } + @Test + void taskListPublishesTargetAndSourceFilters() { + JsonNode parameters = openApi.at("/paths/~1api~1v1~1tasks/get/parameters"); + + assertThat(parameters).anySatisfy(parameter -> + assertThat(parameter.path("name").asText()).isEqualTo("target_type") + ); + assertThat(parameters).anySatisfy(parameter -> + assertThat(parameter.path("name").asText()).isEqualTo("source") + ); + } + private String operationId(String pointer) { return openApi.at(pointer).path("operationId").asText(); }