diff --git a/docs/ngp-monthly-weekly-api.md b/docs/ngp-monthly-weekly-api.md new file mode 100644 index 0000000..19cecea --- /dev/null +++ b/docs/ngp-monthly-weekly-api.md @@ -0,0 +1,69 @@ +# NGP 本月每周业绩统计接口 + +## 请求 + +`POST /api/ngp/performance/monthly-weekly` + +```json +{ + "month": "2026-09" +} +``` + +`month` 可省略,省略时使用 Asia/Shanghai 时区的当前月份。不能查询未来月份。 + +## 周区间 + +按周一至周日切分,月初和月末使用当月实际边界。例如 2026 年 9 月: + +- `9/1-9/6` +- `9/7-9/13` +- `9/14-9/20` +- `9/21-9/27` +- `9/28-9/30` + +当前月份只读取截至今天的日期,未来周金额返回 `null`。 + +## 缓存 + +接口复用 `ngp_performance_daily_total` 和 `ngp_performance_daily_status`: + +- 已缓存且未过期的日期直接从 MySQL 读取。 +- 只为缺失或过期的日期启动后台 NGP 查询。 +- 同一日期同时只运行一个刷新任务。 +- 缓存补齐期间返回 HTTP `202` 和 `retryAfterSeconds`。 +- 缓存就绪后返回 HTTP `200`,不再查询 NGP 全量数据。 + +网点范围和显示顺序由 `foundation.ngp.monthly-store-names` 配置。即使某个配置网点当月 +没有业绩,响应中仍保留该网点,金额返回 `null`,以便表格显示 `-`。 + +## 成功响应示例 + +```json +{ + "success": true, + "month": "2026-09", + "monthLabel": "九月", + "title": "2026年九月业绩周统计", + "weeks": [ + { + "index": 0, + "startDate": "2026-09-01", + "endDate": "2026-09-06", + "label": "9/1-9/6", + "complete": true + } + ], + "stores": [ + { + "storeName": "跃动内容电商(抖店)", + "weeklyAmounts": [506482.76, null, null, null, null], + "subtotal": 506482.76 + } + ], + "weekTotals": [1691720.10, null, null, null, null], + "grandTotal": 1691720.10, + "formattedGrandTotal": "1,691,720.10", + "currency": "CNY" +} +``` diff --git a/docs/ngp-performance-api.md b/docs/ngp-performance-api.md index c6ae1af..94e6366 100644 --- a/docs/ngp-performance-api.md +++ b/docs/ngp-performance-api.md @@ -53,4 +53,4 @@ NGP 的 AppKey、AppSecret、access token 和 refresh token 位于 `application. `foundation.ngp` 节点。access token 失效时服务会使用 refresh token 自动刷新,并在当前 进程内保存新 token。配置文件含明文凭据,只能保存在私有仓库并限制读取权限。 -历史日期结果默认缓存 24 小时,包含今天的结果默认缓存 60 秒。 +历史日期结果默认缓存 24 小时,包含今天的结果默认缓存 10 分钟。 diff --git a/src/main/java/com/company/agentfoundation/config/FoundationProperties.java b/src/main/java/com/company/agentfoundation/config/FoundationProperties.java index 9845b03..7623c04 100644 --- a/src/main/java/com/company/agentfoundation/config/FoundationProperties.java +++ b/src/main/java/com/company/agentfoundation/config/FoundationProperties.java @@ -308,8 +308,9 @@ public class FoundationProperties { private int readTimeoutMs = 60000; private int maxRetries = 2; private int maxResponseBytes = 5 * 1024 * 1024; - private int currentCacheSeconds = 60; + private int currentCacheSeconds = 600; private int historyCacheSeconds = 86400; + private List monthlyStoreNames = new ArrayList<>(); public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } @@ -347,6 +348,8 @@ public class FoundationProperties { public void setCurrentCacheSeconds(int currentCacheSeconds) { this.currentCacheSeconds = currentCacheSeconds; } public int getHistoryCacheSeconds() { return historyCacheSeconds; } public void setHistoryCacheSeconds(int historyCacheSeconds) { this.historyCacheSeconds = historyCacheSeconds; } + public List getMonthlyStoreNames() { return monthlyStoreNames; } + public void setMonthlyStoreNames(List monthlyStoreNames) { this.monthlyStoreNames = monthlyStoreNames; } } public static class Semantic { diff --git a/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyStoreRow.java b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyStoreRow.java new file mode 100644 index 0000000..2dc5980 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyStoreRow.java @@ -0,0 +1,11 @@ +package com.company.agentfoundation.ngp.monthly; + +import java.math.BigDecimal; +import java.util.List; + +public record NgpMonthlyStoreRow( + String storeName, + List weeklyAmounts, + BigDecimal subtotal +) { +} diff --git a/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeek.java b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeek.java new file mode 100644 index 0000000..2eec581 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeek.java @@ -0,0 +1,12 @@ +package com.company.agentfoundation.ngp.monthly; + +import java.time.LocalDate; + +public record NgpMonthlyWeek( + int index, + LocalDate startDate, + LocalDate endDate, + String label, + boolean complete +) { +} diff --git a/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyController.java b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyController.java new file mode 100644 index 0000000..e0d6c15 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyController.java @@ -0,0 +1,31 @@ +package com.company.agentfoundation.ngp.monthly; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/ngp/performance") +public class NgpMonthlyWeeklyController { + private final NgpMonthlyWeeklyService service; + + public NgpMonthlyWeeklyController(NgpMonthlyWeeklyService service) { + this.service = service; + } + + @PostMapping("/monthly-weekly") + public ResponseEntity query( + @RequestBody(required = false) NgpMonthlyWeeklyRequest request + ) { + NgpMonthlyWeeklyResult result = service.query(request); + if (result instanceof NgpMonthlyWeeklyPendingResponse pending) { + return ResponseEntity.accepted() + .header(HttpHeaders.RETRY_AFTER, String.valueOf(pending.retryAfterSeconds())) + .body(pending); + } + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyPendingResponse.java b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyPendingResponse.java new file mode 100644 index 0000000..e85f66a --- /dev/null +++ b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyPendingResponse.java @@ -0,0 +1,11 @@ +package com.company.agentfoundation.ngp.monthly; + +public record NgpMonthlyWeeklyPendingResponse( + boolean success, + String status, + String month, + String message, + int pendingDays, + int retryAfterSeconds +) implements NgpMonthlyWeeklyResult { +} diff --git a/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyRequest.java b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyRequest.java new file mode 100644 index 0000000..8c793e5 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyRequest.java @@ -0,0 +1,4 @@ +package com.company.agentfoundation.ngp.monthly; + +public record NgpMonthlyWeeklyRequest(String month) { +} diff --git a/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyResponse.java b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyResponse.java new file mode 100644 index 0000000..25ec8ee --- /dev/null +++ b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyResponse.java @@ -0,0 +1,20 @@ +package com.company.agentfoundation.ngp.monthly; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record NgpMonthlyWeeklyResponse( + boolean success, + String month, + String monthLabel, + String title, + LocalDateTime generatedAt, + List weeks, + List stores, + List weekTotals, + BigDecimal grandTotal, + String formattedGrandTotal, + String currency +) implements NgpMonthlyWeeklyResult { +} diff --git a/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyResult.java b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyResult.java new file mode 100644 index 0000000..b8f5886 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyResult.java @@ -0,0 +1,5 @@ +package com.company.agentfoundation.ngp.monthly; + +public sealed interface NgpMonthlyWeeklyResult + permits NgpMonthlyWeeklyResponse, NgpMonthlyWeeklyPendingResponse { +} diff --git a/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyService.java b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyService.java new file mode 100644 index 0000000..698cf9f --- /dev/null +++ b/src/main/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyService.java @@ -0,0 +1,224 @@ +package com.company.agentfoundation.ngp.monthly; + +import com.company.agentfoundation.config.FoundationProperties; +import com.company.agentfoundation.ngp.performance.NgpDailyStoreTotal; +import com.company.agentfoundation.ngp.performance.NgpPerformanceCacheRepository; +import com.company.agentfoundation.ngp.performance.NgpPerformanceDayLoader; +import com.company.agentfoundation.ngp.performance.NgpPerformanceRefreshCoordinator; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.NumberFormat; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class NgpMonthlyWeeklyService { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final String[] CHINESE_MONTHS = { + "一月", "二月", "三月", "四月", "五月", "六月", + "七月", "八月", "九月", "十月", "十一月", "十二月" + }; + + private final NgpPerformanceCacheRepository repository; + private final NgpPerformanceRefreshCoordinator refreshCoordinator; + private final FoundationProperties.Ngp config; + private final Clock clock; + + public NgpMonthlyWeeklyService( + NgpPerformanceCacheRepository repository, + NgpPerformanceRefreshCoordinator refreshCoordinator, + FoundationProperties properties + ) { + this(repository, refreshCoordinator, properties, Clock.system(BUSINESS_ZONE)); + } + + NgpMonthlyWeeklyService( + NgpPerformanceCacheRepository repository, + NgpPerformanceRefreshCoordinator refreshCoordinator, + FoundationProperties properties, + Clock clock + ) { + this.repository = repository; + this.refreshCoordinator = refreshCoordinator; + this.config = properties.getNgp(); + this.clock = clock; + } + + public NgpMonthlyWeeklyResult query(NgpMonthlyWeeklyRequest request) { + LocalDate today = LocalDate.now(clock); + YearMonth currentMonth = YearMonth.from(today); + YearMonth month = parseMonth(request == null ? null : request.month(), currentMonth); + if (month.isAfter(currentMonth)) throw new IllegalArgumentException("不能查询未来月份"); + + LocalDate monthStart = month.atDay(1); + LocalDate monthEnd = month.atEndOfMonth(); + LocalDate dataEnd = month.equals(currentMonth) ? today : monthEnd; + LocalDateTime now = LocalDateTime.now(clock); + List missingDates = monthStart.datesUntil(dataEnd.plusDays(1)) + .filter(date -> !fresh(date, repository.findRefreshTime(date).orElse(null), now)) + .toList(); + if (!missingDates.isEmpty()) { + refreshCoordinator.ensureRefresh(missingDates); + int retrySeconds = Math.max(config.getPendingRetrySeconds(), 1); + return new NgpMonthlyWeeklyPendingResponse( + false, + "PROCESSING", + month.toString(), + "后台正在补齐本月 " + missingDates.size() + " 天的缓存,请稍后重试", + missingDates.size(), + retrySeconds + ); + } + + List weeks = buildWeeks(month, today); + List dailyTotals = repository.findDailyTotals(monthStart, dataEnd); + return buildResponse(month, today, weeks, dailyTotals); + } + + private NgpMonthlyWeeklyResponse buildResponse( + YearMonth month, + LocalDate today, + List weeks, + List dailyTotals + ) { + Map stores = new LinkedHashMap<>(); + List configuredStoreNames = config.getMonthlyStoreNames() == null + ? List.of() + : config.getMonthlyStoreNames().stream().filter(name -> name != null && !name.isBlank()).toList(); + boolean restrictStores = !configuredStoreNames.isEmpty(); + configuredStoreNames.forEach(storeName -> stores.put( + NgpPerformanceDayLoader.normalize(storeName), + new StoreAccumulator(storeName.trim(), weeks.size()))); + long[] weekTotals = new long[weeks.size()]; + for (NgpDailyStoreTotal daily : dailyTotals) { + int weekIndex = weekIndex(weeks, daily.businessDate()); + if (weekIndex < 0) continue; + String storeKey = NgpPerformanceDayLoader.normalize(daily.storeKey()); + StoreAccumulator store = stores.get(storeKey); + if (store == null && restrictStores) continue; + if (store == null) { + store = new StoreAccumulator(daily.storeName(), weeks.size()); + stores.put(storeKey, store); + } + store.weeklyCents[weekIndex] += daily.totalCents(); + store.subtotalCents += daily.totalCents(); + weekTotals[weekIndex] += daily.totalCents(); + } + + List rows = stores.values().stream() + .filter(store -> restrictStores || store.subtotalCents != 0) + .map(store -> new NgpMonthlyStoreRow( + store.storeName, + amountColumns(store.weeklyCents, weeks, today), + store.subtotalCents == 0 ? null : amount(store.subtotalCents))) + .toList(); + List totals = amountColumns(weekTotals, weeks, today); + long grandTotalCents = 0; + for (long cents : weekTotals) grandTotalCents += cents; + BigDecimal grandTotal = amount(grandTotalCents); + String monthLabel = CHINESE_MONTHS[month.getMonthValue() - 1]; + return new NgpMonthlyWeeklyResponse( + true, + month.toString(), + monthLabel, + month.getYear() + "年" + monthLabel + "业绩周统计", + LocalDateTime.now(clock), + weeks, + rows, + totals, + grandTotal, + formatAmount(grandTotal), + "CNY" + ); + } + + private List buildWeeks(YearMonth month, LocalDate today) { + List result = new ArrayList<>(); + LocalDate cursor = month.atDay(1); + LocalDate monthEnd = month.atEndOfMonth(); + int index = 0; + while (!cursor.isAfter(monthEnd)) { + LocalDate weekEnd = cursor.with(TemporalAdjusters.nextOrSame(java.time.DayOfWeek.SUNDAY)); + if (weekEnd.isAfter(monthEnd)) weekEnd = monthEnd; + result.add(new NgpMonthlyWeek( + index++, + cursor, + weekEnd, + cursor.getMonthValue() + "/" + cursor.getDayOfMonth() + + "-" + weekEnd.getMonthValue() + "/" + weekEnd.getDayOfMonth(), + !weekEnd.isAfter(today) + )); + cursor = weekEnd.plusDays(1); + } + return List.copyOf(result); + } + + private List amountColumns(long[] cents, List weeks, LocalDate today) { + List result = new ArrayList<>(); + for (int index = 0; index < cents.length; index++) { + boolean futureWeek = weeks.get(index).startDate().isAfter(today); + result.add(futureWeek || cents[index] == 0 ? null : amount(cents[index])); + } + return Collections.unmodifiableList(result); + } + + private int weekIndex(List weeks, LocalDate date) { + for (NgpMonthlyWeek week : weeks) { + if (!date.isBefore(week.startDate()) && !date.isAfter(week.endDate())) return week.index(); + } + return -1; + } + + private boolean fresh(LocalDate date, LocalDateTime refreshedAt, LocalDateTime now) { + if (refreshedAt == null) return false; + int ttlSeconds = date.isBefore(now.toLocalDate()) + ? config.getHistoryCacheSeconds() : config.getCurrentCacheSeconds(); + return Duration.between(refreshedAt, now).getSeconds() <= Math.max(ttlSeconds, 0); + } + + private YearMonth parseMonth(String raw, YearMonth defaultMonth) { + if (raw == null || raw.isBlank()) return defaultMonth; + try { + return YearMonth.parse(raw.trim()); + } catch (DateTimeParseException exception) { + throw new IllegalArgumentException("month 必须使用 YYYY-MM 格式", exception); + } + } + + private BigDecimal amount(long cents) { + return BigDecimal.valueOf(cents, 2).setScale(2, RoundingMode.UNNECESSARY); + } + + private String formatAmount(BigDecimal amount) { + NumberFormat formatter = NumberFormat.getNumberInstance(Locale.CHINA); + formatter.setGroupingUsed(true); + formatter.setMinimumFractionDigits(2); + formatter.setMaximumFractionDigits(2); + return formatter.format(amount); + } + + private static final class StoreAccumulator { + private final String storeName; + private final long[] weeklyCents; + private long subtotalCents; + + private StoreAccumulator(String storeName, int weekCount) { + this.storeName = storeName; + this.weeklyCents = new long[weekCount]; + } + } +} diff --git a/src/main/java/com/company/agentfoundation/ngp/performance/NgpDailyStoreTotal.java b/src/main/java/com/company/agentfoundation/ngp/performance/NgpDailyStoreTotal.java new file mode 100644 index 0000000..51a86c4 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/ngp/performance/NgpDailyStoreTotal.java @@ -0,0 +1,11 @@ +package com.company.agentfoundation.ngp.performance; + +import java.time.LocalDate; + +public record NgpDailyStoreTotal( + LocalDate businessDate, + String storeKey, + String storeName, + long totalCents +) { +} diff --git a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java index ba94a7d..f72f599 100644 --- a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java +++ b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java @@ -72,6 +72,24 @@ public class NgpPerformanceCacheRepository { return Map.copyOf(result); } + public List findDailyTotals(LocalDate startDate, LocalDate endDate) { + return jdbcTemplate.query(""" + SELECT business_date, store_key, store_name, total_cents + FROM ngp_performance_daily_total + WHERE business_date BETWEEN ? AND ? + ORDER BY store_name, business_date + """, + (resultSet, rowNum) -> new NgpDailyStoreTotal( + resultSet.getObject("business_date", LocalDate.class), + resultSet.getString("store_key"), + resultSet.getString("store_name"), + resultSet.getLong("total_cents") + ), + startDate, + endDate + ); + } + @Transactional public void replaceDay(LocalDate date, Map totals, LocalDateTime refreshedAt) { jdbcTemplate.update("DELETE FROM ngp_performance_daily_total WHERE business_date = ?", date); diff --git a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java index 4c23434..4f33e48 100644 --- a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java +++ b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java @@ -150,7 +150,7 @@ public class NgpPerformanceDayLoader { return ""; } - static String normalize(String value) { + public static String normalize(String value) { return value == null ? "" : value.trim().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 83fbb27..bd776ac 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -147,8 +147,30 @@ foundation: read-timeout-ms: 60000 max-retries: 2 max-response-bytes: 5242880 - current-cache-seconds: 60 + current-cache-seconds: 600 history-cache-seconds: 86400 + monthly-store-names: + - 跃动内容电商(小红书2店) + - 跃动内容电商(微信小店) + - 跃动内容电商(抖店) + - 跃动内容电商(小红书) + - 跃动内容电商(快手) + - 跃动京东自营店 + - 拼多多-跃动官方旗舰店 + - 淘宝-跃动体育用品 + - 淘宝-索菲格体育用品 + - 天猫-跃动运动旗舰店 + - 跃动运动户外专营店 + - 跃动京东pop + - 拼多多-盛跃动盈运动户外专卖店 + - 跃动运动健身旗舰店-抖店 + - 跃动运动器械旗舰店-快手 + - 跃动运动健身-微信小店 + - 小红书-盛跃动盈科技的店 + - 拼多多-乐动体育运动专营店 + - 抖店2-跃动健身训练旗舰店 + - 拼多多-盛跃动盈体育用品专营店 + - 盛跃动盈户外专营店 semantic: agent-id: crm-assistant minimum-confidence: 0.60 diff --git a/src/test/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyControllerTest.java b/src/test/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyControllerTest.java new file mode 100644 index 0000000..834d08c --- /dev/null +++ b/src/test/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyControllerTest.java @@ -0,0 +1,31 @@ +package com.company.agentfoundation.ngp.monthly; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class NgpMonthlyWeeklyControllerTest { + @Test + void returnsAcceptedWhileDailyCacheIsBeingFilled() throws Exception { + NgpMonthlyWeeklyService service = mock(NgpMonthlyWeeklyService.class); + when(service.query(any())).thenReturn(new NgpMonthlyWeeklyPendingResponse( + false, "PROCESSING", "2026-09", "正在补齐缓存", 9, 10)); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new NgpMonthlyWeeklyController(service)).build(); + + mockMvc.perform(post("/api/ngp/performance/monthly-weekly") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"month\":\"2026-09\"}")) + .andExpect(status().isAccepted()) + .andExpect(header().string("Retry-After", "10")) + .andExpect(jsonPath("$.status").value("PROCESSING")); + } +} diff --git a/src/test/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyServiceTest.java b/src/test/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyServiceTest.java new file mode 100644 index 0000000..abd7727 --- /dev/null +++ b/src/test/java/com/company/agentfoundation/ngp/monthly/NgpMonthlyWeeklyServiceTest.java @@ -0,0 +1,103 @@ +package com.company.agentfoundation.ngp.monthly; + +import com.company.agentfoundation.config.FoundationProperties; +import com.company.agentfoundation.ngp.performance.NgpDailyStoreTotal; +import com.company.agentfoundation.ngp.performance.NgpPerformanceCacheRepository; +import com.company.agentfoundation.ngp.performance.NgpPerformanceRefreshCoordinator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class NgpMonthlyWeeklyServiceTest { + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-09T04:00:00Z"), ZONE); + + private NgpPerformanceCacheRepository repository; + private NgpPerformanceRefreshCoordinator coordinator; + private NgpMonthlyWeeklyService service; + + @BeforeEach + void setUp() { + repository = mock(NgpPerformanceCacheRepository.class); + coordinator = mock(NgpPerformanceRefreshCoordinator.class); + service = new NgpMonthlyWeeklyService(repository, coordinator, new FoundationProperties(), CLOCK); + } + + @Test + void currentMonthOnlyRequiresDatesThroughToday() { + when(repository.findRefreshTime(any())).thenReturn(Optional.empty()); + + NgpMonthlyWeeklyResult result = service.query(new NgpMonthlyWeeklyRequest(null)); + + assertThat(result).isInstanceOf(NgpMonthlyWeeklyPendingResponse.class); + assertThat(((NgpMonthlyWeeklyPendingResponse) result).pendingDays()).isEqualTo(9); + ArgumentCaptor> dates = ArgumentCaptor.forClass(List.class); + verify(coordinator).ensureRefresh(dates.capture()); + assertThat(dates.getValue()).containsExactly( + LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 2), LocalDate.of(2026, 9, 3), + LocalDate.of(2026, 9, 4), LocalDate.of(2026, 9, 5), LocalDate.of(2026, 9, 6), + LocalDate.of(2026, 9, 7), LocalDate.of(2026, 9, 8), LocalDate.of(2026, 9, 9)); + verify(repository, times(9)).findRefreshTime(any()); + } + + @Test + void buildsMondayToSundayColumnsAndStoreSubtotalsFromDailyCache() { + when(repository.findRefreshTime(any())).thenReturn(Optional.of(LocalDateTime.of(2026, 9, 9, 11, 59))); + when(repository.findDailyTotals(any(), any())).thenReturn(List.of( + new NgpDailyStoreTotal(LocalDate.of(2026, 9, 1), "a", "网点A", 10000), + new NgpDailyStoreTotal(LocalDate.of(2026, 9, 6), "a", "网点A", 20000), + new NgpDailyStoreTotal(LocalDate.of(2026, 9, 7), "a", "网点A", 40000), + new NgpDailyStoreTotal(LocalDate.of(2026, 9, 9), "b", "网点B", 50000) + )); + + NgpMonthlyWeeklyResult result = service.query(new NgpMonthlyWeeklyRequest("2026-09")); + + assertThat(result).isInstanceOf(NgpMonthlyWeeklyResponse.class); + NgpMonthlyWeeklyResponse response = (NgpMonthlyWeeklyResponse) result; + assertThat(response.weeks()).extracting(NgpMonthlyWeek::label) + .containsExactly("9/1-9/6", "9/7-9/13", "9/14-9/20", "9/21-9/27", "9/28-9/30"); + assertThat(response.stores()).hasSize(2); + assertThat(response.stores().get(0).weeklyAmounts().get(0)).isEqualByComparingTo("300.00"); + assertThat(response.stores().get(0).weeklyAmounts().get(1)).isEqualByComparingTo("400.00"); + assertThat(response.stores().get(0).weeklyAmounts().get(2)).isNull(); + assertThat(response.grandTotal()).isEqualByComparingTo("1200.00"); + assertThat(response.formattedGrandTotal()).isEqualTo("1,200.00"); + } + + @Test + void keepsConfiguredStoreOrderAndExcludesOtherStores() { + FoundationProperties properties = new FoundationProperties(); + properties.getNgp().setMonthlyStoreNames(List.of("网点B", "网点A", "无数据网点")); + service = new NgpMonthlyWeeklyService(repository, coordinator, properties, CLOCK); + when(repository.findRefreshTime(any())).thenReturn(Optional.of(LocalDateTime.of(2026, 9, 9, 11, 59))); + when(repository.findDailyTotals(any(), any())).thenReturn(List.of( + new NgpDailyStoreTotal(LocalDate.of(2026, 9, 1), "网点a", "网点A", 10000), + new NgpDailyStoreTotal(LocalDate.of(2026, 9, 1), "其他网点", "其他网点", 99900) + )); + + NgpMonthlyWeeklyResponse response = (NgpMonthlyWeeklyResponse) service.query( + new NgpMonthlyWeeklyRequest("2026-09")); + + assertThat(response.stores()).extracting(NgpMonthlyStoreRow::storeName) + .containsExactly("网点B", "网点A", "无数据网点"); + assertThat(response.stores().get(0).subtotal()).isNull(); + assertThat(response.stores().get(1).subtotal()).isEqualByComparingTo("100.00"); + assertThat(response.stores().get(2).subtotal()).isNull(); + assertThat(response.grandTotal()).isEqualByComparingTo("100.00"); + } +}