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 @@ -2,6 +2,7 @@

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import com.ntropy.common.dto.work.command.JobRegisterCommand;
Expand All @@ -13,8 +14,17 @@
@NoArgsConstructor
public class JobCreateRequest {

/** 사용자 입력 미허용, PER_TASK 잡의 시간당 예상 처리 건수는 3건으로 고정한다 (추후 변경 예정). */
private static final float FIXED_TASK_PER_HOUR = 3f;
/**
* 사용자 입력 미허용, PER_TASK 잡의 시간당 예상 처리 건수는 카테고리별 기본값으로 고정한다
* (2026-08). 매핑에 없는 카테고리(택배/물류 상하차, 펫시터·돌봄, 콘텐츠 제작)는 PER_TASK로
* 등록하지 않을 것으로 보여 값을 지어내지 않고 null로 둔다 - JobService의 예상소득 계산은
* taskPerHour가 null이면 예상소득만 null로 내려가고 등록 자체는 그대로 진행된다.
*/
private static final Map<Long, Float> DEFAULT_TASK_PER_HOUR_BY_CATEGORY = Map.of(
1L, 3.5f, // 배달
2L, 1.5f, // 대리운전
4L, 0.25f // 가사·청소 도우미
);

private Long categoryId;
private String jobName;
Expand All @@ -37,7 +47,7 @@ public JobRegisterCommand toCommand(Long userId) {
hourlyWage,
monthlyWage,
perTaskWage,
"PER_TASK".equals(settlementType) ? FIXED_TASK_PER_HOUR : null,
"PER_TASK".equals(settlementType) ? DEFAULT_TASK_PER_HOUR_BY_CATEGORY.get(categoryId) : null,
isRegular,
baseFatigue,
platformIds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import com.ntropy.common.dto.work.command.JobUpdateCommand;
Expand All @@ -13,8 +14,16 @@
@NoArgsConstructor
public class JobUpdateRequest {

/** 사용자 입력 미허용, PER_TASK 잡의 시간당 예상 처리 건수는 3건으로 고정한다 (2026-08 임시값, 추후 변경 예정). */
private static final float FIXED_TASK_PER_HOUR = 3f;
/**
* 사용자 입력 미허용, PER_TASK 잡의 시간당 예상 처리 건수는 카테고리별 기본값으로 고정한다
* (2026-08). 매핑에 없는 카테고리(택배/물류 상하차, 펫시터·돌봄, 콘텐츠 제작)는 PER_TASK로
* 등록하지 않을 것으로 보여 값을 지어내지 않고 null로 둔다.
*/
private static final Map<Long, Float> DEFAULT_TASK_PER_HOUR_BY_CATEGORY = Map.of(
1L, 3.5f, // 배달
2L, 1.5f, // 대리운전
4L, 0.25f // 가사·청소 도우미
);

private Long categoryId;
private String jobName;
Expand All @@ -31,7 +40,8 @@ public JobUpdateCommand toCommand() {
List<JobScheduleRequest> safeSchedules = schedules == null ? Collections.emptyList() : schedules;
return new JobUpdateCommand(
categoryId, jobName, settlementType, hourlyWage,
monthlyWage, perTaskWage, "PER_TASK".equals(settlementType) ? FIXED_TASK_PER_HOUR : null,
monthlyWage, perTaskWage,
"PER_TASK".equals(settlementType) ? DEFAULT_TASK_PER_HOUR_BY_CATEGORY.get(categoryId) : null,
isRegular, baseFatigue,
safeSchedules.stream()
.map(JobScheduleRequest::toCommand)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
Expand All @@ -31,6 +33,10 @@ public class KmaForecastClient {
private final RestTemplate restTemplate;
private final WeatherProperties properties;

// 격자(nx, ny) 단위 캐시. 기상청 단기예보는 발표시각(baseDateTime) 단위로만 갱신되므로,
// 같은 격자에 대해 발표시각이 그대로면 캐시를 그대로 쓰고, 발표시각이 바뀌면 갱신한다.
private final Map<GridKey, CacheEntry> forecastCache = new ConcurrentHashMap<>();

public KmaForecastClient(
@Qualifier("weatherRestTemplate") RestTemplate restTemplate,
WeatherProperties properties
Expand All @@ -42,6 +48,18 @@ public KmaForecastClient(
public List<KmaForecastItem> fetchForecastItems(int nx, int ny) {
BaseDateTime baseDateTime = resolveBaseDateTime(LocalDateTime.now());

GridKey gridKey = new GridKey(nx, ny);
CacheEntry cached = forecastCache.get(gridKey);
if (cached != null && cached.baseDateTime().equals(baseDateTime)) {
return cached.items();
}

List<KmaForecastItem> items = fetchFromKma(nx, ny, baseDateTime);
forecastCache.put(gridKey, new CacheEntry(baseDateTime, items));
return items;
}

private List<KmaForecastItem> fetchFromKma(int nx, int ny, BaseDateTime baseDateTime) {
String encodedQuery = UriComponentsBuilder.newInstance()
.queryParam("dataType", "JSON")
.queryParam("numOfRows", 1000)
Expand Down Expand Up @@ -88,5 +106,11 @@ private BaseDateTime resolveBaseDateTime(LocalDateTime now) {

private record BaseDateTime(LocalDate date, int hour) {
}

private record GridKey(int nx, int ny) {
}

private record CacheEntry(BaseDateTime baseDateTime, List<KmaForecastItem> items) {
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class JobService {
private final JobScheduleMapper jobScheduleMapper;
private final CategoryService categoryService;
private final AllocationGoalMapper allocationGoalMapper;
private final RecommendedWorkHoursService recommendedWorkHoursService;

/**
* 잡 등록. 정기근무 스케줄이 있으면 같이 등록한다(둘 다 성공하거나 둘 다 롤백).
Expand Down Expand Up @@ -58,8 +59,9 @@ public Job registerJob(Job job, List<JobSchedule> schedules) {
jobScheduleMapper.insert(schedule);
}

// 잡이 추가되면 기존 이번 달 추천 결과는 더 이상 최신이 아니므로 무효화합니다.
// 잡이 추가되면 기존 이번 달 추천 결과는 더 이상 최신이 아니므로 무효화하고 즉시 재계산합니다.
allocationGoalMapper.deleteByUserIdAndTargetMonth(job.getUserId(), currentMonth());
recommendedWorkHoursService.getCurrentMonthRecommendedWorkHours(job.getUserId());

return job;
}
Expand Down Expand Up @@ -110,8 +112,9 @@ public Job updateJob(Long requesterUserId, Job job, List<JobSchedule> schedules)
jobScheduleMapper.insert(schedule);
}

// 시급·정산 방식·피로도·활성 상태 등이 변경될 수 있으므로 재계산을 유도합니다.
// 시급·정산 방식·피로도·활성 상태 등이 변경될 수 있으므로 즉시 재계산합니다.
allocationGoalMapper.deleteByUserIdAndTargetMonth(existing.getUserId(), currentMonth());
recommendedWorkHoursService.getCurrentMonthRecommendedWorkHours(existing.getUserId());

return job;
}
Expand All @@ -124,6 +127,7 @@ public void deactivateJob(Long requesterUserId, Long jobId) {
job.setUpdatedAt(LocalDateTime.now());
jobMapper.update(job);
allocationGoalMapper.deleteByUserIdAndTargetMonth(job.getUserId(), currentMonth());
recommendedWorkHoursService.getCurrentMonthRecommendedWorkHours(job.getUserId());
}

/** 요청자가 이 잡의 소유자가 아니면 예외를 던진다. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import com.ntropy.work.domain.entity.Job;
import com.ntropy.work.domain.entity.SavingGoal;
import com.ntropy.work.mapper.AllocationGoalMapper;
import com.ntropy.work.mapper.JobMapper;

import lombok.RequiredArgsConstructor;

Expand Down Expand Up @@ -44,7 +45,7 @@ public class RecommendedWorkHoursService {
1, 1.00, 2, 0.90, 3, 0.80, 4, 0.70, 5, 0.60);

private final SavingGoalService savingGoalService;
private final JobService jobService;
private final JobMapper jobMapper;
private final AllocationGoalMapper allocationGoalMapper;

@Transactional
Expand All @@ -55,7 +56,7 @@ public RecommendedWorkHoursSummary getCurrentMonthRecommendedWorkHours(Long user
}

String targetMonth = goal.getTargetMonth();
List<Job> jobs = jobService.findByUserId(userId).stream()
List<Job> jobs = jobMapper.findByUserId(userId).stream()
.filter(job -> Boolean.TRUE.equals(job.getIsActive()))
.toList();
List<JobSummary> summaries = jobs.stream().map(this::toSummary).toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
import com.ntropy.work.mapper.InMemoryCategoryMapper;
import com.ntropy.work.mapper.InMemoryJobMapper;
import com.ntropy.work.mapper.InMemoryJobScheduleMapper;
import com.ntropy.work.mapper.InMemorySavingGoalMapper;
import com.ntropy.work.service.CategoryService;
import com.ntropy.work.service.JobService;
import com.ntropy.work.service.RecommendedWorkHoursService;
import com.ntropy.work.service.SavingGoalService;

class LocalExpectedIncomeLossQueryClientTest {

Expand All @@ -34,8 +37,13 @@ void setUp() {
jobMapper = new InMemoryJobMapper();
InMemoryCategoryMapper categoryMapper = new InMemoryCategoryMapper();
categoryMapper.seed(Category.builder().categoryId(1L).name("배달").build());
InMemoryAllocationGoalMapper allocationGoalMapper = new InMemoryAllocationGoalMapper();
SavingGoalService savingGoalService = new SavingGoalService(new InMemorySavingGoalMapper(), allocationGoalMapper);
RecommendedWorkHoursService recommendedWorkHoursService =
new RecommendedWorkHoursService(savingGoalService, jobMapper, allocationGoalMapper);
JobService jobService = new JobService(
jobMapper, new InMemoryJobScheduleMapper(), new CategoryService(categoryMapper), new InMemoryAllocationGoalMapper());
jobMapper, new InMemoryJobScheduleMapper(), new CategoryService(categoryMapper),
allocationGoalMapper, recommendedWorkHoursService);
client = new LocalExpectedIncomeLossQueryClient(jobService);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
import com.ntropy.work.service.JobPlatformMappingService;
import com.ntropy.work.service.JobService;
import com.ntropy.work.service.PlatformService;
import com.ntropy.work.service.RecommendedWorkHoursService;
import com.ntropy.work.service.SavingGoalService;
import com.ntropy.work.mapper.InMemoryAllocationGoalMapper;
import com.ntropy.work.mapper.InMemorySavingGoalMapper;

class LocalJobQueryClientTest {

Expand All @@ -45,7 +48,12 @@ void setUp() {
jobScheduleMapper = new InMemoryJobScheduleMapper();
InMemoryCategoryMapper categoryMapper = new InMemoryCategoryMapper();
categoryMapper.seed(Category.builder().categoryId(1L).name("배달").build());
jobService = new JobService(jobMapper, jobScheduleMapper, new CategoryService(categoryMapper), new InMemoryAllocationGoalMapper());
InMemoryAllocationGoalMapper allocationGoalMapper = new InMemoryAllocationGoalMapper();
SavingGoalService savingGoalService = new SavingGoalService(new InMemorySavingGoalMapper(), allocationGoalMapper);
RecommendedWorkHoursService recommendedWorkHoursService =
new RecommendedWorkHoursService(savingGoalService, jobMapper, allocationGoalMapper);
jobService = new JobService(jobMapper, jobScheduleMapper, new CategoryService(categoryMapper),
allocationGoalMapper, recommendedWorkHoursService);

InMemoryPlatformMapper platformMapper = new InMemoryPlatformMapper();
platformMapper.seed(Platform.builder().platformId(1L).categoryId(1L)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.ntropy.work.client.kma;

import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

import java.util.List;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.ExpectedCount;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.test.web.client.RequestMatcher;
import org.springframework.web.client.RestTemplate;

import com.ntropy.work.config.WeatherProperties;

class KmaForecastClientTest {

private static final String RESPONSE_JSON =
"{\"response\":{\"header\":{\"resultCode\":\"00\",\"resultMsg\":\"OK\"},"
+ "\"body\":{\"items\":{\"item\":[]}}}}";

@Test
@DisplayName("같은 격자를 연속 조회하면 캐시를 재사용해 API를 한 번만 호출한다")
void reusesCacheForSameGridWithinSameBaseTime() {
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
WeatherProperties properties = properties();

server.expect(ExpectedCount.once(), requestToBaseUrl(properties))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(RESPONSE_JSON, MediaType.APPLICATION_JSON));

KmaForecastClient client = new KmaForecastClient(restTemplate, properties);

List<KmaForecastItem> first = client.fetchForecastItems(60, 127);
List<KmaForecastItem> second = client.fetchForecastItems(60, 127);

assertSame(first, second, "같은 격자를 재조회하면 캐시된 동일 리스트 인스턴스를 반환해야 한다");
server.verify();
}

@Test
@DisplayName("격자가 다르면 캐시를 타지 않고 각각 API를 호출한다")
void callsApiSeparatelyForDifferentGrids() {
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
WeatherProperties properties = properties();

server.expect(ExpectedCount.times(2), requestToBaseUrl(properties))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(RESPONSE_JSON, MediaType.APPLICATION_JSON));

KmaForecastClient client = new KmaForecastClient(restTemplate, properties);

client.fetchForecastItems(60, 127);
client.fetchForecastItems(61, 128);

server.verify();
}

/** requestTo(Matcher)는 hamcrest가 필요해 대신 baseUrl 포함 여부만 확인하는 커스텀 매처를 쓴다. */
private static RequestMatcher requestToBaseUrl(WeatherProperties properties) {
return request -> assertTrue(
request.getURI().toString().contains(properties.getBaseUrl()),
"요청 URI에 baseUrl이 포함되어야 합니다: " + request.getURI()
);
}

private static WeatherProperties properties() {
return new WeatherProperties(
"test-service-key",
"https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst",
37.5665, 126.9780,
3000, 5000
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,12 @@ void setUp() {
workLogMapper = new InMemoryWorkLogMapper();
allocationGoalMapper = new InMemoryAllocationGoalMapper();
savingGoalMapper = new InMemorySavingGoalMapper();
SavingGoalService savingGoalService = new SavingGoalService(savingGoalMapper, allocationGoalMapper);
RecommendedWorkHoursService recommendedWorkHoursService =
new RecommendedWorkHoursService(savingGoalService, jobMapper, allocationGoalMapper);
jobService = new JobService(
jobMapper, new InMemoryJobScheduleMapper(), new CategoryService(new InMemoryCategoryMapper()), new InMemoryAllocationGoalMapper()
jobMapper, new InMemoryJobScheduleMapper(), new CategoryService(new InMemoryCategoryMapper()),
allocationGoalMapper, recommendedWorkHoursService
);

calendarService = new CalendarService(
Expand Down Expand Up @@ -205,8 +209,15 @@ void dailySummary_returnsWorksAndKoreanDayOfWeek() {
@DisplayName("일간 요약의 피로도 게이지는 FatigueService 결과를 그대로 담는다")
void dailySummary_attachesFatigueGaugeFromFatigueService() {
CalendarFatigueGauge gauge = new CalendarFatigueGauge(42, "LOW", false);
InMemoryJobMapper freshJobMapper = new InMemoryJobMapper();
InMemoryAllocationGoalMapper freshAllocationGoalMapper = new InMemoryAllocationGoalMapper();
SavingGoalService freshSavingGoalService =
new SavingGoalService(new InMemorySavingGoalMapper(), freshAllocationGoalMapper);
RecommendedWorkHoursService freshRecommendedWorkHoursService =
new RecommendedWorkHoursService(freshSavingGoalService, freshJobMapper, freshAllocationGoalMapper);
JobService jobService = new JobService(
new InMemoryJobMapper(), new InMemoryJobScheduleMapper(), new CategoryService(new InMemoryCategoryMapper()), new InMemoryAllocationGoalMapper()
freshJobMapper, new InMemoryJobScheduleMapper(), new CategoryService(new InMemoryCategoryMapper()),
freshAllocationGoalMapper, freshRecommendedWorkHoursService
);
CalendarService service = new CalendarService(
workLogMapper, allocationGoalMapper, savingGoalMapper, jobService, new StubFatigueService(gauge),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import com.ntropy.work.mapper.InMemoryCategoryMapper;
import com.ntropy.work.mapper.InMemoryJobMapper;
import com.ntropy.work.mapper.InMemoryJobScheduleMapper;
import com.ntropy.work.mapper.InMemorySavingGoalMapper;

class JobServiceTest {

Expand All @@ -36,7 +37,12 @@ void setUp() {
jobScheduleMapper = new InMemoryJobScheduleMapper();
InMemoryCategoryMapper categoryMapper = new InMemoryCategoryMapper();
categoryMapper.seed(Category.builder().categoryId(1L).name("배달").build());
jobService = new JobService(jobMapper, jobScheduleMapper, new CategoryService(categoryMapper), new InMemoryAllocationGoalMapper());
InMemoryAllocationGoalMapper allocationGoalMapper = new InMemoryAllocationGoalMapper();
SavingGoalService savingGoalService = new SavingGoalService(new InMemorySavingGoalMapper(), allocationGoalMapper);
RecommendedWorkHoursService recommendedWorkHoursService =
new RecommendedWorkHoursService(savingGoalService, jobMapper, allocationGoalMapper);
jobService = new JobService(jobMapper, jobScheduleMapper, new CategoryService(categoryMapper),
allocationGoalMapper, recommendedWorkHoursService);
}

private Job.JobBuilder baseJob() {
Expand Down
Loading
Loading