This commit is contained in:
tanglong 2026-09-18 13:31:39 +08:00
parent bcbdc79b6c
commit 2ba76809b3
7 changed files with 117 additions and 25 deletions

View File

@ -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 条;服务会读取全部页面后返回合计。
## 响应

View File

@ -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<String, Object> salesPostBillRequestBody(String beginTime, String endTime, int pageIndex) {
Map<String, Object> 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<String, Object> performanceBillRequestBody(String beginTime, String endTime, int pageIndex) {
Map<String, Object> 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;
}

View File

@ -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<LocalDateTime> findRefreshTime(LocalDate date) {
List<LocalDateTime> 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 ''
""");
}
}
}

View File

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

View File

@ -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<String, Object> body = client.salesPostBillRequestBody(
Map<String, Object> 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");
}
}

View File

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

View File

@ -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<String, NgpStoreTotal> 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<Integer, JsonNode> 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("""