Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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로 바꿀 때 아래 줄을 활성화하세요.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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·배포 설명 |
Expand Down
2 changes: 2 additions & 0 deletions docs/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ server/
│ │ ├── approval/
│ │ ├── audit/
│ │ ├── workerlink/
│ │ ├── stayverification/
│ │ ├── airun/
│ │ ├── aiintegration/
│ │ └── reliability/
Expand Down Expand Up @@ -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 전달과 복구 |
Expand Down
44 changes: 44 additions & 0 deletions docs/stay-verification.md
Original file line number Diff line number Diff line change
@@ -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를 다시 설정한 독립
트랜잭션에서 수행합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ public enum AuditTargetType {
USER_ACCOUNT,
WORKER_IMPORT,
COMPANY_SETTINGS,
DOCUMENT_OCR_RUN
DOCUMENT_OCR_RUN,
STAY_VERIFICATION
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Integer> 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<StayVerificationResponse> 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))
);
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Loading
Loading