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: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ AI 실행, 승인, 근로자 링크, 알림과 장애 복구까지 하나의 Pos
| 핵심 업무 API | Auth·Worker·Document·Task·Approval·Worker Link·Case·Dashboard·Notification 구현 |
| AI 연동 | PLAN에서 대표 Intent·Workflow를 한 번 결정하고, 허용 Slot을 보충한 뒤 같은 결정을 ANALYZE에 재사용하는 AiRun·SSE 흐름 구현 |
| 문서 처리 | 파일 저장·다운로드, HWP/HWPX 검증·생성 결과 연계, OCR 실행·HR 검토 구현 |
| 근로자 협업 | 만료형 보안 링크 발급, 모바일 안내·응답·서류 제출, HR 공식 서류 채택과 Task 재개 구현 |
| 근로자 협업 | 만료형 보안 링크 발급, 모바일 안내·응답·서류 제출, HR 공식 서류 채택과 Task 재개, 퇴사 근로자 안전 보관 구현 |
| 알림 | 업무 Domain Event와 Outbox를 이용한 알림 생성, 읽음 상태, 마감 임박 배치 구현 |
| 운영 기반 | Flyway, PostgreSQL 16, RLS, Transactional Outbox, 감사로그, Micrometer·Prometheus, Docker·Kubernetes·HTTPS 배포와 제품 E2E 검증 |

