From 6934a48871b9afec696d8dc02e6951b619c413b3 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 7 Aug 2026 11:12:23 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(seed):=20=EC=9D=91=EC=9B=AC=EB=B0=98A?= =?UTF-8?q?=EB=A5=BC=20Golden=20Flow=20=EC=8B=9C=EC=9E=91=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 Fixture 순번을 유지하면서 응웬반A 운영 데이터만 Seed 대상에서 제외 - Case, Task, 문서 및 파생 승인·전이·요청·Audit 데이터를 제거 - 다른 근로자의 Showcase 고정 ID와 참조 관계를 보존 - 구버전 Golden Flow Seed를 감지해 Demo DB 초기화를 안내 - 시작 상태와 Seed 멱등성 검증을 새 데이터 계약에 맞게 갱신 --- .../seed/DemoGoldenFlowSeedStateGuard.java | 62 ++++ .../seed/DemoOperationalSeedCatalog.java | 193 +++++++++---- .../DemoOperationalSeedConfiguration.java | 3 + .../seed/DemoOperationalSeedRunner.java | 7 + .../seed/DemoAuthSeedIntegrationTest.java | 271 ++++++------------ .../seed/DemoCaseSeederSnapshotTest.java | 48 ---- .../DemoGoldenFlowSeedStateGuardTest.java | 51 ++++ .../seed/DemoOperationalSeedCatalogTest.java | 122 ++++++++ 8 files changed, 481 insertions(+), 276 deletions(-) create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoGoldenFlowSeedStateGuard.java delete mode 100644 src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederSnapshotTest.java create mode 100644 src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoGoldenFlowSeedStateGuardTest.java create mode 100644 src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalogTest.java diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoGoldenFlowSeedStateGuard.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoGoldenFlowSeedStateGuard.java new file mode 100644 index 00000000..f0b190a2 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoGoldenFlowSeedStateGuard.java @@ -0,0 +1,62 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoGoldenFlowSeedStateGuard { + + private static final String RESET_REQUIRED_MESSAGE = + "legacy Golden Flow demo seed rows detected; reset the personal demo database or " + + "volume before starting with the Issue #94 seed contract"; + + private final JdbcTemplate jdbcTemplate; + + DemoGoldenFlowSeedStateGuard(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + void verifyNoLegacyRows(DemoOperationalSeedContext context) { + int legacyRowCount = count( + "workflow_case", + "case_id", + context.companyId(), + List.of(DemoOperationalSeedCatalog.RETIRED_GOLDEN_FLOW_CASE_ID) + ) + count( + "task", + "task_id", + context.companyId(), + DemoOperationalSeedCatalog.RETIRED_GOLDEN_FLOW_TASK_IDS.stream().toList() + ) + count( + "worker_document", + "worker_document_id", + context.companyId(), + DemoOperationalSeedCatalog.RETIRED_GOLDEN_FLOW_DOCUMENT_IDS.stream().toList() + ) + count( + "audit_event", + "audit_event_id", + context.companyId(), + DemoOperationalSeedCatalog.RETIRED_GOLDEN_FLOW_AUDIT_IDS.stream().toList() + ); + if (legacyRowCount > 0) { + throw new IllegalStateException(RESET_REQUIRED_MESSAGE); + } + } + + private int count(String table, String idColumn, UUID companyId, List ids) { + String placeholders = String.join(", ", java.util.Collections.nCopies(ids.size(), "?")); + Object[] parameters = new Object[ids.size() + 1]; + parameters[0] = companyId; + for (int index = 0; index < ids.size(); index++) { + parameters[index + 1] = ids.get(index); + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table + " WHERE company_id = ? AND " + + idColumn + " IN (" + placeholders + ")", + Integer.class, + parameters + ); + return count == null ? 0 : count; + } +} 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 cfdde96e..f81e59a1 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 @@ -27,26 +27,48 @@ final class DemoOperationalSeedCatalog { UUID.fromString("94000000-0000-0000-0000-000000000002"); static final UUID TEST_ADMIN_USER_ID = UUID.fromString("91000000-0000-0000-0000-000000000002"); - - 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(demoTasks); - private final List testDocuments = DemoDocumentSeedCatalog.testDocuments(testTasks); - private final List demoChecklists = - DemoTaskWorkflowSeedCatalog.demoChecklists(demoTasks); - private final List demoApprovals = - 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 legacyDemoAudits = List.of( + static final UUID GOLDEN_FLOW_WORKER_ID = + UUID.fromString("92000000-0000-0000-0000-000000000006"); + static final UUID RETIRED_GOLDEN_FLOW_CASE_ID = + UUID.fromString("94100000-0000-0000-0000-000000000006"); + static final Set RETIRED_GOLDEN_FLOW_TASK_IDS = Set.of( + UUID.fromString("94000000-0000-0000-0000-000000000006"), + UUID.fromString("94000000-0000-0000-0000-000000000007"), + UUID.fromString("94000000-0000-0000-0000-000000000008") + ); + static final Set RETIRED_GOLDEN_FLOW_DOCUMENT_IDS = Set.of( + UUID.fromString("95000000-0000-0000-0000-000000000016"), + UUID.fromString("95000000-0000-0000-0000-000000000017"), + UUID.fromString("95000000-0000-0000-0000-000000000018") + ); + static final Set RETIRED_GOLDEN_FLOW_AUDIT_IDS = Set.of( + UUID.fromString("96000000-0000-0000-0000-000000000024"), + UUID.fromString("96000000-0000-0000-0000-000000000025"), + UUID.fromString("96000000-0000-0000-0000-000000000026"), + UUID.fromString("96000000-0000-0000-0000-000000000027"), + UUID.fromString("96000000-0000-0000-0000-000000000028"), + UUID.fromString("96000000-0000-0000-0000-000000000045"), + UUID.fromString("96000000-0000-0000-0000-000000000083"), + UUID.fromString("96000000-0000-0000-0000-000000000094") + ); + private static final UUID RETIRED_GOLDEN_FLOW_DRAFT_ID = + UUID.fromString("94700000-0000-0000-0000-000000000002"); + + private final List demoTasks; + private final List testTasks; + private final List demoStoredFiles; + private final List demoDocuments; + private final List testDocuments; + private final List demoChecklists; + private final List demoApprovals; + private final List demoTransitions; + private final List demoExternalSubmissions; + private final List demoEvidence; + private final List demoDocumentRequestDrafts; + private final List demoAudits; + private final List testAudits; + + private static final List BASE_DEMO_AUDITS = List.of( audit( "96000000-0000-0000-0000-000000000001", AuditAction.TASK_CREATED, @@ -69,19 +91,69 @@ 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() { + List sourceDemoTasks = DemoTaskSeedCatalog.demoTasks(); + List sourceDemoStoredFiles = + DemoStoredFileSeedCatalog.demoStoredFiles(sourceDemoTasks); + List sourceDemoDocuments = + DemoDocumentSeedCatalog.demoDocuments(sourceDemoTasks); + List sourceDemoChecklists = + DemoTaskWorkflowSeedCatalog.demoChecklists(sourceDemoTasks); + List sourceDemoApprovals = + DemoTaskWorkflowSeedCatalog.demoApprovals(sourceDemoTasks); + List sourceDemoTransitions = + DemoTaskWorkflowSeedCatalog.demoTransitions(sourceDemoTasks); + List sourceDemoExternalSubmissions = + DemoTaskArtifactSeedCatalog.demoExternalSubmissions(sourceDemoTasks); + List sourceDemoEvidence = + DemoTaskArtifactSeedCatalog.demoEvidence(sourceDemoTasks); + List sourceDemoDocumentRequestDrafts = + DemoTaskArtifactSeedCatalog.demoDocumentRequestDrafts(sourceDemoTasks); + List sourceDemoAudits = Stream.concat( + BASE_DEMO_AUDITS.stream(), + DemoAuditSeedCatalog.additionalDemoAudits( + sourceDemoTasks, + sourceDemoApprovals, + sourceDemoExternalSubmissions, + sourceDemoEvidence, + sourceDemoDocumentRequestDrafts + ).stream() + ).toList(); + + demoTasks = sourceDemoTasks.stream() + .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) + .toList(); + demoStoredFiles = sourceDemoStoredFiles.stream() + .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) + .toList(); + demoDocuments = sourceDemoDocuments.stream() + .filter(seed -> !GOLDEN_FLOW_WORKER_ID.equals(seed.workerId())) + .toList(); + demoChecklists = sourceDemoChecklists.stream() + .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) + .toList(); + demoApprovals = sourceDemoApprovals.stream() + .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) + .toList(); + demoTransitions = sourceDemoTransitions.stream() + .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) + .toList(); + demoExternalSubmissions = sourceDemoExternalSubmissions.stream() + .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) + .toList(); + demoEvidence = sourceDemoEvidence.stream() + .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) + .toList(); + demoDocumentRequestDrafts = sourceDemoDocumentRequestDrafts.stream() + .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) + .toList(); + demoAudits = sourceDemoAudits.stream() + .filter(seed -> !RETIRED_GOLDEN_FLOW_AUDIT_IDS.contains(seed.auditEventId())) + .toList(); + + testTasks = DemoTaskSeedCatalog.testTasks(); + testDocuments = DemoDocumentSeedCatalog.testDocuments(testTasks); + testAudits = DemoAuditSeedCatalog.testAudits(testTasks); verifyDefinitions(); } @@ -138,36 +210,36 @@ List testAudits() { } private void verifyDefinitions() { - requireSize(demoTasks, 24, "Demo Company task"); + requireSize(demoTasks, 21, "Demo Company task"); requireSize(testTasks, 3, "Test Company task"); requireSize(demoStoredFiles, 3, "Demo Company stored file"); - requireSize(demoDocuments, 84, "Demo Company document"); + requireSize(demoDocuments, 81, "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"); + requireSize(demoChecklists, 60, "Demo Company checklist item"); + requireSize(demoApprovals, 12, "Demo Company approval request"); + requireSize(demoTransitions, 48, "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"); - requireSize(demoAudits, 96, "Demo Company audit event"); + requireSize(demoDocumentRequestDrafts, 4, "Demo Company document request draft"); + requireSize(demoAudits, 88, "Demo Company audit event"); requireSize(testAudits, 8, "Test Company audit event"); requireDistribution( demoTasks.stream().map(TaskSeed::taskType).toList(), Map.of( - TaskType.STAY_PERIOD_EXTENSION, 10L, - TaskType.RECONTRACT, 8L, - TaskType.EMPLOYMENT_PERIOD_EXTENSION, 6L + TaskType.STAY_PERIOD_EXTENSION, 9L, + TaskType.RECONTRACT, 7L, + TaskType.EMPLOYMENT_PERIOD_EXTENSION, 5L ), "Demo Company task type" ); requireDistribution( demoTasks.stream().map(TaskSeed::status).toList(), Map.of( - TaskStatus.DRAFT, 3L, + TaskStatus.DRAFT, 2L, TaskStatus.NEEDS_INFO, 2L, - TaskStatus.READY_FOR_REVIEW, 4L, + TaskStatus.READY_FOR_REVIEW, 3L, TaskStatus.APPROVED, 2L, - TaskStatus.WAITING_WORKER, 4L, + TaskStatus.WAITING_WORKER, 3L, TaskStatus.WAITING_EXTERNAL, 3L, TaskStatus.COMPLETED, 5L, TaskStatus.CANCELLED, 1L @@ -177,16 +249,16 @@ private void verifyDefinitions() { requireDistribution( demoDocuments.stream().map(DocumentSeed::submissionStatus).toList(), Map.of( - SubmissionStatus.VERIFIED, 48L, + SubmissionStatus.VERIFIED, 46L, SubmissionStatus.SUBMITTED, 20L, - SubmissionStatus.MISSING, 16L + SubmissionStatus.MISSING, 15L ), "Demo Company document status" ); requireDistribution( demoApprovals.stream().map(ApprovalSeed::status).toList(), Map.of( - ApprovalStatus.PENDING, 4L, + ApprovalStatus.PENDING, 3L, ApprovalStatus.APPROVED, 7L, ApprovalStatus.REJECTED, 1L, ApprovalStatus.INVALIDATED, 1L @@ -206,10 +278,10 @@ private void verifyDefinitions() { requireDistribution( demoAudits.stream().map(AuditSeed::actorType).toList(), Map.of( - ActorType.HR_USER, 79L, - ActorType.AI_AGENT, 7L, + ActorType.HR_USER, 77L, + ActorType.AI_AGENT, 2L, ActorType.SYSTEM_RULE, 6L, - ActorType.WORKER_LINK, 4L + ActorType.WORKER_LINK, 3L ), "Demo Company audit actor type" ); @@ -251,10 +323,29 @@ private void verifyDefinitions() { "completion evidence"); requireTaskReferences(demoDocumentRequestDrafts.stream() .map(DocumentRequestDraftSeed::taskId).toList(), "document request draft"); + verifyGoldenFlowBoundary(); verifyArtifactScenarios(); verifyTemporalScenarios(); } + private void verifyGoldenFlowBoundary() { + boolean hasGoldenFlowTask = demoTasks.stream() + .anyMatch(seed -> GOLDEN_FLOW_WORKER_ID.equals(seed.workerId())); + boolean hasGoldenFlowDocument = demoDocuments.stream() + .anyMatch(seed -> GOLDEN_FLOW_WORKER_ID.equals(seed.workerId())); + boolean hasRetiredDraft = demoDocumentRequestDrafts.stream() + .anyMatch(seed -> RETIRED_GOLDEN_FLOW_DRAFT_ID.equals(seed.draftId())); + boolean hasRetiredAudit = demoAudits.stream() + .anyMatch(seed -> RETIRED_GOLDEN_FLOW_AUDIT_IDS.contains(seed.auditEventId()) + || RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.targetId()) + || RETIRED_GOLDEN_FLOW_DRAFT_ID.equals(seed.targetId())); + if (hasGoldenFlowTask || hasGoldenFlowDocument || hasRetiredDraft || hasRetiredAudit) { + throw new IllegalStateException( + "golden flow worker must start without operational demo seed data" + ); + } + } + private static void requireSize(List seeds, int expected, String name) { if (seeds.size() != expected) { throw new IllegalStateException(name + " seed count must be " + expected); 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 fb8c0ecf..27303780 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 @@ -56,6 +56,8 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( ); } DemoOperationalSeedCatalog catalog = new DemoOperationalSeedCatalog(); + DemoGoldenFlowSeedStateGuard goldenFlowStateGuard = + new DemoGoldenFlowSeedStateGuard(jdbcTemplate); DemoCaseSeeder caseSeeder = new DemoCaseSeeder(jdbcTemplate, objectMapper); DemoTaskSeeder taskSeeder = new DemoTaskSeeder(taskRepository, taskContentCodec); DemoStoredFileSeeder storedFileSeeder = new DemoStoredFileSeeder( @@ -107,6 +109,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( properties, clock, catalog, + goldenFlowStateGuard, caseSeeder, taskSeeder, documentSeeder, 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 ab660f19..f3780b8d 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 @@ -31,6 +31,7 @@ class DemoOperationalSeedRunner implements ApplicationRunner { private final DemoAuthSeedProperties properties; private final Clock clock; private final DemoOperationalSeedCatalog catalog; + private final DemoGoldenFlowSeedStateGuard goldenFlowStateGuard; private final DemoCaseSeeder caseSeeder; private final DemoTaskSeeder taskSeeder; private final DemoWorkerDocumentSeeder documentSeeder; @@ -48,6 +49,7 @@ class DemoOperationalSeedRunner implements ApplicationRunner { DemoAuthSeedProperties properties, Clock clock, DemoOperationalSeedCatalog catalog, + DemoGoldenFlowSeedStateGuard goldenFlowStateGuard, DemoCaseSeeder caseSeeder, DemoTaskSeeder taskSeeder, DemoWorkerDocumentSeeder documentSeeder, @@ -64,6 +66,10 @@ class DemoOperationalSeedRunner implements ApplicationRunner { this.properties = Objects.requireNonNull(properties, "properties 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.goldenFlowStateGuard = Objects.requireNonNull( + goldenFlowStateGuard, + "goldenFlowStateGuard must not be null" + ); this.caseSeeder = Objects.requireNonNull(caseSeeder, "caseSeeder must not be null"); this.taskSeeder = Objects.requireNonNull(taskSeeder, "taskSeeder must not be null"); this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); @@ -97,6 +103,7 @@ public void run(ApplicationArguments arguments) { LocalDate today = LocalDate.now(clock); DemoOperationalSeedContext demoContext = DemoOperationalSeedContext.demo(properties, today, now); DemoOperationalSeedContext testContext = DemoOperationalSeedContext.test(properties, today, now); + goldenFlowStateGuard.verifyNoLegacyRows(demoContext); seedDataset( catalog.demoTasks(), catalog.demoStoredFiles(), 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 58a31537..ae7b9a58 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 @@ -86,6 +86,11 @@ class DemoAuthSeedIntegrationTest { 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 Set REPRESENTATIVE_DOCUMENT_IDS = Set.of( + UUID.fromString("95000000-0000-0000-0000-000000000016"), + UUID.fromString("95000000-0000-0000-0000-000000000017"), + UUID.fromString("95000000-0000-0000-0000-000000000018") + ); private static final UUID CONTRACT_FILE_ID = UUID.fromString("94800000-0000-0000-0000-000000000001"); private static final UUID STAY_RECEIPT_FILE_ID = @@ -104,17 +109,17 @@ class DemoAuthSeedIntegrationTest { private static final Map EXPECTED_DEMO_COUNTS = Map.ofEntries( Map.entry("user_account", 20), Map.entry("worker", 28), - Map.entry("task", 24), - Map.entry("workflow_case", 22), - Map.entry("worker_document", 84), + Map.entry("task", 21), + Map.entry("workflow_case", 21), + Map.entry("worker_document", 81), Map.entry("stored_file", 3), - Map.entry("task_checklist_item", 68), - Map.entry("approval_request", 13), - Map.entry("task_transition_history", 52), + Map.entry("task_checklist_item", 60), + Map.entry("approval_request", 12), + Map.entry("task_transition_history", 48), Map.entry("external_submission", 6), Map.entry("task_evidence", 10), - Map.entry("document_request_draft", 5), - Map.entry("audit_event", 96) + Map.entry("document_request_draft", 4), + Map.entry("audit_event", 88) ); private static final Map EXPECTED_TEST_COUNTS = Map.of( "user_account", 3, @@ -164,8 +169,8 @@ void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throw assertAccountsAndPasswords(); assertExactCountsAndDistributions(); assertRelativeDatesAndSafeData(); - assertCompoundDraftScenario(); - assertReviewApprovalAndSubmissionFixtures(); + assertGoldenFlowStartState(); + assertShowcaseApprovalAndSubmissionFixtures(); assertStoredFileFixtures(); assertTaskTimelineInvariants(); assertRepairsPartialStoredFileState(); @@ -232,15 +237,15 @@ private void assertExactCountsAndDistributions() { .containsExactlyInAnyOrderEntriesOf(EXPECTED_TEST_COUNTS); assertThat(distribution("task", "task_type", COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( - Map.of("STAY_PERIOD_EXTENSION", 10, "RECONTRACT", 8, "EMPLOYMENT_PERIOD_EXTENSION", 6) + Map.of("STAY_PERIOD_EXTENSION", 9, "RECONTRACT", 7, "EMPLOYMENT_PERIOD_EXTENSION", 5) ); assertThat(distribution("task", "status", COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( Map.of( - "DRAFT", 3, + "DRAFT", 2, "NEEDS_INFO", 2, - "READY_FOR_REVIEW", 4, + "READY_FOR_REVIEW", 3, "APPROVED", 2, - "WAITING_WORKER", 4, + "WAITING_WORKER", 3, "WAITING_EXTERNAL", 3, "COMPLETED", 5, "CANCELLED", 1 @@ -248,19 +253,19 @@ private void assertExactCountsAndDistributions() { ); assertThat(distribution("worker_document", "document_type", COMPANY_ID)) .containsExactlyInAnyOrderEntriesOf( - Map.of("PASSPORT_COPY", 26, "ARC", 28, "CONTRACT", 22, "PERMIT", 8) + Map.of("PASSPORT_COPY", 25, "ARC", 27, "CONTRACT", 21, "PERMIT", 8) ); assertThat(distribution("worker_document", "submission_status", COMPANY_ID)) .containsExactlyInAnyOrderEntriesOf( - Map.of("VERIFIED", 48, "SUBMITTED", 20, "MISSING", 16) + Map.of("VERIFIED", 46, "SUBMITTED", 20, "MISSING", 15) ); assertThat(distribution("approval_request", "status", COMPANY_ID)) .containsExactlyInAnyOrderEntriesOf( - Map.of("PENDING", 4, "APPROVED", 7, "REJECTED", 1, "INVALIDATED", 1) + Map.of("PENDING", 3, "APPROVED", 7, "REJECTED", 1, "INVALIDATED", 1) ); assertThat(distribution("audit_event", "actor_type", COMPANY_ID)) .containsExactlyInAnyOrderEntriesOf( - Map.of("HR_USER", 79, "AI_AGENT", 7, "SYSTEM_RULE", 6, "WORKER_LINK", 4) + Map.of("HR_USER", 77, "AI_AGENT", 2, "SYSTEM_RULE", 6, "WORKER_LINK", 3) ); assertThat(jdbcTemplate.queryForObject( @@ -273,12 +278,12 @@ SELECT COUNT(*) Integer.class, COMPANY_ID, COMPANY_ID - )).isEqualTo(68); + )).isEqualTo(60); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(DISTINCT task_id) FROM task_checklist_item WHERE company_id = ?", Integer.class, COMPANY_ID - )).isEqualTo(24); + )).isEqualTo(21); } private void assertRelativeDatesAndSafeData() { @@ -363,127 +368,73 @@ private void assertRelativeDatesAndSafeData() { ).stream().noneMatch(json::contains)); } - private void assertCompoundDraftScenario() { - List> tasks = jdbcTemplate.queryForList( - "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", + private void assertGoldenFlowStartState() { + assertThat(countWhere("worker", "worker_id = ?", COMPANY_ID, REPRESENTATIVE_WORKER_ID)) + .isEqualTo(1); + assertThat(countWhere("workflow_case", "worker_id = ?", COMPANY_ID, REPRESENTATIVE_WORKER_ID)) + .isZero(); + assertThat(countWhere("task", "worker_id = ?", COMPANY_ID, REPRESENTATIVE_WORKER_ID)) + .isZero(); + assertThat(countWhere("worker_document", "worker_id = ?", COMPANY_ID, REPRESENTATIVE_WORKER_ID)) + .isZero(); + assertThat(countWhere("stored_file", "worker_id = ?", COMPANY_ID, REPRESENTATIVE_WORKER_ID)) + .isZero(); + + assertThat(jdbcTemplate.queryForList( + "SELECT task_id FROM task WHERE task_id IN (?, ?, ?) ORDER BY task_id", + UUID.class, 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); - 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"); - 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); - }); + )).isEmpty(); + assertThat(countWhere("workflow_case", "case_id = ?", COMPANY_ID, COMPOUND_CASE_ID)) + .isZero(); + assertThat(countWhere( + "approval_request", + "approval_request_id = ?", + COMPANY_ID, + REPRESENTATIVE_APPROVAL_ID + )).isZero(); + assertThat(countWhere( + "document_request_draft", + "draft_id = ?", + COMPANY_ID, + PASSPORT_REQUEST_DRAFT_ID + )).isZero(); + assertThat(jdbcTemplate.queryForList( + "SELECT worker_document_id FROM worker_document " + + "WHERE worker_document_id IN (?, ?, ?)", + UUID.class, + REPRESENTATIVE_DOCUMENT_IDS.toArray() + )).isEmpty(); + assertThat(countWhere( + "audit_event", + "trace_id = ?", + COMPANY_ID, + "demo-compound-draft-flow" + )).isZero(); - 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"); - 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, task_id, 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("task_id", resultSet.getObject("task_id", UUID.class)); - 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("task_id", PASSPORT_REQUEST_TASK_ID) - .containsEntry("submission_status", "MISSING") - .containsEntry("destination", "근로자 문서 요청"); - assertThat(documentsByType.get("PASSPORT_COPY").get("expiry_date")).isNull(); - assertThat(documentsByType.get("ARC")) - .containsEntry("task_id", PASSPORT_REQUEST_TASK_ID) - .containsEntry("submission_status", "VERIFIED"); - assertThat(documentsByType.get("CONTRACT")) - .containsEntry("task_id", RECONTRACT_CANDIDATE_TASK_ID) - .containsEntry("submission_status", "VERIFIED"); + assertThat(count("ai_run", COMPANY_ID)).isZero(); + assertThat(count("worker_link", COMPANY_ID)).isZero(); + assertThat(countWhere( + "external_submission", + "task_id IN (?, ?, ?)", + COMPANY_ID, + RECONTRACT_CANDIDATE_TASK_ID, + EMPLOYMENT_EXTENSION_CANDIDATE_TASK_ID, + PASSPORT_REQUEST_TASK_ID + )).isZero(); + assertThat(countWhere( + "task_evidence", + "task_id IN (?, ?, ?)", + COMPANY_ID, + RECONTRACT_CANDIDATE_TASK_ID, + EMPLOYMENT_EXTENSION_CANDIDATE_TASK_ID, + PASSPORT_REQUEST_TASK_ID + )).isZero(); } - 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"); - + private void assertShowcaseApprovalAndSubmissionFixtures() { assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM approval_request WHERE task_id = ? " + "AND company_id = ? AND status = 'APPROVED'", @@ -517,20 +468,6 @@ private void assertReviewApprovalAndSubmissionFixtures() { 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 " - + "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 assertStoredFileFixtures() throws Exception { @@ -654,7 +591,7 @@ private void assertTaskTimelineInvariants() { COMPANY_ID ); - assertThat(tasks).hasSize(24); + assertThat(tasks).hasSize(21); tasks.values().forEach(task -> { List transitions = transitionsByTask.getOrDefault(task.taskId(), List.of()); String expectedFrom = "DRAFT"; @@ -883,36 +820,16 @@ private void assertFrontendRelevantApiResultsAndTenantIsolation() throws Excepti HttpResponse tasks = authorizedGet("/api/v1/tasks?size=100", demoToken); assertOk(tasks); - assertThat(JsonPath.read(tasks.body(), "$.total_elements").intValue()).isEqualTo(24); + assertThat(JsonPath.read(tasks.body(), "$.total_elements").intValue()).isEqualTo(21); List> taskItems = JsonPath.read(tasks.body(), "$.items"); - assertThat(taskItems).hasSize(24); + assertThat(taskItems).hasSize(21); HttpResponse compoundCase = authorizedGet( "/api/v1/cases/" + COMPOUND_CASE_ID + "/projection", demoToken ); - assertOk(compoundCase); - assertThat(JsonPath.read(compoundCase.body(), "$.display_status")) - .isEqualTo("REVIEW_REQUIRED"); - assertThat(JsonPath.read(compoundCase.body(), "$.current_task.task_id")) - .isEqualTo(RECONTRACT_CANDIDATE_TASK_ID.toString()); - assertThat(JsonPath.read( - compoundCase.body(), - "$.readiness.verified_documents" - ).intValue()).isEqualTo(2); - assertThat(JsonPath.read( - compoundCase.body(), - "$.readiness.total_documents" - ).intValue()).isEqualTo(3); - assertThat(JsonPath.>read( - compoundCase.body(), - "$.tasks[*].task_id" - )).containsExactly( - RECONTRACT_CANDIDATE_TASK_ID.toString(), - PASSPORT_REQUEST_TASK_ID.toString(), - EMPLOYMENT_EXTENSION_CANDIDATE_TASK_ID.toString() - ); - assertStatusApiCount(demoToken, "READY_FOR_REVIEW", 4); - assertStatusApiCount(demoToken, "DRAFT", 3); + assertThat(compoundCase.statusCode()).isEqualTo(404); + assertStatusApiCount(demoToken, "READY_FOR_REVIEW", 3); + assertStatusApiCount(demoToken, "DRAFT", 2); assertThat(taskItems).anyMatch(task -> today.toString().equals(task.get("due_date"))); HttpResponse dueSoon = authorizedGet( @@ -931,7 +848,7 @@ private void assertFrontendRelevantApiResultsAndTenantIsolation() throws Excepti HttpResponse documents = authorizedGet("/api/v1/documents?size=100", demoToken); assertOk(documents); - assertThat(JsonPath.read(documents.body(), "$.total_elements").intValue()).isEqualTo(84); + assertThat(JsonPath.read(documents.body(), "$.total_elements").intValue()).isEqualTo(81); assertDocumentStatusHasData(demoToken, "SUBMITTED"); assertDocumentStatusHasData(demoToken, "MISSING"); assertDocumentStatusHasData(demoToken, "VERIFIED"); @@ -956,7 +873,7 @@ private void assertFrontendRelevantApiResultsAndTenantIsolation() throws Excepti 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(demoAudits.body(), "$.items")).hasSize(88); assertThat(JsonPath.>read(testAudits.body(), "$.items")).hasSize(8); assertTenantApiCount(testToken, "/api/v1/workers?size=100", 5); diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederSnapshotTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederSnapshotTest.java deleted file mode 100644 index 49079ffc..00000000 --- a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederSnapshotTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.fowoco.server.demo.infrastructure.seed; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; -import com.jayway.jsonpath.JsonPath; -import java.util.List; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import tools.jackson.databind.ObjectMapper; - -class DemoCaseSeederSnapshotTest { - - private static final UUID COMPOUND_CASE_ID = - UUID.fromString("94100000-0000-0000-0000-000000000006"); - - @Test - void keepsCandidateOrderAndAllowlistedWorkflowConditions() { - List compoundTasks = new DemoOperationalSeedCatalog().demoTasks().stream() - .filter(task -> task.caseId().equals(COMPOUND_CASE_ID)) - .toList(); - DemoCaseSeeder seeder = new DemoCaseSeeder( - mock(JdbcTemplate.class), - new ObjectMapper() - ); - - String snapshot = seeder.snapshot(compoundTasks); - - assertThat(JsonPath.>read(snapshot, "$.steps[*].order")) - .containsExactly(1, 2, 3); - assertThat(JsonPath.>read(snapshot, "$.steps[*].task_id")) - .containsExactly( - "94000000-0000-0000-0000-000000000006", - "94000000-0000-0000-0000-000000000008", - "94000000-0000-0000-0000-000000000007" - ); - assertThat(JsonPath.read( - snapshot, - "$.steps[0].required_conditions.approval_required" - )).isTrue(); - assertThat(JsonPath.read( - snapshot, - "$.steps[2].required_conditions.depends_on_task_id" - )).isEqualTo("94000000-0000-0000-0000-000000000006"); - } -} diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoGoldenFlowSeedStateGuardTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoGoldenFlowSeedStateGuardTest.java new file mode 100644 index 00000000..6512b801 --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoGoldenFlowSeedStateGuardTest.java @@ -0,0 +1,51 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +class DemoGoldenFlowSeedStateGuardTest { + + private static final DemoOperationalSeedContext CONTEXT = new DemoOperationalSeedContext( + UUID.fromString("90000000-0000-0000-0000-000000000001"), + UUID.fromString("90000000-0000-0000-0000-000000000002"), + LocalDate.of(2026, 8, 7), + Instant.parse("2026-08-07T00:00:00Z") + ); + + @Test + void allowsEmptyOrCurrentVersionDemoDatabase() { + DemoGoldenFlowSeedStateGuard guard = new DemoGoldenFlowSeedStateGuard( + jdbcTemplateReturning(0, 0, 0, 0) + ); + + assertThatCode(() -> guard.verifyNoLegacyRows(CONTEXT)).doesNotThrowAnyException(); + } + + @Test + void rejectsLegacyGoldenFlowReservedRowsWithoutDeletingThem() { + DemoGoldenFlowSeedStateGuard guard = new DemoGoldenFlowSeedStateGuard( + jdbcTemplateReturning(0, 1, 0, 0) + ); + + assertThatThrownBy(() -> guard.verifyNoLegacyRows(CONTEXT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("reset the personal demo database or volume"); + } + + private static JdbcTemplate jdbcTemplateReturning(Integer... counts) { + AtomicInteger callIndex = new AtomicInteger(); + return new JdbcTemplate() { + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + return requiredType.cast(counts[callIndex.getAndIncrement()]); + } + }; + } +} diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalogTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalogTest.java new file mode 100644 index 00000000..d992d4e7 --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalogTest.java @@ -0,0 +1,122 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +class DemoOperationalSeedCatalogTest { + + @Test + void excludesGoldenFlowOperationalDataWithoutRenumberingShowcaseFixtures() { + DemoOperationalSeedCatalog catalog = new DemoOperationalSeedCatalog(); + + assertThat(catalog.demoTasks().stream().map(TaskSeed::taskId)) + .containsExactlyInAnyOrderElementsOf(idsExcept( + "94000000-0000-0000-0000-000000000", + 1, + 24, + Set.of(6, 7, 8) + )); + assertThat(catalog.demoTasks()) + .noneMatch(task -> task.workerId().equals( + DemoOperationalSeedCatalog.GOLDEN_FLOW_WORKER_ID + )); + assertThat(catalog.demoTasks().stream().map(TaskSeed::caseId)) + .containsExactlyInAnyOrderElementsOf(idsExcept( + "94100000-0000-0000-0000-000000000", + 1, + 24, + Set.of(6, 7, 8) + )); + + assertThat(catalog.demoDocuments().stream().map(seed -> seed.documentId())) + .containsExactlyInAnyOrderElementsOf(idsExcept( + "95000000-0000-0000-0000-000000000", + 1, + 84, + Set.of(16, 17, 18) + )); + assertThat(catalog.demoDocuments()) + .noneMatch(seed -> seed.workerId().equals( + DemoOperationalSeedCatalog.GOLDEN_FLOW_WORKER_ID + )); + assertThat(catalog.demoChecklists().stream().map(seed -> seed.checklistItemId())) + .containsExactlyInAnyOrderElementsOf(idsExcept( + "94200000-0000-0000-0000-000000000", + 1, + 68, + IntStream.rangeClosed(15, 22).boxed().collect(Collectors.toSet()) + )); + assertThat(catalog.demoApprovals().stream().map(seed -> seed.approvalRequestId())) + .containsExactlyInAnyOrderElementsOf(idsExcept( + "94300000-0000-0000-0000-000000000", + 1, + 13, + Set.of(2) + )); + assertThat(catalog.demoTransitions().stream().map(seed -> seed.transitionId())) + .containsExactlyInAnyOrderElementsOf(idsExcept( + "94400000-0000-0000-0000-000000000", + 1, + 52, + IntStream.rangeClosed(13, 16).boxed().collect(Collectors.toSet()) + )); + assertThat(catalog.demoDocumentRequestDrafts().stream().map(seed -> seed.draftId())) + .containsExactlyInAnyOrderElementsOf(idsExcept( + "94700000-0000-0000-0000-000000000", + 1, + 5, + Set.of(2) + )); + assertThat(catalog.demoAudits().stream().map(seed -> seed.auditEventId())) + .containsExactlyInAnyOrderElementsOf(idsExcept( + "96000000-0000-0000-0000-000000000", + 1, + 96, + Set.of(24, 25, 26, 27, 28, 45, 83, 94) + )); + + Set activeTaskIds = catalog.demoTasks().stream() + .map(TaskSeed::taskId) + .collect(Collectors.toSet()); + assertReferencesActiveTasks(catalog.demoChecklists(), seed -> seed.taskId(), activeTaskIds); + assertReferencesActiveTasks(catalog.demoApprovals(), seed -> seed.taskId(), activeTaskIds); + assertReferencesActiveTasks(catalog.demoTransitions(), seed -> seed.taskId(), activeTaskIds); + assertReferencesActiveTasks(catalog.demoExternalSubmissions(), seed -> seed.taskId(), activeTaskIds); + assertReferencesActiveTasks(catalog.demoEvidence(), seed -> seed.taskId(), activeTaskIds); + assertReferencesActiveTasks( + catalog.demoDocumentRequestDrafts(), + seed -> seed.taskId(), + activeTaskIds + ); + } + + private static void assertReferencesActiveTasks( + Iterable seeds, + Function taskId, + Set activeTaskIds + ) { + assertThat(seeds).allMatch(seed -> activeTaskIds.contains(taskId.apply(seed))); + } + + private static Set idsExcept( + String prefix, + int first, + int last, + Set excluded + ) { + Set ids = new HashSet<>(); + IntStream.rangeClosed(first, last) + .filter(number -> !excluded.contains(number)) + .mapToObj(number -> UUID.fromString(prefix + "%03d".formatted(number))) + .forEach(ids::add); + return ids; + } +} From 7665c2a14bf9e5670b39226467fe4937d2b0883d Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 7 Aug 2026 13:05:13 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(seed):=20PostgreSQL=20Case=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=83=80=EC=9E=85=20=EB=B0=94=EC=9D=B8=EB=94=A9=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DemoCaseSeeder의 Instant 값을 JDBC 경계에서 Timestamp로 명시 변환 - Case 생성 시각과 갱신 시각의 정밀도 보존을 단위 테스트로 검증 - PostgreSQL 17에서 시간 저장·재조회와 Seed 재실행 멱등성을 검증 - Domain과 Application 계층의 Instant 타입은 그대로 유지 --- .../infrastructure/seed/DemoCaseSeeder.java | 5 +- .../seed/DemoCaseSeederJdbcBindingTest.java | 90 ++++++++++ ...moCaseSeederPostgreSqlIntegrationTest.java | 164 ++++++++++++++++++ 3 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederJdbcBindingTest.java create mode 100644 src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederPostgreSqlIntegrationTest.java diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeeder.java index 4295b2c0..776dcfbf 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeeder.java @@ -2,6 +2,7 @@ import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; import com.fowoco.server.task.domain.TaskStatus; +import java.sql.Timestamp; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Comparator; @@ -67,8 +68,8 @@ INSERT INTO workflow_case ( DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION, snapshot(tasks), context.actorId(), - createdAt, - context.now() + Timestamp.from(createdAt), + Timestamp.from(context.now()) ); } diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederJdbcBindingTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederJdbcBindingTest.java new file mode 100644 index 00000000..6f276c35 --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederJdbcBindingTest.java @@ -0,0 +1,90 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import static org.assertj.core.api.Assertions.assertThat; + +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.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import tools.jackson.databind.ObjectMapper; + +class DemoCaseSeederJdbcBindingTest { + + private static final UUID COMPANY_ID = + UUID.fromString("a1000000-0000-0000-0000-000000000001"); + private static final UUID ACTOR_ID = + UUID.fromString("a1000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_ID = + UUID.fromString("a1000000-0000-0000-0000-000000000003"); + private static final UUID CASE_ID = + UUID.fromString("a1000000-0000-0000-0000-000000000004"); + private static final UUID TASK_ID = + UUID.fromString("a1000000-0000-0000-0000-000000000005"); + private static final Instant NOW = Instant.parse("2026-08-07T00:00:00.123456Z"); + + @Test + void bindsCaseTimestampsExplicitlyAtTheJdbcBoundary() { + CapturingJdbcTemplate jdbcTemplate = new CapturingJdbcTemplate(); + DemoCaseSeeder seeder = new DemoCaseSeeder(jdbcTemplate, new ObjectMapper()); + + seeder.seed( + List.of(taskSeed()), + new DemoOperationalSeedContext( + COMPANY_ID, + ACTOR_ID, + LocalDate.of(2026, 8, 7), + NOW + ) + ); + + assertThat(jdbcTemplate.insertArguments[9]) + .isInstanceOf(Timestamp.class) + .extracting(value -> ((Timestamp) value).toInstant()) + .isEqualTo(NOW.minusSeconds(2L * 24 * 60 * 60)); + assertThat(jdbcTemplate.insertArguments[10]) + .isInstanceOf(Timestamp.class) + .extracting(value -> ((Timestamp) value).toInstant()) + .isEqualTo(NOW); + } + + private static TaskSeed taskSeed() { + return new TaskSeed( + TASK_ID, + CASE_ID, + WORKER_ID, + TaskType.RECONTRACT, + "WF-CON-001", + "PostgreSQL timestamp binding", + "Verify explicit JDBC timestamp binding", + TaskSource.MANUAL, + TaskStatus.DRAFT, + 30, + 2, + Map.of() + ); + } + + private static final class CapturingJdbcTemplate extends JdbcTemplate { + + private Object[] insertArguments; + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + return requiredType.cast(0); + } + + @Override + public int update(String sql, Object... args) { + insertArguments = args; + return 1; + } + } +} diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederPostgreSqlIntegrationTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..d1014ae3 --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoCaseSeederPostgreSqlIntegrationTest.java @@ -0,0 +1,164 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.common.security.PostgreSqlRlsTestLock; +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.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.SingleConnectionDataSource; +import tools.jackson.databind.ObjectMapper; + +@EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") +class DemoCaseSeederPostgreSqlIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-08-07T00:00:00.123456Z"); + + @Test + void storesAndReloadsCaseTimestampsAndRemainsIdempotentOnPostgreSql17() throws SQLException { + String url = requiredEnvironmentVariable("POSTGRES_TEST_URL"); + String username = requiredEnvironmentVariable("POSTGRES_TEST_USERNAME"); + String password = requiredEnvironmentVariable("POSTGRES_TEST_PASSWORD"); + try (PostgreSqlRlsTestLock ignored = PostgreSqlRlsTestLock.acquire( + url, + username, + password + )) { + migrate(url, username, password); + verifyPostgreSqlBinding(url, username, password); + } + } + + private void verifyPostgreSqlBinding(String url, String username, String password) + throws SQLException { + UUID companyId = UUID.randomUUID(); + UUID actorId = UUID.randomUUID(); + UUID workerId = UUID.randomUUID(); + UUID caseId = UUID.randomUUID(); + UUID taskId = UUID.randomUUID(); + try (Connection connection = DriverManager.getConnection(url, username, password)) { + connection.setAutoCommit(false); + try { + JdbcTemplate jdbcTemplate = new JdbcTemplate( + new SingleConnectionDataSource(connection, true) + ); + insertCaseOwners(jdbcTemplate, companyId, actorId, workerId); + DemoCaseSeeder seeder = new DemoCaseSeeder(jdbcTemplate, new ObjectMapper()); + DemoOperationalSeedContext context = new DemoOperationalSeedContext( + companyId, + actorId, + LocalDate.of(2026, 8, 7), + NOW + ); + List tasks = List.of(taskSeed(taskId, caseId, workerId)); + + seeder.seed(tasks, context); + seeder.seed(tasks, context); + + StoredTimes stored = jdbcTemplate.queryForObject( + "SELECT created_at, updated_at FROM workflow_case WHERE case_id = ?", + (resultSet, rowNumber) -> new StoredTimes( + resultSet.getObject("created_at", OffsetDateTime.class).toInstant(), + resultSet.getObject("updated_at", OffsetDateTime.class).toInstant() + ), + caseId + ); + assertThat(stored).isNotNull(); + assertThat(stored.createdAt()) + .isEqualTo(NOW.minusSeconds(2L * 24 * 60 * 60)); + assertThat(stored.updatedAt()).isEqualTo(NOW); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM workflow_case WHERE case_id = ?", + Integer.class, + caseId + )).isEqualTo(1); + } finally { + connection.rollback(); + } + } + } + + private void insertCaseOwners( + JdbcTemplate jdbcTemplate, + UUID companyId, + UUID actorId, + UUID workerId + ) { + jdbcTemplate.update( + "INSERT INTO company (company_id, name, status) VALUES (?, ?, 'ACTIVE')", + companyId, + "PostgreSQL Demo Seed Test" + ); + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, role, status + ) VALUES (?, ?, ?, ?, 'test-password-hash', 'ADMIN', 'ACTIVE') + """, + actorId, + companyId, + "demo-seed-%s@example.com".formatted(actorId), + "demo-seed-%s@example.com".formatted(actorId) + ); + jdbcTemplate.update( + """ + INSERT INTO worker ( + worker_id, company_id, display_name, nationality_code, + preferred_language, work_status + ) VALUES (?, ?, 'PostgreSQL Seed Worker', 'VN', 'vi', 'ACTIVE') + """, + workerId, + companyId + ); + } + + private TaskSeed taskSeed(UUID taskId, UUID caseId, UUID workerId) { + return new TaskSeed( + taskId, + caseId, + workerId, + TaskType.RECONTRACT, + "WF-CON-001", + "PostgreSQL timestamp binding", + "Verify explicit JDBC timestamp binding", + TaskSource.MANUAL, + TaskStatus.DRAFT, + 30, + 2, + Map.of() + ); + } + + private void migrate(String url, String username, String password) { + Flyway.configure() + .dataSource(url, username, password) + .locations("classpath:db/migration", "classpath:db/migration-postgresql") + .load() + .migrate(); + } + + private String requiredEnvironmentVariable(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " environment variable is required."); + } + return value; + } + + private record StoredTimes(Instant createdAt, Instant updatedAt) { + } +} From e822562b992589d11785375e3ce66fb378d1bd07 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 7 Aug 2026 14:35:22 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test(seed):=20PostgreSQL=20Demo=20Seed=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EA=B8=B0=EB=8F=99=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostgreSQL 17에서 dev profile Application Context와 전체 Demo Seed를 두 번 기동한다. - 재기동 전후 Seed 수량과 Showcase Case timestamp가 유지되는지 검증한다. - 응웬반A의 Case, Task, 문서, Worker Link 및 AI 후속 데이터가 사전 생성되지 않는지 검증한다. - H2 Demo Seed 통합 테스트에서 AiAttempt, Question, Candidate, Decision, WorkerResponse 미생성 조건을 명시적으로 확인한다. --- .../seed/DemoAuthSeedIntegrationTest.java | 15 +- ...dPostgreSqlApplicationIntegrationTest.java | 219 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java 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 ae7b9a58..e875dac6 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 @@ -414,8 +414,21 @@ private void assertGoldenFlowStartState() { "demo-compound-draft-flow" )).isZero(); - assertThat(count("ai_run", COMPANY_ID)).isZero(); + for (String table : List.of( + "ai_run", + "ai_attempt", + "ai_question", + "ai_candidate", + "ai_candidate_decision_batch", + "ai_candidate_decision", + "ai_candidate_decision_task" + )) { + assertThat(count(table, COMPANY_ID)) + .as("%s must not be pre-seeded for the Golden Flow", table) + .isZero(); + } assertThat(count("worker_link", COMPANY_ID)).isZero(); + assertThat(count("worker_response", COMPANY_ID)).isZero(); assertThat(countWhere( "external_submission", "task_id IN (?, ?, ?)", diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java new file mode 100644 index 00000000..e5427c84 --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java @@ -0,0 +1,219 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.ServerApplication; +import com.fowoco.server.common.security.PostgreSqlRlsTestLock; +import java.nio.file.Path; +import java.sql.Timestamp; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.jdbc.core.JdbcTemplate; + +@EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") +class DemoSeedPostgreSqlApplicationIntegrationTest { + + private static final UUID COMPANY_ID = + UUID.fromString("90000000-0000-0000-0000-000000000001"); + private static final UUID REPRESENTATIVE_WORKER_ID = + UUID.fromString("92000000-0000-0000-0000-000000000006"); + private static final UUID SHOWCASE_CASE_ID = + UUID.fromString("94100000-0000-0000-0000-000000000001"); + private static final Map EXPECTED_COUNTS = Map.ofEntries( + Map.entry("user_account", 20), + Map.entry("worker", 28), + Map.entry("workflow_case", 21), + Map.entry("task", 21), + Map.entry("worker_document", 81), + Map.entry("stored_file", 3), + Map.entry("task_checklist_item", 60), + Map.entry("approval_request", 12), + Map.entry("task_transition_history", 48), + Map.entry("external_submission", 6), + Map.entry("task_evidence", 10), + Map.entry("document_request_draft", 4), + Map.entry("audit_event", 88) + ); + + @TempDir + Path fileStoragePath; + + @Test + void bootsFullDemoSeedAndRestartsIdempotentlyOnPostgreSql17() throws Exception { + String url = requiredEnvironmentVariable("POSTGRES_TEST_URL"); + String username = requiredEnvironmentVariable("POSTGRES_TEST_USERNAME"); + String password = requiredEnvironmentVariable("POSTGRES_TEST_PASSWORD"); + + try (PostgreSqlRlsTestLock ignored = PostgreSqlRlsTestLock.acquire( + url, + username, + password + )) { + SeedSnapshot firstBoot; + try (ConfigurableApplicationContext context = startApplication( + url, + username, + password + )) { + JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class); + assertGoldenFlowStartState(jdbcTemplate); + firstBoot = snapshot(jdbcTemplate); + } + + try (ConfigurableApplicationContext context = startApplication( + url, + username, + password + )) { + JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class); + assertGoldenFlowStartState(jdbcTemplate); + assertThat(snapshot(jdbcTemplate)).isEqualTo(firstBoot); + } + } + } + + private ConfigurableApplicationContext startApplication( + String url, + String username, + String password + ) { + Map properties = new LinkedHashMap<>(); + properties.put("spring.datasource.url", url); + properties.put("spring.datasource.username", username); + properties.put("spring.datasource.password", password); + properties.put("spring.datasource.driver-class-name", "org.postgresql.Driver"); + properties.put("spring.datasource.hikari.maximum-pool-size", "2"); + properties.put("spring.datasource.hikari.minimum-idle", "0"); + properties.put("spring.flyway.url", url); + properties.put("spring.flyway.user", username); + properties.put("spring.flyway.password", password); + properties.put( + "spring.flyway.locations", + "classpath:db/migration,classpath:db/migration-postgresql" + ); + properties.put("spring.jpa.show-sql", "false"); + properties.put("app.database.tenant-context-mode", "postgresql"); + properties.put("app.reliability.outbox.enabled", "false"); + properties.put("app.ai-runtime.enabled", "false"); + properties.put("app.demo-seed.enabled", "true"); + properties.put("app.demo-seed.admin-password", "Demo-password-1!"); + properties.put("app.file-storage.local-path", fileStoragePath.toString()); + properties.put( + "app.auth.jwt.secret-base64", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + ); + properties.put("app.auth.refresh-token.cookie.secure", "false"); + properties.put("server.port", "0"); + + StandardEnvironment environment = new StandardEnvironment(); + environment.setActiveProfiles("dev"); + environment.getPropertySources().addFirst( + new MapPropertySource("postgresql-demo-seed-test", properties) + ); + + SpringApplication application = new SpringApplication(ServerApplication.class); + application.setEnvironment(environment); + application.setWebApplicationType(WebApplicationType.SERVLET); + return application.run(); + } + + private void assertGoldenFlowStartState(JdbcTemplate jdbcTemplate) { + assertThat(countByCompany(jdbcTemplate, "worker", COMPANY_ID)).isEqualTo(28); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker WHERE worker_id = ? AND company_id = ?", + Integer.class, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM workflow_case WHERE worker_id = ? AND company_id = ?", + Integer.class, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + )).isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM task WHERE worker_id = ? AND company_id = ?", + Integer.class, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + )).isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE worker_id = ? AND company_id = ?", + Integer.class, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + )).isZero(); + assertThat(jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM worker_link link + JOIN task linked_task + ON linked_task.task_id = link.task_id + AND linked_task.company_id = link.company_id + WHERE linked_task.worker_id = ? AND link.company_id = ? + """, + Integer.class, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + )).isZero(); + + for (String table : new String[] { + "ai_run", + "ai_attempt", + "ai_question", + "ai_candidate", + "ai_candidate_decision_batch", + "ai_candidate_decision" + }) { + assertThat(countByCompany(jdbcTemplate, table, COMPANY_ID)) + .as("%s must not be pre-seeded for the Golden Flow", table) + .isZero(); + } + } + + private SeedSnapshot snapshot(JdbcTemplate jdbcTemplate) { + Map counts = new LinkedHashMap<>(); + EXPECTED_COUNTS.forEach((table, expected) -> { + int actual = countByCompany(jdbcTemplate, table, COMPANY_ID); + assertThat(actual).as("%s count", table).isEqualTo(expected); + counts.put(table, actual); + }); + Timestamp showcaseCreatedAt = jdbcTemplate.queryForObject( + "SELECT created_at FROM workflow_case WHERE case_id = ? AND company_id = ?", + Timestamp.class, + SHOWCASE_CASE_ID, + COMPANY_ID + ); + assertThat(showcaseCreatedAt).isNotNull(); + return new SeedSnapshot(Map.copyOf(counts), showcaseCreatedAt); + } + + private int countByCompany(JdbcTemplate jdbcTemplate, String table, UUID companyId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table + " WHERE company_id = ?", + Integer.class, + companyId + ); + return count == null ? 0 : count; + } + + private String requiredEnvironmentVariable(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " environment variable is required."); + } + return value; + } + + private record SeedSnapshot(Map counts, Timestamp showcaseCreatedAt) { + } +} From d57da3d7a6bbc1c83f20e121af85591cea228e6a Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 7 Aug 2026 14:47:53 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs(seed):=20Golden=20Flow=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=83=81=ED=83=9C=EC=99=80=20PostgreSQL=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=A4=ED=96=89=20=EC=A0=88=EC=B0=A8=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응웬반A Golden Flow 시작 데이터와 다른 근로자의 Showcase Seed를 구분해 문서화한다. - 실제 Demo Seed 수량, 결정적 식별자와 구버전 Golden Flow 제외 대상을 반영한다. - 구버전 개인 Demo DB의 fail-fast 및 초기화 정책을 설명한다. - PostgreSQL 17 첫 기동과 동일 DB 재기동 절차 및 검증 결과를 추가한다. - E-9 날짜, Workplace, 연락처와 Agent/Prompt Version의 현재 범위를 명확히 한다. --- docs/demo-seed-fixture-manifest.md | 204 ++++++++++------- docs/demo-seed.md | 357 ++++++++++++++--------------- docs/deployment-runbook.md | 26 ++- 3 files changed, 314 insertions(+), 273 deletions(-) diff --git a/docs/demo-seed-fixture-manifest.md b/docs/demo-seed-fixture-manifest.md index c8aef593..646b423a 100644 --- a/docs/demo-seed-fixture-manifest.md +++ b/docs/demo-seed-fixture-manifest.md @@ -1,112 +1,142 @@ -# Figma Demo Fixture Manifest +# Demo Seed 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만 다룬다. +이 문서는 Figma Showcase 화면과 Golden Flow 시연이 어떤 Server Demo Seed를 사용하는지 +기록한다. 화면 명세를 새로운 Server Domain으로 간주하지 않으며, 현재 Flyway 스키마와 +API가 지원하는 fixture만 나열한다. -## 상태 표기 +수량, 실행, 초기화와 PostgreSQL 검증 절차는 +[Demo Seed 운영 시나리오](demo-seed.md)를 기준으로 한다. -| 상태 | 의미 | -| --- | --- | -| API 노출 | 현재 조회 API 응답으로 확인할 수 있다. | -| 저장 전용 | DB 또는 로컬 파일 저장소에는 있지만 현재 조회 API가 없다. | -| 근사 | 별도 모델 없이 기존 상태나 식별자로 화면 의미를 표현한다. | -| 제외 | 현재 도메인·스키마로 만들지 않거나 의도적으로 저장하지 않는다. | +## 데이터 목적 구분 + +| 구분 | 목적 | 응웬반A 포함 방식 | +| --- | --- | --- | +| Golden Flow 시작 데이터 | HR 자연어 요청부터 실제 흐름 시연 | Worker와 선행 참조만 존재 | +| Showcase Seed | 목록·업무함·문서함의 상태 다양성과 화면 밀도 | 응웬반A의 진행 데이터는 포함하지 않음 | -## 대표 복합 초안 흐름 +다른 27명 근로자의 기존 Showcase WorkerDocument, Case, Task, Approval, Submission, +Evidence, Audit와 고정 ID는 유지한다. -Figma의 재계약·고용기간 연장·체류기간 연장 초안 흐름은 응웬반A와 동일한 -`case_id`를 사용하는 세 업무로 구성한다. +## Golden Flow 시작 Worker -| Figma 의미 | 예약 fixture | 저장 내용 | 현재 노출 | +| 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` | 세 업무의 연속성과 생성 당시 Workflow Snapshot | Case 목록·Projection API와 Task 응답의 `case_id`로 노출 | -| 재계약 조건 검토 | 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에서 노출 | +| Company | `90000000-0000-0000-0000-000000000001` | `FOWOCO Demo Company`, `ACTIVE` | 인증·tenant 범위 | +| HR 사용자 | `demo.hr01@example.com` | 같은 Company의 `HR` | 로그인 및 권한 기반 API | +| Worker | `92000000-0000-0000-0000-000000000006` | 응웬반A, `VN`, `vi`, `ACTIVE`, 체류 만료 `D+45` | `GET /api/v1/workers`, `GET /api/v1/workers/{workerId}` | +| Workflow Catalog | version `0.2.0` | classpath projection 사용 가능 | Workflow 및 AI 요청 흐름 | -Case 진행률과 표시 상태는 `GET /api/v1/cases`에서 확인하고, 세 Task와 준비도 요약은 -`GET /api/v1/cases/{caseId}/projection`에서 확인한다. +`D+45`는 현재 Worker 모델에 저장되는 상대 날짜 예시다. 정확한 E-9 취업활동기간이나 +3년 만료 의미를 나타내는 전용 필드가 아니며 관련 모델과 판정은 Issue #84 범위다. -## 검토·승인·제출 lifecycle +별도 Workplace, 연락처, 활성 Agent Version 또는 활성 Prompt Version Seed는 없다. +Agent·Prompt 정보는 실제 실행 후 `AiAttempt` 메타데이터로 기록된다. -| 단계 | 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 | +## 응웬반A에게 사전 생성하지 않는 fixture -현재 Approval API는 승인 요청과 결정, 제출·증빙 기록을 위한 write API다. 기존 -fixture를 읽는 Approval·Submission·Evidence 조회 API는 없으므로, 화면에서는 Task -상태와 Audit Event만 직접 사용할 수 있다. +| 영역 | 시작 상태 | +| --- | --- | +| AI 실행 | `AiRun`, `AiAttempt`, Question, Candidate, Candidate Decision 0건 | +| Case·Task | 응웬반A Case 0건, Task 0건 | +| Task 후속 상태 | Checklist, Approval, Transition, Draft, 완료 상태 0건 | +| 근로자 문서 | WorkerDocument 0건; 여권·ARC·계약서·OCR fixture 없음 | +| 근로자 통신 | WorkerLink, WorkerResponse 0건 | +| 제출·증빙 | ExternalSubmission, Evidence 0건 | +| Activity·Audit | 대표 흐름 관련 Event 0건 | -## 합성 PDF와 문서 연결 +Worker Link와 Worker Response API가 구현되어 있어도 Seed는 응웬반A의 발급·응답 완료 +상태를 미리 만들지 않는다. 실제 시연 중 생성되는 식별자도 Seed가 예약하지 않는다. -PDF는 모두 합성 데이터이며 `DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION` 표시가 -있다. 실제 개인정보나 행정 문서 원본을 포함하지 않는다. +## 구버전 Golden Flow fixture -| 파일 | 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 | 저장 전용 | +다음 ID는 과거 응웬반A의 완료·진행 예시에 사용됐지만 현재 생성 대상에서 제외된다. +다른 Worker로 재연결하거나 이후 Showcase ID를 재번호화하지 않는다. -`task_evidence.file_reference`에는 prefix 없는 `stored_file_id` UUID 문자열을 -저장한다. DB FK가 아니므로 Demo Seed verifier가 Task·Worker·파일 관계를 직접 -검증한다. +| 데이터 | 제외된 고정 ID | +| --- | --- | +| Case | `94100000-0000-0000-0000-000000000006` | +| Task | `94000000-0000-0000-0000-000000000006` ~ `...0008` | +| WorkerDocument | `95000000-0000-0000-0000-000000000016` ~ `...0018` | +| Checklist | `94200000-0000-0000-0000-000000000015` ~ `...0022` | +| Approval | `94300000-0000-0000-0000-000000000002` | +| Transition | `94400000-0000-0000-0000-000000000013` ~ `...0016` | +| Document Request Draft | `94700000-0000-0000-0000-000000000002` | +| Audit Event | `96000000-0000-0000-0000-000000000024` ~ `...0028`, `...0045`, `...0083`, `...0094` | + +구버전 개인 DB에 이 ID가 남아 있으면 Seed는 자동 삭제하지 않고 기동을 중단한다. +개인 Demo DB 또는 전용 volume을 초기화해야 한다. + +## 현재 Showcase 수량 + +| fixture | Demo Company 수량 | 비고 | +| --- | ---: | --- | +| Worker | 28 | 응웬반A 1명과 Showcase 근로자 27명 | +| Case | 21 | 다른 근로자의 Showcase Case | +| Task | 21 | 체류연장 9, 재계약 7, 고용기간 연장 5 | +| WorkerDocument | 81 | 여권 25, ARC 27, 계약서 21, 허가서 8 | +| Checklist | 60 | Showcase Task 연결 | +| Approval | 12 | 대기 3, 승인 7, 반려 1, 무효 1 | +| Transition | 48 | Showcase 상태 전이 | +| ExternalSubmission | 6 | 합성 참조 번호만 사용 | +| Evidence | 10 | 합성 문서와 안전한 메모 | +| Document Request Draft | 4 | 다른 근로자의 다국어 초안 | +| Audit Event | 88 | HR 77, AI 2, 시스템 6, Worker Link 3 | +| StoredFile | 3 | 합성 PDF | + +Catalog는 구버전 전체 목록의 순번을 유지한 채 응웬반A 관련 closure만 제외한다. 따라서 +제외 지점 뒤에 있는 다른 Worker의 파생 ID와 연결 관계는 바뀌지 않는다. + +## Showcase 상태 분포 + +| 화면 표현 | Server 상태·fixture | +| --- | --- | +| 승인 검토 | `READY_FOR_REVIEW` 3건, `PENDING` Approval 3건 | +| AI 제안·정보 보완 | `DRAFT` 2건, `NEEDS_INFO` 2건 | +| 근로자 대기 | `WAITING_WORKER` 3건과 문서 요청 Draft·Audit 근사 | +| 외부 처리 대기 | `WAITING_EXTERNAL` 3건 | +| 완료·취소 | `COMPLETED` 5건, `CANCELLED` 1건과 관련 Evidence·Audit | +| 문서 검토 | `VERIFIED` 46, `SUBMITTED` 20, `MISSING` 15 | -Demo Seed가 활성화되면 classpath PDF를 `app.file-storage.local-path`에 설치한다. -동일한 storage key의 일반 파일은 크기와 SHA-256이 같으면 재사용하고, classpath -fixture가 변경되었으면 현재 내용으로 원자적으로 교체한다. 디렉터리나 심볼릭 링크처럼 -일반 파일이 아닌 경로는 덮어쓰지 않고 시작을 중단한다. DB row의 예약 ID·소유권·메타데이터 -충돌도 기존 값을 덮어쓰지 않고 시작을 중단한다. 파일만 남거나 DB row만 남은 상태는 -동일 fixture인 경우 복구한다. 현재 installer는 `LocalFileStorage` 구성만 지원한다. +Dashboard 수치는 별도 Seed Domain이 아니라 조회 결과를 클라이언트가 조합한다. Audit의 +`WORKER_LINK` actor 예시는 Showcase 활동 표현이며 실제 Worker Link token row를 Seed한 +것은 아니다. -## 근로자와 지원 언어 +## 합성 PDF와 연결 -Demo Company의 근로자 28명은 AI 팀이 지원하는 locale 15개를 모두 최소 1명씩 -포함한다. +모든 PDF에는 `DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION` 표시가 있고 실제 개인정보나 +행정 문서 원본을 포함하지 않는다. -`en`, `zh-Hans`, `vi`, `th`, `fil`, `id`, `mn`, `si`, `ru`, `uz`, `ky`, `bn`, -`ur`, `km`, `tet` +| 파일 | StoredFile ID | Showcase 연결 | +| --- | --- | --- | +| `demo-contract-renewal.pdf` | `94800000-0000-0000-0000-000000000001` | 마크 레예스의 계약 문서·Task | +| `demo-stay-extension-receipt.pdf` | `94800000-0000-0000-0000-000000000002` | 모하메드 라힘의 RECEIPT Evidence | +| `demo-stay-extension-result.pdf` | `94800000-0000-0000-0000-000000000003` | 모하메드 라힘의 OFFICIAL_RESULT Evidence | -국적·선호 언어 조합은 데모에서 실제 사용할 법한 조합으로 구성한다. Worker UUID, -업무·문서 연결, 상태와 상대 날짜는 locale 정합화 전후에 유지한다. +Demo Seed는 classpath PDF를 `app.file-storage.local-path`에 설치한다. 같은 storage key의 +일반 파일은 hash와 크기가 같으면 재사용하며, 예약 ID·소유권·메타데이터가 충돌하면 +덮어쓰지 않고 기동을 중단한다. 권한이 있는 사용자는 +`GET /api/v1/files/{fileId}/content`로 파일 내용을 조회할 수 있다. ## API 노출 요약 | 데이터 | 저장 | 조회 API | 비고 | | --- | --- | --- | --- | -| Worker | O | O | 이름·국적·선호 언어·체류/계약일 노출 | -| Case와 Workflow Snapshot | O | O | 진행률·현재 Task·준비도 요약 노출 | -| 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)를 -기준으로 한다. +| Worker | O | O | 이름·국적·언어·현재 날짜 필드 | +| Case와 Workflow Snapshot | O | O | 진행률·현재 Task·준비도 요약 | +| Task와 Checklist | O | O | `case_id`, business data, 상태·마감일 | +| WorkerDocument | O | O | 문서 상태·만료일·선택적 `file_id` | +| Audit Event | O | O | 업무 활동과 ADMIN 감사 검색 | +| WorkerLink·WorkerResponse | Golden Flow에서 X | O | 시연 과정에서 생성·조회 | +| Approval | Showcase에 O | write 중심 | 요청·결정 흐름 | +| ExternalSubmission·Evidence | Showcase에 O | write 중심 | 시연 과정 기록 | +| Document Request Draft | Showcase에 O | X | 조회 Controller 없음 | +| StoredFile content | Showcase에 O | O | 권한 기반 content 조회 | + +## 의도적으로 포함하지 않는 데이터 + +- OCR 상태·결과·신분증 이미지 +- 여권번호, 외국인등록번호, 전화번호, 주소, 임금 +- 실제 Worker Link token, 계정 token, API Key와 Secret +- #84의 E-9 날짜·비자정보 필드와 판정 결과 +- 별도 Workplace, 연락처, Agent/Prompt Version Registry +- HWP/HWPX와 실제 행정 제출 문서 diff --git a/docs/demo-seed.md b/docs/demo-seed.md index 7d518bee..e09cc2a6 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -1,20 +1,25 @@ # Demo Seed 운영 시나리오 -Demo Seed는 현재 구현된 API를 로컬에서 현실적인 운영 데이터로 확인하기 위한 -개발 전용 데이터셋이다. Figma의 업무·근로자 구성을 참고하되, 서버에 존재하는 -도메인과 상태만 사용한다. +Demo Seed는 로컬 H2 또는 개인 PostgreSQL 개발 DB에서 제품 흐름과 Showcase 화면을 +재현하기 위한 합성 데이터다. 실제 개인정보, 행정 문서 원본, 운영 Secret은 포함하지 +않는다. -Figma 화면 요구사항별 예약 ID, DB 저장 위치와 현재 API 노출 여부는 -[Figma Demo Fixture Manifest](demo-seed-fixture-manifest.md)에서 확인한다. +Demo Seed에는 목적이 다른 두 종류의 데이터가 함께 있다. -> Demo Seed는 로컬 H2 또는 개인 PostgreSQL 개발 DB 전용이다. 공유 `dev`와 -> `prod`에서 활성화하거나 실제 개인정보·Secret을 섞어 사용하면 안 된다. +- **Golden Flow 시작 데이터**: HR이 자연어 요청을 입력하는 순간부터 실제 흐름을 + 시연하기 위한 최소 선행 데이터 +- **Showcase Seed**: 목록·업무함·문서함·대시보드의 다양한 상태와 화면 밀도를 위한 + 다른 근로자의 예시 데이터 -## 실행 조건 +예약 ID와 Figma 대응 관계는 +[Demo Fixture Manifest](demo-seed-fixture-manifest.md)에서 확인한다. -Demo Seed의 기본값은 `false`다. 활성화할 때는 12자 이상의 로컬 전용 비밀번호를 -실행 환경의 Secret으로 제공해야 한다. 모든 데모 계정은 이 비밀번호를 공유하며, -저장할 때는 BCrypt 해시만 남긴다. +> Demo Seed의 기본값은 `false`다. 공유 `dev`나 `prod`에서 활성화하지 않으며, 개인 +> 개발 DB에서도 합성 비밀번호만 사용한다. + +## 실행 방법 + +Demo Seed를 활성화하려면 12자 이상의 로컬 전용 비밀번호가 필요하다. ```bash export DEMO_SEED_ENABLED=true @@ -22,240 +27,230 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 전용 12자 이상 값' ./gradlew bootRun ``` -팀 공통 PostgreSQL 실행 스크립트는 `.env.local`을 읽고 `dev` profile과 Demo -Seed를 함께 활성화한다. +```powershell +$env:DEMO_SEED_ENABLED = "true" +$env:DEMO_SEED_ADMIN_PASSWORD = "로컬 전용 12자 이상 값" +.\gradlew.bat bootRun +``` + +기본 `local` profile은 H2 인메모리 DB를 사용한다. PostgreSQL `dev` profile은 +`.env.local`을 구성한 뒤 저장소 스크립트로 실행할 수 있다. ```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, 로그 또는 메신저에 올리지 않는다. +PostgreSQL 17 Compose 실행은 [배포 Runbook](deployment-runbook.md)의 로컬 통합 실행 +절차를 따른다. + +## 로그인 계정 -## 대표 로그인 계정 +모든 계정은 `DEMO_SEED_ADMIN_PASSWORD`에 지정한 동일한 합성 비밀번호를 사용하고, +DB에는 BCrypt hash만 저장한다. | 회사 | 역할 | 이메일 | 용도 | | --- | --- | --- | --- | -| Demo Company | `ADMIN` | `demo.admin@example.com` | 기본 관리자, 환경변수로 변경 가능 | +| 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`, AI 지원 locale 15개, 다양한 체류 만료 구간 | -| Case | 22 | 단일 업무 Case 21개, 세 업무를 묶은 복합 Case 1개 | -| 업무 | 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` 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개다. - -업무 상태는 `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일 초과, -미상 구간을 모두 포함한다. - -현재 근로 상태는 `ACTIVE` 25명, `ON_LEAVE` 3명이다. 문서 만료 구간은 -이미 만료 9건, 30일 이내 28건, 31~90일 19건, 90일 초과 20건, -미상 8건이다. Issue #70의 초기 예시 분포와 정확히 일치시키는 fixture 재배치는 -후속 품질 개선 범위로 남긴다. - -`INVALIDATED` 1건은 마감일 변경으로 기존 승인 snapshot을 무효화한 예시다. -승인 fixture는 AI 원본, HR 최종본, 변경 필드와 원천 버전을 immutable snapshot으로 -저장한다. +| Demo Company | `HR` | `demo.hr01@example.com` | Golden Flow 대표 HR | +| Demo Company | `VIEWER` | `demo.viewer01@example.com` | 읽기 권한 확인 | +| Test Company | `ADMIN` | `test.admin@example.com` | tenant 격리 확인 | +| Test Company | `HR` | `test.hr@example.com` | tenant 격리 확인 | +| Test Company | `VIEWER` | `test.viewer@example.com` | tenant 격리 확인 | -### FOWOCO Test Company +Demo Company에는 `ADMIN` 2명, `HR` 12명, `VIEWER` 6명이 있고 Test Company에는 +역할별 한 명씩 있다. 두 회사의 데이터는 `company_id`로 분리된다. -| 데이터 | 수량 | -| --- | ---: | -| 계정 | 3 | -| 근로자 | 5 | -| Case | 3 | -| 업무 | 3 | -| 근로자 서류 | 8 | -| Audit Event | 8 | +## Golden Flow 시작 상태 -Test Company에는 Demo Company의 전체 운영 데이터를 복제하지 않는다. Demo -계정으로 Test Company 업무를 조회하거나 그 반대로 조회하면 `404` 또는 빈 -목록이 반환되어야 한다. +대표 시작 Worker는 다음과 같다. -## 대표 근로자 시나리오 +| 항목 | 값 | +| --- | --- | +| Worker ID | `92000000-0000-0000-0000-000000000006` | +| 이름 | 응웬반A | +| Company ID | `90000000-0000-0000-0000-000000000001` | +| 국적·기본 언어 | `VN` · `vi` | +| 근로 상태 | `ACTIVE` | +| 현재 상대 날짜 | 체류 만료 `D+45`, 계약 시작 `D-1년`, 계약 종료 `D+180` | +| Workflow Catalog | classpath projection, version `0.2.0` | -모든 날짜는 최초 생성 시 주입된 `Clock`을 기준으로 상대 계산된다. `D+12`와 같은 -표현은 그 최초 생성일을 기준으로 하며, 예약 데이터가 존재하는 상태에서 서버를 -재기동해도 날짜와 과거 이력은 이동하지 않는다. +상대 날짜는 Worker가 처음 생성되는 날을 기준으로 저장되며 재실행 시 기존 값을 +바꾸지 않는다. 현재 Worker 날짜 필드는 정확한 E-9 취업활동기간 의미를 모두 표현하지 +않는다. `D+45`는 대표 요청을 시작할 수 있는 데모 신호일 뿐이며, 정확한 E-9 날짜 의미와 +3년 만료 판정은 Issue #84의 후속 범위다. -| 근로자 | 대표 상황 | 연결 데이터 | -| --- | --- | --- | -| 응웬반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`, 계약서·허가서 요청 초안 | -| 아디 수르야 | 고용기간 연장 자료 보완 | 지원되는 업무 유형의 `NEEDS_INFO`; 신규 등록 업무는 만들지 않음 | -| 모하메드 라힘 | 오늘 마감된 체류기간 연장 완료 | `COMPLETED`, 승인 상태, 외부 제출, 완료 증빙, 상태 전이 이력 | +HR은 같은 Demo Company 범위에서 응웬반A를 조회하고 현재 구현된 AI 요청·Candidate +결정·Case/Task·승인·Worker Link·제출·증빙 흐름을 진행할 수 있다. 별도 Workplace, +Worker 연락처, 활성 Agent Version 또는 활성 Prompt Version Seed 모델은 만들지 않는다. +Agent·Prompt 버전은 실제 AI 실행 후 `AiAttempt` 메타데이터로 기록된다. -응웬반A의 세 업무는 하나의 Case 엔티티에 연결된다. 서버는 Case 목록과 Projection -API에서 진행률, 현재 Task, Workflow Snapshot과 준비도 요약을 제공한다. +### 시연 전에 존재하는 데이터 -## 클라이언트에서 확인되는 데이터 +- Demo Company와 HR 사용자·현재 역할 +- 응웬반A Worker 기본정보 +- 현재 구현된 Workflow Catalog와 Workflow Version +- 현재 구현된 Task Type, 공통 코드와 상태값 -현재 클라이언트는 서버 원본 값을 조합해 화면용 문구와 분류를 만든다. Demo -Seed에는 한국어 배지 문자열이나 별도 UI 전용 필드를 저장하지 않는다. +### 시연 전에 존재하지 않는 데이터 -### 업무 목록과 Metric Strip +- 응웬반A 요청의 `AiRun`, `AiAttempt`, Question, Candidate, Candidate Decision +- 응웬반A의 대표 Case와 Task +- 해당 Task의 Checklist, Approval, Document Request Draft +- 응웬반A의 `WorkerDocument`, 업로드 파일, OCR 결과 +- `WorkerLink`, `WorkerResponse` +- `ExternalSubmission`, `Evidence` +- 대표 흐름의 Activity, Audit Event와 완료 상태 -| 화면 의미 | 서버 데이터 | 기대 수량·예시 | -| --- | --- | --- | -| 승인 대기 | `READY_FOR_REVIEW` | 4개 | -| AI 준비 완료 | `DRAFT` | 3개 | -| 긴급 업무 | `dueDate <= today + 7일` | 오늘, `D+3`, `D+4`, `D+6`, `D+7` 등 복수 | -| 오늘 완료 | `COMPLETED`이고 `updatedAt`이 오늘 | 5개 | +따라서 시연은 다음 요청을 입력하는 시점부터 시작한다. -근로자 분류에는 다음 세 범주가 모두 나타난다. +> 응웬반A가 3년 만료 예정이야. 재계약하고 체류연장 준비해줘. -- `needs-review`: `READY_FOR_REVIEW`, `WAITING_WORKER`, `WAITING_EXTERNAL` 중 하나 이상 -- `ai-suggested`: 위 상태가 없고 `DRAFT` 또는 `NEEDS_INFO`가 존재 -- `done`: 위 두 조건에 해당하지 않음 +현재 모델에서 판단 근거가 부족하면 실제 AI 흐름이 추가 정보를 질문해야 하며, Seed가 +완료 결과를 대신 만들지 않는다. -### 문서 목록 +## 현재 Seed 수량 -- 검토 필요: `SUBMITTED` -- 만료 예정: 오늘부터 30일 이내의 `expiryDate` -- 누락 문서: `MISSING` -- 요청 중: 현재 클라이언트가 `MISSING`으로 근사 -- 최근 업로드: 현재 클라이언트가 `SUBMITTED`로 근사 +### FOWOCO Demo Company -`요청 중`과 `최근 업로드`는 현재 서버가 별도 수명주기를 제공하지 않기 때문에 -정확한 상태가 아니다. +| 데이터 | 수량 | 주요 분포 | +| --- | ---: | --- | +| 계정 | 20 | `ADMIN` 2, `HR` 12, `VIEWER` 6 | +| 근로자 | 28 | `ACTIVE` 25, `ON_LEAVE` 3 | +| Case | 21 | Showcase Task별 Case | +| Task | 21 | 체류연장 9, 재계약 7, 고용기간 연장 5 | +| 근로자 문서 | 81 | 여권 25, ARC 27, 계약서 21, 허가서 8 | +| 체크리스트 항목 | 60 | Showcase Task에 연결 | +| 승인 요청 | 12 | `PENDING` 3, `APPROVED` 7, `REJECTED` 1, `INVALIDATED` 1 | +| 상태 전이 이력 | 48 | Showcase Task 상태 이력 | +| 외부 제출 | 6 | 합성 제출처와 안전한 참조 번호 | +| 완료 증빙 | 10 | 문서·접수증·공식 결과·HR 확인 | +| 문서 요청 초안 | 4 | 다른 근로자의 Showcase 초안 | +| Audit Event | 88 | HR 77, AI 2, 시스템 6, Worker Link 3 | +| StoredFile | 3 | 합성 계약서·접수증·결과 PDF | + +Task 상태는 `DRAFT` 2, `NEEDS_INFO` 2, `READY_FOR_REVIEW` 3, +`APPROVED` 2, `WAITING_WORKER` 3, `WAITING_EXTERNAL` 3, +`COMPLETED` 5, `CANCELLED` 1이다. + +문서 상태는 `VERIFIED` 46, `SUBMITTED` 20, `MISSING` 15다. 응웬반A는 이 +81건에 포함되지 않는다. -### 업무 상세와 Audit +### FOWOCO Test Company -- 업무 상세 API에서 연결된 체크리스트를 확인할 수 있다. -- `94000000-0000-0000-0000-000000000002` 업무는 기존 호환성을 위해 - `TASK_CREATED`, `TASK_UPDATED`, `APPROVAL_REQUESTED` 활동 3개를 유지한다. -- 확장 Audit에는 업무 생성, 체크리스트 변경, 승인 결과, 외부 제출, 완료 증빙, - 문서 요청 초안, 완료·취소 이벤트가 포함된다. -- `WORKER_LINK` 이벤트는 근로자가 제공한 문서 정보가 업무에 반영된 맥락만 - 표현하며 실제 링크 토큰이나 전송·열람 상태를 만들지 않는다. +| 데이터 | 수량 | +| --- | ---: | +| 계정 | 3 | +| 근로자 | 5 | +| Case | 3 | +| Task | 3 | +| 근로자 문서 | 8 | +| Audit Event | 8 | -## Figma 표현 범위 +Test Company는 Demo Company 전체 데이터를 복제하지 않는다. 상호 조회는 빈 목록 또는 +`404`가 되어야 한다. -### 현재 모델로 직접 표현한 항목 +## Showcase Seed 보존 -- 근로자별 지원 업무, 상태, 마감일과 체류 만료일 -- 업무 체크리스트와 승인 요청·결과 -- 외부 제출, 완료 증빙과 상태 전이 이력 -- 문서 누락·제출·검증 및 만료 구간 -- 다국어 문서 요청 초안 -- 합성 PDF, StoredFile 메타데이터와 근로자 문서·완료 증빙 연결 -- HR 사용자, AI 에이전트, 시스템 규칙, 근로자 링크 맥락의 Audit Event +응웬반A와 직접 연결됐던 기존 Case 1건, Task 3건, 문서 3건과 그 하위 데이터만 현재 +생성 목록에서 제외한다. 다른 27명 근로자의 기존 값·고정 ID·상태·관계는 유지한다. -### 현재 모델로 근사한 항목 +Catalog는 과거 전체 fixture를 기존 순번으로 먼저 구성한 다음 Golden Flow 관련 항목만 +filter한다. 이 방식은 목록 위치로 파생되는 다른 Showcase ID가 앞으로 당겨지는 것을 +막는다. 회귀 테스트는 다른 Worker의 ID 집합과 관계가 기존 baseline과 같은지 검증한다. -- 문서 요청·응답 대기: `WAITING_WORKER`, `MISSING`, 문서 요청 초안과 Audit로 표현 -- 요청 중: 클라이언트가 `MISSING`으로 근사 -- 최근 업로드: 클라이언트가 `SUBMITTED`로 근사 -- Case 연속성: 별도 엔티티 없이 여러 업무의 동일한 `caseId`로 표현 +## 멱등성, 구버전 DB와 초기화 -### 의도적으로 제외한 항목 +모든 Seed record는 고정 UUID 또는 안정적인 business key를 사용한다. 같은 DB에서 다시 +실행하면 현재 record를 재사용하고 소유권·핵심 값·snapshot을 검증하며, 날짜·timestamp와 +JPA version을 다시 쓰지 않는다. PostgreSQL에서 서버를 종료하고 동일 DB로 재기동해도 +수량과 고정 ID가 변하지 않아야 한다. -- 담당자 미지정 업무 -- `WORKER_ONBOARDING`, `EMPLOYMENT_CHANGE` 업무 유형 -- 실제 Worker Secure Link와 등록·전송·응답·읽음·안읽음 수명주기 -- 메시지, 근로자 질문, Ticket -- Case 엔티티, Case 진행률과 Case API -- Dashboard 집계 API; 현재 Dashboard는 클라이언트 정적 데이터 유지 -- 급여, 근태, 일정, 수입, OCR, AI 분석 모델 -- HWP/HWPX, 실제 여권·외국인등록증 이미지와 행정 제출 문서 -- 파일 읽기·미리보기·다운로드 API -- 실제 여권번호, 외국인등록번호, 전화번호, 주소, 임금, 토큰, 자격 증명 +구버전 Demo Seed가 응웬반A의 제거 대상 예약 ID를 이미 저장한 DB는 자동 정리하지 않는다. +서버는 다음 메시지로 fail-fast한다. -## 멱등성, 충돌과 날짜 snapshot +```text +legacy Golden Flow demo seed rows detected; reset the personal demo database or volume +``` -모든 예약 레코드는 고정 UUID를 사용한다. 같은 설정으로 다시 실행하면 중복을 -만들지 않고 기존 레코드의 회사·외래키 소유권과 핵심 값을 검증한다. 예약 ID나 -이메일이 다른 데이터에 사용된 경우 기존 데이터를 덮어쓰지 않고 시작을 -중단한다. +이는 시연 과정에서 사용자가 만든 Case나 Task를 Seed가 임의 삭제하지 않기 위한 정책이다. +개인 Demo DB 또는 전용 Compose volume임을 확인한 뒤 초기화하고 다시 실행한다. 공유 DB나 +다른 프로젝트 volume을 삭제하면 안 된다. 이 정리를 위한 Flyway Migration은 없다. -Demo Seed는 최초 생성 시점의 `Clock`을 기준으로 만든 immutable snapshot이다. -예약 데이터가 존재하는 상태에서 같은 날 또는 다음 날 서버를 다시 실행해도 -근로자·문서·업무 날짜, timestamp, fingerprint, revision, `updatedAt`, JPA version을 -다시 쓰지 않는다. +H2 `local`은 인메모리 DB이므로 애플리케이션을 종료하고 다시 실행하면 초기화된다. -## 초기화와 재실행 +## PostgreSQL 17 호환성과 테스트 -단순 재실행은 DB를 초기화할 필요가 없다. 서버를 종료한 뒤 같은 설정으로 실행하면 -멱등성 검증 후 최초 snapshot을 그대로 재사용한다. 현재 날짜 기준의 새로운 Demo -데이터가 필요할 때만 개인 로컬 DB를 초기화한 뒤 Seed를 다시 생성한다. +`DemoCaseSeeder`의 직접 SQL은 `Instant`를 PostgreSQL JDBC에 그대로 전달하지 않고 +JDBC 경계에서 `Timestamp.from(instant)`로 변환한다. Domain의 시간 타입과 DB 스키마는 +변경하지 않았고 Flyway Migration도 추가하지 않았다. -Seed Catalog 자체가 변경되어 예약 전이의 의미나 핵심 값이 달라진 버전으로 -업데이트한 경우에는 기존 snapshot을 자동 변환하지 않는다. 이 경우에도 기존 값을 -덮어쓰지 않고 시작을 중단하므로, 아래 절차로 개인 로컬 DB를 한 번 초기화한다. +일반 테스트에서는 PostgreSQL 환경 테스트가 skip된다. 실제 PostgreSQL 17 검증은 다음 +환경 변수를 같은 shell에 설정한 뒤 실행한다. ```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 +$env:POSTGRES_TEST_ENABLED = "true" +$env:POSTGRES_TEST_URL = "jdbc:postgresql://localhost:5432/fowoco_test" +$env:POSTGRES_TEST_USERNAME = "" +$env:POSTGRES_TEST_PASSWORD = "" +.\gradlew.bat clean test ``` -완전히 새 DB로 확인하려면 먼저 서버를 종료하고, **개인 로컬 DB가 -`fowoco_test`인지 다시 확인한 뒤에만** 삭제·재생성한다. 다음 명령은 해당 DB의 -모든 데이터를 복구 불가능하게 삭제한다. +자동 테스트는 다음을 확인한다. -```bash -dropdb -h localhost -p 5432 -U postgres fowoco_test -createdb -h localhost -p 5432 -U postgres fowoco_test -``` +- PostgreSQL timestamp 저장·재조회와 `DemoCaseSeeder` 재실행 +- PostgreSQL `dev` profile Application Context 전체 기동 +- 빈 DB의 전체 Demo Seed 실행 +- 같은 DB에서 Application Context 재기동과 전체 Seed 재실행 +- 응웬반A Golden Flow 시작 상태 +- 전체 수량과 Showcase Case timestamp 불변 -Docker를 사용한다면 개인 로컬 PostgreSQL 컨테이너의 `fowoco_test` DB 또는 전용 -볼륨만 재생성한다. 공유 컨테이너나 다른 프로젝트 볼륨은 삭제하지 않는다. 그 후 -실행 스크립트를 다시 호출하면 Flyway 적용 후 Demo Seed가 생성된다. - -H2 `local`은 인메모리 DB이므로 애플리케이션을 종료하고 다시 실행하면 초기화된다. +Issue #94 검증에서는 PostgreSQL 17의 빈 Docker DB로 Demo Seed 활성 서버의 첫 기동이 +성공했고, 서버를 중지한 뒤 같은 DB volume으로 재기동해도 Seed 오류 없이 정상 +기동했다. 두 번째 기동에서도 응웬반A Worker가 유지됨을 확인했다. 세부 수량·미생성 +상태·timestamp 불변은 위 PostgreSQL 자동 통합 테스트가 검증한다. ## 확인 쿼리 ```sql SELECT company_id, COUNT(*) FROM worker GROUP BY company_id; +SELECT company_id, COUNT(*) FROM workflow_case 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; + +SELECT worker_id, display_name, nationality_code, preferred_language, work_status +FROM worker +WHERE worker_id = '92000000-0000-0000-0000-000000000006'; + +SELECT COUNT(*) FROM workflow_case +WHERE worker_id = '92000000-0000-0000-0000-000000000006'; +SELECT COUNT(*) FROM task +WHERE worker_id = '92000000-0000-0000-0000-000000000006'; +SELECT COUNT(*) FROM worker_document +WHERE worker_id = '92000000-0000-0000-0000-000000000006'; ``` -실행 로그의 운영 데이터 요약은 다음 값을 포함한다. +기대 로그의 핵심 수량은 다음과 같다. ```text -demo_task_count=24 demo_stored_file_count=3 demo_document_count=84 demo_audit_count=96 +demo_task_count=21 demo_stored_file_count=3 demo_document_count=81 demo_audit_count=88 test_task_count=3 test_document_count=8 test_audit_count=8 ``` + +## 범위 밖 + +- #84의 E-9 날짜·비자정보 필드와 정확한 3년 만료 판정 +- 별도 Workplace 및 Worker 연락처 모델 +- 활성 Agent/Prompt Version Registry Seed +- OCR Job·결과·추출값과 신분증 이미지 +- 여권번호·외국인등록번호·주소 등 상세 개인정보 +- 외부 기관 자동 로그인·자동 제출 +- 실제 SMS·메신저 발송 +- Demo 단계별 S0~S5 profile과 범용 BPMN/Dependency 엔진 diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 3085654f..0f8d149b 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -58,12 +58,14 @@ DB pool은 기본 최대 10개입니다. 클러스터 규모에 따라 `DB_MAX_P ```bash export DEMO_DB_PASSWORD='local-demo-password' export JWT_SECRET_BASE64="$(openssl rand -base64 32)" +export DEMO_SEED_ENABLED=true +export DEMO_SEED_ADMIN_PASSWORD='로컬 전용 12자 이상 값' docker compose -f compose.demo.yml up --build ``` -PostgreSQL Demo Seed는 #94 검증이 끝날 때까지 기본적으로 꺼져 있습니다. 실행 후 -`POST /api/v1/auth/signup`으로 가상 사업장 계정을 만들거나, #94 완료 뒤에만 -`DEMO_SEED_ENABLED=true`와 `DEMO_SEED_ADMIN_PASSWORD`를 추가합니다. +`DEMO_SEED_ENABLED`의 Compose 기본값은 안전하게 `false`입니다. 개인 Demo DB에서 Seed가 +필요한 경우에만 위와 같이 활성화하고 12자 이상의 합성 비밀번호를 지정합니다. 첫 기동은 +빈 PostgreSQL 17 DB에 Flyway와 전체 Demo Seed를 적용합니다. 확인: @@ -72,8 +74,22 @@ curl --fail http://127.0.0.1:8080/actuator/health/readiness curl --fail http://127.0.0.1:8080/health ``` -종료 시 `docker compose -f compose.demo.yml down`을 사용합니다. DB 데이터를 지우려는 경우에만 -영향을 확인한 뒤 별도로 volume 삭제를 결정합니다. +멱등성 Smoke는 서버를 중지하되 volume을 유지하고 같은 설정으로 다시 기동합니다. + +```bash +docker compose -f compose.demo.yml stop server +docker compose -f compose.demo.yml up --build server +``` + +두 번째 기동도 성공하고 응웬반A Worker +`92000000-0000-0000-0000-000000000006`가 한 건 유지되며, 응웬반A의 Golden Flow +Case·Task·WorkerDocument는 0건이어야 합니다. 다른 Showcase Seed의 수량과 고정 ID도 첫 +기동과 같아야 합니다. + +종료 시 `docker compose -f compose.demo.yml down`을 사용합니다. DB 데이터를 지우려는 +경우에만 정확한 Compose project와 전용 volume인지 확인한 뒤 별도로 volume 삭제를 +결정합니다. 구버전 Golden Flow 예약 ID 감지로 기동이 중단된 개인 Demo DB만 초기화 +대상이며, Seed가 기존 데이터를 자동 삭제하거나 Flyway로 정리하지 않습니다. ## 배포 후 Smoke From b354bcf7478c2c166fc78d9a7d2ab7694edb69e3 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 7 Aug 2026 16:22:07 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(seed):=20=EC=9D=91=EC=9B=AC=EB=B0=98A?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=A4=80=EB=B9=84=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20AI=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=EC=97=90?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응웬반A의 여권 사본을 VERIFIED 및 유효한 미래 만료일로 구성 - 외국인등록증 사본을 명시적인 MISSING 상태로 구성 - Golden Flow 시작 문서에서 기존 Task 연결을 제거 - 여권·외국인등록증 상태와 만료일을 tenant 범위 AI 컨텍스트에 제공 - AI Runtime의 Server 소유 문서 상태 변조를 차단 - Demo Seed 수량과 H2/PostgreSQL 회귀 테스트 갱신 --- .../AiRuntimeContractValidator.java | 18 ++++ .../AiSlotResolutionTransaction.java | 18 ++++ .../seed/DemoDocumentSeedCatalog.java | 14 ++-- .../seed/DemoOperationalSeedCatalog.java | 54 +++++++++--- .../application/WorkerAiContextSnapshot.java | 7 +- .../WorkerDocumentAiContextSnapshot.java | 21 +++++ .../persistence/JpaWorkerAiContextReader.java | 60 +++++++++++-- .../workflow/catalog-projection.local.json | 20 ++++- .../AiRuntimeContractValidatorTest.java | 53 ++++++++++++ .../AiSlotResolutionIntegrationTest.java | 48 ++++++++++- .../AiAnalysisContinuationServiceTest.java | 3 +- .../AiSlotResolutionTransactionTest.java | 46 ++++++++-- .../seed/DemoAuthSeedIntegrationTest.java | 84 +++++++++++++++++-- .../seed/DemoOperationalSeedCatalogTest.java | 17 +++- ...dPostgreSqlApplicationIntegrationTest.java | 32 ++++++- 15 files changed, 448 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/fowoco/server/worker/application/WorkerDocumentAiContextSnapshot.java diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java index d45a488b..4961fcdf 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java @@ -14,6 +14,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; @@ -25,6 +26,13 @@ @Component public class AiRuntimeContractValidator { + private static final Set SERVER_OWNED_DOCUMENT_FIELDS = Set.of( + "passport_copy_status", + "passport_copy_expiry_date", + "arc_status", + "arc_expiry_date" + ); + private static final long MIN_DEADLINE_MS = 100; private static final long MAX_DEADLINE_MS = 60_000; private static final int MAX_WORKERS = 1; @@ -335,6 +343,16 @@ private void validateCoreValues(WorkerContext worker, AiCandidate candidate) { "AI Runtime changed a Server-owned core value." ); } + for (String fieldKey : SERVER_OWNED_DOCUMENT_FIELDS) { + String returnedValue = candidate.extractedSlots().get(fieldKey); + if (returnedValue != null + && !Objects.equals(worker.requestedFields().get(fieldKey), returnedValue)) { + reject( + AiRuntimeFailureCode.CORE_VALUE_MISMATCH, + "AI Runtime changed a Server-owned document value." + ); + } + } } private void validateAllowedSlot(String slot, Set allowedSlots) { diff --git a/src/main/java/com/fowoco/server/airun/application/AiSlotResolutionTransaction.java b/src/main/java/com/fowoco/server/airun/application/AiSlotResolutionTransaction.java index 7788e10a..4392bfaa 100644 --- a/src/main/java/com/fowoco/server/airun/application/AiSlotResolutionTransaction.java +++ b/src/main/java/com/fowoco/server/airun/application/AiSlotResolutionTransaction.java @@ -8,6 +8,7 @@ import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.worker.application.WorkerAiContextSnapshot; import com.fowoco.server.worker.application.port.WorkerAiContextReader; +import com.fowoco.server.worker.domain.DocumentType; import com.fowoco.server.workflow.application.WorkflowCatalogService; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -144,10 +145,27 @@ private String resolveWorkerField(String fieldKey, WorkerAiContextSnapshot worke case "worker_id" -> worker.workerId().toString(); case "stay_expiry_date" -> formatDate(worker.stayExpiryDate()); case "contract_end_date" -> formatDate(worker.contractEndDate()); + case "passport_copy_status" -> documentStatus(worker, DocumentType.PASSPORT_COPY); + case "passport_copy_expiry_date" -> documentExpiryDate( + worker, + DocumentType.PASSPORT_COPY + ); + case "arc_status" -> documentStatus(worker, DocumentType.ARC); + case "arc_expiry_date" -> documentExpiryDate(worker, DocumentType.ARC); default -> null; }; } + private String documentStatus(WorkerAiContextSnapshot worker, DocumentType documentType) { + var document = worker.documents().get(documentType); + return document == null ? null : document.submissionStatus().name(); + } + + private String documentExpiryDate(WorkerAiContextSnapshot worker, DocumentType documentType) { + var document = worker.documents().get(documentType); + return document == null ? null : formatDate(document.expiryDate()); + } + private String formatDate(java.time.LocalDate date) { return date == null ? null : date.toString(); } 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 f6901b4e..9f3f873c 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 @@ -142,8 +142,8 @@ private static SubmissionStatus scenarioStatus( ) { if (workerNumber == 6) { return documentType == DocumentType.PASSPORT_COPY - ? SubmissionStatus.MISSING - : SubmissionStatus.VERIFIED; + ? SubmissionStatus.VERIFIED + : SubmissionStatus.MISSING; } return additionStatus(additionIndex); } @@ -155,8 +155,8 @@ private static Integer scenarioExpiryDays( ) { if (workerNumber == 6) { return switch (documentType) { - case PASSPORT_COPY -> null; - case ARC -> 365; + case PASSPORT_COPY -> 365; + case ARC -> null; case CONTRACT -> 180; case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed"); }; @@ -167,7 +167,7 @@ private static Integer scenarioExpiryDays( private static String scenarioDestination(int workerNumber, DocumentType documentType) { if (workerNumber == 6) { return documentType == DocumentType.PASSPORT_COPY - ? "근로자 문서 요청" + ? "체류기간 연장" : "재계약·연장 준비"; } return destination(documentType); @@ -180,8 +180,8 @@ private static String scenarioNote( ) { if (workerNumber == 6) { return switch (documentType) { - case PASSPORT_COPY -> "여권 사본 미보유 · 베트남어 요청 필요"; - case ARC -> "외국인등록증 확인 완료"; + case PASSPORT_COPY -> "검증된 유효 여권 사본"; + case ARC -> "외국인등록증 사본 요청 필요"; case CONTRACT -> "현재 근로계약서 확인 완료"; case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed"); }; 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 f81e59a1..5118e480 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 @@ -36,9 +36,11 @@ final class DemoOperationalSeedCatalog { UUID.fromString("94000000-0000-0000-0000-000000000007"), UUID.fromString("94000000-0000-0000-0000-000000000008") ); - static final Set RETIRED_GOLDEN_FLOW_DOCUMENT_IDS = Set.of( + static final Set GOLDEN_FLOW_CONTEXT_DOCUMENT_IDS = Set.of( UUID.fromString("95000000-0000-0000-0000-000000000016"), - UUID.fromString("95000000-0000-0000-0000-000000000017"), + UUID.fromString("95000000-0000-0000-0000-000000000017") + ); + static final Set RETIRED_GOLDEN_FLOW_DOCUMENT_IDS = Set.of( UUID.fromString("95000000-0000-0000-0000-000000000018") ); static final Set RETIRED_GOLDEN_FLOW_AUDIT_IDS = Set.of( @@ -127,7 +129,10 @@ final class DemoOperationalSeedCatalog { .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) .toList(); demoDocuments = sourceDemoDocuments.stream() - .filter(seed -> !GOLDEN_FLOW_WORKER_ID.equals(seed.workerId())) + .filter(seed -> !RETIRED_GOLDEN_FLOW_DOCUMENT_IDS.contains(seed.documentId())) + .map(seed -> GOLDEN_FLOW_WORKER_ID.equals(seed.workerId()) + ? withoutTask(seed) + : seed) .toList(); demoChecklists = sourceDemoChecklists.stream() .filter(seed -> !RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.taskId())) @@ -213,7 +218,7 @@ private void verifyDefinitions() { requireSize(demoTasks, 21, "Demo Company task"); requireSize(testTasks, 3, "Test Company task"); requireSize(demoStoredFiles, 3, "Demo Company stored file"); - requireSize(demoDocuments, 81, "Demo Company document"); + requireSize(demoDocuments, 83, "Demo Company document"); requireSize(testDocuments, 8, "Test Company document"); requireSize(demoChecklists, 60, "Demo Company checklist item"); requireSize(demoApprovals, 12, "Demo Company approval request"); @@ -249,9 +254,9 @@ private void verifyDefinitions() { requireDistribution( demoDocuments.stream().map(DocumentSeed::submissionStatus).toList(), Map.of( - SubmissionStatus.VERIFIED, 46L, + SubmissionStatus.VERIFIED, 47L, SubmissionStatus.SUBMITTED, 20L, - SubmissionStatus.MISSING, 15L + SubmissionStatus.MISSING, 16L ), "Demo Company document status" ); @@ -331,21 +336,50 @@ private void verifyDefinitions() { private void verifyGoldenFlowBoundary() { boolean hasGoldenFlowTask = demoTasks.stream() .anyMatch(seed -> GOLDEN_FLOW_WORKER_ID.equals(seed.workerId())); - boolean hasGoldenFlowDocument = demoDocuments.stream() - .anyMatch(seed -> GOLDEN_FLOW_WORKER_ID.equals(seed.workerId())); + List goldenFlowDocuments = demoDocuments.stream() + .filter(seed -> GOLDEN_FLOW_WORKER_ID.equals(seed.workerId())) + .toList(); + boolean invalidGoldenFlowDocuments = goldenFlowDocuments.size() != 2 + || !goldenFlowDocuments.stream().map(DocumentSeed::documentId) + .collect(Collectors.toSet()).equals(GOLDEN_FLOW_CONTEXT_DOCUMENT_IDS) + || goldenFlowDocuments.stream().anyMatch(seed -> seed.taskId() != null + || seed.fileId() != null) + || goldenFlowDocuments.stream().noneMatch(seed -> + seed.documentType() == DocumentType.PASSPORT_COPY + && seed.submissionStatus() == SubmissionStatus.VERIFIED + && seed.expiryDays() != null + && seed.expiryDays() > 0) + || goldenFlowDocuments.stream().noneMatch(seed -> + seed.documentType() == DocumentType.ARC + && seed.submissionStatus() == SubmissionStatus.MISSING + && seed.expiryDays() == null); boolean hasRetiredDraft = demoDocumentRequestDrafts.stream() .anyMatch(seed -> RETIRED_GOLDEN_FLOW_DRAFT_ID.equals(seed.draftId())); boolean hasRetiredAudit = demoAudits.stream() .anyMatch(seed -> RETIRED_GOLDEN_FLOW_AUDIT_IDS.contains(seed.auditEventId()) || RETIRED_GOLDEN_FLOW_TASK_IDS.contains(seed.targetId()) || RETIRED_GOLDEN_FLOW_DRAFT_ID.equals(seed.targetId())); - if (hasGoldenFlowTask || hasGoldenFlowDocument || hasRetiredDraft || hasRetiredAudit) { + if (hasGoldenFlowTask || invalidGoldenFlowDocuments || hasRetiredDraft || hasRetiredAudit) { throw new IllegalStateException( - "golden flow worker must start without operational demo seed data" + "golden flow worker must start with only the required document context" ); } } + private static DocumentSeed withoutTask(DocumentSeed seed) { + return new DocumentSeed( + seed.documentId(), + seed.workerId(), + null, + seed.documentType(), + seed.submissionStatus(), + seed.expiryDays(), + seed.destination(), + seed.note(), + seed.fileId() + ); + } + private static void requireSize(List seeds, int expected, String name) { if (seeds.size() != expected) { throw new IllegalStateException(name + " seed count must be " + expected); diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerAiContextSnapshot.java b/src/main/java/com/fowoco/server/worker/application/WorkerAiContextSnapshot.java index 051ee290..db12fd4f 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerAiContextSnapshot.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerAiContextSnapshot.java @@ -1,6 +1,8 @@ package com.fowoco.server.worker.application; +import com.fowoco.server.worker.domain.DocumentType; import java.time.LocalDate; +import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -16,7 +18,8 @@ public record WorkerAiContextSnapshot( String workStatus, LocalDate stayExpiryDate, LocalDate contractStartDate, - LocalDate contractEndDate + LocalDate contractEndDate, + Map documents ) { public WorkerAiContextSnapshot { @@ -24,5 +27,7 @@ public record WorkerAiContextSnapshot( Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(displayName, "displayName must not be null"); Objects.requireNonNull(workStatus, "workStatus must not be null"); + Objects.requireNonNull(documents, "documents must not be null"); + documents = Map.copyOf(documents); } } diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerDocumentAiContextSnapshot.java b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentAiContextSnapshot.java new file mode 100644 index 00000000..fc1dd5ac --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/application/WorkerDocumentAiContextSnapshot.java @@ -0,0 +1,21 @@ +package com.fowoco.server.worker.application; + +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import java.time.LocalDate; +import java.util.Objects; + +/** + * Non-sensitive WorkerDocument facts exposed to the AiRun context boundary. + */ +public record WorkerDocumentAiContextSnapshot( + DocumentType documentType, + SubmissionStatus submissionStatus, + LocalDate expiryDate +) { + + public WorkerDocumentAiContextSnapshot { + Objects.requireNonNull(documentType, "documentType must not be null"); + Objects.requireNonNull(submissionStatus, "submissionStatus must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java index 51efe826..6bf64065 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java @@ -1,9 +1,13 @@ package com.fowoco.server.worker.infrastructure.persistence; import com.fowoco.server.worker.application.WorkerAiContextSnapshot; +import com.fowoco.server.worker.application.WorkerDocumentAiContextSnapshot; import com.fowoco.server.worker.application.port.WorkerAiContextReader; +import com.fowoco.server.worker.domain.DocumentType; import jakarta.persistence.EntityManager; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.UUID; import org.springframework.stereotype.Repository; @@ -23,7 +27,7 @@ public List findByDisplayName(UUID companyId, String di if (displayName == null || displayName.isBlank()) { throw new IllegalArgumentException("displayName must not be blank"); } - return entityManager.createQuery( + List workers = entityManager.createQuery( """ select worker from WorkerJpaEntity worker @@ -36,13 +40,58 @@ public List findByDisplayName(UUID companyId, String di .setParameter("companyId", companyId) .setParameter("displayName", displayName.strip()) .setMaxResults(2) + .getResultList(); + if (workers.isEmpty()) { + return List.of(); + } + List workerIds = workers.stream() + .map(entity -> entity.toDomain().workerId()) + .toList(); + Map> documentsByWorker = + new LinkedHashMap<>(); + entityManager.createQuery( + """ + select document + from WorkerDocumentJpaEntity document + where document.companyId = :companyId + and document.workerId in :workerIds + and document.documentType in :documentTypes + order by document.updatedAt desc, document.workerDocumentId + """, + WorkerDocumentJpaEntity.class + ) + .setParameter("companyId", companyId) + .setParameter("workerIds", workerIds) + .setParameter( + "documentTypes", + List.of(DocumentType.PASSPORT_COPY, DocumentType.ARC) + ) .getResultList() - .stream() - .map(this::toSnapshot) + .forEach(entity -> { + var document = entity.toDomain(); + documentsByWorker + .computeIfAbsent(document.workerId(), ignored -> new LinkedHashMap<>()) + .putIfAbsent( + document.documentType(), + new WorkerDocumentAiContextSnapshot( + document.documentType(), + document.submissionStatus(), + document.expiryDate() + ) + ); + }); + return workers.stream() + .map(worker -> toSnapshot( + worker, + documentsByWorker.getOrDefault(worker.toDomain().workerId(), Map.of()) + )) .toList(); } - private WorkerAiContextSnapshot toSnapshot(WorkerJpaEntity entity) { + private WorkerAiContextSnapshot toSnapshot( + WorkerJpaEntity entity, + Map documents + ) { var worker = entity.toDomain(); return new WorkerAiContextSnapshot( worker.workerId(), @@ -53,7 +102,8 @@ private WorkerAiContextSnapshot toSnapshot(WorkerJpaEntity entity) { worker.workStatus().name(), worker.stayExpiryDate(), worker.contractStartDate(), - worker.contractEndDate() + worker.contractEndDate(), + documents ); } } diff --git a/src/main/resources/workflow/catalog-projection.local.json b/src/main/resources/workflow/catalog-projection.local.json index e3b50d7a..61d6daf5 100644 --- a/src/main/resources/workflow/catalog-projection.local.json +++ b/src/main/resources/workflow/catalog-projection.local.json @@ -45,8 +45,24 @@ "sensitivity": "high", "supported_task_types": ["STAY_PERIOD_EXTENSION"], "required_slots": ["worker_id", "due_at"], - "allowed_slot_keys": ["worker_id", "due_at", "stay_expiry_date"], - "resolvable_slot_keys": ["worker_id", "due_at", "stay_expiry_date"], + "allowed_slot_keys": [ + "worker_id", + "due_at", + "stay_expiry_date", + "passport_copy_status", + "passport_copy_expiry_date", + "arc_status", + "arc_expiry_date" + ], + "resolvable_slot_keys": [ + "worker_id", + "due_at", + "stay_expiry_date", + "passport_copy_status", + "passport_copy_expiry_date", + "arc_status", + "arc_expiry_date" + ], "checklist_items": [ { "item_code": "PASSPORT_COPY_VERIFY_CURRENT", diff --git a/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java index 84ad5457..547a7ae1 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java @@ -27,8 +27,13 @@ import com.fowoco.server.aiintegration.application.model.AiCandidate; import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions; import com.fowoco.server.aiintegration.application.model.AnalysisInput; +import com.fowoco.server.aiintegration.application.model.WorkerContext; +import com.fowoco.server.aiintegration.application.model.WorkflowConstraint; import com.fowoco.server.aiintegration.support.AiRuntimeContractFixture; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.UUID; @@ -228,6 +233,54 @@ void rejectsCandidateThatChangesServerOwnedStayExpiryDate() { ); } + @Test + void rejectsCandidateThatChangesServerOwnedDocumentStatus() { + AiAnalysisRequest baseRequest = validRequest(); + WorkerContext baseWorker = baseRequest.analysisInput().workers().get(0); + Map requestedFields = new HashMap<>(baseWorker.requestedFields()); + requestedFields.put("arc_status", "MISSING"); + WorkerContext worker = new WorkerContext( + baseWorker.workerRef(), + baseWorker.displayName(), + baseWorker.nationalityCode(), + baseWorker.preferredLanguage(), + baseWorker.workStatus(), + baseWorker.stayExpiryDate(), + baseWorker.contractStartDate(), + baseWorker.contractEndDate(), + requestedFields + ); + List requestedFieldKeys = new ArrayList<>( + baseRequest.analysisInput().requestedFieldKeys() + ); + requestedFieldKeys.add("arc_status"); + WorkflowConstraint baseConstraint = + baseRequest.analysisInput().workflowConstraints().get(0); + var allowedSlotKeys = new HashSet<>(baseConstraint.allowedSlotKeys()); + allowedSlotKeys.add("arc_status"); + AnalysisInput input = new AnalysisInput( + baseRequest.analysisInput().instruction(), + baseRequest.analysisInput().extractedSlots(), + requestedFieldKeys, + List.of(worker), + List.of(new WorkflowConstraint(baseConstraint.workflowId(), allowedSlotKeys)) + ); + AiAnalysisRequest request = requestWithPhase(AiAnalysisPhase.ANALYZE, input); + AiCandidate changedStatus = new AiCandidate( + "candidate-changed-document-status", + WORKER_REF, + WORKFLOW_ID, + Map.of("arc_status", "VERIFIED"), + List.of(), + BigDecimal.ONE + ); + + assertFailure( + () -> validator.validateResponse(request, responseWithCandidate(changedStatus)), + AiRuntimeFailureCode.CORE_VALUE_MISMATCH + ); + } + private AiAnalysisResponse responseWithVersions(AiRuntimeVersions versions) { return new AiAnalysisResponse( REQUEST_ID, diff --git a/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java b/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java index 86429147..7f122fa2 100644 --- a/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java +++ b/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java @@ -29,6 +29,9 @@ class AiSlotResolutionIntegrationTest { private static final UUID WORKER_A = UUID.fromString("72000000-0000-0000-0000-000000000001"); private static final UUID WORKER_B = UUID.fromString("72000000-0000-0000-0000-000000000002"); private static final UUID WORKER_A_DUPLICATE = UUID.fromString("72000000-0000-0000-0000-000000000003"); + private static final UUID PASSPORT_A = UUID.fromString("73000000-0000-0000-0000-000000000001"); + private static final UUID ARC_A = UUID.fromString("73000000-0000-0000-0000-000000000002"); + private static final UUID PASSPORT_B = UUID.fromString("73000000-0000-0000-0000-000000000003"); @Autowired private JdbcTemplate jdbcTemplate; @@ -42,6 +45,9 @@ void sameDisplayNameInAnotherCompanyDoesNotAffectTenantScopedResolution() { insertCompany(COMPANY_B, "사업장 B"); insertWorker(WORKER_A, COMPANY_A, "같은이름", "2026-09-30"); insertWorker(WORKER_B, COMPANY_B, "같은이름", "2099-12-31"); + insertDocument(PASSPORT_A, WORKER_A, COMPANY_A, "PASSPORT_COPY", "VERIFIED", "2027-09-30"); + insertDocument(ARC_A, WORKER_A, COMPANY_A, "ARC", "MISSING", null); + insertDocument(PASSPORT_B, WORKER_B, COMPANY_B, "PASSPORT_COPY", "MISSING", null); AiSlotResolution result = resolutionTransaction.resolve( COMPANY_A, @@ -50,7 +56,14 @@ void sameDisplayNameInAnotherCompanyDoesNotAffectTenantScopedResolution() { ); assertThat(result.worker().workerRef()).isEqualTo(WORKER_A); - assertThat(result.resolvedFields()).containsEntry("stay_expiry_date", "2026-09-30"); + assertThat(result.resolvedFields()).containsExactlyInAnyOrderEntriesOf(Map.of( + "worker_id", WORKER_A.toString(), + "stay_expiry_date", "2026-09-30", + "passport_copy_status", "VERIFIED", + "passport_copy_expiry_date", "2027-09-30", + "arc_status", "MISSING" + )); + assertThat(result.missingFieldKeys()).containsExactly("arc_expiry_date"); assertThat(result.resolvedFields()).doesNotContainValue("2099-12-31"); } @@ -78,7 +91,14 @@ private AiContextRequirement requirement(String displayName) { new BigDecimal("0.94"), displayName, Map.of(), - List.of("worker_id", "stay_expiry_date") + List.of( + "worker_id", + "stay_expiry_date", + "passport_copy_status", + "passport_copy_expiry_date", + "arc_status", + "arc_expiry_date" + ) ); } @@ -103,4 +123,28 @@ INSERT INTO worker ( expiryDate ); } + + private void insertDocument( + UUID documentId, + UUID workerId, + UUID companyId, + String documentType, + String submissionStatus, + String expiryDate + ) { + jdbcTemplate.update( + """ + INSERT INTO worker_document ( + worker_document_id, worker_id, company_id, + document_type, submission_status, expiry_date + ) VALUES (?, ?, ?, ?, ?, CAST(? AS DATE)) + """, + documentId, + workerId, + companyId, + documentType, + submissionStatus, + expiryDate + ); + } } diff --git a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java index 720abd9f..a5c0a03b 100644 --- a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java +++ b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java @@ -210,7 +210,8 @@ private WorkerAiContextSnapshot worker() { "ACTIVE", LocalDate.of(2026, 9, 30), LocalDate.of(2026, 1, 1), - LocalDate.of(2026, 8, 31) + LocalDate.of(2026, 8, 31), + Map.of() ); } } diff --git a/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java b/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java index 71b71ff9..a276b053 100644 --- a/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java +++ b/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java @@ -8,7 +8,10 @@ import com.fowoco.server.airun.application.error.AiContextResolutionFailureCode; import com.fowoco.server.task.domain.TaskType; import com.fowoco.server.worker.application.WorkerAiContextSnapshot; +import com.fowoco.server.worker.application.WorkerDocumentAiContextSnapshot; import com.fowoco.server.worker.application.port.WorkerAiContextReader; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; import com.fowoco.server.workflow.application.WorkflowCatalogService; import com.fowoco.server.workflow.domain.WorkflowCatalog; import com.fowoco.server.workflow.domain.WorkflowDefinition; @@ -37,16 +40,27 @@ void resolvesOnlyAllowListedWorkerFieldsAndReportsMissingValues() { AiSlotResolution result = transaction.resolve( COMPANY_A, "0.2.0", - requirement(List.of("worker_id", "stay_expiry_date", "due_at")) + requirement(List.of( + "worker_id", + "stay_expiry_date", + "passport_copy_status", + "passport_copy_expiry_date", + "arc_status", + "arc_expiry_date", + "due_at" + )) ); assertThat(boundCompany.get()).isEqualTo(COMPANY_A); assertThat(result.worker().workerRef()).isEqualTo(WORKER_A); assertThat(result.resolvedFields()).containsExactlyInAnyOrderEntriesOf(Map.of( "worker_id", WORKER_A.toString(), - "stay_expiry_date", "2026-09-30" + "stay_expiry_date", "2026-09-30", + "passport_copy_status", "VERIFIED", + "passport_copy_expiry_date", "2027-09-30", + "arc_status", "MISSING" )); - assertThat(result.missingFieldKeys()).containsExactly("due_at"); + assertThat(result.missingFieldKeys()).containsExactly("arc_expiry_date", "due_at"); assertThat(result.workflowConstraints()) .extracting(constraint -> constraint.workflowId()) .containsExactly("WF-CON-001", "WF-STY-001"); @@ -132,7 +146,15 @@ private WorkflowCatalog catalog() { List.of( workflow( "WF-STY-001", - Set.of("worker_id", "due_at", "stay_expiry_date") + Set.of( + "worker_id", + "due_at", + "stay_expiry_date", + "passport_copy_status", + "passport_copy_expiry_date", + "arc_status", + "arc_expiry_date" + ) ), workflow( "WF-CON-001", @@ -178,7 +200,21 @@ private WorkerAiContextSnapshot worker(UUID companyId) { "ACTIVE", LocalDate.of(2026, 9, 30), LocalDate.of(2026, 1, 1), - LocalDate.of(2026, 8, 31) + LocalDate.of(2026, 8, 31), + Map.of( + DocumentType.PASSPORT_COPY, + new WorkerDocumentAiContextSnapshot( + DocumentType.PASSPORT_COPY, + SubmissionStatus.VERIFIED, + LocalDate.of(2027, 9, 30) + ), + DocumentType.ARC, + new WorkerDocumentAiContextSnapshot( + DocumentType.ARC, + SubmissionStatus.MISSING, + null + ) + ) ); } 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 e875dac6..7de85175 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 @@ -3,6 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import com.jayway.jsonpath.JsonPath; +import com.fowoco.server.aiintegration.application.model.AiContextRequirement; +import com.fowoco.server.airun.application.AiSlotResolutionTransaction; +import java.math.BigDecimal; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -86,9 +89,11 @@ class DemoAuthSeedIntegrationTest { 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 Set REPRESENTATIVE_DOCUMENT_IDS = Set.of( + private static final Set REPRESENTATIVE_CONTEXT_DOCUMENT_IDS = Set.of( UUID.fromString("95000000-0000-0000-0000-000000000016"), - UUID.fromString("95000000-0000-0000-0000-000000000017"), + UUID.fromString("95000000-0000-0000-0000-000000000017") + ); + private static final Set RETIRED_REPRESENTATIVE_DOCUMENT_IDS = Set.of( UUID.fromString("95000000-0000-0000-0000-000000000018") ); private static final UUID CONTRACT_FILE_ID = @@ -111,7 +116,7 @@ class DemoAuthSeedIntegrationTest { Map.entry("worker", 28), Map.entry("task", 21), Map.entry("workflow_case", 21), - Map.entry("worker_document", 81), + Map.entry("worker_document", 83), Map.entry("stored_file", 3), Map.entry("task_checklist_item", 60), Map.entry("approval_request", 12), @@ -145,6 +150,9 @@ class DemoAuthSeedIntegrationTest { @Autowired private MutableClock mutableClock; + @Autowired + private AiSlotResolutionTransaction aiSlotResolutionTransaction; + @Autowired @Qualifier("demoAuthSeedRunner") private ApplicationRunner demoAuthSeedRunner; @@ -170,6 +178,7 @@ void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throw assertExactCountsAndDistributions(); assertRelativeDatesAndSafeData(); assertGoldenFlowStartState(); + assertGoldenFlowDocumentContext(); assertShowcaseApprovalAndSubmissionFixtures(); assertStoredFileFixtures(); assertTaskTimelineInvariants(); @@ -253,11 +262,11 @@ private void assertExactCountsAndDistributions() { ); assertThat(distribution("worker_document", "document_type", COMPANY_ID)) .containsExactlyInAnyOrderEntriesOf( - Map.of("PASSPORT_COPY", 25, "ARC", 27, "CONTRACT", 21, "PERMIT", 8) + Map.of("PASSPORT_COPY", 26, "ARC", 28, "CONTRACT", 21, "PERMIT", 8) ); assertThat(distribution("worker_document", "submission_status", COMPANY_ID)) .containsExactlyInAnyOrderEntriesOf( - Map.of("VERIFIED", 46, "SUBMITTED", 20, "MISSING", 15) + Map.of("VERIFIED", 47, "SUBMITTED", 20, "MISSING", 16) ); assertThat(distribution("approval_request", "status", COMPANY_ID)) .containsExactlyInAnyOrderEntriesOf( @@ -376,7 +385,7 @@ private void assertGoldenFlowStartState() { assertThat(countWhere("task", "worker_id = ?", COMPANY_ID, REPRESENTATIVE_WORKER_ID)) .isZero(); assertThat(countWhere("worker_document", "worker_id = ?", COMPANY_ID, REPRESENTATIVE_WORKER_ID)) - .isZero(); + .isEqualTo(2); assertThat(countWhere("stored_file", "worker_id = ?", COMPANY_ID, REPRESENTATIVE_WORKER_ID)) .isZero(); @@ -403,9 +412,39 @@ private void assertGoldenFlowStartState() { )).isZero(); assertThat(jdbcTemplate.queryForList( "SELECT worker_document_id FROM worker_document " - + "WHERE worker_document_id IN (?, ?, ?)", + + "WHERE worker_document_id IN (?, ?)", + UUID.class, + REPRESENTATIVE_CONTEXT_DOCUMENT_IDS.toArray() + )).containsExactlyInAnyOrderElementsOf(REPRESENTATIVE_CONTEXT_DOCUMENT_IDS); + assertThat(jdbcTemplate.queryForList( + """ + SELECT document_type, submission_status, expiry_date, task_id, file_id + FROM worker_document + WHERE worker_id = ? AND company_id = ? + ORDER BY document_type + """, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + )).satisfiesExactly( + arc -> { + assertThat(arc.get("document_type")).isEqualTo("ARC"); + assertThat(arc.get("submission_status")).isEqualTo("MISSING"); + assertThat(arc.get("expiry_date")).isNull(); + assertThat(arc.get("task_id")).isNull(); + assertThat(arc.get("file_id")).isNull(); + }, + passport -> { + assertThat(passport.get("document_type")).isEqualTo("PASSPORT_COPY"); + assertThat(passport.get("submission_status")).isEqualTo("VERIFIED"); + assertThat(passport.get("expiry_date")).isNotNull(); + assertThat(passport.get("task_id")).isNull(); + assertThat(passport.get("file_id")).isNull(); + } + ); + assertThat(jdbcTemplate.queryForList( + "SELECT worker_document_id FROM worker_document WHERE worker_document_id IN (?)", UUID.class, - REPRESENTATIVE_DOCUMENT_IDS.toArray() + RETIRED_REPRESENTATIVE_DOCUMENT_IDS.toArray() )).isEmpty(); assertThat(countWhere( "audit_event", @@ -447,6 +486,33 @@ private void assertGoldenFlowStartState() { )).isZero(); } + private void assertGoldenFlowDocumentContext() { + var resolution = aiSlotResolutionTransaction.resolve( + COMPANY_ID, + "0.2.0", + new AiContextRequirement( + "EXPIRY_RENEWAL", + BigDecimal.ONE, + "응웬반A", + Map.of(), + List.of( + "passport_copy_status", + "passport_copy_expiry_date", + "arc_status", + "arc_expiry_date" + ) + ) + ); + + assertThat(resolution.resolvedFields()) + .containsEntry("passport_copy_status", "VERIFIED") + .containsEntry("arc_status", "MISSING"); + assertThat(LocalDate.parse( + resolution.resolvedFields().get("passport_copy_expiry_date") + )).isAfter(LocalDate.now(clock)); + assertThat(resolution.missingFieldKeys()).containsExactly("arc_expiry_date"); + } + private void assertShowcaseApprovalAndSubmissionFixtures() { assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM approval_request WHERE task_id = ? " @@ -861,7 +927,7 @@ private void assertFrontendRelevantApiResultsAndTenantIsolation() throws Excepti HttpResponse documents = authorizedGet("/api/v1/documents?size=100", demoToken); assertOk(documents); - assertThat(JsonPath.read(documents.body(), "$.total_elements").intValue()).isEqualTo(81); + assertThat(JsonPath.read(documents.body(), "$.total_elements").intValue()).isEqualTo(83); assertDocumentStatusHasData(demoToken, "SUBMITTED"); assertDocumentStatusHasData(demoToken, "MISSING"); assertDocumentStatusHasData(demoToken, "VERIFIED"); diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalogTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalogTest.java index d992d4e7..37460b5f 100644 --- a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalogTest.java +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalogTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; import java.util.HashSet; import java.util.Set; import java.util.UUID; @@ -41,12 +43,21 @@ void excludesGoldenFlowOperationalDataWithoutRenumberingShowcaseFixtures() { "95000000-0000-0000-0000-000000000", 1, 84, - Set.of(16, 17, 18) + Set.of(18) )); assertThat(catalog.demoDocuments()) - .noneMatch(seed -> seed.workerId().equals( + .filteredOn(seed -> seed.workerId().equals( DemoOperationalSeedCatalog.GOLDEN_FLOW_WORKER_ID - )); + )) + .hasSize(2) + .allMatch(seed -> seed.taskId() == null && seed.fileId() == null) + .anyMatch(seed -> seed.documentType() == DocumentType.PASSPORT_COPY + && seed.submissionStatus() == SubmissionStatus.VERIFIED + && seed.expiryDays() != null + && seed.expiryDays() > 0) + .anyMatch(seed -> seed.documentType() == DocumentType.ARC + && seed.submissionStatus() == SubmissionStatus.MISSING + && seed.expiryDays() == null); assertThat(catalog.demoChecklists().stream().map(seed -> seed.checklistItemId())) .containsExactlyInAnyOrderElementsOf(idsExcept( "94200000-0000-0000-0000-000000000", diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java index e5427c84..de5991ae 100644 --- a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java @@ -33,7 +33,7 @@ class DemoSeedPostgreSqlApplicationIntegrationTest { Map.entry("worker", 28), Map.entry("workflow_case", 21), Map.entry("task", 21), - Map.entry("worker_document", 81), + Map.entry("worker_document", 83), Map.entry("stored_file", 3), Map.entry("task_checklist_item", 60), Map.entry("approval_request", 12), @@ -151,7 +151,35 @@ private void assertGoldenFlowStartState(JdbcTemplate jdbcTemplate) { Integer.class, REPRESENTATIVE_WORKER_ID, COMPANY_ID - )).isZero(); + )).isEqualTo(2); + assertThat(jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM worker_document + WHERE worker_id = ? AND company_id = ? + AND document_type = 'PASSPORT_COPY' + AND submission_status = 'VERIFIED' + AND expiry_date > CURRENT_DATE + AND task_id IS NULL AND file_id IS NULL + """, + Integer.class, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + )).isEqualTo(1); + assertThat(jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM worker_document + WHERE worker_id = ? AND company_id = ? + AND document_type = 'ARC' + AND submission_status = 'MISSING' + AND expiry_date IS NULL + AND task_id IS NULL AND file_id IS NULL + """, + Integer.class, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + )).isEqualTo(1); assertThat(jdbcTemplate.queryForObject( """ SELECT COUNT(*) From c09e80e7a96c7d579dd134f39febfd0f5cce5410 Mon Sep 17 00:00:00 2001 From: krestar Date: Fri, 7 Aug 2026 16:35:52 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs(seed):=20=EC=9D=91=EC=9B=AC=EB=B0=98A?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=A4=80=EB=B9=84=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=99=80=20AI=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응웬반A의 여권 VERIFIED 및 ARC MISSING 시작 상태 문서화 - Demo Company WorkerDocument 수량과 상태 분포 갱신 - Golden Flow 선행 문서와 미생성 데이터 경계 명시 - PostgreSQL 재기동 Smoke 기준을 문서 2건 계약에 맞게 수정 - PR 본문에 WorkerDocument 기반 AI Context 변경 사항 반영 --- docs/demo-seed-fixture-manifest.md | 24 ++++++++++++++++-------- docs/demo-seed.md | 27 ++++++++++++++++++++++----- docs/deployment-runbook.md | 5 +++-- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/docs/demo-seed-fixture-manifest.md b/docs/demo-seed-fixture-manifest.md index 646b423a..0070ac7e 100644 --- a/docs/demo-seed-fixture-manifest.md +++ b/docs/demo-seed-fixture-manifest.md @@ -11,7 +11,7 @@ API가 지원하는 fixture만 나열한다. | 구분 | 목적 | 응웬반A 포함 방식 | | --- | --- | --- | -| Golden Flow 시작 데이터 | HR 자연어 요청부터 실제 흐름 시연 | Worker와 선행 참조만 존재 | +| Golden Flow 시작 데이터 | HR 자연어 요청부터 실제 흐름 시연 | Worker와 최소 문서 상태·선행 참조만 존재 | | Showcase Seed | 목록·업무함·문서함의 상태 다양성과 화면 밀도 | 응웬반A의 진행 데이터는 포함하지 않음 | 다른 27명 근로자의 기존 Showcase WorkerDocument, Case, Task, Approval, Submission, @@ -24,6 +24,8 @@ Evidence, Audit와 고정 ID는 유지한다. | Company | `90000000-0000-0000-0000-000000000001` | `FOWOCO Demo Company`, `ACTIVE` | 인증·tenant 범위 | | HR 사용자 | `demo.hr01@example.com` | 같은 Company의 `HR` | 로그인 및 권한 기반 API | | Worker | `92000000-0000-0000-0000-000000000006` | 응웬반A, `VN`, `vi`, `ACTIVE`, 체류 만료 `D+45` | `GET /api/v1/workers`, `GET /api/v1/workers/{workerId}` | +| 여권 사본 | `95000000-0000-0000-0000-000000000016` | `PASSPORT_COPY`, `VERIFIED`, 만료 `D+365` | 문서 API·AI Context | +| 외국인등록증 사본 | `95000000-0000-0000-0000-000000000017` | `ARC`, `MISSING`, 만료일 없음 | 문서 API·AI Context | | Workflow Catalog | version `0.2.0` | classpath projection 사용 가능 | Workflow 및 AI 요청 흐름 | `D+45`는 현재 Worker 모델에 저장되는 상대 날짜 예시다. 정확한 E-9 취업활동기간이나 @@ -32,14 +34,19 @@ Evidence, Audit와 고정 ID는 유지한다. 별도 Workplace, 연락처, 활성 Agent Version 또는 활성 Prompt Version Seed는 없다. Agent·Prompt 정보는 실제 실행 후 `AiAttempt` 메타데이터로 기록된다. -## 응웬반A에게 사전 생성하지 않는 fixture +두 문서는 Task나 StoredFile에 연결하지 않는다. `VERIFIED` 여권은 검증 상태와 유효한 +만료일만 나타내며 OCR 결과나 여권번호가 있다는 뜻이 아니다. `MISSING` ARC row는 필요한 +문서가 현재 누락된 상태임을 row 부재와 구분한다. Server는 AI Runtime이 요청한 경우에만 +이 네 가지 문서 상태·만료일 필드를 같은 tenant 범위의 Context로 제공한다. + +## 응웬반A의 Golden Flow 경계 | 영역 | 시작 상태 | | --- | --- | | AI 실행 | `AiRun`, `AiAttempt`, Question, Candidate, Candidate Decision 0건 | | Case·Task | 응웬반A Case 0건, Task 0건 | | Task 후속 상태 | Checklist, Approval, Transition, Draft, 완료 상태 0건 | -| 근로자 문서 | WorkerDocument 0건; 여권·ARC·계약서·OCR fixture 없음 | +| 근로자 문서 | 여권·ARC 상태 2건; 계약서·StoredFile·OCR fixture 없음 | | 근로자 통신 | WorkerLink, WorkerResponse 0건 | | 제출·증빙 | ExternalSubmission, Evidence 0건 | | Activity·Audit | 대표 흐름 관련 Event 0건 | @@ -56,7 +63,7 @@ Worker Link와 Worker Response API가 구현되어 있어도 Seed는 응웬반A | --- | --- | | Case | `94100000-0000-0000-0000-000000000006` | | Task | `94000000-0000-0000-0000-000000000006` ~ `...0008` | -| WorkerDocument | `95000000-0000-0000-0000-000000000016` ~ `...0018` | +| WorkerDocument | `95000000-0000-0000-0000-000000000018` | | Checklist | `94200000-0000-0000-0000-000000000015` ~ `...0022` | | Approval | `94300000-0000-0000-0000-000000000002` | | Transition | `94400000-0000-0000-0000-000000000013` ~ `...0016` | @@ -73,7 +80,7 @@ Worker Link와 Worker Response API가 구현되어 있어도 Seed는 응웬반A | Worker | 28 | 응웬반A 1명과 Showcase 근로자 27명 | | Case | 21 | 다른 근로자의 Showcase Case | | Task | 21 | 체류연장 9, 재계약 7, 고용기간 연장 5 | -| WorkerDocument | 81 | 여권 25, ARC 27, 계약서 21, 허가서 8 | +| WorkerDocument | 83 | 여권 26, ARC 28, 계약서 21, 허가서 8 | | Checklist | 60 | Showcase Task 연결 | | Approval | 12 | 대기 3, 승인 7, 반려 1, 무효 1 | | Transition | 48 | Showcase 상태 전이 | @@ -83,8 +90,9 @@ Worker Link와 Worker Response API가 구현되어 있어도 Seed는 응웬반A | Audit Event | 88 | HR 77, AI 2, 시스템 6, Worker Link 3 | | StoredFile | 3 | 합성 PDF | -Catalog는 구버전 전체 목록의 순번을 유지한 채 응웬반A 관련 closure만 제외한다. 따라서 -제외 지점 뒤에 있는 다른 Worker의 파생 ID와 연결 관계는 바뀌지 않는다. +Catalog는 구버전 전체 목록의 순번을 유지한 채 응웬반A의 여권·ARC는 Task 연결을 제거해 +시작 Context로 남기고, 계약서와 나머지 진행 closure만 제외한다. 따라서 제외 지점 뒤에 +있는 다른 Worker의 파생 ID와 연결 관계는 바뀌지 않는다. ## Showcase 상태 분포 @@ -95,7 +103,7 @@ Catalog는 구버전 전체 목록의 순번을 유지한 채 응웬반A 관련 | 근로자 대기 | `WAITING_WORKER` 3건과 문서 요청 Draft·Audit 근사 | | 외부 처리 대기 | `WAITING_EXTERNAL` 3건 | | 완료·취소 | `COMPLETED` 5건, `CANCELLED` 1건과 관련 Evidence·Audit | -| 문서 검토 | `VERIFIED` 46, `SUBMITTED` 20, `MISSING` 15 | +| 문서 검토 | `VERIFIED` 47, `SUBMITTED` 20, `MISSING` 16 | Dashboard 수치는 별도 Seed Domain이 아니라 조회 결과를 클라이언트가 조합한다. Audit의 `WORKER_LINK` actor 예시는 Showcase 활동 표현이며 실제 Worker Link token row를 Seed한 diff --git a/docs/demo-seed.md b/docs/demo-seed.md index e09cc2a6..12f87885 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -79,6 +79,8 @@ Demo Company에는 `ADMIN` 2명, `HR` 12명, `VIEWER` 6명이 있고 Test Compan | 국적·기본 언어 | `VN` · `vi` | | 근로 상태 | `ACTIVE` | | 현재 상대 날짜 | 체류 만료 `D+45`, 계약 시작 `D-1년`, 계약 종료 `D+180` | +| 여권 사본 | `PASSPORT_COPY`, `VERIFIED`, 만료 `D+365` | +| 외국인등록증 사본 | `ARC`, `MISSING`, 만료일 없음 | | Workflow Catalog | classpath projection, version `0.2.0` | 상대 날짜는 Worker가 처음 생성되는 날을 기준으로 저장되며 재실행 시 기존 값을 @@ -95,15 +97,25 @@ Agent·Prompt 버전은 실제 AI 실행 후 `AiAttempt` 메타데이터로 기 - Demo Company와 HR 사용자·현재 역할 - 응웬반A Worker 기본정보 +- 응웬반A의 검증된 유효 여권 사본 상태와 외국인등록증 사본 누락 상태 - 현재 구현된 Workflow Catalog와 Workflow Version - 현재 구현된 Task Type, 공통 코드와 상태값 +응웬반A의 두 `WorkerDocument`는 Golden Flow 판단에 필요한 최소 메타데이터다. 여권은 +`VERIFIED`이며 만료일이 현재보다 미래이고, ARC는 필요한 문서지만 현재 누락된 상태를 +명시하는 `MISSING`이다. 두 문서 모두 `task_id`와 `file_id`가 없으며 여권번호, +외국인등록번호, OCR 결과와 신분증 이미지를 포함하지 않는다. + +AI Runtime이 해당 필드를 요청하면 Server는 같은 `company_id` 범위에서 문서를 조회해 +`passport_copy_status`, `passport_copy_expiry_date`, `arc_status`, `arc_expiry_date`를 +구조화된 Context로 제공한다. Runtime은 이 Server 소유 값을 다른 값으로 변경할 수 없다. + ### 시연 전에 존재하지 않는 데이터 - 응웬반A 요청의 `AiRun`, `AiAttempt`, Question, Candidate, Candidate Decision - 응웬반A의 대표 Case와 Task - 해당 Task의 Checklist, Approval, Document Request Draft -- 응웬반A의 `WorkerDocument`, 업로드 파일, OCR 결과 +- 응웬반A의 계약서 `WorkerDocument`, 업로드 파일, OCR 결과 - `WorkerLink`, `WorkerResponse` - `ExternalSubmission`, `Evidence` - 대표 흐름의 Activity, Audit Event와 완료 상태 @@ -125,7 +137,7 @@ Agent·Prompt 버전은 실제 AI 실행 후 `AiAttempt` 메타데이터로 기 | 근로자 | 28 | `ACTIVE` 25, `ON_LEAVE` 3 | | Case | 21 | Showcase Task별 Case | | Task | 21 | 체류연장 9, 재계약 7, 고용기간 연장 5 | -| 근로자 문서 | 81 | 여권 25, ARC 27, 계약서 21, 허가서 8 | +| 근로자 문서 | 83 | 여권 26, ARC 28, 계약서 21, 허가서 8 | | 체크리스트 항목 | 60 | Showcase Task에 연결 | | 승인 요청 | 12 | `PENDING` 3, `APPROVED` 7, `REJECTED` 1, `INVALIDATED` 1 | | 상태 전이 이력 | 48 | Showcase Task 상태 이력 | @@ -139,8 +151,8 @@ Task 상태는 `DRAFT` 2, `NEEDS_INFO` 2, `READY_FOR_REVIEW` 3, `APPROVED` 2, `WAITING_WORKER` 3, `WAITING_EXTERNAL` 3, `COMPLETED` 5, `CANCELLED` 1이다. -문서 상태는 `VERIFIED` 46, `SUBMITTED` 20, `MISSING` 15다. 응웬반A는 이 -81건에 포함되지 않는다. +문서 상태는 `VERIFIED` 47, `SUBMITTED` 20, `MISSING` 16이다. 응웬반A의 여권과 +ARC 문서 2건이 이 83건에 포함된다. ### FOWOCO Test Company @@ -235,12 +247,17 @@ SELECT COUNT(*) FROM task WHERE worker_id = '92000000-0000-0000-0000-000000000006'; SELECT COUNT(*) FROM worker_document WHERE worker_id = '92000000-0000-0000-0000-000000000006'; + +SELECT document_type, submission_status, expiry_date, task_id, file_id +FROM worker_document +WHERE worker_id = '92000000-0000-0000-0000-000000000006' +ORDER BY document_type; ``` 기대 로그의 핵심 수량은 다음과 같다. ```text -demo_task_count=21 demo_stored_file_count=3 demo_document_count=81 demo_audit_count=88 +demo_task_count=21 demo_stored_file_count=3 demo_document_count=83 demo_audit_count=88 test_task_count=3 test_document_count=8 test_audit_count=8 ``` diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 0f8d149b..58bef30e 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -83,8 +83,9 @@ docker compose -f compose.demo.yml up --build server 두 번째 기동도 성공하고 응웬반A Worker `92000000-0000-0000-0000-000000000006`가 한 건 유지되며, 응웬반A의 Golden Flow -Case·Task·WorkerDocument는 0건이어야 합니다. 다른 Showcase Seed의 수량과 고정 ID도 첫 -기동과 같아야 합니다. +Case·Task는 0건이어야 합니다. WorkerDocument는 Task·StoredFile 연결이 없는 +`PASSPORT_COPY/VERIFIED` 1건과 `ARC/MISSING` 1건만 유지되어야 합니다. 다른 Showcase +Seed의 수량과 고정 ID도 첫 기동과 같아야 합니다. 종료 시 `docker compose -f compose.demo.yml down`을 사용합니다. DB 데이터를 지우려는 경우에만 정확한 Compose project와 전용 volume인지 확인한 뒤 별도로 volume 삭제를