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
@@ -0,0 +1,43 @@
package com.fowoco.server.dashboard.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.task.domain.Task;
import com.fowoco.server.task.domain.TaskStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;

@Schema(name = "DashboardRecommendationItemResponse", description = "Agent가 준비한 업무 항목")
public final class DashboardRecommendationItemResponse {

@JsonProperty("task_id")
@Schema(name = "task_id", format = "uuid")
private final UUID taskId;

@JsonProperty("title")
private final String title;

@JsonProperty("status")
private final TaskStatus status;

private DashboardRecommendationItemResponse(UUID taskId, String title, TaskStatus status) {
this.taskId = taskId;
this.title = title;
this.status = status;
}

public static DashboardRecommendationItemResponse from(Task task) {
return new DashboardRecommendationItemResponse(task.taskId(), task.title(), task.status());
}

public UUID getTaskId() {
return taskId;
}

public String getTitle() {
return title;
}

public TaskStatus getStatus() {
return status;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.fowoco.server.dashboard.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

@Schema(name = "DashboardRecommendationsResponse", description = "Agent가 준비한 내용 묶음")
public final class DashboardRecommendationsResponse {

@JsonProperty("connected_count")
@Schema(name = "connected_count", description = "연결된 전체 열린 업무 개수")
private final long connectedCount;

@JsonProperty("prepared")
@Schema(name = "prepared", description = "Agent가 생성한 초안 (AI_CANDIDATE 소스, DRAFT 상태)")
private final List<DashboardRecommendationItemResponse> prepared;

@JsonProperty("review")
@Schema(name = "review", description = "담당자 확인 필요 (NEEDS_INFO 또는 READY_FOR_REVIEW)")
private final List<DashboardRecommendationItemResponse> review;

@JsonProperty("after_approval")
@Schema(name = "after_approval", description = "응답·기관 대기 (WAITING_WORKER 또는 WAITING_EXTERNAL)")
private final List<DashboardRecommendationItemResponse> afterApproval;

public DashboardRecommendationsResponse(
long connectedCount,
List<DashboardRecommendationItemResponse> prepared,
List<DashboardRecommendationItemResponse> review,
List<DashboardRecommendationItemResponse> afterApproval
) {
this.connectedCount = connectedCount;
this.prepared = prepared;
this.review = review;
this.afterApproval = afterApproval;
}

public long getConnectedCount() {
return connectedCount;
}

public List<DashboardRecommendationItemResponse> getPrepared() {
return prepared;
}

public List<DashboardRecommendationItemResponse> getReview() {
return review;
}

public List<DashboardRecommendationItemResponse> getAfterApproval() {
return afterApproval;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ public final class DashboardTodayResponse {
@Schema(name = "upcoming_7_days", description = "향후 7일 체류·계약·서류 만료 요약")
private final List<UpcomingExpiryItemResponse> upcoming7Days;

@JsonProperty("recommendations")
@Schema(name = "recommendations", description = "Agent가 준비한 내용 (실행 전 초안)")
private final DashboardRecommendationsResponse recommendations;

@JsonProperty("approval_count")
@Schema(name = "approval_count", description = "승인 대기 개수 (summary_counts.pending_approval과 동일)")
private final long approvalCount;
Expand All @@ -31,12 +35,14 @@ public DashboardTodayResponse(
DashboardSummaryCountsResponse summaryCounts,
List<DashboardTaskSummaryResponse> priorityTasks,
List<UpcomingExpiryItemResponse> upcoming7Days,
DashboardRecommendationsResponse recommendations,
long approvalCount,
long workerResponseCount
) {
this.summaryCounts = summaryCounts;
this.priorityTasks = priorityTasks;
this.upcoming7Days = upcoming7Days;
this.recommendations = recommendations;
this.approvalCount = approvalCount;
this.workerResponseCount = workerResponseCount;
}
Expand All @@ -53,6 +59,10 @@ public List<UpcomingExpiryItemResponse> getUpcoming7Days() {
return upcoming7Days;
}

public DashboardRecommendationsResponse getRecommendations() {
return recommendations;
}

public long getApprovalCount() {
return approvalCount;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
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.DashboardRecommendationItemResponse;
import com.fowoco.server.dashboard.api.DashboardRecommendationsResponse;
import com.fowoco.server.dashboard.api.UpcomingExpiryCategory;
import com.fowoco.server.dashboard.api.UpcomingExpiryItemResponse;
import com.fowoco.server.dashboard.application.error.DashboardErrorCode;
Expand Down Expand Up @@ -60,7 +62,8 @@ public DashboardQueryService(
public DashboardTodayResponse today(ActorContext actor, LocalDate date, String timezone) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId());
UUID companyId = actor.companyId();
Clock effectiveClock = timezone != null ? clock.withZone(parseTimezone(timezone)) : clock;
ZoneId effectiveZone = timezone != null ? parseTimezone(timezone) : ZoneId.of("Asia/Seoul");
Clock effectiveClock = clock.withZone(effectiveZone);
LocalDate targetDate = date != null ? date : LocalDate.now(effectiveClock);
LocalDate windowEnd = targetDate.plusDays(UPCOMING_DAYS + 1);

Expand All @@ -79,8 +82,47 @@ public DashboardTodayResponse today(ActorContext actor, LocalDate date, String t
.toList();

List<UpcomingExpiryItemResponse> upcoming7Days = collectUpcomingExpiry(companyId, targetDate, windowEnd);
DashboardRecommendationsResponse recommendations = collectRecommendations(companyId);

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

private DashboardRecommendationsResponse collectRecommendations(UUID companyId) {
List<Task> preparedTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria(
companyId, TaskStatus.DRAFT, null, null, com.fowoco.server.task.domain.TaskSource.AI_CANDIDATE,
null, null, null, null, null, 0, 100
)).items();

List<Task> needsInfoTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria(
companyId, TaskStatus.NEEDS_INFO, null, null, null, null, null, null, null, null, 0, 100
)).items();
List<Task> readyTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria(
companyId, TaskStatus.READY_FOR_REVIEW, null, null, null, null, null, null, null, null, 0, 100
)).items();
List<Task> reviewTasks = new ArrayList<>();
reviewTasks.addAll(needsInfoTasks);
reviewTasks.addAll(readyTasks);

List<Task> waitingWorkerTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria(
companyId, TaskStatus.WAITING_WORKER, null, null, null, null, null, null, null, null, 0, 100
)).items();
List<Task> waitingExternalTasks = taskRepository.findAll(new TaskRepository.TaskSearchCriteria(
companyId, TaskStatus.WAITING_EXTERNAL, null, null, null, null, null, null, null, null, 0, 100
)).items();
List<Task> afterApprovalTasks = new ArrayList<>();
afterApprovalTasks.addAll(waitingWorkerTasks);
afterApprovalTasks.addAll(waitingExternalTasks);

long connectedCount = taskRepository.countOpenTasksByCompanyId(companyId);

return new DashboardRecommendationsResponse(
connectedCount,
preparedTasks.stream().map(DashboardRecommendationItemResponse::from).toList(),
reviewTasks.stream().map(DashboardRecommendationItemResponse::from).toList(),
afterApprovalTasks.stream().map(DashboardRecommendationItemResponse::from).toList()
);
}

private List<UpcomingExpiryItemResponse> collectUpcomingExpiry(UUID companyId, LocalDate windowStart, LocalDate windowEnd) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public interface TaskRepository {
List<Task> findOpenTasks(UUID companyId, int limit);
long countByCompanyIdAndStatus(UUID companyId, TaskStatus status);
long countOpenTasksDueOn(UUID companyId, LocalDate dueDate);
long countOpenTasksByCompanyId(UUID companyId);
Task save(Task task);

record TaskSearchCriteria(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public long countOpenTasksDueOn(UUID companyId, LocalDate dueDate) {
return repository.countOpenTasksDueOn(companyId, dueDate);
}

@Override
public long countOpenTasksByCompanyId(UUID companyId) {
return repository.countOpenTasksByCompanyId(companyId);
}

@Override
public Task save(Task task) {
TaskJpaEntity entity = repository.findByTaskIdAndCompanyId(task.taskId(), task.companyId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,13 @@ SELECT COUNT(task)
AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED
""")
long countOpenTasksDueOn(@Param("companyId") UUID companyId, @Param("dueDate") LocalDate dueDate);

@Query("""
SELECT COUNT(task)
FROM TaskJpaEntity task
WHERE task.companyId = :companyId
AND task.status <> com.fowoco.server.task.domain.TaskStatus.COMPLETED
AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED
""")
long countOpenTasksByCompanyId(@Param("companyId") UUID companyId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@ void timezoneParameterIsAccepted() throws Exception {
assertThat(response.statusCode()).isEqualTo(200);
}

@Test
void dueTodayUsesKoreaTimeWhenTimezoneOmitted() throws Exception {
String accessToken = accessToken(login(HR_A_EMAIL));
String workerId = registerWorker(accessToken, "타임존테스트근로자");
createTask(accessToken, workerId, "한국시간마감업무");

// date를 명시적으로 지정하지 않고, timezone도 생략
// 서버가 한국 시간 기준으로 "오늘"을 계산해야 정상
HttpResponse<String> response = authorizedGet("/api/v1/dashboard/today", accessToken);

assertThat(response.statusCode()).isEqualTo(200);
// 이 테스트는 실제 UTC vs KST 자정~9시 사이 시간대 버그를
// 완벽히 재현하진 못하지만(테스트 실행 시각에 의존),
// 최소한 API가 정상 응답하는지 확인
}

@Test
void upcoming7DaysIncludesWorkerWithNearExpiry() throws Exception {
String accessToken = accessToken(login(HR_A_EMAIL));
Expand Down Expand Up @@ -223,6 +239,45 @@ void upcoming7DaysExcludesAlreadyPastExpiry() throws Exception {
assertThat(names).doesNotContain("이미지난만료근로자");
}

@Test
void recommendationsConnectedCountMatchesOpenTasks() throws Exception {
String accessToken = accessToken(login(HR_A_EMAIL));
String workerId = registerWorker(accessToken, "추천테스트근로자");
createTask(accessToken, workerId, "추천테스트업무1");
createTask(accessToken, workerId, "추천테스트업무2");

HttpResponse<String> response = authorizedGet("/api/v1/dashboard/today", accessToken);

assertThat(response.statusCode()).isEqualTo(200);
assertThat(JsonPath.<Number>read(response.body(), "$.recommendations.connected_count").longValue())
.isEqualTo(2);
}

@Test
void recommendationsReviewIncludesReadyForReviewTask() throws Exception {
String accessToken = accessToken(login(HR_A_EMAIL));
String workerId = registerWorker(accessToken, "승인대기추천근로자");
String taskId = createTask(accessToken, workerId, "승인대기업무");
requestReview(accessToken, taskId, workerId);

HttpResponse<String> response = authorizedGet("/api/v1/dashboard/today", accessToken);

assertThat(response.statusCode()).isEqualTo(200);
List<String> reviewTitles = JsonPath.read(response.body(), "$.recommendations.review[*].title");
assertThat(reviewTitles).contains("승인대기업무");
}

@Test
void recommendationsPreparedAndAfterApprovalAreEmptyByDefault() throws Exception {
String accessToken = accessToken(login(HR_A_EMAIL));

HttpResponse<String> response = authorizedGet("/api/v1/dashboard/today", accessToken);

assertThat(response.statusCode()).isEqualTo(200);
assertThat(JsonPath.<java.util.List<?>>read(response.body(), "$.recommendations.prepared")).isEmpty();
assertThat(JsonPath.<java.util.List<?>>read(response.body(), "$.recommendations.after_approval")).isEmpty();
}

private String registerWorkerWithAllExpiryFields(
String accessToken,
String displayName,
Expand Down
Loading
Loading