diff --git a/docs/ngp-performance-api.md b/docs/ngp-performance-api.md index 36db4fd..ef0496c 100644 --- a/docs/ngp-performance-api.md +++ b/docs/ngp-performance-api.md @@ -14,8 +14,13 @@ - `startDate`、`endDate` 必填,包含首尾日期,最多 31 天。 - `storeName` 选填;不传或传空字符串时查询全部网点。多个网点可用逗号分隔。 -- 后端调用 NGP 时固定传入 `postState=800`(财务核算成功)和 - `refundStatus=0`(无需退款),调用方不需要传这两个参数,也不能覆盖。 +- 后端通过 `ngp.api.bill.getSaleOrderList` 查询业绩,并固定传入 + `postStateList=[800]`(财务核算成功)和 `refundType=0`(无需退款)。 + 调用方不需要传这两个参数,也不能覆盖。 +- 该 NGP 方法需要在开放平台为当前应用开通“往来单位数据授权”。若未授权, + 接口会直接返回 NGP 的授权错误,不会降级为未筛选的全量数据。 +- 业绩缓存记录固定筛选规则的版本。首次部署本规则后,旧的“全部状态”缓存会自动失效并 + 重新拉取,无需手工清空缓存表;月度每周统计也会使用同一份已筛选缓存。 - NGP 每页最多查询 200 条;服务会读取全部页面后返回合计。 ## 响应 diff --git a/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java b/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java index e25ef55..8239c11 100644 --- a/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java +++ b/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java @@ -27,8 +27,9 @@ import java.util.concurrent.atomic.AtomicLong; @Component public class NgpApiClient { static final String SALES_POST_BILL_LIST = "ngp.bill.salepostbill.list"; + static final String PERFORMANCE_SALE_ORDER_LIST = "ngp.api.bill.getSaleOrderList"; static final int FINANCIAL_ACCOUNTING_SUCCESS = 800; - static final int NO_REFUND = 0; + static final int NO_REFUND_TYPE = 0; private final FoundationProperties.Ngp config; private final NgpTokenProvider tokenProvider; @@ -62,6 +63,11 @@ public class NgpApiClient { return call(SALES_POST_BILL_LIST, salesPostBillRequestBody(beginTime, endTime, pageIndex)); } + public JsonNode performanceBillPage(String beginTime, String endTime, int pageIndex) { + return call(PERFORMANCE_SALE_ORDER_LIST, + performanceBillRequestBody(beginTime, endTime, pageIndex)); + } + Map salesPostBillRequestBody(String beginTime, String endTime, int pageIndex) { Map body = new LinkedHashMap<>(); body.put("pageSize", Math.min(Math.max(config.getPageSize(), 1), 200)); @@ -69,8 +75,18 @@ public class NgpApiClient { body.put("vchtypes", List.of(2000)); body.put("beginTime", beginTime); body.put("endTime", endTime); - body.put("postState", FINANCIAL_ACCOUNTING_SUCCESS); - body.put("refundStatus", NO_REFUND); + return body; + } + + Map performanceBillRequestBody(String beginTime, String endTime, int pageIndex) { + Map body = new LinkedHashMap<>(); + body.put("pageSize", Math.min(Math.max(config.getPageSize(), 1), 200)); + body.put("pageIndex", pageIndex); + body.put("startTime", beginTime); + body.put("endTime", endTime); + body.put("vchtypes", List.of(2000)); + body.put("postStateList", List.of(FINANCIAL_ACCOUNTING_SUCCESS)); + body.put("refundType", NO_REFUND_TYPE); return body; } 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 f72f599..fc8ad85 100644 --- a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java +++ b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java @@ -17,6 +17,8 @@ import java.util.Optional; @Repository public class NgpPerformanceCacheRepository { + static final String FILTER_SIGNATURE = "sale-order-post-800-refund-type-0-v1"; + private final JdbcTemplate jdbcTemplate; public NgpPerformanceCacheRepository(JdbcTemplate jdbcTemplate) { @@ -39,16 +41,23 @@ public class NgpPerformanceCacheRepository { jdbcTemplate.execute(""" CREATE TABLE IF NOT EXISTS ngp_performance_daily_status ( business_date DATE NOT NULL PRIMARY KEY, - refreshed_at DATETIME(3) NOT NULL + refreshed_at DATETIME(3) NOT NULL, + filter_signature VARCHAR(64) NOT NULL DEFAULT '' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """); + ensureFilterSignatureColumn(); } public Optional findRefreshTime(LocalDate date) { List rows = jdbcTemplate.query( - "SELECT refreshed_at FROM ngp_performance_daily_status WHERE business_date = ?", + """ + SELECT refreshed_at + FROM ngp_performance_daily_status + WHERE business_date = ? AND filter_signature = ? + """, (resultSet, rowNum) -> resultSet.getTimestamp("refreshed_at").toLocalDateTime(), - date + date, + FILTER_SIGNATURE ); return rows.stream().findFirst(); } @@ -111,9 +120,27 @@ public class NgpPerformanceCacheRepository { }); } jdbcTemplate.update(""" - INSERT INTO ngp_performance_daily_status (business_date, refreshed_at) - VALUES (?, ?) - ON DUPLICATE KEY UPDATE refreshed_at = VALUES(refreshed_at) - """, date, Timestamp.valueOf(refreshedAt)); + INSERT INTO ngp_performance_daily_status (business_date, refreshed_at, filter_signature) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + refreshed_at = VALUES(refreshed_at), + filter_signature = VALUES(filter_signature) + """, date, Timestamp.valueOf(refreshedAt), FILTER_SIGNATURE); + } + + private void ensureFilterSignatureColumn() { + Integer columnCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ngp_performance_daily_status' + AND COLUMN_NAME = 'filter_signature' + """, Integer.class); + if (columnCount == null || columnCount == 0) { + jdbcTemplate.execute(""" + ALTER TABLE ngp_performance_daily_status + ADD COLUMN filter_signature VARCHAR(64) NOT NULL DEFAULT '' + """); + } } } 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 4f33e48..8ef7085 100644 --- a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java +++ b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java @@ -50,7 +50,7 @@ public class NgpPerformanceDayLoader { int concurrency = Math.min(Math.max(config.getConcurrency(), 1), 100); Aggregate aggregate = new Aggregate(); - JsonNode firstPage = apiClient.salesPostBillPage(beginTime, endTime, 1); + JsonNode firstPage = apiClient.performanceBillPage(beginTime, endTime, 1); collect(firstPage, 1, aggregate); long observedTotal = extractTotal(firstPage); int totalPages = Math.max(1, pages(observedTotal, pageSize)); @@ -61,7 +61,7 @@ public class NgpPerformanceDayLoader { for (int page = batchStart; page <= batchEnd; page++) { int pageIndex = page; futures.add(new PageFuture(pageIndex, CompletableFuture.supplyAsync( - () -> apiClient.salesPostBillPage(beginTime, endTime, pageIndex), queryExecutor))); + () -> apiClient.performanceBillPage(beginTime, endTime, pageIndex), queryExecutor))); } for (PageFuture future : futures) { try { diff --git a/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java b/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java index 148c0f8..5d39aaa 100644 --- a/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java +++ b/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import org.springframework.web.reactive.function.client.WebClient; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -38,12 +39,16 @@ class NgpApiClientTest { WebClient.builder() ); - Map body = client.salesPostBillRequestBody( + Map body = client.performanceBillRequestBody( "2026-09-01 00:00:00", "2026-09-01 23:59:59", 3); assertThat(body) - .containsEntry("postState", 800) - .containsEntry("refundStatus", 0) - .containsEntry("pageIndex", 3); + .containsEntry("postStateList", List.of(800)) + .containsEntry("refundType", 0) + .containsEntry("vchtypes", List.of(2000)) + .containsEntry("startTime", "2026-09-01 00:00:00") + .containsEntry("endTime", "2026-09-01 23:59:59") + .containsEntry("pageIndex", 3) + .doesNotContainKeys("postState", "refundStatus"); } } diff --git a/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepositoryTest.java b/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepositoryTest.java new file mode 100644 index 0000000..3b9a8b9 --- /dev/null +++ b/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepositoryTest.java @@ -0,0 +1,40 @@ +package com.company.agentfoundation.ngp.performance; + +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class NgpPerformanceCacheRepositoryTest { + @Test + @SuppressWarnings("unchecked") + void ignoresCacheCreatedWithAnOlderFilterPolicy() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + when(jdbcTemplate.query( + contains("filter_signature = ?"), + any(RowMapper.class), + any(), + any() + )).thenReturn(List.of()); + NgpPerformanceCacheRepository repository = new NgpPerformanceCacheRepository(jdbcTemplate); + LocalDate date = LocalDate.of(2026, 9, 1); + + repository.findRefreshTime(date); + + verify(jdbcTemplate).query( + contains("filter_signature = ?"), + any(RowMapper.class), + eq(date), + eq(NgpPerformanceCacheRepository.FILTER_SIGNATURE) + ); + } +} diff --git a/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoaderTest.java b/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoaderTest.java index 682633e..7fc44be 100644 --- a/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoaderTest.java +++ b/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoaderTest.java @@ -33,29 +33,28 @@ class NgpPerformanceDayLoaderTest { @Test void fetchesEveryPageDeduplicatesBillsAndTotalsByStore() throws Exception { - when(apiClient.salesPostBillPage(anyString(), anyString(), org.mockito.ArgumentMatchers.anyInt())) + when(apiClient.performanceBillPage(anyString(), anyString(), org.mockito.ArgumentMatchers.anyInt())) .thenAnswer(invocation -> pages().get(invocation.getArgument(2, Integer.class))); Map result = loader.load(LocalDate.of(2026, 9, 1)); assertThat(result.get(NgpPerformanceDayLoader.normalize("兰教练(抖店)")).totalCents()).isEqualTo(2195); assertThat(result.get(NgpPerformanceDayLoader.normalize("另一个网点")).totalCents()).isEqualTo(200); - verify(apiClient, times(3)).salesPostBillPage(anyString(), anyString(), org.mockito.ArgumentMatchers.anyInt()); + verify(apiClient, times(3)).performanceBillPage(anyString(), anyString(), org.mockito.ArgumentMatchers.anyInt()); } private Map pages() throws Exception { return Map.of( 1, objectMapper.readTree(""" {"code":0,"data":{"total":5,"list":[ - {"vchcode":"A","ofullname":"兰教练(抖店)","detailList":[ - {"currencyDisedTaxedTotal":"6.15"},{"currencyDisedTotal":"5.80"}]}, - {"vchcode":"B","ofullname":"兰教练(抖店)","currencyBillTotal":"10.00","detailList":[]} + {"vchcode":"A","ofullname":"兰教练(抖店)","currencyBillTotal":"11.95"}, + {"vchcode":"B","ofullname":"兰教练(抖店)","currencyBillTotal":"10.00"} ]}} """), 2, objectMapper.readTree(""" {"code":0,"data":{"total":5,"list":[ - {"vchcode":"B","ofullname":"兰教练(抖店)","currencyBillTotal":"10.00","detailList":[]}, - {"vchcode":"C","ofullname":"另一个网点","detailList":[{"currencyTotal":"-2.00"}]} + {"vchcode":"B","ofullname":"兰教练(抖店)","currencyBillTotal":"10.00"}, + {"vchcode":"C","ofullname":"另一个网点","currencyBillTotal":"-2.00"} ]}} """), 3, objectMapper.readTree("""