优化
This commit is contained in:
@@ -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和数据隔离键',
|
||||
|
||||
@@ -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<PageResult<Customer>> 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) { }
|
||||
}
|
||||
+13
-3
@@ -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")
|
||||
|
||||
@@ -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<AccountSummary> 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<PageResult<IamCustomerClient.Customer>> 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<IamCustomerClient.Customer> customers = response.data();
|
||||
Map<Long, Metrics> metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList());
|
||||
List<AccountSummary> 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<Long, Metrics> loadMetrics(List<Long> customerIds) {
|
||||
if (customerIds.isEmpty()) return Map.of();
|
||||
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
|
||||
Map<Long, MutableMetrics> 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<Long, Metrics> frozen = new HashMap<>();
|
||||
result.forEach((id, value) -> frozen.put(id, value.freeze()));
|
||||
return frozen;
|
||||
}
|
||||
|
||||
private static void metric(Map<Long, MutableMetrics> result, Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,12 @@ public class FinancePhase1OperationService {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String keyword, String status) {
|
||||
public PageEnvelope<AccountSummary> 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," +
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<Long> ledgerCustomerIds = new ArrayList<>();
|
||||
private String accountListSql = "";
|
||||
|
||||
@Override
|
||||
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
|
||||
@@ -245,6 +250,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
|
||||
if (sql.startsWith("SELECT c.customer_id")) {
|
||||
accountListSql = sql;
|
||||
return (List<T>) List.of(new FinancePhase1Dtos.AccountSummary(
|
||||
9L, "9", "客户9", null, null, new BigDecimal("100.00"),
|
||||
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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){ }
|
||||
|
||||
@@ -24,6 +24,12 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
|
||||
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo})
|
||||
AND (#{query.billOfLadingNo} IS NULL OR b.bill_of_lading_no = #{query.billOfLadingNo})
|
||||
AND (#{query.customerId} IS NULL OR 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 (#{query.warehouseId} IS NULL OR b.warehouse_id = #{query.warehouseId})
|
||||
AND (#{query.containerType} IS NULL OR b.container_type = #{query.containerType})
|
||||
AND (#{query.shippingCompanyId} IS NULL OR b.shipping_company_id = #{query.shippingCompanyId})
|
||||
|
||||
@@ -51,6 +51,7 @@ public interface LoadingListQueryMapper {
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item active_item
|
||||
WHERE active_item.loading_plan_id = p.id AND active_item.deleted = 0)
|
||||
<if test="query.status != null and query.status != ''">AND p.plan_status = #{query.status}</if>
|
||||
<if test="query.customerId != null">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})</if>
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
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'
|
||||
<if test="query.customerId != null">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})</if>
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
|
||||
@@ -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<TruckAppointmentEntity>{
|
||||
@Select("<script>SELECT * FROM fl_truck_appointment WHERE (#{q.keyword} IS NULL OR appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR status=#{q.status}) AND (#{q.containerBookingId} IS NULL OR container_booking_id=#{q.containerBookingId}) ORDER BY updated_at DESC,id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
|
||||
@Select("<script>SELECT * FROM fl_truck_appointment WHERE (#{q.keyword} IS NULL OR appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR status=#{q.status}) AND (#{q.customerId} IS NULL OR customer_id=#{q.customerId}) AND (#{q.containerBookingId} IS NULL OR container_booking_id=#{q.containerBookingId}) ORDER BY updated_at DESC,id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+6
-5
@@ -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<PriceBookWarehouseClient.Detail> 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()))
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ConfirmedSummary> 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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '订单买单或退税模式快照',
|
||||
|
||||
@@ -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: 无权限}}
|
||||
|
||||
@@ -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]*$'}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("应收")
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
|
||||
verify(repository).insertFee(fee.capture());
|
||||
assertThat(fee.getValue().getCustomerId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不包税CBM使用生效价格表返回的计费量")
|
||||
void calculatesTaxExcludedCbmFromActivePriceBook() {
|
||||
|
||||
@@ -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));
|
||||
|
||||
+21
-1
@@ -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
|
||||
|
||||
@@ -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<PageResult<FinanceCustomerSummary>> 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<CustomerExportBatch> exportCustomers(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -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 = "分页参数类型无效"),
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
|
||||
+1
-8
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<IamTenant> pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) {
|
||||
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
|
||||
.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));
|
||||
}
|
||||
|
||||
@@ -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<FinanceCustomerSummary> pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) {
|
||||
PageResult<IamTenant> 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<IamTenant> source = repository.exportMerchants(cursor, boundedLimit, keyword, status);
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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\}`/)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user