From d6dbd4d1bf94b4ec9128794be066c6b20794d71b Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 3 Aug 2026 11:21:17 +0900 Subject: [PATCH 01/15] =?UTF-8?q?refactor(seed):=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=ED=98=95=20=EB=8D=B0=EB=AA=A8=20=EC=8B=9C=EB=93=9C=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=EA=B5=AC=EC=A1=B0=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 운영형 데모 fixture를 별도 catalog로 분리 - Task, WorkerDocument, AuditEvent 저장 책임을 엔티티별 seeder로 분리 - 회사 및 실행자 ID와 Clock 기반 날짜를 seed context로 통합 - 예약 ID와 교차 엔티티 일관성을 확인하는 verifier 추가 - 기존 시드 레코드와 실행 순서, 멱등성 동작 유지 --- .../seed/DemoAuditEventSeeder.java | 77 +++ .../seed/DemoOperationalSeedCatalog.java | 286 ++++++++++ .../DemoOperationalSeedConfiguration.java | 22 +- .../seed/DemoOperationalSeedContext.java | 36 ++ .../seed/DemoOperationalSeedRunner.java | 500 +----------------- .../seed/DemoOperationalSeedVerifier.java | 91 ++++ .../infrastructure/seed/DemoTaskSeeder.java | 93 ++++ .../seed/DemoWorkerDocumentSeeder.java | 79 +++ 8 files changed, 704 insertions(+), 480 deletions(-) create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java new file mode 100644 index 00000000..688ef29e --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java @@ -0,0 +1,77 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; + +final class DemoAuditEventSeeder { + + private static final String TRACE_ID = "demo-seed-task-timeline"; + private static final String EVENT_VERSION = "1"; + + private final AuditEventRepository auditEventRepository; + + DemoAuditEventSeeder(AuditEventRepository auditEventRepository) { + this.auditEventRepository = Objects.requireNonNull( + auditEventRepository, + "auditEventRepository must not be null" + ); + } + + void seed(AuditSeed seed, DemoOperationalSeedContext context) { + Optional existing = findExisting(seed, context); + if (existing.isPresent()) { + verifyExisting(existing.orElseThrow(), seed, context); + return; + } + auditEventRepository.append(new AuditEvent( + seed.auditEventId(), + context.companyId(), + ActorType.HR_USER, + context.actorId(), + UserRole.ADMIN, + seed.action(), + AuditTargetType.TASK, + DemoOperationalSeedCatalog.TIMELINE_TASK_ID, + seed.requestId(), + TRACE_ID, + EVENT_VERSION, + seed.changeSummary(), + context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS) + )); + } + + Optional findExisting(AuditSeed seed, DemoOperationalSeedContext context) { + return auditEventRepository + .findTaskActivities(context.companyId(), DemoOperationalSeedCatalog.TIMELINE_TASK_ID) + .stream() + .filter(event -> seed.auditEventId().equals(event.auditEventId())) + .findFirst(); + } + + void verifyExisting( + AuditEvent event, + AuditSeed seed, + DemoOperationalSeedContext context + ) { + if (!context.companyId().equals(event.companyId()) + || event.actorType() != ActorType.HR_USER + || !context.actorId().equals(event.actorId()) + || event.userRole() != UserRole.ADMIN + || event.action() != seed.action() + || event.targetType() != AuditTargetType.TASK + || !DemoOperationalSeedCatalog.TIMELINE_TASK_ID.equals(event.targetId()) + || !seed.requestId().equals(event.requestId()) + || !seed.changeSummary().equals(event.changeSummary())) { + throw new IllegalStateException( + "a reserved demo audit event id already belongs to different audit data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java new file mode 100644 index 00000000..28cdeae9 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -0,0 +1,286 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import java.util.List; +import java.util.UUID; + +final class DemoOperationalSeedCatalog { + + static final String WORKFLOW_CATALOG_VERSION = "0.2.0"; + static final UUID TIMELINE_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000002"); + + private final List tasks = List.of( + task( + "94000000-0000-0000-0000-000000000001", + "94100000-0000-0000-0000-000000000001", + "92000000-0000-0000-0000-000000000001", + TaskType.STAY_PERIOD_EXTENSION, + "WF-STY-001", + "AI 추천: 체류기간 연장 준비", + "체류기간 만료 전에 필요한 서류를 확인하고 연장 신청을 준비합니다.", + TaskSource.AI_CANDIDATE, + TaskStatus.DRAFT, + 3, + 6 + ), + task( + "94000000-0000-0000-0000-000000000002", + "94100000-0000-0000-0000-000000000002", + "92000000-0000-0000-0000-000000000002", + TaskType.RECONTRACT, + "WF-CON-001", + "근로계약 갱신 검토", + "갱신 계약 조건과 제출 서류를 검토하고 승인을 기다립니다.", + TaskSource.MANUAL, + TaskStatus.READY_FOR_REVIEW, + 20, + 5 + ), + task( + "94000000-0000-0000-0000-000000000003", + "94100000-0000-0000-0000-000000000003", + "92000000-0000-0000-0000-000000000003", + TaskType.STAY_PERIOD_EXTENSION, + "WF-STY-001", + "여권 사본 제출 대기", + "근로자에게 최신 여권 사본을 요청했으며 응답을 기다립니다.", + TaskSource.MANUAL, + TaskStatus.WAITING_WORKER, + 6, + 4 + ), + task( + "94000000-0000-0000-0000-000000000004", + "94100000-0000-0000-0000-000000000004", + "92000000-0000-0000-0000-000000000004", + TaskType.EMPLOYMENT_PERIOD_EXTENSION, + "WF-CON-001", + "고용허가기간 연장 결과 대기", + "관할 기관에 제출을 완료하고 처리 결과를 기다립니다.", + TaskSource.MANUAL, + TaskStatus.WAITING_EXTERNAL, + 90, + 12 + ), + task( + "94000000-0000-0000-0000-000000000005", + "94100000-0000-0000-0000-000000000005", + "92000000-0000-0000-0000-000000000005", + TaskType.RECONTRACT, + "WF-CON-001", + "근로계약 갱신 완료", + "서명된 근로계약서 확인과 갱신 처리를 완료했습니다.", + TaskSource.SYSTEM_DDAY, + TaskStatus.COMPLETED, + 30, + 10 + ) + ); + + private final List documents = List.of( + document( + "95000000-0000-0000-0000-000000000001", + "92000000-0000-0000-0000-000000000001", + DocumentType.PASSPORT_COPY, + SubmissionStatus.MISSING, + 25, + "체류기간 연장", + "만료 임박 여권 사본을 다시 받아야 합니다." + ), + document( + "95000000-0000-0000-0000-000000000002", + "92000000-0000-0000-0000-000000000001", + DocumentType.ARC, + SubmissionStatus.VERIFIED, + 180, + "체류기간 연장", + "외국인등록증 사본 확인 완료" + ), + document( + "95000000-0000-0000-0000-000000000003", + "92000000-0000-0000-0000-000000000002", + DocumentType.CONTRACT, + SubmissionStatus.SUBMITTED, + 90, + "근로계약 갱신", + "서명본 검토 대기" + ), + document( + "95000000-0000-0000-0000-000000000004", + "92000000-0000-0000-0000-000000000002", + DocumentType.PERMIT, + SubmissionStatus.VERIFIED, + 150, + "고용허가기간 연장", + "고용허가서 확인 완료" + ), + document( + "95000000-0000-0000-0000-000000000005", + "92000000-0000-0000-0000-000000000003", + DocumentType.PASSPORT_COPY, + SubmissionStatus.VERIFIED, + 300, + "체류기간 연장", + "유효한 여권 사본" + ), + document( + "95000000-0000-0000-0000-000000000006", + "92000000-0000-0000-0000-000000000004", + DocumentType.ARC, + SubmissionStatus.MISSING, + 45, + "고용허가기간 연장", + "외국인등록증 사본 요청 필요" + ), + document( + "95000000-0000-0000-0000-000000000007", + "92000000-0000-0000-0000-000000000005", + DocumentType.CONTRACT, + SubmissionStatus.SUBMITTED, + 120, + "근로계약 갱신", + "갱신 계약서 제출 완료" + ) + ); + + private final List audits = List.of( + audit( + "96000000-0000-0000-0000-000000000001", + AuditAction.TASK_CREATED, + "demo-seed-task-created", + "업무가 생성되었습니다.", + 48 + ), + audit( + "96000000-0000-0000-0000-000000000002", + AuditAction.TASK_UPDATED, + "demo-seed-task-updated", + "계약 갱신 정보와 마감일이 확인되었습니다.", + 24 + ), + audit( + "96000000-0000-0000-0000-000000000003", + AuditAction.APPROVAL_REQUESTED, + "demo-seed-approval-requested", + "관리자 승인을 요청했습니다.", + 6 + ) + ); + + List tasks() { + return tasks; + } + + List documents() { + return documents; + } + + List audits() { + return audits; + } + + private static TaskSeed task( + String taskId, + String caseId, + String workerId, + TaskType taskType, + String workflowId, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + return new TaskSeed( + UUID.fromString(taskId), + UUID.fromString(caseId), + UUID.fromString(workerId), + taskType, + workflowId, + title, + description, + source, + status, + dueDays, + createdDaysAgo + ); + } + + private static DocumentSeed document( + String documentId, + String workerId, + DocumentType documentType, + SubmissionStatus submissionStatus, + int expiryDays, + String destination, + String note + ) { + return new DocumentSeed( + UUID.fromString(documentId), + UUID.fromString(workerId), + documentType, + submissionStatus, + expiryDays, + destination, + note + ); + } + + private static AuditSeed audit( + String auditEventId, + AuditAction action, + String requestId, + String changeSummary, + int hoursAgo + ) { + return new AuditSeed( + UUID.fromString(auditEventId), + action, + requestId, + changeSummary, + hoursAgo + ); + } + + record TaskSeed( + UUID taskId, + UUID caseId, + UUID workerId, + TaskType taskType, + String workflowId, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + } + + record DocumentSeed( + UUID documentId, + UUID workerId, + DocumentType documentType, + SubmissionStatus submissionStatus, + int expiryDays, + String destination, + String note + ) { + } + + record AuditSeed( + UUID auditEventId, + AuditAction action, + String requestId, + String changeSummary, + int hoursAgo + ) { + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java index 0bd2b7e3..21599375 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -23,13 +23,25 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( AuditEventRepository auditEventRepository, Clock clock ) { - return new DemoOperationalSeedRunner( - properties, + DemoOperationalSeedCatalog catalog = new DemoOperationalSeedCatalog(); + DemoTaskSeeder taskSeeder = new DemoTaskSeeder(taskRepository, taskContentCodec); + DemoWorkerDocumentSeeder documentSeeder = new DemoWorkerDocumentSeeder(workerDocumentRepository); + DemoAuditEventSeeder auditSeeder = new DemoAuditEventSeeder(auditEventRepository); + DemoOperationalSeedVerifier verifier = new DemoOperationalSeedVerifier( taskRepository, - taskContentCodec, workerDocumentRepository, - auditEventRepository, - clock + taskSeeder, + documentSeeder, + auditSeeder + ); + return new DemoOperationalSeedRunner( + properties, + clock, + catalog, + taskSeeder, + documentSeeder, + auditSeeder, + verifier ); } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java new file mode 100644 index 00000000..9d57c027 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java @@ -0,0 +1,36 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + +record DemoOperationalSeedContext( + UUID companyId, + UUID actorId, + LocalDate today, + Instant now +) { + + DemoOperationalSeedContext { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(actorId, "actorId must not be null"); + Objects.requireNonNull(today, "today must not be null"); + Objects.requireNonNull(now, "now must not be null"); + } + + static DemoOperationalSeedContext from(DemoAuthSeedProperties properties, Clock clock) { + Objects.requireNonNull(properties, "properties must not be null"); + Objects.requireNonNull(clock, "clock must not be null"); + Instant now = clock.instant(); + LocalDate today = LocalDate.now(clock); + return new DemoOperationalSeedContext( + properties.companyId(), + properties.adminUserId(), + today, + now + ); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java index 69b55246..cadc13ce 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -1,32 +1,8 @@ package com.fowoco.server.demo.infrastructure.seed; -import com.fowoco.server.audit.application.port.AuditEventRepository; -import com.fowoco.server.audit.domain.ActorType; -import com.fowoco.server.audit.domain.AuditAction; -import com.fowoco.server.audit.domain.AuditEvent; -import com.fowoco.server.audit.domain.AuditTargetType; -import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; -import com.fowoco.server.task.application.TaskContentCodec; -import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent; -import com.fowoco.server.task.application.port.TaskRepository; -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.TaskType; -import com.fowoco.server.worker.application.port.WorkerDocumentRepository; -import com.fowoco.server.worker.domain.DocumentType; -import com.fowoco.server.worker.domain.SubmissionStatus; -import com.fowoco.server.worker.domain.WorkerDocument; import java.time.Clock; -import java.time.Instant; -import java.time.LocalDate; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; @@ -38,473 +14,47 @@ class DemoOperationalSeedRunner implements ApplicationRunner { private static final Logger LOGGER = LoggerFactory.getLogger(DemoOperationalSeedRunner.class); - private static final String CATALOG_VERSION = "0.2.0"; - private static final List TASKS = List.of( - demoTask( - "94000000-0000-0000-0000-000000000001", - "94100000-0000-0000-0000-000000000001", - "92000000-0000-0000-0000-000000000001", - TaskType.STAY_PERIOD_EXTENSION, - "WF-STY-001", - "AI 추천: 체류기간 연장 준비", - "체류기간 만료 전에 필요한 서류를 확인하고 연장 신청을 준비합니다.", - TaskSource.AI_CANDIDATE, - TaskStatus.DRAFT, - 3, - 6 - ), - demoTask( - "94000000-0000-0000-0000-000000000002", - "94100000-0000-0000-0000-000000000002", - "92000000-0000-0000-0000-000000000002", - TaskType.RECONTRACT, - "WF-CON-001", - "근로계약 갱신 검토", - "갱신 계약 조건과 제출 서류를 검토하고 승인을 기다립니다.", - TaskSource.MANUAL, - TaskStatus.READY_FOR_REVIEW, - 20, - 5 - ), - demoTask( - "94000000-0000-0000-0000-000000000003", - "94100000-0000-0000-0000-000000000003", - "92000000-0000-0000-0000-000000000003", - TaskType.STAY_PERIOD_EXTENSION, - "WF-STY-001", - "여권 사본 제출 대기", - "근로자에게 최신 여권 사본을 요청했으며 응답을 기다립니다.", - TaskSource.MANUAL, - TaskStatus.WAITING_WORKER, - 6, - 4 - ), - demoTask( - "94000000-0000-0000-0000-000000000004", - "94100000-0000-0000-0000-000000000004", - "92000000-0000-0000-0000-000000000004", - TaskType.EMPLOYMENT_PERIOD_EXTENSION, - "WF-CON-001", - "고용허가기간 연장 결과 대기", - "관할 기관에 제출을 완료하고 처리 결과를 기다립니다.", - TaskSource.MANUAL, - TaskStatus.WAITING_EXTERNAL, - 90, - 12 - ), - demoTask( - "94000000-0000-0000-0000-000000000005", - "94100000-0000-0000-0000-000000000005", - "92000000-0000-0000-0000-000000000005", - TaskType.RECONTRACT, - "WF-CON-001", - "근로계약 갱신 완료", - "서명된 근로계약서 확인과 갱신 처리를 완료했습니다.", - TaskSource.SYSTEM_DDAY, - TaskStatus.COMPLETED, - 30, - 10 - ) - ); - private static final List DOCUMENTS = List.of( - demoDocument( - "95000000-0000-0000-0000-000000000001", - "92000000-0000-0000-0000-000000000001", - DocumentType.PASSPORT_COPY, - SubmissionStatus.MISSING, - 25, - "체류기간 연장", - "만료 임박 여권 사본을 다시 받아야 합니다." - ), - demoDocument( - "95000000-0000-0000-0000-000000000002", - "92000000-0000-0000-0000-000000000001", - DocumentType.ARC, - SubmissionStatus.VERIFIED, - 180, - "체류기간 연장", - "외국인등록증 사본 확인 완료" - ), - demoDocument( - "95000000-0000-0000-0000-000000000003", - "92000000-0000-0000-0000-000000000002", - DocumentType.CONTRACT, - SubmissionStatus.SUBMITTED, - 90, - "근로계약 갱신", - "서명본 검토 대기" - ), - demoDocument( - "95000000-0000-0000-0000-000000000004", - "92000000-0000-0000-0000-000000000002", - DocumentType.PERMIT, - SubmissionStatus.VERIFIED, - 150, - "고용허가기간 연장", - "고용허가서 확인 완료" - ), - demoDocument( - "95000000-0000-0000-0000-000000000005", - "92000000-0000-0000-0000-000000000003", - DocumentType.PASSPORT_COPY, - SubmissionStatus.VERIFIED, - 300, - "체류기간 연장", - "유효한 여권 사본" - ), - demoDocument( - "95000000-0000-0000-0000-000000000006", - "92000000-0000-0000-0000-000000000004", - DocumentType.ARC, - SubmissionStatus.MISSING, - 45, - "고용허가기간 연장", - "외국인등록증 사본 요청 필요" - ), - demoDocument( - "95000000-0000-0000-0000-000000000007", - "92000000-0000-0000-0000-000000000005", - DocumentType.CONTRACT, - SubmissionStatus.SUBMITTED, - 120, - "근로계약 갱신", - "갱신 계약서 제출 완료" - ) - ); - private static final UUID TIMELINE_TASK_ID = - UUID.fromString("94000000-0000-0000-0000-000000000002"); - private static final List AUDITS = List.of( - demoAudit( - "96000000-0000-0000-0000-000000000001", - AuditAction.TASK_CREATED, - "demo-seed-task-created", - "업무가 생성되었습니다.", - 48 - ), - demoAudit( - "96000000-0000-0000-0000-000000000002", - AuditAction.TASK_UPDATED, - "demo-seed-task-updated", - "계약 갱신 정보와 마감일이 확인되었습니다.", - 24 - ), - demoAudit( - "96000000-0000-0000-0000-000000000003", - AuditAction.APPROVAL_REQUESTED, - "demo-seed-approval-requested", - "관리자 승인을 요청했습니다.", - 6 - ) - ); private final DemoAuthSeedProperties properties; - private final TaskRepository taskRepository; - private final TaskContentCodec taskContentCodec; - private final WorkerDocumentRepository workerDocumentRepository; - private final AuditEventRepository auditEventRepository; private final Clock clock; + private final DemoOperationalSeedCatalog catalog; + private final DemoTaskSeeder taskSeeder; + private final DemoWorkerDocumentSeeder documentSeeder; + private final DemoAuditEventSeeder auditSeeder; + private final DemoOperationalSeedVerifier verifier; DemoOperationalSeedRunner( DemoAuthSeedProperties properties, - TaskRepository taskRepository, - TaskContentCodec taskContentCodec, - WorkerDocumentRepository workerDocumentRepository, - AuditEventRepository auditEventRepository, - Clock clock + Clock clock, + DemoOperationalSeedCatalog catalog, + DemoTaskSeeder taskSeeder, + DemoWorkerDocumentSeeder documentSeeder, + DemoAuditEventSeeder auditSeeder, + DemoOperationalSeedVerifier verifier ) { this.properties = Objects.requireNonNull(properties, "properties must not be null"); - this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); - this.taskContentCodec = Objects.requireNonNull(taskContentCodec, "taskContentCodec must not be null"); - this.workerDocumentRepository = Objects.requireNonNull( - workerDocumentRepository, - "workerDocumentRepository must not be null" - ); - this.auditEventRepository = Objects.requireNonNull( - auditEventRepository, - "auditEventRepository must not be null" - ); this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.catalog = Objects.requireNonNull(catalog, "catalog must not be null"); + this.taskSeeder = Objects.requireNonNull(taskSeeder, "taskSeeder must not be null"); + this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); + this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); + this.verifier = Objects.requireNonNull(verifier, "verifier must not be null"); } @Override @Transactional public void run(ApplicationArguments arguments) { - Instant now = clock.instant(); - LocalDate today = LocalDate.now(clock); - TASKS.forEach(task -> seedTask(task, today, now)); - DOCUMENTS.forEach(document -> seedDocument(document, today, now)); - AUDITS.forEach(audit -> seedAudit(audit, now)); + DemoOperationalSeedContext context = DemoOperationalSeedContext.from(properties, clock); + catalog.tasks().forEach(seed -> taskSeeder.seed(seed, context)); + catalog.documents().forEach(seed -> documentSeeder.seed(seed, context)); + catalog.audits().forEach(seed -> auditSeeder.seed(seed, context)); + verifier.verify(catalog, context); LOGGER.info( "demo_operational_seed ready company_id={} task_count={} document_count={} audit_count={}", - properties.companyId(), - TASKS.size(), - DOCUMENTS.size(), - AUDITS.size() - ); - } - - private void seedTask(DemoTask seed, LocalDate today, Instant now) { - LocalDate dueDate = today.plusDays(seed.dueDays()); - EncodedTaskContent content = taskContentCodec.encode( - seed.workerId(), - seed.workflowId(), - seed.taskType().name(), - seed.title(), - seed.description(), - dueDate, - Map.of("worker_id", seed.workerId().toString(), "due_at", dueDate.toString()) + context.companyId(), + catalog.tasks().size(), + catalog.documents().size(), + catalog.audits().size() ); - Optional existing = taskRepository.findByIdAndCompanyId( - seed.taskId(), - properties.companyId() - ); - existing.ifPresent(task -> verifyExistingTask(task, seed)); - Instant createdAt = existing.map(Task::createdAt) - .orElseGet(() -> now.minus(seed.createdDaysAgo(), ChronoUnit.DAYS)); - Instant desiredUpdatedAt = seed.status() == TaskStatus.COMPLETED - ? now - : now.minus(Math.max(seed.createdDaysAgo() - 1, 0), ChronoUnit.DAYS); - Instant updatedAt = desiredUpdatedAt.isBefore(createdAt) ? createdAt : desiredUpdatedAt; - long version = existing.map(Task::version).orElse(0L); - taskRepository.save(new Task( - seed.taskId(), - properties.companyId(), - seed.workerId(), - seed.caseId(), - seed.taskType(), - seed.workflowId(), - CATALOG_VERSION, - seed.title(), - seed.description(), - content.businessDataJson(), - content.criticalFingerprint(), - 0L, - seed.source(), - seed.status(), - dueDate, - properties.adminUserId(), - properties.adminUserId(), - createdAt, - updatedAt, - version - )); - } - - private void verifyExistingTask(Task task, DemoTask seed) { - if (!seed.taskId().equals(task.taskId()) - || !properties.companyId().equals(task.companyId()) - || !seed.workerId().equals(task.workerId()) - || !seed.caseId().equals(task.caseId()) - || seed.taskType() != task.taskType() - || !seed.workflowId().equals(task.workflowId()) - || !CATALOG_VERSION.equals(task.workflowCatalogVersion()) - || !seed.title().equals(task.title()) - || !seed.description().equals(task.description()) - || seed.source() != task.source() - || seed.status() != task.status() - || task.contentRevision() != 0L - || !properties.adminUserId().equals(task.createdBy()) - || !properties.adminUserId().equals(task.updatedBy())) { - throw new IllegalStateException( - "a reserved demo task id already belongs to different task data" - ); - } - } - - private void seedDocument(DemoDocument seed, LocalDate today, Instant now) { - LocalDate expiryDate = today.plusDays(seed.expiryDays()); - Optional existing = - workerDocumentRepository.findByIdAndWorkerIdAndCompanyId( - seed.documentId(), - seed.workerId(), - properties.companyId() - ); - existing.ifPresent(document -> verifyExistingDocument(document, seed)); - if (existing.isEmpty()) { - workerDocumentRepository.insert(WorkerDocument.create( - seed.documentId(), - seed.workerId(), - properties.companyId(), - seed.documentType(), - seed.submissionStatus(), - expiryDate, - seed.destination(), - seed.note(), - now - )); - return; - } - WorkerDocument document = existing.orElseThrow(); - workerDocumentRepository.update(new WorkerDocument( - document.workerDocumentId(), - document.workerId(), - document.companyId(), - document.documentType(), - document.submissionStatus(), - expiryDate, - document.destination(), - document.note(), - null, - document.createdAt(), - now, - document.version() - )); - } - - private void verifyExistingDocument(WorkerDocument document, DemoDocument seed) { - if (!seed.documentId().equals(document.workerDocumentId()) - || !seed.workerId().equals(document.workerId()) - || !properties.companyId().equals(document.companyId()) - || seed.documentType() != document.documentType() - || seed.submissionStatus() != document.submissionStatus() - || !Objects.equals(seed.destination(), document.destination()) - || !Objects.equals(seed.note(), document.note()) - || document.fileId() != null) { - throw new IllegalStateException( - "a reserved demo worker document id already belongs to different document data" - ); - } - } - - private void seedAudit(DemoAudit seed, Instant now) { - Optional existing = auditEventRepository - .findTaskActivities(properties.companyId(), TIMELINE_TASK_ID) - .stream() - .filter(event -> seed.auditEventId().equals(event.auditEventId())) - .findFirst(); - if (existing.isPresent()) { - verifyExistingAudit(existing.orElseThrow(), seed); - return; - } - auditEventRepository.append(new AuditEvent( - seed.auditEventId(), - properties.companyId(), - ActorType.HR_USER, - properties.adminUserId(), - UserRole.ADMIN, - seed.action(), - AuditTargetType.TASK, - TIMELINE_TASK_ID, - seed.requestId(), - "demo-seed-task-timeline", - "1", - seed.changeSummary(), - now.minus(seed.hoursAgo(), ChronoUnit.HOURS) - )); - } - - private void verifyExistingAudit(AuditEvent event, DemoAudit seed) { - if (!properties.companyId().equals(event.companyId()) - || event.actorType() != ActorType.HR_USER - || !properties.adminUserId().equals(event.actorId()) - || event.userRole() != UserRole.ADMIN - || event.action() != seed.action() - || event.targetType() != AuditTargetType.TASK - || !TIMELINE_TASK_ID.equals(event.targetId()) - || !seed.requestId().equals(event.requestId()) - || !seed.changeSummary().equals(event.changeSummary())) { - throw new IllegalStateException( - "a reserved demo audit event id already belongs to different audit data" - ); - } - } - - private static DemoTask demoTask( - String taskId, - String caseId, - String workerId, - TaskType taskType, - String workflowId, - String title, - String description, - TaskSource source, - TaskStatus status, - int dueDays, - int createdDaysAgo - ) { - return new DemoTask( - UUID.fromString(taskId), - UUID.fromString(caseId), - UUID.fromString(workerId), - taskType, - workflowId, - title, - description, - source, - status, - dueDays, - createdDaysAgo - ); - } - - private static DemoDocument demoDocument( - String documentId, - String workerId, - DocumentType documentType, - SubmissionStatus submissionStatus, - int expiryDays, - String destination, - String note - ) { - return new DemoDocument( - UUID.fromString(documentId), - UUID.fromString(workerId), - documentType, - submissionStatus, - expiryDays, - destination, - note - ); - } - - private static DemoAudit demoAudit( - String auditEventId, - AuditAction action, - String requestId, - String changeSummary, - int hoursAgo - ) { - return new DemoAudit( - UUID.fromString(auditEventId), - action, - requestId, - changeSummary, - hoursAgo - ); - } - - private record DemoTask( - UUID taskId, - UUID caseId, - UUID workerId, - TaskType taskType, - String workflowId, - String title, - String description, - TaskSource source, - TaskStatus status, - int dueDays, - int createdDaysAgo - ) { - } - - private record DemoDocument( - UUID documentId, - UUID workerId, - DocumentType documentType, - SubmissionStatus submissionStatus, - int expiryDays, - String destination, - String note - ) { - } - - private record DemoAudit( - UUID auditEventId, - AuditAction action, - String requestId, - String changeSummary, - int hoursAgo - ) { } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java new file mode 100644 index 00000000..31f96525 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java @@ -0,0 +1,91 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +final class DemoOperationalSeedVerifier { + + private final TaskRepository taskRepository; + private final WorkerDocumentRepository workerDocumentRepository; + private final DemoTaskSeeder taskSeeder; + private final DemoWorkerDocumentSeeder documentSeeder; + private final DemoAuditEventSeeder auditSeeder; + + DemoOperationalSeedVerifier( + TaskRepository taskRepository, + WorkerDocumentRepository workerDocumentRepository, + DemoTaskSeeder taskSeeder, + DemoWorkerDocumentSeeder documentSeeder, + DemoAuditEventSeeder auditSeeder + ) { + this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); + this.workerDocumentRepository = Objects.requireNonNull( + workerDocumentRepository, + "workerDocumentRepository must not be null" + ); + this.taskSeeder = Objects.requireNonNull(taskSeeder, "taskSeeder must not be null"); + this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); + this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); + } + + void verify(DemoOperationalSeedCatalog catalog, DemoOperationalSeedContext context) { + verifyUniqueIds(catalog.tasks().stream().map(TaskSeed::taskId).toList(), "task"); + verifyUniqueIds(catalog.documents().stream().map(DocumentSeed::documentId).toList(), "document"); + verifyUniqueIds(catalog.audits().stream().map(AuditSeed::auditEventId).toList(), "audit event"); + verifyDocumentWorkersHaveTasks(catalog); + catalog.tasks().forEach(seed -> verifyTask(seed, context)); + catalog.documents().forEach(seed -> verifyDocument(seed, context)); + catalog.audits().forEach(seed -> verifyAudit(seed, context)); + } + + private void verifyTask(TaskSeed seed, DemoOperationalSeedContext context) { + Task task = taskRepository.findByIdAndCompanyId(seed.taskId(), context.companyId()) + .orElseThrow(() -> new IllegalStateException("a reserved demo task was not seeded")); + taskSeeder.verifyExisting(task, seed, context); + } + + private void verifyDocument(DocumentSeed seed, DemoOperationalSeedContext context) { + WorkerDocument document = workerDocumentRepository.findByIdAndWorkerIdAndCompanyId( + seed.documentId(), + seed.workerId(), + context.companyId() + ) + .orElseThrow(() -> new IllegalStateException("a reserved demo worker document was not seeded")); + documentSeeder.verifyExisting(document, seed, context); + } + + private void verifyAudit(AuditSeed seed, DemoOperationalSeedContext context) { + AuditEvent event = auditSeeder.findExisting(seed, context) + .orElseThrow(() -> new IllegalStateException("a reserved demo audit event was not seeded")); + auditSeeder.verifyExisting(event, seed, context); + } + + private void verifyDocumentWorkersHaveTasks(DemoOperationalSeedCatalog catalog) { + Set taskWorkerIds = catalog.tasks().stream() + .map(TaskSeed::workerId) + .collect(java.util.stream.Collectors.toSet()); + boolean orphanedDocument = catalog.documents().stream() + .map(DocumentSeed::workerId) + .anyMatch(workerId -> !taskWorkerIds.contains(workerId)); + if (orphanedDocument) { + throw new IllegalStateException("a demo document worker has no demo task"); + } + } + + private void verifyUniqueIds(List ids, String entityName) { + if (new HashSet<>(ids).size() != ids.size()) { + throw new IllegalStateException("duplicate reserved demo " + entityName + " id"); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java new file mode 100644 index 00000000..1f90fca1 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java @@ -0,0 +1,93 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.task.application.TaskContentCodec; +import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +final class DemoTaskSeeder { + + private final TaskRepository taskRepository; + private final TaskContentCodec taskContentCodec; + + DemoTaskSeeder(TaskRepository taskRepository, TaskContentCodec taskContentCodec) { + this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); + this.taskContentCodec = Objects.requireNonNull(taskContentCodec, "taskContentCodec must not be null"); + } + + void seed(TaskSeed seed, DemoOperationalSeedContext context) { + LocalDate dueDate = context.today().plusDays(seed.dueDays()); + EncodedTaskContent content = taskContentCodec.encode( + seed.workerId(), + seed.workflowId(), + seed.taskType().name(), + seed.title(), + seed.description(), + dueDate, + Map.of("worker_id", seed.workerId().toString(), "due_at", dueDate.toString()) + ); + Optional existing = taskRepository.findByIdAndCompanyId( + seed.taskId(), + context.companyId() + ); + existing.ifPresent(task -> verifyExisting(task, seed, context)); + Instant createdAt = existing.map(Task::createdAt) + .orElseGet(() -> context.now().minus(seed.createdDaysAgo(), ChronoUnit.DAYS)); + Instant desiredUpdatedAt = seed.status() == TaskStatus.COMPLETED + ? context.now() + : context.now().minus(Math.max(seed.createdDaysAgo() - 1, 0), ChronoUnit.DAYS); + Instant updatedAt = desiredUpdatedAt.isBefore(createdAt) ? createdAt : desiredUpdatedAt; + long version = existing.map(Task::version).orElse(0L); + taskRepository.save(new Task( + seed.taskId(), + context.companyId(), + seed.workerId(), + seed.caseId(), + seed.taskType(), + seed.workflowId(), + DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION, + seed.title(), + seed.description(), + content.businessDataJson(), + content.criticalFingerprint(), + 0L, + seed.source(), + seed.status(), + dueDate, + context.actorId(), + context.actorId(), + createdAt, + updatedAt, + version + )); + } + + void verifyExisting(Task task, TaskSeed seed, DemoOperationalSeedContext context) { + if (!seed.taskId().equals(task.taskId()) + || !context.companyId().equals(task.companyId()) + || !seed.workerId().equals(task.workerId()) + || !seed.caseId().equals(task.caseId()) + || seed.taskType() != task.taskType() + || !seed.workflowId().equals(task.workflowId()) + || !DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION.equals(task.workflowCatalogVersion()) + || !seed.title().equals(task.title()) + || !seed.description().equals(task.description()) + || seed.source() != task.source() + || seed.status() != task.status() + || task.contentRevision() != 0L + || !context.actorId().equals(task.createdBy()) + || !context.actorId().equals(task.updatedBy())) { + throw new IllegalStateException( + "a reserved demo task id already belongs to different task data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java new file mode 100644 index 00000000..defea8a4 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java @@ -0,0 +1,79 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.time.LocalDate; +import java.util.Objects; +import java.util.Optional; + +final class DemoWorkerDocumentSeeder { + + private final WorkerDocumentRepository workerDocumentRepository; + + DemoWorkerDocumentSeeder(WorkerDocumentRepository workerDocumentRepository) { + this.workerDocumentRepository = Objects.requireNonNull( + workerDocumentRepository, + "workerDocumentRepository must not be null" + ); + } + + void seed(DocumentSeed seed, DemoOperationalSeedContext context) { + LocalDate expiryDate = context.today().plusDays(seed.expiryDays()); + Optional existing = + workerDocumentRepository.findByIdAndWorkerIdAndCompanyId( + seed.documentId(), + seed.workerId(), + context.companyId() + ); + existing.ifPresent(document -> verifyExisting(document, seed, context)); + if (existing.isEmpty()) { + workerDocumentRepository.insert(WorkerDocument.create( + seed.documentId(), + seed.workerId(), + context.companyId(), + seed.documentType(), + seed.submissionStatus(), + expiryDate, + seed.destination(), + seed.note(), + context.now() + )); + return; + } + WorkerDocument document = existing.orElseThrow(); + workerDocumentRepository.update(new WorkerDocument( + document.workerDocumentId(), + document.workerId(), + document.companyId(), + document.documentType(), + document.submissionStatus(), + expiryDate, + document.destination(), + document.note(), + null, + document.createdAt(), + context.now(), + document.version() + )); + } + + void verifyExisting( + WorkerDocument document, + DocumentSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.documentId().equals(document.workerDocumentId()) + || !seed.workerId().equals(document.workerId()) + || !context.companyId().equals(document.companyId()) + || seed.documentType() != document.documentType() + || seed.submissionStatus() != document.submissionStatus() + || !Objects.equals(seed.destination(), document.destination()) + || !Objects.equals(seed.note(), document.note()) + || document.fileId() != null) { + throw new IllegalStateException( + "a reserved demo worker document id already belongs to different document data" + ); + } + } +} From 411780040474efa8830051153c2323b5939735c7 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 3 Aug 2026 13:49:45 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(seed):=20=EA=B7=BC=EB=A1=9C=EC=9E=90?= =?UTF-8?q?=20=EC=9A=B4=EC=98=81=ED=98=95=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Demo Company 근로자 시드를 28명으로 확장 - Figma 대표 근로자와 현실적인 국적·언어·근무 상태 구성 - Clock 기반 체류·계약 날짜 분포 및 재실행 갱신 지원 - Test Company의 기존 5명 격리 데이터셋 유지 - 근로자 fixture를 별도 catalog로 분리 - 기존 근로자 시드 테스트 기대값 갱신 --- .../seed/DemoWorkerSeedCatalog.java | 124 ++++++++++++++++++ .../seed/DemoWorkerSeedRunner.java | 109 ++++++--------- .../seed/DemoAuthSeedIntegrationTest.java | 2 +- .../seed/DemoWorkerSeedRunnerTest.java | 26 +++- 4 files changed, 190 insertions(+), 71 deletions(-) create mode 100644 src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java new file mode 100644 index 00000000..74b154d9 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java @@ -0,0 +1,124 @@ +package com.fowoco.server.worker.infrastructure.seed; + +import com.fowoco.server.worker.domain.WorkerStatus; +import java.util.List; +import java.util.UUID; + +final class DemoWorkerSeedCatalog { + + private static final List DEMO_WORKERS = List.of( + activeWorker("92000000-0000-0000-0000-000000000001", "데모 근로자 01", "VN", "vi", 30, 180), + activeWorker("92000000-0000-0000-0000-000000000002", "데모 근로자 02", "KH", "km", 60, 210), + activeWorker("92000000-0000-0000-0000-000000000003", "데모 근로자 03", "NP", "ne", 90, 240), + activeWorker("92000000-0000-0000-0000-000000000004", "데모 근로자 04", "ID", "id", 120, 270), + activeWorker("92000000-0000-0000-0000-000000000005", "데모 근로자 05", "PH", "en", 180, 365), + activeWorker("92000000-0000-0000-0000-000000000006", "응웬반A", "VN", "vi", 12, 180), + activeWorker("92000000-0000-0000-0000-000000000007", "아디 수르야", "ID", "id", 365, 365), + activeWorker("92000000-0000-0000-0000-000000000008", "소팔 타망", "NP", "ne", 21, 120), + activeWorker("92000000-0000-0000-0000-000000000009", "라니 위자야", "ID", "id", 7, 90), + activeWorker("92000000-0000-0000-0000-000000000010", "민 아웅", "MM", "my", 62, 20), + activeWorker("92000000-0000-0000-0000-000000000011", "파티마 누르", "ID", "id", 35, 150), + activeWorker("92000000-0000-0000-0000-000000000012", "트란 티 마이", "VN", "vi", 3, 45), + activeWorker("92000000-0000-0000-0000-000000000013", "쩐 꾸옥 바오", "VN", "vi", 5, 75), + onLeaveWorker("92000000-0000-0000-0000-000000000014", "분미 찬사몽", "LA", "lo", 7, 100), + activeWorker("92000000-0000-0000-0000-000000000015", "찬다라 소쿤", "KH", "km", 21, 135), + activeWorker("92000000-0000-0000-0000-000000000016", "니말 페레라", "LK", "si", 35, 180), + activeWorker("92000000-0000-0000-0000-000000000017", "라훌 쿠마르", "IN", "hi", 62, 210), + activeWorker("92000000-0000-0000-0000-000000000018", "모하메드 라힘", "BD", "bn", 75, 240), + activeWorker("92000000-0000-0000-0000-000000000019", "누르 아지자", "ID", "id", 89, 270), + activeWorker("92000000-0000-0000-0000-000000000020", "아웅 쪼 민", "MM", "my", 91, 300), + onLeaveWorker("92000000-0000-0000-0000-000000000021", "수리야 프라사드", "LK", "ta", 150, 330), + activeWorker("92000000-0000-0000-0000-000000000022", "프라딥 구룽", "NP", "ne", 240, 365), + activeWorker("92000000-0000-0000-0000-000000000023", "솜차이 차이야", "TH", "th", 365, 400), + activeWorker("92000000-0000-0000-0000-000000000024", "응우옌 티 란", "VN", "vi", 6, 60), + onLeaveWorker("92000000-0000-0000-0000-000000000025", "데위 사푸트리", "ID", "id", null, 240), + activeWorker("92000000-0000-0000-0000-000000000026", "띤 자르 윈", "MM", "my", 12, 90), + activeWorker("92000000-0000-0000-0000-000000000027", "압둘 카림", "BD", "bn", 90, 180), + activeWorker("92000000-0000-0000-0000-000000000028", "비벡 타파", "NP", "ne", 540, 540) + ); + + private static final List TEST_WORKERS = List.of( + activeWorker("93000000-0000-0000-0000-000000000001", "테스트 근로자 01", "TH", "th", 45, 190), + activeWorker("93000000-0000-0000-0000-000000000002", "테스트 근로자 02", "MN", "mn", 75, 220), + activeWorker("93000000-0000-0000-0000-000000000003", "테스트 근로자 03", "BD", "bn", 105, 250), + activeWorker("93000000-0000-0000-0000-000000000004", "테스트 근로자 04", "UZ", "uz", 135, 280), + activeWorker("93000000-0000-0000-0000-000000000005", "테스트 근로자 05", "LK", "si", 195, 370) + ); + + List demoWorkers() { + return DEMO_WORKERS; + } + + List testWorkers() { + return TEST_WORKERS; + } + + private static WorkerSeed activeWorker( + String workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + Integer stayExpiryDays, + int contractEndDays + ) { + return worker( + workerId, + displayName, + nationalityCode, + preferredLanguage, + WorkerStatus.ACTIVE, + stayExpiryDays, + contractEndDays + ); + } + + private static WorkerSeed onLeaveWorker( + String workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + Integer stayExpiryDays, + int contractEndDays + ) { + return worker( + workerId, + displayName, + nationalityCode, + preferredLanguage, + WorkerStatus.ON_LEAVE, + stayExpiryDays, + contractEndDays + ); + } + + private static WorkerSeed worker( + String workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + WorkerStatus workStatus, + Integer stayExpiryDays, + int contractEndDays + ) { + return new WorkerSeed( + UUID.fromString(workerId), + displayName, + nationalityCode, + preferredLanguage, + workStatus, + stayExpiryDays, + contractEndDays + ); + } + + record WorkerSeed( + UUID workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + WorkerStatus workStatus, + Integer stayExpiryDays, + int contractEndDays + ) { + } +} diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java index 27b77327..0d3f6d92 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java @@ -5,6 +5,7 @@ import com.fowoco.server.company.domain.Company; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.infrastructure.seed.DemoWorkerSeedCatalog.WorkerSeed; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; @@ -23,25 +24,11 @@ class DemoWorkerSeedRunner implements ApplicationRunner { private static final Logger LOGGER = LoggerFactory.getLogger(DemoWorkerSeedRunner.class); - private static final List DEMO_WORKERS = List.of( - demoWorker("92000000-0000-0000-0000-000000000001", "데모 근로자 01", "VN", "vi", 30, 180), - demoWorker("92000000-0000-0000-0000-000000000002", "데모 근로자 02", "KH", "km", 60, 210), - demoWorker("92000000-0000-0000-0000-000000000003", "데모 근로자 03", "NP", "ne", 90, 240), - demoWorker("92000000-0000-0000-0000-000000000004", "데모 근로자 04", "ID", "id", 120, 270), - demoWorker("92000000-0000-0000-0000-000000000005", "데모 근로자 05", "PH", "en", 180, 365) - ); - private static final List TEST_WORKERS = List.of( - demoWorker("93000000-0000-0000-0000-000000000001", "테스트 근로자 01", "TH", "th", 45, 190), - demoWorker("93000000-0000-0000-0000-000000000002", "테스트 근로자 02", "MN", "mn", 75, 220), - demoWorker("93000000-0000-0000-0000-000000000003", "테스트 근로자 03", "BD", "bn", 105, 250), - demoWorker("93000000-0000-0000-0000-000000000004", "테스트 근로자 04", "UZ", "uz", 135, 280), - demoWorker("93000000-0000-0000-0000-000000000005", "테스트 근로자 05", "LK", "si", 195, 370) - ); - private final DemoAuthSeedProperties properties; private final CompanyRepository companyRepository; private final WorkerRepository workerRepository; private final Clock clock; + private final DemoWorkerSeedCatalog catalog; DemoWorkerSeedRunner( DemoAuthSeedProperties properties, @@ -56,6 +43,7 @@ class DemoWorkerSeedRunner implements ApplicationRunner { ); this.workerRepository = Objects.requireNonNull(workerRepository, "workerRepository must not be null"); this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.catalog = new DemoWorkerSeedCatalog(); } @Override @@ -63,14 +51,18 @@ class DemoWorkerSeedRunner implements ApplicationRunner { public void run(ApplicationArguments arguments) { Instant now = clock.instant(); LocalDate today = LocalDate.now(clock); - seedCompanyWorkers(properties.companyId(), DEMO_WORKERS, today, now); - seedCompanyWorkers(properties.testCompanyId(), TEST_WORKERS, today, now); - LOGGER.info("demo_worker_seed ready company_count={} worker_count={}", 2, 10); + seedCompanyWorkers(properties.companyId(), catalog.demoWorkers(), today, now); + seedCompanyWorkers(properties.testCompanyId(), catalog.testWorkers(), today, now); + LOGGER.info( + "demo_worker_seed ready company_count={} worker_count={}", + 2, + catalog.demoWorkers().size() + catalog.testWorkers().size() + ); } private void seedCompanyWorkers( UUID companyId, - List workers, + List workers, LocalDate today, Instant now ) { @@ -82,64 +74,47 @@ private void seedCompanyWorkers( workers.forEach(worker -> seedWorker(companyId, worker, today, now)); } - private void seedWorker(UUID companyId, DemoWorker demoWorker, LocalDate today, Instant now) { + private void seedWorker(UUID companyId, WorkerSeed workerSeed, LocalDate today, Instant now) { Optional existing = - workerRepository.findByWorkerIdAndCompanyId(demoWorker.workerId(), companyId); - if (existing.isPresent()) { - verifyExistingWorker(existing.orElseThrow(), companyId, demoWorker); - return; - } - workerRepository.insert(Worker.create( - demoWorker.workerId(), + workerRepository.findByWorkerIdAndCompanyId(workerSeed.workerId(), companyId); + existing.ifPresent(worker -> verifyExistingWorker(worker, companyId, workerSeed)); + Instant createdAt = existing.map(Worker::createdAt).orElse(now); + Instant updatedAt = now.isBefore(createdAt) ? createdAt : now; + Worker worker = new Worker( + workerSeed.workerId(), companyId, - demoWorker.displayName(), - demoWorker.nationalityCode(), - demoWorker.preferredLanguage(), - today.plusDays(demoWorker.stayExpiryDays()), + workerSeed.displayName(), + workerSeed.nationalityCode(), + workerSeed.preferredLanguage(), + workerSeed.workStatus(), + relativeDate(today, workerSeed.stayExpiryDays()), today.minusYears(1), - today.plusDays(demoWorker.contractEndDays()), - now - )); + today.plusDays(workerSeed.contractEndDays()), + createdAt, + updatedAt, + existing.map(Worker::version).orElse(0L) + ); + if (existing.isEmpty()) { + workerRepository.insert(worker); + return; + } + workerRepository.update(worker); } - private void verifyExistingWorker(Worker worker, UUID companyId, DemoWorker demoWorker) { - if (!demoWorker.workerId().equals(worker.workerId()) + private void verifyExistingWorker(Worker worker, UUID companyId, WorkerSeed workerSeed) { + if (!workerSeed.workerId().equals(worker.workerId()) || !companyId.equals(worker.companyId()) - || !demoWorker.displayName().equals(worker.displayName()) - || !demoWorker.nationalityCode().equals(worker.nationalityCode()) - || !demoWorker.preferredLanguage().equals(worker.preferredLanguage()) - || !worker.isCurrentlyEmployed()) { + || !workerSeed.displayName().equals(worker.displayName()) + || !workerSeed.nationalityCode().equals(worker.nationalityCode()) + || !workerSeed.preferredLanguage().equals(worker.preferredLanguage()) + || workerSeed.workStatus() != worker.workStatus()) { throw new IllegalStateException( - "a reserved demo worker id already belongs to different or inactive worker data" + "a reserved demo worker id already belongs to different worker data" ); } } - private static DemoWorker demoWorker( - String workerId, - String displayName, - String nationalityCode, - String preferredLanguage, - int stayExpiryDays, - int contractEndDays - ) { - return new DemoWorker( - UUID.fromString(workerId), - displayName, - nationalityCode, - preferredLanguage, - stayExpiryDays, - contractEndDays - ); - } - - private record DemoWorker( - UUID workerId, - String displayName, - String nationalityCode, - String preferredLanguage, - int stayExpiryDays, - int contractEndDays - ) { + private LocalDate relativeDate(LocalDate today, Integer days) { + return days == null ? null : today.plusDays(days); } } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index fe36bf17..96dd5417 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -118,7 +118,7 @@ void enabledSeedCreatesIdempotentCompanyAccountWorkerAndOperationalDataAfterFlyw "SELECT COUNT(*) FROM worker WHERE company_id = ?", Integer.class, COMPANY_ID - )).isEqualTo(5); + )).isEqualTo(28); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM worker WHERE company_id = ?", Integer.class, diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java index c175ef8b..e93e8c5b 100644 --- a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java @@ -8,6 +8,7 @@ import com.fowoco.server.worker.application.WorkerSearchQuery; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.domain.WorkerStatus; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; @@ -32,7 +33,7 @@ class DemoWorkerSeedRunnerTest { private static final Instant NOW = Instant.parse("2026-07-31T00:00:00Z"); @Test - void createsFiveIdempotentWorkersForEachDemoCompany() throws Exception { + void createsOperationalDemoWorkersAndKeepsTestCompanySmall() throws Exception { InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); companyRepository.insert(Company.create(DEMO_COMPANY_ID, "FOWOCO Demo Company", NOW)); companyRepository.insert(Company.create(TEST_COMPANY_ID, "FOWOCO Test Company", NOW)); @@ -49,7 +50,7 @@ void createsFiveIdempotentWorkersForEachDemoCompany() throws Exception { assertThat(workerRepository.workers.values()) .filteredOn(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) - .hasSize(5); + .hasSize(28); assertThat(workerRepository.workers.values()) .filteredOn(worker -> worker.companyId().equals(TEST_COMPANY_ID)) .hasSize(5); @@ -58,6 +59,21 @@ void createsFiveIdempotentWorkersForEachDemoCompany() throws Exception { assertThat(workerRepository.workers.get( UUID.fromString("92000000-0000-0000-0000-000000000001") ).stayExpiryDate()).isEqualTo(LocalDate.of(2026, 8, 30)); + assertThat(workerRepository.workers.get( + UUID.fromString("92000000-0000-0000-0000-000000000006") + )).satisfies(worker -> { + assertThat(worker.displayName()).isEqualTo("응웬반A"); + assertThat(worker.nationalityCode()).isEqualTo("VN"); + assertThat(worker.preferredLanguage()).isEqualTo("vi"); + assertThat(worker.stayExpiryDate()).isEqualTo(LocalDate.of(2026, 8, 12)); + }); + assertThat(workerRepository.workers.values()) + .filteredOn(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) + .filteredOn(worker -> worker.workStatus() == WorkerStatus.ON_LEAVE) + .hasSize(3); + assertThat(workerRepository.workers.get( + UUID.fromString("92000000-0000-0000-0000-000000000025") + ).stayExpiryDate()).isNull(); } private DemoAuthSeedProperties properties() { @@ -110,7 +126,11 @@ public Optional findByWorkerIdAndCompanyId(UUID workerId, UUID companyId @Override public Worker update(Worker worker) { - throw new UnsupportedOperationException(); + if (!workers.containsKey(worker.workerId())) { + throw new IllegalStateException("worker to update was not found"); + } + workers.put(worker.workerId(), worker); + return worker; } @Override From f5d5186bd34015809d1b919b824ce296e4ebe70d Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 3 Aug 2026 14:38:06 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat(seed):=20=EC=97=85=EB=AC=B4=C2=B7?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Demo Company Task 24개와 WorkerDocument 84개 구성 - Task 타입·상태와 문서 상태 목표 분포 적용 - 대표 Figma 근로자 중심 업무·문서 시나리오 추가 - Test Company에 Task 3개와 문서 8개 격리 데이터 추가 - tenant별 seed context와 근로자 참조 검증 확장 - 고정 UUID, 상대 날짜, 재실행 멱등성 유지 --- .../seed/DemoDocumentSeedCatalog.java | 192 ++++++++++++ .../seed/DemoOperationalSeedCatalog.java | 282 ++++++------------ .../DemoOperationalSeedConfiguration.java | 3 + .../seed/DemoOperationalSeedContext.java | 24 +- .../seed/DemoOperationalSeedRunner.java | 46 ++- .../seed/DemoOperationalSeedVerifier.java | 46 +-- .../seed/DemoTaskSeedCatalog.java | 187 ++++++++++++ .../seed/DemoWorkerDocumentSeeder.java | 4 +- .../seed/DemoAuthSeedIntegrationTest.java | 29 +- 9 files changed, 588 insertions(+), 225 deletions(-) create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java new file mode 100644 index 00000000..c73dae34 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java @@ -0,0 +1,192 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +final class DemoDocumentSeedCatalog { + + private static final Set MISSING_ADDITION_INDICES = + Set.of(0, 8, 14, 18, 24, 30, 36, 42, 48, 54, 60, 66, 72, 76); + private static final Set SUBMITTED_ADDITION_INDICES = + Set.of(1, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 69, 73); + private static final Integer[] EXPIRY_DAY_OFFSETS = {-20, 0, 7, 25, 45, 75, 120, 240, null}; + + private DemoDocumentSeedCatalog() { + } + + static List demoDocuments() { + List documents = new ArrayList<>(84); + addExistingDocuments(documents); + int documentNumber = 8; + int additionIndex = 0; + for (int workerNumber = 1; workerNumber <= 28; workerNumber++) { + for (DocumentType documentType : additionalTypes(workerNumber)) { + SubmissionStatus status = additionStatus(additionIndex); + documents.add(document( + "95000000-0000-0000-0000-000000000", + documentNumber, + "92000000-0000-0000-0000-000000000", + workerNumber, + documentType, + status, + EXPIRY_DAY_OFFSETS[additionIndex % EXPIRY_DAY_OFFSETS.length], + destination(documentType), + note(documentType, status) + )); + documentNumber++; + additionIndex++; + } + } + return List.copyOf(documents); + } + + static List testDocuments() { + return List.of( + testDocument(1, 1, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, 120), + testDocument(2, 1, DocumentType.ARC, SubmissionStatus.MISSING, 15), + testDocument(3, 2, DocumentType.CONTRACT, SubmissionStatus.SUBMITTED, 45), + testDocument(4, 2, DocumentType.PERMIT, SubmissionStatus.VERIFIED, 180), + testDocument(5, 3, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, null), + testDocument(6, 3, DocumentType.ARC, SubmissionStatus.SUBMITTED, 7), + testDocument(7, 4, DocumentType.CONTRACT, SubmissionStatus.MISSING, -10), + testDocument(8, 5, DocumentType.PERMIT, SubmissionStatus.VERIFIED, 240) + ); + } + + private static void addExistingDocuments(List documents) { + documents.add(document(1, 1, DocumentType.PASSPORT_COPY, SubmissionStatus.MISSING, 25, + "체류기간 연장", "만료 임박 여권 사본을 다시 받아야 합니다.")); + documents.add(document(2, 1, DocumentType.ARC, SubmissionStatus.VERIFIED, 180, + "체류기간 연장", "외국인등록증 사본 확인 완료")); + documents.add(document(3, 2, DocumentType.CONTRACT, SubmissionStatus.SUBMITTED, 90, + "근로계약 갱신", "서명본 검토 대기")); + documents.add(document(4, 2, DocumentType.PERMIT, SubmissionStatus.VERIFIED, 150, + "고용허가기간 연장", "고용허가서 확인 완료")); + documents.add(document(5, 3, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, 300, + "체류기간 연장", "유효한 여권 사본")); + documents.add(document(6, 4, DocumentType.ARC, SubmissionStatus.MISSING, 45, + "고용허가기간 연장", "외국인등록증 사본 요청 필요")); + documents.add(document(7, 5, DocumentType.CONTRACT, SubmissionStatus.SUBMITTED, 120, + "근로계약 갱신", "갱신 계약서 제출 완료")); + } + + private static List additionalTypes(int workerNumber) { + return switch (workerNumber) { + case 1 -> List.of(DocumentType.CONTRACT); + case 2 -> List.of(DocumentType.ARC); + case 3 -> List.of(DocumentType.ARC, DocumentType.CONTRACT); + case 4 -> List.of(DocumentType.CONTRACT, DocumentType.PERMIT); + case 5 -> List.of(DocumentType.PASSPORT_COPY, DocumentType.ARC); + default -> List.of( + DocumentType.PASSPORT_COPY, + DocumentType.ARC, + workerNumber % 4 == 0 ? DocumentType.PERMIT : DocumentType.CONTRACT + ); + }; + } + + private static SubmissionStatus additionStatus(int additionIndex) { + if (MISSING_ADDITION_INDICES.contains(additionIndex)) { + return SubmissionStatus.MISSING; + } + if (SUBMITTED_ADDITION_INDICES.contains(additionIndex)) { + return SubmissionStatus.SUBMITTED; + } + return SubmissionStatus.VERIFIED; + } + + private static String destination(DocumentType documentType) { + return switch (documentType) { + case PASSPORT_COPY, ARC -> "체류기간 연장"; + case CONTRACT -> "근로계약 갱신"; + case PERMIT -> "고용허가기간 연장"; + }; + } + + private static String note(DocumentType documentType, SubmissionStatus status) { + String documentName = switch (documentType) { + case PASSPORT_COPY -> "여권 사본"; + case ARC -> "외국인등록증 사본"; + case CONTRACT -> "근로계약서"; + case PERMIT -> "고용허가서"; + }; + return switch (status) { + case MISSING -> documentName + " 요청 필요"; + case SUBMITTED -> documentName + " 제출본 검토 대기"; + case VERIFIED -> documentName + " 확인 완료"; + }; + } + + private static DocumentSeed document( + int documentNumber, + int workerNumber, + DocumentType documentType, + SubmissionStatus status, + Integer expiryDays, + String destination, + String note + ) { + return document( + "95000000-0000-0000-0000-000000000", + documentNumber, + "92000000-0000-0000-0000-000000000", + workerNumber, + documentType, + status, + expiryDays, + destination, + note + ); + } + + private static DocumentSeed testDocument( + int documentNumber, + int workerNumber, + DocumentType documentType, + SubmissionStatus status, + Integer expiryDays + ) { + return document( + "98000000-0000-0000-0000-000000000", + documentNumber, + "93000000-0000-0000-0000-000000000", + workerNumber, + documentType, + status, + expiryDays, + destination(documentType), + note(documentType, status) + ); + } + + private static DocumentSeed document( + String documentPrefix, + int documentNumber, + String workerPrefix, + int workerNumber, + DocumentType documentType, + SubmissionStatus status, + Integer expiryDays, + String destination, + String note + ) { + return new DocumentSeed( + demoUuid(documentPrefix, documentNumber), + demoUuid(workerPrefix, workerNumber), + documentType, + status, + expiryDays, + destination, + note + ); + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index 28cdeae9..f15a27b2 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -6,150 +6,27 @@ import com.fowoco.server.task.domain.TaskType; import com.fowoco.server.worker.domain.DocumentType; import com.fowoco.server.worker.domain.SubmissionStatus; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; final class DemoOperationalSeedCatalog { static final String WORKFLOW_CATALOG_VERSION = "0.2.0"; static final UUID TIMELINE_TASK_ID = UUID.fromString("94000000-0000-0000-0000-000000000002"); + static final UUID TEST_ADMIN_USER_ID = + UUID.fromString("91000000-0000-0000-0000-000000000002"); - private final List tasks = List.of( - task( - "94000000-0000-0000-0000-000000000001", - "94100000-0000-0000-0000-000000000001", - "92000000-0000-0000-0000-000000000001", - TaskType.STAY_PERIOD_EXTENSION, - "WF-STY-001", - "AI 추천: 체류기간 연장 준비", - "체류기간 만료 전에 필요한 서류를 확인하고 연장 신청을 준비합니다.", - TaskSource.AI_CANDIDATE, - TaskStatus.DRAFT, - 3, - 6 - ), - task( - "94000000-0000-0000-0000-000000000002", - "94100000-0000-0000-0000-000000000002", - "92000000-0000-0000-0000-000000000002", - TaskType.RECONTRACT, - "WF-CON-001", - "근로계약 갱신 검토", - "갱신 계약 조건과 제출 서류를 검토하고 승인을 기다립니다.", - TaskSource.MANUAL, - TaskStatus.READY_FOR_REVIEW, - 20, - 5 - ), - task( - "94000000-0000-0000-0000-000000000003", - "94100000-0000-0000-0000-000000000003", - "92000000-0000-0000-0000-000000000003", - TaskType.STAY_PERIOD_EXTENSION, - "WF-STY-001", - "여권 사본 제출 대기", - "근로자에게 최신 여권 사본을 요청했으며 응답을 기다립니다.", - TaskSource.MANUAL, - TaskStatus.WAITING_WORKER, - 6, - 4 - ), - task( - "94000000-0000-0000-0000-000000000004", - "94100000-0000-0000-0000-000000000004", - "92000000-0000-0000-0000-000000000004", - TaskType.EMPLOYMENT_PERIOD_EXTENSION, - "WF-CON-001", - "고용허가기간 연장 결과 대기", - "관할 기관에 제출을 완료하고 처리 결과를 기다립니다.", - TaskSource.MANUAL, - TaskStatus.WAITING_EXTERNAL, - 90, - 12 - ), - task( - "94000000-0000-0000-0000-000000000005", - "94100000-0000-0000-0000-000000000005", - "92000000-0000-0000-0000-000000000005", - TaskType.RECONTRACT, - "WF-CON-001", - "근로계약 갱신 완료", - "서명된 근로계약서 확인과 갱신 처리를 완료했습니다.", - TaskSource.SYSTEM_DDAY, - TaskStatus.COMPLETED, - 30, - 10 - ) - ); - - private final List documents = List.of( - document( - "95000000-0000-0000-0000-000000000001", - "92000000-0000-0000-0000-000000000001", - DocumentType.PASSPORT_COPY, - SubmissionStatus.MISSING, - 25, - "체류기간 연장", - "만료 임박 여권 사본을 다시 받아야 합니다." - ), - document( - "95000000-0000-0000-0000-000000000002", - "92000000-0000-0000-0000-000000000001", - DocumentType.ARC, - SubmissionStatus.VERIFIED, - 180, - "체류기간 연장", - "외국인등록증 사본 확인 완료" - ), - document( - "95000000-0000-0000-0000-000000000003", - "92000000-0000-0000-0000-000000000002", - DocumentType.CONTRACT, - SubmissionStatus.SUBMITTED, - 90, - "근로계약 갱신", - "서명본 검토 대기" - ), - document( - "95000000-0000-0000-0000-000000000004", - "92000000-0000-0000-0000-000000000002", - DocumentType.PERMIT, - SubmissionStatus.VERIFIED, - 150, - "고용허가기간 연장", - "고용허가서 확인 완료" - ), - document( - "95000000-0000-0000-0000-000000000005", - "92000000-0000-0000-0000-000000000003", - DocumentType.PASSPORT_COPY, - SubmissionStatus.VERIFIED, - 300, - "체류기간 연장", - "유효한 여권 사본" - ), - document( - "95000000-0000-0000-0000-000000000006", - "92000000-0000-0000-0000-000000000004", - DocumentType.ARC, - SubmissionStatus.MISSING, - 45, - "고용허가기간 연장", - "외국인등록증 사본 요청 필요" - ), - document( - "95000000-0000-0000-0000-000000000007", - "92000000-0000-0000-0000-000000000005", - DocumentType.CONTRACT, - SubmissionStatus.SUBMITTED, - 120, - "근로계약 갱신", - "갱신 계약서 제출 완료" - ) - ); - - private final List audits = List.of( + private final List demoTasks = DemoTaskSeedCatalog.demoTasks(); + private final List testTasks = DemoTaskSeedCatalog.testTasks(); + private final List demoDocuments = DemoDocumentSeedCatalog.demoDocuments(); + private final List testDocuments = DemoDocumentSeedCatalog.testDocuments(); + private final List demoAudits = List.of( audit( "96000000-0000-0000-0000-000000000001", AuditAction.TASK_CREATED, @@ -173,64 +50,101 @@ final class DemoOperationalSeedCatalog { ) ); - List tasks() { - return tasks; + DemoOperationalSeedCatalog() { + verifyDefinitions(); } - List documents() { - return documents; + List demoTasks() { + return demoTasks; } - List audits() { - return audits; + List testTasks() { + return testTasks; } - private static TaskSeed task( - String taskId, - String caseId, - String workerId, - TaskType taskType, - String workflowId, - String title, - String description, - TaskSource source, - TaskStatus status, - int dueDays, - int createdDaysAgo - ) { - return new TaskSeed( - UUID.fromString(taskId), - UUID.fromString(caseId), - UUID.fromString(workerId), - taskType, - workflowId, - title, - description, - source, - status, - dueDays, - createdDaysAgo + List demoDocuments() { + return demoDocuments; + } + + List testDocuments() { + return testDocuments; + } + + List demoAudits() { + return demoAudits; + } + + private void verifyDefinitions() { + requireSize(demoTasks, 24, "Demo Company task"); + requireSize(testTasks, 3, "Test Company task"); + requireSize(demoDocuments, 84, "Demo Company document"); + requireSize(testDocuments, 8, "Test Company document"); + requireDistribution( + demoTasks.stream().map(TaskSeed::taskType).toList(), + Map.of( + TaskType.STAY_PERIOD_EXTENSION, 10L, + TaskType.RECONTRACT, 8L, + TaskType.EMPLOYMENT_PERIOD_EXTENSION, 6L + ), + "Demo Company task type" + ); + requireDistribution( + demoTasks.stream().map(TaskSeed::status).toList(), + Map.of( + TaskStatus.DRAFT, 3L, + TaskStatus.NEEDS_INFO, 2L, + TaskStatus.READY_FOR_REVIEW, 4L, + TaskStatus.APPROVED, 2L, + TaskStatus.WAITING_WORKER, 4L, + TaskStatus.WAITING_EXTERNAL, 3L, + TaskStatus.COMPLETED, 5L, + TaskStatus.CANCELLED, 1L + ), + "Demo Company task status" + ); + requireDistribution( + demoDocuments.stream().map(DocumentSeed::submissionStatus).toList(), + Map.of( + SubmissionStatus.VERIFIED, 48L, + SubmissionStatus.SUBMITTED, 20L, + SubmissionStatus.MISSING, 16L + ), + "Demo Company document status" + ); + requireUniqueIds( + Stream.concat(demoTasks.stream(), testTasks.stream()).map(TaskSeed::taskId).toList(), + "task" + ); + requireUniqueIds( + Stream.concat(demoDocuments.stream(), testDocuments.stream()) + .map(DocumentSeed::documentId) + .toList(), + "document" ); } - private static DocumentSeed document( - String documentId, - String workerId, - DocumentType documentType, - SubmissionStatus submissionStatus, - int expiryDays, - String destination, - String note + private static void requireSize(List seeds, int expected, String name) { + if (seeds.size() != expected) { + throw new IllegalStateException(name + " seed count must be " + expected); + } + } + + private static void requireDistribution( + List values, + Map expected, + String name ) { - return new DocumentSeed( - UUID.fromString(documentId), - UUID.fromString(workerId), - documentType, - submissionStatus, - expiryDays, - destination, - note - ); + Map actual = values.stream() + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + if (!expected.equals(actual)) { + throw new IllegalStateException(name + " seed distribution is invalid: " + actual); + } + } + + private static void requireUniqueIds(List ids, String name) { + if (new HashSet<>(ids).size() != ids.size()) { + throw new IllegalStateException("duplicate reserved demo " + name + " id"); + } } private static AuditSeed audit( @@ -269,7 +183,7 @@ record DocumentSeed( UUID workerId, DocumentType documentType, SubmissionStatus submissionStatus, - int expiryDays, + Integer expiryDays, String destination, String note ) { diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java index 21599375..2a69c264 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -5,6 +5,7 @@ import com.fowoco.server.task.application.TaskContentCodec; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; import java.time.Clock; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; @@ -20,6 +21,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( TaskRepository taskRepository, TaskContentCodec taskContentCodec, WorkerDocumentRepository workerDocumentRepository, + WorkerRepository workerRepository, AuditEventRepository auditEventRepository, Clock clock ) { @@ -30,6 +32,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( DemoOperationalSeedVerifier verifier = new DemoOperationalSeedVerifier( taskRepository, workerDocumentRepository, + workerRepository, taskSeeder, documentSeeder, auditSeeder diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java index 9d57c027..1976a856 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java @@ -1,7 +1,6 @@ package com.fowoco.server.demo.infrastructure.seed; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; -import java.time.Clock; import java.time.Instant; import java.time.LocalDate; import java.util.Objects; @@ -21,11 +20,12 @@ record DemoOperationalSeedContext( Objects.requireNonNull(now, "now must not be null"); } - static DemoOperationalSeedContext from(DemoAuthSeedProperties properties, Clock clock) { + static DemoOperationalSeedContext demo( + DemoAuthSeedProperties properties, + LocalDate today, + Instant now + ) { Objects.requireNonNull(properties, "properties must not be null"); - Objects.requireNonNull(clock, "clock must not be null"); - Instant now = clock.instant(); - LocalDate today = LocalDate.now(clock); return new DemoOperationalSeedContext( properties.companyId(), properties.adminUserId(), @@ -33,4 +33,18 @@ static DemoOperationalSeedContext from(DemoAuthSeedProperties properties, Clock now ); } + + static DemoOperationalSeedContext test( + DemoAuthSeedProperties properties, + LocalDate today, + Instant now + ) { + Objects.requireNonNull(properties, "properties must not be null"); + return new DemoOperationalSeedContext( + properties.testCompanyId(), + DemoOperationalSeedCatalog.TEST_ADMIN_USER_ID, + today, + now + ); + } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java index cadc13ce..ab9e28b6 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -1,7 +1,13 @@ package com.fowoco.server.demo.infrastructure.seed; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,17 +50,37 @@ class DemoOperationalSeedRunner implements ApplicationRunner { @Override @Transactional public void run(ApplicationArguments arguments) { - DemoOperationalSeedContext context = DemoOperationalSeedContext.from(properties, clock); - catalog.tasks().forEach(seed -> taskSeeder.seed(seed, context)); - catalog.documents().forEach(seed -> documentSeeder.seed(seed, context)); - catalog.audits().forEach(seed -> auditSeeder.seed(seed, context)); - verifier.verify(catalog, context); + Instant now = clock.instant(); + LocalDate today = LocalDate.now(clock); + DemoOperationalSeedContext demoContext = DemoOperationalSeedContext.demo(properties, today, now); + DemoOperationalSeedContext testContext = DemoOperationalSeedContext.test(properties, today, now); + seedDataset( + catalog.demoTasks(), + catalog.demoDocuments(), + catalog.demoAudits(), + demoContext + ); + seedDataset(catalog.testTasks(), catalog.testDocuments(), List.of(), testContext); LOGGER.info( - "demo_operational_seed ready company_id={} task_count={} document_count={} audit_count={}", - context.companyId(), - catalog.tasks().size(), - catalog.documents().size(), - catalog.audits().size() + "demo_operational_seed ready demo_task_count={} demo_document_count={} " + + "test_task_count={} test_document_count={} audit_count={}", + catalog.demoTasks().size(), + catalog.demoDocuments().size(), + catalog.testTasks().size(), + catalog.testDocuments().size(), + catalog.demoAudits().size() ); } + + private void seedDataset( + List tasks, + List documents, + List audits, + DemoOperationalSeedContext context + ) { + tasks.forEach(seed -> taskSeeder.seed(seed, context)); + documents.forEach(seed -> documentSeeder.seed(seed, context)); + audits.forEach(seed -> auditSeeder.seed(seed, context)); + verifier.verify(tasks, documents, audits, context); + } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java index 31f96525..246a77ef 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java @@ -7,6 +7,7 @@ import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.WorkerDocument; import java.util.HashSet; import java.util.List; @@ -18,6 +19,7 @@ final class DemoOperationalSeedVerifier { private final TaskRepository taskRepository; private final WorkerDocumentRepository workerDocumentRepository; + private final WorkerRepository workerRepository; private final DemoTaskSeeder taskSeeder; private final DemoWorkerDocumentSeeder documentSeeder; private final DemoAuditEventSeeder auditSeeder; @@ -25,6 +27,7 @@ final class DemoOperationalSeedVerifier { DemoOperationalSeedVerifier( TaskRepository taskRepository, WorkerDocumentRepository workerDocumentRepository, + WorkerRepository workerRepository, DemoTaskSeeder taskSeeder, DemoWorkerDocumentSeeder documentSeeder, DemoAuditEventSeeder auditSeeder @@ -34,19 +37,25 @@ final class DemoOperationalSeedVerifier { workerDocumentRepository, "workerDocumentRepository must not be null" ); + this.workerRepository = Objects.requireNonNull(workerRepository, "workerRepository must not be null"); this.taskSeeder = Objects.requireNonNull(taskSeeder, "taskSeeder must not be null"); this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); } - void verify(DemoOperationalSeedCatalog catalog, DemoOperationalSeedContext context) { - verifyUniqueIds(catalog.tasks().stream().map(TaskSeed::taskId).toList(), "task"); - verifyUniqueIds(catalog.documents().stream().map(DocumentSeed::documentId).toList(), "document"); - verifyUniqueIds(catalog.audits().stream().map(AuditSeed::auditEventId).toList(), "audit event"); - verifyDocumentWorkersHaveTasks(catalog); - catalog.tasks().forEach(seed -> verifyTask(seed, context)); - catalog.documents().forEach(seed -> verifyDocument(seed, context)); - catalog.audits().forEach(seed -> verifyAudit(seed, context)); + void verify( + List tasks, + List documents, + List audits, + DemoOperationalSeedContext context + ) { + verifyUniqueIds(tasks.stream().map(TaskSeed::taskId).toList(), "task"); + verifyUniqueIds(documents.stream().map(DocumentSeed::documentId).toList(), "document"); + verifyUniqueIds(audits.stream().map(AuditSeed::auditEventId).toList(), "audit event"); + verifyWorkersExist(tasks, documents, context); + tasks.forEach(seed -> verifyTask(seed, context)); + documents.forEach(seed -> verifyDocument(seed, context)); + audits.forEach(seed -> verifyAudit(seed, context)); } private void verifyTask(TaskSeed seed, DemoOperationalSeedContext context) { @@ -71,15 +80,18 @@ private void verifyAudit(AuditSeed seed, DemoOperationalSeedContext context) { auditSeeder.verifyExisting(event, seed, context); } - private void verifyDocumentWorkersHaveTasks(DemoOperationalSeedCatalog catalog) { - Set taskWorkerIds = catalog.tasks().stream() - .map(TaskSeed::workerId) - .collect(java.util.stream.Collectors.toSet()); - boolean orphanedDocument = catalog.documents().stream() - .map(DocumentSeed::workerId) - .anyMatch(workerId -> !taskWorkerIds.contains(workerId)); - if (orphanedDocument) { - throw new IllegalStateException("a demo document worker has no demo task"); + private void verifyWorkersExist( + List tasks, + List documents, + DemoOperationalSeedContext context + ) { + Set workerIds = new HashSet<>(); + tasks.stream().map(TaskSeed::workerId).forEach(workerIds::add); + documents.stream().map(DocumentSeed::workerId).forEach(workerIds::add); + boolean missingWorker = workerIds.stream().anyMatch(workerId -> + workerRepository.findByWorkerIdAndCompanyId(workerId, context.companyId()).isEmpty()); + if (missingWorker) { + throw new IllegalStateException("a demo task or document worker does not exist in its company"); } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java new file mode 100644 index 00000000..85da86f1 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java @@ -0,0 +1,187 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.util.List; +import java.util.UUID; + +final class DemoTaskSeedCatalog { + + private DemoTaskSeedCatalog() { + } + + static List demoTasks() { + return List.of( + task(1, 1, TaskType.STAY_PERIOD_EXTENSION, "AI 추천: 체류기간 연장 준비", + "체류기간 만료 전에 필요한 서류를 확인하고 연장 신청을 준비합니다.", + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 3, 6), + task(2, 2, TaskType.RECONTRACT, "근로계약 갱신 검토", + "갱신 계약 조건과 제출 서류를 검토하고 승인을 기다립니다.", + TaskSource.MANUAL, TaskStatus.READY_FOR_REVIEW, 20, 5), + task(3, 3, TaskType.STAY_PERIOD_EXTENSION, "여권 사본 제출 대기", + "근로자에게 최신 여권 사본을 요청했으며 응답을 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_WORKER, 6, 4), + task(4, 4, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용허가기간 연장 결과 대기", + "관할 기관에 제출을 완료하고 처리 결과를 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_EXTERNAL, 90, 12), + task(5, 5, TaskType.RECONTRACT, "근로계약 갱신 완료", + "서명된 근로계약서 확인과 갱신 처리를 완료했습니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.COMPLETED, 30, 10), + task(6, 6, TaskType.RECONTRACT, "응웬반A 재계약 검토", + "재계약 조건과 체류기간 연장 일정을 함께 검토합니다.", + TaskSource.MANUAL, TaskStatus.READY_FOR_REVIEW, 12, 3), + taskWithCase(7, 6, 6, TaskType.STAY_PERIOD_EXTENSION, "체류연장 업무 초안", + "체류기간 연장 신청을 위한 AI 초안을 확인합니다.", + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 0, 2), + taskWithCase(8, 6, 6, TaskType.STAY_PERIOD_EXTENSION, "여권 만료일 확인 대기", + "최신 여권 사본과 만료일 확인을 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_WORKER, 7, 2), + task(9, 8, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 정보 보완", + "연장 신청에 필요한 고용 정보와 제출 서류를 보완합니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.NEEDS_INFO, 4, 5), + task(10, 9, TaskType.STAY_PERIOD_EXTENSION, "체류서류 응답 대기", + "근로자에게 요청한 체류 관련 서류의 응답을 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_WORKER, 7, 4), + task(11, 11, TaskType.STAY_PERIOD_EXTENSION, "외국인등록증 사본 대기", + "체류연장 검토를 위해 외국인등록증 사본을 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_WORKER, 12, 6), + task(12, 10, TaskType.RECONTRACT, "재계약 서류 준비", + "계약 만료 전에 재계약 서류와 조건을 준비합니다.", + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 20, 7), + task(13, 7, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 자료 보완", + "지원 가능한 고용기간 연장 업무의 누락 정보를 확인합니다.", + TaskSource.MANUAL, TaskStatus.NEEDS_INFO, 21, 8), + task(14, 12, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 검토", + "제출 준비가 완료된 체류기간 연장 내용을 검토합니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.READY_FOR_REVIEW, 7, 3), + task(15, 13, TaskType.RECONTRACT, "재계약 조건 최종 검토", + "근로조건과 계약기간을 확인하고 최종 검토합니다.", + TaskSource.MANUAL, TaskStatus.READY_FOR_REVIEW, 21, 9), + task(16, 14, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 승인", + "고용기간 연장 신청 내용이 승인되어 제출을 준비합니다.", + TaskSource.MANUAL, TaskStatus.APPROVED, 35, 10), + task(17, 15, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 승인", + "체류기간 연장 신청 내용이 승인되었습니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.APPROVED, 62, 11), + task(18, 16, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 심사 대기", + "외부기관에 제출한 고용기간 연장 결과를 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_EXTERNAL, 90, 14), + task(19, 17, TaskType.RECONTRACT, "재계약 신고 결과 대기", + "재계약 관련 외부 신고 처리 결과를 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_EXTERNAL, 120, 16), + task(20, 18, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 완료", + "체류기간 연장 결과와 완료 자료를 확인했습니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.COMPLETED, 0, 20), + task(21, 19, TaskType.RECONTRACT, "재계약 처리 완료", + "서명 계약서와 재계약 처리 결과를 확인했습니다.", + TaskSource.MANUAL, TaskStatus.COMPLETED, 35, 25), + task(22, 20, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 완료", + "고용기간 연장 승인 결과를 확인하고 업무를 완료했습니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.COMPLETED, 62, 30), + task(23, 21, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 처리 완료", + "체류기간 연장 신청과 결과 확인을 완료했습니다.", + TaskSource.MANUAL, TaskStatus.COMPLETED, 90, 35), + task(24, 22, TaskType.RECONTRACT, "재계약 검토 취소", + "근로계약 종료 일정 변경으로 재계약 검토를 취소했습니다.", + TaskSource.MANUAL, TaskStatus.CANCELLED, 21, 12) + ); + } + + static List testTasks() { + return List.of( + testTask(1, 1, TaskType.STAY_PERIOD_EXTENSION, "테스트 체류연장 초안", + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 7, 2), + testTask(2, 2, TaskType.RECONTRACT, "테스트 재계약 검토", + TaskSource.MANUAL, TaskStatus.READY_FOR_REVIEW, 30, 5), + testTask(3, 3, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "테스트 고용기간 연장 완료", + TaskSource.SYSTEM_DDAY, TaskStatus.COMPLETED, 60, 10) + ); + } + + private static TaskSeed task( + int taskNumber, + int workerNumber, + TaskType taskType, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + return taskWithCase( + taskNumber, + taskNumber, + workerNumber, + taskType, + title, + description, + source, + status, + dueDays, + createdDaysAgo + ); + } + + private static TaskSeed taskWithCase( + int taskNumber, + int caseNumber, + int workerNumber, + TaskType taskType, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + return new TaskSeed( + demoUuid("94000000-0000-0000-0000-000000000", taskNumber), + demoUuid("94100000-0000-0000-0000-000000000", caseNumber), + demoUuid("92000000-0000-0000-0000-000000000", workerNumber), + taskType, + workflowId(taskType), + title, + description, + source, + status, + dueDays, + createdDaysAgo + ); + } + + private static TaskSeed testTask( + int taskNumber, + int workerNumber, + TaskType taskType, + String title, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + return new TaskSeed( + demoUuid("97000000-0000-0000-0000-000000000", taskNumber), + demoUuid("97100000-0000-0000-0000-000000000", taskNumber), + demoUuid("93000000-0000-0000-0000-000000000", workerNumber), + taskType, + workflowId(taskType), + title, + "Tenant 격리 확인을 위한 소규모 테스트 업무입니다.", + source, + status, + dueDays, + createdDaysAgo + ); + } + + private static String workflowId(TaskType taskType) { + return taskType == TaskType.STAY_PERIOD_EXTENSION ? "WF-STY-001" : "WF-CON-001"; + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java index defea8a4..4384548c 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java @@ -19,7 +19,9 @@ final class DemoWorkerDocumentSeeder { } void seed(DocumentSeed seed, DemoOperationalSeedContext context) { - LocalDate expiryDate = context.today().plusDays(seed.expiryDays()); + LocalDate expiryDate = seed.expiryDays() == null + ? null + : context.today().plusDays(seed.expiryDays()); Optional existing = workerDocumentRepository.findByIdAndWorkerIdAndCompanyId( seed.documentId(), diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index 96dd5417..3953b3d5 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -128,30 +128,33 @@ void enabledSeedCreatesIdempotentCompanyAccountWorkerAndOperationalDataAfterFlyw "SELECT COUNT(*) FROM task WHERE company_id = ?", Integer.class, COMPANY_ID - )).isEqualTo(5); + )).isEqualTo(24); assertThat(jdbcTemplate.queryForList( "SELECT status FROM task WHERE company_id = ?", String.class, COMPANY_ID - )).containsExactlyInAnyOrder( + )).contains( "DRAFT", + "NEEDS_INFO", "READY_FOR_REVIEW", + "APPROVED", "WAITING_WORKER", "WAITING_EXTERNAL", - "COMPLETED" + "COMPLETED", + "CANCELLED" ); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM task WHERE company_id = ? AND source = 'AI_CANDIDATE' " + "AND status = 'DRAFT'", Integer.class, COMPANY_ID - )).isEqualTo(1); + )).isEqualTo(3); List completedUpdates = jdbcTemplate.query( "SELECT updated_at FROM task WHERE company_id = ? AND status = 'COMPLETED'", (resultSet, rowNumber) -> resultSet.getTimestamp("updated_at").toInstant(), COMPANY_ID ); - assertThat(completedUpdates).hasSize(1).allMatch(updatedAt -> + assertThat(completedUpdates).hasSize(5).allMatch(updatedAt -> LocalDate.ofInstant(updatedAt, ZoneOffset.UTC).equals(LocalDate.now(clock))); List dueDates = jdbcTemplate.query( "SELECT due_date FROM task WHERE company_id = ?", @@ -167,7 +170,7 @@ void enabledSeedCreatesIdempotentCompanyAccountWorkerAndOperationalDataAfterFlyw "SELECT COUNT(*) FROM worker_document WHERE company_id = ?", Integer.class, COMPANY_ID - )).isEqualTo(7); + )).isEqualTo(84); assertThat(jdbcTemplate.queryForList( "SELECT submission_status FROM worker_document WHERE company_id = ?", String.class, @@ -179,12 +182,22 @@ void enabledSeedCreatesIdempotentCompanyAccountWorkerAndOperationalDataAfterFlyw COMPANY_ID ); assertThat(expiryDates).anyMatch(date -> - !date.isBefore(today) && !date.isAfter(today.plusDays(30))); + date != null && !date.isBefore(today) && !date.isAfter(today.plusDays(30))); assertThat(jdbcTemplate.queryForList( "SELECT COUNT(*) FROM worker_document WHERE company_id = ? GROUP BY worker_id", Integer.class, COMPANY_ID - )).hasSize(5).allMatch(count -> count >= 1 && count <= 2); + )).hasSize(28).allMatch(count -> count == 3); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM task WHERE company_id = ?", + Integer.class, + TEST_COMPANY_ID + )).isEqualTo(3); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE company_id = ?", + Integer.class, + TEST_COMPANY_ID + )).isEqualTo(8); UUID timelineTaskId = UUID.fromString("94000000-0000-0000-0000-000000000002"); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM audit_event WHERE company_id = ? AND target_id = ?", From c8b39ecc79ad7cac1d38f2b9a2a3581bc4018752 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 3 Aug 2026 15:10:03 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat(seed):=20=EC=B2=B4=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=C2=B7=EC=8A=B9=EC=9D=B8=C2=B7=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=9D=B4=EB=A0=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 워크플로 카탈로그 기반 데모 태스크 체크리스트 68건 추가 - 승인 상태별 요청 13건과 현재 태스크 상태 정합성 구성 - 태스크 상태 전이 이력 52건 및 취소·완료 사유 추가 - 고정 UUID 충돌 검증과 재실행 멱등성 보강 --- .../seed/DemoApprovalRequestSeeder.java | 126 ++++++++++++ .../seed/DemoOperationalSeedCatalog.java | 84 ++++++++ .../DemoOperationalSeedConfiguration.java | 20 ++ .../seed/DemoOperationalSeedRunner.java | 40 +++- .../seed/DemoOperationalSeedVerifier.java | 61 ++++++ .../seed/DemoTaskChecklistSeeder.java | 71 +++++++ .../infrastructure/seed/DemoTaskSeeder.java | 5 +- .../seed/DemoTaskTransitionSeeder.java | 95 +++++++++ .../seed/DemoTaskWorkflowSeedCatalog.java | 190 ++++++++++++++++++ 9 files changed, 689 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskChecklistSeeder.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskTransitionSeeder.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java new file mode 100644 index 00000000..f1aae8ea --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java @@ -0,0 +1,126 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.approval.domain.ApprovalRequest; +import com.fowoco.server.approval.domain.ApprovalStatus; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; + +final class DemoApprovalRequestSeeder { + + private static final String AI_SNAPSHOT_JSON = "{\"summary\":\"demo operational review\"}"; + private static final String HR_SNAPSHOT_JSON = "{\"review\":\"demo operational seed\"}"; + private static final String CHANGED_FIELDS_JSON = "[]"; + private static final String SOURCE_VERSIONS_JSON = "{\"workflow_catalog\":\"0.2.0\"}"; + + private final ApprovalRequestRepository approvalRepository; + private final TaskRepository taskRepository; + + DemoApprovalRequestSeeder( + ApprovalRequestRepository approvalRepository, + TaskRepository taskRepository + ) { + this.approvalRepository = Objects.requireNonNull( + approvalRepository, + "approvalRepository must not be null" + ); + this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); + } + + void seed(ApprovalSeed seed, DemoOperationalSeedContext context) { + Task task = taskRepository.findByIdAndCompanyId(seed.taskId(), context.companyId()) + .orElseThrow(() -> new IllegalStateException("a demo approval task does not exist")); + Optional existing = approvalRepository.findByIdAndCompanyId( + seed.approvalRequestId(), + context.companyId() + ); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context, task); + return; + } + Instant requestedAt = context.now().minus(seed.requestedHoursAgo(), ChronoUnit.HOURS); + ApprovalRequest approval = ApprovalRequest.create( + seed.approvalRequestId(), + seed.taskId(), + context.companyId(), + task.version(), + task.contentRevision(), + task.criticalFingerprint(), + AI_SNAPSHOT_JSON, + HR_SNAPSHOT_JSON, + CHANGED_FIELDS_JSON, + SOURCE_VERSIONS_JSON, + context.actorId(), + requestedAt + ); + applyOutcome(approval, seed, task, context); + approvalRepository.save(approval); + } + + private void applyOutcome( + ApprovalRequest approval, + ApprovalSeed seed, + Task task, + DemoOperationalSeedContext context + ) { + if (seed.status() == ApprovalStatus.PENDING) { + return; + } + Instant outcomeAt = context.now().minus(seed.outcomeHoursAgo(), ChronoUnit.HOURS); + switch (seed.status()) { + case APPROVED -> approval.approve( + task.version(), + task.contentRevision(), + task.criticalFingerprint(), + task.version(), + context.actorId(), + seed.reason(), + outcomeAt + ); + case REJECTED -> approval.reject(task.version(), context.actorId(), seed.reason(), outcomeAt); + case INVALIDATED -> approval.invalidate(seed.reason(), outcomeAt); + case PENDING -> throw new IllegalStateException("pending approval must not have an outcome"); + } + } + + void verifyExisting( + ApprovalRequest approval, + ApprovalSeed seed, + DemoOperationalSeedContext context, + Task task + ) { + if (!seed.approvalRequestId().equals(approval.approvalRequestId()) + || !seed.taskId().equals(approval.taskId()) + || !context.companyId().equals(approval.companyId()) + || seed.status() != approval.status() + || task.version() != approval.targetTaskVersion() + || task.contentRevision() != approval.targetContentRevision() + || !task.criticalFingerprint().equals(approval.targetFingerprint()) + || !context.actorId().equals(approval.requestedBy()) + || !matchesOutcome(approval, seed, context)) { + throw new IllegalStateException( + "a reserved demo approval id already belongs to different approval data" + ); + } + } + + private boolean matchesOutcome( + ApprovalRequest approval, + ApprovalSeed seed, + DemoOperationalSeedContext context + ) { + return switch (seed.status()) { + case PENDING -> approval.decidedBy() == null && approval.invalidatedAt() == null; + case APPROVED, REJECTED -> context.actorId().equals(approval.decidedBy()) + && seed.reason().equals(approval.decisionReason()) + && approval.decidedAt() != null; + case INVALIDATED -> seed.reason().equals(approval.invalidationReason()) + && approval.invalidatedAt() != null; + }; + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index f15a27b2..d2f8f1ef 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -1,6 +1,7 @@ package com.fowoco.server.demo.infrastructure.seed; import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.approval.domain.ApprovalStatus; import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.task.domain.TaskType; @@ -26,6 +27,12 @@ final class DemoOperationalSeedCatalog { private final List testTasks = DemoTaskSeedCatalog.testTasks(); private final List demoDocuments = DemoDocumentSeedCatalog.demoDocuments(); private final List testDocuments = DemoDocumentSeedCatalog.testDocuments(); + private final List demoChecklists = + DemoTaskWorkflowSeedCatalog.demoChecklists(demoTasks); + private final List demoApprovals = + DemoTaskWorkflowSeedCatalog.demoApprovals(demoTasks); + private final List demoTransitions = + DemoTaskWorkflowSeedCatalog.demoTransitions(demoTasks); private final List demoAudits = List.of( audit( "96000000-0000-0000-0000-000000000001", @@ -70,6 +77,18 @@ List testDocuments() { return testDocuments; } + List demoChecklists() { + return demoChecklists; + } + + List demoApprovals() { + return demoApprovals; + } + + List demoTransitions() { + return demoTransitions; + } + List demoAudits() { return demoAudits; } @@ -79,6 +98,9 @@ private void verifyDefinitions() { requireSize(testTasks, 3, "Test Company task"); requireSize(demoDocuments, 84, "Demo Company document"); requireSize(testDocuments, 8, "Test Company document"); + requireSize(demoChecklists, 68, "Demo Company checklist item"); + requireSize(demoApprovals, 13, "Demo Company approval request"); + requireSize(demoTransitions, 52, "Demo Company task transition"); requireDistribution( demoTasks.stream().map(TaskSeed::taskType).toList(), Map.of( @@ -111,6 +133,16 @@ private void verifyDefinitions() { ), "Demo Company document status" ); + requireDistribution( + demoApprovals.stream().map(ApprovalSeed::status).toList(), + Map.of( + ApprovalStatus.PENDING, 4L, + ApprovalStatus.APPROVED, 7L, + ApprovalStatus.REJECTED, 1L, + ApprovalStatus.INVALIDATED, 1L + ), + "Demo Company approval status" + ); requireUniqueIds( Stream.concat(demoTasks.stream(), testTasks.stream()).map(TaskSeed::taskId).toList(), "task" @@ -121,6 +153,18 @@ private void verifyDefinitions() { .toList(), "document" ); + requireUniqueIds(demoChecklists.stream().map(ChecklistSeed::checklistItemId).toList(), + "checklist item"); + requireUniqueIds(demoApprovals.stream().map(ApprovalSeed::approvalRequestId).toList(), + "approval request"); + requireUniqueIds(demoTransitions.stream().map(TransitionSeed::transitionId).toList(), + "task transition"); + requireTaskReferences(demoChecklists.stream().map(ChecklistSeed::taskId).toList(), + "checklist item"); + requireTaskReferences(demoApprovals.stream().map(ApprovalSeed::taskId).toList(), + "approval request"); + requireTaskReferences(demoTransitions.stream().map(TransitionSeed::taskId).toList(), + "task transition"); } private static void requireSize(List seeds, int expected, String name) { @@ -147,6 +191,13 @@ private static void requireUniqueIds(List ids, String name) { } } + private void requireTaskReferences(List taskIds, String name) { + var reservedTaskIds = demoTasks.stream().map(TaskSeed::taskId).collect(Collectors.toSet()); + if (!reservedTaskIds.containsAll(taskIds)) { + throw new IllegalStateException(name + " references an unknown demo task"); + } + } + private static AuditSeed audit( String auditEventId, AuditAction action, @@ -189,6 +240,39 @@ record DocumentSeed( ) { } + record ChecklistSeed( + UUID checklistItemId, + UUID taskId, + String itemCode, + String label, + boolean required, + boolean completed, + int createdHoursAgo, + Integer completedHoursAgo + ) { + } + + record ApprovalSeed( + UUID approvalRequestId, + UUID taskId, + ApprovalStatus status, + String reason, + int requestedHoursAgo, + Integer outcomeHoursAgo + ) { + } + + record TransitionSeed( + UUID transitionId, + UUID taskId, + TaskStatus fromStatus, + TaskStatus toStatus, + String reason, + String requestId, + int hoursAgo + ) { + } + record AuditSeed( UUID auditEventId, AuditAction action, diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java index 2a69c264..aaab1391 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -1,8 +1,10 @@ package com.fowoco.server.demo.infrastructure.seed; import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; import com.fowoco.server.task.application.TaskContentCodec; +import com.fowoco.server.task.application.port.TaskChecklistRepository; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.application.port.WorkerRepository; @@ -10,6 +12,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; @Configuration(proxyBeanMethods = false) public class DemoOperationalSeedConfiguration { @@ -20,21 +23,35 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( DemoAuthSeedProperties properties, TaskRepository taskRepository, TaskContentCodec taskContentCodec, + TaskChecklistRepository checklistRepository, + ApprovalRequestRepository approvalRepository, WorkerDocumentRepository workerDocumentRepository, WorkerRepository workerRepository, AuditEventRepository auditEventRepository, + JdbcTemplate jdbcTemplate, Clock clock ) { DemoOperationalSeedCatalog catalog = new DemoOperationalSeedCatalog(); DemoTaskSeeder taskSeeder = new DemoTaskSeeder(taskRepository, taskContentCodec); DemoWorkerDocumentSeeder documentSeeder = new DemoWorkerDocumentSeeder(workerDocumentRepository); + DemoTaskChecklistSeeder checklistSeeder = new DemoTaskChecklistSeeder(checklistRepository); + DemoApprovalRequestSeeder approvalSeeder = new DemoApprovalRequestSeeder( + approvalRepository, + taskRepository + ); + DemoTaskTransitionSeeder transitionSeeder = new DemoTaskTransitionSeeder(jdbcTemplate); DemoAuditEventSeeder auditSeeder = new DemoAuditEventSeeder(auditEventRepository); DemoOperationalSeedVerifier verifier = new DemoOperationalSeedVerifier( taskRepository, workerDocumentRepository, workerRepository, + checklistRepository, + approvalRepository, taskSeeder, documentSeeder, + checklistSeeder, + approvalSeeder, + transitionSeeder, auditSeeder ); return new DemoOperationalSeedRunner( @@ -43,6 +60,9 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( catalog, taskSeeder, documentSeeder, + checklistSeeder, + approvalSeeder, + transitionSeeder, auditSeeder, verifier ); diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java index ab9e28b6..5aef1113 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -2,8 +2,11 @@ import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; @@ -26,6 +29,9 @@ class DemoOperationalSeedRunner implements ApplicationRunner { private final DemoOperationalSeedCatalog catalog; private final DemoTaskSeeder taskSeeder; private final DemoWorkerDocumentSeeder documentSeeder; + private final DemoTaskChecklistSeeder checklistSeeder; + private final DemoApprovalRequestSeeder approvalSeeder; + private final DemoTaskTransitionSeeder transitionSeeder; private final DemoAuditEventSeeder auditSeeder; private final DemoOperationalSeedVerifier verifier; @@ -35,6 +41,9 @@ class DemoOperationalSeedRunner implements ApplicationRunner { DemoOperationalSeedCatalog catalog, DemoTaskSeeder taskSeeder, DemoWorkerDocumentSeeder documentSeeder, + DemoTaskChecklistSeeder checklistSeeder, + DemoApprovalRequestSeeder approvalSeeder, + DemoTaskTransitionSeeder transitionSeeder, DemoAuditEventSeeder auditSeeder, DemoOperationalSeedVerifier verifier ) { @@ -43,6 +52,12 @@ class DemoOperationalSeedRunner implements ApplicationRunner { this.catalog = Objects.requireNonNull(catalog, "catalog must not be null"); this.taskSeeder = Objects.requireNonNull(taskSeeder, "taskSeeder must not be null"); this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); + this.checklistSeeder = Objects.requireNonNull(checklistSeeder, "checklistSeeder must not be null"); + this.approvalSeeder = Objects.requireNonNull(approvalSeeder, "approvalSeeder must not be null"); + this.transitionSeeder = Objects.requireNonNull( + transitionSeeder, + "transitionSeeder must not be null" + ); this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); this.verifier = Objects.requireNonNull(verifier, "verifier must not be null"); } @@ -57,15 +72,30 @@ public void run(ApplicationArguments arguments) { seedDataset( catalog.demoTasks(), catalog.demoDocuments(), + catalog.demoChecklists(), + catalog.demoApprovals(), + catalog.demoTransitions(), catalog.demoAudits(), demoContext ); - seedDataset(catalog.testTasks(), catalog.testDocuments(), List.of(), testContext); + seedDataset( + catalog.testTasks(), + catalog.testDocuments(), + List.of(), + List.of(), + List.of(), + List.of(), + testContext + ); LOGGER.info( "demo_operational_seed ready demo_task_count={} demo_document_count={} " + + "checklist_count={} approval_count={} transition_count={} " + "test_task_count={} test_document_count={} audit_count={}", catalog.demoTasks().size(), catalog.demoDocuments().size(), + catalog.demoChecklists().size(), + catalog.demoApprovals().size(), + catalog.demoTransitions().size(), catalog.testTasks().size(), catalog.testDocuments().size(), catalog.demoAudits().size() @@ -75,12 +105,18 @@ public void run(ApplicationArguments arguments) { private void seedDataset( List tasks, List documents, + List checklists, + List approvals, + List transitions, List audits, DemoOperationalSeedContext context ) { tasks.forEach(seed -> taskSeeder.seed(seed, context)); documents.forEach(seed -> documentSeeder.seed(seed, context)); + checklists.forEach(seed -> checklistSeeder.seed(seed, context)); + approvals.forEach(seed -> approvalSeeder.seed(seed, context)); + transitions.forEach(seed -> transitionSeeder.seed(seed, context)); audits.forEach(seed -> auditSeeder.seed(seed, context)); - verifier.verify(tasks, documents, audits, context); + verifier.verify(tasks, documents, checklists, approvals, transitions, audits, context); } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java index 246a77ef..2102bc35 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java @@ -1,11 +1,18 @@ package com.fowoco.server.demo.infrastructure.seed; import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.approval.domain.ApprovalRequest; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; +import com.fowoco.server.task.application.port.TaskChecklistRepository; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskChecklistItem; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.WorkerDocument; @@ -20,16 +27,26 @@ final class DemoOperationalSeedVerifier { private final TaskRepository taskRepository; private final WorkerDocumentRepository workerDocumentRepository; private final WorkerRepository workerRepository; + private final TaskChecklistRepository checklistRepository; + private final ApprovalRequestRepository approvalRepository; private final DemoTaskSeeder taskSeeder; private final DemoWorkerDocumentSeeder documentSeeder; + private final DemoTaskChecklistSeeder checklistSeeder; + private final DemoApprovalRequestSeeder approvalSeeder; + private final DemoTaskTransitionSeeder transitionSeeder; private final DemoAuditEventSeeder auditSeeder; DemoOperationalSeedVerifier( TaskRepository taskRepository, WorkerDocumentRepository workerDocumentRepository, WorkerRepository workerRepository, + TaskChecklistRepository checklistRepository, + ApprovalRequestRepository approvalRepository, DemoTaskSeeder taskSeeder, DemoWorkerDocumentSeeder documentSeeder, + DemoTaskChecklistSeeder checklistSeeder, + DemoApprovalRequestSeeder approvalSeeder, + DemoTaskTransitionSeeder transitionSeeder, DemoAuditEventSeeder auditSeeder ) { this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); @@ -38,23 +55,46 @@ final class DemoOperationalSeedVerifier { "workerDocumentRepository must not be null" ); this.workerRepository = Objects.requireNonNull(workerRepository, "workerRepository must not be null"); + this.checklistRepository = Objects.requireNonNull( + checklistRepository, + "checklistRepository must not be null" + ); + this.approvalRepository = Objects.requireNonNull( + approvalRepository, + "approvalRepository must not be null" + ); this.taskSeeder = Objects.requireNonNull(taskSeeder, "taskSeeder must not be null"); this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); + this.checklistSeeder = Objects.requireNonNull(checklistSeeder, "checklistSeeder must not be null"); + this.approvalSeeder = Objects.requireNonNull(approvalSeeder, "approvalSeeder must not be null"); + this.transitionSeeder = Objects.requireNonNull( + transitionSeeder, + "transitionSeeder must not be null" + ); this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); } void verify( List tasks, List documents, + List checklists, + List approvals, + List transitions, List audits, DemoOperationalSeedContext context ) { verifyUniqueIds(tasks.stream().map(TaskSeed::taskId).toList(), "task"); verifyUniqueIds(documents.stream().map(DocumentSeed::documentId).toList(), "document"); + verifyUniqueIds(checklists.stream().map(ChecklistSeed::checklistItemId).toList(), "checklist item"); + verifyUniqueIds(approvals.stream().map(ApprovalSeed::approvalRequestId).toList(), "approval request"); + verifyUniqueIds(transitions.stream().map(TransitionSeed::transitionId).toList(), "task transition"); verifyUniqueIds(audits.stream().map(AuditSeed::auditEventId).toList(), "audit event"); verifyWorkersExist(tasks, documents, context); tasks.forEach(seed -> verifyTask(seed, context)); documents.forEach(seed -> verifyDocument(seed, context)); + checklists.forEach(seed -> verifyChecklist(seed, context)); + approvals.forEach(seed -> verifyApproval(seed, context)); + transitions.forEach(seed -> transitionSeeder.verifyExisting(seed, context)); audits.forEach(seed -> verifyAudit(seed, context)); } @@ -74,6 +114,27 @@ private void verifyDocument(DocumentSeed seed, DemoOperationalSeedContext contex documentSeeder.verifyExisting(document, seed, context); } + private void verifyChecklist(ChecklistSeed seed, DemoOperationalSeedContext context) { + TaskChecklistItem item = checklistRepository.findByIdAndTaskIdAndCompanyId( + seed.checklistItemId(), + seed.taskId(), + context.companyId() + ) + .orElseThrow(() -> new IllegalStateException("a reserved demo checklist item was not seeded")); + checklistSeeder.verifyExisting(item, seed, context); + } + + private void verifyApproval(ApprovalSeed seed, DemoOperationalSeedContext context) { + ApprovalRequest approval = approvalRepository.findByIdAndCompanyId( + seed.approvalRequestId(), + context.companyId() + ) + .orElseThrow(() -> new IllegalStateException("a reserved demo approval request was not seeded")); + Task task = taskRepository.findByIdAndCompanyId(seed.taskId(), context.companyId()) + .orElseThrow(() -> new IllegalStateException("a demo approval task does not exist")); + approvalSeeder.verifyExisting(approval, seed, context, task); + } + private void verifyAudit(AuditSeed seed, DemoOperationalSeedContext context) { AuditEvent event = auditSeeder.findExisting(seed, context) .orElseThrow(() -> new IllegalStateException("a reserved demo audit event was not seeded")); diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskChecklistSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskChecklistSeeder.java new file mode 100644 index 00000000..bdba5f46 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskChecklistSeeder.java @@ -0,0 +1,71 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; +import com.fowoco.server.task.application.port.TaskChecklistRepository; +import com.fowoco.server.task.domain.TaskChecklistItem; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; + +final class DemoTaskChecklistSeeder { + + private final TaskChecklistRepository checklistRepository; + + DemoTaskChecklistSeeder(TaskChecklistRepository checklistRepository) { + this.checklistRepository = Objects.requireNonNull( + checklistRepository, + "checklistRepository must not be null" + ); + } + + void seed(ChecklistSeed seed, DemoOperationalSeedContext context) { + Optional existing = checklistRepository.findByIdAndTaskIdAndCompanyId( + seed.checklistItemId(), + seed.taskId(), + context.companyId() + ); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context); + return; + } + Instant createdAt = context.now().minus(seed.createdHoursAgo(), ChronoUnit.HOURS); + Instant completedAt = seed.completed() + ? context.now().minus(seed.completedHoursAgo(), ChronoUnit.HOURS) + : null; + checklistRepository.save(new TaskChecklistItem( + seed.checklistItemId(), + seed.taskId(), + context.companyId(), + seed.itemCode(), + seed.label(), + seed.required(), + seed.completed(), + seed.completed() ? context.actorId() : null, + completedAt, + createdAt, + completedAt == null ? createdAt : completedAt, + 0L + )); + } + + void verifyExisting( + TaskChecklistItem item, + ChecklistSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.checklistItemId().equals(item.checklistItemId()) + || !seed.taskId().equals(item.taskId()) + || !context.companyId().equals(item.companyId()) + || !seed.itemCode().equals(item.itemCode()) + || !seed.label().equals(item.label()) + || seed.required() != item.required() + || seed.completed() != item.completed() + || (seed.completed() && !context.actorId().equals(item.completedBy())) + || (!seed.completed() && item.completedBy() != null)) { + throw new IllegalStateException( + "a reserved demo checklist item id already belongs to different checklist data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java index 1f90fca1..4be41c8a 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java @@ -38,7 +38,10 @@ void seed(TaskSeed seed, DemoOperationalSeedContext context) { seed.taskId(), context.companyId() ); - existing.ifPresent(task -> verifyExisting(task, seed, context)); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context); + return; + } Instant createdAt = existing.map(Task::createdAt) .orElseGet(() -> context.now().minus(seed.createdDaysAgo(), ChronoUnit.DAYS)); Instant desiredUpdatedAt = seed.status() == TaskStatus.COMPLETED diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskTransitionSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskTransitionSeeder.java new file mode 100644 index 00000000..e40e32d3 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskTransitionSeeder.java @@ -0,0 +1,95 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; +import com.fowoco.server.task.domain.TaskStatus; +import java.sql.Timestamp; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoTaskTransitionSeeder { + + private final JdbcTemplate jdbcTemplate; + + DemoTaskTransitionSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + void seed(TransitionSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.transitionId()); + if (!existing.isEmpty()) { + verifyExisting(existing.get(0), seed, context); + return; + } + jdbcTemplate.update( + "INSERT INTO task_transition_history " + + "(transition_id, task_id, company_id, from_status, to_status, actor_id, " + + "reason, request_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + seed.transitionId(), + seed.taskId(), + context.companyId(), + seed.fromStatus().name(), + seed.toStatus().name(), + context.actorId(), + seed.reason(), + seed.requestId(), + Timestamp.from(context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS)) + ); + } + + void verifyExisting(TransitionSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.transitionId()); + if (existing.size() != 1) { + throw new IllegalStateException("a reserved demo task transition was not seeded"); + } + verifyExisting(existing.get(0), seed, context); + } + + private List find(UUID transitionId) { + return jdbcTemplate.query( + "SELECT task_id, company_id, from_status, to_status, actor_id, reason, request_id " + + "FROM task_transition_history WHERE transition_id = ?", + (resultSet, rowNumber) -> new StoredTransition( + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + TaskStatus.valueOf(resultSet.getString("from_status")), + TaskStatus.valueOf(resultSet.getString("to_status")), + resultSet.getObject("actor_id", UUID.class), + resultSet.getString("reason"), + resultSet.getString("request_id") + ), + transitionId + ); + } + + private void verifyExisting( + StoredTransition transition, + TransitionSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.taskId().equals(transition.taskId()) + || !context.companyId().equals(transition.companyId()) + || seed.fromStatus() != transition.fromStatus() + || seed.toStatus() != transition.toStatus() + || !context.actorId().equals(transition.actorId()) + || !seed.reason().equals(transition.reason()) + || !seed.requestId().equals(transition.requestId())) { + throw new IllegalStateException( + "a reserved demo task transition id already belongs to different transition data" + ); + } + } + + private record StoredTransition( + UUID taskId, + UUID companyId, + TaskStatus fromStatus, + TaskStatus toStatus, + UUID actorId, + String reason, + String requestId + ) { + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java new file mode 100644 index 00000000..a861c516 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java @@ -0,0 +1,190 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.domain.ApprovalStatus; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; +import com.fowoco.server.task.domain.TaskStatus; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +final class DemoTaskWorkflowSeedCatalog { + + private static final List STAY_CHECKLIST = List.of( + new ChecklistTemplate("PASSPORT_COPY_VERIFY_CURRENT", "여권 사본 보유·유효 상태 확인"), + new ChecklistTemplate("ALIEN_REGISTRATION_CARD_VERIFY_CURRENT", "외국인등록증 사본 보유·유효 상태 확인"), + new ChecklistTemplate("EMPLOYMENT_CONTRACT_VERIFY_PERIOD", "근로계약기간 확인"), + new ChecklistTemplate("APPLICATION_FORM_SELECT_CURRENT", "최신 공식 통합신청서 양식 확인") + ); + private static final List CONTRACT_CHECKLIST = List.of( + new ChecklistTemplate("EMPLOYMENT_CONTRACT_USE_CURRENT_STANDARD_FORM", "최신 표준근로계약서 양식 사용 확인"), + new ChecklistTemplate("EMPLOYMENT_PERMIT_VERIFY_PERIOD", "고용허가기간과 연장 적용 여부 확인") + ); + private static final Map INCOMPLETE_ITEMS_BY_TASK = Map.of( + 7, 2, + 9, 1, + 12, 1, + 13, 1 + ); + + private DemoTaskWorkflowSeedCatalog() { + } + + static List demoChecklists(List tasks) { + List seeds = new ArrayList<>(); + for (int taskIndex = 0; taskIndex < tasks.size(); taskIndex++) { + TaskSeed task = tasks.get(taskIndex); + List templates = "WF-STY-001".equals(task.workflowId()) + ? STAY_CHECKLIST + : CONTRACT_CHECKLIST; + int incompleteCount = INCOMPLETE_ITEMS_BY_TASK.getOrDefault(taskIndex + 1, 0); + for (int itemIndex = 0; itemIndex < templates.size(); itemIndex++) { + ChecklistTemplate template = templates.get(itemIndex); + boolean completed = itemIndex < templates.size() - incompleteCount; + int createdHoursAgo = Math.max(task.createdDaysAgo() * 24, 24); + seeds.add(new ChecklistSeed( + demoUuid("94200000-0000-0000-0000-000000000", seeds.size() + 1), + task.taskId(), + template.itemCode(), + template.label(), + true, + completed, + createdHoursAgo, + completed ? Math.max(createdHoursAgo / 2, 1) : null + )); + } + } + return List.copyOf(seeds); + } + + static List demoApprovals(List tasks) { + return List.of( + approval(1, tasks, 2, ApprovalStatus.PENDING, null, 72, null), + approval(2, tasks, 6, ApprovalStatus.PENDING, null, 48, null), + approval(3, tasks, 14, ApprovalStatus.PENDING, null, 36, null), + approval(4, tasks, 15, ApprovalStatus.PENDING, null, 24, null), + approval(5, tasks, 16, ApprovalStatus.APPROVED, "연장 조건과 제출 자료 확인", 120, 96), + approval(6, tasks, 17, ApprovalStatus.APPROVED, "체류 연장 신청 내용 승인", 144, 120), + approval(7, tasks, 5, ApprovalStatus.APPROVED, "서명 계약서와 갱신 조건 확인", 216, 192), + approval(8, tasks, 20, ApprovalStatus.APPROVED, "체류 연장 완료 건 승인", 360, 336), + approval(9, tasks, 21, ApprovalStatus.APPROVED, "재계약 완료 건 승인", 480, 456), + approval(10, tasks, 22, ApprovalStatus.APPROVED, "고용기간 연장 완료 건 승인", 600, 576), + approval(11, tasks, 23, ApprovalStatus.APPROVED, "신청 결과 확인 후 승인", 720, 696), + approval(12, tasks, 9, ApprovalStatus.REJECTED, "필수 고용 정보 보완 필요", 96, 72), + approval(13, tasks, 1, ApprovalStatus.INVALIDATED, "중요 신청 정보 변경으로 기존 검토 무효화", 120, 96) + ); + } + + static List demoTransitions(List tasks) { + List seeds = new ArrayList<>(); + history(seeds, tasks.get(0), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, TaskStatus.DRAFT); + history(seeds, tasks.get(1), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW); + history(seeds, tasks.get(2), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.WAITING_WORKER); + history(seeds, tasks.get(3), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.WAITING_EXTERNAL); + history(seeds, tasks.get(4), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.COMPLETED); + history(seeds, tasks.get(5), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW); + history(seeds, tasks.get(7), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.WAITING_WORKER); + history(seeds, tasks.get(8), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.DRAFT, TaskStatus.NEEDS_INFO); + history(seeds, tasks.get(9), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.WAITING_WORKER); + history(seeds, tasks.get(10), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.WAITING_WORKER); + history(seeds, tasks.get(12), TaskStatus.DRAFT, TaskStatus.NEEDS_INFO); + history(seeds, tasks.get(13), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW); + history(seeds, tasks.get(14), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW); + history(seeds, tasks.get(15), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, TaskStatus.APPROVED); + history(seeds, tasks.get(16), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, TaskStatus.APPROVED); + history(seeds, tasks.get(17), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.WAITING_EXTERNAL); + history(seeds, tasks.get(18), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.WAITING_EXTERNAL); + history(seeds, tasks.get(19), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.WAITING_EXTERNAL, TaskStatus.COMPLETED); + history(seeds, tasks.get(20), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.COMPLETED); + history(seeds, tasks.get(21), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.COMPLETED); + history(seeds, tasks.get(22), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, TaskStatus.COMPLETED); + history(seeds, tasks.get(23), TaskStatus.DRAFT, TaskStatus.CANCELLED); + return List.copyOf(seeds); + } + + private static ApprovalSeed approval( + int approvalNumber, + List tasks, + int taskNumber, + ApprovalStatus status, + String reason, + int requestedHoursAgo, + Integer outcomeHoursAgo + ) { + return new ApprovalSeed( + demoUuid("94300000-0000-0000-0000-000000000", approvalNumber), + tasks.get(taskNumber - 1).taskId(), + status, + reason, + requestedHoursAgo, + outcomeHoursAgo + ); + } + + private static void history( + List seeds, + TaskSeed task, + TaskStatus initialStatus, + TaskStatus... statuses + ) { + TaskStatus fromStatus = initialStatus; + int transitionCount = statuses.length; + int createdHoursAgo = Math.max(task.createdDaysAgo() * 24, transitionCount); + int finalHoursAgo = task.status() == TaskStatus.COMPLETED + ? 0 + : Math.max(task.createdDaysAgo() - 1, 0) * 24; + int transitionWindow = createdHoursAgo - finalHoursAgo; + for (int index = 0; index < transitionCount; index++) { + TaskStatus toStatus = statuses[index]; + int sequence = seeds.size() + 1; + int hoursAgo = createdHoursAgo + - ((index + 1) * transitionWindow / transitionCount); + seeds.add(new TransitionSeed( + demoUuid("94400000-0000-0000-0000-000000000", sequence), + task.taskId(), + fromStatus, + toStatus, + transitionReason(toStatus), + "demo-seed-task-transition-%03d".formatted(sequence), + hoursAgo + )); + fromStatus = toStatus; + } + } + + private static String transitionReason(TaskStatus status) { + return switch (status) { + case NEEDS_INFO -> "필수 정보 또는 체크리스트 보완 필요"; + case READY_FOR_REVIEW -> "필수 준비 항목 확인 완료"; + case APPROVED -> "관리자 검토 승인"; + case WAITING_WORKER -> "근로자 제출 자료 대기"; + case WAITING_EXTERNAL -> "외부 기관 처리 결과 대기"; + case COMPLETED -> "필수 승인과 완료 근거 확인"; + case CANCELLED -> "계약 일정 변경으로 업무 취소"; + case DRAFT -> "검토 결과를 반영하기 위해 초안으로 복귀"; + }; + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } + + private record ChecklistTemplate(String itemCode, String label) { + } +} From 37633591713848af7444e3a27a098699ca67cff5 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 3 Aug 2026 15:43:03 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat(seed):=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=C2=B7=EC=99=84=EB=A3=8C=20=EC=A6=9D=EB=B9=99?= =?UTF-8?q?=C2=B7=EC=9A=94=EC=B2=AD=20=EC=B4=88=EC=95=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 외부기관 제출 시나리오 6건 추가 - 완료 태스크별 안전한 메모 기반 증빙 10건 추가 - 근로자 응답 대기 및 재계약 준비용 문서 요청 초안 5건 추가 - 고정 UUID 충돌 검증과 재실행 멱등성 보강 --- .../seed/DemoDocumentRequestDraftSeeder.java | 102 ++++++++++++++++ .../seed/DemoEvidenceSeeder.java | 97 +++++++++++++++ .../seed/DemoExternalSubmissionSeeder.java | 94 +++++++++++++++ .../seed/DemoOperationalSeedCatalog.java | 108 +++++++++++++++++ .../DemoOperationalSeedConfiguration.java | 28 +++++ .../seed/DemoOperationalSeedRunner.java | 47 +++++++- .../seed/DemoOperationalSeedVerifier.java | 29 +++++ .../seed/DemoTaskArtifactSeedCatalog.java | 114 ++++++++++++++++++ 8 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentRequestDraftSeeder.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoExternalSubmissionSeeder.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentRequestDraftSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentRequestDraftSeeder.java new file mode 100644 index 00000000..8e2a5bac --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentRequestDraftSeeder.java @@ -0,0 +1,102 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import com.fowoco.server.document.domain.DocumentRequestReviewStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoDocumentRequestDraftSeeder { + + private final DocumentRequestDraftRepository repository; + private final JdbcTemplate jdbcTemplate; + + DemoDocumentRequestDraftSeeder( + DocumentRequestDraftRepository repository, + JdbcTemplate jdbcTemplate + ) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + void seed(DocumentRequestDraftSeed seed, DemoOperationalSeedContext context) { + verifyReservedIdOwner(seed, context); + var existing = repository.findByTaskIdAndCompanyId(seed.taskId(), context.companyId()); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context); + return; + } + Instant createdAt = context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS); + repository.insert(new DocumentRequestDraft( + seed.draftId(), + seed.taskId(), + context.companyId(), + seed.language(), + seed.documentTypes(), + seed.message(), + DocumentRequestReviewStatus.DRAFT, + createdAt, + createdAt, + 0L + )); + } + + void verifyExisting(DocumentRequestDraftSeed seed, DemoOperationalSeedContext context) { + DocumentRequestDraft draft = repository.findByTaskIdAndCompanyId( + seed.taskId(), + context.companyId() + ) + .orElseThrow(() -> new IllegalStateException( + "a reserved demo document request draft was not seeded" + )); + verifyExisting(draft, seed, context); + } + + private void verifyReservedIdOwner( + DocumentRequestDraftSeed seed, + DemoOperationalSeedContext context + ) { + List owners = jdbcTemplate.query( + "SELECT task_id, company_id FROM document_request_draft WHERE draft_id = ?", + (resultSet, rowNumber) -> new DraftOwner( + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("company_id", UUID.class) + ), + seed.draftId() + ); + if (!owners.isEmpty() + && (!seed.taskId().equals(owners.get(0).taskId()) + || !context.companyId().equals(owners.get(0).companyId()))) { + throw new IllegalStateException( + "a reserved demo document request draft id belongs to a different task" + ); + } + } + + private void verifyExisting( + DocumentRequestDraft draft, + DocumentRequestDraftSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.draftId().equals(draft.draftId()) + || !seed.taskId().equals(draft.taskId()) + || !context.companyId().equals(draft.companyId()) + || !seed.language().equals(draft.language()) + || !new HashSet<>(seed.documentTypes()).equals(new HashSet<>(draft.documentTypes())) + || !seed.message().equals(draft.message()) + || draft.reviewStatus() != DocumentRequestReviewStatus.DRAFT) { + throw new IllegalStateException( + "a reserved demo task already has different document request draft data" + ); + } + } + + private record DraftOwner(UUID taskId, UUID companyId) { + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java new file mode 100644 index 00000000..ef361b21 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java @@ -0,0 +1,97 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.application.port.EvidenceRepository; +import com.fowoco.server.approval.domain.Evidence; +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoEvidenceSeeder { + + private final EvidenceRepository repository; + private final EntityManager entityManager; + private final JdbcTemplate jdbcTemplate; + + DemoEvidenceSeeder( + EvidenceRepository repository, + EntityManager entityManager, + JdbcTemplate jdbcTemplate + ) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.entityManager = Objects.requireNonNull(entityManager, "entityManager must not be null"); + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + void seed(EvidenceSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.evidenceId()); + if (!existing.isEmpty()) { + verifyExisting(existing.get(0), seed, context); + return; + } + Instant recordedAt = context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS); + repository.save(new Evidence( + seed.evidenceId(), + seed.taskId(), + context.companyId(), + seed.evidenceType(), + null, + seed.note(), + context.actorId(), + recordedAt, + recordedAt + )); + entityManager.flush(); + } + + void verifyExisting(EvidenceSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.evidenceId()); + if (existing.size() != 1) { + throw new IllegalStateException("a reserved demo completion evidence was not seeded"); + } + verifyExisting(existing.get(0), seed, context); + } + + private List find(UUID evidenceId) { + return jdbcTemplate.query( + "SELECT evidence_id, task_id, company_id, evidence_type, file_reference, note, " + + "recorded_by, recorded_at, created_at FROM task_evidence WHERE evidence_id = ?", + (resultSet, rowNumber) -> new Evidence( + resultSet.getObject("evidence_id", UUID.class), + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + EvidenceType.valueOf(resultSet.getString("evidence_type")), + resultSet.getString("file_reference"), + resultSet.getString("note"), + resultSet.getObject("recorded_by", UUID.class), + resultSet.getTimestamp("recorded_at").toInstant(), + resultSet.getTimestamp("created_at").toInstant() + ), + evidenceId + ); + } + + private void verifyExisting( + Evidence evidence, + EvidenceSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.evidenceId().equals(evidence.evidenceId()) + || !seed.taskId().equals(evidence.taskId()) + || !context.companyId().equals(evidence.companyId()) + || seed.evidenceType() != evidence.evidenceType() + || evidence.fileReference() != null + || !seed.note().equals(evidence.note()) + || !context.actorId().equals(evidence.recordedBy()) + || evidence.createdAt().isBefore(evidence.recordedAt())) { + throw new IllegalStateException( + "a reserved demo evidence id already belongs to different evidence data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoExternalSubmissionSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoExternalSubmissionSeeder.java new file mode 100644 index 00000000..bf2e4b19 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoExternalSubmissionSeeder.java @@ -0,0 +1,94 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.application.port.ExternalSubmissionRepository; +import com.fowoco.server.approval.domain.ExternalSubmission; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoExternalSubmissionSeeder { + + private final ExternalSubmissionRepository repository; + private final EntityManager entityManager; + private final JdbcTemplate jdbcTemplate; + + DemoExternalSubmissionSeeder( + ExternalSubmissionRepository repository, + EntityManager entityManager, + JdbcTemplate jdbcTemplate + ) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.entityManager = Objects.requireNonNull(entityManager, "entityManager must not be null"); + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + void seed(ExternalSubmissionSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.externalSubmissionId()); + if (!existing.isEmpty()) { + verifyExisting(existing.get(0), seed, context); + return; + } + Instant submittedAt = context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS); + repository.save(new ExternalSubmission( + seed.externalSubmissionId(), + seed.taskId(), + context.companyId(), + seed.destination(), + seed.safeReference(), + context.actorId(), + submittedAt, + submittedAt + )); + entityManager.flush(); + } + + void verifyExisting(ExternalSubmissionSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.externalSubmissionId()); + if (existing.size() != 1) { + throw new IllegalStateException("a reserved demo external submission was not seeded"); + } + verifyExisting(existing.get(0), seed, context); + } + + private List find(UUID submissionId) { + return jdbcTemplate.query( + "SELECT external_submission_id, task_id, company_id, destination, safe_reference, " + + "submitted_by, submitted_at, created_at FROM external_submission " + + "WHERE external_submission_id = ?", + (resultSet, rowNumber) -> new ExternalSubmission( + resultSet.getObject("external_submission_id", UUID.class), + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getString("destination"), + resultSet.getString("safe_reference"), + resultSet.getObject("submitted_by", UUID.class), + resultSet.getTimestamp("submitted_at").toInstant(), + resultSet.getTimestamp("created_at").toInstant() + ), + submissionId + ); + } + + private void verifyExisting( + ExternalSubmission submission, + ExternalSubmissionSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.externalSubmissionId().equals(submission.externalSubmissionId()) + || !seed.taskId().equals(submission.taskId()) + || !context.companyId().equals(submission.companyId()) + || !seed.destination().equals(submission.destination()) + || !seed.safeReference().equals(submission.safeReference()) + || !context.actorId().equals(submission.submittedBy()) + || submission.createdAt().isBefore(submission.submittedAt())) { + throw new IllegalStateException( + "a reserved demo external submission id belongs to different submission data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index d2f8f1ef..7acd3fa2 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -2,6 +2,7 @@ import com.fowoco.server.audit.domain.AuditAction; import com.fowoco.server.approval.domain.ApprovalStatus; +import com.fowoco.server.approval.domain.EvidenceType; import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.task.domain.TaskType; @@ -33,6 +34,12 @@ final class DemoOperationalSeedCatalog { DemoTaskWorkflowSeedCatalog.demoApprovals(demoTasks); private final List demoTransitions = DemoTaskWorkflowSeedCatalog.demoTransitions(demoTasks); + private final List demoExternalSubmissions = + DemoTaskArtifactSeedCatalog.demoExternalSubmissions(demoTasks); + private final List demoEvidence = + DemoTaskArtifactSeedCatalog.demoEvidence(demoTasks); + private final List demoDocumentRequestDrafts = + DemoTaskArtifactSeedCatalog.demoDocumentRequestDrafts(demoTasks); private final List demoAudits = List.of( audit( "96000000-0000-0000-0000-000000000001", @@ -89,6 +96,18 @@ List demoTransitions() { return demoTransitions; } + List demoExternalSubmissions() { + return demoExternalSubmissions; + } + + List demoEvidence() { + return demoEvidence; + } + + List demoDocumentRequestDrafts() { + return demoDocumentRequestDrafts; + } + List demoAudits() { return demoAudits; } @@ -101,6 +120,9 @@ private void verifyDefinitions() { requireSize(demoChecklists, 68, "Demo Company checklist item"); requireSize(demoApprovals, 13, "Demo Company approval request"); requireSize(demoTransitions, 52, "Demo Company task transition"); + requireSize(demoExternalSubmissions, 6, "Demo Company external submission"); + requireSize(demoEvidence, 10, "Demo Company completion evidence"); + requireSize(demoDocumentRequestDrafts, 5, "Demo Company document request draft"); requireDistribution( demoTasks.stream().map(TaskSeed::taskType).toList(), Map.of( @@ -143,6 +165,16 @@ private void verifyDefinitions() { ), "Demo Company approval status" ); + requireDistribution( + demoEvidence.stream().map(EvidenceSeed::evidenceType).toList(), + Map.of( + EvidenceType.DOCUMENT, 2L, + EvidenceType.RECEIPT, 3L, + EvidenceType.OFFICIAL_RESULT, 3L, + EvidenceType.HR_CONFIRMATION, 2L + ), + "Demo Company evidence type" + ); requireUniqueIds( Stream.concat(demoTasks.stream(), testTasks.stream()).map(TaskSeed::taskId).toList(), "task" @@ -159,12 +191,25 @@ private void verifyDefinitions() { "approval request"); requireUniqueIds(demoTransitions.stream().map(TransitionSeed::transitionId).toList(), "task transition"); + requireUniqueIds(demoExternalSubmissions.stream() + .map(ExternalSubmissionSeed::externalSubmissionId).toList(), "external submission"); + requireUniqueIds(demoEvidence.stream().map(EvidenceSeed::evidenceId).toList(), + "completion evidence"); + requireUniqueIds(demoDocumentRequestDrafts.stream() + .map(DocumentRequestDraftSeed::draftId).toList(), "document request draft"); requireTaskReferences(demoChecklists.stream().map(ChecklistSeed::taskId).toList(), "checklist item"); requireTaskReferences(demoApprovals.stream().map(ApprovalSeed::taskId).toList(), "approval request"); requireTaskReferences(demoTransitions.stream().map(TransitionSeed::taskId).toList(), "task transition"); + requireTaskReferences(demoExternalSubmissions.stream() + .map(ExternalSubmissionSeed::taskId).toList(), "external submission"); + requireTaskReferences(demoEvidence.stream().map(EvidenceSeed::taskId).toList(), + "completion evidence"); + requireTaskReferences(demoDocumentRequestDrafts.stream() + .map(DocumentRequestDraftSeed::taskId).toList(), "document request draft"); + verifyArtifactScenarios(); } private static void requireSize(List seeds, int expected, String name) { @@ -198,6 +243,41 @@ private void requireTaskReferences(List taskIds, String name) { } } + private void verifyArtifactScenarios() { + Map tasksById = demoTasks.stream() + .collect(Collectors.toMap(TaskSeed::taskId, Function.identity())); + boolean invalidSubmission = demoExternalSubmissions.stream() + .map(ExternalSubmissionSeed::taskId) + .map(tasksById::get) + .anyMatch(task -> task.status() != TaskStatus.WAITING_EXTERNAL + && task.status() != TaskStatus.COMPLETED); + if (invalidSubmission) { + throw new IllegalStateException("external submission task status is invalid"); + } + Map evidenceCounts = demoEvidence.stream() + .collect(Collectors.groupingBy(EvidenceSeed::taskId, Collectors.counting())); + boolean incompleteCompletionEvidence = demoTasks.stream() + .filter(task -> task.status() == TaskStatus.COMPLETED) + .anyMatch(task -> evidenceCounts.getOrDefault(task.taskId(), 0L) != 2L); + if (incompleteCompletionEvidence || evidenceCounts.size() != 5) { + throw new IllegalStateException("every completed demo task needs two evidence records"); + } + var requestDraftTaskIds = demoDocumentRequestDrafts.stream() + .map(DocumentRequestDraftSeed::taskId) + .collect(Collectors.toSet()); + boolean missingWaitingWorkerDraft = demoTasks.stream() + .filter(task -> task.status() == TaskStatus.WAITING_WORKER) + .anyMatch(task -> !requestDraftTaskIds.contains(task.taskId())); + boolean invalidDraftStatus = requestDraftTaskIds.stream() + .map(tasksById::get) + .anyMatch(task -> task.status() != TaskStatus.WAITING_WORKER + && task.status() != TaskStatus.DRAFT); + if (missingWaitingWorkerDraft || invalidDraftStatus + || requestDraftTaskIds.size() != demoDocumentRequestDrafts.size()) { + throw new IllegalStateException("document request draft scenario is invalid"); + } + } + private static AuditSeed audit( String auditEventId, AuditAction action, @@ -273,6 +353,34 @@ record TransitionSeed( ) { } + record ExternalSubmissionSeed( + UUID externalSubmissionId, + UUID taskId, + String destination, + String safeReference, + int hoursAgo + ) { + } + + record EvidenceSeed( + UUID evidenceId, + UUID taskId, + EvidenceType evidenceType, + String note, + int hoursAgo + ) { + } + + record DocumentRequestDraftSeed( + UUID draftId, + UUID taskId, + String language, + List documentTypes, + String message, + int hoursAgo + ) { + } + record AuditSeed( UUID auditEventId, AuditAction action, diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java index aaab1391..cf6d274f 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -2,12 +2,16 @@ import com.fowoco.server.audit.application.port.AuditEventRepository; import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.approval.application.port.EvidenceRepository; +import com.fowoco.server.approval.application.port.ExternalSubmissionRepository; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; import com.fowoco.server.task.application.TaskContentCodec; import com.fowoco.server.task.application.port.TaskChecklistRepository; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.application.port.WorkerRepository; +import jakarta.persistence.EntityManager; import java.time.Clock; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; @@ -25,9 +29,13 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( TaskContentCodec taskContentCodec, TaskChecklistRepository checklistRepository, ApprovalRequestRepository approvalRepository, + ExternalSubmissionRepository externalSubmissionRepository, + EvidenceRepository evidenceRepository, + DocumentRequestDraftRepository documentRequestDraftRepository, WorkerDocumentRepository workerDocumentRepository, WorkerRepository workerRepository, AuditEventRepository auditEventRepository, + EntityManager entityManager, JdbcTemplate jdbcTemplate, Clock clock ) { @@ -40,6 +48,20 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( taskRepository ); DemoTaskTransitionSeeder transitionSeeder = new DemoTaskTransitionSeeder(jdbcTemplate); + DemoExternalSubmissionSeeder externalSubmissionSeeder = new DemoExternalSubmissionSeeder( + externalSubmissionRepository, + entityManager, + jdbcTemplate + ); + DemoEvidenceSeeder evidenceSeeder = new DemoEvidenceSeeder( + evidenceRepository, + entityManager, + jdbcTemplate + ); + DemoDocumentRequestDraftSeeder requestDraftSeeder = new DemoDocumentRequestDraftSeeder( + documentRequestDraftRepository, + jdbcTemplate + ); DemoAuditEventSeeder auditSeeder = new DemoAuditEventSeeder(auditEventRepository); DemoOperationalSeedVerifier verifier = new DemoOperationalSeedVerifier( taskRepository, @@ -52,6 +74,9 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( checklistSeeder, approvalSeeder, transitionSeeder, + externalSubmissionSeeder, + evidenceSeeder, + requestDraftSeeder, auditSeeder ); return new DemoOperationalSeedRunner( @@ -63,6 +88,9 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( checklistSeeder, approvalSeeder, transitionSeeder, + externalSubmissionSeeder, + evidenceSeeder, + requestDraftSeeder, auditSeeder, verifier ); diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java index 5aef1113..3e835a4e 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -5,6 +5,9 @@ import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; import java.time.Clock; @@ -32,6 +35,9 @@ class DemoOperationalSeedRunner implements ApplicationRunner { private final DemoTaskChecklistSeeder checklistSeeder; private final DemoApprovalRequestSeeder approvalSeeder; private final DemoTaskTransitionSeeder transitionSeeder; + private final DemoExternalSubmissionSeeder externalSubmissionSeeder; + private final DemoEvidenceSeeder evidenceSeeder; + private final DemoDocumentRequestDraftSeeder requestDraftSeeder; private final DemoAuditEventSeeder auditSeeder; private final DemoOperationalSeedVerifier verifier; @@ -44,6 +50,9 @@ class DemoOperationalSeedRunner implements ApplicationRunner { DemoTaskChecklistSeeder checklistSeeder, DemoApprovalRequestSeeder approvalSeeder, DemoTaskTransitionSeeder transitionSeeder, + DemoExternalSubmissionSeeder externalSubmissionSeeder, + DemoEvidenceSeeder evidenceSeeder, + DemoDocumentRequestDraftSeeder requestDraftSeeder, DemoAuditEventSeeder auditSeeder, DemoOperationalSeedVerifier verifier ) { @@ -58,6 +67,15 @@ class DemoOperationalSeedRunner implements ApplicationRunner { transitionSeeder, "transitionSeeder must not be null" ); + this.externalSubmissionSeeder = Objects.requireNonNull( + externalSubmissionSeeder, + "externalSubmissionSeeder must not be null" + ); + this.evidenceSeeder = Objects.requireNonNull(evidenceSeeder, "evidenceSeeder must not be null"); + this.requestDraftSeeder = Objects.requireNonNull( + requestDraftSeeder, + "requestDraftSeeder must not be null" + ); this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); this.verifier = Objects.requireNonNull(verifier, "verifier must not be null"); } @@ -75,6 +93,9 @@ public void run(ApplicationArguments arguments) { catalog.demoChecklists(), catalog.demoApprovals(), catalog.demoTransitions(), + catalog.demoExternalSubmissions(), + catalog.demoEvidence(), + catalog.demoDocumentRequestDrafts(), catalog.demoAudits(), demoContext ); @@ -85,17 +106,24 @@ public void run(ApplicationArguments arguments) { List.of(), List.of(), List.of(), + List.of(), + List.of(), + List.of(), testContext ); LOGGER.info( "demo_operational_seed ready demo_task_count={} demo_document_count={} " + "checklist_count={} approval_count={} transition_count={} " + + "external_submission_count={} evidence_count={} request_draft_count={} " + "test_task_count={} test_document_count={} audit_count={}", catalog.demoTasks().size(), catalog.demoDocuments().size(), catalog.demoChecklists().size(), catalog.demoApprovals().size(), catalog.demoTransitions().size(), + catalog.demoExternalSubmissions().size(), + catalog.demoEvidence().size(), + catalog.demoDocumentRequestDrafts().size(), catalog.testTasks().size(), catalog.testDocuments().size(), catalog.demoAudits().size() @@ -108,6 +136,9 @@ private void seedDataset( List checklists, List approvals, List transitions, + List externalSubmissions, + List evidence, + List requestDrafts, List audits, DemoOperationalSeedContext context ) { @@ -116,7 +147,21 @@ private void seedDataset( checklists.forEach(seed -> checklistSeeder.seed(seed, context)); approvals.forEach(seed -> approvalSeeder.seed(seed, context)); transitions.forEach(seed -> transitionSeeder.seed(seed, context)); + externalSubmissions.forEach(seed -> externalSubmissionSeeder.seed(seed, context)); + evidence.forEach(seed -> evidenceSeeder.seed(seed, context)); + requestDrafts.forEach(seed -> requestDraftSeeder.seed(seed, context)); audits.forEach(seed -> auditSeeder.seed(seed, context)); - verifier.verify(tasks, documents, checklists, approvals, transitions, audits, context); + verifier.verify( + tasks, + documents, + checklists, + approvals, + transitions, + externalSubmissions, + evidence, + requestDrafts, + audits, + context + ); } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java index 2102bc35..7004a803 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java @@ -7,6 +7,9 @@ import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; import com.fowoco.server.task.application.port.TaskChecklistRepository; @@ -34,6 +37,9 @@ final class DemoOperationalSeedVerifier { private final DemoTaskChecklistSeeder checklistSeeder; private final DemoApprovalRequestSeeder approvalSeeder; private final DemoTaskTransitionSeeder transitionSeeder; + private final DemoExternalSubmissionSeeder externalSubmissionSeeder; + private final DemoEvidenceSeeder evidenceSeeder; + private final DemoDocumentRequestDraftSeeder requestDraftSeeder; private final DemoAuditEventSeeder auditSeeder; DemoOperationalSeedVerifier( @@ -47,6 +53,9 @@ final class DemoOperationalSeedVerifier { DemoTaskChecklistSeeder checklistSeeder, DemoApprovalRequestSeeder approvalSeeder, DemoTaskTransitionSeeder transitionSeeder, + DemoExternalSubmissionSeeder externalSubmissionSeeder, + DemoEvidenceSeeder evidenceSeeder, + DemoDocumentRequestDraftSeeder requestDraftSeeder, DemoAuditEventSeeder auditSeeder ) { this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); @@ -71,6 +80,15 @@ final class DemoOperationalSeedVerifier { transitionSeeder, "transitionSeeder must not be null" ); + this.externalSubmissionSeeder = Objects.requireNonNull( + externalSubmissionSeeder, + "externalSubmissionSeeder must not be null" + ); + this.evidenceSeeder = Objects.requireNonNull(evidenceSeeder, "evidenceSeeder must not be null"); + this.requestDraftSeeder = Objects.requireNonNull( + requestDraftSeeder, + "requestDraftSeeder must not be null" + ); this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); } @@ -80,6 +98,9 @@ void verify( List checklists, List approvals, List transitions, + List externalSubmissions, + List evidence, + List requestDrafts, List audits, DemoOperationalSeedContext context ) { @@ -88,6 +109,11 @@ void verify( verifyUniqueIds(checklists.stream().map(ChecklistSeed::checklistItemId).toList(), "checklist item"); verifyUniqueIds(approvals.stream().map(ApprovalSeed::approvalRequestId).toList(), "approval request"); verifyUniqueIds(transitions.stream().map(TransitionSeed::transitionId).toList(), "task transition"); + verifyUniqueIds(externalSubmissions.stream() + .map(ExternalSubmissionSeed::externalSubmissionId).toList(), "external submission"); + verifyUniqueIds(evidence.stream().map(EvidenceSeed::evidenceId).toList(), "completion evidence"); + verifyUniqueIds(requestDrafts.stream().map(DocumentRequestDraftSeed::draftId).toList(), + "document request draft"); verifyUniqueIds(audits.stream().map(AuditSeed::auditEventId).toList(), "audit event"); verifyWorkersExist(tasks, documents, context); tasks.forEach(seed -> verifyTask(seed, context)); @@ -95,6 +121,9 @@ void verify( checklists.forEach(seed -> verifyChecklist(seed, context)); approvals.forEach(seed -> verifyApproval(seed, context)); transitions.forEach(seed -> transitionSeeder.verifyExisting(seed, context)); + externalSubmissions.forEach(seed -> externalSubmissionSeeder.verifyExisting(seed, context)); + evidence.forEach(seed -> evidenceSeeder.verifyExisting(seed, context)); + requestDrafts.forEach(seed -> requestDraftSeeder.verifyExisting(seed, context)); audits.forEach(seed -> verifyAudit(seed, context)); } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java new file mode 100644 index 00000000..9b17181d --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java @@ -0,0 +1,114 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.worker.domain.DocumentType; +import java.util.List; +import java.util.UUID; + +final class DemoTaskArtifactSeedCatalog { + + private DemoTaskArtifactSeedCatalog() { + } + + static List demoExternalSubmissions(List tasks) { + return List.of( + submission(1, tasks, 4, "고용센터", "DEMO-EMPLOYMENT-EXT-001", 264), + submission(2, tasks, 18, "고용센터", "DEMO-EMPLOYMENT-EXT-002", 312), + submission(3, tasks, 19, "고용센터", "DEMO-RECONTRACT-REPORT-001", 360), + submission(4, tasks, 20, "출입국·외국인청", "DEMO-STAY-EXT-001", 120), + submission(5, tasks, 22, "고용센터", "DEMO-EMPLOYMENT-EXT-003", 144), + submission(6, tasks, 23, "출입국·외국인청", "DEMO-STAY-EXT-002", 168) + ); + } + + static List demoEvidence(List tasks) { + return List.of( + evidence(1, tasks, 5, EvidenceType.DOCUMENT, "서명된 재계약 문서 확인", 48), + evidence(2, tasks, 5, EvidenceType.HR_CONFIRMATION, "계약 갱신 처리 완료 확인", 24), + evidence(3, tasks, 20, EvidenceType.RECEIPT, "체류기간 연장 접수 확인", 48), + evidence(4, tasks, 20, EvidenceType.OFFICIAL_RESULT, "체류기간 연장 승인 결과 확인", 12), + evidence(5, tasks, 21, EvidenceType.DOCUMENT, "갱신 계약서 서명 상태 확인", 72), + evidence(6, tasks, 21, EvidenceType.HR_CONFIRMATION, "재계약 인사 반영 완료", 24), + evidence(7, tasks, 22, EvidenceType.RECEIPT, "고용허가기간 연장 접수 확인", 72), + evidence(8, tasks, 22, EvidenceType.OFFICIAL_RESULT, "고용허가기간 연장 결과 확인", 24), + evidence(9, tasks, 23, EvidenceType.RECEIPT, "체류 연장 신청 접수 확인", 96), + evidence(10, tasks, 23, EvidenceType.OFFICIAL_RESULT, "체류 연장 최종 결과 확인", 24) + ); + } + + static List demoDocumentRequestDrafts(List tasks) { + return List.of( + draft(1, tasks, 3, "ne", List.of(DocumentType.PASSPORT_COPY), + "कृपया राहदानीको प्रतिलिपि तयार गर्नुहोस्।", 72), + draft(2, tasks, 8, "vi", List.of(DocumentType.PASSPORT_COPY, DocumentType.ARC), + "Vui lòng chuẩn bị bản sao hộ chiếu và thẻ đăng ký người nước ngoài.", 36), + draft(3, tasks, 10, "id", List.of(DocumentType.PASSPORT_COPY, DocumentType.ARC), + "Mohon siapkan salinan paspor dan kartu registrasi orang asing.", 72), + draft(4, tasks, 11, "id", List.of(DocumentType.ARC), + "Mohon siapkan salinan terbaru kartu registrasi orang asing.", 120), + draft(5, tasks, 12, "my", List.of(DocumentType.CONTRACT, DocumentType.PERMIT), + "ကျေးဇူးပြု၍ အလုပ်စာချုပ်နှင့် အလုပ်လုပ်ခွင့်စာရွက်စာတမ်းကို ပြင်ဆင်ပါ။", 144) + ); + } + + private static ExternalSubmissionSeed submission( + int number, + List tasks, + int taskNumber, + String destination, + String safeReference, + int hoursAgo + ) { + return new ExternalSubmissionSeed( + demoUuid("94500000-0000-0000-0000-000000000", number), + tasks.get(taskNumber - 1).taskId(), + destination, + safeReference, + hoursAgo + ); + } + + private static EvidenceSeed evidence( + int number, + List tasks, + int taskNumber, + EvidenceType evidenceType, + String note, + int hoursAgo + ) { + return new EvidenceSeed( + demoUuid("94600000-0000-0000-0000-000000000", number), + tasks.get(taskNumber - 1).taskId(), + evidenceType, + note, + hoursAgo + ); + } + + private static DocumentRequestDraftSeed draft( + int number, + List tasks, + int taskNumber, + String language, + List documentTypes, + String message, + int hoursAgo + ) { + return new DocumentRequestDraftSeed( + demoUuid("94700000-0000-0000-0000-000000000", number), + tasks.get(taskNumber - 1).taskId(), + language, + documentTypes, + message, + hoursAgo + ); + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } +} From 8940b708f525e50e2d328892704d3e996ab7be51 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 3 Aug 2026 16:39:59 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat(seed):=20Audit=20Event=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 운영형 데모 및 테스트 회사의 Audit Event 시나리오를 확장한다. - HR 사용자, AI 에이전트, 시스템 규칙, 근로자 링크 행위자를 구성한다. - 업무·체크리스트·승인·외부 제출·완료 증빙 이벤트를 연결한다. - 예약된 Audit Event ID를 단건 조회해 재실행 멱등성을 검증한다. - JPA 영속성 컨텍스트를 사용해 저장 직후 검증이 실패하지 않도록 한다. - 기존 타임라인 Audit Event 3건과의 호환성을 유지한다. --- .../port/AuditEventRepository.java | 3 + .../persistence/JpaAuditEventRepository.java | 6 + .../seed/DemoAuditEventSeeder.java | 42 ++-- .../seed/DemoAuditSeedCatalog.java | 227 ++++++++++++++++++ .../seed/DemoOperationalSeedCatalog.java | 46 +++- .../seed/DemoOperationalSeedRunner.java | 8 +- .../seed/DemoOperationalSeedVerifier.java | 2 +- 7 files changed, 307 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditSeedCatalog.java diff --git a/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java b/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java index bbad891f..0addee93 100644 --- a/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java +++ b/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java @@ -3,12 +3,15 @@ import com.fowoco.server.audit.application.AuditSearchCriteria; import com.fowoco.server.audit.domain.AuditEvent; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface AuditEventRepository { void append(AuditEvent event); + Optional findById(UUID auditEventId); + List findTaskActivities(UUID companyId, UUID taskId); List search(AuditSearchCriteria criteria); diff --git a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java index b1f9abe3..1abc22fc 100644 --- a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java +++ b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java @@ -5,6 +5,7 @@ import com.fowoco.server.audit.domain.AuditEvent; import com.fowoco.server.audit.domain.AuditTargetType; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @@ -23,6 +24,11 @@ public void append(AuditEvent event) { repository.save(new AuditEventJpaEntity(event)); } + @Override + public Optional findById(UUID auditEventId) { + return repository.findById(auditEventId).map(AuditEventJpaEntity::toDomain); + } + @Override public List findTaskActivities(UUID companyId, UUID taskId) { return repository diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java index 688ef29e..9f15fb9b 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java @@ -3,16 +3,14 @@ import com.fowoco.server.audit.application.port.AuditEventRepository; import com.fowoco.server.audit.domain.ActorType; import com.fowoco.server.audit.domain.AuditEvent; -import com.fowoco.server.audit.domain.AuditTargetType; -import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; import java.time.temporal.ChronoUnit; import java.util.Objects; import java.util.Optional; +import java.util.UUID; final class DemoAuditEventSeeder { - private static final String TRACE_ID = "demo-seed-task-timeline"; private static final String EVENT_VERSION = "1"; private final AuditEventRepository auditEventRepository; @@ -25,7 +23,7 @@ final class DemoAuditEventSeeder { } void seed(AuditSeed seed, DemoOperationalSeedContext context) { - Optional existing = findExisting(seed, context); + Optional existing = findExisting(seed); if (existing.isPresent()) { verifyExisting(existing.orElseThrow(), seed, context); return; @@ -33,26 +31,22 @@ void seed(AuditSeed seed, DemoOperationalSeedContext context) { auditEventRepository.append(new AuditEvent( seed.auditEventId(), context.companyId(), - ActorType.HR_USER, - context.actorId(), - UserRole.ADMIN, + seed.actorType(), + actorId(seed, context), + seed.userRole(), seed.action(), - AuditTargetType.TASK, - DemoOperationalSeedCatalog.TIMELINE_TASK_ID, + seed.targetType(), + seed.targetId(), seed.requestId(), - TRACE_ID, + seed.traceId(), EVENT_VERSION, seed.changeSummary(), context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS) )); } - Optional findExisting(AuditSeed seed, DemoOperationalSeedContext context) { - return auditEventRepository - .findTaskActivities(context.companyId(), DemoOperationalSeedCatalog.TIMELINE_TASK_ID) - .stream() - .filter(event -> seed.auditEventId().equals(event.auditEventId())) - .findFirst(); + Optional findExisting(AuditSeed seed) { + return auditEventRepository.findById(seed.auditEventId()); } void verifyExisting( @@ -61,17 +55,23 @@ void verifyExisting( DemoOperationalSeedContext context ) { if (!context.companyId().equals(event.companyId()) - || event.actorType() != ActorType.HR_USER - || !context.actorId().equals(event.actorId()) - || event.userRole() != UserRole.ADMIN + || event.actorType() != seed.actorType() + || !Objects.equals(actorId(seed, context), event.actorId()) + || event.userRole() != seed.userRole() || event.action() != seed.action() - || event.targetType() != AuditTargetType.TASK - || !DemoOperationalSeedCatalog.TIMELINE_TASK_ID.equals(event.targetId()) + || event.targetType() != seed.targetType() + || !seed.targetId().equals(event.targetId()) || !seed.requestId().equals(event.requestId()) + || !seed.traceId().equals(event.traceId()) + || !EVENT_VERSION.equals(event.eventVersion()) || !seed.changeSummary().equals(event.changeSummary())) { throw new IllegalStateException( "a reserved demo audit event id already belongs to different audit data" ); } } + + private UUID actorId(AuditSeed seed, DemoOperationalSeedContext context) { + return seed.actorType() == ActorType.HR_USER ? context.actorId() : null; + } } 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 new file mode 100644 index 00000000..f8a41850 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditSeedCatalog.java @@ -0,0 +1,227 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.domain.ApprovalStatus; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +final class DemoAuditSeedCatalog { + + private static final List CHECKLIST_EVENT_TASKS = List.of( + 3, 4, 5, 6, 8, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22 + ); + + private DemoAuditSeedCatalog() { + } + + static List additionalDemoAudits( + List tasks, + List approvals, + List submissions, + List evidence, + List requestDrafts + ) { + List seeds = new ArrayList<>(); + for (int taskIndex = 0; taskIndex < tasks.size(); taskIndex++) { + int taskNumber = taskIndex + 1; + if (taskNumber != 2) { + TaskSeed task = tasks.get(taskIndex); + add(seeds, actorType(task.source()), AuditAction.TASK_CREATED, + AuditTargetType.TASK, task.taskId(), + "업무 시나리오가 생성됨", task.createdDaysAgo() * 24); + } + } + CHECKLIST_EVENT_TASKS.forEach(taskNumber -> { + TaskSeed task = tasks.get(taskNumber - 1); + add(seeds, ActorType.HR_USER, AuditAction.CHECKLIST_ITEM_UPDATED, + AuditTargetType.TASK, task.taskId(), + "필수 체크리스트 준비 상태를 확인함", + Math.max((task.createdDaysAgo() - 1) * 24 + 2, 2)); + }); + for (int index = 0; index < approvals.size(); index++) { + ApprovalSeed approval = approvals.get(index); + if (index != 0) { + add(seeds, ActorType.HR_USER, AuditAction.APPROVAL_REQUESTED, + AuditTargetType.TASK, approval.taskId(), + "현재 업무 내용에 대한 승인 검토를 요청함", + approval.requestedHoursAgo()); + } + if (approval.status() != ApprovalStatus.PENDING) { + add(seeds, ActorType.HR_USER, approvalAction(approval.status()), + AuditTargetType.TASK, approval.taskId(), approvalSummary(approval.status()), + approval.outcomeHoursAgo()); + } + } + submissions.forEach(submission -> add( + seeds, + ActorType.HR_USER, + AuditAction.EXTERNAL_SUBMISSION_RECORDED, + AuditTargetType.TASK, + submission.taskId(), + "외부 기관 제출과 안전한 접수 참조값을 기록함", + submission.hoursAgo() + )); + evidence.forEach(item -> add( + seeds, + ActorType.HR_USER, + AuditAction.EVIDENCE_RECORDED, + AuditTargetType.TASK, + item.taskId(), + "업무 완료 판단에 필요한 안전한 증빙 메모를 기록함", + item.hoursAgo() + )); + requestDrafts.forEach(draft -> add( + seeds, + ActorType.HR_USER, + AuditAction.DOCUMENT_REQUEST_DRAFT_SAVED, + AuditTargetType.DOCUMENT_REQUEST_DRAFT, + draft.draftId(), + "근로자 안내용 문서 요청 초안을 저장함", + draft.hoursAgo() + )); + tasks.stream() + .filter(task -> task.status() == TaskStatus.COMPLETED) + .forEach(task -> add(seeds, ActorType.HR_USER, AuditAction.TASK_COMPLETED, + AuditTargetType.TASK, task.taskId(), + "승인과 완료 증빙 확인 후 업무를 완료함", 0)); + tasks.stream() + .filter(task -> task.status() == TaskStatus.CANCELLED) + .forEach(task -> add(seeds, ActorType.HR_USER, AuditAction.TASK_CANCELLED, + AuditTargetType.TASK, task.taskId(), + "계약 일정 변경 사유를 확인하고 업무를 취소함", + Math.max((task.createdDaysAgo() - 1) * 24, 1))); + for (DocumentRequestDraftSeed draft : requestDrafts.stream().limit(4).toList()) { + add(seeds, ActorType.WORKER_LINK, AuditAction.TASK_UPDATED, + AuditTargetType.TASK, draft.taskId(), + "근로자 제출 문서 정보가 업무 준비 자료에 반영됨", + Math.max(draft.hoursAgo() - 12, 1)); + } + return List.copyOf(seeds); + } + + static List testAudits(List tasks) { + List seeds = new ArrayList<>(); + tasks.forEach(task -> addTest(seeds, actorType(task.source()), AuditAction.TASK_CREATED, + task.taskId(), "격리 확인용 테스트 업무가 생성됨", task.createdDaysAgo() * 24)); + tasks.forEach(task -> addTest(seeds, ActorType.HR_USER, AuditAction.CHECKLIST_ITEM_UPDATED, + task.taskId(), "테스트 업무의 준비 상태를 확인함", + Math.max((task.createdDaysAgo() - 1) * 24, 1))); + addTest(seeds, ActorType.HR_USER, AuditAction.APPROVAL_REQUESTED, + tasks.get(1).taskId(), "테스트 재계약 업무 승인을 요청함", 24); + addTest(seeds, ActorType.HR_USER, AuditAction.TASK_COMPLETED, + tasks.get(2).taskId(), "테스트 고용기간 연장 업무를 완료함", 0); + return List.copyOf(seeds); + } + + private static void add( + List seeds, + ActorType actorType, + AuditAction action, + AuditTargetType targetType, + UUID targetId, + String summary, + int hoursAgo + ) { + int sequence = seeds.size() + 4; + seeds.add(audit( + demoUuid("96000000-0000-0000-0000-000000000", sequence), + actorType, + action, + targetType, + targetId, + "demo-seed-audit-%03d".formatted(sequence), + "demo-task-%s".formatted(targetId.toString().substring(24)), + summary, + hoursAgo + )); + } + + private static void addTest( + List seeds, + ActorType actorType, + AuditAction action, + UUID targetId, + String summary, + int hoursAgo + ) { + int sequence = seeds.size() + 1; + seeds.add(audit( + demoUuid("99000000-0000-0000-0000-000000000", sequence), + actorType, + action, + AuditTargetType.TASK, + targetId, + "test-seed-audit-%03d".formatted(sequence), + "test-task-%s".formatted(targetId.toString().substring(24)), + summary, + hoursAgo + )); + } + + private static AuditSeed audit( + UUID auditEventId, + ActorType actorType, + AuditAction action, + AuditTargetType targetType, + UUID targetId, + String requestId, + String traceId, + String summary, + int hoursAgo + ) { + return new AuditSeed( + auditEventId, + actorType, + actorType == ActorType.HR_USER ? UserRole.ADMIN : null, + action, + targetType, + targetId, + requestId, + traceId, + summary, + hoursAgo + ); + } + + 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; + }; + } + + private static AuditAction approvalAction(ApprovalStatus status) { + return switch (status) { + case APPROVED -> AuditAction.TASK_APPROVED; + case REJECTED -> AuditAction.TASK_REJECTED; + case INVALIDATED -> AuditAction.APPROVAL_INVALIDATED; + case PENDING -> throw new IllegalArgumentException("pending approval has no outcome action"); + }; + } + + private static String approvalSummary(ApprovalStatus status) { + return switch (status) { + case APPROVED -> "관리자가 현재 업무 내용을 승인함"; + case REJECTED -> "필수 정보 보완을 위해 승인 요청을 반려함"; + case INVALIDATED -> "중요 정보 변경으로 기존 승인 요청을 무효화함"; + case PENDING -> throw new IllegalArgumentException("pending approval has no outcome summary"); + }; + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index 7acd3fa2..183795fd 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -1,8 +1,11 @@ package com.fowoco.server.demo.infrastructure.seed; -import com.fowoco.server.audit.domain.AuditAction; import com.fowoco.server.approval.domain.ApprovalStatus; import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.task.domain.TaskType; @@ -40,7 +43,7 @@ final class DemoOperationalSeedCatalog { DemoTaskArtifactSeedCatalog.demoEvidence(demoTasks); private final List demoDocumentRequestDrafts = DemoTaskArtifactSeedCatalog.demoDocumentRequestDrafts(demoTasks); - private final List demoAudits = List.of( + private final List legacyDemoAudits = List.of( audit( "96000000-0000-0000-0000-000000000001", AuditAction.TASK_CREATED, @@ -63,6 +66,17 @@ final class DemoOperationalSeedCatalog { 6 ) ); + private final List demoAudits = Stream.concat( + legacyDemoAudits.stream(), + DemoAuditSeedCatalog.additionalDemoAudits( + demoTasks, + demoApprovals, + demoExternalSubmissions, + demoEvidence, + demoDocumentRequestDrafts + ).stream() + ).toList(); + private final List testAudits = DemoAuditSeedCatalog.testAudits(testTasks); DemoOperationalSeedCatalog() { verifyDefinitions(); @@ -112,6 +126,10 @@ List demoAudits() { return demoAudits; } + List testAudits() { + return testAudits; + } + private void verifyDefinitions() { requireSize(demoTasks, 24, "Demo Company task"); requireSize(testTasks, 3, "Test Company task"); @@ -123,6 +141,8 @@ private void verifyDefinitions() { requireSize(demoExternalSubmissions, 6, "Demo Company external submission"); requireSize(demoEvidence, 10, "Demo Company completion evidence"); requireSize(demoDocumentRequestDrafts, 5, "Demo Company document request draft"); + requireSize(demoAudits, 96, "Demo Company audit event"); + requireSize(testAudits, 8, "Test Company audit event"); requireDistribution( demoTasks.stream().map(TaskSeed::taskType).toList(), Map.of( @@ -175,6 +195,16 @@ private void verifyDefinitions() { ), "Demo Company evidence type" ); + requireDistribution( + demoAudits.stream().map(AuditSeed::actorType).toList(), + Map.of( + ActorType.HR_USER, 83L, + ActorType.AI_AGENT, 3L, + ActorType.SYSTEM_RULE, 6L, + ActorType.WORKER_LINK, 4L + ), + "Demo Company audit actor type" + ); requireUniqueIds( Stream.concat(demoTasks.stream(), testTasks.stream()).map(TaskSeed::taskId).toList(), "task" @@ -197,6 +227,8 @@ private void verifyDefinitions() { "completion evidence"); requireUniqueIds(demoDocumentRequestDrafts.stream() .map(DocumentRequestDraftSeed::draftId).toList(), "document request draft"); + requireUniqueIds(Stream.concat(demoAudits.stream(), testAudits.stream()) + .map(AuditSeed::auditEventId).toList(), "audit event"); requireTaskReferences(demoChecklists.stream().map(ChecklistSeed::taskId).toList(), "checklist item"); requireTaskReferences(demoApprovals.stream().map(ApprovalSeed::taskId).toList(), @@ -287,8 +319,13 @@ private static AuditSeed audit( ) { return new AuditSeed( UUID.fromString(auditEventId), + ActorType.HR_USER, + UserRole.ADMIN, action, + AuditTargetType.TASK, + TIMELINE_TASK_ID, requestId, + "demo-seed-task-timeline", changeSummary, hoursAgo ); @@ -383,8 +420,13 @@ record DocumentRequestDraftSeed( record AuditSeed( UUID auditEventId, + ActorType actorType, + UserRole userRole, AuditAction action, + AuditTargetType targetType, + UUID targetId, String requestId, + String traceId, String changeSummary, int hoursAgo ) { diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java index 3e835a4e..b0483d4f 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -108,14 +108,15 @@ public void run(ApplicationArguments arguments) { List.of(), List.of(), List.of(), - List.of(), + catalog.testAudits(), testContext ); LOGGER.info( "demo_operational_seed ready demo_task_count={} demo_document_count={} " + "checklist_count={} approval_count={} transition_count={} " + "external_submission_count={} evidence_count={} request_draft_count={} " - + "test_task_count={} test_document_count={} audit_count={}", + + "test_task_count={} test_document_count={} demo_audit_count={} " + + "test_audit_count={}", catalog.demoTasks().size(), catalog.demoDocuments().size(), catalog.demoChecklists().size(), @@ -126,7 +127,8 @@ public void run(ApplicationArguments arguments) { catalog.demoDocumentRequestDrafts().size(), catalog.testTasks().size(), catalog.testDocuments().size(), - catalog.demoAudits().size() + catalog.demoAudits().size(), + catalog.testAudits().size() ); } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java index 7004a803..3caf7f2a 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java @@ -165,7 +165,7 @@ private void verifyApproval(ApprovalSeed seed, DemoOperationalSeedContext contex } private void verifyAudit(AuditSeed seed, DemoOperationalSeedContext context) { - AuditEvent event = auditSeeder.findExisting(seed, context) + AuditEvent event = auditSeeder.findExisting(seed) .orElseThrow(() -> new IllegalStateException("a reserved demo audit event was not seeded")); auditSeeder.verifyExisting(event, seed, context); } From 96aeb2d69dc30564b8b7ab4185d3e50dbe5e50c6 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 3 Aug 2026 17:46:30 +0900 Subject: [PATCH 07/15] =?UTF-8?q?test(seed):=20=EC=88=98=EB=9F=89=C2=B7?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=EC=84=B1=C2=B7=EA=B2=A9=EB=A6=AC=C2=B7API=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Demo/Test 회사의 최종 시드 수량과 주요 상태 분포를 검증한다. - 고정 Clock을 사용해 업무·문서·Audit 상대 날짜를 검증한다. - 전체 시드 러너 재실행 후 데이터가 중복되지 않는지 확인한다. - 예약 ID와 이메일 충돌 시 시드가 실패하는지 검증한다. - Demo/Test 인증을 이용해 근로자·업무·문서·Audit API 결과와 테넌트 격리를 확인한다. - StoredFile 및 민감정보가 생성되지 않는지 검증한다. --- .../AuthRefreshPostgreSqlConcurrencyTest.java | 22 +- .../seed/DemoAuthSeedIntegrationTest.java | 560 +++++++++++++----- .../seed/DemoAuthSeedRunnerTest.java | 52 ++ 3 files changed, 480 insertions(+), 154 deletions(-) diff --git a/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java b/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java index 93c7792e..15b0aa60 100644 --- a/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java +++ b/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java @@ -50,8 +50,8 @@ @Import(AuthRefreshPostgreSqlConcurrencyTest.ConcurrencyConfiguration.class) class AuthRefreshPostgreSqlConcurrencyTest { - private static final UUID COMPANY_ID = UUID.fromString("91000000-0000-0000-0000-000000000001"); - private static final UUID USER_ID = UUID.fromString("92000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_ID = UUID.fromString("ca000000-0000-0000-0000-000000000001"); + private static final UUID USER_ID = UUID.fromString("ca100000-0000-0000-0000-000000000001"); private static final String EMAIL = "postgres.refresh@example.com"; private static final String PASSWORD = "Test-password-1!"; private static final String REFRESH_TOKEN_COOKIE_NAME = "fowoco_refresh_token"; @@ -320,11 +320,19 @@ SELECT COUNT(*) } private void clearAuthenticationRows() { - jdbcTemplate.update("DELETE FROM event_consumption"); - jdbcTemplate.update("DELETE FROM event_publication"); - jdbcTemplate.update("DELETE FROM refresh_token"); - jdbcTemplate.update("DELETE FROM user_account"); - jdbcTemplate.update("DELETE FROM company"); + jdbcTemplate.update("DELETE FROM event_consumption WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update("DELETE FROM event_publication WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update( + "DELETE FROM refresh_token WHERE company_id = ? AND user_id = ?", + COMPANY_ID, + USER_ID + ); + jdbcTemplate.update( + "DELETE FROM user_account WHERE company_id = ? AND user_id = ?", + COMPANY_ID, + USER_ID + ); + jdbcTemplate.update("DELETE FROM company WHERE company_id = ?", COMPANY_ID); } private static String requiredEnvironmentVariable(String name) { diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index 3953b3d5..9f42fa7c 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -2,33 +2,91 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.DefaultApplicationArguments; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpHeaders; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; @ActiveProfiles("test") -@SpringBootTest(properties = { - "app.demo-seed.enabled=true", - "app.demo-seed.admin-password=Demo-password-1!" -}) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "app.demo-seed.enabled=true", + "app.demo-seed.admin-password=Demo-password-1!", + "app.auth.jwt.access-token-ttl=1h" + } +) +@Import(DemoAuthSeedIntegrationTest.FixedClockConfiguration.class) class DemoAuthSeedIntegrationTest { - private static final UUID COMPANY_ID = UUID.fromString("90000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_ID = + UUID.fromString("90000000-0000-0000-0000-000000000001"); private static final UUID TEST_COMPANY_ID = UUID.fromString("91000000-0000-0000-0000-000000000001"); - private static final UUID ADMIN_USER_ID = UUID.fromString("90000000-0000-0000-0000-000000000002"); + private static final UUID ADMIN_USER_ID = + UUID.fromString("90000000-0000-0000-0000-000000000002"); + private static final UUID TIMELINE_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000002"); + private static final UUID TEST_TASK_ID = + UUID.fromString("97000000-0000-0000-0000-000000000001"); + private static final UUID REPRESENTATIVE_WORKER_ID = + UUID.fromString("92000000-0000-0000-0000-000000000006"); + private static final String PASSWORD = "Demo-password-1!"; + private static final String DEMO_ADMIN_EMAIL = "demo.admin@example.com"; + private static final String TEST_ADMIN_EMAIL = "test.admin@example.com"; + private static final Instant TEST_NOW = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS); + + private static final Map EXPECTED_DEMO_COUNTS = Map.ofEntries( + Map.entry("user_account", 20), + Map.entry("worker", 28), + Map.entry("task", 24), + Map.entry("worker_document", 84), + Map.entry("task_checklist_item", 68), + Map.entry("approval_request", 13), + Map.entry("task_transition_history", 52), + Map.entry("external_submission", 6), + Map.entry("task_evidence", 10), + Map.entry("document_request_draft", 5), + Map.entry("audit_event", 96) + ); + private static final Map EXPECTED_TEST_COUNTS = Map.of( + "user_account", 3, + "worker", 5, + "task", 3, + "worker_document", 8, + "audit_event", 8 + ); + + @LocalServerPort + private int port; @Autowired private JdbcTemplate jdbcTemplate; @@ -39,15 +97,45 @@ class DemoAuthSeedIntegrationTest { @Autowired private Clock clock; + @Autowired + @Qualifier("demoAuthSeedRunner") + private ApplicationRunner demoAuthSeedRunner; + + @Autowired + @Qualifier("demoWorkerSeedRunner") + private ApplicationRunner demoWorkerSeedRunner; + @Autowired @Qualifier("demoOperationalSeedRunner") private ApplicationRunner demoOperationalSeedRunner; + private final HttpClient httpClient = HttpClient.newHttpClient(); + @Test - void enabledSeedCreatesIdempotentCompanyAccountWorkerAndOperationalDataAfterFlyway() - throws Exception { - demoOperationalSeedRunner.run(new DefaultApplicationArguments(new String[0])); + void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throws Exception { + assertAccountsAndPasswords(); + assertExactCountsAndDistributions(); + assertRelativeDatesAndSafeData(); + + Map demoCountsBeforeRerun = counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet()); + Map testCountsBeforeRerun = counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet()); + + DefaultApplicationArguments arguments = new DefaultApplicationArguments(new String[0]); + demoAuthSeedRunner.run(arguments); + demoWorkerSeedRunner.run(arguments); + demoOperationalSeedRunner.run(arguments); + assertThat(counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet())) + .isEqualTo(demoCountsBeforeRerun) + .isEqualTo(EXPECTED_DEMO_COUNTS); + assertThat(counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet())) + .isEqualTo(testCountsBeforeRerun) + .isEqualTo(EXPECTED_TEST_COUNTS); + + assertFrontendRelevantApiResultsAndTenantIsolation(); + } + + private void assertAccountsAndPasswords() { String passwordHash = jdbcTemplate.queryForObject( "SELECT password_hash FROM user_account WHERE user_id = ? AND company_id = ?", String.class, @@ -55,161 +143,339 @@ void enabledSeedCreatesIdempotentCompanyAccountWorkerAndOperationalDataAfterFlyw COMPANY_ID ); - assertThat(passwordHash).isNotEqualTo("Demo-password-1!"); - assertThat(passwordEncoder.matches("Demo-password-1!", passwordHash)).isTrue(); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM company WHERE company_id = ? AND status = 'ACTIVE'", - Integer.class, - COMPANY_ID - )).isEqualTo(1); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM company WHERE company_id = ? " - + "AND name = 'FOWOCO Test Company' AND status = 'ACTIVE'", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(1); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE user_id = ? AND display_name = '데모 관리자' " - + "AND role = 'ADMIN' AND status = 'ACTIVE'", - Integer.class, - ADMIN_USER_ID - )).isEqualTo(1); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ?", - Integer.class, - COMPANY_ID - )).isEqualTo(20); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'ADMIN'", - Integer.class, - COMPANY_ID - )).isEqualTo(2); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'HR'", - Integer.class, - COMPANY_ID - )).isEqualTo(12); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'VIEWER'", - Integer.class, - COMPANY_ID - )).isEqualTo(6); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ?", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(3); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'ADMIN'", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(1); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'HR'", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(1); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'VIEWER'", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(1); + assertThat(passwordHash).isNotEqualTo(PASSWORD); + assertThat(passwordEncoder.matches(PASSWORD, passwordHash)).isTrue(); + assertThat(count("company", COMPANY_ID)).isEqualTo(1); + assertThat(count("company", TEST_COMPANY_ID)).isEqualTo(1); + assertThat(distribution("user_account", "role", COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( + Map.of("ADMIN", 2, "HR", 12, "VIEWER", 6) + ); + assertThat(distribution("user_account", "role", TEST_COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( + Map.of("ADMIN", 1, "HR", 1, "VIEWER", 1) + ); + } + + private void assertExactCountsAndDistributions() { + assertThat(counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet())) + .containsExactlyInAnyOrderEntriesOf(EXPECTED_DEMO_COUNTS); + assertThat(counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet())) + .containsExactlyInAnyOrderEntriesOf(EXPECTED_TEST_COUNTS); + + assertThat(distribution("task", "task_type", COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( + Map.of("STAY_PERIOD_EXTENSION", 10, "RECONTRACT", 8, "EMPLOYMENT_PERIOD_EXTENSION", 6) + ); + assertThat(distribution("task", "status", COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( + Map.of( + "DRAFT", 3, + "NEEDS_INFO", 2, + "READY_FOR_REVIEW", 4, + "APPROVED", 2, + "WAITING_WORKER", 4, + "WAITING_EXTERNAL", 3, + "COMPLETED", 5, + "CANCELLED", 1 + ) + ); + assertThat(distribution("worker_document", "document_type", COMPANY_ID)) + .containsExactlyInAnyOrderEntriesOf( + Map.of("PASSPORT_COPY", 26, "ARC", 28, "CONTRACT", 22, "PERMIT", 8) + ); + assertThat(distribution("worker_document", "submission_status", COMPANY_ID)) + .containsExactlyInAnyOrderEntriesOf( + Map.of("VERIFIED", 48, "SUBMITTED", 20, "MISSING", 16) + ); + assertThat(distribution("approval_request", "status", COMPANY_ID)) + .containsExactlyInAnyOrderEntriesOf( + Map.of("PENDING", 4, "APPROVED", 7, "REJECTED", 1, "INVALIDATED", 1) + ); + assertThat(distribution("audit_event", "actor_type", COMPANY_ID)) + .containsExactlyInAnyOrderEntriesOf( + Map.of("HR_USER", 83, "AI_AGENT", 3, "SYSTEM_RULE", 6, "WORKER_LINK", 4) + ); + assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker WHERE company_id = ?", + """ + SELECT COUNT(*) + FROM task_checklist_item checklist + JOIN task seeded_task ON seeded_task.task_id = checklist.task_id + WHERE checklist.company_id = ? AND seeded_task.company_id = ? + """, Integer.class, + COMPANY_ID, COMPANY_ID - )).isEqualTo(28); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker WHERE company_id = ?", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(5); + )).isEqualTo(68); assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM task WHERE company_id = ?", + "SELECT COUNT(DISTINCT task_id) FROM task_checklist_item WHERE company_id = ?", Integer.class, COMPANY_ID )).isEqualTo(24); - assertThat(jdbcTemplate.queryForList( - "SELECT status FROM task WHERE company_id = ?", - String.class, - COMPANY_ID - )).contains( - "DRAFT", - "NEEDS_INFO", - "READY_FOR_REVIEW", - "APPROVED", - "WAITING_WORKER", - "WAITING_EXTERNAL", - "COMPLETED", - "CANCELLED" - ); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM task WHERE company_id = ? AND source = 'AI_CANDIDATE' " - + "AND status = 'DRAFT'", - Integer.class, - COMPANY_ID - )).isEqualTo(3); + } + + private void assertRelativeDatesAndSafeData() { + LocalDate today = LocalDate.now(clock); + assertThat(countWhere("task", "due_date = ?", COMPANY_ID, today)).isGreaterThanOrEqualTo(1); + assertThat(countWhere( + "task", + "due_date >= ? AND due_date <= ?", + COMPANY_ID, + today, + today.plusDays(7) + )).isGreaterThan(1); List completedUpdates = jdbcTemplate.query( "SELECT updated_at FROM task WHERE company_id = ? AND status = 'COMPLETED'", (resultSet, rowNumber) -> resultSet.getTimestamp("updated_at").toInstant(), COMPANY_ID ); - assertThat(completedUpdates).hasSize(5).allMatch(updatedAt -> - LocalDate.ofInstant(updatedAt, ZoneOffset.UTC).equals(LocalDate.now(clock))); - List dueDates = jdbcTemplate.query( - "SELECT due_date FROM task WHERE company_id = ?", - (resultSet, rowNumber) -> resultSet.getObject("due_date", LocalDate.class), - COMPANY_ID - ); - LocalDate today = LocalDate.now(clock); - assertThat(dueDates).anyMatch(date -> !date.isAfter(today.plusDays(7))); - assertThat(dueDates).anyMatch(date -> - date.isAfter(today.plusDays(7)) && !date.isAfter(today.plusDays(30))); - assertThat(dueDates).anyMatch(date -> date.isAfter(today.plusDays(30))); + assertThat(completedUpdates) + .hasSize(5) + .allMatch(updatedAt -> LocalDate.ofInstant(updatedAt, ZoneOffset.UTC).equals(today)); assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker_document WHERE company_id = ?", - Integer.class, + "SELECT stay_expiry_date FROM worker WHERE worker_id = ? AND company_id = ?", + LocalDate.class, + REPRESENTATIVE_WORKER_ID, COMPANY_ID - )).isEqualTo(84); - assertThat(jdbcTemplate.queryForList( - "SELECT submission_status FROM worker_document WHERE company_id = ?", - String.class, - COMPANY_ID - )).contains("MISSING", "SUBMITTED", "VERIFIED"); - List expiryDates = jdbcTemplate.query( - "SELECT expiry_date FROM worker_document WHERE company_id = ?", - (resultSet, rowNumber) -> resultSet.getObject("expiry_date", LocalDate.class), + )).isEqualTo(today.plusDays(12)); + + assertThat(countWhere( + "audit_event", + "created_at >= ? AND created_at <= ?", + COMPANY_ID, + clock.instant().minus(7, ChronoUnit.DAYS), + clock.instant() + )).isPositive(); + assertThat(countWhere( + "audit_event", + "created_at < ?", + COMPANY_ID, + clock.instant().minus(30, ChronoUnit.DAYS) + )).isPositive(); + + assertThat(countWhere("worker_document", "expiry_date < ?", COMPANY_ID, today)).isPositive(); + assertThat(countWhere( + "worker_document", + "expiry_date >= ? AND expiry_date <= ?", + COMPANY_ID, + today, + today.plusDays(30) + )).isPositive(); + assertThat(countWhere( + "worker_document", + "expiry_date > ? AND expiry_date <= ?", + COMPANY_ID, + today.plusDays(30), + today.plusDays(90) + )).isPositive(); + assertThat(countWhere( + "worker_document", + "expiry_date > ?", + COMPANY_ID, + today.plusDays(90) + )).isPositive(); + assertThat(countWhere("worker_document", "expiry_date IS NULL", COMPANY_ID)).isPositive(); + + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stored_file", Integer.class)).isZero(); + List businessData = jdbcTemplate.query( + "SELECT business_data_json FROM task WHERE company_id = ?", + (resultSet, rowNumber) -> resultSet.getString(1).toLowerCase(), COMPANY_ID ); - assertThat(expiryDates).anyMatch(date -> - date != null && !date.isBefore(today) && !date.isAfter(today.plusDays(30))); - assertThat(jdbcTemplate.queryForList( - "SELECT COUNT(*) FROM worker_document WHERE company_id = ? GROUP BY worker_id", - Integer.class, - COMPANY_ID - )).hasSize(28).allMatch(count -> count == 3); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM task WHERE company_id = ?", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(3); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker_document WHERE company_id = ?", + assertThat(businessData).allMatch(json -> List.of( + "passport_number", + "arc_number", + "phone_number", + "bank_account", + "password", + "access_token" + ).stream().noneMatch(json::contains)); + } + + private void assertFrontendRelevantApiResultsAndTenantIsolation() throws Exception { + String demoToken = loginAndGetAccessToken(DEMO_ADMIN_EMAIL); + String testToken = loginAndGetAccessToken(TEST_ADMIN_EMAIL); + LocalDate today = LocalDate.now(clock); + + HttpResponse workers = authorizedGet("/api/v1/workers?size=100", demoToken); + assertOk(workers); + assertThat(JsonPath.read(workers.body(), "$.total_elements").intValue()).isEqualTo(28); + assertThat(JsonPath.>read(workers.body(), "$.items")).hasSize(28); + + HttpResponse tasks = authorizedGet("/api/v1/tasks?size=100", demoToken); + assertOk(tasks); + assertThat(JsonPath.read(tasks.body(), "$.total_elements").intValue()).isEqualTo(24); + List> taskItems = JsonPath.read(tasks.body(), "$.items"); + assertThat(taskItems).hasSize(24); + assertStatusApiCount(demoToken, "READY_FOR_REVIEW", 4); + assertStatusApiCount(demoToken, "DRAFT", 3); + assertThat(taskItems).anyMatch(task -> today.toString().equals(task.get("due_date"))); + + HttpResponse dueSoon = authorizedGet( + "/api/v1/tasks?dueTo=" + today.plusDays(7) + "&size=100", + demoToken + ); + assertOk(dueSoon); + assertThat(JsonPath.read(dueSoon.body(), "$.total_elements").intValue()).isGreaterThan(1); + assertThat(taskItems.stream() + .filter(task -> "COMPLETED".equals(task.get("status"))) + .map(task -> (String) task.get("updated_at")) + .toList()) + .hasSize(5) + .allMatch(updatedAt -> updatedAt.startsWith(today.toString())); + assertWorkerDerivedCategories(taskItems); + + HttpResponse documents = authorizedGet("/api/v1/documents?size=100", demoToken); + assertOk(documents); + assertThat(JsonPath.read(documents.body(), "$.total_elements").intValue()).isEqualTo(84); + assertDocumentStatusHasData(demoToken, "SUBMITTED"); + assertDocumentStatusHasData(demoToken, "MISSING"); + assertDocumentStatusHasData(demoToken, "VERIFIED"); + HttpResponse expiringDocuments = authorizedGet( + "/api/v1/documents?expiryBefore=" + today.plusDays(30) + "&size=100", + demoToken + ); + assertOk(expiringDocuments); + assertThat(JsonPath.read(expiringDocuments.body(), "$.total_elements").intValue()).isPositive(); + + HttpResponse detail = authorizedGet("/api/v1/tasks/" + TIMELINE_TASK_ID, demoToken); + assertOk(detail); + assertThat(JsonPath.>read(detail.body(), "$.checklist_items")).isNotEmpty(); + HttpResponse activities = authorizedGet( + "/api/v1/tasks/" + TIMELINE_TASK_ID + "/activities", + demoToken + ); + assertOk(activities); + assertThat(JsonPath.>read(activities.body(), "$" )).hasSize(3); + + HttpResponse demoAudits = authorizedGet("/api/v1/audit-events?limit=100", demoToken); + HttpResponse testAudits = authorizedGet("/api/v1/audit-events?limit=100", testToken); + assertOk(demoAudits); + assertOk(testAudits); + assertThat(JsonPath.>read(demoAudits.body(), "$.items")).hasSize(96); + assertThat(JsonPath.>read(testAudits.body(), "$.items")).hasSize(8); + + assertTenantApiCount(testToken, "/api/v1/workers?size=100", 5); + assertTenantApiCount(testToken, "/api/v1/tasks?size=100", 3); + assertTenantApiCount(testToken, "/api/v1/documents?size=100", 8); + assertThat(authorizedGet("/api/v1/tasks/" + TEST_TASK_ID, demoToken).statusCode()).isEqualTo(404); + assertThat(authorizedGet("/api/v1/tasks/" + TIMELINE_TASK_ID, testToken).statusCode()).isEqualTo(404); + } + + private void assertWorkerDerivedCategories(List> taskItems) { + Set reviewStatuses = Set.of("READY_FOR_REVIEW", "WAITING_WORKER", "WAITING_EXTERNAL"); + Set aiStatuses = Set.of("DRAFT", "NEEDS_INFO"); + Map> statusesByWorker = taskItems.stream().collect(Collectors.groupingBy( + task -> (String) task.get("worker_id"), + Collectors.mapping(task -> (String) task.get("status"), Collectors.toSet()) + )); + Predicate> needsReview = statuses -> statuses.stream().anyMatch(reviewStatuses::contains); + Predicate> aiSuggested = statuses -> !needsReview.test(statuses) + && statuses.stream().anyMatch(aiStatuses::contains); + + assertThat(statusesByWorker.values()).anyMatch(needsReview); + assertThat(statusesByWorker.values()).anyMatch(aiSuggested); + assertThat(statusesByWorker.values()).anyMatch(statuses -> + !needsReview.test(statuses) && !aiSuggested.test(statuses)); + } + + private void assertStatusApiCount(String token, String status, int expected) throws Exception { + HttpResponse response = authorizedGet( + "/api/v1/tasks?status=" + status + "&size=100", + token + ); + assertOk(response); + assertThat(JsonPath.read(response.body(), "$.total_elements").intValue()) + .isEqualTo(expected); + } + + private void assertDocumentStatusHasData(String token, String status) throws Exception { + HttpResponse response = authorizedGet( + "/api/v1/documents?status=" + status + "&size=100", + token + ); + assertOk(response); + assertThat(JsonPath.read(response.body(), "$.total_elements").intValue()).isPositive(); + } + + private void assertTenantApiCount(String token, String path, int expected) throws Exception { + HttpResponse response = authorizedGet(path, token); + assertOk(response); + assertThat(JsonPath.read(response.body(), "$.total_elements").intValue()) + .isEqualTo(expected); + } + + private String loginAndGetAccessToken(String email) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + "{\"email\":\"%s\",\"password\":\"%s\"}".formatted(email, PASSWORD) + )) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + assertOk(response); + return JsonPath.read(response.body(), "$.access_token"); + } + + private HttpResponse authorizedGet(String path, String token) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private void assertOk(HttpResponse response) { + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } + + private Map counts(UUID companyId, Set tables) { + Map result = new LinkedHashMap<>(); + tables.forEach(table -> result.put(table, count(table, companyId))); + return result; + } + + private int count(String table, UUID companyId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table + " WHERE company_id = ?", Integer.class, - TEST_COMPANY_ID - )).isEqualTo(8); - UUID timelineTaskId = UUID.fromString("94000000-0000-0000-0000-000000000002"); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM audit_event WHERE company_id = ? AND target_id = ?", + companyId + ); + } + + private int countWhere(String table, String predicate, UUID companyId, Object... parameters) { + Object[] arguments = new Object[parameters.length + 1]; + arguments[0] = companyId; + System.arraycopy(parameters, 0, arguments, 1, parameters.length); + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table + " WHERE company_id = ? AND " + predicate, Integer.class, - COMPANY_ID, - timelineTaskId - )).isEqualTo(3); - assertThat(jdbcTemplate.queryForList( - "SELECT action FROM audit_event WHERE company_id = ? AND target_id = ?", - String.class, - COMPANY_ID, - timelineTaskId - )).containsExactlyInAnyOrder("TASK_CREATED", "TASK_UPDATED", "APPROVAL_REQUESTED"); + arguments + ); + } + + private Map distribution(String table, String column, UUID companyId) { + List> rows = jdbcTemplate.queryForList( + "SELECT " + column + " AS item_key, COUNT(*) AS item_count FROM " + table + + " WHERE company_id = ? GROUP BY " + column, + companyId + ); + Map result = new LinkedHashMap<>(); + rows.forEach(row -> result.put( + row.get("item_key").toString(), + ((Number) row.get("item_count")).intValue() + )); + return result; + } + + @TestConfiguration(proxyBeanMethods = false) + static class FixedClockConfiguration { + + @Bean + @Primary + Clock fixedClock() { + return Clock.fixed(TEST_NOW, ZoneOffset.UTC); + } } } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index bb5f2cec..3c17f3ac 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -123,6 +123,58 @@ void existingAdminDoesNotHideAnInactiveDemoCompany() throws Exception { .hasMessageContaining("not active"); } + @Test + void refusesToStartWhenAReservedUserIdBelongsToAnotherEmail() { + InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); + InMemoryUserAccountRepository userAccountRepository = new InMemoryUserAccountRepository(); + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); + userAccountRepository.insert(UserAccount.create( + ADMIN_USER_ID, + COMPANY_ID, + "ID collision", + "different@example.com", + passwordEncoder.encode(ADMIN_PASSWORD), + UserRole.ADMIN, + NOW + )); + DemoAuthSeedRunner runner = runner( + properties(ADMIN_PASSWORD), + companyRepository, + userAccountRepository, + passwordEncoder + ); + + assertThatThrownBy(() -> runner.run(new DefaultApplicationArguments(new String[0]))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("reserved demo user id"); + } + + @Test + void refusesToStartWhenAReservedEmailBelongsToAnotherAccount() { + InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); + InMemoryUserAccountRepository userAccountRepository = new InMemoryUserAccountRepository(); + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); + userAccountRepository.insert(UserAccount.create( + UUID.fromString("aaaaaaaa-0000-0000-0000-000000000001"), + TEST_COMPANY_ID, + "Email collision", + ADMIN_EMAIL, + passwordEncoder.encode(ADMIN_PASSWORD), + UserRole.HR, + NOW + )); + DemoAuthSeedRunner runner = runner( + properties(ADMIN_PASSWORD), + companyRepository, + userAccountRepository, + passwordEncoder + ); + + assertThatThrownBy(() -> runner.run(new DefaultApplicationArguments(new String[0]))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("configured demo email"); + } + private DemoAuthSeedRunner runner( DemoAuthSeedProperties properties, CompanyRepository companyRepository, From 003392f935bba91e59558777d2912eb37bc11b96 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 3 Aug 2026 17:58:08 +0900 Subject: [PATCH 08/15] =?UTF-8?q?docs(seed):=20=EB=8C=80=ED=91=9C=20?= =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Demo/Test Company의 최종 시드 수량과 대표 계정을 문서화한다. - 대표 근로자별 업무·문서·승인·Audit 시나리오를 정리한다. - 현재 프런트엔드 지표와 필터가 사용하는 서버 데이터를 설명한다. - Figma 개념의 정확한 표현, 근사 표현과 제외 범위를 구분한다. - Demo Seed의 멱등성, 초기화, 재실행과 확인 방법을 안내한다. - 개발 가이드, README와 환경변수 예시를 최신 수량으로 갱신한다. --- .env.example | 7 +- README.md | 6 +- docs/demo-seed.md | 237 ++++++++++++++++++++++++++++++++++++++ docs/development-guide.md | 33 +++--- 4 files changed, 259 insertions(+), 24 deletions(-) create mode 100644 docs/demo-seed.md diff --git a/.env.example b/.env.example index b4259b47..67536a3a 100644 --- a/.env.example +++ b/.env.example @@ -47,9 +47,10 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # local=false, dev/prod=true가 기본입니다. HTTPS 정책을 바꿀 때만 명시하세요. # REFRESH_TOKEN_COOKIE_SECURE=true -# 선택 사항: local(H2) 또는 로컬 PostgreSQL(dev)에 회사 2개, 계정 23개, 근로자 10명을 생성합니다. -# Demo Company에는 기존 계정 20개와 근로자 5명, Task 5개, 근로자 서류 7개, -# Task 최근 활동 3개가 생성됩니다. Test Company에는 역할별 계정 3개와 근로자 5명이 생성됩니다. +# 선택 사항: local(H2) 또는 개인 PostgreSQL(dev)에 운영형 데모 데이터를 생성합니다. +# Demo Company: 계정 20, 근로자 28, Task 24, 서류 84, Audit Event 96. +# Test Company: 계정 3, 근로자 5, Task 3, 서류 8, Audit Event 8. +# 상세 시나리오와 초기화 방법은 docs/demo-seed.md를 참고합니다. # 기본값은 false이며, true로 켤 때 비밀번호를 반드시 실행 환경의 Secret으로 넣습니다. # prod에서는 사용하지 않고 #9 provisioning 단계에서 계정을 준비합니다. # 실제 비밀번호를 이 파일이나 Git에 커밋하지 않습니다. diff --git a/README.md b/README.md index 6b73af73..ae8ed422 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 해야 할 일을 업무카 | 현재 구현된 API | [Swagger](https://fowoco.github.io/server/api/) · [OpenAPI JSON](https://fowoco.github.io/server/api/openapi.json) | `main` 코드에서 자동 생성되는 실제 API 계약 | | DB 테이블·ERD | [Database 문서](https://fowoco.github.io/server/) | Flyway를 빈 PostgreSQL에 적용해 자동 생성한 구조 | | 로컬 실행·인증·Workflow | [개발 가이드](docs/development-guide.md) | 처음 서버를 실행하고 기능 흐름을 이해하는 방법 | +| Demo Seed 수량·시나리오 | [Demo Seed 운영 시나리오](docs/demo-seed.md) | 로컬 데모 데이터의 기준 수량, 대표 흐름과 표현 한계 | | 패키지·모듈 경계 | [프로젝트 구조](docs/project-structure.md) | 코드를 어느 패키지에 구현해야 하는지 설명 | | 중요한 설계 결정 | [ADR 목록](docs/adr/README.md) | 저장소 경계, API·보안, Task·AiRun, RLS 결정 원본 | | Server ↔ AI 계약 | [AI Runtime 계약](docs/ai-runtime-contract.md) | Server가 AI에 보내고 받을 수 있는 값과 검증 기준 | @@ -108,8 +109,9 @@ curl http://localhost:8080/health | OpenAPI JSON | | | H2 Console | | -PostgreSQL 실행, 회원가입·로그인, Demo Seed, Task·승인 흐름은 -[개발 가이드](docs/development-guide.md)에서 이어서 확인합니다. +PostgreSQL 실행과 회원가입·로그인은 [개발 가이드](docs/development-guide.md), +Demo Seed의 수량과 대표 업무 구성은 +[Demo Seed 운영 시나리오](docs/demo-seed.md)에서 확인합니다. ## 대표 흐름 diff --git a/docs/demo-seed.md b/docs/demo-seed.md new file mode 100644 index 00000000..f67bc3af --- /dev/null +++ b/docs/demo-seed.md @@ -0,0 +1,237 @@ +# Demo Seed 운영 시나리오 + +Demo Seed는 현재 구현된 API를 로컬에서 현실적인 운영 데이터로 확인하기 위한 +개발 전용 데이터셋이다. Figma의 업무·근로자 구성을 참고하되, 서버에 존재하는 +도메인과 상태만 사용한다. + +> Demo Seed는 로컬 H2 또는 개인 PostgreSQL 개발 DB 전용이다. 공유 `dev`와 +> `prod`에서 활성화하거나 실제 개인정보·Secret을 섞어 사용하면 안 된다. + +## 실행 조건 + +Demo Seed의 기본값은 `false`다. 활성화할 때는 12자 이상의 로컬 전용 비밀번호를 +실행 환경의 Secret으로 제공해야 한다. 모든 데모 계정은 이 비밀번호를 공유하며, +저장할 때는 BCrypt 해시만 남긴다. + +```bash +export DEMO_SEED_ENABLED=true +export DEMO_SEED_ADMIN_PASSWORD='로컬 전용 12자 이상 값' +./gradlew bootRun +``` + +팀 공통 PostgreSQL 실행 스크립트는 `.env.local`을 읽고 `dev` profile과 Demo +Seed를 함께 활성화한다. + +```bash +# macOS / Linux +cp .env.example .env.local +./scripts/run-dev.sh +``` + +```powershell +# Windows PowerShell +Copy-Item .env.example .env.local +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 +``` + +`.env.local`에는 로컬 PostgreSQL 자격 증명, JWT Secret과 Demo 비밀번호를 +직접 설정한다. 이 파일과 실제 값은 Git, Issue, 로그 또는 메신저에 올리지 않는다. + +## 대표 로그인 계정 + +| 회사 | 역할 | 이메일 | 용도 | +| --- | --- | --- | --- | +| Demo Company | `ADMIN` | `demo.admin@example.com` | 기본 관리자, 환경변수로 변경 가능 | +| Demo Company | `ADMIN` | `demo.ops@example.com` | 운영 관리자 | +| Demo Company | `HR` | `demo.hr01@example.com` | 일반 업무 조회·처리 | +| Demo Company | `VIEWER` | `demo.viewer01@example.com` | 읽기 전용 권한 확인 | +| Test Company | `ADMIN` | `test.admin@example.com` | 테넌트 격리 확인 | +| Test Company | `HR` | `test.hr@example.com` | 작은 테스트 회사 업무 확인 | +| Test Company | `VIEWER` | `test.viewer@example.com` | 작은 테스트 회사 읽기 권한 확인 | + +`DEMO_SEED_ADMIN_EMAIL`을 변경하면 첫 번째 Demo Company 관리자 이메일만 +변경된다. Test Company는 Demo Company와 분리된 작은 격리 검증용 데이터셋이다. + +## 최종 데이터 수량 + +### FOWOCO Demo Company + +| 데이터 | 수량 | 주요 분포 | +| --- | ---: | --- | +| 계정 | 20 | `ADMIN` 2, `HR` 12, `VIEWER` 6 | +| 근로자 | 28 | `ACTIVE`와 `ON_LEAVE`, 다양한 체류 만료 구간 | +| 업무 | 24 | 세 가지 지원 업무 유형과 여덟 가지 상태 | +| 근로자 서류 | 84 | `VERIFIED` 48, `SUBMITTED` 20, `MISSING` 16 | +| 체크리스트 항목 | 68 | 24개 업무에 연결 | +| 승인 요청 | 13 | `PENDING` 4, `APPROVED` 7, `REJECTED` 1, `INVALIDATED` 1 | +| 상태 전이 이력 | 52 | 초안부터 완료·취소까지의 업무별 이력 | +| 외부 제출 | 6 | 고용센터 또는 출입국·외국인청 제출 시나리오 | +| 완료 증빙 | 10 | 문서, 접수증, 공식 결과, HR 확인 | +| 문서 요청 초안 | 5 | 네팔어, 베트남어, 인도네시아어, 미얀마어 | +| Audit Event | 96 | `HR_USER` 83, `AI_AGENT` 3, `SYSTEM_RULE` 6, `WORKER_LINK` 4 | +| StoredFile | 0 | 실제 파일이나 가짜 저장 경로를 만들지 않음 | + +Demo Company 업무 유형은 `STAY_PERIOD_EXTENSION` 10개, `RECONTRACT` 8개, +`EMPLOYMENT_PERIOD_EXTENSION` 6개다. + +업무 상태는 `DRAFT` 3개, `NEEDS_INFO` 2개, `READY_FOR_REVIEW` 4개, +`APPROVED` 2개, `WAITING_WORKER` 4개, `WAITING_EXTERNAL` 3개, +`COMPLETED` 5개, `CANCELLED` 1개다. + +서류 유형은 `PASSPORT_COPY` 26개, `ARC` 28개, `CONTRACT` 22개, +`PERMIT` 8개다. 만료일은 이미 만료, 30일 이내, 31~90일, 90일 초과, +미상 구간을 모두 포함한다. + +### FOWOCO Test Company + +| 데이터 | 수량 | +| --- | ---: | +| 계정 | 3 | +| 근로자 | 5 | +| 업무 | 3 | +| 근로자 서류 | 8 | +| Audit Event | 8 | + +Test Company에는 Demo Company의 전체 운영 데이터를 복제하지 않는다. Demo +계정으로 Test Company 업무를 조회하거나 그 반대로 조회하면 `404` 또는 빈 +목록이 반환되어야 한다. + +## 대표 근로자 시나리오 + +모든 날짜는 주입된 `Clock`을 기준으로 상대 계산된다. `D+12`와 같은 값은 서버 +실행일이 바뀌면 함께 이동한다. + +| 근로자 | 대표 상황 | 연결 데이터 | +| --- | --- | --- | +| 응웬반A | 체류 만료 `D+12`, 재계약 검토 | 같은 `caseId`의 `READY_FOR_REVIEW`, `DRAFT`, `WAITING_WORKER` 업무, 승인 요청, 체크리스트, 문서 요청 초안, Audit Event | +| 소팔 타망 | 고용기간 연장 `D+4`, 정보 보완 필요 | `NEEDS_INFO`, 미완료 체크리스트, 누락·제출 서류 | +| 라니 위자야 | 체류 서류 응답 대기 | `WAITING_WORKER`, 다국어 문서 요청 초안, 요청 관련 Audit Event | +| 파티마 누르 | 외국인등록증 사본 대기 | `WAITING_WORKER`, ARC 요청 초안과 서류 상태 | +| 민 아웅 | 재계약·서류 준비 `D+20` | AI 후보 `DRAFT`, 계약서·허가서 요청 초안 | +| 아디 수르야 | 고용기간 연장 자료 보완 | 지원되는 업무 유형의 `NEEDS_INFO`; 신규 등록 업무는 만들지 않음 | +| 모하메드 라힘 | 오늘 마감된 체류기간 연장 완료 | `COMPLETED`, 승인 상태, 외부 제출, 완료 증빙, 상태 전이 이력 | + +응웬반A의 세 업무는 별도 Case 엔티티 없이 같은 `caseId`로 연속성만 표현한다. +서버는 Case 진행률이나 Case 전용 API를 제공하지 않는다. + +## 클라이언트에서 확인되는 데이터 + +현재 클라이언트는 서버 원본 값을 조합해 화면용 문구와 분류를 만든다. Demo +Seed에는 한국어 배지 문자열이나 별도 UI 전용 필드를 저장하지 않는다. + +### 업무 목록과 Metric Strip + +| 화면 의미 | 서버 데이터 | 기대 수량·예시 | +| --- | --- | --- | +| 승인 대기 | `READY_FOR_REVIEW` | 4개 | +| AI 준비 완료 | `DRAFT` | 3개 | +| 긴급 업무 | `dueDate <= today + 7일` | 오늘, `D+3`, `D+4`, `D+6`, `D+7` 등 복수 | +| 오늘 완료 | `COMPLETED`이고 `updatedAt`이 오늘 | 5개 | + +근로자 분류에는 다음 세 범주가 모두 나타난다. + +- `needs-review`: `READY_FOR_REVIEW`, `WAITING_WORKER`, `WAITING_EXTERNAL` 중 하나 이상 +- `ai-suggested`: 위 상태가 없고 `DRAFT` 또는 `NEEDS_INFO`가 존재 +- `done`: 위 두 조건에 해당하지 않음 + +### 문서 목록 + +- 검토 필요: `SUBMITTED` +- 만료 예정: 오늘부터 30일 이내의 `expiryDate` +- 누락 문서: `MISSING` +- 요청 중: 현재 클라이언트가 `MISSING`으로 근사 +- 최근 업로드: 현재 클라이언트가 `SUBMITTED`로 근사 + +`요청 중`과 `최근 업로드`는 현재 서버가 별도 수명주기를 제공하지 않기 때문에 +정확한 상태가 아니다. + +### 업무 상세와 Audit + +- 업무 상세 API에서 연결된 체크리스트를 확인할 수 있다. +- `94000000-0000-0000-0000-000000000002` 업무는 기존 호환성을 위해 + `TASK_CREATED`, `TASK_UPDATED`, `APPROVAL_REQUESTED` 활동 3개를 유지한다. +- 확장 Audit에는 업무 생성, 체크리스트 변경, 승인 결과, 외부 제출, 완료 증빙, + 문서 요청 초안, 완료·취소 이벤트가 포함된다. +- `WORKER_LINK` 이벤트는 근로자가 제공한 문서 정보가 업무에 반영된 맥락만 + 표현하며 실제 링크 토큰이나 전송·열람 상태를 만들지 않는다. + +## Figma 표현 범위 + +### 현재 모델로 직접 표현한 항목 + +- 근로자별 지원 업무, 상태, 마감일과 체류 만료일 +- 업무 체크리스트와 승인 요청·결과 +- 외부 제출, 완료 증빙과 상태 전이 이력 +- 문서 누락·제출·검증 및 만료 구간 +- 다국어 문서 요청 초안 +- HR 사용자, AI 에이전트, 시스템 규칙, 근로자 링크 맥락의 Audit Event + +### 현재 모델로 근사한 항목 + +- 문서 요청·응답 대기: `WAITING_WORKER`, `MISSING`, 문서 요청 초안과 Audit로 표현 +- 요청 중: 클라이언트가 `MISSING`으로 근사 +- 최근 업로드: 클라이언트가 `SUBMITTED`로 근사 +- Case 연속성: 별도 엔티티 없이 여러 업무의 동일한 `caseId`로 표현 + +### 의도적으로 제외한 항목 + +- 담당자 미지정 업무 +- `WORKER_ONBOARDING`, `EMPLOYMENT_CHANGE` 업무 유형 +- 실제 Worker Secure Link와 등록·전송·응답·읽음·안읽음 수명주기 +- 메시지, 근로자 질문, Ticket +- Case 엔티티, Case 진행률과 Case API +- Dashboard 집계 API; 현재 Dashboard는 클라이언트 정적 데이터 유지 +- 급여, 근태, 일정, 수입, OCR, AI 분석 모델 +- 실제 파일 바이너리, StoredFile 또는 가짜 파일 경로 +- 실제 여권번호, 외국인등록번호, 전화번호, 주소, 임금, 토큰, 자격 증명 + +## 멱등성, 충돌과 날짜 갱신 + +모든 예약 레코드는 고정 UUID를 사용한다. 같은 설정으로 다시 실행하면 중복을 +만들지 않고 기존 레코드의 회사·외래키 소유권과 핵심 값을 검증한다. 예약 ID나 +이메일이 다른 데이터에 사용된 경우 기존 데이터를 덮어쓰지 않고 시작을 +중단한다. + +근로자 체류 만료일, 계약 종료일, 업무 마감일처럼 실행일 기준으로 설계된 값은 +재실행 시 현재 `Clock`에 맞게 이동한다. 고정된 식별자와 시나리오 의미는 +유지된다. + +## 초기화와 재실행 + +단순 재실행은 DB를 초기화할 필요가 없다. 서버를 종료한 뒤 같은 설정으로 실행하면 +멱등성 검증 후 기존 시드를 재사용한다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 +``` + +완전히 새 DB로 확인하려면 먼저 서버를 종료하고, **개인 로컬 DB가 +`fowoco_test`인지 다시 확인한 뒤에만** 삭제·재생성한다. 다음 명령은 해당 DB의 +모든 데이터를 복구 불가능하게 삭제한다. + +```bash +dropdb -h localhost -p 5432 -U postgres fowoco_test +createdb -h localhost -p 5432 -U postgres fowoco_test +``` + +Docker를 사용한다면 개인 로컬 PostgreSQL 컨테이너의 `fowoco_test` DB 또는 전용 +볼륨만 재생성한다. 공유 컨테이너나 다른 프로젝트 볼륨은 삭제하지 않는다. 그 후 +실행 스크립트를 다시 호출하면 Flyway 적용 후 Demo Seed가 생성된다. + +H2 `local`은 인메모리 DB이므로 애플리케이션을 종료하고 다시 실행하면 초기화된다. + +## 확인 쿼리 + +```sql +SELECT company_id, COUNT(*) FROM worker GROUP BY company_id; +SELECT company_id, COUNT(*) FROM task GROUP BY company_id; +SELECT company_id, COUNT(*) FROM worker_document GROUP BY company_id; +SELECT company_id, COUNT(*) FROM audit_event GROUP BY company_id; +``` + +실행 로그의 운영 데이터 요약은 다음 값을 포함한다. + +```text +demo_task_count=24 demo_document_count=84 demo_audit_count=96 +test_task_count=3 test_document_count=8 test_audit_count=8 +``` diff --git a/docs/development-guide.md b/docs/development-guide.md index 1b438ad2..a9b66941 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -153,23 +153,17 @@ runtime 계정은 업무 DML, migration 계정은 Flyway 적용만 담당합니 ## 선택 사항: Demo Seed -local H2 또는 로컬 PostgreSQL `dev`에서만 사용할 데모 데이터가 필요할 때 -명시적으로 켭니다. 모든 계정은 로컬에서 설정한 같은 비밀번호로 로그인할 수 -있으며, 기본 비밀번호는 없습니다. +local H2 또는 개인 PostgreSQL `dev`에서만 사용할 데모 데이터가 필요할 때 +명시적으로 켭니다. 기본 비밀번호는 없으며, 모든 계정은 로컬에서 설정한 같은 +비밀번호를 사용합니다. -| 회사 | 계정 | 근로자 | -| --- | --- | --- | -| `FOWOCO Demo Company` | 기존 20명 (`ADMIN` 2, `HR` 12, `VIEWER` 6) | 5명 | -| `FOWOCO Test Company` | 3명 (`ADMIN` 1, `HR` 1, `VIEWER` 1) | 5명 | - -Demo Company에는 화면 검증용 업무 데이터도 함께 생성됩니다. +| 회사 | 계정 | 근로자 | 업무 | 서류 | Audit Event | +| --- | ---: | ---: | ---: | ---: | ---: | +| `FOWOCO Demo Company` | 20 | 28 | 24 | 84 | 96 | +| `FOWOCO Test Company` | 3 | 5 | 3 | 8 | 8 | -- Task 5개: `DRAFT`(AI 후보), `READY_FOR_REVIEW`, `WAITING_WORKER`, - `WAITING_EXTERNAL`, `COMPLETED` 각 1개 -- 마감일: 7일 이내, 30일 이내, 30일 초과 구간을 모두 포함 -- 근로자 서류 7개: 근로자별 1~2개, `MISSING`, `SUBMITTED`, `VERIFIED` 포함 -- 30일 이내 만료 서류 1개 이상 -- 승인 대기 Task의 최근 AuditEvent 3개 +대표 계정, 전체 수량·상태 분포, 근로자 시나리오, 프런트엔드 필터 연결과 +Figma 근사·제외 범위는 [Demo Seed 운영 시나리오](demo-seed.md)를 기준으로 한다. ```bash export DEMO_SEED_ENABLED=true @@ -178,9 +172,8 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 Secret의 12자 이상 값' ``` 같은 설정으로 재실행해도 중복 생성하지 않습니다. 고정 ID나 이메일이 다른 -사업장·역할·근로자 정보와 충돌하면 덮어쓰지 않고 시작을 중단합니다. 이메일과 -근로자 이름은 모두 데모 전용 값이며 실제 개인정보를 포함하지 않습니다. -최초 계정을 확인한 후에는 +데이터와 충돌하면 덮어쓰지 않고 시작을 중단합니다. 실제 개인정보, Secret, +파일 또는 가짜 저장 경로를 만들지 않습니다. 확인 후에는 `DEMO_SEED_ENABLED=false`로 돌려놓습니다. 공유 `dev`와 `prod`에서는 Demo Seed 대신 배포 Provisioning 단계에서 초기 계정을 @@ -207,7 +200,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 두 스크립트 모두 PostgreSQL `dev` profile과 Demo Seed를 활성화합니다. `DB_URL`, migration/runtime username이 비어 있으면 로컬 기본값 `jdbc:postgresql://localhost:5432/fowoco_test`와 `postgres`를 사용합니다. -대상 데이터베이스는 최초 한 번 직접 생성합니다. +대상 데이터베이스는 최초 한 번 직접 생성합니다. 초기화와 안전한 재실행 절차는 +[Demo Seed 운영 시나리오](demo-seed.md#초기화와-재실행)를 참고합니다. ```bash createdb -h localhost -p 5432 -U postgres fowoco_test @@ -263,6 +257,7 @@ Client가 안전한 형식의 `X-Request-Id`를 보내면 Server가 응답과 ## 관련 문서 +- [Demo Seed 운영 시나리오](demo-seed.md) - [API 문서 사용법](api-documentation.md) - [Database 문서 사용법](database-documentation.md) - [프로젝트 구조](project-structure.md) From 7afdc3fa91ddd5bbfbbed64d5ad8b323b49ba683 Mon Sep 17 00:00:00 2001 From: krestar Date: Mon, 3 Aug 2026 23:49:09 +0900 Subject: [PATCH 09/15] =?UTF-8?q?fix(seed):=20=EB=8D=B0=EB=AA=A8=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=20=EC=9E=AC=EC=8B=A4=ED=96=89=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=B6=95=20=EC=A0=95=ED=95=A9=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Demo Worker와 Document를 최초 생성 시점 기준 immutable snapshot으로 유지 - 승인 없는 대기 업무에서 잘못된 APPROVED 전이를 제거 - 체크리스트·승인·제출·증빙·완료 시간 순서를 일관되게 보정 - 24개 Demo Task의 전이 연결성과 시간 불변조건 검증 추가 - same-Clock 및 next-day 재실행 시 수량과 snapshot 불변 테스트 강화 - Demo Seed 재실행 계약과 현재 fixture 분포를 문서에 동기화 --- docs/demo-seed.md | 30 +- .../seed/DemoAuditSeedCatalog.java | 2 +- .../seed/DemoOperationalSeedCatalog.java | 134 ++++++++ .../infrastructure/seed/DemoTaskSeeder.java | 6 +- .../seed/DemoTaskWorkflowSeedCatalog.java | 209 +++++++++---- .../seed/DemoWorkerDocumentSeeder.java | 37 +-- .../seed/DemoWorkerSeedRunner.java | 19 +- .../seed/DemoAuthSeedIntegrationTest.java | 295 +++++++++++++++++- .../seed/DemoWorkerSeedRunnerTest.java | 47 ++- 9 files changed, 652 insertions(+), 127 deletions(-) diff --git a/docs/demo-seed.md b/docs/demo-seed.md index f67bc3af..6359977b 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -82,6 +82,15 @@ Demo Company 업무 유형은 `STAY_PERIOD_EXTENSION` 10개, `RECONTRACT` 8개, `PERMIT` 8개다. 만료일은 이미 만료, 30일 이내, 31~90일, 90일 초과, 미상 구간을 모두 포함한다. +현재 근로 상태는 `ACTIVE` 25명, `ON_LEAVE` 3명이다. 문서 만료 구간은 +이미 만료 9건, 30일 이내 28건, 31~90일 19건, 90일 초과 20건, +미상 8건이다. Issue #70의 초기 예시 분포와 정확히 일치시키는 fixture 재배치는 +후속 품질 개선 범위로 남긴다. + +`INVALIDATED` 1건은 검토 중인 승인 요청을 무효화한 예시다. 승인 완료 후 중요 +내용이 변경되어 과거 승인 snapshot이 무효화되는 전체 revision/fingerprint +수명주기는 이번 Seed에서 표현하지 않는다. + ### FOWOCO Test Company | 데이터 | 수량 | @@ -98,8 +107,9 @@ Test Company에는 Demo Company의 전체 운영 데이터를 복제하지 않 ## 대표 근로자 시나리오 -모든 날짜는 주입된 `Clock`을 기준으로 상대 계산된다. `D+12`와 같은 값은 서버 -실행일이 바뀌면 함께 이동한다. +모든 날짜는 최초 생성 시 주입된 `Clock`을 기준으로 상대 계산된다. `D+12`와 같은 +표현은 그 최초 생성일을 기준으로 하며, 예약 데이터가 존재하는 상태에서 서버를 +재기동해도 날짜와 과거 이력은 이동하지 않는다. | 근로자 | 대표 상황 | 연결 데이터 | | --- | --- | --- | @@ -185,21 +195,27 @@ Seed에는 한국어 배지 문자열이나 별도 UI 전용 필드를 저장하 - 실제 파일 바이너리, StoredFile 또는 가짜 파일 경로 - 실제 여권번호, 외국인등록번호, 전화번호, 주소, 임금, 토큰, 자격 증명 -## 멱등성, 충돌과 날짜 갱신 +## 멱등성, 충돌과 날짜 snapshot 모든 예약 레코드는 고정 UUID를 사용한다. 같은 설정으로 다시 실행하면 중복을 만들지 않고 기존 레코드의 회사·외래키 소유권과 핵심 값을 검증한다. 예약 ID나 이메일이 다른 데이터에 사용된 경우 기존 데이터를 덮어쓰지 않고 시작을 중단한다. -근로자 체류 만료일, 계약 종료일, 업무 마감일처럼 실행일 기준으로 설계된 값은 -재실행 시 현재 `Clock`에 맞게 이동한다. 고정된 식별자와 시나리오 의미는 -유지된다. +Demo Seed는 최초 생성 시점의 `Clock`을 기준으로 만든 immutable snapshot이다. +예약 데이터가 존재하는 상태에서 같은 날 또는 다음 날 서버를 다시 실행해도 +근로자·문서·업무 날짜, timestamp, fingerprint, revision, `updatedAt`, JPA version을 +다시 쓰지 않는다. ## 초기화와 재실행 단순 재실행은 DB를 초기화할 필요가 없다. 서버를 종료한 뒤 같은 설정으로 실행하면 -멱등성 검증 후 기존 시드를 재사용한다. +멱등성 검증 후 최초 snapshot을 그대로 재사용한다. 현재 날짜 기준의 새로운 Demo +데이터가 필요할 때만 개인 로컬 DB를 초기화한 뒤 Seed를 다시 생성한다. + +Seed Catalog 자체가 변경되어 예약 전이의 의미나 핵심 값이 달라진 버전으로 +업데이트한 경우에는 기존 snapshot을 자동 변환하지 않는다. 이 경우에도 기존 값을 +덮어쓰지 않고 시작을 중단하므로, 아래 절차로 개인 로컬 DB를 한 번 초기화한다. ```powershell powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 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 f8a41850..495285c0 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 @@ -216,7 +216,7 @@ private static String approvalSummary(ApprovalStatus status) { return switch (status) { case APPROVED -> "관리자가 현재 업무 내용을 승인함"; case REJECTED -> "필수 정보 보완을 위해 승인 요청을 반려함"; - case INVALIDATED -> "중요 정보 변경으로 기존 승인 요청을 무효화함"; + case INVALIDATED -> "검토 중인 승인 요청을 무효화함"; case PENDING -> throw new IllegalArgumentException("pending approval has no outcome summary"); }; } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index 183795fd..10b64b8e 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -242,6 +242,7 @@ private void verifyDefinitions() { requireTaskReferences(demoDocumentRequestDrafts.stream() .map(DocumentRequestDraftSeed::taskId).toList(), "document request draft"); verifyArtifactScenarios(); + verifyTemporalScenarios(); } private static void requireSize(List seeds, int expected, String name) { @@ -310,6 +311,139 @@ private void verifyArtifactScenarios() { } } + private void verifyTemporalScenarios() { + Map> transitionsByTask = demoTransitions.stream() + .collect(Collectors.groupingBy(TransitionSeed::taskId)); + Map> checklistsByTask = demoChecklists.stream() + .collect(Collectors.groupingBy(ChecklistSeed::taskId)); + Map> approvalsByTask = demoApprovals.stream() + .collect(Collectors.groupingBy(ApprovalSeed::taskId)); + Map> submissionsByTask = demoExternalSubmissions.stream() + .collect(Collectors.groupingBy(ExternalSubmissionSeed::taskId)); + Map> evidenceByTask = demoEvidence.stream() + .collect(Collectors.groupingBy(EvidenceSeed::taskId)); + Map> draftsByTask = demoDocumentRequestDrafts.stream() + .collect(Collectors.groupingBy(DocumentRequestDraftSeed::taskId)); + + for (TaskSeed task : demoTasks) { + int createdHoursAgo = task.createdDaysAgo() * 24; + List transitions = transitionsByTask.getOrDefault(task.taskId(), List.of()); + verifyTransitionChain(task, transitions, createdHoursAgo); + TransitionSeed ready = findTransition(transitions, TaskStatus.READY_FOR_REVIEW); + TransitionSeed approved = findTransition(transitions, TaskStatus.APPROVED); + TransitionSeed completed = findTransition(transitions, TaskStatus.COMPLETED); + + for (ChecklistSeed checklist : checklistsByTask.getOrDefault(task.taskId(), List.of())) { + requireNotBeforeTask(checklist.createdHoursAgo(), createdHoursAgo, "checklist creation"); + if (checklist.completed()) { + requireNotBeforeTask(checklist.completedHoursAgo(), createdHoursAgo, + "checklist completion"); + if (ready != null && checklist.required() + && checklist.completedHoursAgo() < ready.hoursAgo()) { + throw new IllegalStateException( + "required demo checklist must complete before review transition" + ); + } + } + } + + for (ApprovalSeed approval : approvalsByTask.getOrDefault(task.taskId(), List.of())) { + requireNotBeforeTask(approval.requestedHoursAgo(), createdHoursAgo, "approval request"); + if (ready == null || ready.hoursAgo() < approval.requestedHoursAgo()) { + throw new IllegalStateException("demo approval must be requested after review transition"); + } + if (approval.outcomeHoursAgo() != null) { + if (approval.requestedHoursAgo() < approval.outcomeHoursAgo()) { + throw new IllegalStateException("demo approval outcome precedes its request"); + } + if (approval.status() == ApprovalStatus.APPROVED + && (approved == null || approval.outcomeHoursAgo() < approved.hoursAgo())) { + throw new IllegalStateException( + "demo approved transition precedes the approval decision" + ); + } + if (approval.status() == ApprovalStatus.REJECTED + || approval.status() == ApprovalStatus.INVALIDATED) { + TransitionSeed rework = findTransition(transitions, TaskStatus.DRAFT); + if (rework == null || approval.outcomeHoursAgo() < rework.hoursAgo()) { + throw new IllegalStateException( + "demo approval outcome must precede the rework transition" + ); + } + } + } + } + + for (ExternalSubmissionSeed submission + : submissionsByTask.getOrDefault(task.taskId(), List.of())) { + requireNotBeforeTask(submission.hoursAgo(), createdHoursAgo, "external submission"); + if (approved != null && approved.hoursAgo() < submission.hoursAgo()) { + throw new IllegalStateException( + "demo external submission precedes the approved transition" + ); + } + if (completed != null && submission.hoursAgo() < completed.hoursAgo()) { + throw new IllegalStateException( + "demo completed transition precedes the external submission" + ); + } + } + + for (EvidenceSeed item : evidenceByTask.getOrDefault(task.taskId(), List.of())) { + requireNotBeforeTask(item.hoursAgo(), createdHoursAgo, "completion evidence"); + if (completed != null && item.hoursAgo() < completed.hoursAgo()) { + throw new IllegalStateException( + "demo completed transition precedes its evidence" + ); + } + } + + draftsByTask.getOrDefault(task.taskId(), List.of()).forEach(draft -> + requireNotBeforeTask(draft.hoursAgo(), createdHoursAgo, "document request draft")); + } + } + + private static void verifyTransitionChain( + TaskSeed task, + List transitions, + int createdHoursAgo + ) { + TaskStatus expectedFrom = TaskStatus.DRAFT; + int previousHoursAgo = createdHoursAgo; + for (TransitionSeed transition : transitions) { + if (expectedFrom.isTerminal() + || transition.fromStatus() != expectedFrom + || transition.hoursAgo() > previousHoursAgo) { + throw new IllegalStateException("demo task transition chain is invalid"); + } + expectedFrom = transition.toStatus(); + previousHoursAgo = transition.hoursAgo(); + } + if (!transitions.isEmpty() && expectedFrom != task.status()) { + throw new IllegalStateException("final demo transition status differs from task status"); + } + } + + private static TransitionSeed findTransition( + List transitions, + TaskStatus toStatus + ) { + return transitions.stream() + .filter(transition -> transition.toStatus() == toStatus) + .findFirst() + .orElse(null); + } + + private static void requireNotBeforeTask( + int eventHoursAgo, + int createdHoursAgo, + String eventName + ) { + if (eventHoursAgo > createdHoursAgo) { + throw new IllegalStateException("demo " + eventName + " precedes task creation"); + } + } + private static AuditSeed audit( String auditEventId, AuditAction action, diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java index 4be41c8a..7e63fdd2 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java @@ -42,13 +42,11 @@ void seed(TaskSeed seed, DemoOperationalSeedContext context) { verifyExisting(existing.get(), seed, context); return; } - Instant createdAt = existing.map(Task::createdAt) - .orElseGet(() -> context.now().minus(seed.createdDaysAgo(), ChronoUnit.DAYS)); + Instant createdAt = context.now().minus(seed.createdDaysAgo(), ChronoUnit.DAYS); Instant desiredUpdatedAt = seed.status() == TaskStatus.COMPLETED ? context.now() : context.now().minus(Math.max(seed.createdDaysAgo() - 1, 0), ChronoUnit.DAYS); Instant updatedAt = desiredUpdatedAt.isBefore(createdAt) ? createdAt : desiredUpdatedAt; - long version = existing.map(Task::version).orElse(0L); taskRepository.save(new Task( seed.taskId(), context.companyId(), @@ -69,7 +67,7 @@ void seed(TaskSeed seed, DemoOperationalSeedContext context) { context.actorId(), createdAt, updatedAt, - version + 0L )); } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java index a861c516..1d13d75e 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java @@ -41,6 +41,7 @@ static List demoChecklists(List tasks) { ? STAY_CHECKLIST : CONTRACT_CHECKLIST; int incompleteCount = INCOMPLETE_ITEMS_BY_TASK.getOrDefault(taskIndex + 1, 0); + int completedHoursAgo = completedHoursAgo(taskIndex + 1, task); for (int itemIndex = 0; itemIndex < templates.size(); itemIndex++) { ChecklistTemplate template = templates.get(itemIndex); boolean completed = itemIndex < templates.size() - incompleteCount; @@ -53,7 +54,7 @@ static List demoChecklists(List tasks) { true, completed, createdHoursAgo, - completed ? Math.max(createdHoursAgo / 2, 1) : null + completed ? completedHoursAgo : null )); } } @@ -62,59 +63,27 @@ static List demoChecklists(List tasks) { static List demoApprovals(List tasks) { return List.of( - approval(1, tasks, 2, ApprovalStatus.PENDING, null, 72, null), - approval(2, tasks, 6, ApprovalStatus.PENDING, null, 48, null), - approval(3, tasks, 14, ApprovalStatus.PENDING, null, 36, null), - approval(4, tasks, 15, ApprovalStatus.PENDING, null, 24, null), - approval(5, tasks, 16, ApprovalStatus.APPROVED, "연장 조건과 제출 자료 확인", 120, 96), - approval(6, tasks, 17, ApprovalStatus.APPROVED, "체류 연장 신청 내용 승인", 144, 120), - approval(7, tasks, 5, ApprovalStatus.APPROVED, "서명 계약서와 갱신 조건 확인", 216, 192), - approval(8, tasks, 20, ApprovalStatus.APPROVED, "체류 연장 완료 건 승인", 360, 336), - approval(9, tasks, 21, ApprovalStatus.APPROVED, "재계약 완료 건 승인", 480, 456), - approval(10, tasks, 22, ApprovalStatus.APPROVED, "고용기간 연장 완료 건 승인", 600, 576), - approval(11, tasks, 23, ApprovalStatus.APPROVED, "신청 결과 확인 후 승인", 720, 696), - approval(12, tasks, 9, ApprovalStatus.REJECTED, "필수 고용 정보 보완 필요", 96, 72), - approval(13, tasks, 1, ApprovalStatus.INVALIDATED, "중요 신청 정보 변경으로 기존 검토 무효화", 120, 96) + approval(1, tasks, 2, ApprovalStatus.PENDING, null), + approval(2, tasks, 6, ApprovalStatus.PENDING, null), + approval(3, tasks, 14, ApprovalStatus.PENDING, null), + approval(4, tasks, 15, ApprovalStatus.PENDING, null), + approval(5, tasks, 16, ApprovalStatus.APPROVED, "연장 조건과 제출 자료 확인"), + approval(6, tasks, 17, ApprovalStatus.APPROVED, "체류 연장 신청 내용 승인"), + approval(7, tasks, 5, ApprovalStatus.APPROVED, "서명 계약서와 갱신 조건 확인"), + approval(8, tasks, 20, ApprovalStatus.APPROVED, "체류 연장 완료 건 승인"), + approval(9, tasks, 21, ApprovalStatus.APPROVED, "재계약 완료 건 승인"), + approval(10, tasks, 22, ApprovalStatus.APPROVED, "고용기간 연장 완료 건 승인"), + approval(11, tasks, 23, ApprovalStatus.APPROVED, "신청 결과 확인 후 승인"), + approval(12, tasks, 9, ApprovalStatus.REJECTED, "필수 고용 정보 보완 필요"), + approval(13, tasks, 1, ApprovalStatus.INVALIDATED, "검토 중인 승인 요청을 무효화") ); } static List demoTransitions(List tasks) { List seeds = new ArrayList<>(); - history(seeds, tasks.get(0), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, TaskStatus.DRAFT); - history(seeds, tasks.get(1), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW); - history(seeds, tasks.get(2), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.WAITING_WORKER); - history(seeds, tasks.get(3), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.WAITING_EXTERNAL); - history(seeds, tasks.get(4), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.COMPLETED); - history(seeds, tasks.get(5), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW); - history(seeds, tasks.get(7), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.WAITING_WORKER); - history(seeds, tasks.get(8), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.DRAFT, TaskStatus.NEEDS_INFO); - history(seeds, tasks.get(9), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.WAITING_WORKER); - history(seeds, tasks.get(10), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.WAITING_WORKER); - history(seeds, tasks.get(12), TaskStatus.DRAFT, TaskStatus.NEEDS_INFO); - history(seeds, tasks.get(13), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW); - history(seeds, tasks.get(14), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW); - history(seeds, tasks.get(15), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, TaskStatus.APPROVED); - history(seeds, tasks.get(16), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, TaskStatus.APPROVED); - history(seeds, tasks.get(17), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.WAITING_EXTERNAL); - history(seeds, tasks.get(18), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.WAITING_EXTERNAL); - history(seeds, tasks.get(19), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.WAITING_EXTERNAL, TaskStatus.COMPLETED); - history(seeds, tasks.get(20), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.COMPLETED); - history(seeds, tasks.get(21), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.COMPLETED); - history(seeds, tasks.get(22), TaskStatus.DRAFT, TaskStatus.READY_FOR_REVIEW, - TaskStatus.APPROVED, TaskStatus.COMPLETED); - history(seeds, tasks.get(23), TaskStatus.DRAFT, TaskStatus.CANCELLED); + for (int taskIndex = 0; taskIndex < tasks.size(); taskIndex++) { + history(seeds, tasks.get(taskIndex), transitionPath(taskIndex + 1)); + } return List.copyOf(seeds); } @@ -123,13 +92,27 @@ private static ApprovalSeed approval( List tasks, int taskNumber, ApprovalStatus status, - String reason, - int requestedHoursAgo, - Integer outcomeHoursAgo + String reason ) { + TaskSeed task = tasks.get(taskNumber - 1); + List points = transitionPoints(taskNumber, task); + int readyHoursAgo = hoursAgo(points, TaskStatus.READY_FOR_REVIEW); + int requestedHoursAgo = readyHoursAgo - 1; + Integer outcomeHoursAgo = switch (status) { + case PENDING -> null; + case APPROVED -> hoursAgo(points, TaskStatus.APPROVED) + 1; + case REJECTED, INVALIDATED -> hoursAgoAfter( + points, + TaskStatus.DRAFT, + TaskStatus.READY_FOR_REVIEW + ) + 1; + }; + if (outcomeHoursAgo != null && requestedHoursAgo < outcomeHoursAgo) { + throw new IllegalStateException("demo approval timeline is invalid"); + } return new ApprovalSeed( demoUuid("94300000-0000-0000-0000-000000000", approvalNumber), - tasks.get(taskNumber - 1).taskId(), + task.taskId(), status, reason, requestedHoursAgo, @@ -140,21 +123,13 @@ private static ApprovalSeed approval( private static void history( List seeds, TaskSeed task, - TaskStatus initialStatus, - TaskStatus... statuses + List statuses ) { - TaskStatus fromStatus = initialStatus; - int transitionCount = statuses.length; - int createdHoursAgo = Math.max(task.createdDaysAgo() * 24, transitionCount); - int finalHoursAgo = task.status() == TaskStatus.COMPLETED - ? 0 - : Math.max(task.createdDaysAgo() - 1, 0) * 24; - int transitionWindow = createdHoursAgo - finalHoursAgo; - for (int index = 0; index < transitionCount; index++) { - TaskStatus toStatus = statuses[index]; + TaskStatus fromStatus = TaskStatus.DRAFT; + List points = transitionPoints(task, statuses); + for (TransitionPoint point : points) { + TaskStatus toStatus = point.status(); int sequence = seeds.size() + 1; - int hoursAgo = createdHoursAgo - - ((index + 1) * transitionWindow / transitionCount); seeds.add(new TransitionSeed( demoUuid("94400000-0000-0000-0000-000000000", sequence), task.taskId(), @@ -162,12 +137,109 @@ private static void history( toStatus, transitionReason(toStatus), "demo-seed-task-transition-%03d".formatted(sequence), - hoursAgo + point.hoursAgo() )); fromStatus = toStatus; } } + private static int completedHoursAgo(int taskNumber, TaskSeed task) { + List points = transitionPoints(taskNumber, task); + return points.stream() + .filter(point -> point.status() == TaskStatus.READY_FOR_REVIEW) + .findFirst() + .map(point -> Math.min(task.createdDaysAgo() * 24, point.hoursAgo() + 1)) + .orElseGet(() -> Math.max(task.createdDaysAgo() * 12, 1)); + } + + private static List transitionPoints(int taskNumber, TaskSeed task) { + return transitionPoints(task, transitionPath(taskNumber)); + } + + private static List transitionPoints(TaskSeed task, List statuses) { + int transitionCount = statuses.size(); + if (transitionCount == 0) { + return List.of(); + } + int createdHoursAgo = Math.max(task.createdDaysAgo() * 24, transitionCount); + int finalHoursAgo = task.status() == TaskStatus.COMPLETED + ? 0 + : Math.max(task.createdDaysAgo() - 1, 0) * 24; + int transitionWindow = createdHoursAgo - finalHoursAgo; + List points = new ArrayList<>(transitionCount); + for (int index = 0; index < transitionCount; index++) { + points.add(new TransitionPoint( + statuses.get(index), + createdHoursAgo - ((index + 1) * transitionWindow / transitionCount) + )); + } + return List.copyOf(points); + } + + private static int hoursAgo(List points, TaskStatus status) { + return points.stream() + .filter(point -> point.status() == status) + .findFirst() + .map(TransitionPoint::hoursAgo) + .orElseThrow(() -> new IllegalStateException( + "demo approval requires a " + status + " transition" + )); + } + + private static int hoursAgoAfter( + List points, + TaskStatus status, + TaskStatus precedingStatus + ) { + boolean precedingFound = false; + for (TransitionPoint point : points) { + if (point.status() == precedingStatus) { + precedingFound = true; + } else if (precedingFound && point.status() == status) { + return point.hoursAgo(); + } + } + throw new IllegalStateException("demo approval outcome transition is missing"); + } + + private static List transitionPath(int taskNumber) { + return switch (taskNumber) { + case 1 -> List.of(TaskStatus.READY_FOR_REVIEW, TaskStatus.DRAFT); + case 2, 6, 14, 15 -> List.of(TaskStatus.READY_FOR_REVIEW); + case 3, 8, 10, 11 -> List.of( + TaskStatus.NEEDS_INFO, + TaskStatus.DRAFT, + TaskStatus.WAITING_WORKER + ); + case 4, 18, 19 -> List.of( + TaskStatus.NEEDS_INFO, + TaskStatus.WAITING_WORKER, + TaskStatus.WAITING_EXTERNAL + ); + case 5, 21, 22, 23 -> List.of( + TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, + TaskStatus.COMPLETED + ); + case 7, 12 -> List.of(); + case 9 -> List.of( + TaskStatus.READY_FOR_REVIEW, + TaskStatus.DRAFT, + TaskStatus.NEEDS_INFO + ); + case 13 -> List.of(TaskStatus.NEEDS_INFO); + case 16, 17 -> List.of(TaskStatus.READY_FOR_REVIEW, TaskStatus.APPROVED); + case 20 -> List.of( + TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, + TaskStatus.WAITING_EXTERNAL, + TaskStatus.COMPLETED + ); + case 24 -> List.of(TaskStatus.CANCELLED); + default -> throw new IllegalArgumentException("unknown demo task number: " + taskNumber); + }; + } + private static String transitionReason(TaskStatus status) { return switch (status) { case NEEDS_INFO -> "필수 정보 또는 체크리스트 보완 필요"; @@ -187,4 +259,7 @@ private static UUID demoUuid(String prefix, int number) { private record ChecklistTemplate(String itemCode, String label) { } + + private record TransitionPoint(TaskStatus status, int hoursAgo) { + } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java index 4384548c..e1864ccd 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java @@ -28,35 +28,20 @@ void seed(DocumentSeed seed, DemoOperationalSeedContext context) { seed.workerId(), context.companyId() ); - existing.ifPresent(document -> verifyExisting(document, seed, context)); - if (existing.isEmpty()) { - workerDocumentRepository.insert(WorkerDocument.create( - seed.documentId(), - seed.workerId(), - context.companyId(), - seed.documentType(), - seed.submissionStatus(), - expiryDate, - seed.destination(), - seed.note(), - context.now() - )); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context); return; } - WorkerDocument document = existing.orElseThrow(); - workerDocumentRepository.update(new WorkerDocument( - document.workerDocumentId(), - document.workerId(), - document.companyId(), - document.documentType(), - document.submissionStatus(), + workerDocumentRepository.insert(WorkerDocument.create( + seed.documentId(), + seed.workerId(), + context.companyId(), + seed.documentType(), + seed.submissionStatus(), expiryDate, - document.destination(), - document.note(), - null, - document.createdAt(), - context.now(), - document.version() + seed.destination(), + seed.note(), + context.now() )); } diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java index 0d3f6d92..0c32cffa 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java @@ -77,9 +77,10 @@ private void seedCompanyWorkers( private void seedWorker(UUID companyId, WorkerSeed workerSeed, LocalDate today, Instant now) { Optional existing = workerRepository.findByWorkerIdAndCompanyId(workerSeed.workerId(), companyId); - existing.ifPresent(worker -> verifyExistingWorker(worker, companyId, workerSeed)); - Instant createdAt = existing.map(Worker::createdAt).orElse(now); - Instant updatedAt = now.isBefore(createdAt) ? createdAt : now; + if (existing.isPresent()) { + verifyExistingWorker(existing.get(), companyId, workerSeed); + return; + } Worker worker = new Worker( workerSeed.workerId(), companyId, @@ -90,15 +91,11 @@ private void seedWorker(UUID companyId, WorkerSeed workerSeed, LocalDate today, relativeDate(today, workerSeed.stayExpiryDays()), today.minusYears(1), today.plusDays(workerSeed.contractEndDays()), - createdAt, - updatedAt, - existing.map(Worker::version).orElse(0L) + now, + now, + 0L ); - if (existing.isEmpty()) { - workerRepository.insert(worker); - return; - } - workerRepository.update(worker); + workerRepository.insert(worker); } private void verifyExistingWorker(Worker worker, UUID companyId, WorkerSeed workerSeed) { diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index 9f42fa7c..f4af26b9 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -8,8 +8,10 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.util.LinkedHashMap; @@ -17,6 +19,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; @@ -40,8 +43,7 @@ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "app.demo-seed.enabled=true", - "app.demo-seed.admin-password=Demo-password-1!", - "app.auth.jwt.access-token-ttl=1h" + "app.demo-seed.admin-password=Demo-password-1!" } ) @Import(DemoAuthSeedIntegrationTest.FixedClockConfiguration.class) @@ -62,7 +64,7 @@ class DemoAuthSeedIntegrationTest { private static final String PASSWORD = "Demo-password-1!"; private static final String DEMO_ADMIN_EMAIL = "demo.admin@example.com"; private static final String TEST_ADMIN_EMAIL = "test.admin@example.com"; - private static final Instant TEST_NOW = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS); + private static final Instant TEST_NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS); private static final Map EXPECTED_DEMO_COUNTS = Map.ofEntries( Map.entry("user_account", 20), @@ -97,6 +99,9 @@ class DemoAuthSeedIntegrationTest { @Autowired private Clock clock; + @Autowired + private MutableClock mutableClock; + @Autowired @Qualifier("demoAuthSeedRunner") private ApplicationRunner demoAuthSeedRunner; @@ -116,9 +121,11 @@ void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throw assertAccountsAndPasswords(); assertExactCountsAndDistributions(); assertRelativeDatesAndSafeData(); + assertTaskTimelineInvariants(); Map demoCountsBeforeRerun = counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet()); Map testCountsBeforeRerun = counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet()); + Map>> initialSnapshot = seedSnapshot(); DefaultApplicationArguments arguments = new DefaultApplicationArguments(new String[0]); demoAuthSeedRunner.run(arguments); @@ -131,8 +138,23 @@ void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throw assertThat(counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet())) .isEqualTo(testCountsBeforeRerun) .isEqualTo(EXPECTED_TEST_COUNTS); + assertThat(seedSnapshot()).isEqualTo(initialSnapshot); assertFrontendRelevantApiResultsAndTenantIsolation(); + + mutableClock.advance(Duration.ofDays(1)); + demoAuthSeedRunner.run(arguments); + demoWorkerSeedRunner.run(arguments); + demoOperationalSeedRunner.run(arguments); + + assertThat(counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet())) + .isEqualTo(demoCountsBeforeRerun) + .isEqualTo(EXPECTED_DEMO_COUNTS); + assertThat(counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet())) + .isEqualTo(testCountsBeforeRerun) + .isEqualTo(EXPECTED_TEST_COUNTS); + assertThat(seedSnapshot()).isEqualTo(initialSnapshot); + assertTaskTimelineInvariants(); } private void assertAccountsAndPasswords() { @@ -289,6 +311,233 @@ private void assertRelativeDatesAndSafeData() { ).stream().noneMatch(json::contains)); } + private void assertTaskTimelineInvariants() { + Map tasks = jdbcTemplate.query( + "SELECT task_id, status, created_at FROM task WHERE company_id = ?", + resultSet -> { + Map result = new LinkedHashMap<>(); + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + result.put(taskId, new TaskTimeline( + taskId, + resultSet.getString("status"), + resultSet.getTimestamp("created_at").toInstant() + )); + } + return result; + }, + COMPANY_ID + ); + Map> transitionsByTask = jdbcTemplate.query( + "SELECT task_id, from_status, to_status, created_at FROM task_transition_history " + + "WHERE company_id = ? ORDER BY task_id, created_at, transition_id", + resultSet -> { + Map> result = new LinkedHashMap<>(); + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + result.computeIfAbsent(taskId, ignored -> new java.util.ArrayList<>()).add( + new TransitionTimeline( + resultSet.getString("from_status"), + resultSet.getString("to_status"), + resultSet.getTimestamp("created_at").toInstant() + ) + ); + } + return result; + }, + COMPANY_ID + ); + + assertThat(tasks).hasSize(24); + tasks.values().forEach(task -> { + List transitions = transitionsByTask.getOrDefault(task.taskId(), List.of()); + String expectedFrom = "DRAFT"; + Instant previousAt = task.createdAt(); + for (int index = 0; index < transitions.size(); index++) { + TransitionTimeline transition = transitions.get(index); + assertThat(transition.fromStatus()).isEqualTo(expectedFrom); + assertThat(transition.createdAt()).isAfterOrEqualTo(previousAt); + if (isTerminal(transition.toStatus())) { + assertThat(index).isEqualTo(transitions.size() - 1); + } + expectedFrom = transition.toStatus(); + previousAt = transition.createdAt(); + } + if (!transitions.isEmpty()) { + assertThat(expectedFrom).isEqualTo(task.status()); + } + }); + + jdbcTemplate.query( + "SELECT task_id, required, completed, completed_at FROM task_checklist_item " + + "WHERE company_id = ?", + resultSet -> { + while (resultSet.next()) { + if (!resultSet.getBoolean("required") || !resultSet.getBoolean("completed")) { + continue; + } + UUID taskId = resultSet.getObject("task_id", UUID.class); + Instant readyAt = transitionAt( + transitionsByTask.getOrDefault(taskId, List.of()), + "READY_FOR_REVIEW" + ); + if (readyAt != null) { + assertThat(resultSet.getTimestamp("completed_at").toInstant()) + .isBeforeOrEqualTo(readyAt); + } + } + return null; + }, + COMPANY_ID + ); + + jdbcTemplate.query( + "SELECT task_id, status, requested_at, decided_at, invalidated_at " + + "FROM approval_request WHERE company_id = ?", + resultSet -> { + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + String status = resultSet.getString("status"); + Instant requestedAt = resultSet.getTimestamp("requested_at").toInstant(); + var decidedTimestamp = resultSet.getTimestamp("decided_at"); + var invalidatedTimestamp = resultSet.getTimestamp("invalidated_at"); + Instant outcomeAt = "INVALIDATED".equals(status) + ? invalidatedTimestamp.toInstant() + : decidedTimestamp == null ? null : decidedTimestamp.toInstant(); + List transitions = + transitionsByTask.getOrDefault(taskId, List.of()); + Instant readyAt = transitionAt(transitions, "READY_FOR_REVIEW"); + assertThat(readyAt).isNotNull(); + assertThat(requestedAt).isAfterOrEqualTo(readyAt); + if (outcomeAt != null) { + assertThat(outcomeAt).isAfterOrEqualTo(requestedAt); + } + if ("APPROVED".equals(status)) { + assertThat(transitionAt(transitions, "APPROVED")) + .isAfterOrEqualTo(outcomeAt); + } + if ("REJECTED".equals(status) || "INVALIDATED".equals(status)) { + assertThat(transitionAt(transitions, "DRAFT")) + .isAfterOrEqualTo(outcomeAt); + } + } + return null; + }, + COMPANY_ID + ); + + jdbcTemplate.query( + "SELECT task_id, submitted_at FROM external_submission WHERE company_id = ?", + resultSet -> { + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + Instant submittedAt = resultSet.getTimestamp("submitted_at").toInstant(); + TaskTimeline task = tasks.get(taskId); + List transitions = + transitionsByTask.getOrDefault(taskId, List.of()); + Instant approvedAt = transitionAt(transitions, "APPROVED"); + Instant completedAt = transitionAt(transitions, "COMPLETED"); + assertThat(submittedAt).isAfterOrEqualTo(task.createdAt()); + if (approvedAt != null) { + assertThat(submittedAt).isAfterOrEqualTo(approvedAt); + } + if (completedAt != null) { + assertThat(submittedAt).isBeforeOrEqualTo(completedAt); + } + } + return null; + }, + COMPANY_ID + ); + + jdbcTemplate.query( + "SELECT task_id, recorded_at FROM task_evidence WHERE company_id = ?", + resultSet -> { + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + Instant recordedAt = resultSet.getTimestamp("recorded_at").toInstant(); + Instant completedAt = transitionAt( + transitionsByTask.getOrDefault(taskId, List.of()), + "COMPLETED" + ); + assertThat(recordedAt).isAfterOrEqualTo(tasks.get(taskId).createdAt()); + assertThat(completedAt).isNotNull().isAfterOrEqualTo(recordedAt); + } + return null; + }, + COMPANY_ID + ); + } + + private Map>> seedSnapshot() { + Map>> snapshot = new LinkedHashMap<>(); + snapshot.put("worker", snapshotRows( + "SELECT worker_id, company_id, stay_expiry_date, contract_start_date, " + + "contract_end_date, created_at, updated_at, version FROM worker " + + "WHERE company_id IN (?, ?) ORDER BY company_id, worker_id" + )); + snapshot.put("worker_document", snapshotRows( + "SELECT worker_document_id, company_id, expiry_date, created_at, updated_at, version " + + "FROM worker_document WHERE company_id IN (?, ?) " + + "ORDER BY company_id, worker_document_id" + )); + snapshot.put("task", snapshotRows( + "SELECT task_id, company_id, due_date, business_data_json, critical_fingerprint, " + + "content_revision, created_at, updated_at, version FROM task " + + "WHERE company_id IN (?, ?) ORDER BY company_id, task_id" + )); + snapshot.put("task_checklist_item", snapshotRows( + "SELECT checklist_item_id, company_id, completed_at, created_at, updated_at, version " + + "FROM task_checklist_item WHERE company_id IN (?, ?) " + + "ORDER BY company_id, checklist_item_id" + )); + snapshot.put("approval_request", snapshotRows( + "SELECT approval_request_id, company_id, target_task_version, target_content_revision, " + + "approved_task_version, target_fingerprint, requested_at, decided_at, " + + "invalidated_at, created_at, updated_at, version FROM approval_request " + + "WHERE company_id IN (?, ?) ORDER BY company_id, approval_request_id" + )); + snapshot.put("task_transition_history", snapshotRows( + "SELECT transition_id, company_id, created_at FROM task_transition_history " + + "WHERE company_id IN (?, ?) ORDER BY company_id, transition_id" + )); + snapshot.put("external_submission", snapshotRows( + "SELECT external_submission_id, company_id, submitted_at, created_at " + + "FROM external_submission WHERE company_id IN (?, ?) " + + "ORDER BY company_id, external_submission_id" + )); + snapshot.put("task_evidence", snapshotRows( + "SELECT evidence_id, company_id, recorded_at, created_at FROM task_evidence " + + "WHERE company_id IN (?, ?) ORDER BY company_id, evidence_id" + )); + snapshot.put("document_request_draft", snapshotRows( + "SELECT draft_id, company_id, created_at, updated_at, version " + + "FROM document_request_draft WHERE company_id IN (?, ?) " + + "ORDER BY company_id, draft_id" + )); + snapshot.put("audit_event", snapshotRows( + "SELECT audit_event_id, company_id, created_at FROM audit_event " + + "WHERE company_id IN (?, ?) ORDER BY company_id, audit_event_id" + )); + return snapshot; + } + + private List> snapshotRows(String sql) { + return jdbcTemplate.queryForList(sql, COMPANY_ID, TEST_COMPANY_ID); + } + + private Instant transitionAt(List transitions, String toStatus) { + return transitions.stream() + .filter(transition -> toStatus.equals(transition.toStatus())) + .findFirst() + .map(TransitionTimeline::createdAt) + .orElse(null); + } + + private boolean isTerminal(String status) { + return "COMPLETED".equals(status) || "CANCELLED".equals(status); + } + private void assertFrontendRelevantApiResultsAndTenantIsolation() throws Exception { String demoToken = loginAndGetAccessToken(DEMO_ADMIN_EMAIL); String testToken = loginAndGetAccessToken(TEST_ADMIN_EMAIL); @@ -469,13 +718,49 @@ private Map distribution(String table, String column, UUID comp return result; } + private record TaskTimeline(UUID taskId, String status, Instant createdAt) { + } + + private record TransitionTimeline(String fromStatus, String toStatus, Instant createdAt) { + } + @TestConfiguration(proxyBeanMethods = false) static class FixedClockConfiguration { @Bean @Primary - Clock fixedClock() { - return Clock.fixed(TEST_NOW, ZoneOffset.UTC); + MutableClock fixedClock() { + return new MutableClock(TEST_NOW, ZoneOffset.UTC); + } + } + + static final class MutableClock extends Clock { + + private final AtomicReference instant; + private final ZoneId zone; + + private MutableClock(Instant instant, ZoneId zone) { + this.instant = new AtomicReference<>(instant); + this.zone = zone; + } + + void advance(Duration duration) { + instant.updateAndGet(value -> value.plus(duration)); + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId zone) { + return new MutableClock(instant(), zone); + } + + @Override + public Instant instant() { + return instant.get(); } } } diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java index e93e8c5b..c2d562e5 100644 --- a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java @@ -10,8 +10,10 @@ import com.fowoco.server.worker.domain.Worker; import com.fowoco.server.worker.domain.WorkerStatus; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.LinkedHashMap; import java.util.List; @@ -19,6 +21,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.springframework.boot.DefaultApplicationArguments; @@ -38,15 +41,21 @@ void createsOperationalDemoWorkersAndKeepsTestCompanySmall() throws Exception { companyRepository.insert(Company.create(DEMO_COMPANY_ID, "FOWOCO Demo Company", NOW)); companyRepository.insert(Company.create(TEST_COMPANY_ID, "FOWOCO Test Company", NOW)); InMemoryWorkerRepository workerRepository = new InMemoryWorkerRepository(); + MutableClock clock = new MutableClock(NOW, ZoneOffset.UTC); DemoWorkerSeedRunner runner = new DemoWorkerSeedRunner( properties(), companyRepository, workerRepository, - Clock.fixed(NOW, ZoneOffset.UTC) + clock ); runner.run(new DefaultApplicationArguments(new String[0])); + Map initialWorkers = Map.copyOf(workerRepository.workers); runner.run(new DefaultApplicationArguments(new String[0])); + clock.advance(Duration.ofDays(1)); + runner.run(new DefaultApplicationArguments(new String[0])); + + assertThat(workerRepository.workers).containsExactlyInAnyOrderEntriesOf(initialWorkers); assertThat(workerRepository.workers.values()) .filteredOn(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) @@ -126,11 +135,7 @@ public Optional findByWorkerIdAndCompanyId(UUID workerId, UUID companyId @Override public Worker update(Worker worker) { - if (!workers.containsKey(worker.workerId())) { - throw new IllegalStateException("worker to update was not found"); - } - workers.put(worker.workerId(), worker); - return worker; + throw new AssertionError("immutable demo worker snapshot must not be updated"); } @Override @@ -148,4 +153,34 @@ public List findAllByWorkerIdsAndCompanyId(Set workerIds, UUID com throw new UnsupportedOperationException(); } } + + private static final class MutableClock extends Clock { + + private final AtomicReference instant; + private final ZoneId zone; + + private MutableClock(Instant instant, ZoneId zone) { + this.instant = new AtomicReference<>(instant); + this.zone = zone; + } + + void advance(Duration duration) { + instant.updateAndGet(value -> value.plus(duration)); + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId zone) { + return new MutableClock(instant(), zone); + } + + @Override + public Instant instant() { + return instant.get(); + } + } } From 0fd5feddee128ca8ca0a482e20911822980dd8e3 Mon Sep 17 00:00:00 2001 From: krestar Date: Tue, 4 Aug 2026 13:11:27 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat(seed):=20Figma=20PWF=5Fv3=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EB=8C=80=ED=91=9C=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=A0=95=ED=95=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응웬반A의 체류 만료일을 D+45 기준으로 조정 - 재계약, 취업활동기간 연장, 여권 요청 업무를 동일 Case로 연결 - 대표 Task에 요청 원문, 후보 순서, 누락정보 등 demo business_data 추가 - 응웬반A의 여권, 외국인등록증, 계약서 상태를 시나리오에 맞게 정리 - 전체 Task 유형과 문서 상태 분포 유지 - 재실행 시 business_data와 fingerprint 정합성 검증 강화 - 대표 초안 시나리오 및 문서 상태 통합 테스트 추가 --- .../seed/DemoDocumentSeedCatalog.java | 64 +++++++++- .../seed/DemoOperationalSeedCatalog.java | 15 ++- .../seed/DemoTaskSeedCatalog.java | 118 +++++++++++++++--- .../infrastructure/seed/DemoTaskSeeder.java | 30 +++-- .../seed/DemoWorkerSeedCatalog.java | 2 +- .../seed/DemoAuthSeedIntegrationTest.java | 79 +++++++++++- .../seed/DemoWorkerSeedRunnerTest.java | 2 +- 7 files changed, 274 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java index c73dae34..7bd32ef0 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java @@ -13,7 +13,7 @@ final class DemoDocumentSeedCatalog { private static final Set MISSING_ADDITION_INDICES = Set.of(0, 8, 14, 18, 24, 30, 36, 42, 48, 54, 60, 66, 72, 76); private static final Set SUBMITTED_ADDITION_INDICES = - Set.of(1, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 69, 73); + Set.of(1, 5, 11, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 69, 73); private static final Integer[] EXPIRY_DAY_OFFSETS = {-20, 0, 7, 25, 45, 75, 120, 240, null}; private DemoDocumentSeedCatalog() { @@ -26,7 +26,7 @@ static List demoDocuments() { int additionIndex = 0; for (int workerNumber = 1; workerNumber <= 28; workerNumber++) { for (DocumentType documentType : additionalTypes(workerNumber)) { - SubmissionStatus status = additionStatus(additionIndex); + SubmissionStatus status = scenarioStatus(workerNumber, documentType, additionIndex); documents.add(document( "95000000-0000-0000-0000-000000000", documentNumber, @@ -34,9 +34,9 @@ static List demoDocuments() { workerNumber, documentType, status, - EXPIRY_DAY_OFFSETS[additionIndex % EXPIRY_DAY_OFFSETS.length], - destination(documentType), - note(documentType, status) + scenarioExpiryDays(workerNumber, documentType, additionIndex), + scenarioDestination(workerNumber, documentType), + scenarioNote(workerNumber, documentType, status) )); documentNumber++; additionIndex++; @@ -100,6 +100,60 @@ private static SubmissionStatus additionStatus(int additionIndex) { return SubmissionStatus.VERIFIED; } + private static SubmissionStatus scenarioStatus( + int workerNumber, + DocumentType documentType, + int additionIndex + ) { + if (workerNumber == 6) { + return documentType == DocumentType.PASSPORT_COPY + ? SubmissionStatus.MISSING + : SubmissionStatus.VERIFIED; + } + return additionStatus(additionIndex); + } + + private static Integer scenarioExpiryDays( + int workerNumber, + DocumentType documentType, + int additionIndex + ) { + if (workerNumber == 6) { + return switch (documentType) { + case PASSPORT_COPY -> null; + case ARC -> 365; + case CONTRACT -> 180; + case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed"); + }; + } + return EXPIRY_DAY_OFFSETS[additionIndex % EXPIRY_DAY_OFFSETS.length]; + } + + private static String scenarioDestination(int workerNumber, DocumentType documentType) { + if (workerNumber == 6) { + return documentType == DocumentType.PASSPORT_COPY + ? "근로자 문서 요청" + : "재계약·연장 준비"; + } + return destination(documentType); + } + + private static String scenarioNote( + int workerNumber, + DocumentType documentType, + SubmissionStatus status + ) { + if (workerNumber == 6) { + return switch (documentType) { + case PASSPORT_COPY -> "여권 사본 미보유 · 베트남어 요청 필요"; + case ARC -> "외국인등록증 확인 완료"; + case CONTRACT -> "현재 근로계약서 확인 완료"; + case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed"); + }; + } + return note(documentType, status); + } + private static String destination(DocumentType documentType) { return switch (documentType) { case PASSPORT_COPY, ARC -> "체류기간 연장"; diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index 10b64b8e..ea52e1c7 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -198,8 +198,8 @@ private void verifyDefinitions() { requireDistribution( demoAudits.stream().map(AuditSeed::actorType).toList(), Map.of( - ActorType.HR_USER, 83L, - ActorType.AI_AGENT, 3L, + ActorType.HR_USER, 81L, + ActorType.AI_AGENT, 5L, ActorType.SYSTEM_RULE, 6L, ActorType.WORKER_LINK, 4L ), @@ -476,8 +476,17 @@ record TaskSeed( TaskSource source, TaskStatus status, int dueDays, - int createdDaysAgo + int createdDaysAgo, + Map businessData ) { + TaskSeed { + if (businessData.containsKey("worker_id") || businessData.containsKey("due_at")) { + throw new IllegalArgumentException( + "task seed business data must not override worker_id or due_at" + ); + } + businessData = Map.copyOf(businessData); + } } record DocumentSeed( diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java index 85da86f1..568e8b28 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java @@ -4,7 +4,9 @@ import com.fowoco.server.task.domain.TaskSource; import com.fowoco.server.task.domain.TaskStatus; import com.fowoco.server.task.domain.TaskType; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; final class DemoTaskSeedCatalog { @@ -29,15 +31,46 @@ static List demoTasks() { task(5, 5, TaskType.RECONTRACT, "근로계약 갱신 완료", "서명된 근로계약서 확인과 갱신 처리를 완료했습니다.", TaskSource.SYSTEM_DDAY, TaskStatus.COMPLETED, 30, 10), - task(6, 6, TaskType.RECONTRACT, "응웬반A 재계약 검토", - "재계약 조건과 체류기간 연장 일정을 함께 검토합니다.", - TaskSource.MANUAL, TaskStatus.READY_FOR_REVIEW, 12, 3), - taskWithCase(7, 6, 6, TaskType.STAY_PERIOD_EXTENSION, "체류연장 업무 초안", - "체류기간 연장 신청을 위한 AI 초안을 확인합니다.", - TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 0, 2), - taskWithCase(8, 6, 6, TaskType.STAY_PERIOD_EXTENSION, "여권 만료일 확인 대기", - "최신 여권 사본과 만료일 확인을 기다립니다.", - TaskSource.MANUAL, TaskStatus.WAITING_WORKER, 7, 2), + taskWithCase(6, 6, 6, TaskType.RECONTRACT, "재계약 조건 확인", + "응웬반A의 재계약 조건과 계속 고용 의사를 검토합니다.", + TaskSource.AI_CANDIDATE, TaskStatus.READY_FOR_REVIEW, 12, 3, + compoundCaseBusinessData( + "RECONTRACT_CONDITIONS", + 1, + "READY_FOR_REVIEW", + Map.of("approval_required", true) + )), + taskWithCase(7, 6, 6, TaskType.EMPLOYMENT_PERIOD_EXTENSION, + "취업활동기간 연장 준비", + "서명된 재계약 결과를 기다리는 취업활동기간 연장 초안입니다.", + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 20, 2, + compoundCaseBusinessData( + "EMPLOYMENT_PERIOD_EXTENSION", + 3, + "BLOCKED_BY_PREDECESSOR", + Map.of( + "depends_on_task_id", + demoUuid("94000000-0000-0000-0000-000000000", 6).toString(), + "dependency_reason", "SIGNED_CONTRACT_REQUIRED" + ) + )), + taskWithCase(8, 6, 6, TaskType.STAY_PERIOD_EXTENSION, "여권 사본 요청", + "응웬반A에게 베트남어로 여권 사본 제출을 요청하고 응답을 기다립니다.", + TaskSource.AI_CANDIDATE, TaskStatus.WAITING_WORKER, 7, 2, + compoundCaseBusinessData( + "PASSPORT_COPY_REQUEST", + 2, + "WAITING_WORKER", + Map.of( + "requested_document_types", List.of("PASSPORT_COPY"), + "submission_due_offset_days", 7, + "missing_information", List.of(Map.of( + "field", "passport_copy", + "source", "WORKER_CONFIRMATION", + "blocking", true + )) + ) + )), task(9, 8, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 정보 보완", "연장 신청에 필요한 고용 정보와 제출 서류를 보완합니다.", TaskSource.SYSTEM_DDAY, TaskStatus.NEEDS_INFO, 4, 5), @@ -50,8 +83,8 @@ static List demoTasks() { task(12, 10, TaskType.RECONTRACT, "재계약 서류 준비", "계약 만료 전에 재계약 서류와 조건을 준비합니다.", TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 20, 7), - task(13, 7, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 자료 보완", - "지원 가능한 고용기간 연장 업무의 누락 정보를 확인합니다.", + task(13, 7, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 자료 보완", + "체류기간 연장 업무의 누락 정보와 보유 문서를 확인합니다.", TaskSource.MANUAL, TaskStatus.NEEDS_INFO, 21, 8), task(14, 12, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 검토", "제출 준비가 완료된 체류기간 연장 내용을 검토합니다.", @@ -121,7 +154,8 @@ private static TaskSeed task( source, status, dueDays, - createdDaysAgo + createdDaysAgo, + Map.of() ); } @@ -135,7 +169,8 @@ private static TaskSeed taskWithCase( TaskSource source, TaskStatus status, int dueDays, - int createdDaysAgo + int createdDaysAgo, + Map businessData ) { return new TaskSeed( demoUuid("94000000-0000-0000-0000-000000000", taskNumber), @@ -148,8 +183,60 @@ private static TaskSeed taskWithCase( source, status, dueDays, - createdDaysAgo + createdDaysAgo, + businessData + ); + } + + private static TaskSeed taskWithCase( + int taskNumber, + int caseNumber, + int workerNumber, + TaskType taskType, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + return taskWithCase( + taskNumber, + caseNumber, + workerNumber, + taskType, + title, + description, + source, + status, + dueDays, + createdDaysAgo, + Map.of() + ); + } + + private static Map compoundCaseBusinessData( + String candidateKey, + int candidateOrder, + String displayState, + Map details + ) { + Map data = new LinkedHashMap<>(); + data.put("demo_scenario", "compound-draft-flow-v1"); + data.put("case_display_name", "재계약·연장 준비"); + data.put( + "request_text", + "응웬반A의 재계약과 연장 준비를 진행해 주세요. " + + "여권 사본이 없으면 7일 안에 제출하도록 안내해 주세요." ); + data.put("stay_qualification", "E-9"); + data.put("contract_key", "EXPIRY_RENEWAL+DOCUMENT_REQUEST"); + data.put("candidate_key", candidateKey); + data.put("candidate_order", candidateOrder); + data.put("display_state", displayState); + data.put("input_summary", Map.of("required_count", 9, "available_count", 7)); + data.putAll(details); + return Map.copyOf(data); } private static TaskSeed testTask( @@ -173,7 +260,8 @@ private static TaskSeed testTask( source, status, dueDays, - createdDaysAgo + createdDaysAgo, + Map.of() ); } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java index 7e63fdd2..f350ecee 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java @@ -9,6 +9,7 @@ import java.time.Instant; import java.time.LocalDate; import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -25,15 +26,7 @@ final class DemoTaskSeeder { void seed(TaskSeed seed, DemoOperationalSeedContext context) { LocalDate dueDate = context.today().plusDays(seed.dueDays()); - EncodedTaskContent content = taskContentCodec.encode( - seed.workerId(), - seed.workflowId(), - seed.taskType().name(), - seed.title(), - seed.description(), - dueDate, - Map.of("worker_id", seed.workerId().toString(), "due_at", dueDate.toString()) - ); + EncodedTaskContent content = encode(seed, dueDate); Optional existing = taskRepository.findByIdAndCompanyId( seed.taskId(), context.companyId() @@ -72,6 +65,8 @@ void seed(TaskSeed seed, DemoOperationalSeedContext context) { } void verifyExisting(Task task, TaskSeed seed, DemoOperationalSeedContext context) { + LocalDate dueDate = task.dueDate(); + EncodedTaskContent content = encode(seed, dueDate); if (!seed.taskId().equals(task.taskId()) || !context.companyId().equals(task.companyId()) || !seed.workerId().equals(task.workerId()) @@ -81,6 +76,8 @@ void verifyExisting(Task task, TaskSeed seed, DemoOperationalSeedContext context || !DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION.equals(task.workflowCatalogVersion()) || !seed.title().equals(task.title()) || !seed.description().equals(task.description()) + || !content.businessDataJson().equals(task.businessDataJson()) + || !content.criticalFingerprint().equals(task.criticalFingerprint()) || seed.source() != task.source() || seed.status() != task.status() || task.contentRevision() != 0L @@ -91,4 +88,19 @@ void verifyExisting(Task task, TaskSeed seed, DemoOperationalSeedContext context ); } } + + private EncodedTaskContent encode(TaskSeed seed, LocalDate dueDate) { + Map businessData = new LinkedHashMap<>(seed.businessData()); + businessData.put("worker_id", seed.workerId().toString()); + businessData.put("due_at", dueDate.toString()); + return taskContentCodec.encode( + seed.workerId(), + seed.workflowId(), + seed.taskType().name(), + seed.title(), + seed.description(), + dueDate, + businessData + ); + } } diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java index 74b154d9..bd433eb5 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java @@ -12,7 +12,7 @@ final class DemoWorkerSeedCatalog { activeWorker("92000000-0000-0000-0000-000000000003", "데모 근로자 03", "NP", "ne", 90, 240), activeWorker("92000000-0000-0000-0000-000000000004", "데모 근로자 04", "ID", "id", 120, 270), activeWorker("92000000-0000-0000-0000-000000000005", "데모 근로자 05", "PH", "en", 180, 365), - activeWorker("92000000-0000-0000-0000-000000000006", "응웬반A", "VN", "vi", 12, 180), + activeWorker("92000000-0000-0000-0000-000000000006", "응웬반A", "VN", "vi", 45, 180), activeWorker("92000000-0000-0000-0000-000000000007", "아디 수르야", "ID", "id", 365, 365), activeWorker("92000000-0000-0000-0000-000000000008", "소팔 타망", "NP", "ne", 21, 120), activeWorker("92000000-0000-0000-0000-000000000009", "라니 위자야", "ID", "id", 7, 90), diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index f4af26b9..33fd230a 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -61,6 +61,14 @@ class DemoAuthSeedIntegrationTest { UUID.fromString("97000000-0000-0000-0000-000000000001"); private static final UUID REPRESENTATIVE_WORKER_ID = UUID.fromString("92000000-0000-0000-0000-000000000006"); + private static final UUID RECONTRACT_CANDIDATE_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000006"); + private static final UUID EMPLOYMENT_EXTENSION_CANDIDATE_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000007"); + private static final UUID PASSPORT_REQUEST_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000008"); + private static final UUID COMPOUND_CASE_ID = + UUID.fromString("94100000-0000-0000-0000-000000000006"); private static final String PASSWORD = "Demo-password-1!"; private static final String DEMO_ADMIN_EMAIL = "demo.admin@example.com"; private static final String TEST_ADMIN_EMAIL = "test.admin@example.com"; @@ -121,6 +129,7 @@ void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throw assertAccountsAndPasswords(); assertExactCountsAndDistributions(); assertRelativeDatesAndSafeData(); + assertCompoundDraftScenario(); assertTaskTimelineInvariants(); Map demoCountsBeforeRerun = counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet()); @@ -212,7 +221,7 @@ private void assertExactCountsAndDistributions() { ); assertThat(distribution("audit_event", "actor_type", COMPANY_ID)) .containsExactlyInAnyOrderEntriesOf( - Map.of("HR_USER", 83, "AI_AGENT", 3, "SYSTEM_RULE", 6, "WORKER_LINK", 4) + Map.of("HR_USER", 81, "AI_AGENT", 5, "SYSTEM_RULE", 6, "WORKER_LINK", 4) ); assertThat(jdbcTemplate.queryForObject( @@ -256,7 +265,7 @@ private void assertRelativeDatesAndSafeData() { LocalDate.class, REPRESENTATIVE_WORKER_ID, COMPANY_ID - )).isEqualTo(today.plusDays(12)); + )).isEqualTo(today.plusDays(45)); assertThat(countWhere( "audit_event", @@ -311,6 +320,72 @@ private void assertRelativeDatesAndSafeData() { ).stream().noneMatch(json::contains)); } + private void assertCompoundDraftScenario() { + List> tasks = jdbcTemplate.queryForList( + "SELECT task_id, case_id, task_type, workflow_id, source, status, business_data_json " + + "FROM task WHERE task_id IN (?, ?, ?) ORDER BY task_id", + RECONTRACT_CANDIDATE_TASK_ID, + EMPLOYMENT_EXTENSION_CANDIDATE_TASK_ID, + PASSPORT_REQUEST_TASK_ID + ); + assertThat(tasks).hasSize(3).allSatisfy(task -> { + assertThat(task.get("case_id")).isEqualTo(COMPOUND_CASE_ID); + String businessData = (String) task.get("business_data_json"); + assertThat(JsonPath.read(businessData, "$.demo_scenario")) + .isEqualTo("compound-draft-flow-v1"); + assertThat(JsonPath.read(businessData, "$.stay_qualification")) + .isEqualTo("E-9"); + assertThat(JsonPath.read(businessData, "$.input_summary.required_count")) + .isEqualTo(9); + assertThat(JsonPath.read(businessData, "$.input_summary.available_count")) + .isEqualTo(7); + }); + + assertThat(tasks.get(0)) + .containsEntry("task_type", "RECONTRACT") + .containsEntry("workflow_id", "WF-CON-001") + .containsEntry("source", "AI_CANDIDATE") + .containsEntry("status", "READY_FOR_REVIEW"); + assertThat(tasks.get(1)) + .containsEntry("task_type", "EMPLOYMENT_PERIOD_EXTENSION") + .containsEntry("workflow_id", "WF-CON-001") + .containsEntry("source", "AI_CANDIDATE") + .containsEntry("status", "DRAFT"); + assertThat(tasks.get(2)) + .containsEntry("task_type", "STAY_PERIOD_EXTENSION") + .containsEntry("workflow_id", "WF-STY-001") + .containsEntry("source", "AI_CANDIDATE") + .containsEntry("status", "WAITING_WORKER"); + + Map> documentsByType = jdbcTemplate.query( + "SELECT document_type, submission_status, expiry_date, destination, note " + + "FROM worker_document WHERE worker_id = ? AND company_id = ?", + resultSet -> { + Map> result = new LinkedHashMap<>(); + while (resultSet.next()) { + Map document = new LinkedHashMap<>(); + document.put("submission_status", resultSet.getString("submission_status")); + document.put("expiry_date", resultSet.getObject("expiry_date", LocalDate.class)); + document.put("destination", resultSet.getString("destination")); + document.put("note", resultSet.getString("note")); + result.put(resultSet.getString("document_type"), document); + } + return result; + }, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + ); + assertThat(documentsByType).containsOnlyKeys("PASSPORT_COPY", "ARC", "CONTRACT"); + assertThat(documentsByType.get("PASSPORT_COPY")) + .containsEntry("submission_status", "MISSING") + .containsEntry("destination", "근로자 문서 요청"); + assertThat(documentsByType.get("PASSPORT_COPY").get("expiry_date")).isNull(); + assertThat(documentsByType.get("ARC")) + .containsEntry("submission_status", "VERIFIED"); + assertThat(documentsByType.get("CONTRACT")) + .containsEntry("submission_status", "VERIFIED"); + } + private void assertTaskTimelineInvariants() { Map tasks = jdbcTemplate.query( "SELECT task_id, status, created_at FROM task WHERE company_id = ?", diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java index c2d562e5..80481cbf 100644 --- a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java @@ -74,7 +74,7 @@ void createsOperationalDemoWorkersAndKeepsTestCompanySmall() throws Exception { assertThat(worker.displayName()).isEqualTo("응웬반A"); assertThat(worker.nationalityCode()).isEqualTo("VN"); assertThat(worker.preferredLanguage()).isEqualTo("vi"); - assertThat(worker.stayExpiryDate()).isEqualTo(LocalDate.of(2026, 8, 12)); + assertThat(worker.stayExpiryDate()).isEqualTo(LocalDate.of(2026, 9, 14)); }); assertThat(workerRepository.workers.values()) .filteredOn(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) From 088608988b34a04162fe74de575229692a77a0c3 Mon Sep 17 00:00:00 2001 From: krestar Date: Tue, 4 Aug 2026 13:42:01 +0900 Subject: [PATCH 11/15] =?UTF-8?q?feat(seed):=20=EA=B2=80=ED=86=A0=C2=B7?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=C2=B7=EC=A0=9C=EC=B6=9C=20lifecycle=20fixtur?= =?UTF-8?q?e=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 업무별 AI·HR 승인 snapshot과 변경 필드 및 원천 버전을 저장한다 - 누락 여권 문서에 맞춘 베트남어 제출 요청 초안을 구성한다 - 체류기간 연장 업무의 승인·외부 제출·증빙 lifecycle을 연결한다 - 동일 trace ID 기반 AI Agent 처리 이력을 추가한다 - lifecycle fixture와 재실행 불변성 검증을 보강한다 --- .../seed/DemoApprovalRequestSeeder.java | 23 ++-- .../seed/DemoAuditSeedCatalog.java | 44 ++++++- .../seed/DemoOperationalSeedCatalog.java | 16 ++- .../DemoOperationalSeedConfiguration.java | 5 +- .../seed/DemoTaskArtifactSeedCatalog.java | 10 +- .../seed/DemoTaskWorkflowSeedCatalog.java | 85 ++++++++++++- .../seed/DemoAuthSeedIntegrationTest.java | 114 ++++++++++++++++-- 7 files changed, 268 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java index f1aae8ea..7dc3e8be 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java @@ -1,6 +1,7 @@ package com.fowoco.server.demo.infrastructure.seed; import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.approval.application.SafeJsonService; import com.fowoco.server.approval.domain.ApprovalRequest; import com.fowoco.server.approval.domain.ApprovalStatus; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; @@ -13,23 +14,21 @@ final class DemoApprovalRequestSeeder { - private static final String AI_SNAPSHOT_JSON = "{\"summary\":\"demo operational review\"}"; - private static final String HR_SNAPSHOT_JSON = "{\"review\":\"demo operational seed\"}"; - private static final String CHANGED_FIELDS_JSON = "[]"; - private static final String SOURCE_VERSIONS_JSON = "{\"workflow_catalog\":\"0.2.0\"}"; - private final ApprovalRequestRepository approvalRepository; private final TaskRepository taskRepository; + private final SafeJsonService safeJsonService; DemoApprovalRequestSeeder( ApprovalRequestRepository approvalRepository, - TaskRepository taskRepository + TaskRepository taskRepository, + SafeJsonService safeJsonService ) { this.approvalRepository = Objects.requireNonNull( approvalRepository, "approvalRepository must not be null" ); this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); + this.safeJsonService = Objects.requireNonNull(safeJsonService, "safeJsonService must not be null"); } void seed(ApprovalSeed seed, DemoOperationalSeedContext context) { @@ -51,10 +50,10 @@ void seed(ApprovalSeed seed, DemoOperationalSeedContext context) { task.version(), task.contentRevision(), task.criticalFingerprint(), - AI_SNAPSHOT_JSON, - HR_SNAPSHOT_JSON, - CHANGED_FIELDS_JSON, - SOURCE_VERSIONS_JSON, + safeJsonService.write(seed.aiSnapshot(), true), + safeJsonService.write(seed.hrSnapshot(), true), + safeJsonService.write(seed.changedFields(), true), + safeJsonService.write(seed.sourceVersions(), true), context.actorId(), requestedAt ); @@ -101,6 +100,10 @@ void verifyExisting( || task.version() != approval.targetTaskVersion() || task.contentRevision() != approval.targetContentRevision() || !task.criticalFingerprint().equals(approval.targetFingerprint()) + || !safeJsonService.write(seed.aiSnapshot(), true).equals(approval.aiSnapshotJson()) + || !safeJsonService.write(seed.hrSnapshot(), true).equals(approval.hrSnapshotJson()) + || !safeJsonService.write(seed.changedFields(), true).equals(approval.changedFieldsJson()) + || !safeJsonService.write(seed.sourceVersions(), true).equals(approval.sourceVersionsJson()) || !context.actorId().equals(approval.requestedBy()) || !matchesOutcome(approval, seed, context)) { throw new IllegalStateException( 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 495285c0..03c70dfa 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 @@ -20,7 +20,7 @@ final class DemoAuditSeedCatalog { private static final List CHECKLIST_EVENT_TASKS = List.of( - 3, 4, 5, 6, 8, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22 + 3, 4, 5, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22 ); private DemoAuditSeedCatalog() { @@ -36,13 +36,14 @@ static List additionalDemoAudits( List seeds = new ArrayList<>(); for (int taskIndex = 0; taskIndex < tasks.size(); taskIndex++) { int taskNumber = taskIndex + 1; - if (taskNumber != 2) { + if (taskNumber != 2 && taskNumber != 6 && taskNumber != 7 && taskNumber != 8) { TaskSeed task = tasks.get(taskIndex); add(seeds, actorType(task.source()), AuditAction.TASK_CREATED, AuditTargetType.TASK, task.taskId(), "업무 시나리오가 생성됨", task.createdDaysAgo() * 24); } } + addCompoundDraftTrace(seeds, tasks); CHECKLIST_EVENT_TASKS.forEach(taskNumber -> { TaskSeed task = tasks.get(taskNumber - 1); add(seeds, ActorType.HR_USER, AuditAction.CHECKLIST_ITEM_UPDATED, @@ -111,6 +112,23 @@ static List additionalDemoAudits( return List.copyOf(seeds); } + private static void addCompoundDraftTrace(List seeds, List tasks) { + String traceId = "demo-compound-draft-flow"; + TaskSeed recontract = tasks.get(5); + TaskSeed employmentExtension = tasks.get(6); + TaskSeed passportRequest = tasks.get(7); + addWithTrace(seeds, AuditAction.TASK_CREATED, recontract.taskId(), + traceId, "복합 요청의 대상 근로자와 Case 맥락을 확인함", 72); + addWithTrace(seeds, AuditAction.TASK_UPDATED, recontract.taskId(), + traceId, "재계약 Workflow와 필수 정보를 확인함", 60); + addWithTrace(seeds, AuditAction.TASK_UPDATED, passportRequest.taskId(), + traceId, "보유 문서를 비교하고 여권 사본 누락을 확인함", 48); + addWithTrace(seeds, AuditAction.TASK_UPDATED, passportRequest.taskId(), + traceId, "베트남어 문서 요청 초안을 준비함", 40); + addWithTrace(seeds, AuditAction.TASK_CREATED, employmentExtension.taskId(), + traceId, "선행 재계약 결과를 기다리는 연장 후보를 준비함", 36); + } + static List testAudits(List tasks) { List seeds = new ArrayList<>(); tasks.forEach(task -> addTest(seeds, actorType(task.source()), AuditAction.TASK_CREATED, @@ -170,6 +188,28 @@ private static void addTest( )); } + private static void addWithTrace( + List seeds, + AuditAction action, + UUID targetId, + String traceId, + String summary, + int hoursAgo + ) { + int sequence = seeds.size() + 4; + seeds.add(audit( + demoUuid("96000000-0000-0000-0000-000000000", sequence), + ActorType.AI_AGENT, + action, + AuditTargetType.TASK, + targetId, + "demo-seed-audit-%03d".formatted(sequence), + traceId, + summary, + hoursAgo + )); + } + private static AuditSeed audit( UUID auditEventId, ActorType actorType, diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index ea52e1c7..ce093117 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -198,8 +198,8 @@ private void verifyDefinitions() { requireDistribution( demoAudits.stream().map(AuditSeed::actorType).toList(), Map.of( - ActorType.HR_USER, 81L, - ActorType.AI_AGENT, 5L, + ActorType.HR_USER, 79L, + ActorType.AI_AGENT, 7L, ActorType.SYSTEM_RULE, 6L, ActorType.WORKER_LINK, 4L ), @@ -518,8 +518,18 @@ record ApprovalSeed( ApprovalStatus status, String reason, int requestedHoursAgo, - Integer outcomeHoursAgo + Integer outcomeHoursAgo, + Map aiSnapshot, + Map hrSnapshot, + List> changedFields, + Map sourceVersions ) { + ApprovalSeed { + aiSnapshot = Map.copyOf(aiSnapshot); + hrSnapshot = Map.copyOf(hrSnapshot); + changedFields = changedFields.stream().map(Map::copyOf).toList(); + sourceVersions = Map.copyOf(sourceVersions); + } } record TransitionSeed( diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java index cf6d274f..2069e697 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -4,6 +4,7 @@ import com.fowoco.server.approval.application.port.ApprovalRequestRepository; import com.fowoco.server.approval.application.port.EvidenceRepository; import com.fowoco.server.approval.application.port.ExternalSubmissionRepository; +import com.fowoco.server.approval.application.SafeJsonService; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; import com.fowoco.server.task.application.TaskContentCodec; @@ -29,6 +30,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( TaskContentCodec taskContentCodec, TaskChecklistRepository checklistRepository, ApprovalRequestRepository approvalRepository, + SafeJsonService safeJsonService, ExternalSubmissionRepository externalSubmissionRepository, EvidenceRepository evidenceRepository, DocumentRequestDraftRepository documentRequestDraftRepository, @@ -45,7 +47,8 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( DemoTaskChecklistSeeder checklistSeeder = new DemoTaskChecklistSeeder(checklistRepository); DemoApprovalRequestSeeder approvalSeeder = new DemoApprovalRequestSeeder( approvalRepository, - taskRepository + taskRepository, + safeJsonService ); DemoTaskTransitionSeeder transitionSeeder = new DemoTaskTransitionSeeder(jdbcTemplate); DemoExternalSubmissionSeeder externalSubmissionSeeder = new DemoExternalSubmissionSeeder( diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java index 9b17181d..29bd341f 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java @@ -29,8 +29,10 @@ static List demoEvidence(List tasks) { return List.of( evidence(1, tasks, 5, EvidenceType.DOCUMENT, "서명된 재계약 문서 확인", 48), evidence(2, tasks, 5, EvidenceType.HR_CONFIRMATION, "계약 갱신 처리 완료 확인", 24), - evidence(3, tasks, 20, EvidenceType.RECEIPT, "체류기간 연장 접수 확인", 48), - evidence(4, tasks, 20, EvidenceType.OFFICIAL_RESULT, "체류기간 연장 승인 결과 확인", 12), + evidence(3, tasks, 20, EvidenceType.RECEIPT, + "DEMO-STAY-EXT-001 체류기간 연장 접수 확인", 48), + evidence(4, tasks, 20, EvidenceType.OFFICIAL_RESULT, + "DEMO-STAY-EXT-001 체류기간 연장 승인 결과 확인", 12), evidence(5, tasks, 21, EvidenceType.DOCUMENT, "갱신 계약서 서명 상태 확인", 72), evidence(6, tasks, 21, EvidenceType.HR_CONFIRMATION, "재계약 인사 반영 완료", 24), evidence(7, tasks, 22, EvidenceType.RECEIPT, "고용허가기간 연장 접수 확인", 72), @@ -44,8 +46,8 @@ static List demoDocumentRequestDrafts(List t return List.of( draft(1, tasks, 3, "ne", List.of(DocumentType.PASSPORT_COPY), "कृपया राहदानीको प्रतिलिपि तयार गर्नुहोस्।", 72), - draft(2, tasks, 8, "vi", List.of(DocumentType.PASSPORT_COPY, DocumentType.ARC), - "Vui lòng chuẩn bị bản sao hộ chiếu và thẻ đăng ký người nước ngoài.", 36), + draft(2, tasks, 8, "vi", List.of(DocumentType.PASSPORT_COPY), + "Vui lòng nộp bản sao hộ chiếu trong vòng 7 ngày để chuẩn bị gia hạn.", 36), draft(3, tasks, 10, "id", List.of(DocumentType.PASSPORT_COPY, DocumentType.ARC), "Mohon siapkan salinan paspor dan kartu registrasi orang asing.", 72), draft(4, tasks, 11, "id", List.of(DocumentType.ARC), diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java index 1d13d75e..5dea3bd7 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java @@ -7,6 +7,7 @@ import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; import com.fowoco.server.task.domain.TaskStatus; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -75,7 +76,8 @@ static List demoApprovals(List tasks) { approval(10, tasks, 22, ApprovalStatus.APPROVED, "고용기간 연장 완료 건 승인"), approval(11, tasks, 23, ApprovalStatus.APPROVED, "신청 결과 확인 후 승인"), approval(12, tasks, 9, ApprovalStatus.REJECTED, "필수 고용 정보 보완 필요"), - approval(13, tasks, 1, ApprovalStatus.INVALIDATED, "검토 중인 승인 요청을 무효화") + approval(13, tasks, 1, ApprovalStatus.INVALIDATED, + "마감일 변경으로 기존 승인 snapshot을 무효화") ); } @@ -110,13 +112,84 @@ private static ApprovalSeed approval( if (outcomeHoursAgo != null && requestedHoursAgo < outcomeHoursAgo) { throw new IllegalStateException("demo approval timeline is invalid"); } + ApprovalSnapshot snapshot = approvalSnapshot(taskNumber, task, status, reason); return new ApprovalSeed( demoUuid("94300000-0000-0000-0000-000000000", approvalNumber), task.taskId(), status, reason, requestedHoursAgo, - outcomeHoursAgo + outcomeHoursAgo, + snapshot.ai(), + snapshot.hr(), + snapshot.changedFields(), + snapshot.sourceVersions() + ); + } + + private static ApprovalSnapshot approvalSnapshot( + int taskNumber, + TaskSeed task, + ApprovalStatus status, + String reason + ) { + Map ai = new LinkedHashMap<>(); + ai.put("summary", task.title()); + ai.put("task_type", task.taskType().name()); + ai.put("workflow_id", task.workflowId()); + ai.put("due_offset_days", task.dueDays()); + ai.put("proposed_status", TaskStatus.READY_FOR_REVIEW.name()); + + Map hr = new LinkedHashMap<>(); + hr.put("summary", task.title()); + hr.put("review_result", status.name()); + hr.put("approval_required", true); + if (reason != null) { + hr.put("review_note", reason); + } + + List> changedFields = List.of(); + if (taskNumber == 6) { + ai.put("case_display_name", "재계약·연장 준비"); + ai.put("input_summary", Map.of("required_count", 9, "available_count", 7)); + hr.put("confirmed_fields", List.of("continued_employment_intent")); + hr.put("warning_fields", List.of("passport_expiry_date")); + hr.put("validation_summary", Map.of( + "key_fields_valid", 7, + "key_fields_total", 7, + "error_count", 0, + "warning_count", 1 + )); + changedFields = List.of(Map.of( + "field", "continued_employment_intent", + "before", "NEEDS_CONFIRMATION", + "after", "CONFIRMED", + "source", "HR_INPUT" + )); + } else if (taskNumber == 9) { + hr.put("missing_fields", List.of("employment_period", "submission_destination")); + } else if (taskNumber == 1) { + hr.put("invalidation_trigger", "DUE_DATE_CHANGED"); + changedFields = List.of(Map.of( + "field", "due_at", + "before", "D+3", + "after", "D+7", + "source", "HR_INPUT" + )); + } + + return new ApprovalSnapshot( + Map.copyOf(ai), + Map.copyOf(hr), + changedFields, + Map.of( + "workflow_catalog", DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION, + "workflow_id", task.workflowId(), + "task_version", 0, + "content_revision", 0, + "worker_record", "demo-seed-v1", + "document_records", "demo-seed-v1" + ) ); } @@ -262,4 +335,12 @@ private record ChecklistTemplate(String itemCode, String label) { private record TransitionPoint(TaskStatus status, int hoursAgo) { } + + private record ApprovalSnapshot( + Map ai, + Map hr, + List> changedFields, + Map sourceVersions + ) { + } } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index 33fd230a..2d7c1117 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -69,6 +69,12 @@ class DemoAuthSeedIntegrationTest { UUID.fromString("94000000-0000-0000-0000-000000000008"); private static final UUID COMPOUND_CASE_ID = UUID.fromString("94100000-0000-0000-0000-000000000006"); + private static final UUID REPRESENTATIVE_APPROVAL_ID = + UUID.fromString("94300000-0000-0000-0000-000000000002"); + private static final UUID COMPLETED_STAY_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000020"); + private static final UUID PASSPORT_REQUEST_DRAFT_ID = + UUID.fromString("94700000-0000-0000-0000-000000000002"); private static final String PASSWORD = "Demo-password-1!"; private static final String DEMO_ADMIN_EMAIL = "demo.admin@example.com"; private static final String TEST_ADMIN_EMAIL = "test.admin@example.com"; @@ -130,6 +136,7 @@ void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throw assertExactCountsAndDistributions(); assertRelativeDatesAndSafeData(); assertCompoundDraftScenario(); + assertReviewApprovalAndSubmissionFixtures(); assertTaskTimelineInvariants(); Map demoCountsBeforeRerun = counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet()); @@ -221,7 +228,7 @@ private void assertExactCountsAndDistributions() { ); assertThat(distribution("audit_event", "actor_type", COMPANY_ID)) .containsExactlyInAnyOrderEntriesOf( - Map.of("HR_USER", 81, "AI_AGENT", 5, "SYSTEM_RULE", 6, "WORKER_LINK", 4) + Map.of("HR_USER", 79, "AI_AGENT", 7, "SYSTEM_RULE", 6, "WORKER_LINK", 4) ); assertThat(jdbcTemplate.queryForObject( @@ -386,6 +393,86 @@ private void assertCompoundDraftScenario() { .containsEntry("submission_status", "VERIFIED"); } + private void assertReviewApprovalAndSubmissionFixtures() { + Map approval = jdbcTemplate.queryForMap( + "SELECT status, ai_snapshot_json, hr_snapshot_json, changed_fields_json, " + + "source_versions_json FROM approval_request " + + "WHERE approval_request_id = ? AND company_id = ?", + REPRESENTATIVE_APPROVAL_ID, + COMPANY_ID + ); + String aiSnapshot = (String) approval.get("ai_snapshot_json"); + String hrSnapshot = (String) approval.get("hr_snapshot_json"); + String changedFields = (String) approval.get("changed_fields_json"); + String sourceVersions = (String) approval.get("source_versions_json"); + assertThat(approval.get("status")).isEqualTo("PENDING"); + assertThat(JsonPath.read(aiSnapshot, "$.summary")).isEqualTo("재계약 조건 확인"); + assertThat(JsonPath.read(aiSnapshot, "$.input_summary.required_count")).isEqualTo(9); + assertThat(JsonPath.read(hrSnapshot, "$.validation_summary.warning_count")).isEqualTo(1); + assertThat(JsonPath.read(hrSnapshot, "$.warning_fields[0]")) + .isEqualTo("passport_expiry_date"); + assertThat(JsonPath.read(changedFields, "$[0].field")) + .isEqualTo("continued_employment_intent"); + assertThat(JsonPath.read(sourceVersions, "$.workflow_catalog")).isEqualTo("0.2.0"); + + Map requestDraft = jdbcTemplate.queryForMap( + "SELECT language, message, review_status FROM document_request_draft " + + "WHERE draft_id = ? AND company_id = ?", + PASSPORT_REQUEST_DRAFT_ID, + COMPANY_ID + ); + assertThat(requestDraft) + .containsEntry("language", "vi") + .containsEntry("review_status", "DRAFT"); + assertThat((String) requestDraft.get("message")).contains("7 ngày"); + assertThat(jdbcTemplate.queryForList( + "SELECT document_type FROM document_request_draft_type WHERE draft_id = ?", + String.class, + PASSPORT_REQUEST_DRAFT_ID + )).containsExactly("PASSPORT_COPY"); + + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM approval_request WHERE task_id = ? " + + "AND company_id = ? AND status = 'APPROVED'", + Integer.class, + COMPLETED_STAY_TASK_ID, + COMPANY_ID + )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT safe_reference FROM external_submission WHERE task_id = ? AND company_id = ?", + String.class, + COMPLETED_STAY_TASK_ID, + COMPANY_ID + )).isEqualTo("DEMO-STAY-EXT-001"); + assertThat(jdbcTemplate.queryForList( + "SELECT evidence_type FROM task_evidence WHERE task_id = ? AND company_id = ? " + + "ORDER BY evidence_type", + String.class, + COMPLETED_STAY_TASK_ID, + COMPANY_ID + )).containsExactly("OFFICIAL_RESULT", "RECEIPT"); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM task_evidence WHERE company_id = ? AND file_reference IS NOT NULL", + Integer.class, + COMPANY_ID + )).isZero(); + + List> aiTrace = jdbcTemplate.queryForList( + "SELECT actor_type, action, target_id, change_summary FROM audit_event " + + "WHERE company_id = ? AND trace_id = ? ORDER BY created_at DESC", + COMPANY_ID, + "demo-compound-draft-flow" + ); + assertThat(aiTrace).hasSize(5) + .allMatch(event -> "AI_AGENT".equals(event.get("actor_type"))); + assertThat(aiTrace.stream().map(event -> (String) event.get("change_summary")).toList()) + .contains( + "복합 요청의 대상 근로자와 Case 맥락을 확인함", + "보유 문서를 비교하고 여권 사본 누락을 확인함", + "선행 재계약 결과를 기다리는 연장 후보를 준비함" + ); + } + private void assertTaskTimelineInvariants() { Map tasks = jdbcTemplate.query( "SELECT task_id, status, created_at FROM task WHERE company_id = ?", @@ -568,8 +655,10 @@ private Map>> seedSnapshot() { )); snapshot.put("approval_request", snapshotRows( "SELECT approval_request_id, company_id, target_task_version, target_content_revision, " - + "approved_task_version, target_fingerprint, requested_at, decided_at, " - + "invalidated_at, created_at, updated_at, version FROM approval_request " + + "approved_task_version, target_fingerprint, status, ai_snapshot_json, " + + "hr_snapshot_json, changed_fields_json, source_versions_json, requested_at, " + + "decided_at, decision_reason, invalidated_at, invalidation_reason, " + + "created_at, updated_at, version FROM approval_request " + "WHERE company_id IN (?, ?) ORDER BY company_id, approval_request_id" )); snapshot.put("task_transition_history", snapshotRows( @@ -577,21 +666,32 @@ private Map>> seedSnapshot() { + "WHERE company_id IN (?, ?) ORDER BY company_id, transition_id" )); snapshot.put("external_submission", snapshotRows( - "SELECT external_submission_id, company_id, submitted_at, created_at " + "SELECT external_submission_id, company_id, destination, safe_reference, " + + "submitted_at, created_at " + "FROM external_submission WHERE company_id IN (?, ?) " + "ORDER BY company_id, external_submission_id" )); snapshot.put("task_evidence", snapshotRows( - "SELECT evidence_id, company_id, recorded_at, created_at FROM task_evidence " + "SELECT evidence_id, company_id, evidence_type, file_reference, note, " + + "recorded_at, created_at FROM task_evidence " + "WHERE company_id IN (?, ?) ORDER BY company_id, evidence_id" )); snapshot.put("document_request_draft", snapshotRows( - "SELECT draft_id, company_id, created_at, updated_at, version " + "SELECT draft_id, company_id, language, message, review_status, " + + "created_at, updated_at, version " + "FROM document_request_draft WHERE company_id IN (?, ?) " + "ORDER BY company_id, draft_id" )); + snapshot.put("document_request_draft_type", snapshotRows( + "SELECT draft_type.draft_id, draft.company_id, draft_type.document_type " + + "FROM document_request_draft_type draft_type " + + "JOIN document_request_draft draft ON draft.draft_id = draft_type.draft_id " + + "WHERE draft.company_id IN (?, ?) " + + "ORDER BY draft.company_id, draft_type.draft_id, draft_type.document_type" + )); snapshot.put("audit_event", snapshotRows( - "SELECT audit_event_id, company_id, created_at FROM audit_event " + "SELECT audit_event_id, company_id, actor_type, action, target_type, target_id, " + + "trace_id, change_summary, created_at FROM audit_event " + "WHERE company_id IN (?, ?) ORDER BY company_id, audit_event_id" )); return snapshot; From c312859744642f7e38200bca42f4fec6a211b8b8 Mon Sep 17 00:00:00 2001 From: krestar Date: Tue, 4 Aug 2026 16:23:15 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat(seed):=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=8D=B0=EB=AA=A8=20=ED=8C=8C=EC=9D=BC=EA=B3=BC=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=97=B0=EA=B2=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 개인정보 없는 합성 PDF 계약서·접수증·승인 결과를 추가한다 - 예약된 stored_file과 근로자 문서 및 완료 증빙을 연결한다 - plain UUID 형식으로 evidence file reference를 저장한다 - 파일 크기와 SHA-256을 검증하고 충돌 시 덮어쓰지 않는다 - 파일과 DB의 부분 생성 상태를 재실행 시 복구한다 --- .../seed/DemoDocumentSeedCatalog.java | 6 +- .../seed/DemoEvidenceSeeder.java | 4 +- .../seed/DemoFileFixtureInstaller.java | 104 +++++++++++++++++ .../seed/DemoOperationalSeedCatalog.java | 64 ++++++++++- .../DemoOperationalSeedConfiguration.java | 21 +++- .../seed/DemoOperationalSeedRunner.java | 16 ++- .../seed/DemoOperationalSeedVerifier.java | 10 ++ .../seed/DemoStoredFileSeedCatalog.java | 72 ++++++++++++ .../seed/DemoStoredFileSeeder.java | 106 +++++++++++++++++ .../seed/DemoTaskArtifactSeedCatalog.java | 28 +++-- .../seed/DemoWorkerDocumentSeeder.java | 9 +- .../demo/files/demo-contract-renewal.pdf | Bin 0 -> 2733 bytes .../files/demo-stay-extension-receipt.pdf | Bin 0 -> 2774 bytes .../demo/files/demo-stay-extension-result.pdf | Bin 0 -> 2670 bytes .../seed/DemoAuthSeedIntegrationTest.java | 108 +++++++++++++++++- .../seed/DemoFileFixtureInstallerTest.java | 49 ++++++++ 16 files changed, 577 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeedCatalog.java create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeeder.java create mode 100644 src/main/resources/demo/files/demo-contract-renewal.pdf create mode 100644 src/main/resources/demo/files/demo-stay-extension-receipt.pdf create mode 100644 src/main/resources/demo/files/demo-stay-extension-result.pdf create mode 100644 src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java index 7bd32ef0..9075673f 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java @@ -236,7 +236,11 @@ private static DocumentSeed document( status, expiryDays, destination, - note + note, + "95000000-0000-0000-0000-000000000".equals(documentPrefix) + && documentNumber == 7 + ? DemoStoredFileSeedCatalog.CONTRACT_FILE_ID + : null ); } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java index ef361b21..23f36535 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java @@ -40,7 +40,7 @@ void seed(EvidenceSeed seed, DemoOperationalSeedContext context) { seed.taskId(), context.companyId(), seed.evidenceType(), - null, + seed.fileReference(), seed.note(), context.actorId(), recordedAt, @@ -85,7 +85,7 @@ private void verifyExisting( || !seed.taskId().equals(evidence.taskId()) || !context.companyId().equals(evidence.companyId()) || seed.evidenceType() != evidence.evidenceType() - || evidence.fileReference() != null + || !Objects.equals(seed.fileReference(), evidence.fileReference()) || !seed.note().equals(evidence.note()) || !context.actorId().equals(evidence.recordedBy()) || evidence.createdAt().isBefore(evidence.recordedAt())) { diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java new file mode 100644 index 00000000..4cce70f0 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java @@ -0,0 +1,104 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import org.springframework.core.io.ClassPathResource; + +final class DemoFileFixtureInstaller { + + private final Path rootDirectory; + + DemoFileFixtureInstaller(String localPath) { + this.rootDirectory = Path.of(localPath).toAbsolutePath().normalize(); + } + + long expectedSize(StoredFileSeed seed) { + return sourceBytes(seed).length; + } + + void install(StoredFileSeed seed) { + byte[] expected = sourceBytes(seed); + Path target = target(seed.storageKey()); + try { + Files.createDirectories(rootDirectory); + if (Files.exists(target)) { + verify(target, expected, seed); + return; + } + Path temporary = Files.createTempFile(rootDirectory, ".demo-fixture-", ".tmp"); + try { + Files.write(temporary, expected); + moveWithoutOverwrite(temporary, target); + } catch (FileAlreadyExistsException exception) { + verify(target, expected, seed); + } finally { + Files.deleteIfExists(temporary); + } + verify(target, expected, seed); + } catch (IOException exception) { + throw new UncheckedIOException("failed to install demo file fixture: " + seed.name(), exception); + } + } + + void verify(StoredFileSeed seed) { + byte[] expected = sourceBytes(seed); + Path target = target(seed.storageKey()); + try { + verify(target, expected, seed); + } catch (IOException exception) { + throw new UncheckedIOException("failed to verify demo file fixture: " + seed.name(), exception); + } + } + + private byte[] sourceBytes(StoredFileSeed seed) { + try (InputStream input = new ClassPathResource(seed.resourcePath()).getInputStream()) { + return input.readAllBytes(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read demo file fixture: " + seed.resourcePath(), exception); + } + } + + private Path target(String storageKey) { + Path target = rootDirectory.resolve(storageKey).normalize(); + if (!target.startsWith(rootDirectory)) { + throw new IllegalArgumentException("demo storage key must not escape the storage root"); + } + return target; + } + + private void moveWithoutOverwrite(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(source, target); + } + } + + private void verify(Path target, byte[] expected, StoredFileSeed seed) throws IOException { + if (!Files.isRegularFile(target) + || Files.size(target) != expected.length + || !Arrays.equals(sha256(Files.readAllBytes(target)), sha256(expected))) { + throw new IllegalStateException( + "a demo file storage key already contains different content: " + seed.storageKey() + ); + } + } + + private byte[] sha256(byte[] content) { + try { + return MessageDigest.getInstance("SHA-256").digest(content); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index ce093117..3e11f077 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -14,6 +14,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -29,6 +30,8 @@ final class DemoOperationalSeedCatalog { private final List demoTasks = DemoTaskSeedCatalog.demoTasks(); private final List testTasks = DemoTaskSeedCatalog.testTasks(); + private final List demoStoredFiles = + DemoStoredFileSeedCatalog.demoStoredFiles(demoTasks); private final List demoDocuments = DemoDocumentSeedCatalog.demoDocuments(); private final List testDocuments = DemoDocumentSeedCatalog.testDocuments(); private final List demoChecklists = @@ -90,6 +93,10 @@ List testTasks() { return testTasks; } + List demoStoredFiles() { + return demoStoredFiles; + } + List demoDocuments() { return demoDocuments; } @@ -133,6 +140,7 @@ List testAudits() { private void verifyDefinitions() { requireSize(demoTasks, 24, "Demo Company task"); requireSize(testTasks, 3, "Test Company task"); + requireSize(demoStoredFiles, 3, "Demo Company stored file"); requireSize(demoDocuments, 84, "Demo Company document"); requireSize(testDocuments, 8, "Test Company document"); requireSize(demoChecklists, 68, "Demo Company checklist item"); @@ -209,6 +217,8 @@ private void verifyDefinitions() { Stream.concat(demoTasks.stream(), testTasks.stream()).map(TaskSeed::taskId).toList(), "task" ); + requireUniqueIds(demoStoredFiles.stream().map(StoredFileSeed::storedFileId).toList(), + "stored file"); requireUniqueIds( Stream.concat(demoDocuments.stream(), testDocuments.stream()) .map(DocumentSeed::documentId) @@ -279,6 +289,44 @@ private void requireTaskReferences(List taskIds, String name) { private void verifyArtifactScenarios() { Map tasksById = demoTasks.stream() .collect(Collectors.toMap(TaskSeed::taskId, Function.identity())); + Map filesById = demoStoredFiles.stream() + .collect(Collectors.toMap(StoredFileSeed::storedFileId, Function.identity())); + for (StoredFileSeed file : demoStoredFiles) { + TaskSeed task = tasksById.get(file.taskId()); + if (task == null || !task.workerId().equals(file.workerId())) { + throw new IllegalStateException("stored file task and worker relation is invalid"); + } + } + Set linkedFileIds = new HashSet<>(); + for (DocumentSeed document : demoDocuments) { + if (document.fileId() == null) { + continue; + } + StoredFileSeed file = filesById.get(document.fileId()); + if (file == null || !document.workerId().equals(file.workerId())) { + throw new IllegalStateException("worker document stored file relation is invalid"); + } + linkedFileIds.add(document.fileId()); + } + for (EvidenceSeed item : demoEvidence) { + if (item.fileReference() == null) { + continue; + } + UUID fileId; + try { + fileId = UUID.fromString(item.fileReference()); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("evidence file reference must be a stored file UUID", exception); + } + StoredFileSeed file = filesById.get(fileId); + if (file == null || !item.taskId().equals(file.taskId())) { + throw new IllegalStateException("evidence stored file relation is invalid"); + } + linkedFileIds.add(fileId); + } + if (!linkedFileIds.equals(filesById.keySet())) { + throw new IllegalStateException("every demo stored file must be linked to a document or evidence"); + } boolean invalidSubmission = demoExternalSubmissions.stream() .map(ExternalSubmissionSeed::taskId) .map(tasksById::get) @@ -496,7 +544,20 @@ record DocumentSeed( SubmissionStatus submissionStatus, Integer expiryDays, String destination, - String note + String note, + UUID fileId + ) { + } + + record StoredFileSeed( + UUID storedFileId, + String name, + String mimeType, + String purpose, + UUID taskId, + UUID workerId, + String storageKey, + String resourcePath ) { } @@ -556,6 +617,7 @@ record EvidenceSeed( UUID evidenceId, UUID taskId, EvidenceType evidenceType, + String fileReference, String note, int hoursAgo ) { diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java index 2069e697..88c8c413 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -7,6 +7,9 @@ import com.fowoco.server.approval.application.SafeJsonService; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.file.application.port.FileStorage; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.infrastructure.LocalFileStorage; import com.fowoco.server.task.application.TaskContentCodec; import com.fowoco.server.task.application.port.TaskChecklistRepository; import com.fowoco.server.task.application.port.TaskRepository; @@ -14,6 +17,7 @@ import com.fowoco.server.worker.application.port.WorkerRepository; import jakarta.persistence.EntityManager; import java.time.Clock; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -31,6 +35,8 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( TaskChecklistRepository checklistRepository, ApprovalRequestRepository approvalRepository, SafeJsonService safeJsonService, + StoredFileRepository storedFileRepository, + FileStorage fileStorage, ExternalSubmissionRepository externalSubmissionRepository, EvidenceRepository evidenceRepository, DocumentRequestDraftRepository documentRequestDraftRepository, @@ -39,10 +45,21 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( AuditEventRepository auditEventRepository, EntityManager entityManager, JdbcTemplate jdbcTemplate, - Clock clock + Clock clock, + @Value("${app.file-storage.local-path}") String localFileStoragePath ) { + if (!(fileStorage instanceof LocalFileStorage)) { + throw new IllegalStateException( + "demo file fixtures currently require LocalFileStorage" + ); + } DemoOperationalSeedCatalog catalog = new DemoOperationalSeedCatalog(); DemoTaskSeeder taskSeeder = new DemoTaskSeeder(taskRepository, taskContentCodec); + DemoStoredFileSeeder storedFileSeeder = new DemoStoredFileSeeder( + storedFileRepository, + jdbcTemplate, + new DemoFileFixtureInstaller(localFileStoragePath) + ); DemoWorkerDocumentSeeder documentSeeder = new DemoWorkerDocumentSeeder(workerDocumentRepository); DemoTaskChecklistSeeder checklistSeeder = new DemoTaskChecklistSeeder(checklistRepository); DemoApprovalRequestSeeder approvalSeeder = new DemoApprovalRequestSeeder( @@ -76,6 +93,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( documentSeeder, checklistSeeder, approvalSeeder, + storedFileSeeder, transitionSeeder, externalSubmissionSeeder, evidenceSeeder, @@ -90,6 +108,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( documentSeeder, checklistSeeder, approvalSeeder, + storedFileSeeder, transitionSeeder, externalSubmissionSeeder, evidenceSeeder, diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java index b0483d4f..b659efba 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -8,6 +8,7 @@ import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; import java.time.Clock; @@ -34,6 +35,7 @@ class DemoOperationalSeedRunner implements ApplicationRunner { private final DemoWorkerDocumentSeeder documentSeeder; private final DemoTaskChecklistSeeder checklistSeeder; private final DemoApprovalRequestSeeder approvalSeeder; + private final DemoStoredFileSeeder storedFileSeeder; private final DemoTaskTransitionSeeder transitionSeeder; private final DemoExternalSubmissionSeeder externalSubmissionSeeder; private final DemoEvidenceSeeder evidenceSeeder; @@ -49,6 +51,7 @@ class DemoOperationalSeedRunner implements ApplicationRunner { DemoWorkerDocumentSeeder documentSeeder, DemoTaskChecklistSeeder checklistSeeder, DemoApprovalRequestSeeder approvalSeeder, + DemoStoredFileSeeder storedFileSeeder, DemoTaskTransitionSeeder transitionSeeder, DemoExternalSubmissionSeeder externalSubmissionSeeder, DemoEvidenceSeeder evidenceSeeder, @@ -63,6 +66,10 @@ class DemoOperationalSeedRunner implements ApplicationRunner { this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); this.checklistSeeder = Objects.requireNonNull(checklistSeeder, "checklistSeeder must not be null"); this.approvalSeeder = Objects.requireNonNull(approvalSeeder, "approvalSeeder must not be null"); + this.storedFileSeeder = Objects.requireNonNull( + storedFileSeeder, + "storedFileSeeder must not be null" + ); this.transitionSeeder = Objects.requireNonNull( transitionSeeder, "transitionSeeder must not be null" @@ -89,6 +96,7 @@ public void run(ApplicationArguments arguments) { DemoOperationalSeedContext testContext = DemoOperationalSeedContext.test(properties, today, now); seedDataset( catalog.demoTasks(), + catalog.demoStoredFiles(), catalog.demoDocuments(), catalog.demoChecklists(), catalog.demoApprovals(), @@ -101,6 +109,7 @@ public void run(ApplicationArguments arguments) { ); seedDataset( catalog.testTasks(), + List.of(), catalog.testDocuments(), List.of(), List.of(), @@ -112,12 +121,14 @@ public void run(ApplicationArguments arguments) { testContext ); LOGGER.info( - "demo_operational_seed ready demo_task_count={} demo_document_count={} " + "demo_operational_seed ready demo_task_count={} demo_stored_file_count={} " + + "demo_document_count={} " + "checklist_count={} approval_count={} transition_count={} " + "external_submission_count={} evidence_count={} request_draft_count={} " + "test_task_count={} test_document_count={} demo_audit_count={} " + "test_audit_count={}", catalog.demoTasks().size(), + catalog.demoStoredFiles().size(), catalog.demoDocuments().size(), catalog.demoChecklists().size(), catalog.demoApprovals().size(), @@ -134,6 +145,7 @@ public void run(ApplicationArguments arguments) { private void seedDataset( List tasks, + List storedFiles, List documents, List checklists, List approvals, @@ -145,6 +157,7 @@ private void seedDataset( DemoOperationalSeedContext context ) { tasks.forEach(seed -> taskSeeder.seed(seed, context)); + storedFiles.forEach(seed -> storedFileSeeder.seed(seed, context)); documents.forEach(seed -> documentSeeder.seed(seed, context)); checklists.forEach(seed -> checklistSeeder.seed(seed, context)); approvals.forEach(seed -> approvalSeeder.seed(seed, context)); @@ -155,6 +168,7 @@ private void seedDataset( audits.forEach(seed -> auditSeeder.seed(seed, context)); verifier.verify( tasks, + storedFiles, documents, checklists, approvals, diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java index 3caf7f2a..06b6ab4f 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java @@ -10,6 +10,7 @@ import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; import com.fowoco.server.task.application.port.TaskChecklistRepository; @@ -36,6 +37,7 @@ final class DemoOperationalSeedVerifier { private final DemoWorkerDocumentSeeder documentSeeder; private final DemoTaskChecklistSeeder checklistSeeder; private final DemoApprovalRequestSeeder approvalSeeder; + private final DemoStoredFileSeeder storedFileSeeder; private final DemoTaskTransitionSeeder transitionSeeder; private final DemoExternalSubmissionSeeder externalSubmissionSeeder; private final DemoEvidenceSeeder evidenceSeeder; @@ -52,6 +54,7 @@ final class DemoOperationalSeedVerifier { DemoWorkerDocumentSeeder documentSeeder, DemoTaskChecklistSeeder checklistSeeder, DemoApprovalRequestSeeder approvalSeeder, + DemoStoredFileSeeder storedFileSeeder, DemoTaskTransitionSeeder transitionSeeder, DemoExternalSubmissionSeeder externalSubmissionSeeder, DemoEvidenceSeeder evidenceSeeder, @@ -76,6 +79,10 @@ final class DemoOperationalSeedVerifier { this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); this.checklistSeeder = Objects.requireNonNull(checklistSeeder, "checklistSeeder must not be null"); this.approvalSeeder = Objects.requireNonNull(approvalSeeder, "approvalSeeder must not be null"); + this.storedFileSeeder = Objects.requireNonNull( + storedFileSeeder, + "storedFileSeeder must not be null" + ); this.transitionSeeder = Objects.requireNonNull( transitionSeeder, "transitionSeeder must not be null" @@ -94,6 +101,7 @@ final class DemoOperationalSeedVerifier { void verify( List tasks, + List storedFiles, List documents, List checklists, List approvals, @@ -105,6 +113,7 @@ void verify( DemoOperationalSeedContext context ) { verifyUniqueIds(tasks.stream().map(TaskSeed::taskId).toList(), "task"); + verifyUniqueIds(storedFiles.stream().map(StoredFileSeed::storedFileId).toList(), "stored file"); verifyUniqueIds(documents.stream().map(DocumentSeed::documentId).toList(), "document"); verifyUniqueIds(checklists.stream().map(ChecklistSeed::checklistItemId).toList(), "checklist item"); verifyUniqueIds(approvals.stream().map(ApprovalSeed::approvalRequestId).toList(), "approval request"); @@ -117,6 +126,7 @@ void verify( verifyUniqueIds(audits.stream().map(AuditSeed::auditEventId).toList(), "audit event"); verifyWorkersExist(tasks, documents, context); tasks.forEach(seed -> verifyTask(seed, context)); + storedFiles.forEach(seed -> storedFileSeeder.verifyExisting(seed, context)); documents.forEach(seed -> verifyDocument(seed, context)); checklists.forEach(seed -> verifyChecklist(seed, context)); approvals.forEach(seed -> verifyApproval(seed, context)); diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeedCatalog.java new file mode 100644 index 00000000..d7bdcc30 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeedCatalog.java @@ -0,0 +1,72 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import java.util.List; +import java.util.UUID; + +final class DemoStoredFileSeedCatalog { + + static final UUID CONTRACT_FILE_ID = demoUuid(1); + static final UUID STAY_RECEIPT_FILE_ID = demoUuid(2); + static final UUID STAY_RESULT_FILE_ID = demoUuid(3); + + private DemoStoredFileSeedCatalog() { + } + + static List demoStoredFiles(List tasks) { + return List.of( + storedFile( + CONTRACT_FILE_ID, + tasks, + 5, + "demo-contract-renewal.pdf", + "demo/files/demo-contract-renewal.pdf", + "DEMO_CONTRACT_RENEWAL" + ), + storedFile( + STAY_RECEIPT_FILE_ID, + tasks, + 20, + "demo-stay-extension-receipt.pdf", + "demo/files/demo-stay-extension-receipt.pdf", + "DEMO_STAY_EXTENSION_RECEIPT" + ), + storedFile( + STAY_RESULT_FILE_ID, + tasks, + 20, + "demo-stay-extension-result.pdf", + "demo/files/demo-stay-extension-result.pdf", + "DEMO_STAY_EXTENSION_RESULT" + ) + ); + } + + private static StoredFileSeed storedFile( + UUID storedFileId, + List tasks, + int taskNumber, + String name, + String resourcePath, + String purpose + ) { + TaskSeed task = tasks.get(taskNumber - 1); + return new StoredFileSeed( + storedFileId, + name, + "application/pdf", + purpose, + task.taskId(), + task.workerId(), + storedFileId.toString(), + resourcePath + ); + } + + private static UUID demoUuid(int number) { + return UUID.fromString( + "94800000-0000-0000-0000-000000000%03d".formatted(number) + ); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeeder.java new file mode 100644 index 00000000..1a5aadca --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeeder.java @@ -0,0 +1,106 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.ScanStatus; +import com.fowoco.server.file.domain.StoredFile; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoStoredFileSeeder { + + private final StoredFileRepository repository; + private final JdbcTemplate jdbcTemplate; + private final DemoFileFixtureInstaller fixtureInstaller; + + DemoStoredFileSeeder( + StoredFileRepository repository, + JdbcTemplate jdbcTemplate, + DemoFileFixtureInstaller fixtureInstaller + ) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + this.fixtureInstaller = Objects.requireNonNull( + fixtureInstaller, + "fixtureInstaller must not be null" + ); + } + + void seed(StoredFileSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.storedFileId()); + if (!existing.isEmpty()) { + verifyMetadata(existing.get(0), seed, context); + fixtureInstaller.install(seed); + return; + } + fixtureInstaller.install(seed); + repository.insert(StoredFile.create( + seed.storedFileId(), + context.companyId(), + seed.name(), + seed.mimeType(), + fixtureInstaller.expectedSize(seed), + seed.purpose(), + seed.taskId(), + seed.workerId(), + seed.storageKey(), + context.now() + )); + } + + void verifyExisting(StoredFileSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.storedFileId()); + if (existing.size() != 1) { + throw new IllegalStateException("a reserved demo stored file was not seeded"); + } + verifyMetadata(existing.get(0), seed, context); + fixtureInstaller.verify(seed); + } + + private List find(UUID storedFileId) { + return jdbcTemplate.query( + "SELECT stored_file_id, company_id, name, mime_type, size, purpose, task_id, " + + "worker_id, storage_key, scan_status, verified, created_at " + + "FROM stored_file WHERE stored_file_id = ?", + (resultSet, rowNumber) -> new StoredFile( + resultSet.getObject("stored_file_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getString("name"), + resultSet.getString("mime_type"), + resultSet.getLong("size"), + resultSet.getString("purpose"), + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("worker_id", UUID.class), + resultSet.getString("storage_key"), + ScanStatus.valueOf(resultSet.getString("scan_status")), + resultSet.getBoolean("verified"), + resultSet.getTimestamp("created_at").toInstant() + ), + storedFileId + ); + } + + private void verifyMetadata( + StoredFile file, + StoredFileSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.storedFileId().equals(file.storedFileId()) + || !context.companyId().equals(file.companyId()) + || !seed.name().equals(file.name()) + || !seed.mimeType().equals(file.mimeType()) + || fixtureInstaller.expectedSize(seed) != file.size() + || !seed.purpose().equals(file.purpose()) + || !Objects.equals(seed.taskId(), file.taskId()) + || !Objects.equals(seed.workerId(), file.workerId()) + || !seed.storageKey().equals(file.storageKey()) + || file.scanStatus() != ScanStatus.NOT_SCANNED + || file.verified()) { + throw new IllegalStateException( + "a reserved demo stored file id already belongs to different file data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java index 29bd341f..62169fbc 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java @@ -27,18 +27,28 @@ static List demoExternalSubmissions(List tasks static List demoEvidence(List tasks) { return List.of( - evidence(1, tasks, 5, EvidenceType.DOCUMENT, "서명된 재계약 문서 확인", 48), - evidence(2, tasks, 5, EvidenceType.HR_CONFIRMATION, "계약 갱신 처리 완료 확인", 24), + evidence(1, tasks, 5, EvidenceType.DOCUMENT, null, + "서명된 재계약 문서 확인", 48), + evidence(2, tasks, 5, EvidenceType.HR_CONFIRMATION, null, + "계약 갱신 처리 완료 확인", 24), evidence(3, tasks, 20, EvidenceType.RECEIPT, + DemoStoredFileSeedCatalog.STAY_RECEIPT_FILE_ID.toString(), "DEMO-STAY-EXT-001 체류기간 연장 접수 확인", 48), evidence(4, tasks, 20, EvidenceType.OFFICIAL_RESULT, + DemoStoredFileSeedCatalog.STAY_RESULT_FILE_ID.toString(), "DEMO-STAY-EXT-001 체류기간 연장 승인 결과 확인", 12), - evidence(5, tasks, 21, EvidenceType.DOCUMENT, "갱신 계약서 서명 상태 확인", 72), - evidence(6, tasks, 21, EvidenceType.HR_CONFIRMATION, "재계약 인사 반영 완료", 24), - evidence(7, tasks, 22, EvidenceType.RECEIPT, "고용허가기간 연장 접수 확인", 72), - evidence(8, tasks, 22, EvidenceType.OFFICIAL_RESULT, "고용허가기간 연장 결과 확인", 24), - evidence(9, tasks, 23, EvidenceType.RECEIPT, "체류 연장 신청 접수 확인", 96), - evidence(10, tasks, 23, EvidenceType.OFFICIAL_RESULT, "체류 연장 최종 결과 확인", 24) + evidence(5, tasks, 21, EvidenceType.DOCUMENT, null, + "갱신 계약서 서명 상태 확인", 72), + evidence(6, tasks, 21, EvidenceType.HR_CONFIRMATION, null, + "재계약 인사 반영 완료", 24), + evidence(7, tasks, 22, EvidenceType.RECEIPT, null, + "고용허가기간 연장 접수 확인", 72), + evidence(8, tasks, 22, EvidenceType.OFFICIAL_RESULT, null, + "고용허가기간 연장 결과 확인", 24), + evidence(9, tasks, 23, EvidenceType.RECEIPT, null, + "체류 연장 신청 접수 확인", 96), + evidence(10, tasks, 23, EvidenceType.OFFICIAL_RESULT, null, + "체류 연장 최종 결과 확인", 24) ); } @@ -79,6 +89,7 @@ private static EvidenceSeed evidence( List tasks, int taskNumber, EvidenceType evidenceType, + String fileReference, String note, int hoursAgo ) { @@ -86,6 +97,7 @@ private static EvidenceSeed evidence( demoUuid("94600000-0000-0000-0000-000000000", number), tasks.get(taskNumber - 1).taskId(), evidenceType, + fileReference, note, hoursAgo ); diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java index e1864ccd..ad09664f 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java @@ -32,7 +32,7 @@ void seed(DocumentSeed seed, DemoOperationalSeedContext context) { verifyExisting(existing.get(), seed, context); return; } - workerDocumentRepository.insert(WorkerDocument.create( + workerDocumentRepository.insert(new WorkerDocument( seed.documentId(), seed.workerId(), context.companyId(), @@ -41,7 +41,10 @@ void seed(DocumentSeed seed, DemoOperationalSeedContext context) { expiryDate, seed.destination(), seed.note(), - context.now() + seed.fileId(), + context.now(), + context.now(), + 0L )); } @@ -57,7 +60,7 @@ void verifyExisting( || seed.submissionStatus() != document.submissionStatus() || !Objects.equals(seed.destination(), document.destination()) || !Objects.equals(seed.note(), document.note()) - || document.fileId() != null) { + || !Objects.equals(seed.fileId(), document.fileId())) { throw new IllegalStateException( "a reserved demo worker document id already belongs to different document data" ); diff --git a/src/main/resources/demo/files/demo-contract-renewal.pdf b/src/main/resources/demo/files/demo-contract-renewal.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5931f27431b3ba0b9fdfc10cba7f91f93abe823a GIT binary patch literal 2733 zcma);*_xtA7J%=43epu+WDyihH&9X2B^7bkM1%lw0cDx4nu~dX>ASw4XPqFcvO2%2 z`KFr-gE(}87zCeWin`Qy=``G$E_K`gVf)KJBzzu}v zgyLz=yUR!(Lq5<=pz$YkEL8nK7cGrk1M;2wVC*{ZB@+1`iGE|>(gN-3TAzHBKKT&B z;N*G;syVLn_2z~?h7c_eXo2lI?I<&}U7<<3L~pFrrw2z+VeMN9&_o<$D81~Kvmf0ETu+&4xzXRPiZD7E7 ziXB{|l)=qKqK)=6s3XHu@|$)=1j_>;Iv|mvr`_-H+z^s>^*{5C_V|Nu%MIW6mKfOZ zd$veaMsidS-@v}J{)9Cus1H-gz6S7{U?B*zR=>}&Z2%8XbP}!*>h}rsGz5+noDix= zl_F1m0MXxKT`jmhTcf4EOjlkjGthQU<|(@#FLve%eK0zWqj^c))i3BA3^&4N$_tHK z57V$ppzbqsoq1iBlN3;z(tSqzL&7c@)tZkd=KD+OmGg?de4>ZRK6{ws@dKS7?+KRc zTiZ~n1rc_d$oqFxy`y`j#q z)LFIPR?^nVb(44QjkaHyIeYK3Sm8CxRK~6RK^ok8jDE1p+O-kKUM@}X_R`Pv=c7S& zSsuLjE2;G=SM}8SjjD-K+OFImm_HhB*9(3!Q71UncH%zjakVv}eafPhGR3UTXB7n=S_%<+W{cDWclTWFMBb@-)6ac%Co0V9HEWE=z~|TuFQI z>F0uI32fuM=u#co-QH?Ef2d|i5uINiU8b$by$iUngr`L!(LFw{?N)rxGShx1acXJ1 zmC>{;_K>|VI}VRdn_5$Ov=aGMZe4!t_4Kq<%=_Zu&{sE9kiB-jTEjByXPMVRe`!xU zYm9L0(4K1(abe!l@olQm+Fo;uoi!I$G=FIAU1fYztlWJga`fBF<(fLU<Q&c zofhQV*d~SgZd;?DtN>jNm-cpr*Fus(WrsLU&gDi!+9_kB#hmsRN!YJ@*i+>o$bDbV zcGJZ)is2<`DJ+IQf!UR5W_mPt01%J2;>&2a31_J+VT)=n#T++@BSp|lOzEBQxzhK0 z)OF09%Cpvr5#g@K3r=$+@|%1zxdzGbYH;UPzCIeyh5`W&fhscj$~6>x8XGvH^u0NH zSv4%T^SD&h)bP2wT5WGsYMLcRy~^Iio3x4FIE&LulwIu*&%4QVD!IwQ%(m9eO1dO> zlk>)M@1=@sYaOtIyKC>zV{*79v|d==wQYKEp%Q}HJ9%Vymrm70#@ZyP6}93sheb^| zhFL;ygjRZvGo!KUSgp!M-ql3id$XI5;nPHHxvM-+7u~^Fp3iE}!ecQh2k6k$xkB5u zy3FVzKOK{Wj^6W3d`sZ{_@2n_=pF$UtF+)0^U5MCIEn1VHMn$=OQe8%sMo@Dfx7f5 zb4U5DwmJ@m#$Z`VH}QMXu?~&)=42nhcG0~o=4lmBcB8cIc5$&wE0`ko1`_M54Km+< z_Enoa2D_qqLHXBV|-n2`zupUzd#r z{)Bsn$r(HAh9*puf+L7Uy1QvMJ>eK17DpaRyjIiK-e?|bnEgDg+c+@*;~Ayxn*Cs1 zIfX6lUv=2EeE+xn-oJeDfEgnZK1Tj{kP^!lS;Rzs$V%|0jvmDMA)`px`TQiKN&XjE zk^a?|rQx0MlP$~t>W?eKJNG9&9%lAK25&0+3cU9&t=~ps+bUHuHIm`zB5Pg) a`=0<(e!s0WFL=jJ7a2O1N_AeP*na_$Z4==D literal 0 HcmV?d00001 diff --git a/src/main/resources/demo/files/demo-stay-extension-receipt.pdf b/src/main/resources/demo/files/demo-stay-extension-receipt.pdf new file mode 100644 index 0000000000000000000000000000000000000000..adc9d1859420d3954657679aa2b4249ad0feedb2 GIT binary patch literal 2774 zcma);*_xtA7J%=43ep940TICs1r_%N5cf=F2^1Ajz@DnPm=~D7>-%}u39>4y^Q)R~ zy16ij6K9J*PRL2jJ3^i)lp={ge*gD>5DDB}FBq#D(gP0g)BqR=)IbbZf7-wakd%7| zj_*D_9i$@!f+MbWiPY;6OdyaVoJt5L6cI9fi?D12I7X-tu6!u+st*Jgy2AS4_8SN+ zT}802qq~OXoDghfIh^BLe+s6m4WLs3!Q`hFe7(cT0{+5zIKUO?>r<#O^#2K!=iQs} zcj6WQX1vm;cx{*pgybVkxPJtj0K-yw_Y1Ova~z>6RG~;yWrQga1%|0G$SZ=)RWIa9 zf3uLlH?}_V&NRdnLWaKPo~z3sfUdCcK(Gb)0uc^u^#u6uWBdC!Kwcpb1e4qV9w0Qw z7M|+7yA0(KwcV$mMzFRA)WC9`PMBG$Q!V1fGS1*7f+854$>NnHjwe6#el{L~um5m8 z!$(q317`PEP9QVmsRNk8pLHF=8(W&EdS8(|vj0@i`*ix94J@+@&|#@R9sdq=%d~(2 z<1MV<7N!hr7m)+?E7JV}58H=(fwx8n);$Q7JoSEucLo#GoBx?@c)lNO+wSxIT(OY_ z_p(F65|YAN_y+Zz^CzTXF@2Z{)-8bhfknW{?ZJSdIsjgr@D|)4(C-uIX$+iGa7GBS zOoX2N0AjZzJvDI9vzegtDBpd}HK^K|&5q>Ug*k3Fl~ZZvB@)V=z=m9&(PjD}jm$|k z9`$4|yY$PV}>oJH|)(2JfKFia1nS!)o$mG(}~`90E zjoULUNP0H9?XKJ4x*&?OR?9| zR2x;OJ!iUZtvbnj3wO-DBn&Z`<*7hu%6??uM6*y~X7fOX?i!PRmt7CI(ZKy{8_?miT_qFU(nv#2YP?fiI zD!wQROaJY<_3Oj=QooAZI;(m8Zn;8F9G9Xuo_QS&S?}1|bb~{yIkxYkA>*-??r_D# zw*59ikC~WpXy=}co6lbs?90~lFCt4N=?h-k)t7Zy&zA4=wJ>@Z3G~uQR~|vUHrCxq zYg8Q+dP|~UuMSa9xtE+_&x~i=+6(y z=@;!@vNxTfI4MsXV^SPe`iE8gvg3(!il3k|C7%1&Ugua!nDWlA%ZQA{8hdNWF?xJq&t6us?P;Mn-&v| zNv8gI<|e1T=B;pVlr^2i`qnfa=i=41g{eX&U*2l7XfSQ1-L%s}?G|&WH|%{@zBcya z&c|nNs`b_sM65}O_4D+;E&7E7BhMv%xzmVs3%lEjwvQ!umN*=**!7y=*qCwdN4MhT zKKJ64TBqt{qbFy@wlG!SHOf_~Nf%F#K|oi=iA2xX6tw&Be%&v7Yh1bAv`K|G;a}vVfk_HFOsnpOjf$b_V!Cmpgnv2>!*w_U8Lv>-R?I ziwDdIj#MJ}j|VAJ6iFc_@HIn$$ zRi6HLSDDWvh6THepZd!PHgHTA3401D0^1okK*AmczRT7>0i^u?X;Z!69ea^v$Vej5 Ioe7cu0@foVPyhe` literal 0 HcmV?d00001 diff --git a/src/main/resources/demo/files/demo-stay-extension-result.pdf b/src/main/resources/demo/files/demo-stay-extension-result.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e85c4499748613b31b3245476627fc6db6c156f7 GIT binary patch literal 2670 zcma);*_xtA7J%=4ick}UbYT$?)Wi*Uao-gcBtTq15qqlUVqReSuJ7krC&;R-&aY~| z>E;3=PMjs;j}yef268<`r`Ztp$M66C4;aCl>-k+x2My>zPxGMxkOnZ^!}AIqAK>l{ zIuG~h9Uu{+0R>$B6e<)#WSs^K>W%OLO?AgdnKeA{*8U}m_SVJIlL{s=PuLF2pTlX~YD@cLOr+nTJ#5)_5W+!++ss3~xH+Zm38yYCy z#P%=2l;PC{V_*9UYG2T1KhRo0`F8W%-?R%lK9A?c50pLac0*@}tZSFYXU4%ke=x4P z&-eW$J2qO=8U(2{3UcBb+;`re&<08MVOqB@K3W$_hVr=DY6+YS(E$oh!v%r;-a($a z(6Rg@pcz&SJUx7b{~Bs&zJpmqT-e3(oz);dbdPoZIad8x*s-(Gjn-jpzAzP{%RN`? zR-HpeWhxuK+@Y##YPz7D-TL)(Z4_gM;6BzvcV>@_v&rvYWP;A#*D;b1qfx8D&GdupdFa)D>x_L@AW*-P!%sj@_2)a2R}o;*~#FFiZI!TQ&i7h9aM>V48m)HCtn zdVObDNeedy3uTW@*2{)>I$b08_|etRBI#M=Xq!`guQ;C+&Zl7`6}`u)y3QZf&T~4S z6)M7Hf%O`(oYB57hdzI3R#pj(w+eZDr3-5JZCk*b*@*E<>^G3_XQy?&>9ohwS&mdn!aYK*^l-wrn3RIa zdTN`2>5Oq`OJ^xfnx}n69(S&#JDpL^>Ndkj8wyuOtC;fUOIr1{7hXU~qB`YF;%$3f z=A}8o2+i^0!fmJU9N%uuVz+iqZP&Ebr}6mcvO7~nVo|AG%&djI+Hiy{#}B_{y<+BvfZY$=gn~BByrxM3cAihE<}+`bsu4P?>Hz z&V?7rx}6rZrni3VyX?!P+#uH3^p$m4ym7M%s=1G!mN1qQv~hOUoT5Rpa#yW(XF?3k zWMZ&uJ_x&^^!Dv-Jxl3_+{V5nU)aX%m817<^&EHlRWjY(S=7GNPl|7Qvb3G;D?5Bs zJYv)2;xvSLZM<7Glf!02C5frfpR3JgYs(J~RO&S@Pc8*qBE{sJI#G5i7M(>{()wgP8!xXECHLIRmD(sLEKB(eH0d)tu=W;ic-3VTp$fO_E56<9ljbzy z%T_9t<~FNad$cn86`tjC8qeCIYh|R>6yG*$^LQjYT)#CnE$kuun5B3X`{#u*0;T zpvP|XETz4AM1&aU28k(Fj6_b+W_z?Sb^XPeV6ozW7|4^Ez%u14pE|<9{WU90_j%17 zt&3qn!Qk8EUlJ-izW?gJ7a(6eXoe^thNvG8;5jbC0TcX?@#v}z9^m|t(G)6Oev&bi z_=_yV{A$ZF=!gE3Ehql!PY_UX@ROdH`4q$VH2VU*_cE&`gV4Ibn4%63fp@jc)y)>b?-!a!eNlcl{`Y$_&>BLG6Ir aWBZ>FQGUN_G|zv>&M;XqgkiOQJ@j93&it DEMO_FILE_STORAGE_PATH.toString()); + } + @Test void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throws Exception { assertAccountsAndPasswords(); @@ -137,7 +158,9 @@ void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throw assertRelativeDatesAndSafeData(); assertCompoundDraftScenario(); assertReviewApprovalAndSubmissionFixtures(); + assertStoredFileFixtures(); assertTaskTimelineInvariants(); + assertRepairsPartialStoredFileState(); Map demoCountsBeforeRerun = counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet()); Map testCountsBeforeRerun = counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet()); @@ -311,7 +334,11 @@ private void assertRelativeDatesAndSafeData() { )).isPositive(); assertThat(countWhere("worker_document", "expiry_date IS NULL", COMPANY_ID)).isPositive(); - assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM stored_file", Integer.class)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM stored_file WHERE company_id = ? AND size > 0", + Integer.class, + COMPANY_ID + )).isEqualTo(3); List businessData = jdbcTemplate.query( "SELECT business_data_json FROM task WHERE company_id = ?", (resultSet, rowNumber) -> resultSet.getString(1).toLowerCase(), @@ -455,7 +482,14 @@ private void assertReviewApprovalAndSubmissionFixtures() { "SELECT COUNT(*) FROM task_evidence WHERE company_id = ? AND file_reference IS NOT NULL", Integer.class, COMPANY_ID - )).isZero(); + )).isEqualTo(2); + assertThat(jdbcTemplate.queryForList( + "SELECT file_reference FROM task_evidence WHERE task_id = ? " + + "AND company_id = ? AND file_reference IS NOT NULL ORDER BY evidence_type", + String.class, + COMPLETED_STAY_TASK_ID, + COMPANY_ID + )).containsExactly(STAY_RESULT_FILE_ID.toString(), STAY_RECEIPT_FILE_ID.toString()); List> aiTrace = jdbcTemplate.queryForList( "SELECT actor_type, action, target_id, change_summary FROM audit_event " @@ -473,6 +507,67 @@ private void assertReviewApprovalAndSubmissionFixtures() { ); } + private void assertStoredFileFixtures() throws Exception { + List> files = jdbcTemplate.queryForList( + "SELECT stored_file_id, name, mime_type, size, purpose, task_id, worker_id, " + + "storage_key, scan_status, verified FROM stored_file " + + "WHERE company_id = ? ORDER BY stored_file_id", + COMPANY_ID + ); + assertThat(files).hasSize(3) + .allMatch(file -> "application/pdf".equals(file.get("mime_type"))) + .allMatch(file -> "NOT_SCANNED".equals(file.get("scan_status"))) + .allMatch(file -> Boolean.FALSE.equals(file.get("verified"))); + + Map resources = Map.of( + CONTRACT_FILE_ID, "demo/files/demo-contract-renewal.pdf", + STAY_RECEIPT_FILE_ID, "demo/files/demo-stay-extension-receipt.pdf", + STAY_RESULT_FILE_ID, "demo/files/demo-stay-extension-result.pdf" + ); + for (Map file : files) { + UUID fileId = (UUID) file.get("stored_file_id"); + byte[] expected; + try (var input = new ClassPathResource(resources.get(fileId)).getInputStream()) { + expected = input.readAllBytes(); + } + Path actualPath = DEMO_FILE_STORAGE_PATH.resolve((String) file.get("storage_key")); + assertThat(Files.readAllBytes(actualPath)).isEqualTo(expected); + assertThat(((Number) file.get("size")).longValue()).isEqualTo(expected.length); + } + + assertThat(jdbcTemplate.queryForObject( + "SELECT file_id FROM worker_document WHERE worker_document_id = ? AND company_id = ?", + UUID.class, + UUID.fromString("95000000-0000-0000-0000-000000000007"), + COMPANY_ID + )).isEqualTo(CONTRACT_FILE_ID); + } + + private void assertRepairsPartialStoredFileState() throws Exception { + DefaultApplicationArguments arguments = new DefaultApplicationArguments(new String[0]); + + Files.delete(DEMO_FILE_STORAGE_PATH.resolve(STAY_RESULT_FILE_ID.toString())); + demoOperationalSeedRunner.run(arguments); + assertThat(Files.isRegularFile( + DEMO_FILE_STORAGE_PATH.resolve(STAY_RESULT_FILE_ID.toString()) + )).isTrue(); + + jdbcTemplate.update( + "DELETE FROM stored_file WHERE stored_file_id = ? AND company_id = ?", + STAY_RECEIPT_FILE_ID, + COMPANY_ID + ); + demoOperationalSeedRunner.run(arguments); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM stored_file WHERE stored_file_id = ? AND company_id = ?", + Integer.class, + STAY_RECEIPT_FILE_ID, + COMPANY_ID + )).isEqualTo(1); + + assertStoredFileFixtures(); + } + private void assertTaskTimelineInvariants() { Map tasks = jdbcTemplate.query( "SELECT task_id, status, created_at FROM task WHERE company_id = ?", @@ -639,10 +734,17 @@ private Map>> seedSnapshot() { + "WHERE company_id IN (?, ?) ORDER BY company_id, worker_id" )); snapshot.put("worker_document", snapshotRows( - "SELECT worker_document_id, company_id, expiry_date, created_at, updated_at, version " + "SELECT worker_document_id, company_id, expiry_date, file_id, " + + "created_at, updated_at, version " + "FROM worker_document WHERE company_id IN (?, ?) " + "ORDER BY company_id, worker_document_id" )); + snapshot.put("stored_file", snapshotRows( + "SELECT stored_file_id, company_id, name, mime_type, size, purpose, task_id, " + + "worker_id, storage_key, scan_status, verified, created_at " + + "FROM stored_file WHERE company_id IN (?, ?) " + + "ORDER BY company_id, stored_file_id" + )); snapshot.put("task", snapshotRows( "SELECT task_id, company_id, due_date, business_data_json, critical_fingerprint, " + "content_revision, created_at, updated_at, version FROM task " diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java new file mode 100644 index 00000000..16d8933c --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java @@ -0,0 +1,49 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.ClassPathResource; + +class DemoFileFixtureInstallerTest { + + @TempDir + Path storageRoot; + + @Test + void installsReusesAndRejectsConflictingFixtureContent() throws Exception { + UUID fileId = UUID.fromString("94800000-0000-0000-0000-000000000001"); + StoredFileSeed seed = new StoredFileSeed( + fileId, + "demo-contract-renewal.pdf", + "application/pdf", + "DEMO_CONTRACT_RENEWAL", + UUID.randomUUID(), + UUID.randomUUID(), + fileId.toString(), + "demo/files/demo-contract-renewal.pdf" + ); + DemoFileFixtureInstaller installer = new DemoFileFixtureInstaller(storageRoot.toString()); + byte[] expected; + try (var input = new ClassPathResource(seed.resourcePath()).getInputStream()) { + expected = input.readAllBytes(); + } + + installer.install(seed); + installer.install(seed); + + Path installed = storageRoot.resolve(seed.storageKey()); + assertThat(Files.readAllBytes(installed)).isEqualTo(expected); + + Files.writeString(installed, "conflicting fixture"); + assertThatThrownBy(() -> installer.install(seed)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(seed.storageKey()); + } +} From 381e4da0f591ddcf83ad283f550e8270394740ae Mon Sep 17 00:00:00 2001 From: krestar Date: Tue, 4 Aug 2026 16:44:12 +0900 Subject: [PATCH 13/15] =?UTF-8?q?fix(seed):=20=EB=8D=B0=EB=AA=A8=20?= =?UTF-8?q?=EA=B7=BC=EB=A1=9C=EC=9E=90=20=EC=96=B8=EC=96=B4=EB=A5=BC=20AI?= =?UTF-8?q?=ED=8C=80=20=EC=A7=80=EC=9B=90=20=EB=B2=94=EC=9C=84=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EC=A0=95=ED=95=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 미지원 선호 언어를 AI팀과 합의된 locale로 교체한다 - 데모 근로자 28명에 지원 locale 15개를 모두 포함한다(locale 최소 1개 이상 보장) - 국적과 선호 언어에 맞게 일부 근로자 이름을 조정한다(데모 근로자 01~05 활용) - 기존 UUID와 상태·기간·식별 관계는 유지한다 --- .../seed/DemoWorkerSeedCatalog.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java index bd433eb5..e98f1712 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java @@ -7,34 +7,34 @@ final class DemoWorkerSeedCatalog { private static final List DEMO_WORKERS = List.of( - activeWorker("92000000-0000-0000-0000-000000000001", "데모 근로자 01", "VN", "vi", 30, 180), - activeWorker("92000000-0000-0000-0000-000000000002", "데모 근로자 02", "KH", "km", 60, 210), - activeWorker("92000000-0000-0000-0000-000000000003", "데모 근로자 03", "NP", "ne", 90, 240), - activeWorker("92000000-0000-0000-0000-000000000004", "데모 근로자 04", "ID", "id", 120, 270), - activeWorker("92000000-0000-0000-0000-000000000005", "데모 근로자 05", "PH", "en", 180, 365), + activeWorker("92000000-0000-0000-0000-000000000001", "리웨이", "CN", "zh-Hans", 30, 180), + activeWorker("92000000-0000-0000-0000-000000000002", "속 체아", "KH", "km", 60, 210), + activeWorker("92000000-0000-0000-0000-000000000003", "아르준 타파", "NP", "en", 90, 240), + activeWorker("92000000-0000-0000-0000-000000000004", "부디 산토소", "ID", "id", 120, 270), + activeWorker("92000000-0000-0000-0000-000000000005", "마크 레예스", "PH", "en", 180, 365), activeWorker("92000000-0000-0000-0000-000000000006", "응웬반A", "VN", "vi", 45, 180), activeWorker("92000000-0000-0000-0000-000000000007", "아디 수르야", "ID", "id", 365, 365), - activeWorker("92000000-0000-0000-0000-000000000008", "소팔 타망", "NP", "ne", 21, 120), + activeWorker("92000000-0000-0000-0000-000000000008", "바트 에르덴", "MN", "mn", 21, 120), activeWorker("92000000-0000-0000-0000-000000000009", "라니 위자야", "ID", "id", 7, 90), - activeWorker("92000000-0000-0000-0000-000000000010", "민 아웅", "MM", "my", 62, 20), + activeWorker("92000000-0000-0000-0000-000000000010", "민 아웅", "MM", "en", 62, 20), activeWorker("92000000-0000-0000-0000-000000000011", "파티마 누르", "ID", "id", 35, 150), activeWorker("92000000-0000-0000-0000-000000000012", "트란 티 마이", "VN", "vi", 3, 45), activeWorker("92000000-0000-0000-0000-000000000013", "쩐 꾸옥 바오", "VN", "vi", 5, 75), - onLeaveWorker("92000000-0000-0000-0000-000000000014", "분미 찬사몽", "LA", "lo", 7, 100), + onLeaveWorker("92000000-0000-0000-0000-000000000014", "아이다나 베크", "KG", "ky", 7, 100), activeWorker("92000000-0000-0000-0000-000000000015", "찬다라 소쿤", "KH", "km", 21, 135), activeWorker("92000000-0000-0000-0000-000000000016", "니말 페레라", "LK", "si", 35, 180), - activeWorker("92000000-0000-0000-0000-000000000017", "라훌 쿠마르", "IN", "hi", 62, 210), + activeWorker("92000000-0000-0000-0000-000000000017", "알리 칸", "PK", "ur", 62, 210), activeWorker("92000000-0000-0000-0000-000000000018", "모하메드 라힘", "BD", "bn", 75, 240), activeWorker("92000000-0000-0000-0000-000000000019", "누르 아지자", "ID", "id", 89, 270), - activeWorker("92000000-0000-0000-0000-000000000020", "아웅 쪼 민", "MM", "my", 91, 300), - onLeaveWorker("92000000-0000-0000-0000-000000000021", "수리야 프라사드", "LK", "ta", 150, 330), - activeWorker("92000000-0000-0000-0000-000000000022", "프라딥 구룽", "NP", "ne", 240, 365), + activeWorker("92000000-0000-0000-0000-000000000020", "아지즈 라히모프", "UZ", "uz", 91, 300), + onLeaveWorker("92000000-0000-0000-0000-000000000021", "알렉세이 이바노프", "RU", "ru", 150, 330), + activeWorker("92000000-0000-0000-0000-000000000022", "마리아 산토스", "PH", "fil", 240, 365), activeWorker("92000000-0000-0000-0000-000000000023", "솜차이 차이야", "TH", "th", 365, 400), activeWorker("92000000-0000-0000-0000-000000000024", "응우옌 티 란", "VN", "vi", 6, 60), onLeaveWorker("92000000-0000-0000-0000-000000000025", "데위 사푸트리", "ID", "id", null, 240), - activeWorker("92000000-0000-0000-0000-000000000026", "띤 자르 윈", "MM", "my", 12, 90), + activeWorker("92000000-0000-0000-0000-000000000026", "조제 다 코스타", "TL", "tet", 12, 90), activeWorker("92000000-0000-0000-0000-000000000027", "압둘 카림", "BD", "bn", 90, 180), - activeWorker("92000000-0000-0000-0000-000000000028", "비벡 타파", "NP", "ne", 540, 540) + activeWorker("92000000-0000-0000-0000-000000000028", "비벡 타파", "NP", "en", 540, 540) ); private static final List TEST_WORKERS = List.of( From 023e2aae080a65c614d19f4e80c598f3175c014f Mon Sep 17 00:00:00 2001 From: krestar Date: Tue, 4 Aug 2026 17:03:51 +0900 Subject: [PATCH 14/15] =?UTF-8?q?test(seed):=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=C2=B7=ED=8C=8C=EC=9D=BC=C2=B7=EB=A9=B1=EB=93=B1?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI팀 지원 locale 15개와 국적 조합을 검증한다 - 복합 업무 시나리오의 순서와 의존 관계를 확인한다 - 데모 PDF의 메타데이터와 Task·Worker 연결을 검증한다 - 동일 크기 파일의 SHA-256 충돌 감지를 확인한다 - Worker 핵심 데이터를 재실행 불변성 snapshot에 포함한다 --- .../seed/DemoAuthSeedIntegrationTest.java | 50 +++++++++++++++++-- .../seed/DemoFileFixtureInstallerTest.java | 5 +- .../seed/DemoWorkerSeedRunnerTest.java | 30 +++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index 80d5f3e0..d01a21f4 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -78,6 +78,12 @@ class DemoAuthSeedIntegrationTest { UUID.fromString("94300000-0000-0000-0000-000000000002"); private static final UUID COMPLETED_STAY_TASK_ID = UUID.fromString("94000000-0000-0000-0000-000000000020"); + private static final UUID CONTRACT_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000005"); + private static final UUID CONTRACT_WORKER_ID = + UUID.fromString("92000000-0000-0000-0000-000000000005"); + private static final UUID COMPLETED_STAY_WORKER_ID = + UUID.fromString("92000000-0000-0000-0000-000000000018"); private static final UUID PASSPORT_REQUEST_DRAFT_ID = UUID.fromString("94700000-0000-0000-0000-000000000002"); private static final UUID CONTRACT_FILE_ID = @@ -194,6 +200,7 @@ void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throw .isEqualTo(EXPECTED_TEST_COUNTS); assertThat(seedSnapshot()).isEqualTo(initialSnapshot); assertTaskTimelineInvariants(); + assertStoredFileFixtures(); } private void assertAccountsAndPasswords() { @@ -356,7 +363,8 @@ private void assertRelativeDatesAndSafeData() { private void assertCompoundDraftScenario() { List> tasks = jdbcTemplate.queryForList( - "SELECT task_id, case_id, task_type, workflow_id, source, status, business_data_json " + "SELECT task_id, case_id, worker_id, task_type, workflow_id, source, status, " + + "business_data_json " + "FROM task WHERE task_id IN (?, ?, ?) ORDER BY task_id", RECONTRACT_CANDIDATE_TASK_ID, EMPLOYMENT_EXTENSION_CANDIDATE_TASK_ID, @@ -364,6 +372,7 @@ private void assertCompoundDraftScenario() { ); assertThat(tasks).hasSize(3).allSatisfy(task -> { assertThat(task.get("case_id")).isEqualTo(COMPOUND_CASE_ID); + assertThat(task.get("worker_id")).isEqualTo(REPRESENTATIVE_WORKER_ID); String businessData = (String) task.get("business_data_json"); assertThat(JsonPath.read(businessData, "$.demo_scenario")) .isEqualTo("compound-draft-flow-v1"); @@ -390,6 +399,17 @@ private void assertCompoundDraftScenario() { .containsEntry("workflow_id", "WF-STY-001") .containsEntry("source", "AI_CANDIDATE") .containsEntry("status", "WAITING_WORKER"); + assertThat(tasks.stream() + .map(task -> JsonPath.read((String) task.get("business_data_json"), "$.candidate_order")) + .toList()).containsExactly(1, 3, 2); + assertThat(JsonPath.read( + (String) tasks.get(1).get("business_data_json"), + "$.depends_on_task_id" + )).isEqualTo(RECONTRACT_CANDIDATE_TASK_ID.toString()); + assertThat(JsonPath.read( + (String) tasks.get(2).get("business_data_json"), + "$.submission_due_offset_days" + )).isEqualTo(7); Map> documentsByType = jdbcTemplate.query( "SELECT document_type, submission_status, expiry_date, destination, note " @@ -519,6 +539,29 @@ private void assertStoredFileFixtures() throws Exception { .allMatch(file -> "NOT_SCANNED".equals(file.get("scan_status"))) .allMatch(file -> Boolean.FALSE.equals(file.get("verified"))); + Map> filesById = files.stream().collect(Collectors.toMap( + file -> (UUID) file.get("stored_file_id"), + file -> file + )); + assertThat(filesById.get(CONTRACT_FILE_ID)) + .containsEntry("name", "demo-contract-renewal.pdf") + .containsEntry("purpose", "DEMO_CONTRACT_RENEWAL") + .containsEntry("task_id", CONTRACT_TASK_ID) + .containsEntry("worker_id", CONTRACT_WORKER_ID) + .containsEntry("storage_key", CONTRACT_FILE_ID.toString()); + assertThat(filesById.get(STAY_RECEIPT_FILE_ID)) + .containsEntry("name", "demo-stay-extension-receipt.pdf") + .containsEntry("purpose", "DEMO_STAY_EXTENSION_RECEIPT") + .containsEntry("task_id", COMPLETED_STAY_TASK_ID) + .containsEntry("worker_id", COMPLETED_STAY_WORKER_ID) + .containsEntry("storage_key", STAY_RECEIPT_FILE_ID.toString()); + assertThat(filesById.get(STAY_RESULT_FILE_ID)) + .containsEntry("name", "demo-stay-extension-result.pdf") + .containsEntry("purpose", "DEMO_STAY_EXTENSION_RESULT") + .containsEntry("task_id", COMPLETED_STAY_TASK_ID) + .containsEntry("worker_id", COMPLETED_STAY_WORKER_ID) + .containsEntry("storage_key", STAY_RESULT_FILE_ID.toString()); + Map resources = Map.of( CONTRACT_FILE_ID, "demo/files/demo-contract-renewal.pdf", STAY_RECEIPT_FILE_ID, "demo/files/demo-stay-extension-receipt.pdf", @@ -729,8 +772,9 @@ private void assertTaskTimelineInvariants() { private Map>> seedSnapshot() { Map>> snapshot = new LinkedHashMap<>(); snapshot.put("worker", snapshotRows( - "SELECT worker_id, company_id, stay_expiry_date, contract_start_date, " - + "contract_end_date, created_at, updated_at, version FROM worker " + "SELECT worker_id, company_id, display_name, nationality_code, preferred_language, " + + "work_status, stay_expiry_date, contract_start_date, contract_end_date, " + + "created_at, updated_at, version FROM worker " + "WHERE company_id IN (?, ?) ORDER BY company_id, worker_id" )); snapshot.put("worker_document", snapshotRows( diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java index 16d8933c..1b7bd4ca 100644 --- a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java @@ -41,7 +41,10 @@ void installsReusesAndRejectsConflictingFixtureContent() throws Exception { Path installed = storageRoot.resolve(seed.storageKey()); assertThat(Files.readAllBytes(installed)).isEqualTo(expected); - Files.writeString(installed, "conflicting fixture"); + byte[] conflicting = expected.clone(); + conflicting[conflicting.length - 1] ^= 1; + Files.write(installed, conflicting); + assertThat(Files.size(installed)).isEqualTo(expected.length); assertThatThrownBy(() -> installer.install(seed)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining(seed.storageKey()); diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java index 80481cbf..b3dba3a4 100644 --- a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java @@ -27,6 +27,28 @@ class DemoWorkerSeedRunnerTest { + private static final Set AI_SUPPORTED_LANGUAGES = Set.of( + "en", "zh-Hans", "vi", "th", "fil", "id", "mn", "si", + "ru", "uz", "ky", "bn", "ur", "km", "tet" + ); + private static final Map> NATURAL_NATIONALITIES_BY_LANGUAGE = Map.ofEntries( + Map.entry("en", Set.of("MM", "NP", "PH")), + Map.entry("zh-Hans", Set.of("CN")), + Map.entry("vi", Set.of("VN")), + Map.entry("th", Set.of("TH")), + Map.entry("fil", Set.of("PH")), + Map.entry("id", Set.of("ID")), + Map.entry("mn", Set.of("MN")), + Map.entry("si", Set.of("LK")), + Map.entry("ru", Set.of("RU")), + Map.entry("uz", Set.of("UZ")), + Map.entry("ky", Set.of("KG")), + Map.entry("bn", Set.of("BD")), + Map.entry("ur", Set.of("PK")), + Map.entry("km", Set.of("KH")), + Map.entry("tet", Set.of("TL")) + ); + private static final UUID DEMO_COMPANY_ID = UUID.fromString("90000000-0000-0000-0000-000000000001"); private static final UUID TEST_COMPANY_ID = @@ -60,6 +82,14 @@ void createsOperationalDemoWorkersAndKeepsTestCompanySmall() throws Exception { assertThat(workerRepository.workers.values()) .filteredOn(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) .hasSize(28); + List demoWorkers = workerRepository.workers.values().stream() + .filter(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) + .toList(); + assertThat(demoWorkers.stream().map(Worker::preferredLanguage).collect(java.util.stream.Collectors.toSet())) + .containsExactlyInAnyOrderElementsOf(AI_SUPPORTED_LANGUAGES); + assertThat(demoWorkers).allMatch(worker -> NATURAL_NATIONALITIES_BY_LANGUAGE + .getOrDefault(worker.preferredLanguage(), Set.of()) + .contains(worker.nationalityCode())); assertThat(workerRepository.workers.values()) .filteredOn(worker -> worker.companyId().equals(TEST_COMPANY_ID)) .hasSize(5); From 878595de2149080a1725c375acff673874662e25 Mon Sep 17 00:00:00 2001 From: krestar Date: Tue, 4 Aug 2026 17:13:11 +0900 Subject: [PATCH 15/15] =?UTF-8?q?docs(seed):=20Figma=20fixture=20manifest?= =?UTF-8?q?=EC=99=80=20=EB=85=B8=EC=B6=9C=20=EB=B2=94=EC=9C=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PWF_v3 화면 요구사항과 예약 fixture의 대응 관계를 정리한다 - DB·로컬 파일 저장 여부와 현재 조회 API 노출 범위를 구분한다 - 승인 lifecycle, 합성 PDF, 지원 locale 및 근사·제외 범위를 기록한다 - Demo Seed 수량과 Audit·StoredFile 정보를 최신 상태로 갱신한다 --- README.md | 1 + docs/demo-seed-fixture-manifest.md | 109 +++++++++++++++++++++++++++++ docs/demo-seed.md | 26 ++++--- 3 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 docs/demo-seed-fixture-manifest.md diff --git a/README.md b/README.md index 3e57fb94..d961caf2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 해야 할 일을 업무카 | DB 테이블·ERD | [Database 문서](https://fowoco.github.io/server/) | Flyway를 빈 PostgreSQL에 적용해 자동 생성한 구조 | | 로컬 실행·인증·Workflow | [개발 가이드](docs/development-guide.md) | 처음 서버를 실행하고 기능 흐름을 이해하는 방법 | | Demo Seed 수량·시나리오 | [Demo Seed 운영 시나리오](docs/demo-seed.md) | 로컬 데모 데이터의 기준 수량, 대표 흐름과 표현 한계 | +| Figma fixture 대응표 | [Figma Demo Fixture Manifest](docs/demo-seed-fixture-manifest.md) | 화면 요구사항별 예약 데이터와 현재 API 노출 범위 | | 패키지·모듈 경계 | [프로젝트 구조](docs/project-structure.md) | 코드를 어느 패키지에 구현해야 하는지 설명 | | 중요한 설계 결정 | [ADR 목록](docs/adr/README.md) | 저장소 경계, API·보안, Task·AiRun, RLS 결정 원본 | | Server ↔ AI 계약 | [AI Runtime 계약](docs/ai-runtime-contract.md) | Server가 AI에 보내고 받을 수 있는 값과 검증 기준 | diff --git a/docs/demo-seed-fixture-manifest.md b/docs/demo-seed-fixture-manifest.md new file mode 100644 index 00000000..8a3129c9 --- /dev/null +++ b/docs/demo-seed-fixture-manifest.md @@ -0,0 +1,109 @@ +# Figma Demo Fixture Manifest + +이 문서는 [FOWOCO Figma의 PWF_v3 흐름](https://www.figma.com/design/eaOD8OXZOGq6vK4H9pGXNi/FOWOCO?timeline=keyframe&node-id=1246-751&p=f)을 +서버 Demo Seed가 어떤 데이터로 표현하는지와, 그 데이터가 현재 API에서 어디까지 +노출되는지를 기록한다. 이 manifest는 화면 명세를 새로운 API 계약으로 간주하지 +않으며, 기존 Flyway 스키마와 애플리케이션 계약이 수용하는 fixture만 다룬다. + +## 상태 표기 + +| 상태 | 의미 | +| --- | --- | +| API 노출 | 현재 조회 API 응답으로 확인할 수 있다. | +| 저장 전용 | DB 또는 로컬 파일 저장소에는 있지만 현재 조회 API가 없다. | +| 근사 | 별도 모델 없이 기존 상태나 식별자로 화면 의미를 표현한다. | +| 제외 | 현재 도메인·스키마로 만들지 않거나 의도적으로 저장하지 않는다. | + +## 대표 복합 초안 흐름 + +Figma의 재계약·고용기간 연장·체류기간 연장 초안 흐름은 응웬반A와 동일한 +`case_id`를 사용하는 세 업무로 구성한다. + +| Figma 의미 | 예약 fixture | 저장 내용 | 현재 노출 | +| --- | --- | --- | --- | +| 대상 근로자 | Worker `92000000-0000-0000-0000-000000000006` | 응웬반A, `VN`, `vi`, 체류 만료 `D+45` | `GET /api/v1/workers`, `GET /api/v1/workers/{workerId}` | +| 복합 Case | Case ID `94100000-0000-0000-0000-000000000006` | 세 업무의 연속성을 나타내는 공통 식별자 | Task 응답의 `case_id`로 노출; Case 전용 API는 없음 | +| 재계약 조건 검토 | Task `94000000-0000-0000-0000-000000000006` | `RECONTRACT`, `READY_FOR_REVIEW`, candidate order 1 | Task 목록·상세 API | +| 후행 고용기간 연장 | Task `94000000-0000-0000-0000-000000000007` | `DRAFT`, candidate order 3, Task 6 의존 | Task 목록·상세 API | +| 여권 사본 요청 | Task `94000000-0000-0000-0000-000000000008` | `WAITING_WORKER`, candidate order 2, 제출 기한 7일 | Task 목록·상세 API | +| 검토 snapshot | Approval `94300000-0000-0000-0000-000000000002` | AI/HR snapshot, 7/7 검증, 경고 1건, HR 변경 필드와 원천 버전 | 저장 전용; 승인 조회 API 없음 | +| 베트남어 요청 초안 | Draft `94700000-0000-0000-0000-000000000002` | `vi`, `PASSPORT_COPY`, 7일 이내 제출 문구 | 저장 전용; Draft 조회 API 없음 | +| AI 처리 흔적 | trace ID `demo-compound-draft-flow` | 대상 확인부터 후행 후보 준비까지 AI Agent 이벤트 5건 | 업무 활동 API에서 노출 | + +Case 진행률, Case 상태와 Case 전용 엔티티는 만들지 않는다. 클라이언트가 세 Task의 +`case_id`와 `business_data`를 조합해야 한다. + +## 검토·승인·제출 lifecycle + +| 단계 | fixture | 저장 내용 | 현재 노출 | +| --- | --- | --- | --- | +| 승인 대기 | Approval 1~4 | `PENDING` 4건과 업무별 immutable snapshot | 저장 전용 | +| 승인 완료 | Approval 5~11 | `APPROVED` 7건과 결정 사유 | 저장 전용 | +| 반려 | Approval 12 | 필수 고용 정보 부족으로 `REJECTED` | 저장 전용 | +| 무효화 | Approval 13 | 마감일 변경으로 기존 snapshot `INVALIDATED` | 저장 전용 | +| 외부 제출 | External Submission 1~6 | 고용센터·출입국 제출처와 안전한 참조 번호 | 저장 전용 | +| 완료 증빙 | Evidence 1~10 | 문서·HR 확인·접수증·공식 결과 | 저장 전용 | +| 화면 활동 | Audit Event 96건 | 승인·반려·제출·증빙·완료 이벤트 | 업무 활동 및 ADMIN 감사 검색 API | + +현재 Approval API는 승인 요청과 결정, 제출·증빙 기록을 위한 write API다. 기존 +fixture를 읽는 Approval·Submission·Evidence 조회 API는 없으므로, 화면에서는 Task +상태와 Audit Event만 직접 사용할 수 있다. + +## 합성 PDF와 문서 연결 + +PDF는 모두 합성 데이터이며 `DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION` 표시가 +있다. 실제 개인정보나 행정 문서 원본을 포함하지 않는다. + +| 파일 | StoredFile ID | 연결 | 현재 노출 | +| --- | --- | --- | --- | +| `demo-contract-renewal.pdf` | `94800000-0000-0000-0000-000000000001` | 마크 레예스, Task 5, CONTRACT 문서 `95000000-0000-0000-0000-000000000007` | 통합 문서함의 `file_id`; 파일 읽기·다운로드 API는 없음 | +| `demo-stay-extension-receipt.pdf` | `94800000-0000-0000-0000-000000000002` | 모하메드 라힘, Task 20, RECEIPT Evidence | 저장 전용 | +| `demo-stay-extension-result.pdf` | `94800000-0000-0000-0000-000000000003` | 모하메드 라힘, Task 20, OFFICIAL_RESULT Evidence | 저장 전용 | + +`task_evidence.file_reference`에는 prefix 없는 `stored_file_id` UUID 문자열을 +저장한다. DB FK가 아니므로 Demo Seed verifier가 Task·Worker·파일 관계를 직접 +검증한다. + +Demo Seed가 활성화되면 classpath PDF를 `app.file-storage.local-path`에 설치한다. +동일한 storage key의 파일은 크기와 SHA-256이 같을 때만 재사용하며, 다른 파일은 +덮어쓰지 않고 시작을 중단한다. 파일만 남거나 DB row만 남은 상태는 동일 fixture인 +경우 복구한다. 현재 installer는 `LocalFileStorage` 구성만 지원한다. + +## 근로자와 지원 언어 + +Demo Company의 근로자 28명은 AI 팀이 지원하는 locale 15개를 모두 최소 1명씩 +포함한다. + +`en`, `zh-Hans`, `vi`, `th`, `fil`, `id`, `mn`, `si`, `ru`, `uz`, `ky`, `bn`, +`ur`, `km`, `tet` + +국적·선호 언어 조합은 데모에서 실제 사용할 법한 조합으로 구성한다. Worker UUID, +업무·문서 연결, 상태와 상대 날짜는 locale 정합화 전후에 유지한다. + +## API 노출 요약 + +| 데이터 | 저장 | 조회 API | 비고 | +| --- | --- | --- | --- | +| Worker | O | O | 이름·국적·선호 언어·체류/계약일 노출 | +| Task와 Checklist | O | O | `case_id`, `business_data`, 상태와 마감일 노출 | +| WorkerDocument | O | O | 통합 문서함에서 상태·만료일·선택적 `file_id` 노출 | +| Audit Event | O | O | 업무 활동과 ADMIN 감사 검색 지원 | +| Approval snapshot | O | X | write API만 존재 | +| External Submission | O | X | write API만 존재 | +| Evidence | O | X | write API만 존재 | +| Document Request Draft | O | X | 조회 Controller 없음 | +| StoredFile 메타데이터 | O | X | 업로드 API만 존재 | +| PDF 바이너리 | O | X | 로컬 저장소에 설치되지만 읽기·다운로드 API 없음 | + +## 근사 및 제외 범위 + +- 문서 요청·응답 대기는 `WAITING_WORKER`, `MISSING`, Draft와 Audit로 근사한다. +- 요청 중과 최근 업로드는 각각 `MISSING`, `SUBMITTED` 상태로 근사한다. +- Dashboard 수치는 별도 집계 API가 아니라 클라이언트가 조회 결과를 조합한다. +- Worker Secure Link의 토큰·전송·읽음·응답 수명주기는 만들지 않는다. +- HWP/HWPX, 여권·외국인등록증 이미지와 실제 행정 제출 문서는 포함하지 않는다. +- 여권번호, 외국인등록번호, 전화번호, 주소, 임금, 계정·토큰과 실제 Secret은 + 저장하지 않는다. + +수량, 실행 방법, 초기화와 멱등성 규칙은 [Demo Seed 운영 시나리오](demo-seed.md)를 +기준으로 한다. diff --git a/docs/demo-seed.md b/docs/demo-seed.md index 6359977b..25e230d1 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -4,6 +4,9 @@ Demo Seed는 현재 구현된 API를 로컬에서 현실적인 운영 데이터 개발 전용 데이터셋이다. Figma의 업무·근로자 구성을 참고하되, 서버에 존재하는 도메인과 상태만 사용한다. +Figma 화면 요구사항별 예약 ID, DB 저장 위치와 현재 API 노출 여부는 +[Figma Demo Fixture Manifest](demo-seed-fixture-manifest.md)에서 확인한다. + > Demo Seed는 로컬 H2 또는 개인 PostgreSQL 개발 DB 전용이다. 공유 `dev`와 > `prod`에서 활성화하거나 실제 개인정보·Secret을 섞어 사용하면 안 된다. @@ -59,7 +62,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 | 데이터 | 수량 | 주요 분포 | | --- | ---: | --- | | 계정 | 20 | `ADMIN` 2, `HR` 12, `VIEWER` 6 | -| 근로자 | 28 | `ACTIVE`와 `ON_LEAVE`, 다양한 체류 만료 구간 | +| 근로자 | 28 | `ACTIVE`와 `ON_LEAVE`, AI 지원 locale 15개, 다양한 체류 만료 구간 | | 업무 | 24 | 세 가지 지원 업무 유형과 여덟 가지 상태 | | 근로자 서류 | 84 | `VERIFIED` 48, `SUBMITTED` 20, `MISSING` 16 | | 체크리스트 항목 | 68 | 24개 업무에 연결 | @@ -68,8 +71,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 | 외부 제출 | 6 | 고용센터 또는 출입국·외국인청 제출 시나리오 | | 완료 증빙 | 10 | 문서, 접수증, 공식 결과, HR 확인 | | 문서 요청 초안 | 5 | 네팔어, 베트남어, 인도네시아어, 미얀마어 | -| Audit Event | 96 | `HR_USER` 83, `AI_AGENT` 3, `SYSTEM_RULE` 6, `WORKER_LINK` 4 | -| StoredFile | 0 | 실제 파일이나 가짜 저장 경로를 만들지 않음 | +| Audit Event | 96 | `HR_USER` 79, `AI_AGENT` 7, `SYSTEM_RULE` 6, `WORKER_LINK` 4 | +| StoredFile | 3 | 합성 재계약서·체류 연장 접수증·승인 결과 PDF | Demo Company 업무 유형은 `STAY_PERIOD_EXTENSION` 10개, `RECONTRACT` 8개, `EMPLOYMENT_PERIOD_EXTENSION` 6개다. @@ -87,9 +90,9 @@ Demo Company 업무 유형은 `STAY_PERIOD_EXTENSION` 10개, `RECONTRACT` 8개, 미상 8건이다. Issue #70의 초기 예시 분포와 정확히 일치시키는 fixture 재배치는 후속 품질 개선 범위로 남긴다. -`INVALIDATED` 1건은 검토 중인 승인 요청을 무효화한 예시다. 승인 완료 후 중요 -내용이 변경되어 과거 승인 snapshot이 무효화되는 전체 revision/fingerprint -수명주기는 이번 Seed에서 표현하지 않는다. +`INVALIDATED` 1건은 마감일 변경으로 기존 승인 snapshot을 무효화한 예시다. +승인 fixture는 AI 원본, HR 최종본, 변경 필드와 원천 버전을 immutable snapshot으로 +저장한다. ### FOWOCO Test Company @@ -113,8 +116,8 @@ Test Company에는 Demo Company의 전체 운영 데이터를 복제하지 않 | 근로자 | 대표 상황 | 연결 데이터 | | --- | --- | --- | -| 응웬반A | 체류 만료 `D+12`, 재계약 검토 | 같은 `caseId`의 `READY_FOR_REVIEW`, `DRAFT`, `WAITING_WORKER` 업무, 승인 요청, 체크리스트, 문서 요청 초안, Audit Event | -| 소팔 타망 | 고용기간 연장 `D+4`, 정보 보완 필요 | `NEEDS_INFO`, 미완료 체크리스트, 누락·제출 서류 | +| 응웬반A | 체류 만료 `D+45`, 재계약 Task 마감 `D+12` | 같은 `caseId`의 `READY_FOR_REVIEW`, `DRAFT`, `WAITING_WORKER` 업무, 승인 요청, 체크리스트, 문서 요청 초안, Audit Event | +| 바트 에르덴 | 고용기간 연장 `D+4`, 정보 보완 필요 | `NEEDS_INFO`, 미완료 체크리스트, 누락·제출 서류 | | 라니 위자야 | 체류 서류 응답 대기 | `WAITING_WORKER`, 다국어 문서 요청 초안, 요청 관련 Audit Event | | 파티마 누르 | 외국인등록증 사본 대기 | `WAITING_WORKER`, ARC 요청 초안과 서류 상태 | | 민 아웅 | 재계약·서류 준비 `D+20` | AI 후보 `DRAFT`, 계약서·허가서 요청 초안 | @@ -174,6 +177,7 @@ Seed에는 한국어 배지 문자열이나 별도 UI 전용 필드를 저장하 - 외부 제출, 완료 증빙과 상태 전이 이력 - 문서 누락·제출·검증 및 만료 구간 - 다국어 문서 요청 초안 +- 합성 PDF, StoredFile 메타데이터와 근로자 문서·완료 증빙 연결 - HR 사용자, AI 에이전트, 시스템 규칙, 근로자 링크 맥락의 Audit Event ### 현재 모델로 근사한 항목 @@ -192,7 +196,8 @@ Seed에는 한국어 배지 문자열이나 별도 UI 전용 필드를 저장하 - Case 엔티티, Case 진행률과 Case API - Dashboard 집계 API; 현재 Dashboard는 클라이언트 정적 데이터 유지 - 급여, 근태, 일정, 수입, OCR, AI 분석 모델 -- 실제 파일 바이너리, StoredFile 또는 가짜 파일 경로 +- HWP/HWPX, 실제 여권·외국인등록증 이미지와 행정 제출 문서 +- 파일 읽기·미리보기·다운로드 API - 실제 여권번호, 외국인등록번호, 전화번호, 주소, 임금, 토큰, 자격 증명 ## 멱등성, 충돌과 날짜 snapshot @@ -242,12 +247,13 @@ H2 `local`은 인메모리 DB이므로 애플리케이션을 종료하고 다시 SELECT company_id, COUNT(*) FROM worker GROUP BY company_id; SELECT company_id, COUNT(*) FROM task GROUP BY company_id; SELECT company_id, COUNT(*) FROM worker_document GROUP BY company_id; +SELECT company_id, COUNT(*) FROM stored_file GROUP BY company_id; SELECT company_id, COUNT(*) FROM audit_event GROUP BY company_id; ``` 실행 로그의 운영 데이터 요약은 다음 값을 포함한다. ```text -demo_task_count=24 demo_document_count=84 demo_audit_count=96 +demo_task_count=24 demo_stored_file_count=3 demo_document_count=84 demo_audit_count=96 test_task_count=3 test_document_count=8 test_audit_count=8 ```