diff --git a/docs/demo-data/residence-card-safe-design-reference.png b/docs/demo-data/residence-card-safe-design-reference.png new file mode 100644 index 00000000..2a6bb09c Binary files /dev/null and b/docs/demo-data/residence-card-safe-design-reference.png differ diff --git a/docs/demo-document-data.md b/docs/demo-document-data.md index 96f0f136..4dfdcfe1 100644 --- a/docs/demo-document-data.md +++ b/docs/demo-document-data.md @@ -40,11 +40,65 @@ export OCR_RESULT_ENCRYPTION_KEY_BASE64='<32-byte-base64-secret>' fixture를 적재·검증한다. 성공하면 API Server도 기동한다. - `verify`: 고정 ID, 회사·근로자·업무 연결, 날짜·상태, MIME·크기·SHA-256, 실제 파일, OCR 원본 계보를 읽기 검증한다. -- `cleanup`: 이 기능이 예약한 ID와 해시가 모두 일치할 때만 OCR·문서·파일을 제거한다. - 기존 Demo Company, Worker, Task와 기존 Showcase 문서는 삭제하지 않는다. +- `cleanup`: 이 기능이 예약한 ID와 해시가 모두 일치할 때만 OCR·신규 문서·생성 파일을 + 제거한다. 기존 `LEGACY` 문서 83건은 삭제하지 않고, 이 명령이 추가한 파일 연결만 + 원래 상태로 복원한다. 기존 Demo Company, Worker, Task와 Showcase 메타데이터는 유지한다. 같은 `import`를 다시 실행하면 DB row와 파일 수가 증가하지 않는다. 예약 ID 또는 저장 키에 -다른 내용이 있으면 덮어쓰지 않고 즉시 실패한다. +다른 내용이 있으면 덮어쓰지 않고 즉시 실패한다. 서버 재시작 때 Operational Seed가 다시 +검증되어도 결정적 파일 ID를 허용하므로 문서 연결이 원복되거나 시작이 실패하지 않는다. + +## 메타데이터와 파일 본문 일치 + +`import`는 신규 `DEMO_SEED` 문서 43건뿐 아니라 기존 문서함의 `LEGACY` 메타데이터 +83건도 함께 읽는다. 이 중 `MISSING` 16건은 파일이 없는 상태를 의도적으로 유지하고, +나머지 67건에는 근로자와 문서 행을 기준으로 합성 이미지 또는 PDF를 생성해 연결한다. + +적재 완료 기준은 다음과 같다. + +```text +문서 메타데이터 126건 = DEMO_SEED 43 + LEGACY 83 +파일 연결 109건 = DEMO_SEED 42 + LEGACY 비누락 67 +파일 형식 = 이미지 71 + PDF 34 + HWP 1 + HWPX 3 +파일 없는 누락 상태 = 17건 +``` + +모든 파일은 연결된 `worker_id`의 표시 이름, 영문 이름, 국적·국가 코드, 선호 언어, +체류자격, 합성 생년월일·문서번호·주소를 사용한다. 문서 행의 `document_type`, +`submission_status`, `issue_date`, `expiry_date`, `worker_document_id`도 파일 본문 또는 +컨테이너 메타데이터에 그대로 기록한다. Import 전에 실제 Worker 행의 이름·국적 코드·언어· +체류자격이 fixture와 일치하는지 확인하므로 서로 다른 사람의 파일을 잘못 연결할 수 없다. + +OCR 대상 이미지와 PDF의 인적사항·번호·날짜·주소 영역에는 대각선 워터마크나 배경 문구를 +겹치지 않는다. 합성 문서 안전 표시는 OCR 필드 밖의 상하단 고정 영역과 무효 식별번호에만 +배치해 텍스트 인식과 안전 구분을 함께 유지한다. + +PDF와 이미지는 결정적으로 생성하고, HWP/HWPX는 AI 저장소에서 사용하는 실제 템플릿 +구조에 합성값을 주입한다. HWP에는 `FOWOCO-Metadata` 스트림, HWPX에는 미리보기 텍스트와 +본문 XML에 연결 정보를 남겨 원본 문서까지 역추적할 수 있다. + +## 문서 양식과 작성 상태 + +문서의 필드 순서는 다음 1차 자료를 기준으로 한다. + +- [외국인근로자 고용법 시행규칙 현행 서식](https://law.go.kr/LSW/lsInfoP.do?ancYnChk=0&chrClsCd=010202&efYd=20250602&lsiSeq=271599&urlMode=lsInfoP) +- [취업기간 만료자 취업활동 기간 연장신청서(별지 제12호의3)](https://www.law.go.kr/LSW/flDownload.do?bylClsCd=110202&flSeq=152583561) +- [통합신청서 영문 서식(별지 제34호)](https://www.law.go.kr/LSW/flDownload.do?bylClsCd=110202&flSeq=152660449) +- [법무부 외국인등록 안내](https://www.moj.go.kr/bbs/moj/93/559234/artclView.do) + +고용허가서에는 고용허가기간, 사업장·대표자·사업자번호·업종, 근로자 이름·국적· +외국인등록번호·여권번호, 근무장소·직무·시간·임금·숙식·보험을 채운다. 표준근로계약서는 +별지 제6호서식의 앞·뒤쪽 구조에 맞춰 계약기간, 근로장소, 업무, 근로·휴게시간, 휴일, +기본급·수당·지급일·지급방법, 숙식 및 양 당사자 서명 상태를 기록한다. + +취업활동기간 연장신청서는 2024년 개정 별지 제12호의3서식에 맞춰 사업장 정보, 다섯 개 +사실확인 항목, 근로자 인적사항, 체류기간 만료일과 첨부서류를 모두 작성한다. 통합신청서는 +체류기간 연장허가를 선택하고 이름·생년월일·성별·국적·외국인등록번호·여권·국내/본국 주소· +연락처·근무처·소득·직업을 채운다. 두 파일의 `DRAFT`는 빈 원본이 아니라 **모든 업무 필드가 +작성되고 신청인 서명만 남은 초안**을 뜻한다. + +`MISSING` 상태는 누락 필터 검증을 위한 별도 시나리오이므로 파일을 만들지 않는다. 그 밖의 +모든 문서는 본문 또는 컨테이너 메타데이터에 연결 근로자와 문서 행의 값을 기록한다. ## 대표 fixture @@ -61,6 +115,49 @@ export OCR_RESULT_ENCRYPTION_KEY_BASE64='<32-byte-base64-secret>' - 통합신청서 초안 HWPX - 체류지 입증자료 PDF +외국인등록증 앞면은 카드형 가로 비율, 왼쪽 인적사항, 오른쪽 합성 증명사진, 아래 상태 표시를 +사용하고 뒷면은 체류기간과 체류지 변경 이력을 표 형태로 렌더링한다. 실제 관인 대신 +`FOWOCO QA LAB / 관인 아님`을 표시하고, 앞·뒷면 모두에 +`실제 신분증이 아닙니다` 하단 띠를 넣는다. OCR 필드 위에는 워터마크나 안내 문구를 +겹치지 않으며, 무효 식별번호·비공식 발급기관·상하단 고정 안내 영역으로 합성 문서임을 구분한다. +공식 로고·관인·홀로그램·기요셰 등 보안요소는 복제하지 않는다. 안전한 구성 시안은 +[`residence-card-safe-design-reference.png`](demo-data/residence-card-safe-design-reference.png)에 +보관하며, Seed 결과의 정확한 텍스트와 날짜는 이 이미지를 직접 사용하지 않고 Java 렌더러가 +DB fixture에서 생성한다. + +한글 이미지와 PDF는 OFL 라이선스의 Noto Sans KR을 애플리케이션 리소스로 포함해, 한글 폰트가 +설치되지 않은 Server 컨테이너에서도 깨지지 않게 한다. + +Demo Company의 나머지 근로자 27명에게는 각각 다른 합성 여권 사본 PNG를 추가한다. +각 파일은 영문 이름, 국적, 생년월일, 무효 문서번호, 발급·만료일, 실사형 합성 증명사진과 +저장 키가 서로 다르며 SHA-256도 27개 모두 달라야 한다. 응웬반A의 기존 유효 여권과 +아르준 타파의 과거 만료 파일도 해당 근로자 DB 값과 동일한 합성 정보로 생성한다. +기존 `LEGACY` 메타데이터 자체는 덮어쓰지 않고 비누락 행에만 결정적 파일 연결을 추가한다. + +```text +Demo Company Worker 28명 += 응웬반A 기존 유효 여권 1명 ++ 신규 합성 여권 27명(활성 24명, 휴직 3명 포함) +``` + +신규 파일명은 `여권사본_{합성이름}.png`, 저장 파일명은 +`passport-copy-worker-{worker_number}.png` 형식이다. 모든 이미지는 실제 여권 문양 대신 +FOWOCO QA 레이아웃과 `DEMO SAMPLE / NOT A TRAVEL DOCUMENT` 표식을 사용한다. 정보면은 +상단 제목, 좌측 증명사진, 우측의 조밀한 영문 필드, 하단 2줄 판독 영역으로 구성하되 +공식 국가명·국가 문장·보안문양은 포함하지 않는다. 증명사진 +원본 27장은 `demo-data/passport-portraits/worker-{worker_number}.png`에 포함되며, 모두 +이미지 생성 모델로 만든 완전한 합성 성인이다. 생성기에서 사진을 여권형 정보면 비율로 +중앙 크롭하므로 Seed를 다시 실행해도 동일한 파일과 SHA-256이 만들어진다. + +여권 인적사항면은 실제 인물이나 실제 여권 원본을 사용하지 않는다. 프로젝트에 포함된 +`demo-data/nguyen-van-an-portrait.png`와 `demo-data/passport-portraits/*.png`는 이미지 생성 +모델로 만든 완전한 합성 인물이며, 생성기에서 사진·영문 인적사항·발급일·만료일을 조합한다. +실제 여권의 사진·정보 필드·MRZ 배치 비율만 참고하고 실제 국가 문장과 보안문양은 +포함하지 않고, 국가 코드는 비실재 코드 `XDM`, 문서번호와 기계 판독 영역은 명시적으로 +무효인 값만 사용한다. 이미지 전체에는 `DEMO SAMPLE`과 `NOT A TRAVEL DOCUMENT` 표시가 +들어간다. 발급일과 만료일은 DB에 적재되는 날짜를 그대로 렌더링해 본문과 메타데이터가 +어긋나지 않게 한다. + 추가 근로자 4명에게 정상·임박·만료·필수 문서 누락 상태를 연결한다. 대표 근로자의 ARC 앞면에는 암호화된 합성 OCR 결과와 `REVIEW_REQUIRED` 상태를 연결한다. diff --git a/docs/demo-seed.md b/docs/demo-seed.md index 3383d4fd..a71de2a8 100644 --- a/docs/demo-seed.md +++ b/docs/demo-seed.md @@ -56,7 +56,8 @@ PostgreSQL 16 Compose 실행은 [배포 Runbook](deployment-runbook.md)의 로 ## 로그인 계정 모든 계정은 `DEMO_SEED_ADMIN_PASSWORD`에 지정한 동일한 합성 비밀번호를 사용하고, -DB에는 BCrypt hash만 저장한다. +DB에는 BCrypt hash만 저장한다. 영구 Demo DB에서 이 환경변수 값을 변경하면 다음 Seed 실행 시 +예약된 Demo·Test 계정의 hash를 새 합성 비밀번호로 멱등 갱신한다. | 회사 | 역할 | 이메일 | 용도 | | --- | --- | --- | --- | diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java index dceb07f3..45df409a 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java @@ -157,7 +157,14 @@ private void seedUser(UUID companyId, DemoUser demoUser, Instant now) { Optional existingByEmail = userAccountRepository.findByNormalizedEmail(normalizedEmail); if (existingByEmail.isPresent()) { - verifyExistingUser(existingByEmail.orElseThrow(), companyId, demoUser); + UserAccount existing = existingByEmail.orElseThrow(); + verifyExistingUser(existing, companyId, demoUser); + if (!passwordEncoder.matches(properties.adminPassword(), existing.passwordHash())) { + userAccountRepository.update(existing.changePassword( + passwordEncoder.encode(properties.adminPassword()), + now + )); + } return; } if (userAccountRepository diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/DemoDocumentFileIds.java b/src/main/java/com/fowoco/server/demo/infrastructure/DemoDocumentFileIds.java new file mode 100644 index 00000000..b4135d4c --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/DemoDocumentFileIds.java @@ -0,0 +1,39 @@ +package com.fowoco.server.demo.infrastructure; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** Stable identifiers for file rows materialized from the operational demo document catalog. */ +public final class DemoDocumentFileIds { + + private static final String OPERATIONAL_DOCUMENT_PREFIX = + "95000000-0000-0000-0000-000000000"; + private static final String MATERIALIZED_FILE_NAMESPACE = + "fowoco-demo-operational-document-file-v1:"; + + private DemoDocumentFileIds() { + } + + public static UUID materializedFileId(UUID workerDocumentId) { + if (!isOperationalDemoDocumentId(workerDocumentId)) { + throw new IllegalArgumentException("not an operational Demo Company document id"); + } + return UUID.nameUUIDFromBytes( + (MATERIALIZED_FILE_NAMESPACE + workerDocumentId) + .getBytes(StandardCharsets.UTF_8) + ); + } + + public static boolean isOperationalDemoDocumentId(UUID workerDocumentId) { + String value = workerDocumentId.toString(); + if (!value.startsWith(OPERATIONAL_DOCUMENT_PREFIX)) { + return false; + } + try { + int sequence = Integer.parseInt(value.substring(value.length() - 3)); + return sequence >= 1 && sequence <= 84 && sequence != 18; + } catch (NumberFormatException exception) { + return false; + } + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataCommandRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataCommandRunner.java index a10e2386..c5f89853 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataCommandRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataCommandRunner.java @@ -53,7 +53,8 @@ public void run(ApplicationArguments arguments) { }; LOGGER.info( "demo_document_data command={} document_count={} file_count={} image_count={} " - + "pdf_count={} hwp_count={} hwpx_count={} task_linked_count={} missing_count={}", + + "pdf_count={} hwp_count={} hwpx_count={} task_linked_count={} missing_count={} " + + "passport_worker_count={} legacy_materialized_file_count={}", parsed.name().toLowerCase(Locale.ROOT), report.documentCount(), report.fileCount(), @@ -62,7 +63,9 @@ public void run(ApplicationArguments arguments) { report.hwpCount(), report.hwpxCount(), report.taskLinkedDocumentCount(), - report.missingDocumentCount() + report.missingDocumentCount(), + report.passportWorkerCount(), + report.legacyMaterializedFileCount() ); applicationContext.close(); } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataService.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataService.java index 8e675766..101ae514 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataService.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataService.java @@ -17,6 +17,7 @@ import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -47,6 +48,7 @@ class DemoDocumentDataService { private final Clock clock; private final SyntheticDocumentGenerator generator = new SyntheticDocumentGenerator(); private final DemoDocumentFileInstaller fileInstaller; + private final DemoLegacyDocumentFileMaterializer legacyFileMaterializer; DemoDocumentDataService( JdbcTemplate jdbcTemplate, @@ -66,6 +68,11 @@ class DemoDocumentDataService { this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); this.clock = Objects.requireNonNull(clock, "clock must not be null"); this.fileInstaller = new DemoDocumentFileInstaller(localFileStoragePath); + this.legacyFileMaterializer = new DemoLegacyDocumentFileMaterializer( + jdbcTemplate, + fileInstaller, + generator + ); } @Transactional @@ -77,14 +84,15 @@ DemoDocumentDataReport importData() { Instant now = clock.instant(); for (DemoDocumentFixture fixture : DemoDocumentFixtureCatalog.fixtures()) { requireWorkerAndTask(fixture); - byte[] content = content(fixture); + byte[] content = content(fixture, anchorDate); if (content != null) { fileInstaller.install(fixture.storageKey(), content); seedStoredFile(fixture, content, now); } seedWorkerDocument(fixture, anchorDate, now); } - seedOcrReview(now); + legacyFileMaterializer.importFiles(anchorDate, now); + seedOcrReview(now, anchorDate); return verifyInternal(anchorDate); } @@ -100,7 +108,9 @@ DemoDocumentDataReport verifyData() { DemoDocumentDataReport cleanupData() { bindTenant(); requireBaseSeed(); - verifyOwnedRowsBeforeCleanup(anchorDate()); + LocalDate anchorDate = anchorDate(); + verifyOwnedRowsBeforeCleanup(anchorDate); + legacyFileMaterializer.cleanupFiles(anchorDate, clock.instant()); jdbcTemplate.update( "DELETE FROM document_ocr_run WHERE ocr_run_id = ? AND company_id = ?", @@ -114,7 +124,7 @@ DemoDocumentDataReport cleanupData() { ); } for (DemoDocumentFixture fixture : DemoDocumentFixtureCatalog.fixtures()) { - byte[] content = content(fixture); + byte[] content = content(fixture, anchorDate); if (content == null) { continue; } @@ -124,7 +134,7 @@ DemoDocumentDataReport cleanupData() { ); fileInstaller.cleanup(fixture.storageKey(), content); } - return new DemoDocumentDataReport(0, 0, 0, 0, 0, 0, 0, 0); + return new DemoDocumentDataReport(0, 0, 0, 0, 0, 0, 0, 0, 0, 0); } private void seedStoredFile(DemoDocumentFixture fixture, byte[] content, Instant now) { @@ -193,16 +203,16 @@ INSERT INTO worker_document ( ); } - private void seedOcrReview(Instant now) { + private void seedOcrReview(Instant now, LocalDate anchorDate) { var existing = ocrRunRepository.findByIdAndCompanyId( DemoDocumentFixtureCatalog.OCR_RUN_ID, DemoDocumentFixtureCatalog.COMPANY_ID ); if (existing.isPresent()) { - verifyOcrRun(existing.get()); + verifyOcrRun(existing.get(), anchorDate); return; } - byte[] plaintext = ocrPayload(); + byte[] plaintext = ocrPayload(anchorDate); String ciphertext = ocrResultCipher.encrypt( plaintext, DemoDocumentFixtureCatalog.COMPANY_ID, @@ -232,6 +242,7 @@ private DemoDocumentDataReport verifyInternal(LocalDate anchorDate) { int pdfCount = 0; int hwpCount = 0; int hwpxCount = 0; + var passportCoverageHashes = new HashSet(); for (DemoDocumentFixture fixture : DemoDocumentFixtureCatalog.fixtures()) { requireWorkerAndTask(fixture); List> documentRows = jdbcTemplate.queryForList( @@ -242,7 +253,7 @@ private DemoDocumentDataReport verifyInternal(LocalDate anchorDate) { throw new IllegalStateException("demo worker document fixture is missing"); } verifyWorkerDocument(fixture, anchorDate, documentRows.get(0)); - byte[] content = content(fixture); + byte[] content = content(fixture, anchorDate); if (content == null) { continue; } @@ -255,6 +266,9 @@ private DemoDocumentDataReport verifyInternal(LocalDate anchorDate) { } verifyStoredFile(fixture, content, fileRows.get(0)); fileInstaller.verify(fixture.storageKey(), content); + if (DemoDocumentFixtureCatalog.passportCoverageFixtures().contains(fixture)) { + passportCoverageHashes.add(DemoDocumentFileInstaller.sha256(content)); + } fileCount++; switch (fixture.format()) { case PNG, JPEG -> imageCount++; @@ -269,28 +283,51 @@ private DemoDocumentDataReport verifyInternal(LocalDate anchorDate) { DemoDocumentFixtureCatalog.COMPANY_ID ) .orElseThrow(() -> new IllegalStateException("demo OCR review fixture is missing")); - verifyOcrRun(ocrRun); + verifyOcrRun(ocrRun, anchorDate); + DemoLegacyDocumentFileMaterializer.MaterializedFiles legacyFiles = + legacyFileMaterializer.verifyFiles(anchorDate); int taskLinked = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker_document WHERE source = ? AND company_id = ? AND task_id IS NOT NULL", + "SELECT COUNT(*) FROM worker_document WHERE source IN ('DEMO_SEED', 'LEGACY') " + + "AND company_id = ? AND task_id IS NOT NULL", Integer.class, - SOURCE, DemoDocumentFixtureCatalog.COMPANY_ID ); int missing = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker_document WHERE source = ? AND company_id = ? AND submission_status = 'MISSING'", + "SELECT COUNT(*) FROM worker_document WHERE source IN ('DEMO_SEED', 'LEGACY') " + + "AND company_id = ? AND submission_status = 'MISSING'", Integer.class, - SOURCE, DemoDocumentFixtureCatalog.COMPANY_ID ); + int passportWorkers = jdbcTemplate.queryForObject( + """ + SELECT COUNT(DISTINCT worker_id) + FROM worker_document + WHERE source IN ('DEMO_SEED', 'LEGACY') + AND company_id = ? + AND document_type = 'PASSPORT_COPY' + AND submission_status = 'VERIFIED' + AND file_id IS NOT NULL + AND expiry_date > ? + """, + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID, + Date.valueOf(anchorDate) + ); + int expectedCoverageFiles = DemoDocumentFixtureCatalog.passportCoverageFixtures().size(); + if (passportCoverageHashes.size() != expectedCoverageFiles || passportWorkers != 28) { + throw new IllegalStateException("demo passport fixtures are not unique or do not cover every worker"); + } return new DemoDocumentDataReport( - DemoDocumentFixtureCatalog.fixtures().size(), - fileCount, - imageCount, - pdfCount, + DemoDocumentFixtureCatalog.fixtures().size() + 83, + fileCount + legacyFiles.fileCount(), + imageCount + legacyFiles.imageCount(), + pdfCount + legacyFiles.pdfCount(), hwpCount, hwpxCount, taskLinked, - missing + missing, + passportWorkers, + legacyFiles.fileCount() ); } @@ -303,7 +340,7 @@ private void verifyOwnedRowsBeforeCleanup(LocalDate anchorDate) { if (!documents.isEmpty()) { verifyWorkerDocument(fixture, anchorDate, documents.get(0)); } - byte[] content = content(fixture); + byte[] content = content(fixture, anchorDate); if (content == null) { continue; } @@ -360,7 +397,7 @@ private void verifyWorkerDocument( } } - private void verifyOcrRun(DocumentOcrRun run) { + private void verifyOcrRun(DocumentOcrRun run, LocalDate anchorDate) { if (!DemoDocumentFixtureCatalog.OCR_RUN_ID.equals(run.ocrRunId()) || !DemoDocumentFixtureCatalog.COMPANY_ID.equals(run.companyId()) || !DemoDocumentFixtureCatalog.OCR_DOCUMENT_ID.equals(run.workerDocumentId()) @@ -378,28 +415,30 @@ private void verifyOcrRun(DocumentOcrRun run) { } catch (JacksonException exception) { throw new IllegalStateException("failed to deserialize demo OCR payload", exception); } - if (!ocrPayloadObject().equals(actualPayload)) { + if (!ocrPayloadObject(anchorDate).equals(actualPayload)) { throw new IllegalStateException("demo OCR result payload does not match the fixture"); } } - private byte[] ocrPayload() { + private byte[] ocrPayload(LocalDate anchorDate) { try { - return objectMapper.writeValueAsBytes(ocrPayloadObject()); + return objectMapper.writeValueAsBytes(ocrPayloadObject(anchorDate)); } catch (JacksonException exception) { throw new IllegalStateException("failed to serialize demo OCR payload", exception); } } - private DocumentOcrResultPayload ocrPayloadObject() { + private DocumentOcrResultPayload ocrPayloadObject(LocalDate anchorDate) { + DemoDocumentFixture fixture = fixtureById(DemoDocumentFixtureCatalog.OCR_DOCUMENT_ID); + var identity = fixture.passportIdentity(); return new DocumentOcrResultPayload( 43019L, AiOcrDocumentSide.FRONT, new TreeMap<>(Map.of( - "alien_registration_number", "SYNTHETIC-ARC-0001", - "visa_type", "E-9", - "stay_expiration_date", "2099-12-31", - "residence_address_1", "DEMO ADDRESS - NOT REAL" + "alien_registration_number", identity.alienRegistrationNumber(), + "visa_type", identity.visaType(), + "stay_expiration_date", relativeDate(anchorDate, fixture.expiryDays()).toString(), + "residence_address_1", identity.syntheticAddress() )), new TreeMap<>(Map.of( "alien_registration_number", new BigDecimal("0.61"), @@ -430,15 +469,31 @@ private void requireBaseSeed() { } private void requireWorkerAndTask(DemoDocumentFixture fixture) { - Integer worker = jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM worker WHERE worker_id = ? AND company_id = ?", - Integer.class, + List> workers = jdbcTemplate.queryForList( + """ + SELECT worker_id, company_id, display_name, nationality_code, + preferred_language, visa_type + FROM worker + WHERE worker_id = ? AND company_id = ? + """, fixture.workerId(), DemoDocumentFixtureCatalog.COMPANY_ID ); - if (worker != 1) { + if (workers.size() != 1) { throw new IllegalStateException("demo document worker is missing"); } + Map worker = workers.get(0); + var identity = Objects.requireNonNull(fixture.passportIdentity()); + if (!fixture.workerId().equals(worker.get("worker_id")) + || !DemoDocumentFixtureCatalog.COMPANY_ID.equals(worker.get("company_id")) + || !identity.displayName().equals(worker.get("display_name")) + || !identity.nationalityCode().equals(worker.get("nationality_code")) + || !identity.preferredLanguage().equals(worker.get("preferred_language")) + || !identity.visaType().equals(worker.get("visa_type"))) { + throw new IllegalStateException( + "demo document identity metadata does not match its worker row" + ); + } if (fixture.taskId() == null) { return; } @@ -466,8 +521,14 @@ private void bindTenant() { tenantDatabaseContext.setCompanyIdForCurrentTransaction(DemoDocumentFixtureCatalog.COMPANY_ID); } - private byte[] content(DemoDocumentFixture fixture) { - return fixture.format() == FixtureFormat.NONE ? null : generator.generate(fixture); + private byte[] content(DemoDocumentFixture fixture, LocalDate anchorDate) { + return fixture.format() == FixtureFormat.NONE + ? null + : generator.generate( + fixture, + relativeDate(anchorDate, fixture.issueDays()), + relativeDate(anchorDate, fixture.expiryDays()) + ); } private DemoDocumentFixture fixtureById(UUID documentId) { @@ -523,7 +584,9 @@ record DemoDocumentDataReport( int hwpCount, int hwpxCount, int taskLinkedDocumentCount, - int missingDocumentCount + int missingDocumentCount, + int passportWorkerCount, + int legacyMaterializedFileCount ) { } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFixtureCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFixtureCatalog.java index 6af91b95..eb54dbbd 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFixtureCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentFixtureCatalog.java @@ -2,22 +2,26 @@ import com.fowoco.server.worker.domain.DocumentType; import com.fowoco.server.worker.domain.SubmissionStatus; +import java.time.LocalDate; import java.util.List; import java.util.UUID; +import java.util.stream.Stream; final class DemoDocumentFixtureCatalog { static final UUID COMPANY_ID = uuid("90000000-0000-0000-0000-000000000001"); static final UUID ADMIN_USER_ID = uuid("90000000-0000-0000-0000-000000000002"); static final UUID GOLD_WORKER_ID = workerId(6); + static final UUID PASSPORT_BIO_DOCUMENT_ID = documentId(1); static final UUID OCR_DOCUMENT_ID = documentId(3); static final UUID OCR_RUN_ID = uuid("95300000-0000-0000-0000-000000000001"); static final UUID OCR_RUNTIME_REQUEST_ID = uuid("95310000-0000-0000-0000-000000000001"); - private static final List FIXTURES = List.of( - fixture(1, 6, null, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, - -365, 365, "여권_인적사항면_응웬반A.png", "passport-bio.png", - "image/png", FixtureFormat.PNG, "Passport biographical page"), + private static final List BASE_FIXTURES = List.of( + passportImageFixture(1, 6, -365, 365, + "여권_인적사항면_응웬반A.png", "passport-bio.png", + "Passport biographical page", + identityForWorker(6)), fixture(2, 6, null, DocumentType.PASSPORT_COPY, SubmissionStatus.VERIFIED, -365, 365, "여권_사본_응웬반A.pdf", "passport-copy.pdf", "application/pdf", FixtureFormat.PDF, "Passport copy"), @@ -64,6 +68,67 @@ final class DemoDocumentFixtureCatalog { null, null, null, null, null, FixtureFormat.NONE, "Required document missing") ); + private static final List PASSPORT_COVERAGE_FIXTURES = List.of( + passportCoverageFixture(101, 1, "리웨이", "LI WEI", "LI", "WEI", + "CHINA", "CN", LocalDate.of(1992, 3, 14), "M"), + passportCoverageFixture(102, 2, "속 체아", "SOK CHEA", "SOK", "CHEA", + "CAMBODIA", "KH", LocalDate.of(1996, 7, 21), "M"), + passportCoverageFixture(103, 3, "아르준 타파", "ARJUN THAPA", "THAPA", "ARJUN", + "NEPAL", "NP", LocalDate.of(1991, 11, 8), "M"), + passportCoverageFixture(104, 4, "부디 산토소", "BUDI SANTOSO", "SANTOSO", "BUDI", + "INDONESIA", "ID", LocalDate.of(1994, 2, 17), "M"), + passportCoverageFixture(105, 5, "마크 레예스", "MARK REYES", "REYES", "MARK", + "PHILIPPINES", "PH", LocalDate.of(1993, 9, 25), "M"), + passportCoverageFixture(106, 7, "아디 수르야", "ADI SURYA", "SURYA", "ADI", + "INDONESIA", "ID", LocalDate.of(1997, 1, 19), "M"), + passportCoverageFixture(107, 8, "바트 에르덴", "BAT ERDENE", "BAT", "ERDENE", + "MONGOLIA", "MN", LocalDate.of(1990, 5, 30), "M"), + passportCoverageFixture(108, 9, "라니 위자야", "RANI WIJAYA", "WIJAYA", "RANI", + "INDONESIA", "ID", LocalDate.of(1998, 8, 11), "F"), + passportCoverageFixture(109, 10, "민 아웅", "MIN AUNG", "MIN", "AUNG", + "MYANMAR", "MM", LocalDate.of(1995, 12, 4), "M"), + passportCoverageFixture(110, 11, "파티마 누르", "FATIMAH NUR", "NUR", "FATIMAH", + "INDONESIA", "ID", LocalDate.of(1996, 4, 27), "F"), + passportCoverageFixture(111, 12, "트란 티 마이", "TRAN THI MAI", "TRAN", "THI MAI", + "VIET NAM", "VN", LocalDate.of(1999, 6, 15), "F"), + passportCoverageFixture(112, 13, "쩐 꾸옥 바오", "TRAN QUOC BAO", "TRAN", "QUOC BAO", + "VIET NAM", "VN", LocalDate.of(1994, 10, 2), "M"), + passportCoverageFixture(113, 14, "아이다나 베크", "AIDANA BEK", "BEK", "AIDANA", + "KYRGYZSTAN", "KG", LocalDate.of(1997, 3, 9), "F"), + passportCoverageFixture(114, 15, "찬다라 소쿤", "CHANDARA SOKUN", "SOKUN", "CHANDARA", + "CAMBODIA", "KH", LocalDate.of(1992, 7, 6), "M"), + passportCoverageFixture(115, 16, "니말 페레라", "NIMAL PERERA", "PERERA", "NIMAL", + "SRI LANKA", "LK", LocalDate.of(1991, 1, 23), "M"), + passportCoverageFixture(116, 17, "알리 칸", "ALI KHAN", "KHAN", "ALI", + "PAKISTAN", "PK", LocalDate.of(1993, 5, 18), "M"), + passportCoverageFixture(117, 18, "모하메드 라힘", "MOHAMMED RAHIM", "RAHIM", "MOHAMMED", + "BANGLADESH", "BD", LocalDate.of(1990, 9, 12), "M"), + passportCoverageFixture(118, 19, "누르 아지자", "NUR AZIZAH", "AZIZAH", "NUR", + "INDONESIA", "ID", LocalDate.of(1998, 11, 29), "F"), + passportCoverageFixture(119, 20, "아지즈 라히모프", "AZIZ RAKHIMOV", "RAKHIMOV", "AZIZ", + "UZBEKISTAN", "UZ", LocalDate.of(1992, 2, 8), "M"), + passportCoverageFixture(120, 21, "알렉세이 이바노프", "ALEXEI IVANOV", "IVANOV", "ALEXEI", + "RUSSIAN FED.", "RU", LocalDate.of(1989, 12, 20), "M"), + passportCoverageFixture(121, 22, "마리아 산토스", "MARIA SANTOS", "SANTOS", "MARIA", + "PHILIPPINES", "PH", LocalDate.of(1996, 8, 3), "F"), + passportCoverageFixture(122, 23, "솜차이 차이야", "SOMCHAI CHAIYA", "CHAIYA", "SOMCHAI", + "THAILAND", "TH", LocalDate.of(1991, 6, 26), "M"), + passportCoverageFixture(123, 24, "응우옌 티 란", "NGUYEN THI LAN", "NGUYEN", "THI LAN", + "VIET NAM", "VN", LocalDate.of(1997, 10, 14), "F"), + passportCoverageFixture(124, 25, "데위 사푸트리", "DEWI SAPUTRI", "SAPUTRI", "DEWI", + "INDONESIA", "ID", LocalDate.of(1995, 3, 31), "F"), + passportCoverageFixture(125, 26, "조제 다 코스타", "JOSE DA COSTA", "DA COSTA", "JOSE", + "TIMOR-LESTE", "TL", LocalDate.of(1990, 7, 17), "M"), + passportCoverageFixture(126, 27, "압둘 카림", "ABDUL KARIM", "KARIM", "ABDUL", + "BANGLADESH", "BD", LocalDate.of(1994, 4, 5), "M"), + passportCoverageFixture(127, 28, "비벡 타파", "BIBEK THAPA", "THAPA", "BIBEK", + "NEPAL", "NP", LocalDate.of(1993, 1, 28), "M") + ); + + private static final List FIXTURES = Stream.concat( + BASE_FIXTURES.stream(), PASSPORT_COVERAGE_FIXTURES.stream() + ).toList(); + private DemoDocumentFixtureCatalog() { } @@ -71,6 +136,85 @@ static List fixtures() { return FIXTURES; } + static List passportCoverageFixtures() { + return PASSPORT_COVERAGE_FIXTURES; + } + + static PassportIdentity identityForWorkerId(UUID workerId) { + return FIXTURES.stream() + .filter(fixture -> fixture.workerId().equals(workerId)) + .map(DemoDocumentFixture::passportIdentity) + .distinct() + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "demo worker identity is not defined: " + workerId + )); + } + + private static DemoDocumentFixture passportCoverageFixture( + int documentNumber, + int workerNumber, + String displayName, + String englishName, + String surname, + String givenNames, + String nationality, + String nationalityCode, + LocalDate birthDate, + String sex + ) { + return passportImageFixture( + documentNumber, + workerNumber, + -180 - workerNumber, + 540 + workerNumber * 7, + "여권사본_%s.png".formatted(displayName.replace(" ", "")), + "passport-copy-worker-%02d.png".formatted(workerNumber), + "Passport copy - " + englishName, + identity( + displayName, + englishName, + surname, + givenNames, + nationality, + nationalityCode, + birthDate, + sex, + workerNumber + ) + ); + } + + private static DemoDocumentFixture passportImageFixture( + int documentNumber, + int workerNumber, + Integer issueDays, + Integer expiryDays, + String originalFilename, + String storageFilename, + String title, + PassportIdentity passportIdentity + ) { + return new DemoDocumentFixture( + documentId(documentNumber), + fileId(documentNumber), + workerId(workerNumber), + null, + DocumentType.PASSPORT_COPY, + SubmissionStatus.VERIFIED, + issueDays, + expiryDays, + originalFilename, + storageFilename, + "image/png", + FixtureFormat.PNG, + title, + destination(DocumentType.PASSPORT_COPY), + "DEMO/SAMPLE fixture - not for official submission", + passportIdentity + ); + } + private static DemoDocumentFixture fixture( int number, int workerNumber, @@ -100,10 +244,83 @@ private static DemoDocumentFixture fixture( format, title, destination(documentType), - "DEMO/SAMPLE fixture - not for official submission" + "DEMO/SAMPLE fixture - not for official submission", + identityForWorker(workerNumber) ); } + private static PassportIdentity identity( + String displayName, + String englishName, + String surname, + String givenNames, + String nationality, + String nationalityCode, + LocalDate birthDate, + String sex, + int portraitSeed + ) { + return new PassportIdentity( + displayName, + englishName, + surname, + givenNames, + nationality, + nationalityCode, + preferredLanguage(portraitSeed, nationalityCode), + birthDate, + sex, + "E-9", + "DEMO-P%02d-NOT-VALID".formatted(portraitSeed), + "DEMO-ARC-%02d-NOT-VALID".formatted(portraitSeed), + "DEMO RESIDENCE %02d, SAMPLE-RO, FOWOCO CITY".formatted(portraitSeed), + portraitSeed + ); + } + + private static PassportIdentity identityForWorker(int workerNumber) { + return switch (workerNumber) { + case 1 -> identity("리웨이", "LI WEI", "LI", "WEI", "CHINA", "CN", + LocalDate.of(1992, 3, 14), "M", 1); + case 2 -> identity("속 체아", "SOK CHEA", "SOK", "CHEA", "CAMBODIA", "KH", + LocalDate.of(1996, 7, 21), "M", 2); + case 3 -> identity("아르준 타파", "ARJUN THAPA", "THAPA", "ARJUN", "NEPAL", "NP", + LocalDate.of(1991, 11, 8), "M", 3); + case 4 -> identity("부디 산토소", "BUDI SANTOSO", "SANTOSO", "BUDI", "INDONESIA", "ID", + LocalDate.of(1994, 2, 17), "M", 4); + case 6 -> identity("응웬반A", "NGUYEN VAN AN", "NGUYEN", "VAN AN", "VIET NAM", "VN", + LocalDate.of(1995, 4, 12), "M", 6); + default -> throw new IllegalArgumentException( + "base document fixture worker identity is not defined: " + workerNumber + ); + }; + } + + private static String preferredLanguage(int workerNumber, String nationalityCode) { + if (workerNumber == 22) { + return "fil"; + } + return switch (nationalityCode) { + case "CN" -> "zh-Hans"; + case "KH" -> "km"; + case "NP", "PH", "MM" -> "en"; + case "ID" -> "id"; + case "MN" -> "mn"; + case "VN" -> "vi"; + case "KG" -> "ky"; + case "LK" -> "si"; + case "PK" -> "ur"; + case "BD" -> "bn"; + case "UZ" -> "uz"; + case "RU" -> "ru"; + case "TH" -> "th"; + case "TL" -> "tet"; + default -> throw new IllegalArgumentException( + "demo worker language is not defined for nationality: " + nationalityCode + ); + }; + } + private static String destination(DocumentType type) { return switch (type) { case PASSPORT_COPY, ARC, INTEGRATED_APPLICATION, RESIDENCE_PROOF -> "체류기간 연장"; @@ -156,7 +373,8 @@ record DemoDocumentFixture( FixtureFormat format, String title, String destination, - String note + String note, + PassportIdentity passportIdentity ) { String storageKey() { if (fileId == null) { @@ -167,4 +385,22 @@ String storageKey() { ); } } + + record PassportIdentity( + String displayName, + String englishName, + String surname, + String givenNames, + String nationality, + String nationalityCode, + String preferredLanguage, + LocalDate birthDate, + String sex, + String visaType, + String documentNumber, + String alienRegistrationNumber, + String syntheticAddress, + int portraitSeed + ) { + } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoLegacyDocumentFileMaterializer.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoLegacyDocumentFileMaterializer.java new file mode 100644 index 00000000..bae7063c --- /dev/null +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/DemoLegacyDocumentFileMaterializer.java @@ -0,0 +1,390 @@ +package com.fowoco.server.demo.infrastructure.documentdata; + +import com.fowoco.server.demo.infrastructure.DemoDocumentFileIds; +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.DemoDocumentFixture; +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.FixtureFormat; +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.PassportIdentity; +import com.fowoco.server.worker.domain.DocumentType; +import com.fowoco.server.worker.domain.SubmissionStatus; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; + +/** Adds deterministic files to the pre-existing operational demo document metadata rows. */ +final class DemoLegacyDocumentFileMaterializer { + + private static final String SOURCE = "LEGACY"; + private static final String PURPOSE = "DEMO_LEGACY_DOCUMENT"; + private static final UUID ORIGINAL_CONTRACT_DOCUMENT_ID = + UUID.fromString("95000000-0000-0000-0000-000000000007"); + private static final UUID ORIGINAL_CONTRACT_FILE_ID = + UUID.fromString("94800000-0000-0000-0000-000000000001"); + + private final JdbcTemplate jdbcTemplate; + private final DemoDocumentFileInstaller fileInstaller; + private final SyntheticDocumentGenerator generator; + + DemoLegacyDocumentFileMaterializer( + JdbcTemplate jdbcTemplate, + DemoDocumentFileInstaller fileInstaller, + SyntheticDocumentGenerator generator + ) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + this.fileInstaller = Objects.requireNonNull(fileInstaller, "fileInstaller must not be null"); + this.generator = Objects.requireNonNull(generator, "generator must not be null"); + } + + MaterializedFiles importFiles(LocalDate anchorDate, Instant now) { + for (DemoDocumentFixture fixture : fixtures(anchorDate)) { + byte[] content = content(fixture, anchorDate); + fileInstaller.install(fixture.storageKey(), content); + seedStoredFile(fixture, content, now); + linkWorkerDocument(fixture, now); + } + return verifyFiles(anchorDate); + } + + MaterializedFiles verifyFiles(LocalDate anchorDate) { + int imageCount = 0; + int pdfCount = 0; + List fixtures = fixtures(anchorDate); + for (DemoDocumentFixture fixture : fixtures) { + byte[] content = content(fixture, anchorDate); + verifyWorkerDocumentLink(fixture); + List> rows = jdbcTemplate.queryForList( + "SELECT * FROM stored_file WHERE stored_file_id = ? AND company_id = ?", + fixture.fileId(), + DemoDocumentFixtureCatalog.COMPANY_ID + ); + if (rows.size() != 1) { + throw new IllegalStateException("materialized demo stored file is missing"); + } + verifyStoredFile(fixture, content, rows.get(0)); + fileInstaller.verify(fixture.storageKey(), content); + switch (fixture.format()) { + case PNG, JPEG -> imageCount++; + case PDF -> pdfCount++; + default -> throw new IllegalStateException( + "operational demo documents must materialize as image or PDF" + ); + } + } + return new MaterializedFiles(fixtures.size(), imageCount, pdfCount); + } + + void cleanupFiles(LocalDate anchorDate, Instant now) { + for (DemoDocumentFixture fixture : fixtures(anchorDate)) { + UUID currentFileId = workerDocumentFileId(fixture.documentId()); + if (!fixture.fileId().equals(currentFileId)) { + if (!Objects.equals(originalFileId(fixture.documentId()), currentFileId)) { + throw new IllegalStateException( + "operational demo document contains an unexpected file link" + ); + } + continue; + } + byte[] content = content(fixture, anchorDate); + List> files = jdbcTemplate.queryForList( + "SELECT * FROM stored_file WHERE stored_file_id = ? AND company_id = ?", + fixture.fileId(), + DemoDocumentFixtureCatalog.COMPANY_ID + ); + if (files.size() != 1) { + throw new IllegalStateException("materialized demo stored file is missing"); + } + verifyStoredFile(fixture, content, files.get(0)); + fileInstaller.verify(fixture.storageKey(), content); + int updated = jdbcTemplate.update( + "UPDATE worker_document SET file_id = ?, updated_at = ?, version = version + 1 " + + "WHERE worker_document_id = ? AND company_id = ? AND file_id = ?", + originalFileId(fixture.documentId()), + Timestamp.from(now), + fixture.documentId(), + DemoDocumentFixtureCatalog.COMPANY_ID, + fixture.fileId() + ); + if (updated != 1) { + throw new IllegalStateException("failed to detach materialized operational demo file"); + } + jdbcTemplate.update( + "DELETE FROM stored_file WHERE stored_file_id = ? AND company_id = ?", + fixture.fileId(), + DemoDocumentFixtureCatalog.COMPANY_ID + ); + fileInstaller.cleanup(fixture.storageKey(), content); + } + } + + private List fixtures(LocalDate anchorDate) { + List> rows = jdbcTemplate.queryForList( + """ + SELECT wd.worker_document_id, wd.worker_id, wd.task_id, wd.document_type, + wd.submission_status, wd.issue_date, wd.expiry_date, wd.destination, + wd.note, wd.file_id, w.display_name, w.nationality_code, + w.preferred_language, w.visa_type + FROM worker_document wd + JOIN worker w + ON w.worker_id = wd.worker_id + AND w.company_id = wd.company_id + WHERE wd.company_id = ? + AND wd.source = ? + ORDER BY wd.worker_document_id + """, + DemoDocumentFixtureCatalog.COMPANY_ID, + SOURCE + ); + List fixtures = new ArrayList<>(); + for (Map row : rows) { + UUID documentId = (UUID) row.get("worker_document_id"); + if (!DemoDocumentFileIds.isOperationalDemoDocumentId(documentId)) { + continue; + } + SubmissionStatus status = SubmissionStatus.valueOf(row.get("submission_status").toString()); + if (status == SubmissionStatus.MISSING) { + continue; + } + UUID workerId = (UUID) row.get("worker_id"); + PassportIdentity identity = DemoDocumentFixtureCatalog.identityForWorkerId(workerId); + verifyWorkerIdentity(identity, row); + DocumentType documentType = DocumentType.valueOf(row.get("document_type").toString()); + FixtureFormat format = format(documentType); + String extension = format == FixtureFormat.PDF ? "pdf" : "png"; + LocalDate expiryDate = localDate(row.get("expiry_date")); + String storageFilename = "legacy-%s-%s.%s".formatted( + documentType.name().toLowerCase(Locale.ROOT).replace('_', '-'), + documentId.toString().substring(documentId.toString().length() - 3), + extension + ); + fixtures.add(new DemoDocumentFixture( + documentId, + DemoDocumentFileIds.materializedFileId(documentId), + workerId, + (UUID) row.get("task_id"), + documentType, + status, + days(anchorDate, localDate(row.get("issue_date"))), + days(anchorDate, expiryDate), + originalFilename(documentType, identity, documentId, extension), + storageFilename, + format == FixtureFormat.PDF ? "application/pdf" : "image/png", + format, + title(documentType), + Objects.toString(row.get("destination"), "NOT SET"), + Objects.toString(row.get("note"), "NOT SET"), + identity + )); + } + if (fixtures.size() != 67) { + throw new IllegalStateException( + "operational Demo Company must have exactly 67 non-missing document rows" + ); + } + return List.copyOf(fixtures); + } + + private void seedStoredFile(DemoDocumentFixture fixture, byte[] content, Instant now) { + List> existing = jdbcTemplate.queryForList( + "SELECT * FROM stored_file WHERE stored_file_id = ?", + fixture.fileId() + ); + if (!existing.isEmpty()) { + verifyStoredFile(fixture, content, existing.get(0)); + return; + } + jdbcTemplate.update( + """ + INSERT INTO stored_file ( + stored_file_id, company_id, name, mime_type, size, purpose, + task_id, worker_id, storage_key, scan_status, verified, + checksum_sha256, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'NOT_SCANNED', TRUE, ?, ?, ?) + """, + fixture.fileId(), + DemoDocumentFixtureCatalog.COMPANY_ID, + fixture.originalFilename(), + fixture.contentType(), + content.length, + PURPOSE, + fixture.taskId(), + fixture.workerId(), + fixture.storageKey(), + DemoDocumentFileInstaller.sha256(content), + Timestamp.from(now), + Timestamp.from(now) + ); + } + + private void linkWorkerDocument(DemoDocumentFixture fixture, Instant now) { + UUID currentFileId = workerDocumentFileId(fixture.documentId()); + if (fixture.fileId().equals(currentFileId)) { + return; + } + if (!Objects.equals(originalFileId(fixture.documentId()), currentFileId)) { + throw new IllegalStateException( + "operational demo document already links to an unexpected file" + ); + } + int updated = currentFileId == null + ? jdbcTemplate.update( + "UPDATE worker_document SET file_id = ?, updated_at = ?, version = version + 1 " + + "WHERE worker_document_id = ? AND company_id = ? AND source = ? " + + "AND file_id IS NULL", + fixture.fileId(), + Timestamp.from(now), + fixture.documentId(), + DemoDocumentFixtureCatalog.COMPANY_ID, + SOURCE + ) + : jdbcTemplate.update( + "UPDATE worker_document SET file_id = ?, updated_at = ?, version = version + 1 " + + "WHERE worker_document_id = ? AND company_id = ? AND source = ? " + + "AND file_id = ?", + fixture.fileId(), + Timestamp.from(now), + fixture.documentId(), + DemoDocumentFixtureCatalog.COMPANY_ID, + SOURCE, + currentFileId + ); + if (updated != 1) { + throw new IllegalStateException("failed to link materialized operational demo file"); + } + } + + private void verifyWorkerDocumentLink(DemoDocumentFixture fixture) { + if (!fixture.fileId().equals(workerDocumentFileId(fixture.documentId()))) { + throw new IllegalStateException("operational demo document file link is missing"); + } + } + + private UUID workerDocumentFileId(UUID documentId) { + List> rows = jdbcTemplate.queryForList( + "SELECT file_id FROM worker_document WHERE worker_document_id = ? AND company_id = ? " + + "AND source = ?", + documentId, + DemoDocumentFixtureCatalog.COMPANY_ID, + SOURCE + ); + if (rows.size() != 1) { + throw new IllegalStateException("operational demo document metadata row is missing"); + } + return (UUID) rows.get(0).get("file_id"); + } + + private void verifyStoredFile( + DemoDocumentFixture fixture, + byte[] content, + Map row + ) { + if (!fixture.fileId().equals(row.get("stored_file_id")) + || !DemoDocumentFixtureCatalog.COMPANY_ID.equals(row.get("company_id")) + || !fixture.originalFilename().equals(row.get("name")) + || !fixture.contentType().equals(row.get("mime_type")) + || ((Number) row.get("size")).longValue() != content.length + || !PURPOSE.equals(row.get("purpose")) + || !Objects.equals(fixture.taskId(), row.get("task_id")) + || !fixture.workerId().equals(row.get("worker_id")) + || !fixture.storageKey().equals(row.get("storage_key")) + || !"NOT_SCANNED".equals(row.get("scan_status").toString()) + || !Boolean.TRUE.equals(row.get("verified")) + || !DemoDocumentFileInstaller.sha256(content).equals(row.get("checksum_sha256"))) { + throw new IllegalStateException( + "materialized demo stored file id belongs to different data" + ); + } + } + + private void verifyWorkerIdentity(PassportIdentity identity, Map row) { + if (!identity.displayName().equals(row.get("display_name")) + || !identity.nationalityCode().equals(row.get("nationality_code")) + || !identity.preferredLanguage().equals(row.get("preferred_language")) + || !identity.visaType().equals(row.get("visa_type"))) { + throw new IllegalStateException( + "operational document metadata does not match the linked worker identity" + ); + } + } + + private byte[] content(DemoDocumentFixture fixture, LocalDate anchorDate) { + return generator.generate( + fixture, + relativeDate(anchorDate, fixture.issueDays()), + relativeDate(anchorDate, fixture.expiryDays()) + ); + } + + private FixtureFormat format(DocumentType type) { + return switch (type) { + case PASSPORT_COPY, ARC -> FixtureFormat.PNG; + case CONTRACT, PERMIT -> FixtureFormat.PDF; + default -> throw new IllegalArgumentException( + "unsupported operational demo document type: " + type + ); + }; + } + + private String originalFilename( + DocumentType type, + PassportIdentity identity, + UUID documentId, + String extension + ) { + String name = switch (type) { + case PASSPORT_COPY -> "여권사본"; + case ARC -> "외국인등록증"; + case CONTRACT -> "근로계약서"; + case PERMIT -> "고용허가서"; + default -> throw new IllegalArgumentException("unsupported document type: " + type); + }; + return "%s_%s_%s.%s".formatted( + name, + identity.displayName().replace(" ", ""), + documentId.toString().substring(documentId.toString().length() - 3), + extension + ); + } + + private String title(DocumentType type) { + return switch (type) { + case PASSPORT_COPY -> "Operational metadata passport copy"; + case ARC -> "Operational metadata residence card copy"; + case CONTRACT -> "Operational metadata employment contract"; + case PERMIT -> "Operational metadata employment permit"; + default -> throw new IllegalArgumentException("unsupported document type: " + type); + }; + } + + private UUID originalFileId(UUID documentId) { + return ORIGINAL_CONTRACT_DOCUMENT_ID.equals(documentId) ? ORIGINAL_CONTRACT_FILE_ID : null; + } + + private Integer days(LocalDate anchorDate, LocalDate value) { + return value == null ? null : Math.toIntExact(ChronoUnit.DAYS.between(anchorDate, value)); + } + + private LocalDate relativeDate(LocalDate anchorDate, Integer days) { + return days == null ? null : anchorDate.plusDays(days); + } + + private LocalDate localDate(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDate localDate) { + return localDate; + } + return ((Date) value).toLocalDate(); + } + + record MaterializedFiles(int fileCount, int imageCount, int pdfCount) { + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGenerator.java b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGenerator.java index c2bc24ba..6073f72e 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGenerator.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGenerator.java @@ -1,83 +1,533 @@ package com.fowoco.server.demo.infrastructure.documentdata; import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.DemoDocumentFixture; +import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.PassportIdentity; +import com.fowoco.server.worker.domain.DocumentType; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Font; +import java.awt.FontFormatException; +import java.awt.GradientPaint; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.zip.CRC32; import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import javax.imageio.ImageIO; import org.apache.poi.poifs.filesystem.POIFSFileSystem; final class SyntheticDocumentGenerator { - private static final List IDENTITY_LINES = List.of( - "DISPLAY NAME: NGUYEN VAN AN", - "NATIONALITY: VIET NAM (VN)", - "PREFERRED LANGUAGE: vi", - "VISA: E-9", - "SYNTHETIC REFERENCE: DEMO-VN-FOWOCO-0001", - "DATE OF BIRTH: 1995-04-12 (SYNTHETIC)", - "ADDRESS: DEMO ADDRESS 14, SAMPLE-RO, FOWOCO" - ); - - byte[] generate(DemoDocumentFixture fixture) { + private static final String GOLD_WORKER_PORTRAIT = "/demo-data/nguyen-van-an-portrait.png"; + private static final String KOREAN_FONT = "/demo-data/fonts/NotoSansKR-Regular.otf"; + private static final Font DEMO_FONT = loadDemoFont(); + private static final DateTimeFormatter PASSPORT_DATE = + DateTimeFormatter.ofPattern("dd MMM uuuu", Locale.ENGLISH); + + byte[] generate(DemoDocumentFixture fixture, LocalDate issueDate, LocalDate expiryDate) { return switch (fixture.format()) { - case PNG -> image(fixture, "png"); - case JPEG -> image(fixture, "jpg"); - case PDF -> pdf(fixture); - case HWP -> hwp(fixture); - case HWPX -> hwpx(fixture); + case PNG -> image(fixture, issueDate, expiryDate, "png"); + case JPEG -> image(fixture, issueDate, expiryDate, "jpg"); + case PDF -> pdf(fixture, issueDate, expiryDate); + case HWP -> hwp(fixture, issueDate, expiryDate); + case HWPX -> hwpx(fixture, issueDate, expiryDate); case NONE -> throw new IllegalArgumentException("missing fixtures do not have file content"); }; } - private byte[] image(DemoDocumentFixture fixture, String format) { + private byte[] image( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate, + String format + ) { + return switch (fixture.documentType()) { + case PASSPORT_COPY -> passportBiographicalPage(fixture, issueDate, expiryDate, format); + case ARC -> encodeImage(residenceCard(fixture, issueDate, expiryDate), format); + default -> encodeImage(documentPages(fixture, issueDate, expiryDate).get(0), format); + }; + } + + private byte[] passportBiographicalPage( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate, + String format + ) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + boolean goldPassport = fixture.documentId().equals(DemoDocumentFixtureCatalog.PASSPORT_BIO_DOCUMENT_ID); + int width = 1400; + int height = 980; + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try { + enableHighQualityRendering(graphics); + graphics.setPaint(new GradientPaint( + 0, 0, new Color(239, 248, 249), + width, height, new Color(252, 234, 224) + )); + graphics.fillRect(0, 0, width, height); + drawPassportBackdrop(graphics, width, height); + + graphics.setColor(new Color(20, 61, 104)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 23)); + graphics.drawString("DEMO PASSPORT", 56, 72); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 28)); + graphics.drawString("FOWOCO SYNTHETIC IDENTITY PAGE", 450, 72); + drawDemoBadge(graphics); + + graphics.setColor(new Color(20, 61, 104, 120)); + graphics.setStroke(new BasicStroke(2f)); + graphics.drawLine(55, 108, width - 55, 108); + + drawPortrait(graphics, fixture); + + graphics.setColor(new Color(25, 55, 94)); + graphics.setStroke(new BasicStroke(2f)); + graphics.drawRoundRect(52, 160, 350, 492, 14, 14); + + drawField(graphics, "Type", "SAMPLE", 440, 145); + drawField(graphics, "Fictional code", "XDM", 645, 145); + drawField(graphics, "Document No.", identity.documentNumber(), 875, 145); + drawField(graphics, "Surname", identity.surname(), 440, 230); + drawField(graphics, "Given names", identity.givenNames(), 780, 230); + drawField(graphics, "Nationality", + "%s (%s)".formatted(identity.nationality(), identity.nationalityCode()), 440, 325); + drawField(graphics, "Date of birth", passportDate(identity.birthDate()), 800, 325); + drawField(graphics, "Sex", identity.sex(), 1180, 325); + drawField(graphics, "Place of birth", "DEMO CITY", 440, 420); + drawField(graphics, "Visa / Stay", identity.visaType(), 735, 420); + drawField(graphics, "Fictional authority", "FOWOCO QA LAB", 940, 420); + drawField(graphics, "Date of issue", passportDate(issueDate), 440, 515); + drawField(graphics, "Date of expiry", passportDate(expiryDate), 760, 515); + drawField(graphics, "Document status", "DEMO · NOT VALID", 1080, 515); + + graphics.setColor(new Color(71, 85, 105)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 16)); + String holderLabel = goldPassport + ? "SYNTHETIC HOLDER · NGUYEN VAN AN · DEMO PROFILE" + : "SYNTHETIC HOLDER · %s · PROFILE %02d".formatted( + identity.englishName(), identity.portraitSeed() + ); + graphics.drawString(holderLabel, 440, 650); + + graphics.setColor(new Color(20, 61, 104, 90)); + graphics.drawRoundRect(1185, 210, 140, 100, 16, 16); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 52)); + graphics.drawString("D", 1234, 274); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 11)); + graphics.drawString("DEMO MARK", 1219, 296); + + graphics.setColor(new Color(250, 248, 241)); + graphics.fillRect(0, 705, width, 225); + graphics.setColor(new Color(20, 61, 104, 120)); + graphics.drawLine(42, 705, width - 42, 705); + graphics.setColor(new Color(17, 38, 66)); + graphics.setFont(new Font(Font.MONOSPACED, Font.BOLD, 31)); + String mrzLine1 = goldPassport + ? "P targetRatio) { + sourceWidth = (int) Math.round(sourceHeight * targetRatio); + sourceX = (portrait.getWidth() - sourceWidth) / 2; + } else { + sourceHeight = (int) Math.round(sourceWidth / targetRatio); + sourceY = Math.max(0, (portrait.getHeight() - sourceHeight) / 3); + } + graphics.drawImage( + portrait, + x, y, x + width, y + height, + sourceX, sourceY, sourceX + sourceWidth, sourceY + sourceHeight, + null + ); + } + + private void drawField(Graphics2D graphics, String label, String value, int x, int y) { + graphics.setColor(new Color(48, 79, 112)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 13)); + graphics.drawString(label, x, y); + graphics.setColor(new Color(15, 23, 42)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 22)); + graphics.drawString(value, x, y + 29); + } + + private void drawCompactField(Graphics2D graphics, String label, String value, int x, int y) { + graphics.setColor(new Color(48, 79, 112)); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 14)); + graphics.drawString(label, x, y); + graphics.setColor(new Color(15, 23, 42)); + graphics.setFont(new Font(Font.MONOSPACED, Font.BOLD, value.length() > 40 ? 16 : 21)); + graphics.drawString(value, x, y + 30); + } + + private void drawPassportBackdrop(Graphics2D graphics, int width, int height) { + graphics.setColor(new Color(255, 255, 255, 238)); + graphics.fillRoundRect(28, 125, width - 56, height - 410, 24, 24); + } + + private void drawDemoBadge(Graphics2D graphics) { + graphics.setColor(new Color(20, 61, 104)); + graphics.fillRoundRect(1260, 28, 82, 62, 12, 12); + graphics.setColor(new Color(240, 191, 87)); + graphics.fillOval(1274, 40, 34, 34); + graphics.setColor(Color.WHITE); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 18)); + graphics.drawString("D", 1285, 64); + graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 10)); + graphics.drawString("DEMO", 1310, 66); + } + + private String fitMrz(String value) { + String normalized = mrz(value); + String padded = normalized + "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"; + return padded.substring(0, 44); + } + + private String passportDate(LocalDate value) { + return value == null ? "NOT SET" : PASSPORT_DATE.format(value).toUpperCase(Locale.ENGLISH); + } + + private String mrz(String value) { + return value.toUpperCase(Locale.ENGLISH).replaceAll("[^A-Z ]", "").replace(' ', '<'); + } + + private void enableHighQualityRendering(Graphics2D graphics) { + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, + RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BICUBIC); + } + + private byte[] encodeImage(BufferedImage image, String format) { try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { if (!ImageIO.write(image, format, output)) { throw new IllegalStateException("ImageIO writer is unavailable: " + format); @@ -88,32 +538,43 @@ private byte[] image(DemoDocumentFixture fixture, String format) { } } - private byte[] pdf(DemoDocumentFixture fixture) { - List lines = new ArrayList<>(); - lines.add("DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION"); - lines.add(fixture.title()); - lines.addAll(IDENTITY_LINES); - lines.add("NO GOVERNMENT LOGO, SECURITY PATTERN, OR OFFICIAL SIGNATURE"); - - StringBuilder stream = new StringBuilder("BT\n/F1 12 Tf\n50 790 Td\n"); - for (int index = 0; index < lines.size(); index++) { - if (index > 0) { - stream.append("0 -42 Td\n"); - } - stream.append('(').append(pdfEscape(lines.get(index))).append(") Tj\n"); - } - stream.append("ET\n"); - byte[] streamBytes = stream.toString().getBytes(StandardCharsets.US_ASCII); - - List objects = List.of( - ascii("<< /Type /Catalog /Pages 2 0 R >>"), - ascii("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), - ascii("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] " - + "/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"), - concat(ascii("<< /Length " + streamBytes.length + " >>\nstream\n"), - streamBytes, ascii("endstream")), - ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") - ); + private byte[] pdf(DemoDocumentFixture fixture, LocalDate issueDate, LocalDate expiryDate) { + List pages = documentPages(fixture, issueDate, expiryDate); + List jpegPages = pages.stream().map(this::jpeg).toList(); + int objectCount = 2 + jpegPages.size() * 3; + List objects = new ArrayList<>(objectCount); + String kids = java.util.stream.IntStream.range(0, jpegPages.size()) + .mapToObj(index -> (3 + index * 3) + " 0 R") + .collect(java.util.stream.Collectors.joining(" ")); + String searchableMetadata = metadataLines(fixture, issueDate, expiryDate, false).stream() + .map(this::asciiOnly) + .collect(java.util.stream.Collectors.joining(" | ")); + objects.add(ascii("<< /Type /Catalog /Pages 2 0 R /FowocoDemoMetadata (" + + pdfEscape(searchableMetadata) + ") >>")); + objects.add(ascii("<< /Type /Pages /Kids [" + kids + "] /Count " + jpegPages.size() + " >>")); + for (int index = 0; index < jpegPages.size(); index++) { + int pageObject = 3 + index * 3; + int contentsObject = pageObject + 1; + int imageObject = pageObject + 2; + byte[] jpeg = jpegPages.get(index); + byte[] content = ascii("q\n595 0 0 842 0 0 cm\n/Im0 Do\nQ\n"); + objects.add(ascii("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] " + + "/Resources << /XObject << /Im0 " + imageObject + " 0 R >> >> " + + "/Contents " + contentsObject + " 0 R >>")); + objects.add(concat( + ascii("<< /Length " + content.length + " >>\nstream\n"), + content, + ascii("endstream") + )); + objects.add(concat( + ascii("<< /Type /XObject /Subtype /Image /Width " + pages.get(index).getWidth() + + " /Height " + pages.get(index).getHeight() + + " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode " + + "/Length " + jpeg.length + " >>\nstream\n"), + jpeg, + ascii("\nendstream") + )); + } ByteArrayOutputStream output = new ByteArrayOutputStream(); write(output, ascii("%PDF-1.4\n% FOWOCO synthetic fixture\n")); @@ -133,17 +594,573 @@ streamBytes, ascii("endstream")), return output.toByteArray(); } - private byte[] hwp(DemoDocumentFixture fixture) { - try (POIFSFileSystem fileSystem = new POIFSFileSystem(); + private List documentPages( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + return switch (fixture.documentType()) { + case PASSPORT_COPY -> List.of(passportCopyPage(fixture, issueDate, expiryDate)); + case ARC -> List.of(arcCombinedCopyPage(fixture, issueDate, expiryDate)); + case CONTRACT -> employmentContractPages(fixture, issueDate, expiryDate); + case PERMIT -> List.of(employmentPermitPage(fixture, issueDate, expiryDate)); + case EMPLOYMENT_EXTENSION_APPLICATION -> + List.of(employmentExtensionPage(fixture, issueDate, expiryDate)); + case INTEGRATED_APPLICATION -> + List.of(integratedApplicationPage(fixture, issueDate, expiryDate)); + case RESIDENCE_PROOF -> List.of(residenceProofPage(fixture, issueDate, expiryDate)); + }; + } + + private BufferedImage passportCopyPage( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + BufferedImage page = a4Page(); + Graphics2D graphics = page.createGraphics(); + try { + enableHighQualityRendering(graphics); + drawFormHeader(graphics, "여권 사본", "PASSPORT COPY", "원본 대조용 합성 QA 문서"); + BufferedImage passport = ImageIO.read(new ByteArrayInputStream( + passportBiographicalPage(fixture, issueDate, expiryDate, "png") + )); + graphics.drawImage(passport, 82, 315, 1076, 754, null); + drawMetadataFooter(graphics, fixture); + } catch (IOException exception) { + throw new UncheckedIOException("failed to render synthetic passport PDF", exception); + } finally { + graphics.dispose(); + } + return page; + } + + private BufferedImage arcCombinedCopyPage( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + BufferedImage page = a4Page(); + Graphics2D graphics = page.createGraphics(); + try { + enableHighQualityRendering(graphics); + drawFormHeader(graphics, "외국인등록증 통합 사본", "RESIDENCE CARD COMBINED COPY", + "앞면·뒷면 합성본 / 유효기간 임박 시나리오"); + BufferedImage front = residenceCardFront(fixture, issueDate, expiryDate); + BufferedImage back = residenceCardBack(fixture, issueDate, expiryDate); + graphics.drawImage(front, 90, 280, 1060, 670, null); + graphics.drawImage(back, 90, 995, 1060, 670, null); + drawMetadataFooter(graphics, fixture); + } finally { + graphics.dispose(); + } + return page; + } + + private BufferedImage employmentPermitPage( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + BufferedImage page = a4Page(); + Graphics2D graphics = page.createGraphics(); + try { + enableHighQualityRendering(graphics); + drawFormHeader(graphics, "외국인근로자 고용허가서", "EMPLOYMENT PERMIT", + "별지 제5호서식 필드 구조 기반 · 합성 QA 작성본"); + int y = 270; + y = drawSection(graphics, "1. 고용허가 기본정보", y, + List.of( + field("고용허가서 발급번호", documentNumber(fixture)), + field("발급일자", metadataDate(issueDate)), + field("고용허가기간", metadataDate(issueDate) + " ~ " + metadataDate(expiryDate)) + )); + y = drawSection(graphics, "2. 사업장 정보", y, + List.of( + field("사업장명 / 대표자", demoCompanyName() + " / " + demoEmployerName()), + field("사업자등록번호 / 전화", demoBusinessNumber() + " / " + demoCompanyPhone()), + field("소재지", demoCompanyAddress()), + field("업종 / 사업내용", "제조업 / 합성 QA 데이터용 부품 조립"), + field("상시 근로자 수", "총 42명 (내국인 14명 / 외국인 28명)") + )); + y = drawSection(graphics, "3. 외국인근로자 인적사항", y, + List.of( + field("성명(영어)", identity.englishName()), + field("국적", identity.nationality() + " / " + identity.nationalityCode()), + field("외국인등록번호", identity.alienRegistrationNumber()), + field("여권번호", identity.documentNumber()), + field("체류자격", identity.visaType()), + field("한국 내 주소", identity.syntheticAddress()) + )); + y = drawSection(graphics, "4. 고용 조건", y, + List.of( + field("근무장소 / 직무", demoCompanyAddress() + " / 제조 보조"), + field("근로시간", "08:00 ~ 17:00 (휴게 60분, 주 40시간)"), + field("월 통상임금", "2,350,000원 (합성값)"), + field("숙식", "기숙사 제공 / 중식 제공 / 근로자 부담 0원"), + field("가입보험", "고용 · 산재 · 건강 · 출국만기 · 보증") + )); + drawSignatureBlock(graphics, y + 18, issueDate, demoEmployerName(), identity.englishName(), true); + drawMetadataFooter(graphics, fixture); + } finally { + graphics.dispose(); + } + return page; + } + + private List employmentContractPages( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + BufferedImage first = a4Page(); + Graphics2D graphics = first.createGraphics(); + try { + enableHighQualityRendering(graphics); + drawFormHeader(graphics, "표준근로계약서", "STANDARD LABOR CONTRACT", + "외국인근로자 고용법 시행규칙 별지 제6호서식 구조 기반"); + int y = 270; + y = drawSection(graphics, "사용자 / Employer", y, + List.of( + field("업체명", demoCompanyName()), + field("전화번호", demoCompanyPhone()), + field("소재지", demoCompanyAddress()), + field("사용자 성명", demoEmployerName()), + field("사업자등록번호", demoBusinessNumber()) + )); + y = drawSection(graphics, "근로자 / Employee", y, + List.of( + field("성명", identity.englishName() + " (" + identity.displayName() + ")"), + field("생년월일", identity.birthDate().toString()), + field("본국 주소", homeCountryAddress(identity)), + field("국적 / 체류자격", identity.nationality() + " / " + identity.visaType()) + )); + y = drawSection(graphics, "1-3. 계약기간 · 근로장소 · 업무", y, + List.of( + field("근로계약기간", metadataDate(issueDate) + " ~ " + metadataDate(expiryDate)), + field("수습기간", "미활용"), + field("근로장소", demoCompanyAddress()), + field("업종 / 직무", "제조업 / 합성 QA 데이터용 부품 조립 및 검수") + )); + y = drawSection(graphics, "4-6. 근로시간 · 휴게 · 휴일", y, + List.of( + field("근로시간", "08:00 ~ 17:00 / 주 40시간"), + field("평균 시간외 근로", "1일 1시간 이내 (사전 합의 시)"), + field("교대제", "미운영"), + field("휴게시간", "12:00 ~ 13:00 / 1일 60분"), + field("휴일", "매주 일요일 및 법정 공휴일 / 유급") + )); + drawMetadataFooter(graphics, fixture); + } finally { + graphics.dispose(); + } + + BufferedImage second = a4Page(); + graphics = second.createGraphics(); + try { + enableHighQualityRendering(graphics); + drawFormHeader(graphics, "표준근로계약서 (뒤쪽)", "STANDARD LABOR CONTRACT · PAGE 2", + "임금·지급방법·숙식·서명란 작성본"); + int y = 270; + y = drawSection(graphics, "7. 임금 / Payment", y, + List.of( + field("월 통상임금", "2,350,000원 (합성값)"), + field("기본급", "2,250,000원 / 월급"), + field("고정 수당", "직무수당 100,000원"), + field("상여금", "해당 없음"), + field("연장·야간·휴일근로", "통상임금의 50% 가산 지급") + )); + y = drawSection(graphics, "8-9. 임금 지급", y, + List.of( + field("지급일", "매월 25일 (휴일이면 전 영업일)"), + field("지급방법", "근로자 명의 계좌 입금") + )); + y = drawSection(graphics, "10. 숙식 제공", y, + List.of( + field("숙박시설", "제공 / 사업장 외부 기숙사 / 근로자 부담 0원"), + field("식사", "중식 제공 / 근로자 부담 0원") + )); + y = drawSection(graphics, "11-12. 준수 및 기타", y, + List.of( + field("준수", "근로계약·취업규칙·단체협약을 성실히 이행"), + field("기타", "미정 사항은 근로기준법에 따름") + )); + drawSignatureBlock(graphics, y + 35, issueDate, demoEmployerName(), identity.englishName(), true); + drawMetadataFooter(graphics, fixture); + } finally { + graphics.dispose(); + } + return List.of(first, second); + } + + private BufferedImage employmentExtensionPage( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + BufferedImage page = a4Page(); + Graphics2D graphics = page.createGraphics(); + try { + enableHighQualityRendering(graphics); + drawFormHeader(graphics, "취업기간 만료자 취업활동 기간 연장신청서", + "EMPLOYMENT ACTIVITY PERIOD EXTENSION APPLICATION", + "별지 제12호의3서식(2024. 1. 10. 개정) 기반 · 작성 완료 미서명 초안"); + int y = 270; + y = drawSection(graphics, "사업장", y, List.of( + field("사업장명 / 대표자", demoCompanyName() + " / " + demoEmployerName()), + field("소재지", demoCompanyAddress()), + field("전화 / 사업자등록번호", demoCompanyPhone() + " / " + demoBusinessNumber()), + field("사업의 종류", "제조업 / 합성 QA 데이터용 부품 조립") + )); + y = drawSection(graphics, "사실관계 확인", y, List.of( + field("도입 업종", "☑ 해당"), + field("내국인 고용조정 이직 없음", "☑ 확인"), + field("임금체불 없음", "☑ 확인"), + field("고용·산재보험", "☑ 가입"), + field("출국만기·보증보험", "☑ 가입") + )); + y = drawSection(graphics, "취업기간 만료 외국인근로자", y, List.of( + field("성명", identity.englishName()), + field("외국인등록번호", identity.alienRegistrationNumber()), + field("국적 / 여권번호", identity.nationality() + " / " + identity.documentNumber()), + field("체류기간 만료일", metadataDate(expiryDate)), + field("서명 또는 날인", "미서명 · DRAFT") + )); + y = drawSection(graphics, "첨부서류", y, List.of( + field("1", "외국인등록증 사본"), + field("2", "여권 사본"), + field("3", "표준근로계약서 사본") + )); + drawSignatureBlock(graphics, y + 20, issueDate, demoEmployerName(), identity.englishName(), false); + drawMetadataFooter(graphics, fixture); + } finally { + graphics.dispose(); + } + return page; + } + + private BufferedImage integratedApplicationPage( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + BufferedImage page = a4Page(); + Graphics2D graphics = page.createGraphics(); + try { + enableHighQualityRendering(graphics); + drawFormHeader(graphics, "통합신청서(신고서)", "APPLICATION FORM (REPORT FORM)", + "별지 제34호서식 · 체류기간 연장허가 작성 완료 미서명 초안"); + int y = 270; + y = drawSection(graphics, "신청/신고 선택", y, List.of( + field("체류기간 연장허가", "☑ 선택"), + field("그 밖의 신청", "☐ 미선택") + )); + y = drawSection(graphics, "신청인", y, List.of( + field("성명", identity.surname() + " / " + identity.givenNames()), + field("생년월일 / 성별", identity.birthDate() + " / " + identity.sex()), + field("국적", identity.nationality()), + field("외국인등록번호", identity.alienRegistrationNumber()), + field("여권번호", identity.documentNumber()), + field("여권 발급일 / 유효기간", metadataDate(issueDate) + " / " + metadataDate(expiryDate)) + )); + y = drawSection(graphics, "주소·연락처", y, List.of( + field("대한민국 내 주소", identity.syntheticAddress()), + field("휴대전화", syntheticWorkerPhone(identity)), + field("본국 주소", homeCountryAddress(identity)), + field("전자우편", syntheticWorkerEmail(identity)) + )); + y = drawSection(graphics, "근무처·소득", y, List.of( + field("원 근무처", demoCompanyName()), + field("사업자등록번호 / 전화", demoBusinessNumber() + " / " + demoCompanyPhone()), + field("직업", "제조 보조"), + field("연 소득금액", "2,820만원 (합성값)") + )); + drawSignatureBlock(graphics, y + 18, issueDate, "신청인 미서명", identity.englishName(), false); + drawMetadataFooter(graphics, fixture); + } finally { + graphics.dispose(); + } + return page; + } + + private BufferedImage residenceProofPage( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + BufferedImage page = a4Page(); + Graphics2D graphics = page.createGraphics(); + try { + enableHighQualityRendering(graphics); + drawFormHeader(graphics, "체류지 제공 확인서", "PROOF OF RESIDENCE", + "합성 주소·합성 제공자 정보로 작성된 QA 증빙"); + int y = 300; + y = drawSection(graphics, "체류자", y, List.of( + field("성명", identity.englishName() + " (" + identity.displayName() + ")"), + field("국적 / 체류자격", identity.nationality() + " / " + identity.visaType()), + field("외국인등록번호", identity.alienRegistrationNumber()), + field("여권번호", identity.documentNumber()) + )); + y = drawSection(graphics, "체류지", y, List.of( + field("주소", identity.syntheticAddress()), + field("제공 형태", "회사 기숙사 무상 제공"), + field("제공 기간", metadataDate(issueDate) + " ~ " + metadataDate(expiryDate)) + )); + y = drawSection(graphics, "제공자", y, List.of( + field("회사 / 대표자", demoCompanyName() + " / " + demoEmployerName()), + field("사업자등록번호", demoBusinessNumber()), + field("연락처", demoCompanyPhone()), + field("확인 문구", "위 합성 근로자에게 상기 체류지를 제공함을 확인합니다.") + )); + drawSignatureBlock(graphics, y + 35, issueDate, demoEmployerName(), identity.englishName(), true); + drawMetadataFooter(graphics, fixture); + } finally { + graphics.dispose(); + } + return page; + } + + private BufferedImage a4Page() { + BufferedImage page = new BufferedImage(1240, 1754, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = page.createGraphics(); + try { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, page.getWidth(), page.getHeight()); + } finally { + graphics.dispose(); + } + return page; + } + + private void drawFormHeader( + Graphics2D graphics, + String koreanTitle, + String englishTitle, + String subtitle + ) { + graphics.setColor(new Color(14, 54, 96)); + graphics.fillRect(0, 0, 1240, 78); + graphics.setColor(Color.WHITE); + graphics.setFont(demoFont(Font.BOLD, 21)); + graphics.drawString("FOWOCO SYNTHETIC DOCUMENT · DEMO ONLY", 70, 49); + graphics.setColor(new Color(17, 34, 53)); + graphics.setFont(demoFont(Font.BOLD, koreanTitle.length() > 23 ? 37 : 44)); + graphics.drawString(koreanTitle, 70, 153); + graphics.setColor(new Color(47, 87, 126)); + graphics.setFont(demoFont(Font.BOLD, 24)); + graphics.drawString(englishTitle, 70, 194); + graphics.setColor(new Color(71, 85, 105)); + graphics.setFont(demoFont(Font.PLAIN, 18)); + graphics.drawString(subtitle, 70, 229); + graphics.setColor(new Color(87, 129, 168)); + graphics.setStroke(new BasicStroke(3f)); + graphics.drawLine(70, 246, 1170, 246); + } + + private int drawSection( + Graphics2D graphics, + String title, + int y, + List fields + ) { + graphics.setColor(new Color(220, 233, 244)); + graphics.fillRoundRect(70, y, 1100, 42, 8, 8); + graphics.setColor(new Color(20, 61, 104)); + graphics.setFont(demoFont(Font.BOLD, 20)); + graphics.drawString(title, 88, y + 29); + int rowY = y + 54; + for (FormField field : fields) { + graphics.setColor(new Color(219, 226, 233)); + graphics.drawLine(70, rowY + 42, 1170, rowY + 42); + graphics.setColor(new Color(52, 87, 122)); + graphics.setFont(demoFont(Font.BOLD, 16)); + graphics.drawString(field.label(), 88, rowY + 25); + graphics.setColor(new Color(17, 34, 53)); + graphics.setFont(demoFont(Font.PLAIN, field.value().length() > 62 ? 15 : 18)); + drawWrappedText(graphics, field.value(), 380, rowY + 25, 770, 22); + rowY += 48; + } + return rowY + 16; + } + + private void drawSignatureBlock( + Graphics2D graphics, + int requestedY, + LocalDate date, + String employer, + String employee, + boolean completed + ) { + int y = Math.min(requestedY, 1510); + graphics.setColor(new Color(241, 245, 249)); + graphics.fillRoundRect(70, y, 1100, 125, 12, 12); + graphics.setColor(new Color(51, 65, 85)); + graphics.setFont(demoFont(Font.BOLD, 18)); + graphics.drawString("작성일 / Date: " + metadataDate(date), 92, y + 34); + graphics.drawString("사용자 / Employer: " + employer + + (completed ? " [합성 서명 완료]" : " [미서명 초안]"), 92, y + 70); + graphics.drawString("근로자 / Employee: " + employee + + (completed ? " [합성 서명 완료]" : " [미서명 초안]"), 92, y + 106); + } + + private void drawMetadataFooter(Graphics2D graphics, DemoDocumentFixture fixture) { + graphics.setColor(new Color(71, 85, 105)); + graphics.setFont(demoFont(Font.PLAIN, 13)); + graphics.drawString("worker_document_id: " + fixture.documentId(), 70, 1648); + graphics.drawString("worker_id: " + fixture.workerId(), 70, 1672); + graphics.setColor(new Color(176, 47, 43)); + graphics.fillRect(0, 1692, 1240, 62); + graphics.setColor(Color.WHITE); + graphics.setFont(demoFont(Font.BOLD, 18)); + graphics.drawString( + "FOWOCO DEMO · NOT VALID · 실제 제출 불가 · NO OFFICIAL LOGO, SEAL OR SECURITY FEATURE", + 119, + 1731 + ); + } + + private void drawWrappedText( + Graphics2D graphics, + String value, + int x, + int y, + int maxWidth, + int lineHeight + ) { + StringBuilder line = new StringBuilder(); + int currentY = y; + for (String word : value.split(" ")) { + String candidate = line.isEmpty() ? word : line + " " + word; + if (!line.isEmpty() && graphics.getFontMetrics().stringWidth(candidate) > maxWidth) { + graphics.drawString(line.toString(), x, currentY); + currentY += lineHeight; + line = new StringBuilder(word); + } else { + line = new StringBuilder(candidate); + } + } + if (!line.isEmpty()) { + graphics.drawString(line.toString(), x, currentY); + } + } + + private void drawTableRow( + Graphics2D graphics, + int x, + int y, + int width, + int rowHeight, + List labels, + List values + ) { + int cellWidth = width / labels.size(); + graphics.setStroke(new BasicStroke(2f)); + for (int index = 0; index < labels.size(); index++) { + int cellX = x + cellWidth * index; + graphics.setColor(new Color(218, 232, 244)); + graphics.fillRect(cellX, y, cellWidth, rowHeight); + graphics.setColor(new Color(64, 103, 140)); + graphics.drawRect(cellX, y, cellWidth, rowHeight * 2); + graphics.drawLine(cellX, y + rowHeight, cellX + cellWidth, y + rowHeight); + graphics.setFont(demoFont(Font.BOLD, 17)); + graphics.drawString(labels.get(index), cellX + 14, y + 36); + graphics.setColor(new Color(17, 34, 53)); + graphics.setFont(demoFont(Font.PLAIN, values.get(index).length() > 30 ? 13 : 17)); + drawWrappedText(graphics, values.get(index), cellX + 14, y + rowHeight + 35, + cellWidth - 28, 19); + } + } + + private byte[] jpeg(BufferedImage image) { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + if (!ImageIO.write(image, "jpg", output)) { + throw new IllegalStateException("JPEG writer is unavailable"); + } + return output.toByteArray(); + } catch (IOException exception) { + throw new UncheckedIOException("failed to encode synthetic PDF page", exception); + } + } + + private String asciiOnly(String value) { + return value.replaceAll("[^\\x20-\\x7E]", "?"); + } + + private FormField field(String label, String value) { + return new FormField(label, value); + } + + private String demoCompanyName() { + return "FOWOCO DEMO COMPANY"; + } + + private String demoEmployerName() { + return "FOWOCO DEMO HR"; + } + + private String demoBusinessNumber() { + return "DEMO-BIZ-00006"; + } + + private String demoCompanyPhone() { + return "070-DEMO-2606"; + } + + private String demoCompanyAddress() { + return "FOWOCO DEMO INDUSTRIAL PARK 6, SAMPLE-RO, DEMO CITY"; + } + + private String homeCountryAddress(PassportIdentity identity) { + return "DEMO HOME " + identity.portraitSeed() + ", " + + identity.nationality() + " · SYNTHETIC"; + } + + private String syntheticWorkerPhone(PassportIdentity identity) { + return "010-DEMO-%04d".formatted(identity.portraitSeed()); + } + + private String syntheticWorkerEmail(PassportIdentity identity) { + return "worker-%02d@example.invalid".formatted(identity.portraitSeed()); + } + + private static Font loadDemoFont() { + try (InputStream input = Objects.requireNonNull( + SyntheticDocumentGenerator.class.getResourceAsStream(KOREAN_FONT), + "missing bundled demo font: " + KOREAN_FONT + )) { + return Font.createFont(Font.TRUETYPE_FONT, input); + } catch (IOException exception) { + throw new UncheckedIOException("failed to load bundled demo font", exception); + } catch (FontFormatException exception) { + throw new IllegalStateException("invalid bundled demo font", exception); + } + } + + private Font demoFont(int style, float size) { + return DEMO_FONT.deriveFont(style, size); + } + + private record FormField(String label, String value) { + } + + private byte[] hwp(DemoDocumentFixture fixture, LocalDate issueDate, LocalDate expiryDate) { + String resource = "/demo-data/document-templates/employment-contract-template.hwp"; + try (InputStream template = Objects.requireNonNull( + SyntheticDocumentGenerator.class.getResourceAsStream(resource), + "missing synthetic HWP template: " + resource + ); + POIFSFileSystem fileSystem = new POIFSFileSystem(template); ByteArrayOutputStream output = new ByteArrayOutputStream()) { - byte[] header = new byte[256]; - byte[] signature = "HWP Document File".getBytes(StandardCharsets.US_ASCII); - System.arraycopy(signature, 0, header, 0, signature.length); - fileSystem.getRoot().createDocument("FileHeader", new ByteArrayInputStream(header)); - var body = fileSystem.getRoot().createDirectory("BodyText"); - String text = fixture.title() + "\nDEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION\n" - + String.join("\n", IDENTITY_LINES); - body.createDocument("Section0", new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8))); + String text = String.join("\n", metadataLines(fixture, issueDate, expiryDate, true)); + fileSystem.getRoot().createDocument( + "FOWOCO-Metadata", + new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)) + ); fileSystem.writeFilesystem(output); return output.toByteArray(); } catch (IOException exception) { @@ -151,49 +1168,38 @@ private byte[] hwp(DemoDocumentFixture fixture) { } } - private byte[] hwpx(DemoDocumentFixture fixture) { - try (ByteArrayOutputStream output = new ByteArrayOutputStream(); + private byte[] hwpx(DemoDocumentFixture fixture, LocalDate issueDate, LocalDate expiryDate) { + String resource = switch (fixture.documentType()) { + case CONTRACT -> "/demo-data/document-templates/employment-contract-template.hwpx"; + case EMPLOYMENT_EXTENSION_APPLICATION -> + "/demo-data/document-templates/employment-extension-template.hwpx"; + case INTEGRATED_APPLICATION -> + "/demo-data/document-templates/integrated-application-template.hwpx"; + default -> throw new IllegalArgumentException( + "no HWPX template is registered for " + fixture.documentType() + ); + }; + try (InputStream template = Objects.requireNonNull( + SyntheticDocumentGenerator.class.getResourceAsStream(resource), + "missing synthetic HWPX template: " + resource + ); + ZipInputStream input = new ZipInputStream(template, StandardCharsets.UTF_8); + ByteArrayOutputStream output = new ByteArrayOutputStream(); ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) { - byte[] mime = "application/hwp+zip".getBytes(StandardCharsets.US_ASCII); - CRC32 crc = new CRC32(); - crc.update(mime); - ZipEntry mimeEntry = new ZipEntry("mimetype"); - mimeEntry.setMethod(ZipEntry.STORED); - mimeEntry.setSize(mime.length); - mimeEntry.setCompressedSize(mime.length); - mimeEntry.setCrc(crc.getValue()); - mimeEntry.setTime(0L); - zip.putNextEntry(mimeEntry); - zip.write(mime); - zip.closeEntry(); - - addZipEntry(zip, "META-INF/container.xml", """ - - - - - """); - addZipEntry(zip, "Contents/content.hpf", """ - - - FOWOCO synthetic document fixture - - """); - addZipEntry(zip, "Contents/header.xml", """ - - - """); - String body = fixture.title() + " | DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION | " - + String.join(" | ", IDENTITY_LINES); - addZipEntry(zip, "Contents/section0.xml", """ - - - - %s - - - """.formatted(xmlEscape(body))); - addZipEntry(zip, "Preview/PrvText.txt", body); + for (ZipEntry source = input.getNextEntry(); source != null; source = input.getNextEntry()) { + byte[] content = input.readAllBytes(); + if (source.getName().equals("Preview/PrvText.txt")) { + content = String.join("\n", metadataLines(fixture, issueDate, expiryDate, true)) + .getBytes(StandardCharsets.UTF_8); + } else if (source.getName().endsWith(".xml") + || source.getName().endsWith(".hpf") + || source.getName().endsWith(".rdf")) { + String text = new String(content, StandardCharsets.UTF_8); + content = applyHwpxTemplateValues(text, fixture, issueDate, expiryDate) + .getBytes(StandardCharsets.UTF_8); + } + addZipEntry(zip, source.getName(), content, source.getName().equals("mimetype")); + } zip.finish(); return output.toByteArray(); } catch (IOException exception) { @@ -201,14 +1207,281 @@ private byte[] hwpx(DemoDocumentFixture fixture) { } } - private void addZipEntry(ZipOutputStream zip, String name, String content) throws IOException { + private List metadataLines( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate, + boolean includeDisplayName + ) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + List lines = new ArrayList<>(); + lines.add("DEMO / SAMPLE - NOT FOR OFFICIAL SUBMISSION"); + lines.add("DOCUMENT TITLE: " + fixture.title()); + lines.add("WORKER DOCUMENT ID: " + fixture.documentId()); + lines.add("WORKER ID: " + fixture.workerId()); + if (includeDisplayName) { + lines.add("DB DISPLAY NAME: " + identity.displayName()); + } + lines.add("HOLDER ENGLISH NAME: " + identity.englishName()); + lines.add("NATIONALITY: " + identity.nationality() + " (" + identity.nationalityCode() + ")"); + lines.add("PREFERRED LANGUAGE: " + identity.preferredLanguage()); + lines.add("VISA / STAY: " + identity.visaType()); + lines.add("DATE OF BIRTH: " + identity.birthDate() + " / SYNTHETIC"); + lines.add(documentNumberLabel(fixture).toUpperCase(Locale.ENGLISH) + ": " + documentNumber(fixture)); + lines.add("DOCUMENT TYPE: " + fixture.documentType().name()); + lines.add("SUBMISSION STATUS: " + fixture.status().name()); + lines.add("ISSUE DATE: " + metadataDate(issueDate)); + lines.add("EXPIRY DATE: " + metadataDate(expiryDate)); + lines.add("STORAGE FILE: " + fixture.storageFilename()); + lines.add("SYNTHETIC ADDRESS: " + identity.syntheticAddress()); + lines.addAll(formSpecificMetadata(fixture, issueDate, expiryDate)); + lines.add("NOT VALID / NO OFFICIAL LOGO, SIGNATURE, OR SECURITY PATTERN"); + return lines; + } + + private List formSpecificMetadata( + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + return switch (fixture.documentType()) { + case PASSPORT_COPY -> List.of( + "PASSPORT HOLDER: " + identity.englishName(), + "PASSPORT NO: " + identity.documentNumber() + ); + case ARC -> List.of( + "ARC HOLDER: " + identity.englishName(), + "ARC NO: " + identity.alienRegistrationNumber(), + "PERMISSION PERIOD: " + metadataDate(issueDate) + " TO " + metadataDate(expiryDate) + ); + case CONTRACT -> List.of( + "EMPLOYER: " + demoCompanyName() + " / " + demoEmployerName(), + "BUSINESS NO: " + demoBusinessNumber(), + "WORKPLACE: " + demoCompanyAddress(), + "CONTRACT PERIOD: " + metadataDate(issueDate) + " TO " + metadataDate(expiryDate), + "WORKING HOURS: 08:00 TO 17:00 / BREAK 60 MIN / 40 HOURS PER WEEK", + "MONTHLY WAGE: KRW 2,350,000 / PAYMENT DAY: 25", + "ACCOMMODATION: PROVIDED / MEAL: LUNCH PROVIDED / EMPLOYEE COST: KRW 0", + "SIGNATURE STATE: SYNTHETIC SIGNATURE COMPLETED" + ); + case PERMIT -> List.of( + "EMPLOYER: " + demoCompanyName() + " / " + demoBusinessNumber(), + "WORKPLACE: " + demoCompanyAddress(), + "EMPLOYEE: " + identity.englishName(), + "WORK: MANUFACTURING ASSISTANT / 08:00 TO 17:00", + "MONTHLY WAGE: KRW 2,350,000", + "INSURANCE: EMPLOYMENT / INDUSTRIAL ACCIDENT / HEALTH / DEPARTURE / GUARANTEE" + ); + case EMPLOYMENT_EXTENSION_APPLICATION -> List.of( + "FORM: ANNEX 12-3 / REVISION 2024-01-10", + "EMPLOYER: " + demoCompanyName() + " / " + demoBusinessNumber(), + "WORKER: " + identity.englishName() + " / " + identity.alienRegistrationNumber(), + "FACT CHECKS: ELIGIBLE INDUSTRY=YES; NO LAYOFF=YES; NO WAGE ARREARS=YES; " + + "INSURANCE=YES; DEPARTURE/GUARANTEE=YES", + "ATTACHMENTS: ARC COPY / PASSPORT COPY / STANDARD LABOR CONTRACT COPY", + "SIGNATURE STATE: FILLED DRAFT / UNSIGNED" + ); + case INTEGRATED_APPLICATION -> List.of( + "FORM: IMMIGRATION ANNEX 34 / EXTENSION OF SOJOURN PERIOD=SELECTED", + "APPLICANT: " + identity.englishName() + " / " + identity.alienRegistrationNumber(), + "PASSPORT: " + identity.documentNumber(), + "KOREA ADDRESS: " + identity.syntheticAddress(), + "HOME COUNTRY ADDRESS: " + homeCountryAddress(identity), + "PHONE / EMAIL: " + syntheticWorkerPhone(identity) + " / " + syntheticWorkerEmail(identity), + "WORKPLACE: " + demoCompanyName() + " / " + demoBusinessNumber(), + "ANNUAL INCOME: KRW 28,200,000 / OCCUPATION: MANUFACTURING ASSISTANT", + "SIGNATURE STATE: FILLED DRAFT / UNSIGNED" + ); + case RESIDENCE_PROOF -> List.of( + "RESIDENT: " + identity.englishName() + " / " + identity.alienRegistrationNumber(), + "PROVIDED ADDRESS: " + identity.syntheticAddress(), + "PROVIDER: " + demoCompanyName() + " / " + demoEmployerName(), + "PROVISION: COMPANY DORMITORY / EMPLOYEE COST KRW 0" + ); + }; + } + + private String documentHeading(DemoDocumentFixture fixture) { + return switch (fixture.documentType()) { + case PASSPORT_COPY -> "PASSPORT COPY"; + case ARC -> fixture.storageFilename().contains("back") + ? "RESIDENCE CARD - BACK" + : "RESIDENCE CARD"; + case CONTRACT -> "STANDARD EMPLOYMENT CONTRACT"; + case PERMIT -> "EMPLOYMENT PERMIT"; + case EMPLOYMENT_EXTENSION_APPLICATION -> "EMPLOYMENT PERIOD EXTENSION APPLICATION"; + case INTEGRATED_APPLICATION -> "INTEGRATED APPLICATION"; + case RESIDENCE_PROOF -> "RESIDENCE PROOF"; + }; + } + + private String documentNumberLabel(DemoDocumentFixture fixture) { + return fixture.documentType() == com.fowoco.server.worker.domain.DocumentType.ARC + ? "Synthetic ARC No." + : "Synthetic reference No."; + } + + private String documentNumber(DemoDocumentFixture fixture) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + return switch (fixture.documentType()) { + case PASSPORT_COPY -> identity.documentNumber(); + case ARC -> identity.alienRegistrationNumber(); + case CONTRACT -> "DEMO-CONTRACT-%02d-NOT-VALID".formatted(identity.portraitSeed()); + case PERMIT -> "DEMO-PERMIT-%02d-NOT-VALID".formatted(identity.portraitSeed()); + case EMPLOYMENT_EXTENSION_APPLICATION -> + "DEMO-EXT-%02d-DRAFT".formatted(identity.portraitSeed()); + case INTEGRATED_APPLICATION -> "DEMO-INT-%02d-DRAFT".formatted(identity.portraitSeed()); + case RESIDENCE_PROOF -> "DEMO-ADDR-%02d-NOT-VALID".formatted(identity.portraitSeed()); + }; + } + + private String metadataDate(LocalDate value) { + return value == null ? "NOT SET" : value.toString(); + } + + private void appendPdfText( + StringBuilder stream, + String font, + int size, + int x, + int y, + String value + ) { + stream.append("BT\n/").append(font).append(' ').append(size) + .append(" Tf\n1 0 0 1 ").append(x).append(' ').append(y) + .append(" Tm\n(").append(pdfEscape(value)).append(") Tj\nET\n"); + } + + private void addZipEntry( + ZipOutputStream zip, + String name, + byte[] content, + boolean stored + ) throws IOException { ZipEntry entry = new ZipEntry(name); entry.setTime(0L); + if (stored) { + CRC32 crc = new CRC32(); + crc.update(content); + entry.setMethod(ZipEntry.STORED); + entry.setSize(content.length); + entry.setCompressedSize(content.length); + entry.setCrc(crc.getValue()); + } zip.putNextEntry(entry); - zip.write(content.strip().getBytes(StandardCharsets.UTF_8)); + zip.write(content); zip.closeEntry(); } + private String applyHwpxTemplateValues( + String source, + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + PassportIdentity identity = Objects.requireNonNull(fixture.passportIdentity()); + String result = source + .replace("FOWOCO_DEMO_COMPANY", xmlEscape(demoCompanyName())) + .replace("DEMO_HOLDER_NAME", xmlEscape(identity.englishName())) + .replace("DEMO_NATIONALITY", xmlEscape(identity.nationality())) + .replace("DEMO_PASSPORT_NO", xmlEscape(identity.documentNumber())) + .replace("DEMO_SURNAME", xmlEscape(identity.surname())) + .replace("DEMO_GIVEN_NAMES", xmlEscape(identity.givenNames())) + .replace("DEMO-WORKER-06", "DEMO-WORKER-%02d".formatted(identity.portraitSeed())) + .replace("DEMO_WORKPLACE_ADDRESS", xmlEscape(demoCompanyAddress())) + .replace("DEMO_HOME_COUNTRY_ADDRESS", xmlEscape(homeCountryAddress(identity))) + .replace("DEMO_HOME_ADDRESS", xmlEscape( + fixture.documentType() == DocumentType.CONTRACT + ? homeCountryAddress(identity) + : identity.syntheticAddress() + )) + .replace("000-0000-0000", xmlEscape(demoCompanyPhone())) + .replace("000-00-00000", xmlEscape(demoBusinessNumber())) + .replace("2098-01-01", metadataDate(issueDate)) + .replace("2099-01-01", metadataDate(expiryDate)) + .replace("2098. 01. 01.", dotDate(issueDate)) + .replace("2099. 01. 01.", dotDate(expiryDate)) + .replace("2097. 01. 01.", dotDate(identity.birthDate())) + .replace(">2097<", ">" + identity.birthDate().getYear() + "<") + .replace(">97<", ">%02d<".formatted(identity.birthDate().getMonthValue())) + .replace(">98<", ">%02d<".formatted(identity.birthDate().getDayOfMonth())); + result = switch (fixture.documentType()) { + case CONTRACT -> result + .replace(" 시 분 ~ 시 분", "08시 00분 ~ 17시 00분") + .replace("1일 평균 시간외 근로시간: 시간", "1일 평균 시간외 근로시간: 1시간") + .replace(" 변동 가능: 시간 이내)", " 변동 가능: 1시간 이내)") + .replace("1일 분", "1일 60분") + .replace("[ ]일요일 [ ]공휴일", "[v]일요일 [v]공휴일") + .replace("1) 월 통상임금 ( )원", "1) 월 통상임금 (2,350,000)원") + .replace(" - 기본급[(월, 시간, 일, 주)급] ( )원 ", + " - 기본급[(월)급] (2,250,000)원") + .replace(" - 고정적 수당: ( 수당 : 원), ( 수당: 원)", + " - 고정적 수당: (직무 수당: 100,000원), (기타 수당: 0원)") + .replace(" - 상여금 ( 원)", " - 상여금 (0원)") + .replace("매월 ( )일 또는 매주", "매월 (25)일 또는 매주") + .replace("[ ]직접 지급, [ ]통장 입금", "[ ]직접 지급, [v]통장 입금") + .replace(" - 숙박시설 제공 여부: [ ]제공 [ ]미제공", + " - 숙박시설 제공 여부: [v]제공 [ ]미제공") + .replace(" - 숙박시설 제공 시 근로자 부담금액: 매월 원 ", + " - 숙박시설 제공 시 근로자 부담금액: 매월 0원") + .replace("- 식사 제공 여부: 제공([ ]조식, [ ]중식, [ ]석식) [ ]미제공 ", + "- 식사 제공 여부: 제공([ ]조식, [v]중식, [ ]석식) [ ]미제공") + .replace(" - 식사 제공 시 근로자 부담금액: 매월 원", + " - 식사 제공 시 근로자 부담금액: 매월 0원"); + case EMPLOYMENT_EXTENSION_APPLICATION -> result + .replace("2014.7.28", "2024. 1. 10.") + .replace("ㆍ [ ]", "ㆍ [v]") + .replace("DEMO AUTHORITY - NOT FOR SUBMISSION", "미서명 초안 / DEMO NOT VALID"); + case INTEGRATED_APPLICATION -> replaceRegistrationNumberCells( + result + .replace("demo.worker@example.invalid", syntheticWorkerEmail(identity)) + .replace("APPLICATION FORM (REPORT FORM)", + "APPLICATION FORM (REPORT FORM) · FILLED DRAFT / UNSIGNED"), + identity + ); + default -> result; + }; + if (result.contains("DEMO_HOLDER_NAME") + || result.contains("DEMO_NATIONALITY") + || result.contains("2098-01-01") + || result.contains("2099-01-01")) { + throw new IllegalStateException("synthetic HWPX template contains unresolved metadata tokens"); + } + return result; + } + + private String replaceRegistrationNumberCells(String source, PassportIdentity identity) { + int marker = source.indexOf("Foreign Resident Registration No"); + if (marker < 0) { + return source; + } + String prefix = source.substring(0, marker); + String tail = source.substring(marker); + String replacement = "DEMO-ARC-%04d".formatted(identity.portraitSeed()); + Pattern digitCell = Pattern.compile("([0-9])"); + Matcher matcher = digitCell.matcher(tail); + StringBuffer output = new StringBuffer(); + int index = 0; + while (matcher.find() && index < replacement.length()) { + String updatedCell = "" + replacement.charAt(index++) + ""; + matcher.appendReplacement(output, Matcher.quoteReplacement(updatedCell)); + } + matcher.appendTail(output); + if (index != replacement.length()) { + throw new IllegalStateException("integrated application registration number cells are incomplete"); + } + return prefix + output; + } + + private String dotDate(LocalDate value) { + return value == null + ? "NOT SET" + : "%04d. %02d. %02d.".formatted( + value.getYear(), value.getMonthValue(), value.getDayOfMonth() + ); + } + private String pdfEscape(String value) { return value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)"); } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java index 916b17c1..7e9c9742 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoWorkerDocumentSeeder.java @@ -1,5 +1,6 @@ package com.fowoco.server.demo.infrastructure.seed; +import com.fowoco.server.demo.infrastructure.DemoDocumentFileIds; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.DocumentSeed; import com.fowoco.server.worker.application.port.WorkerDocumentRepository; import com.fowoco.server.worker.domain.WorkerDocument; @@ -92,10 +93,20 @@ private void verifyExistingExceptTask( || seed.submissionStatus() != document.submissionStatus() || !Objects.equals(seed.destination(), document.destination()) || !Objects.equals(seed.note(), document.note()) - || !Objects.equals(seed.fileId(), document.fileId())) { + || !fileMatches(document, seed)) { throw new IllegalStateException( "a reserved demo worker document id already belongs to different document data" ); } } + + private boolean fileMatches(WorkerDocument document, DocumentSeed seed) { + if (Objects.equals(seed.fileId(), document.fileId())) { + return true; + } + return seed.submissionStatus() + != com.fowoco.server.worker.domain.SubmissionStatus.MISSING + && DemoDocumentFileIds.isOperationalDemoDocumentId(seed.documentId()) + && DemoDocumentFileIds.materializedFileId(seed.documentId()).equals(document.fileId()); + } } diff --git a/src/main/resources/demo-data/document-templates/employment-contract-template.hwp b/src/main/resources/demo-data/document-templates/employment-contract-template.hwp new file mode 100644 index 00000000..9043aa1f Binary files /dev/null and b/src/main/resources/demo-data/document-templates/employment-contract-template.hwp differ diff --git a/src/main/resources/demo-data/document-templates/employment-contract-template.hwpx b/src/main/resources/demo-data/document-templates/employment-contract-template.hwpx new file mode 100644 index 00000000..1807b2c1 Binary files /dev/null and b/src/main/resources/demo-data/document-templates/employment-contract-template.hwpx differ diff --git a/src/main/resources/demo-data/document-templates/employment-extension-template.hwpx b/src/main/resources/demo-data/document-templates/employment-extension-template.hwpx new file mode 100644 index 00000000..cbe4ddeb Binary files /dev/null and b/src/main/resources/demo-data/document-templates/employment-extension-template.hwpx differ diff --git a/src/main/resources/demo-data/document-templates/integrated-application-template.hwpx b/src/main/resources/demo-data/document-templates/integrated-application-template.hwpx new file mode 100644 index 00000000..746ba239 Binary files /dev/null and b/src/main/resources/demo-data/document-templates/integrated-application-template.hwpx differ diff --git a/src/main/resources/demo-data/fonts/NotoSansKR-Regular.otf b/src/main/resources/demo-data/fonts/NotoSansKR-Regular.otf new file mode 100644 index 00000000..7c5c2fae Binary files /dev/null and b/src/main/resources/demo-data/fonts/NotoSansKR-Regular.otf differ diff --git a/src/main/resources/demo-data/fonts/OFL.txt b/src/main/resources/demo-data/fonts/OFL.txt new file mode 100644 index 00000000..79a917be --- /dev/null +++ b/src/main/resources/demo-data/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/main/resources/demo-data/nguyen-van-an-portrait.png b/src/main/resources/demo-data/nguyen-van-an-portrait.png new file mode 100644 index 00000000..48f6e3c8 Binary files /dev/null and b/src/main/resources/demo-data/nguyen-van-an-portrait.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-01.png b/src/main/resources/demo-data/passport-portraits/worker-01.png new file mode 100644 index 00000000..d1e4748b Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-01.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-02.png b/src/main/resources/demo-data/passport-portraits/worker-02.png new file mode 100644 index 00000000..447a36d2 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-02.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-03.png b/src/main/resources/demo-data/passport-portraits/worker-03.png new file mode 100644 index 00000000..bda9a904 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-03.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-04.png b/src/main/resources/demo-data/passport-portraits/worker-04.png new file mode 100644 index 00000000..16a8b89a Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-04.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-05.png b/src/main/resources/demo-data/passport-portraits/worker-05.png new file mode 100644 index 00000000..0b3c825f Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-05.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-07.png b/src/main/resources/demo-data/passport-portraits/worker-07.png new file mode 100644 index 00000000..c3e89fb8 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-07.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-08.png b/src/main/resources/demo-data/passport-portraits/worker-08.png new file mode 100644 index 00000000..8f4d4610 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-08.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-09.png b/src/main/resources/demo-data/passport-portraits/worker-09.png new file mode 100644 index 00000000..5dbaf6ba Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-09.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-10.png b/src/main/resources/demo-data/passport-portraits/worker-10.png new file mode 100644 index 00000000..cc830dd5 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-10.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-11.png b/src/main/resources/demo-data/passport-portraits/worker-11.png new file mode 100644 index 00000000..12e7e7d2 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-11.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-12.png b/src/main/resources/demo-data/passport-portraits/worker-12.png new file mode 100644 index 00000000..257dc1fa Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-12.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-13.png b/src/main/resources/demo-data/passport-portraits/worker-13.png new file mode 100644 index 00000000..ea06fe72 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-13.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-14.png b/src/main/resources/demo-data/passport-portraits/worker-14.png new file mode 100644 index 00000000..9b69f9d1 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-14.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-15.png b/src/main/resources/demo-data/passport-portraits/worker-15.png new file mode 100644 index 00000000..8f3d90f4 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-15.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-16.png b/src/main/resources/demo-data/passport-portraits/worker-16.png new file mode 100644 index 00000000..896f6107 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-16.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-17.png b/src/main/resources/demo-data/passport-portraits/worker-17.png new file mode 100644 index 00000000..e7076838 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-17.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-18.png b/src/main/resources/demo-data/passport-portraits/worker-18.png new file mode 100644 index 00000000..f1336351 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-18.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-19.png b/src/main/resources/demo-data/passport-portraits/worker-19.png new file mode 100644 index 00000000..2b36deaa Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-19.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-20.png b/src/main/resources/demo-data/passport-portraits/worker-20.png new file mode 100644 index 00000000..46cdf05d Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-20.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-21.png b/src/main/resources/demo-data/passport-portraits/worker-21.png new file mode 100644 index 00000000..ff0cfd55 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-21.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-22.png b/src/main/resources/demo-data/passport-portraits/worker-22.png new file mode 100644 index 00000000..aa62434c Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-22.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-23.png b/src/main/resources/demo-data/passport-portraits/worker-23.png new file mode 100644 index 00000000..1d2c1fe2 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-23.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-24.png b/src/main/resources/demo-data/passport-portraits/worker-24.png new file mode 100644 index 00000000..6889c83e Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-24.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-25.png b/src/main/resources/demo-data/passport-portraits/worker-25.png new file mode 100644 index 00000000..a89a3ac7 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-25.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-26.png b/src/main/resources/demo-data/passport-portraits/worker-26.png new file mode 100644 index 00000000..b3e4f734 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-26.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-27.png b/src/main/resources/demo-data/passport-portraits/worker-27.png new file mode 100644 index 00000000..e720dd53 Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-27.png differ diff --git a/src/main/resources/demo-data/passport-portraits/worker-28.png b/src/main/resources/demo-data/passport-portraits/worker-28.png new file mode 100644 index 00000000..ec963c8c Binary files /dev/null and b/src/main/resources/demo-data/passport-portraits/worker-28.png differ 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 4300e8a6..db7863a6 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 @@ -108,6 +108,32 @@ void configurationTextNeverExposesTheDemoPassword() { .doesNotContain(ADMIN_PASSWORD); } + @Test + void updatesExistingDemoHashesWhenTheConfiguredSyntheticPasswordChanges() throws Exception { + InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); + InMemoryUserAccountRepository userAccountRepository = new InMemoryUserAccountRepository(); + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); + runner( + properties(ADMIN_PASSWORD), + companyRepository, + userAccountRepository, + passwordEncoder + ).run(new DefaultApplicationArguments(new String[0])); + + String rotatedPassword = "Rotated-demo-password-2!"; + runner( + properties(rotatedPassword), + companyRepository, + userAccountRepository, + passwordEncoder + ).run(new DefaultApplicationArguments(new String[0])); + + assertThat(userAccountRepository.users.values()) + .allMatch(user -> passwordEncoder.matches(rotatedPassword, user.passwordHash())); + assertThat(userAccountRepository.users.values()) + .allMatch(user -> user.version() == 1L); + } + @Test void existingAdminDoesNotHideAnInactiveDemoCompany() throws Exception { InMemoryCompanyRepository companyRepository = new InMemoryCompanyRepository(); diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataIntegrationTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataIntegrationTest.java index 5ff5093c..59c1f460 100644 --- a/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataIntegrationTest.java +++ b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/DemoDocumentDataIntegrationTest.java @@ -7,7 +7,10 @@ import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.boot.ApplicationRunner; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; @@ -35,46 +38,94 @@ class DemoDocumentDataIntegrationTest { @Autowired private JdbcTemplate jdbcTemplate; + @Autowired + private ApplicationContext applicationContext; + @DynamicPropertySource static void fileStorage(DynamicPropertyRegistry registry) { registry.add("app.file-storage.local-path", FILE_STORAGE::toString); } @Test - void importsVerifiesCleansAndReimportsWithoutDuplicates() { + void importsVerifiesCleansAndReimportsWithoutDuplicates() throws Exception { assertReport(service.importData()); assertReport(service.verifyData()); - assertCounts(16, 15, 1); + assertCounts(43, 42, 1); + assertMaterializedLegacyFiles(67); + + applicationContext.getBean("demoOperationalSeedRunner", ApplicationRunner.class) + .run(new DefaultApplicationArguments(new String[0])); + assertReport(service.verifyData()); assertReport(service.importData()); - assertCounts(16, 15, 1); + assertCounts(43, 42, 1); + assertMaterializedLegacyFiles(67); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM stored_file WHERE company_id = ? AND checksum_sha256 IS NOT NULL", Integer.class, DemoDocumentFixtureCatalog.COMPANY_ID - )).isEqualTo(15); + )).isEqualTo(109); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM worker_document WHERE company_id = ? AND source = 'DEMO_SEED' " + "AND issue_date IS NOT NULL", Integer.class, DemoDocumentFixtureCatalog.COMPANY_ID - )).isEqualTo(15); + )).isEqualTo(42); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM worker_document WHERE company_id = ? AND submission_status = 'DRAFT'", Integer.class, DemoDocumentFixtureCatalog.COMPANY_ID )).isEqualTo(2); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(DISTINCT checksum_sha256) FROM stored_file " + + "WHERE company_id = ? AND storage_key LIKE '%/passport-copy-worker-%'", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + )).isEqualTo(27); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(DISTINCT worker_id) FROM worker_document " + + "WHERE company_id = ? AND source = 'DEMO_SEED' " + + "AND document_type = 'PASSPORT_COPY' AND file_id IS NOT NULL " + + "AND submission_status = 'VERIFIED' AND expiry_date > CURRENT_DATE", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + )).isEqualTo(28); service.cleanupData(); assertCounts(0, 0, 0); + assertMaterializedLegacyFiles(0); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE company_id = ? AND source = 'LEGACY' " + + "AND file_id IS NOT NULL", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + )).isEqualTo(1); assertReport(service.importData()); - assertCounts(16, 15, 1); + assertCounts(43, 42, 1); + assertMaterializedLegacyFiles(67); } private void assertReport(DemoDocumentDataReport report) { - assertThat(report).isEqualTo(new DemoDocumentDataReport(16, 15, 4, 7, 1, 3, 4, 1)); + int taskLinked = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM worker_document WHERE company_id = ? " + + "AND source IN ('DEMO_SEED', 'LEGACY') AND task_id IS NOT NULL", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + ); + assertThat(report).isEqualTo(new DemoDocumentDataReport( + 126, 109, 71, 34, 1, 3, taskLinked, 17, 28, 67 + )); + } + + private void assertMaterializedLegacyFiles(int expected) { + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM stored_file WHERE company_id = ? " + + "AND purpose = 'DEMO_LEGACY_DOCUMENT'", + Integer.class, + DemoDocumentFixtureCatalog.COMPANY_ID + )).isEqualTo(expected); } private void assertCounts(int documents, int files, int ocrRuns) { diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGeneratorTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGeneratorTest.java index a7b89297..5c1a33d2 100644 --- a/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGeneratorTest.java +++ b/src/test/java/com/fowoco/server/demo/infrastructure/documentdata/SyntheticDocumentGeneratorTest.java @@ -6,7 +6,20 @@ import com.fowoco.server.demo.infrastructure.documentdata.DemoDocumentFixtureCatalog.FixtureFormat; import com.fowoco.server.file.application.validation.HwpSignatureValidator; import com.fowoco.server.file.application.validation.HwpxSignatureValidator; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.zip.ZipInputStream; +import javax.imageio.ImageIO; +import org.apache.poi.poifs.filesystem.DocumentEntry; +import org.apache.poi.poifs.filesystem.DocumentInputStream; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; import org.junit.jupiter.api.Test; class SyntheticDocumentGeneratorTest { @@ -23,11 +36,321 @@ void generatesRealSignaturesForEverySupportedDemoFormat() { assertThat(new HwpxSignatureValidator().isValidHwpx(generate(FixtureFormat.HWPX))).isTrue(); } + @Test + void generatesPassportLikeGoldWorkerFixtureWithSyntheticPortrait() throws IOException { + DemoDocumentFixture passport = DemoDocumentFixtureCatalog.fixtures().stream() + .filter(fixture -> fixture.documentId() + .equals(DemoDocumentFixtureCatalog.PASSPORT_BIO_DOCUMENT_ID)) + .findFirst() + .orElseThrow(); + + var image = ImageIO.read(new ByteArrayInputStream(generator.generate( + passport, + LocalDate.of(2025, 8, 15), + LocalDate.of(2027, 8, 15) + ))); + + assertThat(image.getWidth()).isEqualTo(1400); + assertThat(image.getHeight()).isEqualTo(980); + assertThat(image.getRGB(200, 350)).isNotEqualTo(image.getRGB(500, 350)); + assertThat(generator.generate( + passport, + LocalDate.of(2025, 8, 16), + LocalDate.of(2027, 8, 16) + )).isNotEqualTo(generator.generate( + passport, + LocalDate.of(2025, 8, 15), + LocalDate.of(2027, 8, 15) + )); + } + + @Test + void generatesTwentySevenDifferentPassportCopiesForRemainingWorkers() throws IOException { + LocalDate anchorDate = LocalDate.of(2026, 8, 15); + var fixtures = DemoDocumentFixtureCatalog.passportCoverageFixtures(); + Set checksums = new HashSet<>(); + Set workerIds = new HashSet<>(); + + assertThat(fixtures).hasSize(27); + for (DemoDocumentFixture fixture : fixtures) { + byte[] content = generator.generate( + fixture, + anchorDate.plusDays(fixture.issueDays()), + anchorDate.plusDays(fixture.expiryDays()) + ); + var image = ImageIO.read(new ByteArrayInputStream(content)); + + assertThat(fixture.passportIdentity()).isNotNull(); + assertThat(fixture.originalFilename()).endsWith(".png"); + assertThat(fixture.contentType()).isEqualTo("image/png"); + assertThat(image.getWidth()).isEqualTo(1400); + assertThat(image.getHeight()).isEqualTo(980); + checksums.add(DemoDocumentFileInstaller.sha256(content)); + workerIds.add(fixture.workerId()); + } + + assertThat(checksums).hasSize(27); + assertThat(workerIds).hasSize(27); + } + + @Test + void generatesResidenceCardFrontAndBackWithConsistentSyntheticIdentity() throws IOException { + var arcFixtures = DemoDocumentFixtureCatalog.fixtures().stream() + .filter(fixture -> fixture.documentType() + == com.fowoco.server.worker.domain.DocumentType.ARC) + .filter(fixture -> fixture.format() == FixtureFormat.PNG + || fixture.format() == FixtureFormat.JPEG) + .toList(); + + assertThat(arcFixtures).hasSize(2); + byte[] front = generator.generate( + arcFixtures.get(0), + LocalDate.of(2026, 2, 16), + LocalDate.of(2027, 5, 12) + ); + byte[] back = generator.generate( + arcFixtures.get(1), + LocalDate.of(2026, 2, 16), + LocalDate.of(2027, 5, 12) + ); + var frontImage = ImageIO.read(new ByteArrayInputStream(front)); + var backImage = ImageIO.read(new ByteArrayInputStream(back)); + + assertThat(frontImage.getWidth()).isEqualTo(1200); + assertThat(frontImage.getHeight()).isEqualTo(760); + assertThat(backImage.getWidth()).isEqualTo(1200); + assertThat(backImage.getHeight()).isEqualTo(760); + assertThat(DemoDocumentFileInstaller.sha256(front)) + .isNotEqualTo(DemoDocumentFileInstaller.sha256(back)); + assertThat(arcFixtures.get(0).passportIdentity()) + .isEqualTo(arcFixtures.get(1).passportIdentity()); + assertThat(redOverlayPixelCount(frontImage, 70, 150, 790, 630)).isZero(); + assertThat(redOverlayPixelCount(backImage, 70, 150, 1130, 630)).isZero(); + } + + @Test + void keepsPassportOcrFieldsFreeOfRedOverlay() throws IOException { + DemoDocumentFixture passport = DemoDocumentFixtureCatalog.fixtures().stream() + .filter(fixture -> fixture.documentId() + .equals(DemoDocumentFixtureCatalog.PASSPORT_BIO_DOCUMENT_ID)) + .findFirst() + .orElseThrow(); + + var image = ImageIO.read(new ByteArrayInputStream(generator.generate( + passport, + LocalDate.of(2025, 8, 15), + LocalDate.of(2027, 8, 15) + ))); + + assertThat(redOverlayPixelCount(image, 420, 110, 1360, 700)).isZero(); + } + + @Test + void fillsContractExtensionAndIntegratedApplicationFields() throws IOException { + LocalDate issueDate = LocalDate.of(2026, 8, 15); + LocalDate expiryDate = LocalDate.of(2027, 2, 11); + + String contract = plainHwpxText(generateHwpx( + com.fowoco.server.worker.domain.DocumentType.CONTRACT, + issueDate, + expiryDate + )); + assertThat(contract) + .contains("NGUYEN VAN AN") + .contains("08시 00분 ~ 17시 00분") + .contains("2,350,000") + .contains("[v]통장 입금") + .contains("[v]제공"); + + String extension = plainHwpxText(generateHwpx( + com.fowoco.server.worker.domain.DocumentType.EMPLOYMENT_EXTENSION_APPLICATION, + issueDate, + expiryDate + )); + assertThat(extension) + .contains("2024. 1. 10.") + .contains("NGUYEN VAN AN") + .contains("DEMO-P06-NOT-VALID") + .contains("ㆍ [v]") + .contains("미서명 초안 / DEMO NOT VALID"); + + String integrated = plainHwpxText(generateHwpx( + com.fowoco.server.worker.domain.DocumentType.INTEGRATED_APPLICATION, + issueDate, + expiryDate + )); + assertThat(integrated) + .contains("NGUYEN") + .contains("VAN AN") + .contains("DEMO-ARC-0006") + .contains("DEMO-P06-NOT-VALID") + .contains("worker-06@example.invalid") + .contains("FILLED DRAFT / UNSIGNED"); + } + + @Test + void rendersStructuredTwoPageContractPdf() { + DemoDocumentFixture contract = DemoDocumentFixtureCatalog.fixtures().stream() + .filter(fixture -> fixture.documentType() + == com.fowoco.server.worker.domain.DocumentType.CONTRACT) + .filter(fixture -> fixture.format() == FixtureFormat.PDF) + .findFirst() + .orElseThrow(); + + String pdf = new String(generator.generate( + contract, + LocalDate.of(2026, 7, 16), + LocalDate.of(2027, 2, 11) + ), StandardCharsets.ISO_8859_1); + + assertThat(pdf) + .startsWith("%PDF-1.4") + .contains("/Count 2") + .contains("MONTHLY WAGE: KRW 2,350,000") + .contains(contract.documentId().toString()); + } + + @Test + void assignsOneConsistentSyntheticIdentityToEveryWorkerDocumentFixture() { + Map identitiesByWorker = new HashMap<>(); + + for (DemoDocumentFixture fixture : DemoDocumentFixtureCatalog.fixtures()) { + assertThat(fixture.passportIdentity()).isNotNull(); + Object previous = identitiesByWorker.putIfAbsent( + fixture.workerId(), fixture.passportIdentity() + ); + if (previous != null) { + assertThat(fixture.passportIdentity()).isEqualTo(previous); + } + } + + assertThat(identitiesByWorker).hasSize(28); + } + + @Test + void embedsWorkerAndDocumentMetadataInPdfHwpAndHwpxContent() throws IOException { + LocalDate issueDate = LocalDate.of(2026, 7, 16); + LocalDate expiryDate = LocalDate.of(2027, 2, 11); + + DemoDocumentFixture pdfFixture = fixture(FixtureFormat.PDF); + String pdf = new String( + generator.generate(pdfFixture, issueDate, expiryDate), + StandardCharsets.US_ASCII + ); + assertMetadataText(pdf, pdfFixture, issueDate, expiryDate); + + DemoDocumentFixture hwpFixture = fixture(FixtureFormat.HWP); + byte[] hwp = generator.generate(hwpFixture, issueDate, expiryDate); + try (POIFSFileSystem fileSystem = new POIFSFileSystem(new ByteArrayInputStream(hwp)); + var stream = new DocumentInputStream( + (DocumentEntry) fileSystem.getRoot().getEntry("FOWOCO-Metadata") + )) { + assertMetadataText( + new String(stream.readAllBytes(), StandardCharsets.UTF_8), + hwpFixture, + issueDate, + expiryDate + ); + } + + DemoDocumentFixture hwpxFixture = fixture(FixtureFormat.HWPX); + byte[] hwpx = generator.generate(hwpxFixture, issueDate, expiryDate); + String preview = hwpxEntry(hwpx, "Preview/PrvText.txt"); + assertMetadataText(preview, hwpxFixture, issueDate, expiryDate); + assertThat(hwpxEntry(hwpx, "Contents/section0.xml")) + .contains(hwpxFixture.passportIdentity().englishName()) + .contains(issueDate.toString()) + .contains(expiryDate.toString()) + .doesNotContain("DEMO_HOLDER_NAME", "2098-01-01", "2099-01-01"); + } + + private void assertMetadataText( + String content, + DemoDocumentFixture fixture, + LocalDate issueDate, + LocalDate expiryDate + ) { + assertThat(content) + .contains(fixture.documentId().toString()) + .contains(fixture.workerId().toString()) + .contains(fixture.passportIdentity().englishName()) + .contains(fixture.passportIdentity().nationalityCode()) + .contains(fixture.passportIdentity().preferredLanguage()) + .contains(fixture.passportIdentity().visaType()) + .contains(fixture.documentType().name()) + .contains(fixture.status().name()) + .contains(issueDate.toString()) + .contains(expiryDate.toString()) + .contains(fixture.storageFilename()); + } + + private String hwpxEntry(byte[] content, String expectedName) throws IOException { + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(content))) { + for (var entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) { + if (entry.getName().equals(expectedName)) { + return new String(zip.readAllBytes(), StandardCharsets.UTF_8); + } + } + } + throw new AssertionError("HWPX entry is missing: " + expectedName); + } + + private byte[] generateHwpx( + com.fowoco.server.worker.domain.DocumentType documentType, + LocalDate issueDate, + LocalDate expiryDate + ) { + DemoDocumentFixture fixture = DemoDocumentFixtureCatalog.fixtures().stream() + .filter(candidate -> candidate.documentType() == documentType) + .filter(candidate -> candidate.format() == FixtureFormat.HWPX) + .findFirst() + .orElseThrow(); + return generator.generate(fixture, issueDate, expiryDate); + } + + private String plainHwpxText(byte[] content) throws IOException { + return hwpxEntry(content, "Contents/section0.xml") + .replaceAll("<[^>]+>", "") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&"); + } + private byte[] generate(FixtureFormat format) { - DemoDocumentFixture template = DemoDocumentFixtureCatalog.fixtures().stream() + DemoDocumentFixture template = fixture(format); + return generator.generate( + template, + LocalDate.of(2025, 8, 15), + LocalDate.of(2027, 8, 15) + ); + } + + private int redOverlayPixelCount( + java.awt.image.BufferedImage image, + int startX, + int startY, + int endX, + int endY + ) { + int count = 0; + for (int y = startY; y < endY; y++) { + for (int x = startX; x < endX; x++) { + int rgb = image.getRGB(x, y); + int red = (rgb >> 16) & 0xff; + int green = (rgb >> 8) & 0xff; + int blue = rgb & 0xff; + if (red > 130 && green < 105 && blue < 105) { + count++; + } + } + } + return count; + } + + private DemoDocumentFixture fixture(FixtureFormat format) { + return DemoDocumentFixtureCatalog.fixtures().stream() .filter(fixture -> fixture.format() == format) .findFirst() .orElseThrow(); - return generator.generate(template); } }