diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java index ce2e08df..1efca4b9 100644 --- a/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java @@ -15,6 +15,10 @@ public final class DashboardTodayResponse { @Schema(name = "priority_tasks", description = "오늘의 우선 업무 (열린 업무, 마감일 순 최대 5건)") private final List priorityTasks; + @JsonProperty("upcoming_7_days") + @Schema(name = "upcoming_7_days", description = "향후 7일 체류·계약·서류 만료 요약") + private final List upcoming7Days; + @JsonProperty("approval_count") @Schema(name = "approval_count", description = "승인 대기 개수 (summary_counts.pending_approval과 동일)") private final long approvalCount; @@ -26,11 +30,13 @@ public final class DashboardTodayResponse { public DashboardTodayResponse( DashboardSummaryCountsResponse summaryCounts, List priorityTasks, + List upcoming7Days, long approvalCount, long workerResponseCount ) { this.summaryCounts = summaryCounts; this.priorityTasks = priorityTasks; + this.upcoming7Days = upcoming7Days; this.approvalCount = approvalCount; this.workerResponseCount = workerResponseCount; } @@ -43,6 +49,10 @@ public List getPriorityTasks() { return priorityTasks; } + public List getUpcoming7Days() { + return upcoming7Days; + } + public long getApprovalCount() { return approvalCount; } diff --git a/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryCategory.java b/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryCategory.java new file mode 100644 index 00000000..9e4d2c51 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryCategory.java @@ -0,0 +1,9 @@ +package com.fowoco.server.dashboard.api; + +public enum UpcomingExpiryCategory { + STAY_EXPIRY, + CONTRACT_END, + EMPLOYMENT_PERMIT_END, + EMPLOYMENT_ACTIVITY_END, + DOCUMENT_EXPIRY +} diff --git a/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryItemResponse.java b/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryItemResponse.java new file mode 100644 index 00000000..f137ccb1 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/UpcomingExpiryItemResponse.java @@ -0,0 +1,65 @@ +package com.fowoco.server.dashboard.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.domain.DocumentType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.UUID; + +@Schema(name = "UpcomingExpiryItemResponse", description = "7일 이내 체류·계약·서류 만료 항목") +public final class UpcomingExpiryItemResponse { + + @JsonProperty("worker_id") + @Schema(name = "worker_id", format = "uuid") + private final UUID workerId; + + @JsonProperty("display_name") + @Schema(name = "display_name") + private final String displayName; + + @JsonProperty("category") + @Schema(name = "category", description = "만료 종류") + private final UpcomingExpiryCategory category; + + @JsonProperty("expiry_date") + @Schema(name = "expiry_date", format = "date") + private final LocalDate expiryDate; + + @JsonProperty("document_type") + @Schema(name = "document_type", description = "category가 DOCUMENT_EXPIRY일 때만 값이 있음") + private final DocumentType documentType; + + public UpcomingExpiryItemResponse( + UUID workerId, + String displayName, + UpcomingExpiryCategory category, + LocalDate expiryDate, + DocumentType documentType + ) { + this.workerId = workerId; + this.displayName = displayName; + this.category = category; + this.expiryDate = expiryDate; + this.documentType = documentType; + } + + public UUID getWorkerId() { + return workerId; + } + + public String getDisplayName() { + return displayName; + } + + public UpcomingExpiryCategory getCategory() { + return category; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public DocumentType getDocumentType() { + return documentType; + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java index 98d78d0f..cccaaa9a 100644 --- a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -6,16 +6,27 @@ import com.fowoco.server.dashboard.api.DashboardSummaryCountsResponse; import com.fowoco.server.dashboard.api.DashboardTaskSummaryResponse; import com.fowoco.server.dashboard.api.DashboardTodayResponse; +import com.fowoco.server.dashboard.api.UpcomingExpiryCategory; +import com.fowoco.server.dashboard.api.UpcomingExpiryItemResponse; import com.fowoco.server.dashboard.application.error.DashboardErrorCode; import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; import com.fowoco.server.task.domain.TaskStatus; +import com.fowoco.server.worker.application.WorkerDocumentSearchQuery; +import com.fowoco.server.worker.application.WorkerSearchQuery; +import com.fowoco.server.worker.application.port.WorkerDocumentRepository; +import com.fowoco.server.worker.application.port.WorkerRepository; +import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.domain.WorkerDocument; import java.time.Clock; import java.time.DateTimeException; import java.time.LocalDate; import java.time.ZoneId; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,17 +34,24 @@ public class DashboardQueryService { private static final int PRIORITY_TASK_LIMIT = 5; + private static final int UPCOMING_DAYS = 7; private final TaskRepository taskRepository; + private final WorkerRepository workerRepository; + private final WorkerDocumentRepository workerDocumentRepository; private final TenantDatabaseContext tenantDatabaseContext; private final Clock clock; public DashboardQueryService( TaskRepository taskRepository, + WorkerRepository workerRepository, + WorkerDocumentRepository workerDocumentRepository, TenantDatabaseContext tenantDatabaseContext, Clock clock ) { this.taskRepository = taskRepository; + this.workerRepository = workerRepository; + this.workerDocumentRepository = workerDocumentRepository; this.tenantDatabaseContext = tenantDatabaseContext; this.clock = clock; } @@ -44,6 +62,7 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t UUID companyId = actor.companyId(); Clock effectiveClock = timezone != null ? clock.withZone(parseTimezone(timezone)) : clock; LocalDate targetDate = date != null ? date : LocalDate.now(effectiveClock); + LocalDate windowEnd = targetDate.plusDays(UPCOMING_DAYS + 1); long pendingApproval = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.READY_FOR_REVIEW); long needsInfo = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.NEEDS_INFO); @@ -59,7 +78,77 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t .map(DashboardTaskSummaryResponse::from) .toList(); - return new DashboardTodayResponse(summaryCounts, priorityTasks, pendingApproval, workerResponse); + List upcoming7Days = collectUpcomingExpiry(companyId, targetDate, windowEnd); + + return new DashboardTodayResponse(summaryCounts, priorityTasks, upcoming7Days, pendingApproval, workerResponse); + } + + private List collectUpcomingExpiry(UUID companyId, LocalDate windowStart, LocalDate windowEnd) { + List result = new ArrayList<>(); + + List stayExpiringWorkers = workerRepository.findPage( + companyId, + new WorkerSearchQuery(null, null, windowEnd, null, null, null, 0, 100) + ); + List contractEndingWorkers = workerRepository.findPage( + companyId, + new WorkerSearchQuery(null, null, null, windowEnd, null, null, 0, 100) + ); + List permitEndingWorkers = workerRepository.findPage( + companyId, + new WorkerSearchQuery(null, null, null, null, windowEnd, null, 0, 100) + ); + List activityEndingWorkers = workerRepository.findPage( + companyId, + new WorkerSearchQuery(null, null, null, null, null, windowEnd, 0, 100) + ); + + addWorkerExpiry(result, stayExpiringWorkers, UpcomingExpiryCategory.STAY_EXPIRY, Worker::stayExpiryDate); + addWorkerExpiry(result, contractEndingWorkers, UpcomingExpiryCategory.CONTRACT_END, Worker::contractEndDate); + addWorkerExpiry(result, permitEndingWorkers, UpcomingExpiryCategory.EMPLOYMENT_PERMIT_END, Worker::employmentPermitEndDate); + addWorkerExpiry(result, activityEndingWorkers, UpcomingExpiryCategory.EMPLOYMENT_ACTIVITY_END, Worker::employmentActivityEndDate); + + List expiringDocuments = workerDocumentRepository.findPage( + companyId, + new WorkerDocumentSearchQuery(null, null, null, null, windowEnd, 0, 100) + ); + Map workerNames = expiringDocuments.isEmpty() + ? Map.of() + : workerRepository.findAllByWorkerIdsAndCompanyId( + expiringDocuments.stream().map(WorkerDocument::workerId).collect(Collectors.toSet()), + companyId + ).stream().collect(Collectors.toMap(Worker::workerId, Worker::displayName)); + + for (WorkerDocument document : expiringDocuments) { + result.add(new UpcomingExpiryItemResponse( + document.workerId(), + workerNames.get(document.workerId()), + UpcomingExpiryCategory.DOCUMENT_EXPIRY, + document.expiryDate(), + document.documentType() + )); + } + + return result.stream() + .filter(item -> !item.getExpiryDate().isBefore(windowStart)) + .toList(); + } + + private void addWorkerExpiry( + List result, + List workers, + UpcomingExpiryCategory category, + java.util.function.Function dateExtractor + ) { + for (Worker worker : workers) { + result.add(new UpcomingExpiryItemResponse( + worker.workerId(), + worker.displayName(), + category, + dateExtractor.apply(worker), + null + )); + } } private ZoneId parseTimezone(String timezone) { diff --git a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java index 8b10e107..c4943833 100644 --- a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java @@ -74,6 +74,7 @@ void seedCompaniesAndUsers() { @BeforeEach void resetTaskState() { + jdbcTemplate.update("DELETE FROM worker_document"); jdbcTemplate.update("DELETE FROM task_checklist_item"); jdbcTemplate.update("DELETE FROM task_transition_history"); jdbcTemplate.update("DELETE FROM approval_request"); @@ -157,6 +158,115 @@ void timezoneParameterIsAccepted() throws Exception { assertThat(response.statusCode()).isEqualTo(200); } + @Test + void upcoming7DaysIncludesWorkerWithNearExpiry() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + registerWorkerWithStayExpiry(accessToken, "체류만료임박근로자", "2026-08-12"); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2026-08-08", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + java.util.List upcoming = JsonPath.read(response.body(), "$.upcoming_7_days"); + assertThat(upcoming).isNotEmpty(); + List categories = JsonPath.read(response.body(), "$.upcoming_7_days[*].category"); + assertThat(categories).contains("STAY_EXPIRY"); + } + + @Test + void upcoming7DaysExcludesWorkerWithFarExpiry() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + registerWorkerWithStayExpiry(accessToken, "체류만료여유근로자", "2026-12-31"); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2026-08-08", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + List names = JsonPath.read(response.body(), "$.upcoming_7_days[*].display_name"); + assertThat(names).doesNotContain("체류만료여유근로자"); + } + + @Test + void upcoming7DaysIncludesAllExpiryCategories() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + registerWorkerWithAllExpiryFields( + accessToken, "전체만료임박근로자", + "2026-08-12", "2026-08-13", "2026-08-14", "2026-08-11" + ); + String workerId = registerWorker(accessToken, "서류만료임박근로자"); + registerDocumentWithExpiry(accessToken, workerId, "2026-08-10"); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2026-08-08", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + List categories = JsonPath.read(response.body(), "$.upcoming_7_days[*].category"); + assertThat(categories) + .contains("STAY_EXPIRY", "CONTRACT_END", "EMPLOYMENT_PERMIT_END", + "EMPLOYMENT_ACTIVITY_END", "DOCUMENT_EXPIRY"); + } + + @Test + void upcoming7DaysExcludesAlreadyPastExpiry() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + registerWorkerWithStayExpiry(accessToken, "이미지난만료근로자", "2026-07-01"); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2026-08-08", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + List names = JsonPath.read(response.body(), "$.upcoming_7_days[*].display_name"); + assertThat(names).doesNotContain("이미지난만료근로자"); + } + + private String registerWorkerWithAllExpiryFields( + String accessToken, + String displayName, + String stayExpiryDate, + String contractEndDate, + String employmentPermitEndDate, + String employmentActivityEndDate + ) throws Exception { + String body = """ + { + "display_name": "%s", + "stay_expiry_date": "%s", + "contract_end_date": "%s", + "employment_permit_end_date": "%s", + "employment_activity_end_date": "%s" + } + """.formatted(displayName, stayExpiryDate, contractEndDate, + employmentPermitEndDate, employmentActivityEndDate); + HttpResponse response = postJson("/api/v1/workers", body, accessToken); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + + private void registerDocumentWithExpiry(String accessToken, String workerId, String expiryDate) + throws Exception { + String body = """ + {"document_type": "PASSPORT_COPY", "submission_status": "SUBMITTED", "expiry_date": "%s"} + """.formatted(expiryDate); + HttpResponse response = postJson( + "/api/v1/workers/" + workerId + "/documents", body, accessToken + ); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + } + + private String registerWorkerWithStayExpiry(String accessToken, String displayName, String stayExpiryDate) + throws Exception { + String body = """ + {"display_name": "%s", "stay_expiry_date": "%s"} + """.formatted(displayName, stayExpiryDate); + HttpResponse response = postJson("/api/v1/workers", body, accessToken); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + @Test void invalidTimezoneReturnsClientError() throws Exception { String accessToken = accessToken(login(HR_A_EMAIL));