This commit is contained in:
2026-08-15 11:05:58 +08:00
parent c4282020b5
commit 557ab75319
50 changed files with 591 additions and 394 deletions
+1
View File
@@ -4,3 +4,4 @@ spring:
username: ${FINANCE_DB_USERNAME}
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
+1
View File
@@ -4,3 +4,4 @@ spring:
url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
@@ -799,7 +799,7 @@ CREATE TABLE IF NOT EXISTS wh_partner (
id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0',
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围',
partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称',
partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH',
partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CUSTOMER、CARRIER、SHIPPER或CONSIGNEE',
contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话',
contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县',
@@ -810,13 +810,15 @@ CREATE TABLE IF NOT EXISTS wh_partner (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted),
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted)
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted),
CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CUSTOMER','CARRIER','SHIPPER','CONSIGNEE'))
) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据';
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0',
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围,平台公共数据为0', partner_type VARCHAR(32) NOT NULL COMMENT '稳定业务类型:CUSTOMER客户、SHIPPER发货方等',
PRIMARY KEY(partner_id,partner_type),
KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id)
KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id),
CONSTRAINT chk_wh_partner_type_value CHECK (partner_type IN ('CUSTOMER','CARRIER','SHIPPER','CONSIGNEE'))
) ENGINE=InnoDB COMMENT='平台共享往来单位稳定业务类型';
INSERT INTO wh_partner(id,tenant_id,data_scope_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status)
VALUES(191000000000000001,0,0,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE')
@@ -13835,7 +13837,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'district', '区县'
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'id', '往来单位ID(雪花ID)');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'partner_code', '平台内唯一往来单位编码');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'partner_name', '往来单位名称');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'partner_type', 'CUSTOMER、SUPPLIER或BOTH');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'partner_type', '往来单位类型:CUSTOMER、CARRIER、SHIPPER或CONSIGNEE');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'postal_code', '邮政编码');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'province', '省或州');
CALL queue_column_comment('baiyue_warehouse', 'wh_partner', 'remark', '备注');
@@ -0,0 +1,20 @@
package com.baiyue.finance.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "warehouse-service", contextId = "financeWarehouseSupplierClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers")
public interface WarehouseSupplierClient {
@GetMapping("/{supplierId}")
ApiResponse<SupplierSnapshot> get(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long supplierId,
@RequestParam(defaultValue = "true") boolean requireActive,
@RequestParam String category);
record SupplierSnapshot(long supplierId, String name, String category, String scope, String status) {}
}
@@ -28,6 +28,7 @@ public class FinanceFeeSourceEventService {
private final JdbcTemplate jdbc;
private final FirstlegFeeSourceClient firstleg;
private final ObjectMapper objectMapper;
private final FinanceSupplierValidator supplierValidator;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -126,6 +127,13 @@ public class FinanceFeeSourceEventService {
if (!"CNY".equals(value.currency()) || value.totalAmount() == null || value.totalAmount().signum() <= 0) throw error("FIN-EVT-010", "费用金额或币种无效");
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
if ("PAYABLE".equals(value.direction())) {
if (value.partnerId() == null || value.partnerId() <= 0 || blank(value.partnerName())) {
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
}
String category = supplierValidator.categoryFor(value.feeItemCode(), value.feeItemName());
supplierValidator.requireActive(value.partnerId(), category);
}
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
}
@@ -27,11 +27,14 @@ public class FinanceService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
}
public Map<String, Object> customerTerms(long tenantId) {
@@ -265,14 +268,23 @@ public class FinanceService {
if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=? AND tenant_id=?", replay, tenantId);
if (!"SINGLE_ORDER".equals(request.costType()) && !"CONTAINER_SHARED".equals(request.costType())) throw bad("成本类型只能是SINGLE_ORDER或CONTAINER_SHARED");
if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0");
String supplierName = null;
if (request.supplierId() != null) {
if (request.supplierId() <= 0) throw bad("供应商ID无效");
String category = supplierValidator.categoryFor(request.feeItemCode(), request.feeItemName());
supplierName = supplierValidator.requireActive(request.supplierId(), category).name();
} else if (org.springframework.util.StringUtils.hasText(request.supplierName())) {
throw bad("供应商名称必须来自Warehouse供应商主数据");
}
validateVoucherFiles(tenantId, request.voucherFileIds());
if ("SINGLE_ORDER".equals(request.costType()) && (request.orderId() == null || request.containerId() != null)) throw bad("单票成本必须指定订单且不能指定柜次");
if ("CONTAINER_SHARED".equals(request.costType()) && (request.containerId() == null || containerOrders == null || containerOrders.isEmpty())) throw bad("整柜成本必须指定柜次及柜内有效订单体积");
if ("SINGLE_ORDER".equals(request.costType())) validateSingleOrder(tenantId, request.orderId());
validateContainerOrders(tenantId, request.containerId(), containerOrders);
String supplierNameSnapshot = supplierName;
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, request.supplierName()); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setLong(15, operatorId); return ps; }, holder);
ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, supplierNameSnapshot); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setLong(15, operatorId); return ps; }, holder);
long costId = holder.getKey().longValue();
jdbc.update("UPDATE fin_cost SET cost_no=? WHERE id=?", stableNo("COST", costId), costId);
if ("CONTAINER_SHARED".equals(request.costType())) {
@@ -46,11 +46,14 @@ public class FinanceSettlementService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
}
@Transactional
@@ -111,7 +114,7 @@ public class FinanceSettlementService {
List<Object> args = new ArrayList<>();
args.addAll(ids);
List<Map<String, Object>> sources = jdbc.queryForList(
"SELECT id,customer_id,direction,currency_code,status,amount,source_snapshot," +
"SELECT id,customer_id,direction,currency_code,status,amount,fee_item_code,fee_item_name,source_snapshot," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus')) review_status," +
"JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded')) settlement_included," +
"CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId'))," +
@@ -137,8 +140,11 @@ public class FinanceSettlementService {
}
BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0");
String supplierName = sources.stream().map(row -> text(row, "supplier_name"))
.filter(StringUtils::hasText).findFirst().orElse("供应商" + request.supplierId());
Set<String> supplierCategories = sources.stream()
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
.collect(java.util.stream.Collectors.toSet());
if (supplierCategories.size() != 1) throw error("FIN_409", "应付结算费用来源必须属于同一供应商类别");
String supplierName = supplierValidator.requireActive(request.supplierId(), supplierCategories.iterator().next()).name();
String customerName = sources.stream().map(row -> jsonText(parseTree(text(row, "source_snapshot")), "customerName"))
.filter(StringUtils::hasText).findFirst().orElse("客户" + customerId);
LocalDate generatedDate = LocalDate.now();
@@ -215,7 +221,7 @@ public class FinanceSettlementService {
CreateSettlementPaymentRequest request) {
validatePaymentRequest(request);
Map<String, Object> settlement = one(
"SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version " +
"SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version,supplier_id " +
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
long customerId = number(settlement, "tenant_id").longValue();
Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST",
@@ -229,6 +235,8 @@ public class FinanceSettlementService {
if (number(settlement, "version").intValue() != request.expectedVersion()) throw error("FIN_409", "结算单版本冲突");
BigDecimal outstanding = decimal(settlement, "outstanding_amount");
if (request.amount().compareTo(outstanding) > 0) throw error("FIN_409", "付款金额不得超过未付余额");
String supplierCategory = payableSupplierCategory(customerId, settlementId);
supplierValidator.requireActive(number(settlement, "supplier_id").longValue(), supplierCategory);
validateVoucherFiles(customerId, request.voucherFileIds());
insertPayment(customerId, settlementId, request, operatorId);
@@ -353,6 +361,18 @@ public class FinanceSettlementService {
voucherValidator.validate(customerId, fileIds, "付款凭证");
}
private String payableSupplierCategory(long customerId, long settlementId) {
Set<String> categories = jdbc.queryForList(
"SELECT f.fee_item_code,f.fee_item_name FROM fin_settlement_line sl " +
"JOIN fin_fee_source f ON f.id=sl.fee_source_id AND f.customer_id=sl.tenant_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=?",
customerId, settlementId).stream()
.map(row -> supplierValidator.categoryFor(text(row, "fee_item_code"), text(row, "fee_item_name")))
.collect(java.util.stream.Collectors.toSet());
if (categories.size() != 1) throw error("FIN_409", "结算单费用来源供应商类别不一致");
return categories.iterator().next();
}
private Long reserve(long customerId, long operatorId, String operation, String key,
String method, String path, Object body) {
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key");
@@ -417,10 +437,13 @@ public class FinanceSettlementService {
BigDecimal amount = decimal(row, "amount");
BigDecimal unitPrice = quantity == null || quantity.signum() == 0 ? null
: amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP);
Long supplierId = jsonLong(snapshot, "supplierId");
if (supplierId == null) supplierId = jsonLong(snapshot, "partnerId");
return new SettlementLine(number(row, "fee_source_id").longValue(), text(row, "source_type"),
number(row, "source_fee_id").longValue(), number(row, "source_fee_version").intValue(),
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
jsonLong(snapshot, "supplierId"), firstText(jsonText(snapshot, "supplierName"), settlement.supplierName()),
supplierId, firstText(jsonText(snapshot, "supplierName"),
firstText(jsonText(snapshot, "partnerName"), settlement.supplierName())),
nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"),
jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"),
quantity, unitPrice, amount, text(row, "currency_code"), firstText(jsonText(snapshot, "reviewStatus"), "PENDING"),
@@ -0,0 +1,66 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.WarehouseSupplierClient;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Locale;
import java.util.Set;
@Service
public class FinanceSupplierValidator {
private static final Set<String> CATEGORIES = Set.of("TRUCKING", "CUSTOMS", "BOOKING", "CERTIFICATE");
private final WarehouseSupplierClient client;
private final String internalToken;
public FinanceSupplierValidator(WarehouseSupplierClient client,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.client = client;
this.internalToken = internalToken;
}
public WarehouseSupplierClient.SupplierSnapshot requireActive(long supplierId, String category) {
String normalizedCategory = normalizeCategory(category);
try {
var response = client.get(internalToken, supplierId, true, normalizedCategory);
var supplier = response == null ? null : response.data();
if (response == null || !response.successful() || supplier == null
|| supplier.supplierId() != supplierId || !"ACTIVE".equals(supplier.status())
|| !normalizedCategory.equals(supplier.category()) || !StringUtils.hasText(supplier.name())) {
throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配");
}
return supplier;
} catch (FeignException exception) {
if (exception.status() == 400 || exception.status() == 404 || exception.status() == 409) {
throw new FinanceEventException("FIN_409", "供应商不存在、未启用或类别与费用来源不匹配");
}
throw new FinanceEventException("FIN_503", "供应商主数据服务暂不可用");
}
}
public String categoryFor(String feeItemCode, String feeItemName) {
String value = ((feeItemCode == null ? "" : feeItemCode) + " "
+ (feeItemName == null ? "" : feeItemName)).toUpperCase(Locale.ROOT);
if (containsAny(value, "TRUCK", "TRAILER", "DRAYAGE", "拖车", "卡车")) return "TRUCKING";
if (containsAny(value, "CUSTOMS", "CLEARANCE", "DECLARATION", "报关", "清关")) return "CUSTOMS";
if (containsAny(value, "CERTIFICATE", "CERTIFICATION", "CERT_", "认证", "证书")) return "CERTIFICATE";
if (containsAny(value, "BOOKING", "FREIGHT", "OCEAN", "SHIPPING", "订舱", "海运", "运费")) return "BOOKING";
throw new FinanceEventException("FIN_409", "费用来源缺少可验证的供应商类别");
}
private static boolean containsAny(String value, String... candidates) {
for (String candidate : candidates) if (value.contains(candidate)) return true;
return false;
}
private static String normalizeCategory(String category) {
String normalized = category == null ? "" : category.trim().toUpperCase(Locale.ROOT);
if (!CATEGORIES.contains(normalized)) {
throw new FinanceEventException("FIN_409", "费用来源供应商类别无效");
}
return normalized;
}
}
@@ -5,6 +5,7 @@ import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -30,11 +31,16 @@ class FinanceFeeSourceEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper);
private final FinanceSupplierValidator supplierValidator = mock(FinanceSupplierValidator.class);
private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper, supplierValidator);
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "internalToken", "test-token");
lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("TRUCKING");
lenient().when(supplierValidator.requireActive(anyLong(), any())).thenReturn(
new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
71L, "现有供应商", "TRUCKING", "DOMESTIC", "ACTIVE"));
}
@Test
@@ -73,6 +79,8 @@ class FinanceFeeSourceEventServiceTest {
assertThat(service.consume(event)).isEqualTo(101L);
verify(supplierValidator).categoryFor("TRUCKING", "拖车费");
verify(supplierValidator).requireActive(71L, "TRUCKING");
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
assertThat(values.getValue()[14].toString())
@@ -1,6 +1,7 @@
package com.baiyue.finance;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.common.core.api.ApiResponse;
@@ -119,7 +120,7 @@ class FinanceServiceValidationTest {
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceService service = new FinanceService(jdbc, new ObjectMapper(),
new FinanceVoucherValidator(null, ""));
new FinanceVoucherValidator(null, ""), mock(FinanceSupplierValidator.class));
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
@@ -275,12 +276,12 @@ class FinanceServiceValidationTest {
private static FinanceService service(JdbcTemplate jdbc, FinanceFileClient files) {
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(files, "internal-token"));
new FinanceVoucherValidator(files, "internal-token"), mock(FinanceSupplierValidator.class));
}
private static FinanceService service(JdbcTemplate jdbc) {
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceVoucherValidator.class));
mock(FinanceVoucherValidator.class), mock(FinanceSupplierValidator.class));
}
private static FinanceService.BillLine billLine(long sourceId, int version, String amount) {
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceSettlementService;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -38,6 +39,7 @@ import java.util.Set;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
@@ -302,7 +304,8 @@ class FinanceSettlementBehaviorTest {
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L)));
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(0);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class), supplierValidator);
SettlementDetail result = service.createPayableSettlement(7L, "ap-key",
new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY"));
@@ -310,6 +313,8 @@ class FinanceSettlementBehaviorTest {
assertThat(result.summary().direction()).isEqualTo("PAYABLE");
assertThat(result.summary().supplierId()).isEqualTo(20L);
assertThat(result.summary().totalAmount()).isEqualByComparingTo("100.00");
verify(supplierValidator).categoryFor("FBA_HEAD_FREIGHT", "头程运费");
verify(supplierValidator).requireActive(20L, "BOOKING");
verify(jdbc).update(startsWith("INSERT INTO fin_settlement_line"), eq(10L), eq(88L), eq(31L),
eq(new BigDecimal("100.00")), eq("CNY"));
}
@@ -363,11 +368,13 @@ class FinanceSettlementBehaviorTest {
stubSettlementDetail(jdbc, after);
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
FinanceSettlementService service = service(jdbc, fileClient);
FinanceSupplierValidator supplierValidator = supplierValidator();
FinanceSettlementService service = service(jdbc, fileClient, supplierValidator);
SettlementDetail result = service.createSettlementPayment(88L, 7L, "final-key", payment(new BigDecimal("60.00"), 1));
assertThat(result.summary().status()).isEqualTo("PAID");
verify(supplierValidator).requireActive(20L, "BOOKING");
assertThat(result.summary().outstandingAmount()).isZero();
}
@@ -522,8 +529,24 @@ class FinanceSettlementBehaviorTest {
}
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) {
return service(jdbc, fileClient, supplierValidator());
}
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient,
FinanceSupplierValidator supplierValidator) {
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT f.fee_item_code"), anyLong(), anyLong()))
.thenReturn(List.of(Map.of("fee_item_code", "FBA_HEAD_FREIGHT", "fee_item_name", "头程运费")));
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"));
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator);
}
private static FinanceSupplierValidator supplierValidator() {
FinanceSupplierValidator supplierValidator = org.mockito.Mockito.mock(FinanceSupplierValidator.class);
org.mockito.Mockito.lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("BOOKING");
org.mockito.Mockito.lenient().when(supplierValidator.requireActive(anyLong(), any()))
.thenReturn(new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(
20L, "供应商20", "BOOKING", "INTERNATIONAL", "ACTIVE"));
return supplierValidator;
}
private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) {
@@ -558,6 +581,7 @@ class FinanceSettlementBehaviorTest {
Map.entry("currency_code", "CNY"), Map.entry("status", "AVAILABLE"),
Map.entry("amount", new BigDecimal("100.00")), Map.entry("review_status", "APPROVED"),
Map.entry("settlement_included", "true"), Map.entry("supplier_id", supplierId),
Map.entry("fee_item_code", "FBA_HEAD_FREIGHT"), Map.entry("fee_item_name", "头程运费"),
Map.entry("supplier_name", "供应商20"), Map.entry("source_snapshot",
"{\"customerName\":\"客户10\",\"supplierId\":20,\"supplierName\":\"供应商20\"}")));
}
@@ -0,0 +1,52 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.client.WarehouseSupplierClient;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceSupplierValidator;
import feign.FeignException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceSupplierValidatorTest {
private final WarehouseSupplierClient client = mock(WarehouseSupplierClient.class);
private final FinanceSupplierValidator validator = new FinanceSupplierValidator(client, "internal-token");
@Test
void resolvesSupportedFeeCategories() {
assertThat(validator.categoryFor("TRUCKING", "拖车费")).isEqualTo("TRUCKING");
assertThat(validator.categoryFor("CUSTOMS_CLEARANCE", "报关费")).isEqualTo("CUSTOMS");
assertThat(validator.categoryFor("FBA_HEAD_FREIGHT", "头程运费")).isEqualTo("BOOKING");
assertThat(validator.categoryFor("CERTIFICATE_FEE", "证书费")).isEqualTo("CERTIFICATE");
assertCode(() -> validator.categoryFor("ACPT_UNKNOWN", "操作费"), "FIN_409");
}
@Test
void requiresActiveWarehouseSupplierWithMatchingCategory() {
var supplier = new WarehouseSupplierClient.SupplierSnapshot(20L, "正式供应商", "TRUCKING", "DOMESTIC", "ACTIVE");
when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(supplier));
assertThat(validator.requireActive(20L, "TRUCKING").name()).isEqualTo("正式供应商");
verify(client).get("internal-token", 20L, true, "TRUCKING");
}
@Test
void rejectsMismatchedOrUnavailableSupplier() {
var mismatched = new WarehouseSupplierClient.SupplierSnapshot(20L, "订舱供应商", "BOOKING", "INTERNATIONAL", "ACTIVE");
when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(mismatched));
assertCode(() -> validator.requireActive(20L, "TRUCKING"), "FIN_409");
when(client.get("internal-token", 21L, true, "BOOKING")).thenThrow(mock(FeignException.class));
assertCode(() -> validator.requireActive(21L, "BOOKING"), "FIN_503");
}
private static void assertCode(org.assertj.core.api.ThrowableAssert.ThrowingCallable call, String code) {
assertThatThrownBy(call).isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo(code);
}
}
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
@@ -42,6 +43,7 @@ class OceanBookingServiceTest {
@Mock private OceanBookingRepository repository;
@Mock private PriceBookQueryRepository priceBooks;
@Mock private LoadingPickService loadingPickService;
@Mock private WarehouseSupplierSnapshotService suppliers;
@InjectMocks private OceanBookingService service;
@BeforeEach
@@ -53,6 +55,7 @@ class OceanBookingServiceTest {
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.allocations(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
when(suppliers.booking(any(), any())).thenReturn(null);
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
when(repository.transition(anyLong(), eq("DRAFT"), eq("CONFIRMED"), isNull(), eq(0),
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.common.core.api.ApiResponse;
class WarehouseSupplierSnapshotServiceTest {
@Mock WarehouseSupplierClient client;
private WarehouseSupplierSnapshotService service;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
service = new WarehouseSupplierSnapshotService();
ReflectionTestUtils.setField(service, "client", client);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void rejectsFreeTextWithoutSupplierId() {
assertThatThrownBy(() -> service.trucking(null, "任意承运商"))
.isInstanceOf(BizException.class).hasMessageContaining("自由文本");
verifyNoInteractions(client);
}
@Test
void resolvesActiveBookingSupplierWithFixedScopeAndServerName() {
when(client.get("internal-token", 7L, true, "BOOKING", "INTERNATIONAL"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "INTERNATIONAL", "ACTIVE")));
var snapshot = service.booking(7L, "海运代理");
assertThat(snapshot.name()).isEqualTo("海运代理");
verify(client).get("internal-token", 7L, true, "BOOKING", "INTERNATIONAL");
}
@Test
void rejectsClientNameThatDiffersFromWarehouseSnapshot() {
when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS"), isNull()))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", null, "ACTIVE")));
assertThatThrownBy(() -> service.customs(9L, "伪造名称"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
}
}
@@ -70,7 +70,7 @@ public class OrderApplicationService {
long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId));
order.setStatus(OrderStatus.DRAFT);
apply(order, request, snapshots);
apply(order, tenantId, request, snapshots);
order.setInboundCode(nextInboundCode(inboundCodePrefix));
order.setCreatedBy(userId); order.setUpdatedBy(userId);
repository.insertOrder(order);
@@ -108,7 +108,7 @@ public class OrderApplicationService {
}
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode(),
request.destinationType());
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.SUBMITTED) {
order.setStatus(OrderStatus.SUBMITTED);
}
@@ -411,9 +411,9 @@ public class OrderApplicationService {
default -> false;
};
}
private void apply(OrderEntity order, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
order.setCustomerId(snapshots.partner() == null ? null : snapshots.partner().id());
order.setCustomerName(snapshots.partner() == null ? request.customerName() : snapshots.partner().partnerName());
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
order.setCustomerId(tenantId);
order.setCustomerName(request.customerName().trim());
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode()
: snapshots.destination().warehouse().countryCode());
@@ -5,7 +5,6 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests;
import feign.FeignException;
@@ -21,13 +20,11 @@ import org.slf4j.LoggerFactory;
public class OrderMasterDataGuard {
private static final Logger log = LoggerFactory.getLogger(OrderMasterDataGuard.class);
@Resource private WarehouseClient warehouses;
@Resource private PartnerClient partners;
@Value("${baiyue.security.internal-service-token}") private String token;
@Value("${baiyue.warehouse.public-tenant-id:0}")
private long publicTenantId = 0L;
public Snapshots validate(long tenantId, OrderRequests.SaveRequest request) {
try {
PartnerClient.Partner partner=request.customerId()==null ? null : loadPartner(request.customerId());
var receiving=loadWarehouse(request.receivingWarehouseId(),"收货仓库");
requireWarehouseType(receiving, "DOMESTIC", "收货仓库");
if (receiving.addresses()==null || receiving.addresses().stream().noneMatch(Objects::nonNull))
@@ -47,7 +44,7 @@ public class OrderMasterDataGuard {
destination=loadWarehouse(request.destinationWarehouseId(),"目的仓库");
requireWarehouseType(destination, request.destinationType(), "目的仓库");
}
return new Snapshots(partner,receiving,destination);
return new Snapshots(receiving,destination);
} catch (FeignException exception) {
throw downstreamFailure(exception);
} catch (BizException exception) {
@@ -56,10 +53,12 @@ public class OrderMasterDataGuard {
throw downstreamFailure(exception);
}
}
public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId,
public void validatePersisted(long tenantId, Long customerId, Long receivingWarehouseId,
Long destinationWarehouseId, String destinationType) {
try {
if (partnerId!=null) loadPartner(partnerId);
if (customerId != null && customerId != tenantId) {
throw new BizException(ResultCode.FORBIDDEN,"订单客户必须是当前登录企业");
}
if (receivingWarehouseId==null) throw new BizException(ResultCode.NOT_FOUND,"收货仓库已停用或不可用");
WarehouseClient.Detail receiving=loadWarehouse(receivingWarehouseId,"收货仓库");
requireWarehouseType(receiving,"DOMESTIC","收货仓库");
@@ -78,25 +77,6 @@ public class OrderMasterDataGuard {
throw downstreamFailure(exception);
}
}
private PartnerClient.Partner loadPartner(long id) {
ApiResponse<PartnerClient.Partner> response;
try {
response=partners.active(token,publicTenantId,id);
} catch (FeignException.NotFound exception) {
throw new BizException(ResultCode.NOT_FOUND,"平台往来单位不存在或不可用");
}
requireSuccessful(response,"往来单位");
if (response.data()==null) throw unavailable("往来单位");
var value=response.data();
if (!Long.valueOf(publicTenantId).equals(value.tenantId()))
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于平台公共主数据");
if (!"ACTIVE".equals(value.status()))
throw new BizException(ResultCode.CONFLICT,"往来单位已停用");
if (value.types() == null || !value.types().contains("SHIPPER"))
throw new BizException(ResultCode.VALIDATION_FAILED,"所选往来单位不具备发货单位类型");
if (!StringUtils.hasText(value.partnerName())) throw unavailable("往来单位");
return value;
}
private WarehouseClient.Detail loadWarehouse(long id,String name) {
ApiResponse<WarehouseClient.Detail> response;
try {
@@ -139,6 +119,5 @@ public class OrderMasterDataGuard {
throw new BizException(code, message);
}
}
public record Snapshots(PartnerClient.Partner partner, WarehouseClient.Detail receiving,
WarehouseClient.Detail destination) { }
public record Snapshots(WarehouseClient.Detail receiving, WarehouseClient.Detail destination) { }
}
@@ -2,7 +2,6 @@ package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
@@ -558,15 +557,15 @@ class OrderApplicationServiceStateTest {
}
private OrderMasterDataGuard.Snapshots snapshots() {
return snapshots(new PartnerClient.Partner(7L, 0L, "客户", "ACTIVE", "SHIPPER", List.of("SHIPPER")));
return snapshots(null);
}
private OrderMasterDataGuard.Snapshots snapshots(PartnerClient.Partner partner) {
private OrderMasterDataGuard.Snapshots snapshots(Object ignored) {
var warehouse = new WarehouseClient.Warehouse(8L, 0L, "CN-SZX-01", "收货仓", "DOMESTIC", "CN", "ACTIVE");
var address = new WarehouseClient.Address("CN", "广东", "深圳", "南山", "518000", "测试路1号", null);
var capability = new WarehouseClient.Capability("RECEIVING", true, LocalTime.of(16, 0));
var receiving = new WarehouseClient.Detail(warehouse, List.of(address), List.of(capability));
return new OrderMasterDataGuard.Snapshots(partner, receiving, null);
return new OrderMasterDataGuard.Snapshots(receiving, null);
}
private OrderEntity persistedDraft(String pickupMethod, String destinationType) {
@@ -3,241 +3,92 @@ package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.PartnerClient;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.dto.OrderRequests;
import feign.FeignException;
import feign.Request;
import feign.Response;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@DisplayName("订单主数据校验")
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(OrderMasterDataGuard.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=token")
class OrderMasterDataGuardTest {
@MockBean private WarehouseClient warehouses;
@MockBean private PartnerClient partners;
@Resource private OrderMasterDataGuard guard;
private static final long PUBLIC_TENANT_ID = 0L;
@Test
void validMasterDataReturnsTrustedServerSnapshot() {
var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null);
var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),List.of(address),List.of(capability));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh));
var result=guard.validate(10001,request(7L,8L));
assertThat(result.partner().partnerName()).isEqualTo("服务端客户名");
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓");
}
@Test
void inactiveOrCrossTenantMasterDataIsRejected() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(null));
assertThatThrownBy(() -> guard.validate(10002,request(7L,8L)))
.isInstanceOf(BizException.class).hasMessageContaining("服务暂时不可用");
}
@Test
void downstreamHttp200BusinessFailureKeepsBusinessCodeAndMessage() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(
ApiResponse.<PartnerClient.Partner>failure(ResultCode.NOT_FOUND,"往来单位不存在",null,null));
@MockBean private WarehouseClient warehouses;
@Resource private OrderMasterDataGuard guard;
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
@Test
void validatesWarehousesWithoutTreatingCustomerAsWarehousePartner() {
when(warehouses.active("token", PUBLIC_TENANT_ID, 8L))
.thenReturn(ApiResponse.success(warehouse(8L, "DOMESTIC", true)));
var result = guard.validate(20002L, request(8L, "PRIVATE", null));
assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("仓库8");
verify(warehouses).active("token", PUBLIC_TENANT_ID, 8L);
}
@Test
void persistedCustomerMustMatchAuthenticatedEnterprise() {
assertThatThrownBy(() -> guard.validatePersisted(20002L, 30003L, 8L, null, "PRIVATE"))
.isInstanceOf(BizException.class)
.hasMessage("往来单位不存在")
.hasMessage("订单客户必须是当前登录企业")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
verifyNoInteractions(warehouses);
.isEqualTo(ResultCode.FORBIDDEN);
}
@Test
void masterDataResponseWithNullElementReturnsControlledBusinessError() {
var partner=new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,PUBLIC_TENANT_ID,"CN-SZX-01","深圳仓","DOMESTIC","CN","ACTIVE"),
Collections.singletonList(null), Collections.singletonList(null));
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(partner));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(wh));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
@Test
void customerTenantCanUsePublicDomesticReceivingWarehouse() {
when(warehouses.active("token", PUBLIC_TENANT_ID, 8L))
.thenReturn(ApiResponse.success(warehouse(8L, "DOMESTIC", true)));
guard.validate(20002L, request(8L, "PRIVATE", null));
verify(warehouses).active("token", PUBLIC_TENANT_ID, 8L);
}
@Test
void masterDataResponseMissingSnapshotNameIsServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,null,"ACTIVE","SHIPPER",List.of("SHIPPER"))));
void destinationWarehouseTypeMustMatchDestinationType() {
when(warehouses.active("token", PUBLIC_TENANT_ID, 8L))
.thenReturn(ApiResponse.success(warehouse(8L, "DOMESTIC", true)));
when(warehouses.active("token", PUBLIC_TENANT_ID, 9L))
.thenReturn(ApiResponse.success(warehouse(9L, "OVERSEAS", false)));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
verifyNoInteractions(warehouses);
}
@Test
void downstreamNonFeignRuntimeExceptionMapsToServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("解码失败"));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamForbiddenMapsToServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(403));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamHttpNotFoundReturnsPlatformPartnerUnavailableMessage() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(404));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.hasMessage("平台往来单位不存在或不可用")
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
}
@Test
void wrappedDownstreamHttpConflictMapsToServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(new IllegalStateException("wrapped", feign(409)));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void downstreamServerErrorMapsToServiceUnavailable() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenThrow(feign(500));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void inactiveMasterDataInSuccessfulResponseMapsToConflict() {
when(partners.active("token",PUBLIC_TENANT_ID,7))
.thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","INACTIVE","SHIPPER",List.of("SHIPPER"))));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
}
@Test
void partnerWithoutShipperTypeIsRejected() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE"))));
assertThatThrownBy(() -> guard.validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
verifyNoInteractions(warehouses);
}
@Test
void customerTenantCanUseDomesticWarehouseFromPublicTenant() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
guard.validate(20002,request(7L,8L));
verify(warehouses).active("token",PUBLIC_TENANT_ID,8);
}
@Test
void customersAAndBUseTheSamePlatformWarehouseAndPartner() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"平台共享单位","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
guard.validate(20002,request(7L,8L));
guard.validate(30003,request(7L,8L));
verify(partners,times(2)).active("token",PUBLIC_TENANT_ID,7);
verify(warehouses,times(2)).active("token",PUBLIC_TENANT_ID,8);
}
@Test
void orderWithoutPartnerUsesPublicWarehouseWithoutPartnerLookup() {
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
var result=guard
.validate(20002,request(null,8L));
assertThat(result.partner()).isNull();
verifyNoInteractions(partners);
verify(warehouses).active("token",PUBLIC_TENANT_ID,8);
}
@Test
void fbaDestinationMustReferenceMatchingPublicWarehouse() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
when(warehouses.active("token",PUBLIC_TENANT_ID,9)).thenReturn(ApiResponse.success(
warehouse(9L,"OVERSEAS",false)));
assertThatThrownBy(() -> guard
.validate(20002,request(7L,8L,"FBA",9L)))
assertThatThrownBy(() -> guard.validate(20002L, request(8L, "FBA", 9L)))
.isInstanceOf(BizException.class)
.hasMessageContaining("目的仓库类型必须为 FBA");
}
@Test
void privateDestinationCannotReferencePublicWarehouse() {
when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,PUBLIC_TENANT_ID,"客户","ACTIVE","SHIPPER",List.of("SHIPPER"))));
when(warehouses.active("token",PUBLIC_TENANT_ID,8)).thenReturn(ApiResponse.success(
warehouse(8L,"DOMESTIC",true)));
assertThatThrownBy(() -> guard
.validate(20002,request(7L,8L,"PRIVATE",9L)))
.isInstanceOf(BizException.class)
.hasMessageContaining("私人地址不能引用公共仓库");
verify(warehouses,never()).active("token",PUBLIC_TENANT_ID,9);
private OrderRequests.SaveRequest request(long receivingWarehouseId, String destinationType,
Long destinationWarehouseId) {
var party = new OrderRequests.Party("公司", "联系人", "13800138000", null,
"CN", null, "深圳", null, "地址", null);
var cargo = new OrderRequests.Cargo("货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null);
return new OrderRequests.SaveRequest(null, "当前企业", "SEA", null, "CN", destinationWarehouseId,
"目的仓", null, null, party, party, List.of(cargo), "PICKUP", receivingWarehouseId,
"收货仓", "CH", "渠道", null, null, "BUY_DECLARATION", null, "LCL", false,
destinationType, null, null, null);
}
private FeignException feign(int status) {
Request request = Request.create(Request.HttpMethod.GET, "http://warehouse-service/internal/test",
Map.of(), null, StandardCharsets.UTF_8, null);
Response response = Response.builder().status(status).reason("test").request(request)
.headers(Map.of()).build();
return FeignException.errorStatus("masterData", response);
}
private OrderRequests.SaveRequest request(Long partner,Long warehouse) {
return request(partner,warehouse,"PRIVATE",null);
}
private OrderRequests.SaveRequest request(Long partner,Long warehouse,String destinationType,Long destinationWarehouseId) {
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null,
null,null,null,null,null,null);
return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",null,null,"BUY_DECLARATION",null,"LCL",false,destinationType,null,null,null);
}
private WarehouseClient.Detail warehouse(long id,String type,boolean receiving) {
var value=new WarehouseClient.Warehouse(id,PUBLIC_TENANT_ID,"WH-"+id,"仓库"+id,type,"CN","ACTIVE");
var address=new WarehouseClient.Address("CN","广东省","深圳市",null,null,"示例地址",null);
var capabilities=receiving
? List.of(new WarehouseClient.Capability("RECEIVING",true,null))
private WarehouseClient.Detail warehouse(long id, String type, boolean receiving) {
var value = new WarehouseClient.Warehouse(id, PUBLIC_TENANT_ID, "WH-" + id, "仓库" + id,
type, "CN", "ACTIVE");
var address = new WarehouseClient.Address("CN", "广东省", "深圳市", null, null, "示例地址", null);
var capabilities = receiving
? List.of(new WarehouseClient.Capability("RECEIVING", true, null))
: List.<WarehouseClient.Capability>of();
return new WarehouseClient.Detail(value,List.of(address),capabilities);
return new WarehouseClient.Detail(value, List.of(address), capabilities);
}
}
@@ -46,7 +46,7 @@ test('writes carry marker idempotency and include replay and stale-version confl
assert.match(source, /错误 sourceVersion 调整产生并发版本冲突/)
})
test('uses existing ACTIVE partner and never creates one', () => {
assert.match(source, /\/api\/platform\/v1\/partners\?page=1&size=200&status=ACTIVE/)
assert.doesNotMatch(source, /request\(context, 'POST', '\/api\/platform\/v1\/partners'/)
test('uses existing ACTIVE Warehouse supplier and never creates one', () => {
assert.match(source, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/)
assert.doesNotMatch(source, /\/api\/platform\/v1\/partners/)
})
@@ -34,13 +34,14 @@ async function loadFeeItem(context, direction) {
const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const data = context.apiSuccess(MODULE, `读取 ${direction} ACTIVE 费用项目`, response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true : item.warehousePayable === true)) || null
? item.warehouseReceivable === true
: item.warehousePayable === true && /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`))) || null
}
async function loadActivePartner(context) {
const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE')
const data = context.apiSuccess(MODULE, '读取现有 ACTIVE 往来单位', response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE') || null
const response = await context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING')
const data = context.apiSuccess(MODULE, '读取 Warehouse ACTIVE 订舱供应商', response, 'category=BOOKING', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING') || null
}
async function createFee(context, resource, item, spec, partner) {
@@ -50,7 +51,7 @@ async function createFee(context, resource, item, spec, partner) {
feeItemId: Number(item.id), direction: spec.direction, businessDate: spec.businessDate,
unitPrice: spec.amount, currency: 'CNY', amount: spec.amount, settlementIncluded: true,
partnerId: spec.direction === 'PAYABLE' ? String(partner.id) : null,
partnerName: spec.direction === 'PAYABLE' ? (partner.partnerName || partner.name || null) : null,
partnerName: spec.direction === 'PAYABLE' ? (partner.name || null) : null,
remark: `${FIXED_MARKER}-${spec.name}`,
}
const response = await request(context, 'POST', path, body, `create-${spec.name}`)
@@ -46,7 +46,8 @@ test('扩展财务验收输出可回传资源与柜次部署阻断', () => {
assert.match(source, /containerPayableDeploymentBlocker/)
})
test('按柜 PAYABLE 费用携带有效往来单位', () => {
test('按柜 PAYABLE 费用携带 Warehouse 供应商', () => {
assert.match(fixtureSource, /partnerId: id\(supplier\.id\)/)
assert.match(fixtureSource, /partnerName: supplier\.partnerName \|\| null/)
assert.match(fixtureSource, /partnerName: supplier\.name \|\| null/)
assert.match(fixtureSource, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/)
})
@@ -49,16 +49,17 @@ async function runOwnedFixture(context) {
}
async function loadPayableInputs(context) {
const [feeItemsResponse, partnersResponse] = await Promise.all([
const [feeItemsResponse, suppliersResponse] = await Promise.all([
context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200'),
context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE'),
context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING'),
])
const feeItems = rows(data(feeItemsResponse))
const partners = rows(data(partnersResponse))
const suppliers = rows(data(suppliersResponse))
return {
feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true) || null,
supplier: partners.find((item) => item?.status === 'ACTIVE' && item?.id) || null,
response: !ok(feeItemsResponse) ? feeItemsResponse : partnersResponse,
feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true
&& /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`)) || null,
supplier: suppliers.find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING' && item?.id) || null,
response: !ok(feeItemsResponse) ? feeItemsResponse : suppliersResponse,
}
}
@@ -68,7 +69,7 @@ async function createWaybillPayable(context, fixture, order, input, index) {
const path = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(fixture.customerId)}`
const body = { feeItemId: Number(input.feeItem.id), direction: 'PAYABLE', businessDate: dateOffset(-3 - index),
unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', settlementIncluded: true,
partnerId: id(input.supplier.id), partnerName: input.supplier.partnerName || null,
partnerId: id(input.supplier.id), partnerName: input.supplier.name || null,
remark: `${context.marker}-WAYBILL-PAYABLE-${index + 1}` }
const response = await write(context, 'POST', path, body, `waybill-payable-${index + 1}`)
const detail = context.apiSuccess(MODULE, `创建第 ${index + 1} 条独占运单 PAYABLE 来源`, response,
@@ -82,7 +83,7 @@ async function createWaybillPayable(context, fixture, order, input, index) {
return { sourceFeeId, inboundId, orderId: id(order.orderId),
waybillId: id(order.waybillId ?? order.shipmentId ?? order.orderNo),
businessDate: body.businessDate, amount: body.amount,
supplierId: id(input.supplier.id), supplierName: input.supplier.partnerName || null }
supplierId: id(input.supplier.id), supplierName: input.supplier.name || null }
}
async function resolvePoolSources(context, fixture, created) {
@@ -71,7 +71,7 @@ function hookSet({ missing = false } = {}) {
orderId: `O-${suffix}`, orderNo: `NO-${suffix}`, waybillId: suffix === 'AUTO1' ? 'WB-1' : null, inboundId: `I-${suffix}` }),
measureOrder: async (context, resource) => ({ ...resource, measurementId: `M-${resource.orderId}`, actualWeightKg: 12.5, totalVolumeCbm: 0.072 }),
loadFeeItem: async (context, direction) => ({ id: direction === 'PAYABLE' ? '902' : '901', status: 'ACTIVE' }),
loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }),
loadSupplier: async () => missing ? null : ({ id: 'SUPPLIER-1', name: '订舱供应商一', category: 'BOOKING', status: 'ACTIVE' }),
uploadVoucher: async () => missing ? null : 'VOUCHER-1',
verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }),
loadPublishedAutomaticFee: async (context, resource) => {
@@ -104,8 +104,8 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup
assert.deepEqual(new Set(fixture.feeSources.map((item) => item.billingBasis).filter((value) => ['WEIGHT_KG', 'VOLUME_CBM'].includes(value))),
new Set(['WEIGHT_KG', 'VOLUME_CBM']))
assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2)
assert.equal(fixture.supplier.supplierId, 'PARTNER-1')
assert.equal(fixture.supplier.supplierName, '往来单位一')
assert.equal(fixture.supplier.supplierId, 'SUPPLIER-1')
assert.equal(fixture.supplier.supplierName, '订舱供应商一')
assert.equal(fixture.voucherFileId, 'VOUCHER-1')
assert.ok(Array.isArray(fixture.allocations))
assert.ok(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').length >= 4)
@@ -143,7 +143,7 @@ test('run behavior preserves null IDs and deployment blockers when supplier, vou
assert.deepEqual(fixture.container, { containerBookingId: null, containerNo: null })
assert.deepEqual(fixture.allocations, [])
assert.ok(fixture.deploymentBlocker.length >= 3)
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('往来单位')))
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('供应商')))
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('voucherFileId')))
})
@@ -105,7 +105,8 @@ async function loadFeeItem(context, direction) {
const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS)
return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true : item.warehousePayable === true)) || null
? item.warehouseReceivable === true
: item.warehousePayable === true && /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`))) || null
}
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, partner = null) {
@@ -114,7 +115,7 @@ async function createManualFee(context, resource, feeItem, direction, amount, ba
const listPath = `${basePath}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = { feeItemId: Number(feeItem.id), direction, businessDate,
unitPrice: amount, currency: 'CNY', amount, settlementIncluded: true,
partnerId: partner?.id ? String(partner.id) : null, partnerName: partner?.partnerName || null,
partnerId: partner?.id ? String(partner.id) : null, partnerName: partner?.name || null,
remark: `${context.marker}-${basis}` }
const response = await request(context, 'POST', listPath, body)
const detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS)
@@ -201,9 +202,9 @@ async function loadPublishedAutomaticFee(context, resource) {
async function loadSupplier(context) {
if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context)
const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE')
const page = context.apiSuccess(MODULE, '查询真实供应商往来单位', response, '', OPTIONS)
return rows(page).find((item) => item?.status === 'ACTIVE') || null
const response = await context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING')
const page = context.apiSuccess(MODULE, '查询 Warehouse ACTIVE 订舱供应商', response, 'category=BOOKING', OPTIONS)
return rows(page).find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING') || null
}
async function uploadVoucher(context, customerId) {
@@ -352,7 +353,7 @@ export async function run(context) {
PAYABLE: await loadFeeItem(context, 'PAYABLE'),
}
const supplier = await loadSupplier(context)
if (!supplier?.id) blocker(context, '取得真实 PAYABLE 往来单位', '未找到 ACTIVE 往来单位;不能伪造 supplierId/partnerId')
if (!supplier?.id) blocker(context, '取得 Warehouse PAYABLE 供应商', '未找到 ACTIVE BOOKING 供应商;不能伪造 supplierId/partnerId')
for (const direction of ['RECEIVABLE', 'PAYABLE']) {
if (!feeItems[direction]) context.addCoverageGap(MODULE, `missing-${direction.toLowerCase()}-fee-item`, `${direction} 人工费用 fixture`,
`没有找到带 warehouse${direction === 'RECEIVABLE' ? 'Receivable' : 'Payable'}=true 的 ACTIVE 费用项目,禁止猜测费用项目 ID`, 'missing-deployed-fee-item')
@@ -381,7 +382,7 @@ export async function run(context) {
for (const [index, manualResource] of manualResources.entries()) {
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10,
'MANUAL_ORDER_FEE', dateOffset(-3 - index), supplier)
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null })
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.name || null })
}
}
@@ -395,7 +396,7 @@ export async function run(context) {
const path = `${basePath}?accountId=${encodeURIComponent(container.tenantId)}`
const body = { feeItemId: Number(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2),
amount: 300, currency: 'CNY', settlementIncluded: true, partnerId: id(supplier.id),
partnerName: supplier.partnerName || null, remark: `${context.marker}-CONTAINER_SHARED` }
partnerName: supplier.name || null, remark: `${context.marker}-CONTAINER_SHARED` }
const response = await request(context, 'POST', path, body)
if (response?.http === 404) {
blocker(context, '按柜费用来源接口已部署并可路由',
@@ -455,7 +456,7 @@ export async function run(context) {
feeSources: fixtures,
container: containerFixture ? { containerBookingId: containerFixture.containerBookingId,
containerNo: containerFixture.containerNo } : { containerBookingId: null, containerNo: null },
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null } : null,
allocations, supplier: supplier?.id ? { supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.name || null } : null,
voucherFileId, pricingEvidence,
deploymentBlocker: context.steps.slice(start).filter((step) => step.deploymentBlocker).map((step) => step.evidence),
}
@@ -22,6 +22,9 @@ test('retains all clickable container, loading-list and PDA workflow states thro
const transportIndex = source.indexOf('`${CONTAINERS}/${containerId}/transport`')
assert.ok(transportIndex >= 0 && pushIndex > transportIndex)
assert.match(source, /transported\?\.cabinetNo !== container\?\.cabinetNo/)
assert.match(source, /shippingCompanyId: null, shippingCompanyName: null/)
assert.match(source, /bookingAgentId: null, bookingAgentName: null/)
assert.match(source, /billOfLadingNo: confirmed\?\.oceanBooking\?\.billOfLadingNo/)
const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete'))
assert.doesNotMatch(batch, /containerNo:/)
})
@@ -102,8 +102,8 @@ async function createConfirmedContainer(context, source, label) {
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
channelCode: summary.channelCode, warehouseId: source.warehouseId,
warehouseName: summary.warehouseName, billOfLadingNo: `BL-${value}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`,
bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX',
shippingCompanyId: null, shippingCompanyName: null,
bookingAgentId: null, bookingAgentName: null, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -140,9 +140,11 @@ async function createConfirmedContainer(context, source, label) {
throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`)
}
const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmed?.oceanBooking?.version), billOfLadingNo: `BL-${value}`.slice(0, 128),
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
expectedVersion: Number(confirmed?.oceanBooking?.version),
billOfLadingNo: confirmed?.oceanBooking?.billOfLadingNo,
vesselName: confirmed?.oceanBooking?.vesselName, voyageNo: confirmed?.oceanBooking?.voyageNo,
estimatedSailingAt: confirmed?.oceanBooking?.estimatedSailingAt,
estimatedArrivalAt: confirmed?.oceanBooking?.estimatedArrivalAt,
reason: context.marker,
}, `${label}-booking-release`)
context.apiSuccess(MODULE, `${label} 放舱已关联真实订舱`, releasedResponse, '', OPTIONS)
@@ -28,5 +28,8 @@ test('retains only operational ocean booking states from direct CONFIRMED regist
assert.match(source, /warehouseId: oceanBooking\.warehouseId/)
assert.doesNotMatch(source, /customerId: ['"]10001['"]/)
assert.doesNotMatch(source, /warehouseId: ['"]193000000000200001['"]/)
assert.match(source, /carrierId: null, carrierName: null/)
assert.doesNotMatch(source, /人工验收车队-/)
assert.match(source, /billOfLadingNo: value\.oceanBooking\.billOfLadingNo/)
assert.doesNotMatch(source, /DELETE/)
})
@@ -26,8 +26,9 @@ function oceanPayload(context, template, index) {
billOfLadingNo: `BL-${suffix(context, index)}`, vesselName: `人工轮-${suffix(context, index)}`,
voyageNo: `V${suffix(context, index)}`,
shippingCompanyId: source.shippingCompanyId,
shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId,
bookingAgentName: source.bookingAgentName,
shippingCompanyName: source.shippingCompanyId ? source.shippingCompanyName : null,
bookingAgentId: source.bookingAgentId,
bookingAgentName: source.bookingAgentId ? source.bookingAgentName : null,
departurePortCode: source.departurePortCode, departurePortName: source.departurePortName,
destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName,
placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm,
@@ -68,9 +69,9 @@ async function buildOcean(context, template, status, index) {
vesselName: `变更轮-${suffix(context, index)}`, voyageNo: `CV${suffix(context, index)}`,
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' })
if (status === 'RELEASED') return oceanAction(context, value, 'release', {
billOfLadingNo: `REL-BL-${suffix(context, index)}`, vesselName: `放舱轮-${suffix(context, index)}`,
voyageNo: `RV${suffix(context, index)}`, estimatedSailingAt: '2026-09-16T08:00:00',
estimatedArrivalAt: '2026-10-16T08:00:00' })
billOfLadingNo: value.oceanBooking.billOfLadingNo, vesselName: value.oceanBooking.vesselName,
voyageNo: value.oceanBooking.voyageNo, estimatedSailingAt: value.oceanBooking.estimatedSailingAt,
estimatedArrivalAt: value.oceanBooking.estimatedArrivalAt })
throw new Error(`unsupported ocean status ${status}`)
}
@@ -80,7 +81,7 @@ function truckPayload(context, oceanBooking, index) {
containerBookingId: null, loadingPlanId: null,
warehouseId: oceanBooking.warehouseId, customerId: oceanBooking.customerId,
customerName: oceanBooking.customerName,
carrierName: `人工验收车队-${value}`, fleetContact: '人工验收联系人', fleetPhone: '13800000004',
carrierId: null, carrierName: null, fleetContact: '人工验收联系人', fleetPhone: '13800000004',
driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`,
storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006',
storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000',
@@ -40,6 +40,10 @@ test('shared booking data omits server-owned channelName and includes offline-co
assert.ok(draft[key], `${key} is required`)
}
assert.equal(draft.channelName, undefined)
assert.equal(draft.shippingCompanyId, null)
assert.equal(draft.shippingCompanyName, null)
assert.equal(draft.bookingAgentId, null)
assert.equal(draft.bookingAgentName, null)
assert.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO')
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
@@ -56,6 +60,15 @@ test('shared booking data omits server-owned channelName and includes offline-co
}
})
test('shared booking data never sends supplier names without valid supplier ids', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-SUPPLIER' }, 'supplier', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
}, { shippingCompanyName: '无来源船公司', bookingAgentName: '无来源代理' })
assert.equal(draft.shippingCompanyName, null)
assert.equal(draft.bookingAgentName, null)
})
test('shared booking draft can omit customer identity without inventing placeholders', () => {
const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', {
customerId: '21', customerName: '不应写入', channelCode: 'SEA-US', channelName: '美西海运',
@@ -57,8 +57,10 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
return {
...writableMaster,
billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`,
shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`,
bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`,
shippingCompanyId: labels.shippingCompanyId ?? null,
shippingCompanyName: labels.shippingCompanyId ? labels.shippingCompanyName ?? null : null,
bookingAgentId: labels.bookingAgentId ?? null,
bookingAgentName: labels.bookingAgentId ? labels.bookingAgentName ?? null : null,
vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V-${suffix}`,
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -103,10 +103,12 @@ test('HTTP fixtures cover unassigned CONFIRMED persistence and assigned customer
})
test('independent ocean booking release reuses the server-frozen transport snapshot', () => {
assert.match(bookingModule, /estimatedSailingAt: detail\.oceanBooking\.estimatedSailingAt/)
assert.match(bookingModule, /estimatedArrivalAt: detail\.oceanBooking\.estimatedArrivalAt/)
assert.match(bookingModule, /const releaseSnapshotResponse = await request\(context, 'GET', path\)/)
assert.match(bookingModule, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(bookingModule, /const frozenTransportValid = transportFields\.every/)
assert.match(bookingModule, /if \(!frozenTransportValid\) return/)
assert.match(bookingModule, /const frozenTransportMatches = frozenTransportValid/)
assert.match(bookingModule, /if \(!frozenTransportMatches\) return/)
assert.match(bookingModule, /expectedVersion: version, \.\.\.frozenTransport, reason: context\.marker/)
assert.doesNotMatch(bookingModule, /expectedVersion: version, billOfLadingNo: `BL-\$\{suffix\}`/)
assert.doesNotMatch(bookingModule, /estimatedSailingAt: '2026-09-01T08:00:00'/)
@@ -141,7 +141,12 @@ export async function run(context) {
const path = `${BASE}/${bookingId}`
let version = Number(detail.oceanBooking.version)
const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-ocean-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d }
const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` })
const changed = await request(context, 'POST', `${path}/change-request`, {
expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`,
estimatedSailingAt: detail.oceanBooking.estimatedSailingAt,
estimatedArrivalAt: detail.oceanBooking.estimatedArrivalAt,
reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-change` })
context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
const releaseSnapshotResponse = await request(context, 'GET', path)
@@ -149,10 +154,11 @@ export async function run(context) {
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, releaseSnapshot?.oceanBooking?.[field]]))
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
context.assert(MODULE, '改船后回读完整冻结运输快照供放舱', releaseSnapshot, frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(changeConfirmed?.oceanBooking?.[field])),
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(changeConfirmed?.oceanBooking?.[field]))
context.assert(MODULE, '改船后回读完整冻结运输快照供放舱', releaseSnapshotResponse, frozenTransportMatches,
`bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS)
if (!frozenTransportValid) return
if (!frozenTransportMatches) return
const allocatedContainerBookingId = ''
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
@@ -116,8 +116,8 @@ export async function run(context) {
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
channelCode, warehouseId, warehouseName,
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: `验收船公司-${suffix}`,
bookingAgentId: null, bookingAgentName: `验收代理-${suffix}`,
shippingCompanyId: null, shippingCompanyName: null,
bookingAgentId: null, bookingAgentName: null,
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳',
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
@@ -240,10 +240,19 @@ export async function run(context) {
const bookingNo = id(bookingCreated?.oceanBooking?.bookingNo)
if (!bookingId) return
const confirmedBooking = bookingCreated
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, confirmedBooking?.oceanBooking?.[field]]))
const frozenTransportMatches = transportFields.every((field) => frozenTransport[field] != null
&& String(frozenTransport[field]).trim() !== ''
&& String(frozenTransport[field]) === String(bookingDraft[field]))
context.assert(MODULE, '一次登记直接返回 CONFIRMED 且尚未占用模拟柜', bookingCreate,
confirmedBooking?.oceanBooking?.status === 'CONFIRMED'
&& Array.isArray(confirmedBooking?.containerAllocations) && confirmedBooking.containerAllocations.length === 0,
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, allocations=${confirmedBooking?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '已确认订舱回显完整且一致的冻结运输快照', bookingCreate,
frozenTransportMatches,
transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', '), OPTIONS)
if (!frozenTransportMatches) return
const confirmedListResponse = await context.request('GET',
`/api/platform/v1/ocean-bookings?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`)
const confirmedList = context.apiSuccess(MODULE, '按真实订舱号查询正式列表', confirmedListResponse, '', OPTIONS)
@@ -362,19 +371,16 @@ export async function run(context) {
`HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS)
if (!confirmedTaskAbsent) return
const sailingAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const arrivalAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString()
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(confirmedBooking?.oceanBooking?.version),
billOfLadingNo: `BL-${suffix}`.slice(0, 128), vesselName: `VESSEL-${suffix}`.slice(0, 128),
voyageNo: `V-${suffix}`.slice(0, 64), estimatedSailingAt: sailingAt,
estimatedArrivalAt: arrivalAt, reason: context.marker,
...frozenTransport, reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) })
const releasedBooking = context.apiSuccess(MODULE, '订舱携带已冻结运输快照放舱并满足 PDA 门禁', releaseResponse, '', OPTIONS)
context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse,
releasedBooking?.oceanBooking?.status === 'RELEASED'
&& releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId),
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS)
&& releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId)
&& transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}, transportFrozen=${transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
if (releasedBooking?.oceanBooking?.status !== 'RELEASED') return
const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
@@ -276,7 +276,28 @@ export async function run(context) {
approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4,
`status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS)
const batchPayload = { customsAgentName: `代理-${suffix}`, externalReference: `EXT-${suffix}`,
const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', {
name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', scope: null, region: '深圳',
legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000',
email: 'customs-acceptance@example.com', address: '深圳市验收地址',
businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null,
status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker,
})
const customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商',
supplierCreateResponse, `marker=${context.marker}`, OPTIONS))
if (!customsAgentId) return
const supplierDetailResponse = await context.request('GET',
`/api/platform/v1/suppliers/${encodeURIComponent(customsAgentId)}?tenantId=0`)
const customsAgent = context.apiSuccess(MODULE, '回查本轮报关代理供应商', supplierDetailResponse, '', OPTIONS)
context.assert(MODULE, '代理提交使用本轮正式 CUSTOMS 供应商身份', supplierDetailResponse,
id(customsAgent?.id) === customsAgentId && customsAgent?.status === 'ACTIVE'
&& customsAgent?.category === 'CUSTOMS' && customsAgent?.remark === context.marker,
`customsAgentId=${id(customsAgent?.id) || 'missing'}, customsAgentName=${customsAgent?.name || 'missing'}`, OPTIONS)
if (id(customsAgent?.id) !== customsAgentId || customsAgent?.status !== 'ACTIVE'
|| customsAgent?.category !== 'CUSTOMS') return
context.createdResources.phase3CustomsSupplier = { marker: context.marker, supplierId: customsAgentId,
supplierName: customsAgent.name, status: customsAgent.status }
const batchPayload = { customsAgentId, externalReference: `EXT-${suffix}`,
note: context.marker, expectedVersion: 4 }
const batchResponse = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, batchPayload,
context.token, { 'Idempotency-Key': key(context, 'batch') })
@@ -298,7 +319,7 @@ export async function run(context) {
`HTTP=${batchReplay.http}, code=${codeOf(batchReplay)}, batchId=${id(batchReplay.body?.data?.id) || 'missing'}`, OPTIONS)
const staleSubmit = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, {
...batchPayload, customsAgentName: '不同请求',
...batchPayload, externalReference: `EXT-DIFFERENT-${suffix}`,
}, context.token, { 'Idempotency-Key': key(context, 'batch') })
context.addStep(MODULE, '同一幂等键不同代理提交请求被拒绝', staleSubmit,
staleSubmit.http === 200 && codeOf(staleSubmit) !== '200',
@@ -39,11 +39,11 @@ export async function run(context) {
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`)
const inbound = successful(inboundResponse) ? inboundResponse.body?.data : null
const serverLoading = inbound?.summary?.processStatus === 'LOADING'
context.assert(MODULE, '服务端入库进入 LOADING 后才开放装柜证据', inboundResponse,
serverLoading,
const evidenceReadable = ['LOADING', 'LOADED'].includes(inbound?.summary?.processStatus)
context.assert(MODULE, '服务端入库进入 LOADING 或 LOADED 后开放装柜证据只读回查', inboundResponse,
evidenceReadable,
`processStatus=${inbound?.summary?.processStatus || 'missing'}, taskWorkflow=${task?.workflowStatus || 'missing'}`, OPTIONS)
if (!serverLoading) {
if (!evidenceReadable) {
context.addCoverageGap(MODULE, OPTIONS.scenario, '服务端装柜阶段前不请求证据',
`taskId=${taskId}, inboundId=${inboundId}, loadingPlanId=${loadingPlanId}, containerBookingId=${containerBookingId}, processStatus=${inbound?.summary?.processStatus || 'missing'};AWAITING_REVIEW 不作为证据开放条件`, 'deployment-blocker')
return
@@ -14,8 +14,8 @@ test('evidence gate requires stable PDA identity and server LOADING before any e
assert.match(source, /task\?\.loadingPlanId\) === loadingPlanId/)
assert.match(source, /task\?\.containerBookingId\) === containerBookingId/)
assert.match(source, /taskItem\?\.inboundId\) === inboundId/)
assert.match(source, /processStatus === 'LOADING'/)
assert.match(source, /if \(!serverLoading\)[\s\S]*return/)
assert.match(source, /\['LOADING', 'LOADED'\]\.includes\(inbound\?\.summary\?\.processStatus\)/)
assert.match(source, /if \(!evidenceReadable\)[\s\S]*return/)
assert.match(source, /AWAITING_REVIEW 不作为证据开放条件/)
const gate = source.slice(source.indexOf('const taskResponse'), source.indexOf('const path ='))
assert.doesNotMatch(gate, /loading\/evidence/)
@@ -49,7 +49,8 @@ async function request(context, method, path, body, headers = {}) {
export async function run(context) {
const fixture = context.createdResources?.phase3Loading
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
if (!tenantId || !inboundId || fixture?.marker !== context.marker
const orderId = id(fixture?.orderId)
if (!tenantId || !orderId || !inboundId || fixture?.marker !== context.marker
|| fixture?.loadingPlanStatus !== 'COMPLETED') {
context.addStep(MODULE, '取得下游门禁待完成装柜 fixture', null, false,
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, marker=${fixture?.marker || 'missing'}, plan=${fixture?.loadingPlanStatus || 'missing'}`, OPTIONS)
@@ -93,6 +94,23 @@ export async function run(context) {
&& item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'),
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
const unifiedDetailPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
const unifiedDetailResponse = await context.request('GET', unifiedDetailPath)
const unifiedDetail = context.apiSuccess(MODULE, 'LOADED 后回查同一统一订单详情', unifiedDetailResponse, '', OPTIONS)
const orderNo = String(unifiedDetail?.summary?.orderNo || '').trim()
context.assert(MODULE, '统一订单详情投影同一订单为 LOADED', unifiedDetailResponse,
id(unifiedDetail?.summary?.orderId) === orderId && unifiedDetail?.summary?.mainStatus === 'LOADED'
&& Boolean(orderNo),
`orderId=${id(unifiedDetail?.summary?.orderId) || 'missing'}, mainStatus=${unifiedDetail?.summary?.mainStatus || 'missing'}, orderNo=${orderNo || 'missing'}`, OPTIONS)
if (id(unifiedDetail?.summary?.orderId) !== orderId || unifiedDetail?.summary?.mainStatus !== 'LOADED' || !orderNo) return
const unifiedListPath = `/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}`
const unifiedListResponse = await context.request('GET', unifiedListPath)
const unifiedPage = context.apiSuccess(MODULE, 'LOADED 后回查同一统一订单列表', unifiedListResponse, '', OPTIONS)
const unifiedRow = unifiedPage?.records?.find?.((row) => id(row?.orderId) === orderId)
context.assert(MODULE, '统一订单列表投影同一订单为 LOADED', unifiedListResponse,
Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED',
`orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS)
const evidencePath = `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`
const immutableEvidenceResponse = await context.request('GET', evidencePath)
const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS)
@@ -122,7 +122,6 @@ export async function run(context) {
warehouseId, warehouseName: initial?.summary?.warehouseName,
channelCode: initial?.summary?.channelCode,
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128),
shippingCompanyName: `P3-SHIPPING-${suffix}`, bookingAgentName: `P3-AGENT-${suffix}`,
vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -880,7 +879,8 @@ export async function run(context) {
`status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
context.createdResources.phase3Loading = {
tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)),
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
evidenceIds: evidenceItems.map((item) => id(item.id)),
voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`,
missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId,
immutableEvidenceId: id(firstPhoto?.id), immutableEvidenceVersion: Number(firstPhoto?.version),
@@ -111,6 +111,7 @@ test('bridge loading starts through a real PDA batch before preload confirmation
assert.match(value, /version: transportVersion/)
assert.match(value, /Number\.isInteger\(transportVersion\)/)
assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/)
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/)
assertOrder(value, [
@@ -123,6 +124,20 @@ test('bridge loading starts through a real PDA batch before preload confirmation
assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
})
test('finalize rechecks the same unified order detail and list as LOADED', () => {
const value = source('./phase3-loading-finalize.mjs')
assertOrder(value, [
'入库单终态 LOADED、版本递增且审计历史存在',
'LOADED 后回查同一统一订单详情',
'统一订单详情投影同一订单为 LOADED',
'LOADED 后回查同一统一订单列表',
'统一订单列表投影同一订单为 LOADED',
])
assert.match(value, /summary\?\.mainStatus === 'LOADED'/)
assert.match(value, /unifiedRow\?\.mainStatus === 'LOADED'/)
assert.match(source('./phase3-loading.mjs'), /tenantId, orderId: id\(source\?\.orderId\), inboundId/)
})
test('PDA inventory snapshot gates batch and records the only safe mutation gap', () => {
const loading = source('./phase3-loading.mjs')
assertOrder(loading, [
@@ -285,6 +300,14 @@ test('container loading enforces channel isolation and stable PDA identity acros
assert.match(value, /id\(replacedTask\?\.cabinetNo\) === stableIdentity\.cabinetNo/)
assert.match(value, /afterReplacementSnapshot\?\.snapshotToken === beforeReplacementSnapshot\?\.snapshotToken/)
assert.match(value, /pdaIdentityStable: true/)
assert.match(value, /shippingCompanyId: null, shippingCompanyName: null/)
assert.match(value, /bookingAgentId: null, bookingAgentName: null/)
assert.doesNotMatch(value, /shippingCompanyName: `验收船公司-/)
assert.doesNotMatch(value, /bookingAgentName: `验收代理-/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
assert.match(value, /if \(!frozenTransportMatches\) return/)
assert.match(value, /expectedVersion: Number\(confirmedBooking\?\.oceanBooking\?\.version\),\s*\.\.\.frozenTransport, reason: context\.marker/s)
assert.doesNotMatch(value, /const sailingAt = new Date\(Date\.now\(\)/)
const batch = value.slice(value.indexOf('const staleBatchResponse'), value.indexOf('const completePath'))
assert.doesNotMatch(batch, /containerNo:/)
})
@@ -355,6 +378,11 @@ test('customs provisions released booking and completed truck for the selected l
])
assert.match(value, /oceanBookings\.run\(context\)/)
assert.match(value, /serviceCenter\.run\(context\)/)
assert.match(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
assert.match(value, /category: 'CUSTOMS'/)
assert.match(value, /context\.createdResources\.phase3CustomsSupplier/)
assert.match(value, /const batchPayload = \{ customsAgentId,/)
assert.doesNotMatch(value, /customsAgentName: `代理-/)
})
test('downstream producers prefer the phase3Loading container fixture', () => {
@@ -376,6 +404,8 @@ test('downstream producers prefer the phase3Loading container fixture', () => {
assert.match(serviceCenter, /if \(!allocationMatches\) return/)
assert.match(serviceCenter, /const customerName = String\(oceanDetail\?\.oceanBooking\?\.customerName \|\| ''\)\.trim\(\)/)
assert.match(serviceCenter, /customerId, customerName, carrierId: null/)
assert.match(serviceCenter, /carrierId: null, carrierName: null/)
assert.doesNotMatch(serviceCenter, /链路承运商-/)
assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/)
const schedules = source('./phase3-port-schedules.mjs')
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containerAllocations \|\| \[\]\)/)
@@ -78,7 +78,7 @@ export async function run(context) {
warehouseId: linkContainer
? id(containerFlow.warehouseId)
: id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
customerId, customerName, carrierId: null, carrierName: `链路承运商-${suffix}`,
customerId, customerName, carrierId: null, carrierName: null,
fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008',
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000',
@@ -24,13 +24,13 @@ public final class MasterDataQueries {
@Schema(description = "编码、名称、联系人或联系电话关键字", example = "示例客户")
@Size(max = 128) String keyword,
@Schema(description = "往来单位类型", allowableValues = {
"CUSTOMER", "SUPPLIER", "BOTH", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String type,
"CUSTOMER", "CARRIER", "SHIPPER", "CONSIGNEE"})
@Pattern(regexp = "^(CUSTOMER|CARRIER|SHIPPER|CONSIGNEE)$") String type,
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
@Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status,
@Schema(description = "往来单位类型集合(可重复传参)", allowableValues = {
"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Size(max = 6) List<@Pattern(regexp = "^(CUSTOMER|SUPPLIER|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String> types
"CUSTOMER", "CARRIER", "SHIPPER", "CONSIGNEE"})
@Size(max = 4) List<@Pattern(regexp = "^(CUSTOMER|CARRIER|SHIPPER|CONSIGNEE)$") String> types
) {
public PartnerPageQuery {
if (page == 0) {
@@ -69,8 +69,8 @@ public final class MasterDataQueries {
@NotBlank @Size(max = 50) String partnerCode,
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
@NotBlank @Size(max = 128) String partnerName,
@Schema(description = "兼容旧客户端的单值类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String partnerType,
@Schema(description = "单值单位类型", allowableValues = {"CUSTOMER", "CARRIER", "SHIPPER", "CONSIGNEE"})
@Pattern(regexp = "^(CUSTOMER|CARRIER|SHIPPER|CONSIGNEE)$") String partnerType,
@Schema(description = "联系人", example = "张三")
@NotBlank @Size(max = 64) String contactName,
@Schema(description = "联系电话", example = "13800138000")
@@ -91,8 +91,8 @@ public final class MasterDataQueries {
@Size(max = 20) String postalCode,
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
@NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status,
@Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Size(max = 6) List<@Pattern(regexp = "^(CUSTOMER|SUPPLIER|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String> types,
@Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "CARRIER", "SHIPPER", "CONSIGNEE"})
@Size(max = 4) List<@Pattern(regexp = "^(CUSTOMER|CARRIER|SHIPPER|CONSIGNEE)$") String> types,
@Schema(description = "备注") @Size(max = 500) String remark
) {
}
@@ -26,10 +26,10 @@ public class PartnerEntity extends BaseEntity {
private String partnerCode;
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
private String partnerName;
@Schema(description = "类型", example = "CUSTOMER", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"})
@Schema(description = "类型", example = "CUSTOMER", allowableValues = {"CUSTOMER", "CARRIER", "SHIPPER", "CONSIGNEE"})
private String partnerType;
@TableField(exist = false)
@Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "CARRIER", "SHIPPER", "CONSIGNEE"})
private List<String> types;
@Schema(description = "联系人")
private String contactName;
@@ -37,30 +37,12 @@ public interface PartnerMapper extends BaseMapper<PartnerEntity> {
<if test="tenantId != null">
AND p.tenant_id = #{tenantId}
</if>
<choose>
<when test="type == 'BOTH'">
AND EXISTS (
SELECT 1 FROM wh_partner_type customer_type
WHERE customer_type.partner_id = p.id
AND customer_type.partner_type = 'CUSTOMER'
AND customer_type.tenant_id = p.tenant_id
)
AND EXISTS (
SELECT 1 FROM wh_partner_type supplier_type
WHERE supplier_type.partner_id = p.id
AND supplier_type.partner_type = 'SUPPLIER'
AND supplier_type.tenant_id = p.tenant_id
)
</when>
<otherwise>
AND EXISTS (
SELECT 1 FROM wh_partner_type pt
WHERE pt.partner_id = p.id
AND pt.partner_type = #{type}
AND pt.tenant_id = p.tenant_id
)
</otherwise>
</choose>
AND EXISTS (
SELECT 1 FROM wh_partner_type pt
WHERE pt.partner_id = p.id
AND pt.partner_type = #{type}
AND pt.tenant_id = p.tenant_id
)
<if test="status != null and status != ''">
AND p.status = #{status}
</if>
@@ -141,7 +141,6 @@ public class PartnerRepository {
private record PartnerKey(Long tenantId, Long partnerId) { }
private static List<String> legacyTypes(String type) {
if ("BOTH".equals(type)) return List.of("CUSTOMER", "SUPPLIER");
return StringUtils.hasText(type) ? List.of(type) : List.of();
}
}
@@ -34,7 +34,7 @@ public class PartnerImportService {
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "1";
private static final Set<String> ALLOWED_TYPES = Set.of(
"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE");
"CUSTOMER", "CARRIER", "SHIPPER", "CONSIGNEE");
private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
private static final String[] HEADERS = {
"单位编码*", "单位名称*", "单位类型*", "联系人*", "联系电话*", "联系邮箱",
@@ -53,7 +53,7 @@ public class PartnerImportService {
Row instruction = sheet.createRow(1);
instruction.createCell(0).setCellValue("说明");
instruction.createCell(1).setCellValue(
"仅新增;类型可用中文逗号或英文逗号分隔:CUSTOMER、SUPPLIER、CARRIER、CUSTOMS_BROKER、SHIPPER、CONSIGNEE;状态为 ACTIVE 或 INACTIVE");
"仅新增;类型可用中文逗号或英文逗号分隔:CUSTOMER、CARRIER、SHIPPER、CONSIGNEE;供应商与报关代理请在供应商中心维护;状态为 ACTIVE 或 INACTIVE");
Row header = sheet.createRow(2);
for (int i = 0; i < HEADERS.length; i++) {
header.createCell(i).setCellValue(HEADERS[i]);
@@ -162,7 +162,7 @@ public class PartnerImportService {
entity.setPartnerCode(row.code);
entity.setPartnerName(row.name);
entity.setTypes(row.types);
entity.setPartnerType(row.types.contains("CUSTOMER") && row.types.contains("SUPPLIER") ? "BOTH" : row.types.get(0));
entity.setPartnerType(row.types.get(0));
entity.setContactName(row.contactName);
entity.setContactPhone(row.contactPhone);
entity.setContactEmail(nullable(row.email));
@@ -23,7 +23,7 @@ import java.util.Locale;
public class PlatformPartnerQueryService {
private static final long PUBLIC_TENANT_ID = 0L;
private static final Set<String> ALLOWED_QUERY_TYPES = Set.of(
"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE", "BOTH");
"CUSTOMER", "CARRIER", "SHIPPER", "CONSIGNEE");
@Resource
private PartnerRepository repository;
@@ -143,18 +143,17 @@ public class PlatformPartnerQueryService {
if (requested != null) requested.forEach(type -> {
if (type != null && !type.isBlank()) normalized.add(type);
});
if (normalized.isEmpty() && "BOTH".equals(legacyType)) {
normalized.add("CUSTOMER");
normalized.add("SUPPLIER");
} else if (normalized.isEmpty() && legacyType != null && !legacyType.isBlank()) {
if (normalized.isEmpty() && legacyType != null && !legacyType.isBlank()) {
normalized.add(legacyType);
}
if (normalized.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "请至少选择一个单位类型");
if (normalized.stream().anyMatch(type -> !ALLOWED_QUERY_TYPES.contains(type))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "往来单位仅支持 CUSTOMER、CARRIER、SHIPPER、CONSIGNEE");
}
return List.copyOf(normalized);
}
private static String legacyType(List<String> types) {
if (types.contains("CUSTOMER") && types.contains("SUPPLIER")) return "BOTH";
return types.get(0);
}
}
@@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS wh_partner (
id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0',
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围',
partner_code VARCHAR(50) NOT NULL COMMENT '平台内唯一往来单位编码', partner_name VARCHAR(128) NOT NULL COMMENT '往来单位名称',
partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH',
partner_type VARCHAR(20) NOT NULL COMMENT '往来单位类型:CUSTOMER、CARRIER、SHIPPER或CONSIGNEE',
contact_name VARCHAR(64) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(32) NOT NULL COMMENT '联系电话',
contact_email VARCHAR(128) NULL COMMENT '联系邮箱', country_code CHAR(2) NOT NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省或州', city VARCHAR(64) NOT NULL COMMENT '城市', district VARCHAR(64) NULL COMMENT '区县',
@@ -15,13 +15,15 @@ CREATE TABLE IF NOT EXISTS wh_partner (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted),
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted)
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted),
CONSTRAINT chk_wh_partner_type CHECK (partner_type IN ('CUSTOMER','CARRIER','SHIPPER','CONSIGNEE'))
) ENGINE=InnoDB COMMENT='平台统一维护、供全部客户共享的往来单位主数据';
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL COMMENT '往来单位ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共往来单位固定使用 tenant_id=0',
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围,平台公共数据为0', partner_type VARCHAR(32) NOT NULL COMMENT '稳定业务类型:CUSTOMER客户、SHIPPER发货方等',
PRIMARY KEY(partner_id,partner_type),
KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id)
KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id),
CONSTRAINT chk_wh_partner_type_value CHECK (partner_type IN ('CUSTOMER','CARRIER','SHIPPER','CONSIGNEE'))
) ENGINE=InnoDB COMMENT='平台共享往来单位稳定业务类型';
INSERT INTO wh_partner(id,tenant_id,data_scope_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status)
VALUES(191000000000000001,0,0,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE')
@@ -25,12 +25,18 @@ class PlatformMasterDataControllerContractTest {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
for (String type : java.util.List.of(
"CUSTOMER", "SUPPLIER", "BOTH", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE")) {
"CUSTOMER", "CARRIER", "SHIPPER", "CONSIGNEE")) {
var query = new com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery(
1, 20, null, type, null, null);
assertThat(validator.validate(query)).isEmpty();
}
for (String removed : java.util.List.of("SUPPLIER", "CUSTOMS_BROKER", "BOTH")) {
var query = new com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery(
1, 20, null, removed, null, null);
assertThat(validator.validate(query)).isNotEmpty();
}
var invalid = new com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery(
1, 20, null, "UNKNOWN", null, null);
assertThat(validator.validate(invalid))
@@ -8,18 +8,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class PartnerMapperContractTest {
@Test
void bothTypeRequiresCustomerAndSupplierAssociations() throws Exception {
void typeFilterUsesOnlyStablePartnerTypeAssociation() throws Exception {
Select select = PartnerMapper.class.getDeclaredMethod("selectPartnerPageByType",
Page.class, Long.class, String.class, String.class, String.class)
.getAnnotation(Select.class);
String sql = String.join("\n", select.value());
assertThat(sql)
.contains("<when test=\"type == 'BOTH'\">")
.contains("customer_type.partner_type = 'CUSTOMER'")
.contains("supplier_type.partner_type = 'SUPPLIER'")
.contains("<otherwise>")
.contains("pt.partner_type = #{type}");
assertThat(sql).contains("pt.partner_type = #{type}")
.doesNotContain("type == 'BOTH'", "supplier_type.partner_type = 'SUPPLIER'");
}
@Test
@@ -89,7 +89,7 @@ class PartnerRepositoryTest {
PartnerEntity customerA = new PartnerEntity();
customerA.setId(7L); customerA.setTenantId(10001L); customerA.setPartnerType("CUSTOMER");
PartnerEntity customerB = new PartnerEntity();
customerB.setId(7L); customerB.setTenantId(10002L); customerB.setPartnerType("SUPPLIER");
customerB.setId(7L); customerB.setTenantId(10002L); customerB.setPartnerType("SHIPPER");
Page<PartnerEntity> source = new Page<>(1, 20);
source.setRecords(List.of(customerA, customerB));
source.setTotal(2);
@@ -98,14 +98,14 @@ class PartnerRepositoryTest {
PartnerTypeEntity typeA = new PartnerTypeEntity();
typeA.setTenantId(10001L); typeA.setPartnerId(7L); typeA.setPartnerType("CUSTOMER");
PartnerTypeEntity typeB = new PartnerTypeEntity();
typeB.setTenantId(10002L); typeB.setPartnerId(7L); typeB.setPartnerType("SUPPLIER");
typeB.setTenantId(10002L); typeB.setPartnerId(7L); typeB.setPartnerType("SHIPPER");
when(mapper.selectTypes(List.of(10001L, 10002L), List.of(7L, 7L)))
.thenReturn(List.of(typeA, typeB));
repository.page(null, new PartnerPageQuery(1, 20, null, null, null, null));
assertThat(customerA.getTypes()).containsExactly("CUSTOMER");
assertThat(customerB.getTypes()).containsExactly("SUPPLIER");
assertThat(customerB.getTypes()).containsExactly("SHIPPER");
}
@Test