This commit is contained in:
2026-08-14 12:50:37 +08:00
parent 91e781b3ff
commit ce6963a25e
46 changed files with 664 additions and 76 deletions
@@ -2910,7 +2910,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照',
price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照',
rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', 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 '客户名称快照', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照',
tax_mode VARCHAR(24) 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) CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id)
) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
tenant_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) { }
}
@@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.service.FinancePhase1OperationService; import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import org.springframework.beans.factory.annotation.Autowired;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -24,15 +26,23 @@ import java.time.LocalDate;
@RequestMapping("/api/platform/finance/v1") @RequestMapping("/api/platform/finance/v1")
public class FinancePhase1OperationController { public class FinancePhase1OperationController {
private final FinancePhase1OperationService service; 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") @GetMapping("/accounts")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page, public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize, @RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String status) { @RequestParam(required = false) String status,
return service.listDerivedCustomerAccounts(page, pageSize, keyword, status); @RequestParam(required = false) Long customerId) {
return customerAccountService.list(page, pageSize, keyword, status, customerId);
} }
@GetMapping("/accounts/{customerId}/ledger") @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; 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); page = page(page); pageSize = pageSize(pageSize);
String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(java.util.Locale.ROOT); 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", "账户状态无效"); 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')=?)"; String filter = " WHERE (? IS NULL 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}; 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; 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); 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," + 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.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.service.FinanceEventException; import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinancePhase1OperationService; import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -39,21 +40,22 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class FinancePhase1OperationBehaviorTest { class FinancePhase1OperationBehaviorTest {
@Mock FinancePhase1OperationService operationService; @Mock FinancePhase1OperationService operationService;
@Mock FinanceCustomerAccountService customerAccountService;
@Test @Test
void controllerDelegatesFiveFrozenOperationsAndUsesAuthenticatedOperator() { 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()); AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of());
var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0); var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0);
var inclusion = new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入本期结算", 1); 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.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
controller.getFinanceFeeSource(88L); controller.getFinanceFeeSource(88L);
controller.reviewFinanceFeeSource(user, 88L, "review-key", review); controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion); 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).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
verify(operationService).getFinanceFeeSource(88L); verify(operationService).getFinanceFeeSource(88L);
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review); verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
@@ -93,6 +95,8 @@ class FinancePhase1OperationBehaviorTest {
assertThat(result.page().total()).isEqualTo(1); assertThat(result.page().total()).isEqualTo(1);
assertThat(result.requestId()).isNotBlank(); assertThat(result.requestId()).isNotBlank();
assertThat(result.traceId()).isNotBlank(); assertThat(result.traceId()).isNotBlank();
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot");
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
} }
@Test @Test
@@ -234,6 +238,7 @@ class FinancePhase1OperationBehaviorTest {
private static final class AccountJdbc extends JdbcTemplate { private static final class AccountJdbc extends JdbcTemplate {
private final List<Long> ledgerCustomerIds = new ArrayList<>(); private final List<Long> ledgerCustomerIds = new ArrayList<>();
private String accountListSql = "";
@Override @Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) { public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
@@ -245,6 +250,7 @@ class FinancePhase1OperationBehaviorTest {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) { public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.startsWith("SELECT c.customer_id")) { if (sql.startsWith("SELECT c.customer_id")) {
accountListSql = sql;
return (List<T>) List.of(new FinancePhase1Dtos.AccountSummary( return (List<T>) List.of(new FinancePhase1Dtos.AccountSummary(
9L, "9", "客户9", null, null, new BigDecimal("100.00"), 9L, "9", "客户9", null, null, new BigDecimal("100.00"),
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, 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 = 64) String cabinetNo,
@Size(max = 128) String billOfLadingNo, @Size(max = 128) String billOfLadingNo,
@Size(max = 128) String keyword, @Size(max = 128) String keyword,
@Positive Long customerId,
@Positive Long warehouseId, @Positive Long warehouseId,
@Size(max = 32) String containerType, @Size(max = 32) String containerType,
@Positive Long shippingCompanyId, @Positive Long shippingCompanyId,
@@ -18,6 +18,7 @@ public final class LoadingListDtos {
public record PreloadQuery( public record PreloadQuery(
@Size(max = 128) String keyword, @Size(max = 128) String keyword,
String status, String status,
@Positive Long customerId,
@Min(1) Long warehouseId, @Min(1) Long warehouseId,
LocalDateTime createdFrom, LocalDateTime createdFrom,
LocalDateTime createdTo, LocalDateTime createdTo,
@@ -32,6 +33,7 @@ public final class LoadingListDtos {
public record ConfirmedQuery( public record ConfirmedQuery(
@Size(max = 128) String keyword, @Size(max = 128) String keyword,
@Positive Long customerId,
@Min(1) Long warehouseId, @Min(1) Long warehouseId,
LocalDateTime createdFrom, LocalDateTime createdFrom,
LocalDateTime createdTo, LocalDateTime createdTo,
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.dto; package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} 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 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 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){ } 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}) WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo}) 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.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.warehouseId} IS NULL OR b.warehouse_id = #{query.warehouseId})
AND (#{query.containerType} IS NULL OR b.container_type = #{query.containerType}) AND (#{query.containerType} IS NULL OR b.container_type = #{query.containerType})
AND (#{query.shippingCompanyId} IS NULL OR b.shipping_company_id = #{query.shippingCompanyId}) 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 AND EXISTS (SELECT 1 FROM fl_loading_plan_item active_item
WHERE active_item.loading_plan_id = p.id AND active_item.deleted = 0) 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.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 != ''"> <if test="query.keyword != null and query.keyword != ''">
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%') AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.container_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 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 LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE p.plan_status = 'COMPLETED' 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 != ''"> <if test="query.keyword != null and query.keyword != ''">
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%') AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.mapper; 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.*; 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>{ @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 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 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); @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) { private Estimate estimateRegion(long versionId, String channelCode, EstimateRequest request) {
if (request.destinationType() != PricingDestinationType.FBA) { if (request.destinationType() != PricingDestinationType.FBA
throw validation("海派渠道仅支持FBA目的仓参考估价"); && request.destinationType() != PricingDestinationType.OVERSEAS) {
throw validation("海派渠道仅支持平台FBA仓或海外中转仓参考估价");
} }
if (request.pricingTaxMode() == null) { if (request.pricingTaxMode() == null) {
throw validation("请选择包税或不包税报价"); throw validation("请选择包税或不包税报价");
@@ -95,7 +96,7 @@ public class ActivePriceBookEstimator {
private PriceRegion resolveWarehouseRegion(Long warehouseId) { private PriceRegion resolveWarehouseRegion(Long warehouseId) {
if (warehouseId == null || warehouseId <= 0) { if (warehouseId == null || warehouseId <= 0) {
throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域"); throw validation("请提供目的仓区域、平台FBA仓或海外中转仓,以确定海派区域");
} }
return warehouseRegionResolver.resolve(warehouseId); return warehouseRegionResolver.resolve(warehouseId);
} }
@@ -11,7 +11,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; 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 @Component
public class PriceBookWarehouseRegionResolver { public class PriceBookWarehouseRegionResolver {
@Resource private PriceBookWarehouseClient warehouses; @Resource private PriceBookWarehouseClient warehouses;
@@ -20,7 +20,7 @@ public class PriceBookWarehouseRegionResolver {
public PriceRegion resolve(long warehouseId) { public PriceRegion resolve(long warehouseId) {
if (warehouseId <= 0) { if (warehouseId <= 0) {
throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域"); throw validation("请提供目的仓区域、平台FBA仓或海外中转仓,以确定海派区域");
} }
try { try {
ApiResponse<PriceBookWarehouseClient.Detail> response = warehouses.active(internalToken, 0L, warehouseId); ApiResponse<PriceBookWarehouseClient.Detail> response = warehouses.active(internalToken, 0L, warehouseId);
@@ -28,12 +28,13 @@ public class PriceBookWarehouseRegionResolver {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用");
} }
if (!response.successful() || response.data() == null || response.data().warehouse() == null) { if (!response.successful() || response.data() == null || response.data().warehouse() == null) {
throw validation("目的仓不是可用的平台FBA仓"); throw validation("目的仓不是可用的平台目的仓");
} }
var warehouse = response.data().warehouse(); var warehouse = response.data().warehouse();
if (!Long.valueOf(warehouseId).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId()) if (!Long.valueOf(warehouseId).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId())
|| !"FBA".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) { || !("FBA".equals(warehouse.warehouseType()) || "OVERSEAS".equals(warehouse.warehouseType()))
throw validation("目的仓不是可用的平台FBA仓"); || !"ACTIVE".equals(warehouse.status())) {
throw validation("目的仓不是可用的平台FBA仓或海外中转仓");
} }
String postalCode = response.data().addresses() == null ? null : response.data().addresses().stream() String postalCode = response.data().addresses() == null ? null : response.data().addresses().stream()
.filter(address -> address != null && StringUtils.hasText(address.postalCode())) .filter(address -> address != null && StringUtils.hasText(address.postalCode()))
@@ -191,7 +191,7 @@ public class ContainerService {
} }
private static Query normalize(Query q) { private static Query normalize(Query q) {
return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()), 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(), trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(),
q.estimatedSailingTo(), q.page(), q.size()); q.estimatedSailingTo(), q.page(), q.size());
} }
@@ -227,9 +227,8 @@ public class FbaPricingService {
|| inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) { || inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) {
throw conflict("审核通过的测量快照必须包含大于零的实重和体积"); throw conflict("审核通过的测量快照必须包含大于零的实重和体积");
} }
if (inbound.getCustomerId() == null || inbound.getWarehouseId() == null if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) {
|| !StringUtils.hasText(inbound.getChannelCode())) { throw conflict("入库单缺少起运仓或渠道匹配维度");
throw conflict("入库单缺少客户、起运仓或渠道匹配维度");
} }
return inbound; return inbound;
} }
@@ -71,13 +71,13 @@ public class LoadingListService {
if (StringUtils.hasText(status) && !PRELOAD_STATUSES.contains(status)) { if (StringUtils.hasText(status) && !PRELOAD_STATUSES.contains(status)) {
throw badRequest("预装清单状态只能是 EDITING 或 LOADING"); 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())); query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
} }
public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) { public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
validateRange(query.createdFrom(), query.createdTo()); 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())); 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 '船司价格版本快照', carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照',
price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照',
rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', 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 '客户名称快照', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照',
tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照', tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照',
@@ -130,7 +130,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/calculate: /api/platform/v1/inbounds/{inboundId}/fees/calculate:
post: post:
summary: 按审核通过测量快照试算 FBA 费用 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'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
@@ -205,6 +205,7 @@ paths:
- {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Size'}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}} - {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: containerNo, in: query, schema: {type: string, maxLength: 64}}
- {name: cabinetNo, 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]*$'}} - {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -7,12 +7,13 @@ paths:
/api/platform/v1/first-leg/loading-plans: /api/platform/v1/first-leg/loading-plans:
get: get:
summary: 分页查询预装清单 summary: 分页查询预装清单
description: 默认返回全部仓库的装柜计划;可按仓库筛选,客户账号仅用于展示货物归属。 description: 默认返回全部仓库的装柜计划;可按客户和仓库筛选。
x-permission: platform:firstleg:preload:list x-permission: platform:firstleg:preload:list
parameters: parameters:
- {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Size'}
- {$ref: '#/components/parameters/Keyword'} - {$ref: '#/components/parameters/Keyword'}
- {$ref: '#/components/parameters/CustomerId'}
- name: status - name: status
in: query in: query
description: 预装状态 description: 预装状态
@@ -49,12 +50,13 @@ paths:
/api/platform/v1/first-leg/loading-lists: /api/platform/v1/first-leg/loading-lists:
get: get:
summary: 分页查询装柜清单 summary: 分页查询装柜清单
description: 默认返回全部仓库的 COMPLETED 装柜计划;可按仓库筛选,包含全部客户单据。 description: 默认返回全部仓库的 COMPLETED 装柜计划;可按客户和仓库筛选。
x-permission: platform:firstleg:loading-list:list x-permission: platform:firstleg:loading-list:list
parameters: parameters:
- {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Size'}
- {$ref: '#/components/parameters/Keyword'} - {$ref: '#/components/parameters/Keyword'}
- {$ref: '#/components/parameters/CustomerId'}
- {$ref: '#/components/parameters/OptionalWarehouseId'} - {$ref: '#/components/parameters/OptionalWarehouseId'}
- {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'} - {$ref: '#/components/parameters/CreatedTo'}
@@ -138,6 +140,12 @@ components:
in: query in: query
description: 按预装单号、集装箱号或柜号模糊查询 description: 按预装单号、集装箱号或柜号模糊查询
schema: {type: string, maxLength: 128} schema: {type: string, maxLength: 128}
CustomerId:
name: customerId
in: query
required: false
description: 仅返回包含该客户货物的清单
schema: {$ref: '#/components/schemas/LongId'}
WarehouseId: WarehouseId:
name: warehouseId name: warehouseId
in: query in: query
@@ -6,6 +6,8 @@ paths:
/api/platform/v1/truck-appointments: /api/platform/v1/truck-appointments:
get: get:
summary: Query truck appointments summary: Query truck appointments
parameters:
- {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: responses:
'200': {description: Paged truck appointments} '200': {description: Paged truck appointments}
post: post:
@@ -49,6 +49,8 @@ class FbaPricingContractTest {
.contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:")
.contains("Idempotency-Key") .contains("Idempotency-Key")
.contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态") .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态")
.contains("区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域")
.contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用") .contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("不回退 WAIT_SPLIT 状态") .contains("不回退 WAIT_SPLIT 状态")
.doesNotContain("应收") .doesNotContain("应收")
@@ -51,9 +51,9 @@ class LoadingListRepositoryTest {
confirmedPage.setTotal(1); confirmedPage.setTotal(1);
when(mapper.selectConfirmedPage(any(), any())).thenReturn(confirmedPage); 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); .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); .records().get(0);
assertThat(preload.planId()).isEqualTo(41L); assertThat(preload.planId()).isEqualTo(41L);
@@ -42,6 +42,8 @@ class PriceBookSqlContractTest {
.contains("destination_name VARCHAR(128)") .contains("destination_name VARCHAR(128)")
.contains("parse_issues_json JSON NULL") .contains("parse_issues_json JSON NULL")
.contains("DEFAULT 'PENDING_CONFIRMATION'"); .contains("DEFAULT 'PENDING_CONFIRMATION'");
assertThat(schema)
.contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'");
assertThat(schema) assertThat(schema)
.contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001") .contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001")
.contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002") .contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002")
@@ -92,6 +92,24 @@ class FbaPricingServiceTest {
assertThat(fee.getValue().getCarrierId()).isNull(); 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 @Test
@DisplayName("不包税CBM使用生效价格表返回的计费量") @DisplayName("不包税CBM使用生效价格表返回的计费量")
void calculatesTaxExcludedCbmFromActivePriceBook() { void calculatesTaxExcludedCbmFromActivePriceBook() {
@@ -61,7 +61,7 @@ class LoadingListServiceTest {
@Test @Test
void shouldAcceptEditingStatusFromPreloadList() { 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); service.preloadPage(query);
@@ -184,7 +184,7 @@ class LoadingListServiceTest {
@Test @Test
void confirmedPageAlwaysUsesRepositoryConfirmedProjection() { 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( when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
new PageResult<>(List.of(), 1, 20, 0, 0)); new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -201,8 +201,8 @@ class LoadingListServiceTest {
when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
new PageResult<>(List.of(), 1, 20, 0, 0)); new PageResult<>(List.of(), 1, 20, 0, 0));
service.preloadPage(new PreloadQuery(null, "LOADING", 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, 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).preloadPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null));
verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null));
@@ -187,6 +187,26 @@ class MerchantPriceBookServiceTest {
verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED"); 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 @Test
void shouldRejectMissingRequiredEstimateFields() { void shouldRejectMissingRequiredEstimateFields() {
stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
@@ -206,7 +226,7 @@ class MerchantPriceBookServiceTest {
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null, request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null,
PricingTaxMode.TAX_INCLUDED, "51", null))) PricingTaxMode.TAX_INCLUDED, "51", null)))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("目的仓区域或平台FBA仓"); .hasMessageContaining("目的仓区域");
} }
@Test @Test
@@ -16,6 +16,8 @@ import com.baiyue.iam.service.MerchantOnboardingService;
import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.TenantService;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; 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.PostConstruct;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -106,6 +108,18 @@ public class InternalTenantController {
return ApiResponse.success(tenantService.orderEligibility(tenantId)); 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") @GetMapping("/exports/customers")
public ApiResponse<CustomerExportBatch> exportCustomers( public ApiResponse<CustomerExportBatch> exportCustomers(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -49,7 +49,7 @@ public class PlatformTenantController {
@GetMapping("/tenants") @GetMapping("/tenants")
@PreAuthorize("hasAuthority('platform:customer:list')") @PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。") @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持客户名称和状态筛选。客户编号、联系人等仅作展示信息,不作为搜索条件。")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"),
@@ -124,4 +124,8 @@ public final class TenantDtos {
LocalDate establishedDate, BigDecimal registeredCapital, LocalDate establishedDate, BigDecimal registeredCapital,
String registeredCapitalCurrency, String registeredAddress, String registeredCapitalCurrency, String registeredAddress,
String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { } 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) { }
} }
@@ -44,14 +44,7 @@ public class CustomerListExportProviderRepository {
.eq(IamTenant::getTenantType, "MERCHANT") .eq(IamTenant::getTenantType, "MERCHANT")
.eq(StringUtils.hasText(status), IamTenant::getStatus, status); .eq(StringUtils.hasText(status), IamTenant::getStatus, status);
if (StringUtils.hasText(keyword)) { if (StringUtils.hasText(keyword)) {
wrapper.and(condition -> condition wrapper.like(IamTenant::getTenantName, 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 wrapper; return wrapper;
} }
@@ -91,15 +91,28 @@ public class TenantRepository {
.eq(IamTenant::getTenantType, "MERCHANT") .eq(IamTenant::getTenantType, "MERCHANT")
.eq(StringUtils.hasText(status), IamTenant::getStatus, status) .eq(StringUtils.hasText(status), IamTenant::getStatus, status)
.orderByDesc(IamTenant::getCreatedAt); .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)) { if (StringUtils.hasText(keyword)) {
wrapper.and(condition -> condition wrapper.and(condition -> condition
.like(IamTenant::getTenantCode, keyword) .like(IamTenant::getTenantCode, keyword)
.or().like(IamTenant::getTenantName, keyword) .or().like(IamTenant::getTenantName, keyword)
.or().like(IamTenant::getLegalName, keyword) .or().like(IamTenant::getLegalName, keyword)
.or().like(IamTenant::getUnifiedSocialCreditCode, keyword) .or().like(IamTenant::getUnifiedSocialCreditCode, keyword)
.or().like(IamTenant::getContactName, keyword) .or().like(IamTenant::getContactName, keyword)
.or().like(IamTenant::getContactPhone, keyword) .or().like(IamTenant::getContactPhone, keyword)
.or().like(IamTenant::getSalesOwnerName, keyword)); .or().like(IamTenant::getSalesOwnerName, keyword));
} }
return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); 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.RegisterRequest;
import com.baiyue.iam.dto.TenantDtos.TenantResponse; 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.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
@@ -198,6 +199,14 @@ public class TenantService {
source.page(), source.size(), source.total(), source.pages()); 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) { public CustomerExportBatch exportMerchants(Long cursor, int limit, String keyword, String status) {
int boundedLimit = Math.min(Math.max(limit, 1), 500); int boundedLimit = Math.min(Math.max(limit, 1), 500);
List<IamTenant> source = repository.exportMerchants(cursor, boundedLimit, keyword, status); List<IamTenant> source = repository.exportMerchants(cursor, boundedLimit, keyword, status);
+44 -1
View File
@@ -44,7 +44,7 @@ paths:
parameters: parameters:
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {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: 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]}} - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}}
responses: responses:
'200': '200':
@@ -740,6 +740,39 @@ paths:
application/json: application/json:
schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'} schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'} '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: /internal/exports/v1/CUSTOMER_LIST/batches:
post: post:
tags: [Internal Export Provider] tags: [Internal Export Provider]
@@ -971,6 +1004,16 @@ components:
internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见} internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见}
createdAt: {type: string, format: date-time, description: 入驻时间} createdAt: {type: string, format: date-time, description: 入驻时间}
approvedAt: {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: TenantAddressListApiResponse:
allOf: allOf:
- {$ref: '#/components/schemas/ApiResponse'} - {$ref: '#/components/schemas/ApiResponse'}
@@ -40,4 +40,17 @@ class InternalCustomerExportContractTest {
assertEquals(ResultCode.SUCCESS.code(), response.code()); assertEquals(ResultCode.SUCCESS.code(), response.code());
verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE"); 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);
}
} }
@@ -40,8 +40,8 @@ class CustomerListExportProviderRepositoryTest {
assertThat(query.getSqlSegment()) assertThat(query.getSqlSegment())
.contains("tenant_type") .contains("tenant_type")
.contains("status") .contains("status")
.contains("tenant_code")
.contains("tenant_name") .contains("tenant_name")
.doesNotContain("tenant_code")
.contains("id >") .contains("id >")
.contains("id <=") .contains("id <=")
.contains("ORDER BY id ASC") .contains("ORDER BY id ASC")
@@ -93,6 +93,24 @@ class TenantServiceTest {
verify(repository).pageMerchants(1, 10, "Example", "ACTIVE"); 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 @Test
void platformCanMaintainAndDisableOnlyMerchantTenant() { void platformCanMaintainAndDisableOnlyMerchantTenant() {
IamTenant tenant = merchant(10001L, "ACTIVE"); IamTenant tenant = merchant(10001L, "ACTIVE");
+17
View File
@@ -16,6 +16,23 @@ const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'over
export function normalizeModuleOrder(modules) { export function normalizeModuleOrder(modules) {
const normalized = [...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')) { if (normalized.includes('fee-items')) {
const prerequisites = ['merchant-qualification-fixture', 'orders'] const prerequisites = ['merchant-qualification-fixture', 'orders']
const feeItemsIndex = normalized.indexOf('fee-items') const feeItemsIndex = normalized.indexOf('fee-items')
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' 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 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') 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'")) 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', () => { 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', for (const group of ['order-inbound-status-fixtures', 'manual-state-loading-pda',
'manual-state-ocean-trucking']) assert.match(source, new RegExp(group)) 'manual-state-ocean-trucking']) assert.match(source, new RegExp(group))
assert.doesNotMatch(source, /manual-state-customs-dispatch/) assert.doesNotMatch(source, /manual-state-customs-dispatch/)
assert.match(source, /createdResources\.manualStateMatrix/) assert.match(source, /createdResources\.manualStateMatrix/)
assert.match(source, /supportedStatuses/)
assert.match(source, /requiredStatuses/) assert.match(source, /requiredStatuses/)
assert.match(source, /fixtures\?\.\[status\]\?\.id/) assert.match(source, /fixtures\?\.\[status\]\?\.id/)
assert.match(source, /requiredActions/) assert.match(source, /requiredActions/)
assert.match(source, /actions\?\.\[action\]\?\.documentId/) assert.match(source, /actions\?\.\[action\]\?\.documentId/)
assert.match(source, /actions\?\.\[action\]\?\.completedAt/) 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', '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 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) { export async function run(context) {
const matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} } 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 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 pages = Object.entries(matrix.pages)
const missing = pages.flatMap(([page, value]) => rows(value?.requiredStatuses) const missing = missingRequiredStateFixtures(matrix)
.filter((status) => !value?.fixtures?.[status]?.id)
.map((status) => `${page}:${status}`))
const missingActions = pages.flatMap(([page, value]) => rows(value?.requiredActions) const missingActions = pages.flatMap(([page, value]) => rows(value?.requiredActions)
.filter((action) => !value?.actions?.[action]?.id .filter((action) => !value?.actions?.[action]?.id
|| !value?.actions?.[action]?.documentId || !value?.actions?.[action]?.completedAt) || !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', 'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED',
'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`)) 'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`))
assert.match(source, /change-request/) 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, /\/reschedule\?plannedLoadingAt=/)
assert.match(source, /page=1&size=100&status=/) assert.match(source, /page=1&size=100&status=/)
assert.match(source, /customerId: oceanBooking\.customerId/) assert.match(source, /customerId: oceanBooking\.customerId/)
@@ -17,18 +17,17 @@ async function post(context, path, body, operation) {
} }
function oceanPayload(context, template, index) { function oceanPayload(context, template, index) {
const value = suffix(context, index); const source = template.oceanBooking const source = template.oceanBooking
return { expectedVersion: 0, data: { return { expectedVersion: 0, data: {
customerId: source.customerId, customerName: source.customerName, customerId: source.customerId, customerName: source.customerName,
channelCode: source.channelCode, channelName: source.channelName, channelCode: source.channelCode, channelName: source.channelName,
warehouseId: source.warehouseId, warehouseName: source.warehouseName, warehouseId: source.warehouseId, warehouseName: source.warehouseName,
billOfLadingNo: `MAN-BL-${value}`, shippingCompanyId: source.shippingCompanyId, shippingCompanyId: source.shippingCompanyId,
shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId, shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId,
bookingAgentName: source.bookingAgentName, vesselName: `人工验收轮-${value}`, voyageNo: `V${value}`, bookingAgentName: source.bookingAgentName,
departurePortCode: source.departurePortCode, departurePortName: source.departurePortName, departurePortCode: source.departurePortCode, departurePortName: source.departurePortName,
destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName, destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName,
placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm, 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}`, remark: `${context.marker} 人工列表状态 ${index}`,
parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) => parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) =>
({ partyType, partyName, address, contactName, contactPhone })), ({ partyType, partyName, address, contactName, contactPhone })),
@@ -45,7 +45,11 @@ test('FBA 状态样本为订单头和每条货物行提供四个必填标识', (
test('测量状态截停发生在正确的 HTTP 边界', () => { test('测量状态截停发生在正确的 HTTP 边界', () => {
assert.match(source, /includes\(summary\.mainStatus\)/) assert.match(source, /includes\(summary\.mainStatus\)/)
assert.doesNotMatch(source, /includes\(summary\.processStatus\)/) 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_PENDING' && isSubmit/)
assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/) assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/)
assert.match(source, /measurements\/reject/) assert.match(source, /measurements\/reject/)
@@ -174,14 +174,13 @@ async function prepareInboundStatus(context, target) {
if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`) if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`)
const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}` 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) => { context.request = async (method, path, body, token, headers) => {
const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`) 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) { if (target === 'MEASURE_PENDING' && isSubmit) {
const response = await originalRequest.call(context, method, path, body, token, headers) 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}`) 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) return originalRequest.call(context, method, path, body, token, headers)
} }
try { if (target !== 'MEASURING') {
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) try {
await measurement.run(context) const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
} catch (error) { await measurement.run(context)
if (error !== stop) throw error } catch (error) {
} finally { if (error !== stop) throw error
context.request = originalRequest } finally {
context.request = originalRequest
}
} }
const response = await originalRequest.call(context, 'GET', 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) { 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', const loginResponse = await context.request('POST', '/api/auth/login',
{ username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD }, '') { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD }, '')
const token = context.apiSuccess(MODULE, 'IT 测试客户管理员登录', loginResponse)?.accessToken const token = context.apiSuccess(MODULE, 'IT 测试客户管理员登录', loginResponse)?.accessToken