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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ public final class DashboardTodayResponse {
@Schema(name = "priority_tasks", description = "오늘의 우선 업무 (열린 업무, 마감일 순 최대 5건)")
private final List<DashboardTaskSummaryResponse> priorityTasks;

@JsonProperty("upcoming_7_days")
@Schema(name = "upcoming_7_days", description = "향후 7일 체류·계약·서류 만료 요약")
private final List<UpcomingExpiryItemResponse> upcoming7Days;

@JsonProperty("approval_count")
@Schema(name = "approval_count", description = "승인 대기 개수 (summary_counts.pending_approval과 동일)")
private final long approvalCount;
Expand All @@ -26,11 +30,13 @@ public final class DashboardTodayResponse {
public DashboardTodayResponse(
DashboardSummaryCountsResponse summaryCounts,
List<DashboardTaskSummaryResponse> priorityTasks,
List<UpcomingExpiryItemResponse> upcoming7Days,
long approvalCount,
long workerResponseCount
) {
this.summaryCounts = summaryCounts;
this.priorityTasks = priorityTasks;
this.upcoming7Days = upcoming7Days;
this.approvalCount = approvalCount;
this.workerResponseCount = workerResponseCount;
}
Expand All @@ -43,6 +49,10 @@ public List<DashboardTaskSummaryResponse> getPriorityTasks() {
return priorityTasks;
}

public List<UpcomingExpiryItemResponse> getUpcoming7Days() {
return upcoming7Days;
}

public long getApprovalCount() {
return approvalCount;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,52 @@
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;

@Service
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;
}
Expand All @@ -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);
Expand All @@ -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<UpcomingExpiryItemResponse> upcoming7Days = collectUpcomingExpiry(companyId, targetDate, windowEnd);

return new DashboardTodayResponse(summaryCounts, priorityTasks, upcoming7Days, pendingApproval, workerResponse);
}

private List<UpcomingExpiryItemResponse> collectUpcomingExpiry(UUID companyId, LocalDate windowStart, LocalDate windowEnd) {
List<UpcomingExpiryItemResponse> result = new ArrayList<>();

List<Worker> stayExpiringWorkers = workerRepository.findPage(
companyId,
new WorkerSearchQuery(null, null, windowEnd, null, null, null, 0, 100)
);
List<Worker> contractEndingWorkers = workerRepository.findPage(
companyId,
new WorkerSearchQuery(null, null, null, windowEnd, null, null, 0, 100)
);
List<Worker> permitEndingWorkers = workerRepository.findPage(
companyId,
new WorkerSearchQuery(null, null, null, null, windowEnd, null, 0, 100)
);
List<Worker> 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<WorkerDocument> expiringDocuments = workerDocumentRepository.findPage(
companyId,
new WorkerDocumentSearchQuery(null, null, null, null, windowEnd, 0, 100)
);
Map<UUID, String> 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<UpcomingExpiryItemResponse> result,
List<Worker> workers,
UpcomingExpiryCategory category,
java.util.function.Function<Worker, LocalDate> dateExtractor
) {
for (Worker worker : workers) {
result.add(new UpcomingExpiryItemResponse(
worker.workerId(),
worker.displayName(),
category,
dateExtractor.apply(worker),
null
));
}
}

private ZoneId parseTimezone(String timezone) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<String> response = authorizedGet(
"/api/v1/dashboard/today?date=2026-08-08", accessToken
);

assertThat(response.statusCode()).isEqualTo(200);
java.util.List<Object> upcoming = JsonPath.read(response.body(), "$.upcoming_7_days");
assertThat(upcoming).isNotEmpty();
List<String> 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<String> response = authorizedGet(
"/api/v1/dashboard/today?date=2026-08-08", accessToken
);

assertThat(response.statusCode()).isEqualTo(200);
List<String> 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<String> response = authorizedGet(
"/api/v1/dashboard/today?date=2026-08-08", accessToken
);

assertThat(response.statusCode()).isEqualTo(200);
List<String> 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<String> response = authorizedGet(
"/api/v1/dashboard/today?date=2026-08-08", accessToken
);

assertThat(response.statusCode()).isEqualTo(200);
List<String> 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<String> 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<String> 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<String> 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));
Expand Down
Loading