diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e5963fffd..ae1f5ed12 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9029,6 +9029,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填', booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照', booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照', vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照', @@ -9057,10 +9058,23 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')), + CONSTRAINT chk_ocean_booking_carrier_code CHECK + (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_ocean_booking_schedule CHECK (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; +SET @ocean_add_carrier_code = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE fl_ocean_booking ADD COLUMN carrier_code VARCHAR(16) NULL COMMENT ''船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填'' AFTER shipping_company_name', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking' AND column_name='carrier_code' +); +PREPARE ocean_add_carrier_code_stmt FROM @ocean_add_carrier_code; +EXECUTE ocean_add_carrier_code_stmt; +DEALLOCATE PREPARE ocean_add_carrier_code_stmt; + -- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional. ALTER TABLE fl_ocean_booking MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', @@ -10297,11 +10311,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空', container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0', - carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德', + carrier_code VARCHAR(16) NOT NULL COMMENT '船公司标准编码;OTHER表示人工跟踪', tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式;MANUAL表示仅人工补录节点', status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', @@ -10343,11 +10357,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id), CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0), CONSTRAINT chk_carrier_schedule_query_carrier CHECK - (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')), + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')), CONSTRAINT chk_carrier_schedule_query_status CHECK (status IN ('READY','MANUAL_REQUIRED','STOPPED')), CONSTRAINT chk_carrier_schedule_query_node CHECK @@ -10706,6 +10720,7 @@ DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns; -- CREATE TABLE IF NOT EXISTS 不会替换历史 CHECK 与唯一索引。统一收敛到当前 -- READY/MANUAL_REQUIRED/STOPPED、五种访问方式、十个实际节点和每业务范围唯一任务。 ALTER TABLE fl_carrier_schedule_query + DROP CHECK chk_carrier_schedule_query_carrier, DROP CHECK chk_carrier_schedule_query_access, DROP CHECK chk_carrier_schedule_query_status; DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check; @@ -10722,8 +10737,10 @@ DELIMITER ; CALL drop_legacy_carrier_schedule_node_check(); DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check; ALTER TABLE fl_carrier_schedule_query + ADD CONSTRAINT chk_carrier_schedule_query_carrier CHECK + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), ADD CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')), ADD CONSTRAINT chk_carrier_schedule_query_status CHECK (status IN ('READY','MANUAL_REQUIRED','STOPPED')), ADD CONSTRAINT chk_carrier_schedule_query_node CHECK @@ -13848,6 +13865,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'up CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'weight_kg', '设备实测重量kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'width_cm', '设备实测宽度cm'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'bill_of_lading_no', '提单号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'carrier_code', '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_id', '订舱代理逻辑ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_name', '订舱代理名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_no', '平台海运订舱编号'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 7481453cb..6d049a277 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -60,6 +60,7 @@ public final class OceanBookingDtos { @Size(max = 128) String billOfLadingNo, @Positive Long shippingCompanyId, @Size(max = 128) String shippingCompanyName, + @Size(max = 16) String carrierCode, @Positive Long bookingAgentId, @Size(max = 128) String bookingAgentName, @Size(max = 128) String vesselName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java index f7cc765f5..4e79948fd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java @@ -27,6 +27,7 @@ public class OceanBookingEntity { private String billOfLadingNo; private Long shippingCompanyId; private String shippingCompanyName; + private String carrierCode; private Long bookingAgentId; private String bookingAgentName; private String vesselName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index 598aad9ef..a8aa3e557 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -15,7 +15,7 @@ import org.apache.ibatis.annotations.Update; @Mapper public interface OceanBookingMapper extends BaseMapper { - @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") + @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int deleteDraft(@Param("id") long id, @Param("version") int version); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index a8b265082..624a2f2b8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -36,6 +36,7 @@ import java.util.Set; @Service public class OceanBookingService { private static final Set PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); + private static final Set CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"); private static final Set OPERATIONAL_LIST_STATUSES = Set.of( OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED); @Resource private OceanBookingRepository repository; @@ -196,6 +197,10 @@ public class OceanBookingService { ContainerSpecificationCatalog.requireMatch(container.containerType(), container.capacityCbm(), container.maxWeightKg()); } } + if (StringUtils.hasText(data.carrierCode()) + && !CARRIER_CODES.contains(data.carrierCode().trim().toUpperCase())) { + throw badRequest("carrierCode仅支持MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER"); + } } private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } private static void validateRelease(OceanBookingEntity booking, List parties, @@ -206,6 +211,7 @@ public class OceanBookingService { || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null + || !StringUtils.hasText(booking.getCarrierCode()) || booking.getBookingAgentId() == null || parties == null || parties.size() != 3 || requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt()); @@ -252,6 +258,7 @@ public class OceanBookingService { v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName()); v.setBillOfLadingNo(trim(d.billOfLadingNo())); v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); + v.setCarrierCode(d.carrierCode() == null ? null : d.carrierCode().trim().toUpperCase()); v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(resolved.bookingAgentName()); v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java index 50a5919ec..cd7ed02d5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java @@ -36,23 +36,26 @@ public class CarrierScheduleAdapterRegistry { public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) { Definition definition = required(carrierCode); + boolean automated = automated(definition); return new PreparedAttempt(definition.code(), definition.accessMode(), - officialUrl(definition, trackingType, trackingNo), automated(definition) ? "READY" : "MANUAL_REQUIRED", - definition.coverageGapCode(), definition.manualReason()); + officialUrl(definition, trackingType, trackingNo), automated ? "READY" : "MANUAL_REQUIRED", + definition.coverageGapCode(), automated ? null : manualReason(definition)); + } + + /** Outbound confirmation is a physical fact and must not be blocked by provider coverage. */ + public PreparedAttempt prepareOutbound(String carrierCode, String trackingType, String trackingNo) { + String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT); + if (normalized.isEmpty() || "OTHER".equals(normalized)) { + return new PreparedAttempt("OTHER", "MANUAL", "", "MANUAL_REQUIRED", + "UNSUPPORTED_CARRIER", "船司未接入自动查询,需人工补录船节点"); + } + return prepare(normalized, trackingType, trackingNo); } public String normalize(String carrierCode) { return required(carrierCode).code(); } - public String resolve(String carrierName) { - String normalized = carrierName == null ? "" : carrierName.trim().toUpperCase(Locale.ROOT); - return DEFINITIONS.values().stream().filter(value -> value.matches(normalized)) - .findFirst().map(Definition::code) - .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, - "出仓订舱船司无法映射至已支持的船节点供应商:" + carrierName)); - } - private static Definition required(String carrierCode) { String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT); Definition value = DEFINITIONS.get(normalized); @@ -105,6 +108,12 @@ public class CarrierScheduleAdapterRegistry { }; } + private static String manualReason(Definition definition) { + return definition.manualReason() == null + ? definition.name() + "自动查询当前未启用,需人工补录船节点" + : definition.manualReason(); + } + private record Definition(String code, String name, String officialUrl, String accessMode, String coverageGapCode, String manualReason) { CarrierDescriptor descriptor(boolean automated) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 4ab755ac0..3c87e8347 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -95,7 +95,6 @@ public class CarrierScheduleQueryService { ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); validateLinks(customerId, oceanBookingId, containerBookingId); - String carrierCode = adapters.resolve(booking.getShippingCompanyName()); String trackingType; String trackingNo; if (StringUtils.hasText(container.getContainerNo())) { @@ -107,18 +106,20 @@ public class CarrierScheduleQueryService { } else { throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一"); } - PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo); + PreparedAttempt strategy = adapters.prepareOutbound(booking.getCarrierCode(), trackingType, trackingNo); LocalDateTime now = now(); CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId); - value.setContainerBookingId(containerBookingId); value.setCarrierCode(carrierCode); + value.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode()); value.setTrackingType(trackingType); value.setTrackingNo(trackingNo); value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); - value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM"); + value.setStatus(strategy.status()); value.setTriggerSource("OUTBOUND_CONFIRM"); value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0); - value.setConsecutiveFailures(0); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); + value.setConsecutiveFailures(0); value.setNextAllowedAt("MANUAL".equals(strategy.accessMode()) + ? null : CarrierScheduleQuerySlots.nextSlot(now)); + value.setManualReason(outboundManualReason(strategy, booking)); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); try { repository.insert(value); } @@ -137,6 +138,15 @@ public class CarrierScheduleQueryService { return value; } + private static String outboundManualReason(PreparedAttempt strategy, OceanBookingEntity booking) { + if (!"MANUAL_REQUIRED".equals(strategy.status())) return null; + String reason = strategy.manualReason(); + if (!"MANUAL".equals(strategy.accessMode())) return reason; + return reason + ";carrierCode=" + Objects.toString(booking.getCarrierCode(), "") + + ";shippingCompanyId=" + booking.getShippingCompanyId() + + ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), ""); + } + @Transactional public Detail applyProviderObservation(long id, ProviderObservationRequest request) { CarrierScheduleQueryEntity value = requiredLocked(id); diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index f37916125..0f47efd54 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -8,11 +8,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空', container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0', - carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德', + carrier_code VARCHAR(16) NOT NULL COMMENT '船公司标准编码;OTHER表示人工跟踪', tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式;MANUAL表示仅人工补录节点', status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', @@ -54,11 +54,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id), CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0), CONSTRAINT chk_carrier_schedule_query_carrier CHECK - (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')), + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')), CONSTRAINT chk_carrier_schedule_query_status CHECK (status IN ('READY','MANUAL_REQUIRED','STOPPED')), CONSTRAINT chk_carrier_schedule_query_node CHECK diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 6512d8b88..3e9a30339 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', + carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填', booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照', booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照', vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照', @@ -42,6 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0), CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')), + CONSTRAINT chk_ocean_booking_carrier_code CHECK + (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_ocean_booking_schedule CHECK (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 4f6dd8f73..2e5af0055 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -90,6 +90,10 @@ components: IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} schemas: CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} + TaskCarrierCode: + type: string + enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] + description: OTHER is frozen on the ocean booking and creates a manual-only task after outbound confirmation. TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]} StandardNode: @@ -137,11 +141,15 @@ components: customerId: {type: integer, format: int64} oceanBookingId: {type: integer, format: int64} containerBookingId: {type: integer, format: int64, nullable: true} - carrierCode: {$ref: '#/components/schemas/CarrierCode'} + carrierCode: {$ref: '#/components/schemas/TaskCarrierCode'} trackingType: {$ref: '#/components/schemas/TrackingType'} trackingNo: {type: string} officialUrl: {type: string, nullable: true} - accessMode: {type: string, nullable: true} + accessMode: + type: string + enum: [PUBLIC_FORM, PUBLIC_WEB_PROXY, PUBLIC_HTML, HCAPTCHA, OFFICIAL_API, MANUAL] + nullable: true + description: MANUAL tasks preserve physical outbound confirmation and accept platform manual node facts without scheduler/provider calls. status: {$ref: '#/components/schemas/QueryStatus'} triggerSource: {type: string} latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true} @@ -150,7 +158,10 @@ components: sourceUpdatedAt: {type: string, format: date-time, nullable: true} expiresAt: {type: string, format: date-time, nullable: true} resultPortScheduleId: {type: integer, format: int64, nullable: true} - manualReason: {type: string, nullable: true} + manualReason: + type: string + nullable: true + description: Explicit provider-disabled or unsupported-carrier reason; unsupported outbound carriers retain the booking shippingCompanyId/name here and through oceanBookingId. lastErrorCode: {type: string, nullable: true} lastErrorMessage: {type: string, nullable: true} stoppedAt: {type: string, format: date-time, nullable: true} diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index a7ac3f556..85d652016 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -80,6 +80,11 @@ components: billOfLadingNo: {type: string, nullable: true} shippingCompanyId: {type: integer, format: int64, nullable: true} shippingCompanyName: {type: string, nullable: true} + carrierCode: + type: string + enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] + nullable: true + description: Frozen standard carrier code. Required before RELEASE; OTHER means manual carrier-node tracking. bookingAgentId: type: integer format: int64 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index 7a4ba2a08..df0b2fca1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -86,6 +86,16 @@ class PlatformOceanBookingControllerContractTest { .contains("Sum of all container requirement quantities; independent of allocation, linking or unlinking."); } + @Test + void openApiFreezesStandardCarrierCodeOnBooking() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("carrierCode:") + .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]") + .contains("Required before RELEASE") + .contains("OTHER means manual carrier-node tracking"); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index 9f2df34e0..5d73959ff 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -24,6 +24,8 @@ class OceanBookingSqlContractTest { .contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") .contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'") .contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'") + .contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码") + .contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") .contains("REFERENCES fl_container_booking(id)") .contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE") 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 7cbc45b12..2b846c7ba 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 @@ -259,12 +259,12 @@ class OceanBookingServiceTest { } private static DraftData partialDraft(String remark) { - return new DraftData(null, null, null, null, null, null, null, null, null, + return new DraftData(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, remark, null, null); } private static DraftData completeDraft() { - return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", + return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON", 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", "LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), null, @@ -279,6 +279,7 @@ class OceanBookingServiceTest { OceanBookingEntity value = booking(status, version); value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US"); value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L); + value.setCarrierCode("MATSON"); value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001"); value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID"); value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index ed8d27e52..1c8463923 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -63,6 +63,8 @@ class CarrierScheduleQuerySqlContractTest { .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") .contains("provider_event_id") .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") + .contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'") + .contains("'OFFICIAL_API','MANUAL'") .contains("lease_owner") .contains("consecutive_failures") .doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'"); @@ -102,11 +104,14 @@ class CarrierScheduleQuerySqlContractTest { .contains("ADD COLUMN next_allowed_at DATETIME(3) NULL") .contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)") .contains("DROP CHECK chk_carrier_schedule_query_access") + .contains("DROP CHECK chk_carrier_schedule_query_carrier") .contains("DROP CHECK chk_carrier_schedule_query_status") .contains("DROP INDEX uk_carrier_schedule_query_scope") .contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope") .contains("(customer_id,ocean_booking_id,container_scope_id)") .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") + .contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'") + .contains("'OFFICIAL_API','MANUAL'") .contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')") .contains("ARRIVED_DESTINATION_PORT") .contains("EMPTY_RETURNED") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java index 04f896a2b..2778ca65e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java @@ -39,4 +39,16 @@ class CarrierScheduleAdapterRegistryTest { assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X")) .hasMessageContaining("仅支持MATSON"); } + + @Test + void outboundUnknownCarrierFallsBackToManualWithoutPretendingToBeAnotherProvider() { + var result = registry.prepareOutbound("OTHER", "CONTAINER_NO", "CSNU1234567"); + + assertThat(result.carrierCode()).isEqualTo("OTHER"); + assertThat(result.accessMode()).isEqualTo("MANUAL"); + assertThat(result.status()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.coverageGapCode()).isEqualTo("UNSUPPORTED_CARRIER"); + assertThat(result.officialUrl()).isEmpty(); + assertThat(registry.prepareOutbound(null, "BOOKING_NO", "OB-1").carrierCode()).isEqualTo("OTHER"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index e88ed6f9c..06102d733 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -47,12 +47,14 @@ class CarrierScheduleQueryServiceTest { @Mock private PortScheduleRepository portSchedules; @Mock private CarrierScheduleNodeOutboxService nodeOutbox; @InjectMocks private CarrierScheduleQueryService service; + private CarrierScheduleAdapterRegistry adapters; @BeforeEach void setUp() throws Exception { MockitoAnnotations.openMocks(this); set("objectMapper", new ObjectMapper().findAndRegisterModules()); - set("adapters", new CarrierScheduleAdapterRegistry()); + adapters = new CarrierScheduleAdapterRegistry(); + set("adapters", adapters); set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC)); when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1); @@ -85,7 +87,7 @@ class CarrierScheduleQueryServiceTest { @Test void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); - booking.setShippingCompanyName("中远海运"); booking.setBookingNo("OB-260810-0001"); + booking.setShippingCompanyName("中远海运"); booking.setCarrierCode("COSCO"); booking.setBookingNo("OB-260810-0001"); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); container.setContainerNo("CSNU1234567"); when(portSchedules.oceanBooking(10L)).thenReturn(booking); @@ -96,12 +98,74 @@ class CarrierScheduleQueryServiceTest { assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); assertThat(result.getCarrierCode()).isEqualTo("COSCO"); + assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.getAccessMode()).isEqualTo("OFFICIAL_API"); + assertThat(result.getManualReason()).contains("Cargo Tracking API"); assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); } + @Test + void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyId(9988L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(result.getCarrierCode()).isEqualTo("OTHER"); + assertThat(result.getAccessMode()).isEqualTo("MANUAL"); + assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.getManualReason()).contains("carrierCode=OTHER", "shippingCompanyId=9988", "shippingCompanyName=示例船公司"); + assertThat(result.getNextAllowedAt()).isNull(); + assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); + } + + @Test + void outboundSupportedCarrierCreatesReadyTask() { + ReflectionTestUtils.setField(adapters, "matsonAutomated", true); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("美森"); booking.setCarrierCode("MATSON"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("MATU4646247"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(result.getCarrierCode()).isEqualTo("MATSON"); + assertThat(result.getStatus()).isEqualTo("READY"); + assertThat(result.getAccessMode()).isEqualTo("PUBLIC_WEB_PROXY"); + assertThat(result.getManualReason()).isNull(); + assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + } + + @Test + void outboundReplayKeepsSingleTaskAndSingleDepartureFact() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + + CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first); + CarrierScheduleQueryEntity replay = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + + assertThat(replay).isSameAs(first); + verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class)); + verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + } + @Test void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { CarrierScheduleQueryEntity task = task(0, 0); 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 bc845596b..2f4e5b403 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/MasterDataQueries.java @@ -71,6 +71,9 @@ public final class MasterDataQueries { @NotBlank @Size(max = 128) String partnerName, @Schema(description = "单值单位类型", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"}) @Pattern(regexp = "^(CARRIER|SHIPPER|CONSIGNEE)$") String partnerType, + @Schema(description = "船司标准编码;包含 CARRIER 类型时必填,其他类型必须为空", + allowableValues = {"MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"}) + @Pattern(regexp = "^(MATSON|SML|ZIM|COSCO|EMC|HEDE|OTHER)$") String carrierCode, @Schema(description = "联系人", example = "张三") @NotBlank @Size(max = 64) String contactName, @Schema(description = "联系电话", example = "13800138000") 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 a476c5133..ad39a13e5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PartnerEntity.java @@ -28,6 +28,9 @@ public class PartnerEntity extends BaseEntity { private String partnerName; @Schema(description = "类型", example = "CARRIER", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"}) private String partnerType; + @Schema(description = "船司标准编码;船公司必填,其他类型必须为空", example = "MATSON", + allowableValues = {"MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"}) + private String carrierCode; @TableField(exist = false) @Schema(description = "单位类型集合", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"}) private List types; 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 2069c48ac..23bc71d7a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PartnerImportService.java @@ -35,10 +35,12 @@ public class PartnerImportService { private static final String TEMPLATE_VERSION = "1"; private static final Set ALLOWED_TYPES = Set.of( "CARRIER", "SHIPPER", "CONSIGNEE"); + private static final Set ALLOWED_CARRIER_CODES = Set.of( + "MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"); private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); private static final String[] HEADERS = { "单位编码*", "单位名称*", "单位类型*", "联系人*", "联系电话*", "联系邮箱", - "国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注" + "船司标准编码", "国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注" }; @Resource @@ -107,9 +109,9 @@ public class PartnerImportService { ImportRow item = new ImportRow(index + 1, value(row, 0, formatter), value(row, 1, formatter), parseTypes(value(row, 2, formatter)), value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter), - value(row, 6, formatter).toUpperCase(Locale.ROOT), value(row, 7, formatter), - value(row, 8, formatter), value(row, 9, formatter), value(row, 10, formatter), - value(row, 11, formatter), value(row, 12, formatter).toUpperCase(Locale.ROOT), value(row, 13, formatter)); + value(row, 6, formatter).toUpperCase(Locale.ROOT), value(row, 7, formatter).toUpperCase(Locale.ROOT), + value(row, 8, formatter), value(row, 9, formatter), value(row, 10, formatter), value(row, 11, formatter), + value(row, 12, formatter), value(row, 13, formatter), value(row, 14, formatter).toUpperCase(Locale.ROOT), value(row, 15, formatter)); validate(tenantId, item, rows, errors); rows.add(item); } @@ -134,6 +136,11 @@ public class PartnerImportService { if (row.types.isEmpty()) errors.add(error(row, "单位类型", "至少填写一个类型")); row.types.stream().filter(type -> !ALLOWED_TYPES.contains(type)) .forEach(type -> errors.add(error(row, "单位类型", "不支持类型 " + type))); + if (row.types.contains("CARRIER") && !ALLOWED_CARRIER_CODES.contains(row.carrierCode)) { + errors.add(error(row, "船司标准编码", "船公司必须选择 MATSON、SML、ZIM、COSCO、EMC、HEDE 或 OTHER")); + } else if (!row.types.contains("CARRIER") && !row.carrierCode.isBlank()) { + errors.add(error(row, "船司标准编码", "非船公司必须留空")); + } required(row, errors, "联系人", row.contactName, 64); required(row, errors, "联系电话", row.contactPhone, 32); if (row.email.length() > 128 || (!row.email.isBlank() && !EMAIL.matcher(row.email).matches())) { @@ -162,7 +169,8 @@ public class PartnerImportService { entity.setPartnerCode(row.code); entity.setPartnerName(row.name); entity.setTypes(row.types); - entity.setPartnerType(row.types.get(0)); + entity.setPartnerType(row.types.contains("CARRIER") ? "CARRIER" : row.types.get(0)); + entity.setCarrierCode(nullable(row.carrierCode)); entity.setContactName(row.contactName); entity.setContactPhone(row.contactPhone); entity.setContactEmail(nullable(row.email)); @@ -216,6 +224,6 @@ public class PartnerImportService { } private record ImportRow(int row, String code, String name, List types, String contactName, - String contactPhone, String email, String countryCode, String province, String city, + String contactPhone, String email, String carrierCode, String countryCode, String province, String city, String district, String address, String postalCode, String status, String remark) { } } 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 4b668d769..f906ead93 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java @@ -24,6 +24,8 @@ public class PlatformPartnerQueryService { private static final long PUBLIC_TENANT_ID = 0L; private static final Set ALLOWED_QUERY_TYPES = Set.of( "CARRIER", "SHIPPER", "CONSIGNEE"); + private static final Set ALLOWED_CARRIER_CODES = Set.of( + "MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"); @Resource private PartnerRepository repository; @@ -121,10 +123,12 @@ public class PlatformPartnerQueryService { private static void copy(PartnerEntity entity, PartnerSaveRequest request) { List types = normalizeTypes(request.types(), request.partnerType()); + String carrierCode = normalizeCarrierCode(types, request.carrierCode()); entity.setPartnerCode(request.partnerCode()); entity.setPartnerName(request.partnerName()); entity.setTypes(types); entity.setPartnerType(legacyType(types)); + entity.setCarrierCode(carrierCode); entity.setContactName(request.contactName()); entity.setContactPhone(request.contactPhone()); entity.setContactEmail(request.contactEmail()); @@ -154,6 +158,25 @@ public class PlatformPartnerQueryService { } private static String legacyType(List types) { - return types.get(0); + return types.contains("CARRIER") ? "CARRIER" : types.get(0); + } + + static String normalizeCarrierCode(List types, String carrierCode) { + boolean carrier = types.contains("CARRIER"); + if (!carrier) { + if (carrierCode != null && !carrierCode.isBlank()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "非船公司不得设置船司标准编码"); + } + return null; + } + if (carrierCode == null || carrierCode.isBlank()) { + throw new BizException(ResultCode.BAD_REQUEST, "船公司必须选择船司标准编码"); + } + String normalized = carrierCode.trim().toUpperCase(Locale.ROOT); + if (!ALLOWED_CARRIER_CODES.contains(normalized)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "船司标准编码仅支持 MATSON、SML、ZIM、COSCO、EMC、HEDE 或 OTHER"); + } + return normalized; } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java index 453e3bb6d..2f5ae5bb6 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PlatformMasterDataManagementServiceTest.java @@ -79,6 +79,7 @@ class PlatformMasterDataManagementServiceTest { assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001"); assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户"); assertThat(entity.getValue().getTypes()).containsExactly("CARRIER", "SHIPPER"); + assertThat(entity.getValue().getCarrierCode()).isEqualTo("OTHER"); assertThat(entity.getValue().getRemark()).isEqualTo("测试备注"); } @@ -210,7 +211,7 @@ class PlatformMasterDataManagementServiceTest { } private static PartnerSaveRequest partnerRequest() { - return new PartnerSaveRequest("CUS-001", "示例客户", "CARRIER", "张三", "13800138000", + return new PartnerSaveRequest("CUS-001", "示例客户", "CARRIER", "OTHER", "张三", "13800138000", "test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE", java.util.List.of("CARRIER", "SHIPPER"), "测试备注"); }