优化
This commit is contained in:
@@ -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:}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
@@ -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),
|
||||
|
||||
+52
@@ -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));
|
||||
|
||||
+5
-6
@@ -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')
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user