diff --git a/.env.example b/.env.example index 7a57cb01..5922587b 100644 --- a/.env.example +++ b/.env.example @@ -99,6 +99,11 @@ OUTBOX_MAX_BACKOFF=5m # local/dev에서 DRAFT projection 실험이 필요할 때만 true로 둡니다. prod는 항상 false입니다. WORKFLOW_CATALOG_ALLOW_UNRELEASED=true +# 기록상 체류기간이 지난 재직 근로자를 매일 02:10(Asia/Seoul)에 긴급 확인 Case로 만듭니다. +# 같은 근로자·같은 만료일 조합은 한 번만 생성됩니다. +STAY_VERIFICATION_SCHEDULER_ENABLED=true +STAY_VERIFICATION_SCAN_CRON="0 10 2 * * *" + # Access Token 서명 키입니다. dev/prod에서는 반드시 32바이트 이상의 난수를 Base64로 넣습니다. # 생성 예시: openssl rand -base64 32 # local 프로필은 로컬 전용 기본 키를 사용합니다. dev/prod로 바꿀 때 아래 줄을 활성화하세요. diff --git a/README.md b/README.md index 9cbe510e..7f2cd811 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ src/main/java/com/fowoco/server/ ├── worker / workerimport / document / file ├── workflow / task / casework ├── approval / audit -├── workerlink / dashboard / notification / settings +├── workerlink / stayverification / dashboard / notification / settings ├── airun / aiintegration └── reliability ``` @@ -277,6 +277,7 @@ src/main/java/com/fowoco/server/ | Agent DB 정보 보충 | [Slot 조회·재호출](docs/ai-slot-resolution.md) | canonical key allow-list, tenant 조회와 ANALYZE 재호출 기준 | | AI 단계별 성능 측정 | [AI 파이프라인 관측·Prometheus 가이드](docs/ai-pipeline-observability.md) | PLAN·Slot·ANALYZE·Renewal 구간의 정량 평가와 로컬 Prometheus 확인 기준 | | 이벤트 유실·재처리 | [Outbox 운영 가이드](docs/reliability/transactional-outbox.md) | 이벤트 발행, lease, 재시도와 장애 복구 기준 | +| 체류기간 경과 안전 확인 | [체류기간 만료 경과 긴급 확인](docs/stay-verification.md) | 날짜 경과와 적법 체류·고용 종료 판단을 분리하는 기준 | | 파일 rollback·orphan 대응 | [File Storage rollback 보상 운영 가이드](docs/reliability/file-storage-rollback-compensation.md) | atomic finalize, rollback cleanup, `UNKNOWN` reconciliation과 배포 volume Smoke 기준 | | 구현 계획·업무 상태 | [Server Roadmap](https://github.com/orgs/fowoco/projects/3) · [Issues](https://github.com/fowoco/server/issues) | 실제 담당자, 우선순위와 진행 상태 | | 전체 설명·운영 가이드 | [Server Wiki](https://github.com/fowoco/server/wiki) | 초보자용 아키텍처·API·배포 설명 | diff --git a/docs/project-structure.md b/docs/project-structure.md index 38521834..21638069 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -36,6 +36,7 @@ server/ │ │ ├── approval/ │ │ ├── audit/ │ │ ├── workerlink/ + │ │ ├── stayverification/ │ │ ├── airun/ │ │ ├── aiintegration/ │ │ └── reliability/ @@ -67,6 +68,7 @@ server/ | `approval` | 승인 요청, 승인·반려와 snapshot | | `audit` | append-only 감사 이벤트 | | `workerlink` | 로그인 없는 근로자 보안 링크 | +| `stayverification` | 기록상 체류기간 경과 대상의 긴급 확인 Case와 HR 증빙 상태 | | `airun` | AI 실행, Candidate, Attempt와 retry 상태 | | `aiintegration` | AI Runtime HTTP 계약과 Client | | `reliability` | Outbox, event 전달과 복구 | diff --git a/docs/stay-verification.md b/docs/stay-verification.md new file mode 100644 index 00000000..0daa15f2 --- /dev/null +++ b/docs/stay-verification.md @@ -0,0 +1,44 @@ +# 체류기간 만료 경과 긴급 확인 + +`stay_expiry_date`는 Server에 저장된 마지막 기록일 뿐, 근로자의 적법 체류 여부나 고용 종료를 +자동으로 판정하는 값이 아닙니다. FOWOCO는 날짜가 지난 재직 근로자를 삭제하거나 퇴사 처리하지 +않고 `WF-STY-EXC-001` 긴급 확인 Case로 분리합니다. + +## 처리 흐름 + +```text +매일 02:10 또는 HR 수동 스캔 +→ stay_expiry_date < 오늘인 ACTIVE·ON_LEAVE 근로자 탐색 +→ (company_id, worker_id, source_stay_expiry_date) 중복 차단 +→ UNKNOWN 확인 Case 생성 +→ HR이 공식 결과·접수 증빙·확인 메모를 기록 +→ APPROVED이면 새 체류만료일만 갱신 +→ EMPLOYMENT_ENDED이면 WF-CHG-001 후보만 제시 +``` + +날짜 경과만으로 `Worker.work_status`를 변경하지 않습니다. `EMPLOYMENT_ENDED` 역시 HR이 확인 +시각과 공식 확인 메모를 입력해야 선택할 수 있으며, Server는 고용변동 Workflow를 자동 실행하지 +않고 후보로만 반환합니다. + +## API + +| API | 역할 | +| --- | --- | +| `POST /api/v1/stay-verifications/scan` | 현재 사업장을 즉시 멱등 스캔 | +| `GET /api/v1/stay-verifications` | 긴급 확인 Case 목록 조회 | +| `PATCH /api/v1/stay-verifications/{id}` | 상태·증빙·재확인일 기록 | + +상태 변경은 `expected_version`으로 동시 수정을 차단합니다. 승인 완료는 기존 만료일보다 늦은 +`new_stay_expiry_date`와 승인 결과 문서 또는 공식 확인 메모가 필요합니다. 심사 중은 신청일, +재확인일, 접수 문서 또는 공식 확인 메모가 필요합니다. + +## 운영 설정 + +| 환경변수 | 기본값 | 설명 | +| --- | --- | --- | +| `STAY_VERIFICATION_SCHEDULER_ENABLED` | `true` | 일일 스캔 사용 여부 | +| `STAY_VERIFICATION_SCAN_CRON` | `0 10 2 * * *` | Asia/Seoul 기준 실행 Cron | + +PostgreSQL에서는 `bootstrap_expired_stay_candidates(date)` SECURITY DEFINER 함수가 RLS 밖에서 +최소 후보 식별자만 읽고, 각 Case 저장은 해당 사업장 tenant context를 다시 설정한 독립 +트랜잭션에서 수행합니다. diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java index 7e2dc016..cb23ee7e 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -44,5 +44,7 @@ public enum AuditAction { DOCUMENT_OCR_FAILED, DOCUMENT_OCR_RESULT_VIEWED, DOCUMENT_OCR_APPROVED, - DOCUMENT_OCR_REJECTED + DOCUMENT_OCR_REJECTED, + STAY_VERIFICATION_CASE_CREATED, + STAY_VERIFICATION_STATUS_UPDATED } diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java index 62f983ff..46c92d2d 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java @@ -14,5 +14,6 @@ public enum AuditTargetType { USER_ACCOUNT, WORKER_IMPORT, COMPANY_SETTINGS, - DOCUMENT_OCR_RUN + DOCUMENT_OCR_RUN, + STAY_VERIFICATION } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java index 1062e1bd..1186b73a 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedCatalog.java @@ -22,7 +22,7 @@ final class DemoOperationalSeedCatalog { - static final String WORKFLOW_CATALOG_VERSION = "0.3.0"; + static final String WORKFLOW_CATALOG_VERSION = "0.3.1"; static final UUID TIMELINE_TASK_ID = UUID.fromString("94000000-0000-0000-0000-000000000002"); static final UUID TEST_ADMIN_USER_ID = diff --git a/src/main/java/com/fowoco/server/stayverification/api/StayVerificationController.java b/src/main/java/com/fowoco/server/stayverification/api/StayVerificationController.java new file mode 100644 index 00000000..581a2798 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/api/StayVerificationController.java @@ -0,0 +1,95 @@ +package com.fowoco.server.stayverification.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.stayverification.application.StayVerificationCommand; +import com.fowoco.server.stayverification.application.StayVerificationService; +import com.fowoco.server.stayverification.domain.StayVerificationStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Stay Verification", description = "체류기간 만료 경과 대상의 긴급 확인 Case") +@RestController +@RequestMapping("/api/v1/stay-verifications") +@SecurityRequirement(name = "bearerAuth") +public class StayVerificationController { + + private final StayVerificationService service; + private final ActorContextProvider actorContextProvider; + + public StayVerificationController( + StayVerificationService service, + ActorContextProvider actorContextProvider + ) { + this.service = service; + this.actorContextProvider = actorContextProvider; + } + + @Operation(summary = "체류기간 경과 대상 즉시 스캔", description = "일일 배치와 같은 멱등 규칙으로 현재 사업장만 스캔합니다.") + @PostMapping(path = "/scan", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public Map scan(HttpServletRequest servletRequest) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + int created = service.scanCompany(actor, RequestMetadata.from(servletRequest)); + return Map.of("created_count", created); + } + + @Operation(summary = "체류상태 확인 Case 목록 조회") + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public List list( + @RequestParam(required = false) StayVerificationStatus status + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return service.findAll(status, actor).stream() + .map(StayVerificationResponse::from) + .toList(); + } + + @Operation(summary = "체류상태 확인 결과와 증빙 기록") + @PatchMapping( + path = "/{stayVerificationId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public StayVerificationResponse update( + @PathVariable UUID stayVerificationId, + @Valid @RequestBody StayVerificationUpdateRequest request, + HttpServletRequest servletRequest + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + StayVerificationCommand command = new StayVerificationCommand( + stayVerificationId, + request.status(), + request.extensionAppliedAt(), + request.extensionReceiptDocumentId(), + request.approvalResultDocumentId(), + request.newStayExpiryDate(), + request.officialConsultationNote(), + request.employmentEndConfirmedAt(), + request.recheckDate(), + request.expectedVersion() + ); + return StayVerificationResponse.from( + service.update(command, actor, RequestMetadata.from(servletRequest)) + ); + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/api/StayVerificationResponse.java b/src/main/java/com/fowoco/server/stayverification/api/StayVerificationResponse.java new file mode 100644 index 00000000..1e14f4c5 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/api/StayVerificationResponse.java @@ -0,0 +1,48 @@ +package com.fowoco.server.stayverification.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.stayverification.domain.StayVerificationCase; +import com.fowoco.server.stayverification.domain.StayVerificationStatus; +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +public record StayVerificationResponse( + @JsonProperty("stay_verification_id") UUID stayVerificationId, + @JsonProperty("worker_id") UUID workerId, + @JsonProperty("worker_display_name") String workerDisplayName, + @JsonProperty("source_stay_expiry_date") LocalDate sourceStayExpiryDate, + @JsonProperty("verification_status") StayVerificationStatus verificationStatus, + @JsonProperty("status_checked_at") Instant statusCheckedAt, + @JsonProperty("extension_applied_at") LocalDate extensionAppliedAt, + @JsonProperty("extension_receipt_document_id") UUID extensionReceiptDocumentId, + @JsonProperty("approval_result_document_id") UUID approvalResultDocumentId, + @JsonProperty("new_stay_expiry_date") LocalDate newStayExpiryDate, + @JsonProperty("official_consultation_note") String officialConsultationNote, + @JsonProperty("employment_end_confirmed_at") Instant employmentEndConfirmedAt, + @JsonProperty("recheck_date") LocalDate recheckDate, + @JsonProperty("employment_change_candidate_available") boolean employmentChangeCandidateAvailable, + @JsonProperty("suggested_workflow_id") String suggestedWorkflowId, + long version +) { + public static StayVerificationResponse from(StayVerificationCase value) { + return new StayVerificationResponse( + value.stayVerificationId(), + value.workerId(), + value.workerDisplayName(), + value.sourceStayExpiryDate(), + value.verificationStatus(), + value.statusCheckedAt(), + value.extensionAppliedAt(), + value.extensionReceiptDocumentId(), + value.approvalResultDocumentId(), + value.newStayExpiryDate(), + value.officialConsultationNote(), + value.employmentEndConfirmedAt(), + value.recheckDate(), + value.employmentChangeCandidateAvailable(), + value.employmentChangeCandidateAvailable() ? "WF-CHG-001" : null, + value.version() + ); + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/api/StayVerificationUpdateRequest.java b/src/main/java/com/fowoco/server/stayverification/api/StayVerificationUpdateRequest.java new file mode 100644 index 00000000..eb491097 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/api/StayVerificationUpdateRequest.java @@ -0,0 +1,24 @@ +package com.fowoco.server.stayverification.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.stayverification.domain.StayVerificationStatus; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +public record StayVerificationUpdateRequest( + @NotNull StayVerificationStatus status, + @JsonProperty("extension_applied_at") LocalDate extensionAppliedAt, + @JsonProperty("extension_receipt_document_id") UUID extensionReceiptDocumentId, + @JsonProperty("approval_result_document_id") UUID approvalResultDocumentId, + @JsonProperty("new_stay_expiry_date") LocalDate newStayExpiryDate, + @JsonProperty("official_consultation_note") + @Size(max = 1000) String officialConsultationNote, + @JsonProperty("employment_end_confirmed_at") Instant employmentEndConfirmedAt, + @JsonProperty("recheck_date") LocalDate recheckDate, + @JsonProperty("expected_version") @NotNull @PositiveOrZero Long expectedVersion +) { +} diff --git a/src/main/java/com/fowoco/server/stayverification/application/StayVerificationCaseCreationTransaction.java b/src/main/java/com/fowoco/server/stayverification/application/StayVerificationCaseCreationTransaction.java new file mode 100644 index 00000000..b8712f60 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/application/StayVerificationCaseCreationTransaction.java @@ -0,0 +1,75 @@ +package com.fowoco.server.stayverification.application; + +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.stayverification.application.port.StayVerificationRepository; +import com.fowoco.server.stayverification.application.port.StayVerificationRepository.ExpiredWorker; +import com.fowoco.server.stayverification.domain.StayVerificationCase; +import java.time.Instant; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class StayVerificationCaseCreationTransaction { + + private static final String AUDIT_VERSION = "1"; + private final TenantDatabaseContext tenantDatabaseContext; + private final StayVerificationRepository repository; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + + public StayVerificationCaseCreationTransaction( + TenantDatabaseContext tenantDatabaseContext, + StayVerificationRepository repository, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator + ) { + this.tenantDatabaseContext = tenantDatabaseContext; + this.repository = repository; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean createIfAbsent( + ExpiredWorker worker, + Instant now, + ActorType actorType, + UUID actorId, + UserRole role, + String requestId, + String traceId + ) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(worker.companyId()); + UUID verificationId = uuidGenerator.generate(); + if (!repository.insertIfAbsent(verificationId, worker, now)) { + return false; + } + StayVerificationCase created = repository.findById(verificationId, worker.companyId()) + .orElseThrow(); + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + worker.companyId(), + actorType, + actorId, + role, + AuditAction.STAY_VERIFICATION_CASE_CREATED, + AuditTargetType.STAY_VERIFICATION, + created.stayVerificationId(), + requestId, + traceId, + AUDIT_VERSION, + "기록상 체류기간 경과를 감지해 긴급 확인 Case를 생성함", + now + )); + return true; + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/application/StayVerificationCommand.java b/src/main/java/com/fowoco/server/stayverification/application/StayVerificationCommand.java new file mode 100644 index 00000000..793b3046 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/application/StayVerificationCommand.java @@ -0,0 +1,20 @@ +package com.fowoco.server.stayverification.application; + +import com.fowoco.server.stayverification.domain.StayVerificationStatus; +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +public record StayVerificationCommand( + UUID stayVerificationId, + StayVerificationStatus status, + LocalDate extensionAppliedAt, + UUID extensionReceiptDocumentId, + UUID approvalResultDocumentId, + LocalDate newStayExpiryDate, + String officialConsultationNote, + Instant employmentEndConfirmedAt, + LocalDate recheckDate, + long expectedVersion +) { +} diff --git a/src/main/java/com/fowoco/server/stayverification/application/StayVerificationService.java b/src/main/java/com/fowoco/server/stayverification/application/StayVerificationService.java new file mode 100644 index 00000000..487d81ce --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/application/StayVerificationService.java @@ -0,0 +1,316 @@ +package com.fowoco.server.stayverification.application; + +import static com.fowoco.server.stayverification.application.error.StayVerificationErrorCode.STAY_VERIFICATION_DOCUMENT_NOT_FOUND; +import static com.fowoco.server.stayverification.application.error.StayVerificationErrorCode.STAY_VERIFICATION_EMPLOYMENT_END_NOTE_REQUIRED; +import static com.fowoco.server.stayverification.application.error.StayVerificationErrorCode.STAY_VERIFICATION_EVIDENCE_REQUIRED; +import static com.fowoco.server.stayverification.application.error.StayVerificationErrorCode.STAY_VERIFICATION_NEW_EXPIRY_REQUIRED; +import static com.fowoco.server.stayverification.application.error.StayVerificationErrorCode.STAY_VERIFICATION_NOT_FOUND; +import static com.fowoco.server.stayverification.application.error.StayVerificationErrorCode.STAY_VERIFICATION_PENDING_DETAILS_REQUIRED; +import static com.fowoco.server.stayverification.application.error.StayVerificationErrorCode.STAY_VERIFICATION_VERSION_CONFLICT; + +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.auth.application.ActorAuthorizer; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.time.DatabaseTimestamp; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.stayverification.application.port.StayVerificationRepository; +import com.fowoco.server.stayverification.application.port.StayVerificationRepository.ExpiredWorker; +import com.fowoco.server.stayverification.application.port.ExpiredStayCandidateReader; +import com.fowoco.server.stayverification.domain.StayVerificationCase; +import com.fowoco.server.stayverification.domain.StayVerificationStatus; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.Worker; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DataIntegrityViolationException; + +@Service +public class StayVerificationService { + + private static final String AUDIT_VERSION = "1"; + private static final String DAILY_SCAN_REQUEST_ID = "stay-verification-daily-scan"; + + private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; + private final StayVerificationRepository repository; + private final ExpiredStayCandidateReader expiredStayCandidateReader; + private final StayVerificationCaseCreationTransaction caseCreationTransaction; + private final WorkerRepository workerRepository; + private final WorkerDocumentRepository workerDocumentRepository; + private final AuditEventRepository auditRepository; + private final UuidGenerator uuidGenerator; + private final Clock clock; + + public StayVerificationService( + ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, + StayVerificationRepository repository, + ExpiredStayCandidateReader expiredStayCandidateReader, + StayVerificationCaseCreationTransaction caseCreationTransaction, + WorkerRepository workerRepository, + WorkerDocumentRepository workerDocumentRepository, + AuditEventRepository auditRepository, + UuidGenerator uuidGenerator, + Clock clock + ) { + this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; + this.repository = repository; + this.expiredStayCandidateReader = expiredStayCandidateReader; + this.caseCreationTransaction = caseCreationTransaction; + this.workerRepository = workerRepository; + this.workerDocumentRepository = workerDocumentRepository; + this.auditRepository = auditRepository; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + } + + @Transactional + public int scanCompany(ActorContext actor, RequestMetadata metadata) { + bindTenant(actor); + actorAuthorizer.requireHrWrite(actor); + Instant now = DatabaseTimestamp.now(clock); + return createCases( + repository.findExpiredWorkers(actor.companyId(), LocalDate.now(clock)), + now, + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + metadata.requestId(), + metadata.traceId() + ); + } + + @Transactional + public int scanAllCompanies() { + Instant now = DatabaseTimestamp.now(clock); + return createCases( + expiredStayCandidateReader.findExpiredWorkers(LocalDate.now(clock)), + now, + ActorType.SYSTEM_RULE, + null, + null, + DAILY_SCAN_REQUEST_ID, + null + ); + } + + @Transactional(readOnly = true) + public List findAll(StayVerificationStatus status, ActorContext actor) { + bindTenant(actor); + actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); + return repository.findAll(actor.companyId(), status); + } + + @Transactional + public StayVerificationCase update( + StayVerificationCommand command, + ActorContext actor, + RequestMetadata metadata + ) { + bindTenant(actor); + actorAuthorizer.requireHrWrite(actor); + StayVerificationCase current = repository + .findById(command.stayVerificationId(), actor.companyId()) + .orElseThrow(() -> new ApiException(STAY_VERIFICATION_NOT_FOUND)); + validate(command, current, actor.companyId()); + + Instant now = DatabaseTimestamp.now(clock); + if (!repository.update(command, actor.companyId(), now, now)) { + if (repository.findById(command.stayVerificationId(), actor.companyId()).isEmpty()) { + throw new ApiException(STAY_VERIFICATION_NOT_FOUND); + } + throw new ApiException(STAY_VERIFICATION_VERSION_CONFLICT); + } + if (command.status() == StayVerificationStatus.APPROVED) { + updateWorkerExpiry(current, command.newStayExpiryDate(), now); + } + StayVerificationCase updated = repository + .findById(command.stayVerificationId(), actor.companyId()) + .orElseThrow(() -> new ApiException(STAY_VERIFICATION_NOT_FOUND)); + appendAudit( + updated, + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + AuditAction.STAY_VERIFICATION_STATUS_UPDATED, + changeSummary(current, updated), + metadata.requestId(), + metadata.traceId(), + now + ); + return updated; + } + + private int createCases( + List workers, + Instant now, + ActorType actorType, + UUID actorId, + UserRole role, + String requestId, + String traceId + ) { + int created = 0; + for (ExpiredWorker worker : workers) { + try { + if (caseCreationTransaction.createIfAbsent( + worker, + now, + actorType, + actorId, + role, + requestId, + traceId + )) { + created++; + } + } catch (DataIntegrityViolationException duplicateScan) { + // 동시 스캔은 UNIQUE(company_id, worker_id, source_stay_expiry_date)가 최종 차단합니다. + } + } + return created; + } + + private void validate(StayVerificationCommand command, StayVerificationCase current, UUID companyId) { + boolean hasNote = command.officialConsultationNote() != null + && !command.officialConsultationNote().isBlank(); + validateDocument(command.extensionReceiptDocumentId(), current.workerId(), companyId); + validateDocument(command.approvalResultDocumentId(), current.workerId(), companyId); + switch (command.status()) { + case APPROVED -> { + if (command.newStayExpiryDate() == null + || !command.newStayExpiryDate().isAfter(current.sourceStayExpiryDate())) { + throw new ApiException(STAY_VERIFICATION_NEW_EXPIRY_REQUIRED); + } + if (command.approvalResultDocumentId() == null && !hasNote) { + throw new ApiException(STAY_VERIFICATION_EVIDENCE_REQUIRED); + } + } + case APPLICATION_PENDING -> { + boolean missing = command.extensionAppliedAt() == null + || command.recheckDate() == null + || !command.recheckDate().isAfter(LocalDate.now(clock)) + || (command.extensionReceiptDocumentId() == null && !hasNote); + if (missing) { + throw new ApiException(STAY_VERIFICATION_PENDING_DETAILS_REQUIRED); + } + } + case EMPLOYMENT_ENDED -> { + if (command.employmentEndConfirmedAt() == null || !hasNote) { + throw new ApiException(STAY_VERIFICATION_EMPLOYMENT_END_NOTE_REQUIRED); + } + } + case NOT_APPLIED -> { + if (!hasNote) { + throw new ApiException(STAY_VERIFICATION_EVIDENCE_REQUIRED); + } + } + case UNKNOWN -> { + // UNKNOWN은 확인 진행 중 상태이므로 증빙 없이 저장할 수 있습니다. + } + } + } + + private void validateDocument(UUID documentId, UUID workerId, UUID companyId) { + if (documentId == null) { + return; + } + workerDocumentRepository.findByIdAndWorkerIdAndCompanyId(documentId, workerId, companyId) + .orElseThrow(() -> new ApiException(STAY_VERIFICATION_DOCUMENT_NOT_FOUND)); + } + + private void updateWorkerExpiry(StayVerificationCase verification, LocalDate newExpiry, Instant now) { + Worker existing = workerRepository + .findByWorkerIdAndCompanyId(verification.workerId(), verification.companyId()) + .orElseThrow(() -> new ApiException(STAY_VERIFICATION_NOT_FOUND)); + Worker updated = new Worker( + existing.workerId(), + existing.companyId(), + existing.displayName(), + existing.nationalityCode(), + existing.preferredLanguage(), + existing.workStatus(), + existing.visaType(), + newExpiry, + existing.contractStartDate(), + existing.contractEndDate(), + existing.employmentPermitEndDate(), + existing.employmentActivityEndDate(), + existing.createdAt(), + now.isBefore(existing.createdAt()) ? existing.createdAt() : now, + existing.version() + ); + workerRepository.update(updated); + } + + private void appendAudit( + StayVerificationCase verification, + ActorType actorType, + UUID actorId, + UserRole userRole, + AuditAction action, + String summary, + String requestId, + String traceId, + Instant now + ) { + auditRepository.append(new AuditEvent( + uuidGenerator.generate(), + verification.companyId(), + actorType, + actorId, + userRole, + action, + AuditTargetType.STAY_VERIFICATION, + verification.stayVerificationId(), + requestId, + traceId, + AUDIT_VERSION, + summary, + now + )); + } + + private String changeSummary(StayVerificationCase before, StayVerificationCase after) { + return "status=" + before.verificationStatus() + "->" + after.verificationStatus() + + ", new_stay_expiry_date=" + before.newStayExpiryDate() + + "->" + after.newStayExpiryDate() + + ", recheck_date=" + before.recheckDate() + "->" + after.recheckDate() + + ", evidence_fields_changed=" + + (!java.util.Objects.equals( + before.extensionReceiptDocumentId(), after.extensionReceiptDocumentId() + ) || !java.util.Objects.equals( + before.approvalResultDocumentId(), after.approvalResultDocumentId() + )); + } + + private UserRole effectiveRole(ActorContext actor) { + return actor.roles().stream() + .min(Comparator.comparingInt(role -> switch (role) { + case ADMIN -> 0; + case HR -> 1; + case VIEWER -> 2; + })) + .orElseThrow(); + } + + private void bindTenant(ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/application/error/StayVerificationErrorCode.java b/src/main/java/com/fowoco/server/stayverification/application/error/StayVerificationErrorCode.java new file mode 100644 index 00000000..0a8cc681 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/application/error/StayVerificationErrorCode.java @@ -0,0 +1,52 @@ +package com.fowoco.server.stayverification.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum StayVerificationErrorCode implements ApiErrorCode { + STAY_VERIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "체류상태 확인 Case를 찾을 수 없습니다."), + STAY_VERIFICATION_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 담당자가 먼저 상태를 변경했습니다."), + STAY_VERIFICATION_EVIDENCE_REQUIRED( + HttpStatus.UNPROCESSABLE_CONTENT, + "상태를 확정하려면 승인 결과 서류 또는 공식 확인 메모가 필요합니다." + ), + STAY_VERIFICATION_NEW_EXPIRY_REQUIRED( + HttpStatus.UNPROCESSABLE_CONTENT, + "승인 완료 상태에는 기존 만료일보다 늦은 새 체류 만료일이 필요합니다." + ), + STAY_VERIFICATION_PENDING_DETAILS_REQUIRED( + HttpStatus.UNPROCESSABLE_CONTENT, + "심사 중 상태에는 신청일, 재확인일과 접수 증빙 또는 공식 확인 메모가 필요합니다." + ), + STAY_VERIFICATION_EMPLOYMENT_END_NOTE_REQUIRED( + HttpStatus.UNPROCESSABLE_CONTENT, + "고용 종료 확인에는 확인 시각과 공식 확인 메모가 필요합니다." + ), + STAY_VERIFICATION_DOCUMENT_NOT_FOUND( + HttpStatus.UNPROCESSABLE_CONTENT, + "이 근로자에게 연결된 증빙 서류를 찾을 수 없습니다." + ); + + private final HttpStatus status; + private final String defaultMessage; + + StayVerificationErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String defaultMessage() { + return defaultMessage; + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/application/port/ExpiredStayCandidateReader.java b/src/main/java/com/fowoco/server/stayverification/application/port/ExpiredStayCandidateReader.java new file mode 100644 index 00000000..e6070ce8 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/application/port/ExpiredStayCandidateReader.java @@ -0,0 +1,10 @@ +package com.fowoco.server.stayverification.application.port; + +import com.fowoco.server.stayverification.application.port.StayVerificationRepository.ExpiredWorker; +import java.time.LocalDate; +import java.util.List; + +public interface ExpiredStayCandidateReader { + + List findExpiredWorkers(LocalDate today); +} diff --git a/src/main/java/com/fowoco/server/stayverification/application/port/StayVerificationRepository.java b/src/main/java/com/fowoco/server/stayverification/application/port/StayVerificationRepository.java new file mode 100644 index 00000000..e7a18c8f --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/application/port/StayVerificationRepository.java @@ -0,0 +1,33 @@ +package com.fowoco.server.stayverification.application.port; + +import com.fowoco.server.stayverification.application.StayVerificationCommand; +import com.fowoco.server.stayverification.domain.StayVerificationCase; +import com.fowoco.server.stayverification.domain.StayVerificationStatus; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface StayVerificationRepository { + + List findExpiredWorkers(LocalDate today); + + List findExpiredWorkers(UUID companyId, LocalDate today); + + boolean insertIfAbsent(UUID verificationId, ExpiredWorker worker, Instant now); + + List findAll(UUID companyId, StayVerificationStatus status); + + Optional findById(UUID verificationId, UUID companyId); + + boolean update(StayVerificationCommand command, UUID companyId, Instant checkedAt, Instant now); + + record ExpiredWorker( + UUID companyId, + UUID workerId, + String displayName, + LocalDate stayExpiryDate + ) { + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/domain/StayVerificationCase.java b/src/main/java/com/fowoco/server/stayverification/domain/StayVerificationCase.java new file mode 100644 index 00000000..ed67f9b5 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/domain/StayVerificationCase.java @@ -0,0 +1,29 @@ +package com.fowoco.server.stayverification.domain; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +public record StayVerificationCase( + UUID stayVerificationId, + UUID companyId, + UUID workerId, + String workerDisplayName, + LocalDate sourceStayExpiryDate, + StayVerificationStatus verificationStatus, + Instant statusCheckedAt, + LocalDate extensionAppliedAt, + UUID extensionReceiptDocumentId, + UUID approvalResultDocumentId, + LocalDate newStayExpiryDate, + String officialConsultationNote, + Instant employmentEndConfirmedAt, + LocalDate recheckDate, + Instant createdAt, + Instant updatedAt, + long version +) { + public boolean employmentChangeCandidateAvailable() { + return verificationStatus == StayVerificationStatus.EMPLOYMENT_ENDED; + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/domain/StayVerificationStatus.java b/src/main/java/com/fowoco/server/stayverification/domain/StayVerificationStatus.java new file mode 100644 index 00000000..928e8180 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/domain/StayVerificationStatus.java @@ -0,0 +1,9 @@ +package com.fowoco.server.stayverification.domain; + +public enum StayVerificationStatus { + APPROVED, + APPLICATION_PENDING, + UNKNOWN, + NOT_APPLIED, + EMPLOYMENT_ENDED +} diff --git a/src/main/java/com/fowoco/server/stayverification/infrastructure/persistence/JdbcStayVerificationRepository.java b/src/main/java/com/fowoco/server/stayverification/infrastructure/persistence/JdbcStayVerificationRepository.java new file mode 100644 index 00000000..9a58ce7c --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/infrastructure/persistence/JdbcStayVerificationRepository.java @@ -0,0 +1,212 @@ +package com.fowoco.server.stayverification.infrastructure.persistence; + +import com.fowoco.server.stayverification.application.StayVerificationCommand; +import com.fowoco.server.stayverification.application.port.StayVerificationRepository; +import com.fowoco.server.stayverification.domain.StayVerificationCase; +import com.fowoco.server.stayverification.domain.StayVerificationStatus; +import java.sql.Date; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class JdbcStayVerificationRepository implements StayVerificationRepository { + + private static final String SELECT_CASE = """ + SELECT verification.stay_verification_id, verification.company_id, + verification.worker_id, worker.display_name, + verification.source_stay_expiry_date, verification.verification_status, + verification.status_checked_at, verification.extension_applied_at, + verification.extension_receipt_document_id, + verification.approval_result_document_id, + verification.new_stay_expiry_date, + verification.official_consultation_note, + verification.employment_end_confirmed_at, + verification.recheck_date, verification.created_at, + verification.updated_at, verification.version + FROM stay_verification_case verification + JOIN worker + ON worker.worker_id = verification.worker_id + AND worker.company_id = verification.company_id + """; + + private final JdbcTemplate jdbcTemplate; + + public JdbcStayVerificationRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public List findExpiredWorkers(LocalDate today) { + return jdbcTemplate.query( + """ + SELECT company_id, worker_id, display_name, stay_expiry_date + FROM worker + WHERE stay_expiry_date < ? + AND work_status IN ('ACTIVE', 'ON_LEAVE') + ORDER BY company_id, worker_id + """, + this::mapExpiredWorker, + Date.valueOf(today) + ); + } + + @Override + public List findExpiredWorkers(UUID companyId, LocalDate today) { + return jdbcTemplate.query( + """ + SELECT company_id, worker_id, display_name, stay_expiry_date + FROM worker + WHERE company_id = ? + AND stay_expiry_date < ? + AND work_status IN ('ACTIVE', 'ON_LEAVE') + ORDER BY worker_id + """, + this::mapExpiredWorker, + companyId, + Date.valueOf(today) + ); + } + + @Override + public boolean insertIfAbsent(UUID verificationId, ExpiredWorker worker, Instant now) { + return jdbcTemplate.update( + """ + INSERT INTO stay_verification_case ( + stay_verification_id, company_id, worker_id, + source_stay_expiry_date, verification_status, + created_at, updated_at, version + ) + SELECT ?, ?, ?, ?, 'UNKNOWN', ?, ?, 0 + WHERE NOT EXISTS ( + SELECT 1 + FROM stay_verification_case + WHERE company_id = ? + AND worker_id = ? + AND source_stay_expiry_date = ? + ) + """, + verificationId, + worker.companyId(), + worker.workerId(), + Date.valueOf(worker.stayExpiryDate()), + Timestamp.from(now), + Timestamp.from(now), + worker.companyId(), + worker.workerId(), + Date.valueOf(worker.stayExpiryDate()) + ) == 1; + } + + @Override + public List findAll(UUID companyId, StayVerificationStatus status) { + String statusFilter = status == null ? "" : " AND verification.verification_status = ?"; + String sql = SELECT_CASE + + " WHERE verification.company_id = ?" + + statusFilter + + " ORDER BY verification.source_stay_expiry_date ASC, verification.created_at ASC"; + return status == null + ? jdbcTemplate.query(sql, this::mapCase, companyId) + : jdbcTemplate.query(sql, this::mapCase, companyId, status.name()); + } + + @Override + public Optional findById(UUID verificationId, UUID companyId) { + return jdbcTemplate.query( + SELECT_CASE + " WHERE verification.stay_verification_id = ? AND verification.company_id = ?", + this::mapCase, + verificationId, + companyId + ) + .stream() + .findFirst(); + } + + @Override + public boolean update(StayVerificationCommand command, UUID companyId, Instant checkedAt, Instant now) { + return jdbcTemplate.update( + """ + UPDATE stay_verification_case + SET verification_status = ?, status_checked_at = ?, + extension_applied_at = ?, extension_receipt_document_id = ?, + approval_result_document_id = ?, new_stay_expiry_date = ?, + official_consultation_note = ?, employment_end_confirmed_at = ?, + recheck_date = ?, updated_at = ?, version = version + 1 + WHERE stay_verification_id = ? AND company_id = ? AND version = ? + """, + command.status().name(), + Timestamp.from(checkedAt), + nullableDate(command.extensionAppliedAt()), + command.extensionReceiptDocumentId(), + command.approvalResultDocumentId(), + nullableDate(command.newStayExpiryDate()), + normalize(command.officialConsultationNote()), + nullableTimestamp(command.employmentEndConfirmedAt()), + nullableDate(command.recheckDate()), + Timestamp.from(now), + command.stayVerificationId(), + companyId, + command.expectedVersion() + ) == 1; + } + + private ExpiredWorker mapExpiredWorker(ResultSet resultSet, int rowNumber) throws SQLException { + return new ExpiredWorker( + resultSet.getObject("company_id", UUID.class), + resultSet.getObject("worker_id", UUID.class), + resultSet.getString("display_name"), + resultSet.getDate("stay_expiry_date").toLocalDate() + ); + } + + private StayVerificationCase mapCase(ResultSet resultSet, int rowNumber) throws SQLException { + return new StayVerificationCase( + resultSet.getObject("stay_verification_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getObject("worker_id", UUID.class), + resultSet.getString("display_name"), + resultSet.getDate("source_stay_expiry_date").toLocalDate(), + StayVerificationStatus.valueOf(resultSet.getString("verification_status")), + instant(resultSet, "status_checked_at"), + localDate(resultSet, "extension_applied_at"), + resultSet.getObject("extension_receipt_document_id", UUID.class), + resultSet.getObject("approval_result_document_id", UUID.class), + localDate(resultSet, "new_stay_expiry_date"), + resultSet.getString("official_consultation_note"), + instant(resultSet, "employment_end_confirmed_at"), + localDate(resultSet, "recheck_date"), + resultSet.getTimestamp("created_at").toInstant(), + resultSet.getTimestamp("updated_at").toInstant(), + resultSet.getLong("version") + ); + } + + private static LocalDate localDate(ResultSet resultSet, String column) throws SQLException { + Date value = resultSet.getDate(column); + return value == null ? null : value.toLocalDate(); + } + + private static Instant instant(ResultSet resultSet, String column) throws SQLException { + Timestamp value = resultSet.getTimestamp(column); + return value == null ? null : value.toInstant(); + } + + private static Date nullableDate(LocalDate value) { + return value == null ? null : Date.valueOf(value); + } + + private static Timestamp nullableTimestamp(Instant value) { + return value == null ? null : Timestamp.from(value); + } + + private static String normalize(String value) { + return value == null ? null : value.strip(); + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/infrastructure/persistence/LocalExpiredStayCandidateReader.java b/src/main/java/com/fowoco/server/stayverification/infrastructure/persistence/LocalExpiredStayCandidateReader.java new file mode 100644 index 00000000..83e2edc2 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/infrastructure/persistence/LocalExpiredStayCandidateReader.java @@ -0,0 +1,29 @@ +package com.fowoco.server.stayverification.infrastructure.persistence; + +import com.fowoco.server.stayverification.application.port.ExpiredStayCandidateReader; +import com.fowoco.server.stayverification.application.port.StayVerificationRepository; +import com.fowoco.server.stayverification.application.port.StayVerificationRepository.ExpiredWorker; +import java.time.LocalDate; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "transaction-only", + matchIfMissing = true +) +public class LocalExpiredStayCandidateReader implements ExpiredStayCandidateReader { + + private final StayVerificationRepository repository; + + public LocalExpiredStayCandidateReader(StayVerificationRepository repository) { + this.repository = repository; + } + + @Override + public List findExpiredWorkers(LocalDate today) { + return repository.findExpiredWorkers(today); + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/infrastructure/persistence/PostgreSqlExpiredStayCandidateReader.java b/src/main/java/com/fowoco/server/stayverification/infrastructure/persistence/PostgreSqlExpiredStayCandidateReader.java new file mode 100644 index 00000000..04711b74 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/infrastructure/persistence/PostgreSqlExpiredStayCandidateReader.java @@ -0,0 +1,51 @@ +package com.fowoco.server.stayverification.infrastructure.persistence; + +import com.fowoco.server.stayverification.application.port.ExpiredStayCandidateReader; +import com.fowoco.server.stayverification.application.port.StayVerificationRepository.ExpiredWorker; +import jakarta.persistence.EntityManager; +import java.sql.Date; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +@Repository +@ConditionalOnProperty( + name = "app.database.tenant-context-mode", + havingValue = "postgresql" +) +public class PostgreSqlExpiredStayCandidateReader implements ExpiredStayCandidateReader { + + private static final String SQL = """ + SELECT company_id, worker_id, display_name, stay_expiry_date + FROM public.bootstrap_expired_stay_candidates(?1) + """; + + private final EntityManager entityManager; + + public PostgreSqlExpiredStayCandidateReader(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @SuppressWarnings("unchecked") + public List findExpiredWorkers(LocalDate today) { + return ((List) entityManager.createNativeQuery(SQL) + .setParameter(1, today) + .getResultList()).stream() + .map(row -> new ExpiredWorker( + uuid(row[0]), + uuid(row[1]), + row[2].toString(), + row[3] instanceof LocalDate date + ? date + : ((Date) row[3]).toLocalDate() + )) + .toList(); + } + + private static UUID uuid(Object value) { + return value instanceof UUID uuid ? uuid : UUID.fromString(value.toString()); + } +} diff --git a/src/main/java/com/fowoco/server/stayverification/infrastructure/scheduling/StayVerificationScheduler.java b/src/main/java/com/fowoco/server/stayverification/infrastructure/scheduling/StayVerificationScheduler.java new file mode 100644 index 00000000..2f19de23 --- /dev/null +++ b/src/main/java/com/fowoco/server/stayverification/infrastructure/scheduling/StayVerificationScheduler.java @@ -0,0 +1,32 @@ +package com.fowoco.server.stayverification.infrastructure.scheduling; + +import com.fowoco.server.stayverification.application.StayVerificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty( + name = "app.stay-verification.scheduler-enabled", + havingValue = "true", + matchIfMissing = true +) +public class StayVerificationScheduler { + + private static final Logger log = LoggerFactory.getLogger(StayVerificationScheduler.class); + private final StayVerificationService service; + + public StayVerificationScheduler(StayVerificationService service) { + this.service = service; + } + + @Scheduled(cron = "${app.stay-verification.scan-cron:0 10 2 * * *}", zone = "Asia/Seoul") + public void scanExpiredStayDates() { + int created = service.scanAllCompanies(); + if (created > 0) { + log.info("stay verification daily scan created {} case(s)", created); + } + } +} diff --git a/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java b/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java index f696c6b2..436bf55d 100644 --- a/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java +++ b/src/main/java/com/fowoco/server/task/application/AiCandidateTaskCreationService.java @@ -121,7 +121,9 @@ public CreationResult create( } WorkflowCatalog catalog = catalogService.getActiveCatalog(); - List workflows = catalog.findByIntent(command.detectedIntent()); + List workflows = catalog.findByIntent(command.detectedIntent()).stream() + .filter(workflow -> isStandardExpiryRenewalWorkflow(workflow.workflowId())) + .toList(); if (workflows.isEmpty() || workflows.stream().noneMatch(workflow -> workflow.workflowId() .equals(command.candidateWorkflowId()))) { @@ -214,6 +216,10 @@ private List plans(List workflows) { .toList(); } + private boolean isStandardExpiryRenewalWorkflow(String workflowId) { + return workflowId.equals("WF-CON-001") || workflowId.equals("WF-STY-001"); + } + private WorkflowDefinition documentRequestWorkflow(WorkflowCatalog catalog) { return catalog.findByIntent("DOCUMENT_REQUEST").stream() .filter(workflow -> workflow.supportedTaskTypes().contains(TaskType.DOCUMENT_REQUEST)) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 59e19ce7..13348918 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -132,6 +132,9 @@ app: catalog: location: ${WORKFLOW_CATALOG_LOCATION:classpath:workflow/catalog-projection.local.json} allow-unreleased: ${WORKFLOW_CATALOG_ALLOW_UNRELEASED:true} + stay-verification: + scheduler-enabled: ${STAY_VERIFICATION_SCHEDULER_ENABLED:true} + scan-cron: ${STAY_VERIFICATION_SCAN_CRON:0 10 2 * * *} file-storage: local-path: ${FILE_STORAGE_LOCAL_PATH:./data/files} cors: @@ -254,6 +257,8 @@ app: reliability: outbox: enabled: false + stay-verification: + scheduler-enabled: false auth: jwt: secret-base64: ${JWT_SECRET_BASE64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=} diff --git a/src/main/resources/db/migration-postgresql/V55__prepare_expired_stay_scan.sql b/src/main/resources/db/migration-postgresql/V55__prepare_expired_stay_scan.sql new file mode 100644 index 00000000..86caa8e6 --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V55__prepare_expired_stay_scan.sql @@ -0,0 +1,47 @@ +CREATE FUNCTION public.bootstrap_expired_stay_candidates( + p_today DATE +) +RETURNS TABLE ( + company_id UUID, + worker_id UUID, + display_name VARCHAR(120), + stay_expiry_date DATE +) +LANGUAGE SQL +STABLE +SECURITY DEFINER +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT worker.company_id, + worker.worker_id, + worker.display_name, + worker.stay_expiry_date + FROM public.worker AS worker + WHERE worker.stay_expiry_date < p_today + AND worker.work_status IN ('ACTIVE', 'ON_LEAVE') + AND NOT EXISTS ( + SELECT 1 + FROM public.stay_verification_case AS verification + WHERE verification.company_id = worker.company_id + AND verification.worker_id = worker.worker_id + AND verification.source_stay_expiry_date = worker.stay_expiry_date + ) + ORDER BY worker.company_id, worker.worker_id +$$; + +REVOKE ALL + ON FUNCTION public.bootstrap_expired_stay_candidates(DATE) + FROM PUBLIC; + +CREATE POLICY pl_stay_verification_tenant_isolation + ON public.stay_verification_case + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); diff --git a/src/main/resources/db/migration/V54__create_stay_verification_case.sql b/src/main/resources/db/migration/V54__create_stay_verification_case.sql new file mode 100644 index 00000000..aafa0846 --- /dev/null +++ b/src/main/resources/db/migration/V54__create_stay_verification_case.sql @@ -0,0 +1,50 @@ +CREATE TABLE stay_verification_case ( + stay_verification_id UUID NOT NULL, + company_id UUID NOT NULL, + worker_id UUID NOT NULL, + source_stay_expiry_date DATE NOT NULL, + verification_status VARCHAR(30) NOT NULL, + status_checked_at TIMESTAMP(6) WITH TIME ZONE, + extension_applied_at DATE, + extension_receipt_document_id UUID, + approval_result_document_id UUID, + new_stay_expiry_date DATE, + official_consultation_note VARCHAR(1000), + employment_end_confirmed_at TIMESTAMP(6) WITH TIME ZONE, + recheck_date DATE, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_stay_verification_case PRIMARY KEY (stay_verification_id), + CONSTRAINT uq_stay_verification_worker_expiry + UNIQUE (company_id, worker_id, source_stay_expiry_date), + CONSTRAINT fk_stay_verification_worker_company + FOREIGN KEY (worker_id, company_id) + REFERENCES worker (worker_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_stay_verification_extension_receipt + FOREIGN KEY (extension_receipt_document_id, company_id) + REFERENCES worker_document (worker_document_id, company_id) ON DELETE RESTRICT, + CONSTRAINT fk_stay_verification_approval_result + FOREIGN KEY (approval_result_document_id, company_id) + REFERENCES worker_document (worker_document_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_stay_verification_status CHECK ( + verification_status IN ( + 'APPROVED', 'APPLICATION_PENDING', 'UNKNOWN', 'NOT_APPLIED', 'EMPLOYMENT_ENDED' + ) + ), + CONSTRAINT ck_stay_verification_note CHECK ( + official_consultation_note IS NULL + OR CHAR_LENGTH(TRIM(official_consultation_note)) > 0 + ), + CONSTRAINT ck_stay_verification_new_expiry CHECK ( + new_stay_expiry_date IS NULL OR new_stay_expiry_date > source_stay_expiry_date + ), + CONSTRAINT ck_stay_verification_version CHECK (version >= 0), + CONSTRAINT ck_stay_verification_updated_at CHECK (updated_at >= created_at) +); + +CREATE INDEX idx_stay_verification_company_status + ON stay_verification_case (company_id, verification_status, updated_at DESC); + +CREATE INDEX idx_stay_verification_company_worker + ON stay_verification_case (company_id, worker_id, created_at DESC); diff --git a/src/main/resources/workflow/catalog-projection.local.json b/src/main/resources/workflow/catalog-projection.local.json index fe85425c..9d542227 100644 --- a/src/main/resources/workflow/catalog-projection.local.json +++ b/src/main/resources/workflow/catalog-projection.local.json @@ -1,6 +1,6 @@ { "bundle_id": "FOWOCO-KNOWLEDGE", - "bundle_version": "0.3.0", + "bundle_version": "0.3.1", "bundle_status": "DRAFT", "source_repository": "fowoco/knowledge", "generated_at": "2026-08-16T00:00:00Z", @@ -156,6 +156,51 @@ "SRC-LAW-IMMIGRATION-ACT-25", "SRC-KEIS-REQUIRED-DOCS" ] + }, + { + "workflow_id": "WF-STY-EXC-001", + "name": "체류기간 만료 경과 긴급 확인", + "intent": "EXPIRY_RENEWAL", + "sensitivity": "critical", + "supported_task_types": ["STAY_PERIOD_EXTENSION"], + "required_slots": [ + "worker_id", + "stay_expiry_date", + "stay_verification_status" + ], + "allowed_slot_keys": [ + "worker_id", + "stay_expiry_date", + "stay_verification_status", + "status_checked_at", + "extension_applied_at", + "extension_receipt_document_id", + "approval_result_document_id", + "new_stay_expiry_date", + "official_consultation_note", + "employment_end_confirmed_at" + ], + "resolvable_slot_keys": ["worker_id", "stay_expiry_date"], + "checklist_items": [ + { + "item_code": "VERIFY_STAY_STATUS_WITH_EVIDENCE", + "label": "공식 결과 또는 확인 메모로 현재 체류상태 확인", + "required": true + }, + { + "item_code": "BLOCK_AUTOMATIC_EMPLOYMENT_CHANGE", + "label": "날짜 경과만으로 고용상태를 변경하지 않았는지 확인", + "required": true + } + ], + "completion_evidence": [ + "approval_result_document_or_official_note", + "status_checked_at" + ], + "source_ids": [ + "SRC-HIKOREA", + "SRC-LAW-IMMIGRATION-ACT-25" + ] } ] } diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 9edbf552..06a94c63 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -109,7 +109,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "worker_import_job", "worker_import_row", "worker_import_commit_idempotency", - "document_ocr_run" + "document_ocr_run", + "stay_verification_case" ); assertThat(columnSpecs(connection, "company")) @@ -541,7 +542,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_worker_import_row_tenant_isolation", "pl_worker_import_commit_idempotency_tenant_isolation", "pl_document_ocr_run_tenant_isolation", - "pl_notification_tenant_isolation" + "pl_notification_tenant_isolation", + "pl_stay_verification_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) @@ -552,7 +554,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "bootstrap_company_id_by_worker_link_token_hash", "bootstrap_claim_event_publications", "bootstrap_count_outstanding_event_publications", - "bootstrap_oldest_outstanding_event_occurred_at" + "bootstrap_oldest_outstanding_event_occurred_at", + "bootstrap_expired_stay_candidates" ); assertThat(functionsWithLockedSearchPath(connection)) .containsExactlyInAnyOrder( @@ -562,7 +565,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "bootstrap_company_id_by_worker_link_token_hash", "bootstrap_claim_event_publications", "bootstrap_count_outstanding_event_publications", - "bootstrap_oldest_outstanding_event_occurred_at" + "bootstrap_oldest_outstanding_event_occurred_at", + "bootstrap_expired_stay_candidates" ); } diff --git a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java index a8299c30..2476b078 100644 --- a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java +++ b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java @@ -26,7 +26,7 @@ public final class AiRuntimeContractFixture { public static final UUID ATTEMPT_ID = UUID.fromString("20000000-0000-0000-0000-000000000001"); public static final UUID WORKER_REF = UUID.fromString("30000000-0000-0000-0000-000000000001"); public static final String CONTRACT_VERSION = "1.1.0"; - public static final String KNOWLEDGE_VERSION = "0.3.0"; + public static final String KNOWLEDGE_VERSION = "0.3.1"; public static final String INTENT = "EXPIRY_RENEWAL"; public static final String WORKFLOW_ID = "WF-STY-001"; @@ -196,7 +196,7 @@ public static AiRuntimeVersions validVersions() { "gpt-5-mini", "2026-07-01", "prompt-3", - "0.3.0", + "0.3.1", KNOWLEDGE_VERSION, CONTRACT_VERSION ); diff --git a/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java b/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java index 4794d63f..b4862969 100644 --- a/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java +++ b/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java @@ -824,7 +824,7 @@ private AiRuntimeVersions versions() { "1", "prompt-demo-1", "context-demo-1", - "0.3.0", + "0.3.1", "1.1.0" ); } diff --git a/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java b/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java index 4477da84..7a32a167 100644 --- a/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java +++ b/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java @@ -46,7 +46,7 @@ void sameDisplayNameInAnotherCompanyDoesNotAffectTenantScopedResolution() { AiSlotResolution result = resolutionTransaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement("같은이름") ); @@ -64,7 +64,7 @@ void normalizedLookupDoesNotReadAnotherCompanyCandidate() { AiSlotResolution result = resolutionTransaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement("응 우 옌 반 안") ); @@ -81,7 +81,7 @@ void duplicateDisplayNameInsideTheSameCompanyIsReportedAsAmbiguous() { assertThatThrownBy(() -> resolutionTransaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement("동명이인") )) .isInstanceOfSatisfying(AiContextResolutionException.class, exception -> @@ -98,7 +98,7 @@ void resolvesSpacingSeparatorAndCaseVariantsInsideTheCurrentCompany() { AiSlotResolution korean = resolutionTransaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement("응 우 옌-반_안") ); @@ -108,7 +108,7 @@ void resolvesSpacingSeparatorAndCaseVariantsInsideTheCurrentCompany() { AiSlotResolution romanized = resolutionTransaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement("NGUYEN-VAN_AN") ); @@ -123,7 +123,7 @@ void exactMatchWinsBeforeNormalizedCandidates() { AiSlotResolution result = resolutionTransaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement("응우옌 반 안") ); @@ -138,7 +138,7 @@ void multipleNormalizedCandidatesAreReportedAsAmbiguous() { assertThatThrownBy(() -> resolutionTransaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement("응 우 옌 반 안") )) .isInstanceOfSatisfying(AiContextResolutionException.class, exception -> @@ -155,7 +155,7 @@ void separatorOnlyTargetIsReportedAsNotFound() { assertThatThrownBy(() -> resolutionTransaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement("- _ .") )) .isInstanceOfSatisfying(AiContextResolutionException.class, exception -> @@ -198,7 +198,7 @@ void resolvesLatestIdentityDocumentStatusesWithoutReadingAnotherCompany() { AiSlotResolution result = resolutionTransaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement("문서상태근로자", List.of("passport_status", "arc_status")) ); diff --git a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java index b4b1ca63..c1636853 100644 --- a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java +++ b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java @@ -232,7 +232,7 @@ private WorkflowCatalog catalog() { ); return new WorkflowCatalog( "FOWOCO-KNOWLEDGE", - "0.3.0", + "0.3.1", "DRAFT", "fowoco/knowledge", Instant.parse("2026-07-23T00:00:00Z"), diff --git a/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java b/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java index 37092cef..1daecc56 100644 --- a/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java +++ b/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java @@ -37,7 +37,7 @@ void resolvesOnlyAllowListedWorkerFieldsAndReportsMissingValues() { AiSlotResolution result = transaction.resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement(List.of( "worker_id", "stay_expiry_date", @@ -72,7 +72,7 @@ void rejectsFieldOutsideKnowledgeAllowListBeforeReadingWorkerData() { assertFailure( () -> transaction(reader, new AtomicReference<>()).resolve( COMPANY_A, - "0.3.0", + "0.3.1", requirement(List.of("legal_name")) ), AiContextResolutionFailureCode.FORBIDDEN_FIELD @@ -110,7 +110,7 @@ void rejectsWorkflowThatDoesNotBelongToTheDetectedIntent() { () -> transaction( (companyId, displayName) -> List.of(worker(COMPANY_A)), new AtomicReference<>() - ).resolve(COMPANY_A, "0.3.0", mismatched), + ).resolve(COMPANY_A, "0.3.1", mismatched), AiContextResolutionFailureCode.UNSUPPORTED_WORKFLOW ); } @@ -119,7 +119,7 @@ void rejectsWorkflowThatDoesNotBelongToTheDetectedIntent() { void distinguishesMissingAmbiguousAndCrossCompanyTargetsWithoutLeakingNames() { assertFailure( () -> transaction((companyId, displayName) -> List.of(), new AtomicReference<>()) - .resolve(COMPANY_A, "0.3.0", requirement(List.of("worker_id"))), + .resolve(COMPANY_A, "0.3.1", requirement(List.of("worker_id"))), AiContextResolutionFailureCode.TARGET_NOT_FOUND ); @@ -127,14 +127,14 @@ void distinguishesMissingAmbiguousAndCrossCompanyTargetsWithoutLeakingNames() { () -> transaction( (companyId, displayName) -> List.of(worker(COMPANY_A), worker(COMPANY_A)), new AtomicReference<>() - ).resolve(COMPANY_A, "0.3.0", requirement(List.of("worker_id"))), + ).resolve(COMPANY_A, "0.3.1", requirement(List.of("worker_id"))), AiContextResolutionFailureCode.TARGET_AMBIGUOUS ); assertThatThrownBy(() -> transaction( (companyId, displayName) -> List.of(worker(COMPANY_B)), new AtomicReference<>() - ).resolve(COMPANY_A, "0.3.0", requirement(List.of("worker_id")))) + ).resolve(COMPANY_A, "0.3.1", requirement(List.of("worker_id")))) .isInstanceOfSatisfying(AiContextResolutionException.class, exception -> { assertThat(exception.failureCode()).isEqualTo( AiContextResolutionFailureCode.TARGET_NOT_FOUND @@ -158,7 +158,7 @@ private AiSlotResolutionTransaction transaction( private WorkflowCatalog catalog() { return new WorkflowCatalog( "FOWOCO-KNOWLEDGE", - "0.3.0", + "0.3.1", "DRAFT", "fowoco/knowledge", Instant.parse("2026-07-23T00:00:00Z"), diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java index d4213fb7..cf967fe8 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedIntegrationTest.java @@ -490,7 +490,7 @@ private void assertGoldenFlowStartState() { private void assertGoldenFlowDocumentContext() { var resolution = aiSlotResolutionTransaction.resolve( COMPANY_ID, - "0.3.0", + "0.3.1", new AiContextRequirement( "EXPIRY_RENEWAL", BigDecimal.ONE, diff --git a/src/test/java/com/fowoco/server/stayverification/StayVerificationIntegrationTest.java b/src/test/java/com/fowoco/server/stayverification/StayVerificationIntegrationTest.java new file mode 100644 index 00000000..d48e9599 --- /dev/null +++ b/src/test/java/com/fowoco/server/stayverification/StayVerificationIntegrationTest.java @@ -0,0 +1,296 @@ +package com.fowoco.server.stayverification; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StayVerificationIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("a1000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("b1000000-0000-0000-0000-000000000001"); + private static final UUID HR_A = UUID.fromString("a2000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("b2000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_A = UUID.fromString("a3000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_B = UUID.fromString("b3000000-0000-0000-0000-000000000001"); + private static final String EMAIL_A = "hr.stay.a@example.com"; + private static final String EMAIL_B = "hr.stay.b@example.com"; + private static final String PASSWORD = "Test-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private String tokenA; + private String tokenB; + + @BeforeAll + void seedAccounts() throws Exception { + insertCompany(COMPANY_A, "체류확인 사업장 A"); + insertCompany(COMPANY_B, "체류확인 사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, EMAIL_A, passwordHash); + insertUser(HR_B, COMPANY_B, EMAIL_B, passwordHash); + tokenA = accessToken(login(EMAIL_A)); + tokenB = accessToken(login(EMAIL_B)); + } + + @BeforeEach + void resetCasesAndWorkers() { + jdbcTemplate.update("DELETE FROM audit_event WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM stay_verification_case WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM worker_document WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM worker WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + insertWorker(WORKER_A, COMPANY_A, "응웬반A", "ACTIVE", LocalDate.now().minusDays(3)); + insertWorker(WORKER_B, COMPANY_B, "타사업장근로자", "ACTIVE", LocalDate.now().minusDays(5)); + } + + @AfterEach + void removeCasesAndWorkers() { + jdbcTemplate.update("DELETE FROM audit_event WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM stay_verification_case WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM worker_document WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM worker WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + } + + @AfterAll + void removeAccounts() { + jdbcTemplate.update("DELETE FROM refresh_token WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM user_account WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + jdbcTemplate.update("DELETE FROM company WHERE company_id IN (?, ?)", COMPANY_A, COMPANY_B); + } + + @Test + void scanIsIdempotentAndTenantScoped() throws Exception { + HttpResponse first = post("/api/v1/stay-verifications/scan", "{}", tokenA); + HttpResponse second = post("/api/v1/stay-verifications/scan", "{}", tokenA); + HttpResponse companyAList = get("/api/v1/stay-verifications", tokenA); + HttpResponse companyBList = get("/api/v1/stay-verifications", tokenB); + + assertThat(first.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(first.body(), "$.created_count").intValue()).isEqualTo(1); + assertThat(JsonPath.read(second.body(), "$.created_count").intValue()).isZero(); + assertThat(JsonPath.>read(companyAList.body(), "$[*]")).hasSize(1); + assertThat(JsonPath.read(companyAList.body(), "$[0].verification_status")) + .isEqualTo("UNKNOWN"); + assertThat(JsonPath.>read(companyBList.body(), "$[*]")).isEmpty(); + } + + @Test + void approvedStatusUpdatesStayExpiryAndWritesAudit() throws Exception { + String verificationId = createVerificationAndGetId(); + String newExpiry = LocalDate.now().plusYears(1).toString(); + + HttpResponse response = patch( + "/api/v1/stay-verifications/" + verificationId, + """ + { + "status": "APPROVED", + "new_stay_expiry_date": "%s", + "official_consultation_note": "하이코리아 승인 결과를 확인함", + "expected_version": 0 + } + """.formatted(newExpiry), + tokenA + ); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.verification_status")).isEqualTo("APPROVED"); + assertThat(jdbcTemplate.queryForObject( + "SELECT stay_expiry_date FROM worker WHERE worker_id = ?", + LocalDate.class, + WORKER_A + )).isEqualTo(LocalDate.parse(newExpiry)); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_event WHERE target_id = ? AND action = 'STAY_VERIFICATION_STATUS_UPDATED'", + Integer.class, + UUID.fromString(verificationId) + )).isEqualTo(1); + } + + @Test + void approvalWithoutEvidenceIsRejectedAndDoesNotChangeWorker() throws Exception { + String verificationId = createVerificationAndGetId(); + LocalDate originalExpiry = LocalDate.now().minusDays(3); + + HttpResponse response = patch( + "/api/v1/stay-verifications/" + verificationId, + """ + { + "status": "APPROVED", + "new_stay_expiry_date": "%s", + "expected_version": 0 + } + """.formatted(LocalDate.now().plusYears(1)), + tokenA + ); + + assertThat(response.statusCode()).isEqualTo(422); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("STAY_VERIFICATION_EVIDENCE_REQUIRED"); + assertThat(jdbcTemplate.queryForObject( + "SELECT stay_expiry_date FROM worker WHERE worker_id = ?", + LocalDate.class, + WORKER_A + )).isEqualTo(originalExpiry); + } + + @Test + void employmentEndedOnlySuggestsEmploymentChangeAfterHrConfirmation() throws Exception { + String verificationId = createVerificationAndGetId(); + + HttpResponse response = patch( + "/api/v1/stay-verifications/" + verificationId, + """ + { + "status": "EMPLOYMENT_ENDED", + "official_consultation_note": "HR이 근로관계 종료 사실을 확인함", + "employment_end_confirmed_at": "2026-08-17T04:00:00Z", + "expected_version": 0 + } + """, + tokenA + ); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.employment_change_candidate_available")) + .isTrue(); + assertThat(JsonPath.read(response.body(), "$.suggested_workflow_id")) + .isEqualTo("WF-CHG-001"); + assertThat(jdbcTemplate.queryForObject( + "SELECT work_status FROM worker WHERE worker_id = ?", + String.class, + WORKER_A + )).isEqualTo("ACTIVE"); + } + + private String createVerificationAndGetId() throws Exception { + assertThat(post("/api/v1/stay-verifications/scan", "{}", tokenA).statusCode()).isEqualTo(200); + HttpResponse list = get("/api/v1/stay-verifications", tokenA); + return JsonPath.read(list.body(), "$[0].stay_verification_id"); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + companyId, + name + ); + } + + private void insertUser(UUID userId, UUID companyId, String email, String passwordHash) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, + companyId, + email, + email, + passwordHash + ); + } + + private void insertWorker( + UUID workerId, + UUID companyId, + String displayName, + String status, + LocalDate stayExpiryDate + ) { + jdbcTemplate.update( + """ + INSERT INTO worker ( + worker_id, company_id, display_name, work_status, stay_expiry_date, + created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + workerId, + companyId, + displayName, + status, + stayExpiryDate + ); + } + + private HttpResponse login(String email) throws Exception { + return post( + "/api/v1/auth/login", + """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD), + null + ); + } + + private String accessToken(HttpResponse response) { + assertThat(response.statusCode()).isEqualTo(200); + return JsonPath.read(response.body(), "$.access_token"); + } + + private HttpResponse get(String path, String token) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private HttpResponse post(String path, String body, String token) throws Exception { + return send(path, body, token, "POST"); + } + + private HttpResponse patch(String path, String body, String token) throws Exception { + return send(path, body, token, "PATCH"); + } + + private HttpResponse send(String path, String body, String token, String method) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} diff --git a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java index af8ad015..945cef68 100644 --- a/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/TaskWorkflowIntegrationTest.java @@ -208,7 +208,7 @@ void supportsTheCatalogTaskChecklistAndCancelApiFlow() throws Exception { assertThat(catalog.statusCode()).isEqualTo(200); assertThat(JsonPath.read(catalog.body(), "$.source_repository")) .isEqualTo("fowoco/knowledge"); - assertThat(JsonPath.>read(catalog.body(), "$.workflows")).hasSize(7); + assertThat(JsonPath.>read(catalog.body(), "$.workflows")).hasSize(8); HttpResponse created = post("/api/v1/tasks", validCreateBody(), token); assertThat(created.statusCode()).isEqualTo(201); @@ -218,7 +218,7 @@ void supportsTheCatalogTaskChecklistAndCancelApiFlow() throws Exception { assertThat(JsonPath.read(created.body(), "$.status")).isEqualTo("DRAFT"); assertThat(JsonPath.read(created.body(), "$.target_type")).isEqualTo("WORKER"); assertThat(JsonPath.read(created.body(), "$.workflow_catalog_version")) - .isEqualTo("0.3.0"); + .isEqualTo("0.3.1"); assertThat(jdbcTemplate.queryForList( "SELECT event_type FROM event_publication " + "WHERE aggregate_id = ? ORDER BY occurred_at", diff --git a/src/test/java/com/fowoco/server/task/application/renewal/RenewalExecutionServiceTest.java b/src/test/java/com/fowoco/server/task/application/renewal/RenewalExecutionServiceTest.java index 69d2f948..16274e56 100644 --- a/src/test/java/com/fowoco/server/task/application/renewal/RenewalExecutionServiceTest.java +++ b/src/test/java/com/fowoco/server/task/application/renewal/RenewalExecutionServiceTest.java @@ -111,7 +111,7 @@ void ocrApprovalContinuationGeneratesDraftWhenAgentReturnsGenerate() { private RenewalTaskSnapshot taskSnapshot() { return new RenewalTaskSnapshot( TASK_ID, COMPANY_ID, WORKER_ID, null, - "RECONTRACT", "WF-CON-001", "0.3.0", + "RECONTRACT", "WF-CON-001", "0.3.1", "재계약 조건 확인", null, Map.of(), 0, "MANUAL", "DRAFT", null, ACTOR_ID, ACTOR_ID, null, null, 0 diff --git a/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java b/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java index 167c644b..9b71e06f 100644 --- a/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java +++ b/src/test/java/com/fowoco/server/workflow/infrastructure/WorkflowCatalogProjectionTest.java @@ -11,7 +11,7 @@ class WorkflowCatalogProjectionTest { @Test - void loadsKnowledge030StayRenewalContract() { + void loadsKnowledge031StayRenewalAndExpiredStayExceptionContracts() { ResourceWorkflowCatalogRepository repository = new ResourceWorkflowCatalogRepository( JsonMapper.builder() .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) @@ -24,8 +24,9 @@ void loadsKnowledge030StayRenewalContract() { var catalog = repository.getActiveCatalog(); var stayRenewal = catalog.findWorkflow("WF-STY-001").orElseThrow(); + var expiredStayException = catalog.findWorkflow("WF-STY-EXC-001").orElseThrow(); - assertThat(catalog.bundleVersion()).isEqualTo("0.3.0"); + assertThat(catalog.bundleVersion()).isEqualTo("0.3.1"); assertThat(stayRenewal.requiredSlots()) .containsExactlyInAnyOrder("worker_id", "due_at"); assertThat(stayRenewal.allowedSlotKeys()).containsAll(Set.of( @@ -42,5 +43,10 @@ void loadsKnowledge030StayRenewalContract() { "passport_status", "arc_status" )); + assertThat(expiredStayException.requiredSlots()).containsExactlyInAnyOrder( + "worker_id", + "stay_expiry_date", + "stay_verification_status" + ); } }