周汇总

This commit is contained in:
tanglong 2026-09-09 10:03:40 +08:00
parent 5e8d919f0d
commit 5217a3e96c
17 changed files with 579 additions and 4 deletions

View File

@ -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"
}
```

View File

@ -53,4 +53,4 @@ NGP 的 AppKey、AppSecret、access token 和 refresh token 位于 `application.
`foundation.ngp` 节点。access token 失效时服务会使用 refresh token 自动刷新,并在当前
进程内保存新 token。配置文件含明文凭据,只能保存在私有仓库并限制读取权限。
历史日期结果默认缓存 24 小时,包含今天的结果默认缓存 60 秒。
历史日期结果默认缓存 24 小时,包含今天的结果默认缓存 10 分钟。

View File

@ -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<String> 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<String> getMonthlyStoreNames() { return monthlyStoreNames; }
public void setMonthlyStoreNames(List<String> monthlyStoreNames) { this.monthlyStoreNames = monthlyStoreNames; }
}
public static class Semantic {

View File

@ -0,0 +1,11 @@
package com.company.agentfoundation.ngp.monthly;
import java.math.BigDecimal;
import java.util.List;
public record NgpMonthlyStoreRow(
String storeName,
List<BigDecimal> weeklyAmounts,
BigDecimal subtotal
) {
}

View File

@ -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
) {
}

View File

@ -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<NgpMonthlyWeeklyResult> 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);
}
}

View File

@ -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 {
}

View File

@ -0,0 +1,4 @@
package com.company.agentfoundation.ngp.monthly;
public record NgpMonthlyWeeklyRequest(String month) {
}

View File

@ -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<NgpMonthlyWeek> weeks,
List<NgpMonthlyStoreRow> stores,
List<BigDecimal> weekTotals,
BigDecimal grandTotal,
String formattedGrandTotal,
String currency
) implements NgpMonthlyWeeklyResult {
}

View File

@ -0,0 +1,5 @@
package com.company.agentfoundation.ngp.monthly;
public sealed interface NgpMonthlyWeeklyResult
permits NgpMonthlyWeeklyResponse, NgpMonthlyWeeklyPendingResponse {
}

View File

@ -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<LocalDate> 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<NgpMonthlyWeek> weeks = buildWeeks(month, today);
List<NgpDailyStoreTotal> dailyTotals = repository.findDailyTotals(monthStart, dataEnd);
return buildResponse(month, today, weeks, dailyTotals);
}
private NgpMonthlyWeeklyResponse buildResponse(
YearMonth month,
LocalDate today,
List<NgpMonthlyWeek> weeks,
List<NgpDailyStoreTotal> dailyTotals
) {
Map<String, StoreAccumulator> stores = new LinkedHashMap<>();
List<String> 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<NgpMonthlyStoreRow> 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<BigDecimal> 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<NgpMonthlyWeek> buildWeeks(YearMonth month, LocalDate today) {
List<NgpMonthlyWeek> 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<BigDecimal> amountColumns(long[] cents, List<NgpMonthlyWeek> weeks, LocalDate today) {
List<BigDecimal> 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<NgpMonthlyWeek> 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];
}
}
}

View File

@ -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
) {
}

View File

@ -72,6 +72,24 @@ public class NgpPerformanceCacheRepository {
return Map.copyOf(result);
}
public List<NgpDailyStoreTotal> 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<String, NgpStoreTotal> totals, LocalDateTime refreshedAt) {
jdbcTemplate.update("DELETE FROM ngp_performance_daily_total WHERE business_date = ?", date);

View File

@ -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);
}

View File

@ -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

View File

@ -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"));
}
}

View File

@ -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<List<LocalDate>> 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");
}
}