Expand Down Expand Up @@ -65,6 +65,7 @@ AI 실행, 승인, 근로자 링크, 알림과 장애 복구까지 하나의 Pos
- 사업장 사용자 인증과 `ADMIN`·`HR`·`VIEWER` 권한
- `company_id`를 기준으로 한 사업장 데이터 격리
- 근로자 기본정보와 서류 메타데이터 관리
- 체류 만료 경과 확인과 퇴사 근로자의 삭제 없는 안전 보관·업무 차단
- CSV/XLSX 근로자 명단 가져오기와 OCR 검토
- 업무카드·체크리스트·상태 전이 관리
- HR 승인·반려·외부 제출·증빙·완료와 감사로그
Expand Down Expand Up @@ -102,6 +103,7 @@ HR 로그인
→ 승인된 OCR Context로 기존 Task 재개·문서 초안 생성
→ 외부 제출·처리결과 기록
→ 완료·감사로그
→ 퇴사·업무 종료 확인 후 운영 목록에서 안전 보관
```

대표 입력:
Expand Down Expand Up @@ -274,6 +276,7 @@ src/main/java/com/fowoco/server/
| 중요한 설계 결정 | [ADR 목록](docs/adr/README.md) | 저장소 경계, API·보안, Task·AiRun, RLS 결정 원본 |
| Server ↔ AI 계약 | [AI Runtime 계약](docs/ai-runtime-contract.md) | Server가 AI에 보내고 받을 수 있는 값과 검증 기준 |
| 근로자 명단 가져오기 | [Worker Import 가이드](docs/worker-import.md) | CSV/XLSX 업로드부터 검증·수정·등록까지의 API 순서 |
| 퇴사 근로자 보관 | [근로자 안전 보관 가이드](docs/worker-archive.md) | 삭제 없이 운영 대상에서 분리하는 조건·API·감사 기준 |
| 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, 재시도와 장애 복구 기준 |
Expand Down
55 changes: 55 additions & 0 deletions docs/worker-archive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 근로자 안전 보관 운영 가이드

## 목적

퇴사하거나 고용이 종료된 근로자를 물리적으로 삭제하지 않고 운영 목록에서 분리합니다.
과거 Task·문서·근로자 응답·감사로그의 참조는 그대로 보존합니다.

체류기간이 지났다는 사실만으로 퇴사나 불법체류를 판단하거나 자동 보관하지 않습니다.
체류 만료 경과 확인은 `stay_verification_case`에서 먼저 처리하고, 실제 근무상태와
남은 업무가 정리된 뒤 HR 또는 ADMIN이 사유를 입력해 보관합니다.

## API 흐름

```text
GET /api/v1/workers/{workerId}/archive-eligibility
→ 보관 차단 사유 확인
→ HR이 남은 Task·승인·Worker Link 정리
→ POST /api/v1/workers/{workerId}/archive
→ worker_archive와 WORKER_ARCHIVED 감사로그 저장
→ 기본 목록·AI 대상 탐색·신규 Task 대상에서 제외
```

보관 요청 예시:

```json
{
"reason": "퇴사 및 진행 업무 종료 확인",
"expected_version": 3
}
```

## 보관 차단 조건

| 코드 | 의미 | 담당자 행동 |
| --- | --- | --- |
| `ACTIVE_EMPLOYMENT_STATUS` | `ACTIVE` 또는 `ON_LEAVE` 상태 | 실제 근무상태를 먼저 확인 |
| `OPEN_TASK` | 완료·취소되지 않은 Task 존재 | 업무를 완료하거나 취소 |
| `PENDING_APPROVAL` | 결정을 기다리는 승인 존재 | 승인 또는 반려 처리 |
| `ACTIVE_WORKER_LINK` | 아직 유효한 근로자 링크 존재 | 응답을 마치거나 링크 만료·폐기 처리 |
| `ALREADY_ARCHIVED` | 이미 보관됨 | 중복 처리하지 않고 기존 기록 확인 |

## 보존과 차단 범위

- 기본 `GET /workers` 목록과 검색에서는 보관 근로자를 제외합니다.
- 자연어 분석의 근로자 탐색과 신규 Task 대상 확인에서도 제외합니다.
- `GET /workers/{workerId}` 상세와 기존 Task·문서·응답·감사 기록은 삭제하지 않습니다.
- 보관은 복구할 수 없는 삭제가 아니며, 원문 개인정보 자동 삭제 정책을 의미하지 않습니다.
- `expected_version`으로 동시 변경을 막고 보관 시각·처리자·사유를 감사 가능하게 남깁니다.

## DB와 RLS

- `V56__create_worker_archive.sql`: 보관 메타데이터와 복합 FK 생성
- `V57__prepare_worker_archive_rls.sql`: 사업장 격리 정책 준비

RLS 활성화는 공통 RLS Migration PR에서 기존 테이블과 함께 수행합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,6 @@ public enum AuditAction {
DOCUMENT_OCR_APPROVED,
DOCUMENT_OCR_REJECTED,
STAY_VERIFICATION_CASE_CREATED,
STAY_VERIFICATION_STATUS_UPDATED
STAY_VERIFICATION_STATUS_UPDATED,
WORKER_ARCHIVED
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ public enum AuditTargetType {
WORKER_IMPORT,
COMPANY_SETTINGS,
DOCUMENT_OCR_RUN,
STAY_VERIFICATION
STAY_VERIFICATION,
WORKER
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.fowoco.server.worker.archive.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.worker.archive.application.WorkerArchiveCommand;
import com.fowoco.server.worker.archive.application.WorkerArchiveService;
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.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Worker Archive", description = "퇴사 근로자의 안전 보관 가능 여부 확인·처리")
@RestController
@RequestMapping("/api/v1/workers/{workerId}")
@SecurityRequirement(name = "bearerAuth")
public class WorkerArchiveController {

private final WorkerArchiveService archiveService;
private final ActorContextProvider actorContextProvider;

public WorkerArchiveController(
WorkerArchiveService archiveService,
ActorContextProvider actorContextProvider
) {
this.archiveService = archiveService;
this.actorContextProvider = actorContextProvider;
}

@Operation(summary = "근로자 보관 가능 여부 확인")
@GetMapping("/archive-eligibility")
@PreAuthorize("hasAnyRole('ADMIN', 'HR')")
public WorkerArchiveEligibilityResponse checkEligibility(@PathVariable UUID workerId) {
ActorContext actor = actorContextProvider.requireCurrentActor();
return WorkerArchiveEligibilityResponse.from(archiveService.checkEligibility(workerId, actor));
}

@Operation(summary = "퇴사 근로자 안전 보관")
@PostMapping("/archive")
@ResponseStatus(HttpStatus.CREATED)
@PreAuthorize("hasAnyRole('ADMIN', 'HR')")
public WorkerArchiveResponse archive(
@PathVariable UUID workerId,
@Valid @RequestBody WorkerArchiveRequest request,
HttpServletRequest servletRequest
) {
ActorContext actor = actorContextProvider.requireCurrentActor();
return WorkerArchiveResponse.from(archiveService.archive(
new WorkerArchiveCommand(workerId, request.reason(), request.expectedVersion()),
actor,
RequestMetadata.from(servletRequest)
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.fowoco.server.worker.archive.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.worker.archive.application.WorkerArchiveBlocker;
import com.fowoco.server.worker.archive.application.WorkerArchiveEligibility;
import java.util.List;
import java.util.UUID;

public record WorkerArchiveEligibilityResponse(
@JsonProperty("worker_id") UUID workerId,
boolean archivable,
List<WorkerArchiveBlocker> blockers,
@JsonProperty("worker_version") long workerVersion
) {
public static WorkerArchiveEligibilityResponse from(WorkerArchiveEligibility eligibility) {
return new WorkerArchiveEligibilityResponse(
eligibility.workerId(),
eligibility.archivable(),
eligibility.blockers(),
eligibility.workerVersion()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.fowoco.server.worker.archive.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;

public record WorkerArchiveRequest(
@NotBlank @Size(max = 500) String reason,
@JsonProperty("expected_version") @NotNull @PositiveOrZero Long expectedVersion
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.fowoco.server.worker.archive.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.worker.archive.domain.WorkerArchive;
import java.time.Instant;
import java.util.UUID;

public record WorkerArchiveResponse(
@JsonProperty("worker_id") UUID workerId,
@JsonProperty("archived_at") Instant archivedAt,
@JsonProperty("archived_by") UUID archivedBy,
@JsonProperty("archive_reason") String archiveReason,
@JsonProperty("worker_version") long workerVersion
) {
public static WorkerArchiveResponse from(WorkerArchive archive) {
return new WorkerArchiveResponse(
archive.workerId(),
archive.archivedAt(),
archive.archivedBy(),
archive.archiveReason(),
archive.workerVersion()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.fowoco.server.worker.archive.application;

public enum WorkerArchiveBlocker {
ACTIVE_EMPLOYMENT_STATUS,
OPEN_TASK,
PENDING_APPROVAL,
ACTIVE_WORKER_LINK,
ALREADY_ARCHIVED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.fowoco.server.worker.archive.application;

import java.util.UUID;

public record WorkerArchiveCommand(
UUID workerId,
String reason,
long expectedVersion
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.fowoco.server.worker.archive.application;

import java.util.List;
import java.util.UUID;

public record WorkerArchiveEligibility(
UUID workerId,
boolean archivable,
List<WorkerArchiveBlocker> blockers,
long workerVersion
) {
public WorkerArchiveEligibility {
blockers = List.copyOf(blockers);
}
}
Loading
Loading