diff --git a/config/nacos/cn/finance-service.yml b/config/nacos/cn/finance-service.yml index 5bf4ba68c..a41070aa9 100644 --- a/config/nacos/cn/finance-service.yml +++ b/config/nacos/cn/finance-service.yml @@ -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:} diff --git a/config/nacos/local/finance-service.yml b/config/nacos/local/finance-service.yml index 3c78aee86..562d7e0a8 100644 --- a/config/nacos/local/finance-service.yml +++ b/config/nacos/local/finance-service.yml @@ -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:} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f9f53e5b2..b41c4b217 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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', '备注'); diff --git a/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java b/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java new file mode 100644 index 000000000..71449119d --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java @@ -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 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) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java index 69d61ead8..e3d49e7fc 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -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", "柜子归属不一致"); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 5df93c394..79fea18bb 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -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 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())) { diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index a81311e13..11a456340 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -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 args = new ArrayList<>(); args.addAll(ids); List> 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 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 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 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"), diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java new file mode 100644 index 000000000..ae96703f3 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSupplierValidator.java @@ -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 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; + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index aee0b8c8d..57f8ddad0 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -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 values = ArgumentCaptor.forClass(Object[].class); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); assertThat(values.getValue()[14].toString()) diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index 5c2495798..bdbc2efb6 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -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) { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index df28cad86..65094240b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -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\"}"))); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java new file mode 100644 index 000000000..5988a202c --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java @@ -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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index be53482d9..5948814f4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -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), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java new file mode 100644 index 000000000..2fd9f845e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java @@ -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("不一致"); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 75b7693b4..185dcbcbf 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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()); diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index 3840cb691..f2bf8af87 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -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 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 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) { } } diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 505d38937..f3b7d2825 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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) { diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 6b3fc6e73..afdbe3649 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -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.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.of(); - return new WarehouseClient.Detail(value,List.of(address),capabilities); + return new WarehouseClient.Detail(value, List.of(address), capabilities); } } diff --git a/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs index 1a18d9957..f07805750 100644 --- a/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs @@ -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/) }) diff --git a/scripts/admin-test-env-modules/finance-extended-fees.mjs b/scripts/admin-test-env-modules/finance-extended-fees.mjs index 43f586dbf..2292109ba 100644 --- a/scripts/admin-test-env-modules/finance-extended-fees.mjs +++ b/scripts/admin-test-env-modules/finance-extended-fees.mjs @@ -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}`) diff --git a/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs b/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs index d66515b9e..42c1cd5f0 100644 --- a/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-extended-settlement-contract.test.mjs @@ -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/) }) diff --git a/scripts/admin-test-env-modules/finance-extended-settlement.mjs b/scripts/admin-test-env-modules/finance-extended-settlement.mjs index 897575295..16a623ed9 100644 --- a/scripts/admin-test-env-modules/finance-extended-settlement.mjs +++ b/scripts/admin-test-env-modules/finance-extended-settlement.mjs @@ -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) { diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index f5438fa79..17f8eb4b9 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -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'))) }) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 7d7eace8d..6a54f00bb 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -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), } diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 5e891039d..28c62b923 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -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:/) }) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index c341f861e..64d0ab42c 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs index ae7e0b276..7d3cd63d0 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -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/) }) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 0ee369772..26d68590e 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -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', diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index 719181310..1a9e8dcd5 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -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: '美西海运', diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 50fa4c3ac..1ed071ace 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -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', diff --git a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs index bf0c7d42e..2d10f7bc8 100644 --- a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs @@ -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'/) diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 3df4837ea..3abec50f5 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index df4cd8986..8f0928275 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -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}`) diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index 08c6d38fc..d868351eb 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -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', diff --git a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs index f06d07a6a..f7085d98d 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs index 071d4c070..aaf0d0a81 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs @@ -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/) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 4101099fa..64c9a9ac1 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 2451863d8..c371337ef 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -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), diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index a78a4e5b9..89e18295e 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -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 \|\| \[\]\)/) diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 37da9e2db..5c4b55b69 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -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', diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java index 9b7d616ca..ec19eaa24 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java @@ -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 ) { } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java index 5a24de83b..fd5b437f5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java @@ -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 types; @Schema(description = "联系人") private String contactName; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java index b7998c1c3..2b6ccb687 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java @@ -37,30 +37,12 @@ public interface PartnerMapper extends BaseMapper { AND p.tenant_id = #{tenantId} - - - 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 - ) - - - 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 - ) - - + 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 + ) AND p.status = #{status} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java index df0653335..dbe71510a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java @@ -141,7 +141,6 @@ public class PartnerRepository { private record PartnerKey(Long tenantId, Long partnerId) { } private static List legacyTypes(String type) { - if ("BOTH".equals(type)) return List.of("CUSTOMER", "SUPPLIER"); return StringUtils.hasText(type) ? List.of(type) : List.of(); } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java index beb2d16f5..2ea72dd28 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java @@ -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 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)); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java index 69e1eb3b1..918cbffde 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java @@ -23,7 +23,7 @@ import java.util.Locale; public class PlatformPartnerQueryService { private static final long PUBLIC_TENANT_ID = 0L; private static final Set 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 types) { - if (types.contains("CUSTOMER") && types.contains("SUPPLIER")) return "BOTH"; return types.get(0); } } diff --git a/warehouse/src/main/resources/db/warehouse-partner.sql b/warehouse/src/main/resources/db/warehouse-partner.sql index b70707d67..3aa7a2aa8 100644 --- a/warehouse/src/main/resources/db/warehouse-partner.sql +++ b/warehouse/src/main/resources/db/warehouse-partner.sql @@ -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') diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java index c2cc9a927..6f11791f7 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java @@ -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)) diff --git a/warehouse/src/test/java/com/baiyue/warehouse/mapper/PartnerMapperContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/mapper/PartnerMapperContractTest.java index d68db5bab..2e16f525b 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/mapper/PartnerMapperContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/mapper/PartnerMapperContractTest.java @@ -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("") - .contains("customer_type.partner_type = 'CUSTOMER'") - .contains("supplier_type.partner_type = 'SUPPLIER'") - .contains("") - .contains("pt.partner_type = #{type}"); + assertThat(sql).contains("pt.partner_type = #{type}") + .doesNotContain("type == 'BOTH'", "supplier_type.partner_type = 'SUPPLIER'"); } @Test diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java index 913fe3849..923be64b2 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java @@ -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 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