From 5193471509d287fa9b9d195fb190b54f33197fd8 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 11:15:08 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(workflow):=20Knowledge=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9E=AC=EA=B3=84=EC=95=BD=20Case=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/demo-seed.md | 4 + docs/golden-renewal-manual-e2e.md | 244 ++++++++++++++++++ .../application/CaseQueryService.java | 35 ++- .../persistence/JdbcTaskCaseRegistrar.java | 14 +- .../AiCandidateTaskCreationService.java | 207 ++++++++------- .../workflow/domain/WorkflowCaseTemplate.java | 53 ++++ .../workflow/domain/WorkflowCatalog.java | 29 ++- .../ResourceWorkflowCatalogRepository.java | 140 +++++++++- .../workflow/catalog-projection.local.json | 85 ++++++ .../server/airun/AiRunApiIntegrationTest.java | 36 ++- .../casework/CaseQueryIntegrationTest.java | 58 +++++ .../WorkflowCatalogProjectionTest.java | 16 ++ 13 files changed, 812 insertions(+), 110 deletions(-) create mode 100644 docs/golden-renewal-manual-e2e.md create mode 100644 src/main/java/com/fowoco/server/workflow/domain/WorkflowCaseTemplate.java diff --git a/README.md b/README.md index b13d2a86..38ebf859 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,7 @@ src/main/java/com/fowoco/server/ | DB 테이블·ERD | [Database 문서](https://fowoco.github.io/server/) | Flyway를 빈 PostgreSQL에 적용해 자동 생성한 구조 | | 로컬 실행·인증·Workflow | [개발 가이드](docs/development-guide.md) | 처음 서버를 실행하고 기능 흐름을 이해하는 방법 | | Demo Seed 수량·시나리오 | [Demo Seed 운영 시나리오](docs/demo-seed.md) | 로컬 데모 데이터의 기준 수량, 대표 흐름과 표현 한계 | +| 재계약·연장 수동 E2E | [Golden Flow 수동 시연 가이드](docs/golden-renewal-manual-e2e.md) | HR 요청부터 근로자 서류 제출, OCR 검토와 연장 업무 완료까지 직접 확인하는 순서 | | Docker·데모 배포 | [Server 데모 배포 Runbook](docs/deployment-runbook.md) | 로컬 Compose, 필수 Secret, Smoke와 rollback 기준 | | Figma fixture 대응표 | [Figma Demo Fixture Manifest](docs/demo-seed-fixture-manifest.md) | 화면 요구사항별 예약 데이터와 현재 API 노출 범위 | | 패키지·모듈 경계 | [프로젝트 구조](docs/project-structure.md) | 코드를 어느 패키지에 구현해야 하는지 설명 | diff --git a/docs/demo-seed.md b/docs/demo-seed.md index 7fa052ef..ee64c284 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -98,6 +98,10 @@ HR은 같은 Demo Company 범위에서 응웬반A를 조회하고 현재 구현 Worker 연락처, 활성 Agent Version 또는 활성 Prompt Version Seed 모델은 만들지 않는다. Agent·Prompt 버전은 실제 AI 실행 후 `AiAttempt` 메타데이터로 기록된다. +Candidate를 채택하면 Knowledge의 `CASE-EXPIRY-RENEWAL-001`을 기준으로 재계약, +조건부 신분서류 보완, 취업활동기간 연장, 체류기간 연장 업무가 생성된다. 전체 시연 순서는 +[재계약·연장 Golden Flow 수동 시연 가이드](golden-renewal-manual-e2e.md)를 따른다. + ### 시연 전에 존재하는 데이터 - Demo Company와 HR 사용자·현재 역할 diff --git a/docs/golden-renewal-manual-e2e.md b/docs/golden-renewal-manual-e2e.md new file mode 100644 index 00000000..d1f33322 --- /dev/null +++ b/docs/golden-renewal-manual-e2e.md @@ -0,0 +1,244 @@ +# 재계약·연장 Golden Flow 수동 시연 가이드 + +이 문서는 합성 근로자 `응웬반A`의 재계약, 취업활동기간 연장, 체류기간 연장을 +HR과 근로자 역할로 직접 끝까지 진행하기 위한 체크리스트다. 실제 개인정보나 실제 +기관 제출 자료를 사용하지 않는다. + +## 시연 목표 + +```text +HR 자연어 요청 +→ AI가 EXPIRY_RENEWAL / WF-STY-001 선택 +→ HR이 후보 채택 +→ CASE-EXPIRY-RENEWAL-001 Case와 업무 4개 생성 +→ HR이 재계약 조건과 계약서 초안 검토 +→ 근로자에게 ARC 보완 링크 전달 +→ 근로자가 합성 ARC 제출 +→ HR이 제출 파일 채택·OCR 검토 +→ 취업활동기간 연장과 체류기간 연장을 차례로 기록 +→ 모든 업무와 Case 완료 +``` + +외부기관 로그인과 실제 제출은 자동화하지 않는다. HR이 실제 실행 결과를 기록하는 +지점까지만 FOWOCO가 관리한다. + +## 1. 시작 전 준비 + +### Demo Seed만 빠르게 실행 + +```bash +export DEMO_SEED_ENABLED=true +export DEMO_SEED_ADMIN_PASSWORD='<12자 이상 로컬 합성 비밀번호>' +./gradlew bootRun +``` + +기본 `local` profile은 H2 인메모리 DB를 사용한다. 서버를 종료하면 시연 중 만든 +Case와 Task도 초기화된다. + +### 제출 파일까지 준비하는 PostgreSQL 통합 실행 + +민감한 네 환경변수는 터미널에만 설정하고 Git, Issue, PR에 값을 남기지 않는다. + +```bash +export DEMO_DB_PASSWORD='' +export JWT_SECRET_BASE64='<32-byte-base64-secret>' +export DEMO_SEED_ADMIN_PASSWORD='<12자 이상 로컬 합성 비밀번호>' +export OCR_RESULT_ENCRYPTION_KEY_BASE64='<32-byte-base64-secret>' + +./scripts/demo-data import +./scripts/demo-data verify +``` + +이 명령은 PostgreSQL 16, Server와 합성 이미지·PDF·HWP·HWPX 파일을 준비한다. 같은 +명령을 다시 실행해도 예약 데이터가 중복 생성되지 않는다. + +준비 완료 기준은 다음과 같다. + +- Server: `http://127.0.0.1:8080/actuator/health` +- Swagger: `http://127.0.0.1:8080/swagger-ui/index.html` +- Client: `http://127.0.0.1:5173` +- AI Runtime: `http://127.0.0.1:8000/docs` +- 대표 HR: `demo.hr01@example.com` +- 비밀번호: `DEMO_SEED_ADMIN_PASSWORD`에 설정한 값 +- 대표 근로자: `응웬반A`, ID `92000000-0000-0000-0000-000000000006` + +> Client의 자연어 분석 화면 연결은 Client Draft PR #360을 사용자 검수 후 반영해야 +> 한다. 그 전에도 Swagger의 같은 API로 Server 흐름을 검증할 수 있다. + +## 2. HR이 업무를 시작한다 + +1. 대표 HR 계정으로 로그인한다. +2. Today 화면의 자연어 요청 입력창에 다음 문장을 입력한다. + + > 응웬반A의 재계약과 취업활동기간·체류기간 연장을 준비해줘 + +3. AI가 목표 완료일을 질문하면 오늘 이후의 날짜를 선택해 답한다. +4. 분석 결과가 다음 값인지 확인한다. + + - Intent: `EXPIRY_RENEWAL` + - Workflow: `WF-STY-001` + - 대상 근로자: `응웬반A` + +5. 후보를 채택한다. 이때만 Case와 Task가 만들어진다. + +화면 연결을 확인할 때 사용하는 Server API는 다음과 같다. + +```text +POST /api/v1/ai-runs +POST /api/v1/ai-runs/{aiRunId}/answers +POST /api/v1/ai-runs/{aiRunId}/candidate-decisions +GET /api/v1/ai-runs/{aiRunId} +GET /api/v1/ai-runs/{aiRunId}/events +``` + +생성 결과는 하나의 `caseId` 아래 다음 순서를 가져야 한다. + +| 순서 | 업무 | 시작 조건 | +| ---: | --- | --- | +| 1 | 재계약 의사와 근로조건 확정 | 즉시 시작 | +| 2 | 신분서류 보완 | 여권 또는 ARC가 누락·만료·검토 필요할 때만 생성 | +| 3 | 취업활동기간 연장 | 1번과, 생성된 경우 2번 완료 후 | +| 4 | 체류기간 연장 | 3번과, 생성된 경우 2번 완료 후 | + +Demo Seed의 응웬반A는 여권이 유효하고 ARC가 누락되어 있으므로 네 업무가 모두 생성되어야 +한다. Case 상세는 `GET /api/v1/cases/{caseId}/projection`으로도 확인할 수 있다. + +## 3. 재계약 조건과 계약서 초안을 검토한다 + +첫 업무에서 다음 항목을 HR이 확인하거나 입력한다. + +- 재계약 의사와 새 계약기간 +- 임금, 근로시간·휴게시간 +- 근무 장소와 업무 내용 +- 숙식 제공 조건 +- 최신 표준근로계약서 양식 사용 여부 +- 고용허가기간 연장 적용 여부 + +필수 체크리스트를 완료한 뒤 Renewal을 실행한다. AI 결과는 자동 승인·자동 발송되지 +않으며, Server가 기존 Task, 안내 초안, 생성 문서에 연결한다. + +```text +POST /api/v1/tasks/{taskId}/renewal-run +GET /api/v1/tasks/{taskId}/document-request-draft +``` + +HR은 생성된 계약서의 주요 값과 원본 근거를 확인하고 승인한다. 서명본 또는 HR 확인을 +증빙으로 남긴 뒤 첫 업무를 완료한다. + +```text +POST /api/v1/tasks/{taskId}/approval-requests +POST /api/v1/tasks/{taskId}/approve +POST /api/v1/tasks/{taskId}/evidence +POST /api/v1/tasks/{taskId}/complete +``` + +## 4. 근로자에게 ARC 보완을 요청한다 + +두 번째 업무에서 안내 초안을 HR이 확인한 뒤 Worker Link를 발급한다. SMS Provider를 +사용하지 않는 로컬 시연에서는 응답의 공개 URL을 브라우저 시크릿 창에 직접 연다. + +```text +POST /api/v1/tasks/{taskId}/worker-link +GET /api/v1/tasks/{taskId}/worker-link/delivery +``` + +실제 SMS 시험은 승인된 발신번호와 테스트 수신번호가 설정된 경우에만 실행한다. + +```text +POST /api/v1/worker-links/{workerLinkId}/sms-deliveries +``` + +근로자 화면에는 내부 key 대신 `외국인등록증 사본`처럼 이해할 수 있는 이름, 쉬운 한국어와 +대상 언어 안내, 제출기한이 보여야 한다. 안내 생성에 실패한 상태에서는 자동 발송하지 않는다. + +## 5. 근로자 역할로 합성 서류를 제출한다 + +1. 공개 Worker Link를 시크릿 창 또는 다른 브라우저에서 연다. +2. 안내 내용을 확인한다. +3. 요청 서류에서 `외국인등록증 사본`을 선택한다. +4. Demo Data가 생성한 응웬반A의 합성 ARC 앞면 PNG를 선택한다. +5. 제출 버튼을 누르고 완료 화면을 확인한다. + +합성 파일에는 `DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION` 표시가 있으며 실제 신분증이 +아니다. 공개 API는 JWT 대신 만료되는 Worker Link token만 사용한다. + +```text +GET /api/v1/public/worker-links/{token} +POST /api/v1/public/worker-links/{token}/files +POST /api/v1/public/worker-links/{token}/responses +``` + +같은 응답을 다시 제출하거나 새로고침해도 공식 문서가 중복 생성되지 않아야 한다. 만료된 +링크는 제출을 거부하고 HR에게 재발급을 요청하도록 안내해야 한다. + +## 6. HR이 제출물을 회수하고 OCR을 검토한다 + +1. HR 화면으로 돌아와 근로자 응답과 파일을 연다. +2. 파일이 응웬반A의 합성 ARC인지 확인한다. +3. 제출 파일을 공식 `WorkerDocument`로 채택한다. +4. OCR 상태가 완료될 때까지 조회한다. +5. OCR 원본과 추출값을 비교하고 필요한 값만 수정한 뒤 검토 완료한다. + +```text +GET /api/v1/tasks/{taskId}/worker-responses +POST /api/v1/tasks/{taskId}/worker-responses/{responseId}/documents/adopt +POST /api/v1/documents/{documentId}/ocr-runs +GET /api/v1/documents/{documentId}/ocr-runs/latest +POST /api/v1/documents/{documentId}/ocr-runs/{ocrRunId}/review +``` + +OCR 결과만으로 Worker의 개인정보를 자동 수정하지 않는다. HR이 승인한 OCR Context만 +기존 업무를 재실행할 때 사용한다. 같은 파일·이벤트를 다시 처리해도 기존 OCR Run과 +WorkerDocument를 재사용해야 한다. + +## 7. 취업활동기간과 체류기간 연장을 마친다 + +앞선 의존 업무가 완료되면 Case의 `currentTask`가 세 번째 업무로 이동해야 한다. + +### 취업활동기간 연장 + +- 별지 제12호의3 초안과 첨부서류를 검토한다. +- HR 승인 후 고용 관련 기관에 직접 제출한다. +- 접수처, 안전한 합성 접수번호, 제출시각을 기록한다. +- 접수증을 증빙으로 남기고 업무를 완료한다. + +### 체류기간 연장 + +- 통합신청서와 체류 관련 증빙을 검토한다. +- HR 승인 후 공식 사이트 또는 관할기관에서 직접 제출한다. +- 접수 결과와 최종 처리 결과를 기록한다. +- 증빙을 남기고 마지막 업무를 완료한다. + +```text +POST /api/v1/tasks/{taskId}/external-submissions +POST /api/v1/tasks/{taskId}/evidence +POST /api/v1/tasks/{taskId}/complete +``` + +## 8. 최종 완료 기준 + +- Case에 생성된 Task가 Catalog 순서와 의존성을 유지한다. +- 조건부 신분서류 업무는 실제 문서 상태에 따라 생성된다. +- 근로자 제출 파일이 다른 회사나 다른 근로자에게 연결되지 않는다. +- HR 승인 없이 안내·SMS·문서·외부 제출이 자동 실행되지 않는다. +- 생성 HWP/HWPX/PDF를 미리보기 또는 다운로드할 수 있다. +- 모든 외부 제출과 완료에는 증빙과 Audit Event가 남는다. +- 마지막 Task 완료 후 Case 진행률은 100%이고 완료 상태로 조회된다. +- 같은 Idempotency-Key와 같은 이벤트를 재실행해도 Case, Task, 문서가 중복되지 않는다. + +## 9. 문제가 생겼을 때 확인 순서 + +| 증상 | 먼저 확인할 값 | +| --- | --- | +| 대상 근로자를 찾지 못함 | 발화문 이름, `응웬반A` 조회 결과, Server #206 반영 여부 | +| 추가정보 답변이 저장되지 않음 | `POST .../answers`의 실제 HTTP status와 `expected_version` | +| Case에 업무가 2개만 보임 | Knowledge #60과 Server Golden Flow PR 반영 여부 | +| 신분서류 업무가 없음 | 응웬반A ARC 상태가 `MISSING`인지 확인 | +| 모바일 링크가 열리지 않음 | 원본 token, 링크 만료시각, public path 확인 | +| 제출 파일이 안 보임 | Worker Response와 StoredFile 저장 여부, HR 사업장 권한 확인 | +| OCR이 계속 대기 | Outbox backlog, OCR Provider 활성화, 암호화 키 확인 | +| 다음 업무가 열리지 않음 | 선행 Task들의 `COMPLETED`, 생성된 조건부 업무 완료 여부 | +| 문서가 생성되지 않음 | Renewal 응답 `missingSlots`, 생성 문서 status와 FileStorage 경로 확인 | + +Server 로그와 Prometheus 측정 방법은 +[AI 파이프라인 관측 가이드](ai-pipeline-observability.md)를 따른다. diff --git a/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java b/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java index e47fbf48..30902f2b 100644 --- a/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java +++ b/src/main/java/com/fowoco/server/casework/application/CaseQueryService.java @@ -169,10 +169,11 @@ private boolean dependencySatisfied( Map statusesByTaskId ) { SnapshotStep step = snapshotSteps.get(task.taskId()); - if (step == null || step.dependsOnTaskId() == null) { + if (step == null || step.dependsOnTaskIds().isEmpty()) { return true; } - return statusesByTaskId.get(step.dependsOnTaskId()) == TaskStatus.COMPLETED; + return step.dependsOnTaskIds().stream() + .allMatch(taskId -> statusesByTaskId.get(taskId) == TaskStatus.COMPLETED); } private Map snapshotSteps(Map snapshot) { @@ -192,11 +193,17 @@ private Map snapshotSteps(Map snapshot) { int order = step.get("order") instanceof Number number ? number.intValue() : Integer.MAX_VALUE; - UUID dependsOnTaskId = null; + List dependsOnTaskIds = List.of(); if (step.get("required_conditions") instanceof Map conditions) { - dependsOnTaskId = uuidValue(conditions.get("depends_on_task_id")); + dependsOnTaskIds = uuidValues(conditions.get("depends_on_task_ids")); + if (dependsOnTaskIds.isEmpty()) { + UUID legacyDependency = uuidValue(conditions.get("depends_on_task_id")); + dependsOnTaskIds = legacyDependency == null + ? List.of() + : List.of(legacyDependency); + } } - result.put(taskId, new SnapshotStep(order, dependsOnTaskId)); + result.put(taskId, new SnapshotStep(order, dependsOnTaskIds)); } return Map.copyOf(result); } @@ -212,6 +219,16 @@ private UUID uuidValue(Object value) { } } + private List uuidValues(Object value) { + if (!(value instanceof List values)) { + return List.of(); + } + return values.stream() + .map(this::uuidValue) + .filter(java.util.Objects::nonNull) + .toList(); + } + private static CaseTaskProjection toTaskProjection(CaseTaskRecord task) { return new CaseTaskProjection( task.taskId(), @@ -232,10 +249,14 @@ private Map decodeSnapshot(String snapshotJson) { } } - private record SnapshotStep(int order, UUID dependsOnTaskId) { + private record SnapshotStep(int order, List dependsOnTaskIds) { + + private SnapshotStep { + dependsOnTaskIds = List.copyOf(dependsOnTaskIds); + } private static SnapshotStep fallback() { - return new SnapshotStep(Integer.MAX_VALUE, null); + return new SnapshotStep(Integer.MAX_VALUE, List.of()); } } } diff --git a/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseRegistrar.java b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseRegistrar.java index 16736634..5b1ea8f3 100644 --- a/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseRegistrar.java +++ b/src/main/java/com/fowoco/server/casework/infrastructure/persistence/JdbcTaskCaseRegistrar.java @@ -78,7 +78,7 @@ INSERT INTO workflow_case ( first.caseId(), first.companyId(), first.workerId(), - orderedTasks.size() == 1 ? first.title() : "3년 만료 연장 준비", + orderedTasks.size() == 1 ? first.title() : caseTitle(orderedTasks), priority(orderedTasks, today), first.workflowCatalogVersion(), snapshot(orderedTasks), @@ -96,6 +96,10 @@ private String snapshot(List caseTasks) { Task first = caseTasks.get(0).task(); Map snapshot = new LinkedHashMap<>(); snapshot.put("workflow_catalog_version", first.workflowCatalogVersion()); + Object caseTemplateId = businessData(first).get("case_template_id"); + if (caseTemplateId instanceof String value && !value.isBlank()) { + snapshot.put("case_template_id", value); + } snapshot.put("steps", steps); try { return objectMapper.writeValueAsString(snapshot); @@ -114,6 +118,7 @@ private Map snapshotStep(CaseTask caseTask, int fallbackOrder) { List.of( "approval_required", "depends_on_task_id", + "depends_on_task_ids", "dependency_reason", "missing_information", "submission_due_offset_days", @@ -135,6 +140,13 @@ private Map snapshotStep(CaseTask caseTask, int fallbackOrder) { return Map.copyOf(step); } + private String caseTitle(List caseTasks) { + Object value = businessData(caseTasks.get(0).task()).get("case_title"); + return value instanceof String title && !title.isBlank() + ? title + : "3년 만료 연장 준비"; + } + @SuppressWarnings("unchecked") private Map businessData(Task task) { try { diff --git a/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java b/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java index 436bf55d..a48b680a 100644 --- a/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java +++ b/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java @@ -32,6 +32,9 @@ 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.WorkflowCaseTemplate; +import com.fowoco.server.workflow.domain.WorkflowCaseTemplate.ActivationMode; +import com.fowoco.server.workflow.domain.WorkflowCaseTemplate.TaskTemplate; import com.fowoco.server.workflow.domain.WorkflowDefinition; import java.time.Clock; import java.time.Instant; @@ -41,7 +44,6 @@ import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Comparator; -import java.util.EnumMap; import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; @@ -121,32 +123,41 @@ public CreationResult create( } WorkflowCatalog catalog = catalogService.getActiveCatalog(); - List workflows = catalog.findByIntent(command.detectedIntent()).stream() - .filter(workflow -> isStandardExpiryRenewalWorkflow(workflow.workflowId())) + List matchingTemplates = catalog + .findCaseTemplatesByIntent(command.detectedIntent()) + .stream() + .filter(template -> template.workflowIds().contains(command.candidateWorkflowId())) .toList(); - if (workflows.isEmpty() - || workflows.stream().noneMatch(workflow -> workflow.workflowId() - .equals(command.candidateWorkflowId()))) { + if (matchingTemplates.size() != 1) { throw new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH); } - List renewalPlans = plans(workflows); - EnumSet plannedTaskTypes = renewalPlans.stream() - .map(TaskPlan::taskType) - .collect(java.util.stream.Collectors.toCollection(() -> EnumSet.noneOf(TaskType.class))); - if (!plannedTaskTypes.equals(EXPIRY_RENEWAL_TASK_TYPES)) { + WorkflowCaseTemplate caseTemplate = matchingTemplates.get(0); + Map workflows = catalog.workflows().stream() + .filter(workflow -> caseTemplate.workflowIds().contains(workflow.workflowId())) + .collect(java.util.stream.Collectors.toMap( + WorkflowDefinition::workflowId, + workflow -> workflow + )); + if (!workflows.keySet().containsAll(caseTemplate.workflowIds())) { throw new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH); } - - List plans = new ArrayList<>(renewalPlans); + WorkerIdentityDocumentStatuses identityDocumentStatuses = identityDocumentStatusReader + .findCurrentStatuses(actor.companyId(), command.workerId()); List missingIdentityDocuments = missingIdentityDocuments( - identityDocumentStatusReader.findCurrentStatuses(actor.companyId(), command.workerId()) + identityDocumentStatuses ); - if (!missingIdentityDocuments.isEmpty()) { - plans.add(new TaskPlan( - TaskType.DOCUMENT_REQUEST, - documentRequestWorkflow(catalog), - missingIdentityDocuments - )); + List plans = plans( + caseTemplate, + workflows, + identityDocumentStatuses, + missingIdentityDocuments + ); + EnumSet plannedTaskTypes = plans.stream() + .map(TaskPlan::taskType) + .filter(EXPIRY_RENEWAL_TASK_TYPES::contains) + .collect(java.util.stream.Collectors.toCollection(() -> EnumSet.noneOf(TaskType.class))); + if (!plannedTaskTypes.equals(EXPIRY_RENEWAL_TASK_TYPES)) { + throw new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH); } LocalDate dueDate = dueDate(command.extractedSlots(), worker); @@ -155,10 +166,10 @@ public CreationResult create( List plannedTasks = plans.stream() .map(plan -> new PlannedTask(plan, uuidGenerator.generate())) .toList(); - Map taskIds = new EnumMap<>(TaskType.class); - plannedTasks.stream() - .filter(plannedTask -> EXPIRY_RENEWAL_TASK_TYPES.contains(plannedTask.plan().taskType())) - .forEach(plannedTask -> taskIds.put(plannedTask.plan().taskType(), plannedTask.taskId())); + Map taskIds = plannedTasks.stream().collect(java.util.stream.Collectors.toMap( + plannedTask -> plannedTask.plan().template().key(), + PlannedTask::taskId + )); List caseTasks = plannedTasks.stream() .map(plannedTask -> createTask( @@ -167,6 +178,7 @@ public CreationResult create( command, actor, catalog.bundleVersion(), + caseTemplate, caseId, taskIds, worker, @@ -203,28 +215,59 @@ public CreationResult create( return new CreationResult(caseId, createdTaskIds); } - private List plans(List workflows) { - Map workflowByType = new EnumMap<>(TaskType.class); - workflows.forEach(workflow -> workflow.supportedTaskTypes().forEach(taskType -> { - if (workflowByType.putIfAbsent(taskType, workflow) != null) { - throw new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH); - } - })); - return workflowByType.entrySet().stream() - .map(entry -> new TaskPlan(entry.getKey(), entry.getValue(), List.of())) - .sorted(Comparator.comparingInt(this::order)) + private List plans( + WorkflowCaseTemplate caseTemplate, + Map workflows, + WorkerIdentityDocumentStatuses statuses, + List missingIdentityDocuments + ) { + return caseTemplate.tasks().stream() + .filter(template -> active(template, statuses)) + .map(template -> { + WorkflowDefinition workflow = workflows.get(template.workflowId()); + if (workflow == null || !workflow.supportedTaskTypes().contains(template.taskType())) { + throw new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH); + } + List documents = template.taskType() == TaskType.DOCUMENT_REQUEST + ? missingIdentityDocuments + : List.of(); + return new TaskPlan(template, effectiveWorkflow(workflow, template), documents); + }) + .sorted(Comparator.comparingInt(plan -> plan.template().order())) .toList(); } - private boolean isStandardExpiryRenewalWorkflow(String workflowId) { - return workflowId.equals("WF-CON-001") || workflowId.equals("WF-STY-001"); + private boolean active(TaskTemplate template, WorkerIdentityDocumentStatuses statuses) { + if (template.activation().mode() == ActivationMode.ALWAYS) { + return true; + } + if (template.activation().mode() != ActivationMode.MISSING_ANY) { + throw new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH); + } + return template.activation().fieldKeys().stream().anyMatch(field -> switch (field) { + case "passport_status" -> statuses.passportStatus() == SubmissionStatus.MISSING; + case "arc_status" -> statuses.arcStatus() == SubmissionStatus.MISSING; + default -> throw new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH); + }); } - private WorkflowDefinition documentRequestWorkflow(WorkflowCatalog catalog) { - return catalog.findByIntent("DOCUMENT_REQUEST").stream() - .filter(workflow -> workflow.supportedTaskTypes().contains(TaskType.DOCUMENT_REQUEST)) - .findFirst() - .orElseThrow(() -> new ApiException(TaskErrorCode.WORKFLOW_TASK_TYPE_MISMATCH)); + private WorkflowDefinition effectiveWorkflow( + WorkflowDefinition workflow, + TaskTemplate template + ) { + return new WorkflowDefinition( + workflow.workflowId(), + workflow.name(), + workflow.intent(), + workflow.sensitivity(), + workflow.supportedTaskTypes(), + workflow.requiredSlots(), + workflow.allowedSlotKeys(), + workflow.resolvableSlotKeys(), + template.checklistItems(), + template.completionEvidence(), + workflow.sourceIds() + ); } private List missingIdentityDocuments(WorkerIdentityDocumentStatuses statuses) { @@ -244,14 +287,15 @@ private TaskCaseRegistrar.CaseTask createTask( CreationCommand command, ActorContext actor, String catalogVersion, + WorkflowCaseTemplate caseTemplate, UUID caseId, - Map taskIds, + Map taskIds, WorkerTaskContext worker, LocalDate dueDate, Instant now ) { TaskType taskType = plan.taskType(); - Map businessData = businessData(command, plan, taskIds); + Map businessData = businessData(command, caseTemplate, plan, taskIds); List missingSlots = missingRequiredSlots( plan.workflow(), worker, @@ -261,8 +305,8 @@ private TaskCaseRegistrar.CaseTask createTask( if (!missingSlots.isEmpty()) { throw new ApiException(TaskErrorCode.INVALID_AI_CANDIDATE_TASK_DATA); } - String title = title(plan); - String description = description(plan); + String title = plan.template().title(); + String description = plan.template().description(); EncodedTaskContent content = contentCodec.encode( command.workerId(), plan.workflow().workflowId(), @@ -295,20 +339,35 @@ private TaskCaseRegistrar.CaseTask createTask( private Map businessData( CreationCommand command, + WorkflowCaseTemplate caseTemplate, TaskPlan plan, - Map taskIds + Map taskIds ) { TaskType taskType = plan.taskType(); Map data = new LinkedHashMap<>(); data.put("ai_run_id", command.aiRunId().toString()); data.put("ai_candidate_id", command.candidateId().toString()); data.put("source_intent", command.detectedIntent()); - data.put("candidate_order", order(plan)); + data.put("case_template_id", caseTemplate.caseTemplateId()); + data.put("case_title", caseTemplate.name()); + data.put("task_template_key", plan.template().key()); + data.put("candidate_order", plan.template().order()); + List dependencyTaskIds = java.util.stream.Stream.concat( + plan.template().dependsOn().stream(), + plan.template().dependsOnIfPresent().stream() + ) + .map(taskIds::get) + .filter(java.util.Objects::nonNull) + .map(UUID::toString) + .toList(); + if (!dependencyTaskIds.isEmpty()) { + data.put("depends_on_task_ids", dependencyTaskIds); + data.put("depends_on_task_id", dependencyTaskIds.get(0)); + } switch (taskType) { case RECONTRACT -> data.put("approval_required", true); case STAY_PERIOD_EXTENSION -> data.put("submission_due_offset_days", 7); case EMPLOYMENT_PERIOD_EXTENSION -> { - data.put("depends_on_task_id", taskIds.get(TaskType.RECONTRACT).toString()); data.put("dependency_reason", "SIGNED_CONTRACT_REQUIRED"); } case DOCUMENT_REQUEST -> { @@ -381,52 +440,6 @@ private LocalDate parseDueDate(String value) { } } - private int order(TaskPlan plan) { - return switch (plan.taskType()) { - case RECONTRACT -> 1; - case STAY_PERIOD_EXTENSION -> 2; - case EMPLOYMENT_PERIOD_EXTENSION -> 3; - case DOCUMENT_REQUEST -> 4; - default -> throw new ApiException(TaskErrorCode.INVALID_AI_CANDIDATE_TASK_DATA); - }; - } - - private String title(TaskPlan plan) { - return switch (plan.taskType()) { - case RECONTRACT -> "재계약 조건 확인"; - case STAY_PERIOD_EXTENSION -> "체류기간 연장 준비"; - case EMPLOYMENT_PERIOD_EXTENSION -> "취업활동기간 연장 준비"; - case DOCUMENT_REQUEST -> identityDocumentLabel(plan.requestedDocumentTypes()) + " 사본 요청"; - default -> throw new ApiException(TaskErrorCode.INVALID_AI_CANDIDATE_TASK_DATA); - }; - } - - private String description(TaskPlan plan) { - return switch (plan.taskType()) { - case RECONTRACT -> "재계약 조건과 계속 고용 의사를 검토합니다."; - case STAY_PERIOD_EXTENSION -> "체류기간 연장에 필요한 정보와 서류를 확인합니다."; - case EMPLOYMENT_PERIOD_EXTENSION -> "재계약 결과를 바탕으로 취업활동기간 연장을 준비합니다."; - case DOCUMENT_REQUEST -> "근로자에게 " + identityDocumentLabel(plan.requestedDocumentTypes()) - + " 사본 제출을 요청합니다."; - default -> throw new ApiException(TaskErrorCode.INVALID_AI_CANDIDATE_TASK_DATA); - }; - } - - private String identityDocumentLabel(List documentTypes) { - return documentTypes.stream() - .map(documentType -> switch (documentType) { - case PASSPORT_COPY -> "여권"; - case ARC -> "외국인등록증"; - case CONTRACT -> "근로계약서"; - case PERMIT -> "고용허가서"; - case EMPLOYMENT_EXTENSION_APPLICATION -> "취업활동기간 연장신청서"; - case INTEGRATED_APPLICATION -> "통합신청서"; - case IDENTITY_GUARANTY -> "신원보증서"; - case RESIDENCE_PROOF -> "체류지 입증자료"; - }) - .collect(java.util.stream.Collectors.joining("·")); - } - private void appendAudit( Task task, ActorContext actor, @@ -461,13 +474,17 @@ private UserRole effectiveRole(ActorContext actor) { } private record TaskPlan( - TaskType taskType, + TaskTemplate template, WorkflowDefinition workflow, List requestedDocumentTypes ) { private TaskPlan { requestedDocumentTypes = List.copyOf(requestedDocumentTypes); } + + private TaskType taskType() { + return template.taskType(); + } } private record PlannedTask(TaskPlan plan, UUID taskId) { diff --git a/src/main/java/com/fowoco/server/workflow/domain/WorkflowCaseTemplate.java b/src/main/java/com/fowoco/server/workflow/domain/WorkflowCaseTemplate.java new file mode 100644 index 00000000..98188a00 --- /dev/null +++ b/src/main/java/com/fowoco/server/workflow/domain/WorkflowCaseTemplate.java @@ -0,0 +1,53 @@ +package com.fowoco.server.workflow.domain; + +import com.fowoco.server.task.domain.TaskType; +import java.util.List; +import java.util.Set; + +public record WorkflowCaseTemplate( + String caseTemplateId, + String name, + String intent, + Set workflowIds, + List tasks +) { + + public WorkflowCaseTemplate { + workflowIds = Set.copyOf(workflowIds); + tasks = List.copyOf(tasks); + } + + public record TaskTemplate( + String key, + int order, + TaskType taskType, + String workflowId, + String title, + String description, + Activation activation, + List dependsOn, + List dependsOnIfPresent, + List checklistItems, + List completionEvidence + ) { + + public TaskTemplate { + dependsOn = List.copyOf(dependsOn); + dependsOnIfPresent = List.copyOf(dependsOnIfPresent); + checklistItems = List.copyOf(checklistItems); + completionEvidence = List.copyOf(completionEvidence); + } + } + + public record Activation(ActivationMode mode, Set fieldKeys) { + + public Activation { + fieldKeys = Set.copyOf(fieldKeys); + } + } + + public enum ActivationMode { + ALWAYS, + MISSING_ANY + } +} diff --git a/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java b/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java index 22b1793d..2f9ad290 100644 --- a/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java +++ b/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java @@ -10,11 +10,32 @@ public record WorkflowCatalog( String bundleStatus, String sourceRepository, Instant generatedAt, - List workflows + List workflows, + List caseTemplates ) { public WorkflowCatalog { workflows = List.copyOf(workflows); + caseTemplates = List.copyOf(caseTemplates); + } + + public WorkflowCatalog( + String bundleId, + String bundleVersion, + String bundleStatus, + String sourceRepository, + Instant generatedAt, + List workflows + ) { + this( + bundleId, + bundleVersion, + bundleStatus, + sourceRepository, + generatedAt, + workflows, + List.of() + ); } public Optional findWorkflow(String workflowId) { @@ -28,4 +49,10 @@ public List findByIntent(String intent) { .filter(workflow -> workflow.intent().equals(intent)) .toList(); } + + public List findCaseTemplatesByIntent(String intent) { + return caseTemplates.stream() + .filter(template -> template.intent().equals(intent)) + .toList(); + } } diff --git a/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java b/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java index c2f17593..936064d8 100644 --- a/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java +++ b/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java @@ -4,6 +4,8 @@ import com.fowoco.server.workflow.domain.WorkflowCatalog; import com.fowoco.server.workflow.domain.WorkflowChecklistTemplate; import com.fowoco.server.workflow.domain.WorkflowDefinition; +import com.fowoco.server.workflow.domain.WorkflowCaseTemplate; +import com.fowoco.server.workflow.domain.WorkflowCaseTemplate.ActivationMode; import com.fowoco.server.task.domain.TaskType; import jakarta.annotation.PostConstruct; import java.io.IOException; @@ -11,6 +13,7 @@ import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; @@ -71,7 +74,9 @@ private void validate(CatalogProjection projection) { throw new IllegalStateException("운영 환경은 RELEASED Workflow Catalog만 사용할 수 있습니다."); } if (projection.generatedAt() == null || projection.workflows() == null - || projection.workflows().isEmpty()) { + || projection.workflows().isEmpty() + || projection.caseTemplates() == null + || projection.caseTemplates().isEmpty()) { throw new IllegalStateException("Workflow Catalog 생성시각과 workflow가 필요합니다."); } Set workflowIds = new HashSet<>(); @@ -109,6 +114,73 @@ private void validate(CatalogProjection projection) { } }); }); + validateCaseTemplates(projection, workflowIds); + } + + private void validateCaseTemplates(CatalogProjection projection, Set workflowIds) { + Set templateIds = new HashSet<>(); + projection.caseTemplates().forEach(template -> { + requireText(template.caseTemplateId(), "case_template_id"); + requireText(template.name(), "case template name"); + requireText(template.intent(), "case template intent"); + if (!templateIds.add(template.caseTemplateId())) { + throw new IllegalStateException("중복 case_template_id: " + template.caseTemplateId()); + } + if (template.workflowIds() == null || template.workflowIds().isEmpty() + || !workflowIds.containsAll(template.workflowIds()) + || template.tasks() == null || template.tasks().isEmpty()) { + throw new IllegalStateException("Case template의 workflow와 task 구성이 올바르지 않습니다."); + } + Set taskKeys = new HashSet<>(); + Set orders = new HashSet<>(); + template.tasks().forEach(task -> { + requireText(task.key(), "case task key"); + requireText(task.title(), "case task title"); + requireText(task.description(), "case task description"); + if (!taskKeys.add(task.key()) || !orders.add(task.order()) || task.order() < 1) { + throw new IllegalStateException("Case template task key와 order는 양수이며 고유해야 합니다."); + } + if (!template.workflowIds().contains(task.workflowId())) { + throw new IllegalStateException("Case task workflow_id가 template에 선언되지 않았습니다."); + } + if (task.taskType() == null || task.activation() == null + || task.activation().mode() == null + || task.dependsOn() == null || task.dependsOnIfPresent() == null + || task.checklistItems() == null || task.checklistItems().isEmpty() + || task.completionEvidence() == null || task.completionEvidence().isEmpty()) { + throw new IllegalStateException("Case template task 계약 값이 누락되었습니다."); + } + if (task.activation().mode() == ActivationMode.MISSING_ANY + && (task.activation().fieldKeys() == null + || task.activation().fieldKeys().isEmpty())) { + throw new IllegalStateException("MISSING_ANY task에는 field_keys가 필요합니다."); + } + }); + Map taskOrders = template.tasks().stream() + .collect(java.util.stream.Collectors.toMap( + CaseTaskProjection::key, + CaseTaskProjection::order + )); + template.tasks().forEach(task -> { + if (!taskKeys.containsAll(task.dependsOn()) + || !taskKeys.containsAll(task.dependsOnIfPresent()) + || task.dependsOn().contains(task.key()) + || task.dependsOnIfPresent().contains(task.key())) { + throw new IllegalStateException("Case template task 의존성이 올바르지 않습니다."); + } + java.util.stream.Stream.concat( + task.dependsOn().stream(), + task.dependsOnIfPresent().stream() + ) + .filter(dependency -> taskOrders.get(dependency) >= task.order()) + .findFirst() + .ifPresent(dependency -> { + throw new IllegalStateException( + "Case template task는 앞 순서 task에만 의존할 수 있습니다." + ); + }); + }); + }); } private void requireText(String value, String field) { @@ -123,7 +195,8 @@ private record CatalogProjection( String bundleStatus, String sourceRepository, Instant generatedAt, - List workflows + List workflows, + List caseTemplates ) { WorkflowCatalog toDomain() { @@ -133,7 +206,8 @@ WorkflowCatalog toDomain() { bundleStatus, sourceRepository, generatedAt, - workflows.stream().map(WorkflowProjection::toDomain).toList() + workflows.stream().map(WorkflowProjection::toDomain).toList(), + caseTemplates.stream().map(CaseTemplateProjection::toDomain).toList() ); } } @@ -175,4 +249,64 @@ WorkflowChecklistTemplate toDomain() { return new WorkflowChecklistTemplate(itemCode, label, required); } } + + private record CaseTemplateProjection( + String caseTemplateId, + String name, + String intent, + Set workflowIds, + List tasks + ) { + + WorkflowCaseTemplate toDomain() { + return new WorkflowCaseTemplate( + caseTemplateId, + name, + intent, + workflowIds, + tasks.stream().map(CaseTaskProjection::toDomain).toList() + ); + } + } + + private record CaseTaskProjection( + String key, + int order, + TaskType taskType, + String workflowId, + String title, + String description, + ActivationProjection activation, + List dependsOn, + List dependsOnIfPresent, + List checklistItems, + List completionEvidence + ) { + + WorkflowCaseTemplate.TaskTemplate toDomain() { + return new WorkflowCaseTemplate.TaskTemplate( + key, + order, + taskType, + workflowId, + title, + description, + activation.toDomain(), + dependsOn, + dependsOnIfPresent, + checklistItems.stream().map(ChecklistProjection::toDomain).toList(), + completionEvidence + ); + } + } + + private record ActivationProjection(ActivationMode mode, Set fieldKeys) { + + WorkflowCaseTemplate.Activation toDomain() { + return new WorkflowCaseTemplate.Activation( + mode, + fieldKeys == null ? Set.of() : fieldKeys + ); + } + } } diff --git a/src/main/resources/workflow/catalog-projection.local.json b/src/main/resources/workflow/catalog-projection.local.json index 9d542227..d79b1a21 100644 --- a/src/main/resources/workflow/catalog-projection.local.json +++ b/src/main/resources/workflow/catalog-projection.local.json @@ -202,5 +202,90 @@ "SRC-LAW-IMMIGRATION-ACT-25" ] } + ], + "case_templates": [ + { + "case_template_id": "CASE-EXPIRY-RENEWAL-001", + "name": "E-9 재계약·취업활동기간·체류기간 연장", + "intent": "EXPIRY_RENEWAL", + "workflow_ids": ["WF-CON-001", "WF-DOC-001", "WF-STY-001"], + "tasks": [ + { + "key": "recontract", + "order": 1, + "task_type": "RECONTRACT", + "workflow_id": "WF-CON-001", + "title": "재계약 의사와 근로조건 확정", + "description": "회사와 근로자의 재계약 의사, 네 가지 기준일, 변경 근로조건을 확인하고 표준근로계약서 초안을 검토합니다.", + "activation": {"mode": "ALWAYS", "field_keys": []}, + "depends_on": [], + "depends_on_if_present": [], + "checklist_items": [ + {"item_code": "COMPANY_RECONTRACT_INTENT_CONFIRMED", "label": "회사의 재계약 의사 확인", "required": true}, + {"item_code": "WORKER_CONTINUED_EMPLOYMENT_INTENT_CONFIRMED", "label": "근로자의 계속 근무 의사 확인", "required": true}, + {"item_code": "FOUR_KEY_DATES_VERIFIED", "label": "계약 시작일·계약 종료일·취업활동기간 만료일·체류기간 만료일 확인", "required": true}, + {"item_code": "CHANGED_EMPLOYMENT_CONDITIONS_CONFIRMED", "label": "임금·근무시간·업무·근무지·숙소 변경조건 확인", "required": true}, + {"item_code": "CURRENT_STANDARD_CONTRACT_DRAFT_REVIEWED", "label": "최신 표준근로계약서 초안 검토", "required": true}, + {"item_code": "SIGNED_CONTRACT_STORED", "label": "당사자 서명본 보관", "required": true} + ], + "completion_evidence": ["company_intent", "worker_intent", "confirmed_contract_conditions", "signed_contract_record", "hr_approval_id"] + }, + { + "key": "identity_documents", + "order": 2, + "task_type": "DOCUMENT_REQUEST", + "workflow_id": "WF-DOC-001", + "title": "신분서류 보완", + "description": "보유하지 않았거나 유효성을 확인하지 못한 신분서류만 근로자 보안 링크로 요청하고 HR이 제출물을 검토합니다.", + "activation": {"mode": "MISSING_ANY", "field_keys": ["passport_status", "arc_status"]}, + "depends_on": [], + "depends_on_if_present": [], + "checklist_items": [ + {"item_code": "WORKER_DOCUMENT_REQUEST_APPROVED", "label": "근로자 안내문과 요청 서류 검토", "required": true}, + {"item_code": "SECURE_LINK_DELIVERY_RECORDED", "label": "보안 링크 전달 완료 기록", "required": true}, + {"item_code": "IDENTITY_DOCUMENTS_SUBMITTED", "label": "근로자 여권·외국인등록증 제출 확인", "required": true}, + {"item_code": "OCR_RESULT_HR_REVIEWED", "label": "OCR 추출값과 원본을 HR이 대조 검토", "required": true} + ], + "completion_evidence": ["approved_notice_id", "verified_document_id", "ocr_review_id"] + }, + { + "key": "employment_period_extension", + "order": 3, + "task_type": "EMPLOYMENT_PERIOD_EXTENSION", + "workflow_id": "WF-CON-001", + "title": "취업활동기간 연장 준비", + "description": "서명된 근로계약서와 공식 안내를 기준으로 고용허가 관련 연장 신청을 준비하고 HR이 처리 결과를 기록합니다.", + "activation": {"mode": "ALWAYS", "field_keys": []}, + "depends_on": ["recontract"], + "depends_on_if_present": ["identity_documents"], + "checklist_items": [ + {"item_code": "SIGNED_CONTRACT_READY_FOR_EXTENSION", "label": "서명된 표준근로계약서 준비 확인", "required": true}, + {"item_code": "EMPLOYMENT_EXTENSION_REQUIREMENTS_REVIEWED", "label": "취업활동기간 연장 신청정보와 필요서류 확인", "required": true}, + {"item_code": "EMPLOYMENT_EXTENSION_MANUALLY_SUBMITTED", "label": "HR이 공식 채널에서 직접 제출", "required": true}, + {"item_code": "EMPLOYMENT_EXTENSION_RESULT_RECORDED", "label": "접수증 또는 처리결과 기록", "required": true} + ], + "completion_evidence": ["signed_contract_record", "employment_permit_extension_receipt_or_note", "hr_approval_id"] + }, + { + "key": "stay_period_extension", + "order": 4, + "task_type": "STAY_PERIOD_EXTENSION", + "workflow_id": "WF-STY-001", + "title": "체류기간 연장 준비와 결과 추적", + "description": "앞선 재계약·취업활동기간 연장 결과와 신분서류를 바탕으로 통합신청서 초안을 검토하고 HR이 제출 결과를 기록합니다.", + "activation": {"mode": "ALWAYS", "field_keys": []}, + "depends_on": ["employment_period_extension"], + "depends_on_if_present": ["identity_documents"], + "checklist_items": [ + {"item_code": "PASSPORT_AND_ARC_CURRENT_VERIFIED", "label": "여권·외국인등록증 보유 및 유효 상태 확인", "required": true}, + {"item_code": "EMPLOYMENT_EXTENSION_RESULT_AVAILABLE", "label": "취업활동기간 연장 처리결과 확인", "required": true}, + {"item_code": "INTEGRATED_APPLICATION_DRAFT_REVIEWED", "label": "통합신청서 초안과 체류 관련 증빙 검토", "required": true}, + {"item_code": "STAY_EXTENSION_MANUALLY_SUBMITTED", "label": "HR이 하이코리아 또는 관할 기관에서 직접 제출", "required": true}, + {"item_code": "STAY_EXTENSION_RESULT_AND_NEXT_REVIEW_RECORDED", "label": "처리결과와 다음 확인일 기록", "required": true} + ], + "completion_evidence": ["stay_extension_receipt_or_note", "next_review_at", "hr_approval_id"] + } + ] + } ] } diff --git a/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java b/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java index b4862969..39785297 100644 --- a/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java +++ b/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java @@ -512,13 +512,32 @@ void acceptedCandidateCreatesOneCaseAndThreeTasksIdempotently() throws Exception caseId ); assertThat(JsonPath.>read(snapshot, "$.steps[*].order")) - .containsExactly(1, 2, 3); + .containsExactly(1, 3, 4); assertThat(JsonPath.>read(snapshot, "$.steps[*].task_type")) .containsExactly( "RECONTRACT", - "STAY_PERIOD_EXTENSION", - "EMPLOYMENT_PERIOD_EXTENSION" + "EMPLOYMENT_PERIOD_EXTENSION", + "STAY_PERIOD_EXTENSION" ); + assertThat(JsonPath.read(snapshot, "$.case_template_id")) + .isEqualTo("CASE-EXPIRY-RENEWAL-001"); + assertThat(jdbcTemplate.queryForList( + """ + SELECT label + FROM task_checklist_item + WHERE task_id = ( + SELECT task_id FROM task + WHERE case_id = ? AND task_type = 'RECONTRACT' + ) + ORDER BY item_code + """, + String.class, + caseId + )).contains( + "회사의 재계약 의사 확인", + "근로자의 계속 근무 의사 확인", + "최신 표준근로계약서 초안 검토" + ); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM ai_candidate_decision_batch WHERE ai_run_id = ?", Integer.class, @@ -640,6 +659,17 @@ void missingArcCreatesOneDraftDocumentRequestInTheRenewalCase() throws Exception assertThat((String) requestTask.get("business_data_json")) .contains("ARC", "SECURE_LINK") .doesNotContain("PASSPORT_COPY"); + String snapshot = jdbcTemplate.queryForObject( + "SELECT workflow_snapshot_json FROM workflow_case WHERE case_id = ?", + String.class, + caseId + ); + assertThat(JsonPath.>read(snapshot, "$.steps[*].order")) + .containsExactly(1, 2, 3, 4); + assertThat(JsonPath.>read( + snapshot, + "$.steps[2].required_conditions.depends_on_task_ids" + )).hasSize(2); } @Test diff --git a/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java b/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java index 9da39a70..1eb2d0a0 100644 --- a/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java +++ b/src/test/java/com/fowoco/server/casework/CaseQueryIntegrationTest.java @@ -44,6 +44,8 @@ class CaseQueryIntegrationTest { private static final UUID TASK_B = UUID.fromString("cb400000-0000-0000-0000-000000000001"); private static final UUID TASK_CANCELLED = UUID.fromString("ca400000-0000-0000-0000-000000000003"); + private static final UUID TASK_A_SECOND_PREREQUISITE = + UUID.fromString("ca400000-0000-0000-0000-000000000004"); private static final String PASSWORD = "Test-password-1!"; private static final String HR_A_EMAIL = "case.hr.a@example.com"; @@ -185,6 +187,62 @@ void dependentTaskBecomesCurrentOnlyAfterItsPredecessorCompletes() throws Except .isEqualTo(TASK_A_WAITING.toString()); } + @Test + void taskWithTwoDependenciesBecomesCurrentOnlyAfterBothComplete() throws Exception { + insertTask( + TASK_A_SECOND_PREREQUISITE, + CASE_A, + COMPANY_A, + WORKER_A, + HR_A, + "EMPLOYMENT_PERIOD_EXTENSION", + "DRAFT", + 3 + ); + jdbcTemplate.update( + """ + UPDATE workflow_case + SET workflow_snapshot_json = ? + WHERE case_id = ? + """, + """ + {"steps":[ + {"order":1,"task_id":"%s","required_conditions":{}}, + {"order":2,"task_id":"%s","required_conditions":{}}, + {"order":3,"task_id":"%s","required_conditions":{ + "depends_on_task_ids":["%s","%s"] + }} + ]} + """.formatted( + TASK_A_DONE, + TASK_A_SECOND_PREREQUISITE, + TASK_A_WAITING, + TASK_A_DONE, + TASK_A_SECOND_PREREQUISITE + ), + CASE_A + ); + String token = login(); + + HttpResponse beforeCompletion = get( + "/api/v1/cases/" + CASE_A + "/projection", + token + ); + assertThat(JsonPath.read(beforeCompletion.body(), "$.current_task.task_id")) + .isEqualTo(TASK_A_SECOND_PREREQUISITE.toString()); + + jdbcTemplate.update( + "UPDATE task SET status = 'COMPLETED' WHERE task_id = ?", + TASK_A_SECOND_PREREQUISITE + ); + HttpResponse afterCompletion = get( + "/api/v1/cases/" + CASE_A + "/projection", + token + ); + assertThat(JsonPath.read(afterCompletion.body(), "$.current_task.task_id")) + .isEqualTo(TASK_A_WAITING.toString()); + } + @Test void documentsCaseEndpointsInOpenApi() throws Exception { HttpResponse response = getWithoutToken("/v3/api-docs"); diff --git a/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java b/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java index 9b71e06f..d91a1ab5 100644 --- a/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java +++ b/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java @@ -48,5 +48,21 @@ void loadsKnowledge031StayRenewalAndExpiredStayExceptionContracts() { "stay_expiry_date", "stay_verification_status" ); + var renewalCase = catalog.findCaseTemplatesByIntent("EXPIRY_RENEWAL") + .stream() + .filter(template -> template.caseTemplateId().equals("CASE-EXPIRY-RENEWAL-001")) + .findFirst() + .orElseThrow(); + assertThat(renewalCase.tasks()).extracting(task -> task.taskType().name()) + .containsExactly( + "RECONTRACT", + "DOCUMENT_REQUEST", + "EMPLOYMENT_PERIOD_EXTENSION", + "STAY_PERIOD_EXTENSION" + ); + assertThat(renewalCase.tasks().get(0).checklistItems()).hasSize(6); + assertThat(renewalCase.tasks().get(2).dependsOn()).containsExactly("recontract"); + assertThat(renewalCase.tasks().get(3).dependsOn()) + .containsExactly("employment_period_extension"); } } From eec879312f52e3d863b9e38fd9076ee9c4a59f99 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 11:36:58 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(demo):=20Golden=20Renewal=20=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EC=B6=9C=20=ED=8C=8C=EC=9D=BC=20=EB=82=B4?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- build.gradle | 10 +++ docs/golden-renewal-manual-e2e.md | 14 +++++ scripts/export-golden-demo-files | 9 +++ .../DemoGoldenUploadFixtureExporter.java | 63 +++++++++++++++++++ .../DemoGoldenUploadFixtureExporterTest.java | 46 ++++++++++++++ 6 files changed, 143 insertions(+), 1 deletion(-) create mode 100755 scripts/export-golden-demo-files create mode 100644 src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporter.java create mode 100644 src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporterTest.java diff --git a/README.md b/README.md index 38ebf859..40c4dbcb 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ src/main/java/com/fowoco/server/ | DB 테이블·ERD | [Database 문서](https://fowoco.github.io/server/) | Flyway를 빈 PostgreSQL에 적용해 자동 생성한 구조 | | 로컬 실행·인증·Workflow | [개발 가이드](docs/development-guide.md) | 처음 서버를 실행하고 기능 흐름을 이해하는 방법 | | Demo Seed 수량·시나리오 | [Demo Seed 운영 시나리오](docs/demo-seed.md) | 로컬 데모 데이터의 기준 수량, 대표 흐름과 표현 한계 | -| 재계약·연장 수동 E2E | [Golden Flow 수동 시연 가이드](docs/golden-renewal-manual-e2e.md) | HR 요청부터 근로자 서류 제출, OCR 검토와 연장 업무 완료까지 직접 확인하는 순서 | +| 재계약·연장 수동 E2E | [Golden Flow 수동 시연 가이드](docs/golden-renewal-manual-e2e.md) | HR 요청부터 근로자 서류 제출, OCR 검토와 연장 업무 완료까지 직접 확인하는 순서. `./scripts/export-golden-demo-files`로 합성 ARC를 생성 | | Docker·데모 배포 | [Server 데모 배포 Runbook](docs/deployment-runbook.md) | 로컬 Compose, 필수 Secret, Smoke와 rollback 기준 | | Figma fixture 대응표 | [Figma Demo Fixture Manifest](docs/demo-seed-fixture-manifest.md) | 화면 요구사항별 예약 데이터와 현재 API 노출 범위 | | 패키지·모듈 경계 | [프로젝트 구조](docs/project-structure.md) | 코드를 어느 패키지에 구현해야 하는지 설명 | diff --git a/build.gradle b/build.gradle index 3ce9dd13..115fb0d2 100644 --- a/build.gradle +++ b/build.gradle @@ -50,3 +50,13 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.register('exportGoldenDemoFiles', JavaExec) { + group = 'fowoco demo' + description = 'Golden Renewal 수동 E2E에 사용할 합성 근로자 서류를 내보냅니다.' + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.fowoco.server.demo.infrastructure.documentdata.DemoGoldenUploadFixtureExporter' + if (project.hasProperty('demoOutputDir')) { + args project.property('demoOutputDir') + } +} diff --git a/docs/golden-renewal-manual-e2e.md b/docs/golden-renewal-manual-e2e.md index d1f33322..693d2e50 100644 --- a/docs/golden-renewal-manual-e2e.md +++ b/docs/golden-renewal-manual-e2e.md @@ -153,6 +153,20 @@ POST /api/v1/worker-links/{workerLinkId}/sms-deliveries ## 5. 근로자 역할로 합성 서류를 제출한다 +먼저 Server 저장소에서 아래 명령을 한 번 실행한다. PostgreSQL이나 기존 Demo Seed를 +수정하지 않고 모바일 제출에 사용할 응웬반A의 합성 ARC 앞·뒷면만 생성한다. + +```bash +./scripts/export-golden-demo-files +``` + +파일은 기본적으로 `build/demo-upload-fixtures/`에 생성된다. 원하는 폴더를 첫 번째 인자로 +지정할 수도 있다. + +```bash +./scripts/export-golden-demo-files "$HOME/Desktop/fowoco-demo-files" +``` + 1. 공개 Worker Link를 시크릿 창 또는 다른 브라우저에서 연다. 2. 안내 내용을 확인한다. 3. 요청 서류에서 `외국인등록증 사본`을 선택한다. diff --git a/scripts/export-golden-demo-files b/scripts/export-golden-demo-files new file mode 100755 index 00000000..ef8fd265 --- /dev/null +++ b/scripts/export-golden-demo-files @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +server_dir="$(cd "${script_dir}/.." && pwd)" +output_dir="${1:-${server_dir}/build/demo-upload-fixtures}" + +cd "${server_dir}" +./gradlew exportGoldenDemoFiles -PdemoOutputDir="${output_dir}" diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporter.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporter.java new file mode 100644 index 00000000..3a87dc3c --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporter.java @@ -0,0 +1,63 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.DemoDocumentFixture; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; + +/** + * DB 적재 없이 Golden Renewal 수동 E2E용 합성 신분서류만 로컬로 내보냅니다. + */ +public final class DemoGoldenUploadFixtureExporter { + + private static final Path DEFAULT_OUTPUT = Path.of("build", "demo-upload-fixtures"); + private static final List GOLDEN_FILENAMES = List.of( + "외국인등록증_앞면_응웬반A.png", + "외국인등록증_뒷면_응웬반A.jpg" + ); + + private DemoGoldenUploadFixtureExporter() { + } + + public static void main(String[] args) { + Path output = args.length == 0 ? DEFAULT_OUTPUT : Path.of(args[0]); + try { + List exported = export(output, Clock.systemUTC()); + System.out.println("Golden Renewal 합성 제출 파일을 생성했습니다."); + exported.forEach(path -> System.out.println(path.toAbsolutePath().normalize())); + } catch (IOException exception) { + throw new UncheckedIOException("Golden Renewal 합성 제출 파일 생성에 실패했습니다.", exception); + } + } + + static List export(Path output, Clock clock) throws IOException { + Files.createDirectories(output); + LocalDate anchorDate = LocalDate.now(clock.withZone(ZoneOffset.UTC)); + SyntheticDocumentGenerator generator = new SyntheticDocumentGenerator(); + + List fixtures = DemoDocumentFixtureCatalog.fixtures().stream() + .filter(fixture -> fixture.workerId().equals(DemoDocumentFixtureCatalog.GOLD_WORKER_ID)) + .filter(fixture -> GOLDEN_FILENAMES.contains(fixture.originalFilename())) + .toList(); + if (fixtures.size() != GOLDEN_FILENAMES.size()) { + throw new IllegalStateException("Golden Renewal 합성 ARC fixture 구성이 올바르지 않습니다."); + } + + return fixtures.stream().map(fixture -> { + Path destination = output.resolve(fixture.originalFilename()); + LocalDate issueDate = anchorDate.plusDays(fixture.issueDays()); + LocalDate expiryDate = anchorDate.plusDays(fixture.expiryDays()); + try { + Files.write(destination, generator.generate(fixture, issueDate, expiryDate)); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + return destination; + }).toList(); + } +} diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporterTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporterTest.java new file mode 100644 index 00000000..df2bf0f9 --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporterTest.java @@ -0,0 +1,46 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DemoGoldenUploadFixtureExporterTest { + + @TempDir + Path output; + + @Test + void exportsDeterministicSyntheticArcFilesWithoutDatabaseAccess() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-18T00:00:00Z"), ZoneOffset.UTC); + + List first = DemoGoldenUploadFixtureExporter.export(output, clock); + List originalContents = first.stream().map(this::read).toList(); + List second = DemoGoldenUploadFixtureExporter.export(output, clock); + + assertThat(first).extracting(path -> path.getFileName().toString()) + .containsExactly( + "외국인등록증_앞면_응웬반A.png", + "외국인등록증_뒷면_응웬반A.jpg" + ); + assertThat(second.stream().map(this::read).toList()) + .usingElementComparator((left, right) -> java.util.Arrays.compare(left, right)) + .containsExactlyElementsOf(originalContents); + assertThat(originalContents.get(0)).startsWith((byte) 0x89, (byte) 'P', (byte) 'N', (byte) 'G'); + assertThat(originalContents.get(1)).startsWith((byte) 0xFF, (byte) 0xD8, (byte) 0xFF); + } + + private byte[] read(Path path) { + try { + return Files.readAllBytes(path); + } catch (java.io.IOException exception) { + throw new java.io.UncheckedIOException(exception); + } + } +} From 9d3cad60e81ace0e6c2fc1c91947d6ba248d865b Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 11:42:59 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(workflow):=20=EC=84=9C=EB=A5=98=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=8A=B9=EC=9D=B8=20=EC=88=9C=ED=99=98=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/workflow/catalog-projection.local.json | 6 +++--- .../WorkflowCatalogProjectionTest.java | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/resources/workflow/catalog-projection.local.json b/src/main/resources/workflow/catalog-projection.local.json index d79b1a21..fa857cc4 100644 --- a/src/main/resources/workflow/catalog-projection.local.json +++ b/src/main/resources/workflow/catalog-projection.local.json @@ -242,9 +242,9 @@ "depends_on_if_present": [], "checklist_items": [ {"item_code": "WORKER_DOCUMENT_REQUEST_APPROVED", "label": "근로자 안내문과 요청 서류 검토", "required": true}, - {"item_code": "SECURE_LINK_DELIVERY_RECORDED", "label": "보안 링크 전달 완료 기록", "required": true}, - {"item_code": "IDENTITY_DOCUMENTS_SUBMITTED", "label": "근로자 여권·외국인등록증 제출 확인", "required": true}, - {"item_code": "OCR_RESULT_HR_REVIEWED", "label": "OCR 추출값과 원본을 HR이 대조 검토", "required": true} + {"item_code": "SECURE_LINK_DELIVERY_RECORDED", "label": "보안 링크 전달 완료 기록", "required": false}, + {"item_code": "IDENTITY_DOCUMENTS_SUBMITTED", "label": "근로자 여권·외국인등록증 제출 확인", "required": false}, + {"item_code": "OCR_RESULT_HR_REVIEWED", "label": "OCR 추출값과 원본을 HR이 대조 검토", "required": false} ], "completion_evidence": ["approved_notice_id", "verified_document_id", "ocr_review_id"] }, diff --git a/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java b/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java index d91a1ab5..f41ff19f 100644 --- a/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java +++ b/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java @@ -61,6 +61,18 @@ void loadsKnowledge031StayRenewalAndExpiredStayExceptionContracts() { "STAY_PERIOD_EXTENSION" ); assertThat(renewalCase.tasks().get(0).checklistItems()).hasSize(6); + assertThat(renewalCase.tasks().get(1).checklistItems()) + .filteredOn(item -> item.required()) + .extracting(item -> item.itemCode()) + .containsExactly("WORKER_DOCUMENT_REQUEST_APPROVED"); + assertThat(renewalCase.tasks().get(1).checklistItems()) + .filteredOn(item -> !item.required()) + .extracting(item -> item.itemCode()) + .containsExactly( + "SECURE_LINK_DELIVERY_RECORDED", + "IDENTITY_DOCUMENTS_SUBMITTED", + "OCR_RESULT_HR_REVIEWED" + ); assertThat(renewalCase.tasks().get(2).dependsOn()).containsExactly("recontract"); assertThat(renewalCase.tasks().get(3).dependsOn()) .containsExactly("employment_period_extension"); From 7df6abdc019e21ab5c98d0d5b81c279aefdc78f3 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 11:56:48 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(demo):=20Golden=20Renewal=20=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EC=97=AC=EA=B6=8C=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../documentdata/DemoGoldenUploadFixtureExporter.java | 3 ++- .../documentdata/DemoGoldenUploadFixtureExporterTest.java | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporter.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporter.java index 3a87dc3c..de7e0569 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporter.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporter.java @@ -17,6 +17,7 @@ public final class DemoGoldenUploadFixtureExporter { private static final Path DEFAULT_OUTPUT = Path.of("build", "demo-upload-fixtures"); private static final List GOLDEN_FILENAMES = List.of( + "여권_인적사항면_응웬반A.png", "외국인등록증_앞면_응웬반A.png", "외국인등록증_뒷면_응웬반A.jpg" ); @@ -45,7 +46,7 @@ static List export(Path output, Clock clock) throws IOException { .filter(fixture -> GOLDEN_FILENAMES.contains(fixture.originalFilename())) .toList(); if (fixtures.size() != GOLDEN_FILENAMES.size()) { - throw new IllegalStateException("Golden Renewal 합성 ARC fixture 구성이 올바르지 않습니다."); + throw new IllegalStateException("Golden Renewal 합성 신분서류 fixture 구성이 올바르지 않습니다."); } return fixtures.stream().map(fixture -> { diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporterTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporterTest.java index df2bf0f9..0a8208d7 100644 --- a/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporterTest.java +++ b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoGoldenUploadFixtureExporterTest.java @@ -17,7 +17,7 @@ class DemoGoldenUploadFixtureExporterTest { Path output; @Test - void exportsDeterministicSyntheticArcFilesWithoutDatabaseAccess() throws Exception { + void exportsDeterministicSyntheticIdentityFilesWithoutDatabaseAccess() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-18T00:00:00Z"), ZoneOffset.UTC); List first = DemoGoldenUploadFixtureExporter.export(output, clock); @@ -26,6 +26,7 @@ void exportsDeterministicSyntheticArcFilesWithoutDatabaseAccess() throws Excepti assertThat(first).extracting(path -> path.getFileName().toString()) .containsExactly( + "여권_인적사항면_응웬반A.png", "외국인등록증_앞면_응웬반A.png", "외국인등록증_뒷면_응웬반A.jpg" ); @@ -33,7 +34,8 @@ void exportsDeterministicSyntheticArcFilesWithoutDatabaseAccess() throws Excepti .usingElementComparator((left, right) -> java.util.Arrays.compare(left, right)) .containsExactlyElementsOf(originalContents); assertThat(originalContents.get(0)).startsWith((byte) 0x89, (byte) 'P', (byte) 'N', (byte) 'G'); - assertThat(originalContents.get(1)).startsWith((byte) 0xFF, (byte) 0xD8, (byte) 0xFF); + assertThat(originalContents.get(1)).startsWith((byte) 0x89, (byte) 'P', (byte) 'N', (byte) 'G'); + assertThat(originalContents.get(2)).startsWith((byte) 0xFF, (byte) 0xD8, (byte) 0xFF); } private byte[] read(Path path) { From c322b1faaae66f4cd8a6a137e7d8353c34156bbf Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 12:35:50 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(local):=20=EC=9E=A5=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20H2=20=EB=8D=B0=EB=AA=A8=20=EC=97=B0=EA=B2=B0=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 13348918..8333495f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -192,6 +192,11 @@ spring: driver-class-name: org.h2.Driver username: sa password: + hikari: + # H2 2.4.240은 CHECK 제약을 만든 세션이 닫히면 다른 세션의 갱신에서 + # "database has been closed"를 반환한다. 로컬 JVM 수명 동안 Flyway의 + # 물리 연결을 유지해 30분 이후 수동 E2E가 중단되지 않게 한다. + max-lifetime: 0 jpa: show-sql: true h2: From db5da1a0dee836be70d65f1906fcad0d4d006a37 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 12:35:50 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(workflow):=20=EC=97=B0=EC=9E=A5=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EC=A0=84=ED=9B=84=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=88=9C=EC=84=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflow/catalog-projection.local.json | 8 ++--- .../WorkflowCatalogProjectionTest.java | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/main/resources/workflow/catalog-projection.local.json b/src/main/resources/workflow/catalog-projection.local.json index fa857cc4..dd9b5777 100644 --- a/src/main/resources/workflow/catalog-projection.local.json +++ b/src/main/resources/workflow/catalog-projection.local.json @@ -261,8 +261,8 @@ "checklist_items": [ {"item_code": "SIGNED_CONTRACT_READY_FOR_EXTENSION", "label": "서명된 표준근로계약서 준비 확인", "required": true}, {"item_code": "EMPLOYMENT_EXTENSION_REQUIREMENTS_REVIEWED", "label": "취업활동기간 연장 신청정보와 필요서류 확인", "required": true}, - {"item_code": "EMPLOYMENT_EXTENSION_MANUALLY_SUBMITTED", "label": "HR이 공식 채널에서 직접 제출", "required": true}, - {"item_code": "EMPLOYMENT_EXTENSION_RESULT_RECORDED", "label": "접수증 또는 처리결과 기록", "required": true} + {"item_code": "EMPLOYMENT_EXTENSION_MANUALLY_SUBMITTED", "label": "HR이 공식 채널에서 직접 제출", "required": false}, + {"item_code": "EMPLOYMENT_EXTENSION_RESULT_RECORDED", "label": "접수증 또는 처리결과 기록", "required": false} ], "completion_evidence": ["signed_contract_record", "employment_permit_extension_receipt_or_note", "hr_approval_id"] }, @@ -280,8 +280,8 @@ {"item_code": "PASSPORT_AND_ARC_CURRENT_VERIFIED", "label": "여권·외국인등록증 보유 및 유효 상태 확인", "required": true}, {"item_code": "EMPLOYMENT_EXTENSION_RESULT_AVAILABLE", "label": "취업활동기간 연장 처리결과 확인", "required": true}, {"item_code": "INTEGRATED_APPLICATION_DRAFT_REVIEWED", "label": "통합신청서 초안과 체류 관련 증빙 검토", "required": true}, - {"item_code": "STAY_EXTENSION_MANUALLY_SUBMITTED", "label": "HR이 하이코리아 또는 관할 기관에서 직접 제출", "required": true}, - {"item_code": "STAY_EXTENSION_RESULT_AND_NEXT_REVIEW_RECORDED", "label": "처리결과와 다음 확인일 기록", "required": true} + {"item_code": "STAY_EXTENSION_MANUALLY_SUBMITTED", "label": "HR이 하이코리아 또는 관할 기관에서 직접 제출", "required": false}, + {"item_code": "STAY_EXTENSION_RESULT_AND_NEXT_REVIEW_RECORDED", "label": "처리결과와 다음 확인일 기록", "required": false} ], "completion_evidence": ["stay_extension_receipt_or_note", "next_review_at", "hr_approval_id"] } diff --git a/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java b/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java index f41ff19f..c434d2c0 100644 --- a/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java +++ b/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java @@ -74,7 +74,36 @@ void loadsKnowledge031StayRenewalAndExpiredStayExceptionContracts() { "OCR_RESULT_HR_REVIEWED" ); assertThat(renewalCase.tasks().get(2).dependsOn()).containsExactly("recontract"); + assertThat(renewalCase.tasks().get(2).checklistItems()) + .filteredOn(item -> item.required()) + .extracting(item -> item.itemCode()) + .containsExactly( + "SIGNED_CONTRACT_READY_FOR_EXTENSION", + "EMPLOYMENT_EXTENSION_REQUIREMENTS_REVIEWED" + ); + assertThat(renewalCase.tasks().get(2).checklistItems()) + .filteredOn(item -> !item.required()) + .extracting(item -> item.itemCode()) + .containsExactly( + "EMPLOYMENT_EXTENSION_MANUALLY_SUBMITTED", + "EMPLOYMENT_EXTENSION_RESULT_RECORDED" + ); assertThat(renewalCase.tasks().get(3).dependsOn()) .containsExactly("employment_period_extension"); + assertThat(renewalCase.tasks().get(3).checklistItems()) + .filteredOn(item -> item.required()) + .extracting(item -> item.itemCode()) + .containsExactly( + "PASSPORT_AND_ARC_CURRENT_VERIFIED", + "EMPLOYMENT_EXTENSION_RESULT_AVAILABLE", + "INTEGRATED_APPLICATION_DRAFT_REVIEWED" + ); + assertThat(renewalCase.tasks().get(3).checklistItems()) + .filteredOn(item -> !item.required()) + .extracting(item -> item.itemCode()) + .containsExactly( + "STAY_EXTENSION_MANUALLY_SUBMITTED", + "STAY_EXTENSION_RESULT_AND_NEXT_REVIEW_RECORDED" + ); } } From 6db8450376dca9367883e4f49f6eca67bc42090e Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 18 Aug 2026 12:35:50 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs(demo):=20Golden=20Flow=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20E2E=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/golden-renewal-manual-e2e.md | 72 ++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 40c4dbcb..04cb272b 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ src/main/java/com/fowoco/server/ | DB 테이블·ERD | [Database 문서](https://fowoco.github.io/server/) | Flyway를 빈 PostgreSQL에 적용해 자동 생성한 구조 | | 로컬 실행·인증·Workflow | [개발 가이드](docs/development-guide.md) | 처음 서버를 실행하고 기능 흐름을 이해하는 방법 | | Demo Seed 수량·시나리오 | [Demo Seed 운영 시나리오](docs/demo-seed.md) | 로컬 데모 데이터의 기준 수량, 대표 흐름과 표현 한계 | -| 재계약·연장 수동 E2E | [Golden Flow 수동 시연 가이드](docs/golden-renewal-manual-e2e.md) | HR 요청부터 근로자 서류 제출, OCR 검토와 연장 업무 완료까지 직접 확인하는 순서. `./scripts/export-golden-demo-files`로 합성 ARC를 생성 | +| 재계약·연장 수동 E2E | [Golden Flow 수동 시연 가이드](docs/golden-renewal-manual-e2e.md) | HR 요청부터 근로자 서류 제출, OCR 검토와 연장 업무 완료까지 직접 확인하는 순서. `./scripts/export-golden-demo-files`로 합성 여권·ARC를 생성 | | Docker·데모 배포 | [Server 데모 배포 Runbook](docs/deployment-runbook.md) | 로컬 Compose, 필수 Secret, Smoke와 rollback 기준 | | Figma fixture 대응표 | [Figma Demo Fixture Manifest](docs/demo-seed-fixture-manifest.md) | 화면 요구사항별 예약 데이터와 현재 API 노출 범위 | | 패키지·모듈 경계 | [프로젝트 구조](docs/project-structure.md) | 코드를 어느 패키지에 구현해야 하는지 설명 | diff --git a/docs/golden-renewal-manual-e2e.md b/docs/golden-renewal-manual-e2e.md index 693d2e50..534caeae 100644 --- a/docs/golden-renewal-manual-e2e.md +++ b/docs/golden-renewal-manual-e2e.md @@ -12,8 +12,8 @@ HR 자연어 요청 → HR이 후보 채택 → CASE-EXPIRY-RENEWAL-001 Case와 업무 4개 생성 → HR이 재계약 조건과 계약서 초안 검토 -→ 근로자에게 ARC 보완 링크 전달 -→ 근로자가 합성 ARC 제출 +→ 근로자에게 여권·ARC 보완 링크 전달 +→ 근로자가 합성 여권·ARC 제출 → HR이 제출 파일 채택·OCR 검토 → 취업활동기간 연장과 체류기간 연장을 차례로 기록 → 모든 업무와 Case 완료 @@ -22,6 +22,21 @@ HR 자연어 요청 외부기관 로그인과 실제 제출은 자동화하지 않는다. HR이 실제 실행 결과를 기록하는 지점까지만 FOWOCO가 관리한다. +### 로컬 검증 기준선 + +2026-08-18에 Server·AI Runtime을 로컬 HTTP로 연결해 아래 왕복을 직접 확인했다. + +- 발화문 분석 후 Golden Case와 Catalog 순서의 Task 4개 생성 +- 여권·ARC 요청, 공개 링크 조회, 합성 파일 제출과 HR 공식 문서 채택 +- OCR 검토값 승인 후 Outbox가 기존 Renewal을 재개하고 HWP 원본 생성 +- 재계약 승인·증빙 완료 후 취업활동기간 연장 업무가 현재 업무로 이동 +- 두 연장 업무의 HR 승인, 합성 외부 접수 기록, 증빙과 완료 처리 +- 최종 Case `COMPLETED`, `4/4`, 진행률 `100%`, `currentTask=null` + +실제 기관 제출과 실제 개인정보 저장은 이 검증에 포함하지 않았다. OCR Provider가 값을 +추출하지 못한 항목은 합성 원본을 보며 HR 수정값으로 검토 완료했으며, 이는 실패를 숨기는 +Fallback이 아니라 제품의 HITL 검토 경로다. + ## 1. 시작 전 준비 ### Demo Seed만 빠르게 실행 @@ -35,6 +50,11 @@ export DEMO_SEED_ADMIN_PASSWORD='<12자 이상 로컬 합성 비밀번호>' 기본 `local` profile은 H2 인메모리 DB를 사용한다. 서버를 종료하면 시연 중 만든 Case와 Task도 초기화된다. +H2 `2.4.240`에는 CHECK 제약을 만든 DB 연결이 닫힌 뒤 다른 연결에서 값을 갱신하면 +오류가 발생하는 알려진 문제가 있다. Server는 local profile에서 Hikari 연결 수명을 +JVM과 같게 유지해 30분 이후에도 시연이 중단되지 않게 한다. 운영·통합 환경의 기준 +DB는 계속 PostgreSQL 16이다. + ### 제출 파일까지 준비하는 PostgreSQL 통합 실행 민감한 네 환경변수는 터미널에만 설정하고 Git, Issue, PR에 값을 남기지 않는다. @@ -100,8 +120,10 @@ GET /api/v1/ai-runs/{aiRunId}/events | 3 | 취업활동기간 연장 | 1번과, 생성된 경우 2번 완료 후 | | 4 | 체류기간 연장 | 3번과, 생성된 경우 2번 완료 후 | -Demo Seed의 응웬반A는 여권이 유효하고 ARC가 누락되어 있으므로 네 업무가 모두 생성되어야 -한다. Case 상세는 `GET /api/v1/cases/{caseId}/projection`으로도 확인할 수 있다. +Demo Seed의 응웬반A는 ARC가 누락되어 있으므로 네 업무가 모두 생성되어야 한다. 실제 +Renewal 문서 초안에는 여권번호와 생년월일도 필요하므로 Golden Flow에서는 합성 여권과 +ARC를 함께 요청한다. Case 상세는 `GET /api/v1/cases/{caseId}/projection`으로도 확인할 +수 있다. ## 3. 재계약 조건과 계약서 초안을 검토한다 @@ -132,7 +154,7 @@ POST /api/v1/tasks/{taskId}/evidence POST /api/v1/tasks/{taskId}/complete ``` -## 4. 근로자에게 ARC 보완을 요청한다 +## 4. 근로자에게 여권·ARC 보완을 요청한다 두 번째 업무에서 안내 초안을 HR이 확인한 뒤 Worker Link를 발급한다. SMS Provider를 사용하지 않는 로컬 시연에서는 응답의 공개 URL을 브라우저 시크릿 창에 직접 연다. @@ -154,7 +176,8 @@ POST /api/v1/worker-links/{workerLinkId}/sms-deliveries ## 5. 근로자 역할로 합성 서류를 제출한다 먼저 Server 저장소에서 아래 명령을 한 번 실행한다. PostgreSQL이나 기존 Demo Seed를 -수정하지 않고 모바일 제출에 사용할 응웬반A의 합성 ARC 앞·뒷면만 생성한다. +수정하지 않고 모바일 제출에 사용할 응웬반A의 합성 여권 인적사항면과 ARC 앞·뒷면을 +생성한다. ```bash ./scripts/export-golden-demo-files @@ -169,16 +192,17 @@ POST /api/v1/worker-links/{workerLinkId}/sms-deliveries 1. 공개 Worker Link를 시크릿 창 또는 다른 브라우저에서 연다. 2. 안내 내용을 확인한다. -3. 요청 서류에서 `외국인등록증 사본`을 선택한다. -4. Demo Data가 생성한 응웬반A의 합성 ARC 앞면 PNG를 선택한다. -5. 제출 버튼을 누르고 완료 화면을 확인한다. +3. 요청 서류에서 `여권 사본`, `외국인등록증 사본`을 선택한다. +4. Demo Data가 생성한 합성 여권 인적사항면과 ARC 앞면 PNG를 선택한다. +5. 제출 버튼을 누르고 완료 화면을 확인한다. ARC 뒷면은 추가 검증이 필요할 때만 + 제출한다. 합성 파일에는 `DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION` 표시가 있으며 실제 신분증이 아니다. 공개 API는 JWT 대신 만료되는 Worker Link token만 사용한다. ```text GET /api/v1/public/worker-links/{token} -POST /api/v1/public/worker-links/{token}/files +POST /api/v1/public/worker-links/{token}/documents POST /api/v1/public/worker-links/{token}/responses ``` @@ -188,10 +212,11 @@ POST /api/v1/public/worker-links/{token}/responses ## 6. HR이 제출물을 회수하고 OCR을 검토한다 1. HR 화면으로 돌아와 근로자 응답과 파일을 연다. -2. 파일이 응웬반A의 합성 ARC인지 확인한다. -3. 제출 파일을 공식 `WorkerDocument`로 채택한다. -4. OCR 상태가 완료될 때까지 조회한다. -5. OCR 원본과 추출값을 비교하고 필요한 값만 수정한 뒤 검토 완료한다. +2. 파일이 응웬반A의 합성 여권·ARC인지 확인한다. +3. 제출 파일을 공식 `WorkerDocument`로 각각 채택한다. +4. 여권과 ARC의 OCR 상태가 완료될 때까지 조회한다. +5. OCR 원본과 추출값을 비교하고 필요한 값만 수정한 뒤 검토 완료한다. 합성 파일의 + 데모 값은 실제 개인정보가 아니며 공식 업무에 사용하지 않는다. ```text GET /api/v1/tasks/{taskId}/worker-responses @@ -205,21 +230,35 @@ OCR 결과만으로 Worker의 개인정보를 자동 수정하지 않는다. HR 기존 업무를 재실행할 때 사용한다. 같은 파일·이벤트를 다시 처리해도 기존 OCR Run과 WorkerDocument를 재사용해야 한다. +두 OCR 검토가 끝나면 Outbox가 기존 Renewal 실행을 자동 재개한다. `missingSlots`가 +비어 있고 생성 문서에 `storedFileId`가 연결됐는지 확인한다. 생성된 원본은 다음 API로 +내려받는다. + +```text +GET /api/v1/files/{storedFileId}/content +``` + +현재 HWP → PDF 브라우저 미리보기 품질은 AI #53의 후속 범위다. Golden Flow 완료 +여부는 원본 HWP 다운로드와 문서 내부 필드값으로 검증하며, 미리보기 실패를 문서 생성 +실패로 처리하지 않는다. + ## 7. 취업활동기간과 체류기간 연장을 마친다 앞선 의존 업무가 완료되면 Case의 `currentTask`가 세 번째 업무로 이동해야 한다. ### 취업활동기간 연장 -- 별지 제12호의3 초안과 첨부서류를 검토한다. +- 서명된 계약서와 별지 제12호의3 준비정보·첨부서류를 필수 확인한다. - HR 승인 후 고용 관련 기관에 직접 제출한다. +- `HR이 공식 채널에서 직접 제출`은 승인 후 실행 확인 항목으로 기록한다. - 접수처, 안전한 합성 접수번호, 제출시각을 기록한다. - 접수증을 증빙으로 남기고 업무를 완료한다. ### 체류기간 연장 -- 통합신청서와 체류 관련 증빙을 검토한다. +- 여권·ARC, 앞선 연장 결과, 통합신청서와 체류 관련 증빙을 필수 확인한다. - HR 승인 후 공식 사이트 또는 관할기관에서 직접 제출한다. +- `직접 제출`과 `처리결과·다음 확인일`은 승인 후 실행 확인 항목으로 기록한다. - 접수 결과와 최종 처리 결과를 기록한다. - 증빙을 남기고 마지막 업무를 완료한다. @@ -251,6 +290,7 @@ POST /api/v1/tasks/{taskId}/complete | 모바일 링크가 열리지 않음 | 원본 token, 링크 만료시각, public path 확인 | | 제출 파일이 안 보임 | Worker Response와 StoredFile 저장 여부, HR 사업장 권한 확인 | | OCR이 계속 대기 | Outbox backlog, OCR Provider 활성화, 암호화 키 확인 | +| 30분 뒤 승인 API가 H2에서 500 | local profile의 `spring.datasource.hikari.max-lifetime=0` 적용 여부 확인 | | 다음 업무가 열리지 않음 | 선행 Task들의 `COMPLETED`, 생성된 조건부 업무 완료 여부 | | 문서가 생성되지 않음 | Renewal 응답 `missingSlots`, 생성 문서 status와 FileStorage 경로 확인 |