Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
103 changes: 100 additions & 3 deletions docs/demo-document-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` 상태를 연결한다.

Expand Down
3 changes: 2 additions & 1 deletion docs/demo-seed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 새 합성 비밀번호로 멱등 갱신한다.

| 회사 | 역할 | 이메일 | 용도 |
| --- | --- | --- | --- |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,14 @@ private void seedUser(UUID companyId, DemoUser demoUser, Instant now) {
Optional<UserAccount> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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();
}
Expand Down
Loading
Loading