diff --git a/.env.example b/.env.example index cc67799..3e55ea8 100644 --- a/.env.example +++ b/.env.example @@ -64,9 +64,10 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # local=false, dev/prod=true가 기본입니다. HTTPS 정책을 바꿀 때만 명시하세요. # REFRESH_TOKEN_COOKIE_SECURE=true -# 선택 사항: local(H2) 또는 로컬 PostgreSQL(dev)에 회사 2개, 계정 23개, 근로자 10명을 생성합니다. -# Demo Company에는 기존 계정 20개와 근로자 5명, Task 5개, 근로자 서류 7개, -# Task 최근 활동 3개가 생성됩니다. Test Company에는 역할별 계정 3개와 근로자 5명이 생성됩니다. +# 선택 사항: local(H2) 또는 개인 PostgreSQL(dev)에 운영형 데모 데이터를 생성합니다. +# Demo Company: 계정 20, 근로자 28, Task 24, 서류 84, Audit Event 96. +# Test Company: 계정 3, 근로자 5, Task 3, 서류 8, Audit Event 8. +# 상세 시나리오와 초기화 방법은 docs/demo-seed.md를 참고합니다. # 기본값은 false이며, true로 켤 때 비밀번호를 반드시 실행 환경의 Secret으로 넣습니다. # prod에서는 사용하지 않고 #9 provisioning 단계에서 계정을 준비합니다. # 실제 비밀번호를 이 파일이나 Git에 커밋하지 않습니다. diff --git a/README.md b/README.md index 9cd01b8..d961caf 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 해야 할 일을 업무카 | 현재 구현된 API | [Swagger](https://fowoco.github.io/server/api/) · [OpenAPI JSON](https://fowoco.github.io/server/api/openapi.json) | `main` 코드에서 자동 생성되는 실제 API 계약 | | DB 테이블·ERD | [Database 문서](https://fowoco.github.io/server/) | Flyway를 빈 PostgreSQL에 적용해 자동 생성한 구조 | | 로컬 실행·인증·Workflow | [개발 가이드](docs/development-guide.md) | 처음 서버를 실행하고 기능 흐름을 이해하는 방법 | +| Demo Seed 수량·시나리오 | [Demo Seed 운영 시나리오](docs/demo-seed.md) | 로컬 데모 데이터의 기준 수량, 대표 흐름과 표현 한계 | +| Figma fixture 대응표 | [Figma Demo Fixture Manifest](docs/demo-seed-fixture-manifest.md) | 화면 요구사항별 예약 데이터와 현재 API 노출 범위 | | 패키지·모듈 경계 | [프로젝트 구조](docs/project-structure.md) | 코드를 어느 패키지에 구현해야 하는지 설명 | | 중요한 설계 결정 | [ADR 목록](docs/adr/README.md) | 저장소 경계, API·보안, Task·AiRun, RLS 결정 원본 | | Server ↔ AI 계약 | [AI Runtime 계약](docs/ai-runtime-contract.md) | Server가 AI에 보내고 받을 수 있는 값과 검증 기준 | @@ -109,8 +111,9 @@ curl http://localhost:8080/health | OpenAPI JSON | | | H2 Console | | -PostgreSQL 실행, 회원가입·로그인, Demo Seed, Task·승인 흐름은 -[개발 가이드](docs/development-guide.md)에서 이어서 확인합니다. +PostgreSQL 실행과 회원가입·로그인은 [개발 가이드](docs/development-guide.md), +Demo Seed의 수량과 대표 업무 구성은 +[Demo Seed 운영 시나리오](docs/demo-seed.md)에서 확인합니다. ## 대표 흐름 diff --git a/docs/demo-seed-fixture-manifest.md b/docs/demo-seed-fixture-manifest.md new file mode 100644 index 0000000..8a3129c --- /dev/null +++ b/docs/demo-seed-fixture-manifest.md @@ -0,0 +1,109 @@ +# Figma Demo Fixture Manifest + +이 문서는 [FOWOCO Figma의 PWF_v3 흐름](https://www.figma.com/design/eaOD8OXZOGq6vK4H9pGXNi/FOWOCO?timeline=keyframe&node-id=1246-751&p=f)을 +서버 Demo Seed가 어떤 데이터로 표현하는지와, 그 데이터가 현재 API에서 어디까지 +노출되는지를 기록한다. 이 manifest는 화면 명세를 새로운 API 계약으로 간주하지 +않으며, 기존 Flyway 스키마와 애플리케이션 계약이 수용하는 fixture만 다룬다. + +## 상태 표기 + +| 상태 | 의미 | +| --- | --- | +| API 노출 | 현재 조회 API 응답으로 확인할 수 있다. | +| 저장 전용 | DB 또는 로컬 파일 저장소에는 있지만 현재 조회 API가 없다. | +| 근사 | 별도 모델 없이 기존 상태나 식별자로 화면 의미를 표현한다. | +| 제외 | 현재 도메인·스키마로 만들지 않거나 의도적으로 저장하지 않는다. | + +## 대표 복합 초안 흐름 + +Figma의 재계약·고용기간 연장·체류기간 연장 초안 흐름은 응웬반A와 동일한 +`case_id`를 사용하는 세 업무로 구성한다. + +| Figma 의미 | 예약 fixture | 저장 내용 | 현재 노출 | +| --- | --- | --- | --- | +| 대상 근로자 | Worker `92000000-0000-0000-0000-000000000006` | 응웬반A, `VN`, `vi`, 체류 만료 `D+45` | `GET /api/v1/workers`, `GET /api/v1/workers/{workerId}` | +| 복합 Case | Case ID `94100000-0000-0000-0000-000000000006` | 세 업무의 연속성을 나타내는 공통 식별자 | Task 응답의 `case_id`로 노출; Case 전용 API는 없음 | +| 재계약 조건 검토 | Task `94000000-0000-0000-0000-000000000006` | `RECONTRACT`, `READY_FOR_REVIEW`, candidate order 1 | Task 목록·상세 API | +| 후행 고용기간 연장 | Task `94000000-0000-0000-0000-000000000007` | `DRAFT`, candidate order 3, Task 6 의존 | Task 목록·상세 API | +| 여권 사본 요청 | Task `94000000-0000-0000-0000-000000000008` | `WAITING_WORKER`, candidate order 2, 제출 기한 7일 | Task 목록·상세 API | +| 검토 snapshot | Approval `94300000-0000-0000-0000-000000000002` | AI/HR snapshot, 7/7 검증, 경고 1건, HR 변경 필드와 원천 버전 | 저장 전용; 승인 조회 API 없음 | +| 베트남어 요청 초안 | Draft `94700000-0000-0000-0000-000000000002` | `vi`, `PASSPORT_COPY`, 7일 이내 제출 문구 | 저장 전용; Draft 조회 API 없음 | +| AI 처리 흔적 | trace ID `demo-compound-draft-flow` | 대상 확인부터 후행 후보 준비까지 AI Agent 이벤트 5건 | 업무 활동 API에서 노출 | + +Case 진행률, Case 상태와 Case 전용 엔티티는 만들지 않는다. 클라이언트가 세 Task의 +`case_id`와 `business_data`를 조합해야 한다. + +## 검토·승인·제출 lifecycle + +| 단계 | fixture | 저장 내용 | 현재 노출 | +| --- | --- | --- | --- | +| 승인 대기 | Approval 1~4 | `PENDING` 4건과 업무별 immutable snapshot | 저장 전용 | +| 승인 완료 | Approval 5~11 | `APPROVED` 7건과 결정 사유 | 저장 전용 | +| 반려 | Approval 12 | 필수 고용 정보 부족으로 `REJECTED` | 저장 전용 | +| 무효화 | Approval 13 | 마감일 변경으로 기존 snapshot `INVALIDATED` | 저장 전용 | +| 외부 제출 | External Submission 1~6 | 고용센터·출입국 제출처와 안전한 참조 번호 | 저장 전용 | +| 완료 증빙 | Evidence 1~10 | 문서·HR 확인·접수증·공식 결과 | 저장 전용 | +| 화면 활동 | Audit Event 96건 | 승인·반려·제출·증빙·완료 이벤트 | 업무 활동 및 ADMIN 감사 검색 API | + +현재 Approval API는 승인 요청과 결정, 제출·증빙 기록을 위한 write API다. 기존 +fixture를 읽는 Approval·Submission·Evidence 조회 API는 없으므로, 화면에서는 Task +상태와 Audit Event만 직접 사용할 수 있다. + +## 합성 PDF와 문서 연결 + +PDF는 모두 합성 데이터이며 `DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION` 표시가 +있다. 실제 개인정보나 행정 문서 원본을 포함하지 않는다. + +| 파일 | StoredFile ID | 연결 | 현재 노출 | +| --- | --- | --- | --- | +| `demo-contract-renewal.pdf` | `94800000-0000-0000-0000-000000000001` | 마크 레예스, Task 5, CONTRACT 문서 `95000000-0000-0000-0000-000000000007` | 통합 문서함의 `file_id`; 파일 읽기·다운로드 API는 없음 | +| `demo-stay-extension-receipt.pdf` | `94800000-0000-0000-0000-000000000002` | 모하메드 라힘, Task 20, RECEIPT Evidence | 저장 전용 | +| `demo-stay-extension-result.pdf` | `94800000-0000-0000-0000-000000000003` | 모하메드 라힘, Task 20, OFFICIAL_RESULT Evidence | 저장 전용 | + +`task_evidence.file_reference`에는 prefix 없는 `stored_file_id` UUID 문자열을 +저장한다. DB FK가 아니므로 Demo Seed verifier가 Task·Worker·파일 관계를 직접 +검증한다. + +Demo Seed가 활성화되면 classpath PDF를 `app.file-storage.local-path`에 설치한다. +동일한 storage key의 파일은 크기와 SHA-256이 같을 때만 재사용하며, 다른 파일은 +덮어쓰지 않고 시작을 중단한다. 파일만 남거나 DB row만 남은 상태는 동일 fixture인 +경우 복구한다. 현재 installer는 `LocalFileStorage` 구성만 지원한다. + +## 근로자와 지원 언어 + +Demo Company의 근로자 28명은 AI 팀이 지원하는 locale 15개를 모두 최소 1명씩 +포함한다. + +`en`, `zh-Hans`, `vi`, `th`, `fil`, `id`, `mn`, `si`, `ru`, `uz`, `ky`, `bn`, +`ur`, `km`, `tet` + +국적·선호 언어 조합은 데모에서 실제 사용할 법한 조합으로 구성한다. Worker UUID, +업무·문서 연결, 상태와 상대 날짜는 locale 정합화 전후에 유지한다. + +## API 노출 요약 + +| 데이터 | 저장 | 조회 API | 비고 | +| --- | --- | --- | --- | +| Worker | O | O | 이름·국적·선호 언어·체류/계약일 노출 | +| Task와 Checklist | O | O | `case_id`, `business_data`, 상태와 마감일 노출 | +| WorkerDocument | O | O | 통합 문서함에서 상태·만료일·선택적 `file_id` 노출 | +| Audit Event | O | O | 업무 활동과 ADMIN 감사 검색 지원 | +| Approval snapshot | O | X | write API만 존재 | +| External Submission | O | X | write API만 존재 | +| Evidence | O | X | write API만 존재 | +| Document Request Draft | O | X | 조회 Controller 없음 | +| StoredFile 메타데이터 | O | X | 업로드 API만 존재 | +| PDF 바이너리 | O | X | 로컬 저장소에 설치되지만 읽기·다운로드 API 없음 | + +## 근사 및 제외 범위 + +- 문서 요청·응답 대기는 `WAITING_WORKER`, `MISSING`, Draft와 Audit로 근사한다. +- 요청 중과 최근 업로드는 각각 `MISSING`, `SUBMITTED` 상태로 근사한다. +- Dashboard 수치는 별도 집계 API가 아니라 클라이언트가 조회 결과를 조합한다. +- Worker Secure Link의 토큰·전송·읽음·응답 수명주기는 만들지 않는다. +- HWP/HWPX, 여권·외국인등록증 이미지와 실제 행정 제출 문서는 포함하지 않는다. +- 여권번호, 외국인등록번호, 전화번호, 주소, 임금, 계정·토큰과 실제 Secret은 + 저장하지 않는다. + +수량, 실행 방법, 초기화와 멱등성 규칙은 [Demo Seed 운영 시나리오](demo-seed.md)를 +기준으로 한다. diff --git a/docs/demo-seed.md b/docs/demo-seed.md new file mode 100644 index 0000000..25e230d --- /dev/null +++ b/docs/demo-seed.md @@ -0,0 +1,259 @@ +# Demo Seed 운영 시나리오 + +Demo Seed는 현재 구현된 API를 로컬에서 현실적인 운영 데이터로 확인하기 위한 +개발 전용 데이터셋이다. Figma의 업무·근로자 구성을 참고하되, 서버에 존재하는 +도메인과 상태만 사용한다. + +Figma 화면 요구사항별 예약 ID, DB 저장 위치와 현재 API 노출 여부는 +[Figma Demo Fixture Manifest](demo-seed-fixture-manifest.md)에서 확인한다. + +> Demo Seed는 로컬 H2 또는 개인 PostgreSQL 개발 DB 전용이다. 공유 `dev`와 +> `prod`에서 활성화하거나 실제 개인정보·Secret을 섞어 사용하면 안 된다. + +## 실행 조건 + +Demo Seed의 기본값은 `false`다. 활성화할 때는 12자 이상의 로컬 전용 비밀번호를 +실행 환경의 Secret으로 제공해야 한다. 모든 데모 계정은 이 비밀번호를 공유하며, +저장할 때는 BCrypt 해시만 남긴다. + +```bash +export DEMO_SEED_ENABLED=true +export DEMO_SEED_ADMIN_PASSWORD='로컬 전용 12자 이상 값' +./gradlew bootRun +``` + +팀 공통 PostgreSQL 실행 스크립트는 `.env.local`을 읽고 `dev` profile과 Demo +Seed를 함께 활성화한다. + +```bash +# macOS / Linux +cp .env.example .env.local +./scripts/run-dev.sh +``` + +```powershell +# Windows PowerShell +Copy-Item .env.example .env.local +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 +``` + +`.env.local`에는 로컬 PostgreSQL 자격 증명, JWT Secret과 Demo 비밀번호를 +직접 설정한다. 이 파일과 실제 값은 Git, Issue, 로그 또는 메신저에 올리지 않는다. + +## 대표 로그인 계정 + +| 회사 | 역할 | 이메일 | 용도 | +| --- | --- | --- | --- | +| Demo Company | `ADMIN` | `demo.admin@example.com` | 기본 관리자, 환경변수로 변경 가능 | +| Demo Company | `ADMIN` | `demo.ops@example.com` | 운영 관리자 | +| Demo Company | `HR` | `demo.hr01@example.com` | 일반 업무 조회·처리 | +| Demo Company | `VIEWER` | `demo.viewer01@example.com` | 읽기 전용 권한 확인 | +| Test Company | `ADMIN` | `test.admin@example.com` | 테넌트 격리 확인 | +| Test Company | `HR` | `test.hr@example.com` | 작은 테스트 회사 업무 확인 | +| Test Company | `VIEWER` | `test.viewer@example.com` | 작은 테스트 회사 읽기 권한 확인 | + +`DEMO_SEED_ADMIN_EMAIL`을 변경하면 첫 번째 Demo Company 관리자 이메일만 +변경된다. Test Company는 Demo Company와 분리된 작은 격리 검증용 데이터셋이다. + +## 최종 데이터 수량 + +### FOWOCO Demo Company + +| 데이터 | 수량 | 주요 분포 | +| --- | ---: | --- | +| 계정 | 20 | `ADMIN` 2, `HR` 12, `VIEWER` 6 | +| 근로자 | 28 | `ACTIVE`와 `ON_LEAVE`, AI 지원 locale 15개, 다양한 체류 만료 구간 | +| 업무 | 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으로 +저장한다. + +### FOWOCO Test Company + +| 데이터 | 수량 | +| --- | ---: | +| 계정 | 3 | +| 근로자 | 5 | +| 업무 | 3 | +| 근로자 서류 | 8 | +| Audit Event | 8 | + +Test Company에는 Demo Company의 전체 운영 데이터를 복제하지 않는다. Demo +계정으로 Test Company 업무를 조회하거나 그 반대로 조회하면 `404` 또는 빈 +목록이 반환되어야 한다. + +## 대표 근로자 시나리오 + +모든 날짜는 최초 생성 시 주입된 `Clock`을 기준으로 상대 계산된다. `D+12`와 같은 +표현은 그 최초 생성일을 기준으로 하며, 예약 데이터가 존재하는 상태에서 서버를 +재기동해도 날짜와 과거 이력은 이동하지 않는다. + +| 근로자 | 대표 상황 | 연결 데이터 | +| --- | --- | --- | +| 응웬반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`, 승인 상태, 외부 제출, 완료 증빙, 상태 전이 이력 | + +응웬반A의 세 업무는 별도 Case 엔티티 없이 같은 `caseId`로 연속성만 표현한다. +서버는 Case 진행률이나 Case 전용 API를 제공하지 않는다. + +## 클라이언트에서 확인되는 데이터 + +현재 클라이언트는 서버 원본 값을 조합해 화면용 문구와 분류를 만든다. Demo +Seed에는 한국어 배지 문자열이나 별도 UI 전용 필드를 저장하지 않는다. + +### 업무 목록과 Metric Strip + +| 화면 의미 | 서버 데이터 | 기대 수량·예시 | +| --- | --- | --- | +| 승인 대기 | `READY_FOR_REVIEW` | 4개 | +| AI 준비 완료 | `DRAFT` | 3개 | +| 긴급 업무 | `dueDate <= today + 7일` | 오늘, `D+3`, `D+4`, `D+6`, `D+7` 등 복수 | +| 오늘 완료 | `COMPLETED`이고 `updatedAt`이 오늘 | 5개 | + +근로자 분류에는 다음 세 범주가 모두 나타난다. + +- `needs-review`: `READY_FOR_REVIEW`, `WAITING_WORKER`, `WAITING_EXTERNAL` 중 하나 이상 +- `ai-suggested`: 위 상태가 없고 `DRAFT` 또는 `NEEDS_INFO`가 존재 +- `done`: 위 두 조건에 해당하지 않음 + +### 문서 목록 + +- 검토 필요: `SUBMITTED` +- 만료 예정: 오늘부터 30일 이내의 `expiryDate` +- 누락 문서: `MISSING` +- 요청 중: 현재 클라이언트가 `MISSING`으로 근사 +- 최근 업로드: 현재 클라이언트가 `SUBMITTED`로 근사 + +`요청 중`과 `최근 업로드`는 현재 서버가 별도 수명주기를 제공하지 않기 때문에 +정확한 상태가 아니다. + +### 업무 상세와 Audit + +- 업무 상세 API에서 연결된 체크리스트를 확인할 수 있다. +- `94000000-0000-0000-0000-000000000002` 업무는 기존 호환성을 위해 + `TASK_CREATED`, `TASK_UPDATED`, `APPROVAL_REQUESTED` 활동 3개를 유지한다. +- 확장 Audit에는 업무 생성, 체크리스트 변경, 승인 결과, 외부 제출, 완료 증빙, + 문서 요청 초안, 완료·취소 이벤트가 포함된다. +- `WORKER_LINK` 이벤트는 근로자가 제공한 문서 정보가 업무에 반영된 맥락만 + 표현하며 실제 링크 토큰이나 전송·열람 상태를 만들지 않는다. + +## Figma 표현 범위 + +### 현재 모델로 직접 표현한 항목 + +- 근로자별 지원 업무, 상태, 마감일과 체류 만료일 +- 업무 체크리스트와 승인 요청·결과 +- 외부 제출, 완료 증빙과 상태 전이 이력 +- 문서 누락·제출·검증 및 만료 구간 +- 다국어 문서 요청 초안 +- 합성 PDF, StoredFile 메타데이터와 근로자 문서·완료 증빙 연결 +- HR 사용자, AI 에이전트, 시스템 규칙, 근로자 링크 맥락의 Audit Event + +### 현재 모델로 근사한 항목 + +- 문서 요청·응답 대기: `WAITING_WORKER`, `MISSING`, 문서 요청 초안과 Audit로 표현 +- 요청 중: 클라이언트가 `MISSING`으로 근사 +- 최근 업로드: 클라이언트가 `SUBMITTED`로 근사 +- Case 연속성: 별도 엔티티 없이 여러 업무의 동일한 `caseId`로 표현 + +### 의도적으로 제외한 항목 + +- 담당자 미지정 업무 +- `WORKER_ONBOARDING`, `EMPLOYMENT_CHANGE` 업무 유형 +- 실제 Worker Secure Link와 등록·전송·응답·읽음·안읽음 수명주기 +- 메시지, 근로자 질문, Ticket +- Case 엔티티, Case 진행률과 Case API +- Dashboard 집계 API; 현재 Dashboard는 클라이언트 정적 데이터 유지 +- 급여, 근태, 일정, 수입, OCR, AI 분석 모델 +- HWP/HWPX, 실제 여권·외국인등록증 이미지와 행정 제출 문서 +- 파일 읽기·미리보기·다운로드 API +- 실제 여권번호, 외국인등록번호, 전화번호, 주소, 임금, 토큰, 자격 증명 + +## 멱등성, 충돌과 날짜 snapshot + +모든 예약 레코드는 고정 UUID를 사용한다. 같은 설정으로 다시 실행하면 중복을 +만들지 않고 기존 레코드의 회사·외래키 소유권과 핵심 값을 검증한다. 예약 ID나 +이메일이 다른 데이터에 사용된 경우 기존 데이터를 덮어쓰지 않고 시작을 +중단한다. + +Demo Seed는 최초 생성 시점의 `Clock`을 기준으로 만든 immutable snapshot이다. +예약 데이터가 존재하는 상태에서 같은 날 또는 다음 날 서버를 다시 실행해도 +근로자·문서·업무 날짜, timestamp, fingerprint, revision, `updatedAt`, JPA version을 +다시 쓰지 않는다. + +## 초기화와 재실행 + +단순 재실행은 DB를 초기화할 필요가 없다. 서버를 종료한 뒤 같은 설정으로 실행하면 +멱등성 검증 후 최초 snapshot을 그대로 재사용한다. 현재 날짜 기준의 새로운 Demo +데이터가 필요할 때만 개인 로컬 DB를 초기화한 뒤 Seed를 다시 생성한다. + +Seed Catalog 자체가 변경되어 예약 전이의 의미나 핵심 값이 달라진 버전으로 +업데이트한 경우에는 기존 snapshot을 자동 변환하지 않는다. 이 경우에도 기존 값을 +덮어쓰지 않고 시작을 중단하므로, 아래 절차로 개인 로컬 DB를 한 번 초기화한다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 +``` + +완전히 새 DB로 확인하려면 먼저 서버를 종료하고, **개인 로컬 DB가 +`fowoco_test`인지 다시 확인한 뒤에만** 삭제·재생성한다. 다음 명령은 해당 DB의 +모든 데이터를 복구 불가능하게 삭제한다. + +```bash +dropdb -h localhost -p 5432 -U postgres fowoco_test +createdb -h localhost -p 5432 -U postgres fowoco_test +``` + +Docker를 사용한다면 개인 로컬 PostgreSQL 컨테이너의 `fowoco_test` DB 또는 전용 +볼륨만 재생성한다. 공유 컨테이너나 다른 프로젝트 볼륨은 삭제하지 않는다. 그 후 +실행 스크립트를 다시 호출하면 Flyway 적용 후 Demo Seed가 생성된다. + +H2 `local`은 인메모리 DB이므로 애플리케이션을 종료하고 다시 실행하면 초기화된다. + +## 확인 쿼리 + +```sql +SELECT company_id, COUNT(*) FROM worker GROUP BY company_id; +SELECT company_id, COUNT(*) FROM task GROUP BY company_id; +SELECT company_id, COUNT(*) FROM worker_document GROUP BY company_id; +SELECT company_id, COUNT(*) FROM stored_file GROUP BY company_id; +SELECT company_id, COUNT(*) FROM audit_event GROUP BY company_id; +``` + +실행 로그의 운영 데이터 요약은 다음 값을 포함한다. + +```text +demo_task_count=24 demo_stored_file_count=3 demo_document_count=84 demo_audit_count=96 +test_task_count=3 test_document_count=8 test_audit_count=8 +``` diff --git a/docs/development-guide.md b/docs/development-guide.md index bb1bd13..b2ed169 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -160,23 +160,17 @@ Runtime Hikari Connection에는 기본 `statement_timeout=30s`, `lock_timeout=3s ## 선택 사항: local Demo Seed -local H2 또는 로컬 PostgreSQL `dev`에서만 사용할 데모 데이터가 필요할 때 -명시적으로 켭니다. 모든 계정은 로컬에서 설정한 같은 비밀번호로 로그인할 수 -있으며, 기본 비밀번호는 없습니다. +local H2 또는 개인 PostgreSQL `dev`에서만 사용할 데모 데이터가 필요할 때 +명시적으로 켭니다. 기본 비밀번호는 없으며, 모든 계정은 로컬에서 설정한 같은 +비밀번호를 사용합니다. -| 회사 | 계정 | 근로자 | -| --- | --- | --- | -| `FOWOCO Demo Company` | 기존 20명 (`ADMIN` 2, `HR` 12, `VIEWER` 6) | 5명 | -| `FOWOCO Test Company` | 3명 (`ADMIN` 1, `HR` 1, `VIEWER` 1) | 5명 | - -Demo Company에는 화면 검증용 업무 데이터도 함께 생성됩니다. +| 회사 | 계정 | 근로자 | 업무 | 서류 | Audit Event | +| --- | ---: | ---: | ---: | ---: | ---: | +| `FOWOCO Demo Company` | 20 | 28 | 24 | 84 | 96 | +| `FOWOCO Test Company` | 3 | 5 | 3 | 8 | 8 | -- Task 5개: `DRAFT`(AI 후보), `READY_FOR_REVIEW`, `WAITING_WORKER`, - `WAITING_EXTERNAL`, `COMPLETED` 각 1개 -- 마감일: 7일 이내, 30일 이내, 30일 초과 구간을 모두 포함 -- 근로자 서류 7개: 근로자별 1~2개, `MISSING`, `SUBMITTED`, `VERIFIED` 포함 -- 30일 이내 만료 서류 1개 이상 -- 승인 대기 Task의 최근 AuditEvent 3개 +대표 계정, 전체 수량·상태 분포, 근로자 시나리오, 프런트엔드 필터 연결과 +Figma 근사·제외 범위는 [Demo Seed 운영 시나리오](demo-seed.md)를 기준으로 한다. ```bash export DEMO_SEED_ENABLED=true @@ -185,9 +179,8 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 Secret의 12자 이상 값' ``` 같은 설정으로 재실행해도 중복 생성하지 않습니다. 고정 ID나 이메일이 다른 -사업장·역할·근로자 정보와 충돌하면 덮어쓰지 않고 시작을 중단합니다. 이메일과 -근로자 이름은 모두 데모 전용 값이며 실제 개인정보를 포함하지 않습니다. -최초 계정을 확인한 후에는 +데이터와 충돌하면 덮어쓰지 않고 시작을 중단합니다. 실제 개인정보, Secret, +파일 또는 가짜 저장 경로를 만들지 않습니다. 확인 후에는 `DEMO_SEED_ENABLED=false`로 돌려놓습니다. 공유 `dev`와 `prod`에서는 Demo Seed 대신 배포 Provisioning 단계에서 초기 계정을 @@ -214,7 +207,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1 두 스크립트 모두 PostgreSQL `dev` profile과 Demo Seed를 활성화합니다. `DB_URL`, migration/runtime username이 비어 있으면 로컬 기본값 `jdbc:postgresql://localhost:5432/fowoco_test`와 `postgres`를 사용합니다. -대상 데이터베이스는 최초 한 번 직접 생성합니다. +대상 데이터베이스는 최초 한 번 직접 생성합니다. 초기화와 안전한 재실행 절차는 +[Demo Seed 운영 시나리오](demo-seed.md#초기화와-재실행)를 참고합니다. ```bash createdb -h localhost -p 5432 -U postgres fowoco_test @@ -270,6 +264,7 @@ Client가 안전한 형식의 `X-Request-Id`를 보내면 Server가 응답과 ## 관련 문서 +- [Demo Seed 운영 시나리오](demo-seed.md) - [API 문서 사용법](api-documentation.md) - [Database 문서 사용법](database-documentation.md) - [프로젝트 구조](project-structure.md) diff --git a/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java b/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java index bbad891..0addee9 100644 --- a/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java +++ b/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java @@ -3,12 +3,15 @@ import com.fowoco.server.audit.application.AuditSearchCriteria; import com.fowoco.server.audit.domain.AuditEvent; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface AuditEventRepository { void append(AuditEvent event); + Optional findById(UUID auditEventId); + List findTaskActivities(UUID companyId, UUID taskId); List search(AuditSearchCriteria criteria); diff --git a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java index b1f9abe..1abc22f 100644 --- a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java +++ b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java @@ -5,6 +5,7 @@ import com.fowoco.server.audit.domain.AuditEvent; import com.fowoco.server.audit.domain.AuditTargetType; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @@ -23,6 +24,11 @@ public void append(AuditEvent event) { repository.save(new AuditEventJpaEntity(event)); } + @Override + public Optional findById(UUID auditEventId) { + return repository.findById(auditEventId).map(AuditEventJpaEntity::toDomain); + } + @Override public List findTaskActivities(UUID companyId, UUID taskId) { return repository diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java new file mode 100644 index 0000000..7dc3e8b --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoApprovalRequestSeeder.java @@ -0,0 +1,129 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.approval.application.SafeJsonService; +import com.fowoco.server.approval.domain.ApprovalRequest; +import com.fowoco.server.approval.domain.ApprovalStatus; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; + +final class DemoApprovalRequestSeeder { + + private final ApprovalRequestRepository approvalRepository; + private final TaskRepository taskRepository; + private final SafeJsonService safeJsonService; + + DemoApprovalRequestSeeder( + ApprovalRequestRepository approvalRepository, + TaskRepository taskRepository, + SafeJsonService safeJsonService + ) { + this.approvalRepository = Objects.requireNonNull( + approvalRepository, + "approvalRepository must not be null" + ); + this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); + this.safeJsonService = Objects.requireNonNull(safeJsonService, "safeJsonService must not be null"); + } + + void seed(ApprovalSeed seed, DemoOperationalSeedContext context) { + Task task = taskRepository.findByIdAndCompanyId(seed.taskId(), context.companyId()) + .orElseThrow(() -> new IllegalStateException("a demo approval task does not exist")); + Optional existing = approvalRepository.findByIdAndCompanyId( + seed.approvalRequestId(), + context.companyId() + ); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context, task); + return; + } + Instant requestedAt = context.now().minus(seed.requestedHoursAgo(), ChronoUnit.HOURS); + ApprovalRequest approval = ApprovalRequest.create( + seed.approvalRequestId(), + seed.taskId(), + context.companyId(), + task.version(), + task.contentRevision(), + task.criticalFingerprint(), + safeJsonService.write(seed.aiSnapshot(), true), + safeJsonService.write(seed.hrSnapshot(), true), + safeJsonService.write(seed.changedFields(), true), + safeJsonService.write(seed.sourceVersions(), true), + context.actorId(), + requestedAt + ); + applyOutcome(approval, seed, task, context); + approvalRepository.save(approval); + } + + private void applyOutcome( + ApprovalRequest approval, + ApprovalSeed seed, + Task task, + DemoOperationalSeedContext context + ) { + if (seed.status() == ApprovalStatus.PENDING) { + return; + } + Instant outcomeAt = context.now().minus(seed.outcomeHoursAgo(), ChronoUnit.HOURS); + switch (seed.status()) { + case APPROVED -> approval.approve( + task.version(), + task.contentRevision(), + task.criticalFingerprint(), + task.version(), + context.actorId(), + seed.reason(), + outcomeAt + ); + case REJECTED -> approval.reject(task.version(), context.actorId(), seed.reason(), outcomeAt); + case INVALIDATED -> approval.invalidate(seed.reason(), outcomeAt); + case PENDING -> throw new IllegalStateException("pending approval must not have an outcome"); + } + } + + void verifyExisting( + ApprovalRequest approval, + ApprovalSeed seed, + DemoOperationalSeedContext context, + Task task + ) { + if (!seed.approvalRequestId().equals(approval.approvalRequestId()) + || !seed.taskId().equals(approval.taskId()) + || !context.companyId().equals(approval.companyId()) + || seed.status() != approval.status() + || task.version() != approval.targetTaskVersion() + || task.contentRevision() != approval.targetContentRevision() + || !task.criticalFingerprint().equals(approval.targetFingerprint()) + || !safeJsonService.write(seed.aiSnapshot(), true).equals(approval.aiSnapshotJson()) + || !safeJsonService.write(seed.hrSnapshot(), true).equals(approval.hrSnapshotJson()) + || !safeJsonService.write(seed.changedFields(), true).equals(approval.changedFieldsJson()) + || !safeJsonService.write(seed.sourceVersions(), true).equals(approval.sourceVersionsJson()) + || !context.actorId().equals(approval.requestedBy()) + || !matchesOutcome(approval, seed, context)) { + throw new IllegalStateException( + "a reserved demo approval id already belongs to different approval data" + ); + } + } + + private boolean matchesOutcome( + ApprovalRequest approval, + ApprovalSeed seed, + DemoOperationalSeedContext context + ) { + return switch (seed.status()) { + case PENDING -> approval.decidedBy() == null && approval.invalidatedAt() == null; + case APPROVED, REJECTED -> context.actorId().equals(approval.decidedBy()) + && seed.reason().equals(approval.decisionReason()) + && approval.decidedAt() != null; + case INVALIDATED -> seed.reason().equals(approval.invalidationReason()) + && approval.invalidatedAt() != null; + }; + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java new file mode 100644 index 0000000..9f15fb9 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditEventSeeder.java @@ -0,0 +1,77 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +final class DemoAuditEventSeeder { + + private static final String EVENT_VERSION = "1"; + + private final AuditEventRepository auditEventRepository; + + DemoAuditEventSeeder(AuditEventRepository auditEventRepository) { + this.auditEventRepository = Objects.requireNonNull( + auditEventRepository, + "auditEventRepository must not be null" + ); + } + + void seed(AuditSeed seed, DemoOperationalSeedContext context) { + Optional existing = findExisting(seed); + if (existing.isPresent()) { + verifyExisting(existing.orElseThrow(), seed, context); + return; + } + auditEventRepository.append(new AuditEvent( + seed.auditEventId(), + context.companyId(), + seed.actorType(), + actorId(seed, context), + seed.userRole(), + seed.action(), + seed.targetType(), + seed.targetId(), + seed.requestId(), + seed.traceId(), + EVENT_VERSION, + seed.changeSummary(), + context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS) + )); + } + + Optional findExisting(AuditSeed seed) { + return auditEventRepository.findById(seed.auditEventId()); + } + + void verifyExisting( + AuditEvent event, + AuditSeed seed, + DemoOperationalSeedContext context + ) { + if (!context.companyId().equals(event.companyId()) + || event.actorType() != seed.actorType() + || !Objects.equals(actorId(seed, context), event.actorId()) + || event.userRole() != seed.userRole() + || event.action() != seed.action() + || event.targetType() != seed.targetType() + || !seed.targetId().equals(event.targetId()) + || !seed.requestId().equals(event.requestId()) + || !seed.traceId().equals(event.traceId()) + || !EVENT_VERSION.equals(event.eventVersion()) + || !seed.changeSummary().equals(event.changeSummary())) { + throw new IllegalStateException( + "a reserved demo audit event id already belongs to different audit data" + ); + } + } + + private UUID actorId(AuditSeed seed, DemoOperationalSeedContext context) { + return seed.actorType() == ActorType.HR_USER ? context.actorId() : null; + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditSeedCatalog.java new file mode 100644 index 0000000..03c70df --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoAuditSeedCatalog.java @@ -0,0 +1,267 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.domain.ApprovalStatus; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +final class DemoAuditSeedCatalog { + + private static final List CHECKLIST_EVENT_TASKS = List.of( + 3, 4, 5, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22 + ); + + private DemoAuditSeedCatalog() { + } + + static List additionalDemoAudits( + List tasks, + List approvals, + List submissions, + List evidence, + List requestDrafts + ) { + List seeds = new ArrayList<>(); + for (int taskIndex = 0; taskIndex < tasks.size(); taskIndex++) { + int taskNumber = taskIndex + 1; + if (taskNumber != 2 && taskNumber != 6 && taskNumber != 7 && taskNumber != 8) { + TaskSeed task = tasks.get(taskIndex); + add(seeds, actorType(task.source()), AuditAction.TASK_CREATED, + AuditTargetType.TASK, task.taskId(), + "업무 시나리오가 생성됨", task.createdDaysAgo() * 24); + } + } + addCompoundDraftTrace(seeds, tasks); + CHECKLIST_EVENT_TASKS.forEach(taskNumber -> { + TaskSeed task = tasks.get(taskNumber - 1); + add(seeds, ActorType.HR_USER, AuditAction.CHECKLIST_ITEM_UPDATED, + AuditTargetType.TASK, task.taskId(), + "필수 체크리스트 준비 상태를 확인함", + Math.max((task.createdDaysAgo() - 1) * 24 + 2, 2)); + }); + for (int index = 0; index < approvals.size(); index++) { + ApprovalSeed approval = approvals.get(index); + if (index != 0) { + add(seeds, ActorType.HR_USER, AuditAction.APPROVAL_REQUESTED, + AuditTargetType.TASK, approval.taskId(), + "현재 업무 내용에 대한 승인 검토를 요청함", + approval.requestedHoursAgo()); + } + if (approval.status() != ApprovalStatus.PENDING) { + add(seeds, ActorType.HR_USER, approvalAction(approval.status()), + AuditTargetType.TASK, approval.taskId(), approvalSummary(approval.status()), + approval.outcomeHoursAgo()); + } + } + submissions.forEach(submission -> add( + seeds, + ActorType.HR_USER, + AuditAction.EXTERNAL_SUBMISSION_RECORDED, + AuditTargetType.TASK, + submission.taskId(), + "외부 기관 제출과 안전한 접수 참조값을 기록함", + submission.hoursAgo() + )); + evidence.forEach(item -> add( + seeds, + ActorType.HR_USER, + AuditAction.EVIDENCE_RECORDED, + AuditTargetType.TASK, + item.taskId(), + "업무 완료 판단에 필요한 안전한 증빙 메모를 기록함", + item.hoursAgo() + )); + requestDrafts.forEach(draft -> add( + seeds, + ActorType.HR_USER, + AuditAction.DOCUMENT_REQUEST_DRAFT_SAVED, + AuditTargetType.DOCUMENT_REQUEST_DRAFT, + draft.draftId(), + "근로자 안내용 문서 요청 초안을 저장함", + draft.hoursAgo() + )); + tasks.stream() + .filter(task -> task.status() == TaskStatus.COMPLETED) + .forEach(task -> add(seeds, ActorType.HR_USER, AuditAction.TASK_COMPLETED, + AuditTargetType.TASK, task.taskId(), + "승인과 완료 증빙 확인 후 업무를 완료함", 0)); + tasks.stream() + .filter(task -> task.status() == TaskStatus.CANCELLED) + .forEach(task -> add(seeds, ActorType.HR_USER, AuditAction.TASK_CANCELLED, + AuditTargetType.TASK, task.taskId(), + "계약 일정 변경 사유를 확인하고 업무를 취소함", + Math.max((task.createdDaysAgo() - 1) * 24, 1))); + for (DocumentRequestDraftSeed draft : requestDrafts.stream().limit(4).toList()) { + add(seeds, ActorType.WORKER_LINK, AuditAction.TASK_UPDATED, + AuditTargetType.TASK, draft.taskId(), + "근로자 제출 문서 정보가 업무 준비 자료에 반영됨", + Math.max(draft.hoursAgo() - 12, 1)); + } + return List.copyOf(seeds); + } + + private static void addCompoundDraftTrace(List seeds, List tasks) { + String traceId = "demo-compound-draft-flow"; + TaskSeed recontract = tasks.get(5); + TaskSeed employmentExtension = tasks.get(6); + TaskSeed passportRequest = tasks.get(7); + addWithTrace(seeds, AuditAction.TASK_CREATED, recontract.taskId(), + traceId, "복합 요청의 대상 근로자와 Case 맥락을 확인함", 72); + addWithTrace(seeds, AuditAction.TASK_UPDATED, recontract.taskId(), + traceId, "재계약 Workflow와 필수 정보를 확인함", 60); + addWithTrace(seeds, AuditAction.TASK_UPDATED, passportRequest.taskId(), + traceId, "보유 문서를 비교하고 여권 사본 누락을 확인함", 48); + addWithTrace(seeds, AuditAction.TASK_UPDATED, passportRequest.taskId(), + traceId, "베트남어 문서 요청 초안을 준비함", 40); + addWithTrace(seeds, AuditAction.TASK_CREATED, employmentExtension.taskId(), + traceId, "선행 재계약 결과를 기다리는 연장 후보를 준비함", 36); + } + + static List testAudits(List tasks) { + List seeds = new ArrayList<>(); + tasks.forEach(task -> addTest(seeds, actorType(task.source()), AuditAction.TASK_CREATED, + task.taskId(), "격리 확인용 테스트 업무가 생성됨", task.createdDaysAgo() * 24)); + tasks.forEach(task -> addTest(seeds, ActorType.HR_USER, AuditAction.CHECKLIST_ITEM_UPDATED, + task.taskId(), "테스트 업무의 준비 상태를 확인함", + Math.max((task.createdDaysAgo() - 1) * 24, 1))); + addTest(seeds, ActorType.HR_USER, AuditAction.APPROVAL_REQUESTED, + tasks.get(1).taskId(), "테스트 재계약 업무 승인을 요청함", 24); + addTest(seeds, ActorType.HR_USER, AuditAction.TASK_COMPLETED, + tasks.get(2).taskId(), "테스트 고용기간 연장 업무를 완료함", 0); + return List.copyOf(seeds); + } + + private static void add( + List seeds, + ActorType actorType, + AuditAction action, + AuditTargetType targetType, + UUID targetId, + String summary, + int hoursAgo + ) { + int sequence = seeds.size() + 4; + seeds.add(audit( + demoUuid("96000000-0000-0000-0000-000000000", sequence), + actorType, + action, + targetType, + targetId, + "demo-seed-audit-%03d".formatted(sequence), + "demo-task-%s".formatted(targetId.toString().substring(24)), + summary, + hoursAgo + )); + } + + private static void addTest( + List seeds, + ActorType actorType, + AuditAction action, + UUID targetId, + String summary, + int hoursAgo + ) { + int sequence = seeds.size() + 1; + seeds.add(audit( + demoUuid("99000000-0000-0000-0000-000000000", sequence), + actorType, + action, + AuditTargetType.TASK, + targetId, + "test-seed-audit-%03d".formatted(sequence), + "test-task-%s".formatted(targetId.toString().substring(24)), + summary, + hoursAgo + )); + } + + private static void addWithTrace( + List seeds, + AuditAction action, + UUID targetId, + String traceId, + String summary, + int hoursAgo + ) { + int sequence = seeds.size() + 4; + seeds.add(audit( + demoUuid("96000000-0000-0000-0000-000000000", sequence), + ActorType.AI_AGENT, + action, + AuditTargetType.TASK, + targetId, + "demo-seed-audit-%03d".formatted(sequence), + traceId, + summary, + hoursAgo + )); + } + + private static AuditSeed audit( + UUID auditEventId, + ActorType actorType, + AuditAction action, + AuditTargetType targetType, + UUID targetId, + String requestId, + String traceId, + String summary, + int hoursAgo + ) { + return new AuditSeed( + auditEventId, + actorType, + actorType == ActorType.HR_USER ? UserRole.ADMIN : null, + action, + targetType, + targetId, + requestId, + traceId, + summary, + hoursAgo + ); + } + + private static ActorType actorType(TaskSource source) { + return switch (source) { + case AI_CANDIDATE -> ActorType.AI_AGENT; + case SYSTEM_DDAY -> ActorType.SYSTEM_RULE; + case MANUAL -> ActorType.HR_USER; + }; + } + + private static AuditAction approvalAction(ApprovalStatus status) { + return switch (status) { + case APPROVED -> AuditAction.TASK_APPROVED; + case REJECTED -> AuditAction.TASK_REJECTED; + case INVALIDATED -> AuditAction.APPROVAL_INVALIDATED; + case PENDING -> throw new IllegalArgumentException("pending approval has no outcome action"); + }; + } + + private static String approvalSummary(ApprovalStatus status) { + return switch (status) { + case APPROVED -> "관리자가 현재 업무 내용을 승인함"; + case REJECTED -> "필수 정보 보완을 위해 승인 요청을 반려함"; + case INVALIDATED -> "검토 중인 승인 요청을 무효화함"; + case PENDING -> throw new IllegalArgumentException("pending approval has no outcome summary"); + }; + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentRequestDraftSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentRequestDraftSeeder.java new file mode 100644 index 0000000..8e2a5ba --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentRequestDraftSeeder.java @@ -0,0 +1,102 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import com.fowoco.server.document.domain.DocumentRequestReviewStatus; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoDocumentRequestDraftSeeder { + + private final DocumentRequestDraftRepository repository; + private final JdbcTemplate jdbcTemplate; + + DemoDocumentRequestDraftSeeder( + DocumentRequestDraftRepository repository, + JdbcTemplate jdbcTemplate + ) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + void seed(DocumentRequestDraftSeed seed, DemoOperationalSeedContext context) { + verifyReservedIdOwner(seed, context); + var existing = repository.findByTaskIdAndCompanyId(seed.taskId(), context.companyId()); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context); + return; + } + Instant createdAt = context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS); + repository.insert(new DocumentRequestDraft( + seed.draftId(), + seed.taskId(), + context.companyId(), + seed.language(), + seed.documentTypes(), + seed.message(), + DocumentRequestReviewStatus.DRAFT, + createdAt, + createdAt, + 0L + )); + } + + void verifyExisting(DocumentRequestDraftSeed seed, DemoOperationalSeedContext context) { + DocumentRequestDraft draft = repository.findByTaskIdAndCompanyId( + seed.taskId(), + context.companyId() + ) + .orElseThrow(() -> new IllegalStateException( + "a reserved demo document request draft was not seeded" + )); + verifyExisting(draft, seed, context); + } + + private void verifyReservedIdOwner( + DocumentRequestDraftSeed seed, + DemoOperationalSeedContext context + ) { + List owners = jdbcTemplate.query( + "SELECT task_id, company_id FROM document_request_draft WHERE draft_id = ?", + (resultSet, rowNumber) -> new DraftOwner( + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("company_id", UUID.class) + ), + seed.draftId() + ); + if (!owners.isEmpty() + && (!seed.taskId().equals(owners.get(0).taskId()) + || !context.companyId().equals(owners.get(0).companyId()))) { + throw new IllegalStateException( + "a reserved demo document request draft id belongs to a different task" + ); + } + } + + private void verifyExisting( + DocumentRequestDraft draft, + DocumentRequestDraftSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.draftId().equals(draft.draftId()) + || !seed.taskId().equals(draft.taskId()) + || !context.companyId().equals(draft.companyId()) + || !seed.language().equals(draft.language()) + || !new HashSet<>(seed.documentTypes()).equals(new HashSet<>(draft.documentTypes())) + || !seed.message().equals(draft.message()) + || draft.reviewStatus() != DocumentRequestReviewStatus.DRAFT) { + throw new IllegalStateException( + "a reserved demo task already has different document request draft data" + ); + } + } + + private record DraftOwner(UUID taskId, UUID companyId) { + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java new file mode 100644 index 0000000..9075673 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoDocumentSeedCatalog.java @@ -0,0 +1,250 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +final class DemoDocumentSeedCatalog { + + private static final Set MISSING_ADDITION_INDICES = + Set.of(0, 8, 14, 18, 24, 30, 36, 42, 48, 54, 60, 66, 72, 76); + private static final Set SUBMITTED_ADDITION_INDICES = + Set.of(1, 5, 11, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 69, 73); + private static final Integer[] EXPIRY_DAY_OFFSETS = {-20, 0, 7, 25, 45, 75, 120, 240, null}; + + private DemoDocumentSeedCatalog() { + } + + static List demoDocuments() { + List documents = new ArrayList<>(84); + addExistingDocuments(documents); + int documentNumber = 8; + int additionIndex = 0; + for (int workerNumber = 1; workerNumber <= 28; workerNumber++) { + for (DocumentType documentType : additionalTypes(workerNumber)) { + SubmissionStatus status = scenarioStatus(workerNumber, documentType, additionIndex); + documents.add(document( + "95000000-0000-0000-0000-000000000", + documentNumber, + "92000000-0000-0000-0000-000000000", + workerNumber, + documentType, + status, + scenarioExpiryDays(workerNumber, documentType, additionIndex), + scenarioDestination(workerNumber, documentType), + scenarioNote(workerNumber, documentType, status) + )); + documentNumber++; + additionIndex++; + } + } + return List.copyOf(documents); + } + + static List testDocuments() { + return List.of( + testDocument(1, 1, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, 120), + testDocument(2, 1, DocumentType.ARC, SubmissionStatus.MISSING, 15), + testDocument(3, 2, DocumentType.CONTRACT, SubmissionStatus.SUBMITTED, 45), + testDocument(4, 2, DocumentType.PERMIT, SubmissionStatus.VERIFIED, 180), + testDocument(5, 3, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, null), + testDocument(6, 3, DocumentType.ARC, SubmissionStatus.SUBMITTED, 7), + testDocument(7, 4, DocumentType.CONTRACT, SubmissionStatus.MISSING, -10), + testDocument(8, 5, DocumentType.PERMIT, SubmissionStatus.VERIFIED, 240) + ); + } + + private static void addExistingDocuments(List documents) { + documents.add(document(1, 1, DocumentType.PASSPORT_COPY, SubmissionStatus.MISSING, 25, + "체류기간 연장", "만료 임박 여권 사본을 다시 받아야 합니다.")); + documents.add(document(2, 1, DocumentType.ARC, SubmissionStatus.VERIFIED, 180, + "체류기간 연장", "외국인등록증 사본 확인 완료")); + documents.add(document(3, 2, DocumentType.CONTRACT, SubmissionStatus.SUBMITTED, 90, + "근로계약 갱신", "서명본 검토 대기")); + documents.add(document(4, 2, DocumentType.PERMIT, SubmissionStatus.VERIFIED, 150, + "고용허가기간 연장", "고용허가서 확인 완료")); + documents.add(document(5, 3, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, 300, + "체류기간 연장", "유효한 여권 사본")); + documents.add(document(6, 4, DocumentType.ARC, SubmissionStatus.MISSING, 45, + "고용허가기간 연장", "외국인등록증 사본 요청 필요")); + documents.add(document(7, 5, DocumentType.CONTRACT, SubmissionStatus.SUBMITTED, 120, + "근로계약 갱신", "갱신 계약서 제출 완료")); + } + + private static List additionalTypes(int workerNumber) { + return switch (workerNumber) { + case 1 -> List.of(DocumentType.CONTRACT); + case 2 -> List.of(DocumentType.ARC); + case 3 -> List.of(DocumentType.ARC, DocumentType.CONTRACT); + case 4 -> List.of(DocumentType.CONTRACT, DocumentType.PERMIT); + case 5 -> List.of(DocumentType.PASSPORT_COPY, DocumentType.ARC); + default -> List.of( + DocumentType.PASSPORT_COPY, + DocumentType.ARC, + workerNumber % 4 == 0 ? DocumentType.PERMIT : DocumentType.CONTRACT + ); + }; + } + + private static SubmissionStatus additionStatus(int additionIndex) { + if (MISSING_ADDITION_INDICES.contains(additionIndex)) { + return SubmissionStatus.MISSING; + } + if (SUBMITTED_ADDITION_INDICES.contains(additionIndex)) { + return SubmissionStatus.SUBMITTED; + } + return SubmissionStatus.VERIFIED; + } + + private static SubmissionStatus scenarioStatus( + int workerNumber, + DocumentType documentType, + int additionIndex + ) { + if (workerNumber == 6) { + return documentType == DocumentType.PASSPORT_COPY + ? SubmissionStatus.MISSING + : SubmissionStatus.VERIFIED; + } + return additionStatus(additionIndex); + } + + private static Integer scenarioExpiryDays( + int workerNumber, + DocumentType documentType, + int additionIndex + ) { + if (workerNumber == 6) { + return switch (documentType) { + case PASSPORT_COPY -> null; + case ARC -> 365; + case CONTRACT -> 180; + case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed"); + }; + } + return EXPIRY_DAY_OFFSETS[additionIndex % EXPIRY_DAY_OFFSETS.length]; + } + + private static String scenarioDestination(int workerNumber, DocumentType documentType) { + if (workerNumber == 6) { + return documentType == DocumentType.PASSPORT_COPY + ? "근로자 문서 요청" + : "재계약·연장 준비"; + } + return destination(documentType); + } + + private static String scenarioNote( + int workerNumber, + DocumentType documentType, + SubmissionStatus status + ) { + if (workerNumber == 6) { + return switch (documentType) { + case PASSPORT_COPY -> "여권 사본 미보유 · 베트남어 요청 필요"; + case ARC -> "외국인등록증 확인 완료"; + case CONTRACT -> "현재 근로계약서 확인 완료"; + case PERMIT -> throw new IllegalStateException("worker 6 has no permit document seed"); + }; + } + return note(documentType, status); + } + + private static String destination(DocumentType documentType) { + return switch (documentType) { + case PASSPORT_COPY, ARC -> "체류기간 연장"; + case CONTRACT -> "근로계약 갱신"; + case PERMIT -> "고용허가기간 연장"; + }; + } + + private static String note(DocumentType documentType, SubmissionStatus status) { + String documentName = switch (documentType) { + case PASSPORT_COPY -> "여권 사본"; + case ARC -> "외국인등록증 사본"; + case CONTRACT -> "근로계약서"; + case PERMIT -> "고용허가서"; + }; + return switch (status) { + case MISSING -> documentName + " 요청 필요"; + case SUBMITTED -> documentName + " 제출본 검토 대기"; + case VERIFIED -> documentName + " 확인 완료"; + }; + } + + private static DocumentSeed document( + int documentNumber, + int workerNumber, + DocumentType documentType, + SubmissionStatus status, + Integer expiryDays, + String destination, + String note + ) { + return document( + "95000000-0000-0000-0000-000000000", + documentNumber, + "92000000-0000-0000-0000-000000000", + workerNumber, + documentType, + status, + expiryDays, + destination, + note + ); + } + + private static DocumentSeed testDocument( + int documentNumber, + int workerNumber, + DocumentType documentType, + SubmissionStatus status, + Integer expiryDays + ) { + return document( + "98000000-0000-0000-0000-000000000", + documentNumber, + "93000000-0000-0000-0000-000000000", + workerNumber, + documentType, + status, + expiryDays, + destination(documentType), + note(documentType, status) + ); + } + + private static DocumentSeed document( + String documentPrefix, + int documentNumber, + String workerPrefix, + int workerNumber, + DocumentType documentType, + SubmissionStatus status, + Integer expiryDays, + String destination, + String note + ) { + return new DocumentSeed( + demoUuid(documentPrefix, documentNumber), + demoUuid(workerPrefix, workerNumber), + documentType, + status, + expiryDays, + destination, + note, + "95000000-0000-0000-0000-000000000".equals(documentPrefix) + && documentNumber == 7 + ? DemoStoredFileSeedCatalog.CONTRACT_FILE_ID + : null + ); + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java new file mode 100644 index 0000000..23f3653 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoEvidenceSeeder.java @@ -0,0 +1,97 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.application.port.EvidenceRepository; +import com.fowoco.server.approval.domain.Evidence; +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoEvidenceSeeder { + + private final EvidenceRepository repository; + private final EntityManager entityManager; + private final JdbcTemplate jdbcTemplate; + + DemoEvidenceSeeder( + EvidenceRepository repository, + EntityManager entityManager, + JdbcTemplate jdbcTemplate + ) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.entityManager = Objects.requireNonNull(entityManager, "entityManager must not be null"); + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + void seed(EvidenceSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.evidenceId()); + if (!existing.isEmpty()) { + verifyExisting(existing.get(0), seed, context); + return; + } + Instant recordedAt = context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS); + repository.save(new Evidence( + seed.evidenceId(), + seed.taskId(), + context.companyId(), + seed.evidenceType(), + seed.fileReference(), + seed.note(), + context.actorId(), + recordedAt, + recordedAt + )); + entityManager.flush(); + } + + void verifyExisting(EvidenceSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.evidenceId()); + if (existing.size() != 1) { + throw new IllegalStateException("a reserved demo completion evidence was not seeded"); + } + verifyExisting(existing.get(0), seed, context); + } + + private List find(UUID evidenceId) { + return jdbcTemplate.query( + "SELECT evidence_id, task_id, company_id, evidence_type, file_reference, note, " + + "recorded_by, recorded_at, created_at FROM task_evidence WHERE evidence_id = ?", + (resultSet, rowNumber) -> new Evidence( + resultSet.getObject("evidence_id", UUID.class), + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + EvidenceType.valueOf(resultSet.getString("evidence_type")), + resultSet.getString("file_reference"), + resultSet.getString("note"), + resultSet.getObject("recorded_by", UUID.class), + resultSet.getTimestamp("recorded_at").toInstant(), + resultSet.getTimestamp("created_at").toInstant() + ), + evidenceId + ); + } + + private void verifyExisting( + Evidence evidence, + EvidenceSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.evidenceId().equals(evidence.evidenceId()) + || !seed.taskId().equals(evidence.taskId()) + || !context.companyId().equals(evidence.companyId()) + || seed.evidenceType() != evidence.evidenceType() + || !Objects.equals(seed.fileReference(), evidence.fileReference()) + || !seed.note().equals(evidence.note()) + || !context.actorId().equals(evidence.recordedBy()) + || evidence.createdAt().isBefore(evidence.recordedAt())) { + throw new IllegalStateException( + "a reserved demo evidence id already belongs to different evidence data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoExternalSubmissionSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoExternalSubmissionSeeder.java new file mode 100644 index 0000000..bf2e4b1 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoExternalSubmissionSeeder.java @@ -0,0 +1,94 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.application.port.ExternalSubmissionRepository; +import com.fowoco.server.approval.domain.ExternalSubmission; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoExternalSubmissionSeeder { + + private final ExternalSubmissionRepository repository; + private final EntityManager entityManager; + private final JdbcTemplate jdbcTemplate; + + DemoExternalSubmissionSeeder( + ExternalSubmissionRepository repository, + EntityManager entityManager, + JdbcTemplate jdbcTemplate + ) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.entityManager = Objects.requireNonNull(entityManager, "entityManager must not be null"); + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + void seed(ExternalSubmissionSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.externalSubmissionId()); + if (!existing.isEmpty()) { + verifyExisting(existing.get(0), seed, context); + return; + } + Instant submittedAt = context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS); + repository.save(new ExternalSubmission( + seed.externalSubmissionId(), + seed.taskId(), + context.companyId(), + seed.destination(), + seed.safeReference(), + context.actorId(), + submittedAt, + submittedAt + )); + entityManager.flush(); + } + + void verifyExisting(ExternalSubmissionSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.externalSubmissionId()); + if (existing.size() != 1) { + throw new IllegalStateException("a reserved demo external submission was not seeded"); + } + verifyExisting(existing.get(0), seed, context); + } + + private List find(UUID submissionId) { + return jdbcTemplate.query( + "SELECT external_submission_id, task_id, company_id, destination, safe_reference, " + + "submitted_by, submitted_at, created_at FROM external_submission " + + "WHERE external_submission_id = ?", + (resultSet, rowNumber) -> new ExternalSubmission( + resultSet.getObject("external_submission_id", UUID.class), + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getString("destination"), + resultSet.getString("safe_reference"), + resultSet.getObject("submitted_by", UUID.class), + resultSet.getTimestamp("submitted_at").toInstant(), + resultSet.getTimestamp("created_at").toInstant() + ), + submissionId + ); + } + + private void verifyExisting( + ExternalSubmission submission, + ExternalSubmissionSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.externalSubmissionId().equals(submission.externalSubmissionId()) + || !seed.taskId().equals(submission.taskId()) + || !context.companyId().equals(submission.companyId()) + || !seed.destination().equals(submission.destination()) + || !seed.safeReference().equals(submission.safeReference()) + || !context.actorId().equals(submission.submittedBy()) + || submission.createdAt().isBefore(submission.submittedAt())) { + throw new IllegalStateException( + "a reserved demo external submission id belongs to different submission data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java new file mode 100644 index 0000000..4cce70f --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstaller.java @@ -0,0 +1,104 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import org.springframework.core.io.ClassPathResource; + +final class DemoFileFixtureInstaller { + + private final Path rootDirectory; + + DemoFileFixtureInstaller(String localPath) { + this.rootDirectory = Path.of(localPath).toAbsolutePath().normalize(); + } + + long expectedSize(StoredFileSeed seed) { + return sourceBytes(seed).length; + } + + void install(StoredFileSeed seed) { + byte[] expected = sourceBytes(seed); + Path target = target(seed.storageKey()); + try { + Files.createDirectories(rootDirectory); + if (Files.exists(target)) { + verify(target, expected, seed); + return; + } + Path temporary = Files.createTempFile(rootDirectory, ".demo-fixture-", ".tmp"); + try { + Files.write(temporary, expected); + moveWithoutOverwrite(temporary, target); + } catch (FileAlreadyExistsException exception) { + verify(target, expected, seed); + } finally { + Files.deleteIfExists(temporary); + } + verify(target, expected, seed); + } catch (IOException exception) { + throw new UncheckedIOException("failed to install demo file fixture: " + seed.name(), exception); + } + } + + void verify(StoredFileSeed seed) { + byte[] expected = sourceBytes(seed); + Path target = target(seed.storageKey()); + try { + verify(target, expected, seed); + } catch (IOException exception) { + throw new UncheckedIOException("failed to verify demo file fixture: " + seed.name(), exception); + } + } + + private byte[] sourceBytes(StoredFileSeed seed) { + try (InputStream input = new ClassPathResource(seed.resourcePath()).getInputStream()) { + return input.readAllBytes(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to read demo file fixture: " + seed.resourcePath(), exception); + } + } + + private Path target(String storageKey) { + Path target = rootDirectory.resolve(storageKey).normalize(); + if (!target.startsWith(rootDirectory)) { + throw new IllegalArgumentException("demo storage key must not escape the storage root"); + } + return target; + } + + private void moveWithoutOverwrite(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(source, target); + } + } + + private void verify(Path target, byte[] expected, StoredFileSeed seed) throws IOException { + if (!Files.isRegularFile(target) + || Files.size(target) != expected.length + || !Arrays.equals(sha256(Files.readAllBytes(target)), sha256(expected))) { + throw new IllegalStateException( + "a demo file storage key already contains different content: " + seed.storageKey() + ); + } + } + + private byte[] sha256(byte[] content) { + try { + return MessageDigest.getInstance("SHA-256").digest(content); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java new file mode 100644 index 0000000..3e11f07 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -0,0 +1,649 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.domain.ApprovalStatus; +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +final class DemoOperationalSeedCatalog { + + static final String WORKFLOW_CATALOG_VERSION = "0.2.0"; + static final UUID TIMELINE_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000002"); + static final UUID TEST_ADMIN_USER_ID = + UUID.fromString("91000000-0000-0000-0000-000000000002"); + + private final List demoTasks = DemoTaskSeedCatalog.demoTasks(); + private final List testTasks = DemoTaskSeedCatalog.testTasks(); + private final List demoStoredFiles = + DemoStoredFileSeedCatalog.demoStoredFiles(demoTasks); + private final List demoDocuments = DemoDocumentSeedCatalog.demoDocuments(); + private final List testDocuments = DemoDocumentSeedCatalog.testDocuments(); + private final List demoChecklists = + 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( + audit( + "96000000-0000-0000-0000-000000000001", + AuditAction.TASK_CREATED, + "demo-seed-task-created", + "업무가 생성되었습니다.", + 48 + ), + audit( + "96000000-0000-0000-0000-000000000002", + AuditAction.TASK_UPDATED, + "demo-seed-task-updated", + "계약 갱신 정보와 마감일이 확인되었습니다.", + 24 + ), + audit( + "96000000-0000-0000-0000-000000000003", + AuditAction.APPROVAL_REQUESTED, + "demo-seed-approval-requested", + "관리자 승인을 요청했습니다.", + 6 + ) + ); + private final List demoAudits = Stream.concat( + legacyDemoAudits.stream(), + DemoAuditSeedCatalog.additionalDemoAudits( + demoTasks, + demoApprovals, + demoExternalSubmissions, + demoEvidence, + demoDocumentRequestDrafts + ).stream() + ).toList(); + private final List testAudits = DemoAuditSeedCatalog.testAudits(testTasks); + + DemoOperationalSeedCatalog() { + verifyDefinitions(); + } + + List demoTasks() { + return demoTasks; + } + + List testTasks() { + return testTasks; + } + + List demoStoredFiles() { + return demoStoredFiles; + } + + List demoDocuments() { + return demoDocuments; + } + + List testDocuments() { + return testDocuments; + } + + List demoChecklists() { + return demoChecklists; + } + + List demoApprovals() { + return demoApprovals; + } + + List demoTransitions() { + return demoTransitions; + } + + List demoExternalSubmissions() { + return demoExternalSubmissions; + } + + List demoEvidence() { + return demoEvidence; + } + + List demoDocumentRequestDrafts() { + return demoDocumentRequestDrafts; + } + + List demoAudits() { + return demoAudits; + } + + List testAudits() { + return testAudits; + } + + private void verifyDefinitions() { + requireSize(demoTasks, 24, "Demo Company task"); + requireSize(testTasks, 3, "Test Company task"); + requireSize(demoStoredFiles, 3, "Demo Company stored file"); + requireSize(demoDocuments, 84, "Demo Company document"); + requireSize(testDocuments, 8, "Test Company document"); + requireSize(demoChecklists, 68, "Demo Company checklist item"); + requireSize(demoApprovals, 13, "Demo Company approval request"); + requireSize(demoTransitions, 52, "Demo Company task transition"); + requireSize(demoExternalSubmissions, 6, "Demo Company external submission"); + requireSize(demoEvidence, 10, "Demo Company completion evidence"); + requireSize(demoDocumentRequestDrafts, 5, "Demo Company document request draft"); + requireSize(demoAudits, 96, "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 + ), + "Demo Company task type" + ); + requireDistribution( + demoTasks.stream().map(TaskSeed::status).toList(), + Map.of( + TaskStatus.DRAFT, 3L, + TaskStatus.NEEDS_INFO, 2L, + TaskStatus.READY_FOR_REVIEW, 4L, + TaskStatus.APPROVED, 2L, + TaskStatus.WAITING_WORKER, 4L, + TaskStatus.WAITING_EXTERNAL, 3L, + TaskStatus.COMPLETED, 5L, + TaskStatus.CANCELLED, 1L + ), + "Demo Company task status" + ); + requireDistribution( + demoDocuments.stream().map(DocumentSeed::submissionStatus).toList(), + Map.of( + SubmissionStatus.VERIFIED, 48L, + SubmissionStatus.SUBMITTED, 20L, + SubmissionStatus.MISSING, 16L + ), + "Demo Company document status" + ); + requireDistribution( + demoApprovals.stream().map(ApprovalSeed::status).toList(), + Map.of( + ApprovalStatus.PENDING, 4L, + ApprovalStatus.APPROVED, 7L, + ApprovalStatus.REJECTED, 1L, + ApprovalStatus.INVALIDATED, 1L + ), + "Demo Company approval status" + ); + requireDistribution( + demoEvidence.stream().map(EvidenceSeed::evidenceType).toList(), + Map.of( + EvidenceType.DOCUMENT, 2L, + EvidenceType.RECEIPT, 3L, + EvidenceType.OFFICIAL_RESULT, 3L, + EvidenceType.HR_CONFIRMATION, 2L + ), + "Demo Company evidence type" + ); + requireDistribution( + demoAudits.stream().map(AuditSeed::actorType).toList(), + Map.of( + ActorType.HR_USER, 79L, + ActorType.AI_AGENT, 7L, + ActorType.SYSTEM_RULE, 6L, + ActorType.WORKER_LINK, 4L + ), + "Demo Company audit actor type" + ); + requireUniqueIds( + Stream.concat(demoTasks.stream(), testTasks.stream()).map(TaskSeed::taskId).toList(), + "task" + ); + requireUniqueIds(demoStoredFiles.stream().map(StoredFileSeed::storedFileId).toList(), + "stored file"); + requireUniqueIds( + Stream.concat(demoDocuments.stream(), testDocuments.stream()) + .map(DocumentSeed::documentId) + .toList(), + "document" + ); + requireUniqueIds(demoChecklists.stream().map(ChecklistSeed::checklistItemId).toList(), + "checklist item"); + requireUniqueIds(demoApprovals.stream().map(ApprovalSeed::approvalRequestId).toList(), + "approval request"); + requireUniqueIds(demoTransitions.stream().map(TransitionSeed::transitionId).toList(), + "task transition"); + requireUniqueIds(demoExternalSubmissions.stream() + .map(ExternalSubmissionSeed::externalSubmissionId).toList(), "external submission"); + requireUniqueIds(demoEvidence.stream().map(EvidenceSeed::evidenceId).toList(), + "completion evidence"); + requireUniqueIds(demoDocumentRequestDrafts.stream() + .map(DocumentRequestDraftSeed::draftId).toList(), "document request draft"); + requireUniqueIds(Stream.concat(demoAudits.stream(), testAudits.stream()) + .map(AuditSeed::auditEventId).toList(), "audit event"); + requireTaskReferences(demoChecklists.stream().map(ChecklistSeed::taskId).toList(), + "checklist item"); + requireTaskReferences(demoApprovals.stream().map(ApprovalSeed::taskId).toList(), + "approval request"); + requireTaskReferences(demoTransitions.stream().map(TransitionSeed::taskId).toList(), + "task transition"); + requireTaskReferences(demoExternalSubmissions.stream() + .map(ExternalSubmissionSeed::taskId).toList(), "external submission"); + requireTaskReferences(demoEvidence.stream().map(EvidenceSeed::taskId).toList(), + "completion evidence"); + requireTaskReferences(demoDocumentRequestDrafts.stream() + .map(DocumentRequestDraftSeed::taskId).toList(), "document request draft"); + verifyArtifactScenarios(); + verifyTemporalScenarios(); + } + + private static void requireSize(List seeds, int expected, String name) { + if (seeds.size() != expected) { + throw new IllegalStateException(name + " seed count must be " + expected); + } + } + + private static void requireDistribution( + List values, + Map expected, + String name + ) { + Map actual = values.stream() + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + if (!expected.equals(actual)) { + throw new IllegalStateException(name + " seed distribution is invalid: " + actual); + } + } + + private static void requireUniqueIds(List ids, String name) { + if (new HashSet<>(ids).size() != ids.size()) { + throw new IllegalStateException("duplicate reserved demo " + name + " id"); + } + } + + private void requireTaskReferences(List taskIds, String name) { + var reservedTaskIds = demoTasks.stream().map(TaskSeed::taskId).collect(Collectors.toSet()); + if (!reservedTaskIds.containsAll(taskIds)) { + throw new IllegalStateException(name + " references an unknown demo task"); + } + } + + private void verifyArtifactScenarios() { + Map tasksById = demoTasks.stream() + .collect(Collectors.toMap(TaskSeed::taskId, Function.identity())); + Map filesById = demoStoredFiles.stream() + .collect(Collectors.toMap(StoredFileSeed::storedFileId, Function.identity())); + for (StoredFileSeed file : demoStoredFiles) { + TaskSeed task = tasksById.get(file.taskId()); + if (task == null || !task.workerId().equals(file.workerId())) { + throw new IllegalStateException("stored file task and worker relation is invalid"); + } + } + Set linkedFileIds = new HashSet<>(); + for (DocumentSeed document : demoDocuments) { + if (document.fileId() == null) { + continue; + } + StoredFileSeed file = filesById.get(document.fileId()); + if (file == null || !document.workerId().equals(file.workerId())) { + throw new IllegalStateException("worker document stored file relation is invalid"); + } + linkedFileIds.add(document.fileId()); + } + for (EvidenceSeed item : demoEvidence) { + if (item.fileReference() == null) { + continue; + } + UUID fileId; + try { + fileId = UUID.fromString(item.fileReference()); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("evidence file reference must be a stored file UUID", exception); + } + StoredFileSeed file = filesById.get(fileId); + if (file == null || !item.taskId().equals(file.taskId())) { + throw new IllegalStateException("evidence stored file relation is invalid"); + } + linkedFileIds.add(fileId); + } + if (!linkedFileIds.equals(filesById.keySet())) { + throw new IllegalStateException("every demo stored file must be linked to a document or evidence"); + } + boolean invalidSubmission = demoExternalSubmissions.stream() + .map(ExternalSubmissionSeed::taskId) + .map(tasksById::get) + .anyMatch(task -> task.status() != TaskStatus.WAITING_EXTERNAL + && task.status() != TaskStatus.COMPLETED); + if (invalidSubmission) { + throw new IllegalStateException("external submission task status is invalid"); + } + Map evidenceCounts = demoEvidence.stream() + .collect(Collectors.groupingBy(EvidenceSeed::taskId, Collectors.counting())); + boolean incompleteCompletionEvidence = demoTasks.stream() + .filter(task -> task.status() == TaskStatus.COMPLETED) + .anyMatch(task -> evidenceCounts.getOrDefault(task.taskId(), 0L) != 2L); + if (incompleteCompletionEvidence || evidenceCounts.size() != 5) { + throw new IllegalStateException("every completed demo task needs two evidence records"); + } + var requestDraftTaskIds = demoDocumentRequestDrafts.stream() + .map(DocumentRequestDraftSeed::taskId) + .collect(Collectors.toSet()); + boolean missingWaitingWorkerDraft = demoTasks.stream() + .filter(task -> task.status() == TaskStatus.WAITING_WORKER) + .anyMatch(task -> !requestDraftTaskIds.contains(task.taskId())); + boolean invalidDraftStatus = requestDraftTaskIds.stream() + .map(tasksById::get) + .anyMatch(task -> task.status() != TaskStatus.WAITING_WORKER + && task.status() != TaskStatus.DRAFT); + if (missingWaitingWorkerDraft || invalidDraftStatus + || requestDraftTaskIds.size() != demoDocumentRequestDrafts.size()) { + throw new IllegalStateException("document request draft scenario is invalid"); + } + } + + private void verifyTemporalScenarios() { + Map> transitionsByTask = demoTransitions.stream() + .collect(Collectors.groupingBy(TransitionSeed::taskId)); + Map> checklistsByTask = demoChecklists.stream() + .collect(Collectors.groupingBy(ChecklistSeed::taskId)); + Map> approvalsByTask = demoApprovals.stream() + .collect(Collectors.groupingBy(ApprovalSeed::taskId)); + Map> submissionsByTask = demoExternalSubmissions.stream() + .collect(Collectors.groupingBy(ExternalSubmissionSeed::taskId)); + Map> evidenceByTask = demoEvidence.stream() + .collect(Collectors.groupingBy(EvidenceSeed::taskId)); + Map> draftsByTask = demoDocumentRequestDrafts.stream() + .collect(Collectors.groupingBy(DocumentRequestDraftSeed::taskId)); + + for (TaskSeed task : demoTasks) { + int createdHoursAgo = task.createdDaysAgo() * 24; + List transitions = transitionsByTask.getOrDefault(task.taskId(), List.of()); + verifyTransitionChain(task, transitions, createdHoursAgo); + TransitionSeed ready = findTransition(transitions, TaskStatus.READY_FOR_REVIEW); + TransitionSeed approved = findTransition(transitions, TaskStatus.APPROVED); + TransitionSeed completed = findTransition(transitions, TaskStatus.COMPLETED); + + for (ChecklistSeed checklist : checklistsByTask.getOrDefault(task.taskId(), List.of())) { + requireNotBeforeTask(checklist.createdHoursAgo(), createdHoursAgo, "checklist creation"); + if (checklist.completed()) { + requireNotBeforeTask(checklist.completedHoursAgo(), createdHoursAgo, + "checklist completion"); + if (ready != null && checklist.required() + && checklist.completedHoursAgo() < ready.hoursAgo()) { + throw new IllegalStateException( + "required demo checklist must complete before review transition" + ); + } + } + } + + for (ApprovalSeed approval : approvalsByTask.getOrDefault(task.taskId(), List.of())) { + requireNotBeforeTask(approval.requestedHoursAgo(), createdHoursAgo, "approval request"); + if (ready == null || ready.hoursAgo() < approval.requestedHoursAgo()) { + throw new IllegalStateException("demo approval must be requested after review transition"); + } + if (approval.outcomeHoursAgo() != null) { + if (approval.requestedHoursAgo() < approval.outcomeHoursAgo()) { + throw new IllegalStateException("demo approval outcome precedes its request"); + } + if (approval.status() == ApprovalStatus.APPROVED + && (approved == null || approval.outcomeHoursAgo() < approved.hoursAgo())) { + throw new IllegalStateException( + "demo approved transition precedes the approval decision" + ); + } + if (approval.status() == ApprovalStatus.REJECTED + || approval.status() == ApprovalStatus.INVALIDATED) { + TransitionSeed rework = findTransition(transitions, TaskStatus.DRAFT); + if (rework == null || approval.outcomeHoursAgo() < rework.hoursAgo()) { + throw new IllegalStateException( + "demo approval outcome must precede the rework transition" + ); + } + } + } + } + + for (ExternalSubmissionSeed submission + : submissionsByTask.getOrDefault(task.taskId(), List.of())) { + requireNotBeforeTask(submission.hoursAgo(), createdHoursAgo, "external submission"); + if (approved != null && approved.hoursAgo() < submission.hoursAgo()) { + throw new IllegalStateException( + "demo external submission precedes the approved transition" + ); + } + if (completed != null && submission.hoursAgo() < completed.hoursAgo()) { + throw new IllegalStateException( + "demo completed transition precedes the external submission" + ); + } + } + + for (EvidenceSeed item : evidenceByTask.getOrDefault(task.taskId(), List.of())) { + requireNotBeforeTask(item.hoursAgo(), createdHoursAgo, "completion evidence"); + if (completed != null && item.hoursAgo() < completed.hoursAgo()) { + throw new IllegalStateException( + "demo completed transition precedes its evidence" + ); + } + } + + draftsByTask.getOrDefault(task.taskId(), List.of()).forEach(draft -> + requireNotBeforeTask(draft.hoursAgo(), createdHoursAgo, "document request draft")); + } + } + + private static void verifyTransitionChain( + TaskSeed task, + List transitions, + int createdHoursAgo + ) { + TaskStatus expectedFrom = TaskStatus.DRAFT; + int previousHoursAgo = createdHoursAgo; + for (TransitionSeed transition : transitions) { + if (expectedFrom.isTerminal() + || transition.fromStatus() != expectedFrom + || transition.hoursAgo() > previousHoursAgo) { + throw new IllegalStateException("demo task transition chain is invalid"); + } + expectedFrom = transition.toStatus(); + previousHoursAgo = transition.hoursAgo(); + } + if (!transitions.isEmpty() && expectedFrom != task.status()) { + throw new IllegalStateException("final demo transition status differs from task status"); + } + } + + private static TransitionSeed findTransition( + List transitions, + TaskStatus toStatus + ) { + return transitions.stream() + .filter(transition -> transition.toStatus() == toStatus) + .findFirst() + .orElse(null); + } + + private static void requireNotBeforeTask( + int eventHoursAgo, + int createdHoursAgo, + String eventName + ) { + if (eventHoursAgo > createdHoursAgo) { + throw new IllegalStateException("demo " + eventName + " precedes task creation"); + } + } + + private static AuditSeed audit( + String auditEventId, + AuditAction action, + String requestId, + String changeSummary, + int hoursAgo + ) { + return new AuditSeed( + UUID.fromString(auditEventId), + ActorType.HR_USER, + UserRole.ADMIN, + action, + AuditTargetType.TASK, + TIMELINE_TASK_ID, + requestId, + "demo-seed-task-timeline", + changeSummary, + hoursAgo + ); + } + + record TaskSeed( + UUID taskId, + UUID caseId, + UUID workerId, + TaskType taskType, + String workflowId, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo, + Map businessData + ) { + TaskSeed { + if (businessData.containsKey("worker_id") || businessData.containsKey("due_at")) { + throw new IllegalArgumentException( + "task seed business data must not override worker_id or due_at" + ); + } + businessData = Map.copyOf(businessData); + } + } + + record DocumentSeed( + UUID documentId, + UUID workerId, + DocumentType documentType, + SubmissionStatus submissionStatus, + Integer expiryDays, + String destination, + String note, + UUID fileId + ) { + } + + record StoredFileSeed( + UUID storedFileId, + String name, + String mimeType, + String purpose, + UUID taskId, + UUID workerId, + String storageKey, + String resourcePath + ) { + } + + record ChecklistSeed( + UUID checklistItemId, + UUID taskId, + String itemCode, + String label, + boolean required, + boolean completed, + int createdHoursAgo, + Integer completedHoursAgo + ) { + } + + record ApprovalSeed( + UUID approvalRequestId, + UUID taskId, + ApprovalStatus status, + String reason, + int requestedHoursAgo, + Integer outcomeHoursAgo, + Map aiSnapshot, + Map hrSnapshot, + List> changedFields, + Map sourceVersions + ) { + ApprovalSeed { + aiSnapshot = Map.copyOf(aiSnapshot); + hrSnapshot = Map.copyOf(hrSnapshot); + changedFields = changedFields.stream().map(Map::copyOf).toList(); + sourceVersions = Map.copyOf(sourceVersions); + } + } + + record TransitionSeed( + UUID transitionId, + UUID taskId, + TaskStatus fromStatus, + TaskStatus toStatus, + String reason, + String requestId, + int hoursAgo + ) { + } + + record ExternalSubmissionSeed( + UUID externalSubmissionId, + UUID taskId, + String destination, + String safeReference, + int hoursAgo + ) { + } + + record EvidenceSeed( + UUID evidenceId, + UUID taskId, + EvidenceType evidenceType, + String fileReference, + String note, + int hoursAgo + ) { + } + + record DocumentRequestDraftSeed( + UUID draftId, + UUID taskId, + String language, + List documentTypes, + String message, + int hoursAgo + ) { + } + + record AuditSeed( + UUID auditEventId, + ActorType actorType, + UserRole userRole, + AuditAction action, + AuditTargetType targetType, + UUID targetId, + String requestId, + String traceId, + String changeSummary, + int hoursAgo + ) { + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java index 0bd2b7e..88c8c41 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -1,14 +1,27 @@ package com.fowoco.server.demo.infrastructure.seed; import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.approval.application.port.EvidenceRepository; +import com.fowoco.server.approval.application.port.ExternalSubmissionRepository; +import com.fowoco.server.approval.application.SafeJsonService; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.file.application.port.FileStorage; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.infrastructure.LocalFileStorage; import com.fowoco.server.task.application.TaskContentCodec; +import com.fowoco.server.task.application.port.TaskChecklistRepository; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import jakarta.persistence.EntityManager; import java.time.Clock; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; @Configuration(proxyBeanMethods = false) public class DemoOperationalSeedConfiguration { @@ -19,17 +32,89 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( DemoAuthSeedProperties properties, TaskRepository taskRepository, TaskContentCodec taskContentCodec, + TaskChecklistRepository checklistRepository, + ApprovalRequestRepository approvalRepository, + SafeJsonService safeJsonService, + StoredFileRepository storedFileRepository, + FileStorage fileStorage, + ExternalSubmissionRepository externalSubmissionRepository, + EvidenceRepository evidenceRepository, + DocumentRequestDraftRepository documentRequestDraftRepository, WorkerDocumentRepository workerDocumentRepository, + WorkerRepository workerRepository, AuditEventRepository auditEventRepository, - Clock clock + EntityManager entityManager, + JdbcTemplate jdbcTemplate, + Clock clock, + @Value("${app.file-storage.local-path}") String localFileStoragePath ) { - return new DemoOperationalSeedRunner( - properties, + if (!(fileStorage instanceof LocalFileStorage)) { + throw new IllegalStateException( + "demo file fixtures currently require LocalFileStorage" + ); + } + DemoOperationalSeedCatalog catalog = new DemoOperationalSeedCatalog(); + DemoTaskSeeder taskSeeder = new DemoTaskSeeder(taskRepository, taskContentCodec); + DemoStoredFileSeeder storedFileSeeder = new DemoStoredFileSeeder( + storedFileRepository, + jdbcTemplate, + new DemoFileFixtureInstaller(localFileStoragePath) + ); + DemoWorkerDocumentSeeder documentSeeder = new DemoWorkerDocumentSeeder(workerDocumentRepository); + DemoTaskChecklistSeeder checklistSeeder = new DemoTaskChecklistSeeder(checklistRepository); + DemoApprovalRequestSeeder approvalSeeder = new DemoApprovalRequestSeeder( + approvalRepository, + taskRepository, + safeJsonService + ); + DemoTaskTransitionSeeder transitionSeeder = new DemoTaskTransitionSeeder(jdbcTemplate); + DemoExternalSubmissionSeeder externalSubmissionSeeder = new DemoExternalSubmissionSeeder( + externalSubmissionRepository, + entityManager, + jdbcTemplate + ); + DemoEvidenceSeeder evidenceSeeder = new DemoEvidenceSeeder( + evidenceRepository, + entityManager, + jdbcTemplate + ); + DemoDocumentRequestDraftSeeder requestDraftSeeder = new DemoDocumentRequestDraftSeeder( + documentRequestDraftRepository, + jdbcTemplate + ); + DemoAuditEventSeeder auditSeeder = new DemoAuditEventSeeder(auditEventRepository); + DemoOperationalSeedVerifier verifier = new DemoOperationalSeedVerifier( taskRepository, - taskContentCodec, workerDocumentRepository, - auditEventRepository, - clock + workerRepository, + checklistRepository, + approvalRepository, + taskSeeder, + documentSeeder, + checklistSeeder, + approvalSeeder, + storedFileSeeder, + transitionSeeder, + externalSubmissionSeeder, + evidenceSeeder, + requestDraftSeeder, + auditSeeder + ); + return new DemoOperationalSeedRunner( + properties, + clock, + catalog, + taskSeeder, + documentSeeder, + checklistSeeder, + approvalSeeder, + storedFileSeeder, + transitionSeeder, + externalSubmissionSeeder, + evidenceSeeder, + requestDraftSeeder, + auditSeeder, + verifier ); } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java new file mode 100644 index 0000000..1976a85 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedContext.java @@ -0,0 +1,50 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + +record DemoOperationalSeedContext( + UUID companyId, + UUID actorId, + LocalDate today, + Instant now +) { + + DemoOperationalSeedContext { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(actorId, "actorId must not be null"); + Objects.requireNonNull(today, "today must not be null"); + Objects.requireNonNull(now, "now must not be null"); + } + + static DemoOperationalSeedContext demo( + DemoAuthSeedProperties properties, + LocalDate today, + Instant now + ) { + Objects.requireNonNull(properties, "properties must not be null"); + return new DemoOperationalSeedContext( + properties.companyId(), + properties.adminUserId(), + today, + now + ); + } + + static DemoOperationalSeedContext test( + DemoAuthSeedProperties properties, + LocalDate today, + Instant now + ) { + Objects.requireNonNull(properties, "properties must not be null"); + return new DemoOperationalSeedContext( + properties.testCompanyId(), + DemoOperationalSeedCatalog.TEST_ADMIN_USER_ID, + today, + now + ); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java index 69b5524..b659efb 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -1,32 +1,21 @@ package com.fowoco.server.demo.infrastructure.seed; -import com.fowoco.server.audit.application.port.AuditEventRepository; -import com.fowoco.server.audit.domain.ActorType; -import com.fowoco.server.audit.domain.AuditAction; -import com.fowoco.server.audit.domain.AuditEvent; -import com.fowoco.server.audit.domain.AuditTargetType; -import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; -import com.fowoco.server.task.application.TaskContentCodec; -import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent; -import com.fowoco.server.task.application.port.TaskRepository; -import com.fowoco.server.task.domain.Task; -import com.fowoco.server.task.domain.TaskSource; -import com.fowoco.server.task.domain.TaskStatus; -import com.fowoco.server.task.domain.TaskType; -import com.fowoco.server.worker.application.port.WorkerDocumentRepository; -import com.fowoco.server.worker.domain.DocumentType; -import com.fowoco.server.worker.domain.SubmissionStatus; -import com.fowoco.server.worker.domain.WorkerDocument; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; -import java.time.temporal.ChronoUnit; import java.util.List; -import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; @@ -38,192 +27,64 @@ class DemoOperationalSeedRunner implements ApplicationRunner { private static final Logger LOGGER = LoggerFactory.getLogger(DemoOperationalSeedRunner.class); - private static final String CATALOG_VERSION = "0.2.0"; - private static final List TASKS = List.of( - demoTask( - "94000000-0000-0000-0000-000000000001", - "94100000-0000-0000-0000-000000000001", - "92000000-0000-0000-0000-000000000001", - TaskType.STAY_PERIOD_EXTENSION, - "WF-STY-001", - "AI 추천: 체류기간 연장 준비", - "체류기간 만료 전에 필요한 서류를 확인하고 연장 신청을 준비합니다.", - TaskSource.AI_CANDIDATE, - TaskStatus.DRAFT, - 3, - 6 - ), - demoTask( - "94000000-0000-0000-0000-000000000002", - "94100000-0000-0000-0000-000000000002", - "92000000-0000-0000-0000-000000000002", - TaskType.RECONTRACT, - "WF-CON-001", - "근로계약 갱신 검토", - "갱신 계약 조건과 제출 서류를 검토하고 승인을 기다립니다.", - TaskSource.MANUAL, - TaskStatus.READY_FOR_REVIEW, - 20, - 5 - ), - demoTask( - "94000000-0000-0000-0000-000000000003", - "94100000-0000-0000-0000-000000000003", - "92000000-0000-0000-0000-000000000003", - TaskType.STAY_PERIOD_EXTENSION, - "WF-STY-001", - "여권 사본 제출 대기", - "근로자에게 최신 여권 사본을 요청했으며 응답을 기다립니다.", - TaskSource.MANUAL, - TaskStatus.WAITING_WORKER, - 6, - 4 - ), - demoTask( - "94000000-0000-0000-0000-000000000004", - "94100000-0000-0000-0000-000000000004", - "92000000-0000-0000-0000-000000000004", - TaskType.EMPLOYMENT_PERIOD_EXTENSION, - "WF-CON-001", - "고용허가기간 연장 결과 대기", - "관할 기관에 제출을 완료하고 처리 결과를 기다립니다.", - TaskSource.MANUAL, - TaskStatus.WAITING_EXTERNAL, - 90, - 12 - ), - demoTask( - "94000000-0000-0000-0000-000000000005", - "94100000-0000-0000-0000-000000000005", - "92000000-0000-0000-0000-000000000005", - TaskType.RECONTRACT, - "WF-CON-001", - "근로계약 갱신 완료", - "서명된 근로계약서 확인과 갱신 처리를 완료했습니다.", - TaskSource.SYSTEM_DDAY, - TaskStatus.COMPLETED, - 30, - 10 - ) - ); - private static final List DOCUMENTS = List.of( - demoDocument( - "95000000-0000-0000-0000-000000000001", - "92000000-0000-0000-0000-000000000001", - DocumentType.PASSPORT_COPY, - SubmissionStatus.MISSING, - 25, - "체류기간 연장", - "만료 임박 여권 사본을 다시 받아야 합니다." - ), - demoDocument( - "95000000-0000-0000-0000-000000000002", - "92000000-0000-0000-0000-000000000001", - DocumentType.ARC, - SubmissionStatus.VERIFIED, - 180, - "체류기간 연장", - "외국인등록증 사본 확인 완료" - ), - demoDocument( - "95000000-0000-0000-0000-000000000003", - "92000000-0000-0000-0000-000000000002", - DocumentType.CONTRACT, - SubmissionStatus.SUBMITTED, - 90, - "근로계약 갱신", - "서명본 검토 대기" - ), - demoDocument( - "95000000-0000-0000-0000-000000000004", - "92000000-0000-0000-0000-000000000002", - DocumentType.PERMIT, - SubmissionStatus.VERIFIED, - 150, - "고용허가기간 연장", - "고용허가서 확인 완료" - ), - demoDocument( - "95000000-0000-0000-0000-000000000005", - "92000000-0000-0000-0000-000000000003", - DocumentType.PASSPORT_COPY, - SubmissionStatus.VERIFIED, - 300, - "체류기간 연장", - "유효한 여권 사본" - ), - demoDocument( - "95000000-0000-0000-0000-000000000006", - "92000000-0000-0000-0000-000000000004", - DocumentType.ARC, - SubmissionStatus.MISSING, - 45, - "고용허가기간 연장", - "외국인등록증 사본 요청 필요" - ), - demoDocument( - "95000000-0000-0000-0000-000000000007", - "92000000-0000-0000-0000-000000000005", - DocumentType.CONTRACT, - SubmissionStatus.SUBMITTED, - 120, - "근로계약 갱신", - "갱신 계약서 제출 완료" - ) - ); - private static final UUID TIMELINE_TASK_ID = - UUID.fromString("94000000-0000-0000-0000-000000000002"); - private static final List AUDITS = List.of( - demoAudit( - "96000000-0000-0000-0000-000000000001", - AuditAction.TASK_CREATED, - "demo-seed-task-created", - "업무가 생성되었습니다.", - 48 - ), - demoAudit( - "96000000-0000-0000-0000-000000000002", - AuditAction.TASK_UPDATED, - "demo-seed-task-updated", - "계약 갱신 정보와 마감일이 확인되었습니다.", - 24 - ), - demoAudit( - "96000000-0000-0000-0000-000000000003", - AuditAction.APPROVAL_REQUESTED, - "demo-seed-approval-requested", - "관리자 승인을 요청했습니다.", - 6 - ) - ); private final DemoAuthSeedProperties properties; - private final TaskRepository taskRepository; - private final TaskContentCodec taskContentCodec; - private final WorkerDocumentRepository workerDocumentRepository; - private final AuditEventRepository auditEventRepository; private final Clock clock; + private final DemoOperationalSeedCatalog catalog; + private final DemoTaskSeeder taskSeeder; + private final DemoWorkerDocumentSeeder documentSeeder; + private final DemoTaskChecklistSeeder checklistSeeder; + private final DemoApprovalRequestSeeder approvalSeeder; + private final DemoStoredFileSeeder storedFileSeeder; + private final DemoTaskTransitionSeeder transitionSeeder; + private final DemoExternalSubmissionSeeder externalSubmissionSeeder; + private final DemoEvidenceSeeder evidenceSeeder; + private final DemoDocumentRequestDraftSeeder requestDraftSeeder; + private final DemoAuditEventSeeder auditSeeder; + private final DemoOperationalSeedVerifier verifier; DemoOperationalSeedRunner( DemoAuthSeedProperties properties, - TaskRepository taskRepository, - TaskContentCodec taskContentCodec, - WorkerDocumentRepository workerDocumentRepository, - AuditEventRepository auditEventRepository, - Clock clock + Clock clock, + DemoOperationalSeedCatalog catalog, + DemoTaskSeeder taskSeeder, + DemoWorkerDocumentSeeder documentSeeder, + DemoTaskChecklistSeeder checklistSeeder, + DemoApprovalRequestSeeder approvalSeeder, + DemoStoredFileSeeder storedFileSeeder, + DemoTaskTransitionSeeder transitionSeeder, + DemoExternalSubmissionSeeder externalSubmissionSeeder, + DemoEvidenceSeeder evidenceSeeder, + DemoDocumentRequestDraftSeeder requestDraftSeeder, + DemoAuditEventSeeder auditSeeder, + DemoOperationalSeedVerifier verifier ) { this.properties = Objects.requireNonNull(properties, "properties must not be null"); - this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); - this.taskContentCodec = Objects.requireNonNull(taskContentCodec, "taskContentCodec must not be null"); - this.workerDocumentRepository = Objects.requireNonNull( - workerDocumentRepository, - "workerDocumentRepository must not be null" + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.catalog = Objects.requireNonNull(catalog, "catalog must not be null"); + this.taskSeeder = Objects.requireNonNull(taskSeeder, "taskSeeder must not be null"); + this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); + this.checklistSeeder = Objects.requireNonNull(checklistSeeder, "checklistSeeder must not be null"); + this.approvalSeeder = Objects.requireNonNull(approvalSeeder, "approvalSeeder must not be null"); + this.storedFileSeeder = Objects.requireNonNull( + storedFileSeeder, + "storedFileSeeder must not be null" ); - this.auditEventRepository = Objects.requireNonNull( - auditEventRepository, - "auditEventRepository must not be null" + this.transitionSeeder = Objects.requireNonNull( + transitionSeeder, + "transitionSeeder must not be null" ); - this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.externalSubmissionSeeder = Objects.requireNonNull( + externalSubmissionSeeder, + "externalSubmissionSeeder must not be null" + ); + this.evidenceSeeder = Objects.requireNonNull(evidenceSeeder, "evidenceSeeder must not be null"); + this.requestDraftSeeder = Objects.requireNonNull( + requestDraftSeeder, + "requestDraftSeeder must not be null" + ); + this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); + this.verifier = Objects.requireNonNull(verifier, "verifier must not be null"); } @Override @@ -231,280 +92,92 @@ class DemoOperationalSeedRunner implements ApplicationRunner { public void run(ApplicationArguments arguments) { Instant now = clock.instant(); LocalDate today = LocalDate.now(clock); - TASKS.forEach(task -> seedTask(task, today, now)); - DOCUMENTS.forEach(document -> seedDocument(document, today, now)); - AUDITS.forEach(audit -> seedAudit(audit, now)); - LOGGER.info( - "demo_operational_seed ready company_id={} task_count={} document_count={} audit_count={}", - properties.companyId(), - TASKS.size(), - DOCUMENTS.size(), - AUDITS.size() + DemoOperationalSeedContext demoContext = DemoOperationalSeedContext.demo(properties, today, now); + DemoOperationalSeedContext testContext = DemoOperationalSeedContext.test(properties, today, now); + seedDataset( + catalog.demoTasks(), + catalog.demoStoredFiles(), + catalog.demoDocuments(), + catalog.demoChecklists(), + catalog.demoApprovals(), + catalog.demoTransitions(), + catalog.demoExternalSubmissions(), + catalog.demoEvidence(), + catalog.demoDocumentRequestDrafts(), + catalog.demoAudits(), + demoContext ); - } - - private void seedTask(DemoTask seed, LocalDate today, Instant now) { - LocalDate dueDate = today.plusDays(seed.dueDays()); - EncodedTaskContent content = taskContentCodec.encode( - seed.workerId(), - seed.workflowId(), - seed.taskType().name(), - seed.title(), - seed.description(), - dueDate, - Map.of("worker_id", seed.workerId().toString(), "due_at", dueDate.toString()) + seedDataset( + catalog.testTasks(), + List.of(), + catalog.testDocuments(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + catalog.testAudits(), + testContext ); - Optional existing = taskRepository.findByIdAndCompanyId( - seed.taskId(), - properties.companyId() - ); - existing.ifPresent(task -> verifyExistingTask(task, seed)); - Instant createdAt = existing.map(Task::createdAt) - .orElseGet(() -> now.minus(seed.createdDaysAgo(), ChronoUnit.DAYS)); - Instant desiredUpdatedAt = seed.status() == TaskStatus.COMPLETED - ? now - : now.minus(Math.max(seed.createdDaysAgo() - 1, 0), ChronoUnit.DAYS); - Instant updatedAt = desiredUpdatedAt.isBefore(createdAt) ? createdAt : desiredUpdatedAt; - long version = existing.map(Task::version).orElse(0L); - taskRepository.save(new Task( - seed.taskId(), - properties.companyId(), - seed.workerId(), - seed.caseId(), - seed.taskType(), - seed.workflowId(), - CATALOG_VERSION, - seed.title(), - seed.description(), - content.businessDataJson(), - content.criticalFingerprint(), - 0L, - seed.source(), - seed.status(), - dueDate, - properties.adminUserId(), - properties.adminUserId(), - createdAt, - updatedAt, - version - )); - } - - private void verifyExistingTask(Task task, DemoTask seed) { - if (!seed.taskId().equals(task.taskId()) - || !properties.companyId().equals(task.companyId()) - || !seed.workerId().equals(task.workerId()) - || !seed.caseId().equals(task.caseId()) - || seed.taskType() != task.taskType() - || !seed.workflowId().equals(task.workflowId()) - || !CATALOG_VERSION.equals(task.workflowCatalogVersion()) - || !seed.title().equals(task.title()) - || !seed.description().equals(task.description()) - || seed.source() != task.source() - || seed.status() != task.status() - || task.contentRevision() != 0L - || !properties.adminUserId().equals(task.createdBy()) - || !properties.adminUserId().equals(task.updatedBy())) { - throw new IllegalStateException( - "a reserved demo task id already belongs to different task data" - ); - } - } - - private void seedDocument(DemoDocument seed, LocalDate today, Instant now) { - LocalDate expiryDate = today.plusDays(seed.expiryDays()); - Optional existing = - workerDocumentRepository.findByIdAndWorkerIdAndCompanyId( - seed.documentId(), - seed.workerId(), - properties.companyId() - ); - existing.ifPresent(document -> verifyExistingDocument(document, seed)); - if (existing.isEmpty()) { - workerDocumentRepository.insert(WorkerDocument.create( - seed.documentId(), - seed.workerId(), - properties.companyId(), - seed.documentType(), - seed.submissionStatus(), - expiryDate, - seed.destination(), - seed.note(), - now - )); - return; - } - WorkerDocument document = existing.orElseThrow(); - workerDocumentRepository.update(new WorkerDocument( - document.workerDocumentId(), - document.workerId(), - document.companyId(), - document.documentType(), - document.submissionStatus(), - expiryDate, - document.destination(), - document.note(), - null, - document.createdAt(), - now, - document.version() - )); - } - - private void verifyExistingDocument(WorkerDocument document, DemoDocument seed) { - if (!seed.documentId().equals(document.workerDocumentId()) - || !seed.workerId().equals(document.workerId()) - || !properties.companyId().equals(document.companyId()) - || seed.documentType() != document.documentType() - || seed.submissionStatus() != document.submissionStatus() - || !Objects.equals(seed.destination(), document.destination()) - || !Objects.equals(seed.note(), document.note()) - || document.fileId() != null) { - throw new IllegalStateException( - "a reserved demo worker document id already belongs to different document data" - ); - } - } - - private void seedAudit(DemoAudit seed, Instant now) { - Optional existing = auditEventRepository - .findTaskActivities(properties.companyId(), TIMELINE_TASK_ID) - .stream() - .filter(event -> seed.auditEventId().equals(event.auditEventId())) - .findFirst(); - if (existing.isPresent()) { - verifyExistingAudit(existing.orElseThrow(), seed); - return; - } - auditEventRepository.append(new AuditEvent( - seed.auditEventId(), - properties.companyId(), - ActorType.HR_USER, - properties.adminUserId(), - UserRole.ADMIN, - seed.action(), - AuditTargetType.TASK, - TIMELINE_TASK_ID, - seed.requestId(), - "demo-seed-task-timeline", - "1", - seed.changeSummary(), - now.minus(seed.hoursAgo(), ChronoUnit.HOURS) - )); - } - - private void verifyExistingAudit(AuditEvent event, DemoAudit seed) { - if (!properties.companyId().equals(event.companyId()) - || event.actorType() != ActorType.HR_USER - || !properties.adminUserId().equals(event.actorId()) - || event.userRole() != UserRole.ADMIN - || event.action() != seed.action() - || event.targetType() != AuditTargetType.TASK - || !TIMELINE_TASK_ID.equals(event.targetId()) - || !seed.requestId().equals(event.requestId()) - || !seed.changeSummary().equals(event.changeSummary())) { - throw new IllegalStateException( - "a reserved demo audit event id already belongs to different audit data" - ); - } - } - - private static DemoTask demoTask( - String taskId, - String caseId, - String workerId, - TaskType taskType, - String workflowId, - String title, - String description, - TaskSource source, - TaskStatus status, - int dueDays, - int createdDaysAgo - ) { - return new DemoTask( - UUID.fromString(taskId), - UUID.fromString(caseId), - UUID.fromString(workerId), - taskType, - workflowId, - title, - description, - source, - status, - dueDays, - createdDaysAgo - ); - } - - private static DemoDocument demoDocument( - String documentId, - String workerId, - DocumentType documentType, - SubmissionStatus submissionStatus, - int expiryDays, - String destination, - String note - ) { - return new DemoDocument( - UUID.fromString(documentId), - UUID.fromString(workerId), - documentType, - submissionStatus, - expiryDays, - destination, - note + LOGGER.info( + "demo_operational_seed ready demo_task_count={} demo_stored_file_count={} " + + "demo_document_count={} " + + "checklist_count={} approval_count={} transition_count={} " + + "external_submission_count={} evidence_count={} request_draft_count={} " + + "test_task_count={} test_document_count={} demo_audit_count={} " + + "test_audit_count={}", + catalog.demoTasks().size(), + catalog.demoStoredFiles().size(), + catalog.demoDocuments().size(), + catalog.demoChecklists().size(), + catalog.demoApprovals().size(), + catalog.demoTransitions().size(), + catalog.demoExternalSubmissions().size(), + catalog.demoEvidence().size(), + catalog.demoDocumentRequestDrafts().size(), + catalog.testTasks().size(), + catalog.testDocuments().size(), + catalog.demoAudits().size(), + catalog.testAudits().size() ); } - private static DemoAudit demoAudit( - String auditEventId, - AuditAction action, - String requestId, - String changeSummary, - int hoursAgo + private void seedDataset( + List tasks, + List storedFiles, + List documents, + List checklists, + List approvals, + List transitions, + List externalSubmissions, + List evidence, + List requestDrafts, + List audits, + DemoOperationalSeedContext context ) { - return new DemoAudit( - UUID.fromString(auditEventId), - action, - requestId, - changeSummary, - hoursAgo + tasks.forEach(seed -> taskSeeder.seed(seed, context)); + storedFiles.forEach(seed -> storedFileSeeder.seed(seed, context)); + documents.forEach(seed -> documentSeeder.seed(seed, context)); + checklists.forEach(seed -> checklistSeeder.seed(seed, context)); + approvals.forEach(seed -> approvalSeeder.seed(seed, context)); + transitions.forEach(seed -> transitionSeeder.seed(seed, context)); + externalSubmissions.forEach(seed -> externalSubmissionSeeder.seed(seed, context)); + evidence.forEach(seed -> evidenceSeeder.seed(seed, context)); + requestDrafts.forEach(seed -> requestDraftSeeder.seed(seed, context)); + audits.forEach(seed -> auditSeeder.seed(seed, context)); + verifier.verify( + tasks, + storedFiles, + documents, + checklists, + approvals, + transitions, + externalSubmissions, + evidence, + requestDrafts, + audits, + context ); } - - private record DemoTask( - UUID taskId, - UUID caseId, - UUID workerId, - TaskType taskType, - String workflowId, - String title, - String description, - TaskSource source, - TaskStatus status, - int dueDays, - int createdDaysAgo - ) { - } - - private record DemoDocument( - UUID documentId, - UUID workerId, - DocumentType documentType, - SubmissionStatus submissionStatus, - int expiryDays, - String destination, - String note - ) { - } - - private record DemoAudit( - UUID auditEventId, - AuditAction action, - String requestId, - String changeSummary, - int hoursAgo - ) { - } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java new file mode 100644 index 0000000..06b6ab4 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedVerifier.java @@ -0,0 +1,203 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.approval.application.port.ApprovalRequestRepository; +import com.fowoco.server.approval.domain.ApprovalRequest; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; +import com.fowoco.server.task.application.port.TaskChecklistRepository; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskChecklistItem; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +final class DemoOperationalSeedVerifier { + + private final TaskRepository taskRepository; + private final WorkerDocumentRepository workerDocumentRepository; + private final WorkerRepository workerRepository; + private final TaskChecklistRepository checklistRepository; + private final ApprovalRequestRepository approvalRepository; + private final DemoTaskSeeder taskSeeder; + private final DemoWorkerDocumentSeeder documentSeeder; + private final DemoTaskChecklistSeeder checklistSeeder; + private final DemoApprovalRequestSeeder approvalSeeder; + private final DemoStoredFileSeeder storedFileSeeder; + private final DemoTaskTransitionSeeder transitionSeeder; + private final DemoExternalSubmissionSeeder externalSubmissionSeeder; + private final DemoEvidenceSeeder evidenceSeeder; + private final DemoDocumentRequestDraftSeeder requestDraftSeeder; + private final DemoAuditEventSeeder auditSeeder; + + DemoOperationalSeedVerifier( + TaskRepository taskRepository, + WorkerDocumentRepository workerDocumentRepository, + WorkerRepository workerRepository, + TaskChecklistRepository checklistRepository, + ApprovalRequestRepository approvalRepository, + DemoTaskSeeder taskSeeder, + DemoWorkerDocumentSeeder documentSeeder, + DemoTaskChecklistSeeder checklistSeeder, + DemoApprovalRequestSeeder approvalSeeder, + DemoStoredFileSeeder storedFileSeeder, + DemoTaskTransitionSeeder transitionSeeder, + DemoExternalSubmissionSeeder externalSubmissionSeeder, + DemoEvidenceSeeder evidenceSeeder, + DemoDocumentRequestDraftSeeder requestDraftSeeder, + DemoAuditEventSeeder auditSeeder + ) { + this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); + this.workerDocumentRepository = Objects.requireNonNull( + workerDocumentRepository, + "workerDocumentRepository must not be null" + ); + this.workerRepository = Objects.requireNonNull(workerRepository, "workerRepository must not be null"); + this.checklistRepository = Objects.requireNonNull( + checklistRepository, + "checklistRepository must not be null" + ); + this.approvalRepository = Objects.requireNonNull( + approvalRepository, + "approvalRepository must not be null" + ); + this.taskSeeder = Objects.requireNonNull(taskSeeder, "taskSeeder must not be null"); + this.documentSeeder = Objects.requireNonNull(documentSeeder, "documentSeeder must not be null"); + this.checklistSeeder = Objects.requireNonNull(checklistSeeder, "checklistSeeder must not be null"); + this.approvalSeeder = Objects.requireNonNull(approvalSeeder, "approvalSeeder must not be null"); + this.storedFileSeeder = Objects.requireNonNull( + storedFileSeeder, + "storedFileSeeder must not be null" + ); + this.transitionSeeder = Objects.requireNonNull( + transitionSeeder, + "transitionSeeder must not be null" + ); + this.externalSubmissionSeeder = Objects.requireNonNull( + externalSubmissionSeeder, + "externalSubmissionSeeder must not be null" + ); + this.evidenceSeeder = Objects.requireNonNull(evidenceSeeder, "evidenceSeeder must not be null"); + this.requestDraftSeeder = Objects.requireNonNull( + requestDraftSeeder, + "requestDraftSeeder must not be null" + ); + this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); + } + + void verify( + List tasks, + List storedFiles, + List documents, + List checklists, + List approvals, + List transitions, + List externalSubmissions, + List evidence, + List requestDrafts, + List audits, + DemoOperationalSeedContext context + ) { + verifyUniqueIds(tasks.stream().map(TaskSeed::taskId).toList(), "task"); + verifyUniqueIds(storedFiles.stream().map(StoredFileSeed::storedFileId).toList(), "stored file"); + verifyUniqueIds(documents.stream().map(DocumentSeed::documentId).toList(), "document"); + verifyUniqueIds(checklists.stream().map(ChecklistSeed::checklistItemId).toList(), "checklist item"); + verifyUniqueIds(approvals.stream().map(ApprovalSeed::approvalRequestId).toList(), "approval request"); + verifyUniqueIds(transitions.stream().map(TransitionSeed::transitionId).toList(), "task transition"); + verifyUniqueIds(externalSubmissions.stream() + .map(ExternalSubmissionSeed::externalSubmissionId).toList(), "external submission"); + verifyUniqueIds(evidence.stream().map(EvidenceSeed::evidenceId).toList(), "completion evidence"); + verifyUniqueIds(requestDrafts.stream().map(DocumentRequestDraftSeed::draftId).toList(), + "document request draft"); + verifyUniqueIds(audits.stream().map(AuditSeed::auditEventId).toList(), "audit event"); + verifyWorkersExist(tasks, documents, context); + tasks.forEach(seed -> verifyTask(seed, context)); + storedFiles.forEach(seed -> storedFileSeeder.verifyExisting(seed, context)); + documents.forEach(seed -> verifyDocument(seed, context)); + checklists.forEach(seed -> verifyChecklist(seed, context)); + approvals.forEach(seed -> verifyApproval(seed, context)); + transitions.forEach(seed -> transitionSeeder.verifyExisting(seed, context)); + externalSubmissions.forEach(seed -> externalSubmissionSeeder.verifyExisting(seed, context)); + evidence.forEach(seed -> evidenceSeeder.verifyExisting(seed, context)); + requestDrafts.forEach(seed -> requestDraftSeeder.verifyExisting(seed, context)); + audits.forEach(seed -> verifyAudit(seed, context)); + } + + private void verifyTask(TaskSeed seed, DemoOperationalSeedContext context) { + Task task = taskRepository.findByIdAndCompanyId(seed.taskId(), context.companyId()) + .orElseThrow(() -> new IllegalStateException("a reserved demo task was not seeded")); + taskSeeder.verifyExisting(task, seed, context); + } + + private void verifyDocument(DocumentSeed seed, DemoOperationalSeedContext context) { + WorkerDocument document = workerDocumentRepository.findByIdAndWorkerIdAndCompanyId( + seed.documentId(), + seed.workerId(), + context.companyId() + ) + .orElseThrow(() -> new IllegalStateException("a reserved demo worker document was not seeded")); + documentSeeder.verifyExisting(document, seed, context); + } + + private void verifyChecklist(ChecklistSeed seed, DemoOperationalSeedContext context) { + TaskChecklistItem item = checklistRepository.findByIdAndTaskIdAndCompanyId( + seed.checklistItemId(), + seed.taskId(), + context.companyId() + ) + .orElseThrow(() -> new IllegalStateException("a reserved demo checklist item was not seeded")); + checklistSeeder.verifyExisting(item, seed, context); + } + + private void verifyApproval(ApprovalSeed seed, DemoOperationalSeedContext context) { + ApprovalRequest approval = approvalRepository.findByIdAndCompanyId( + seed.approvalRequestId(), + context.companyId() + ) + .orElseThrow(() -> new IllegalStateException("a reserved demo approval request was not seeded")); + Task task = taskRepository.findByIdAndCompanyId(seed.taskId(), context.companyId()) + .orElseThrow(() -> new IllegalStateException("a demo approval task does not exist")); + approvalSeeder.verifyExisting(approval, seed, context, task); + } + + private void verifyAudit(AuditSeed seed, DemoOperationalSeedContext context) { + AuditEvent event = auditSeeder.findExisting(seed) + .orElseThrow(() -> new IllegalStateException("a reserved demo audit event was not seeded")); + auditSeeder.verifyExisting(event, seed, context); + } + + private void verifyWorkersExist( + List tasks, + List documents, + DemoOperationalSeedContext context + ) { + Set workerIds = new HashSet<>(); + tasks.stream().map(TaskSeed::workerId).forEach(workerIds::add); + documents.stream().map(DocumentSeed::workerId).forEach(workerIds::add); + boolean missingWorker = workerIds.stream().anyMatch(workerId -> + workerRepository.findByWorkerIdAndCompanyId(workerId, context.companyId()).isEmpty()); + if (missingWorker) { + throw new IllegalStateException("a demo task or document worker does not exist in its company"); + } + } + + private void verifyUniqueIds(List ids, String entityName) { + if (new HashSet<>(ids).size() != ids.size()) { + throw new IllegalStateException("duplicate reserved demo " + entityName + " id"); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeedCatalog.java new file mode 100644 index 0000000..d7bdcc3 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeedCatalog.java @@ -0,0 +1,72 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import java.util.List; +import java.util.UUID; + +final class DemoStoredFileSeedCatalog { + + static final UUID CONTRACT_FILE_ID = demoUuid(1); + static final UUID STAY_RECEIPT_FILE_ID = demoUuid(2); + static final UUID STAY_RESULT_FILE_ID = demoUuid(3); + + private DemoStoredFileSeedCatalog() { + } + + static List demoStoredFiles(List tasks) { + return List.of( + storedFile( + CONTRACT_FILE_ID, + tasks, + 5, + "demo-contract-renewal.pdf", + "demo/files/demo-contract-renewal.pdf", + "DEMO_CONTRACT_RENEWAL" + ), + storedFile( + STAY_RECEIPT_FILE_ID, + tasks, + 20, + "demo-stay-extension-receipt.pdf", + "demo/files/demo-stay-extension-receipt.pdf", + "DEMO_STAY_EXTENSION_RECEIPT" + ), + storedFile( + STAY_RESULT_FILE_ID, + tasks, + 20, + "demo-stay-extension-result.pdf", + "demo/files/demo-stay-extension-result.pdf", + "DEMO_STAY_EXTENSION_RESULT" + ) + ); + } + + private static StoredFileSeed storedFile( + UUID storedFileId, + List tasks, + int taskNumber, + String name, + String resourcePath, + String purpose + ) { + TaskSeed task = tasks.get(taskNumber - 1); + return new StoredFileSeed( + storedFileId, + name, + "application/pdf", + purpose, + task.taskId(), + task.workerId(), + storedFileId.toString(), + resourcePath + ); + } + + private static UUID demoUuid(int number) { + return UUID.fromString( + "94800000-0000-0000-0000-000000000%03d".formatted(number) + ); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeeder.java new file mode 100644 index 0000000..1a5aadc --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoStoredFileSeeder.java @@ -0,0 +1,106 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import com.fowoco.server.file.application.port.StoredFileRepository; +import com.fowoco.server.file.domain.ScanStatus; +import com.fowoco.server.file.domain.StoredFile; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoStoredFileSeeder { + + private final StoredFileRepository repository; + private final JdbcTemplate jdbcTemplate; + private final DemoFileFixtureInstaller fixtureInstaller; + + DemoStoredFileSeeder( + StoredFileRepository repository, + JdbcTemplate jdbcTemplate, + DemoFileFixtureInstaller fixtureInstaller + ) { + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + this.fixtureInstaller = Objects.requireNonNull( + fixtureInstaller, + "fixtureInstaller must not be null" + ); + } + + void seed(StoredFileSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.storedFileId()); + if (!existing.isEmpty()) { + verifyMetadata(existing.get(0), seed, context); + fixtureInstaller.install(seed); + return; + } + fixtureInstaller.install(seed); + repository.insert(StoredFile.create( + seed.storedFileId(), + context.companyId(), + seed.name(), + seed.mimeType(), + fixtureInstaller.expectedSize(seed), + seed.purpose(), + seed.taskId(), + seed.workerId(), + seed.storageKey(), + context.now() + )); + } + + void verifyExisting(StoredFileSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.storedFileId()); + if (existing.size() != 1) { + throw new IllegalStateException("a reserved demo stored file was not seeded"); + } + verifyMetadata(existing.get(0), seed, context); + fixtureInstaller.verify(seed); + } + + private List find(UUID storedFileId) { + return jdbcTemplate.query( + "SELECT stored_file_id, company_id, name, mime_type, size, purpose, task_id, " + + "worker_id, storage_key, scan_status, verified, created_at " + + "FROM stored_file WHERE stored_file_id = ?", + (resultSet, rowNumber) -> new StoredFile( + resultSet.getObject("stored_file_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getString("name"), + resultSet.getString("mime_type"), + resultSet.getLong("size"), + resultSet.getString("purpose"), + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("worker_id", UUID.class), + resultSet.getString("storage_key"), + ScanStatus.valueOf(resultSet.getString("scan_status")), + resultSet.getBoolean("verified"), + resultSet.getTimestamp("created_at").toInstant() + ), + storedFileId + ); + } + + private void verifyMetadata( + StoredFile file, + StoredFileSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.storedFileId().equals(file.storedFileId()) + || !context.companyId().equals(file.companyId()) + || !seed.name().equals(file.name()) + || !seed.mimeType().equals(file.mimeType()) + || fixtureInstaller.expectedSize(seed) != file.size() + || !seed.purpose().equals(file.purpose()) + || !Objects.equals(seed.taskId(), file.taskId()) + || !Objects.equals(seed.workerId(), file.workerId()) + || !seed.storageKey().equals(file.storageKey()) + || file.scanStatus() != ScanStatus.NOT_SCANNED + || file.verified()) { + throw new IllegalStateException( + "a reserved demo stored file id already belongs to different file data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java new file mode 100644 index 0000000..62169fb --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskArtifactSeedCatalog.java @@ -0,0 +1,128 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.domain.EvidenceType; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentRequestDraftSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.EvidenceSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ExternalSubmissionSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.worker.domain.DocumentType; +import java.util.List; +import java.util.UUID; + +final class DemoTaskArtifactSeedCatalog { + + private DemoTaskArtifactSeedCatalog() { + } + + static List demoExternalSubmissions(List tasks) { + return List.of( + submission(1, tasks, 4, "고용센터", "DEMO-EMPLOYMENT-EXT-001", 264), + submission(2, tasks, 18, "고용센터", "DEMO-EMPLOYMENT-EXT-002", 312), + submission(3, tasks, 19, "고용센터", "DEMO-RECONTRACT-REPORT-001", 360), + submission(4, tasks, 20, "출입국·외국인청", "DEMO-STAY-EXT-001", 120), + submission(5, tasks, 22, "고용센터", "DEMO-EMPLOYMENT-EXT-003", 144), + submission(6, tasks, 23, "출입국·외국인청", "DEMO-STAY-EXT-002", 168) + ); + } + + static List demoEvidence(List tasks) { + return List.of( + evidence(1, tasks, 5, EvidenceType.DOCUMENT, null, + "서명된 재계약 문서 확인", 48), + evidence(2, tasks, 5, EvidenceType.HR_CONFIRMATION, null, + "계약 갱신 처리 완료 확인", 24), + evidence(3, tasks, 20, EvidenceType.RECEIPT, + DemoStoredFileSeedCatalog.STAY_RECEIPT_FILE_ID.toString(), + "DEMO-STAY-EXT-001 체류기간 연장 접수 확인", 48), + evidence(4, tasks, 20, EvidenceType.OFFICIAL_RESULT, + DemoStoredFileSeedCatalog.STAY_RESULT_FILE_ID.toString(), + "DEMO-STAY-EXT-001 체류기간 연장 승인 결과 확인", 12), + evidence(5, tasks, 21, EvidenceType.DOCUMENT, null, + "갱신 계약서 서명 상태 확인", 72), + evidence(6, tasks, 21, EvidenceType.HR_CONFIRMATION, null, + "재계약 인사 반영 완료", 24), + evidence(7, tasks, 22, EvidenceType.RECEIPT, null, + "고용허가기간 연장 접수 확인", 72), + evidence(8, tasks, 22, EvidenceType.OFFICIAL_RESULT, null, + "고용허가기간 연장 결과 확인", 24), + evidence(9, tasks, 23, EvidenceType.RECEIPT, null, + "체류 연장 신청 접수 확인", 96), + evidence(10, tasks, 23, EvidenceType.OFFICIAL_RESULT, null, + "체류 연장 최종 결과 확인", 24) + ); + } + + static List demoDocumentRequestDrafts(List tasks) { + return List.of( + draft(1, tasks, 3, "ne", List.of(DocumentType.PASSPORT_COPY), + "कृपया राहदानीको प्रतिलिपि तयार गर्नुहोस्।", 72), + draft(2, tasks, 8, "vi", List.of(DocumentType.PASSPORT_COPY), + "Vui lòng nộp bản sao hộ chiếu trong vòng 7 ngày để chuẩn bị gia hạn.", 36), + draft(3, tasks, 10, "id", List.of(DocumentType.PASSPORT_COPY, DocumentType.ARC), + "Mohon siapkan salinan paspor dan kartu registrasi orang asing.", 72), + draft(4, tasks, 11, "id", List.of(DocumentType.ARC), + "Mohon siapkan salinan terbaru kartu registrasi orang asing.", 120), + draft(5, tasks, 12, "my", List.of(DocumentType.CONTRACT, DocumentType.PERMIT), + "ကျေးဇူးပြု၍ အလုပ်စာချုပ်နှင့် အလုပ်လုပ်ခွင့်စာရွက်စာတမ်းကို ပြင်ဆင်ပါ။", 144) + ); + } + + private static ExternalSubmissionSeed submission( + int number, + List tasks, + int taskNumber, + String destination, + String safeReference, + int hoursAgo + ) { + return new ExternalSubmissionSeed( + demoUuid("94500000-0000-0000-0000-000000000", number), + tasks.get(taskNumber - 1).taskId(), + destination, + safeReference, + hoursAgo + ); + } + + private static EvidenceSeed evidence( + int number, + List tasks, + int taskNumber, + EvidenceType evidenceType, + String fileReference, + String note, + int hoursAgo + ) { + return new EvidenceSeed( + demoUuid("94600000-0000-0000-0000-000000000", number), + tasks.get(taskNumber - 1).taskId(), + evidenceType, + fileReference, + note, + hoursAgo + ); + } + + private static DocumentRequestDraftSeed draft( + int number, + List tasks, + int taskNumber, + String language, + List documentTypes, + String message, + int hoursAgo + ) { + return new DocumentRequestDraftSeed( + demoUuid("94700000-0000-0000-0000-000000000", number), + tasks.get(taskNumber - 1).taskId(), + language, + documentTypes, + message, + hoursAgo + ); + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskChecklistSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskChecklistSeeder.java new file mode 100644 index 0000000..bdba5f4 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskChecklistSeeder.java @@ -0,0 +1,71 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; +import com.fowoco.server.task.application.port.TaskChecklistRepository; +import com.fowoco.server.task.domain.TaskChecklistItem; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; + +final class DemoTaskChecklistSeeder { + + private final TaskChecklistRepository checklistRepository; + + DemoTaskChecklistSeeder(TaskChecklistRepository checklistRepository) { + this.checklistRepository = Objects.requireNonNull( + checklistRepository, + "checklistRepository must not be null" + ); + } + + void seed(ChecklistSeed seed, DemoOperationalSeedContext context) { + Optional existing = checklistRepository.findByIdAndTaskIdAndCompanyId( + seed.checklistItemId(), + seed.taskId(), + context.companyId() + ); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context); + return; + } + Instant createdAt = context.now().minus(seed.createdHoursAgo(), ChronoUnit.HOURS); + Instant completedAt = seed.completed() + ? context.now().minus(seed.completedHoursAgo(), ChronoUnit.HOURS) + : null; + checklistRepository.save(new TaskChecklistItem( + seed.checklistItemId(), + seed.taskId(), + context.companyId(), + seed.itemCode(), + seed.label(), + seed.required(), + seed.completed(), + seed.completed() ? context.actorId() : null, + completedAt, + createdAt, + completedAt == null ? createdAt : completedAt, + 0L + )); + } + + void verifyExisting( + TaskChecklistItem item, + ChecklistSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.checklistItemId().equals(item.checklistItemId()) + || !seed.taskId().equals(item.taskId()) + || !context.companyId().equals(item.companyId()) + || !seed.itemCode().equals(item.itemCode()) + || !seed.label().equals(item.label()) + || seed.required() != item.required() + || seed.completed() != item.completed() + || (seed.completed() && !context.actorId().equals(item.completedBy())) + || (!seed.completed() && item.completedBy() != null)) { + throw new IllegalStateException( + "a reserved demo checklist item id already belongs to different checklist data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java new file mode 100644 index 0000000..568e8b2 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeedCatalog.java @@ -0,0 +1,275 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.task.domain.TaskSource; +import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.task.domain.TaskType; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +final class DemoTaskSeedCatalog { + + private DemoTaskSeedCatalog() { + } + + static List demoTasks() { + return List.of( + task(1, 1, TaskType.STAY_PERIOD_EXTENSION, "AI 추천: 체류기간 연장 준비", + "체류기간 만료 전에 필요한 서류를 확인하고 연장 신청을 준비합니다.", + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 3, 6), + task(2, 2, TaskType.RECONTRACT, "근로계약 갱신 검토", + "갱신 계약 조건과 제출 서류를 검토하고 승인을 기다립니다.", + TaskSource.MANUAL, TaskStatus.READY_FOR_REVIEW, 20, 5), + task(3, 3, TaskType.STAY_PERIOD_EXTENSION, "여권 사본 제출 대기", + "근로자에게 최신 여권 사본을 요청했으며 응답을 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_WORKER, 6, 4), + task(4, 4, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용허가기간 연장 결과 대기", + "관할 기관에 제출을 완료하고 처리 결과를 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_EXTERNAL, 90, 12), + task(5, 5, TaskType.RECONTRACT, "근로계약 갱신 완료", + "서명된 근로계약서 확인과 갱신 처리를 완료했습니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.COMPLETED, 30, 10), + taskWithCase(6, 6, 6, TaskType.RECONTRACT, "재계약 조건 확인", + "응웬반A의 재계약 조건과 계속 고용 의사를 검토합니다.", + TaskSource.AI_CANDIDATE, TaskStatus.READY_FOR_REVIEW, 12, 3, + compoundCaseBusinessData( + "RECONTRACT_CONDITIONS", + 1, + "READY_FOR_REVIEW", + Map.of("approval_required", true) + )), + taskWithCase(7, 6, 6, TaskType.EMPLOYMENT_PERIOD_EXTENSION, + "취업활동기간 연장 준비", + "서명된 재계약 결과를 기다리는 취업활동기간 연장 초안입니다.", + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 20, 2, + compoundCaseBusinessData( + "EMPLOYMENT_PERIOD_EXTENSION", + 3, + "BLOCKED_BY_PREDECESSOR", + Map.of( + "depends_on_task_id", + demoUuid("94000000-0000-0000-0000-000000000", 6).toString(), + "dependency_reason", "SIGNED_CONTRACT_REQUIRED" + ) + )), + taskWithCase(8, 6, 6, TaskType.STAY_PERIOD_EXTENSION, "여권 사본 요청", + "응웬반A에게 베트남어로 여권 사본 제출을 요청하고 응답을 기다립니다.", + TaskSource.AI_CANDIDATE, TaskStatus.WAITING_WORKER, 7, 2, + compoundCaseBusinessData( + "PASSPORT_COPY_REQUEST", + 2, + "WAITING_WORKER", + Map.of( + "requested_document_types", List.of("PASSPORT_COPY"), + "submission_due_offset_days", 7, + "missing_information", List.of(Map.of( + "field", "passport_copy", + "source", "WORKER_CONFIRMATION", + "blocking", true + )) + ) + )), + task(9, 8, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 정보 보완", + "연장 신청에 필요한 고용 정보와 제출 서류를 보완합니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.NEEDS_INFO, 4, 5), + task(10, 9, TaskType.STAY_PERIOD_EXTENSION, "체류서류 응답 대기", + "근로자에게 요청한 체류 관련 서류의 응답을 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_WORKER, 7, 4), + task(11, 11, TaskType.STAY_PERIOD_EXTENSION, "외국인등록증 사본 대기", + "체류연장 검토를 위해 외국인등록증 사본을 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_WORKER, 12, 6), + task(12, 10, TaskType.RECONTRACT, "재계약 서류 준비", + "계약 만료 전에 재계약 서류와 조건을 준비합니다.", + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 20, 7), + task(13, 7, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 자료 보완", + "체류기간 연장 업무의 누락 정보와 보유 문서를 확인합니다.", + TaskSource.MANUAL, TaskStatus.NEEDS_INFO, 21, 8), + task(14, 12, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 검토", + "제출 준비가 완료된 체류기간 연장 내용을 검토합니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.READY_FOR_REVIEW, 7, 3), + task(15, 13, TaskType.RECONTRACT, "재계약 조건 최종 검토", + "근로조건과 계약기간을 확인하고 최종 검토합니다.", + TaskSource.MANUAL, TaskStatus.READY_FOR_REVIEW, 21, 9), + task(16, 14, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 승인", + "고용기간 연장 신청 내용이 승인되어 제출을 준비합니다.", + TaskSource.MANUAL, TaskStatus.APPROVED, 35, 10), + task(17, 15, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 승인", + "체류기간 연장 신청 내용이 승인되었습니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.APPROVED, 62, 11), + task(18, 16, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 심사 대기", + "외부기관에 제출한 고용기간 연장 결과를 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_EXTERNAL, 90, 14), + task(19, 17, TaskType.RECONTRACT, "재계약 신고 결과 대기", + "재계약 관련 외부 신고 처리 결과를 기다립니다.", + TaskSource.MANUAL, TaskStatus.WAITING_EXTERNAL, 120, 16), + task(20, 18, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 완료", + "체류기간 연장 결과와 완료 자료를 확인했습니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.COMPLETED, 0, 20), + task(21, 19, TaskType.RECONTRACT, "재계약 처리 완료", + "서명 계약서와 재계약 처리 결과를 확인했습니다.", + TaskSource.MANUAL, TaskStatus.COMPLETED, 35, 25), + task(22, 20, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "고용기간 연장 완료", + "고용기간 연장 승인 결과를 확인하고 업무를 완료했습니다.", + TaskSource.SYSTEM_DDAY, TaskStatus.COMPLETED, 62, 30), + task(23, 21, TaskType.STAY_PERIOD_EXTENSION, "체류기간 연장 처리 완료", + "체류기간 연장 신청과 결과 확인을 완료했습니다.", + TaskSource.MANUAL, TaskStatus.COMPLETED, 90, 35), + task(24, 22, TaskType.RECONTRACT, "재계약 검토 취소", + "근로계약 종료 일정 변경으로 재계약 검토를 취소했습니다.", + TaskSource.MANUAL, TaskStatus.CANCELLED, 21, 12) + ); + } + + static List testTasks() { + return List.of( + testTask(1, 1, TaskType.STAY_PERIOD_EXTENSION, "테스트 체류연장 초안", + TaskSource.AI_CANDIDATE, TaskStatus.DRAFT, 7, 2), + testTask(2, 2, TaskType.RECONTRACT, "테스트 재계약 검토", + TaskSource.MANUAL, TaskStatus.READY_FOR_REVIEW, 30, 5), + testTask(3, 3, TaskType.EMPLOYMENT_PERIOD_EXTENSION, "테스트 고용기간 연장 완료", + TaskSource.SYSTEM_DDAY, TaskStatus.COMPLETED, 60, 10) + ); + } + + private static TaskSeed task( + int taskNumber, + int workerNumber, + TaskType taskType, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + return taskWithCase( + taskNumber, + taskNumber, + workerNumber, + taskType, + title, + description, + source, + status, + dueDays, + createdDaysAgo, + Map.of() + ); + } + + private static TaskSeed taskWithCase( + int taskNumber, + int caseNumber, + int workerNumber, + TaskType taskType, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo, + Map businessData + ) { + return new TaskSeed( + demoUuid("94000000-0000-0000-0000-000000000", taskNumber), + demoUuid("94100000-0000-0000-0000-000000000", caseNumber), + demoUuid("92000000-0000-0000-0000-000000000", workerNumber), + taskType, + workflowId(taskType), + title, + description, + source, + status, + dueDays, + createdDaysAgo, + businessData + ); + } + + private static TaskSeed taskWithCase( + int taskNumber, + int caseNumber, + int workerNumber, + TaskType taskType, + String title, + String description, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + return taskWithCase( + taskNumber, + caseNumber, + workerNumber, + taskType, + title, + description, + source, + status, + dueDays, + createdDaysAgo, + Map.of() + ); + } + + private static Map compoundCaseBusinessData( + String candidateKey, + int candidateOrder, + String displayState, + Map details + ) { + Map data = new LinkedHashMap<>(); + data.put("demo_scenario", "compound-draft-flow-v1"); + data.put("case_display_name", "재계약·연장 준비"); + data.put( + "request_text", + "응웬반A의 재계약과 연장 준비를 진행해 주세요. " + + "여권 사본이 없으면 7일 안에 제출하도록 안내해 주세요." + ); + data.put("stay_qualification", "E-9"); + data.put("contract_key", "EXPIRY_RENEWAL+DOCUMENT_REQUEST"); + data.put("candidate_key", candidateKey); + data.put("candidate_order", candidateOrder); + data.put("display_state", displayState); + data.put("input_summary", Map.of("required_count", 9, "available_count", 7)); + data.putAll(details); + return Map.copyOf(data); + } + + private static TaskSeed testTask( + int taskNumber, + int workerNumber, + TaskType taskType, + String title, + TaskSource source, + TaskStatus status, + int dueDays, + int createdDaysAgo + ) { + return new TaskSeed( + demoUuid("97000000-0000-0000-0000-000000000", taskNumber), + demoUuid("97100000-0000-0000-0000-000000000", taskNumber), + demoUuid("93000000-0000-0000-0000-000000000", workerNumber), + taskType, + workflowId(taskType), + title, + "Tenant 격리 확인을 위한 소규모 테스트 업무입니다.", + source, + status, + dueDays, + createdDaysAgo, + Map.of() + ); + } + + private static String workflowId(TaskType taskType) { + return taskType == TaskType.STAY_PERIOD_EXTENSION ? "WF-STY-001" : "WF-CON-001"; + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java new file mode 100644 index 0000000..f350ece --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskSeeder.java @@ -0,0 +1,106 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.task.application.TaskContentCodec; +import com.fowoco.server.task.application.TaskContentCodec.EncodedTaskContent; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +final class DemoTaskSeeder { + + private final TaskRepository taskRepository; + private final TaskContentCodec taskContentCodec; + + DemoTaskSeeder(TaskRepository taskRepository, TaskContentCodec taskContentCodec) { + this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository must not be null"); + this.taskContentCodec = Objects.requireNonNull(taskContentCodec, "taskContentCodec must not be null"); + } + + void seed(TaskSeed seed, DemoOperationalSeedContext context) { + LocalDate dueDate = context.today().plusDays(seed.dueDays()); + EncodedTaskContent content = encode(seed, dueDate); + Optional existing = taskRepository.findByIdAndCompanyId( + seed.taskId(), + context.companyId() + ); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context); + return; + } + Instant createdAt = context.now().minus(seed.createdDaysAgo(), ChronoUnit.DAYS); + Instant desiredUpdatedAt = seed.status() == TaskStatus.COMPLETED + ? context.now() + : context.now().minus(Math.max(seed.createdDaysAgo() - 1, 0), ChronoUnit.DAYS); + Instant updatedAt = desiredUpdatedAt.isBefore(createdAt) ? createdAt : desiredUpdatedAt; + taskRepository.save(new Task( + seed.taskId(), + context.companyId(), + seed.workerId(), + seed.caseId(), + seed.taskType(), + seed.workflowId(), + DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION, + seed.title(), + seed.description(), + content.businessDataJson(), + content.criticalFingerprint(), + 0L, + seed.source(), + seed.status(), + dueDate, + context.actorId(), + context.actorId(), + createdAt, + updatedAt, + 0L + )); + } + + void verifyExisting(Task task, TaskSeed seed, DemoOperationalSeedContext context) { + LocalDate dueDate = task.dueDate(); + EncodedTaskContent content = encode(seed, dueDate); + if (!seed.taskId().equals(task.taskId()) + || !context.companyId().equals(task.companyId()) + || !seed.workerId().equals(task.workerId()) + || !seed.caseId().equals(task.caseId()) + || seed.taskType() != task.taskType() + || !seed.workflowId().equals(task.workflowId()) + || !DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION.equals(task.workflowCatalogVersion()) + || !seed.title().equals(task.title()) + || !seed.description().equals(task.description()) + || !content.businessDataJson().equals(task.businessDataJson()) + || !content.criticalFingerprint().equals(task.criticalFingerprint()) + || seed.source() != task.source() + || seed.status() != task.status() + || task.contentRevision() != 0L + || !context.actorId().equals(task.createdBy()) + || !context.actorId().equals(task.updatedBy())) { + throw new IllegalStateException( + "a reserved demo task id already belongs to different task data" + ); + } + } + + private EncodedTaskContent encode(TaskSeed seed, LocalDate dueDate) { + Map businessData = new LinkedHashMap<>(seed.businessData()); + businessData.put("worker_id", seed.workerId().toString()); + businessData.put("due_at", dueDate.toString()); + return taskContentCodec.encode( + seed.workerId(), + seed.workflowId(), + seed.taskType().name(), + seed.title(), + seed.description(), + dueDate, + businessData + ); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskTransitionSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskTransitionSeeder.java new file mode 100644 index 0000000..e40e32d --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskTransitionSeeder.java @@ -0,0 +1,95 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; +import com.fowoco.server.task.domain.TaskStatus; +import java.sql.Timestamp; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +final class DemoTaskTransitionSeeder { + + private final JdbcTemplate jdbcTemplate; + + DemoTaskTransitionSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + void seed(TransitionSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.transitionId()); + if (!existing.isEmpty()) { + verifyExisting(existing.get(0), seed, context); + return; + } + jdbcTemplate.update( + "INSERT INTO task_transition_history " + + "(transition_id, task_id, company_id, from_status, to_status, actor_id, " + + "reason, request_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + seed.transitionId(), + seed.taskId(), + context.companyId(), + seed.fromStatus().name(), + seed.toStatus().name(), + context.actorId(), + seed.reason(), + seed.requestId(), + Timestamp.from(context.now().minus(seed.hoursAgo(), ChronoUnit.HOURS)) + ); + } + + void verifyExisting(TransitionSeed seed, DemoOperationalSeedContext context) { + List existing = find(seed.transitionId()); + if (existing.size() != 1) { + throw new IllegalStateException("a reserved demo task transition was not seeded"); + } + verifyExisting(existing.get(0), seed, context); + } + + private List find(UUID transitionId) { + return jdbcTemplate.query( + "SELECT task_id, company_id, from_status, to_status, actor_id, reason, request_id " + + "FROM task_transition_history WHERE transition_id = ?", + (resultSet, rowNumber) -> new StoredTransition( + resultSet.getObject("task_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + TaskStatus.valueOf(resultSet.getString("from_status")), + TaskStatus.valueOf(resultSet.getString("to_status")), + resultSet.getObject("actor_id", UUID.class), + resultSet.getString("reason"), + resultSet.getString("request_id") + ), + transitionId + ); + } + + private void verifyExisting( + StoredTransition transition, + TransitionSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.taskId().equals(transition.taskId()) + || !context.companyId().equals(transition.companyId()) + || seed.fromStatus() != transition.fromStatus() + || seed.toStatus() != transition.toStatus() + || !context.actorId().equals(transition.actorId()) + || !seed.reason().equals(transition.reason()) + || !seed.requestId().equals(transition.requestId())) { + throw new IllegalStateException( + "a reserved demo task transition id already belongs to different transition data" + ); + } + } + + private record StoredTransition( + UUID taskId, + UUID companyId, + TaskStatus fromStatus, + TaskStatus toStatus, + UUID actorId, + String reason, + String requestId + ) { + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java new file mode 100644 index 0000000..5dea3bd --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoTaskWorkflowSeedCatalog.java @@ -0,0 +1,346 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.approval.domain.ApprovalStatus; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TaskSeed; +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.TransitionSeed; +import com.fowoco.server.task.domain.TaskStatus; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +final class DemoTaskWorkflowSeedCatalog { + + private static final List STAY_CHECKLIST = List.of( + new ChecklistTemplate("PASSPORT_COPY_VERIFY_CURRENT", "여권 사본 보유·유효 상태 확인"), + new ChecklistTemplate("ALIEN_REGISTRATION_CARD_VERIFY_CURRENT", "외국인등록증 사본 보유·유효 상태 확인"), + new ChecklistTemplate("EMPLOYMENT_CONTRACT_VERIFY_PERIOD", "근로계약기간 확인"), + new ChecklistTemplate("APPLICATION_FORM_SELECT_CURRENT", "최신 공식 통합신청서 양식 확인") + ); + private static final List CONTRACT_CHECKLIST = List.of( + new ChecklistTemplate("EMPLOYMENT_CONTRACT_USE_CURRENT_STANDARD_FORM", "최신 표준근로계약서 양식 사용 확인"), + new ChecklistTemplate("EMPLOYMENT_PERMIT_VERIFY_PERIOD", "고용허가기간과 연장 적용 여부 확인") + ); + private static final Map INCOMPLETE_ITEMS_BY_TASK = Map.of( + 7, 2, + 9, 1, + 12, 1, + 13, 1 + ); + + private DemoTaskWorkflowSeedCatalog() { + } + + static List demoChecklists(List tasks) { + List seeds = new ArrayList<>(); + for (int taskIndex = 0; taskIndex < tasks.size(); taskIndex++) { + TaskSeed task = tasks.get(taskIndex); + List templates = "WF-STY-001".equals(task.workflowId()) + ? STAY_CHECKLIST + : CONTRACT_CHECKLIST; + int incompleteCount = INCOMPLETE_ITEMS_BY_TASK.getOrDefault(taskIndex + 1, 0); + int completedHoursAgo = completedHoursAgo(taskIndex + 1, task); + for (int itemIndex = 0; itemIndex < templates.size(); itemIndex++) { + ChecklistTemplate template = templates.get(itemIndex); + boolean completed = itemIndex < templates.size() - incompleteCount; + int createdHoursAgo = Math.max(task.createdDaysAgo() * 24, 24); + seeds.add(new ChecklistSeed( + demoUuid("94200000-0000-0000-0000-000000000", seeds.size() + 1), + task.taskId(), + template.itemCode(), + template.label(), + true, + completed, + createdHoursAgo, + completed ? completedHoursAgo : null + )); + } + } + return List.copyOf(seeds); + } + + static List demoApprovals(List tasks) { + return List.of( + approval(1, tasks, 2, ApprovalStatus.PENDING, null), + approval(2, tasks, 6, ApprovalStatus.PENDING, null), + approval(3, tasks, 14, ApprovalStatus.PENDING, null), + approval(4, tasks, 15, ApprovalStatus.PENDING, null), + approval(5, tasks, 16, ApprovalStatus.APPROVED, "연장 조건과 제출 자료 확인"), + approval(6, tasks, 17, ApprovalStatus.APPROVED, "체류 연장 신청 내용 승인"), + approval(7, tasks, 5, ApprovalStatus.APPROVED, "서명 계약서와 갱신 조건 확인"), + approval(8, tasks, 20, ApprovalStatus.APPROVED, "체류 연장 완료 건 승인"), + approval(9, tasks, 21, ApprovalStatus.APPROVED, "재계약 완료 건 승인"), + approval(10, tasks, 22, ApprovalStatus.APPROVED, "고용기간 연장 완료 건 승인"), + approval(11, tasks, 23, ApprovalStatus.APPROVED, "신청 결과 확인 후 승인"), + approval(12, tasks, 9, ApprovalStatus.REJECTED, "필수 고용 정보 보완 필요"), + approval(13, tasks, 1, ApprovalStatus.INVALIDATED, + "마감일 변경으로 기존 승인 snapshot을 무효화") + ); + } + + static List demoTransitions(List tasks) { + List seeds = new ArrayList<>(); + for (int taskIndex = 0; taskIndex < tasks.size(); taskIndex++) { + history(seeds, tasks.get(taskIndex), transitionPath(taskIndex + 1)); + } + return List.copyOf(seeds); + } + + private static ApprovalSeed approval( + int approvalNumber, + List tasks, + int taskNumber, + ApprovalStatus status, + String reason + ) { + TaskSeed task = tasks.get(taskNumber - 1); + List points = transitionPoints(taskNumber, task); + int readyHoursAgo = hoursAgo(points, TaskStatus.READY_FOR_REVIEW); + int requestedHoursAgo = readyHoursAgo - 1; + Integer outcomeHoursAgo = switch (status) { + case PENDING -> null; + case APPROVED -> hoursAgo(points, TaskStatus.APPROVED) + 1; + case REJECTED, INVALIDATED -> hoursAgoAfter( + points, + TaskStatus.DRAFT, + TaskStatus.READY_FOR_REVIEW + ) + 1; + }; + if (outcomeHoursAgo != null && requestedHoursAgo < outcomeHoursAgo) { + throw new IllegalStateException("demo approval timeline is invalid"); + } + ApprovalSnapshot snapshot = approvalSnapshot(taskNumber, task, status, reason); + return new ApprovalSeed( + demoUuid("94300000-0000-0000-0000-000000000", approvalNumber), + task.taskId(), + status, + reason, + requestedHoursAgo, + outcomeHoursAgo, + snapshot.ai(), + snapshot.hr(), + snapshot.changedFields(), + snapshot.sourceVersions() + ); + } + + private static ApprovalSnapshot approvalSnapshot( + int taskNumber, + TaskSeed task, + ApprovalStatus status, + String reason + ) { + Map ai = new LinkedHashMap<>(); + ai.put("summary", task.title()); + ai.put("task_type", task.taskType().name()); + ai.put("workflow_id", task.workflowId()); + ai.put("due_offset_days", task.dueDays()); + ai.put("proposed_status", TaskStatus.READY_FOR_REVIEW.name()); + + Map hr = new LinkedHashMap<>(); + hr.put("summary", task.title()); + hr.put("review_result", status.name()); + hr.put("approval_required", true); + if (reason != null) { + hr.put("review_note", reason); + } + + List> changedFields = List.of(); + if (taskNumber == 6) { + ai.put("case_display_name", "재계약·연장 준비"); + ai.put("input_summary", Map.of("required_count", 9, "available_count", 7)); + hr.put("confirmed_fields", List.of("continued_employment_intent")); + hr.put("warning_fields", List.of("passport_expiry_date")); + hr.put("validation_summary", Map.of( + "key_fields_valid", 7, + "key_fields_total", 7, + "error_count", 0, + "warning_count", 1 + )); + changedFields = List.of(Map.of( + "field", "continued_employment_intent", + "before", "NEEDS_CONFIRMATION", + "after", "CONFIRMED", + "source", "HR_INPUT" + )); + } else if (taskNumber == 9) { + hr.put("missing_fields", List.of("employment_period", "submission_destination")); + } else if (taskNumber == 1) { + hr.put("invalidation_trigger", "DUE_DATE_CHANGED"); + changedFields = List.of(Map.of( + "field", "due_at", + "before", "D+3", + "after", "D+7", + "source", "HR_INPUT" + )); + } + + return new ApprovalSnapshot( + Map.copyOf(ai), + Map.copyOf(hr), + changedFields, + Map.of( + "workflow_catalog", DemoOperationalSeedCatalog.WORKFLOW_CATALOG_VERSION, + "workflow_id", task.workflowId(), + "task_version", 0, + "content_revision", 0, + "worker_record", "demo-seed-v1", + "document_records", "demo-seed-v1" + ) + ); + } + + private static void history( + List seeds, + TaskSeed task, + List statuses + ) { + TaskStatus fromStatus = TaskStatus.DRAFT; + List points = transitionPoints(task, statuses); + for (TransitionPoint point : points) { + TaskStatus toStatus = point.status(); + int sequence = seeds.size() + 1; + seeds.add(new TransitionSeed( + demoUuid("94400000-0000-0000-0000-000000000", sequence), + task.taskId(), + fromStatus, + toStatus, + transitionReason(toStatus), + "demo-seed-task-transition-%03d".formatted(sequence), + point.hoursAgo() + )); + fromStatus = toStatus; + } + } + + private static int completedHoursAgo(int taskNumber, TaskSeed task) { + List points = transitionPoints(taskNumber, task); + return points.stream() + .filter(point -> point.status() == TaskStatus.READY_FOR_REVIEW) + .findFirst() + .map(point -> Math.min(task.createdDaysAgo() * 24, point.hoursAgo() + 1)) + .orElseGet(() -> Math.max(task.createdDaysAgo() * 12, 1)); + } + + private static List transitionPoints(int taskNumber, TaskSeed task) { + return transitionPoints(task, transitionPath(taskNumber)); + } + + private static List transitionPoints(TaskSeed task, List statuses) { + int transitionCount = statuses.size(); + if (transitionCount == 0) { + return List.of(); + } + int createdHoursAgo = Math.max(task.createdDaysAgo() * 24, transitionCount); + int finalHoursAgo = task.status() == TaskStatus.COMPLETED + ? 0 + : Math.max(task.createdDaysAgo() - 1, 0) * 24; + int transitionWindow = createdHoursAgo - finalHoursAgo; + List points = new ArrayList<>(transitionCount); + for (int index = 0; index < transitionCount; index++) { + points.add(new TransitionPoint( + statuses.get(index), + createdHoursAgo - ((index + 1) * transitionWindow / transitionCount) + )); + } + return List.copyOf(points); + } + + private static int hoursAgo(List points, TaskStatus status) { + return points.stream() + .filter(point -> point.status() == status) + .findFirst() + .map(TransitionPoint::hoursAgo) + .orElseThrow(() -> new IllegalStateException( + "demo approval requires a " + status + " transition" + )); + } + + private static int hoursAgoAfter( + List points, + TaskStatus status, + TaskStatus precedingStatus + ) { + boolean precedingFound = false; + for (TransitionPoint point : points) { + if (point.status() == precedingStatus) { + precedingFound = true; + } else if (precedingFound && point.status() == status) { + return point.hoursAgo(); + } + } + throw new IllegalStateException("demo approval outcome transition is missing"); + } + + private static List transitionPath(int taskNumber) { + return switch (taskNumber) { + case 1 -> List.of(TaskStatus.READY_FOR_REVIEW, TaskStatus.DRAFT); + case 2, 6, 14, 15 -> List.of(TaskStatus.READY_FOR_REVIEW); + case 3, 8, 10, 11 -> List.of( + TaskStatus.NEEDS_INFO, + TaskStatus.DRAFT, + TaskStatus.WAITING_WORKER + ); + case 4, 18, 19 -> List.of( + TaskStatus.NEEDS_INFO, + TaskStatus.WAITING_WORKER, + TaskStatus.WAITING_EXTERNAL + ); + case 5, 21, 22, 23 -> List.of( + TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, + TaskStatus.COMPLETED + ); + case 7, 12 -> List.of(); + case 9 -> List.of( + TaskStatus.READY_FOR_REVIEW, + TaskStatus.DRAFT, + TaskStatus.NEEDS_INFO + ); + case 13 -> List.of(TaskStatus.NEEDS_INFO); + case 16, 17 -> List.of(TaskStatus.READY_FOR_REVIEW, TaskStatus.APPROVED); + case 20 -> List.of( + TaskStatus.READY_FOR_REVIEW, + TaskStatus.APPROVED, + TaskStatus.WAITING_EXTERNAL, + TaskStatus.COMPLETED + ); + case 24 -> List.of(TaskStatus.CANCELLED); + default -> throw new IllegalArgumentException("unknown demo task number: " + taskNumber); + }; + } + + private static String transitionReason(TaskStatus status) { + return switch (status) { + case NEEDS_INFO -> "필수 정보 또는 체크리스트 보완 필요"; + case READY_FOR_REVIEW -> "필수 준비 항목 확인 완료"; + case APPROVED -> "관리자 검토 승인"; + case WAITING_WORKER -> "근로자 제출 자료 대기"; + case WAITING_EXTERNAL -> "외부 기관 처리 결과 대기"; + case COMPLETED -> "필수 승인과 완료 근거 확인"; + case CANCELLED -> "계약 일정 변경으로 업무 취소"; + case DRAFT -> "검토 결과를 반영하기 위해 초안으로 복귀"; + }; + } + + private static UUID demoUuid(String prefix, int number) { + return UUID.fromString(prefix + "%03d".formatted(number)); + } + + private record ChecklistTemplate(String itemCode, String label) { + } + + private record TransitionPoint(TaskStatus status, int hoursAgo) { + } + + private record ApprovalSnapshot( + Map ai, + Map hr, + List> changedFields, + Map sourceVersions + ) { + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java new file mode 100644 index 0000000..ad09664 --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java @@ -0,0 +1,69 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.domain.WorkerDocument; +import java.time.LocalDate; +import java.util.Objects; +import java.util.Optional; + +final class DemoWorkerDocumentSeeder { + + private final WorkerDocumentRepository workerDocumentRepository; + + DemoWorkerDocumentSeeder(WorkerDocumentRepository workerDocumentRepository) { + this.workerDocumentRepository = Objects.requireNonNull( + workerDocumentRepository, + "workerDocumentRepository must not be null" + ); + } + + void seed(DocumentSeed seed, DemoOperationalSeedContext context) { + LocalDate expiryDate = seed.expiryDays() == null + ? null + : context.today().plusDays(seed.expiryDays()); + Optional existing = + workerDocumentRepository.findByIdAndWorkerIdAndCompanyId( + seed.documentId(), + seed.workerId(), + context.companyId() + ); + if (existing.isPresent()) { + verifyExisting(existing.get(), seed, context); + return; + } + workerDocumentRepository.insert(new WorkerDocument( + seed.documentId(), + seed.workerId(), + context.companyId(), + seed.documentType(), + seed.submissionStatus(), + expiryDate, + seed.destination(), + seed.note(), + seed.fileId(), + context.now(), + context.now(), + 0L + )); + } + + void verifyExisting( + WorkerDocument document, + DocumentSeed seed, + DemoOperationalSeedContext context + ) { + if (!seed.documentId().equals(document.workerDocumentId()) + || !seed.workerId().equals(document.workerId()) + || !context.companyId().equals(document.companyId()) + || seed.documentType() != document.documentType() + || seed.submissionStatus() != document.submissionStatus() + || !Objects.equals(seed.destination(), document.destination()) + || !Objects.equals(seed.note(), document.note()) + || !Objects.equals(seed.fileId(), document.fileId())) { + throw new IllegalStateException( + "a reserved demo worker document id already belongs to different document data" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java new file mode 100644 index 0000000..e98f171 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedCatalog.java @@ -0,0 +1,124 @@ +package com.fowoco.server.worker.infrastructure.seed; + +import com.fowoco.server.worker.domain.WorkerStatus; +import java.util.List; +import java.util.UUID; + +final class DemoWorkerSeedCatalog { + + private static final List DEMO_WORKERS = List.of( + activeWorker("92000000-0000-0000-0000-000000000001", "리웨이", "CN", "zh-Hans", 30, 180), + activeWorker("92000000-0000-0000-0000-000000000002", "속 체아", "KH", "km", 60, 210), + activeWorker("92000000-0000-0000-0000-000000000003", "아르준 타파", "NP", "en", 90, 240), + activeWorker("92000000-0000-0000-0000-000000000004", "부디 산토소", "ID", "id", 120, 270), + activeWorker("92000000-0000-0000-0000-000000000005", "마크 레예스", "PH", "en", 180, 365), + activeWorker("92000000-0000-0000-0000-000000000006", "응웬반A", "VN", "vi", 45, 180), + activeWorker("92000000-0000-0000-0000-000000000007", "아디 수르야", "ID", "id", 365, 365), + activeWorker("92000000-0000-0000-0000-000000000008", "바트 에르덴", "MN", "mn", 21, 120), + activeWorker("92000000-0000-0000-0000-000000000009", "라니 위자야", "ID", "id", 7, 90), + activeWorker("92000000-0000-0000-0000-000000000010", "민 아웅", "MM", "en", 62, 20), + activeWorker("92000000-0000-0000-0000-000000000011", "파티마 누르", "ID", "id", 35, 150), + activeWorker("92000000-0000-0000-0000-000000000012", "트란 티 마이", "VN", "vi", 3, 45), + activeWorker("92000000-0000-0000-0000-000000000013", "쩐 꾸옥 바오", "VN", "vi", 5, 75), + onLeaveWorker("92000000-0000-0000-0000-000000000014", "아이다나 베크", "KG", "ky", 7, 100), + activeWorker("92000000-0000-0000-0000-000000000015", "찬다라 소쿤", "KH", "km", 21, 135), + activeWorker("92000000-0000-0000-0000-000000000016", "니말 페레라", "LK", "si", 35, 180), + activeWorker("92000000-0000-0000-0000-000000000017", "알리 칸", "PK", "ur", 62, 210), + activeWorker("92000000-0000-0000-0000-000000000018", "모하메드 라힘", "BD", "bn", 75, 240), + activeWorker("92000000-0000-0000-0000-000000000019", "누르 아지자", "ID", "id", 89, 270), + activeWorker("92000000-0000-0000-0000-000000000020", "아지즈 라히모프", "UZ", "uz", 91, 300), + onLeaveWorker("92000000-0000-0000-0000-000000000021", "알렉세이 이바노프", "RU", "ru", 150, 330), + activeWorker("92000000-0000-0000-0000-000000000022", "마리아 산토스", "PH", "fil", 240, 365), + activeWorker("92000000-0000-0000-0000-000000000023", "솜차이 차이야", "TH", "th", 365, 400), + activeWorker("92000000-0000-0000-0000-000000000024", "응우옌 티 란", "VN", "vi", 6, 60), + onLeaveWorker("92000000-0000-0000-0000-000000000025", "데위 사푸트리", "ID", "id", null, 240), + activeWorker("92000000-0000-0000-0000-000000000026", "조제 다 코스타", "TL", "tet", 12, 90), + activeWorker("92000000-0000-0000-0000-000000000027", "압둘 카림", "BD", "bn", 90, 180), + activeWorker("92000000-0000-0000-0000-000000000028", "비벡 타파", "NP", "en", 540, 540) + ); + + private static final List TEST_WORKERS = List.of( + activeWorker("93000000-0000-0000-0000-000000000001", "테스트 근로자 01", "TH", "th", 45, 190), + activeWorker("93000000-0000-0000-0000-000000000002", "테스트 근로자 02", "MN", "mn", 75, 220), + activeWorker("93000000-0000-0000-0000-000000000003", "테스트 근로자 03", "BD", "bn", 105, 250), + activeWorker("93000000-0000-0000-0000-000000000004", "테스트 근로자 04", "UZ", "uz", 135, 280), + activeWorker("93000000-0000-0000-0000-000000000005", "테스트 근로자 05", "LK", "si", 195, 370) + ); + + List demoWorkers() { + return DEMO_WORKERS; + } + + List testWorkers() { + return TEST_WORKERS; + } + + private static WorkerSeed activeWorker( + String workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + Integer stayExpiryDays, + int contractEndDays + ) { + return worker( + workerId, + displayName, + nationalityCode, + preferredLanguage, + WorkerStatus.ACTIVE, + stayExpiryDays, + contractEndDays + ); + } + + private static WorkerSeed onLeaveWorker( + String workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + Integer stayExpiryDays, + int contractEndDays + ) { + return worker( + workerId, + displayName, + nationalityCode, + preferredLanguage, + WorkerStatus.ON_LEAVE, + stayExpiryDays, + contractEndDays + ); + } + + private static WorkerSeed worker( + String workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + WorkerStatus workStatus, + Integer stayExpiryDays, + int contractEndDays + ) { + return new WorkerSeed( + UUID.fromString(workerId), + displayName, + nationalityCode, + preferredLanguage, + workStatus, + stayExpiryDays, + contractEndDays + ); + } + + record WorkerSeed( + UUID workerId, + String displayName, + String nationalityCode, + String preferredLanguage, + WorkerStatus workStatus, + Integer stayExpiryDays, + int contractEndDays + ) { + } +} diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java index 27b7732..0c32cff 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java @@ -5,6 +5,7 @@ import com.fowoco.server.company.domain.Company; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.infrastructure.seed.DemoWorkerSeedCatalog.WorkerSeed; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; @@ -23,25 +24,11 @@ class DemoWorkerSeedRunner implements ApplicationRunner { private static final Logger LOGGER = LoggerFactory.getLogger(DemoWorkerSeedRunner.class); - private static final List DEMO_WORKERS = List.of( - demoWorker("92000000-0000-0000-0000-000000000001", "데모 근로자 01", "VN", "vi", 30, 180), - demoWorker("92000000-0000-0000-0000-000000000002", "데모 근로자 02", "KH", "km", 60, 210), - demoWorker("92000000-0000-0000-0000-000000000003", "데모 근로자 03", "NP", "ne", 90, 240), - demoWorker("92000000-0000-0000-0000-000000000004", "데모 근로자 04", "ID", "id", 120, 270), - demoWorker("92000000-0000-0000-0000-000000000005", "데모 근로자 05", "PH", "en", 180, 365) - ); - private static final List TEST_WORKERS = List.of( - demoWorker("93000000-0000-0000-0000-000000000001", "테스트 근로자 01", "TH", "th", 45, 190), - demoWorker("93000000-0000-0000-0000-000000000002", "테스트 근로자 02", "MN", "mn", 75, 220), - demoWorker("93000000-0000-0000-0000-000000000003", "테스트 근로자 03", "BD", "bn", 105, 250), - demoWorker("93000000-0000-0000-0000-000000000004", "테스트 근로자 04", "UZ", "uz", 135, 280), - demoWorker("93000000-0000-0000-0000-000000000005", "테스트 근로자 05", "LK", "si", 195, 370) - ); - private final DemoAuthSeedProperties properties; private final CompanyRepository companyRepository; private final WorkerRepository workerRepository; private final Clock clock; + private final DemoWorkerSeedCatalog catalog; DemoWorkerSeedRunner( DemoAuthSeedProperties properties, @@ -56,6 +43,7 @@ class DemoWorkerSeedRunner implements ApplicationRunner { ); this.workerRepository = Objects.requireNonNull(workerRepository, "workerRepository must not be null"); this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.catalog = new DemoWorkerSeedCatalog(); } @Override @@ -63,14 +51,18 @@ class DemoWorkerSeedRunner implements ApplicationRunner { public void run(ApplicationArguments arguments) { Instant now = clock.instant(); LocalDate today = LocalDate.now(clock); - seedCompanyWorkers(properties.companyId(), DEMO_WORKERS, today, now); - seedCompanyWorkers(properties.testCompanyId(), TEST_WORKERS, today, now); - LOGGER.info("demo_worker_seed ready company_count={} worker_count={}", 2, 10); + seedCompanyWorkers(properties.companyId(), catalog.demoWorkers(), today, now); + seedCompanyWorkers(properties.testCompanyId(), catalog.testWorkers(), today, now); + LOGGER.info( + "demo_worker_seed ready company_count={} worker_count={}", + 2, + catalog.demoWorkers().size() + catalog.testWorkers().size() + ); } private void seedCompanyWorkers( UUID companyId, - List workers, + List workers, LocalDate today, Instant now ) { @@ -82,64 +74,44 @@ private void seedCompanyWorkers( workers.forEach(worker -> seedWorker(companyId, worker, today, now)); } - private void seedWorker(UUID companyId, DemoWorker demoWorker, LocalDate today, Instant now) { + private void seedWorker(UUID companyId, WorkerSeed workerSeed, LocalDate today, Instant now) { Optional existing = - workerRepository.findByWorkerIdAndCompanyId(demoWorker.workerId(), companyId); + workerRepository.findByWorkerIdAndCompanyId(workerSeed.workerId(), companyId); if (existing.isPresent()) { - verifyExistingWorker(existing.orElseThrow(), companyId, demoWorker); + verifyExistingWorker(existing.get(), companyId, workerSeed); return; } - workerRepository.insert(Worker.create( - demoWorker.workerId(), + Worker worker = new Worker( + workerSeed.workerId(), companyId, - demoWorker.displayName(), - demoWorker.nationalityCode(), - demoWorker.preferredLanguage(), - today.plusDays(demoWorker.stayExpiryDays()), + workerSeed.displayName(), + workerSeed.nationalityCode(), + workerSeed.preferredLanguage(), + workerSeed.workStatus(), + relativeDate(today, workerSeed.stayExpiryDays()), today.minusYears(1), - today.plusDays(demoWorker.contractEndDays()), - now - )); + today.plusDays(workerSeed.contractEndDays()), + now, + now, + 0L + ); + workerRepository.insert(worker); } - private void verifyExistingWorker(Worker worker, UUID companyId, DemoWorker demoWorker) { - if (!demoWorker.workerId().equals(worker.workerId()) + private void verifyExistingWorker(Worker worker, UUID companyId, WorkerSeed workerSeed) { + if (!workerSeed.workerId().equals(worker.workerId()) || !companyId.equals(worker.companyId()) - || !demoWorker.displayName().equals(worker.displayName()) - || !demoWorker.nationalityCode().equals(worker.nationalityCode()) - || !demoWorker.preferredLanguage().equals(worker.preferredLanguage()) - || !worker.isCurrentlyEmployed()) { + || !workerSeed.displayName().equals(worker.displayName()) + || !workerSeed.nationalityCode().equals(worker.nationalityCode()) + || !workerSeed.preferredLanguage().equals(worker.preferredLanguage()) + || workerSeed.workStatus() != worker.workStatus()) { throw new IllegalStateException( - "a reserved demo worker id already belongs to different or inactive worker data" + "a reserved demo worker id already belongs to different worker data" ); } } - private static DemoWorker demoWorker( - String workerId, - String displayName, - String nationalityCode, - String preferredLanguage, - int stayExpiryDays, - int contractEndDays - ) { - return new DemoWorker( - UUID.fromString(workerId), - displayName, - nationalityCode, - preferredLanguage, - stayExpiryDays, - contractEndDays - ); - } - - private record DemoWorker( - UUID workerId, - String displayName, - String nationalityCode, - String preferredLanguage, - int stayExpiryDays, - int contractEndDays - ) { + private LocalDate relativeDate(LocalDate today, Integer days) { + return days == null ? null : today.plusDays(days); } } diff --git a/src/main/resources/demo/files/demo-contract-renewal.pdf b/src/main/resources/demo/files/demo-contract-renewal.pdf new file mode 100644 index 0000000..5931f27 Binary files /dev/null and b/src/main/resources/demo/files/demo-contract-renewal.pdf differ diff --git a/src/main/resources/demo/files/demo-stay-extension-receipt.pdf b/src/main/resources/demo/files/demo-stay-extension-receipt.pdf new file mode 100644 index 0000000..adc9d18 Binary files /dev/null and b/src/main/resources/demo/files/demo-stay-extension-receipt.pdf differ diff --git a/src/main/resources/demo/files/demo-stay-extension-result.pdf b/src/main/resources/demo/files/demo-stay-extension-result.pdf new file mode 100644 index 0000000..e85c449 Binary files /dev/null and b/src/main/resources/demo/files/demo-stay-extension-result.pdf differ diff --git a/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java b/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java index 93c7792..15b0aa6 100644 --- a/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java +++ b/src/test/java/com/fowoco/server/auth/AuthRefreshPostgreSqlConcurrencyTest.java @@ -50,8 +50,8 @@ @Import(AuthRefreshPostgreSqlConcurrencyTest.ConcurrencyConfiguration.class) class AuthRefreshPostgreSqlConcurrencyTest { - private static final UUID COMPANY_ID = UUID.fromString("91000000-0000-0000-0000-000000000001"); - private static final UUID USER_ID = UUID.fromString("92000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_ID = UUID.fromString("ca000000-0000-0000-0000-000000000001"); + private static final UUID USER_ID = UUID.fromString("ca100000-0000-0000-0000-000000000001"); private static final String EMAIL = "postgres.refresh@example.com"; private static final String PASSWORD = "Test-password-1!"; private static final String REFRESH_TOKEN_COOKIE_NAME = "fowoco_refresh_token"; @@ -320,11 +320,19 @@ SELECT COUNT(*) } private void clearAuthenticationRows() { - jdbcTemplate.update("DELETE FROM event_consumption"); - jdbcTemplate.update("DELETE FROM event_publication"); - jdbcTemplate.update("DELETE FROM refresh_token"); - jdbcTemplate.update("DELETE FROM user_account"); - jdbcTemplate.update("DELETE FROM company"); + jdbcTemplate.update("DELETE FROM event_consumption WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update("DELETE FROM event_publication WHERE company_id = ?", COMPANY_ID); + jdbcTemplate.update( + "DELETE FROM refresh_token WHERE company_id = ? AND user_id = ?", + COMPANY_ID, + USER_ID + ); + jdbcTemplate.update( + "DELETE FROM user_account WHERE company_id = ? AND user_id = ?", + COMPANY_ID, + USER_ID + ); + jdbcTemplate.update("DELETE FROM company WHERE company_id = ?", COMPANY_ID); } private static String requiredEnvironmentVariable(String name) { diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index fe36bf1..d01a21f 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -2,33 +2,129 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.DefaultApplicationArguments; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; @ActiveProfiles("test") -@SpringBootTest(properties = { - "app.demo-seed.enabled=true", - "app.demo-seed.admin-password=Demo-password-1!" -}) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "app.demo-seed.enabled=true", + "app.demo-seed.admin-password=Demo-password-1!" + } +) +@Import(DemoAuthSeedIntegrationTest.FixedClockConfiguration.class) class DemoAuthSeedIntegrationTest { - private static final UUID COMPANY_ID = UUID.fromString("90000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_ID = + UUID.fromString("90000000-0000-0000-0000-000000000001"); private static final UUID TEST_COMPANY_ID = UUID.fromString("91000000-0000-0000-0000-000000000001"); - private static final UUID ADMIN_USER_ID = UUID.fromString("90000000-0000-0000-0000-000000000002"); + private static final UUID ADMIN_USER_ID = + UUID.fromString("90000000-0000-0000-0000-000000000002"); + private static final UUID TIMELINE_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000002"); + private static final UUID TEST_TASK_ID = + UUID.fromString("97000000-0000-0000-0000-000000000001"); + private static final UUID REPRESENTATIVE_WORKER_ID = + UUID.fromString("92000000-0000-0000-0000-000000000006"); + private static final UUID RECONTRACT_CANDIDATE_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000006"); + private static final UUID EMPLOYMENT_EXTENSION_CANDIDATE_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000007"); + private static final UUID PASSPORT_REQUEST_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000008"); + private static final UUID COMPOUND_CASE_ID = + UUID.fromString("94100000-0000-0000-0000-000000000006"); + private static final UUID REPRESENTATIVE_APPROVAL_ID = + UUID.fromString("94300000-0000-0000-0000-000000000002"); + private static final UUID COMPLETED_STAY_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000020"); + private static final UUID CONTRACT_TASK_ID = + UUID.fromString("94000000-0000-0000-0000-000000000005"); + private static final UUID CONTRACT_WORKER_ID = + UUID.fromString("92000000-0000-0000-0000-000000000005"); + private static final UUID COMPLETED_STAY_WORKER_ID = + UUID.fromString("92000000-0000-0000-0000-000000000018"); + private static final UUID PASSPORT_REQUEST_DRAFT_ID = + UUID.fromString("94700000-0000-0000-0000-000000000002"); + private static final UUID CONTRACT_FILE_ID = + UUID.fromString("94800000-0000-0000-0000-000000000001"); + private static final UUID STAY_RECEIPT_FILE_ID = + UUID.fromString("94800000-0000-0000-0000-000000000002"); + private static final UUID STAY_RESULT_FILE_ID = + UUID.fromString("94800000-0000-0000-0000-000000000003"); + private static final Path DEMO_FILE_STORAGE_PATH = Path.of( + System.getProperty("java.io.tmpdir"), + "fowoco-demo-seed-" + UUID.randomUUID() + ); + private static final String PASSWORD = "Demo-password-1!"; + private static final String DEMO_ADMIN_EMAIL = "demo.admin@example.com"; + private static final String TEST_ADMIN_EMAIL = "test.admin@example.com"; + private static final Instant TEST_NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS); + + private static final Map EXPECTED_DEMO_COUNTS = Map.ofEntries( + Map.entry("user_account", 20), + Map.entry("worker", 28), + Map.entry("task", 24), + Map.entry("worker_document", 84), + Map.entry("stored_file", 3), + Map.entry("task_checklist_item", 68), + Map.entry("approval_request", 13), + Map.entry("task_transition_history", 52), + Map.entry("external_submission", 6), + Map.entry("task_evidence", 10), + Map.entry("document_request_draft", 5), + Map.entry("audit_event", 96) + ); + private static final Map EXPECTED_TEST_COUNTS = Map.of( + "user_account", 3, + "worker", 5, + "task", 3, + "worker_document", 8, + "audit_event", 8 + ); + + @LocalServerPort + private int port; @Autowired private JdbcTemplate jdbcTemplate; @@ -39,15 +135,75 @@ class DemoAuthSeedIntegrationTest { @Autowired private Clock clock; + @Autowired + private MutableClock mutableClock; + + @Autowired + @Qualifier("demoAuthSeedRunner") + private ApplicationRunner demoAuthSeedRunner; + + @Autowired + @Qualifier("demoWorkerSeedRunner") + private ApplicationRunner demoWorkerSeedRunner; + @Autowired @Qualifier("demoOperationalSeedRunner") private ApplicationRunner demoOperationalSeedRunner; + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @DynamicPropertySource + static void demoFileStorageProperties(DynamicPropertyRegistry registry) { + registry.add("app.file-storage.local-path", () -> DEMO_FILE_STORAGE_PATH.toString()); + } + @Test - void enabledSeedCreatesIdempotentCompanyAccountWorkerAndOperationalDataAfterFlyway() - throws Exception { - demoOperationalSeedRunner.run(new DefaultApplicationArguments(new String[0])); + void enabledSeedIsCompleteIdempotentTenantIsolatedAndReadableThroughApis() throws Exception { + assertAccountsAndPasswords(); + assertExactCountsAndDistributions(); + assertRelativeDatesAndSafeData(); + assertCompoundDraftScenario(); + assertReviewApprovalAndSubmissionFixtures(); + assertStoredFileFixtures(); + assertTaskTimelineInvariants(); + assertRepairsPartialStoredFileState(); + + Map demoCountsBeforeRerun = counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet()); + Map testCountsBeforeRerun = counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet()); + Map>> initialSnapshot = seedSnapshot(); + + DefaultApplicationArguments arguments = new DefaultApplicationArguments(new String[0]); + demoAuthSeedRunner.run(arguments); + demoWorkerSeedRunner.run(arguments); + demoOperationalSeedRunner.run(arguments); + assertThat(counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet())) + .isEqualTo(demoCountsBeforeRerun) + .isEqualTo(EXPECTED_DEMO_COUNTS); + assertThat(counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet())) + .isEqualTo(testCountsBeforeRerun) + .isEqualTo(EXPECTED_TEST_COUNTS); + assertThat(seedSnapshot()).isEqualTo(initialSnapshot); + + assertFrontendRelevantApiResultsAndTenantIsolation(); + + mutableClock.advance(Duration.ofDays(1)); + demoAuthSeedRunner.run(arguments); + demoWorkerSeedRunner.run(arguments); + demoOperationalSeedRunner.run(arguments); + + assertThat(counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet())) + .isEqualTo(demoCountsBeforeRerun) + .isEqualTo(EXPECTED_DEMO_COUNTS); + assertThat(counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet())) + .isEqualTo(testCountsBeforeRerun) + .isEqualTo(EXPECTED_TEST_COUNTS); + assertThat(seedSnapshot()).isEqualTo(initialSnapshot); + assertTaskTimelineInvariants(); + assertStoredFileFixtures(); + } + + private void assertAccountsAndPasswords() { String passwordHash = jdbcTemplate.queryForObject( "SELECT password_hash FROM user_account WHERE user_id = ? AND company_id = ?", String.class, @@ -55,148 +211,877 @@ void enabledSeedCreatesIdempotentCompanyAccountWorkerAndOperationalDataAfterFlyw COMPANY_ID ); - assertThat(passwordHash).isNotEqualTo("Demo-password-1!"); - assertThat(passwordEncoder.matches("Demo-password-1!", passwordHash)).isTrue(); + assertThat(passwordHash).isNotEqualTo(PASSWORD); + assertThat(passwordEncoder.matches(PASSWORD, passwordHash)).isTrue(); + assertThat(count("company", COMPANY_ID)).isEqualTo(1); + assertThat(count("company", TEST_COMPANY_ID)).isEqualTo(1); + assertThat(distribution("user_account", "role", COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( + Map.of("ADMIN", 2, "HR", 12, "VIEWER", 6) + ); + assertThat(distribution("user_account", "role", TEST_COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( + Map.of("ADMIN", 1, "HR", 1, "VIEWER", 1) + ); + } + + private void assertExactCountsAndDistributions() { + assertThat(counts(COMPANY_ID, EXPECTED_DEMO_COUNTS.keySet())) + .containsExactlyInAnyOrderEntriesOf(EXPECTED_DEMO_COUNTS); + assertThat(counts(TEST_COMPANY_ID, EXPECTED_TEST_COUNTS.keySet())) + .containsExactlyInAnyOrderEntriesOf(EXPECTED_TEST_COUNTS); + + assertThat(distribution("task", "task_type", COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( + Map.of("STAY_PERIOD_EXTENSION", 10, "RECONTRACT", 8, "EMPLOYMENT_PERIOD_EXTENSION", 6) + ); + assertThat(distribution("task", "status", COMPANY_ID)).containsExactlyInAnyOrderEntriesOf( + Map.of( + "DRAFT", 3, + "NEEDS_INFO", 2, + "READY_FOR_REVIEW", 4, + "APPROVED", 2, + "WAITING_WORKER", 4, + "WAITING_EXTERNAL", 3, + "COMPLETED", 5, + "CANCELLED", 1 + ) + ); + assertThat(distribution("worker_document", "document_type", COMPANY_ID)) + .containsExactlyInAnyOrderEntriesOf( + Map.of("PASSPORT_COPY", 26, "ARC", 28, "CONTRACT", 22, "PERMIT", 8) + ); + assertThat(distribution("worker_document", "submission_status", COMPANY_ID)) + .containsExactlyInAnyOrderEntriesOf( + Map.of("VERIFIED", 48, "SUBMITTED", 20, "MISSING", 16) + ); + assertThat(distribution("approval_request", "status", COMPANY_ID)) + .containsExactlyInAnyOrderEntriesOf( + Map.of("PENDING", 4, "APPROVED", 7, "REJECTED", 1, "INVALIDATED", 1) + ); + assertThat(distribution("audit_event", "actor_type", COMPANY_ID)) + .containsExactlyInAnyOrderEntriesOf( + Map.of("HR_USER", 79, "AI_AGENT", 7, "SYSTEM_RULE", 6, "WORKER_LINK", 4) + ); + assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM company WHERE company_id = ? AND status = 'ACTIVE'", + """ + SELECT COUNT(*) + FROM task_checklist_item checklist + JOIN task seeded_task ON seeded_task.task_id = checklist.task_id + WHERE checklist.company_id = ? AND seeded_task.company_id = ? + """, Integer.class, + COMPANY_ID, COMPANY_ID - )).isEqualTo(1); + )).isEqualTo(68); assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM company WHERE company_id = ? " - + "AND name = 'FOWOCO Test Company' AND status = 'ACTIVE'", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(1); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE user_id = ? AND display_name = '데모 관리자' " - + "AND role = 'ADMIN' AND status = 'ACTIVE'", - Integer.class, - ADMIN_USER_ID - )).isEqualTo(1); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ?", + "SELECT COUNT(DISTINCT task_id) FROM task_checklist_item WHERE company_id = ?", Integer.class, COMPANY_ID - )).isEqualTo(20); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'ADMIN'", - Integer.class, + )).isEqualTo(24); + } + + private void assertRelativeDatesAndSafeData() { + LocalDate today = LocalDate.now(clock); + assertThat(countWhere("task", "due_date = ?", COMPANY_ID, today)).isGreaterThanOrEqualTo(1); + assertThat(countWhere( + "task", + "due_date >= ? AND due_date <= ?", + COMPANY_ID, + today, + today.plusDays(7) + )).isGreaterThan(1); + List completedUpdates = jdbcTemplate.query( + "SELECT updated_at FROM task WHERE company_id = ? AND status = 'COMPLETED'", + (resultSet, rowNumber) -> resultSet.getTimestamp("updated_at").toInstant(), COMPANY_ID - )).isEqualTo(2); + ); + assertThat(completedUpdates) + .hasSize(5) + .allMatch(updatedAt -> LocalDate.ofInstant(updatedAt, ZoneOffset.UTC).equals(today)); assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'HR'", - Integer.class, + "SELECT stay_expiry_date FROM worker WHERE worker_id = ? AND company_id = ?", + LocalDate.class, + REPRESENTATIVE_WORKER_ID, COMPANY_ID - )).isEqualTo(12); + )).isEqualTo(today.plusDays(45)); + + assertThat(countWhere( + "audit_event", + "created_at >= ? AND created_at <= ?", + COMPANY_ID, + clock.instant().minus(7, ChronoUnit.DAYS), + clock.instant() + )).isPositive(); + assertThat(countWhere( + "audit_event", + "created_at < ?", + COMPANY_ID, + clock.instant().minus(30, ChronoUnit.DAYS) + )).isPositive(); + + assertThat(countWhere("worker_document", "expiry_date < ?", COMPANY_ID, today)).isPositive(); + assertThat(countWhere( + "worker_document", + "expiry_date >= ? AND expiry_date <= ?", + COMPANY_ID, + today, + today.plusDays(30) + )).isPositive(); + assertThat(countWhere( + "worker_document", + "expiry_date > ? AND expiry_date <= ?", + COMPANY_ID, + today.plusDays(30), + today.plusDays(90) + )).isPositive(); + assertThat(countWhere( + "worker_document", + "expiry_date > ?", + COMPANY_ID, + today.plusDays(90) + )).isPositive(); + assertThat(countWhere("worker_document", "expiry_date IS NULL", COMPANY_ID)).isPositive(); + assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'VIEWER'", + "SELECT COUNT(*) FROM stored_file WHERE company_id = ? AND size > 0", Integer.class, COMPANY_ID - )).isEqualTo(6); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ?", - Integer.class, - TEST_COMPANY_ID )).isEqualTo(3); + List businessData = jdbcTemplate.query( + "SELECT business_data_json FROM task WHERE company_id = ?", + (resultSet, rowNumber) -> resultSet.getString(1).toLowerCase(), + COMPANY_ID + ); + assertThat(businessData).allMatch(json -> List.of( + "passport_number", + "arc_number", + "phone_number", + "bank_account", + "password", + "access_token" + ).stream().noneMatch(json::contains)); + } + + private void 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", + 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); + }); + + 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, submission_status, expiry_date, destination, note " + + "FROM worker_document WHERE worker_id = ? AND company_id = ?", + resultSet -> { + Map> result = new LinkedHashMap<>(); + while (resultSet.next()) { + Map document = new LinkedHashMap<>(); + document.put("submission_status", resultSet.getString("submission_status")); + document.put("expiry_date", resultSet.getObject("expiry_date", LocalDate.class)); + document.put("destination", resultSet.getString("destination")); + document.put("note", resultSet.getString("note")); + result.put(resultSet.getString("document_type"), document); + } + return result; + }, + REPRESENTATIVE_WORKER_ID, + COMPANY_ID + ); + assertThat(documentsByType).containsOnlyKeys("PASSPORT_COPY", "ARC", "CONTRACT"); + assertThat(documentsByType.get("PASSPORT_COPY")) + .containsEntry("submission_status", "MISSING") + .containsEntry("destination", "근로자 문서 요청"); + assertThat(documentsByType.get("PASSPORT_COPY").get("expiry_date")).isNull(); + assertThat(documentsByType.get("ARC")) + .containsEntry("submission_status", "VERIFIED"); + assertThat(documentsByType.get("CONTRACT")) + .containsEntry("submission_status", "VERIFIED"); + } + + private void assertReviewApprovalAndSubmissionFixtures() { + Map approval = jdbcTemplate.queryForMap( + "SELECT status, ai_snapshot_json, hr_snapshot_json, changed_fields_json, " + + "source_versions_json FROM approval_request " + + "WHERE approval_request_id = ? AND company_id = ?", + REPRESENTATIVE_APPROVAL_ID, + COMPANY_ID + ); + String aiSnapshot = (String) approval.get("ai_snapshot_json"); + String hrSnapshot = (String) approval.get("hr_snapshot_json"); + String changedFields = (String) approval.get("changed_fields_json"); + String sourceVersions = (String) approval.get("source_versions_json"); + assertThat(approval.get("status")).isEqualTo("PENDING"); + assertThat(JsonPath.read(aiSnapshot, "$.summary")).isEqualTo("재계약 조건 확인"); + assertThat(JsonPath.read(aiSnapshot, "$.input_summary.required_count")).isEqualTo(9); + assertThat(JsonPath.read(hrSnapshot, "$.validation_summary.warning_count")).isEqualTo(1); + assertThat(JsonPath.read(hrSnapshot, "$.warning_fields[0]")) + .isEqualTo("passport_expiry_date"); + assertThat(JsonPath.read(changedFields, "$[0].field")) + .isEqualTo("continued_employment_intent"); + assertThat(JsonPath.read(sourceVersions, "$.workflow_catalog")).isEqualTo("0.2.0"); + + Map requestDraft = jdbcTemplate.queryForMap( + "SELECT language, message, review_status FROM document_request_draft " + + "WHERE draft_id = ? AND company_id = ?", + PASSPORT_REQUEST_DRAFT_ID, + COMPANY_ID + ); + assertThat(requestDraft) + .containsEntry("language", "vi") + .containsEntry("review_status", "DRAFT"); + assertThat((String) requestDraft.get("message")).contains("7 ngày"); + assertThat(jdbcTemplate.queryForList( + "SELECT document_type FROM document_request_draft_type WHERE draft_id = ?", + String.class, + PASSPORT_REQUEST_DRAFT_ID + )).containsExactly("PASSPORT_COPY"); + assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'ADMIN'", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(1); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'HR'", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(1); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM user_account WHERE company_id = ? AND role = 'VIEWER'", + "SELECT COUNT(*) FROM approval_request WHERE task_id = ? " + + "AND company_id = ? AND status = 'APPROVED'", Integer.class, - TEST_COMPANY_ID + COMPLETED_STAY_TASK_ID, + COMPANY_ID )).isEqualTo(1); assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker WHERE company_id = ?", - Integer.class, + "SELECT safe_reference FROM external_submission WHERE task_id = ? AND company_id = ?", + String.class, + COMPLETED_STAY_TASK_ID, COMPANY_ID - )).isEqualTo(5); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker WHERE company_id = ?", - Integer.class, - TEST_COMPANY_ID - )).isEqualTo(5); + )).isEqualTo("DEMO-STAY-EXT-001"); + assertThat(jdbcTemplate.queryForList( + "SELECT evidence_type FROM task_evidence WHERE task_id = ? AND company_id = ? " + + "ORDER BY evidence_type", + String.class, + COMPLETED_STAY_TASK_ID, + COMPANY_ID + )).containsExactly("OFFICIAL_RESULT", "RECEIPT"); assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM task WHERE company_id = ?", + "SELECT COUNT(*) FROM task_evidence WHERE company_id = ? AND file_reference IS NOT NULL", Integer.class, COMPANY_ID - )).isEqualTo(5); + )).isEqualTo(2); assertThat(jdbcTemplate.queryForList( - "SELECT status FROM task WHERE company_id = ?", + "SELECT file_reference FROM task_evidence WHERE task_id = ? " + + "AND company_id = ? AND file_reference IS NOT NULL ORDER BY evidence_type", String.class, + COMPLETED_STAY_TASK_ID, + COMPANY_ID + )).containsExactly(STAY_RESULT_FILE_ID.toString(), STAY_RECEIPT_FILE_ID.toString()); + + List> aiTrace = jdbcTemplate.queryForList( + "SELECT actor_type, action, target_id, change_summary FROM audit_event " + + "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 { + List> files = jdbcTemplate.queryForList( + "SELECT stored_file_id, name, mime_type, size, purpose, task_id, worker_id, " + + "storage_key, scan_status, verified FROM stored_file " + + "WHERE company_id = ? ORDER BY stored_file_id", + COMPANY_ID + ); + assertThat(files).hasSize(3) + .allMatch(file -> "application/pdf".equals(file.get("mime_type"))) + .allMatch(file -> "NOT_SCANNED".equals(file.get("scan_status"))) + .allMatch(file -> Boolean.FALSE.equals(file.get("verified"))); + + Map> filesById = files.stream().collect(Collectors.toMap( + file -> (UUID) file.get("stored_file_id"), + file -> file + )); + assertThat(filesById.get(CONTRACT_FILE_ID)) + .containsEntry("name", "demo-contract-renewal.pdf") + .containsEntry("purpose", "DEMO_CONTRACT_RENEWAL") + .containsEntry("task_id", CONTRACT_TASK_ID) + .containsEntry("worker_id", CONTRACT_WORKER_ID) + .containsEntry("storage_key", CONTRACT_FILE_ID.toString()); + assertThat(filesById.get(STAY_RECEIPT_FILE_ID)) + .containsEntry("name", "demo-stay-extension-receipt.pdf") + .containsEntry("purpose", "DEMO_STAY_EXTENSION_RECEIPT") + .containsEntry("task_id", COMPLETED_STAY_TASK_ID) + .containsEntry("worker_id", COMPLETED_STAY_WORKER_ID) + .containsEntry("storage_key", STAY_RECEIPT_FILE_ID.toString()); + assertThat(filesById.get(STAY_RESULT_FILE_ID)) + .containsEntry("name", "demo-stay-extension-result.pdf") + .containsEntry("purpose", "DEMO_STAY_EXTENSION_RESULT") + .containsEntry("task_id", COMPLETED_STAY_TASK_ID) + .containsEntry("worker_id", COMPLETED_STAY_WORKER_ID) + .containsEntry("storage_key", STAY_RESULT_FILE_ID.toString()); + + Map resources = Map.of( + CONTRACT_FILE_ID, "demo/files/demo-contract-renewal.pdf", + STAY_RECEIPT_FILE_ID, "demo/files/demo-stay-extension-receipt.pdf", + STAY_RESULT_FILE_ID, "demo/files/demo-stay-extension-result.pdf" + ); + for (Map file : files) { + UUID fileId = (UUID) file.get("stored_file_id"); + byte[] expected; + try (var input = new ClassPathResource(resources.get(fileId)).getInputStream()) { + expected = input.readAllBytes(); + } + Path actualPath = DEMO_FILE_STORAGE_PATH.resolve((String) file.get("storage_key")); + assertThat(Files.readAllBytes(actualPath)).isEqualTo(expected); + assertThat(((Number) file.get("size")).longValue()).isEqualTo(expected.length); + } + + assertThat(jdbcTemplate.queryForObject( + "SELECT file_id FROM worker_document WHERE worker_document_id = ? AND company_id = ?", + UUID.class, + UUID.fromString("95000000-0000-0000-0000-000000000007"), + COMPANY_ID + )).isEqualTo(CONTRACT_FILE_ID); + } + + private void assertRepairsPartialStoredFileState() throws Exception { + DefaultApplicationArguments arguments = new DefaultApplicationArguments(new String[0]); + + Files.delete(DEMO_FILE_STORAGE_PATH.resolve(STAY_RESULT_FILE_ID.toString())); + demoOperationalSeedRunner.run(arguments); + assertThat(Files.isRegularFile( + DEMO_FILE_STORAGE_PATH.resolve(STAY_RESULT_FILE_ID.toString()) + )).isTrue(); + + jdbcTemplate.update( + "DELETE FROM stored_file WHERE stored_file_id = ? AND company_id = ?", + STAY_RECEIPT_FILE_ID, COMPANY_ID - )).containsExactlyInAnyOrder( - "DRAFT", - "READY_FOR_REVIEW", - "WAITING_WORKER", - "WAITING_EXTERNAL", - "COMPLETED" ); + demoOperationalSeedRunner.run(arguments); assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM task WHERE company_id = ? AND source = 'AI_CANDIDATE' " - + "AND status = 'DRAFT'", + "SELECT COUNT(*) FROM stored_file WHERE stored_file_id = ? AND company_id = ?", Integer.class, + STAY_RECEIPT_FILE_ID, COMPANY_ID )).isEqualTo(1); - List completedUpdates = jdbcTemplate.query( - "SELECT updated_at FROM task WHERE company_id = ? AND status = 'COMPLETED'", - (resultSet, rowNumber) -> resultSet.getTimestamp("updated_at").toInstant(), + + assertStoredFileFixtures(); + } + + private void assertTaskTimelineInvariants() { + Map tasks = jdbcTemplate.query( + "SELECT task_id, status, created_at FROM task WHERE company_id = ?", + resultSet -> { + Map result = new LinkedHashMap<>(); + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + result.put(taskId, new TaskTimeline( + taskId, + resultSet.getString("status"), + resultSet.getTimestamp("created_at").toInstant() + )); + } + return result; + }, COMPANY_ID ); - assertThat(completedUpdates).hasSize(1).allMatch(updatedAt -> - LocalDate.ofInstant(updatedAt, ZoneOffset.UTC).equals(LocalDate.now(clock))); - List dueDates = jdbcTemplate.query( - "SELECT due_date FROM task WHERE company_id = ?", - (resultSet, rowNumber) -> resultSet.getObject("due_date", LocalDate.class), + Map> transitionsByTask = jdbcTemplate.query( + "SELECT task_id, from_status, to_status, created_at FROM task_transition_history " + + "WHERE company_id = ? ORDER BY task_id, created_at, transition_id", + resultSet -> { + Map> result = new LinkedHashMap<>(); + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + result.computeIfAbsent(taskId, ignored -> new java.util.ArrayList<>()).add( + new TransitionTimeline( + resultSet.getString("from_status"), + resultSet.getString("to_status"), + resultSet.getTimestamp("created_at").toInstant() + ) + ); + } + return result; + }, COMPANY_ID ); - LocalDate today = LocalDate.now(clock); - assertThat(dueDates).anyMatch(date -> !date.isAfter(today.plusDays(7))); - assertThat(dueDates).anyMatch(date -> - date.isAfter(today.plusDays(7)) && !date.isAfter(today.plusDays(30))); - assertThat(dueDates).anyMatch(date -> date.isAfter(today.plusDays(30))); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker_document WHERE company_id = ?", - Integer.class, + + assertThat(tasks).hasSize(24); + tasks.values().forEach(task -> { + List transitions = transitionsByTask.getOrDefault(task.taskId(), List.of()); + String expectedFrom = "DRAFT"; + Instant previousAt = task.createdAt(); + for (int index = 0; index < transitions.size(); index++) { + TransitionTimeline transition = transitions.get(index); + assertThat(transition.fromStatus()).isEqualTo(expectedFrom); + assertThat(transition.createdAt()).isAfterOrEqualTo(previousAt); + if (isTerminal(transition.toStatus())) { + assertThat(index).isEqualTo(transitions.size() - 1); + } + expectedFrom = transition.toStatus(); + previousAt = transition.createdAt(); + } + if (!transitions.isEmpty()) { + assertThat(expectedFrom).isEqualTo(task.status()); + } + }); + + jdbcTemplate.query( + "SELECT task_id, required, completed, completed_at FROM task_checklist_item " + + "WHERE company_id = ?", + resultSet -> { + while (resultSet.next()) { + if (!resultSet.getBoolean("required") || !resultSet.getBoolean("completed")) { + continue; + } + UUID taskId = resultSet.getObject("task_id", UUID.class); + Instant readyAt = transitionAt( + transitionsByTask.getOrDefault(taskId, List.of()), + "READY_FOR_REVIEW" + ); + if (readyAt != null) { + assertThat(resultSet.getTimestamp("completed_at").toInstant()) + .isBeforeOrEqualTo(readyAt); + } + } + return null; + }, COMPANY_ID - )).isEqualTo(7); - assertThat(jdbcTemplate.queryForList( - "SELECT submission_status FROM worker_document WHERE company_id = ?", - String.class, + ); + + jdbcTemplate.query( + "SELECT task_id, status, requested_at, decided_at, invalidated_at " + + "FROM approval_request WHERE company_id = ?", + resultSet -> { + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + String status = resultSet.getString("status"); + Instant requestedAt = resultSet.getTimestamp("requested_at").toInstant(); + var decidedTimestamp = resultSet.getTimestamp("decided_at"); + var invalidatedTimestamp = resultSet.getTimestamp("invalidated_at"); + Instant outcomeAt = "INVALIDATED".equals(status) + ? invalidatedTimestamp.toInstant() + : decidedTimestamp == null ? null : decidedTimestamp.toInstant(); + List transitions = + transitionsByTask.getOrDefault(taskId, List.of()); + Instant readyAt = transitionAt(transitions, "READY_FOR_REVIEW"); + assertThat(readyAt).isNotNull(); + assertThat(requestedAt).isAfterOrEqualTo(readyAt); + if (outcomeAt != null) { + assertThat(outcomeAt).isAfterOrEqualTo(requestedAt); + } + if ("APPROVED".equals(status)) { + assertThat(transitionAt(transitions, "APPROVED")) + .isAfterOrEqualTo(outcomeAt); + } + if ("REJECTED".equals(status) || "INVALIDATED".equals(status)) { + assertThat(transitionAt(transitions, "DRAFT")) + .isAfterOrEqualTo(outcomeAt); + } + } + return null; + }, COMPANY_ID - )).contains("MISSING", "SUBMITTED", "VERIFIED"); - List expiryDates = jdbcTemplate.query( - "SELECT expiry_date FROM worker_document WHERE company_id = ?", - (resultSet, rowNumber) -> resultSet.getObject("expiry_date", LocalDate.class), + ); + + jdbcTemplate.query( + "SELECT task_id, submitted_at FROM external_submission WHERE company_id = ?", + resultSet -> { + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + Instant submittedAt = resultSet.getTimestamp("submitted_at").toInstant(); + TaskTimeline task = tasks.get(taskId); + List transitions = + transitionsByTask.getOrDefault(taskId, List.of()); + Instant approvedAt = transitionAt(transitions, "APPROVED"); + Instant completedAt = transitionAt(transitions, "COMPLETED"); + assertThat(submittedAt).isAfterOrEqualTo(task.createdAt()); + if (approvedAt != null) { + assertThat(submittedAt).isAfterOrEqualTo(approvedAt); + } + if (completedAt != null) { + assertThat(submittedAt).isBeforeOrEqualTo(completedAt); + } + } + return null; + }, COMPANY_ID ); - assertThat(expiryDates).anyMatch(date -> - !date.isBefore(today) && !date.isAfter(today.plusDays(30))); - assertThat(jdbcTemplate.queryForList( - "SELECT COUNT(*) FROM worker_document WHERE company_id = ? GROUP BY worker_id", - Integer.class, + + jdbcTemplate.query( + "SELECT task_id, recorded_at FROM task_evidence WHERE company_id = ?", + resultSet -> { + while (resultSet.next()) { + UUID taskId = resultSet.getObject("task_id", UUID.class); + Instant recordedAt = resultSet.getTimestamp("recorded_at").toInstant(); + Instant completedAt = transitionAt( + transitionsByTask.getOrDefault(taskId, List.of()), + "COMPLETED" + ); + assertThat(recordedAt).isAfterOrEqualTo(tasks.get(taskId).createdAt()); + assertThat(completedAt).isNotNull().isAfterOrEqualTo(recordedAt); + } + return null; + }, COMPANY_ID - )).hasSize(5).allMatch(count -> count >= 1 && count <= 2); - UUID timelineTaskId = UUID.fromString("94000000-0000-0000-0000-000000000002"); - assertThat(jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM audit_event WHERE company_id = ? AND target_id = ?", + ); + } + + private Map>> seedSnapshot() { + Map>> snapshot = new LinkedHashMap<>(); + snapshot.put("worker", snapshotRows( + "SELECT worker_id, company_id, display_name, nationality_code, preferred_language, " + + "work_status, stay_expiry_date, contract_start_date, contract_end_date, " + + "created_at, updated_at, version FROM worker " + + "WHERE company_id IN (?, ?) ORDER BY company_id, worker_id" + )); + snapshot.put("worker_document", snapshotRows( + "SELECT worker_document_id, company_id, expiry_date, file_id, " + + "created_at, updated_at, version " + + "FROM worker_document WHERE company_id IN (?, ?) " + + "ORDER BY company_id, worker_document_id" + )); + snapshot.put("stored_file", snapshotRows( + "SELECT stored_file_id, company_id, name, mime_type, size, purpose, task_id, " + + "worker_id, storage_key, scan_status, verified, created_at " + + "FROM stored_file WHERE company_id IN (?, ?) " + + "ORDER BY company_id, stored_file_id" + )); + snapshot.put("task", snapshotRows( + "SELECT task_id, company_id, due_date, business_data_json, critical_fingerprint, " + + "content_revision, created_at, updated_at, version FROM task " + + "WHERE company_id IN (?, ?) ORDER BY company_id, task_id" + )); + snapshot.put("task_checklist_item", snapshotRows( + "SELECT checklist_item_id, company_id, completed_at, created_at, updated_at, version " + + "FROM task_checklist_item WHERE company_id IN (?, ?) " + + "ORDER BY company_id, checklist_item_id" + )); + snapshot.put("approval_request", snapshotRows( + "SELECT approval_request_id, company_id, target_task_version, target_content_revision, " + + "approved_task_version, target_fingerprint, status, ai_snapshot_json, " + + "hr_snapshot_json, changed_fields_json, source_versions_json, requested_at, " + + "decided_at, decision_reason, invalidated_at, invalidation_reason, " + + "created_at, updated_at, version FROM approval_request " + + "WHERE company_id IN (?, ?) ORDER BY company_id, approval_request_id" + )); + snapshot.put("task_transition_history", snapshotRows( + "SELECT transition_id, company_id, created_at FROM task_transition_history " + + "WHERE company_id IN (?, ?) ORDER BY company_id, transition_id" + )); + snapshot.put("external_submission", snapshotRows( + "SELECT external_submission_id, company_id, destination, safe_reference, " + + "submitted_at, created_at " + + "FROM external_submission WHERE company_id IN (?, ?) " + + "ORDER BY company_id, external_submission_id" + )); + snapshot.put("task_evidence", snapshotRows( + "SELECT evidence_id, company_id, evidence_type, file_reference, note, " + + "recorded_at, created_at FROM task_evidence " + + "WHERE company_id IN (?, ?) ORDER BY company_id, evidence_id" + )); + snapshot.put("document_request_draft", snapshotRows( + "SELECT draft_id, company_id, language, message, review_status, " + + "created_at, updated_at, version " + + "FROM document_request_draft WHERE company_id IN (?, ?) " + + "ORDER BY company_id, draft_id" + )); + snapshot.put("document_request_draft_type", snapshotRows( + "SELECT draft_type.draft_id, draft.company_id, draft_type.document_type " + + "FROM document_request_draft_type draft_type " + + "JOIN document_request_draft draft ON draft.draft_id = draft_type.draft_id " + + "WHERE draft.company_id IN (?, ?) " + + "ORDER BY draft.company_id, draft_type.draft_id, draft_type.document_type" + )); + snapshot.put("audit_event", snapshotRows( + "SELECT audit_event_id, company_id, actor_type, action, target_type, target_id, " + + "trace_id, change_summary, created_at FROM audit_event " + + "WHERE company_id IN (?, ?) ORDER BY company_id, audit_event_id" + )); + return snapshot; + } + + private List> snapshotRows(String sql) { + return jdbcTemplate.queryForList(sql, COMPANY_ID, TEST_COMPANY_ID); + } + + private Instant transitionAt(List transitions, String toStatus) { + return transitions.stream() + .filter(transition -> toStatus.equals(transition.toStatus())) + .findFirst() + .map(TransitionTimeline::createdAt) + .orElse(null); + } + + private boolean isTerminal(String status) { + return "COMPLETED".equals(status) || "CANCELLED".equals(status); + } + + private void assertFrontendRelevantApiResultsAndTenantIsolation() throws Exception { + String demoToken = loginAndGetAccessToken(DEMO_ADMIN_EMAIL); + String testToken = loginAndGetAccessToken(TEST_ADMIN_EMAIL); + LocalDate today = LocalDate.now(clock); + + HttpResponse workers = authorizedGet("/api/v1/workers?size=100", demoToken); + assertOk(workers); + assertThat(JsonPath.read(workers.body(), "$.total_elements").intValue()).isEqualTo(28); + assertThat(JsonPath.>read(workers.body(), "$.items")).hasSize(28); + + HttpResponse tasks = authorizedGet("/api/v1/tasks?size=100", demoToken); + assertOk(tasks); + assertThat(JsonPath.read(tasks.body(), "$.total_elements").intValue()).isEqualTo(24); + List> taskItems = JsonPath.read(tasks.body(), "$.items"); + assertThat(taskItems).hasSize(24); + assertStatusApiCount(demoToken, "READY_FOR_REVIEW", 4); + assertStatusApiCount(demoToken, "DRAFT", 3); + assertThat(taskItems).anyMatch(task -> today.toString().equals(task.get("due_date"))); + + HttpResponse dueSoon = authorizedGet( + "/api/v1/tasks?dueTo=" + today.plusDays(7) + "&size=100", + demoToken + ); + assertOk(dueSoon); + assertThat(JsonPath.read(dueSoon.body(), "$.total_elements").intValue()).isGreaterThan(1); + assertThat(taskItems.stream() + .filter(task -> "COMPLETED".equals(task.get("status"))) + .map(task -> (String) task.get("updated_at")) + .toList()) + .hasSize(5) + .allMatch(updatedAt -> updatedAt.startsWith(today.toString())); + assertWorkerDerivedCategories(taskItems); + + HttpResponse documents = authorizedGet("/api/v1/documents?size=100", demoToken); + assertOk(documents); + assertThat(JsonPath.read(documents.body(), "$.total_elements").intValue()).isEqualTo(84); + assertDocumentStatusHasData(demoToken, "SUBMITTED"); + assertDocumentStatusHasData(demoToken, "MISSING"); + assertDocumentStatusHasData(demoToken, "VERIFIED"); + HttpResponse expiringDocuments = authorizedGet( + "/api/v1/documents?expiryBefore=" + today.plusDays(30) + "&size=100", + demoToken + ); + assertOk(expiringDocuments); + assertThat(JsonPath.read(expiringDocuments.body(), "$.total_elements").intValue()).isPositive(); + + HttpResponse detail = authorizedGet("/api/v1/tasks/" + TIMELINE_TASK_ID, demoToken); + assertOk(detail); + assertThat(JsonPath.>read(detail.body(), "$.checklist_items")).isNotEmpty(); + HttpResponse activities = authorizedGet( + "/api/v1/tasks/" + TIMELINE_TASK_ID + "/activities", + demoToken + ); + assertOk(activities); + assertThat(JsonPath.>read(activities.body(), "$" )).hasSize(3); + + HttpResponse demoAudits = authorizedGet("/api/v1/audit-events?limit=100", demoToken); + HttpResponse testAudits = authorizedGet("/api/v1/audit-events?limit=100", testToken); + assertOk(demoAudits); + assertOk(testAudits); + assertThat(JsonPath.>read(demoAudits.body(), "$.items")).hasSize(96); + assertThat(JsonPath.>read(testAudits.body(), "$.items")).hasSize(8); + + assertTenantApiCount(testToken, "/api/v1/workers?size=100", 5); + assertTenantApiCount(testToken, "/api/v1/tasks?size=100", 3); + assertTenantApiCount(testToken, "/api/v1/documents?size=100", 8); + assertThat(authorizedGet("/api/v1/tasks/" + TEST_TASK_ID, demoToken).statusCode()).isEqualTo(404); + assertThat(authorizedGet("/api/v1/tasks/" + TIMELINE_TASK_ID, testToken).statusCode()).isEqualTo(404); + } + + private void assertWorkerDerivedCategories(List> taskItems) { + Set reviewStatuses = Set.of("READY_FOR_REVIEW", "WAITING_WORKER", "WAITING_EXTERNAL"); + Set aiStatuses = Set.of("DRAFT", "NEEDS_INFO"); + Map> statusesByWorker = taskItems.stream().collect(Collectors.groupingBy( + task -> (String) task.get("worker_id"), + Collectors.mapping(task -> (String) task.get("status"), Collectors.toSet()) + )); + Predicate> needsReview = statuses -> statuses.stream().anyMatch(reviewStatuses::contains); + Predicate> aiSuggested = statuses -> !needsReview.test(statuses) + && statuses.stream().anyMatch(aiStatuses::contains); + + assertThat(statusesByWorker.values()).anyMatch(needsReview); + assertThat(statusesByWorker.values()).anyMatch(aiSuggested); + assertThat(statusesByWorker.values()).anyMatch(statuses -> + !needsReview.test(statuses) && !aiSuggested.test(statuses)); + } + + private void assertStatusApiCount(String token, String status, int expected) throws Exception { + HttpResponse response = authorizedGet( + "/api/v1/tasks?status=" + status + "&size=100", + token + ); + assertOk(response); + assertThat(JsonPath.read(response.body(), "$.total_elements").intValue()) + .isEqualTo(expected); + } + + private void assertDocumentStatusHasData(String token, String status) throws Exception { + HttpResponse response = authorizedGet( + "/api/v1/documents?status=" + status + "&size=100", + token + ); + assertOk(response); + assertThat(JsonPath.read(response.body(), "$.total_elements").intValue()).isPositive(); + } + + private void assertTenantApiCount(String token, String path, int expected) throws Exception { + HttpResponse response = authorizedGet(path, token); + assertOk(response); + assertThat(JsonPath.read(response.body(), "$.total_elements").intValue()) + .isEqualTo(expected); + } + + private String loginAndGetAccessToken(String email) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/auth/login")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + "{\"email\":\"%s\",\"password\":\"%s\"}".formatted(email, PASSWORD) + )) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + assertOk(response); + return JsonPath.read(response.body(), "$.access_token"); + } + + private HttpResponse authorizedGet(String path, String token) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private void assertOk(HttpResponse response) { + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } + + private Map counts(UUID companyId, Set tables) { + Map result = new LinkedHashMap<>(); + tables.forEach(table -> result.put(table, count(table, companyId))); + return result; + } + + private int count(String table, UUID companyId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table + " WHERE company_id = ?", Integer.class, - COMPANY_ID, - timelineTaskId - )).isEqualTo(3); - assertThat(jdbcTemplate.queryForList( - "SELECT action FROM audit_event WHERE company_id = ? AND target_id = ?", - String.class, - COMPANY_ID, - timelineTaskId - )).containsExactlyInAnyOrder("TASK_CREATED", "TASK_UPDATED", "APPROVAL_REQUESTED"); + companyId + ); + } + + private int countWhere(String table, String predicate, UUID companyId, Object... parameters) { + Object[] arguments = new Object[parameters.length + 1]; + arguments[0] = companyId; + System.arraycopy(parameters, 0, arguments, 1, parameters.length); + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table + " WHERE company_id = ? AND " + predicate, + Integer.class, + arguments + ); + } + + private Map distribution(String table, String column, UUID companyId) { + List> rows = jdbcTemplate.queryForList( + "SELECT " + column + " AS item_key, COUNT(*) AS item_count FROM " + table + + " WHERE company_id = ? GROUP BY " + column, + companyId + ); + Map result = new LinkedHashMap<>(); + rows.forEach(row -> result.put( + row.get("item_key").toString(), + ((Number) row.get("item_count")).intValue() + )); + return result; + } + + private record TaskTimeline(UUID taskId, String status, Instant createdAt) { + } + + private record TransitionTimeline(String fromStatus, String toStatus, Instant createdAt) { + } + + @TestConfiguration(proxyBeanMethods = false) + static class FixedClockConfiguration { + + @Bean + @Primary + MutableClock fixedClock() { + return new MutableClock(TEST_NOW, ZoneOffset.UTC); + } + } + + static final class MutableClock extends Clock { + + private final AtomicReference instant; + private final ZoneId zone; + + private MutableClock(Instant instant, ZoneId zone) { + this.instant = new AtomicReference<>(instant); + this.zone = zone; + } + + void advance(Duration duration) { + instant.updateAndGet(value -> value.plus(duration)); + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId zone) { + return new MutableClock(instant(), zone); + } + + @Override + public Instant instant() { + return instant.get(); + } } } diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index bb5f2ce..3c17f3a 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -123,6 +123,58 @@ void existingAdminDoesNotHideAnInactiveDemoCompany() throws Exception { .hasMessageContaining("not active"); } + @Test + void refusesToStartWhenAReservedUserIdBelongsToAnotherEmail() { + InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); + InMemoryUserAccountRepository userAccountRepository = new InMemoryUserAccountRepository(); + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); + userAccountRepository.insert(UserAccount.create( + ADMIN_USER_ID, + COMPANY_ID, + "ID collision", + "different@example.com", + passwordEncoder.encode(ADMIN_PASSWORD), + UserRole.ADMIN, + NOW + )); + DemoAuthSeedRunner runner = runner( + properties(ADMIN_PASSWORD), + companyRepository, + userAccountRepository, + passwordEncoder + ); + + assertThatThrownBy(() -> runner.run(new DefaultApplicationArguments(new String[0]))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("reserved demo user id"); + } + + @Test + void refusesToStartWhenAReservedEmailBelongsToAnotherAccount() { + InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); + InMemoryUserAccountRepository userAccountRepository = new InMemoryUserAccountRepository(); + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); + userAccountRepository.insert(UserAccount.create( + UUID.fromString("aaaaaaaa-0000-0000-0000-000000000001"), + TEST_COMPANY_ID, + "Email collision", + ADMIN_EMAIL, + passwordEncoder.encode(ADMIN_PASSWORD), + UserRole.HR, + NOW + )); + DemoAuthSeedRunner runner = runner( + properties(ADMIN_PASSWORD), + companyRepository, + userAccountRepository, + passwordEncoder + ); + + assertThatThrownBy(() -> runner.run(new DefaultApplicationArguments(new String[0]))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("configured demo email"); + } + private DemoAuthSeedRunner runner( DemoAuthSeedProperties properties, CompanyRepository companyRepository, diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java new file mode 100644 index 0000000..1b7bd4c --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoFileFixtureInstallerTest.java @@ -0,0 +1,52 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.StoredFileSeed; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.ClassPathResource; + +class DemoFileFixtureInstallerTest { + + @TempDir + Path storageRoot; + + @Test + void installsReusesAndRejectsConflictingFixtureContent() throws Exception { + UUID fileId = UUID.fromString("94800000-0000-0000-0000-000000000001"); + StoredFileSeed seed = new StoredFileSeed( + fileId, + "demo-contract-renewal.pdf", + "application/pdf", + "DEMO_CONTRACT_RENEWAL", + UUID.randomUUID(), + UUID.randomUUID(), + fileId.toString(), + "demo/files/demo-contract-renewal.pdf" + ); + DemoFileFixtureInstaller installer = new DemoFileFixtureInstaller(storageRoot.toString()); + byte[] expected; + try (var input = new ClassPathResource(seed.resourcePath()).getInputStream()) { + expected = input.readAllBytes(); + } + + installer.install(seed); + installer.install(seed); + + Path installed = storageRoot.resolve(seed.storageKey()); + assertThat(Files.readAllBytes(installed)).isEqualTo(expected); + + byte[] conflicting = expected.clone(); + conflicting[conflicting.length - 1] ^= 1; + Files.write(installed, conflicting); + assertThat(Files.size(installed)).isEqualTo(expected.length); + assertThatThrownBy(() -> installer.install(seed)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(seed.storageKey()); + } +} diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java index c175ef8..b3dba3a 100644 --- a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java @@ -8,9 +8,12 @@ import com.fowoco.server.worker.application.WorkerSearchQuery; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.domain.WorkerStatus; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.LinkedHashMap; import java.util.List; @@ -18,11 +21,34 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.springframework.boot.DefaultApplicationArguments; class DemoWorkerSeedRunnerTest { + private static final Set AI_SUPPORTED_LANGUAGES = Set.of( + "en", "zh-Hans", "vi", "th", "fil", "id", "mn", "si", + "ru", "uz", "ky", "bn", "ur", "km", "tet" + ); + private static final Map> NATURAL_NATIONALITIES_BY_LANGUAGE = Map.ofEntries( + Map.entry("en", Set.of("MM", "NP", "PH")), + Map.entry("zh-Hans", Set.of("CN")), + Map.entry("vi", Set.of("VN")), + Map.entry("th", Set.of("TH")), + Map.entry("fil", Set.of("PH")), + Map.entry("id", Set.of("ID")), + Map.entry("mn", Set.of("MN")), + Map.entry("si", Set.of("LK")), + Map.entry("ru", Set.of("RU")), + Map.entry("uz", Set.of("UZ")), + Map.entry("ky", Set.of("KG")), + Map.entry("bn", Set.of("BD")), + Map.entry("ur", Set.of("PK")), + Map.entry("km", Set.of("KH")), + Map.entry("tet", Set.of("TL")) + ); + private static final UUID DEMO_COMPANY_ID = UUID.fromString("90000000-0000-0000-0000-000000000001"); private static final UUID TEST_COMPANY_ID = @@ -32,24 +58,38 @@ class DemoWorkerSeedRunnerTest { private static final Instant NOW = Instant.parse("2026-07-31T00:00:00Z"); @Test - void createsFiveIdempotentWorkersForEachDemoCompany() throws Exception { + void createsOperationalDemoWorkersAndKeepsTestCompanySmall() throws Exception { InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); companyRepository.insert(Company.create(DEMO_COMPANY_ID, "FOWOCO Demo Company", NOW)); companyRepository.insert(Company.create(TEST_COMPANY_ID, "FOWOCO Test Company", NOW)); InMemoryWorkerRepository workerRepository = new InMemoryWorkerRepository(); + MutableClock clock = new MutableClock(NOW, ZoneOffset.UTC); DemoWorkerSeedRunner runner = new DemoWorkerSeedRunner( properties(), companyRepository, workerRepository, - Clock.fixed(NOW, ZoneOffset.UTC) + clock ); runner.run(new DefaultApplicationArguments(new String[0])); + Map initialWorkers = Map.copyOf(workerRepository.workers); + runner.run(new DefaultApplicationArguments(new String[0])); + clock.advance(Duration.ofDays(1)); runner.run(new DefaultApplicationArguments(new String[0])); + assertThat(workerRepository.workers).containsExactlyInAnyOrderEntriesOf(initialWorkers); + assertThat(workerRepository.workers.values()) .filteredOn(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) - .hasSize(5); + .hasSize(28); + List demoWorkers = workerRepository.workers.values().stream() + .filter(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) + .toList(); + assertThat(demoWorkers.stream().map(Worker::preferredLanguage).collect(java.util.stream.Collectors.toSet())) + .containsExactlyInAnyOrderElementsOf(AI_SUPPORTED_LANGUAGES); + assertThat(demoWorkers).allMatch(worker -> NATURAL_NATIONALITIES_BY_LANGUAGE + .getOrDefault(worker.preferredLanguage(), Set.of()) + .contains(worker.nationalityCode())); assertThat(workerRepository.workers.values()) .filteredOn(worker -> worker.companyId().equals(TEST_COMPANY_ID)) .hasSize(5); @@ -58,6 +98,21 @@ void createsFiveIdempotentWorkersForEachDemoCompany() throws Exception { assertThat(workerRepository.workers.get( UUID.fromString("92000000-0000-0000-0000-000000000001") ).stayExpiryDate()).isEqualTo(LocalDate.of(2026, 8, 30)); + assertThat(workerRepository.workers.get( + UUID.fromString("92000000-0000-0000-0000-000000000006") + )).satisfies(worker -> { + assertThat(worker.displayName()).isEqualTo("응웬반A"); + assertThat(worker.nationalityCode()).isEqualTo("VN"); + assertThat(worker.preferredLanguage()).isEqualTo("vi"); + assertThat(worker.stayExpiryDate()).isEqualTo(LocalDate.of(2026, 9, 14)); + }); + assertThat(workerRepository.workers.values()) + .filteredOn(worker -> worker.companyId().equals(DEMO_COMPANY_ID)) + .filteredOn(worker -> worker.workStatus() == WorkerStatus.ON_LEAVE) + .hasSize(3); + assertThat(workerRepository.workers.get( + UUID.fromString("92000000-0000-0000-0000-000000000025") + ).stayExpiryDate()).isNull(); } private DemoAuthSeedProperties properties() { @@ -110,7 +165,7 @@ public Optional findByWorkerIdAndCompanyId(UUID workerId, UUID companyId @Override public Worker update(Worker worker) { - throw new UnsupportedOperationException(); + throw new AssertionError("immutable demo worker snapshot must not be updated"); } @Override @@ -128,4 +183,34 @@ public List findAllByWorkerIdsAndCompanyId(Set workerIds, UUID com throw new UnsupportedOperationException(); } } + + private static final class MutableClock extends Clock { + + private final AtomicReference instant; + private final ZoneId zone; + + private MutableClock(Instant instant, ZoneId zone) { + this.instant = new AtomicReference<>(instant); + this.zone = zone; + } + + void advance(Duration duration) { + instant.updateAndGet(value -> value.plus(duration)); + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId zone) { + return new MutableClock(instant(), zone); + } + + @Override + public Instant instant() { + return instant.get(); + } + } }