From ce6963a25e87fcb9487adb23363f480f1794b835 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 12:50:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 +- .../finance/client/IamCustomerClient.java | 24 ++++ .../FinancePhase1OperationController.java | 16 ++- .../FinanceCustomerAccountService.java | 124 ++++++++++++++++++ .../FinancePhase1OperationService.java | 6 +- .../FinanceCustomerAccountServiceTest.java | 53 ++++++++ .../FinancePhase1OperationBehaviorTest.java | 12 +- .../baiyue/firstleg/dto/ContainerDtos.java | 1 + .../baiyue/firstleg/dto/LoadingListDtos.java | 2 + .../firstleg/dto/TruckAppointmentDtos.java | 2 +- .../mapper/ContainerBookingMapper.java | 6 + .../mapper/LoadingListQueryMapper.java | 2 + .../mapper/TruckAppointmentMapper.java | 2 +- .../pricing/ActivePriceBookEstimator.java | 7 +- .../PriceBookWarehouseRegionResolver.java | 11 +- .../firstleg/service/ContainerService.java | 2 +- .../firstleg/service/FbaPricingService.java | 5 +- .../firstleg/service/LoadingListService.java | 4 +- .../resources/db/phase2-inbound-schema.sql | 2 +- .../resources/openapi/fba-pricing-v1.yaml | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 1 + .../resources/openapi/loading-lists-v1.yaml | 12 +- .../openapi/truck-appointments-v1.yaml | 2 + .../controller/FbaPricingContractTest.java | 2 + .../repository/LoadingListRepositoryTest.java | 4 +- .../repository/PriceBookSqlContractTest.java | 2 + .../service/FbaPricingServiceTest.java | 18 +++ .../service/LoadingListServiceTest.java | 8 +- .../service/MerchantPriceBookServiceTest.java | 22 +++- .../controller/InternalTenantController.java | 14 ++ .../controller/PlatformTenantController.java | 2 +- .../java/com/baiyue/iam/dto/TenantDtos.java | 4 + .../CustomerListExportProviderRepository.java | 9 +- .../iam/repository/TenantRepository.java | 27 +++- .../com/baiyue/iam/service/TenantService.java | 9 ++ iam/src/main/resources/openapi/iam-v1.yaml | 45 ++++++- .../InternalCustomerExportContractTest.java | 13 ++ ...tomerListExportProviderRepositoryTest.java | 2 +- .../baiyue/iam/service/TenantServiceTest.java | 18 +++ scripts/admin-test-env-acceptance.mjs | 17 +++ .../manual-state-matrix-contract.test.mjs | 65 +++++++++ .../manual-state-matrix.mjs | 102 +++++++++++++- ...ual-state-ocean-trucking-contract.test.mjs | 8 ++ .../manual-state-ocean-trucking.mjs | 7 +- ...-inbound-status-fixtures-contract.test.mjs | 6 +- .../order-inbound-status-fixtures.mjs | 32 +++-- 46 files changed, 664 insertions(+), 76 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index afdfb9ac4..8c9c13d1f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2910,7 +2910,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照', @@ -2962,6 +2962,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) ) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; +ALTER TABLE fl_inbound_fee + MODIFY COLUMN customer_id BIGINT UNSIGNED NULL + COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'; + CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java b/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java new file mode 100644 index 000000000..c0591cbe2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java @@ -0,0 +1,24 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "iam-service", contextId = "financeIamCustomerClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface IamCustomerClient { + @GetMapping("/finance-customers") + ApiResponse> page( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam int page, + @RequestParam int size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) Long customerId); + + record Customer(long id, String tenantCode, String tenantName, String status, + String contactName, String contactPhone) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java index a9fd3597f..1d2b5aff5 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java @@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest; import com.baiyue.finance.service.FinancePhase1OperationService; +import com.baiyue.finance.service.FinanceCustomerAccountService; +import org.springframework.beans.factory.annotation.Autowired; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -24,15 +26,23 @@ import java.time.LocalDate; @RequestMapping("/api/platform/finance/v1") public class FinancePhase1OperationController { private final FinancePhase1OperationService service; - public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; } + private final FinanceCustomerAccountService customerAccountService; + + @Autowired + public FinancePhase1OperationController(FinancePhase1OperationService service, + FinanceCustomerAccountService customerAccountService) { + this.service = service; + this.customerAccountService = customerAccountService; + } @GetMapping("/accounts") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String keyword, - @RequestParam(required = false) String status) { - return service.listDerivedCustomerAccounts(page, pageSize, keyword, status); + @RequestParam(required = false) String status, + @RequestParam(required = false) Long customerId) { + return customerAccountService.list(page, pageSize, keyword, status, customerId); } @GetMapping("/accounts/{customerId}/ledger") diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java new file mode 100644 index 000000000..55efe872a --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -0,0 +1,124 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class FinanceCustomerAccountService { + private final JdbcTemplate jdbc; + private final IamCustomerClient iamCustomerClient; + private final String internalToken; + + public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient, + @Value("${baiyue.security.internal-service-token:}") String internalToken) { + this.jdbc = jdbc; + this.iamCustomerClient = iamCustomerClient; + this.internalToken = internalToken; + } + + public PageEnvelope list(int page, int pageSize, String keyword, String status, Long customerId) { + validatePage(page, pageSize); + String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(); + if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) { + throw new FinanceEventException("FIN-REQ-400", "账户状态无效"); + } + ApiResponse> response; + try { + response = iamCustomerClient.page(internalToken, page, pageSize, blankToNull(keyword), normalizedStatus, customerId); + } catch (RuntimeException error) { + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + } + if (response == null || !response.successful() || response.data() == null) { + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + } + PageResult customers = response.data(); + Map metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList()); + List rows = customers.records().stream().map(customer -> { + Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO); + return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(), + customer.contactName(), customer.contactPhone(), value.outstandingReceivable(), + value.pendingBilling(), value.receivedInPeriod(), value.outstandingPayable(), + customer.status(), value.lastChangedAt()); + }).toList(); + return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total()); + } + + private Map loadMetrics(List customerIds) { + if (customerIds.isEmpty()) return Map.of(); + String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList()); + Map result = new HashMap<>(); + customerIds.forEach(id -> result.put(id, new MutableMetrics())); + jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_bill WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "outstandingReceivable", "amount")); + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version) GROUP BY customer_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "pendingBilling", "amount")); + jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "receivedInPeriod", "amount")); + jdbc.queryForList("SELECT tenant_id customer_id,SUM(cost_amount) amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "outstandingPayable", "amount")); + Object[] changeArgs = new Object[customerIds.size() * 3]; + for (int i = 0; i < 3; i++) System.arraycopy(customerIds.toArray(), 0, changeArgs, i * customerIds.size(), customerIds.size()); + jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", changeArgs) + .forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at")); + Map frozen = new HashMap<>(); + result.forEach((id, value) -> frozen.put(id, value.freeze())); + return frozen; + } + + private static void metric(Map result, Map row, String field, String valueKey) { + Object id = row.get("customer_id"); + if (!(id instanceof Number number)) return; + MutableMetrics target = result.get(number.longValue()); + if (target == null) return; + Object value = row.get(valueKey); + if ("lastChangedAt".equals(field)) { + target.lastChangedAt = value instanceof Timestamp timestamp ? timestamp.toLocalDateTime() + : value instanceof LocalDateTime dateTime ? dateTime : null; + } else if (value != null) { + target.set(field, value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString())); + } + } + + private static void validatePage(int page, int pageSize) { + if (page < 1 || pageSize < 1 || pageSize > 200) throw new FinanceEventException("FIN-REQ-400", "分页参数无效"); + } + + private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + + private static final class MutableMetrics { + private BigDecimal outstandingReceivable = BigDecimal.ZERO; + private BigDecimal pendingBilling = BigDecimal.ZERO; + private BigDecimal receivedInPeriod = BigDecimal.ZERO; + private BigDecimal outstandingPayable = BigDecimal.ZERO; + private LocalDateTime lastChangedAt; + void set(String field, BigDecimal value) { + switch (field) { + case "outstandingReceivable" -> outstandingReceivable = value; + case "pendingBilling" -> pendingBilling = value; + case "receivedInPeriod" -> receivedInPeriod = value; + case "outstandingPayable" -> outstandingPayable = value; + default -> { } + } + } + Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, outstandingPayable, lastChangedAt); } + } + + private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling, + BigDecimal receivedInPeriod, BigDecimal outstandingPayable, + LocalDateTime lastChangedAt) { + private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index bafab4e56..0cc8821b4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -40,12 +40,12 @@ public class FinancePhase1OperationService { this.objectMapper = objectMapper; } - public PageEnvelope listDerivedCustomerAccounts(int page, int pageSize, String keyword, String status) { + public PageEnvelope listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) { page = page(page); pageSize = pageSize(pageSize); String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(java.util.Locale.ROOT); if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) throw error("FIN-REQ-400", "账户状态无效"); - String filter = " WHERE (? IS NULL OR CAST(c.customer_id AS CHAR) LIKE CONCAT('%',?,'%') OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)"; - Object[] filterArgs = {blankToNull(keyword), blankToNull(keyword), blankToNull(keyword), normalizedStatus, normalizedStatus}; + String filter = " WHERE (? IS NULL OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)"; + Object[] filterArgs = {blankToNull(customerName), blankToNull(customerName), normalizedStatus, normalizedStatus}; String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter; Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs); String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," + diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java new file mode 100644 index 000000000..60e14fd13 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java @@ -0,0 +1,53 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; +import com.baiyue.finance.service.FinanceCustomerAccountService; +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceCustomerAccountServiceTest { + @Test + void listsMasterCustomersWithZeroFinanceMetricsAndForwardsSearchFields() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + IamCustomerClient iam = mock(IamCustomerClient.class); + when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of()); + when(iam.page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of( + new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")), + 1, 20, 1, 1))); + + var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token") + .list(1, 20, " Alice ", "active", 900000000000000001L); + + AccountSummary row = result.data().get(0); + assertThat(row.customerCode()).isEqualTo("TEN-001"); + assertThat(row.contactName()).isEqualTo("Alice"); + assertThat(row.contactPhone()).isEqualTo("13800138000"); + assertThat(row.outstandingReceivable()).isZero(); + assertThat(row.pendingBilling()).isZero(); + verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L); + } + + @Test + void rejectsUnsupportedFinanceAccountStatus() { + var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token"); + assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()) + .isEqualTo("FIN-REQ-400"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 26993080a..65f4a78f0 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -7,6 +7,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.service.FinanceEventException; import com.baiyue.finance.service.FinancePhase1OperationService; +import com.baiyue.finance.service.FinanceCustomerAccountService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -39,21 +40,22 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class FinancePhase1OperationBehaviorTest { @Mock FinancePhase1OperationService operationService; + @Mock FinanceCustomerAccountService customerAccountService; @Test void controllerDelegatesFiveFrozenOperationsAndUsesAuthenticatedOperator() { - FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService); + FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService, customerAccountService); AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of()); var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0); var inclusion = new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入本期结算", 1); - controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE"); + controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L); controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31)); controller.getFinanceFeeSource(88L); controller.reviewFinanceFeeSource(user, 88L, "review-key", review); controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion); - verify(operationService).listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE"); + verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L); verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any()); verify(operationService).getFinanceFeeSource(88L); verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review); @@ -93,6 +95,8 @@ class FinancePhase1OperationBehaviorTest { assertThat(result.page().total()).isEqualTo(1); assertThat(result.requestId()).isNotBlank(); assertThat(result.traceId()).isNotBlank(); + assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot"); + assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)"); } @Test @@ -234,6 +238,7 @@ class FinancePhase1OperationBehaviorTest { private static final class AccountJdbc extends JdbcTemplate { private final List ledgerCustomerIds = new ArrayList<>(); + private String accountListSql = ""; @Override public T queryForObject(String sql, Class requiredType, Object... args) { @@ -245,6 +250,7 @@ class FinancePhase1OperationBehaviorTest { @SuppressWarnings("unchecked") public List query(String sql, RowMapper rowMapper, Object... args) { if (sql.startsWith("SELECT c.customer_id")) { + accountListSql = sql; return (List) List.of(new FinancePhase1Dtos.AccountSummary( 9L, "9", "客户9", null, null, new BigDecimal("100.00"), new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 39b899631..1c6b42a8a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -25,6 +25,7 @@ public final class ContainerDtos { @Size(max = 64) String cabinetNo, @Size(max = 128) String billOfLadingNo, @Size(max = 128) String keyword, + @Positive Long customerId, @Positive Long warehouseId, @Size(max = 32) String containerType, @Positive Long shippingCompanyId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index f72262371..6b68abf4a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -18,6 +18,7 @@ public final class LoadingListDtos { public record PreloadQuery( @Size(max = 128) String keyword, String status, + @Positive Long customerId, @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @@ -32,6 +33,7 @@ public final class LoadingListDtos { public record ConfirmedQuery( @Size(max = 128) String keyword, + @Positive Long customerId, @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index a51c8ab17..dcffc7ff4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} - public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } + public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long customerId, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@Positive Long customerId,@Size(max=128) String customerName,@Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} public record AttachmentRequest(@NotNull @Positive Long fileId,@Size(max=64) String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index b13344e11..37b000929 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -24,6 +24,12 @@ public interface ContainerBookingMapper extends BaseMapperAND p.plan_status = #{query.status} + AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId}) AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') @@ -74,6 +75,7 @@ public interface LoadingListQueryMapper { JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 WHERE p.plan_status = 'COMPLETED' + AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId}) AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 538c8ebd7..76f34210b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; @Mapper public interface TruckAppointmentMapper extends BaseMapper{ - @Select("") Page page(Page p,@Param("q") Query q); + @Select("") Page page(Page p,@Param("q") Query q); @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index 77a3b8a0c..e1733566e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -73,8 +73,9 @@ public class ActivePriceBookEstimator { } private Estimate estimateRegion(long versionId, String channelCode, EstimateRequest request) { - if (request.destinationType() != PricingDestinationType.FBA) { - throw validation("海派渠道仅支持FBA目的仓参考估价"); + if (request.destinationType() != PricingDestinationType.FBA + && request.destinationType() != PricingDestinationType.OVERSEAS) { + throw validation("海派渠道仅支持平台FBA仓或海外中转仓参考估价"); } if (request.pricingTaxMode() == null) { throw validation("请选择包税或不包税报价"); @@ -95,7 +96,7 @@ public class ActivePriceBookEstimator { private PriceRegion resolveWarehouseRegion(Long warehouseId) { if (warehouseId == null || warehouseId <= 0) { - throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域"); + throw validation("请提供目的仓区域、平台FBA仓或海外中转仓,以确定海派区域"); } return warehouseRegionResolver.resolve(warehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java index 7123a2a3c..2d0189243 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java @@ -11,7 +11,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -/** Resolves a platform FBA warehouse to the fixed sea-parcel price region. */ +/** Resolves a platform destination warehouse to the fixed sea-parcel price region. */ @Component public class PriceBookWarehouseRegionResolver { @Resource private PriceBookWarehouseClient warehouses; @@ -20,7 +20,7 @@ public class PriceBookWarehouseRegionResolver { public PriceRegion resolve(long warehouseId) { if (warehouseId <= 0) { - throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域"); + throw validation("请提供目的仓区域、平台FBA仓或海外中转仓,以确定海派区域"); } try { ApiResponse response = warehouses.active(internalToken, 0L, warehouseId); @@ -28,12 +28,13 @@ public class PriceBookWarehouseRegionResolver { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用"); } if (!response.successful() || response.data() == null || response.data().warehouse() == null) { - throw validation("目的仓不是可用的平台FBA仓"); + throw validation("目的仓不是可用的平台目的仓"); } var warehouse = response.data().warehouse(); if (!Long.valueOf(warehouseId).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId()) - || !"FBA".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) { - throw validation("目的仓不是可用的平台FBA仓"); + || !("FBA".equals(warehouse.warehouseType()) || "OVERSEAS".equals(warehouse.warehouseType())) + || !"ACTIVE".equals(warehouse.status())) { + throw validation("目的仓不是可用的平台FBA仓或海外中转仓"); } String postalCode = response.data().addresses() == null ? null : response.data().addresses().stream() .filter(address -> address != null && StringUtils.hasText(address.postalCode())) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index e9954f131..c3657cab2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -191,7 +191,7 @@ public class ContainerService { } private static Query normalize(Query q) { return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()), - q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()), + q.customerId(), q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()), trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(), q.estimatedSailingTo(), q.page(), q.size()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 32474bab7..92885d7ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -227,9 +227,8 @@ public class FbaPricingService { || inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) { throw conflict("审核通过的测量快照必须包含大于零的实重和体积"); } - if (inbound.getCustomerId() == null || inbound.getWarehouseId() == null - || !StringUtils.hasText(inbound.getChannelCode())) { - throw conflict("入库单缺少客户、起运仓或渠道匹配维度"); + if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) { + throw conflict("入库单缺少起运仓或渠道匹配维度"); } return inbound; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index c70c01d36..11881909a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -71,13 +71,13 @@ public class LoadingListService { if (StringUtils.hasText(status) && !PRELOAD_STATUSES.contains(status)) { throw badRequest("预装清单状态只能是 EDITING 或 LOADING"); } - return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, + return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.customerId(), query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); } public PageResult confirmedPage(ConfirmedQuery query) { validateRange(query.createdFrom(), query.createdTo()); - return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.warehouseId(), + return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.customerId(), query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); } diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index bb9bc4393..0eb74c458 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -566,7 +566,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 694f8417b..2f37808a3 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -130,7 +130,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/calculate: post: summary: 按审核通过测量快照试算 FBA 费用 - description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId 或客户计费规则作为前置。海派还需按FBA区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 + description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId、业务 customerId 或客户计费规则作为前置。区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index a1c6d1b88..e5fbf5b7e 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -205,6 +205,7 @@ paths: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {name: keyword, in: query, schema: {type: string, maxLength: 128}} + - {name: customerId, in: query, description: 仅返回包含该客户货物的柜次, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: containerNo, in: query, schema: {type: string, maxLength: 64}} - {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}} - {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 23c3b10a1..abc037a61 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -7,12 +7,13 @@ paths: /api/platform/v1/first-leg/loading-plans: get: summary: 分页查询预装清单 - description: 默认返回全部仓库的装柜计划;可按仓库筛选,客户账号仅用于展示货物归属。 + description: 默认返回全部仓库的装柜计划;可按客户和仓库筛选。 x-permission: platform:firstleg:preload:list parameters: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Keyword'} + - {$ref: '#/components/parameters/CustomerId'} - name: status in: query description: 预装状态 @@ -49,12 +50,13 @@ paths: /api/platform/v1/first-leg/loading-lists: get: summary: 分页查询装柜清单 - description: 默认返回全部仓库的 COMPLETED 装柜计划;可按仓库筛选,包含全部客户单据。 + description: 默认返回全部仓库的 COMPLETED 装柜计划;可按客户和仓库筛选。 x-permission: platform:firstleg:loading-list:list parameters: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Keyword'} + - {$ref: '#/components/parameters/CustomerId'} - {$ref: '#/components/parameters/OptionalWarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} @@ -138,6 +140,12 @@ components: in: query description: 按预装单号、集装箱号或柜号模糊查询 schema: {type: string, maxLength: 128} + CustomerId: + name: customerId + in: query + required: false + description: 仅返回包含该客户货物的清单 + schema: {$ref: '#/components/schemas/LongId'} WarehouseId: name: warehouseId in: query diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 04c6ec185..1ea424e01 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -6,6 +6,8 @@ paths: /api/platform/v1/truck-appointments: get: summary: Query truck appointments + parameters: + - {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: '200': {description: Paged truck appointments} post: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index caa9d096f..9fa935393 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -49,6 +49,8 @@ class FbaPricingContractTest { .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") .contains("Idempotency-Key") .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态") + .contains("区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域") + .contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置") .contains("该动作涉及后续仓储流程,本轮二期验收不调用") .contains("不回退 WAIT_SPLIT 状态") .doesNotContain("应收") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java index 86132d2aa..0052368b6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -51,9 +51,9 @@ class LoadingListRepositoryTest { confirmedPage.setTotal(1); when(mapper.selectConfirmedPage(any(), any())).thenReturn(confirmedPage); - var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, 1, 20)) + var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, null, 1, 20)) .records().get(0); - var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20)) + var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20)) .records().get(0); assertThat(preload.planId()).isEqualTo(41L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java index 3ef7e2a0f..878d7df0a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java @@ -42,6 +42,8 @@ class PriceBookSqlContractTest { .contains("destination_name VARCHAR(128)") .contains("parse_issues_json JSON NULL") .contains("DEFAULT 'PENDING_CONFIRMATION'"); + assertThat(schema) + .contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'"); assertThat(schema) .contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001") .contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 40699bec3..e38f3be1a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -92,6 +92,24 @@ class FbaPricingServiceTest { assertThat(fee.getValue().getCarrierId()).isNull(); } + @Test + @DisplayName("平台公共渠道计费允许业务客户快照为空") + void calculatesWithoutOptionalBusinessCustomerSnapshot() { + readyRepository(); + InboundPricingEntity inbound = inbound("200.000", "0.2000"); + inbound.setCustomerId(null); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(any())).thenReturn( + estimate("KG", "200.000", "14.300000", "2860.00", "0.00")); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer"); + + assertThat(response.totalAmount()).isEqualByComparingTo("2860.00"); + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getCustomerId()).isNull(); + } + @Test @DisplayName("不包税CBM使用生效价格表返回的计费量") void calculatesTaxExcludedCbmFromActivePriceBook() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 071387fa7..080e3632d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -61,7 +61,7 @@ class LoadingListServiceTest { @Test void shouldAcceptEditingStatusFromPreloadList() { - PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20); + PreloadQuery query = new PreloadQuery(null, "EDITING", null, 100L, null, null, 1, 20); service.preloadPage(query); @@ -184,7 +184,7 @@ class LoadingListServiceTest { @Test void confirmedPageAlwaysUsesRepositoryConfirmedProjection() { - ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, null, null, 1, 20); + ConfirmedQuery query = new ConfirmedQuery(" plan ", null, 100L, null, null, 1, 20); when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( new PageResult<>(List.of(), 1, 20, 0, 0)); @@ -201,8 +201,8 @@ class LoadingListServiceTest { when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( new PageResult<>(List.of(), 1, 20, 0, 0)); - service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, 1, 20)); - service.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20)); + service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, null, 1, 20)); + service.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20)); verify(repository).preloadPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index ad4d849dd..c41872e2b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -187,6 +187,26 @@ class MerchantPriceBookServiceTest { verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED"); } + @Test + void shouldResolveTierRegionFromPlatformOverseasWarehousePostalCode() { + stubChannel(8L, "MATSON_MAX_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED")) + .thenReturn(tiers()); + var warehouse = new PriceBookWarehouseClient.Warehouse(901L, 0L, "US-LAX-01", "洛杉矶仓", + "OVERSEAS", "US", "ACTIVE"); + var address = new PriceBookWarehouseClient.Address("US", "CA", "Los Angeles", null, + "90001", "address", null); + when(warehouses.active("internal-token", 0L, 901L)) + .thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address)))); + + var result = service.estimate(10001L, + request("MATSON_MAX_PARCEL", PricingDestinationType.OVERSEAS, null, 901L, null, + PricingTaxMode.TAX_INCLUDED, "51", null)); + + assertThat(result.unitPrice()).isEqualByComparingTo("13"); + verify(repository).regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED"); + } + @Test void shouldRejectMissingRequiredEstimateFields() { stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); @@ -206,7 +226,7 @@ class MerchantPriceBookServiceTest { request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null, PricingTaxMode.TAX_INCLUDED, "51", null))) .isInstanceOf(BizException.class) - .hasMessageContaining("目的仓区域或平台FBA仓"); + .hasMessageContaining("目的仓区域"); } @Test diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index e7def5792..bcc7206ba 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -16,6 +16,8 @@ import com.baiyue.iam.service.MerchantOnboardingService; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.TenantService; import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; @@ -106,6 +108,18 @@ public class InternalTenantController { return ApiResponse.success(tenantService.orderEligibility(tenantId)); } + @GetMapping("/finance-customers") + public ApiResponse> financeCustomers( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @Pattern(regexp = "ACTIVE|DISABLED") String status, + @RequestParam(required = false) Long customerId) { + verify(token); + return ApiResponse.success(tenantService.pageFinanceCustomers(page, size, keyword, status, customerId)); + } + @GetMapping("/exports/customers") public ApiResponse exportCustomers( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 0bf0ca592..4140dd770 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -49,7 +49,7 @@ public class PlatformTenantController { @GetMapping("/tenants") @PreAuthorize("hasAuthority('platform:customer:list')") - @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。") + @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持客户名称和状态筛选。客户编号、联系人等仅作展示信息,不作为搜索条件。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"), diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 728925b1f..bc74467f3 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -124,4 +124,8 @@ public final class TenantDtos { LocalDate establishedDate, BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress, String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { } + + /** Full customer contact summary for trusted internal finance read models. */ + public record FinanceCustomerSummary(Long id, String tenantCode, String tenantName, String status, + String contactName, String contactPhone) { } } diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java index 97360fc86..ae4f2d17e 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java @@ -44,14 +44,7 @@ public class CustomerListExportProviderRepository { .eq(IamTenant::getTenantType, "MERCHANT") .eq(StringUtils.hasText(status), IamTenant::getStatus, status); if (StringUtils.hasText(keyword)) { - wrapper.and(condition -> condition - .like(IamTenant::getTenantCode, keyword) - .or().like(IamTenant::getTenantName, keyword) - .or().like(IamTenant::getLegalName, keyword) - .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) - .or().like(IamTenant::getContactName, keyword) - .or().like(IamTenant::getContactPhone, keyword) - .or().like(IamTenant::getSalesOwnerName, keyword)); + wrapper.like(IamTenant::getTenantName, keyword); } return wrapper; } diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index f023f730b..a5257de94 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -91,15 +91,28 @@ public class TenantRepository { .eq(IamTenant::getTenantType, "MERCHANT") .eq(StringUtils.hasText(status), IamTenant::getStatus, status) .orderByDesc(IamTenant::getCreatedAt); + if (StringUtils.hasText(keyword)) { + wrapper.like(IamTenant::getTenantName, keyword); + } + return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); + } + + public PageResult pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .eq(customerId != null, IamTenant::getId, customerId) + .in(!StringUtils.hasText(status), IamTenant::getStatus, List.of("ACTIVE", "DISABLED")) + .eq(StringUtils.hasText(status), IamTenant::getStatus, status) + .orderByDesc(IamTenant::getCreatedAt); if (StringUtils.hasText(keyword)) { wrapper.and(condition -> condition - .like(IamTenant::getTenantCode, keyword) - .or().like(IamTenant::getTenantName, keyword) - .or().like(IamTenant::getLegalName, keyword) - .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) - .or().like(IamTenant::getContactName, keyword) - .or().like(IamTenant::getContactPhone, keyword) - .or().like(IamTenant::getSalesOwnerName, keyword)); + .like(IamTenant::getTenantCode, keyword) + .or().like(IamTenant::getTenantName, keyword) + .or().like(IamTenant::getLegalName, keyword) + .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) + .or().like(IamTenant::getContactName, keyword) + .or().like(IamTenant::getContactPhone, keyword) + .or().like(IamTenant::getSalesOwnerName, keyword)); } return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index e84f24792..aa3bce84a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -21,6 +21,7 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.TenantResponse; +import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; @@ -198,6 +199,14 @@ public class TenantService { source.page(), source.size(), source.total(), source.pages()); } + public PageResult pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { + PageResult source = repository.pageFinanceCustomers(page, size, keyword, status, customerId); + return new PageResult<>(source.records().stream().map(tenant -> new FinanceCustomerSummary( + tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getContactName(), tenant.getContactPhone())).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + public CustomerExportBatch exportMerchants(Long cursor, int limit, String keyword, String status) { int boundedLimit = Math.min(Math.max(limit, 1), 500); List source = repository.exportMerchants(cursor, boundedLimit, keyword, status); diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 10709a991..00fccfde0 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -44,7 +44,7 @@ paths: parameters: - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} - - {name: keyword, in: query, required: false, schema: {type: string}, description: 企业编码、名称、信用代码或联系人关键字} + - {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索} - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}} responses: '200': @@ -740,6 +740,39 @@ paths: application/json: schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} + /internal/iam/finance-customers: + get: + tags: [IAM Resources / IAM 资源] + summary: 提供财务客户主数据摘要 + description: 仅供 Finance Service 调用;只返回正常/停用客户及财务账户所需的客户编号、名称和联系方式,不依赖财务表是否已有记录。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, required: false, schema: {type: string}} + - {name: status, in: query, required: false, schema: {type: string, enum: [ACTIVE, DISABLED]}} + - {name: customerId, in: query, required: false, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: 财务客户主数据分页摘要 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/FinanceCustomerSummary'}} + page: {type: integer} + size: {type: integer} + total: {type: integer, format: int64} + pages: {type: integer} + '403': {$ref: '#/components/responses/Forbidden'} /internal/exports/v1/CUSTOMER_LIST/batches: post: tags: [Internal Export Provider] @@ -971,6 +1004,16 @@ components: internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见} createdAt: {type: string, format: date-time, description: 入驻时间} approvedAt: {type: string, format: date-time, description: 审批通过时间} + FinanceCustomerSummary: + type: object + required: [id, tenantCode, tenantName, status] + properties: + id: {type: string, pattern: '^\d+$'} + tenantCode: {type: string} + tenantName: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + contactName: {type: string, nullable: true} + contactPhone: {type: string, nullable: true} TenantAddressListApiResponse: allOf: - {$ref: '#/components/schemas/ApiResponse'} diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java index 97345fce8..ca0d0306c 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java @@ -40,4 +40,17 @@ class InternalCustomerExportContractTest { assertEquals(ResultCode.SUCCESS.code(), response.code()); verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE"); } + + @Test + void exposesFinanceCustomerMasterDataOnlyWithInternalToken() { + BizException denied = assertThrows(BizException.class, + () -> controller.financeCustomers(null, 1, 20, "Alice", "ACTIVE")); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + + when(service.pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null)) + .thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0)); + var response = controller.financeCustomers("test-internal-service-token-at-least-32-bytes", 1, 20, "Alice", "ACTIVE"); + assertEquals(ResultCode.SUCCESS.code(), response.code()); + verify(service).pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null); + } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java index e97c8f356..0205e4283 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java @@ -40,8 +40,8 @@ class CustomerListExportProviderRepositoryTest { assertThat(query.getSqlSegment()) .contains("tenant_type") .contains("status") - .contains("tenant_code") .contains("tenant_name") + .doesNotContain("tenant_code") .contains("id >") .contains("id <=") .contains("ORDER BY id ASC") diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 4b274ddec..37eacb10d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -93,6 +93,24 @@ class TenantServiceTest { verify(repository).pageMerchants(1, 10, "Example", "ACTIVE"); } + @Test + void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setTenantCode("TEN10001"); + tenant.setTenantName("Example Merchant"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + when(repository.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null)) + .thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1)); + + var result = service.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); + + assertEquals("TEN10001", result.records().get(0).tenantCode()); + assertEquals("Alice", result.records().get(0).contactName()); + assertEquals("13800138000", result.records().get(0).contactPhone()); + verify(repository).pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); + } + @Test void platformCanMaintainAndDisableOnlyMerchantTenant() { IamTenant tenant = merchant(10001L, "ACTIVE"); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 2e692db46..361582d2b 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -16,6 +16,23 @@ const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'over export function normalizeModuleOrder(modules) { const normalized = [...modules] + const manualMatrixIndex = normalized.indexOf('manual-state-matrix') + if (manualMatrixIndex >= 0) { + const internalGroupIndex = normalized.indexOf('order-inbound-status-fixtures') + if (internalGroupIndex >= 0) normalized.splice(internalGroupIndex, 1) + const prerequisites = [ + 'merchant-qualification-fixture', + 'phase3-container-loading', + 'phase3-loading', + 'phase3-customs', + 'phase3-loading-finalize', + ] + for (const prerequisite of prerequisites) { + if (!normalized.includes(prerequisite)) { + normalized.splice(normalized.indexOf('manual-state-matrix'), 0, prerequisite) + } + } + } if (normalized.includes('fee-items')) { const prerequisites = ['merchant-qualification-fixture', 'orders'] const feeItemsIndex = normalized.indexOf('fee-items') diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 4633d86a8..8f0f66925 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { missingRequiredStateFixtures, normalizeStateRequirements } from './manual-state-matrix.mjs' +import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./manual-state-matrix.mjs', import.meta.url), 'utf8') @@ -11,14 +13,77 @@ test('default full acceptance includes the retained manual state matrix after li assert.ok(entry.indexOf("'manual-state-matrix'") < entry.indexOf("'finance-full-fixtures'")) }) +test('focused matrix execution injects real container lifecycle prerequisites and removes its internal group', () => { + const expected = [ + 'merchant-qualification-fixture', + 'phase3-container-loading', + 'phase3-loading', + 'phase3-customs', + 'phase3-loading-finalize', + 'manual-state-matrix', + ] + assert.deepEqual(normalizeModuleOrder(['order-inbound-status-fixtures', 'manual-state-matrix']), expected) + assert.deepEqual(parseArguments([ + '--modules', 'order-inbound-status-fixtures,manual-state-matrix', + ]).modules, expected) +}) + test('matrix delegates bounded groups and asserts every declared status has a retained id', () => { for (const group of ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking']) assert.match(source, new RegExp(group)) assert.doesNotMatch(source, /manual-state-customs-dispatch/) assert.match(source, /createdResources\.manualStateMatrix/) + assert.match(source, /supportedStatuses/) assert.match(source, /requiredStatuses/) assert.match(source, /fixtures\?\.\[status\]\?\.id/) assert.match(source, /requiredActions/) assert.match(source, /actions\?\.\[action\]\?\.documentId/) assert.match(source, /actions\?\.\[action\]\?\.completedAt/) }) + +test('fixture-unavailable statuses stay supported without becoming hard requirements', () => { + const matrix = { pages: { + inbounds: { requiredStatuses: ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'], fixtures: { + WAIT_MEASURE: { id: '1' }, + } }, + unifiedOrders: { requiredStatuses: ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'], fixtures: { + WAIT_SPLIT: { id: '2' }, LOADING: { id: '3' }, LOADED: { id: '4' }, + } }, + } } + + normalizeStateRequirements(matrix) + + assert.deepEqual(matrix.pages.inbounds.supportedStatuses, + ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) + assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE']) + assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED']) + assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable') + assert.equal(matrix.pages.unifiedOrders.coverageGaps.DELIVERED.category, 'fixture-unavailable') + assert.deepEqual(missingRequiredStateFixtures(matrix), []) +}) + +test('unknown missing status is never swallowed by the known coverage-gap allowlist', () => { + const matrix = { pages: { + unifiedOrders: { supportedStatuses: ['WAIT_SPLIT', 'UNKNOWN_FUTURE_STATUS'], requiredStatuses: [], fixtures: { + WAIT_SPLIT: { id: '1' }, + } }, + } } + + normalizeStateRequirements(matrix) + + assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'UNKNOWN_FUTURE_STATUS']) + assert.deepEqual(missingRequiredStateFixtures(matrix), ['unifiedOrders:UNKNOWN_FUTURE_STATUS']) +}) + +test('LOADING and LOADED fixtures are resolved through inbound and unified-order details', () => { + assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3ContainerLoading', 'LOADING'\)/) + assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3Loading', 'LOADED'\)/) + assert.match(source, /\/api\/platform\/inbounds\/\$\{encodeURIComponent\(inboundId\)\}/) + assert.match(source, /id\(inbound\?\.summary\?\.id\) === inboundId/) + assert.match(source, /inbound\?\.summary\?\.processStatus === expectedStatus/) + assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/) + assert.match(source, /unified\?\.summary\?\.mainStatus === expectedStatus/) + assert.match(source, /matrix\.pages\.unifiedOrders\.fixtures\[expectedStatus\]/) + assert.match(source, /clearResolvedProvisionalGap\(context, expectedStatus\)/) + assert.match(source, /gap\?\.name === `统一订单列表保留 \$\{status\}`/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs index 27bd9c67d..2bf5d0ff5 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -7,7 +7,100 @@ const GROUPS = [ 'manual-state-ocean-trucking', ] +const FIXTURE_UNAVAILABLE = { + inbounds: { + WAIT_PRICING: '自动计费会继续推进,无法稳定保留 WAIT_PRICING', + FEE_CONFIRMING: '当前无可稳定保留的费用确认前公开生成链路', + INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API', + VOIDED: '当前 Controller/Service 没有作废入库单的公开 API', + }, + unifiedOrders: { + DELIVERED: '当前后端没有完成派送并写入 DELIVERED 的公开业务 API', + }, +} + function rows(value) { return Array.isArray(value) ? value : [] } +function id(value) { return value == null ? '' : String(value) } + +export function normalizeStateRequirements(matrix) { + for (const [page, value] of Object.entries(matrix?.pages || {})) { + const supportedStatuses = [...new Set([ + ...rows(value?.supportedStatuses), ...rows(value?.requiredStatuses), + ])] + const unavailable = FIXTURE_UNAVAILABLE[page] || {} + value.supportedStatuses = supportedStatuses + value.requiredStatuses = supportedStatuses.filter((status) => !unavailable[status]) + if (Object.keys(unavailable).length > 0) { + value.coverageGaps = Object.fromEntries(Object.entries(unavailable).map(([status, reason]) => [status, { + category: 'fixture-unavailable', reason, + }])) + } + } + return matrix +} + +export function missingRequiredStateFixtures(matrix) { + return Object.entries(matrix?.pages || {}).flatMap(([page, value]) => rows(value?.requiredStatuses) + .filter((status) => !value?.fixtures?.[status]?.id) + .map((status) => `${page}:${status}`)) +} + +function registerFixtureUnavailableGaps(context, matrix) { + for (const [page, value] of Object.entries(matrix?.pages || {})) { + for (const [status, gap] of Object.entries(value?.coverageGaps || {})) { + if (value?.fixtures?.[status]?.id) continue + if (rows(context.coverageGaps).some((item) => item?.category === gap.category + && String(item?.name || '').includes(status))) continue + context.addCoverageGap?.(MODULE, OPTIONS.scenario, `${page}:${status}`, gap.reason, + gap.category, { marker: context.marker, page, status }) + } + } +} + +function clearResolvedProvisionalGap(context, status) { + if (!Array.isArray(context.coverageGaps)) return + for (let index = context.coverageGaps.length - 1; index >= 0; index -= 1) { + const gap = context.coverageGaps[index] + if (gap?.module === 'order-inbound-status-fixtures' + && gap?.scenario === 'manual-order-inbound-status-matrix' + && gap?.category === 'fixture-unavailable' + && gap?.name === `统一订单列表保留 ${status}`) context.coverageGaps.splice(index, 1) + } +} + +async function attachUnifiedOrderFixture(context, matrix, resourceName, expectedStatus) { + const resource = context.createdResources?.[resourceName] + const inboundId = id(resource?.inboundId) + const tenantId = id(resource?.tenantId) + context.assert(MODULE, `${expectedStatus} 专项资源已生成`, null, + Boolean(inboundId && tenantId), + `resource=${resourceName}, inboundId=${inboundId || 'missing'}, tenantId=${tenantId || 'missing'}`, OPTIONS) + if (!inboundId || !tenantId) return + + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`) + const inbound = context.apiSuccess(MODULE, `读取 ${expectedStatus} 专项入库详情`, inboundResponse, '', OPTIONS) + const orderId = id(inbound?.summary?.orderId) + context.assert(MODULE, `${expectedStatus} 入库详情身份与状态正确`, inboundResponse, + id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId + && Boolean(orderId) && inbound?.summary?.processStatus === expectedStatus, + `inboundId=${id(inbound?.summary?.id) || 'missing'}, orderId=${orderId || 'missing'}, status=${inbound?.summary?.processStatus || 'missing'}`, OPTIONS) + if (!orderId || inbound?.summary?.processStatus !== expectedStatus) return + + const unifiedResponse = await context.request('GET', + `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`) + const unified = context.apiSuccess(MODULE, `读取 ${expectedStatus} 统一订单详情`, unifiedResponse, '', OPTIONS) + const valid = id(unified?.summary?.orderId) === orderId && unified?.summary?.mainStatus === expectedStatus + context.assert(MODULE, `${expectedStatus} 统一订单详情可打开`, unifiedResponse, valid, + `orderId=${id(unified?.summary?.orderId) || 'missing'}, mainStatus=${unified?.summary?.mainStatus || 'missing'}`, OPTIONS) + if (!valid) return + + matrix.pages.unifiedOrders.fixtures[expectedStatus] = { + id: orderId, status: expectedStatus, inboundId, + detailPath: `/orders/${orderId}`, + } + clearResolvedProvisionalGap(context, expectedStatus) +} export async function run(context) { const matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} } @@ -19,10 +112,13 @@ export async function run(context) { await extension.run(context, matrix) } + await attachUnifiedOrderFixture(context, matrix, 'phase3ContainerLoading', 'LOADING') + await attachUnifiedOrderFixture(context, matrix, 'phase3Loading', 'LOADED') + normalizeStateRequirements(matrix) + registerFixtureUnavailableGaps(context, matrix) + const pages = Object.entries(matrix.pages) - const missing = pages.flatMap(([page, value]) => rows(value?.requiredStatuses) - .filter((status) => !value?.fixtures?.[status]?.id) - .map((status) => `${page}:${status}`)) + const missing = missingRequiredStateFixtures(matrix) const missingActions = pages.flatMap(([page, value]) => rows(value?.requiredActions) .filter((action) => !value?.actions?.[action]?.id || !value?.actions?.[action]?.documentId || !value?.actions?.[action]?.completedAt) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs index ba65e3d2b..ad246eee5 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -11,6 +11,14 @@ test('retains every ocean booking and truck appointment list status through real 'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED', 'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`)) assert.match(source, /change-request/) + const draftPayload = source.slice(source.indexOf('function oceanPayload'), source.indexOf('async function createOcean')) + for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { + assert.doesNotMatch(draftPayload, new RegExp(`${field}:`)) + } + const lifecycle = source.slice(source.indexOf('async function buildOcean'), source.indexOf('function truckPayload')) + for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { + assert.match(lifecycle, new RegExp(`${field}:`)) + } assert.match(source, /\/reschedule\?plannedLoadingAt=/) assert.match(source, /page=1&size=100&status=/) assert.match(source, /customerId: oceanBooking\.customerId/) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 96f7214e5..099820204 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -17,18 +17,17 @@ async function post(context, path, body, operation) { } function oceanPayload(context, template, index) { - const value = suffix(context, index); const source = template.oceanBooking + const source = template.oceanBooking return { expectedVersion: 0, data: { customerId: source.customerId, customerName: source.customerName, channelCode: source.channelCode, channelName: source.channelName, warehouseId: source.warehouseId, warehouseName: source.warehouseName, - billOfLadingNo: `MAN-BL-${value}`, shippingCompanyId: source.shippingCompanyId, + shippingCompanyId: source.shippingCompanyId, shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId, - bookingAgentName: source.bookingAgentName, vesselName: `人工验收轮-${value}`, voyageNo: `V${value}`, + bookingAgentName: source.bookingAgentName, departurePortCode: source.departurePortCode, departurePortName: source.departurePortName, destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName, placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm, - estimatedSailingAt: '2026-09-15T08:00:00', estimatedArrivalAt: '2026-10-15T08:00:00', remark: `${context.marker} 人工列表状态 ${index}`, parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) => ({ partyType, partyName, address, contactName, contactPhone })), diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index e3d5275d5..8a1324fdb 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -45,7 +45,11 @@ test('FBA 状态样本为订单头和每条货物行提供四个必填标识', ( test('测量状态截停发生在正确的 HTTP 边界', () => { assert.match(source, /includes\(summary\.mainStatus\)/) assert.doesNotMatch(source, /includes\(summary\.processStatus\)/) - assert.match(source, /target === 'MEASURING' && ok\(response\) && data\(response\)\?\.measurementId/) + assert.match(source, /target === 'MEASURING'/) + assert.match(source, /\.\/inbound-weight-ocr\.mjs/) + assert.match(source, /await weightOcr\.run\(context\)/) + assert.match(source, /target !== 'MEASURING'/) + assert.doesNotMatch(source, /target === 'MEASURING' && ok\(response\)/) assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/) assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/) assert.match(source, /measurements\/reject/) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index f1e87501f..d2e626fe4 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -174,14 +174,13 @@ async function prepareInboundStatus(context, target) { if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`) const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}` + if (target === 'MEASURING') { + const weightOcr = await import(new URL('./inbound-weight-ocr.mjs', import.meta.url)) + await weightOcr.run(context) + } + context.request = async (method, path, body, token, headers) => { const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`) - const isMeasurementQuery = method === 'GET' && path.includes(`/inbounds/${inboundId}/measurement?`) - if (isMeasurementQuery) { - const response = await originalRequest.call(context, method, path, body, token, headers) - if (target === 'MEASURING' && ok(response) && data(response)?.measurementId) throw stop - return response - } if (target === 'MEASURE_PENDING' && isSubmit) { const response = await originalRequest.call(context, method, path, body, token, headers) if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`) @@ -201,13 +200,15 @@ async function prepareInboundStatus(context, target) { } return originalRequest.call(context, method, path, body, token, headers) } - try { - const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) - await measurement.run(context) - } catch (error) { - if (error !== stop) throw error - } finally { - context.request = originalRequest + if (target !== 'MEASURING') { + try { + const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) + await measurement.run(context) + } catch (error) { + if (error !== stop) throw error + } finally { + context.request = originalRequest + } } const response = await originalRequest.call(context, 'GET', @@ -232,6 +233,11 @@ async function prepareInboundStatus(context, target) { } export async function run(context, matrix = context.createdResources.manualStateMatrix) { + if (!matrix) { + matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} } + context.createdResources.manualStateMatrix = matrix + } + matrix.pages ||= {} const loginResponse = await context.request('POST', '/api/auth/login', { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD }, '') const token = context.apiSuccess(MODULE, 'IT 测试客户管理员登录', loginResponse)?.accessToken