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
7 changes: 4 additions & 3 deletions docs/demo-seed-fixture-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ Figma의 재계약·고용기간 연장·체류기간 연장 초안 흐름은
| Figma 의미 | 예약 fixture | 저장 내용 | 현재 노출 |
| --- | --- | --- | --- |
| 대상 근로자 | Worker `92000000-0000-0000-0000-000000000006` | 응웬반A, `VN`, `vi`, 체류 만료 `D+45` | `GET /api/v1/workers`, `GET /api/v1/workers/{workerId}` |
| 복합 Case | Case ID `94100000-0000-0000-0000-000000000006` | 세 업무의 연속성을 나타내는 공통 식별자 | Task 응답의 `case_id`로 노출; Case 전용 API는 없음 |
| 복합 Case | Case ID `94100000-0000-0000-0000-000000000006` | 세 업무의 연속성과 생성 당시 Workflow Snapshot | Case 목록·Projection API와 Task 응답의 `case_id`로 노출 |
| 재계약 조건 검토 | Task `94000000-0000-0000-0000-000000000006` | `RECONTRACT`, `READY_FOR_REVIEW`, candidate order 1 | Task 목록·상세 API |
| 후행 고용기간 연장 | Task `94000000-0000-0000-0000-000000000007` | `DRAFT`, candidate order 3, Task 6 의존 | Task 목록·상세 API |
| 여권 사본 요청 | Task `94000000-0000-0000-0000-000000000008` | `WAITING_WORKER`, candidate order 2, 제출 기한 7일 | Task 목록·상세 API |
| 검토 snapshot | Approval `94300000-0000-0000-0000-000000000002` | AI/HR snapshot, 7/7 검증, 경고 1건, HR 변경 필드와 원천 버전 | 저장 전용; 승인 조회 API 없음 |
| 베트남어 요청 초안 | Draft `94700000-0000-0000-0000-000000000002` | `vi`, `PASSPORT_COPY`, 7일 이내 제출 문구 | 저장 전용; Draft 조회 API 없음 |
| AI 처리 흔적 | trace ID `demo-compound-draft-flow` | 대상 확인부터 후행 후보 준비까지 AI Agent 이벤트 5건 | 업무 활동 API에서 노출 |

Case 진행률, Case 상태와 Case 전용 엔티티는 만들지 않는다. 클라이언트가 세 Task의
`case_id`와 `business_data`를 조합해야 한다.
Case 진행률과 표시 상태는 `GET /api/v1/cases`에서 확인하고, 세 Task와 준비도 요약은
`GET /api/v1/cases/{caseId}/projection`에서 확인한다.

## 검토·승인·제출 lifecycle

Expand Down Expand Up @@ -85,6 +85,7 @@ Demo Company의 근로자 28명은 AI 팀이 지원하는 locale 15개를 모두
| 데이터 | 저장 | 조회 API | 비고 |
| --- | --- | --- | --- |
| Worker | O | O | 이름·국적·선호 언어·체류/계약일 노출 |
| Case와 Workflow Snapshot | O | O | 진행률·현재 Task·준비도 요약 노출 |
| Task와 Checklist | O | O | `case_id`, `business_data`, 상태와 마감일 노출 |
| WorkerDocument | O | O | 통합 문서함에서 상태·만료일·선택적 `file_id` 노출 |
| Audit Event | O | O | 업무 활동과 ADMIN 감사 검색 지원 |
Expand Down
6 changes: 4 additions & 2 deletions docs/demo-seed.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1
| --- | ---: | --- |
| 계정 | 20 | `ADMIN` 2, `HR` 12, `VIEWER` 6 |
| 근로자 | 28 | `ACTIVE`와 `ON_LEAVE`, AI 지원 locale 15개, 다양한 체류 만료 구간 |
| Case | 22 | 단일 업무 Case 21개, 세 업무를 묶은 복합 Case 1개 |
| 업무 | 24 | 세 가지 지원 업무 유형과 여덟 가지 상태 |
| 근로자 서류 | 84 | `VERIFIED` 48, `SUBMITTED` 20, `MISSING` 16 |
| 체크리스트 항목 | 68 | 24개 업무에 연결 |
Expand Down Expand Up @@ -100,6 +101,7 @@ Demo Company 업무 유형은 `STAY_PERIOD_EXTENSION` 10개, `RECONTRACT` 8개,
| --- | ---: |
| 계정 | 3 |
| 근로자 | 5 |
| Case | 3 |
| 업무 | 3 |
| 근로자 서류 | 8 |
| Audit Event | 8 |
Expand All @@ -124,8 +126,8 @@ Test Company에는 Demo Company의 전체 운영 데이터를 복제하지 않
| 아디 수르야 | 고용기간 연장 자료 보완 | 지원되는 업무 유형의 `NEEDS_INFO`; 신규 등록 업무는 만들지 않음 |
| 모하메드 라힘 | 오늘 마감된 체류기간 연장 완료 | `COMPLETED`, 승인 상태, 외부 제출, 완료 증빙, 상태 전이 이력 |

응웬반A의 세 업무는 별도 Case 엔티티 없이 같은 `caseId`로 연속성만 표현한다.
서버는 Case 진행률이나 Case 전용 API를 제공하지 않는다.
응웬반A의 세 업무는 하나의 Case 엔티티에 연결된다. 서버는 Case 목록과 Projection
API에서 진행률, 현재 Task, Workflow Snapshot과 준비도 요약을 제공한다.

## 클라이언트에서 확인되는 데이터

Expand Down
78 changes: 78 additions & 0 deletions src/main/java/com/fowoco/server/casework/api/CaseController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.fowoco.server.casework.api;

import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.auth.application.port.ActorContextProvider;
import com.fowoco.server.casework.application.CaseQueryService;
import com.fowoco.server.casework.application.CaseSearchQuery;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.util.UUID;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Case", description = "업무함 Case와 Workflow 진행 현황")
@SecurityRequirement(name = "bearerAuth")
@Validated
@RestController
@RequestMapping("/api/v1/cases")
public class CaseController {

private final CaseQueryService caseQueryService;
private final ActorContextProvider actorContextProvider;

public CaseController(
CaseQueryService caseQueryService,
ActorContextProvider actorContextProvider
) {
this.caseQueryService = caseQueryService;
this.actorContextProvider = actorContextProvider;
}

@Operation(operationId = "listCases", summary = "업무함 Case 목록 조회")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "사업장 범위 Case 목록"),
@ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest")
})
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public CasePageResponse findAll(
@Parameter(description = "근로자 표시 이름 또는 Case 제목 검색")
@RequestParam(required = false) String keyword,
@Parameter(description = "페이지 번호 (0부터 시작)")
@RequestParam(defaultValue = "0") @Min(0) int page,
@Parameter(description = "페이지당 Case 수 (1~100)")
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size
) {
return CasePageResponse.from(caseQueryService.findPage(
new CaseSearchQuery(keyword, page, size),
actor()
));
}

@Operation(operationId = "getCaseProjection", summary = "Case 진행 현황 조회")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Case와 하위 업무 진행 현황"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound")
})
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
@GetMapping(path = "/{caseId}/projection", produces = MediaType.APPLICATION_JSON_VALUE)
public CaseProjectionResponse findProjection(@PathVariable UUID caseId) {
return CaseProjectionResponse.from(caseQueryService.findById(caseId, actor()));
}

private ActorContext actor() {
return actorContextProvider.requireCurrentActor();
}
}
25 changes: 25 additions & 0 deletions src/main/java/com/fowoco/server/casework/api/CasePageResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.fowoco.server.casework.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.casework.application.CasePageResult;
import java.util.List;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record CasePageResponse(
List<CaseSummaryResponse> items,
int page,
int size,
long totalElements,
int totalPages
) {
static CasePageResponse from(CasePageResult result) {
return new CasePageResponse(
result.items().stream().map(CaseSummaryResponse::from).toList(),
result.page(),
result.size(),
result.totalElements(),
result.totalPages()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.fowoco.server.casework.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.casework.application.CaseProgress;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record CaseProgressResponse(int completedSteps, int totalSteps, int percentage) {
static CaseProgressResponse from(CaseProgress progress) {
return new CaseProgressResponse(
progress.completedSteps(),
progress.totalSteps(),
progress.percentage()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.fowoco.server.casework.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.casework.application.CaseProjection;
import com.fowoco.server.casework.domain.CaseDisplayStatus;
import com.fowoco.server.casework.domain.CaseLifecycleStatus;
import com.fowoco.server.casework.domain.CasePriority;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record CaseProjectionResponse(
UUID caseId,
UUID workerId,
String workerDisplayName,
String title,
CaseLifecycleStatus lifecycleStatus,
CaseDisplayStatus displayStatus,
boolean hasUnreadResponse,
CasePriority priority,
CaseProgressResponse progress,
CaseReadinessResponse readiness,
LocalDate dueDate,
CaseTaskResponse currentTask,
List<CaseTaskResponse> tasks,
String workflowCatalogVersion,
Map<String, Object> workflowSnapshot,
Instant updatedAt
) {
static CaseProjectionResponse from(CaseProjection projection) {
return new CaseProjectionResponse(
projection.caseId(),
projection.workerId(),
projection.workerDisplayName(),
projection.title(),
projection.lifecycleStatus(),
projection.displayStatus(),
projection.hasUnreadResponse(),
projection.priority(),
CaseProgressResponse.from(projection.progress()),
CaseReadinessResponse.from(projection.readiness()),
projection.dueDate(),
CaseTaskResponse.from(projection.currentTask()),
projection.tasks().stream().map(CaseTaskResponse::from).toList(),
projection.workflowCatalogVersion(),
projection.workflowSnapshot(),
projection.updatedAt()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.fowoco.server.casework.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.casework.application.CaseReadiness;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record CaseReadinessResponse(
int completedChecklistItems,
int totalChecklistItems,
int verifiedDocuments,
int totalDocuments,
int pendingApprovals,
int approvedApprovals,
int workerResponses,
int evidenceItems
) {
static CaseReadinessResponse from(CaseReadiness readiness) {
return new CaseReadinessResponse(
readiness.completedChecklistItems(),
readiness.totalChecklistItems(),
readiness.verifiedDocuments(),
readiness.totalDocuments(),
readiness.pendingApprovals(),
readiness.approvedApprovals(),
readiness.workerResponses(),
readiness.evidenceItems()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.fowoco.server.casework.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.casework.application.CaseProjection;
import com.fowoco.server.casework.domain.CaseDisplayStatus;
import com.fowoco.server.casework.domain.CasePriority;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record CaseSummaryResponse(
UUID caseId,
UUID workerId,
String workerDisplayName,
String title,
CaseDisplayStatus displayStatus,
boolean hasUnreadResponse,
CasePriority priority,
CaseProgressResponse progress,
LocalDate dueDate,
CaseTaskResponse currentTask,
Instant updatedAt
) {
static CaseSummaryResponse from(CaseProjection projection) {
return new CaseSummaryResponse(
projection.caseId(),
projection.workerId(),
projection.workerDisplayName(),
projection.title(),
projection.displayStatus(),
projection.hasUnreadResponse(),
projection.priority(),
CaseProgressResponse.from(projection.progress()),
projection.dueDate(),
CaseTaskResponse.from(projection.currentTask()),
projection.updatedAt()
);
}
}
28 changes: 28 additions & 0 deletions src/main/java/com/fowoco/server/casework/api/CaseTaskResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.fowoco.server.casework.api;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.casework.application.CaseTaskProjection;
import com.fowoco.server.task.domain.TaskStatus;
import com.fowoco.server.task.domain.TaskType;
import java.time.LocalDate;
import java.util.UUID;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record CaseTaskResponse(
UUID taskId,
TaskType taskType,
String title,
TaskStatus status,
LocalDate dueDate
) {
static CaseTaskResponse from(CaseTaskProjection task) {
return task == null ? null : new CaseTaskResponse(
task.taskId(),
task.taskType(),
task.title(),
task.status(),
task.dueDate()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
* Case 표시 상태를 계산할 때 사용하는 조회 사실입니다.
*
* @param completed Case 업무가 모두 완료됐는지 여부
* @param cancelled Case가 취소됐는지 여부
* @param linkIssued 근로자용 요청 링크를 발급했는지 여부
* @param reviewRequired 도착한 답변이나 서류에 HR 검토가 남아 있는지 여부
* @param unreadResponse 도착했지만 아직 읽지 않은 근로자 응답이 있는지 여부
*/
public record CaseDisplayFacts(
boolean completed,
boolean cancelled,
boolean linkIssued,
boolean reviewRequired,
boolean unreadResponse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public final class CaseDisplayStatusResolver {
public CaseDisplayState resolve(CaseDisplayFacts facts) {
Objects.requireNonNull(facts, "facts must not be null");

if (facts.cancelled()) {
return new CaseDisplayState(CaseDisplayStatus.CANCELLED, false);
}
if (facts.completed()) {
return new CaseDisplayState(CaseDisplayStatus.COMPLETED, false);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.fowoco.server.casework.application;

import java.util.List;

public record CasePageResult(
List<CaseProjection> items,
int page,
int size,
long totalElements,
int totalPages
) {
public CasePageResult(List<CaseProjection> items, int page, int size, long totalElements) {
this(
List.copyOf(items),
page,
size,
totalElements,
totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fowoco.server.casework.application;

public record CaseProgress(int completedSteps, int totalSteps, int percentage) {
}
Loading
Loading